From e7b1ead56e7be17cb8e8aeb69f47b3b8b9c5e341 Mon Sep 17 00:00:00 2001 From: Farouk Adeleke Date: Sat, 6 Dec 2025 01:41:32 -0500 Subject: [PATCH] Add more functionality to signature description parsing --- README.md | 332 +++++++++++++++++++++++++++++++++++++++---- claude_dspy/agent.py | 189 +++++++++++++++++++----- main.py | 25 ++-- 3 files changed, 470 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9d11317..1be8fc6 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,21 @@ A DSPy module that wraps the Claude Code Python SDK with a signature-driven inte ## Installation ```bash -# Install with uv +# install with uv uv add claude-agent-sdk dspy modaic nest-asyncio -# Or with pip +# or with pip pip install claude-agent-sdk dspy modaic nest-asyncio ``` **Prerequisites:** - Python 3.10+ -- Claude Code CLI installed (get it from [code.claude.com](https://code.claude.com)) - 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. @@ -35,10 +38,10 @@ The fastest way to use ClaudeCode is to pull a pre-configured agent from Modaic ### 1. Set up environment ```bash -# Copy the example file +# copy the example file cp .env.example .env -# Edit .env with your keys +# edit .env with your keys ANTHROPIC_API_KEY="" MODAIC_TOKEN="" # Optional, for pushing to hub ``` @@ -48,17 +51,21 @@ MODAIC_TOKEN="" # Optional, for pushing to 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", - config={ - "signature": "message:str -> output:FileList", - "working_directory": ".", - } + signature=FileSignature, + working_directory=".", ) # Use it! @@ -70,15 +77,26 @@ print(result.usage) # Token usage ### 3. Override Config Options ```python +from modaic import AutoProgram +from claude_dspy import ClaudeCodeConfig +import dspy + +class MySignature(dspy.Signature): + message: str = dspy.InputField(desc="Request to process") + answer: str = dspy.OutputField(desc="Response") + +# Create config with custom model +config = ClaudeCodeConfig(model="claude-opus-4-5-20251101") + # Load with custom configuration +# Model comes from config, other params are kwargs agent = AutoProgram.from_precompiled( "farouk1/claude-code", - config={ - "signature": "message:str -> answer:str", - "model": "claude-opus-4-5-20251101", - "permission_mode": "acceptEdits", - "allowed_tools": ["Read", "Write", "Bash"], - } + config=config, + signature=MySignature, + working_directory=".", + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], ) ``` @@ -174,6 +192,8 @@ class ClaudeCodeConfig: - **`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. @@ -196,16 +216,49 @@ class ClaudeCode(PrecompiledProgram): **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"]`) +- **`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 +- **`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`) @@ -292,11 +345,234 @@ print(agent.session_id) # 'eb1b2f39-e04c-4506-9398-b50053b1fd83' ##### `config: ClaudeCodeConfig` -Access to the agent's configuration. +Access to the agent's configuration (model only). ```python print(agent.config.model) # 'claude-opus-4-5-20251101' -print(agent.config.working_directory) # '.' +``` + +**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 @@ -446,7 +722,7 @@ agent = ClaudeCode( signature="message:str -> answer:str", working_directory=".", permission_mode="default", - allowed_tools=["Read", "Glob", "Grep"], + allowed_tools=["Read"], # Only allow reading files ) # Auto-accept file edits @@ -456,16 +732,17 @@ agent = ClaudeCode( signature="message:str -> answer:str", working_directory=".", permission_mode="acceptEdits", - allowed_tools=["Read", "Write", "Edit"], + allowed_tools=["Read", "Write"], # Allow reading and writing ) -# Sandbox mode for command execution +# Full permissions with command execution config = ClaudeCodeConfig() agent = ClaudeCode( config, signature="message:str -> answer:str", working_directory=".", - sandbox={"enabled": True}, + permission_mode="acceptEdits", + allowed_tools=["Read", "Write", "Bash"], # All tools enabled ) ``` @@ -489,7 +766,7 @@ agent = ClaudeCode( signature="message:str -> review:CodeReview", working_directory="/path/to/project", permission_mode="default", - allowed_tools=["Read", "Glob", "Grep"], + allowed_tools=["Read"], # Read-only for code review ) result = agent(message="Review the changes in src/main.py") @@ -757,9 +1034,10 @@ See LICENSE file. ## Related Documentation -- [Claude Code SDK API Reference](https://docs.claude.com/en/agent-sdk/python) -- [DSPy Documentation](https://dspy-docs.vercel.app/) -- [Claude Code Documentation](https://code.claude.com/docs) +- [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 --- diff --git a/claude_dspy/agent.py b/claude_dspy/agent.py index 549d80f..285ef5f 100644 --- a/claude_dspy/agent.py +++ b/claude_dspy/agent.py @@ -44,19 +44,61 @@ class ClaudeCodeConfig(PrecompiledConfig): model: str = "claude-opus-4-5-20251101" - class ClaudeCodeKwargs(BaseModel): - model_config = {"arbitrary_types_allowed": True} + """Arguments for ClaudeCode initialization. - signature: Any # str | dspy.Signature (validated manually) + 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__ + + # Authentication api_key: str | None = None + + # Basic configuration working_directory: str = "." permission_mode: str | None = None - allowed_tools: list[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. @@ -88,22 +130,14 @@ class ClaudeCode(PrecompiledProgram): args = ClaudeCodeKwargs(**kwargs) + # Parse and validate signature signature = args.signature - api_key = args.api_key - working_directory = args.working_directory - permission_mode = args.permission_mode - allowed_tools = args.allowed_tools - disallowed_tools = args.disallowed_tools - sandbox = args.sandbox - system_prompt = args.system_prompt - - # parse and validate signature if isinstance(signature, str): self.signature = dspy.Signature(signature) else: self.signature = signature - # validate signature has exactly 1 input and 1 output + # Validate signature has exactly 1 input and 1 output input_fields = list(self.signature.input_fields.keys()) output_fields = list(self.signature.output_fields.keys()) @@ -124,17 +158,51 @@ class ClaudeCode(PrecompiledProgram): self.input_field = self.signature.input_fields[self.input_field_name] self.output_field = self.signature.output_fields[self.output_field_name] - # store config values - self.working_directory = Path(working_directory).resolve() + # 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 - self.permission_mode = permission_mode - self.allowed_tools = allowed_tools - self.disallowed_tools = disallowed_tools - self.sandbox = sandbox - self.system_prompt = system_prompt - self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY") - # No extra options since all kwargs are parsed by ClaudeCodeKwargs - self.extra_options = {} + + # 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() @@ -154,19 +222,68 @@ class ClaudeCode(PrecompiledProgram): def _create_client(self) -> ClaudeSDKClient: """Create ClaudeSDKClient with configured options.""" - options = ClaudeAgentOptions( - cwd=str(self.working_directory), - model=self.model, - permission_mode=self.permission_mode, - allowed_tools=self.allowed_tools or [], - disallowed_tools=self.disallowed_tools or [], - sandbox=self.sandbox, - system_prompt=self.system_prompt, - output_format=self.output_format, # include output format - **self.extra_options, - ) + # Build options dict, only including non-None values + options_dict = { + "cwd": str(self.working_directory), + "model": self.model, + "output_format": self.output_format, + } - # set API key if provided + # 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 diff --git a/main.py b/main.py index ec22564..5a9bd73 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ from claude_dspy import ClaudeCode, ClaudeCodeConfig from pydantic import BaseModel +from modaic import AutoProgram import dspy @@ -9,7 +10,7 @@ class Output(BaseModel): class ClaudeCodeSignature(dspy.Signature): message: str = dspy.InputField(desc="Request to process") - output: list[str] = dspy.OutputField(desc="List of files modified or created") + output: Output = dspy.OutputField(desc="List of files modified or created") def main(): @@ -22,24 +23,22 @@ def main(): signature=ClaudeCodeSignature, working_directory=".", permission_mode="acceptEdits", - allowed_tools=["Read", "Glob", "Write"], + allowed_tools=["Read", "Bash", "Write"], ) - - # use it - print("Running ClaudeCode...") - result = cc( - message="Create a new file called helloworld.txt with the alphabet backwards" - ) - - print(f"Success: {result.output}") - print(result.usage) - - print(f"Session ID: {cc.session_id}") cc.push_to_hub( "farouk1/claude-code", with_code=True, commit_message="Add more functionality to signature description parsing", ) + """ + agent = AutoProgram.from_precompiled("farouk1/claude-code", signature=ClaudeCodeSignature, working_directory=".", permission_mode="acceptEdits", allowed_tools=["Read", "Write", "Bash"]) + + # Test the agent + result = agent(message="create a python program that prints 'Hello, World!' and save it to a file in this directory") + print(result.output.files) + print(result.output) + print(result.usage) + """ if __name__ == "__main__":