From d1a3e1db32018ebf1c56e422ead5aace7cfd4db9 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey <8761706+killswitch-GUI@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:04:47 -0400 Subject: [PATCH 1/2] fix(provider): project Anthropic-compatible tool schemas --- crates/colossus-provider/src/anthropic.rs | 3 +- .../src/anthropic/gateway_tests.rs | 57 +++++++- .../colossus-provider/src/anthropic/tests.rs | 128 ++++++++++++++++++ crates/colossus-provider/src/normalization.rs | 17 ++- docs/use/providers/anthropic.md | 5 + 5 files changed, 204 insertions(+), 6 deletions(-) diff --git a/crates/colossus-provider/src/anthropic.rs b/crates/colossus-provider/src/anthropic.rs index 454fdd29..0829f19c 100644 --- a/crates/colossus-provider/src/anthropic.rs +++ b/crates/colossus-provider/src/anthropic.rs @@ -133,7 +133,8 @@ pub(super) fn payload( .map(|tool| { Ok(json!({ "name":projection.tool_names.provider_name(&tool.name)?, - "description":tool.description, "input_schema":tool.input_schema + "description":tool.description, + "input_schema":compatible_anthropic_tool_schema(&tool.input_schema)? })) }) .collect::, ProviderError>>()?, diff --git a/crates/colossus-provider/src/anthropic/gateway_tests.rs b/crates/colossus-provider/src/anthropic/gateway_tests.rs index 2c3349fb..66a00be4 100644 --- a/crates/colossus-provider/src/anthropic/gateway_tests.rs +++ b/crates/colossus-provider/src/anthropic/gateway_tests.rs @@ -16,6 +16,54 @@ fn gateway(profile: &ProviderProfile, post: bool) -> EffectGateway { ) } +fn builtin_tool_request(profile: &ProviderProfile) -> EffectRequest { + let mut model = model_request(); + model.tools = colossus_tools::builtin_specs() + .into_iter() + .map(|spec| ModelToolDefinition { + name: spec.name, + description: spec.description, + input_schema: spec.input_schema, + }) + .collect(); + for (name, keyword) in [ + ("shell.run", "oneOf"), + ("user.ask", "allOf"), + ("workflow.schedule.create", "allOf"), + ] { + let schema = &model + .tools + .iter() + .find(|tool| tool.name == name) + .unwrap() + .input_schema; + assert!( + schema.get(keyword).is_some(), + "{name} lost its canonical {keyword}" + ); + } + let mut request = provider_request(profile); + request.content["request"] = serde_json::to_value(model).unwrap(); + request +} + +fn assert_builtin_tool_wire(wire: &str) { + let (_, body) = wire.split_once("\r\n\r\n").unwrap(); + let payload: Value = serde_json::from_str(body).unwrap(); + let tools = payload["tools"].as_array().unwrap(); + assert_eq!(tools.len(), colossus_tools::builtin_specs().len()); + for tool in tools { + assert_eq!(tool["input_schema"]["type"], "object"); + for keyword in ["oneOf", "anyOf", "allOf"] { + assert!( + tool["input_schema"].get(keyword).is_none(), + "{} retained unsupported root {keyword}", + tool["name"] + ); + } + } +} + #[tokio::test] async fn anthropic_json_uses_native_headers_and_policy_release() { let (url, server) = one_response_server(json!({"id":"msg-test","type":"message","role":"assistant", @@ -33,13 +81,14 @@ async fn anthropic_json_uses_native_headers_and_policy_release() { let credentials = Arc::new(CountingCredentialResolver::new()); let executor = ProviderExecutor::with_credentials(profile.clone(), credentials.clone()); let released = gateway(&profile, true) - .execute(provider_request(&profile), &executor) + .execute(builtin_tool_request(&profile), &executor) .await .unwrap(); let turn: ProviderTurn = serde_json::from_slice(&released.bytes).unwrap(); assert_eq!(turn.provider, "anthropic_messages"); assert!(!String::from_utf8_lossy(&released.bytes).contains("PRIVATE_THOUGHT")); let wire = server.await.unwrap(); + assert_builtin_tool_wire(&wire); assert!(wire.contains("POST /v1/messages HTTP/1.1")); let headers = wire.split("\r\n\r\n").next().unwrap().to_ascii_lowercase(); assert!(headers.contains("x-api-key: unit-secret")); @@ -74,13 +123,15 @@ async fn anthropic_sse_crosses_gateway_with_exact_text_and_cumulative_usage() { let executor = ProviderExecutor::new(profile.clone()); let mut items = ReleasedItems::default(); gateway(&profile, true) - .execute_stream(provider_request(&profile), &executor, &mut items) + .execute_stream(builtin_tool_request(&profile), &executor, &mut items) .await .unwrap(); let encoded = serde_json::to_string(&items.0).unwrap(); assert!(encoded.contains("hello 🌍")); assert!(encoded.contains("\"total_tokens\":8")); - assert!(server.await.unwrap().contains("POST /v1/messages")); + let wire = server.await.unwrap(); + assert!(wire.contains("POST /v1/messages")); + assert_builtin_tool_wire(&wire); } #[test] diff --git a/crates/colossus-provider/src/anthropic/tests.rs b/crates/colossus-provider/src/anthropic/tests.rs index 000dfecc..fe571ed9 100644 --- a/crates/colossus-provider/src/anthropic/tests.rs +++ b/crates/colossus-provider/src/anthropic/tests.rs @@ -79,6 +79,134 @@ fn native_endpoint_projection_and_usage() { ); } +#[test] +fn native_tool_schema_projection_preserves_nested_constraints_and_canonical_schema() { + let mut request = request(); + let canonical_schema = json!({ + "type": "object", + "properties": { + "selector": {"$ref": "#/$defs/selector"}, + "fallback": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "labels": {"type": "array", "items": {"allOf": [ + {"type": "string"}, {"minLength": 1, "maxLength": 32} + ]}} + }, + "$defs": {"selector": {"oneOf": [ + {"type": "string", "minLength": 1, "maxLength": 64}, + {"type": "integer", "minimum": 1} + ]}}, + "required": ["selector"], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 3, + "oneOf": [{"required": ["selector"]}, {"required": ["missing"]}], + "anyOf": [{"required": ["selector"]}, {"required": ["fallback"]}], + "allOf": [{"minProperties": 1}], + "enum": [{"selector": "a"}], + "const": {"selector": "a"} + }); + request.tools[0].input_schema = canonical_schema.clone(); + let canonical_request = serde_json::to_value(&request).unwrap(); + let names = ProviderToolNames::from_request(&request).unwrap(); + let images = ProviderResolvedImages::default(); + let mut expected = canonical_schema.clone(); + for keyword in ["oneOf", "allOf", "anyOf"] { + expected.as_object_mut().unwrap().remove(keyword); + } + + for streaming in [false, true] { + let wire = payload( + &request, + "test-model", + 1024, + None, + streaming, + ProviderProjection::new(&names, &images), + ) + .unwrap(); + assert_eq!(wire["stream"], streaming); + assert_eq!(wire["tools"][0]["input_schema"], expected); + assert_eq!(serde_json::to_value(&request).unwrap(), canonical_request); + assert_eq!(request.tools[0].input_schema, canonical_schema); + } +} + +#[test] +fn native_tool_schema_projection_keeps_canonical_builtin_validation() { + use colossus_ports::ToolRegistry as _; + + let registry = colossus_tools::StaticToolRegistry::builtins(&[ + "shell.run".into(), + "user.ask".into(), + "workflow.schedule.create".into(), + ]) + .unwrap(); + let mut request = request(); + request.tools = colossus_tools::model_definitions(®istry); + for (name, keyword) in [ + ("shell.run", "oneOf"), + ("user.ask", "allOf"), + ("workflow.schedule.create", "allOf"), + ] { + let tool = request.tools.iter().find(|tool| tool.name == name).unwrap(); + assert!( + tool.input_schema.get(keyword).is_some(), + "{name}: {keyword}" + ); + } + let canonical_request = serde_json::to_value(&request).unwrap(); + let names = ProviderToolNames::from_request(&request).unwrap(); + let images = ProviderResolvedImages::default(); + for streaming in [false, true] { + let wire = payload( + &request, + "test-model", + 1024, + None, + streaming, + ProviderProjection::new(&names, &images), + ) + .unwrap(); + for projected in wire["tools"].as_array().unwrap() { + for keyword in ["oneOf", "allOf", "anyOf"] { + assert!(projected["input_schema"].get(keyword).is_none()); + } + } + assert_eq!(serde_json::to_value(&request).unwrap(), canonical_request); + } + + for (name, arguments, valid_arguments) in [ + ( + "shell.run", + json!({"justification": "Read a fixture", "command": "echo fixture", "argv": ["echo", "fixture"]}), + json!({"justification": "Read a fixture", "command": "echo fixture"}), + ), + ( + "user.ask", + json!({"question": "Continue?", "allow_free_form": false}), + json!({"question": "Continue?", "allow_free_form": true}), + ), + ] { + registry + .validate(&colossus_contracts::ToolCall { + call_id: "valid-call".into(), + name: name.into(), + arguments: valid_arguments, + }) + .unwrap(); + assert!( + registry + .validate(&colossus_contracts::ToolCall { + call_id: "invalid-call".into(), + name: name.into(), + arguments, + }) + .is_err(), + "{name} must retain canonical validation" + ); + } +} + #[test] fn nullable_cache_usage_preserves_absence_and_rejects_invalid_counters() { for value in [ diff --git a/crates/colossus-provider/src/normalization.rs b/crates/colossus-provider/src/normalization.rs index 3bb8095e..f924360e 100644 --- a/crates/colossus-provider/src/normalization.rs +++ b/crates/colossus-provider/src/normalization.rs @@ -226,6 +226,17 @@ fn object_tool_schema(value: &Value) -> Option<&Map> { } fn compatible_openai_tool_schema(value: &Value) -> Result { + compatible_tool_schema(value, &["oneOf", "anyOf", "allOf", "enum", "const"]) +} + +pub(super) fn compatible_anthropic_tool_schema(value: &Value) -> Result { + compatible_tool_schema(value, &["oneOf", "anyOf", "allOf"]) +} + +fn compatible_tool_schema( + value: &Value, + unsupported_root_keywords: &[&str], +) -> Result { let mut projected = object_tool_schema(value) .ok_or_else(|| { ProviderError::Configuration( @@ -233,8 +244,10 @@ fn compatible_openai_tool_schema(value: &Value) -> Result ) })? .clone(); - for keyword in ["oneOf", "anyOf", "allOf", "enum", "const"] { - projected.remove(keyword); + // Only the model-facing copy is relaxed. The registry and continuation bindings + // retain the complete canonical schema for validation and authorization. + for keyword in unsupported_root_keywords { + projected.remove(*keyword); } Ok(Value::Object(projected)) } diff --git a/docs/use/providers/anthropic.md b/docs/use/providers/anthropic.md index 470c0dd8..f5e2e60d 100644 --- a/docs/use/providers/anthropic.md +++ b/docs/use/providers/anthropic.md @@ -38,6 +38,11 @@ Model discovery follows bounded catalog pagination. Missing model metadata remai unknown. Native input limits are used as conservative context budgets with an additional output and safety reservation. +Tool schemas omit top-level `oneOf`, `anyOf`, and `allOf` from the definitions sent +to Anthropic, which rejects those keywords at the root. Nested schemas remain +intact. Colossus still validates every tool call against the full original schema +before execution. + Leave reasoning effort unset to use the model's default. Explicit `low`, `medium`, `high`, `xhigh`, or `max` requests use native adaptive thinking and effort controls; the selected model must support the combination. `none` requests disabled thinking From 653069c198076ff30cedccb786f8549918479424 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey <8761706+killswitch-GUI@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:43:51 -0400 Subject: [PATCH 2/2] test(desktop): allow bounded plugin inventory refresh --- apps/desktop/tests/browser/plugin-runtime.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/browser/plugin-runtime.spec.ts b/apps/desktop/tests/browser/plugin-runtime.spec.ts index b2ae0487..9bdb749e 100644 --- a/apps/desktop/tests/browser/plugin-runtime.spec.ts +++ b/apps/desktop/tests/browser/plugin-runtime.spec.ts @@ -298,9 +298,11 @@ test("browser → production native adapter → authenticated worker: offline co expect(listed).toContain("disabled"); await detail.getByRole("button", { name: "Activate this digest" }).click(); await continueOperation("enable"); + // Native activation completes before the renderer's separate inventory + // refresh. Allow that command's bounded budget for the enabled state. await expect( detail.getByRole("button", { name: "Disable", exact: true }), - ).toBeVisible(); + ).toBeVisible({ timeout: 45_000 }); await expect(detail.locator(".plugin-icon img")).toHaveJSProperty( "naturalWidth", 128,