diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d78aea3fc6..6a0ccd33b5 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -976,6 +976,59 @@ pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Optio } } +/// Whether the route is an official DeepSeek-family endpoint — the only place +/// the DeepSeek strict tool pipeline may act on the wire. +/// +/// The pipeline (`prepare_tools_for_strict_mode` plus +/// `tool_choice: "required"`) encodes DeepSeek's beta strict contract: every +/// property required, `additionalProperties: false`, optional properties +/// removed, `minItems`/`maxItems`/`minLength`/`maxLength`/`dependentRequired` +/// stripped, and schemas carrying undocumented `nullable`/null-type union +/// shapes left non-strict. Applied +/// anywhere else it would strip keywords the other provider's documents +/// explicitly support (OpenAI, for one, documents `minItems`/`maxItems`) and +/// remove optional properties no other provider's contract asks to remove. +/// Provider typing plus the official host keeps a misconfigured +/// `provider`/`base_url` entry from receiving DeepSeek-only reshaping. +/// +/// Source: the DeepSeek strict function-calling contract, "`strict` Mode +/// (Beta)" (, fetched +/// 2026-11-05). `api.deepseeki.com` is not an official host — the docs never +/// mention it, and `docs/REBRAND.md` records it as a legacy typo kept only in +/// URL heuristics for existing configs (#1079); it is accepted here to mirror +/// the compat set the rest of the codebase already recognizes +/// (`client.rs`, `client/chat.rs`, the doctor). +pub fn route_is_official_deepseek_family(provider: ApiProvider, base_url: &str) -> bool { + if !matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ) { + return false; + } + // No whitespace trimming: the doctor's URL classifier and the client's + // strict-host sets string-match the same URL without trimming, so + // admitting a padded URL here alone would put the pipeline in scope on + // a route the wire does not canonicalize — the flag would leak past the + // non-beta strip. A padded URL degrades to out-of-scope instead. + let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); + let official_host = |prefix: &str| { + normalized == prefix + || normalized + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }; + official_host("https://api.deepseek.com") || official_host("https://api.deepseeki.com") +} + +/// Whether the DeepSeek strict tool pipeline (schema reshaping plus +/// `tool_choice: "required"`) acts on a route: the user flag AND the +/// official-route gate. The engine turn-surface build, the child sub-agent +/// surface, and the doctor all answer from this one composition so the +/// surfaces can never disagree about where strict mode is in scope. +pub fn strict_tool_mode_for_route(flag: bool, provider: ApiProvider, base_url: &str) -> bool { + flag && route_is_official_deepseek_family(provider, base_url) +} + /// Reject a provider/model tuple that we can be confident is invalid *before* /// it reaches the network (#3227). /// @@ -3058,8 +3111,22 @@ pub struct Config { pub notes_path: Option, pub memory_path: Option, /// When true, set `tool_choice: "required"` and opt compatible function - /// schemas into DeepSeek beta strict mode. Schemas with root alternatives - /// stay non-strict to avoid changing optional/one-of tool semantics. + /// schemas into DeepSeek beta strict mode — effective only on official + /// DeepSeek-family routes (provider-typed DeepSeek on the + /// `api.deepseek.com` / legacy `api.deepseeki.com` hosts), because the + /// schema reshaping follows DeepSeek's strict documents and would + /// otherwise strip keywords other providers document (e.g. OpenAI's + /// `minItems`/`maxItems`). On those routes optional parameters are removed + /// from the schema (reported in the tool description), non-numeric + /// `const` values land on `enum`, and `dependentRequired` tables are + /// stripped, per the strict contract. Schemas the strict subset cannot + /// represent faithfully — root alternatives, required nullables + /// (`nullable` / null-type union branches), or tools whose runtime + /// demands schema-optional parameters (`update_goal`, `send_later`) — + /// stay non-strict: they keep their original schema. `tool_choice` + /// follows the route gate, not per-tool outcomes: it stays `required` + /// on an in-scope route even when some tools fail closed, and falls + /// back to `auto` only when the route itself is out of scope. pub strict_tool_mode: Option, /// Additional user-owned system-prompt sources concatenated in declared /// order (#454). Paths are expanded via `expand_path` so `~` and env vars diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 6f83f477a8..68085f7a9e 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -13701,3 +13701,98 @@ fn picker_and_request_path_disagree_when_the_secret_slot_marker_is_missing() -> println!("CAPTURED first-fix: {:?}", resolution.first_fix()); Ok(()) } + +#[test] +fn strict_pipeline_scope_is_official_deepseek_family_routes() { + // The DeepSeek strict pipeline reshapes schemas per DeepSeek's strict + // documents, so only provider-typed DeepSeek family routes on the + // official hosts may act on it — a misconfigured provider or a lookalike + // host must not receive the reshaping. + for provider in [ + ApiProvider::Deepseek, + ApiProvider::DeepseekCN, + ApiProvider::DeepseekAnthropic, + ] { + for base_url in [ + "https://api.deepseek.com", + "https://api.deepseek.com/", + "https://api.deepseek.com/v1", + "https://api.deepseek.com/beta", + "https://api.deepseek.com/anthropic", + "https://api.deepseeki.com", + "https://api.deepseeki.com/beta", + ] { + assert!( + super::route_is_official_deepseek_family(provider, base_url), + "{provider:?} on {base_url} is an official family route" + ); + } + } + + for (provider, base_url, why) in [ + ( + ApiProvider::Openai, + "https://api.deepseek.com/beta", + "a DeepSeek URL under a non-DeepSeek provider stays out of scope", + ), + ( + ApiProvider::Deepseek, + "https://llm.example.com/v1", + "a custom endpoint is not an official family route", + ), + ( + ApiProvider::Deepseek, + "https://api.deepseek.com.evil.example/beta", + "a lookalike host must not pass the host-boundary check", + ), + ( + ApiProvider::Deepseek, + "http://api.deepseek.com/beta", + "plaintext http is not the official endpoint", + ), + ( + ApiProvider::Moonshot, + "https://api.moonshot.ai/v1", + "unrelated providers stay out of scope", + ), + ( + ApiProvider::Deepseek, + " HTTPS://API.DEEPSEEK.COM/BETA ", + "a whitespace-padded URL matches no surface that string-compares the base URL \ + (this gate, the doctor classifier, the wire strip set), so admitting it here \ + alone would leak the strict flag past the non-beta strip; it stays out of scope", + ), + ] { + assert!( + !super::route_is_official_deepseek_family(provider, base_url), + "{why}: {provider:?} on {base_url}" + ); + } +} + +#[test] +fn strict_tool_mode_for_route_combines_the_flag_with_the_official_route_gate() { + // One composition answers for the engine turn surface, the child + // sub-agent surface, and the doctor — the user flag alone must never + // enable the pipeline, and an official URL alone must not either. + assert!(super::strict_tool_mode_for_route( + true, + ApiProvider::Deepseek, + "https://api.deepseek.com/beta" + )); + assert!(!super::strict_tool_mode_for_route( + false, + ApiProvider::Deepseek, + "https://api.deepseek.com/beta" + )); + assert!(!super::strict_tool_mode_for_route( + true, + ApiProvider::Openai, + "https://api.deepseek.com/beta" + )); + assert!(!super::strict_tool_mode_for_route( + true, + ApiProvider::Deepseek, + "https://llm.example.com/v1" + )); +} diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f9d0c55661..94aa879af7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -491,7 +491,8 @@ pub struct EngineConfig { /// construction; the engine never touches disk for it. pub locale_tag: String, /// When true, force `tool_choice: "required"` and opt compatible function - /// schemas into DeepSeek beta strict mode. + /// schemas into DeepSeek beta strict mode — on official DeepSeek-family + /// routes only (`crate::config::strict_tool_mode_for_route`). pub strict_tool_mode: bool, /// Workshop / large-tool-output routing (#548). `None` disables routing. pub workshop: Option, @@ -5718,13 +5719,24 @@ impl Engine { if self.config.features.enabled(Feature::Mcp) { apply_registry_first_shell_guidance(&mut catalog); } + // The strict pipeline encodes DeepSeek's strict-mode contract, so it + // acts only on official DeepSeek-family routes; elsewhere it would + // strip keywords the other provider documents (minItems/maxItems on + // OpenAI) and remove optional properties no other contract asks to + // remove. This gate also drives the turn loop's `tool_choice` and the + // preview surface, which read the flag off this policy. + let strict_tool_mode = crate::config::strict_tool_mode_for_route( + self.config.strict_tool_mode, + route.provider, + &route.api_config.deepseek_base_url(), + ); let surface = ToolSurfacePolicy::new( tool_registry, Some(catalog), input_policy.mode, &always_load, &input_policy.dynamic_active_tools, - self.config.strict_tool_mode, + strict_tool_mode, allowed_tools, self.config.disallowed_tools.clone(), self.config.max_tool_calls, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 35ff93980d..2deb9cfc05 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -36355,3 +36355,139 @@ fn run_completion_real_factories_observe_shutdown_and_direct_handles_do_not() { ); }); } + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn turn_surface_strict_tool_mode_follows_the_official_route_gate() { + // The turn surface must receive the strict flag only through the + // official-route gate. Dropping that gate would re-expose the pre-gate + // behavior where any provider with `strict_tool_mode` on received the + // DeepSeek reshaping and `tool_choice:"required"` on every route. + let _env_lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).expect("create isolated home"); + let _home = EnvVarGuard::set("HOME", &home); + let _userprofile = EnvVarGuard::set("USERPROFILE", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("create workspace"); + + let strict_api_config = Config { + api_key: Some("strict-gate-fixture".to_string()), + default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()), + strict_tool_mode: Some(true), + ..Config::default() + }; + let (mut engine, _engine_handle) = Engine::new( + EngineConfig { + workspace, + allow_shell: true, + strict_tool_mode: true, + ..EngineConfig::default() + }, + &strict_api_config, + ); + engine.config.features.disable(Feature::Mcp); + let client = engine.deepseek_client.clone(); + let locale_tag = engine.config.locale_tag.clone(); + let role_models = engine.subagent_role_models(); + let authority = crate::core::authority::TurnAuthority::from_effective_fields( + AppMode::Agent, + true, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ); + let route_with = |provider: ApiProvider, api_config: Config| TurnRouteContext { + provider, + model: DEFAULT_TEXT_MODEL.to_string(), + capabilities: codewhale_config::route::RouteCapabilities::default(), + limits: None, + client: client.clone(), + api_config: Box::new(api_config), + locale_tag: locale_tag.clone(), + role_models: role_models.clone(), + auto_model: false, + reasoning_effort: None, + reasoning_effort_auto: false, + }; + + // Family provider on the official endpoint: in scope. + let build = engine + .build_turn_tool_registry_and_catalog( + &authority, + &[], + None, + SubAgentWiring::Inert, + McpAccess::PassiveSnapshot, + route_with(ApiProvider::Deepseek, strict_api_config.clone()), + "", + ) + .await; + assert!( + build.surface.strict_tool_mode, + "the official family route is in scope" + ); + + // The same family provider on a custom endpoint: the gate keeps the + // pipeline off — no reshaping, no `tool_choice:"required"`. + let custom = Config { + api_key: Some("strict-gate-fixture".to_string()), + default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + base_url: Some("https://llm.example.com/v1".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Config::default() + }; + let build = engine + .build_turn_tool_registry_and_catalog( + &authority, + &[], + None, + SubAgentWiring::Inert, + McpAccess::PassiveSnapshot, + route_with(ApiProvider::Deepseek, custom), + "", + ) + .await; + assert!( + !build.surface.strict_tool_mode, + "a family provider on a custom endpoint stays out of scope" + ); + + // An official DeepSeek URL under a non-family provider: out of scope. + let openai_on_official = Config { + api_key: Some("strict-gate-fixture".to_string()), + default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + openai: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/beta".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Config::default() + }; + let build = engine + .build_turn_tool_registry_and_catalog( + &authority, + &[], + None, + SubAgentWiring::Inert, + McpAccess::PassiveSnapshot, + route_with(ApiProvider::Openai, openai_on_official), + "", + ) + .await; + assert!( + !build.surface.strict_tool_mode, + "a non-family provider on an official URL stays out of scope" + ); +} diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 10dd8298eb..4f09804339 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -883,6 +883,17 @@ pub(crate) fn active_tools_for_request( } let mut tools = active_tools_for_step(catalog, active); if strict_tool_mode { + // `prepare_tools_for_strict_mode` returns whether EVERY tool shipped + // `strict: true`. The return is intentionally not consulted: a + // per-tool fail-closed outcome (a schema that needs an undocumented + // keyword, or a runtime demanding parameters the strict removal + // takes away) ships that tool unchanged without the flag beside + // `strict: true` siblings, while `tool_choice` follows the route + // gate rather than per-tool results. The documents describe strict + // mode with every function flagged and leave the server's mixed-set + // behavior undocumented; if a probe ever shows the endpoint + // rejecting or silently downgrading mixed sets, this discarded + // boolean is the lever for an all-or-nothing policy. crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools); } Some(tools) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 7741a034ea..bc5697b5b9 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -4718,8 +4718,10 @@ async fn run_doctor( } let strict_tool_mode = doctor_strict_tool_mode_status(config); let strict_icon = match strict_tool_mode.status { - "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b), - "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b), + "ready" | "official_route" => "✓".truecolor(aqua_r, aqua_g, aqua_b), + "fallback_non_beta" | "custom_endpoint" | "provider_mismatch" | "responses_dialect" => { + "!".truecolor(sky_r, sky_g, sky_b) + } _ => "·".dimmed(), }; println!( @@ -4727,10 +4729,13 @@ async fn run_doctor( strict_icon, strict_tool_mode.message ); if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() { - println!( - " Use the {} endpoint for DeepSeek strict schemas.", - crate::doctor::structural_url_authority(recommended) - ); + // The recommendation is a built-in constant, never a configured + // URL, so its path must survive verbatim: the authority-only + // redaction used for configured endpoints would render + // `https://api.deepseek.com/beta` as `https://api.deepseek.com`, + // and the bare host is exactly where the chat wire still strips + // `function.strict`. + println!(" Use the {recommended} endpoint for DeepSeek strict schemas."); } let capability = crate::config::provider_capability(config.api_provider(), &api_target.model); if let Some(alias) = capability.alias_deprecation.as_ref() { @@ -7465,6 +7470,11 @@ struct DoctorApiTarget { base_url: String, model: String, resolution: DoctorModelResolution, + /// The resolver's endpoint key for the resolved route (`Some` only when + /// `resolution` is `Resolved`). The dialect answers — Anthropic + /// forwarding, Responses `strict: false` — are wire facts keyed on this, + /// not on the URL. + endpoint_key: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -7484,12 +7494,17 @@ fn doctor_api_target(config: &Config) -> DoctorApiTarget { // saved provider models, alias normalization, and roster preference // included — instead of re-deriving a config default that can diverge // from the engine (DGF-01, dogfood 2026-08-02). - let (model, resolution) = + let (model, resolution, endpoint_key) = match crate::route_runtime::resolve_runtime_route(config, provider, None) { - Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved), + Ok(route) => ( + route.model.clone(), + DoctorModelResolution::Resolved, + Some(route.candidate.endpoint().endpoint_key.clone()), + ), Err(_) => ( config.default_model(), DoctorModelResolution::ConfiguredOnly, + None, ), }; DoctorApiTarget { @@ -7497,6 +7512,7 @@ fn doctor_api_target(config: &Config) -> DoctorApiTarget { base_url: config.deepseek_base_url(), model, resolution, + endpoint_key, } } @@ -7512,6 +7528,81 @@ fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus } let target = doctor_api_target(config); + // Answer from the same composition the engine and sub-agent gates use + // (`strict_tool_mode_for_route`): the flag plus provider typing plus the + // official host. Classifying the URL alone made the Beta arm claim strict + // was sent to a DeepSeek URL under a non-family provider, and made this + // arm claim "unreshaped" for official-host paths (e.g. the + // Anthropic-family endpoint) that the gate actually reshapes. + if !crate::config::strict_tool_mode_for_route( + config.strict_tool_mode.unwrap_or(false), + config.api_provider(), + &target.base_url, + ) { + if known_deepseek_base_url_kind(&target.base_url).is_some() { + return DoctorStrictToolModeStatus { + enabled: true, + status: "provider_mismatch", + function_strict_sent: false, + message: "enabled, but this route's provider is not the DeepSeek family, so \ + the strict tool pipeline stays off even though the base URL is a \ + recognized DeepSeek endpoint URL; tools keep their schemas and \ + `tool_choice` falls back to `auto`" + .to_string(), + recommended_base_url: None, + }; + } + return DoctorStrictToolModeStatus { + enabled: true, + status: "custom_endpoint", + function_strict_sent: false, + message: "enabled, but the strict tool pipeline only acts on official DeepSeek \ + https endpoints; this custom endpoint keeps its tools unreshaped and \ + `tool_choice` falls back to `auto`" + .to_string(), + recommended_base_url: None, + }; + } + // The Anthropic Messages wire forwards `function.strict` untranslated on + // every official path — the chat-completions strip does not apply there — + // while the beta message below describes a chat-completions surface an + // Anthropic-dialect route must not be pointed at. Answer that provider + // from the wire, not from the URL kind: the flag IS sent, and the + // beta/non-beta recommendation is withheld for the dialect. + if config.api_provider() == crate::config::ApiProvider::DeepseekAnthropic { + return DoctorStrictToolModeStatus { + enabled: true, + status: "official_route", + function_strict_sent: true, + // "DeepSeek-family", not "official": the gate also accepts the + // legacy `api.deepseeki.com` compat host, which is not an + // official endpoint (docs/REBRAND.md) — the same wording the + // provider_mismatch arm uses ("recognized"). + message: "enabled; the Anthropic wire forwards function.strict \ + untranslated on this DeepSeek-family endpoint" + .to_string(), + recommended_base_url: None, + }; + } + // The Responses dialect answers from its wire too: a route whose model + // resolves to the Responses endpoint ships `"strict": false` per tool + // and `tool_choice: "auto"` regardless of which official URL serves it, + // so the reshaping applies with no enforcement. The dialect is + // model-driven, not URL-driven — no base-URL recommendation changes it. + if target.endpoint_key.as_deref() == Some("responses") { + return DoctorStrictToolModeStatus { + enabled: true, + status: "responses_dialect", + function_strict_sent: false, + message: "enabled, but this route's model resolves to the Responses dialect, \ + whose wire sends `strict: false` and `tool_choice: \"auto\"`; schemas \ + are still reshaped for the strict contract, but no strict enforcement \ + is sent — pick a model that resolves to the chat-completions surface \ + to send the flag" + .to_string(), + recommended_base_url: None, + }; + } match known_deepseek_base_url_kind(&target.base_url) { Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus { enabled: true, @@ -7532,11 +7623,19 @@ fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus recommended_base_url: Some(recommended.to_string()), } } + // Official host, non-canonical path (the Anthropic-family endpoint, + // /v2, ...): the gate acts here — schemas are reshaped and + // `tool_choice:"required"` is sent — and the client keeps the strict + // flag on official paths it does not canonicalize. None => DoctorStrictToolModeStatus { enabled: true, - status: "custom_endpoint", + status: "official_route", function_strict_sent: true, - message: "enabled; function.strict will be sent to this custom endpoint".to_string(), + // "DeepSeek-family": the arm also covers the legacy + // `api.deepseeki.com` compat host, which is recognized but not + // official (docs/REBRAND.md). + message: "enabled; the strict tool pipeline acts on this DeepSeek-family route" + .to_string(), recommended_base_url: None, }, } @@ -7548,10 +7647,11 @@ fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> s "status": status.status, "function_strict_sent": status.function_strict_sent, "message": status.message, - "recommended_base_url": status - .recommended_base_url - .as_deref() - .map(crate::doctor::structural_url_authority), + // Verbatim, not authority-redacted: the recommendation is a + // built-in constant, and its `/beta` path is the whole point — + // the redacted `https://api.deepseek.com` is a URL where the + // chat wire still strips `function.strict`. + "recommended_base_url": status.recommended_base_url, }) } @@ -13995,9 +14095,13 @@ mod doctor_endpoint_tests { status.recommended_base_url.as_deref(), Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL) ); + // The recommendation ships with its `/beta` path intact — the + // bare `https://api.deepseek.com` authority is exactly where the + // chat wire still strips `function.strict`, so a path-stripped + // recommendation would send the user back into this same verdict. assert_eq!( doctor_strict_tool_mode_report_json(&status)["recommended_base_url"], - "https://api.deepseek.com" + "https://api.deepseek.com/beta" ); } @@ -14018,7 +14122,10 @@ mod doctor_endpoint_tests { } #[test] - fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() { + fn strict_tool_mode_doctor_marks_custom_endpoint_as_out_of_scope() { + // The strict pipeline reshapes schemas per DeepSeek's strict-mode + // documents; a custom (non-DeepSeek-family) endpoint keeps its tools + // unreshaped, and nothing is advertised as sent strict. let config = Config { provider: Some("vllm".to_string()), strict_tool_mode: Some(true), @@ -14028,8 +14135,174 @@ mod doctor_endpoint_tests { let status = doctor_strict_tool_mode_status(&config); assert_eq!(status.status, "custom_endpoint"); + assert!(!status.function_strict_sent); + assert!(status.message.contains("official DeepSeek")); + } + + #[test] + fn strict_tool_mode_doctor_keeps_official_deepseek_urls_out_of_scope_for_other_providers() { + // The engine gate requires DeepSeek-family provider typing, so an + // official DeepSeek URL under another provider stays out of scope and + // must not be reported as "ready". + let config = Config { + provider: Some("openai".to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + openai: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/beta".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "provider_mismatch"); + assert!(!status.function_strict_sent); + assert!(status.message.contains("not the DeepSeek family")); + assert!(status.recommended_base_url.is_none()); + } + + #[test] + fn strict_tool_mode_doctor_reports_official_custom_paths_as_in_scope() { + // Any path on the official host passes the engine gate, so a chat + // dialect on a non-canonical official path is in scope: schemas are + // reshaped and `tool_choice:"required"` is sent — the doctor must + // not claim the tools stay unreshaped there. + let config = Config { + provider: Some("deepseek".to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/v2".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "official_route"); + assert!(status.function_strict_sent); + // "DeepSeek-family", not "official": the arm also covers the legacy + // api.deepseeki.com compat host, which is recognized but not + // official (docs/REBRAND.md). + assert!(status.message.contains("DeepSeek-family route")); + assert!(status.recommended_base_url.is_none()); + } + + #[test] + fn strict_tool_mode_doctor_answers_the_anthropic_provider_from_the_wire_on_beta() { + // The gate passes for the Anthropic-family provider on the official + // beta host, but the beta verdict describes a chat-completions + // surface; the Anthropic Messages wire forwards the flag on its own + // dialect, so the doctor answers from the wire (`official_route`) + // and never shows the chat-dialect `ready` message for it. + let config = Config { + provider: Some("deepseek-anthropic".to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek_anthropic: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/beta".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "official_route"); + assert!(status.function_strict_sent); + assert!(status.message.contains("Anthropic wire forwards")); + assert!(status.recommended_base_url.is_none()); + } + + #[test] + fn strict_tool_mode_doctor_reports_anthropic_forwarding_on_non_beta_canonical_endpoints() { + // The chat wire strips `function.strict` on the canonical non-beta + // endpoints, but the Anthropic Messages wire forwards it + // untranslated — the doctor must not claim the flag is stripped for + // the Anthropic-family provider, and must not recommend pointing an + // Anthropic-dialect route at the chat-completions beta surface. + let config = Config { + provider: Some("deepseek-anthropic".to_string()), + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek_anthropic: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "official_route"); + assert!(status.function_strict_sent); + assert!(status.message.contains("Anthropic wire forwards")); + assert!(status.recommended_base_url.is_none()); + } + + #[test] + fn strict_tool_mode_doctor_reports_the_responses_dialect_for_versioned_models() { + // A route whose model resolves to the Responses endpoint ships + // `"strict": false` per tool and `tool_choice: "auto"` on the wire, + // so the doctor must not claim the flag was sent — the dialect is + // model-driven, so no base-URL recommendation is offered either. + let config = Config { + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/beta".to_string()), + model: Some("deepseek-v3".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "responses_dialect"); + assert!(!status.function_strict_sent); + assert!(status.message.contains("Responses dialect")); + assert!(status.recommended_base_url.is_none()); + let report = doctor_strict_tool_mode_report_json(&status); + assert_eq!(report["status"], "responses_dialect"); + assert_eq!(report["function_strict_sent"], false); + } + + #[test] + fn strict_tool_mode_doctor_keeps_the_url_verdict_for_a_chat_surface_model() { + // Exact catalog rows stay authoritative: a model whose row resolves + // to the chat surface keeps the URL-kind verdict (`ready` on the + // beta endpoint) even though it is versioned. + let config = Config { + strict_tool_mode: Some(true), + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + base_url: Some("https://api.deepseek.com/beta".to_string()), + model: Some("deepseek-v4-pro".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + let status = doctor_strict_tool_mode_status(&config); + + assert_eq!(status.status, "ready"); assert!(status.function_strict_sent); - assert!(status.message.contains("custom endpoint")); } #[test] diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs index 5641e0671f..cda54d1de6 100644 --- a/crates/tui/src/tools/goal.rs +++ b/crates/tui/src/tools/goal.rs @@ -1992,4 +1992,38 @@ mod tests { assert_eq!(state.time_used_seconds, 0); assert_eq!(state.continuation_count, 0); } + + #[test] + fn strict_mode_fails_the_update_goal_tool_closed() { + // `update_goal` keeps only `status` in strict mode while every + // status value runtime-requires a sibling parameter the schema + // marks optional (`execute` demands `evidence`/`verification` for + // `complete`, `blocker` for `blocked`, `verification` for + // `not_achieved`, `advisory` for `advisory`). The strict pipeline + // fails the tool closed by name (`STRICT_UNUSABLE_TOOLS`) so it + // ships unchanged, non-strict, and usable. This census test feeds + // the real schema through the pipeline so the two cannot drift. + let tool = UpdateGoalTool::new(new_shared_goal_state()); + let schema = tool.input_schema(); + assert_eq!(schema["required"], json!(["status"])); + let mut tools = vec![crate::models::Tool { + tool_type: None, + name: tool.name().to_string(), + description: tool.description().to_string(), + input_schema: schema.clone(), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!( + !crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools), + "update_goal must fail closed, not ship strict with only `status`" + ); + assert_eq!(tools[0].strict, None); + assert_eq!(tools[0].input_schema, schema); + assert_eq!(tools[0].description, tool.description()); + } } diff --git a/crates/tui/src/tools/remember.rs b/crates/tui/src/tools/remember.rs index d53e6ea728..43c55b92a0 100644 --- a/crates/tui/src/tools/remember.rs +++ b/crates/tui/src/tools/remember.rs @@ -75,7 +75,13 @@ impl ToolSpec for RememberTool { "description": "Native backend scope; defaults to global. workspace requires a git repository with an origin." } }, - "required": [] + // `note` is required at runtime for append and revise (`execute` + // rejects its absence there; `retire` ignores the value), so the + // schema says so — and the field stays required for retire too, + // or an all-optional schema would leave strict mode nothing to + // keep: DeepSeek's strict contract removes optional properties, + // which would leave this tool callable only with an empty object. + "required": ["note"] }) } @@ -445,4 +451,52 @@ mod tests { let err = tool.execute(json!({}), &ctx).await.unwrap_err(); assert!(err.to_string().to_lowercase().contains("note"), "{err}"); } + + #[test] + fn strict_mode_keeps_remember_usable_with_its_note() { + // The schema declares `note` required so strict mode has something + // to keep: the pipeline marks the tool strict, `note` survives + // (append is the default action, so `{"note": ...}` stays callable), + // and the other parameters are removed with the description note. + // Reverting the schema to an all-optional shape must fail here — + // without this pin the schema and the strict pipeline could drift + // apart silently. + let tool = RememberTool; + let schema = tool.input_schema(); + let mut tools = vec![crate::models::Tool { + tool_type: None, + name: tool.name().to_string(), + description: tool.description().to_string(), + input_schema: schema.clone(), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!( + crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools), + "remember's schema is strict-representable: {}", + schema + ); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!(tools[0].input_schema["required"], json!(["note"])); + let properties = tools[0].input_schema["properties"].as_object().unwrap(); + assert_eq!( + properties.len(), + 1, + "only `note` ships in strict mode: {properties:?}" + ); + assert!(properties.contains_key("note")); + assert_eq!( + tools[0].input_schema["properties"]["note"]["type"], + schema["properties"]["note"]["type"] + ); + assert!( + tools[0].description.contains("Strict mode:"), + "the removal note replaces nothing: {}", + tools[0].description + ); + } } diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index bdc0bda33b..ac18f23585 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -46,12 +46,118 @@ pub fn sanitize(schema: &mut Value) { /// /// Each tool is evaluated independently: compatible schemas are sanitized and /// marked strict, while incompatible schemas remain unchanged and non-strict. -/// Returns `true` only when every tool in the set can use strict mode. +/// Optional properties removed to satisfy the strict contract are reported in +/// the tool description so the model knows not to send them. Returns `true` +/// only when every tool in the set can use strict mode. +/// +/// Incompatible includes schemas the documented strict subset cannot +/// represent faithfully: root alternatives, required nullables (`nullable` +/// or a null-type union branch — `sanitize` collapses the common `X | null` +/// union into `nullable: true`, so the keyword is how a required nullable +/// survives to the gate), null-carrying `type` arrays / `const` / `enum` +/// members, structural or conditional keywords outside the documented subset +/// (the same family the Moonshot MFJS path fails closed on), a null `const` +/// or schema-form `dependencies` table (stripped before the gate could see +/// them), and a removal that would leave the tool callable only with an +/// empty object — an all-optional root, or a nested object whose every +/// property was optional. Such a tool keeps its original schema and ships +/// non-strict rather than carrying an undocumented keyword under a strict +/// flag or advertising parameters it can no longer accept. The gate runs on +/// the enforced shape, so a null-carrying shape under an *optional* property +/// is exempt: the removal already represents it faithfully. +/// +/// Callers gate this pipeline on official DeepSeek-family routes +/// (`crate::config::route_is_official_deepseek_family`): the reshaping +/// follows DeepSeek's strict-mode documents and would strip keywords other +/// providers document (e.g. `minItems`/`maxItems` on OpenAI). +/// Tools the strict pipeline fails closed by name because no schema-level +/// analysis available to the reshaping can see the breakage: the schema +/// advertises one discriminating parameter as the only required property +/// while `execute` demands sibling parameters the schema leaves optional, +/// so after the optional-removal pass the tool ships `strict: true` +/// callable only with values its runtime rejects. +/// +/// `update_goal` runtime-requires `evidence` and `verification` for +/// `status: "complete"`, `blocker` for `"blocked"`, `verification` for +/// `"not_achieved"`, and `advisory` for `"advisory"` (`tools/goal.rs`); +/// `send_later` runtime-requires `message` for `action: "schedule"` and +/// `trigger_id` for `"read"`/`"cancel"` (`tools/send_later.rs`). The +/// documented strict subset cannot express those per-value requirements — +/// `if`/`then` and root alternatives are outside it — and widening the +/// schema requirements would make non-strict callers send junk for every +/// other value, so the tools ship unchanged and non-strict instead. +/// Per-action schema honesty would retire this list. +const STRICT_UNUSABLE_TOOLS: &[&str] = &["update_goal", "send_later"]; + +/// Structural and conditional keywords outside the documented strict +/// subset. Their constraints cannot be expressed by the reshaping — the +/// same family the Moonshot MFJS path fails closed on — so a schema +/// relying on one ships unchanged and non-strict. Shared between the +/// composition gate and the test loop that pins each keyword so the two +/// lists cannot drift. +const STRICT_UNREPRESENTABLE_KEYWORDS: &[&str] = &[ + "not", + "if", + "then", + "else", + "contains", + "prefixItems", + "propertyNames", + "unevaluatedProperties", + "unevaluatedItems", +]; + pub fn prepare_tools_for_strict_mode(tools: &mut [Tool]) -> bool { let mut all_strict = true; for tool in tools { + if STRICT_UNUSABLE_TOOLS.contains(&tool.name.as_str()) { + // The runtime demands parameters the schema marks optional (see + // the list above); the removal pass would leave the tool strict + // with only its discriminator. Ship unchanged and non-strict. + tool.strict = None; + all_strict = false; + continue; + } if strict_schema_supported(&tool.input_schema) { - sanitize_for_strict(&mut tool.input_schema); + let original_schema = tool.input_schema.clone(); + let removed_optionals = sanitize_for_strict(&mut tool.input_schema); + // The strict contract removes every property the schema left + // optional. A tool whose whole root property set was optional is + // left with no way to pass any parameter while its runtime still + // demands them (action-discriminated tools mark everything + // optional and dispatch on the values `execute` re-reads) — that + // shape cannot serve the tool's contract under a strict flag, so + // it ships unchanged and non-strict, the same fail-closed rule + // as required nullables. No removal note either: nothing was + // removed from the schema that ships. + let emptied = tool + .input_schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(Map::is_empty) + && original_schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.is_empty()); + if emptied { + tool.input_schema = original_schema; + tool.strict = None; + all_strict = false; + continue; + } + if !removed_optionals.is_empty() { + let note = format!( + "Strict mode: these optional parameters were removed from the schema and \ + must not be sent: {}.", + removed_optionals.join(", ") + ); + let description = tool.description.trim(); + tool.description = if description.is_empty() { + note + } else { + format!("{description}\n\n{note}") + }; + } tool.strict = Some(true); } else { tool.strict = None; @@ -65,10 +171,20 @@ pub fn prepare_tools_for_strict_mode(tools: &mut [Tool]) -> bool { /// /// This extends the general sanitizer with the official strict-mode object /// rules: every object must set `additionalProperties: false`, and every -/// property must be listed in `required`. -pub fn sanitize_for_strict(schema: &mut Value) { +/// property must be listed in `required`. A property the schema does not +/// require cannot satisfy "every property listed in `required`" honestly — +/// marking it `nullable:true` would inject a keyword outside the documented +/// strict subset — so it is removed from `properties` instead, and its name +/// is returned here so the caller can surface the removal. +/// +/// Returns the removed optional property names, sorted and deduplicated. +pub fn sanitize_for_strict(schema: &mut Value) -> Vec { sanitize(schema); - enforce_strict_subset(schema); + let mut removed_optionals = Vec::new(); + enforce_strict_subset(schema, &mut removed_optionals); + removed_optionals.sort(); + removed_optionals.dedup(); + removed_optionals } /// Sanitize a tool `parameters` schema for xAI chat completions. @@ -126,23 +242,235 @@ pub fn sanitize_for_responses(schema: &mut Value) -> Option { fn strict_schema_supported(schema: &Value) -> bool { let mut normalized = schema.clone(); sanitize(&mut normalized); + // A root map shape — no properties of its own beside a `true` or + // schema-valued `additionalProperties` — collapses under the enforce + // pass into `additionalProperties: false` plus an empty `properties` + // map: `strict: true` callable only with `{}` while the tool's + // runtime reads arbitrary keys. The authored no-argument roots that + // stay strict are the ones whose `additionalProperties` is absent or + // already `false` (tools that take no values at all); the same shape + // fails closed one level down through the gate's nested rule, so the + // root fails closed here, while the authored keyword shape is still + // visible. A node the enforce pass would not normalize at all keeps + // the keyword and is caught by the detector below. + if normalized + .get("properties") + .and_then(Value::as_object) + .is_none_or(Map::is_empty) + && normalized + .get("additionalProperties") + .is_some_and(|additional| { + !additional.is_boolean() || additional.as_bool() == Some(true) + }) + { + return false; + } + // The enforce pass strips some constraints before the compatibility + // gate below can judge them: a null `const` (dropped outright) and a + // schema-form draft-7 `dependencies` table (dropped with the name-table + // form) carry requirements the shipped schema would no longer express. + // They are detected here, on the sanitized shape, with the + // optional-removal in view — an occurrence under an optional property + // is moot because the removal takes the whole property. + if has_unrepresentable_stripped_constraint(&normalized, true) { + return false; + } + // Gate the shape that would actually ship: the enforce pass removes + // optional properties, so a null-carrying shape under an optional + // property is faithfully represented by that removal and no longer + // forces the tool non-strict, while the same shape under a required + // property still does. + enforce_strict_subset(&mut normalized, &mut Vec::new()); !has_strict_incompatible_composition(&normalized, true) } +/// Detect the constraints the enforce pass strips before the compatibility +/// gate can judge them. `ships` reports whether the node survives the +/// optional-removal pass — only then does its constraint still reach the +/// wire. A null `const` is a required nullable wearing another keyword, and +/// a schema-form `dependencies` value is semantically `dependentSchemas` +/// (which the gate fails closed), so its draft-7 spelling fails closed too +/// rather than shipping strict with the cross-schema requirement silently +/// gone; the name-table form is `dependentRequired`, whose strip only +/// widens and stays a strip. +fn has_unrepresentable_stripped_constraint(schema: &Value, ships: bool) -> bool { + let Some(obj) = schema.as_object() else { + return schema.as_array().is_some_and(|values| { + values + .iter() + .any(|value| has_unrepresentable_stripped_constraint(value, ships)) + }); + }; + if ships && obj.get("const").is_some_and(Value::is_null) { + return true; + } + if ships + && obj + .get("dependencies") + .and_then(Value::as_object) + .is_some_and(|table| table.values().any(Value::is_object)) + { + return true; + } + // The enforce pass normalizes `additionalProperties` to `false` only + // on schemas it treats as objects (`type: "object"` or a `properties` + // map). On any other node the authored keyword ships verbatim under a + // strict flag — and a `true` or schema-valued `additionalProperties` + // is map semantics the documented subset does not describe, so the + // node fails closed instead. + if ships + && !is_object_schema(obj) + && obj.get("additionalProperties").is_some_and(|additional| { + !additional.is_boolean() || additional.as_bool() == Some(true) + }) + { + return true; + } + let required = required_names(obj); + for (key, value) in obj { + match key.as_str() { + "properties" | "$defs" | "definitions" | "$def" => { + if let Some(named_schemas) = value.as_object() { + for (name, named_schema) in named_schemas { + // Definitions ship whenever their `$ref` resolves, + // so they are judged without the optional-property + // exemption; only a property the schema leaves + // optional is removed wholesale. + let ships_child = + key != "properties" || required.iter().any(|required| required == name); + if has_unrepresentable_stripped_constraint(named_schema, ships_child) { + return true; + } + } + } + } + "enum" | "const" | "default" | "examples" => {} + _ => { + if has_unrepresentable_stripped_constraint(value, ships) { + return true; + } + } + } + } + false +} + fn has_strict_incompatible_composition(schema: &Value, is_root: bool) -> bool { if let Some(obj) = schema.as_object() { if obj.contains_key("oneOf") || obj.contains_key("allOf") || obj.contains_key("dependentSchemas") + // `nullable` is outside the documented strict subset — the + // supported-type list has no null and the documents never + // mention the keyword. `sanitize` collapses the common + // `X | null` union into `nullable: true`, so by the time a + // schema reaches here the keyword is the surviving shape of a + // required nullable; such a tool falls back to non-strict + // instead of shipping an undocumented keyword under a strict + // flag. + || obj.contains_key("nullable") { return true; } if is_root && obj.contains_key("anyOf") { return true; } - return obj - .values() - .any(|value| has_strict_incompatible_composition(value, false)); + // A union that still carries a null branch after the collapse pass + // (three or more alternatives) has the same undocumented-null + // problem. + if obj + .get("anyOf") + .and_then(Value::as_array) + .is_some_and(|branches| branches.iter().any(is_null_type)) + { + return true; + } + // The documented subset's union mechanism is `anyOf`; a `type` array + // is a draft-4-style union the documents never describe, and `null` + // is not a supported type anywhere — neither bare nor as an array + // member. Either shape cannot be represented faithfully under a + // strict flag. + match obj.get("type") { + Some(Value::String(schema_type)) if schema_type == "null" => return true, + Some(Value::Array(_)) => return true, + _ => {} + } + // An enum or const that accepts null is a required nullable wearing + // another keyword: the property must be sent and can be null. + if obj + .get("enum") + .and_then(Value::as_array) + .is_some_and(|members| members.iter().any(Value::is_null)) + { + return true; + } + if obj.get("const").is_some_and(Value::is_null) { + return true; + } + // Keywords in `STRICT_UNREPRESENTABLE_KEYWORDS` carry constraints + // the reshaping cannot express — the same family the Moonshot MFJS + // path fails closed on. A schema relying on one ships unchanged and + // non-strict rather than reshaped-and-strict. + if obj + .keys() + .any(|key| STRICT_UNREPRESENTABLE_KEYWORDS.contains(&key.as_str())) + { + return true; + } + if obj + .get("items") + .is_some_and(|items| matches!(items, Value::Array(_))) + { + return true; + } + // A nested object left with no properties by the optional-removal + // pass ships callable only with `{}` while the tool's runtime may + // still read the removed members — the same degenerate shape the + // root-emptied check in `prepare_tools_for_strict_mode` fails + // closed, one level down (the root keeps its own check there so + // authored no-argument roots stay strict). A bare + // `{"type":"object"}` node gets an empty `properties` injected by + // `sanitize`, and under `additionalProperties: false` that shape + // cannot carry a value either. `$defs` entries are judged here too: + // the gate does not track `$ref` reachability, so a definition that + // could not serve a resolved reference also fails the tool closed. + if !is_root + && is_object_schema(obj) + && obj + .get("properties") + .and_then(Value::as_object) + .is_some_and(Map::is_empty) + { + return true; + } + // Walk children like the sibling walkers do: `properties`, + // `$defs`, `definitions`, and `$def` map names to schemas, and + // `enum`/`const`/`default`/`examples` hold literal values — only + // their values are schemas. Judging the name maps themselves as + // schemas made the gate fire on names alone: a required property + // named after a keyword (or a definition named `not`) failed the + // tool closed on its name, and object-shaped `enum` members or + // `default` values failed it on their shape. + for (key, value) in obj { + match key.as_str() { + "properties" | "$defs" | "definitions" | "$def" => { + if let Some(named_schemas) = value.as_object() { + if named_schemas.values().any(|named_schema| { + has_strict_incompatible_composition(named_schema, false) + }) { + return true; + } + } + } + "enum" | "const" | "default" | "examples" => {} + _ => { + if has_strict_incompatible_composition(value, false) { + return true; + } + } + } + } + return false; } schema.as_array().is_some_and(|arr| { arr.iter() @@ -260,7 +588,7 @@ fn collapse_single_element_unions(schema: &mut Value) { } } -fn enforce_strict_subset(schema: &mut Value) { +fn enforce_strict_subset(schema: &mut Value, removed_optionals: &mut Vec) { if let Some(obj) = schema.as_object_mut() { strip_unsupported_strict_keywords(obj); if is_object_schema(obj) { @@ -268,34 +596,118 @@ fn enforce_strict_subset(schema: &mut Value) { let properties = ensure_properties_object(obj); let mut property_names: Vec = properties.keys().cloned().collect(); property_names.sort(); - for property_name in &property_names { - if !originally_required - .iter() - .any(|required| required == property_name) - && let Some(property_schema) = properties.get_mut(property_name) - { - mark_nullable(property_schema); - } + // The strict contract is "every property is required". A property + // the tool left optional would have to carry `nullable: true` to + // keep accepting omission — a keyword outside the documented + // strict subset — so it is removed instead and reported through + // `removed_optionals` (see `sanitize_for_strict`). + let removed_here: Vec = property_names + .iter() + .filter(|name| !originally_required.iter().any(|required| required == *name)) + .cloned() + .collect(); + for name in &removed_here { + properties.shift_remove(name); } + removed_optionals.extend(removed_here); + let mut remaining: Vec = properties.keys().cloned().collect(); + remaining.sort(); obj.insert( "required".into(), - Value::Array(property_names.into_iter().map(Value::String).collect()), + Value::Array(remaining.into_iter().map(Value::String).collect()), ); obj.insert("additionalProperties".into(), Value::Bool(false)); } - for value in obj.values_mut() { - enforce_strict_subset(value); + // Recurse into sub-schemas, but never into the map-shaped values + // that hold named schemas or literal values. Walking those as if + // they were schemas applied the keyword strips and the object rules + // to property/definition NAMES: a tool with a property named + // `const`, `properties`, or `patternProperties` came out corrupted + // (renamed, erased, or dangling in `required`) while marked strict. + // Property and definition schemas are visited through their name + // maps, and the literal keywords are left alone — their members are + // values, not schemas. + for (key, value) in obj.iter_mut() { + match key.as_str() { + // `$def` is the singular spelling the DeepSeek documents + // themselves use for the definitions map; walk it like the + // standard `$defs`/`definitions` so a definition named after + // a stripped keyword is not deleted or renamed when its map + // is visited as if it were a schema. + "properties" | "$defs" | "definitions" | "$def" => { + if let Some(named_schemas) = value.as_object_mut() { + for named_schema in named_schemas.values_mut() { + enforce_strict_subset(named_schema, removed_optionals); + } + } + } + "enum" | "const" | "default" | "examples" => {} + _ => enforce_strict_subset(value, removed_optionals), + } } } else if let Some(arr) = schema.as_array_mut() { for value in arr { - enforce_strict_subset(value); + enforce_strict_subset(value, removed_optionals); } } } fn strip_unsupported_strict_keywords(obj: &mut Map) { obj.remove("patternProperties"); + // Conditional dependency tables are likewise outside the documented + // strict subset — draft-7 `dependencies` covers both forms (a + // name table like `dependentRequired`, a schema map like + // `dependentSchemas`); dropping one only widens what the endpoint + // accepts. + obj.remove("dependentRequired"); + obj.remove("dependencies"); + // Uniqueness and the property/containment count bounds are not in the + // documented subset either. With every property required and + // `additionalProperties: false` the property-count bounds are redundant + // or contradictory anyway, `minContains`/`maxContains` are meaningless + // without `contains` (which the compatibility gate rejects outright), + // and dropping a bound only widens what the endpoint accepts. + obj.remove("uniqueItems"); + obj.remove("minProperties"); + obj.remove("maxProperties"); + obj.remove("minContains"); + obj.remove("maxContains"); + // The strict documents support `const` for number/integer schemas only. + // A non-numeric const moves onto the documented `enum` keyword with its + // value as the single member; a numeric const stays put, and a non-numeric + // const beside an existing enum yields to it. A `const: null` is dropped + // outright — null is not a supported type, and landing it on `enum` would + // ship a null member (the compatibility gate already fails a tool closed + // for a null const; this is the defense for direct `sanitize_for_strict` + // calls). `default` stays verbatim (the documents show it, and list no + // ban). + if let Some(const_value) = obj.remove("const") { + if !const_value.is_null() { + if const_value.is_number() { + obj.insert("const".into(), const_value); + } else if !obj.contains_key("enum") { + obj.insert("enum".into(), Value::Array(vec![const_value])); + } + } + } + // `format` is documented with an enumerated set (email, hostname, + // ipv4, ipv6, uuid); any other value ships a constraint outside that + // list, so it is dropped — dropping a format only widens what the + // endpoint accepts. A draft-4 boolean `exclusiveMinimum`/`Maximum` + // is likewise outside the documented numeric form. + if obj + .get("format") + .and_then(Value::as_str) + .is_some_and(|format| !matches!(format, "email" | "hostname" | "ipv4" | "ipv6" | "uuid")) + { + obj.remove("format"); + } + for key in ["exclusiveMinimum", "exclusiveMaximum"] { + if obj.get(key).is_some_and(Value::is_boolean) { + obj.remove(key); + } + } match obj.get("type").and_then(Value::as_str) { Some("string") => { obj.remove("minLength"); @@ -336,12 +748,6 @@ fn required_names(obj: &Map) -> Vec { .unwrap_or_default() } -fn mark_nullable(schema: &mut Value) { - if let Some(obj) = schema.as_object_mut() { - obj.insert("nullable".into(), Value::Bool(true)); - } -} - fn merge_root_composition_properties(obj: &mut Map) { let mut merged = Map::new(); for key in ["oneOf", "anyOf", "allOf"] { @@ -670,16 +1076,26 @@ mod tests { "additionalProperties": {"type": "string"} }); - sanitize_for_strict(&mut schema); + let removed = sanitize_for_strict(&mut schema); assert_eq!(schema["additionalProperties"], false); - assert_eq!(schema["required"], json!(["count", "name"])); - assert_eq!(schema["properties"]["count"]["nullable"], true); + assert_eq!(schema["required"], json!(["name"])); + assert!( + schema["properties"].get("count").is_none(), + "an optional property is removed, not nullable-marked: {schema}" + ); + assert_eq!(removed, vec!["count".to_string()]); assert!(schema["properties"]["name"].get("nullable").is_none()); } #[test] - fn strict_sanitize_preserves_optional_properties_as_nullable() { + fn strict_sanitize_removes_optional_properties_and_reports_them() { + // The strict contract is "every property is required". The strict + // documents' supported keyword set has no `nullable`, so keeping an + // optional property would need an undocumented keyword to keep + // accepting omission; the property is removed instead and its name + // reported so the caller can tell the model. Nested objects are + // treated the same way, recursively. let mut schema = json!({ "type": "object", "properties": { @@ -698,29 +1114,28 @@ mod tests { "required": ["path", "options"] }); - sanitize_for_strict(&mut schema); + let removed = sanitize_for_strict(&mut schema); - assert_eq!( - schema["required"], - json!(["max_lines", "options", "path", "start_line"]) - ); + assert_eq!(removed, vec!["max_lines", "start_line", "trim"]); + assert_eq!(schema["required"], json!(["options", "path"])); + assert!(schema["properties"].get("start_line").is_none()); + assert!(schema["properties"].get("max_lines").is_none()); assert!(schema["properties"]["path"].get("nullable").is_none()); assert!(schema["properties"]["options"].get("nullable").is_none()); - assert_eq!(schema["properties"]["start_line"]["nullable"], true); - assert_eq!(schema["properties"]["max_lines"]["nullable"], true); assert_eq!( schema["properties"]["options"]["required"], - json!(["encoding", "trim"]) + json!(["encoding"]) + ); + assert!( + schema["properties"]["options"]["properties"] + .get("trim") + .is_none() ); assert!( schema["properties"]["options"]["properties"]["encoding"] .get("nullable") .is_none() ); - assert_eq!( - schema["properties"]["options"]["properties"]["trim"]["nullable"], - true - ); } #[test] @@ -739,16 +1154,73 @@ mod tests { "required": [] }); - sanitize_for_strict(&mut schema); + let removed = sanitize_for_strict(&mut schema); - assert_eq!(schema["required"], json!(["outer"])); + // Nothing was required at the root, so `outer` is removed with its + // whole subtree — a removed property's internals go with it, so only + // the name at the level where removal happened is reported. + assert_eq!(removed, vec!["outer"]); + assert_eq!(schema["required"], json!([])); + assert!(schema["properties"].get("outer").is_none()); assert_eq!(schema["additionalProperties"], false); - assert_eq!(schema["properties"]["outer"]["required"], json!(["inner"])); - assert_eq!(schema["properties"]["outer"]["additionalProperties"], false); + } + + #[test] + fn strict_sanitize_lands_non_numeric_const_on_the_documented_enum() { + // The strict documents support `const` for number/integer schemas + // only. A string/bool const moves onto the documented `enum` keyword + // with its value as the single member; a numeric const stays `const`. + let mut schema = json!({ + "type": "object", + "properties": { + "kind": {"type": "string", "const": "fast"}, + "level": {"type": "integer", "const": 3}, + "flag": {"type": "boolean", "const": true} + }, + "required": ["kind", "level", "flag"] + }); + + sanitize_for_strict(&mut schema); + + assert!(schema["properties"]["kind"].get("const").is_none()); + assert_eq!(schema["properties"]["kind"]["enum"], json!(["fast"])); + assert_eq!(schema["properties"]["level"]["const"], 3); + assert!(schema["properties"]["flag"].get("const").is_none()); + assert_eq!(schema["properties"]["flag"]["enum"], json!([true])); + } + + #[test] + fn strict_sanitize_non_numeric_const_yields_to_an_existing_enum() { + // A non-numeric const beside an existing enum yields to the enum: the + // const is dropped and the enum passes through verbatim, whether the + // const value agrees with the enum or contradicts it. + let mut schema = json!({ + "type": "object", + "properties": { + "matching": {"type": "string", "const": "fast", "enum": ["fast", "slow"]}, + "contradicting": {"type": "string", "const": "turbo", "enum": ["fast", "slow"]} + }, + "required": ["matching", "contradicting"] + }); + + sanitize_for_strict(&mut schema); + + assert!(schema["properties"]["matching"].get("const").is_none()); + assert_eq!( + schema["properties"]["matching"]["enum"], + json!(["fast", "slow"]) + ); + assert!(schema["properties"]["contradicting"].get("const").is_none()); + assert_eq!( + schema["properties"]["contradicting"]["enum"], + json!(["fast", "slow"]) + ); } #[test] fn strict_sanitize_removes_unsupported_string_and_array_bounds() { + // Every property is required so the nodes survive the optional-removal + // pass; this test pins the bound keyword strip specifically. let mut schema = json!({ "type": "object", "properties": { @@ -769,7 +1241,8 @@ mod tests { "minimum": 1, "maximum": 5 } - } + }, + "required": ["items", "name", "score"] }); sanitize_for_strict(&mut schema); @@ -796,9 +1269,10 @@ mod tests { json!({ "type": "object", "properties": { - "query": {"type": "string"} + "query": {"type": "string"}, + "index": {"type": "string", "enum": ["global", "workspace"]} }, - "required": [] + "required": ["index"] }), ), test_tool( @@ -833,8 +1307,19 @@ mod tests { assert!(!prepare_tools_for_strict_mode(&mut tools)); assert_eq!(tools[0].strict, Some(true)); - assert_eq!(tools[0].input_schema["required"], json!(["query"])); - assert_eq!(tools[0].input_schema["additionalProperties"], false); + assert_eq!( + tools[0].input_schema["required"], + json!(["index"]), + "the optional query is removed, not nullable-marked" + ); + assert!(tools[0].input_schema["properties"].get("query").is_none()); + assert!( + tools[0] + .description + .contains("optional parameters were removed"), + "the removal must be surfaced in the tool description: {}", + tools[0].description + ); assert_eq!(tools[1].strict, None); assert!(tools[1].input_schema.get("anyOf").is_some()); assert_eq!(tools[2].strict, None); @@ -897,6 +1382,10 @@ mod tests { #[test] fn strict_mode_marks_compatible_tools_strict() { + // A required property stays so the removal does not empty the tool + // (an all-optional root fails closed instead — see + // `strict_mode_fails_closed_when_removal_empties_the_tool`); the + // optional one is removed with a note. let mut tools = vec![Tool { tool_type: None, name: "lookup".to_string(), @@ -904,9 +1393,10 @@ mod tests { input_schema: json!({ "type": "object", "properties": { - "query": {"type": "string"} + "query": {"type": "string"}, + "index": {"type": "string", "enum": ["global", "workspace"]} }, - "required": [] + "required": ["index"] }), allowed_callers: None, defer_loading: None, @@ -917,8 +1407,1007 @@ mod tests { assert!(prepare_tools_for_strict_mode(&mut tools)); assert_eq!(tools[0].strict, Some(true)); - assert_eq!(tools[0].input_schema["required"], json!(["query"])); + assert_eq!(tools[0].input_schema["required"], json!(["index"])); + assert!(tools[0].input_schema["properties"].get("query").is_none()); assert_eq!(tools[0].input_schema["additionalProperties"], false); + assert!( + tools[0].description.contains("must not be sent: query"), + "the note names the removed optional: {}", + tools[0].description + ); + } + + #[test] + fn strict_mode_note_replaces_an_empty_tool_description() { + // A tool with no description gets the removal note as its whole + // description instead of an empty prefix. + let mut tools = vec![Tool { + tool_type: None, + name: "lookup".to_string(), + description: String::new(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "index": {"type": "string", "enum": ["global", "workspace"]} + }, + "required": ["index"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!(prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, Some(true)); + assert!( + tools[0].description.starts_with("Strict mode:"), + "the note must replace an empty description: {:?}", + tools[0].description + ); + assert!( + tools[0].description.contains("query"), + "the note names the removed optional: {}", + tools[0].description + ); + } + + #[test] + fn strict_mode_fails_closed_when_removal_empties_the_tool() { + // A tool whose whole root property set is optional loses every + // parameter to the strict contract while its runtime still demands + // them (action-discriminated tools mark everything optional and + // dispatch on the values `execute` re-reads). That shape cannot + // serve the tool under a strict flag, so it ships unchanged and + // non-strict — with no removal note, because nothing was removed + // from the schema that ships. + let mut tools = vec![Tool { + tool_type: None, + name: "tasks".to_string(), + description: "Durable tasks".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["create", "read", "cancel"]}, + "prompt": {"type": "string"} + }, + "required": [] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["action"]["enum"], + json!(["create", "read", "cancel"]) + ); + assert_eq!(tools[0].input_schema["required"], json!([])); + assert_eq!( + tools[0].input_schema["properties"].get("prompt"), + Some(&json!({"type": "string"})) + ); + assert!(tools[0].input_schema.get("additionalProperties").is_none()); + assert_eq!(tools[0].description, "Durable tasks"); + } + + #[test] + fn strict_mode_fails_closed_on_type_array_unions() { + // The documented subset's union mechanism is `anyOf`; a `type` + // array is a draft-4-style union the documents never describe, and + // `null` is not a supported type anywhere — neither bare nor as an + // array member. Either shape ships unchanged and non-strict instead + // of carrying an undocumented construct under a strict flag. + let mut tools = vec![Tool { + tool_type: None, + name: "search".to_string(), + description: "Search".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "mode": {"type": ["string", "null"]}, + "level": {"type": ["integer", "string"]} + }, + "required": ["mode", "level"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["mode"], + json!({"type": ["string", "null"]}) + ); + assert_eq!( + tools[0].input_schema["properties"]["level"], + json!({"type": ["integer", "string"]}) + ); + assert_eq!(tools[0].description, "Search"); + } + + #[test] + fn strict_mode_fails_closed_on_a_bare_null_type() { + // A bare `{"type":"null"}` is a required nullable wearing another + // keyword: the property must be sent and can be null, and null is + // not a supported type anywhere in the documented subset. The tool + // stays non-strict with its original schema. (Pinned per arm: a + // shared fixture let a single-arm regression pass.) + let mut tools = vec![test_tool( + "probe", + json!({ + "type": "object", + "properties": { + "empty": {"type": "null"} + }, + "required": ["empty"] + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["empty"], + json!({"type": "null"}) + ); + assert_eq!(tools[0].description, "probe"); + } + + #[test] + fn strict_mode_fails_closed_on_a_null_const() { + // A `const: null` is the same required nullable in another keyword: + // the strip drops it outright for direct callers, so the gate must + // catch it before the strip or the tool would ship strict without + // the constraint it advertises. + let mut tools = vec![test_tool( + "probe", + json!({ + "type": "object", + "properties": { + "fixed": {"const": null} + }, + "required": ["fixed"] + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["fixed"], + json!({"const": null}) + ); + assert_eq!(tools[0].description, "probe"); + } + + #[test] + fn strict_mode_fails_closed_on_a_null_enum_member() { + // An enum with a null member is a required nullable wearing the + // documented `enum` keyword: landing on it would ship a null member + // for a type the documents never support. + let mut tools = vec![test_tool( + "probe", + json!({ + "type": "object", + "properties": { + "choice": {"enum": [null, "a"]} + }, + "required": ["choice"] + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["choice"], + json!({"enum": [null, "a"]}) + ); + assert_eq!(tools[0].description, "probe"); + } + + #[test] + fn strict_mode_keeps_strict_when_null_shapes_sit_under_optional_properties() { + // The gate judges the enforced shape: a null-carrying shape under + // an OPTIONAL property is faithfully represented by that removal, + // so the tool stays strict and the property is removed with the + // standard note instead of failing the whole tool closed. The same + // shapes under a required property keep failing the tool closed + // (see the per-arm tests above). + let mut tools = vec![test_tool( + "probe", + json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "opt_union": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "opt_nullable": {"type": "string", "nullable": true}, + "opt_null_type": {"type": "null"}, + "opt_null_const": {"const": null}, + "opt_null_enum": {"enum": [null, "a"]}, + "opt_type_array": {"type": ["string", "null"]} + }, + "required": ["name"] + }), + )]; + + assert!(prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!(tools[0].input_schema["required"], json!(["name"])); + let properties = tools[0].input_schema["properties"].as_object().unwrap(); + assert_eq!( + properties.len(), + 1, + "only the required property ships: {properties:?}" + ); + assert!(properties.contains_key("name")); + assert!( + tools[0].description.contains("opt_null_const"), + "the note names the removed optional: {}", + tools[0].description + ); + } + + #[test] + fn strict_mode_fails_closed_on_structural_and_conditional_keywords() { + // Structural and conditional keywords outside the documented subset + // carry constraints the reshaping cannot express — the same family + // the Moonshot MFJS path fails closed on. Tuple-form `items` is + // likewise a draft construct the documents never describe. + let mut tools = vec![Tool { + tool_type: None, + name: "check".to_string(), + description: "Check".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "tags": { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "string"}] + } + }, + "required": ["pattern", "tags"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert!( + tools[0].input_schema["properties"]["tags"] + .get("prefixItems") + .is_some(), + "the schema ships unchanged: {}", + tools[0].input_schema + ); + } + + #[test] + fn strict_mode_fails_closed_on_each_structural_keyword() { + // Each member of `STRICT_UNREPRESENTABLE_KEYWORDS` is pinned + // individually — a shared fixture let a single-arm regression (a + // typo in one keyword arm) pass silently. Iterating the same + // constant the gate checks keeps the two lists from drifting. + for &keyword in STRICT_UNREPRESENTABLE_KEYWORDS { + let mut target = serde_json::Map::new(); + target.insert("type".into(), json!("string")); + target.insert(keyword.into(), json!(true)); + let mut tools = vec![test_tool( + "check", + json!({ + "type": "object", + "properties": {"target": Value::Object(target)}, + "required": ["target"] + }), + )]; + + assert!( + !prepare_tools_for_strict_mode(&mut tools), + "the {keyword} keyword must fail the tool closed" + ); + assert_eq!(tools[0].strict, None, "keyword: {keyword}"); + assert!( + tools[0].input_schema["properties"]["target"] + .get(keyword) + .is_some(), + "the {keyword} keyword ships unchanged" + ); + } + } + + #[test] + fn strict_mode_keeps_strict_when_a_required_property_is_named_after_a_keyword() { + // The gate walks name maps as name maps: a required property named + // after a structural keyword (or `nullable`) is a name, not a + // keyword occurrence, and must not fail the tool closed on it. + let mut tools = vec![test_tool( + "search", + json!({ + "type": "object", + "properties": { + "needle": {"type": "string"}, + "contains": { + "type": "string", + "description": "substring the result must contain" + } + }, + "required": ["needle", "contains"] + }), + )]; + + assert!(prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!( + tools[0].input_schema["required"], + json!(["contains", "needle"]) + ); + } + + #[test] + fn strict_mode_keeps_strict_when_a_definition_is_named_after_a_keyword() { + // `$defs` is a name map too: a definition named `not` ships when its + // `$ref` resolves, and its name is not a keyword occurrence. A + // definition whose SCHEMA relies on a structural keyword still fails + // the tool closed. + let mut named = vec![test_tool( + "cite", + json!({ + "type": "object", + "properties": {"span": {"$ref": "#/$defs/not"}}, + "required": ["span"], + "$defs": {"not": {"type": "string"}} + }), + )]; + assert!(prepare_tools_for_strict_mode(&mut named)); + assert_eq!(named[0].strict, Some(true)); + assert_eq!( + named[0].input_schema["$defs"]["not"], + json!({"type": "string"}) + ); + + let mut structural = vec![test_tool( + "cite", + json!({ + "type": "object", + "properties": {"span": {"$ref": "#/$defs/span"}}, + "required": ["span"], + "$defs": {"span": {"type": "string", "not": {"type": "string"}}} + }), + )]; + assert!(!prepare_tools_for_strict_mode(&mut structural)); + assert_eq!(structural[0].strict, None); + } + + #[test] + fn strict_mode_keeps_strict_when_literals_hold_schema_shaped_values() { + // `enum` members and `default` values are values, not schemas: an + // object member that happens to carry a keyword-shaped key, or a + // `default` shaped like `{"nullable": true}`, is not a keyword + // occurrence. + let mut tools = vec![test_tool( + "filter", + json!({ + "type": "object", + "properties": { + "mode": {"enum": [{"contains": "abc"}, {"exact": "abc"}]}, + "fallback": { + "type": "object", + "properties": {"limit": {"type": "integer"}}, + "required": ["limit"], + "default": {"nullable": true} + } + }, + "required": ["mode", "fallback"] + }), + )]; + + assert!(prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!( + tools[0].input_schema["properties"]["mode"]["enum"], + json!([{"contains": "abc"}, {"exact": "abc"}]) + ); + } + + #[test] + fn strict_mode_fails_closed_on_a_root_map_shape() { + // A root that declares no properties but takes arbitrary keys + // (`additionalProperties` in schema form or `true`) collapses under + // the enforce pass into `properties: {}` + + // `additionalProperties: false` — `strict: true` callable only with + // `{}` while the runtime reads the keys. It ships unchanged and + // non-strict instead. The authored no-argument roots that stay + // strict are the ones whose `additionalProperties` is absent or + // already `false`. + let mut map_schema = vec![test_tool( + "annotate", + json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }), + )]; + assert!(!prepare_tools_for_strict_mode(&mut map_schema)); + assert_eq!(map_schema[0].strict, None); + assert_eq!( + map_schema[0].input_schema, + json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }), + "the schema ships unchanged" + ); + + let mut open_map = vec![test_tool( + "annotate", + json!({ + "type": "object", + "additionalProperties": true + }), + )]; + assert!(!prepare_tools_for_strict_mode(&mut open_map)); + assert_eq!(open_map[0].strict, None); + + let mut no_arg = vec![test_tool( + "ping", + json!({ + "type": "object", + "additionalProperties": false + }), + )]; + assert!(prepare_tools_for_strict_mode(&mut no_arg)); + assert_eq!(no_arg[0].strict, Some(true)); + } + + #[test] + fn strict_mode_fails_closed_when_a_typeless_map_keyword_would_ship_verbatim() { + // The enforce pass normalizes `additionalProperties` only on object + // schemas; a node without `type`/`properties` keeps the authored + // keyword, and a non-`false` value is map semantics the documented + // subset does not describe — it fails closed instead of shipping the + // keyword under a strict flag. + let mut tools = vec![test_tool( + "tag", + json!({ + "type": "object", + "properties": { + "labels": {"additionalProperties": {"type": "string"}} + }, + "required": ["labels"] + }), + )]; + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + } + + #[test] + fn strict_mode_fails_closed_on_a_required_nested_map() { + // One level down the same shape already fails closed through the + // gate's nested rule once the enforce pass injects the empty + // `properties` map — pinned here beside the root variants. + let mut tools = vec![test_tool( + "annotate", + json!({ + "type": "object", + "properties": { + "meta": { + "type": "object", + "additionalProperties": {"type": "string"} + } + }, + "required": ["meta"] + }), + )]; + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + } + + #[test] + fn strict_mode_fails_closed_when_removal_empties_a_nested_object() { + // The root-emptied rule has a nested analog: a REQUIRED object whose + // every property is optional loses them all and ships callable only + // with `{}` while the runtime still reads the removed members. The + // tool ships unchanged and non-strict, like the root case. + let mut tools = vec![test_tool( + "deploy", + json!({ + "type": "object", + "properties": { + "id": {"type": "string"}, + "opts": { + "type": "object", + "properties": { + "region": {"type": "string"}, + "replicas": {"type": "integer"} + }, + "required": [] + } + }, + "required": ["id", "opts"] + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["opts"]["required"], + json!([]) + ); + assert!( + tools[0].input_schema["properties"]["opts"]["properties"] + .get("region") + .is_some(), + "the nested schema ships unchanged: {}", + tools[0].input_schema + ); + assert_eq!(tools[0].description, "deploy"); + } + + #[test] + fn strict_mode_fails_closed_on_a_required_bare_object_parameter() { + // `sanitize` injects an empty `properties` on a bare + // `{"type":"object"}` node; under `additionalProperties: false` that + // shape cannot carry a value, so a required bare-object parameter + // fails the tool closed instead of shipping strict accepting only + // `{}`. + let mut tools = vec![test_tool( + "configure", + json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "config": {"type": "object"} + }, + "required": ["name", "config"] + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["properties"]["config"], + json!({"type": "object"}), + "the schema ships unchanged" + ); + assert_eq!(tools[0].description, "configure"); + } + + #[test] + fn strict_mode_fails_closed_on_a_schema_form_dependencies_table() { + // A draft-7 `dependencies` entry whose value is a schema is + // semantically `dependentSchemas`, which the gate fails closed on; + // the strip drops both spellings, so the schema form must be + // detected before the strip or the tool would ship strict with the + // cross-schema requirement silently gone. The name-table form + // (`dependentRequired`) keeps its widening-only strip. + let mut tools = vec![test_tool( + "agent", + json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "required": ["a"], + "dependencies": {"a": {"required": ["b"]}} + }), + )]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!( + tools[0].input_schema["dependencies"]["a"], + json!({"required": ["b"]}), + "the table ships unchanged" + ); + assert_eq!(tools[0].description, "agent"); + } + + #[test] + fn strict_mode_keeps_strict_when_a_schema_form_dependencies_table_is_optional() { + // The same table under an OPTIONAL property is removed wholesale — + // no fail-closed, no widening concern: the constraint ships nowhere. + let mut tools = vec![test_tool( + "agent", + json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "opts": { + "type": "object", + "properties": { + "a": {"type": "string"} + }, + "required": ["a"], + "dependencies": {"a": {"required": ["b"]}, "b": {"type": "string"}} + } + }, + "required": ["name"] + }), + )]; + + assert!(prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!(tools[0].input_schema["required"], json!(["name"])); + assert!(tools[0].input_schema["properties"].get("opts").is_none()); + } + + #[test] + fn strict_mode_fails_closed_on_required_nullable_unions() { + // `nullable` is outside the documented strict subset (the supported + // type list has no null and the documents never mention the keyword), + // and `sanitize` collapses the common `X | null` union into + // `nullable: true`. A schema that still carries the keyword after + // that collapse — i.e. a required nullable — cannot be represented + // faithfully under a strict flag, so the tool ships unchanged and + // non-strict instead of inviting the server 400 the pipeline exists + // to prevent. + let mut tools = vec![Tool { + tool_type: None, + name: "search".to_string(), + description: "Search".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "mode": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "limit": { + "anyOf": [ + {"type": "integer"}, + {"type": "string"}, + {"type": "null"} + ] + } + }, + "required": ["query", "mode", "limit"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!(!prepare_tools_for_strict_mode(&mut tools)); + assert_eq!(tools[0].strict, None); + assert_eq!(tools[0].description, "Search"); + assert_eq!( + tools[0].input_schema["properties"]["mode"]["anyOf"], + json!([{"type": "string"}, {"type": "null"}]) + ); + assert_eq!( + tools[0].input_schema["properties"]["limit"]["anyOf"], + json!([ + {"type": "integer"}, + {"type": "string"}, + {"type": "null"} + ]) + ); + assert_eq!( + tools[0].input_schema["required"], + json!(["query", "mode", "limit"]) + ); + } + + #[test] + fn strict_sanitize_keeps_properties_named_after_schema_keywords_intact() { + // The recursion must treat the `properties` value as a name map, not + // as a schema: walking it as a schema applied the keyword strips and + // the object rules to property NAMES, renaming a required `const` + // property to `enum`, erasing a `properties` property's schema, and + // dangling `patternProperties` in `required` — all while marking the + // tool strict. Required keyword-named properties survive intact; the + // strict object rules land on the tool schema and on each property's + // own sub-schema, never on the name map. + let mut schema = json!({ + "type": "object", + "properties": { + "const": {"type": "string"}, + "properties": {"type": "object"}, + "patternProperties": {"type": "object"}, + "type": {"type": "string"} + }, + "required": ["const", "patternProperties", "properties", "type"] + }); + + let removed = sanitize_for_strict(&mut schema); + + assert!(removed.is_empty()); + assert_eq!(schema["properties"]["const"], json!({"type": "string"})); + assert_eq!(schema["properties"]["type"], json!({"type": "string"})); + assert_eq!( + schema["properties"]["properties"]["type"], + json!("object"), + "the property's own schema keeps its type keyword" + ); + assert_eq!( + schema["properties"]["properties"]["additionalProperties"], false, + "the strict object rules apply to the property schema itself" + ); + assert_eq!( + schema["properties"]["patternProperties"]["type"], + json!("object") + ); + assert_eq!( + schema["required"], + json!(["const", "patternProperties", "properties", "type"]) + ); + assert_eq!(schema["additionalProperties"], false); + assert!( + schema["properties"].get("required").is_none() + && schema["properties"].get("additionalProperties").is_none(), + "the name map gains no spurious schema keys: {schema}" + ); + } + + #[test] + fn strict_sanitize_reports_an_optional_property_named_properties() { + // An optional keyword-named property is removed and reported like any + // other optional, and the name map stays clean. + let mut schema = json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "properties": {"type": "object"} + }, + "required": ["query"] + }); + + let removed = sanitize_for_strict(&mut schema); + + assert_eq!(removed, vec!["properties".to_string()]); + assert!(schema["properties"].get("properties").is_none()); + assert_eq!(schema["properties"]["query"], json!({"type": "string"})); + assert_eq!(schema["required"], json!(["query"])); + assert!(schema["properties"].get("required").is_none()); + } + + #[test] + fn strict_sanitize_walks_schemas_but_never_name_maps_or_literals() { + // `$defs` entries are schemas and keep getting the strict rules, but + // a definition named after a keyword must not corrupt the map, and + // `enum`/`default` members are values, not schemas — they ship + // verbatim. + let mut schema = json!({ + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + {"type": "object", "properties": {"a": {"type": "string"}}} + ], + "default": {"const": 1} + } + }, + "required": ["mode"], + "$defs": { + "properties": {"type": "string"}, + "span": { + "type": "object", + "properties": {"b": {"type": "integer"}}, + "required": ["b"] + } + } + }); + + let removed = sanitize_for_strict(&mut schema); + + assert!(removed.is_empty()); + assert_eq!( + schema["properties"]["mode"]["enum"], + json!([{"type": "object", "properties": {"a": {"type": "string"}}}]) + ); + assert_eq!(schema["properties"]["mode"]["default"], json!({"const": 1})); + assert_eq!(schema["$defs"]["properties"], json!({"type": "string"})); + assert_eq!(schema["$defs"]["span"]["additionalProperties"], false); + assert_eq!(schema["$defs"]["span"]["required"], json!(["b"])); + } + + #[test] + fn strict_sanitize_strips_dependent_required_tables() { + // Conditional dependency tables are outside the documented strict + // subset; stripping one only widens what the endpoint accepts, the + // same treatment as `patternProperties` and the length bounds. + let mut schema = json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "required": ["a"], + "dependentRequired": {"a": ["b"]} + }); + + let removed = sanitize_for_strict(&mut schema); + + assert_eq!(removed, vec!["b".to_string()]); + assert!(schema.get("dependentRequired").is_none()); + assert_eq!(schema["required"], json!(["a"])); + } + + #[test] + fn strict_sanitize_strips_undocumented_dependency_and_bound_keywords() { + // Draft-7 `dependencies` (both the name-table and the schema form), + // `uniqueItems`, and the property/containment count bounds are not + // in the documented subset; dropping one only widens what the + // endpoint accepts, the same treatment as `dependentRequired`. + let mut schema = json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "required": ["a"], + "dependencies": {"a": ["b"]}, + "uniqueItems": true, + "minProperties": 1, + "maxProperties": 5, + "minContains": 1, + "maxContains": 3 + }); + + let removed = sanitize_for_strict(&mut schema); + + assert!(schema.get("dependencies").is_none()); + assert!(schema.get("uniqueItems").is_none()); + assert!(schema.get("minProperties").is_none()); + assert!(schema.get("maxProperties").is_none()); + assert!(schema.get("minContains").is_none()); + assert!(schema.get("maxContains").is_none()); + assert_eq!(schema["required"], json!(["a"])); + assert_eq!(removed, vec!["b".to_string()]); + } + + #[test] + fn strict_sanitize_strips_undocumented_formats_and_draft4_exclusive_bounds() { + // `format` is documented with an enumerated set (email, hostname, + // ipv4, ipv6, uuid); any other value ships a constraint outside + // that list, so it is dropped — dropping a format only widens what + // the endpoint accepts. A draft-4 boolean + // `exclusiveMinimum`/`Maximum` is likewise outside the documented + // numeric form, while the numeric form stays. + let mut schema = json!({ + "type": "object", + "properties": { + "when": {"type": "string", "format": "date-time"}, + "mail": {"type": "string", "format": "email"}, + "host": {"type": "string", "format": "uuid"}, + "score": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 10 + }, + "weight": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + } + }, + "required": ["when", "mail", "host", "score", "weight"] + }); + + let removed = sanitize_for_strict(&mut schema); + + assert_eq!(removed, Vec::::new()); + assert!( + schema["properties"]["when"].get("format").is_none(), + "an undocumented format is dropped: {}", + schema["properties"]["when"] + ); + assert_eq!(schema["properties"]["mail"]["format"], "email"); + assert_eq!(schema["properties"]["host"]["format"], "uuid"); + assert!( + schema["properties"]["score"] + .get("exclusiveMinimum") + .is_none(), + "a draft-4 boolean exclusiveMinimum is dropped" + ); + assert_eq!(schema["properties"]["weight"]["exclusiveMinimum"], 0); + assert_eq!(schema["properties"]["weight"]["exclusiveMaximum"], 100); + assert_eq!(schema["properties"]["weight"]["type"], "number"); + } + + #[test] + fn strict_sanitize_drops_a_null_const_instead_of_landing_it_on_enum() { + // `const: null` cannot be represented (null is not a supported + // type), and landing it on `enum` would ship a null member. The + // compatibility gate fails such a tool closed; for direct + // `sanitize_for_strict` calls the strip drops the const outright + // instead of manufacturing `enum: [null]`. + let mut schema = json!({ + "type": "object", + "properties": { + "fixed": {"type": "string", "const": null}, + "kept": {"type": "integer", "const": 3} + }, + "required": ["fixed", "kept"] + }); + + let removed = sanitize_for_strict(&mut schema); + + assert_eq!( + schema["properties"]["fixed"], + json!({"type": "string"}), + "a null const is dropped, not turned into a null enum member" + ); + assert_eq!(schema["properties"]["kept"]["const"], 3); + assert_eq!(removed, Vec::::new()); + } + + #[test] + fn strict_sanitize_walks_the_documented_def_spelling_as_a_name_map() { + // The DeepSeek documents spell the definitions map `$def` + // (singular). It must be walked as a name map like + // `$defs`/`definitions`, so a definition named after a stripped + // keyword survives intact instead of being deleted or renamed when + // its map is visited as if it were a schema. + let mut schema = json!({ + "type": "object", + "properties": {"item": {"$ref": "#/$def/const"}}, + "required": ["item"], + "$def": { + "const": {"type": "string"}, + "patternProperties": {"type": "object"}, + "span": { + "type": "object", + "properties": {"b": {"type": "integer"}}, + "required": ["b"] + } + } + }); + + let removed = sanitize_for_strict(&mut schema); + + assert!(removed.is_empty()); + assert_eq!( + schema["$def"]["const"], + json!({"type": "string"}), + "a definition named `const` keeps its schema instead of becoming an `enum` key" + ); + assert_eq!( + schema["$def"]["patternProperties"]["type"], + json!("object"), + "a definition named `patternProperties` is not deleted by the keyword strip" + ); + assert_eq!(schema["$def"]["span"]["additionalProperties"], false); + assert_eq!(schema["$def"]["span"]["required"], json!(["b"])); + } + + #[test] + fn strict_sanitize_dedupes_optionals_shared_across_levels() { + // The same optional name at two levels is reported once. + let mut schema = json!({ + "type": "object", + "properties": { + "trim": {"type": "boolean"}, + "outer": { + "type": "object", + "properties": { + "trim": {"type": "boolean"}, + "alpha": {"type": "string"} + }, + "required": ["alpha"] + } + }, + "required": ["outer"] + }); + + let removed = sanitize_for_strict(&mut schema); + + assert_eq!(removed, vec!["trim".to_string()]); } #[test] diff --git a/crates/tui/src/tools/send_later.rs b/crates/tui/src/tools/send_later.rs index 8d97792636..91450cb310 100644 --- a/crates/tui/src/tools/send_later.rs +++ b/crates/tui/src/tools/send_later.rs @@ -989,4 +989,58 @@ mod tests { let due = manager.collect_due_triggers(chrono::Utc::now()).unwrap(); assert_eq!(due.len(), 1, "should fire a past-due trigger"); } + + #[test] + fn strict_mode_fails_the_send_later_tool_closed() { + // `send_later` keeps only `action` in strict mode while + // `schedule` runtime-requires `message` and `read`/`cancel` + // runtime-require `trigger_id` — only `list` would stay reachable. + // The strict pipeline fails the tool closed by name + // (`STRICT_UNUSABLE_TOOLS`) so it ships unchanged, non-strict, and + // usable. This census test feeds the real schema through the + // pipeline so the two cannot drift. + let tool = SendLaterTool::new("send_later"); + let schema = tool.input_schema(); + assert_eq!(schema["required"], json!(["action"])); + let mut tools = vec![crate::models::Tool { + tool_type: None, + name: tool.name().to_string(), + description: tool.description().to_string(), + input_schema: schema.clone(), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + + assert!( + !crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut tools), + "send_later must fail closed, not ship strict with only `action`" + ); + assert_eq!(tools[0].strict, None); + assert_eq!(tools[0].input_schema, schema); + assert_eq!(tools[0].description, tool.description()); + + // The read-only variant advertises the same discriminator-only + // shape, so it fails closed identically. + let read_only = SendLaterTool::read_only("send_later"); + let read_only_schema = read_only.input_schema(); + let mut read_only_tools = vec![crate::models::Tool { + tool_type: None, + name: read_only.name().to_string(), + description: read_only.description().to_string(), + input_schema: read_only_schema.clone(), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]; + assert!( + !crate::tools::schema_sanitize::prepare_tools_for_strict_mode(&mut read_only_tools) + ); + assert_eq!(read_only_tools[0].strict, None); + assert_eq!(read_only_tools[0].input_schema, read_only_schema); + } } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 45e96577cf..019da06626 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -12941,13 +12941,19 @@ async fn run_subagent( messages.push(child_completion_runtime_message(&child_completions)); } + // Same gate as the parent path: the strict pipeline encodes DeepSeek's + // strict-mode contract, so it acts only on official DeepSeek-family + // routes (crate::config::strict_tool_mode_for_route). + let strict_tool_mode = runtime.api_config.as_deref().is_some_and(|config| { + crate::config::strict_tool_mode_for_route( + config.strict_tool_mode.unwrap_or(false), + config.api_provider(), + &config.deepseek_base_url(), + ) + }); let tools = tool_surface.request_tools( tool_registry.deferred_catalog_for_model(&agent_type), - runtime - .api_config - .as_ref() - .and_then(|config| config.strict_tool_mode) - .unwrap_or(false), + strict_tool_mode, ); let request_active_tool_names = tool_surface.active_names.clone(); let has_tools = !tools.is_empty(); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 6c89e4085c..99e09b1834 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -6042,11 +6042,23 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { "chat completions sanitize must not erase or widen the role enum" ); - // Strict-mode structured outputs pass. + // Strict-mode structured outputs pass. The strict contract removes + // optional properties outright (the Fleet role is optional), so the + // property — and with it the enum — leaves the schema, and + // `prepare_tools_for_strict_mode` reports the removal in the tool + // description. When the role is required, the property survives and its + // enum must be neither erased nor widened. let mut strict = agent_schema.clone(); schema_sanitize::sanitize_for_strict(&mut strict); + assert!( + strict["properties"].get("type").is_none(), + "the strict contract removes the optional role property outright: {strict}" + ); + let mut strict_required = agent_schema.clone(); + strict_required["required"] = json!(["action", "type"]); + schema_sanitize::sanitize_for_strict(&mut strict_required); assert_eq!( - strict["properties"]["type"]["enum"], expected, + strict_required["properties"]["type"]["enum"], expected, "strict-mode sanitize must not erase or widen the role enum" );