From 2e26d111a6f747e50c0e52bcec9ce2b6193d5028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= <166786347+SonAIengine@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:14:00 +0900 Subject: [PATCH] =?UTF-8?q?JSON=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20schema?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=ED=96=89=20=EA=B3=84=EC=95=BD=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=98=EA=B3=A0=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=88=EB=8B=A8=C2=B7429=C2=B7=EC=9A=94=EC=B2=AD=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/xgeny-cli/src/composition.rs | 29 +- crates/xgeny-cli/src/main.rs | 13 +- crates/xgeny-cli/src/model_profile.rs | 7 +- crates/xgeny-cli/tests/live_go50902_public.rs | 9 + crates/xgeny-provider-openai/Cargo.toml | 1 + crates/xgeny-provider-openai/src/lib.rs | 488 +++++++++++++++++- .../tests/http_contract.rs | 2 +- crates/xgeny-runtime/src/agent_loop.rs | 22 +- crates/xgeny-workgraph/src/lib.rs | 3 + docs/adr/0044-atomic-json-artifact-wire.md | 117 +++++ .../development/openai-compatible-provider.md | 3 + 12 files changed, 665 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0044-atomic-json-artifact-wire.md diff --git a/Cargo.lock b/Cargo.lock index 087ba50..7c6907c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2761,6 +2761,7 @@ dependencies = [ name = "xgeny-provider-openai" version = "0.1.0-rc.3" dependencies = [ + "jsonschema", "serde", "serde_jcs", "serde_json", diff --git a/crates/xgeny-cli/src/composition.rs b/crates/xgeny-cli/src/composition.rs index f6884ce..76ccc12 100644 --- a/crates/xgeny-cli/src/composition.rs +++ b/crates/xgeny-cli/src/composition.rs @@ -433,6 +433,7 @@ fn compatibility_probe_config( .and_then(|config| config.with_timeout(limits.timeout())) .and_then(|config| config.with_response_format(options.response_format)) .and_then(|config| config.with_thinking(options.thinking)) + .and_then(artifact_schema_from_environment) .map_err(map_model_check_config) } @@ -504,6 +505,9 @@ const fn model_rejection_code(reason: ModelCallRejectionReason) -> &'static str "model_rejected.planner_invalid_response" } ModelCallRejectionReason::ProviderLimit => "model_rejected.provider_limit", + ModelCallRejectionReason::RequestTooLarge => "model_rejected.request_too_large", + ModelCallRejectionReason::RateLimited => "model_rejected.rate_limited", + ModelCallRejectionReason::OutputTruncated => "model_rejected.output_truncated", ModelCallRejectionReason::ProviderRejected => "model_rejected.provider_rejected", ModelCallRejectionReason::ProposalRejected => "model_rejected.proposal_rejected", ModelCallRejectionReason::MaterializationFailed => "model_rejected.materialization_failed", @@ -1734,6 +1738,7 @@ fn planner_config( .and_then(|config| config.with_timeout(limits.timeout())) .and_then(|config| config.with_response_format(options.response_format)) .and_then(|config| config.with_thinking(options.thinking)) + .and_then(artifact_schema_from_environment) .map_err(map_provider_config)?; if planning_constraints_required { config @@ -1744,6 +1749,20 @@ fn planner_config( } } +// Per-invocation host contract, not a model-profile default. Resume checks the +// resulting schema-bound request digest before sending anything. +fn artifact_schema_from_environment( + config: OpenAiPlannerConfig, +) -> Result { + match std::env::var("XGENY_OPENAI_ARTIFACT_SCHEMA") { + Ok(encoded) => config.with_artifact_schema(&encoded), + Err(std::env::VarError::NotPresent) => Ok(config), + Err(std::env::VarError::NotUnicode(_)) => Err( + OpenAiPlannerConfigError::InvalidProfileField("artifact_schema"), + ), + } +} + fn remote_planner( config: OpenAiPlannerConfig, credential: Option, @@ -1855,6 +1874,9 @@ fn map_planner_unavailable(run_id: String, failure: PlannerPortFailure) -> Local } PlannerPortFailure::InvalidResponse => ModelCallRejectionReason::PlannerInvalidResponse, PlannerPortFailure::ProviderLimit => ModelCallRejectionReason::ProviderLimit, + PlannerPortFailure::RequestTooLarge => ModelCallRejectionReason::RequestTooLarge, + PlannerPortFailure::RateLimited => ModelCallRejectionReason::RateLimited, + PlannerPortFailure::OutputTruncated => ModelCallRejectionReason::OutputTruncated, PlannerPortFailure::ProviderRejected => ModelCallRejectionReason::ProviderRejected, }; LocalCommandResult::Rejected { @@ -2812,11 +2834,16 @@ mod tests { #[test] fn explicit_wire_options_have_identical_probe_and_planner_digests() { use xgeny_provider_openai::{ResponseFormat, ThinkingMode}; - for response_format in [ResponseFormat::JsonSchema, ResponseFormat::JsonObject] { + for response_format in [ + ResponseFormat::JsonSchema, + ResponseFormat::JsonObject, + ResponseFormat::JsonSchemaAtomicJson, + ] { for thinking in [ ThinkingMode::Default, ThinkingMode::Disabled, ThinkingMode::Enabled, + ThinkingMode::ChatTemplateDisabled, ] { let options = RequestOptions { response_format, diff --git a/crates/xgeny-cli/src/main.rs b/crates/xgeny-cli/src/main.rs index 13e2fbd..63fd8ec 100644 --- a/crates/xgeny-cli/src/main.rs +++ b/crates/xgeny-cli/src/main.rs @@ -187,7 +187,7 @@ struct RunArgs { #[command(flatten)] request_options: RequestOptionArgs, /// Goal sent to the bounded planner. - #[arg(help = format!("Goal sent to the bounded planner. XGENY_MAX_GOAL_BYTES={}", xgeny_cli::MAX_GOAL_BYTES))] + #[arg(help = format!("Goal sent to the bounded planner. XGENY_MAX_GOAL_BYTES={} XGENY_OPENAI_ARTIFACT_SCHEMA=atomic-json-schema-v1 (optional per-invocation JSON Schema; also required unchanged on resume)", xgeny_cli::MAX_GOAL_BYTES))] goal: String, /// Workspace root opened as the local filesystem capability. #[arg(long, default_value = ".")] @@ -287,10 +287,10 @@ struct ResumeArgs { #[derive(Debug, Args, Default)] struct RequestOptionArgs { /// Structured output transport; `json_object` is validated by `XGENy`, not server-enforced schema. - #[arg(long, value_parser = ["json_schema", "json_object"])] + #[arg(long, value_parser = ["json_schema", "json_object", "json_schema_atomic_json"])] response_format: Option, /// Explicit provider thinking setting; default omits the provider-specific setting. - #[arg(long, value_parser = ["default", "disabled", "enabled"])] + #[arg(long, value_parser = ["default", "disabled", "enabled", "chat_template_disabled"])] thinking: Option, } @@ -1206,6 +1206,7 @@ fn resolve_request_options( None => base.response_format, Some("json_schema") => ResponseFormat::JsonSchema, Some("json_object") => ResponseFormat::JsonObject, + Some("json_schema_atomic_json") => ResponseFormat::JsonSchemaAtomicJson, Some(_) => return Err(ModelCliError::InvalidRequestOptions), }; let thinking = match explicit @@ -1217,6 +1218,7 @@ fn resolve_request_options( Some("default") => ThinkingMode::Default, Some("disabled") => ThinkingMode::Disabled, Some("enabled") => ThinkingMode::Enabled, + Some("chat_template_disabled") => ThinkingMode::ChatTemplateDisabled, Some(_) => return Err(ModelCliError::InvalidRequestOptions), }; Ok(RequestOptions { @@ -1229,6 +1231,7 @@ const fn response_format_label(format: ResponseFormat) -> &'static str { match format { ResponseFormat::JsonSchema => "json_schema", ResponseFormat::JsonObject => "json_object", + ResponseFormat::JsonSchemaAtomicJson => "json_schema_atomic_json", } } @@ -1237,12 +1240,16 @@ const fn thinking_label(thinking: ThinkingMode) -> &'static str { ThinkingMode::Default => "default", ThinkingMode::Disabled => "disabled", ThinkingMode::Enabled => "enabled", + ThinkingMode::ChatTemplateDisabled => "chat_template_disabled", } } const fn compatibility_label(options: RequestOptions) -> &'static str { match options.response_format { ResponseFormat::JsonSchema => "strict JSON compatible", + ResponseFormat::JsonSchemaAtomicJson => { + "atomic JSON wire compatible (native write still verified)" + } ResponseFormat::JsonObject => { "JSON object compatible (host-validated; no server schema guarantee)" } diff --git a/crates/xgeny-cli/src/model_profile.rs b/crates/xgeny-cli/src/model_profile.rs index c1bda89..800a333 100644 --- a/crates/xgeny-cli/src/model_profile.rs +++ b/crates/xgeny-cli/src/model_profile.rs @@ -1159,11 +1159,16 @@ mod tests { #[test] fn request_options_round_trip_and_reject_unknown_modes() { - for response_format in [ResponseFormat::JsonSchema, ResponseFormat::JsonObject] { + for response_format in [ + ResponseFormat::JsonSchema, + ResponseFormat::JsonObject, + ResponseFormat::JsonSchemaAtomicJson, + ] { for thinking in [ ThinkingMode::Default, ThinkingMode::Disabled, ThinkingMode::Enabled, + ThinkingMode::ChatTemplateDisabled, ] { let mut original = profile("wire"); let options = RequestOptions { diff --git a/crates/xgeny-cli/tests/live_go50902_public.rs b/crates/xgeny-cli/tests/live_go50902_public.rs index f16492e..59f12f4 100644 --- a/crates/xgeny-cli/tests/live_go50902_public.rs +++ b/crates/xgeny-cli/tests/live_go50902_public.rs @@ -1342,6 +1342,15 @@ fn require_workspace_completion(output: &Output, state_root: &Path) { ModelCallRejectionReason::ProviderLimit => { "live workspace provider response exceeded a limit" } + ModelCallRejectionReason::RequestTooLarge => { + "live workspace request too large" + } + ModelCallRejectionReason::RateLimited => { + "live workspace provider rate limited request" + } + ModelCallRejectionReason::OutputTruncated => { + "live workspace output truncated" + } ModelCallRejectionReason::ProviderRejected => { "live workspace provider rejected the request" } diff --git a/crates/xgeny-provider-openai/Cargo.toml b/crates/xgeny-provider-openai/Cargo.toml index b1aaffd..bad0d24 100644 --- a/crates/xgeny-provider-openai/Cargo.toml +++ b/crates/xgeny-provider-openai/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true publish = false [dependencies] +jsonschema.workspace = true serde.workspace = true serde_jcs.workspace = true serde_json.workspace = true diff --git a/crates/xgeny-provider-openai/src/lib.rs b/crates/xgeny-provider-openai/src/lib.rs index 1410dd9..636c5ac 100644 --- a/crates/xgeny-provider-openai/src/lib.rs +++ b/crates/xgeny-provider-openai/src/lib.rs @@ -75,6 +75,8 @@ pub enum ResponseFormat { JsonSchema, /// Ask for JSON syntax only; include the schema in the committed system prompt. JsonObject, + /// Atomic JSON artifact wire codec; native permissions and receipts remain unchanged. + JsonSchemaAtomicJson, } /// Opt-in vendor thinking extension, never inferred from an endpoint hostname. @@ -88,6 +90,8 @@ pub enum ThinkingMode { Disabled, /// Explicit thinking with low reasoning effort and the existing output/time limits. Enabled, + /// Explicit chat-template switch for compatible serving engines; no provider guessing. + ChatTemplateDisabled, } /// A bearer credential retained only as a sensitive HTTP header value. @@ -132,6 +136,7 @@ pub struct OpenAiPlannerConfig { max_proposal_bytes: usize, max_json_depth: usize, proposal_schema: Value, + artifact_validator: Option, response_format: ResponseFormat, thinking: ThinkingMode, planning_constraints_required: bool, @@ -175,6 +180,7 @@ impl OpenAiPlannerConfig { max_proposal_bytes: DEFAULT_MAX_PROPOSAL_BYTES, max_json_depth: DEFAULT_MAX_JSON_DEPTH, proposal_schema: proposal_schema(), + artifact_validator: None, response_format: ResponseFormat::default(), thinking: ThinkingMode::default(), planning_constraints_required: false, @@ -192,7 +198,46 @@ impl OpenAiPlannerConfig { mut self, response_format: ResponseFormat, ) -> Result { + if self.artifact_validator.is_some() { + return Err(OpenAiPlannerConfigError::InvalidProfileField( + "artifact_schema", + )); + } self.response_format = response_format; + self.proposal_schema = if response_format == ResponseFormat::JsonSchemaAtomicJson { + atomic_json_proposal_schema() + } else { + proposal_schema() + }; + self.refresh_profile_digest()?; + Ok(self) + } + + /// Attach a bounded, local-only JSON Schema to the atomic artifact wire. + /// The compiled validator rejects invalid domain objects before a Plan is admitted. + /// + /// # Errors + /// Rejects wrong dialects, remote references, duplicate keys, invalid or oversized schemas. + pub fn with_artifact_schema(mut self, encoded: &str) -> Result { + let invalid = || OpenAiPlannerConfigError::InvalidProfileField("artifact_schema"); + if self.response_format != ResponseFormat::JsonSchemaAtomicJson || encoded.len() > 32_768 { + return Err(invalid()); + } + let schema = parse_unique_json(encoded.as_bytes(), 32).map_err(|_| invalid())?; + if schema.get("type") != Some(&json!("object")) || !local_artifact_schema(&schema) { + return Err(invalid()); + } + jsonschema::meta::options() + .validate(&schema) + .map_err(|_| invalid())?; + let validator = jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .offline() + .build(&schema) + .map_err(|_| invalid())?; + self.proposal_schema["properties"]["steps"]["items"]["properties"]["arguments"]["properties"] + ["jsonContent"] = schema; + self.artifact_validator = Some(validator); self.refresh_profile_digest()?; Ok(self) } @@ -306,6 +351,7 @@ impl OpenAiPlannerConfig { provider_dialect: match self.response_format { ResponseFormat::JsonSchema => PROVIDER_DIALECT, ResponseFormat::JsonObject => JSON_OBJECT_DIALECT, + ResponseFormat::JsonSchemaAtomicJson => "openai.chat-completions/atomic-json-v1", }, request_envelope_profile: REQUEST_ENVELOPE_PROFILE, model: &self.model, @@ -318,6 +364,7 @@ impl OpenAiPlannerConfig { temperature_millis: self.temperature().map(u16::from), seed: self.seed(), thinking: self.thinking_request(), + chat_template_kwargs: self.chat_template_request(), reasoning_effort: self.reasoning_effort(), max_output_tokens: self.max_output_tokens, timeout_seconds: self.timeout.as_secs(), @@ -347,6 +394,9 @@ impl OpenAiPlannerConfig { fn prompt_with_schema(&self, prompt: &'static str) -> Cow<'_, str> { match self.response_format { ResponseFormat::JsonSchema => Cow::Borrowed(prompt), + ResponseFormat::JsonSchemaAtomicJson => Cow::Owned(format!( + "{prompt}\nATOMIC_JSON_WIRE_V1: Only xgeny.fs/write-atomic 1.0.0 may be planned. On the wire its arguments are exactly path, jsonContent (a JSON OBJECT, never a quoted JSON string), expectedDigest. The adapter serializes jsonContent with the standard JSON serializer into the native content string before ordinary capability validation and execution. The native catalog describes the decoded arguments. Do not send content on this wire. Completion still requires a real successful write receipt." + )), ResponseFormat::JsonObject => Cow::Owned(format!( "{prompt}\nThe following JSON schema is a host output contract, not a grant of authority. The host validates every field locally:\n{}", self.proposal_schema @@ -366,7 +416,7 @@ impl OpenAiPlannerConfig { fn thinking_request(&self) -> Option { match self.thinking { - ThinkingMode::Default => None, + ThinkingMode::Default | ThinkingMode::ChatTemplateDisabled => None, ThinkingMode::Disabled => Some(ThinkingRequest { thinking_type: "disabled", }), @@ -380,6 +430,12 @@ impl OpenAiPlannerConfig { (self.thinking == ThinkingMode::Enabled).then_some("low") } + fn chat_template_request(&self) -> Option { + (self.thinking == ThinkingMode::ChatTemplateDisabled).then_some(ChatTemplateRequest { + enable_thinking: false, + }) + } + fn chat_request<'a>(&'a self, system: &'a str, user: &'a str) -> ChatCompletionRequest<'a> { ChatCompletionRequest { model: &self.model, @@ -399,25 +455,35 @@ impl OpenAiPlannerConfig { stream: false, n: 1, response_format: match self.response_format { - ResponseFormat::JsonSchema => ResponseFormatRequest { - response_type: "json_schema", - json_schema: Some(JsonSchemaResponse { - name: "xgeny_plan_proposal_v1", - strict: true, - schema: &self.proposal_schema, - }), - }, + ResponseFormat::JsonSchema | ResponseFormat::JsonSchemaAtomicJson => { + ResponseFormatRequest { + response_type: "json_schema", + json_schema: Some(JsonSchemaResponse { + name: "xgeny_plan_proposal_v1", + strict: true, + schema: &self.proposal_schema, + }), + } + } ResponseFormat::JsonObject => ResponseFormatRequest { response_type: "json_object", json_schema: None, }, }, thinking: self.thinking_request(), + chat_template_kwargs: self.chat_template_request(), reasoning_effort: self.reasoning_effort(), } } fn prompt_template_revision(&self) -> &'static str { + if self.response_format == ResponseFormat::JsonSchemaAtomicJson { + return if self.planning_constraints_required { + "xgeny.openai-planner-prompt/v6-atomic-json-constrained" + } else { + "xgeny.openai-planner-prompt/v6-atomic-json" + }; + } if self.response_format == ResponseFormat::JsonObject { return if self.planning_constraints_required { "xgeny.openai-planner-prompt/v5-json-object-constrained" @@ -449,6 +515,7 @@ impl fmt::Debug for OpenAiPlannerConfig { .field("max_proposal_bytes", &self.max_proposal_bytes) .field("max_json_depth", &self.max_json_depth) .field("proposal_schema", &"") + .field("artifact_validator", &self.artifact_validator.is_some()) .field("response_format", &self.response_format) .field("thinking", &self.thinking) .field( @@ -645,7 +712,9 @@ impl OpenAiCompatibilityChecker { // JSON-object APIs promise syntax, not provider-side schema enforcement. // Asking them to emit an extra field would deliberately fail this probe. let user = match self.config.response_format { - ResponseFormat::JsonSchema => COMPATIBILITY_USER_PROMPT, + ResponseFormat::JsonSchema | ResponseFormat::JsonSchemaAtomicJson => { + COMPATIBILITY_USER_PROMPT + } ResponseFormat::JsonObject => JSON_OBJECT_COMPATIBILITY_USER_PROMPT, }; let body = serde_json::to_vec(&self.config.chat_request(&system, user)) @@ -777,7 +846,7 @@ impl PlannerPort for OpenAiPlanner { let body = serde_json::to_vec(&self.config.chat_request(&system, &prompt)) .map_err(|_| PlannerPortFailure::ProviderLimit)?; if body.len() > self.config.max_request_bytes { - return Err(PlannerPortFailure::ProviderLimit); + return Err(PlannerPortFailure::RequestTooLarge); } let response = self.transport.send(TransportRequest { endpoint: &self.config.endpoint, @@ -785,12 +854,17 @@ impl PlannerPort for OpenAiPlanner { body: &body, max_response_bytes: self.config.max_response_bytes, })?; - decode_chat_response( + let proposal = decode_chat_response_with_codec( &response, &self.config.model, self.config.max_proposal_bytes, self.config.max_json_depth, - ) + self.config.response_format == ResponseFormat::JsonSchemaAtomicJson, + )?; + if let Some(validator) = &self.config.artifact_validator { + validate_artifact_response(&response, &self.config, validator)?; + } + Ok(proposal) } } @@ -836,6 +910,8 @@ struct RequestProfileDescriptor<'a> { #[serde(skip_serializing_if = "Option::is_none")] thinking: Option, #[serde(skip_serializing_if = "Option::is_none")] + chat_template_kwargs: Option, + #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option<&'static str>, max_output_tokens: u32, timeout_seconds: u64, @@ -875,6 +951,8 @@ struct ChatCompletionRequest<'a> { #[serde(skip_serializing_if = "Option::is_none")] thinking: Option, #[serde(skip_serializing_if = "Option::is_none")] + chat_template_kwargs: Option, + #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option<&'static str>, } @@ -898,6 +976,11 @@ struct ThinkingRequest { thinking_type: &'static str, } +#[derive(Serialize)] +struct ChatTemplateRequest { + enable_thinking: bool, +} + #[derive(Serialize)] struct JsonSchemaResponse<'a> { name: &'static str, @@ -1061,7 +1144,8 @@ fn map_transport_error(error: &ureq::Error) -> PlannerPortFailure { fn map_status(status: u16) -> PlannerPortFailure { match status { 408 | 504 => PlannerPortFailure::Timeout, - 413 | 429 => PlannerPortFailure::ProviderLimit, + 413 => PlannerPortFailure::RequestTooLarge, + 429 => PlannerPortFailure::RateLimited, 300..=499 => PlannerPortFailure::ProviderRejected, _ => PlannerPortFailure::Unavailable, } @@ -1091,9 +1175,15 @@ const fn map_compatibility_transport_failure( match failure { PlannerPortFailure::Timeout => OpenAiCompatibilityCheckFailure::Timeout, PlannerPortFailure::Unavailable => OpenAiCompatibilityCheckFailure::Unavailable, - PlannerPortFailure::InvalidResponse => OpenAiCompatibilityCheckFailure::InvalidResponse, - PlannerPortFailure::ProviderLimit => OpenAiCompatibilityCheckFailure::ProviderLimit, - PlannerPortFailure::ProviderRejected => OpenAiCompatibilityCheckFailure::RequestRejected, + PlannerPortFailure::InvalidResponse | PlannerPortFailure::OutputTruncated => { + OpenAiCompatibilityCheckFailure::InvalidResponse + } + PlannerPortFailure::ProviderLimit | PlannerPortFailure::RateLimited => { + OpenAiCompatibilityCheckFailure::ProviderLimit + } + PlannerPortFailure::RequestTooLarge | PlannerPortFailure::ProviderRejected => { + OpenAiCompatibilityCheckFailure::RequestRejected + } } } @@ -1173,11 +1263,28 @@ enum DependencyKind { ProposedStep, } +#[cfg(test)] fn decode_chat_response( body: &[u8], expected_model: &str, max_proposal_bytes: usize, max_json_depth: usize, +) -> Result { + decode_chat_response_with_codec( + body, + expected_model, + max_proposal_bytes, + max_json_depth, + false, + ) +} + +fn decode_chat_response_with_codec( + body: &[u8], + expected_model: &str, + max_proposal_bytes: usize, + max_json_depth: usize, + atomic_json: bool, ) -> Result { let envelope = parse_unique_json(body, max_json_depth)?; let response: ChatCompletionResponse = @@ -1192,7 +1299,7 @@ fn decode_chat_response( return Err(PlannerPortFailure::InvalidResponse); } if choice.finish_reason == "length" { - return Err(PlannerPortFailure::ProviderLimit); + return Err(PlannerPortFailure::OutputTruncated); } if choice.finish_reason != "stop" || choice.message.refusal.is_some() @@ -1223,7 +1330,11 @@ fn decode_chat_response( let steps = proposal .steps .into_iter() - .map(|step| { + .map(|mut step| { + if atomic_json { + step.arguments = + decode_atomic_json_arguments(&step.capability, step.arguments)?; + } let dependencies = step .depends_on .into_iter() @@ -1249,6 +1360,96 @@ fn decode_chat_response( } } +// Restrict schema *vocabulary*, not application fields. No references or resource loading. +fn local_artifact_schema(schema: &Value) -> bool { + let Some(object) = schema.as_object() else { + return false; + }; + object.iter().all(|(key, value)| match key.as_str() { + "type" | "enum" | "const" | "required" | "minItems" | "maxItems" | "minLength" + | "maxLength" | "minimum" | "maximum" | "title" | "description" => true, + "additionalProperties" => value == &Value::Bool(false), + "properties" => value + .as_object() + .is_some_and(|props| props.values().all(local_artifact_schema)), + "items" => local_artifact_schema(value), + "anyOf" => value + .as_array() + .is_some_and(|items| !items.is_empty() && items.iter().all(local_artifact_schema)), + _ => false, + }) +} + +fn validate_artifact_response( + body: &[u8], + config: &OpenAiPlannerConfig, + validator: &jsonschema::Validator, +) -> Result<(), PlannerPortFailure> { + let envelope = parse_unique_json(body, config.max_json_depth)?; + let response: ChatCompletionResponse = + serde_json::from_value(envelope).map_err(|_| PlannerPortFailure::InvalidResponse)?; + let [choice] = response.choices.as_slice() else { + return Err(PlannerPortFailure::InvalidResponse); + }; + // Preserve the existing truncation classification; full envelope validation follows. + if choice.finish_reason == "length" { + return Err(PlannerPortFailure::OutputTruncated); + } + let content = choice + .message + .content + .as_deref() + .ok_or(PlannerPortFailure::InvalidResponse)?; + if content.len() > config.max_proposal_bytes { + return Err(PlannerPortFailure::InvalidResponse); + } + let proposal: ProposalDocument = serde_json::from_value(parse_unique_json( + content.as_bytes(), + config.max_json_depth, + )?) + .map_err(|_| PlannerPortFailure::InvalidResponse)?; + for step in proposal.steps { + if !step + .arguments + .get("jsonContent") + .is_some_and(|value| validator.is_valid(value)) + { + return Err(PlannerPortFailure::InvalidResponse); + } + } + Ok(()) +} + +fn decode_atomic_json_arguments( + capability: &CapabilityRef, + value: Value, +) -> Result { + let capability = + serde_json::to_value(capability).map_err(|_| PlannerPortFailure::InvalidResponse)?; + if capability != json!({"capabilityId":"xgeny.fs/write-atomic", "contractVersion":"1.0.0"}) { + return Err(PlannerPortFailure::InvalidResponse); + } + let Value::Object(mut args) = value else { + return Err(PlannerPortFailure::InvalidResponse); + }; + if args.len() != 3 + || !args.get("path").is_some_and(Value::is_string) + || !args + .get("expectedDigest") + .is_some_and(|v| v.is_null() || v.is_string()) + || !args.get("jsonContent").is_some_and(Value::is_object) + { + return Err(PlannerPortFailure::InvalidResponse); + } + let object = args + .remove("jsonContent") + .ok_or(PlannerPortFailure::InvalidResponse)?; + let content = + serde_json::to_string(&object).map_err(|_| PlannerPortFailure::InvalidResponse)?; + args.insert("content".into(), Value::String(content)); + Ok(Value::Object(args)) +} + #[cfg(test)] fn decode_model_catalog( body: &[u8], @@ -1550,6 +1751,21 @@ fn proposal_schema() -> Value { }) } +fn atomic_json_proposal_schema() -> Value { + let mut schema = proposal_schema(); + let step = &mut schema["properties"]["steps"]["items"]["properties"]; + step["capability"]["properties"]["capabilityId"] = + json!({"type":"string", "const":"xgeny.fs/write-atomic"}); + step["capability"]["properties"]["contractVersion"] = json!({"type":"string", "const":"1.0.0"}); + step["arguments"] = json!({ + "type":"object", "properties": { + "path":{"type":"string"}, "jsonContent":{"type":"object", "additionalProperties":true}, + "expectedDigest":{"type":["string","null"]} + }, "required":["path","jsonContent","expectedDigest"], "additionalProperties":false + }); + schema +} + fn api_endpoints(base_url: &str) -> Result<(Url, Url), OpenAiPlannerConfigError> { if base_url.len() > MAX_BASE_URL_BYTES { return Err(OpenAiPlannerConfigError::InvalidBaseUrl); @@ -1804,6 +2020,234 @@ mod tests { ); } + #[test] + fn chat_template_switch_is_explicit_and_profile_bound() { + let base = config("https://provider.example/v1"); + let old = base.request_profile_digest().to_owned(); + let configured = base + .with_thinking(ThinkingMode::ChatTemplateDisabled) + .unwrap(); + let body = request_body(&configured); + assert_eq!( + body["chat_template_kwargs"], + json!({"enable_thinking":false}) + ); + assert!(body.get("thinking").is_none()); + assert!(body.get("reasoning_effort").is_none()); + assert_ne!(old, configured.request_profile_digest()); + assert_eq!( + old, + configured + .with_thinking(ThinkingMode::Default) + .unwrap() + .request_profile_digest() + ); + } + + #[test] + fn atomic_json_codec_preserves_nested_values_and_native_write_contract() { + let capability: CapabilityRef = serde_json::from_value(json!({ + "capabilityId":"xgeny.fs/write-atomic", "contractVersion":"1.0.0" + })) + .unwrap(); + for object in [ + json!({"report":{"quote":"a\"b\\c\n한글", "values":[1,null,true, {"x":-2.5}]}}), + json!({"contract":{"requirements":[]}, "large":9_007_199_254_740_993_u64}), + ] { + let decoded = decode_atomic_json_arguments( + &capability, + json!({ + "path":"result.json", "jsonContent":object, "expectedDigest":null + }), + ) + .unwrap(); + assert_eq!(decoded["path"], "result.json"); + assert!(decoded["expectedDigest"].is_null()); + assert_eq!(decoded.as_object().unwrap().len(), 3); + assert_eq!( + serde_json::from_str::(decoded["content"].as_str().unwrap()).unwrap(), + object + ); + } + for bad in [ + json!({"path":"x", "content":"{}", "expectedDigest":null}), + json!({"path":"x", "jsonContent":"{}", "expectedDigest":null}), + json!({"path":"x", "jsonContent":{}, "expectedDigest":null, "extra":true}), + json!({"path":"x", "jsonContent":[], "expectedDigest":null}), + ] { + assert!(decode_atomic_json_arguments(&capability, bad).is_err()); + } + } + + #[test] + fn atomic_json_full_codec_rejects_mixed_plans_and_nested_duplicates() { + let step = json!({ + "key":"write", "objective":"record", "dependsOn":[], + "capability":{"capabilityId":"xgeny.fs/write-atomic", "contractVersion":"1.0.0"}, + "arguments":{"path":"result.json", "jsonContent":{"value":1}, "expectedDigest":null} + }); + let plan = json!({"formatVersion":1, "kind":"plan", "summary":"", "steps":[step.clone()]}); + let decode = |value: &Value| { + decode_chat_response_with_codec( + &response(&value.to_string(), "stop"), + MODEL, + 256 * 1024, + 64, + true, + ) + }; + assert!(matches!(decode(&plan), Ok(PlanProposal::Plan { .. }))); + let mut mixed = plan.clone(); + let mut other = step; + other["key"] = json!("other"); + other["capability"]["capabilityId"] = json!("xgeny.process/execute"); + mixed["steps"].as_array_mut().unwrap().push(other); + assert_eq!(decode(&mixed), Err(PlannerPortFailure::InvalidResponse)); + let duplicate = plan + .to_string() + .replace("\"value\":1", "\"value\":1,\"value\":2"); + assert_eq!( + decode_chat_response_with_codec( + &response(&duplicate, "stop"), + MODEL, + 256 * 1024, + 64, + true, + ), + Err(PlannerPortFailure::InvalidResponse) + ); + let mut deep = json!({}); + for _ in 0..65 { + deep = json!({"nested":deep}); + } + let mut too_deep = plan.clone(); + too_deep["steps"][0]["arguments"]["jsonContent"] = deep; + assert_eq!(decode(&too_deep), Err(PlannerPortFailure::InvalidResponse)); + assert_eq!( + decode_chat_response_with_codec( + &response(&plan.to_string(), "stop"), + MODEL, + 8, + 64, + true, + ), + Err(PlannerPortFailure::InvalidResponse) + ); + assert_eq!( + decode_chat_response_with_codec( + &response(&plan.to_string(), "length"), + MODEL, + 256 * 1024, + 64, + true, + ), + Err(PlannerPortFailure::OutputTruncated) + ); + } + + #[test] + fn artifact_schema_is_bound_and_validated_before_admission() { + let schema = json!({"type":"object", "properties":{"answer":{"type":"string"}, + "nested":{"type":"object", "properties":{"values":{"type":"array", "items":{"type":"integer"}}}, + "required":["values"], "additionalProperties":false}}, + "required":["answer","nested"], "additionalProperties":false}); + let base = config("https://provider.example/v1") + .with_response_format(ResponseFormat::JsonSchemaAtomicJson) + .unwrap(); + let prior_digest = base.request_profile_digest().to_owned(); + let configured = base.with_artifact_schema(&schema.to_string()).unwrap(); + assert_ne!(prior_digest, configured.request_profile_digest()); + assert_eq!( + request_body(&configured)["response_format"]["json_schema"]["schema"]["properties"]["steps"] + ["items"]["properties"]["arguments"]["properties"]["jsonContent"], + schema + ); + for (value, valid) in [ + (json!({"answer":"내용", "nested":{"values":[1,2]}}), true), + (json!({"answer":"내용", "nested":{"values":null}}), false), + (json!({"answer":"내용", "nested":{"values":["1"]}}), false), + ( + json!({"answer":"내용", "nested":{"values":[]}, "path":"x"}), + false, + ), + ] { + let proposal = json!({"formatVersion":1,"kind":"plan","summary":"","steps":[{ + "key":"write","objective":"record","dependsOn":[], + "capability":{"capabilityId":"xgeny.fs/write-atomic","contractVersion":"1.0.0"}, + "arguments":{"path":"result.json","jsonContent":value,"expectedDigest":null}}]}); + assert_eq!( + validate_artifact_response( + &response(&proposal.to_string(), "stop"), + &configured, + configured.artifact_validator.as_ref().unwrap() + ) + .is_ok(), + valid + ); + } + assert!( + configured + .with_response_format(ResponseFormat::JsonSchema) + .is_err() + ); + assert!( + config("https://provider.example/v1") + .with_artifact_schema(&schema.to_string()) + .is_err() + ); + for invalid in [ + r#"{"type":"object","$ref":"https://example.com/schema"}"#, + r#"{"type":"object","properties":{"a":{"$ref":"file:///private"}}}"#, + r#"{"type":"object","type":"string"}"#, + r#"{"type":"object","properties":{"a":{"type":"bogus"}}}"#, + ] { + assert!( + config("https://provider.example/v1") + .with_response_format(ResponseFormat::JsonSchemaAtomicJson) + .unwrap() + .with_artifact_schema(invalid) + .is_err() + ); + } + } + + #[test] + fn atomic_json_profile_is_opt_in_and_cannot_select_other_capabilities() { + let base = config("https://provider.example/v1"); + let old = base.request_profile_digest().to_owned(); + let configured = base + .with_response_format(ResponseFormat::JsonSchemaAtomicJson) + .unwrap(); + assert_ne!(old, configured.request_profile_digest()); + let body = request_body(&configured); + assert_eq!(body["response_format"]["type"], "json_schema"); + assert_eq!( + body["response_format"]["json_schema"]["schema"], + atomic_json_proposal_schema() + ); + assert!(configured.system_prompt().contains("ATOMIC_JSON_WIRE_V1")); + assert_eq!( + old, + configured + .with_response_format(ResponseFormat::JsonSchema) + .unwrap() + .request_profile_digest() + ); + let capability: CapabilityRef = serde_json::from_value(json!({ + "capabilityId":"xgeny.process/execute", "contractVersion":"1.0.0" + })) + .unwrap(); + assert!( + decode_atomic_json_arguments( + &capability, + json!({ + "path":"x", "jsonContent":{}, "expectedDigest":null + }) + ) + .is_err() + ); + } + #[test] fn output_options_have_explicit_serialized_names_and_reject_unknown_values() { assert_eq!( @@ -2123,7 +2567,7 @@ mod tests { } assert_eq!( decode_chat_response(&response(&valid_plan(), "length"), MODEL, 256 * 1024, 64,), - Err(PlannerPortFailure::ProviderLimit) + Err(PlannerPortFailure::OutputTruncated) ); assert_eq!( decode_chat_response(&response(&valid_plan(), "stop"), MODEL, 8, 64), @@ -2765,8 +3209,8 @@ mod tests { fn status_mapping_is_closed_or_unknown_without_raw_body() { assert_eq!(map_status(400), PlannerPortFailure::ProviderRejected); assert_eq!(map_status(401), PlannerPortFailure::ProviderRejected); - assert_eq!(map_status(413), PlannerPortFailure::ProviderLimit); - assert_eq!(map_status(429), PlannerPortFailure::ProviderLimit); + assert_eq!(map_status(413), PlannerPortFailure::RequestTooLarge); + assert_eq!(map_status(429), PlannerPortFailure::RateLimited); assert_eq!(map_status(202), PlannerPortFailure::Unavailable); assert_eq!(map_status(500), PlannerPortFailure::Unavailable); assert_eq!(map_status(600), PlannerPortFailure::Unavailable); diff --git a/crates/xgeny-provider-openai/tests/http_contract.rs b/crates/xgeny-provider-openai/tests/http_contract.rs index 6ebb144..3c56c53 100644 --- a/crates/xgeny-provider-openai/tests/http_contract.rs +++ b/crates/xgeny-provider-openai/tests/http_contract.rs @@ -510,7 +510,7 @@ fn json_object_native_calls_still_reserve_validate_and_settle_exactly_once() { for (content, finish, expected_failure) in [ (valid.clone(), "stop", None), (invalid, "stop", Some(PlannerPortFailure::InvalidResponse)), - (valid, "length", Some(PlannerPortFailure::ProviderLimit)), + (valid, "length", Some(PlannerPortFailure::OutputTruncated)), ] { let mut envelope: Value = serde_json::from_slice(&provider_response(&content)).unwrap(); envelope["choices"][0]["finish_reason"] = json!(finish); diff --git a/crates/xgeny-runtime/src/agent_loop.rs b/crates/xgeny-runtime/src/agent_loop.rs index 2aa791f..c0ecfa0 100644 --- a/crates/xgeny-runtime/src/agent_loop.rs +++ b/crates/xgeny-runtime/src/agent_loop.rs @@ -685,6 +685,12 @@ pub enum PlannerPortFailure { InvalidResponse, #[error("planner request exceeded provider limits")] ProviderLimit, + #[error("planner request is too large")] + RequestTooLarge, + #[error("planner provider rate limited the request")] + RateLimited, + #[error("planner response was truncated")] + OutputTruncated, #[error("planner provider rejected the request")] ProviderRejected, } @@ -1696,13 +1702,25 @@ impl AgentLoop { ), ModelCallConflictIntent::RejectStale, ), - PlannerPortFailure::ProviderLimit => ( + PlannerPortFailure::ProviderLimit + | PlannerPortFailure::RequestTooLarge + | PlannerPortFailure::RateLimited + | PlannerPortFailure::OutputTruncated => ( append_model_call_rejection( store, events, reserved_state, call_id, - ModelCallRejectionReason::ProviderLimit, + match failure { + PlannerPortFailure::RequestTooLarge => { + ModelCallRejectionReason::RequestTooLarge + } + PlannerPortFailure::RateLimited => ModelCallRejectionReason::RateLimited, + PlannerPortFailure::OutputTruncated => { + ModelCallRejectionReason::OutputTruncated + } + _ => ModelCallRejectionReason::ProviderLimit, + }, ), ModelCallConflictIntent::RejectStale, ), diff --git a/crates/xgeny-workgraph/src/lib.rs b/crates/xgeny-workgraph/src/lib.rs index d6d0be7..7709062 100644 --- a/crates/xgeny-workgraph/src/lib.rs +++ b/crates/xgeny-workgraph/src/lib.rs @@ -1462,6 +1462,9 @@ pub enum ModelCallUnknownReason { pub enum ModelCallRejectionReason { PlannerInvalidResponse, ProviderLimit, + RequestTooLarge, + RateLimited, + OutputTruncated, ProviderRejected, ProposalRejected, MaterializationFailed, diff --git a/docs/adr/0044-atomic-json-artifact-wire.md b/docs/adr/0044-atomic-json-artifact-wire.md new file mode 100644 index 0000000..3d1b892 --- /dev/null +++ b/docs/adr/0044-atomic-json-artifact-wire.md @@ -0,0 +1,117 @@ +# ADR-0044: JSON 산출물의 객체 전송과 명시적 chat-template 옵션 + +- 상태: opt-in 구현·통합 검사 완료, 운영 반영 전 PR 검증 +- 날짜: 2026-09-28 +- 범위: OpenAI-compatible adapter와 CLI request profile + +## 9/28 후속: 내부 도메인 schema + +호스트는 `XGENY_OPENAI_ARTIFACT_SCHEMA`에 요청별 JSON Schema **본문**을 선택적으로 전달한다. +help의 `XGENY_OPENAI_ARTIFACT_SCHEMA=atomic-json-schema-v1`은 offline 지원 탐지 marker이며, +환경 변수에 `atomic-json-schema-v1` 문자열을 그대로 넣는 설정이 아니다. +이 옵션은 `json_schema_atomic_json`에서만 허용한다. 모델 ID/profile의 영구 기본값으로 저장하지 않는다. + +- root object, 최대32768bytes·깊이32의 schema를 strict JSON으로 읽는다. +- 지원 vocabulary: type/enum/const/properties/required/additionalProperties(false)/items/anyOf, + minItems/maxItems/minLength/maxLength/minimum/maximum/title/description. +- 참조·외부 리소스·미지원 키워드는 거부한다. 표준 `jsonschema` meta 검사와 Draft202012 offline validator를 쓴다. +- 동일 schema를 provider response_format의 `jsonContent`에 넣고, 돌아온 도메인 객체를 계획 수락 전에 검사한다. + 전체 envelope/model identity/출력 절단 검사가 먼저다. 파일 쓰기·영수증 권위는 변하지 않는다. +- schema가 request profile digest에 포함되므로 resume에도 동일 schema가 필요하다. + 새 schema로 바뀌거나 환경에서 빠지면 기존 실행을 조용히 다른 계약으로 재개하지 않는다. +- 프로세스 환경은 CLI composition이 읽고 provider builder는 명시적인 문자열을 받는다. provider가 환경을 읽지 않는다. + +플랫폼은 하나의 Pydantic 구조에서 schema를 생성하고 strict host 검사도 수행한다. 내용·출처·요구사항 간 +관계 검사는 별도로 유지한다. 범용 provider에 ML 도메인의 필드나 판단 규칙을 넣지 않는다. + +실제 모델 후속 소규모 검사3/3은 필드·타입뿐 아니라 값까지 일치했다(15.007/20.762/11.847초). +하지만 실제 데이터 대화는0/3이었다. 두 timeout과 한 도구 오판/관계 제약 반복 실패로 운영 채택을 보류한다. +이후 아래 초기 검증 실패 기록은 삭제하거나 성공으로 덮어쓰지 않는다. + +## 문제 + +파일 쓰기 계획의 바깥 JSON이 유효해도 `arguments.content`에 모델이 직접 작성한 JSON 문자열은 +깨질 수 있다. ML 플랫폼의 센서 계획과 배송 답변에서 서로 다른 내부 JSON 형식 실패가 관찰됐다. +단순 괄호 보정이나 모델 재요청은 원본 판단을 바꾸거나 미확정 요청을 중복 실행할 수 있다. + +또한 기존 `thinking=disabled`의 `thinking.type=disabled`와 vLLM의 +`chat_template_kwargs.enable_thinking=false`는 다른 옵션이다. 모델 ID나 endpoint로 이를 추측하지 않는다. + +## 결정 + +`--response-format json_schema_atomic_json`을 명시적으로 선택하면 다음 wire를 사용한다. + +```json +{ + "path": "DECISION.json", + "jsonContent": {"answer": "도메인 판단 결과"}, + "expectedDigest": null +} +``` + +- `xgeny.fs/write-atomic@1.0.0`만 허용한다. 다른 capability가 섞이면 계획 전체를 거부한다. +- `jsonContent`는 객체이며 문자열·배열을 받지 않는다. 인자는 위 세 필드로 한정한다. +- 표준 `serde_json::to_string`으로 객체를 native `content` 문자열로 변환한다. JSON 복구나 의미 보정이 아니다. +- 기존 native capability schema, 경로/권한/CAS 검사, 원자 쓰기, 검증과 영수증을 그대로 거친다. +- 파일 쓰기 계획과 완료 판단은 여전히 각각 실제 모델 호출이다. 완료 호출을 가짜 영수증으로 대체하지 않는다. +- 정수 정밀도, Unicode, 인용·역슬래시·개행, 중첩 구조를 모델이 반환한 값 그대로 직렬화한다. +- 기존 envelope/proposal 바이트·깊이 제한, 중복 필드·truncation 거부를 유지한다. + +`--thinking chat_template_disabled`는 `chat_template_kwargs.enable_thinking=false`만 보낸다. +기존 `default`, `disabled`, `enabled`의 wire 의미는 바꾸지 않는다. 이 옵션을 지원하는 provider에서만 쓴다. + +두 옵션은 profile 저장/복원·request digest에 포함한다. 변경된 profile로 기존 run을 몰래 재개하지 않는다. +기본값은 `json_schema/default`이며 거절·timeout에서 다른 모드로 자동 fallback하지 않는다. + +## 한계 + +`jsonContent.additionalProperties=true`이므로 **도메인 필드의 완전성이나 의미를 보장하지 않는다**. +모든 strict-schema provider가 이 열린 객체 schema를 지원하는 것도 아니다. 호환성 확인을 따로 해야 한다. +CLI model check는 작은 completion probe이며, 실제 artifact 작성이나 의미 정확성 검증이 아니다. + +2026-09-28 실제 Qwen endpoint의 호환성 probe는 16.95초에 통과했다. 작은 객체 복사 두 건은 +실제 쓰기·검증·완료와 JSON parsing까지 통과했지만, 추가 필드/변경된 중첩 구조로 내용 일치 0/2였다. +브라우저 대화 세 건도 전체 CLI 90초 안에 끝나지 못했다. 당시 공유 서버는 GPU 두 장 100%, +실행 4~6건·대기 1~3건이었다. 이는 병목 관찰이지 모든 실패가 부하 때문이라는 인과 증명이 아니다. +따라서 성능 개선·사용자 대화 완주·운영 준비 완료라고 주장하지 않는다. + +## 검증 + +```bash +cargo test -p xgeny-provider-openai -p xgeny-cli --all-targets --locked -j 2 +``` + +offline 검사는 기존 profile digest, 새 옵션 저장/복원, native codec, 혼합 capability 거부, +중첩 중복 키·깊이·크기·출력 절단을 확인한다. 플랫폼의 loopback integration은 실제 CLI를 통해 +두 객체의 값 보존·파일·영수증·정확히 두 번의 HTTP 호출을 확인한다. fixture는 모델 품질 증거가 아니다. + +후속은 요청별 도메인 output schema를 wire schema와 host 검증의 단일 소스로 연결하는 설계다. +ML 필드나 특정 데이터셋 용어는 provider 안에 넣지 않는다. 추가적인 native 완료 호출 축소도 +별도 execution contract 없이 이 codec에서 수행하지 않는다. + +공식 chat-template 옵션 근거: [Qwen 모델 카드](https://huggingface.co/Qwen/Qwen3.8-27B-FP8). + +## 9/29 후속: 호출 실패 원인 분리 + +기존 `provider_limit`은 호환 기록으로 유지한다. HTTP413/로컬 요청 크기 초과는 +`request_too_large`, HTTP429는 `rate_limited`, finish_reason=length는 `output_truncated`로 +provider→runtime→영속 ModelCallRejectionReason→CLI verdict에 전달한다. 원본 HTTP 오류 본문, +모델 partial output, credential은 진단으로 출력하지 않는다. timeout/unavailable은 계속 unknown이다. + +원인 분류 자체는 자동 재시도 권한이 아니다. 플랫폼은 정확한 첫 호출/goal digest/거부 원장과 +부작용0을 확인한 output_truncated에 한해서만 변경된 간결화 요청을 별도 원장으로 보낼 수 있다. +완료 단계의 잘림은 이미 도구가 실행됐을 수 있으므로 같은 경로로 재실행하지 않는다. +기존 model compatibility probe의 외부 enum은 유지하고 실제 실행 원장만 세분화했다. + +provider/CLI/runtime/workgraph393개 통과, 외부 환경 검사4개 ignored. 실제 CLI/로컬 HTTP fixture는 +두 객체 구조에서413/429/잘림/완료 단계 잘림/504의 원인·부작용·unknown을 검증했다. +구 binary는 새 reason을 읽지 못할 수 있으므로 배포 시 digest별 binary와 원장 호환성을 보존해야 한다. +운영에는 아직 반영하지 않았다. 플랫폼 상세는 `docs/NATIVE_FAILURE_RECOVERY_20260929.md`에 있다. + +## 9/29 릴리스 준비 + +플랫폼의 단일 행동·관찰 루프와 결합한 실제 모델 브라우저 검사에서 서로 다른 4과제의 +첫 질문·후속 질문·새로고침이 통과했다. 50 HTTP 호출 중 출력 절단0, HTTP 실패0이며 +최대 completion652 tokens였다. 이는 해당 데이터 대화의 표본 결과이며 학습 전체의 성공 보장이 아니다. +native 전체 workspace test, clippy `-D warnings`, fmt, third-party license 검사도 통과했다. +기본 request profile은 변경하지 않는다. 운영 변경은 플랫폼 통합·필수 CI 이후 versioned binary로 진행한다. diff --git a/docs/development/openai-compatible-provider.md b/docs/development/openai-compatible-provider.md index b7e3c63..4d2eb0e 100644 --- a/docs/development/openai-compatible-provider.md +++ b/docs/development/openai-compatible-provider.md @@ -1,5 +1,8 @@ # OpenAI-compatible Provider Adapter +2026-09-28 로컬 opt-in `json_schema_atomic_json`과 `chat_template_disabled`의 계약·실측 실패· +배포 보류 사유는 [ADR-0044](../adr/0044-atomic-json-artifact-wire.md)에 둔다. 기존 기본값은 유지한다. + ## 현재 제공 범위 `xgeny-provider-openai`는 synchronous `PlannerPort`를 OpenAI-compatible Chat Completions에 연결한다.