From 169d855f6495c4bed9935b3b32f8741869c16d48 Mon Sep 17 00:00:00 2001 From: Luke Ellison <12530203+lukeellison@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:32:41 +0000 Subject: [PATCH 01/10] fix(claude-agent-sdk): grant claude_code preset when tools: is omitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent that omits `tools:` was receiving zero tools instead of the default claude_code preset, so e.g. a "read a file and answer" agent came up with no filesystem tools and failed. Root cause is a lost distinction across the executor↔provider boundary. `resolve_agent_tools(agent.tools, workflow_tools)` returns `workflow_tools.copy()` for an omitted `tools:`, which is `[]` whenever the workflow declares no `runtime` MCP tools (the common case for this provider, which rejects `runtime.mcp_servers` at the factory). The executor therefore always hands the provider a concrete list and never `None`, making `_resolve_tool_config`'s `tools is None -> preset` branch dead code: an omitted `tools:` always hit `if not tools: return [], None` and got no tools. Fix it provider-locally: when the resolved list is empty, consult the raw `agent.tools` field (the only place the omitted-vs-explicit signal survives) to disambiguate. `agent.tools is None` (omitted) grants the claude_code preset with bypassPermissions; explicit `tools: []` still disables all tools; an explicit non-empty allowlist still raises ProviderError. The shared `resolve_agent_tools` is left untouched to avoid affecting the copilot/claude providers. Docstrings and the preset comment are updated to reflect the now-reachable semantics. Adds tests demonstrating the regression (omitted tools -> preset, including end-to-end through AgentExecutor) and an example workflow. Co-Authored-By: Claude Opus 4.8 --- examples/claude-agent-sdk-repo-qa.yaml | 77 ++++++++++ src/conductor/providers/claude_agent_sdk.py | 47 ++++-- tests/test_providers/test_claude_agent_sdk.py | 139 +++++++++++++++++- 3 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 examples/claude-agent-sdk-repo-qa.yaml diff --git a/examples/claude-agent-sdk-repo-qa.yaml b/examples/claude-agent-sdk-repo-qa.yaml new file mode 100644 index 00000000..6b86fa1a --- /dev/null +++ b/examples/claude-agent-sdk-repo-qa.yaml @@ -0,0 +1,77 @@ +# Repo Q&A with the claude-agent-sdk provider (default tools preset) +# +# This single-agent workflow asks the claude-agent-sdk provider to READ a file +# in its working directory and answer a question about it. The agent does NOT +# declare a `tools:` list — it relies on the provider's default behavior. +# +# WHY THIS EXAMPLE EXISTS (the bug it guards against): +# Before the fix, an agent that omitted `tools:` received NO tools and could +# not read the file: the executor's resolve_agent_tools() turned the omitted +# `tools:` into an empty list (the workflow had no `runtime` MCP tools to +# inherit), and the provider could not tell that empty list apart from an +# explicit `tools: []`, so it disabled every tool. The agent came up with +# ZERO filesystem tools and failed to read the file. +# +# After the fix, the provider inspects the raw `agent.tools` field: an OMITTED +# `tools:` (agent.tools is None) now grants the full `claude_code` preset +# (Read / Bash / Edit / Glob / Grep / WebFetch / ...), exactly like the bare +# `claude` CLI. An explicit `tools: []` still disables all tools. +# +# Pre-requisites: +# pip install conductor[claude-agent-sdk] # or: uv add 'claude-agent-sdk>=0.1.64' +# npm install -g @anthropic-ai/claude-code # the `claude` CLI +# claude login +# +# Usage (run from the repo root so README.md is in the working directory): +# conductor run examples/claude-agent-sdk-repo-qa.yaml \ +# --input question="In one sentence, what is Conductor?" +# +# Validation only (no execution / no API calls): +# conductor validate examples/claude-agent-sdk-repo-qa.yaml + +workflow: + name: claude-agent-sdk-repo-qa + description: > + Single claude-agent-sdk agent that reads a file in its working + directory and answers a question about it. Demonstrates the default + claude_code tool preset granted when `tools:` is omitted. + version: "1.0.0" + entry_point: repo_reader + + runtime: + provider: claude-agent-sdk + # claude-sonnet-4-5 is the SDK's documented default; adjust to whatever + # your Claude Code backend (anthropic.com / Vertex AI / Bedrock) expects. + default_model: claude-sonnet-4-5 + + input: + question: + type: string + required: true + description: A question to answer using a file in the working directory. + +agents: + - name: repo_reader + description: Read README.md in the working directory and answer the question. + # NOTE: there is intentionally NO `tools:` field here. Omitting it grants + # the full `claude_code` preset (Read/Bash/Edit/...). Before the fix this + # agent received no tools and could not read the file; after the fix it can. + prompt: | + Read the file README.md in your current working directory, then answer + the following question using only what that file says. Cite the relevant + phrase from the file. + + Question: {{ workflow.input.question }} + output: + answer: + type: string + description: The answer, grounded in README.md. + evidence: + type: string + description: A short quote from README.md that supports the answer. + routes: + - to: $end + +output: + answer: "{{ repo_reader.output.answer }}" + evidence: "{{ repo_reader.output.evidence }}" diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index fb06b44b..b64cfdd8 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -83,9 +83,12 @@ def _build_output_format(output: dict[str, OutputField]) -> dict[str, Any]: } -# Default tool preset granted when an agent does not declare a `tools:` list. -# This mirrors the SDK's `claude_code` preset (filesystem, bash, web, etc.) — -# i.e. the same behavior the user gets when running the `claude` CLI directly. +# Default tool preset granted when an agent omits the `tools:` list. This +# mirrors the SDK's `claude_code` preset (filesystem, bash, web, etc.) — i.e. +# the same behavior the user gets when running the `claude` CLI directly. It is +# selected from the RAW ``agent.tools is None`` signal, NOT from the executor's +# resolved list: the executor erases the "omitted" distinction by returning an +# empty list (the workflow tools copy) for an agent that declares no `tools:`. _DEFAULT_TOOL_PRESET: dict[str, str] = {"type": "preset", "preset": "claude_code"} # Display-only previews for the verbose CLI pretty-printer (NOT surfaced @@ -444,15 +447,24 @@ def _resolve_tool_config( identifiers. We therefore refuse to forward a non-empty allowlist to the SDK rather than silently grant the wrong native tools. + The ``tools`` argument is the executor's *resolved* list from + :func:`conductor.executor.agent.resolve_agent_tools`. That function + erases the distinction between an omitted ``tools:`` and an explicit + ``tools: []``: both arrive here as an empty list whenever the + workflow declares no ``runtime`` MCP tools (the common case for this + provider, which rejects ``runtime.mcp_servers`` at the factory). We + therefore consult the RAW ``agent.tools`` field — the only place the + omitted-vs-explicit signal survives — to pick the default. + Semantics: - * ``tools is None`` — no allowlist declared. Fall back to the - ``claude_code`` preset (filesystem, bash, web) and bypass - permissions, matching what the user gets from the bare - ``claude`` CLI. Backward compatible. - * ``tools == []`` — explicit "no tools" request. Pass an empty - list to the SDK so all tools are disabled. Drop the permission - bypass because there are no tools to permit. + * ``tools`` empty (``[]`` or ``None``) and ``agent.tools is None`` — + the agent omitted ``tools:``. Fall back to the ``claude_code`` + preset (filesystem, bash, web) and bypass permissions, matching + what the user gets from the bare ``claude`` CLI. + * ``tools`` empty and ``agent.tools == []`` — explicit "no tools" + request. Pass an empty list to the SDK so all tools are disabled. + Drop the permission bypass because there are no tools to permit. * ``tools`` non-empty — raise ``ProviderError``. Workflow tool name → CLI tool ID translation is not implemented (tracked as a follow-up). Silently dropping the allowlist would be a @@ -460,8 +472,10 @@ def _resolve_tool_config( the wrong native tool. Refuse loudly. Args: - tools: The workflow ``tools:`` allowlist for this agent. - agent: The agent definition (used for the error message). + tools: The executor-resolved ``tools:`` allowlist for this agent. + agent: The agent definition. ``agent.tools`` carries the raw + omitted-vs-explicit-empty signal; ``agent.name`` is used in + the error message. Returns: A ``(sdk_tools, permission_mode)`` tuple suitable for @@ -470,9 +484,14 @@ def _resolve_tool_config( Raises: ProviderError: If ``tools`` is a non-empty list. """ - if tools is None: - return _DEFAULT_TOOL_PRESET, "bypassPermissions" if not tools: + # The executor passes [] for BOTH "omitted (no workflow tools to + # inherit)" and explicit "tools: []". Disambiguate via the raw + # per-agent field, which the executor's resolution erased. + if getattr(agent, "tools", None) is None: + # Omitted -> default claude_code preset (filesystem/bash/web). + return _DEFAULT_TOOL_PRESET, "bypassPermissions" + # Explicit `tools: []` -> no tools, no permission bypass. return [], None raise ProviderError( f"Agent '{agent.name}' declares tools={tools!r}, but " diff --git a/tests/test_providers/test_claude_agent_sdk.py b/tests/test_providers/test_claude_agent_sdk.py index 348fab0b..8d5e5429 100644 --- a/tests/test_providers/test_claude_agent_sdk.py +++ b/tests/test_providers/test_claude_agent_sdk.py @@ -576,11 +576,15 @@ async def fake_query(**kwargs): @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) async def test_empty_tools_list_disables_tools(self) -> None: - """``tools: []`` disables ALL tools and drops the permission bypass. + """Explicit ``tools: []`` disables ALL tools and drops the permission bypass. Regression test for #241 (A1): previously the empty list was silently ignored and the agent got the full ``claude_code`` preset (filesystem/bash/web) — a security regression. + + The agent here declares ``tools: []`` explicitly (``agent.tools == []``), + which is what distinguishes it from an omitted ``tools:`` (the latter + gets the preset — see :class:`TestOmittedToolsDefaultPreset`). """ options_mock = Mock() @@ -592,7 +596,7 @@ async def fake_query(**kwargs): patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", options_mock), ): provider = ClaudeAgentSdkProvider() - agent = AgentDef(name="test", prompt="hi") + agent = AgentDef(name="test", prompt="hi", tools=[]) await provider.execute(agent=agent, context={}, rendered_prompt="hi", tools=[]) call_kwargs = options_mock.call_args[1] @@ -625,6 +629,137 @@ async def fake_query(**kwargs): ) +class TestOmittedToolsDefaultPreset: + """An agent that omits ``tools:`` must receive the ``claude_code`` preset. + + Regression test for the executor↔provider contract bug: the executor's + ``resolve_agent_tools(agent.tools, workflow_tools)`` returns + ``workflow_tools.copy()`` (``[]`` when the workflow declares no + ``runtime`` MCP tools) for an omitted ``tools:``, so the provider is + ALWAYS handed a concrete list and never ``None``. Before the fix, the + provider could not tell "omitted (defaults to all)" from explicit + ``tools: []`` (both arrive as ``[]``) and granted ZERO tools to an agent + that simply forgot to declare ``tools:`` — e.g. a "read a file and + answer" agent came up with no filesystem tools and failed. + + The provider distinguishes the two cases by inspecting the raw + ``agent.tools`` field, which preserves the omitted (``None``) vs. + explicit-empty (``[]``) distinction the executor erases. + """ + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + async def test_omitted_tools_with_empty_executor_list_grants_preset(self) -> None: + """The real bug: executor passes ``tools=[]`` for an omitted ``tools:``. + + ``AgentDef`` defaults ``tools`` to ``None`` (omitted), and the + executor turns that into ``[]`` before calling the provider. The + provider must still grant the ``claude_code`` preset, not no tools. + """ + options_mock = Mock() + + async def fake_query(**kwargs): + yield _result(result="done") + + with ( + patch("conductor.providers.claude_agent_sdk.query", fake_query), + patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", options_mock), + ): + provider = ClaudeAgentSdkProvider() + # agent.tools is None (omitted), but the executor erases that to [] + # before calling the provider — exactly what AgentExecutor does. + agent = AgentDef(name="reader", prompt="read a file and answer") + assert agent.tools is None + await provider.execute(agent=agent, context={}, rendered_prompt="hi", tools=[]) + + call_kwargs = options_mock.call_args[1] + assert call_kwargs["tools"] == {"type": "preset", "preset": "claude_code"}, ( + "An agent that omits `tools:` must receive the claude_code preset " + "even though the executor hands the provider an empty list." + ) + assert call_kwargs["permission_mode"] == "bypassPermissions" + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + async def test_explicit_empty_tools_still_disables_tools(self) -> None: + """An agent that explicitly declares ``tools: []`` still gets no tools. + + The executor passes ``[]`` here too, but ``agent.tools == []`` (not + ``None``) records the explicit opt-out, so the provider disables all + tools and drops the permission bypass. + """ + options_mock = Mock() + + async def fake_query(**kwargs): + yield _result(result="done") + + with ( + patch("conductor.providers.claude_agent_sdk.query", fake_query), + patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", options_mock), + ): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="no_tools", prompt="hi", tools=[]) + assert agent.tools == [] + await provider.execute(agent=agent, context={}, rendered_prompt="hi", tools=[]) + + call_kwargs = options_mock.call_args[1] + assert call_kwargs["tools"] == [] + assert call_kwargs["permission_mode"] is None + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_explicit_non_empty_tools_still_raises(self) -> None: + """An explicit non-empty per-agent allowlist is still refused loudly.""" + + async def fake_query(**kwargs): + yield _result(result="done") + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="my_agent", prompt="hi", tools=["search", "read_file"]) + with pytest.raises(ProviderError, match="does not support per-agent workflow tool"): + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + tools=["search", "read_file"], + ) + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + async def test_executor_to_provider_end_to_end_grants_preset(self) -> None: + """End-to-end through AgentExecutor: an omitted ``tools:`` reaches the + provider as the ``claude_code`` preset, with NO workflow tools declared. + + This pins the full call chain that the original bug broke: + ``AgentExecutor.execute`` → ``resolve_agent_tools(None, [])`` → ``[]`` + → ``provider.execute(tools=[])`` → preset. + """ + from conductor.executor.agent import AgentExecutor + + options_mock = Mock() + captured: dict = {} + + async def fake_query(**kwargs): + yield _result(structured_output={"answer": "from the file"}) + + with ( + patch("conductor.providers.claude_agent_sdk.query", fake_query), + patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", options_mock), + ): + provider = ClaudeAgentSdkProvider() + # No workflow-level tools — resolve_agent_tools returns []. + executor = AgentExecutor(provider, workflow_tools=[]) + agent = AgentDef( + name="reader", + prompt="Read README.md and answer.", + output={"answer": OutputField(type="string")}, + ) + assert agent.tools is None + await executor.execute(agent=agent, context={}) + captured.update(options_mock.call_args[1]) + + assert captured["tools"] == {"type": "preset", "preset": "claude_code"} + assert captured["permission_mode"] == "bypassPermissions" + + class TestAgentTurnStartOrdering: """Provider parity: agent_turn_start event ordering (#241 / A3). From 23326581008720e1cc76188ecbb075a70204ae68 Mon Sep 17 00:00:00 2001 From: Luke Ellison <12530203+lukeellison@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:58:46 +0100 Subject: [PATCH 02/10] fix(claude-agent-sdk): reject inherited workflow tools at validate time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preceding commit made an omitted `tools:` grant the claude_code preset, but that disambiguation only works when the executor-resolved list is empty. When a workflow declares a non-empty workflow-level `tools:`, an agent that omits `tools:` inherits that list (resolve_agent_tools returns a copy), so it reaches the provider as a non-empty allowlist. The provider then refuses it at execute time — correct, but late and with a misleading message claiming the agent "declares tools=[...]" when it declared none. `conductor validate` passed, so the failure only surfaced mid-run (a realistic footgun when migrating a Copilot workflow that carried a workflow-level `tools:` block). Per user decision, fix by rejecting early rather than honoring or silently re-granting: - Honoring the inherited tools is not viable — workflow tool names do not map to Claude CLI tool IDs (the reason the provider refuses non-empty lists); real passthrough needs a name-translation layer (tracked follow-up). - Granting the preset on omit anyway would silently over-grant (full filesystem/bash/web + bypassPermissions instead of the declared subset). Changes: - validator: error when an agent omits `tools:`, the workflow declares a non-empty `tools:`, and the resolved provider is non-passthrough. Sits beside the existing per-agent allowlist check and keys off each agent's *resolved* provider, so mixed-provider workflows are not over-rejected; explicit `tools: []` stays valid as the opt-out. - claude_agent_sdk: soften the runtime ProviderError to say the agent "resolves to" tools (declared or inherited) instead of "declares" them, and point the suggestion at both the per-agent and workflow-level fields. Kept as defense-in-depth for any path that bypasses validation. - docs/comparison: document the new validate-time rejection. - tests: validator coverage (inherited errors; explicit `[]` and passthrough provider still pass) + updated provider-error match strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/providers/comparison.md | 3 +- src/conductor/config/validator.py | 18 ++++++++ src/conductor/providers/claude_agent_sdk.py | 13 +++--- .../test_validator_capabilities.py | 45 +++++++++++++++++++ tests/test_providers/test_claude_agent_sdk.py | 4 +- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md index b61e2a38..237fed18 100644 --- a/docs/providers/comparison.md +++ b/docs/providers/comparison.md @@ -153,7 +153,8 @@ The `claude-agent-sdk` provider does not bridge workflow-level tools/MCP into th - `runtime.mcp_servers` — **rejected at the factory** with a clear error. Translation to the CLI's MCP configuration is not implemented. Configure MCP servers through your Claude Code settings instead. - Per-agent `tools: []` — disables all tools for that agent. - Per-agent `tools: [list]` — **refused loudly**. Workflow tool names do not translate to Claude CLI tool IDs; silently passing them through would risk granting the wrong native tool. -- Omitting `tools:` entirely — grants the full `claude_code` preset (filesystem, bash, web), matching the bare `claude` CLI experience. +- Workflow-level `tools:` combined with an agent that omits `tools:` — **rejected at `conductor validate`**. The agent would otherwise inherit that non-empty list at runtime and hit the same refusal with a confusing message. Remove the workflow-level `tools:` (so omitting `tools:` grants the preset) or set the agent's `tools: []`. +- Omitting `tools:` entirely (with no workflow-level `tools:`) — grants the full `claude_code` preset (filesystem, bash, web), matching the bare `claude` CLI experience. - `temperature` and `max_tokens` are **rejected at the factory** — sampling behavior is controlled by the CLI. ### Example Claude Agent SDK Workflow diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index e5de78c4..947c0f67 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -1689,6 +1689,24 @@ def _caps_for(name: str) -> ProviderCapabilities | None: f"granting different tools than declared is a security regression." ) + # Omitted per-agent ``tools:`` inherits the workflow-level ``tools:`` + # list at runtime (``resolve_agent_tools`` returns a copy of it). For a + # non-passthrough provider that inherited list is a non-empty allowlist + # it cannot honor, so the agent fails at execute time with a confusing + # "declares tools=[...]" error even though it declared none. Catch it at + # validate time with an accurate message. (An explicit ``tools: []`` is + # the "no tools" opt-out and stays valid — only ``None`` inherits.) + elif agent.tools is None and config.tools and not caps.workflow_tools_passthrough: + errors.append( + f"Agent '{agent.name}' omits 'tools:' and would inherit the " + f"workflow-level tools={config.tools!r}, but provider " + f"'{provider_name}' does not honor tool allowlists " + f"(capabilities.workflow_tools_passthrough=False). Remove the " + f"workflow-level 'tools:' so omitting 'tools:' grants the " + f"provider's default tool preset, or set this agent's " + f"'tools: []' to disable all tools." + ) + # reasoning.effort: validate per-agent override OR workflow-wide # default against the supported levels tuple. Per-agent override # takes precedence — if it's set, the default doesn't apply. diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index b64cfdd8..3fd8214b 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -494,13 +494,14 @@ def _resolve_tool_config( # Explicit `tools: []` -> no tools, no permission bypass. return [], None raise ProviderError( - f"Agent '{agent.name}' declares tools={tools!r}, but " - "claude-agent-sdk does not support per-agent workflow tool " - "allowlists (workflow tool names do not translate to Claude " - "CLI tool IDs).", + f"Agent '{agent.name}' resolves to tools={tools!r} (declared on " + "the agent or inherited from the workflow-level 'tools:' list), " + "but claude-agent-sdk does not support workflow tool allowlists " + "(workflow tool names do not translate to Claude CLI tool IDs).", suggestion=( - "Remove the 'tools:' field to grant the full claude_code " - "preset, or set 'tools: []' to disable all tools." + "Omit both the per-agent and workflow-level 'tools:' to grant " + "the full claude_code preset, or set 'tools: []' to disable " + "all tools." ), ) diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index b5257c1c..6cb574bd 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -48,10 +48,14 @@ def _build_workflow( parallel: list[ParallelGroup] | None = None, for_each: list[ForEachDef] | None = None, mcp_servers: dict[str, MCPServerDef] | None = None, + tools: list[str] | None = None, ) -> WorkflowConfig: runtime_kwargs: dict[str, Any] = {"provider": "copilot"} if mcp_servers is not None: runtime_kwargs["mcp_servers"] = mcp_servers + workflow_kwargs: dict[str, Any] = {} + if tools is not None: + workflow_kwargs["tools"] = tools return WorkflowConfig( workflow=WorkflowDef( name="test", @@ -61,6 +65,7 @@ def _build_workflow( agents=agents, parallel=parallel or [], for_each=for_each or [], + **workflow_kwargs, ) @@ -144,6 +149,46 @@ def test_omitted_tools_against_no_passthrough_does_not_error(self, patch_caps: A config = _build_workflow(agents=[AgentDef(name="a", prompt="hi")]) validate_workflow_config(config) # no raise + def test_omitted_tools_inherits_workflow_tools_against_no_passthrough_errors( + self, patch_caps: Any + ) -> None: + """Omitted ``tools:`` + non-empty workflow ``tools:`` + no passthrough. + + An omitted per-agent ``tools:`` inherits the workflow-level list at + runtime; a non-passthrough provider would then refuse it at execute + time with a confusing "declares tools=[...]" error even though the + agent declared none. Catch it at validate time instead. + """ + patch_caps({"copilot": _caps(workflow_tools_passthrough=False)}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi")], + tools=["search", "read_file"], + ) + with pytest.raises(ConfigurationError, match="omits 'tools:' and would inherit"): + validate_workflow_config(config) + + def test_explicit_empty_tools_with_workflow_tools_no_passthrough_passes( + self, patch_caps: Any + ) -> None: + """Explicit ``tools: []`` opts out of inheritance, so it stays valid.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=False)}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi", tools=[])], + tools=["search"], + ) + validate_workflow_config(config) # no raise + + def test_omitted_tools_inherits_workflow_tools_with_passthrough_passes( + self, patch_caps: Any + ) -> None: + """A passthrough provider honors the inherited list, so no error.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=True)}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi")], + tools=["search"], + ) + validate_workflow_config(config) # no raise + class TestReasoningEffortCrossCheck: def test_unsupported_level_errors(self, patch_caps: Any) -> None: diff --git a/tests/test_providers/test_claude_agent_sdk.py b/tests/test_providers/test_claude_agent_sdk.py index 8d5e5429..b45a9d9b 100644 --- a/tests/test_providers/test_claude_agent_sdk.py +++ b/tests/test_providers/test_claude_agent_sdk.py @@ -620,7 +620,7 @@ async def fake_query(**kwargs): with patch("conductor.providers.claude_agent_sdk.query", fake_query): provider = ClaudeAgentSdkProvider() agent = AgentDef(name="my_agent", prompt="hi") - with pytest.raises(ProviderError, match="does not support per-agent workflow tool"): + with pytest.raises(ProviderError, match="does not support workflow tool allowlists"): await provider.execute( agent=agent, context={}, @@ -715,7 +715,7 @@ async def fake_query(**kwargs): with patch("conductor.providers.claude_agent_sdk.query", fake_query): provider = ClaudeAgentSdkProvider() agent = AgentDef(name="my_agent", prompt="hi", tools=["search", "read_file"]) - with pytest.raises(ProviderError, match="does not support per-agent workflow tool"): + with pytest.raises(ProviderError, match="does not support workflow tool allowlists"): await provider.execute( agent=agent, context={}, From 594bf2ba77e3648b6e47b13b0744793b10ba6a99 Mon Sep 17 00:00:00 2001 From: Luke Ellison <12530203+lukeellison@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:16:47 +0100 Subject: [PATCH 03/10] fix(claude-agent-sdk): catch for_each inline tools footgun at validate time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review fixes for the default-tools work on this branch. Per user decision, address findings 1, 2, and 5 from the branch review. validator: the per-agent tools cross-check (explicit-allowlist refusal + the omitted-tools workflow-inheritance footgun) only iterated config.agents, so a for_each group's INLINE agent — which runs at runtime with workflow_tools=config.tools exactly like a top-level agent — slipped past `conductor validate` and only failed mid-iteration with the same confusing runtime error this branch set out to catch early. Extract the two checks into a shared _check_agent_tools helper and run it over for_each inline agents too. Scoped to the tools check; the remaining per-agent capability checks for inline agents are a separate, broader follow-up. (finding 2) claude_agent_sdk: AgentDef always carries `tools`, so the defensive getattr(agent, "tools", None) is now a plain agent.tools, matching how execute() reads its other fields. (finding 5) README: the claude-agent-sdk note claimed workflow-level `tools` and `runtime.mcp_servers` are "ignored". Neither is — mcp_servers is rejected at the factory and a workflow-level `tools:` now errors at validate for any agent that omits `tools:`. Reworded to the actual behavior plus the two correct remediations. (finding 1) tests: TestForEachInlineToolsCrossCheck covers inline omit -> error, inline tools:[] -> pass, inline non-empty list -> error, and inline omit against a passthrough provider -> pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/conductor/config/validator.py | 83 ++++++++++++------- src/conductor/providers/claude_agent_sdk.py | 2 +- .../test_validator_capabilities.py | 71 ++++++++++++++++ 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index cada0bb1..b80b1fd3 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ workflow: Requires the `claude` CLI to be installed and authenticated. Install the SDK: `uv add 'claude-agent-sdk>=0.1.0'` -> **Note:** The `claude-agent-sdk` provider delegates tool and MCP server management to the `claude` CLI. Workflow-level `tools` and `runtime.mcp_servers` fields are ignored — configure these through your Claude Code settings instead. +> **Note:** The `claude-agent-sdk` provider delegates tool and MCP management to the `claude` CLI; workflow-level tool/MCP config is **not** bridged into it. `runtime.mcp_servers` is rejected at the factory, and a workflow-level `tools:` block is rejected at `conductor validate` for any agent that omits `tools:` (it would otherwise inherit a list the CLI can't map). Omit `tools:` to grant the full `claude_code` preset, set an agent's `tools: []` to disable all tools, and configure MCP servers through your Claude Code settings instead. **See also:** [Claude Documentation](docs/providers/claude.md) | [Provider Comparison](docs/providers/comparison.md) | [Migration Guide](docs/providers/migration.md) diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 947c0f67..e3b96f0a 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -1597,6 +1597,38 @@ def _caps_for(name: str) -> ProviderCapabilities | None: cache[name] = None # type: ignore[assignment] return cache.get(name) + def _check_agent_tools(agent: AgentDef, provider_name: str, caps: ProviderCapabilities) -> None: + """Tools-capability cross-check shared by top-level and for_each agents. + + Two failure modes against a non-passthrough provider: + + * Explicit non-empty ``tools:`` — a declared allowlist the provider + cannot honor; silently granting different tools is a security + regression. + * Omitted ``tools:`` + non-empty workflow-level ``tools:`` — the agent + inherits that list at runtime (``resolve_agent_tools`` returns a copy) + and hits the same refusal mid-run with a confusing "declares + tools=[...]" error even though it declared none. An explicit + ``tools: []`` is the "no tools" opt-out and stays valid. + """ + if agent.tools and not caps.workflow_tools_passthrough: + errors.append( + f"Agent '{agent.name}' declares tools={agent.tools!r} but provider " + f"'{provider_name}' does not honor per-agent tool allowlists " + f"(capabilities.workflow_tools_passthrough=False). Silently " + f"granting different tools than declared is a security regression." + ) + elif agent.tools is None and config.tools and not caps.workflow_tools_passthrough: + errors.append( + f"Agent '{agent.name}' omits 'tools:' and would inherit the " + f"workflow-level tools={config.tools!r}, but provider " + f"'{provider_name}' does not honor tool allowlists " + f"(capabilities.workflow_tools_passthrough=False). Remove the " + f"workflow-level 'tools:' so omitting 'tools:' grants the " + f"provider's default tool preset, or set this agent's " + f"'tools: []' to disable all tools." + ) + # ----- Workflow-level: MCP servers ----- # An mcp_servers block applies only to provider-backed agents that # actually resolve to a provider lacking MCP support. If every LLM @@ -1677,35 +1709,10 @@ def _caps_for(name: str) -> ProviderCapabilities | None: f"(capabilities.mcp_tools=False)." ) - # tools allowlist: any explicit non-empty list against a provider - # that doesn't pass through is a security regression risk. An empty - # list ("no tools") is fine — the provider may honor it as - # "no tools" semantics. Only non-empty triggers the security concern. - if agent.tools and not caps.workflow_tools_passthrough: - errors.append( - f"Agent '{agent.name}' declares tools={agent.tools!r} but provider " - f"'{provider_name}' does not honor per-agent tool allowlists " - f"(capabilities.workflow_tools_passthrough=False). Silently " - f"granting different tools than declared is a security regression." - ) - - # Omitted per-agent ``tools:`` inherits the workflow-level ``tools:`` - # list at runtime (``resolve_agent_tools`` returns a copy of it). For a - # non-passthrough provider that inherited list is a non-empty allowlist - # it cannot honor, so the agent fails at execute time with a confusing - # "declares tools=[...]" error even though it declared none. Catch it at - # validate time with an accurate message. (An explicit ``tools: []`` is - # the "no tools" opt-out and stays valid — only ``None`` inherits.) - elif agent.tools is None and config.tools and not caps.workflow_tools_passthrough: - errors.append( - f"Agent '{agent.name}' omits 'tools:' and would inherit the " - f"workflow-level tools={config.tools!r}, but provider " - f"'{provider_name}' does not honor tool allowlists " - f"(capabilities.workflow_tools_passthrough=False). Remove the " - f"workflow-level 'tools:' so omitting 'tools:' grants the " - f"provider's default tool preset, or set this agent's " - f"'tools: []' to disable all tools." - ) + # tools allowlist (explicit non-empty list) and the omitted-tools + # inheritance footgun against non-passthrough providers. Shared with + # the for_each inline-agent pass below. + _check_agent_tools(agent, provider_name, caps) # reasoning.effort: validate per-agent override OR workflow-wide # default against the supported levels tuple. Per-agent override @@ -1761,6 +1768,24 @@ def _caps_for(name: str) -> ProviderCapabilities | None: f"(capabilities.max_session_seconds=False)." ) + # ----- For-each inline agents: tools capability cross-check ----- + # A for_each group carries an INLINE ``AgentDef`` (not in ``config.agents``) + # that runs with ``workflow_tools=config.tools``, exactly like a top-level + # agent. The per-agent loop above skips it, so re-run the tools check here — + # otherwise an inline agent that omits ``tools:`` against a non-passthrough + # provider slips past ``validate`` and fails mid-iteration with the same + # confusing runtime error. (The other per-agent capability checks for inline + # agents are a separate, broader follow-up; this closes the tools footgun.) + for fe in config.for_each: + inline_agent = fe.agent + if not _is_llm_agent(inline_agent): + continue + inline_provider = _resolved_provider_name(inline_agent, default_provider) + inline_caps = _caps_for(inline_provider) + if inline_caps is None: + continue # error already recorded by _caps_for + _check_agent_tools(inline_agent, inline_provider, inline_caps) + # ----- Concurrency safety in parallel / for_each groups ----- agent_by_name = {a.name: a for a in config.agents} for pg in config.parallel: diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index 3fd8214b..094d8cdd 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -488,7 +488,7 @@ def _resolve_tool_config( # The executor passes [] for BOTH "omitted (no workflow tools to # inherit)" and explicit "tools: []". Disambiguate via the raw # per-agent field, which the executor's resolution erased. - if getattr(agent, "tools", None) is None: + if agent.tools is None: # Omitted -> default claude_code preset (filesystem/bash/web). return _DEFAULT_TOOL_PRESET, "bypassPermissions" # Explicit `tools: []` -> no tools, no permission bypass. diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index 6cb574bd..304e0725 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -190,6 +190,77 @@ def test_omitted_tools_inherits_workflow_tools_with_passthrough_passes( validate_workflow_config(config) # no raise +class TestForEachInlineToolsCrossCheck: + """The tools cross-check must also cover a for_each group's INLINE agent. + + A ``for_each`` group carries an inline ``AgentDef`` that is NOT in + ``config.agents`` but runs at runtime with ``workflow_tools=config.tools``, + exactly like a top-level agent. Without an explicit pass it would slip past + ``validate`` and fail mid-iteration with the same confusing error. + """ + + def _for_each_config( + self, + *, + inline: AgentDef, + tools: list[str] | None = None, + ) -> WorkflowConfig: + # The entry agent opts out with ``tools: []`` so only the inline agent + # can trip the check — isolating the assertion to the for_each path. + return _build_workflow( + agents=[AgentDef(name="entry", prompt="hi", tools=[])], + tools=tools, + for_each=[ + ForEachDef( + name="loop", + type="for_each", + source="entry.output.items", + **{"as": "item"}, + agent=inline, + ) + ], + ) + + def test_inline_omitted_tools_inherits_workflow_tools_errors(self, patch_caps: Any) -> None: + """Inline agent omits ``tools:`` + non-empty workflow ``tools:`` -> error.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=False)}) + config = self._for_each_config( + inline=AgentDef(name="inner", prompt="{{ item }}"), + tools=["search"], + ) + with pytest.raises( + ConfigurationError, match="Agent 'inner' omits 'tools:' and would inherit" + ): + validate_workflow_config(config) + + def test_inline_explicit_empty_tools_passes(self, patch_caps: Any) -> None: + """Inline ``tools: []`` opts out of inheritance, so it stays valid.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=False)}) + config = self._for_each_config( + inline=AgentDef(name="inner", prompt="{{ item }}", tools=[]), + tools=["search"], + ) + validate_workflow_config(config) # no raise + + def test_inline_explicit_nonempty_tools_errors(self, patch_caps: Any) -> None: + """An explicit non-empty inline allowlist against a non-passthrough provider errors.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=False)}) + config = self._for_each_config( + inline=AgentDef(name="inner", prompt="{{ item }}", tools=["search"]), + ) + with pytest.raises(ConfigurationError, match="Agent 'inner' declares tools="): + validate_workflow_config(config) + + def test_inline_omitted_tools_with_passthrough_passes(self, patch_caps: Any) -> None: + """A passthrough provider honors the inherited list, so no error.""" + patch_caps({"copilot": _caps(workflow_tools_passthrough=True)}) + config = self._for_each_config( + inline=AgentDef(name="inner", prompt="{{ item }}"), + tools=["search"], + ) + validate_workflow_config(config) # no raise + + class TestReasoningEffortCrossCheck: def test_unsupported_level_errors(self, patch_caps: Any) -> None: patch_caps({"copilot": _caps(reasoning_effort=("low", "medium"))}) From 11c574e78dfe3faf29fb86fed0392bfd21b8b7df Mon Sep 17 00:00:00 2001 From: Luke Ellison Date: Fri, 26 Jun 2026 10:36:16 +0300 Subject: [PATCH 04/10] Update src/conductor/providers/claude_agent_sdk.py comment to be more accurate Co-authored-by: Jason Robert --- src/conductor/providers/claude_agent_sdk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index 094d8cdd..a9482ea5 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -451,8 +451,8 @@ def _resolve_tool_config( :func:`conductor.executor.agent.resolve_agent_tools`. That function erases the distinction between an omitted ``tools:`` and an explicit ``tools: []``: both arrive here as an empty list whenever the - workflow declares no ``runtime`` MCP tools (the common case for this - provider, which rejects ``runtime.mcp_servers`` at the factory). We + workflow declares no workflow-level ``tools:`` (``config.tools`` is + empty; a non-empty list makes an omitted agent resolve non-empty). We therefore consult the RAW ``agent.tools`` field — the only place the omitted-vs-explicit signal survives — to pick the default. From 6f4e5407b6e42660c2e06396214a165baf81ab67 Mon Sep 17 00:00:00 2001 From: Luke Ellison Date: Fri, 26 Jun 2026 10:36:46 +0300 Subject: [PATCH 05/10] Update examples/claude-agent-sdk-repo-qa.yaml comment to be more accurate Co-authored-by: Jason Robert --- examples/claude-agent-sdk-repo-qa.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/claude-agent-sdk-repo-qa.yaml b/examples/claude-agent-sdk-repo-qa.yaml index 6b86fa1a..0e43d590 100644 --- a/examples/claude-agent-sdk-repo-qa.yaml +++ b/examples/claude-agent-sdk-repo-qa.yaml @@ -7,7 +7,7 @@ # WHY THIS EXAMPLE EXISTS (the bug it guards against): # Before the fix, an agent that omitted `tools:` received NO tools and could # not read the file: the executor's resolve_agent_tools() turned the omitted -# `tools:` into an empty list (the workflow had no `runtime` MCP tools to +# `tools:` into an empty list (this workflow declares no workflow-level `tools:` to # inherit), and the provider could not tell that empty list apart from an # explicit `tools: []`, so it disabled every tool. The agent came up with # ZERO filesystem tools and failed to read the file. From 48b3b0223ca136d18b6931ec24d0669e86ebe0c2 Mon Sep 17 00:00:00 2001 From: Luke Ellison Date: Fri, 26 Jun 2026 10:37:36 +0300 Subject: [PATCH 06/10] Update comment to match changes to error string Co-authored-by: Jason Robert --- src/conductor/config/validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index e3b96f0a..392ff6d8 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -1607,8 +1607,8 @@ def _check_agent_tools(agent: AgentDef, provider_name: str, caps: ProviderCapabi regression. * Omitted ``tools:`` + non-empty workflow-level ``tools:`` — the agent inherits that list at runtime (``resolve_agent_tools`` returns a copy) - and hits the same refusal mid-run with a confusing "declares - tools=[...]" error even though it declared none. An explicit + and hits the same refusal mid-run (now a ``resolves to tools=[...]`` + ``ProviderError``) rather than failing fast at validate. An explicit ``tools: []`` is the "no tools" opt-out and stays valid. """ if agent.tools and not caps.workflow_tools_passthrough: From e3c20dc1fb332b77fd695e9afa4150aee19e26a6 Mon Sep 17 00:00:00 2001 From: Luke Ellison Date: Fri, 26 Jun 2026 10:38:40 +0300 Subject: [PATCH 07/10] Update src/conductor/providers/claude_agent_sdk.py comment to better describe what actually happens for omitted tools Co-authored-by: Jason Robert --- src/conductor/providers/claude_agent_sdk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index a9482ea5..61f7a41c 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -87,8 +87,8 @@ def _build_output_format(output: dict[str, OutputField]) -> dict[str, Any]: # mirrors the SDK's `claude_code` preset (filesystem, bash, web, etc.) — i.e. # the same behavior the user gets when running the `claude` CLI directly. It is # selected from the RAW ``agent.tools is None`` signal, NOT from the executor's -# resolved list: the executor erases the "omitted" distinction by returning an -# empty list (the workflow tools copy) for an agent that declares no `tools:`. +# resolved list: for an agent that declares no `tools:`, the executor returns the +# workflow-tools copy, which is empty only when the workflow declares no `tools:`. _DEFAULT_TOOL_PRESET: dict[str, str] = {"type": "preset", "preset": "claude_code"} # Display-only previews for the verbose CLI pretty-printer (NOT surfaced From 51485e7aed8184ab188e948479c8983c6094140d Mon Sep 17 00:00:00 2001 From: Luke Ellison <12530203+lukeellison@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:43:00 +0300 Subject: [PATCH 08/10] ci: install claude-agent-sdk extra so provider tests run in CI Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95360a67..61b3b37c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,10 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --group dev + # Include the claude-agent-sdk extra so the provider's regression tests + # (gated by `pytest.importorskip("claude_agent_sdk")`) actually run in + # CI instead of silently skipping. + run: uv sync --group dev --extra claude-agent-sdk - name: Remove bundled Copilot CLI binary run: | From c00fb9e3596df7093b6fdb7a39ff36f5b7089816 Mon Sep 17 00:00:00 2001 From: Luke Ellison <12530203+lukeellison@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:57:38 +0300 Subject: [PATCH 09/10] chore: link inline-agent capability follow-up to #270 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/conductor/config/validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 392ff6d8..512fcfd9 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -1774,8 +1774,8 @@ def _check_agent_tools(agent: AgentDef, provider_name: str, caps: ProviderCapabi # agent. The per-agent loop above skips it, so re-run the tools check here — # otherwise an inline agent that omits ``tools:`` against a non-passthrough # provider slips past ``validate`` and fails mid-iteration with the same - # confusing runtime error. (The other per-agent capability checks for inline - # agents are a separate, broader follow-up; this closes the tools footgun.) + # confusing runtime error. (Extending the remaining per-agent capability + # checks to inline agents is tracked in #270.) for fe in config.for_each: inline_agent = fe.agent if not _is_llm_agent(inline_agent): From 84632f8ddc363a3e31b76697acb3d0e64c2a57e0 Mon Sep 17 00:00:00 2001 From: jrob5756 Date: Fri, 26 Jun 2026 16:34:33 -0400 Subject: [PATCH 10/10] test(claude-agent-sdk): assert suggestion + cover inherited-tools path Addresses review feedback on the reworded tool-refusal error: - test_explicit_non_empty_tools_still_raises now captures the exception and asserts the (also-reworded) `suggestion`, locking in the "set 'tools: []'" escape hatch alongside the message. - Adds test_inherited_workflow_tools_raise_with_inheritance_wording to cover the inheritance path (agent.tools is None + a non-empty resolved list), asserting the new "inherited from the workflow-level" wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_providers/test_claude_agent_sdk.py | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/test_providers/test_claude_agent_sdk.py b/tests/test_providers/test_claude_agent_sdk.py index b45a9d9b..563194b0 100644 --- a/tests/test_providers/test_claude_agent_sdk.py +++ b/tests/test_providers/test_claude_agent_sdk.py @@ -707,7 +707,12 @@ async def fake_query(**kwargs): @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) async def test_explicit_non_empty_tools_still_raises(self) -> None: - """An explicit non-empty per-agent allowlist is still refused loudly.""" + """An explicit non-empty per-agent allowlist is still refused loudly. + + Captures the exception so both the message AND the ``suggestion`` (also + reworded by this fix) are pinned — a regression that drops the + "set 'tools: []'" escape hatch from the suggestion is caught here. + """ async def fake_query(**kwargs): yield _result(result="done") @@ -715,7 +720,41 @@ async def fake_query(**kwargs): with patch("conductor.providers.claude_agent_sdk.query", fake_query): provider = ClaudeAgentSdkProvider() agent = AgentDef(name="my_agent", prompt="hi", tools=["search", "read_file"]) - with pytest.raises(ProviderError, match="does not support workflow tool allowlists"): + with pytest.raises(ProviderError) as exc: + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + tools=["search", "read_file"], + ) + + assert "does not support workflow tool allowlists" in str(exc.value) + assert exc.value.suggestion is not None + assert "tools: []" in exc.value.suggestion + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_inherited_workflow_tools_raise_with_inheritance_wording(self) -> None: + """An omitted ``tools:`` that inherits a non-empty workflow-level list. + + This is the inheritance path: ``agent.tools is None`` (omitted) but the + executor resolved a non-empty list from the workflow-level ``tools:`` and + handed it to the provider. The refusal must name the workflow-level + inheritance — not just "declared on the agent" — so the user knows where + the list came from. This guards the new "inherited from the + workflow-level" wording the fix introduced. + """ + + async def fake_query(**kwargs): + yield _result(result="done") + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + # Omitted per-agent tools (None), but the executor resolved a + # non-empty list inherited from the workflow-level `tools:`. + agent = AgentDef(name="inheritor", prompt="hi") + assert agent.tools is None + with pytest.raises(ProviderError) as exc: await provider.execute( agent=agent, context={}, @@ -723,6 +762,10 @@ async def fake_query(**kwargs): tools=["search", "read_file"], ) + assert "inherited from the workflow-level" in str(exc.value) + assert exc.value.suggestion is not None + assert "tools: []" in exc.value.suggestion + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) async def test_executor_to_provider_end_to_end_grants_preset(self) -> None: """End-to-end through AgentExecutor: an omitted ``tools:`` reaches the