Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion docs/providers/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions examples/claude-agent-sdk-repo-qa.yaml
Original file line number Diff line number Diff line change
@@ -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 (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.
#
# 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 }}"
65 changes: 54 additions & 11 deletions src/conductor/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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:
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
Expand Down Expand Up @@ -1677,17 +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."
)
# 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
Expand Down Expand Up @@ -1743,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. (Extending the remaining per-agent capability
# checks to inline agents is tracked in #270.)
for fe in config.for_each:
Comment thread
lukeellison marked this conversation as resolved.
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:
Expand Down
60 changes: 40 additions & 20 deletions src/conductor/providers/claude_agent_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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
Expand Down Expand Up @@ -444,24 +447,35 @@ 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 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.

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
security regression; silently passing it through could grant
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
Expand All @@ -470,18 +484,24 @@ 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 agent.tools 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 "
"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."
),
)

Expand Down
Loading
Loading