mirror of
https://github.com/anthropics/claude-agent-sdk-python.git
synced 2025-10-06 01:00:03 +03:00
feat: add agents and setting sources support (#182)
## Summary - Add support for custom agent definitions via `agents` option - Add support for controlling setting sources via `setting_sources` option - Add `/commit` slash command to project - Add examples demonstrating both features - Add e2e tests for verification ## Changes ### Core Implementation - Add `AgentDefinition` and `SettingSource` types to `types.py` - Add `agents` and `setting_sources` fields to `ClaudeCodeOptions` - Update subprocess CLI transport to pass `--agents` and `--setting-sources` flags - **Default behavior**: When `setting_sources` is not provided, pass empty string (no settings loaded) - Handle empty `setting_sources` array correctly (pass empty string to CLI) ### Examples - `examples/agents.py`: Demonstrates custom agent definitions with different tools and models - `examples/setting_sources.py`: Shows how setting sources control which settings are loaded - Default behavior (no settings) - User-only settings - User + project settings ### Tests - Add e2e tests verifying agents and setting_sources functionality - Test default behavior (no settings loaded) - Test filtering by setting source - Use `output_style` checking to verify settings loaded/not loaded - Tests use temporary directories for isolated testing ### Project Config - Add `.claude/commands/commit.md` slash command for git commits ## Test Plan - [x] E2E tests added for all new functionality - [ ] CI tests pass - [ ] Examples run successfully 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
17
.claude/commands/commit.md
Normal file
17
.claude/commands/commit.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
|
||||
description: Create a git commit
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- Current git status: !`git status`
|
||||
- Current git diff (staged and unstaged changes): !`git diff HEAD`
|
||||
- Current branch: !`git branch --show-current`
|
||||
- Recent commits: !`git log --oneline -10`
|
||||
|
||||
## Your task
|
||||
|
||||
Based on the above changes, create a single git commit.
|
||||
|
||||
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.
|
||||
155
e2e-tests/test_agents_and_settings.py
Normal file
155
e2e-tests/test_agents_and_settings.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""End-to-end tests for agents and setting sources with real Claude API calls."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from claude_code_sdk import (
|
||||
AgentDefinition,
|
||||
ClaudeCodeOptions,
|
||||
ClaudeSDKClient,
|
||||
SystemMessage,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_definition():
|
||||
"""Test that custom agent definitions work."""
|
||||
options = ClaudeCodeOptions(
|
||||
agents={
|
||||
"test-agent": AgentDefinition(
|
||||
description="A test agent for verification",
|
||||
prompt="You are a test agent. Always respond with 'Test agent activated'",
|
||||
tools=["Read"],
|
||||
model="sonnet",
|
||||
)
|
||||
},
|
||||
max_turns=1,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
# Check that agent is available in init message
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||
agents = message.data.get("agents", [])
|
||||
assert isinstance(
|
||||
agents, list
|
||||
), f"agents should be a list of strings, got: {type(agents)}"
|
||||
assert (
|
||||
"test-agent" in agents
|
||||
), f"test-agent should be available, got: {agents}"
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_setting_sources_default():
|
||||
"""Test that default (no setting_sources) loads no settings."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a temporary project with local settings
|
||||
project_dir = Path(tmpdir)
|
||||
claude_dir = project_dir / ".claude"
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
# Create local settings with custom outputStyle
|
||||
settings_file = claude_dir / "settings.local.json"
|
||||
settings_file.write_text('{"outputStyle": "local-test-style"}')
|
||||
|
||||
# Don't provide setting_sources - should default to no settings
|
||||
options = ClaudeCodeOptions(
|
||||
cwd=project_dir,
|
||||
max_turns=1,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
# Check that settings were NOT loaded
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||
output_style = message.data.get("output_style")
|
||||
assert (
|
||||
output_style != "local-test-style"
|
||||
), f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}"
|
||||
assert (
|
||||
output_style == "default"
|
||||
), f"outputStyle should be 'default', got: {output_style}"
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_setting_sources_user_only():
|
||||
"""Test that setting_sources=['user'] excludes project settings."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a temporary project with a slash command
|
||||
project_dir = Path(tmpdir)
|
||||
commands_dir = project_dir / ".claude" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
|
||||
test_command = commands_dir / "testcmd.md"
|
||||
test_command.write_text(
|
||||
"""---
|
||||
description: Test command
|
||||
---
|
||||
|
||||
This is a test command.
|
||||
"""
|
||||
)
|
||||
|
||||
# Use setting_sources=["user"] to exclude project settings
|
||||
options = ClaudeCodeOptions(
|
||||
setting_sources=["user"],
|
||||
cwd=project_dir,
|
||||
max_turns=1,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
# Check that project command is NOT available
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||
commands = message.data.get("slash_commands", [])
|
||||
assert (
|
||||
"testcmd" not in commands
|
||||
), f"testcmd should NOT be available with user-only sources, got: {commands}"
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_setting_sources_project_included():
|
||||
"""Test that setting_sources=['user', 'project'] includes project settings."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a temporary project with local settings
|
||||
project_dir = Path(tmpdir)
|
||||
claude_dir = project_dir / ".claude"
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
# Create local settings with custom outputStyle
|
||||
settings_file = claude_dir / "settings.local.json"
|
||||
settings_file.write_text('{"outputStyle": "local-test-style"}')
|
||||
|
||||
# Use setting_sources=["user", "project", "local"] to include local settings
|
||||
options = ClaudeCodeOptions(
|
||||
setting_sources=["user", "project", "local"],
|
||||
cwd=project_dir,
|
||||
max_turns=1,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
# Check that settings WERE loaded
|
||||
async for message in client.receive_response():
|
||||
if isinstance(message, SystemMessage) and message.subtype == "init":
|
||||
output_style = message.data.get("output_style")
|
||||
assert (
|
||||
output_style == "local-test-style"
|
||||
), f"outputStyle should be from local settings, got: {output_style}"
|
||||
break
|
||||
124
examples/agents.py
Normal file
124
examples/agents.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example of using custom agents with Claude Code SDK.
|
||||
|
||||
This example demonstrates how to define and use custom agents with specific
|
||||
tools, prompts, and models.
|
||||
|
||||
Usage:
|
||||
./examples/agents.py - Run the example
|
||||
"""
|
||||
|
||||
import anyio
|
||||
|
||||
from claude_code_sdk import (
|
||||
AgentDefinition,
|
||||
AssistantMessage,
|
||||
ClaudeCodeOptions,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
query,
|
||||
)
|
||||
|
||||
|
||||
async def code_reviewer_example():
|
||||
"""Example using a custom code reviewer agent."""
|
||||
print("=== Code Reviewer Agent Example ===")
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
agents={
|
||||
"code-reviewer": AgentDefinition(
|
||||
description="Reviews code for best practices and potential issues",
|
||||
prompt="You are a code reviewer. Analyze code for bugs, performance issues, "
|
||||
"security vulnerabilities, and adherence to best practices. "
|
||||
"Provide constructive feedback.",
|
||||
tools=["Read", "Grep"],
|
||||
model="sonnet",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async for message in query(
|
||||
prompt="Use the code-reviewer agent to review the code in src/claude_code_sdk/types.py",
|
||||
options=options,
|
||||
):
|
||||
if isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(f"Claude: {block.text}")
|
||||
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||
print()
|
||||
|
||||
|
||||
async def documentation_writer_example():
|
||||
"""Example using a documentation writer agent."""
|
||||
print("=== Documentation Writer Agent Example ===")
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
agents={
|
||||
"doc-writer": AgentDefinition(
|
||||
description="Writes comprehensive documentation",
|
||||
prompt="You are a technical documentation expert. Write clear, comprehensive "
|
||||
"documentation with examples. Focus on clarity and completeness.",
|
||||
tools=["Read", "Write", "Edit"],
|
||||
model="sonnet",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async for message in query(
|
||||
prompt="Use the doc-writer agent to explain what AgentDefinition is used for",
|
||||
options=options,
|
||||
):
|
||||
if isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(f"Claude: {block.text}")
|
||||
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||
print()
|
||||
|
||||
|
||||
async def multiple_agents_example():
|
||||
"""Example with multiple custom agents."""
|
||||
print("=== Multiple Agents Example ===")
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
agents={
|
||||
"analyzer": AgentDefinition(
|
||||
description="Analyzes code structure and patterns",
|
||||
prompt="You are a code analyzer. Examine code structure, patterns, and architecture.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
),
|
||||
"tester": AgentDefinition(
|
||||
description="Creates and runs tests",
|
||||
prompt="You are a testing expert. Write comprehensive tests and ensure code quality.",
|
||||
tools=["Read", "Write", "Bash"],
|
||||
model="sonnet",
|
||||
),
|
||||
},
|
||||
setting_sources=["user", "project"],
|
||||
)
|
||||
|
||||
async for message in query(
|
||||
prompt="Use the analyzer agent to find all Python files in the examples/ directory",
|
||||
options=options,
|
||||
):
|
||||
if isinstance(message, AssistantMessage):
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock):
|
||||
print(f"Claude: {block.text}")
|
||||
elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0:
|
||||
print(f"\nCost: ${message.total_cost_usd:.4f}")
|
||||
print()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all agent examples."""
|
||||
await code_reviewer_example()
|
||||
await documentation_writer_example()
|
||||
await multiple_agents_example()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
anyio.run(main)
|
||||
174
examples/setting_sources.py
Normal file
174
examples/setting_sources.py
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example demonstrating setting sources control.
|
||||
|
||||
This example shows how to use the setting_sources option to control which
|
||||
settings are loaded, including custom slash commands, agents, and other
|
||||
configurations.
|
||||
|
||||
Setting sources determine where Claude Code loads configurations from:
|
||||
- "user": Global user settings (~/.claude/)
|
||||
- "project": Project-level settings (.claude/ in project)
|
||||
- "local": Local gitignored settings (.claude-local/)
|
||||
|
||||
IMPORTANT: When setting_sources is not provided (None), NO settings are loaded
|
||||
by default. This creates an isolated environment. To load settings, explicitly
|
||||
specify which sources to use.
|
||||
|
||||
By controlling which sources are loaded, you can:
|
||||
- Create isolated environments with no custom settings (default)
|
||||
- Load only user settings, excluding project-specific configurations
|
||||
- Combine multiple sources as needed
|
||||
|
||||
Usage:
|
||||
./examples/setting_sources.py - List the examples
|
||||
./examples/setting_sources.py all - Run all examples
|
||||
./examples/setting_sources.py default - Run a specific example
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from claude_code_sdk import (
|
||||
ClaudeCodeOptions,
|
||||
ClaudeSDKClient,
|
||||
SystemMessage,
|
||||
)
|
||||
|
||||
|
||||
def extract_slash_commands(msg: SystemMessage) -> list[str]:
|
||||
"""Extract slash command names from system message."""
|
||||
if msg.subtype == "init":
|
||||
commands = msg.data.get("slash_commands", [])
|
||||
return commands
|
||||
return []
|
||||
|
||||
|
||||
async def example_default():
|
||||
"""Default behavior - no settings loaded."""
|
||||
print("=== Default Behavior Example ===")
|
||||
print("Setting sources: None (default)")
|
||||
print("Expected: No custom slash commands will be available\n")
|
||||
|
||||
sdk_dir = Path(__file__).parent.parent
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
cwd=sdk_dir,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
async for msg in client.receive_response():
|
||||
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||
commands = extract_slash_commands(msg)
|
||||
print(f"Available slash commands: {commands}")
|
||||
if "commit" in commands:
|
||||
print("❌ /commit is available (unexpected)")
|
||||
else:
|
||||
print("✓ /commit is NOT available (expected - no settings loaded)")
|
||||
break
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def example_user_only():
|
||||
"""Load only user-level settings, excluding project settings."""
|
||||
print("=== User Settings Only Example ===")
|
||||
print("Setting sources: ['user']")
|
||||
print("Expected: Project slash commands (like /commit) will NOT be available\n")
|
||||
|
||||
# Use the SDK repo directory which has .claude/commands/commit.md
|
||||
sdk_dir = Path(__file__).parent.parent
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
setting_sources=["user"],
|
||||
cwd=sdk_dir,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
# Send a simple query
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
# Check the initialize message for available commands
|
||||
async for msg in client.receive_response():
|
||||
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||
commands = extract_slash_commands(msg)
|
||||
print(f"Available slash commands: {commands}")
|
||||
if "commit" in commands:
|
||||
print("❌ /commit is available (unexpected)")
|
||||
else:
|
||||
print("✓ /commit is NOT available (expected)")
|
||||
break
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def example_project_and_user():
|
||||
"""Load both project and user settings."""
|
||||
print("=== Project + User Settings Example ===")
|
||||
print("Setting sources: ['user', 'project']")
|
||||
print("Expected: Project slash commands (like /commit) WILL be available\n")
|
||||
|
||||
sdk_dir = Path(__file__).parent.parent
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
setting_sources=["user", "project"],
|
||||
cwd=sdk_dir,
|
||||
)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query("What is 2 + 2?")
|
||||
|
||||
async for msg in client.receive_response():
|
||||
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||
commands = extract_slash_commands(msg)
|
||||
print(f"Available slash commands: {commands}")
|
||||
if "commit" in commands:
|
||||
print("✓ /commit is available (expected)")
|
||||
else:
|
||||
print("❌ /commit is NOT available (unexpected)")
|
||||
break
|
||||
|
||||
print()
|
||||
|
||||
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all examples or a specific example based on command line argument."""
|
||||
examples = {
|
||||
"default": example_default,
|
||||
"user_only": example_user_only,
|
||||
"project_and_user": example_project_and_user,
|
||||
}
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python setting_sources.py <example_name>")
|
||||
print("\nAvailable examples:")
|
||||
print(" all - Run all examples")
|
||||
for name in examples:
|
||||
print(f" {name}")
|
||||
sys.exit(0)
|
||||
|
||||
example_name = sys.argv[1]
|
||||
|
||||
if example_name == "all":
|
||||
for example in examples.values():
|
||||
await example()
|
||||
print("-" * 50 + "\n")
|
||||
elif example_name in examples:
|
||||
await examples[example_name]()
|
||||
else:
|
||||
print(f"Error: Unknown example '{example_name}'")
|
||||
print("\nAvailable examples:")
|
||||
print(" all - Run all examples")
|
||||
for name in examples:
|
||||
print(f" {name}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting Claude SDK Setting Sources Examples...")
|
||||
print("=" * 50 + "\n")
|
||||
asyncio.run(main())
|
||||
@@ -16,6 +16,7 @@ from ._version import __version__
|
||||
from .client import ClaudeSDKClient
|
||||
from .query import query
|
||||
from .types import (
|
||||
AgentDefinition,
|
||||
AssistantMessage,
|
||||
CanUseTool,
|
||||
ClaudeCodeOptions,
|
||||
@@ -32,6 +33,7 @@ from .types import (
|
||||
PermissionResultDeny,
|
||||
PermissionUpdate,
|
||||
ResultMessage,
|
||||
SettingSource,
|
||||
SystemMessage,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
@@ -307,6 +309,9 @@ __all__ = [
|
||||
"HookCallback",
|
||||
"HookContext",
|
||||
"HookMatcher",
|
||||
# Agent support
|
||||
"AgentDefinition",
|
||||
"SettingSource",
|
||||
# MCP Server Support
|
||||
"create_sdk_mcp_server",
|
||||
"tool",
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import shutil
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE
|
||||
from typing import Any
|
||||
@@ -157,6 +158,20 @@ class SubprocessCLITransport(Transport):
|
||||
if self._options.fork_session:
|
||||
cmd.append("--fork-session")
|
||||
|
||||
if self._options.agents:
|
||||
agents_dict = {
|
||||
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
|
||||
for name, agent_def in self._options.agents.items()
|
||||
}
|
||||
cmd.extend(["--agents", json.dumps(agents_dict)])
|
||||
|
||||
sources_value = (
|
||||
",".join(self._options.setting_sources)
|
||||
if self._options.setting_sources is not None
|
||||
else ""
|
||||
)
|
||||
cmd.extend(["--setting-sources", sources_value])
|
||||
|
||||
# Add extra args for future CLI flags
|
||||
for flag, value in self._options.extra_args.items():
|
||||
if value is None:
|
||||
|
||||
@@ -14,6 +14,19 @@ if TYPE_CHECKING:
|
||||
# Permission modes
|
||||
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
||||
|
||||
# Agent definitions
|
||||
SettingSource = Literal["user", "project", "local"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
"""Agent definition configuration."""
|
||||
|
||||
description: str
|
||||
prompt: str
|
||||
tools: list[str] | None = None
|
||||
model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None
|
||||
|
||||
|
||||
# Permission Update types (matching TypeScript SDK)
|
||||
PermissionUpdateDestination = Literal[
|
||||
@@ -317,6 +330,10 @@ class ClaudeCodeOptions:
|
||||
# When true resumed sessions will fork to a new session ID rather than
|
||||
# continuing the previous session.
|
||||
fork_session: bool = False
|
||||
# Agent definitions for custom agents
|
||||
agents: dict[str, AgentDefinition] | None = None
|
||||
# Setting sources to load (user, project, local)
|
||||
setting_sources: list[SettingSource] | None = None
|
||||
|
||||
|
||||
# SDK Control Protocol
|
||||
|
||||
Reference in New Issue
Block a user