From 0cc63fe3e607cf47ff32bc61e7eaca2f21e6cc23 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 23 Sep 2026 08:44:13 -0700 Subject: [PATCH] fix(agent): preserve Databricks Opus UC reasoning and tool continuation (#7840) ## Summary Fix `reasoning_effort: Extra inputs are not permitted` for Databricks v2 model `data_workflow_tools.goose.goose-claude-opus-5-5` without dropping explicit High. - Add one provider-qualified exact capability record: Anthropic Messages, adaptive thinking, `output_config.effort`, medium upstream default. Mirror exact-record precedence in Rust/TS; keep uncurated UC services neutral and existing GPT routing unchanged. - Preserve ordered native content, including signed/empty thinking and redacted blocks, across tool results. Do not cache-stamp thinking blocks or leak native state into Chat reasoning details. Discard truncated native state; reject more than 64 native calls before any tool executes instead of altering signed content. - Add production-dispatch/ACP regressions and six shared capability vectors. No relay/ACP protocol, schema, settings, inheritance, retries, or other harness changes. ## Validation At final head `82f43f8d07bfcc3f4af492afc0a58a02f8e549c4`, clean tree: - Rebuilt real `buzz-agent`, drove ACP with a harmless MCP fixture against the actual service. Two POSTs to `/ai-gateway/anthropic/v1/messages`, both HTTP 200, exact FQN, High via `output_config`, adaptive/summarized thinking, no `reasoning_effort`. First response contained signed thinking + tool use; second request preserved signature/content order and paired result; final ACP stop `end_turn`. No credentials, signature contents, or conversation data included here. - Mandatory pre-commit/pre-push checks passed: formatting/lint, file-size gate, Rust test lanes, desktop typechecking and full JS tests, Tauri clippy/tests. - Full `cargo test -p buzz-agent` passed on the pre-commit tree (only hook formatting followed), including the new native truncation/65-call ACP regressions, ordered interleaved signed/redacted two-tool capture, nearby-ID negatives, and existing provider regressions. - Independent API/replay, regression-boundary, and Rust/TS capability reviews cleared. Full `just ci`, packaging and manual desktop UI testing were not run locally; repository CI remains the merge gate. Cross-harness internals and other UC service contracts are outside scope. ## References - [Databricks model services](https://docs.databricks.com/aws/en/ai-gateway/query-model-services) - [Anthropic thinking preservation](https://platform.claude.com/docs/en/build-with-claude/thinking#thinking-block-preservation-by-model) - [Anthropic effort](https://platform.claude.com/docs/en/build-with-claude/effort) - Originating Buzz channel: `fccdea96-0f31-4343-ba6a-324214dc8d73` - Originating thread: buzz://message?channel=fccdea96-0f31-4343-ba6a-324214dc8d73&id=33f242923a317e269c1ff7b960cacd6ec1f3371eff2f97592857804c50fb100c Signed-off-by: Wes Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- crates/buzz-agent/src/llm.rs | 38 ++++- crates/buzz-agent/src/llm_fqn_tests.rs | 109 ++++++++++++++ crates/buzz-agent/src/model_capabilities.rs | 22 ++- crates/buzz-agent/src/types.rs | 7 +- .../tests/anthropic_continuation.rs | 136 ++++++++++++++++++ desktop/src/features/agents/AGENTS.md | 2 +- .../features/agents/ui/modelCapabilities.ts | 6 +- .../ui/modelCapabilitiesCorpus.test.mjs | 4 +- scripts/model-capabilities.json | 18 +++ scripts/normative-corpus.json | 121 ++++++++++++++++ 10 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 crates/buzz-agent/tests/anthropic_continuation.rs diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index c0eae9a4d2..433ece7f23 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -10,6 +10,7 @@ use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, Toke use crate::config::{ is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_databricks_v2, normalize_effort_for_provider, Config, OpenAiApi, Provider, ThinkingEffort, + MAX_TOOL_CALLS_PER_TURN, }; use crate::types::{ AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent, @@ -588,9 +589,21 @@ fn anthropic_body( HistoryItem::Assistant { text, tool_calls, - reasoning_details: _, + reasoning_details, } => { flush(&mut messages, &mut pending); + // Native Anthropic blocks carry signed, potentially interleaved + // thinking. Replay the original order, not flattened text/calls. + if let Some(blocks) = reasoning_details + .as_ref() + .and_then(|v| v.get("anthropic_content")) + .and_then(Value::as_array) + { + if !blocks.is_empty() { + messages.push(json!({ "role": "assistant", "content": blocks })); + } + continue; + } let mut content: Vec = Vec::new(); if !text.is_empty() { content.push(json!({ "type": "text", "text": text })); @@ -688,6 +701,9 @@ fn stamp_rolling_cache_breakpoint(messages: &mut [Value]) { .get_mut("content") .and_then(Value::as_array_mut) .and_then(|c| c.last_mut()) + // Thinking blocks must remain byte-for-byte unchanged, and cannot + // carry an explicit cache breakpoint. + .filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking"))) .and_then(Value::as_object_mut) { block.insert("cache_control".into(), json!({ "type": "ephemeral" })); @@ -746,7 +762,9 @@ fn openai_body( let mut msg = serde_json::Map::new(); msg.insert("role".into(), json!("assistant")); msg.insert("content".into(), json!(text.as_str())); - if let Some(details) = reasoning_details { + // OpenRouter owns the array shape; native Anthropic state must + // not leak into Chat requests after a session model switch. + if let Some(details) = reasoning_details.as_ref().filter(|v| v.is_array()) { msg.insert("reasoning_details".into(), details.clone()); } if !tool_calls.is_empty() { @@ -1386,6 +1404,14 @@ fn parse_anthropic(v: Value) -> Result { } } } + // The run loop caps other providers' calls by truncating them. Native + // content must be replayed intact, so reject oversized turns before any + // tools execute rather than silently altering signed/interleaved state. + if tool_calls.len() > MAX_TOOL_CALLS_PER_TURN { + return Err(AgentError::Llm(format!( + "Anthropic response exceeds {MAX_TOOL_CALLS_PER_TURN} tool calls; cannot truncate native content" + ))); + } // anthropic_input_tokens() returns Option because it sums // three fields that can collectively overflow u64. Propagate the overflow // signal via `input_tokens_overflowed` so the run loop can poison the @@ -1415,7 +1441,13 @@ fn parse_anthropic(v: Value) -> Result { // total from them. Always None for this provider. total_tokens: None, reasoning, - reasoning_details: None, + // Truncated content is not a valid signed assistant turn; the run loop + // keeps only its text and never executes its tool calls. + reasoning_details: v + .get("content") + .filter(|v| v.is_array()) + .filter(|_| stop != ProviderStop::MaxTokens) + .map(|blocks| json!({ "anthropic_content": blocks })), // Stamped by the dispatch layer (complete) after parse. request_model: None, }) diff --git a/crates/buzz-agent/src/llm_fqn_tests.rs b/crates/buzz-agent/src/llm_fqn_tests.rs index cf79358b86..e122318b01 100644 --- a/crates/buzz-agent/src/llm_fqn_tests.rs +++ b/crates/buzz-agent/src/llm_fqn_tests.rs @@ -54,3 +54,112 @@ async fn gpt_fqn_completion_and_summary_use_responses() { assert_eq!(completion["tools"][0]["name"], "test_tool"); assert_eq!(posts[1].body.as_ref().unwrap()["max_output_tokens"], 128); } + +const OPUS_UC: &str = "data_workflow_tools.goose.goose-claude-opus-5-5"; + +#[tokio::test] +async fn opus_uc_replays_ordered_signed_content_with_high_effort() { + // Empty thinking can still carry a signature; redacted data is opaque. + let blocks = json!([ + {"type":"thinking", "thinking":"", "signature":"signed-empty"}, + {"type":"tool_use", "id":"a", "name":"test_tool", "input":{"n":1}}, + {"type":"redacted_thinking", "data":"opaque-redacted"}, + {"type":"tool_use", "id":"b", "name":"test_tool", "input":{"n":2}}, + {"type":"thinking", "thinking":"checking", "signature":"signed-check"}, + {"type":"text", "text":"running both"} + ]); + let done = json!({"stop_reason":"end_turn", "content":[{"type":"text","text":"done"}]}); + let (base_url, captured) = spawn_sequence_stub(vec![ + StubHttpResponse::ok(json!({"stop_reason":"tool_use", "content":blocks})), + StubHttpResponse::ok(done.clone()), + StubHttpResponse::ok(done), + ]).await; + let mut config = cfg(Provider::DatabricksV2); + config.base_url = base_url; + config.thinking_effort = Some(ThinkingEffort::High); + config.prompt_caching = true; + let llm = Llm::new(&config).unwrap(); + let tools = vec![ToolDef { name:"test_tool".into(), description:"Test".into(), + input_schema:json!({"type":"object", "properties":{"n":{"type":"integer"}}}) }]; + let mut history = vec![HistoryItem::User("run both".into())]; + let first = llm.complete(&config, "system", &history, &tools, OPUS_UC).await.unwrap(); + assert_eq!(first.stop, ProviderStop::ToolUse); + assert_eq!(first.reasoning, "checking"); + assert_eq!(first.tool_calls.len(), 2); + assert_eq!(first.tool_calls[1].arguments, json!({"n":2})); + history.push(HistoryItem::Assistant { text:first.text, tool_calls:first.tool_calls, + reasoning_details:first.reasoning_details }); + for id in ["a", "b"] { + history.push(HistoryItem::ToolResult(crate::types::ToolResult { + provider_id:id.into(), content:vec![ToolResultContent::Text(format!("result-{id}"))], is_error:false, + })); + } + let second = llm.complete(&config, "system", &history, &tools, OPUS_UC).await.unwrap(); + assert_eq!(second.stop, ProviderStop::EndTurn); + assert_eq!(second.text, "done"); + assert_eq!(llm.summarize(&config, "system", "history", 128, OPUS_UC).await.unwrap(), "done"); + let requests = captured.lock().await; + let posts: Vec<_> = requests.iter().filter(|r| r.method == "POST").collect(); + assert_eq!(posts.len(), 3); + for post in &posts { + assert_eq!(post.path, "/v1/ai-gateway/anthropic/v1/messages"); + assert_eq!(post.body.as_ref().unwrap()["model"], OPUS_UC); + assert!(post.body.as_ref().unwrap().get("reasoning_effort").is_none()); + } + for post in &posts[..2] { + let body = post.body.as_ref().unwrap(); + assert_eq!(body["output_config"], json!({"effort":"high"})); + assert_eq!(body["thinking"], json!({"type":"adaptive", "display":"summarized"})); + assert_eq!(body["tools"][0]["input_schema"], tools[0].input_schema); + } + let body = posts[1].body.as_ref().unwrap(); + let mut replay = body["messages"][1]["content"].clone(); + assert_eq!(replay[5]["cache_control"], json!({"type":"ephemeral"})); + replay[5].as_object_mut().unwrap().remove("cache_control"); + assert_eq!(replay, blocks); + let results = body["messages"][2]["content"].as_array().unwrap(); + assert_eq!(results.len(), 2); + for (result, id) in results.iter().zip(["a", "b"]) { + assert_eq!(result["type"], "tool_result"); + assert_eq!(result["tool_use_id"], id); + assert_eq!(result["content"][0]["text"], format!("result-{id}")); + } +} + +#[tokio::test] +async fn neighboring_opus_uc_services_stay_on_mlflow_chat() { + for model in ["system.ai.claude-opus-5-5", "other.goose.goose-claude-opus-5-5", + "data_workflow_tools.goose.goose-claude-opus-5-5-preview"] { + let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse::ok(json!({ + "choices":[{"finish_reason":"stop", "message":{"content":"ok"}}] + }))]).await; + let mut config = cfg(Provider::DatabricksV2); + config.base_url = base_url; + config.thinking_effort = Some(ThinkingEffort::High); + Llm::new(&config).unwrap().complete(&config, "system", &[HistoryItem::User("hi".into())], &[], model).await.unwrap(); + let requests = captured.lock().await; + let post = requests.iter().find(|r| r.method == "POST").unwrap(); + assert_eq!(post.path, "/v1/ai-gateway/mlflow/v1/chat/completions"); + let body = post.body.as_ref().unwrap(); + assert_eq!(body["model"], model); + assert_eq!(body["reasoning_effort"], "high"); + assert!(body.get("thinking").is_none()); + } +} + +#[test] +fn native_thinking_tail_is_not_cache_stamped_or_leaked_to_chat() { + for block in [json!({"type":"thinking", "thinking":"", "signature":"signed"}), + json!({"type":"redacted_thinking", "data":"opaque"})] { + let response = parse_anthropic(json!({"stop_reason":"end_turn", "content":[block]})).unwrap(); + let history = vec![HistoryItem::Assistant { text:response.text, tool_calls:response.tool_calls, + reasoning_details:response.reasoning_details }]; + let mut config = cfg(Provider::DatabricksV2); + config.prompt_caching = true; + let native = anthropic_body(&config, "system", &history, &[], OPUS_UC, Some(ThinkingEffort::High), "databricks_v2"); + assert_eq!(native["messages"][0]["content"], json!([block])); + let chat = openai_body(&config, "system", &history, &[], "other", None); + assert!(chat["messages"][1].get("reasoning_details").is_none()); + assert!(!responses_body(&config, "system", &history, &[], "other", None).to_string().contains("anthropic_content")); + } +} diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 18a4726012..df5ff5cfa9 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -293,7 +293,8 @@ fn prefix_matches(token: &str, s: &str) -> bool { /// Databricks Unity Catalog model-service names are catalog data, not model /// family hints. Both capability interpreters use this shape check before /// family matching so services cannot inherit endpoint capabilities accidentally. -/// GPT-5+ services have a route-only Responses exception. +/// Verified exact records may supply capabilities; other GPT-5+ services have +/// a route-only Responses exception. pub(crate) fn is_databricks_model_service_fqn(model: &str) -> bool { let mut components = model.split('.'); let (Some(catalog), Some(schema), Some(service)) = @@ -334,14 +335,14 @@ pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult { let canon = canonical_provider(provider); let blank = raw_model_id.trim().is_empty(); - // FQNs keep neutral effort capabilities, but GPT-5+ service names need - // Responses for tools with reasoning. Only inspect the service component: - // catalog/schema names must never choose a model protocol. + // Uncurated FQNs keep neutral effort capabilities. Exact records are verified + // service contracts, not family-name inference. The GPT-5+ route-only fallback + // inspects just the service component, never catalog/schema names. let model_service_fqn = canon == "databricks_v2" && is_databricks_model_service_fqn(raw_model_id); // 1. Provider-qualified exact-record lookup (case-insensitive on the id). - if !blank && !model_service_fqn { + if !blank { for rec in &m.exact_records { if rec.provider == canon && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) { return CapabilityResult { @@ -747,6 +748,13 @@ mod tests { Q::Vector { id: "boundary-claude-3-digit-run-anthropic-probe", provider: "anthropic", raw_model_id: "claude-35", note: Some("Probes whether the claude-3 prefix binds a longer digit run ('35').") }, Q::Vector { id: "boundary-claude-opus-4-70-anthropic-probe", provider: "anthropic", raw_model_id: "claude-opus-4-70", note: Some("Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').") }, Q::Vector { id: "boundary-gpt-5-1234-openai-probe", provider: "openai", raw_model_id: "gpt-5-1234", note: Some("Probes a 4-digit run after the gpt-5 stem.") }, + Q::Section { group: "Verified Databricks Opus UC service and exact-record boundaries", note: None }, + Q::Vector { id: "dbv2-opus-uc-exact", provider: "databricks_v2", raw_model_id: "data_workflow_tools.goose.goose-claude-opus-5-5", note: None }, + Q::Vector { id: "dbv2-opus-uc-case", provider: "databricks_v2", raw_model_id: "DATA_WORKFLOW_TOOLS.GOOSE.GOOSE-CLAUDE-OPUS-5-5", note: None }, + Q::Vector { id: "dbv2-opus-uc-namespace", provider: "databricks_v2", raw_model_id: "other.goose.goose-claude-opus-5-5", note: None }, + Q::Vector { id: "dbv2-opus-uc-service", provider: "databricks_v2", raw_model_id: "data_workflow_tools.goose.goose-claude-opus-5-5-preview", note: None }, + Q::Vector { id: "dbv2-opus-uc-system", provider: "databricks_v2", raw_model_id: "system.ai.claude-opus-5-5", note: None }, + Q::Vector { id: "dbv2-opus-uc-provider", provider: "openai", raw_model_id: "data_workflow_tools.goose.goose-claude-opus-5-5", note: None }, Q::Section { group: "Databricks FQN GPT-5+ Responses routing", note: Some("Only the service component selects Responses; effort capabilities remain neutral.") }, Q::Vector { id: "dbv2-fqn-responses-0", provider: "databricks_v2", raw_model_id: "catalog.schema.goose-gpt-6-astra", note: None }, Q::Vector { id: "dbv2-fqn-responses-1", provider: "databricks_v2", raw_model_id: "catalog.schema.goose-gpt-5", note: None }, @@ -886,7 +894,7 @@ mod tests { } #[test] - fn corpus_has_exactly_158_executable_vectors() { + fn corpus_has_exactly_164_executable_vectors() { // Locks the vector count so a silent INPUTS edit can't quietly drop // coverage; must equal the gate in the TS harness // (modelCapabilitiesCorpus.test.mjs). @@ -895,7 +903,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 158, + vectors, 164, "corpus executable-vector count changed; update this gate deliberately" ); } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 10ac65b46e..cb7a59c90a 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -215,9 +215,10 @@ pub struct LlmResponse { /// /// Empty string when the provider returned no reasoning content. pub reasoning: String, - /// Raw `reasoning_details` array from an OpenRouter response, if present. - /// Replayed on subsequent turns so the model can continue its chain-of-thought. - /// `None` for all non-OpenRouter providers. + /// Provider-owned replay state: OpenRouter's raw `reasoning_details` array, + /// or an `anthropic_content` object holding ordered native content blocks. + /// Kept opaque so signed thinking survives tool-result continuation. Each + /// serializer consumes only its own shape, including after model switches. pub reasoning_details: Option, /// The model id that was actually sent in the request body — the /// actually-requested model after any auto/mesh resolution. Populated by diff --git a/crates/buzz-agent/tests/anthropic_continuation.rs b/crates/buzz-agent/tests/anthropic_continuation.rs new file mode 100644 index 0000000000..9fe4bd0f05 --- /dev/null +++ b/crates/buzz-agent/tests/anthropic_continuation.rs @@ -0,0 +1,136 @@ +//! Native replay must remain consistent with the real ACP loop's recovery limits. +mod common; + +use common::{spawn_capturing_llm, Harness}; +use serde_json::json; + +async fn session(h: &mut Harness) -> String { + let id = h + .send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + h.recv_until(|v| v["id"] == id).await; + let id = h + .send( + "session/new", + json!({"cwd":"/tmp","mcpServers":[{ + "name":"probe", "command":env!("CARGO_BIN_EXE_fake-mcp"), "args":[], "env":[] + }]}), + ) + .await; + h.recv_until(|v| v["id"] == id).await["result"]["sessionId"] + .as_str() + .unwrap() + .to_owned() +} + +async fn native_harness(url: &str) -> Harness { + // Native Anthropic avoids Databricks discovery consuming response fixtures; + // the exact UC dispatcher and effort contract are tested in llm_fqn_tests. + Harness::spawn_with_env( + url, + &[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_API_KEY", "test"), + ("ANTHROPIC_BASE_URL", url), + ("BUZZ_AGENT_MODEL", "claude-opus-5-5"), + ("BUZZ_AGENT_THINKING_EFFORT", "high"), + ], + ) + .await +} + +#[tokio::test] +async fn oversized_native_turn_fails_before_tools_execute() { + let mut content = vec![json!({"type":"thinking", "thinking":"", "signature":"signed"})]; + content.extend((0..65).map(|n| { + json!({"type":"tool_use", "id":format!("call-{n}"), + "name":"probe__tool_0", "input":{}}) + })); + let llm = spawn_capturing_llm(vec![json!({"stop_reason":"tool_use", "content":content})]).await; + let mut h = native_harness(&llm.url).await; + let sid = session(&mut h).await; + let id = h + .send( + "session/prompt", + json!({"sessionId":sid,"prompt":[{"type":"text","text":"go"}]}), + ) + .await; + let mut tool_started = false; + let reply = h + .recv_until_approving(|v| { + tool_started |= v["params"]["update"]["sessionUpdate"] == "tool_call"; + v["id"] == id + }) + .await; + assert!( + reply["error"] + .to_string() + .contains("cannot truncate native content"), + "{reply}" + ); + assert!( + !tool_started, + "oversized native turn must execute no partial subset" + ); + assert_eq!(llm.captured.lock().await.len(), 1); + h.shutdown().await; +} + +#[tokio::test] +async fn native_max_tokens_recovers_without_signed_state_or_partial_tools() { + let llm = spawn_capturing_llm(vec![ + json!({"stop_reason":"max_tokens", "content":[ + {"type":"thinking", "thinking":"", "signature":"incomplete-signature"}, + {"type":"text", "text":"partial-text"}, + {"type":"tool_use", "id":"partial-call", "name":"probe__tool_0", "input":"{broken"}, + {"type":"redacted_thinking", "data":"incomplete-redaction"} + ]}), + json!({"stop_reason":"end_turn", "content":[{"type":"text", "text":"done"}]}), + ]) + .await; + let mut h = native_harness(&llm.url).await; + let sid = session(&mut h).await; + let id = h + .send( + "session/prompt", + json!({"sessionId":sid,"prompt":[{"type":"text","text":"go"}]}), + ) + .await; + let mut tool_started = false; + let reply = h + .recv_until_approving(|v| { + tool_started |= v["params"]["update"]["sessionUpdate"] == "tool_call"; + v["id"] == id + }) + .await; + assert_eq!(reply["result"]["stopReason"], "end_turn", "{reply}"); + assert!(!tool_started); + let requests = llm.captured.lock().await; + assert_eq!(requests.len(), 2); + let retry = &requests[1]["messages"]; + let assistant = retry + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "assistant") + .unwrap(); + let blocks = assistant["content"].as_array().unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0]["type"], "text"); + assert_eq!(blocks[0]["text"], "partial-text"); + let serialized = retry.to_string(); + for forbidden in [ + "incomplete-signature", + "incomplete-redaction", + "partial-call", + "tool_result", + ] { + assert!(!serialized.contains(forbidden), "{retry}"); + } + assert!(serialized.contains("output token limit")); + drop(requests); + h.shutdown().await; +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index aa586b206a..1081bce81b 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -306,7 +306,7 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs retain neutral effort capabilities. A boundary-matched GPT-5-or-newer family in the service-name component selects OpenAI Responses so tools can coexist with reasoning; other FQNs use MLflow Chat Completions. Catalog/schema components never influence routing. Keep this route-only rule identical in the Rust and TypeScript capability interpreters. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. Verified provider-qualified exact records in `scripts/model-capabilities.json` take precedence for UC FQNs: `data_workflow_tools.goose.goose-claude-opus-5-5` uses Anthropic Messages, adaptive thinking, and `output_config.effort` (default medium). This does not confer capabilities on other namespaces or similarly named services. Uncurated UC FQNs retain neutral effort capabilities. A boundary-matched GPT-5-or-newer family in their service-name component selects OpenAI Responses so tools can coexist with reasoning; other uncurated FQNs use MLflow Chat Completions. Catalog/schema components never infer routing. Keep exact-record precedence and fallback rules identical in the Rust and TypeScript capability interpreters and shared corpus. Effort inheritance, persistence, and clearing semantics are unchanged. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. ## Channel-only runtime controls diff --git a/desktop/src/features/agents/ui/modelCapabilities.ts b/desktop/src/features/agents/ui/modelCapabilities.ts index d6866c5c7e..045cc9de13 100644 --- a/desktop/src/features/agents/ui/modelCapabilities.ts +++ b/desktop/src/features/agents/ui/modelCapabilities.ts @@ -328,13 +328,13 @@ export function resolveModelCapabilities( ): CapabilityResult { const canon = canonicalizeProvider(provider); const blank = rawModelId.trim().length === 0; - // FQNs keep neutral effort capabilities; only GPT-5+ service names - // select Responses. Catalog/schema names never choose the protocol. + // Uncurated FQNs keep neutral effort capabilities; verified exact records + // take precedence. Catalog/schema names never infer a protocol. const modelServiceFqn = canon === "databricks_v2" && isDatabricksModelServiceFqn(rawModelId); // 1. Provider-qualified exact-record lookup (case-insensitive on the id). - if (!blank && !modelServiceFqn) { + if (!blank) { const idLower = rawModelId.toLowerCase(); for (const rec of MANIFEST.exact_records) { if ( diff --git a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs index 21baf6b64b..a27e0d675c 100644 --- a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -25,10 +25,10 @@ const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8")); // (`_group`) are skipped. Mirrors the Rust corpus filter. const executable = corpus.filter((entry) => entry.expect != null); -test("corpus has exactly 158 executable vectors", () => { +test("corpus has exactly 164 executable vectors", () => { // Locks the vector count so a silent corpus edit can't quietly drop coverage; // must equal the gate in the Rust suite (model_capabilities.rs). - assert.equal(executable.length, 158); + assert.equal(executable.length, 164); }); test("registry label aliases refuse an unprefixed query", () => { diff --git a/scripts/model-capabilities.json b/scripts/model-capabilities.json index edcf25a783..85d25fccdb 100644 --- a/scripts/model-capabilities.json +++ b/scripts/model-capabilities.json @@ -431,6 +431,24 @@ "databricks-claude-opus-4-7" ], "exact_records": [ + { + "provider": "databricks_v2", + "raw_model_id": "data_workflow_tools.goose.goose-claude-opus-5-5", + "registry_label": "Claude Opus 5.5", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "Exact UC service: native Messages with adaptive thinking and output_config.effort=high verified by synthetic tool-result round trip on 2026-09-23. Not inferred for other UC namespaces.", + "source": "https://docs.databricks.com/aws/en/ai-gateway/query-model-services; https://platform.claude.com/docs/en/build-with-claude/effort" + }, { "provider": "databricks_v2", "raw_model_id": "databricks-gpt-5-4-mini", diff --git a/scripts/normative-corpus.json b/scripts/normative-corpus.json index cb0ff4ae27..fe0b99d846 100644 --- a/scripts/normative-corpus.json +++ b/scripts/normative-corpus.json @@ -2347,6 +2347,127 @@ "registry_label": null } }, + { + "_group": "Verified Databricks Opus UC service and exact-record boundaries" + }, + { + "id": "dbv2-opus-uc-exact", + "provider": "databricks_v2", + "raw_model_id": "data_workflow_tools.goose.goose-claude-opus-5-5", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": "Claude Opus 5.5" + } + }, + { + "id": "dbv2-opus-uc-case", + "provider": "databricks_v2", + "raw_model_id": "DATA_WORKFLOW_TOOLS.GOOSE.GOOSE-CLAUDE-OPUS-5-5", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": "Claude Opus 5.5" + } + }, + { + "id": "dbv2-opus-uc-namespace", + "provider": "databricks_v2", + "raw_model_id": "other.goose.goose-claude-opus-5-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-opus-uc-service", + "provider": "databricks_v2", + "raw_model_id": "data_workflow_tools.goose.goose-claude-opus-5-5-preview", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-opus-uc-system", + "provider": "databricks_v2", + "raw_model_id": "system.ai.claude-opus-5-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-opus-uc-provider", + "provider": "openai", + "raw_model_id": "data_workflow_tools.goose.goose-claude-opus-5-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, { "_group": "Databricks FQN GPT-5+ Responses routing", "_note": "Only the service component selects Responses; effort capabilities remain neutral."