diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 792e1a1..77bd421 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -114,6 +114,9 @@ Done: - [x] Query-engine runtime context-reduction summaries - [x] Query-engine runtime lineage summaries - [x] Query-engine runtime resumed-child orchestration summaries +- [x] Filesystem-backed custom agent discovery from `~/.claude/agents` and `./.claude/agents` +- [x] Active agent override precedence across built-in, user, and project agent definitions +- [x] Custom agent resolution in the `Agent` tool with model, tool-filter, and initial-prompt support Missing: @@ -141,6 +144,7 @@ Done: - [x] `agent-context` command - [x] `agent-context-raw` command - [x] `token-budget` command +- [x] `agents` command - [x] Local background session mode - [x] Local background session listing (`agent-ps`) - [x] Local background session logs (`agent-logs`) @@ -195,6 +199,7 @@ Done: - [x] Local planning guidance section in the Python system prompt - [x] Local task guidance section in the Python system prompt - [x] Local LSP guidance section in the Python system prompt +- [x] Local agent-configuration guidance section in the Python system prompt - [x] Product metadata/branding from `constants/product.ts` — ported to `src/prompt_constants.py` - [x] API limits constants from `constants/apiLimits.ts` — ported to `src/prompt_constants.py` @@ -312,7 +317,7 @@ Done (53 slash command names in 37 specs): Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/add-dir` — Add a new working directory -- [ ] `/agents` — Manage agent configurations +- [x] `/agents` — Inspect local agent configurations and show active definitions - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions - [ ] `/btw` — Quick side question without interrupting main conversation @@ -361,6 +366,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. - [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. - [x] `/commit` — Create a git commit (prompt-type with injected git context) +- [ ] Full `/agents` parity for create/edit/delete flows and multi-source management UI ## 6. Built-in Tools diff --git a/README.md b/README.md index d85b56d..e8a3c62 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ | 🆕 | **Plugin Runtime** | Full manifest-based plugin system — hooks, tool aliases, virtual tools, tool blocking | | 🆕 | **Nested Agent Delegation** | Delegate subtasks to child agents with dependency-aware topological batching | | 🆕 | **Agent Manager** | Lineage tracking, group membership, batch summaries for nested agents | +| 🆕 | **Custom Agent Profiles** | Discover local markdown-defined agents from `~/.claude/agents` and `./.claude/agents` and use them through the `Agent` tool | | 🆕 | **Cost Tracking & Budgets** | Token budgets, cost budgets, tool-call limits, model-call limits, session-turn limits | | 🆕 | **Structured Output** | JSON schema response mode with `--response-schema-file` | | 🆕 | **Context Compaction** | Auto-snip, auto-compact, and reactive compaction on prompt-too-long errors | @@ -87,6 +88,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github | 🧰 **Core Tools** | File read / write / edit, glob search, grep search, shell execution | | 🔌 **Plugin Runtime** | Manifest-based plugins with hooks, aliases, virtual tools, and tool blocking | | 🪆 **Nested Delegation** | Delegate subtasks to child agents with dependency-aware topological batching | +| 🧩 **Custom Agents** | Load local agent profiles from `~/.claude/agents` and `./.claude/agents`, inspect them via `/agents`, and delegate with `subagent_type` | | 📡 **Streaming** | Token-by-token streaming output with `--stream` | | 💬 **Slash Commands** | Local commands for context, config, account, search, MCP, remote, tasks, plan, hooks, and model control | | 🌐 **Remote Runtime** | Manifest-backed remote profiles with local `remote-mode`, `ssh-mode`, `teleport-mode`, and connect/disconnect state | @@ -134,7 +136,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [x] Ollama, LiteLLM Proxy, and OpenRouter backends - [x] Core tools: `list_dir`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `bash` - [x] Context building and `/context`-style usage reporting -- [x] Slash commands: `/help`, `/context`, `/context-raw`, `/prompt`, `/permissions`, `/model`, `/tools`, `/memory`, `/status`, `/clear` +- [x] Slash commands: `/help`, `/context`, `/context-raw`, `/token-budget`, `/prompt`, `/permissions`, `/model`, `/tools`, `/agents`, `/memory`, `/status`, `/clear` - [x] Session persistence and `agent-resume` flow - [x] Permission system (read-only, write, shell, unsafe tiers) - [x] Streaming token-by-token assistant output @@ -149,6 +151,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [x] File history journaling with snapshot IDs and replay summaries - [x] Nested agent delegation with dependency-aware topological batching - [x] Agent manager with lineage tracking and group membership +- [x] Filesystem-backed custom agent profiles with built-in/user/project precedence - [x] Local daemon-style background command family - [x] Local background session workflows: `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, `agent-kill` - [x] Local remote runtime: manifest discovery, profile listing, connect/disconnect persistence, and CLI/slash flows @@ -205,6 +208,7 @@ claw-code/ │ ├── agent_runtime.py # Core agent loop (LocalCodingAgent) │ ├── agent_tools.py # Tool definitions & execution engine │ ├── agent_prompting.py # System prompt assembly +│ ├── agent_registry.py # Built-in + filesystem-backed custom agent discovery │ ├── agent_context.py # Context building & CLAUDE.md discovery │ ├── agent_context_usage.py # Context usage estimation & reporting │ ├── agent_session.py # Session state management @@ -446,6 +450,7 @@ python3 -m src.main agent \ | `agent-context` | Show estimated context usage | | `agent-context-raw` | Show the raw context snapshot | | `token-budget` | Show prompt-window budget, reserves, and soft/hard input limits | +| `agents [agent_type]` | List active local agent definitions or show one agent profile | | `agent-resume ` | Resume a saved session | ### Runtime Utility Commands @@ -545,6 +550,7 @@ These are handled **locally** before the model loop: | `/permissions` | — | Show active tool permission mode | | `/model` | — | Show or update the active model | | `/tools` | — | List registered tools with permission status | +| `/agents` | — | List active local agent definitions or show one profile | | `/memory` | — | Show loaded CLAUDE.md memory bundle | | `/status` | `/session` | Show runtime/session status summary | | `/clear` | — | Clear ephemeral runtime state | @@ -554,9 +560,42 @@ python3 -m src.main agent "/help" python3 -m src.main agent "/context" --cwd . python3 -m src.main agent "/token-budget" --cwd . python3 -m src.main agent "/tools" --cwd . +python3 -m src.main agent "/agents" --cwd . python3 -m src.main agent "/status" --cwd . ``` +### Custom Agent Definitions + +Custom agent profiles can live in either of these directories: + +- `./.claude/agents/*.md` +- `~/.claude/agents/*.md` + +Project agents override user agents, and user agents override built-ins when the `agent_type` matches. + +Example agent file: + +```md +--- +name: reviewer +description: "Review implementation changes carefully." +tools: read_file, grep_search +model: Qwen/Qwen3-Coder-30B-A3B-Instruct +initialPrompt: Start by identifying the highest-risk files. +--- + +Inspect code changes and summarize correctness risks, regressions, and missing tests. +``` + +Inspect the loaded profiles: + +```bash +python3 -m src.main agents --cwd . +python3 -m src.main agents reviewer --cwd . +python3 -m src.main agent "/agents" --cwd . +python3 -m src.main agent "/agents show reviewer" --cwd . +``` + ### Utility Commands ```bash diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index a47a38d..9dd88f4 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -580,6 +580,24 @@ EOF mkdir -p ./test_cases_tasks ``` +### 4.9 Custom agent definitions + +```bash +mkdir -p ./test_cases_agents/.claude/agents + +cat > ./test_cases_agents/.claude/agents/reviewer.md <<'EOF' +--- +name: reviewer +description: "Review implementation changes carefully." +tools: read_file, grep_search +model: Qwen/Qwen3-Coder-30B-A3B-Instruct +initialPrompt: Start by identifying the highest-risk files. +--- + +Inspect code changes and summarize correctness risks, regressions, and missing tests. +EOF +``` + ## 5. Slash Command Matrix Slash commands are handled locally before the model loop. @@ -601,6 +619,8 @@ python3 -m src.main agent "/permissions" --cwd ./test_cases python3 -m src.main agent "/model" --cwd ./test_cases python3 -m src.main agent "/model demo-model" --cwd ./test_cases python3 -m src.main agent "/tools" --cwd ./test_cases +python3 -m src.main agent "/agents" --cwd ./test_cases_agents +python3 -m src.main agent "/agents show reviewer" --cwd ./test_cases_agents python3 -m src.main agent "/memory" --cwd ./test_cases python3 -m src.main agent "/status" --cwd ./test_cases python3 -m src.main agent "/session" --cwd ./test_cases @@ -678,6 +698,8 @@ python3 -m src.main agent-prompt --cwd ./test_cases python3 -m src.main agent-context --cwd ./test_cases python3 -m src.main agent-context-raw --cwd ./test_cases python3 -m src.main token-budget --cwd ./test_cases +python3 -m src.main agents --cwd ./test_cases_agents +python3 -m src.main agents reviewer --cwd ./test_cases_agents ``` ### 6.2 Extra working directories and `CLAUDE.md` toggle diff --git a/src/__init__.py b/src/__init__.py index e540ac7..f195de4 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -11,6 +11,14 @@ from .agent_context import ( set_system_prompt_injection, ) from .agent_manager import AgentManager +from .agent_registry import ( + AgentLoadError, + AgentRegistrySnapshot, + find_agent_definition, + load_agent_registry, + render_agent_detail, + render_agents_report, +) from .agent_runtime import LocalCodingAgent from .agent_session import AgentMessage, AgentSessionState from .agent_tools import build_tool_context, default_tool_registry, execute_tool @@ -42,7 +50,9 @@ from .tools import PORTED_TOOLS, build_tool_backlog __all__ = [ 'AgentContextSnapshot', 'AgentManager', + 'AgentLoadError', 'AgentPermissions', + 'AgentRegistrySnapshot', 'AgentRunResult', 'AgentRuntimeConfig', 'AccountProfile', @@ -114,10 +124,14 @@ __all__ = [ 'describe_token_counter', 'estimate_chat_overhead', 'execute_tool', + 'find_agent_definition', 'format_token_budget', 'get_system_context', 'get_user_context', + 'load_agent_registry', 'load_session', + 'render_agent_detail', + 'render_agents_report', 'run_parity_audit', 'save_session', 'set_system_prompt_injection', diff --git a/src/agent_prompting.py b/src/agent_prompting.py index 42b5224..2b6b728 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -6,6 +6,7 @@ from pathlib import Path from .agent_context import build_context_snapshot from .agent_tools import AgentTool from .agent_types import AgentRuntimeConfig, ModelConfig +from .builtin_agents import AgentDefinition, format_agent_listing SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__' @@ -76,6 +77,7 @@ def build_system_prompt_parts( prompt_context: PromptContext, runtime_config: AgentRuntimeConfig, tools: dict[str, AgentTool], + available_agents: tuple[AgentDefinition, ...] = (), custom_system_prompt: str | None = None, append_system_prompt: str | None = None, override_system_prompt: str | None = None, @@ -90,6 +92,7 @@ def build_system_prompt_parts( get_doing_tasks_section(), get_actions_section(), get_using_your_tools_section(enabled_tool_names), + get_agent_guidance_section(enabled_tool_names, available_agents), get_plugin_guidance_section(prompt_context), get_mcp_guidance_section(prompt_context), get_remote_guidance_section(prompt_context), @@ -204,6 +207,26 @@ def get_tone_and_style_section() -> str: return '\n'.join(['# Tone and style', *prepend_bullets(items)]) +def get_agent_guidance_section( + enabled_tool_names: set[str], + available_agents: tuple[AgentDefinition, ...], +) -> str: + if 'Agent' not in enabled_tool_names and 'delegate_agent' not in enabled_tool_names: + return '' + items: list[str] = [ + 'Use the Agent tool when a bounded subtask can be delegated to a specialized agent profile.', + 'Pick a specific subagent_type when one of the available agent profiles clearly fits the task.', + ] + if not available_agents: + return '\n'.join(['# Agents', *prepend_bullets(items)]) + rendered_agents = list(format_agent_listing(available_agents[:20]).splitlines()) + items.append('Available agent types:') + items.append(rendered_agents) + if len(available_agents) > 20: + items.append(f'... plus {len(available_agents) - 20} more agent definitions.') + return '\n'.join(['# Agents', *prepend_bullets(items)]) + + def get_plugin_guidance_section(prompt_context: PromptContext) -> str: plugin_cache = prompt_context.user_context.get('pluginCache') plugin_runtime = prompt_context.user_context.get('pluginRuntime') diff --git a/src/agent_registry.py b/src/agent_registry.py new file mode 100644 index 0000000..cd8f359 --- /dev/null +++ b/src/agent_registry.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import ast +import csv +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .builtin_agents import AgentDefinition, describe_agent_tools, get_builtin_agents + +_FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n?(.*)$', re.DOTALL) +_AGENTS_DIR = Path('.claude') / 'agents' +_SOURCE_ORDER = { + 'built-in': 0, + 'userSettings': 1, + 'projectSettings': 2, +} + + +@dataclass(frozen=True) +class AgentLoadError: + path: str + source: str + error: str + + +@dataclass(frozen=True) +class AgentRegistrySnapshot: + all_agents: tuple[AgentDefinition, ...] + active_agents: tuple[AgentDefinition, ...] + shadowed_agents: tuple[AgentDefinition, ...] + failed_files: tuple[AgentLoadError, ...] + + +def load_agent_registry(cwd: Path) -> AgentRegistrySnapshot: + builtin_agents = tuple(get_builtin_agents()) + loaded_agents: list[AgentDefinition] = list(builtin_agents) + failed_files: list[AgentLoadError] = [] + for source, directory in iter_agent_directories(cwd): + if not directory.is_dir(): + continue + for path in sorted(directory.glob('*.md')): + try: + loaded_agents.append(load_agent_markdown(path, source=source)) + except (OSError, ValueError) as exc: + failed_files.append( + AgentLoadError( + path=str(path), + source=source, + error=str(exc), + ) + ) + active_agents, shadowed_agents = resolve_active_agents(tuple(loaded_agents)) + return AgentRegistrySnapshot( + all_agents=tuple(loaded_agents), + active_agents=active_agents, + shadowed_agents=shadowed_agents, + failed_files=tuple(failed_files), + ) + + +def iter_agent_directories(cwd: Path) -> tuple[tuple[str, Path], ...]: + return ( + ('userSettings', Path.home() / _AGENTS_DIR), + ('projectSettings', cwd / _AGENTS_DIR), + ) + + +def find_agent_definition( + cwd: Path, + agent_type: str, + *, + active_only: bool = True, +) -> AgentDefinition | None: + snapshot = load_agent_registry(cwd) + pool = snapshot.active_agents if active_only else snapshot.all_agents + for agent in pool: + if agent.agent_type == agent_type: + return agent + return None + + +def resolve_active_agents( + all_agents: tuple[AgentDefinition, ...], +) -> tuple[tuple[AgentDefinition, ...], tuple[AgentDefinition, ...]]: + active_by_name: dict[str, AgentDefinition] = {} + for agent in all_agents: + current = active_by_name.get(agent.agent_type) + if current is None or _source_rank(agent.source) >= _source_rank(current.source): + active_by_name[agent.agent_type] = agent + + active_agents = tuple( + sorted( + active_by_name.values(), + key=lambda agent: (_source_rank(agent.source), agent.agent_type.lower()), + ) + ) + shadowed_agents = tuple( + sorted( + ( + agent + for agent in all_agents + if active_by_name.get(agent.agent_type) is not agent + ), + key=lambda agent: (agent.agent_type.lower(), _source_rank(agent.source)), + ) + ) + return active_agents, shadowed_agents + + +def load_agent_markdown(path: Path, *, source: str) -> AgentDefinition: + text = path.read_text(encoding='utf-8') + metadata, body = _split_frontmatter(text) + agent_type = str(metadata.get('name') or path.stem).strip() + if not agent_type: + raise ValueError(f'Agent file {path} is missing a name') + when_to_use = str( + metadata.get('description') + or metadata.get('whenToUse') + or metadata.get('when_to_use') + or '' + ).strip() + if not when_to_use: + raise ValueError(f'Agent file {path} is missing a description') + system_prompt = body.strip() + if not system_prompt: + system_prompt = str(metadata.get('prompt') or '').strip() + if not system_prompt: + raise ValueError(f'Agent file {path} is missing a system prompt body') + tools = _parse_tool_list(metadata.get('tools')) + disallowed_tools = tuple(_coerce_string_list(metadata.get('disallowedTools'))) + return AgentDefinition( + agent_type=agent_type, + when_to_use=when_to_use, + system_prompt=system_prompt, + model=_coerce_optional_string(metadata.get('model')), + tools=tools, + disallowed_tools=disallowed_tools, + color=_coerce_optional_string(metadata.get('color')), + background=_coerce_bool(metadata.get('background')), + one_shot=_coerce_bool(metadata.get('oneShot')), + omit_claude_md=_coerce_bool(metadata.get('omitClaudeMd')), + permission_mode=_coerce_optional_string( + metadata.get('permissionMode') or metadata.get('permission_mode') + ), + max_turns=_coerce_optional_int(metadata.get('maxTurns')), + critical_system_reminder=_coerce_optional_string( + metadata.get('criticalSystemReminder') + or metadata.get('criticalSystemReminder_EXPERIMENTAL') + ), + source=source, + filename=path.stem, + base_dir=str(path.parent), + skills=tuple(_coerce_string_list(metadata.get('skills'))), + memory=_coerce_optional_string(metadata.get('memory')), + effort=_coerce_effort(metadata.get('effort')), + initial_prompt=_coerce_optional_string( + metadata.get('initialPrompt') or metadata.get('initial_prompt') + ), + isolation=_coerce_optional_string(metadata.get('isolation')), + hook_names=tuple(_coerce_hook_names(metadata.get('hooks'))), + ) + + +def render_agents_report( + snapshot: AgentRegistrySnapshot, + *, + cwd: Path, + show_all: bool = False, +) -> str: + lines = [ + '# Agents', + '', + f'- Active agent types: {len(snapshot.active_agents)}', + f'- All discovered definitions: {len(snapshot.all_agents)}', + f'- Shadowed definitions: {len(snapshot.shadowed_agents)}', + f'- Failed files: {len(snapshot.failed_files)}', + '- Source precedence: built-in < userSettings < projectSettings', + '', + '## Sources', + f'- built-in: {_count_source(snapshot.all_agents, "built-in")}', + ] + for source, directory in iter_agent_directories(cwd): + lines.append( + f'- {source}: {_count_source(snapshot.all_agents, source)} ({directory})' + ) + lines.extend(['', '## All Agents' if show_all else '## Active Agents']) + visible_agents = snapshot.all_agents if show_all else snapshot.active_agents + if not visible_agents: + lines.append('No agent definitions were discovered.') + else: + for agent in visible_agents: + lines.append( + f'- {agent.agent_type} [{agent.source}]' + f' ; tools={describe_agent_tools(agent)}' + f' ; model={agent.model or "inherit"}' + ) + if snapshot.shadowed_agents: + lines.extend(['', '## Shadowed Agents']) + for agent in snapshot.shadowed_agents: + winner = _winner_for(snapshot, agent.agent_type) + winner_desc = winner.source if winner is not None else 'unknown' + lines.append( + f'- {agent.agent_type} [{agent.source}] shadowed by {winner_desc}' + ) + if snapshot.failed_files: + lines.extend(['', '## Failed Files']) + for item in snapshot.failed_files: + lines.append(f'- {item.path}: {item.error}') + return '\n'.join(lines) + + +def render_agent_detail(snapshot: AgentRegistrySnapshot, agent_type: str) -> str: + agent = next( + (candidate for candidate in snapshot.active_agents if candidate.agent_type == agent_type), + None, + ) + if agent is None: + agent = next( + (candidate for candidate in snapshot.all_agents if candidate.agent_type == agent_type), + None, + ) + if agent is None: + return f'# Agent\n\nUnknown agent: {agent_type}' + + lines = [ + f'# Agent: {agent.agent_type}', + '', + f'- Source: {agent.source}', + f'- Model: {agent.model or "inherit"}', + f'- Tools: {describe_agent_tools(agent)}', + f'- Permission mode: {agent.permission_mode or "(default)"}', + f'- Max turns: {agent.max_turns if agent.max_turns is not None else "(default)"}', + f'- Background: {agent.background}', + f'- One-shot: {agent.one_shot}', + f'- Omit CLAUDE.md: {agent.omit_claude_md}', + ] + if agent.base_dir: + lines.append(f'- Base directory: {agent.base_dir}') + if agent.filename: + lines.append(f'- Definition file: {Path(agent.base_dir or ".") / (agent.filename + ".md")}') + if agent.color: + lines.append(f'- Color: {agent.color}') + if agent.memory: + lines.append(f'- Memory: {agent.memory}') + if agent.effort is not None: + lines.append(f'- Effort: {agent.effort}') + if agent.isolation: + lines.append(f'- Isolation: {agent.isolation}') + if agent.skills: + lines.append(f'- Skills: {", ".join(agent.skills)}') + if agent.hook_names: + lines.append(f'- Hooks: {", ".join(agent.hook_names)}') + if agent.initial_prompt: + lines.extend(['', '## Initial Prompt', agent.initial_prompt]) + lines.extend(['', '## When To Use', agent.when_to_use, '', '## System Prompt', agent.system_prompt]) + return '\n'.join(lines) + + +def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: + normalized = text.replace('\r\n', '\n') + match = _FRONTMATTER_RE.match(normalized) + if match is None: + return {}, normalized + return _parse_frontmatter(match.group(1)), match.group(2) + + +def _parse_frontmatter(block: str) -> dict[str, Any]: + metadata: dict[str, Any] = {} + for raw_line in block.splitlines(): + line = raw_line.strip() + if not line or line.startswith('#'): + continue + if ':' not in line: + continue + key, raw_value = line.split(':', 1) + metadata[key.strip()] = _parse_frontmatter_value(raw_value.strip()) + return metadata + + +def _parse_frontmatter_value(value: str) -> Any: + if not value: + return '' + if value.startswith(('"', "'")) and value.endswith(('"', "'")): + try: + return ast.literal_eval(value) + except (ValueError, SyntaxError): + return value[1:-1] + lowered = value.lower() + if lowered == 'true': + return True + if lowered == 'false': + return False + if re.fullmatch(r'-?\d+', value): + return int(value) + if value.startswith('[') and value.endswith(']'): + inner = value[1:-1].strip() + if not inner: + return [] + reader = csv.reader([inner], skipinitialspace=True) + return [item.strip().strip('"').strip("'") for item in next(reader)] + return value + + +def _parse_tool_list(value: Any) -> tuple[str, ...] | None: + if value is None or value == '': + return None + tools = tuple(_coerce_string_list(value)) + if not tools: + return None + if tools == ('*',): + return None + return tools + + +def _coerce_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return [] + if stripped.startswith('[') and stripped.endswith(']'): + parsed = _parse_frontmatter_value(stripped) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + return [item.strip() for item in stripped.split(',') if item.strip()] + if isinstance(value, (list, tuple)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] + + +def _coerce_optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _coerce_optional_int(value: Any) -> int | None: + if value is None or value == '': + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + try: + return int(str(value).strip()) + except ValueError: + return None + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + return bool(value) + + +def _coerce_effort(value: Any) -> str | int | None: + if value is None or value == '': + return None + if isinstance(value, int): + return value + text = str(value).strip() + if not text: + return None + if text.isdigit(): + return int(text) + return text + + +def _coerce_hook_names(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, dict): + return [str(item).strip() for item in value if str(item).strip()] + return _coerce_string_list(value) + + +def _count_source(all_agents: tuple[AgentDefinition, ...], source: str) -> int: + return sum(1 for agent in all_agents if agent.source == source) + + +def _winner_for(snapshot: AgentRegistrySnapshot, agent_type: str) -> AgentDefinition | None: + for agent in snapshot.active_agents: + if agent.agent_type == agent_type: + return agent + return None + + +def _source_rank(source: str) -> int: + return _SOURCE_ORDER.get(source, -1) diff --git a/src/agent_runtime.py b/src/agent_runtime.py index d509259..b94dd91 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -14,6 +14,12 @@ from .agent_context import render_context_report as render_agent_context_report from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage from .compact import compact_conversation from .ask_user_runtime import AskUserRuntime +from .agent_registry import ( + find_agent_definition, + load_agent_registry, + render_agent_detail, + render_agents_report, +) from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime from .lsp_runtime import LSPRuntime @@ -67,9 +73,6 @@ from .session_store import ( from .token_budget import calculate_token_budget, format_token_budget from .builtin_agents import ( AgentDefinition, - get_agent_definition, - get_builtin_agents, - format_agent_listing, ALL_AGENT_DISALLOWED_TOOLS, GENERAL_PURPOSE_AGENT, ) @@ -256,11 +259,18 @@ class LocalCodingAgent: prompt_context=prompt_context, runtime_config=self.runtime_config, tools=self.tool_registry, + available_agents=self.available_agents(), custom_system_prompt=self.custom_system_prompt, append_system_prompt=self.append_system_prompt, override_system_prompt=self.override_system_prompt, ) + def load_agent_registry(self): + return load_agent_registry(self.runtime_config.cwd) + + def available_agents(self) -> tuple[AgentDefinition, ...]: + return self.load_agent_registry().active_agents + def build_session( self, user_prompt: str | None = None, @@ -2130,7 +2140,7 @@ class LocalCodingAgent: """Resolve the agent definition from subagent_type or default to general-purpose.""" subagent_type = arguments.get('subagent_type') if isinstance(subagent_type, str) and subagent_type: - agent_def = get_agent_definition(subagent_type) + agent_def = find_agent_definition(self.runtime_config.cwd, subagent_type) if agent_def is not None: return agent_def return GENERAL_PURPOSE_AGENT @@ -2145,17 +2155,12 @@ class LocalCodingAgent: agent_model = agent_def.model # Explicit model param in arguments takes priority - if isinstance(model_override, str) and model_override in ('sonnet', 'opus', 'haiku'): - # Map friendly names to actual model identifiers if a mapping is available, - # otherwise use the parent's model config as base - return self.model_config + if isinstance(model_override, str) and model_override.strip(): + return replace(self.model_config, model=model_override.strip()) # Agent definition model if agent_model and agent_model != 'inherit': - # Agent definitions may specify 'haiku', 'sonnet', 'opus' - # For now, inherit the parent's model config (model routing would - # require a model registry which is out of scope) - return self.model_config + return replace(self.model_config, model=agent_model) return self.model_config @@ -2394,7 +2399,12 @@ class LocalCodingAgent: child_agent.managed_agent_id, child_index=index, ) + resume_session_id = subtask.get('resume_session_id') child_prompt = str(subtask['prompt']) + if agent_def.initial_prompt and not ( + isinstance(resume_session_id, str) and resume_session_id + ): + child_prompt = f'{agent_def.initial_prompt.strip()}\n\n{child_prompt}'.strip() if delegate_preflight_messages: child_prompt = self._prepend_plugin_delegate_context( child_prompt, @@ -2402,7 +2412,6 @@ class LocalCodingAgent: ) if include_parent_context and prior_results: child_prompt = self._prepend_delegate_context(child_prompt, prior_results) - resume_session_id = subtask.get('resume_session_id') resume_used = False if isinstance(resume_session_id, str) and resume_session_id: try: @@ -3397,6 +3406,18 @@ class LocalCodingAgent: lines.append(f'- `{tool.name}`: {tool.description} [{state}]') return '\n'.join(lines) + def render_agents_report(self, *, show_all: bool = False) -> str: + snapshot = self.load_agent_registry() + return render_agents_report( + snapshot, + cwd=self.runtime_config.cwd, + show_all=show_all, + ) + + def render_agent_detail_report(self, agent_type: str) -> str: + snapshot = self.load_agent_registry() + return render_agent_detail(snapshot, agent_type) + def render_memory_report(self) -> str: prompt_context = self.build_prompt_context() claude_md = prompt_context.user_context.get('claudeMd') diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 0bd4070..fd72ccc 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -289,6 +289,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='List the registered tools and whether the current permissions allow them.', handler=_handle_tools, ), + SlashCommandSpec( + names=('agents',), + description='List active local agent configurations or show one agent definition by name.', + handler=_handle_agents, + ), SlashCommandSpec( names=('memory',), description='Show the currently loaded CLAUDE.md memory bundle and discovered files.', @@ -798,6 +803,20 @@ def _handle_tools(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sla return _local_result(input_text, agent.render_tools_report()) +def _handle_agents(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + trimmed = args.strip() + if not trimmed or trimmed in {'active', 'list'}: + return _local_result(input_text, agent.render_agents_report()) + if trimmed == 'all': + return _local_result(input_text, agent.render_agents_report(show_all=True)) + if trimmed.startswith('show '): + agent_type = trimmed[5:].strip() + if not agent_type: + return _local_result(input_text, 'Usage: /agents [all|active|show ]') + return _local_result(input_text, agent.render_agent_detail_report(agent_type)) + return _local_result(input_text, agent.render_agent_detail_report(trimmed)) + + def _handle_memory(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: return _local_result(input_text, agent.render_memory_report()) diff --git a/src/builtin_agents.py b/src/builtin_agents.py index 7283fc1..d211888 100644 --- a/src/builtin_agents.py +++ b/src/builtin_agents.py @@ -34,6 +34,14 @@ class AgentDefinition: max_turns: int | None = None critical_system_reminder: str | None = None source: str = 'built-in' + filename: str | None = None + base_dir: str | None = None + skills: tuple[str, ...] = () + memory: str | None = None + effort: str | int | None = None + initial_prompt: str | None = None + isolation: str | None = None + hook_names: tuple[str, ...] = () # --------------------------------------------------------------------------- @@ -411,12 +419,12 @@ def format_agent_listing(agents: tuple[AgentDefinition, ...] | None = None) -> s agents = _BUILTIN_AGENTS lines: list[str] = [] for agent in agents: - tools_desc = _describe_tools(agent) + tools_desc = describe_agent_tools(agent) lines.append(f'- {agent.agent_type}: {agent.when_to_use} (Tools: {tools_desc})') return '\n'.join(lines) -def _describe_tools(agent: AgentDefinition) -> str: +def describe_agent_tools(agent: AgentDefinition) -> str: """Describe the tool access for an agent definition.""" if agent.tools is not None: return ', '.join(agent.tools) if agent.tools else 'none' diff --git a/src/main.py b/src/main.py index 586c2e5..1d1cae9 100644 --- a/src/main.py +++ b/src/main.py @@ -874,6 +874,11 @@ def build_parser() -> argparse.ArgumentParser: token_budget_parser = subparsers.add_parser('token-budget', help='render the current token budget and prompt-length limits') _add_agent_common_args(token_budget_parser, include_backend=False) + + agents_parser = subparsers.add_parser('agents', help='list active local agent configurations or show one agent definition') + agents_parser.add_argument('agent_type', nargs='?') + agents_parser.add_argument('--all', action='store_true') + _add_agent_common_args(agents_parser, include_backend=False) return parser @@ -1507,6 +1512,13 @@ def main(argv: list[str] | None = None) -> int: agent.last_session = agent.build_session() print(agent.render_token_budget_report()) return 0 + if args.command == 'agents': + agent = _build_agent(args) + if args.agent_type: + print(agent.render_agent_detail_report(args.agent_type)) + else: + print(agent.render_agents_report(show_all=bool(args.all))) + return 0 parser.error(f'unknown command: {args.command}') return 2 diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py new file mode 100644 index 0000000..f8966ff --- /dev/null +++ b/tests/test_agent_registry.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.agent_registry import load_agent_registry, render_agent_detail, render_agents_report + + +def _write_agent(path: Path, *, name: str, description: str, body: str, extra: str = '') -> None: + payload = ( + '---\n' + f'name: {name}\n' + f'description: "{description}"\n' + f'{extra}' + '---\n\n' + f'{body}\n' + ) + path.write_text(payload, encoding='utf-8') + + +class AgentRegistryTests(unittest.TestCase): + def test_project_agent_overrides_built_in_agent(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + agents_dir = workspace / '.claude' / 'agents' + agents_dir.mkdir(parents=True) + _write_agent( + agents_dir / 'Explore.md', + name='Explore', + description='Project-specific explore agent.', + body='Search this repository carefully before answering.', + extra=( + 'tools: read_file, grep_search\n' + 'model: child-model\n' + 'initialPrompt: Begin with rg-style discovery.\n' + ), + ) + + with patch.dict(os.environ, {'HOME': home_dir}): + snapshot = load_agent_registry(workspace) + + active = {agent.agent_type: agent for agent in snapshot.active_agents} + self.assertIn('Explore', active) + self.assertEqual(active['Explore'].source, 'projectSettings') + self.assertEqual(active['Explore'].model, 'child-model') + self.assertEqual(active['Explore'].tools, ('read_file', 'grep_search')) + self.assertEqual(active['Explore'].initial_prompt, 'Begin with rg-style discovery.') + + report = render_agents_report(snapshot, cwd=workspace) + self.assertIn('Explore [projectSettings]', report) + self.assertIn('Shadowed Agents', report) + + detail = render_agent_detail(snapshot, 'Explore') + self.assertIn('Project-specific explore agent.', detail) + self.assertIn('Begin with rg-style discovery.', detail) + self.assertIn('Search this repository carefully before answering.', detail) + + def test_invalid_agent_file_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + agents_dir = workspace / '.claude' / 'agents' + agents_dir.mkdir(parents=True) + (agents_dir / 'broken.md').write_text( + '---\nname: broken\n---\n', + encoding='utf-8', + ) + + with patch.dict(os.environ, {'HOME': home_dir}): + snapshot = load_agent_registry(workspace) + + self.assertEqual(len(snapshot.failed_files), 1) + self.assertIn('missing a description', snapshot.failed_files[0].error) diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index e091ca3..b2a12d2 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import tempfile import unittest from pathlib import Path @@ -112,6 +113,37 @@ def make_recording_streaming_urlopen_side_effect( class AgentRuntimeTests(unittest.TestCase): + def test_custom_project_agent_is_resolved_for_child_agent_configuration(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + agents_dir = workspace / '.claude' / 'agents' + agents_dir.mkdir(parents=True) + (agents_dir / 'Explore.md').write_text( + ( + '---\n' + 'name: Explore\n' + 'description: "Project-specific explore agent."\n' + 'tools: read_file, grep_search\n' + 'model: child-model\n' + 'initialPrompt: Start with a repository scan.\n' + '---\n\n' + 'Search the repository and report findings.\n' + ), + encoding='utf-8', + ) + with patch.dict(os.environ, {'HOME': home_dir}): + agent = LocalCodingAgent( + model_config=ModelConfig(model='parent-model'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + agent_def = agent._resolve_agent_definition({'subagent_type': 'Explore'}) + child_model = agent._resolve_child_model_config({}, agent_def) + child_tools = agent._filter_tools_for_agent(agent_def) + self.assertEqual(agent_def.source, 'projectSettings') + self.assertEqual(agent_def.initial_prompt, 'Start with a repository scan.') + self.assertEqual(child_model.model, 'child-model') + self.assertEqual(sorted(child_tools), ['grep_search', 'read_file']) + def test_openai_client_parses_tool_calls(self) -> None: responses = [ { diff --git a/tests/test_agent_slash_commands.py b/tests/test_agent_slash_commands.py index 3746045..2ec63c7 100644 --- a/tests/test_agent_slash_commands.py +++ b/tests/test_agent_slash_commands.py @@ -4,6 +4,7 @@ import tempfile import unittest import json import sys +import os from pathlib import Path from unittest.mock import patch @@ -119,6 +120,35 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('Hard input limit', result.final_output) self.assertIn('Auto-compact buffer', result.final_output) + def test_agents_command_lists_and_shows_custom_agents(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + agents_dir = workspace / '.claude' / 'agents' + agents_dir.mkdir(parents=True) + (agents_dir / 'reviewer.md').write_text( + ( + '---\n' + 'name: reviewer\n' + 'description: "Review implementation changes carefully."\n' + 'tools: read_file, grep_search\n' + 'model: test-child-model\n' + '---\n\n' + 'Inspect code changes and summarize risks.\n' + ), + encoding='utf-8', + ) + with patch.dict(os.environ, {'HOME': home_dir}): + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + list_result = agent.run('/agents') + detail_result = agent.run('/agents show reviewer') + self.assertIn('# Agents', list_result.final_output) + self.assertIn('reviewer [projectSettings]', list_result.final_output) + self.assertIn('# Agent: reviewer', detail_result.final_output) + self.assertIn('Inspect code changes and summarize risks.', detail_result.final_output) + def test_mcp_and_resource_commands_render_local_reports(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_main.py b/tests/test_main.py index d39d8d2..a66c8e0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -213,6 +213,14 @@ class MainCliTests(unittest.TestCase): self.assertEqual(args.command, 'token-budget') self.assertEqual(args.cwd, '.') + def test_parser_accepts_agents_command(self) -> None: + parser = build_parser() + args = parser.parse_args(['agents', 'reviewer', '--cwd', '.', '--all']) + self.assertEqual(args.command, 'agents') + self.assertEqual(args.agent_type, 'reviewer') + self.assertTrue(args.all) + self.assertEqual(args.cwd, '.') + def test_parser_accepts_team_runtime_commands(self) -> None: parser = build_parser() args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.'])