Merge pull request #25 from HarnessLab/update_17_4

Implemented the next missing parity slice around agent configurations.
This commit is contained in:
Abdelrahman Abdallah
2026-04-17 05:04:36 +02:00
committed by GitHub
14 changed files with 721 additions and 17 deletions
+7 -1
View File
@@ -114,6 +114,9 @@ Done:
- [x] Query-engine runtime context-reduction summaries - [x] Query-engine runtime context-reduction summaries
- [x] Query-engine runtime lineage summaries - [x] Query-engine runtime lineage summaries
- [x] Query-engine runtime resumed-child orchestration 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: Missing:
@@ -141,6 +144,7 @@ Done:
- [x] `agent-context` command - [x] `agent-context` command
- [x] `agent-context-raw` command - [x] `agent-context-raw` command
- [x] `token-budget` command - [x] `token-budget` command
- [x] `agents` command
- [x] Local background session mode - [x] Local background session mode
- [x] Local background session listing (`agent-ps`) - [x] Local background session listing (`agent-ps`)
- [x] Local background session logs (`agent-logs`) - [x] Local background session logs (`agent-logs`)
@@ -195,6 +199,7 @@ Done:
- [x] Local planning guidance section in the Python system prompt - [x] Local planning guidance section in the Python system prompt
- [x] Local task 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 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] 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` - [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): Missing npm slash commands (from `src/commands/` — 80+ commands total):
- [x] `/add-dir` — Add a new working directory - [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 - [x] `/branch` — Create a branch of the current conversation
- [ ] `/bridge` — Connect for remote-control sessions - [ ] `/bridge` — Connect for remote-control sessions
- [ ] `/btw` — Quick side question without interrupting main conversation - [ ] `/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. - [ ] 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. - [ ] 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) - [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 ## 6. Built-in Tools
+40 -1
View File
@@ -31,6 +31,7 @@
| 🆕 | **Plugin Runtime** | Full manifest-based plugin system — hooks, tool aliases, virtual tools, tool blocking | | 🆕 | **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 | | 🆕 | **Nested Agent Delegation** | Delegate subtasks to child agents with dependency-aware topological batching |
| 🆕 | **Agent Manager** | Lineage tracking, group membership, batch summaries for nested agents | | 🆕 | **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 | | 🆕 | **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` | | 🆕 | **Structured Output** | JSON schema response mode with `--response-schema-file` |
| 🆕 | **Context Compaction** | Auto-snip, auto-compact, and reactive compaction on prompt-too-long errors | | 🆕 | **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 | | 🧰 **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 | | 🔌 **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 | | 🪆 **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` | | 📡 **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 | | 💬 **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 | | 🌐 **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] Ollama, LiteLLM Proxy, and OpenRouter backends
- [x] Core tools: `list_dir`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `bash` - [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] 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] Session persistence and `agent-resume` flow
- [x] Permission system (read-only, write, shell, unsafe tiers) - [x] Permission system (read-only, write, shell, unsafe tiers)
- [x] Streaming token-by-token assistant output - [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] File history journaling with snapshot IDs and replay summaries
- [x] Nested agent delegation with dependency-aware topological batching - [x] Nested agent delegation with dependency-aware topological batching
- [x] Agent manager with lineage tracking and group membership - [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 daemon-style background command family
- [x] Local background session workflows: `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, `agent-kill` - [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 - [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_runtime.py # Core agent loop (LocalCodingAgent)
│ ├── agent_tools.py # Tool definitions & execution engine │ ├── agent_tools.py # Tool definitions & execution engine
│ ├── agent_prompting.py # System prompt assembly │ ├── 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.py # Context building & CLAUDE.md discovery
│ ├── agent_context_usage.py # Context usage estimation & reporting │ ├── agent_context_usage.py # Context usage estimation & reporting
│ ├── agent_session.py # Session state management │ ├── agent_session.py # Session state management
@@ -446,6 +450,7 @@ python3 -m src.main agent \
| `agent-context` | Show estimated context usage | | `agent-context` | Show estimated context usage |
| `agent-context-raw` | Show the raw context snapshot | | `agent-context-raw` | Show the raw context snapshot |
| `token-budget` | Show prompt-window budget, reserves, and soft/hard input limits | | `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 <id> <prompt>` | Resume a saved session | | `agent-resume <id> <prompt>` | Resume a saved session |
### Runtime Utility Commands ### Runtime Utility Commands
@@ -545,6 +550,7 @@ These are handled **locally** before the model loop:
| `/permissions` | — | Show active tool permission mode | | `/permissions` | — | Show active tool permission mode |
| `/model` | — | Show or update the active model | | `/model` | — | Show or update the active model |
| `/tools` | — | List registered tools with permission status | | `/tools` | — | List registered tools with permission status |
| `/agents` | — | List active local agent definitions or show one profile |
| `/memory` | — | Show loaded CLAUDE.md memory bundle | | `/memory` | — | Show loaded CLAUDE.md memory bundle |
| `/status` | `/session` | Show runtime/session status summary | | `/status` | `/session` | Show runtime/session status summary |
| `/clear` | — | Clear ephemeral runtime state | | `/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 "/context" --cwd .
python3 -m src.main agent "/token-budget" --cwd . python3 -m src.main agent "/token-budget" --cwd .
python3 -m src.main agent "/tools" --cwd . python3 -m src.main agent "/tools" --cwd .
python3 -m src.main agent "/agents" --cwd .
python3 -m src.main agent "/status" --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 ### Utility Commands
```bash ```bash
+22
View File
@@ -580,6 +580,24 @@ EOF
mkdir -p ./test_cases_tasks 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 ## 5. Slash Command Matrix
Slash commands are handled locally before the model loop. 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" --cwd ./test_cases
python3 -m src.main agent "/model demo-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 "/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 "/memory" --cwd ./test_cases
python3 -m src.main agent "/status" --cwd ./test_cases python3 -m src.main agent "/status" --cwd ./test_cases
python3 -m src.main agent "/session" --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 --cwd ./test_cases
python3 -m src.main agent-context-raw --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 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 ### 6.2 Extra working directories and `CLAUDE.md` toggle
+14
View File
@@ -11,6 +11,14 @@ from .agent_context import (
set_system_prompt_injection, set_system_prompt_injection,
) )
from .agent_manager import AgentManager 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_runtime import LocalCodingAgent
from .agent_session import AgentMessage, AgentSessionState from .agent_session import AgentMessage, AgentSessionState
from .agent_tools import build_tool_context, default_tool_registry, execute_tool 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__ = [ __all__ = [
'AgentContextSnapshot', 'AgentContextSnapshot',
'AgentManager', 'AgentManager',
'AgentLoadError',
'AgentPermissions', 'AgentPermissions',
'AgentRegistrySnapshot',
'AgentRunResult', 'AgentRunResult',
'AgentRuntimeConfig', 'AgentRuntimeConfig',
'AccountProfile', 'AccountProfile',
@@ -114,10 +124,14 @@ __all__ = [
'describe_token_counter', 'describe_token_counter',
'estimate_chat_overhead', 'estimate_chat_overhead',
'execute_tool', 'execute_tool',
'find_agent_definition',
'format_token_budget', 'format_token_budget',
'get_system_context', 'get_system_context',
'get_user_context', 'get_user_context',
'load_agent_registry',
'load_session', 'load_session',
'render_agent_detail',
'render_agents_report',
'run_parity_audit', 'run_parity_audit',
'save_session', 'save_session',
'set_system_prompt_injection', 'set_system_prompt_injection',
+23
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from .agent_context import build_context_snapshot from .agent_context import build_context_snapshot
from .agent_tools import AgentTool from .agent_tools import AgentTool
from .agent_types import AgentRuntimeConfig, ModelConfig from .agent_types import AgentRuntimeConfig, ModelConfig
from .builtin_agents import AgentDefinition, format_agent_listing
SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__' SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
@@ -76,6 +77,7 @@ def build_system_prompt_parts(
prompt_context: PromptContext, prompt_context: PromptContext,
runtime_config: AgentRuntimeConfig, runtime_config: AgentRuntimeConfig,
tools: dict[str, AgentTool], tools: dict[str, AgentTool],
available_agents: tuple[AgentDefinition, ...] = (),
custom_system_prompt: str | None = None, custom_system_prompt: str | None = None,
append_system_prompt: str | None = None, append_system_prompt: str | None = None,
override_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_doing_tasks_section(),
get_actions_section(), get_actions_section(),
get_using_your_tools_section(enabled_tool_names), get_using_your_tools_section(enabled_tool_names),
get_agent_guidance_section(enabled_tool_names, available_agents),
get_plugin_guidance_section(prompt_context), get_plugin_guidance_section(prompt_context),
get_mcp_guidance_section(prompt_context), get_mcp_guidance_section(prompt_context),
get_remote_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)]) 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: def get_plugin_guidance_section(prompt_context: PromptContext) -> str:
plugin_cache = prompt_context.user_context.get('pluginCache') plugin_cache = prompt_context.user_context.get('pluginCache')
plugin_runtime = prompt_context.user_context.get('pluginRuntime') plugin_runtime = prompt_context.user_context.get('pluginRuntime')
+395
View File
@@ -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)
+34 -13
View File
@@ -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 .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .compact import compact_conversation from .compact import compact_conversation
from .ask_user_runtime import AskUserRuntime 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 .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime from .hook_policy import HookPolicyRuntime
from .lsp_runtime import LSPRuntime from .lsp_runtime import LSPRuntime
@@ -67,9 +73,6 @@ from .session_store import (
from .token_budget import calculate_token_budget, format_token_budget from .token_budget import calculate_token_budget, format_token_budget
from .builtin_agents import ( from .builtin_agents import (
AgentDefinition, AgentDefinition,
get_agent_definition,
get_builtin_agents,
format_agent_listing,
ALL_AGENT_DISALLOWED_TOOLS, ALL_AGENT_DISALLOWED_TOOLS,
GENERAL_PURPOSE_AGENT, GENERAL_PURPOSE_AGENT,
) )
@@ -256,11 +259,18 @@ class LocalCodingAgent:
prompt_context=prompt_context, prompt_context=prompt_context,
runtime_config=self.runtime_config, runtime_config=self.runtime_config,
tools=self.tool_registry, tools=self.tool_registry,
available_agents=self.available_agents(),
custom_system_prompt=self.custom_system_prompt, custom_system_prompt=self.custom_system_prompt,
append_system_prompt=self.append_system_prompt, append_system_prompt=self.append_system_prompt,
override_system_prompt=self.override_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( def build_session(
self, self,
user_prompt: str | None = None, user_prompt: str | None = None,
@@ -2130,7 +2140,7 @@ class LocalCodingAgent:
"""Resolve the agent definition from subagent_type or default to general-purpose.""" """Resolve the agent definition from subagent_type or default to general-purpose."""
subagent_type = arguments.get('subagent_type') subagent_type = arguments.get('subagent_type')
if isinstance(subagent_type, str) and 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: if agent_def is not None:
return agent_def return agent_def
return GENERAL_PURPOSE_AGENT return GENERAL_PURPOSE_AGENT
@@ -2145,17 +2155,12 @@ class LocalCodingAgent:
agent_model = agent_def.model agent_model = agent_def.model
# Explicit model param in arguments takes priority # Explicit model param in arguments takes priority
if isinstance(model_override, str) and model_override in ('sonnet', 'opus', 'haiku'): if isinstance(model_override, str) and model_override.strip():
# Map friendly names to actual model identifiers if a mapping is available, return replace(self.model_config, model=model_override.strip())
# otherwise use the parent's model config as base
return self.model_config
# Agent definition model # Agent definition model
if agent_model and agent_model != 'inherit': if agent_model and agent_model != 'inherit':
# Agent definitions may specify 'haiku', 'sonnet', 'opus' return replace(self.model_config, model=agent_model)
# 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 self.model_config return self.model_config
@@ -2394,7 +2399,12 @@ class LocalCodingAgent:
child_agent.managed_agent_id, child_agent.managed_agent_id,
child_index=index, child_index=index,
) )
resume_session_id = subtask.get('resume_session_id')
child_prompt = str(subtask['prompt']) 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: if delegate_preflight_messages:
child_prompt = self._prepend_plugin_delegate_context( child_prompt = self._prepend_plugin_delegate_context(
child_prompt, child_prompt,
@@ -2402,7 +2412,6 @@ class LocalCodingAgent:
) )
if include_parent_context and prior_results: if include_parent_context and prior_results:
child_prompt = self._prepend_delegate_context(child_prompt, prior_results) child_prompt = self._prepend_delegate_context(child_prompt, prior_results)
resume_session_id = subtask.get('resume_session_id')
resume_used = False resume_used = False
if isinstance(resume_session_id, str) and resume_session_id: if isinstance(resume_session_id, str) and resume_session_id:
try: try:
@@ -3397,6 +3406,18 @@ class LocalCodingAgent:
lines.append(f'- `{tool.name}`: {tool.description} [{state}]') lines.append(f'- `{tool.name}`: {tool.description} [{state}]')
return '\n'.join(lines) 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: def render_memory_report(self) -> str:
prompt_context = self.build_prompt_context() prompt_context = self.build_prompt_context()
claude_md = prompt_context.user_context.get('claudeMd') claude_md = prompt_context.user_context.get('claudeMd')
+19
View File
@@ -289,6 +289,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='List the registered tools and whether the current permissions allow them.', description='List the registered tools and whether the current permissions allow them.',
handler=_handle_tools, handler=_handle_tools,
), ),
SlashCommandSpec(
names=('agents',),
description='List active local agent configurations or show one agent definition by name.',
handler=_handle_agents,
),
SlashCommandSpec( SlashCommandSpec(
names=('memory',), names=('memory',),
description='Show the currently loaded CLAUDE.md memory bundle and discovered files.', 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()) 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 <agent-type>]')
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: def _handle_memory(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_memory_report()) return _local_result(input_text, agent.render_memory_report())
+10 -2
View File
@@ -34,6 +34,14 @@ class AgentDefinition:
max_turns: int | None = None max_turns: int | None = None
critical_system_reminder: str | None = None critical_system_reminder: str | None = None
source: str = 'built-in' 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 agents = _BUILTIN_AGENTS
lines: list[str] = [] lines: list[str] = []
for agent in agents: 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})') lines.append(f'- {agent.agent_type}: {agent.when_to_use} (Tools: {tools_desc})')
return '\n'.join(lines) 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.""" """Describe the tool access for an agent definition."""
if agent.tools is not None: if agent.tools is not None:
return ', '.join(agent.tools) if agent.tools else 'none' return ', '.join(agent.tools) if agent.tools else 'none'
+12
View File
@@ -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') 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) _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 return parser
@@ -1507,6 +1512,13 @@ def main(argv: list[str] | None = None) -> int:
agent.last_session = agent.build_session() agent.last_session = agent.build_session()
print(agent.render_token_budget_report()) print(agent.render_token_budget_report())
return 0 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}') parser.error(f'unknown command: {args.command}')
return 2 return 2
+75
View File
@@ -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)
+32
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -112,6 +113,37 @@ def make_recording_streaming_urlopen_side_effect(
class AgentRuntimeTests(unittest.TestCase): 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: def test_openai_client_parses_tool_calls(self) -> None:
responses = [ responses = [
{ {
+30
View File
@@ -4,6 +4,7 @@ import tempfile
import unittest import unittest
import json import json
import sys import sys
import os
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -119,6 +120,35 @@ class AgentSlashCommandTests(unittest.TestCase):
self.assertIn('Hard input limit', result.final_output) self.assertIn('Hard input limit', result.final_output)
self.assertIn('Auto-compact buffer', 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: def test_mcp_and_resource_commands_render_local_reports(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
workspace = Path(tmp_dir) workspace = Path(tmp_dir)
+8
View File
@@ -213,6 +213,14 @@ class MainCliTests(unittest.TestCase):
self.assertEqual(args.command, 'token-budget') self.assertEqual(args.command, 'token-budget')
self.assertEqual(args.cwd, '.') 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: def test_parser_accepts_team_runtime_commands(self) -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.']) args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.'])