diff --git a/api/scripts/run_pydantic.py b/api/scripts/run_pydantic.py index 5c9bb597..70605228 100644 --- a/api/scripts/run_pydantic.py +++ b/api/scripts/run_pydantic.py @@ -1082,15 +1082,38 @@ def build_agent( name = spec.get("name") if isinstance(name, str) and name.strip(): kwargs["name"] = name + # Provider-adaptive capabilities from `capabilities:` (e.g. WebSearch). + # Built before model settings because WebSearch changes which settings are + # legal (see below). + capabilities = _build_capabilities(spec) + has_web_search = any(type(c).__name__ == "WebSearch" for c in capabilities) + # Default to SEQUENTIAL tool calls. parallel_tool_calls=False is a real, # API-level limiter (OpenAI parallel_tool_calls / Anthropic # disable_parallel_tool_use): the model emits one tool call per turn instead # of fanning out many at once, which is what was getting Attio (and other # providers) rate-limited. An agent that genuinely needs parallel calls can # opt back in with model_settings.parallel_tool_calls: true in its spec. + # EXCEPT WebSearch on Anthropic: the current web_search tool runs with + # server-side programmatic tool calling, and the API rejects + # `tool_choice.disable_parallel_tool_use: true` combined with it (400) as + # soon as the agent also has any client/MCP tool. Skip the default there — + # pydantic-ai only emits disable_parallel_tool_use when the key is present. + anthropic_web_search = ( + has_web_search and isinstance(model, str) and model.startswith("anthropic:") + ) model_settings = spec.get("model_settings") ms = dict(model_settings) if isinstance(model_settings, dict) else {} - ms.setdefault("parallel_tool_calls", False) + if not anthropic_web_search: + ms.setdefault("parallel_tool_calls", False) + elif "parallel_tool_calls" in ms: + print( + "[capabilities] WebSearch on Anthropic is incompatible with the " + "parallel_tool_calls setting (programmatic tool calling); " + "dropping it for this run", + file=sys.stderr, + ) + ms.pop("parallel_tool_calls", None) # Anthropic prompt caching. An agentic run re-sends the whole prompt every # step; without caching the big static prefix — system instructions + the # MCP/Composio tool schemas — is re-billed at full input rate on each of the @@ -1129,10 +1152,7 @@ def build_agent( if tools: kwargs["tools"] = tools - # Provider-adaptive capabilities from `capabilities:` (e.g. WebSearch). - # These are model-side abilities (not MCP/sidecar) — pydantic-ai uses the - # provider's native implementation where available, with a local fallback. - capabilities = _build_capabilities(spec) + # Capabilities were built up top (before model settings); attach them here. if capabilities: kwargs["capabilities"] = capabilities diff --git a/api/tests/test_run_pydantic_build_agent.py b/api/tests/test_run_pydantic_build_agent.py index 07831713..961c5a99 100644 --- a/api/tests/test_run_pydantic_build_agent.py +++ b/api/tests/test_run_pydantic_build_agent.py @@ -100,6 +100,43 @@ def test_build_agent_attaches_websearch_capability() -> None: assert isinstance(agent, Agent) +def test_websearch_on_anthropic_skips_parallel_tool_calls_setting() -> None: + # Anthropic's web_search tool uses server-side programmatic tool calling, + # which the API rejects in combination with disable_parallel_tool_use + # (400 as soon as the agent also has a client/MCP tool). The sequential + # default must not apply — and an explicit spec value must be dropped — + # for WebSearch agents on Anthropic, while non-WebSearch agents keep it. + ws = run_pydantic.build_agent( + { + "name": "searcher", + "model": "anthropic:claude-sonnet-5", + "instructions": "Search.", + "capabilities": ["WebSearch"], + } + ) + assert "parallel_tool_calls" not in ws.model_settings + + ws_explicit = run_pydantic.build_agent( + { + "name": "searcher-explicit", + "model": "anthropic:claude-sonnet-5", + "instructions": "Search.", + "capabilities": ["WebSearch"], + "model_settings": {"parallel_tool_calls": False}, + } + ) + assert "parallel_tool_calls" not in ws_explicit.model_settings + + plain = run_pydantic.build_agent( + { + "name": "plain", + "model": "anthropic:claude-sonnet-5", + "instructions": "Reply.", + } + ) + assert plain.model_settings["parallel_tool_calls"] is False + + def test_build_agent_with_scaledown_enabled(monkeypatch: pytest.MonkeyPatch) -> None: # scaledown attaches its compressor as a ProcessHistory capability # (pydantic-ai 2.x dropped Agent(history_processors=...)). diff --git a/web/src/lib/cap-api.ts b/web/src/lib/cap-api.ts index e3f266ed..b91afc82 100644 --- a/web/src/lib/cap-api.ts +++ b/web/src/lib/cap-api.ts @@ -12,11 +12,13 @@ import type { CommitMode } from "@/lib/commit-mode-constants"; // Thin client for the Tembo Coding Agent Platform task API. The task // endpoints live under the **/public-api** namespace and authenticate -// with the workspace's Tembo API key as `Authorization: Bearer` — the -// bare `/task/create` path hits a different internal auth gate that -// rejects the public key ("Invalid token"). POSTs a free-text prompt + -// repo URL to POST /public-api/task/create and returns a task record -// with an htmlUrl the user can follow; the task is what opens the PR. +// with the workspace's Tembo API key as `Authorization: Bearer`. POSTs +// a free-text prompt + repo URL to POST /public-api/session/create and +// returns a task record with an htmlUrl the user can follow; the task +// is what opens the PR. CAP renamed the mount from /public-api/task to +// /public-api/session with no alias (tembo/monorepo#9519, 2026-07-16); +// the old path falls through to a catch-all that 400s with +// {"error":{"message":"invalid request path"}}. const DEFAULT_TEMBO_API_URL = "https://api.tembo.io"; @@ -61,7 +63,7 @@ export async function createTemboTask(args: { queueRightAway: true, }; - const url = `${baseUrl}/public-api/task/create`; + const url = `${baseUrl}/public-api/session/create`; // Breadcrumb only — never log `body`: it embeds the prompt (run input/output, // user data) and would leak to plaintext container logs / aggregators (#44). console.log("[cap] POST", url);