Implemented the next missing parity slice around agent configurations.
This commit is contained in:
+7
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 <id> <prompt>` | 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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 <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:
|
||||
return _local_result(input_text, agent.render_memory_report())
|
||||
|
||||
|
||||
+10
-2
@@ -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'
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '.'])
|
||||
|
||||
Reference in New Issue
Block a user