Implemented the next missing parity slice around agent configurations.

This commit is contained in:
Abdelrahman Abdallah
2026-04-17 05:03:19 +02:00
parent fabbda53a5
commit 02674ba594
14 changed files with 721 additions and 17 deletions
+14
View File
@@ -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',
+23
View File
@@ -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')
+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 .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')
+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.',
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
View File
@@ -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
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')
_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