From c300d8ceba1a52e5c8a6fd3df6bc100d6cfeaaa6 Mon Sep 17 00:00:00 2001 From: Farouk Adeleke Date: Thu, 25 Dec 2025 01:36:44 -0800 Subject: [PATCH] move connect debug prints --- LICENSE | 19 + README.md | 1044 ++++++++++++++++++++++++++++++++++++++- auto_classes.json | 4 + claude_dspy/__init__.py | 24 + claude_dspy/agent.py | 658 ++++++++++++++++++++++++ claude_dspy/trace.py | 52 ++ claude_dspy/utils.py | 214 ++++++++ config.json | 3 + main.py | 47 ++ program.json | 9 + pyproject.toml | 13 + 11 files changed, 2086 insertions(+), 1 deletion(-) create mode 100644 LICENSE create mode 100644 auto_classes.json create mode 100644 claude_dspy/__init__.py create mode 100644 claude_dspy/agent.py create mode 100644 claude_dspy/trace.py create mode 100644 claude_dspy/utils.py create mode 100644 config.json create mode 100644 main.py create mode 100644 program.json create mode 100644 pyproject.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 521c11e..7fb7d33 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1044 @@ -# claude-code +# ClaudeCode - DSPy Module for Claude Code SDK +A DSPy module that wraps the Claude Code Python SDK with a signature-driven interface. Each agent instance maintains a stateful conversation session, making it perfect for multi-turn agentic workflows. + +Reference to the original Claude Agent Python SDK: [Claude Agent Python SDK](https://platform.claude.com/docs/en/agent-sdk/python) + +## Features + +- **Signature-driven** - Use DSPy signatures for type safety and clarity +- **Stateful sessions** - Each agent instance = one conversation session +- **Smart schema handling** - Automatically handles str vs Pydantic outputs +- **Rich outputs** - Get typed results + execution trace + token usage +- **Multi-turn conversations** - Context preserved across calls +- **Enhanced prompts** - Automatically includes signature docstrings + InputField/OutputField descriptions for better context +- **Async support** - Both sync and async execution modes +- **Modaic Hub Integration** - Push and pull agents from Modaic Hub + + + +**Prerequisites:** +- Python 3.10+ +- Anthropic API key set in `ANTHROPIC_API_KEY` environment variable + +**Note:** The Claude Code CLI is automatically bundled with the `claude-agent-sdk` package - no separate installation required! The SDK uses the bundled CLI by default. If you prefer to use a system-wide installation or a specific version: +- Install separately: `curl -fsSL https://claude.ai/install.sh | bash` +- Specify custom path: `ClaudeAgentOptions(cli_path="/path/to/claude")` + +## Quick Start with Modaic Hub (Recommended) + +The fastest way to use ClaudeCode is to pull a pre-configured agent from Modaic Hub. + +### 0. Installation + +```bash +# install with uv +uv add claude-agent-sdk dspy modaic +``` + +### 1. Set up environment + +```bash +# copy the example file +cp .env.example .env + +# edit .env with your keys +ANTHROPIC_API_KEY="" +MODAIC_TOKEN="" # Optional, for pushing to hub +``` + +### 2. Load from Modaic Hub + +```python +from modaic import AutoProgram +from pydantic import BaseModel +import dspy + +class FileList(BaseModel): + files: list[str] + +class FileSignature(dspy.Signature): + message: str = dspy.InputField(desc="Request to process") + output: FileList = dspy.OutputField(desc="List of files") + +# Load pre-compiled agent from hub +# Note: Only model is set via config, other params are kwargs +agent = AutoProgram.from_precompiled( + "farouk1/claude-code", + signature=FileSignature, + working_directory=".", +) + +# Use it! +result = agent(message="List Python files here") +print(result.output.files) # Typed access +print(result.usage) # Token usage +``` + +### 3. Override Config Options + +```python +from modaic import AutoProgram +import dspy + +class MySignature(dspy.Signature): + message: str = dspy.InputField(desc="Request to process") + answer: str = dspy.OutputField(desc="Response") + +# Load with custom configuration +# Model comes from config, other params are kwargs +agent = AutoProgram.from_precompiled( + "farouk1/claude-code", + config={"model": "claude-opus-4-5-20251101"}, + signature=MySignature, + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], +) +``` + +## Local Development + +For local development and hacking on this project: + +### Basic String Output + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +# create config +config = ClaudeCodeConfig() + +# create agent +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory="." +) + +# use it +result = agent(message="What files are in this directory?") +print(result.answer) # String response +print(result.trace) # Execution items +print(result.usage) # Token counts +``` + +### Structured Output with Pydantic + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig +from pydantic import BaseModel, Field +import dspy + +class BugReport(BaseModel): + severity: str = Field(description="critical, high, medium, or low") + description: str + affected_files: list[str] + +# Create config with Pydantic output +config = ClaudeCodeConfig() + +# Option 1: Pre-construct signature in your module (where BugReport is defined) +sig = dspy.Signature("message:str -> report:BugReport") +agent = ClaudeCode( + config, + signature=sig, + working_directory="." +) + +# Option 2: Use class-based signature (recommended) +class BugReportSignature(dspy.Signature): + """Analyze bugs and generate report.""" + message: str = dspy.InputField() + report: BugReport = dspy.OutputField() + +agent = ClaudeCode( + config, + signature=BugReportSignature, + working_directory="." +) + +result = agent(message="Analyze the bug in error.log") +print(result.report.severity) # Typed access! +print(result.report.affected_files) +``` + +**Note**: String signatures like `"message:str -> report:BugReport"` only work with built-in types unless you use `dspy.Signature()`. For custom Pydantic models, either: +- Use `dspy.Signature("...")` +- Use class-based signatures (recommended) + +### Push to Modaic Hub + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +# create your agent +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") + +agent = ClaudeCode(config) + +# push to Modaic Hub +agent.push_to_hub("{USERNAME}/your-agent-name") +``` + +## API Reference + +### ClaudeCodeConfig + +Configuration object for ClaudeCode agents. + +```python +class ClaudeCodeConfig: + def __init__( + self, + model: str = "claude-opus-4-5-20251101", # Default model + ) +``` + +**Parameters:** + +- **`model`** - Claude model to use (default: `"claude-opus-4-5-20251101"`) + +**Important:** `ClaudeCodeConfig` only contains the model. All other parameters (`signature`, `working_directory`, `permission_mode`, `allowed_tools`, etc.) are passed as keyword arguments to `ClaudeCode.__init__()` or `AutoProgram.from_precompiled()`, not through the config object. + +### ClaudeCode + +Main agent class. + +```python +class ClaudeCode(PrecompiledProgram): + def __init__( + self, + config: ClaudeCodeConfig, + **kwargs, + ) +``` + +**Parameters:** + +**Core Configuration:** +- **`config`** - ClaudeCodeConfig instance with model configuration +- **`signature`** (required) - DSPy signature defining input/output fields (must have exactly 1 input and 1 output) +- **`working_directory`** - Directory where Claude will execute commands (default: `"."`) +- **`permission_mode`** - Permission mode: `"default"`, `"acceptEdits"`, `"plan"`, `"bypassPermissions"` +- **`allowed_tools`** - List of allowed tool names (e.g., `["Read", "Write", "Bash", "Glob", "Grep"]`). See [Available Tools](#available-tools) section for complete list. +- **`disallowed_tools`** - List of disallowed tool names +- **`sandbox`** - Sandbox configuration dict. See [SDK docs](https://platform.claude.com/docs/en/agent-sdk/python#sandboxsettings) for details. +- **`system_prompt`** - Custom system prompt or preset config +- **`api_key`** - Anthropic API key (falls back to `ANTHROPIC_API_KEY` env var) + +**MCP Servers:** +- **`mcp_servers`** - MCP server configurations for custom tools. See [MCP section](#using-mcp-servers) below. + +**Session Management:** +- **`continue_conversation`** - Continue the most recent conversation (default: `False`) +- **`resume`** - Session ID to resume from a previous session +- **`max_turns`** - Maximum number of conversation turns +- **`fork_session`** - Fork to a new session when resuming (default: `False`) + +**Advanced Options:** +- **`permission_prompt_tool_name`** - MCP tool name for permission prompts +- **`settings`** - Path to custom settings file +- **`add_dirs`** - Additional directories Claude can access +- **`env`** - Environment variables to pass to Claude Code +- **`extra_args`** - Additional CLI arguments +- **`max_buffer_size`** - Maximum bytes when buffering CLI stdout +- **`cli_path`** - Custom path to Claude Code CLI executable + +**Callbacks and Hooks:** +- **`stderr`** - Callback function for stderr output: `Callable[[str], None]` +- **`can_use_tool`** - Permission callback for tool usage control +- **`hooks`** - Hook configurations for intercepting events. See [SDK docs](https://platform.claude.com/docs/en/agent-sdk/python#hook-types) for details. + +**User and Settings:** +- **`user`** - User identifier +- **`include_partial_messages`** - Include partial message streaming events (default: `False`) +- **`setting_sources`** - Which settings to load: `["user", "project", "local"]` + +**Subagents and Plugins:** +- **`agents`** - Programmatically defined subagents +- **`plugins`** - Custom plugins to load + +#### Methods + +##### `__call__(**kwargs) -> Prediction` (or `forward`) + +Execute the agent with an input message. + +**Arguments:** +- `**kwargs` - Must contain the input field specified in signature + +**Returns:** +- `Prediction` object with: + - **Typed output field** - Named according to signature (e.g., `result.answer`) + - **`trace`** - `list[TraceItem]` - Execution trace + - **`usage`** - `Usage` - Token usage statistics + +**Example:** +```python +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory="." +) + +result = agent(message="Hello") +print(result.answer) # Access typed output +print(result.trace) # List of execution items +print(result.usage) # Token usage stats +``` + +##### `push_to_hub(repo_id: str) -> None` + +Push the agent to Modaic Hub. + +**Arguments:** +- `repo_id` - Repository ID in format "username/repo-name" + +**Example:** +```python +agent.push_to_hub("{USERNAME}/your-agent") +``` + +##### `aforward(**kwargs) -> Prediction` + +Async version of `__call__()` for use in async contexts. + +**Example:** +```python +async def main(): + config = ClaudeCodeConfig() + agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory="." + ) + result = await agent.aforward(message="Hello") + print(result.answer) +``` + +#### Properties + +##### `session_id: Optional[str]` + +Get the session ID for this agent instance. + +- Returns `None` until first call +- Persists across multiple calls +- Useful for debugging and logging + +**Example:** +```python +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory="." +) + +print(agent.session_id) # None + +result = agent(message="Hello") +print(agent.session_id) # 'eb1b2f39-e04c-4506-9398-b50053b1fd83' +``` + +##### `config: ClaudeCodeConfig` + +Access to the agent's configuration (model only). + +```python +print(agent.config.model) # 'claude-opus-4-5-20251101' +``` + +**Note:** Only the `model` is stored in config. Other parameters like `working_directory`, `permission_mode`, and `allowed_tools` are instance attributes, not config properties. + +## Key Implementation Details + +### Config vs Kwargs + +The `ClaudeCodeConfig` **only** contains the `model` parameter. All other configuration options are passed as keyword arguments: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +# Config contains ONLY the model +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") + +# All other params are kwargs +agent = ClaudeCode( + config, # Config with model + signature="message:str -> answer:str", # Kwarg + working_directory=".", # Kwarg + permission_mode="acceptEdits", # Kwarg + allowed_tools=["Read", "Write", "Bash"], # Kwarg +) +``` + +### AutoProgram.from_precompiled + +When loading from Modaic Hub, the same pattern applies: + +```python +from modaic import AutoProgram +from claude_dspy import ClaudeCodeConfig +import dspy + +class MySignature(dspy.Signature): + message: str = dspy.InputField(desc="Request") + answer: str = dspy.OutputField(desc="Response") + +# Option 1: Use default model (no config needed) +agent = AutoProgram.from_precompiled( + "farouk1/claude-code", + signature=MySignature, # Kwarg + working_directory=".", # Kwarg + permission_mode="acceptEdits", # Kwarg + allowed_tools=["Read", "Write", "Bash"], # Kwarg +) + +# Option 2: Override model via config +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") +agent = AutoProgram.from_precompiled( + "farouk1/claude-code", + config=config, # Config with model + signature=MySignature, # Kwarg + working_directory=".", # Kwarg + permission_mode="acceptEdits", # Kwarg + allowed_tools=["Read", "Write", "Bash"], # Kwarg +) +``` + +### Available Tools + +The `allowed_tools` parameter accepts any valid Claude Code tool name: + +**File Operations:** +- `"Read"` - Read files and directories +- `"Write"` - Write and create files +- `"Edit"` - Edit existing files + +**Command Execution:** +- `"Bash"` - Execute bash commands + +**Code Search:** +- `"Glob"` - Search for files by pattern +- `"Grep"` - Search file contents + +**Web Tools:** +- `"WebSearch"` - Search the web +- `"WebFetch"` - Fetch web content + +**Other Tools:** +- `"NotebookEdit"` - Edit Jupyter notebooks +- And other Claude Code tools... + +## Advanced Features + +### Using MCP Servers + +MCP (Model Context Protocol) servers allow you to add custom tools to Claude. The SDK supports creating in-process MCP servers with custom tools. + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig +from claude_agent_sdk import tool, create_sdk_mcp_server +from typing import Any +import dspy + +# Define custom tools with @tool decorator +@tool("calculate", "Perform mathematical calculations", {"expression": str}) +async def calculate(args: dict[str, Any]) -> dict[str, Any]: + try: + result = eval(args["expression"], {"__builtins__": {}}) + return { + "content": [{"type": "text", "text": f"Result: {result}"}] + } + except Exception as e: + return { + "content": [{"type": "text", "text": f"Error: {str(e)}"}], + "is_error": True + } + +# Create MCP server +calculator_server = create_sdk_mcp_server( + name="calculator", + version="1.0.0", + tools=[calculate] +) + +# Use with ClaudeCode +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + mcp_servers={"calc": calculator_server}, + allowed_tools=["mcp__calc__calculate"] # MCP tools are prefixed with "mcp____" +) + +result = agent(message="Calculate 123 * 456") +print(result.answer) +``` + +### Session Management + +Resume and continue conversations from previous sessions: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +config = ClaudeCodeConfig() + +# First conversation +agent1 = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory="." +) +result1 = agent1(message="Create a file called notes.txt") +session_id = agent1.session_id +print(f"Session ID: {session_id}") + +# Resume the same conversation later +agent2 = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + resume=session_id # Resume from session ID +) +result2 = agent2(message="What file did we just create?") +print(result2.answer) # Claude remembers the previous context! +``` + +### Using Hooks + +Intercept and modify tool execution with hooks: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig +from typing import Any + +async def pre_tool_logger(input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]: + """Log all tool usage before execution.""" + tool_name = input_data.get('tool_name', 'unknown') + print(f"About to use tool: {tool_name}") + + # Block dangerous commands + if tool_name == "Bash" and "rm -rf /" in str(input_data.get('tool_input', {})): + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Dangerous command blocked' + } + } + return {} + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + hooks={ + 'PreToolUse': [ + {'matcher': 'Bash', 'hooks': [pre_tool_logger]} + ] + }, + allowed_tools=["Read", "Write", "Bash"] +) + +result = agent(message="List files in this directory") +``` + +### Loading Project Settings + +Control which filesystem settings to load: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +config = ClaudeCodeConfig() + +# Load only project settings (e.g., CLAUDE.md files) +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + system_prompt={ + "type": "preset", + "preset": "claude_code" + }, + setting_sources=["project"], # Load .claude/settings.json and CLAUDE.md + allowed_tools=["Read", "Write"] +) + +result = agent(message="Add a feature following project conventions") +``` + +## Usage Patterns + +### Pattern 1: Multi-turn Conversation + +Each agent instance maintains a stateful session: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", +) + +# Turn 1 +result1 = agent(message="What's the main bug?") +print(result1.answer) + +# Turn 2 - has context from Turn 1 +result2 = agent(message="How do we fix it?") +print(result2.answer) + +# Turn 3 - has context from Turn 1 + 2 +result3 = agent(message="Write tests for the fix") +print(result3.answer) + +# All use same session_id +print(agent.session_id) +``` + +### Pattern 2: Fresh Context + +Want a new conversation? Create a new agent: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +config = ClaudeCodeConfig() + +# Agent 1 - Task A +agent1 = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", +) +result1 = agent1(message="Analyze bug in module A") + +# Agent 2 - Task B (no context from Agent 1) +agent2 = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", +) +result2 = agent2(message="Analyze bug in module B") +``` + +### Pattern 3: Field Descriptions for Enhanced Context + +Enhance prompts with signature docstrings and field descriptions - all automatically included in the prompt: + +```python +import dspy +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +class MySignature(dspy.Signature): + """Analyze code architecture.""" # Used as task description + + message: str = dspy.InputField( + desc="Request to process" # Provides input context + ) + analysis: str = dspy.OutputField( + desc="A detailed markdown report with sections: " + "1) Architecture overview, 2) Key components, 3) Dependencies" # Guides output format + ) + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature=MySignature, + working_directory=".", +) +result = agent(message="Analyze this codebase") + +# The prompt sent to Claude will include: +# 1. Task: "Analyze code architecture." (from docstring) +# 2. Input context: "Request to process" (from InputField desc) +# 3. Your message: "Analyze this codebase" +# 4. Output guidance: "Please produce the following output: A detailed markdown report..." (from OutputField desc) +``` + +### Pattern 4: Inspecting Execution Trace + +Access detailed execution information: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig, ToolUseItem, ToolResultItem + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", +) + +result = agent(message="Fix the bug") + +# Filter trace by type +tool_uses = [item for item in result.trace if isinstance(item, ToolUseItem)] +for tool in tool_uses: + print(f"Tool: {tool.tool_name}") + print(f"Input: {tool.tool_input}") + +tool_results = [item for item in result.trace if isinstance(item, ToolResultItem)] +for result_item in tool_results: + print(f"Result: {result_item.content}") + print(f"Error: {result_item.is_error}") +``` + +### Pattern 5: Token Usage Tracking + +Monitor API usage: + +```python +result = agent(message="...") + +print(f"Input tokens: {result.usage.input_tokens}") +print(f"Cached tokens: {result.usage.cached_input_tokens}") +print(f"Output tokens: {result.usage.output_tokens}") +print(f"Total: {result.usage.total_tokens}") +``` + +### Pattern 6: Safe Execution with Permissions + +Control what the agent can do: + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +# Read-only (safest) +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + permission_mode="default", + allowed_tools=["Read"], # Only allow reading files +) + +# Auto-accept file edits +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write"], # Allow reading and writing +) + +# Full permissions with command execution +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], # All tools enabled +) +``` + +## Advanced Examples + +### Example 1: Code Review Agent + +```python +from pydantic import BaseModel, Field +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +class CodeReview(BaseModel): + summary: str = Field(description="High-level summary") + issues: list[str] = Field(description="List of issues found") + severity: str = Field(description="critical, high, medium, or low") + recommendations: list[str] = Field(description="Actionable recommendations") + +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") +agent = ClaudeCode( + config, + signature="message:str -> review:CodeReview", + working_directory="/path/to/project", + permission_mode="default", + allowed_tools=["Read"], # Read-only for code review +) + +result = agent(message="Review the changes in src/main.py") + +print(f"Severity: {result.review.severity}") +for issue in result.review.issues: + print(f"- {issue}") +``` + +### Example 2: Iterative Debugging + +```python +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature="message:str -> response:str", + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], +) + +# Turn 1: Find the bug +result1 = agent(message="Find the bug in src/calculator.py") +print(result1.response) + +# Turn 2: Propose a fix +result2 = agent(message="What's the best way to fix it?") +print(result2.response) + +# Turn 3: Implement the fix +result3 = agent(message="Implement the fix") +print(result3.response) + +# Turn 4: Write tests +result4 = agent(message="Write tests for the fix") +print(result4.response) +``` + +### Example 3: Async Usage + +```python +import asyncio +from claude_dspy import ClaudeCode, ClaudeCodeConfig + +async def main(): + config = ClaudeCodeConfig() + agent = ClaudeCode( + config, + signature="message:str -> answer:str", + working_directory=".", + ) + + # Use aforward in async context + result = await agent.aforward(message="Analyze this code") + print(result.answer) + + # Cleanup + await agent.disconnect() + +asyncio.run(main()) +``` + +## Trace Item Types + +When accessing `result.trace`, you'll see various item types: + +| Type | Fields | Description | +|------|--------|-------------| +| `AgentMessageItem` | `text`, `model` | Agent's text response | +| `ThinkingItem` | `text`, `model` | Agent's internal reasoning | +| `ToolUseItem` | `tool_name`, `tool_input`, `tool_use_id` | Tool invocation | +| `ToolResultItem` | `tool_name`, `tool_use_id`, `content`, `is_error` | Tool result | +| `ErrorItem` | `message`, `error_type` | Error that occurred | + +## How It Works + +### Signature � Claude Flow + +``` +1. Define signature: 'message:str -> answer:str' + +2. ClaudeCode validates (must have 1 input, 1 output) + +3. __init__ creates ClaudeSDKClient with options + +4. forward(message="...") extracts message + +5. If output field has desc � append to message + +6. If output type ` str � generate JSON schema + +7. Call client.query(message) with optional output_format + +8. Iterate through receive_response(), collect messages + +9. Parse response (JSON if Pydantic, str otherwise) + +10. Return Prediction(output=..., trace=..., usage=...) +``` + +### Output Type Handling + +**String output:** +```python +sig = dspy.Signature('message:str -> answer:str') +# No schema passed to Claude Code +# Response used as-is +``` + +**Pydantic output:** +```python +sig = dspy.Signature('message:str -> report:BugReport') +# JSON schema generated from BugReport +# Schema passed to Claude Code via output_format +# Response parsed with BugReport.model_validate_json() +``` + +### Prompt Building + +ClaudeCode automatically builds rich prompts from your signature to provide maximum context to Claude: + +```python +class MySignature(dspy.Signature): + """Analyze code quality.""" # 1. Task description + + message: str = dspy.InputField( + desc="Path to file or module" # 2. Input context + ) + report: str = dspy.OutputField( + desc="Markdown report with issues and recommendations" # 3. Output guidance + ) + +config = ClaudeCodeConfig() +agent = ClaudeCode( + config, + signature=MySignature, + working_directory="." +) +result = agent(message="Analyze src/main.py") # 4. Your actual input +``` + +**The final prompt sent to Claude:** +``` +Task: Analyze code quality. + +Input context: Path to file or module + +Analyze src/main.py + +Please produce the following output: Markdown report with issues and recommendations +``` + +This automatic context enhancement helps Claude better understand: +- **What** the overall task is (docstring) +- **What** the input represents (InputField desc) +- **What** format the output should have (OutputField desc) + +## Troubleshooting + +### Error: "ClaudeCode requires exactly 1 input field" + +Your signature has too many or too few fields. ClaudeCode expects exactly one input and one output: + +```python +# L Wrong - multiple inputs +sig = dspy.Signature('context:str, question:str -> answer:str') + +# Correct - single input +sig = dspy.Signature('message:str -> answer:str') +``` + +### Error: "Failed to parse Claude response as MyModel" + +The model returned JSON that doesn't match your Pydantic schema. Check: +1. Schema is valid and clear +2. Field descriptions are helpful +3. Model has enough context to generate correct structure + +### Error: "Claude Code CLI not found" + +Install Claude Code CLI: +```bash +# Visit code.claude.com for installation instructions +# or use npm: +npm install -g @anthropic-ai/claude-code +``` + +### Async event loop issues + +Use `aforward()` when already in an async context: + +```python +# L Don't do this in async context +async def main(): + result = agent(message="...") # Can cause issues + +#  Do this instead +async def main(): + result = await agent.aforward(message="...") +``` + +## Design Philosophy + +### Why 1 input, 1 output? + +ClaudeCode is designed for conversational agentic workflows. The input is always a message/prompt, and the output is always a response. This keeps the interface simple and predictable. + +For complex inputs, compose them into the message: + +```python +# Instead of: 'context:str, question:str -> answer:str' +message = f"Context: {context}\n\nQuestion: {question}" +result = agent(message=message) +``` + +### Why stateful sessions? + +Agents often need multi-turn context (e.g., "fix the bug" � "write tests for it"). Stateful sessions make this natural without manual history management. + +Want fresh context? Create a new agent instance. + +### Why return trace + usage? + +Observability is critical for agentic systems. You need to know: +- What tools were used +- What the agent was thinking +- How many tokens were consumed +- If any errors occurred + +The trace provides full visibility into agent execution. + +## Comparison with CodexAgent + +| Feature | CodexAgent | ClaudeCode | +|---------|-----------|-------------| +| SDK | OpenAI Codex SDK | Claude Code Python SDK | +| Thread management | Built-in thread ID | Session-based (ClaudeSDKClient) | +| Streaming | Yes | Yes (via receive_response) | +| Async support | No | Yes (aforward) | +| Tool types | Codex-specific | Claude Code tools (Bash, Read, Write, etc.) | +| Sandbox | Simple mode enum | Detailed config dict | +| Permission control | Sandbox modes | Permission modes + allowed_tools | +| Configuration | Direct parameters | Config object (ClaudeCodeConfig) | + +## Examples Directory + +Check out the `examples/` directory for more: + +- `basic_string_output.py` - Simple string output +- `pydantic_output.py` - Structured Pydantic output +- `multi_turn_conversation.py` - Multi-turn conversation +- `output_field_description.py` - Using output field descriptions +- `inspect_trace.py` - Inspecting execution trace +- `code_review_agent.py` - Advanced code review agent + +## Contributing + +Issues and PRs welcome! This is an implementation of Claude Code SDK integration with DSPy. + +## License + +See LICENSE file. + +## Related Documentation + +- [Claude Agent SDK - Python Reference](https://platform.claude.com/docs/en/agent-sdk/python) - Complete SDK API reference +- [Claude Agent SDK - Overview](https://platform.claude.com/docs/en/agent-sdk/overview) - SDK concepts and guides +- [DSPy Documentation](https://dspy-docs.vercel.app/) - DSPy framework documentation +- [Claude Code CLI](https://code.claude.com) - Claude Code command-line interface + +--- + +**Note:** This is a community implementation of Claude Code SDK integration with DSPy, inspired by the CodexAgent design pattern. diff --git a/auto_classes.json b/auto_classes.json new file mode 100644 index 0000000..3e02c0b --- /dev/null +++ b/auto_classes.json @@ -0,0 +1,4 @@ +{ + "AutoConfig": "claude_dspy.agent.ClaudeCodeConfig", + "AutoProgram": "claude_dspy.agent.ClaudeCode" +} \ No newline at end of file diff --git a/claude_dspy/__init__.py b/claude_dspy/__init__.py new file mode 100644 index 0000000..7ab2bcd --- /dev/null +++ b/claude_dspy/__init__.py @@ -0,0 +1,24 @@ +from .agent import ClaudeCode, ClaudeCodeConfig +from .trace import ( + TraceItem, + AgentMessageItem, + ThinkingItem, + ToolUseItem, + ToolResultItem, + ErrorItem, +) +from .utils import Usage + +__version__ = "0.1.0" + +__all__ = [ + "ClaudeCode", + "ClaudeCodeConfig", + "TraceItem", + "AgentMessageItem", + "ThinkingItem", + "ToolUseItem", + "ToolResultItem", + "ErrorItem", + "Usage", +] diff --git a/claude_dspy/agent.py b/claude_dspy/agent.py new file mode 100644 index 0000000..c6ad0c0 --- /dev/null +++ b/claude_dspy/agent.py @@ -0,0 +1,658 @@ +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Optional + +from modaic import PrecompiledProgram, PrecompiledConfig +from pydantic import BaseModel + +import dspy +from dspy.primitives.prediction import Prediction + +from claude_agent_sdk import ( + ClaudeSDKClient, + ClaudeAgentOptions, + AssistantMessage, + ResultMessage, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, +) + +from .trace import ( + TraceItem, + AgentMessageItem, + ThinkingItem, + ToolUseItem, + ToolResultItem, + ErrorItem, +) +from .utils import ( + Usage, + is_pydantic_model, + get_json_schema, + parse_json_response, + extract_text_from_response, +) + + +class ClaudeCodeConfig(PrecompiledConfig): + """Configuration for ClaudeCode agent.""" + + model: str = "claude-opus-4-5-20251101" + + +class ClaudeCodeKwargs(BaseModel): + """Arguments for ClaudeCode initialization. + + Matches ClaudeAgentOptions from the SDK with additional DSPy-specific fields. + See: https://platform.claude.com/docs/en/agent-sdk/python#claudeagentoptions + """ + + # DSPy-specific (required) + signature: Any # str | dspy.Signature - validated manually in __init__ + + # auth + api_key: str | None = None + + # basic config + working_directory: str = "." + permission_mode: str | None = None + allowed_tools: list[str] | None = None # Any Claude Code tools + disallowed_tools: list[str] | None = None + sandbox: dict[str, Any] | None = None + system_prompt: str | dict[str, Any] | None = None + + # mcp servers + mcp_servers: dict[str, Any] | str | Path | None = None + + # session management + continue_conversation: bool = False + resume: str | None = None + max_turns: int | None = None + fork_session: bool = False + + # advanced options + permission_prompt_tool_name: str | None = None + settings: str | None = None + add_dirs: list[str | Path] | None = None + env: dict[str, str] | None = None + extra_args: dict[str, str | None] | None = None + max_buffer_size: int | None = None + + # callbacks and hooks + stderr: Any | None = ( + None # Callable[[str], None] - can't type check callables in Pydantic easily + ) + can_use_tool: Any | None = None # CanUseTool callback + hooks: dict[str, list[dict[str, Any]]] | None = None + + # user and settings + user: str | None = None + include_partial_messages: bool = False + setting_sources: list[str] | None = None # List of "user" | "project" | "local" + + # subagents and plugins + agents: dict[str, dict[str, Any]] | None = None + plugins: list[dict[str, Any]] | None = None + + # cli configuration + cli_path: str | Path | None = None + + +class ClaudeCode(PrecompiledProgram): + """DSPy module that wraps Claude Code SDK. + + Each agent instance maintains a stateful conversation session. + Perfect for multi-turn agentic workflows with context preservation. + """ + + config: ClaudeCodeConfig + + def __init__( + self, + config: ClaudeCodeConfig, + **kwargs: dict, + ): + super().__init__(config=config) + + args = ClaudeCodeKwargs(**kwargs) + + # validate signature + # Note: Raw string signatures only work with built-in types. + # For custom Pydantic models, users must pass: + # 1. A class-based signature, OR + # 2. Pre-constructed dspy.Signature (in their module where types are defined) + signature = args.signature + if isinstance(signature, str): + try: + self.signature = dspy.Signature(signature) + except ValueError as e: + if "Unknown name:" in str(e): + type_name = str(e).split("Unknown name: ")[-1] + raise ValueError( + f"Cannot resolve type '{type_name}' in string signature.\n" + f"String signatures only work with built-in types (str, int, list[str], etc.).\n\n" + f"For custom Pydantic models, use one of these approaches:\n\n" + f"Option 1 - Class-based signature (recommended):\n" + f" class MySignature(dspy.Signature):\n" + f" input: str = dspy.InputField()\n" + f" output: {type_name} = dspy.OutputField()\n" + f" agent = ClaudeCode(config, signature=MySignature, ...)\n\n" + f"Option 2 - Pre-construct signature in your module:\n" + f" sig = dspy.Signature('{signature}')\n" + f" agent = ClaudeCode(config, signature=sig, ...)\n" + ) from e + raise + else: + self.signature = signature + + # validate signature has exactly 1 input and 1 output TODO: support multiple inputs/outputs + input_fields = list(self.signature.input_fields.keys()) + output_fields = list(self.signature.output_fields.keys()) + + if len(input_fields) != 1: + raise ValueError( + f"ClaudeCode requires exactly 1 input field, got {len(input_fields)}. " + f"Found: {input_fields}" + ) + + if len(output_fields) != 1: + raise ValueError( + f"ClaudeCode requires exactly 1 output field, got {len(output_fields)}. " + f"Found: {output_fields}" + ) + + self.input_field_name = input_fields[0] + self.output_field_name = output_fields[0] + self.input_field = self.signature.input_fields[self.input_field_name] + self.output_field = self.signature.output_fields[self.output_field_name] + + # store all configuration values + self.api_key = args.api_key or os.getenv("ANTHROPIC_API_KEY") + self.working_directory = Path(args.working_directory).resolve() + self.model = config.model + + # basic options + self.permission_mode = args.permission_mode + self.allowed_tools = args.allowed_tools + self.disallowed_tools = args.disallowed_tools + self.sandbox = args.sandbox + self.system_prompt = args.system_prompt + + # mcp servers + self.mcp_servers = args.mcp_servers + + # session management + self.continue_conversation = args.continue_conversation + self.resume = args.resume + self.max_turns = args.max_turns + self.fork_session = args.fork_session + + # advanced options + self.permission_prompt_tool_name = args.permission_prompt_tool_name + self.settings = args.settings + self.add_dirs = args.add_dirs + self.env = args.env + self.extra_args = args.extra_args + self.max_buffer_size = args.max_buffer_size + + # callbacks and hooks + self.stderr = args.stderr + self.can_use_tool = args.can_use_tool + self.hooks = args.hooks + + # user and settings + self.user = args.user + self.include_partial_messages = args.include_partial_messages + self.setting_sources = args.setting_sources + + # subagents and plugins + self.agents = args.agents + self.plugins = args.plugins + + # cli configuration + self.cli_path = args.cli_path + + # determine output format upfront + self.output_format = self._get_output_format() + + # session state + self._client: Optional[ClaudeSDKClient] = None + self._session_id: Optional[str] = None + self._is_connected = False + + @property + def session_id(self) -> Optional[str]: + """Get the session ID for this agent instance. + + Returns None until first forward() call. + """ + return self._session_id + + def _create_client(self) -> ClaudeSDKClient: + """Create ClaudeSDKClient with configured options.""" + # build options dict, only including non-None values + options_dict = { + "cwd": str(self.working_directory), + "model": self.model, + "output_format": self.output_format, + } + + # add optional fields only if they're not None + if self.permission_mode is not None: + options_dict["permission_mode"] = self.permission_mode + if self.allowed_tools is not None: + options_dict["allowed_tools"] = self.allowed_tools + if self.disallowed_tools is not None: + options_dict["disallowed_tools"] = self.disallowed_tools + if self.sandbox is not None: + options_dict["sandbox"] = self.sandbox + if self.system_prompt is not None: + options_dict["system_prompt"] = self.system_prompt + if self.mcp_servers is not None: + options_dict["mcp_servers"] = self.mcp_servers + if self.continue_conversation: + options_dict["continue_conversation"] = self.continue_conversation + if self.resume is not None: + options_dict["resume"] = self.resume + if self.max_turns is not None: + options_dict["max_turns"] = self.max_turns + if self.fork_session: + options_dict["fork_session"] = self.fork_session + if self.permission_prompt_tool_name is not None: + options_dict["permission_prompt_tool_name"] = ( + self.permission_prompt_tool_name + ) + if self.settings is not None: + options_dict["settings"] = self.settings + if self.add_dirs is not None: + options_dict["add_dirs"] = self.add_dirs + if self.env is not None: + options_dict["env"] = self.env + if self.extra_args is not None: + options_dict["extra_args"] = self.extra_args + if self.max_buffer_size is not None: + options_dict["max_buffer_size"] = self.max_buffer_size + if self.stderr is not None: + options_dict["stderr"] = self.stderr + if self.can_use_tool is not None: + options_dict["can_use_tool"] = self.can_use_tool + if self.hooks is not None: + options_dict["hooks"] = self.hooks + if self.user is not None: + options_dict["user"] = self.user + if self.include_partial_messages: + options_dict["include_partial_messages"] = self.include_partial_messages + if self.setting_sources is not None: + options_dict["setting_sources"] = self.setting_sources + if self.agents is not None: + options_dict["agents"] = self.agents + if self.plugins is not None: + options_dict["plugins"] = self.plugins + if self.cli_path is not None: + options_dict["cli_path"] = self.cli_path + + options = ClaudeAgentOptions(**options_dict) + + # set API key if provided + if self.api_key: + os.environ["ANTHROPIC_API_KEY"] = self.api_key + + return ClaudeSDKClient(options=options) + + def _build_prompt(self, input_value: str) -> str: + """Build prompt from signature docstring, field descriptions, and input value. + + Note: When using structured outputs, the SDK handles JSON formatting automatically + via the output_format parameter, so we don't add JSON instructions to the prompt. + """ + prompt_parts = [] + + # add signature docstring if present + if self.signature.__doc__: + doc = self.signature.__doc__.strip() + if doc: + prompt_parts.append(f"Task: {doc}") + + # add input field description if present + # DSPy fields store desc in json_schema_extra + input_desc = None + if ( + hasattr(self.input_field, "json_schema_extra") + and self.input_field.json_schema_extra + ): + input_desc = self.input_field.json_schema_extra.get("desc") + + # add the actual input value + prompt_parts.append(f"{self.input_field_name}: {input_value}") + + if input_desc: + prompt_parts.append(f"({input_desc})") + + # add output field description if present + output_desc = None + if ( + hasattr(self.output_field, "json_schema_extra") + and self.output_field.json_schema_extra + ): + output_desc = self.output_field.json_schema_extra.get("desc") + + if output_desc: + prompt_parts.append(f"\nPlease produce the following output: {output_desc}") + + # the schema is passed through ClaudeAgentOptions and enforced by the SDK + + return "\n\n".join(prompt_parts) + + def _get_output_format(self) -> Optional[dict[str, Any]]: + """Get output format configuration for structured outputs. + + Supports: + - Direct Pydantic models: MyModel + - Generic types: list[MyModel], dict[str, MyModel] + """ + output_type = self.output_field.annotation + + if is_pydantic_model(output_type): + schema = get_json_schema(output_type) + return { + "type": "json_schema", + "schema": schema, + } + + return None + + async def _run_async( + self, prompt: str + ) -> tuple[str | dict | list | None, list[TraceItem], Usage]: + """Run the agent asynchronously and collect results. + + Returns: + - response: For structured outputs, returns dict/list from structured_output. + For text outputs, returns string from result or text blocks. + - trace: Execution trace items + - usage: Token usage statistics + """ + # create client if needed + if self._client is None: + self._client = self._create_client() + + # connect if not already connected + if not self._is_connected: + await self._client.connect() + self._is_connected = True + print( + f"[ClaudeCode._run_async] Client connected (connected={self._is_connected})" + ) + + await self._client.query(prompt) + print(f"[ClaudeCode._run_async] Query sent, waiting for response...") + + # collect messages and build trace + trace: list[TraceItem] = [] + usage = Usage() + response_text = "" + structured_output = None + message_count = 0 + + async for message in self._client.receive_response(): + message_count += 1 + + # handle assistant messages + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + response_text += block.text + trace.append( + AgentMessageItem(text=block.text, model=message.model) + ) + elif isinstance(block, ThinkingBlock): + trace.append( + ThinkingItem(text=block.thinking, model=message.model) + ) + elif isinstance(block, ToolUseBlock): + # handle StructuredOutput tool (contains JSON response) + if block.name == "StructuredOutput": + # the JSON is directly in the tool input (already a dict) + response_text = json.dumps(block.input) + + trace.append( + ToolUseItem( + tool_name=block.name, + tool_input=block.input, + tool_use_id=block.id, + ) + ) + elif isinstance(block, ToolResultBlock): + content_str = "" + if isinstance(block.content, str): + content_str = block.content + elif isinstance(block.content, list): + # extract text from content blocks + for item in block.content: + if ( + isinstance(item, dict) + and item.get("type") == "text" + ): + content_str += item.get("text", "") + + trace.append( + ToolResultItem( + tool_name="", # tool name not in ToolResultBlock + tool_use_id=block.tool_use_id, + content=content_str, + is_error=block.is_error or False, + ) + ) + + # handle result messages (final message with usage info) + elif isinstance(message, ResultMessage): + # store session ID + if hasattr(message, "session_id"): + self._session_id = message.session_id + print(f"[ClaudeCode._run_async] - Session ID: {self._session_id}") + + # extract usage + if hasattr(message, "usage") and message.usage: + usage_data = message.usage + usage = Usage( + input_tokens=usage_data.get("input_tokens", 0), + cached_input_tokens=usage_data.get( + "cache_read_input_tokens", 0 + ), + output_tokens=usage_data.get("output_tokens", 0), + ) + + # check for errors + if hasattr(message, "is_error") and message.is_error: + error_msg = ( + message.result + if hasattr(message, "result") + else "Unknown error" + ) + trace.append( + ErrorItem(message=error_msg, error_type="execution_error") + ) + raise RuntimeError(f"Agent execution failed: {error_msg}") + + # prefer structured_output over result (when using output_format) + if ( + hasattr(message, "structured_output") + and message.structured_output is not None + ): + structured_output = message.structured_output + # fallback to result field for text outputs + elif hasattr(message, "result") and message.result: + response_text = message.result + + # handle system messages + elif isinstance(message, SystemMessage): + # log system messages to trace but don't error + if hasattr(message, "data") and message.data: + data_str = str(message.data) + trace.append( + AgentMessageItem(text=f"[System: {data_str}]", model="system") + ) + + # return structured_output if available (for Pydantic outputs), otherwise text + if structured_output is not None: + return structured_output, trace, usage + else: + return response_text, trace, usage + + def forward(self, **kwargs: Any) -> Prediction: + """Execute the agent with an input message. + + Args: + **kwargs: Must contain the input field specified in signature + + Returns: + Prediction with: + - Typed output field (named according to signature) + - trace: list[TraceItem] - Execution trace + - usage: Usage - Token usage statistics + + Example: + >>> result = agent(message="Hello") + >>> print(result.answer) # Access typed output + >>> print(result.trace) # List of execution items + >>> print(result.usage) # Token usage stats + """ + # extract input value + if self.input_field_name not in kwargs: + raise ValueError( + f"Missing required input field: {self.input_field_name}. " + f"Received: {list(kwargs.keys())}" + ) + + input_value = kwargs[self.input_field_name] + + # build prompt + prompt = self._build_prompt(input_value) + print(prompt) + # run async execution in event loop + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # If already in async context, create new loop + import nest_asyncio + + nest_asyncio.apply() + response_text, trace, usage = loop.run_until_complete( + self._run_async(prompt) + ) + else: + response_text, trace, usage = loop.run_until_complete( + self._run_async(prompt) + ) + except RuntimeError: + # no event loop, create one + response_text, trace, usage = asyncio.run(self._run_async(prompt)) + + # parse response based on output type + output_type = self.output_field.annotation + if is_pydantic_model(output_type): + try: + # response_text can be dict/list (from structured_output) or str (legacy) + parsed_output = parse_json_response(response_text, output_type) + except Exception as e: + raise ValueError( + f"Failed to parse Claude response as {output_type}: {e}\n" + f"Response type: {type(response_text)}\n" + f"Response: {response_text}" + ) + else: + # string output - extract text + if isinstance(response_text, str): + parsed_output = extract_text_from_response(response_text) + else: + # Shouldn't happen, but handle gracefully + parsed_output = str(response_text) + + + # return prediction with typed output, trace, and usage + return Prediction( + **{ + self.output_field_name: parsed_output, + "trace": trace, + "usage": usage, + } + ) + + async def aforward(self, **kwargs: Any) -> Prediction: + """Async version of forward(). + + Use this when already in an async context to avoid event loop issues. + + Args: + **kwargs: Must contain the input field specified in signature + + Returns: + Prediction with typed output, trace, and usage + """ + + # extract input value + if self.input_field_name not in kwargs: + raise ValueError( + f"Missing required input field: {self.input_field_name}. " + f"Received: {list(kwargs.keys())}" + ) + + input_value = kwargs[self.input_field_name] + + # build prompt + prompt = self._build_prompt(input_value) + + # run async execution + response_text, trace, usage = await self._run_async(prompt) + + # parse response based on output type + output_type = self.output_field.annotation + if is_pydantic_model(output_type): + try: + # response_text can be dict/list (from structured_output) or str (legacy) + parsed_output = parse_json_response(response_text, output_type) + except Exception as e: + raise ValueError( + f"Failed to parse Claude response as {output_type}: {e}\n" + f"Response type: {type(response_text)}\n" + f"Response: {response_text}" + ) + else: + # string output - extract text + if isinstance(response_text, str): + parsed_output = extract_text_from_response(response_text) + else: + # Shouldn't happen, but handle gracefully + parsed_output = str(response_text) + + # return prediction with typed output, trace, and usage + return Prediction( + **{ + self.output_field_name: parsed_output, + "trace": trace, + "usage": usage, + } + ) + + async def disconnect(self) -> None: + """Disconnect from Claude Code and clean up resources.""" + if self._client and self._is_connected: + await self._client.disconnect() + self._is_connected = False + + def __del__(self): + """Cleanup on deletion.""" + # Check attributes exist before accessing (may fail during __init__) + if hasattr(self, "_client") and hasattr(self, "_is_connected"): + if self._client and self._is_connected: + try: + asyncio.run(self.disconnect()) + except Exception: + # best effort cleanup + pass + diff --git a/claude_dspy/trace.py b/claude_dspy/trace.py new file mode 100644 index 0000000..6587b0c --- /dev/null +++ b/claude_dspy/trace.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class TraceItem: + """Base class for trace items.""" + + pass + + +@dataclass +class AgentMessageItem(TraceItem): + """Agent's text response.""" + + text: str + model: str + + +@dataclass +class ThinkingItem(TraceItem): + """Agent's internal reasoning (extended thinking).""" + + text: str + model: str + + +@dataclass +class ToolUseItem(TraceItem): + """Tool invocation request.""" + + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + + +@dataclass +class ToolResultItem(TraceItem): + """Tool execution result.""" + + tool_name: str + tool_use_id: str + content: str | list[dict[str, Any]] | None = None + is_error: bool = False + + +@dataclass +class ErrorItem(TraceItem): + """Error that occurred during execution.""" + + message: str + error_type: str | None = None diff --git a/claude_dspy/utils.py b/claude_dspy/utils.py new file mode 100644 index 0000000..1a74922 --- /dev/null +++ b/claude_dspy/utils.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +from typing import Any, get_origin, get_args + +from pydantic import BaseModel + + +@dataclass +class Usage: + """Token usage statistics.""" + + input_tokens: int = 0 + cached_input_tokens: int = 0 + output_tokens: int = 0 + + @property + def total_tokens(self) -> int: + """Total tokens used (input + output).""" + return self.input_tokens + self.output_tokens + + def __repr__(self) -> str: + return ( + f"Usage(input={self.input_tokens}, " + f"cached={self.cached_input_tokens}, " + f"output={self.output_tokens}, " + f"total={self.total_tokens})" + ) + + +def is_pydantic_model(type_hint: Any) -> bool: + """Check if a type hint is a Pydantic model or contains one (e.g., list[Model]). + + Returns True for: + - Pydantic models: MyModel + - Generic types containing Pydantic: list[MyModel], dict[str, MyModel] + """ + try: + # direct Pydantic model + if isinstance(type_hint, type) and issubclass(type_hint, BaseModel): + return True + + # generic type (list, dict, etc.) + origin = get_origin(type_hint) + if origin is not None: + args = get_args(type_hint) + # check if any type argument is a Pydantic model + for arg in args: + if isinstance(arg, type) and issubclass(arg, BaseModel): + return True + + return False + except TypeError: + return False + + +def get_json_schema(type_hint: Any) -> dict[str, Any]: + """Generate JSON schema from type hint. + + Handles: + - Pydantic models: MyModel + - Generic types: list[MyModel], dict[str, MyModel] + + Note: Claude API requires root type to be "object" for structured outputs (tools). + For list/dict types, we wrap them in an object with a single property. + + Sets additionalProperties to false for all objects. + """ + origin = get_origin(type_hint) + args = get_args(type_hint) + + # handle generic types (list, dict, etc.) + if origin is list: + # list[Model] - wrap in object since API requires root type = "object" + # {"type": "object", "properties": {"items": {"type": "array", "items": {...}}}} + if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): + model = args[0] + schema = { + "type": "object", + "properties": { + "items": {"type": "array", "items": model.model_json_schema()} + }, + "required": ["items"], + "additionalProperties": False, + } + else: + raise ValueError(f"Unsupported list type: {type_hint}") + + elif origin is dict: + # dict[str, Model] - wrap in object since API requires root type = "object" + # {"type": "object", "properties": {"values": {"type": "object", "additionalProperties": {...}}}} + if ( + len(args) >= 2 + and isinstance(args[1], type) + and issubclass(args[1], BaseModel) + ): + model = args[1] + schema = { + "type": "object", + "properties": { + "values": { + "type": "object", + "additionalProperties": model.model_json_schema(), + } + }, + "required": ["values"], + "additionalProperties": False, + } + else: + raise ValueError(f"Unsupported dict type: {type_hint}") + + elif isinstance(type_hint, type) and issubclass(type_hint, BaseModel): + # direct Pydantic model - already an object + schema = type_hint.model_json_schema() + + else: + raise ValueError(f"Unsupported type for structured output: {type_hint}") + + # recursively set additionalProperties: false for all nested objects + def set_additional_properties(obj: dict[str, Any]) -> None: + if isinstance(obj, dict): + if obj.get("type") == "object" and "additionalProperties" not in obj: + obj["additionalProperties"] = False + for value in obj.values(): + if isinstance(value, dict): + set_additional_properties(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + set_additional_properties(item) + + set_additional_properties(schema) + return schema + + +def parse_json_response( + response: str | dict | list, type_hint: Any +) -> BaseModel | list[BaseModel] | dict[str, BaseModel]: + """Parse JSON response into typed output. + + Handles: + - Pydantic models: MyModel + - Generic types: list[MyModel], dict[str, MyModel] + + Note: When schema has list/dict at root, the SDK wraps them in {"items": [...]} + or {"values": {...}} because API requires root type = "object". + + Args: + response: JSON string or already-parsed dict/list from structured_output + type_hint: The output type annotation + + Returns: + Validated and typed output + + Raises: + json.JSONDecodeError: If response string is not valid JSON + pydantic.ValidationError: If JSON doesn't match schema + """ + import json + + origin = get_origin(type_hint) + args = get_args(type_hint) + + # parse string to dict/list if needed + if isinstance(response, str): + parsed = json.loads(response) + else: + parsed = response + + # handle list[Model] + if origin is list: + if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): + model = args[0] + + # unwrap from {"items": [...]} if present (from structured_output) + if isinstance(parsed, dict) and "items" in parsed: + parsed = parsed["items"] + + if not isinstance(parsed, list): + raise ValueError(f"Expected list, got {type(parsed)}") + return [model.model_validate(item) for item in parsed] + + # handle dict[str, Model] + elif origin is dict: + if ( + len(args) >= 2 + and isinstance(args[1], type) + and issubclass(args[1], BaseModel) + ): + model = args[1] + + # unwrap from {"values": {...}} if present (from structured_output) + if isinstance(parsed, dict) and "values" in parsed: + parsed = parsed["values"] + + if not isinstance(parsed, dict): + raise ValueError(f"Expected dict, got {type(parsed)}") + return {key: model.model_validate(value) for key, value in parsed.items()} + + # handle direct Pydantic model + elif isinstance(type_hint, type) and issubclass(type_hint, BaseModel): + if isinstance(response, str): + return type_hint.model_validate_json(response) + else: + return type_hint.model_validate(parsed) + + raise ValueError(f"Unsupported type for parsing: {type_hint}") + + +def extract_text_from_response(response: str) -> str: + """Extract plain text from response. + + For string outputs, we just return the text as-is. + Claude Code may wrap responses in markdown or other formatting. + """ + return response.strip() diff --git a/config.json b/config.json new file mode 100644 index 0000000..d071d3a --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "model": "claude-opus-4-5-20251101" +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..112911d --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +from claude_dspy import ClaudeCode, ClaudeCodeConfig +from pydantic import BaseModel, Field +from typing import Literal +from modaic import AutoProgram +import dspy + +class AffectedFile(BaseModel): + file_name: str = Field(..., description="Name of the file") + action: Literal["created", "updated", "deleted", "renamed"] = Field(..., description="Action taken on the file") + + +class OutputResult(BaseModel): + success: bool = Field(..., description="Whether or not execution of the query was successful") + message: str = Field(..., description="Message") + affected_files: list[AffectedFile] = Field(..., description="List of files affected by the query") + + +class ClaudeCodeSignature(dspy.Signature): + query: str = dspy.InputField(desc="Query to process") + output: OutputResult = dspy.OutputField(desc="Result of the query") + + +def main(): + # create config + config = ClaudeCodeConfig() + + # create agent + cc = ClaudeCode( + config, + signature=ClaudeCodeSignature, + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], + ) + result = cc(query="list the files in this directory") + output = result.output + print(output) + + cc.push_to_hub( + "modaic/claude-code", + with_code=True, + commit_message="move connect debug prints", + ) + + +if __name__ == "__main__": + main() diff --git a/program.json b/program.json new file mode 100644 index 0000000..ac56cba --- /dev/null +++ b/program.json @@ -0,0 +1,9 @@ +{ + "metadata": { + "dependency_versions": { + "python": "3.13", + "dspy": "3.0.4", + "cloudpickle": "3.1" + } + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..05a517d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "claude-code" +version = "0.1.0" +description = "Claude Code SDK wrapped in a DSPy module" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["claude-agent-sdk>=0.1.12", "dspy>=3.0.4", "modaic>=0.8.2", "nest-asyncio>=1.6.0", "pydantic>=2.0.0"] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", +]