diff --git a/AGENTS.md b/AGENTS.md index b9393001..84c5c141 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ make validate-examples # validate all examples - `base.py` - `AgentProvider` ABC defining `execute()`, `validate_connection()`, `close()` - `copilot.py` - GitHub Copilot SDK implementation - `claude.py` - Anthropic Claude API implementation + - `claude_agent_sdk.py` - Claude Agent SDK implementation (uses `claude-agent-sdk` package) - `factory.py` - Provider instantiation - **gates/**: Human-in-the-loop support @@ -183,7 +184,7 @@ Use `pytest.mark.performance` for performance tests (exclude with `-m "not perfo ### Provider Parity -All providers (`copilot.py`, `claude.py`) must maintain feature parity. Any change to one provider's behavior, contract, or capabilities must be applied to all providers. This includes: +All providers must maintain feature parity where applicable. Any change to one provider's behavior, contract, or capabilities must be applied to all providers. This includes: - **Event callbacks**: Same event types emitted at the same semantic points - `agent_turn_start` with `{"turn": "awaiting_model"}` — immediately before each API call @@ -199,6 +200,13 @@ All providers (`copilot.py`, `claude.py`) must maintain feature parity. Any chan When modifying any provider, check all other providers for the same change. The dashboard, JSONL logger, console subscriber, and workflow engine all depend on consistent behavior across providers. +#### `claude_agent_sdk.py` parity notes + +The Claude Agent SDK provider (`claude_agent_sdk.py`) delegates the agentic loop to the `claude` CLI via the `claude-agent-sdk` package. This achieves **event and output parity** but the following are managed by the SDK rather than Conductor: + +- **Retry and error handling**: The SDK handles retries, backoff, and parse recovery internally. The provider wraps SDK errors in `ProviderError` but does not implement its own retry logic. +- **Tool execution**: Tools and MCP servers are managed by the `claude` CLI's own configuration. Workflow-level `tools` and `runtime.mcp_servers` fields are ignored. +- **Runtime config**: `temperature`, `max_tokens`, and `timeout` are not configurable per-workflow — they are controlled by the CLI. ### Run / Resume Parity The `run` and `resume` commands must accept the same flags wherever a flag is meaningful for a resumed run. When adding a new flag to `run`, add it to `resume` too unless there's a specific reason it cannot apply. diff --git a/README.md b/README.md index e0979b22..1280c8a1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Conductor makes multi-agent workflows — code review pipelines, research-then-s ## Features - **YAML-based workflows** - Define multi-agent workflows in readable YAML -- **Multiple providers** - GitHub Copilot or Anthropic Claude with seamless switching +- **Multiple providers** - GitHub Copilot, Anthropic Claude, or Claude Agent SDK with seamless switching - **Parallel execution** - Run agents concurrently (static groups or dynamic for-each) - **Sub-workflow composition** - Reusable sub-workflows with templated `input_mapping`, usable inside `for_each` groups for dynamic fan-out - **Script steps** - Run shell commands and route on exit code or parsed JSON stdout @@ -211,13 +211,13 @@ conductor stop Conductor supports multiple AI providers. Choose based on your needs: -| Feature | Copilot | Claude | -|---------|---------|--------| -| **Pricing** | Subscription ($10-39/mo) | Pay-per-token | -| **Context Window** | 8K-128K tokens | 200K tokens | -| **Tool Support (MCP)** | Yes | Planned | -| **Streaming** | Yes | Planned | -| **Best For** | Heavy usage, tools | Large context, pay-per-use | +| Feature | Copilot | Claude | Claude Agent SDK | +|---------|---------|--------|------------------| +| **Pricing** | Subscription ($10-39/mo) | Pay-per-token | Via Claude Code CLI | +| **Context Window** | 8K-128K tokens | 200K tokens | 200K tokens | +| **Tool Support (MCP)** | Yes | Planned | Yes (built-in) | +| **Streaming** | Yes | Planned | Yes | +| **Best For** | Heavy usage, tools | Large context, pay-per-use | Full Claude Code toolset | ### Using Claude @@ -230,6 +230,19 @@ workflow: Set your API key: `export ANTHROPIC_API_KEY=sk-ant-...` +### Using Claude Agent SDK + +```yaml +workflow: + runtime: + provider: claude-agent-sdk + default_model: claude-sonnet-4-6 +``` + +Requires the `claude` CLI to be installed and authenticated. Install the SDK: `uv add claude-agent-sdk` + +> **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. + **See also:** [Claude Documentation](docs/providers/claude.md) | [Provider Comparison](docs/providers/comparison.md) | [Migration Guide](docs/providers/migration.md) ### Using a Local / Custom LLM Endpoint (Ollama, vLLM, Azure OpenAI, ...) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 44f0c527..e441c586 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -26,7 +26,7 @@ conductor run [OPTIONS] | `--metadata KEY=VALUE` | `-m` | Workflow metadata (repeatable). Merged on top of YAML `metadata:` and surfaced in the `workflow_started` event. | | `--workspace-instructions` | | Auto-discover convention files (`AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, and `.github/instructions/**/*.instructions.md`) by walking from CWD up to the git root. Concatenated and prepended to every agent's prompt. See [Workspace Instructions](#workspace-instructions) below for details on the `.github/instructions/` directory convention. | | `--instructions PATH` | | Explicit path to an instructions file (repeatable). Combines with auto-discovered files when both flags are used. | -| `--provider PROVIDER` | `-p` | Override provider (copilot, claude) | +| `--provider PROVIDER` | `-p` | Override provider (copilot, claude, claude-agent-sdk) | | `--dry-run` | | Show execution plan without running | | `--skip-gates` | | Auto-select first option at human gates | | `--quiet` | `-q` | Minimal output (agent lifecycle and routing only) | diff --git a/docs/configuration.md b/docs/configuration.md index 509255ba..6a3919d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ The `runtime` section of your workflow defines provider settings and global defa ```yaml workflow: runtime: - provider: copilot # or 'claude' + provider: copilot # or 'claude' or 'claude-agent-sdk' default_model: gpt-5.2 temperature: 0.7 max_tokens: 4096 diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md index f335c150..7aa74312 100644 --- a/docs/providers/comparison.md +++ b/docs/providers/comparison.md @@ -1,22 +1,23 @@ -# Provider Comparison: Copilot vs Claude +# Provider Comparison: Copilot vs Claude vs Claude Agent SDK -This guide helps you choose between GitHub Copilot and Anthropic Claude providers for your workflows. +This guide helps you choose between GitHub Copilot, Anthropic Claude, and Claude Agent SDK providers for your workflows. ## Quick Comparison -| Feature | Copilot | Claude | Winner | -|---------|---------|--------|--------| -| **Context Window** | per-model (SDK-reported) | per-model (SDK-reported) | Tie | -| **Pricing Model** | Subscription ($10-39/mo) | Pay-per-token | Depends | -| **Setup** | GitHub auth | API key | Copilot (easier) | -| **Model Selection** | GPT-5.2, o1 | Haiku, Sonnet, Opus | Tie | -| **Streaming** | Yes | No (Phase 1) | Copilot | -| **Tool Support** | Yes (MCP, all types) | Yes (MCP, stdio only) | Copilot | -| **Reasoning / Extended Thinking** | Yes (`reasoning_effort` on session) | Yes (extended `thinking` budget) | Tie | -| **Speed** | Fast | Fast | Tie | -| **Output Quality** | Excellent | Excellent | Tie | -| **Cost Predictability** | High (flat rate) | Variable (usage-based) | Copilot | -| **Multi-provider** | No | Yes (via Conductor) | Claude | +| Feature | Copilot | Claude | Claude Agent SDK | Winner | +|---------|---------|--------|------------------|--------| +| **Context Window** | per-model (SDK-reported) | per-model (SDK-reported) | 200K | Tie | +| **Pricing Model** | Subscription ($10-39/mo) | Pay-per-token | Via Claude Code CLI | Depends | +| **Setup** | GitHub auth | API key | `claude` CLI auth | Copilot (easier) | +| **Model Selection** | GPT-5.2, o1 | Haiku, Sonnet, Opus | Haiku, Sonnet, Opus | Tie | +| **Streaming** | Yes | No (Phase 1) | Yes | Copilot / Claude Agent SDK | +| **Tool Support** | Yes (MCP, all types) | Yes (MCP, stdio only) | Yes (built-in, CLI-managed) | Copilot | +| **Reasoning / Extended Thinking** | Yes (`reasoning_effort` on session) | Yes (extended `thinking` budget) | Inherits from CLI config | Tie | +| **Speed** | Fast | Fast | Fast | Tie | +| **Output Quality** | Excellent | Excellent | Excellent | Tie | +| **Cost Predictability** | High (flat rate) | Variable (usage-based) | Variable | Copilot | +| **Multi-provider** | No | Yes (via Conductor) | No | Claude | +| **Agentic Loop** | SDK-managed | Manual (provider code) | SDK-managed (delegated to CLI) | Depends | ## When to Use Copilot @@ -112,6 +113,51 @@ agents: prompt: "Analyze the following document ({{ document | length }} chars)" ``` +## When to Use Claude Agent SDK + +### ✅ Choose Claude Agent SDK if: + +1. **You want built-in tool support with Claude models** + - WebSearch, WebFetch, Bash, file operations out of the box + - No MCP server configuration needed for common tools + - Full Claude Code toolset available + +2. **You already use the `claude` CLI** + - Authentication handled by the CLI + - No separate API key management + - Settings inherited from your Claude Code environment + +3. **You want the SDK to manage the agentic loop** + - Retry logic, tool execution, and structured output handled by the SDK + - Less provider code to maintain + - Native interrupt support + +4. **You need streaming with Claude models** + - Real-time message streaming (unlike the raw Claude provider) + - Typed message objects for each event + +### Important: Tools and MCP Servers + +The `claude-agent-sdk` provider delegates tool and MCP server management entirely to the `claude` CLI. This means: + +- Workflow-level `tools` and `runtime.mcp_servers` fields are **ignored** — configure tools and MCP servers through your Claude Code settings instead +- The full Claude Code toolset (WebSearch, Bash, Read, Write, etc.) is available automatically +- `temperature`, `max_tokens`, and `timeout` are also managed by the CLI and not configurable per-workflow + +### Example Claude Agent SDK Workflow + +```yaml +workflow: + name: sdk-workflow + runtime: + provider: claude-agent-sdk + default_model: claude-sonnet-4-6 + +agents: + - name: researcher + prompt: "Research {{ topic }} using web search" +``` + ## Cost Comparison ### Scenario 1: Light Usage (10 hours/month) @@ -326,6 +372,8 @@ Use this matrix to decide: | Process long documents | **Claude** | | Complex reasoning tasks | **Claude** (Opus) | | Simple high-volume tasks | **Claude** (Haiku 4.5) | +| Already use `claude` CLI | **Claude Agent SDK** | +| Want streaming with Claude | **Claude Agent SDK** | ## Multi-Provider Strategy @@ -375,4 +423,11 @@ workflow: - ✅ Long document processing - ✅ Cost optimization (Haiku) -**Bottom line**: Both are excellent. Choose based on your usage patterns, budget, and feature requirements. Conductor makes it easy to switch between them or use both strategically. +**Choose Claude Agent SDK** for: +- ✅ Built-in tools (WebSearch, Bash, etc.) +- ✅ Streaming with Claude models +- ✅ SDK-managed agentic loop +- ✅ Existing `claude` CLI users +- ✅ No API key management + +**Bottom line**: All three are excellent. Choose based on your usage patterns, budget, and feature requirements. Conductor makes it easy to switch between them or use all three strategically. diff --git a/examples/test-claude-agent-sdk.yaml b/examples/test-claude-agent-sdk.yaml new file mode 100644 index 00000000..4a934df5 --- /dev/null +++ b/examples/test-claude-agent-sdk.yaml @@ -0,0 +1,53 @@ +# Simple Question-Answering Workflow using Claude Agent SDK +# +# This example demonstrates a basic linear workflow with a single agent +# that answers questions. It shows: +# - Basic workflow structure +# - Input parameters +# - Output schema validation +# - Simple routing to $end +# +# Usage: +# conductor run examples/test-claude-agent-sdk.yaml --input question="What is Microsoft Conductor?" +# +# Note: Requires Claude Code CLI installed and configured. Adjust the runtime model as needed based on your Claude Code setup. + +workflow: + name: simple-qa + description: A simple question-answering workflow with a single agent + version: "1.0.0" + entry_point: answerer + + runtime: + provider: claude-agent-sdk + default_model: claude-haiku-4-5@20251001 # Vertex AI naming; it depends on the backedend your Claude Code setup uses. Adjust as needed. + + input: + question: + type: string + required: true + description: The question to answer + +agents: + - name: answerer + description: Answers the user's question clearly and concisely + prompt: | + You are a helpful assistant and researcher. Please answer the following question + clearly and concisely: + + Question: {{ workflow.input.question }} + + Provide a direct answer without unnecessary preamble. + output: + answer: + type: string + description: The answer to the question + confidence: + type: string + description: Confidence level (high, medium, low) + routes: + - to: $end + +output: + answer: "{{ answerer.output.answer }}" + confidence: "{{ answerer.output.confidence }}" \ No newline at end of file diff --git a/plugins/conductor/skills/conductor/SKILL.md b/plugins/conductor/skills/conductor/SKILL.md index 0aa2f07d..db30c5ee 100644 --- a/plugins/conductor/skills/conductor/SKILL.md +++ b/plugins/conductor/skills/conductor/SKILL.md @@ -5,7 +5,7 @@ description: Validate, run, and execute workflows; creating new workflows when e # Conductor -CLI tool for defining and running multi-agent workflows with the GitHub Copilot SDK or Anthropic Claude. +CLI tool for defining and running multi-agent workflows with the GitHub Copilot SDK, Anthropic Claude, or Claude Agent SDK. > **DO NOT create new workflow files unless the user explicitly asks you to create one.** Default to running, validating, or debugging existing workflows. If the user's request is ambiguous, assume they want to run or modify an existing workflow rather than create a new one. diff --git a/plugins/conductor/skills/conductor/references/yaml-schema.md b/plugins/conductor/skills/conductor/references/yaml-schema.md index f107d508..dee163cb 100644 --- a/plugins/conductor/skills/conductor/references/yaml-schema.md +++ b/plugins/conductor/skills/conductor/references/yaml-schema.md @@ -6,7 +6,7 @@ Complete reference for all YAML configuration options. Derived from the Pydantic ```yaml workflow: WorkflowDef # Required: workflow configuration -tools: [string] # Optional: workflow-level tool names +tools: [string] # Optional: workflow-level tool names (ignored by claude-agent-sdk — uses CLI config) agents: [AgentDef] # Required: agent definitions parallel: [ParallelGroup] # Optional: static parallel groups for_each: [ForEachDef] # Optional: dynamic parallel groups @@ -27,16 +27,16 @@ workflow: # Runtime configuration runtime: - provider: string | object # "copilot" (default), "claude", or "openai-agents" + provider: string | object # "copilot" (default), "claude", "claude-agent-sdk", or "openai-agents" # — or a ProviderSettings object (see below) default_model: string # Default model for all agents - temperature: float # 0.0-1.0, controls randomness (optional) - max_tokens: integer # Max OUTPUT tokens per response, 1-200000 (optional) - timeout: float # Per-request timeout in seconds (optional, default: 600) + temperature: float # 0.0-1.0, controls randomness (optional, copilot/claude only) + max_tokens: integer # Max OUTPUT tokens per response, 1-200000 (optional, copilot/claude only) + timeout: float # Per-request timeout in seconds (optional, default: 600, copilot/claude only) max_agent_iterations: integer # Max tool-use roundtrips per agent (1-500, optional) max_session_seconds: float # Wall-clock timeout per agent session in seconds (optional) default_reasoning_effort: string # Workflow-wide reasoning/thinking effort: low, medium, high, xhigh (optional) - mcp_servers: # MCP server configurations + mcp_servers: # MCP server configurations (ignored by claude-agent-sdk — uses CLI config) : type: string # "stdio" (default), "http", or "sse" command: string # Command to run (required for stdio) @@ -107,7 +107,7 @@ agents: type: string # "agent" (default), "human_gate", "script", "workflow", "wait", or "terminate" description: string # What this agent does model: string # Override default_model - provider: string # Per-agent provider override ("copilot" or "claude") + provider: string # Per-agent provider override ("copilot", "claude", or "claude-agent-sdk") # Input specification (for explicit context mode) input: diff --git a/pyproject.toml b/pyproject.toml index c0779fb1..7b8f87bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,11 @@ dependencies = [ "packaging>=21.0", ] +[project.optional-dependencies] +claude-agent-sdk = [ + "claude-agent-sdk>=0.1.64", +] + [project.urls] Homepage = "https://github.com/microsoft/conductor" Documentation = "https://github.com/microsoft/conductor#readme" diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 859375cc..a2ea6300 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -505,7 +505,7 @@ class AgentDef(BaseModel): ) = None """Agent type. Defaults to 'agent' if not specified.""" - provider: Literal["copilot", "claude"] | None = None + provider: Literal["copilot", "claude", "claude-agent-sdk"] | None = None """Provider override for this agent. If None (default), the agent uses the workflow.runtime.provider. @@ -1319,7 +1319,7 @@ class ProviderSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - name: Literal["copilot", "openai-agents", "claude"] = "copilot" + name: Literal["copilot", "openai-agents", "claude", "claude-agent-sdk"] = "copilot" """SDK provider to use for agent execution.""" type: Literal["openai", "azure", "anthropic"] | None = None diff --git a/src/conductor/providers/__init__.py b/src/conductor/providers/__init__.py index 9794e9dc..92bc5cb8 100644 --- a/src/conductor/providers/__init__.py +++ b/src/conductor/providers/__init__.py @@ -12,12 +12,14 @@ if TYPE_CHECKING: from conductor.providers.claude import ClaudeProvider + from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider from conductor.providers.copilot import CopilotProvider from conductor.providers.factory import create_provider __all__ = [ "AgentOutput", "AgentProvider", + "ClaudeAgentSdkProvider", "ClaudeProvider", "CopilotProvider", "create_provider", @@ -29,6 +31,10 @@ def __getattr__(name: str) -> Any: from conductor.providers.claude import ClaudeProvider return ClaudeProvider + if name == "ClaudeAgentSdkProvider": + from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider + + return ClaudeAgentSdkProvider if name == "CopilotProvider": from conductor.providers.copilot import CopilotProvider diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py new file mode 100644 index 00000000..77f725cd --- /dev/null +++ b/src/conductor/providers/claude_agent_sdk.py @@ -0,0 +1,419 @@ +"""Claude Agent SDK provider — delegates agentic loop to the claude-agent-sdk package.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import TYPE_CHECKING, Any, cast + +from conductor.exceptions import ProviderError +from conductor.providers.base import AgentOutput, AgentProvider, EventCallback + +if TYPE_CHECKING: + from conductor.config.schema import AgentDef, OutputField + +try: + from claude_agent_sdk import ClaudeAgentOptions, query # ty: ignore[unresolved-import] + + CLAUDE_AGENT_SDK_AVAILABLE = True +except ImportError: + CLAUDE_AGENT_SDK_AVAILABLE = False + query: Any = None + ClaudeAgentOptions: Any = None + +logger = logging.getLogger(__name__) + + +def _build_field_schema(field: OutputField, depth: int = 0) -> dict[str, Any]: + if depth > 10: + raise ProviderError("Output schema nesting exceeds 10 levels") + + schema: dict[str, Any] = {"type": field.type} + if field.description: + schema["description"] = field.description + if field.type == "object" and field.properties: + schema["properties"] = _build_properties(field.properties, depth + 1) + schema["required"] = list(field.properties.keys()) + if field.type == "array" and field.items: + schema["items"] = _build_field_schema(field.items, depth + 1) + return schema + + +def _build_properties(fields: dict[str, OutputField], depth: int = 0) -> dict[str, Any]: + return {name: _build_field_schema(field, depth) for name, field in fields.items()} + + +def _build_output_format(output: dict[str, OutputField]) -> dict[str, Any]: + return { + "type": "json_schema", + "schema": { + "type": "object", + "properties": _build_properties(output), + "required": list(output.keys()), + }, + } + + +class ClaudeAgentSdkProvider(AgentProvider): + """Claude Agent SDK provider. + + Uses the claude-agent-sdk package (async iterator API) to execute agents. + The SDK manages the agentic loop, tool execution, and structured output + extraction internally. + """ + + def __init__( + self, + model: str | None = None, + max_turns: int | None = None, + max_session_seconds: float | None = None, + ) -> None: + if not CLAUDE_AGENT_SDK_AVAILABLE: + raise ProviderError( + "Claude Agent SDK not installed", + suggestion="Install with: uv add 'claude-agent-sdk>=0.1.0'", + ) + + self._default_model = model or "claude-sonnet-4-6" + self._default_max_turns = max_turns if max_turns is not None else 50 + self._max_session_seconds = max_session_seconds + + async def execute( + self, + agent: AgentDef, + context: dict[str, Any], + rendered_prompt: str, + tools: list[str] | None = None, + interrupt_signal: asyncio.Event | None = None, + event_callback: EventCallback | None = None, + ) -> AgentOutput: + if query is None or ClaudeAgentOptions is None: + raise ProviderError("Claude Agent SDK not available") + + from conductor.cli.app import is_full, is_verbose + + verbose_enabled = is_verbose() + full_enabled = is_full() + + model = agent.model or self._default_model + max_turns = ( + agent.max_agent_iterations + if agent.max_agent_iterations is not None + else self._default_max_turns + ) + + options = ClaudeAgentOptions( + model=model, + system_prompt=agent.system_prompt, + output_format=_build_output_format(agent.output) if agent.output else None, + max_turns=max_turns, + permission_mode="bypassPermissions", + tools={"type": "preset", "preset": "claude_code"}, + ) + + content_parts: list[str] = [] + structured_output: Any = None + total_input_tokens = 0 + total_output_tokens = 0 + result_model: str | None = model + turn_count = 0 + # Track pending tool_use IDs so we can pair them with ToolResultBlocks + pending_tools: dict[str, str] = {} + + try: + async for message in query(prompt=rendered_prompt, options=options): + if interrupt_signal is not None and interrupt_signal.is_set(): + return self._build_output( + content_parts, + structured_output, + agent, + result_model, + total_input_tokens, + total_output_tokens, + partial=True, + ) + + msg_type = type(message).__name__ + + if msg_type == "AssistantMessage": + msg = cast(Any, message) + blocks = getattr(msg, "content", None) + if blocks: + if event_callback: + _safe_callback( + event_callback, + "agent_turn_start", + {"turn": "awaiting_model"}, + ) + self._process_assistant_blocks( + blocks, + content_parts, + pending_tools, + event_callback, + verbose_enabled, + full_enabled, + ) + + if hasattr(msg, "model") and msg.model: + result_model = msg.model + if hasattr(msg, "usage") and msg.usage: + total_input_tokens += msg.usage.get("input_tokens", 0) + total_output_tokens += msg.usage.get("output_tokens", 0) + turn_count += 1 + if event_callback: + _safe_callback( + event_callback, + "agent_turn_start", + {"turn": turn_count}, + ) + + elif msg_type == "UserMessage": + msg_content = getattr(message, "content", None) + if msg_content: + self._process_tool_results( + msg_content, + pending_tools, + event_callback, + verbose_enabled, + full_enabled, + ) + + elif msg_type == "ResultMessage": + msg = cast(Any, message) + if getattr(msg, "structured_output", None) is not None: + structured_output = msg.structured_output + elif getattr(msg, "result", None) and not content_parts: + content_parts.append(msg.result) + if hasattr(msg, "usage") and msg.usage: + total_input_tokens += msg.usage.get("input_tokens", 0) + total_output_tokens += msg.usage.get("output_tokens", 0) + if getattr(msg, "is_error", False): + raise ProviderError( + self._build_error_message(msg), + is_retryable=False, + ) + + except ProviderError: + raise + except Exception as e: + raise ProviderError( + f"Claude Agent SDK execution error: {e}", + suggestion="Check that the claude CLI is installed and accessible", + ) from e + + return self._build_output( + content_parts, + structured_output, + agent, + result_model, + total_input_tokens, + total_output_tokens, + ) + + async def validate_connection(self) -> bool: + return CLAUDE_AGENT_SDK_AVAILABLE + + async def close(self) -> None: + pass + + @staticmethod + def _process_assistant_blocks( + blocks: list[Any], + content_parts: list[str], + pending_tools: dict[str, str], + event_callback: EventCallback | None, + verbose: bool = False, + full_mode: bool = False, + ) -> None: + for block in blocks: + block_type = getattr(block, "type", None) or type(block).__name__ + + if block_type in ("text", "TextBlock"): + text = getattr(block, "text", "") + if text: + content_parts.append(text) + if event_callback: + _safe_callback(event_callback, "agent_message", {"content": text}) + + elif block_type in ("thinking", "ThinkingBlock"): + thinking = getattr(block, "thinking", "") + if thinking: + if event_callback: + _safe_callback( + event_callback, + "agent_reasoning", + {"content": thinking}, + ) + if verbose: + _log_event_verbose("agent_reasoning", {"content": thinking}, full_mode) + + elif block_type in ("tool_use", "ToolUseBlock"): + tool_name = getattr(block, "name", "unknown") + tool_id = getattr(block, "id", "") + tool_input = getattr(block, "input", {}) + pending_tools[tool_id] = tool_name + data = {"tool_name": tool_name, "arguments": tool_input} + if event_callback: + _safe_callback(event_callback, "agent_tool_start", data) + if verbose: + _log_event_verbose("agent_tool_start", data, full_mode) + + @staticmethod + def _process_tool_results( + blocks: list[Any], + pending_tools: dict[str, str], + event_callback: EventCallback | None, + verbose: bool = False, + full_mode: bool = False, + ) -> None: + for block in blocks: + block_type = getattr(block, "type", None) or type(block).__name__ + if block_type not in ("tool_result", "ToolResultBlock"): + continue + + tool_use_id = getattr(block, "tool_use_id", "") + tool_name = pending_tools.pop(tool_use_id, "unknown") + content = getattr(block, "content", "") + result_str = str(content)[:500] if content else None + data = {"tool_name": tool_name, "result": result_str} + + if event_callback: + _safe_callback(event_callback, "agent_tool_complete", data) + if verbose: + _log_event_verbose("agent_tool_complete", data, full_mode) + + @staticmethod + def _build_error_message(message: Any) -> str: + parts: list[str] = [] + + errors = getattr(message, "errors", None) + if errors: + parts.append("; ".join(str(e) for e in errors)) + + result = getattr(message, "result", None) + if result: + parts.append(str(result)) + + stop_reason = getattr(message, "stop_reason", None) + if stop_reason: + parts.append(f"stop_reason={stop_reason}") + + num_turns = getattr(message, "num_turns", None) + if num_turns is not None: + parts.append(f"after {num_turns} turns") + + if parts: + return f"Claude Agent SDK execution failed: {', '.join(parts)}" + return "Claude Agent SDK execution failed (no details available)" + + @staticmethod + def _build_output( + content_parts: list[str], + structured_output: Any, + agent: AgentDef, + model: str | None, + input_tokens: int, + output_tokens: int, + partial: bool = False, + ) -> AgentOutput: + if structured_output is not None: + if isinstance(structured_output, dict): + content = structured_output + elif isinstance(structured_output, str): + try: + content = json.loads(structured_output) + except json.JSONDecodeError: + content = {"response": structured_output} + else: + content = {"response": str(structured_output)} + elif agent.output: + combined = "\n".join(content_parts) + try: + content = json.loads(combined) + except json.JSONDecodeError: + content = {"response": combined} + else: + content = {"response": "\n".join(content_parts)} + + total = input_tokens + output_tokens + return AgentOutput( + content=content, + raw_response=structured_output or "\n".join(content_parts), + tokens_used=total if total else None, + input_tokens=input_tokens or None, + output_tokens=output_tokens or None, + model=model, + partial=partial, + ) + + +def _log_event_verbose(event_type: str, data: dict[str, Any], full_mode: bool) -> None: + from rich.console import Console + from rich.text import Text + + from conductor.cli.run import _file_console + + console = Console(stderr=True, highlight=False) + + def _print(renderable: Any) -> None: + console.print(renderable) + if _file_console is not None: + _file_console.print(renderable) + + if event_type == "agent_tool_start": + tool_name = data.get("tool_name", "unknown") + text = Text() + text.append(" ├─ ", style="dim") + text.append("🔧 ", style="") + text.append(str(tool_name), style="cyan bold") + _print(text) + + if full_mode: + args = data.get("arguments") + if args: + args_str = str(args) + args_preview = args_str[:200] + "..." if len(args_str) > 200 else args_str + arg_text = Text() + arg_text.append(" │ ", style="dim") + arg_text.append("args: ", style="dim italic") + arg_text.append(args_preview, style="dim") + _print(arg_text) + + elif event_type == "agent_tool_complete": + tool_name = data.get("tool_name") + if tool_name: + text = Text() + text.append(" │ ", style="dim") + text.append("✓ ", style="green") + text.append(str(tool_name), style="dim") + _print(text) + + if full_mode: + result = data.get("result") + if result: + result_str = str(result) + result_preview = result_str[:200] + "..." if len(result_str) > 200 else result_str + result_text = Text() + result_text.append(" │ ", style="dim") + result_text.append("result: ", style="dim italic") + result_text.append(result_preview, style="dim") + _print(result_text) + + elif event_type == "agent_reasoning": + if full_mode: + reasoning = data.get("content", "") + if reasoning: + display = reasoning[:150] + "..." if len(reasoning) > 150 else reasoning + text = Text() + text.append(" │ ", style="dim") + text.append("💭 ", style="") + text.append(display.replace("\n", " "), style="italic dim") + _print(text) + + +def _safe_callback(callback: EventCallback, event_type: str, data: dict[str, Any]) -> None: + try: + callback(event_type, data) + except Exception: + logger.debug("Error in event_callback for %s", event_type, exc_info=True) diff --git a/src/conductor/providers/factory.py b/src/conductor/providers/factory.py index 17833b6d..b408695f 100644 --- a/src/conductor/providers/factory.py +++ b/src/conductor/providers/factory.py @@ -11,6 +11,10 @@ from conductor.exceptions import ProviderError from conductor.providers.base import AgentProvider from conductor.providers.claude import ANTHROPIC_SDK_AVAILABLE, ClaudeProvider +from conductor.providers.claude_agent_sdk import ( + CLAUDE_AGENT_SDK_AVAILABLE, + ClaudeAgentSdkProvider, +) from conductor.providers.copilot import CopilotProvider, IdleRecoveryConfig from conductor.providers.reasoning import ReasoningEffort @@ -19,7 +23,7 @@ async def create_provider( - provider_type: Literal["copilot", "openai-agents", "claude"] = "copilot", + provider_type: Literal["copilot", "openai-agents", "claude", "claude-agent-sdk"] = "copilot", validate: bool = True, mcp_servers: dict[str, Any] | None = None, default_model: str | None = None, @@ -106,10 +110,21 @@ async def create_provider( max_session_seconds=max_session_seconds, default_reasoning_effort=default_reasoning_effort, ) + case "claude-agent-sdk": + if not CLAUDE_AGENT_SDK_AVAILABLE: + raise ProviderError( + "Claude Agent SDK provider requires claude-agent-sdk package", + suggestion="Install with: uv add 'claude-agent-sdk>=0.1.0'", + ) + provider = ClaudeAgentSdkProvider( + model=default_model, + max_turns=max_agent_iterations, + max_session_seconds=max_session_seconds, + ) case _: raise ProviderError( f"Unknown provider: {provider_type}", - suggestion="Valid providers are: copilot, openai-agents, claude", + suggestion="Valid providers are: copilot, openai-agents, claude, claude-agent-sdk", ) if validate and not await provider.validate_connection(): diff --git a/src/conductor/providers/registry.py b/src/conductor/providers/registry.py index 762b5e54..4b390276 100644 --- a/src/conductor/providers/registry.py +++ b/src/conductor/providers/registry.py @@ -15,7 +15,7 @@ from conductor.config.schema import AgentDef, WorkflowConfig -ProviderType = Literal["copilot", "openai-agents", "claude"] +ProviderType = Literal["copilot", "openai-agents", "claude", "claude-agent-sdk"] class ProviderRegistry: diff --git a/tests/test_providers/test_claude_agent_sdk.py b/tests/test_providers/test_claude_agent_sdk.py new file mode 100644 index 00000000..82b04f77 --- /dev/null +++ b/tests/test_providers/test_claude_agent_sdk.py @@ -0,0 +1,499 @@ +"""Unit tests for the ClaudeAgentSdkProvider implementation.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from unittest.mock import Mock, patch + +import pytest + +pytest.importorskip( + "claude_agent_sdk", + reason="claude-agent-sdk extra not installed (pip install conductor[claude-agent-sdk])", +) + +from claude_agent_sdk import ( # noqa: E402 + AssistantMessage, + ResultMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +from conductor.config.schema import AgentDef, OutputField # noqa: E402 +from conductor.exceptions import ProviderError # noqa: E402 +from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider # noqa: E402 + + +def _assistant( + content: list, + model: str = "claude-sonnet-4-6", + usage: dict | None = None, +) -> AssistantMessage: + return AssistantMessage(content=content, model=model, usage=usage) + + +def _result( + result: str | None = None, + structured_output: object | None = None, + usage: dict | None = None, + is_error: bool = False, +) -> ResultMessage: + return ResultMessage( + subtype="result", + duration_ms=1000, + duration_api_ms=900, + is_error=is_error, + num_turns=1, + session_id="test-session", + usage=usage, + result=result, + structured_output=structured_output, + ) + + +class TestClaudeAgentSdkProviderInitialization: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", False) + def test_init_raises_when_sdk_not_installed(self) -> None: + with pytest.raises(ProviderError, match="Claude Agent SDK not installed"): + ClaudeAgentSdkProvider() + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.query", lambda **kwargs: None) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + def test_init_with_defaults(self) -> None: + provider = ClaudeAgentSdkProvider() + assert provider._default_model == "claude-sonnet-4-6" + assert provider._default_max_turns == 50 + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.query", lambda **kwargs: None) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + def test_init_with_custom_params(self) -> None: + provider = ClaudeAgentSdkProvider( + model="claude-opus-4-20250514", + max_turns=10, + ) + assert provider._default_model == "claude-opus-4-20250514" + assert provider._default_max_turns == 10 + + +class TestValidateConnection: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.query", lambda **kwargs: None) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_validate_connection_returns_true(self) -> None: + provider = ClaudeAgentSdkProvider() + assert await provider.validate_connection() is True + + +class TestExecute: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_text_only(self) -> None: + async def fake_query(**kwargs): + yield _assistant( + content=[TextBlock(text="The answer is 42")], + usage={"input_tokens": 100, "output_tokens": 50}, + ) + yield _result( + result="The answer is 42", + usage={"input_tokens": 0, "output_tokens": 0}, + ) + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test_agent", prompt="What is the answer?") + output = await provider.execute( + agent=agent, + context={}, + rendered_prompt="What is the answer?", + ) + + assert output.content == {"response": "The answer is 42"} + assert output.input_tokens == 100 + assert output.output_tokens == 50 + assert output.partial is False + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_structured_output(self) -> None: + async def fake_query(**kwargs): + yield _assistant( + content=[TextBlock(text="thinking...")], + usage={"input_tokens": 100, "output_tokens": 50}, + ) + yield _result( + structured_output={"answer": "42", "confidence": 0.95}, + usage={"input_tokens": 0, "output_tokens": 0}, + ) + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef( + name="test_agent", + prompt="What is the answer?", + output={ + "answer": OutputField(type="string"), + "confidence": OutputField(type="number"), + }, + ) + output = await provider.execute( + agent=agent, + context={}, + rendered_prompt="What is the answer?", + ) + + assert output.content == {"answer": "42", "confidence": 0.95} + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_emits_event_callbacks(self) -> None: + async def fake_query(**kwargs): + yield _assistant( + content=[ + TextBlock(text="Hello"), + ToolUseBlock(id="t1", name="search", input={"q": "test"}), + ThinkingBlock(thinking="Hmm", signature="sig"), + ], + usage={"input_tokens": 50, "output_tokens": 25}, + ) + yield UserMessage(content=[ToolResultBlock(tool_use_id="t1", content="search results")]) + yield _result() + + events: list[tuple[str, dict]] = [] + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + event_callback=lambda t, d: events.append((t, d)), + ) + + event_types = [e[0] for e in events] + assert "agent_turn_start" in event_types + assert "agent_message" in event_types + assert "agent_tool_start" in event_types + assert "agent_tool_complete" in event_types + assert "agent_reasoning" in event_types + + tool_complete = next(e for e in events if e[0] == "agent_tool_complete") + assert tool_complete[1]["tool_name"] == "search" + assert "search results" in tool_complete[1]["result"] + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_interrupt_signal(self) -> None: + interrupt = asyncio.Event() + interrupt.set() + + async def fake_query(**kwargs): + yield _assistant( + content=[TextBlock(text="partial")], + usage={"input_tokens": 10, "output_tokens": 5}, + ) + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + output = await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + interrupt_signal=interrupt, + ) + + assert output.partial is True + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_error_result(self) -> None: + async def fake_query(**kwargs): + yield _result(is_error=True, result="API key invalid") + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + with pytest.raises(ProviderError, match="API key invalid"): + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + ) + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_wraps_unexpected_errors(self) -> None: + async def failing_query(**kwargs): + raise RuntimeError("connection refused") + yield # noqa: F401 - make it an async generator + + with patch("conductor.providers.claude_agent_sdk.query", failing_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + with pytest.raises(ProviderError, match="connection refused"): + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + ) + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_execute_token_accumulation(self) -> None: + async def fake_query(**kwargs): + yield _assistant( + content=[TextBlock(text="part1")], + usage={"input_tokens": 100, "output_tokens": 50}, + ) + yield _assistant( + content=[TextBlock(text="part2")], + usage={"input_tokens": 80, "output_tokens": 40}, + ) + yield _result(usage={"input_tokens": 10, "output_tokens": 5}) + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + output = await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + ) + + assert output.input_tokens == 190 + assert output.output_tokens == 95 + assert output.tokens_used == 285 + + +class TestOutputFormatConstruction: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_output_format_built_from_output_fields(self) -> None: + original_options = Mock() + + async def capture_query(**kwargs): + yield _result(structured_output={"name": "test", "score": 5}) + + with ( + patch("conductor.providers.claude_agent_sdk.query", capture_query), + patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", original_options), + ): + provider = ClaudeAgentSdkProvider() + agent = AgentDef( + name="test", + prompt="hi", + output={ + "name": OutputField(type="string", description="The name"), + "score": OutputField(type="number"), + }, + ) + await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + call_kwargs = original_options.call_args[1] + output_format = call_kwargs["output_format"] + assert output_format["type"] == "json_schema" + schema = output_format["schema"] + assert schema["type"] == "object" + assert "name" in schema["properties"] + assert "score" in schema["properties"] + assert schema["properties"]["name"]["description"] == "The name" + assert set(schema["required"]) == {"name", "score"} + + +class TestSchemaBuilding: + def test_nested_object_schema(self) -> None: + from conductor.providers.claude_agent_sdk import _build_output_format + + output = { + "person": OutputField( + type="object", + properties={ + "name": OutputField(type="string", description="Full name"), + "age": OutputField(type="number"), + }, + ), + } + result = _build_output_format(output) + person = result["schema"]["properties"]["person"] + assert person["type"] == "object" + assert "name" in person["properties"] + assert person["properties"]["name"]["description"] == "Full name" + assert person["required"] == ["name", "age"] + + def test_array_schema(self) -> None: + from conductor.providers.claude_agent_sdk import _build_output_format + + output = { + "tags": OutputField( + type="array", + items=OutputField(type="string"), + ), + } + result = _build_output_format(output) + tags = result["schema"]["properties"]["tags"] + assert tags["type"] == "array" + assert tags["items"]["type"] == "string" + + def test_array_of_objects_schema(self) -> None: + from conductor.providers.claude_agent_sdk import _build_output_format + + output = { + "items": OutputField( + type="array", + items=OutputField( + type="object", + properties={ + "id": OutputField(type="number"), + "label": OutputField(type="string"), + }, + ), + ), + } + result = _build_output_format(output) + items_schema = result["schema"]["properties"]["items"]["items"] + assert items_schema["type"] == "object" + assert set(items_schema["required"]) == {"id", "label"} + + def test_depth_limit_raises(self) -> None: + from conductor.providers.claude_agent_sdk import _build_field_schema + + field_def = OutputField(type="string") + for _ in range(12): + field_def = OutputField(type="object", properties={"nested": field_def}) + + with pytest.raises(ProviderError, match="nesting exceeds 10"): + _build_field_schema(field_def) + + +class TestBuildOutput: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_string_structured_output_parsed_as_json(self) -> None: + async def fake_query(**kwargs): + yield _result(structured_output='{"answer": "yes"}') + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef( + name="test", + prompt="hi", + output={"answer": OutputField(type="string")}, + ) + output = await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + assert output.content == {"answer": "yes"} + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_non_dict_structured_output_wrapped(self) -> None: + async def fake_query(**kwargs): + yield _result(structured_output=42) + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + output = await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + assert output.content == {"response": "42"} + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_output_schema_with_non_json_text_falls_back(self) -> None: + async def fake_query(**kwargs): + yield _assistant( + content=[TextBlock(text="not valid json")], + usage={"input_tokens": 10, "output_tokens": 5}, + ) + yield _result() + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef( + name="test", + prompt="hi", + output={"answer": OutputField(type="string")}, + ) + output = await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + assert output.content == {"response": "not valid json"} + + +class TestMessageDispatch: + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_unknown_message_types_ignored(self) -> None: + @dataclass + class FakeSystemMessage: + subtype: str = "init" + data: dict = field(default_factory=dict) + + @dataclass + class FakeStreamEvent: + event: str = "keepalive" + + async def fake_query(**kwargs): + yield FakeSystemMessage() + yield FakeStreamEvent() + yield _result(result="done") + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + output = await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + assert output.content == {"response": "done"} + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_tool_result_with_no_matching_pending_tool(self) -> None: + events: list[tuple[str, dict]] = [] + + async def fake_query(**kwargs): + yield UserMessage( + content=[ToolResultBlock(tool_use_id="orphan_id", content="orphan result")] + ) + yield _result(result="done") + + with patch("conductor.providers.claude_agent_sdk.query", fake_query): + provider = ClaudeAgentSdkProvider() + agent = AgentDef(name="test", prompt="hi") + await provider.execute( + agent=agent, + context={}, + rendered_prompt="hi", + event_callback=lambda t, d: events.append((t, d)), + ) + + tool_complete = [e for e in events if e[0] == "agent_tool_complete"] + assert len(tool_complete) == 1 + assert tool_complete[0][1]["tool_name"] == "unknown" + + @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True) + @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) + async def test_system_prompt_passed_to_options(self) -> None: + 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="test", + prompt="hi", + system_prompt="You are a helpful assistant", + ) + await provider.execute(agent=agent, context={}, rendered_prompt="hi") + + call_kwargs = options_mock.call_args[1] + assert call_kwargs["system_prompt"] == "You are a helpful assistant" diff --git a/uv.lock b/uv.lock index 69470d6c..25d81858 100644 --- a/uv.lock +++ b/uv.lock @@ -127,6 +127,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.2.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -169,6 +187,11 @@ dependencies = [ { name = "websockets" }, ] +[package.optional-dependencies] +claude-agent-sdk = [ + { name = "claude-agent-sdk" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -181,6 +204,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.77.0,<1.0.0" }, + { name = "claude-agent-sdk", marker = "extra == 'claude-agent-sdk'", specifier = ">=0.1.64" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "github-copilot-sdk", specifier = ">=0.3.0" }, { name = "httpx", specifier = ">=0.27.0" }, @@ -195,6 +219,7 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.30.0" }, { name = "websockets", specifier = ">=12.0" }, ] +provides-extras = ["claude-agent-sdk"] [package.metadata.requires-dev] dev = [