Merge pull request #23 from HarnessLab/agent_update
Here's a summary of all the features I ported from the TypeScript s…
This commit is contained in:
@@ -34,3 +34,4 @@ test_cases
|
|||||||
e-commerce
|
e-commerce
|
||||||
benchmarks/data/*.jsonl
|
benchmarks/data/*.jsonl
|
||||||
benchmarks/data/manifest.json
|
benchmarks/data/manifest.json
|
||||||
|
claude-code-sourcemap-main
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
## 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
+12
-8
@@ -363,7 +363,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
|||||||
|
|
||||||
## 6. Built-in Tools
|
## 6. Built-in Tools
|
||||||
|
|
||||||
### Tools implemented in Python (58 tools):
|
### Tools implemented in Python (61 tools):
|
||||||
|
|
||||||
- [x] `list_dir`
|
- [x] `list_dir`
|
||||||
- [x] `read_file`
|
- [x] `read_file`
|
||||||
@@ -428,8 +428,8 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
|||||||
### Tools in npm `tools.ts` not yet ported with full fidelity (40 tool dirs):
|
### Tools in npm `tools.ts` not yet ported with full fidelity (40 tool dirs):
|
||||||
|
|
||||||
Core tools needing full port:
|
Core tools needing full port:
|
||||||
- [ ] `AgentTool` — Sub-agent spawning with built-in agents (explore, general-purpose, verification, plan, claudeCodeGuide, statusline), fork support, agent memory/snapshots, resume agent, color management
|
- [x] `AgentTool` — Sub-agent spawning with built-in agents (explore, general-purpose, verification, plan, claudeCodeGuide, statusline) → `src/builtin_agents.py` — remaining: fork support, agent memory/snapshots, resume agent, color management
|
||||||
- [ ] `SkillTool` — Skill execution with bundled skills
|
- [x] `SkillTool` — Skill execution via slash commands → `src/agent_tools.py`, `src/agent_runtime.py` — remaining: bundled skill definitions, forked skill execution
|
||||||
- [ ] `BriefTool` — Brief mode with attachments and file upload
|
- [ ] `BriefTool` — Brief mode with attachments and file upload
|
||||||
- [ ] `LSPTool` — Full upstream LSP fidelity beyond the current local heuristic runtime (server-backed diagnostics, go-to-definition, references, hover, symbol search, formatting)
|
- [ ] `LSPTool` — Full upstream LSP fidelity beyond the current local heuristic runtime (server-backed diagnostics, go-to-definition, references, hover, symbol search, formatting)
|
||||||
- [ ] `PowerShellTool` — Full PowerShell execution with security, path validation, CLM types, git safety
|
- [ ] `PowerShellTool` — Full PowerShell execution with security, path validation, CLM types, git safety
|
||||||
@@ -737,8 +737,8 @@ Missing:
|
|||||||
Working Python runtime today (21,193 lines across 51 source files, 10,480 lines across 37 test files):
|
Working Python runtime today (21,193 lines across 51 source files, 10,480 lines across 37 test files):
|
||||||
|
|
||||||
- [x] `src/main.py` (1,353 lines)
|
- [x] `src/main.py` (1,353 lines)
|
||||||
- [x] `src/agent_runtime.py` (3,664 lines)
|
- [x] `src/agent_runtime.py` (4,318 lines)
|
||||||
- [x] `src/agent_tools.py` (2,994 lines)
|
- [x] `src/agent_tools.py` (3,183 lines)
|
||||||
- [x] `src/agent_prompting.py` (390 lines)
|
- [x] `src/agent_prompting.py` (390 lines)
|
||||||
- [x] `src/agent_context.py` (459 lines)
|
- [x] `src/agent_context.py` (459 lines)
|
||||||
- [x] `src/agent_context_usage.py` (356 lines)
|
- [x] `src/agent_context_usage.py` (356 lines)
|
||||||
@@ -768,6 +768,8 @@ Working Python runtime today (21,193 lines across 51 source files, 10,480 lines
|
|||||||
- [x] `src/tokenizer_runtime.py` (202 lines)
|
- [x] `src/tokenizer_runtime.py` (202 lines)
|
||||||
- [x] `src/workflow_runtime.py` (319 lines)
|
- [x] `src/workflow_runtime.py` (319 lines)
|
||||||
- [x] `src/worktree_runtime.py` (448 lines)
|
- [x] `src/worktree_runtime.py` (448 lines)
|
||||||
|
- [x] `src/builtin_agents.py` (426 lines)
|
||||||
|
- [x] `src/microcompact.py` (236 lines)
|
||||||
- [x] Plus 19 supporting modules
|
- [x] Plus 19 supporting modules
|
||||||
|
|
||||||
Mirrored / scaffold areas needing real implementation:
|
Mirrored / scaffold areas needing real implementation:
|
||||||
@@ -785,13 +787,15 @@ Mirrored / scaffold areas needing real implementation:
|
|||||||
### Tier 1 — Core Feature Gaps (highest user impact)
|
### Tier 1 — Core Feature Gaps (highest user impact)
|
||||||
- [x] Full BashTool security parity (sed validation, path validation, sandbox, destructive command warnings, command semantics) → `src/bash_security.py`
|
- [x] Full BashTool security parity (sed validation, path validation, sandbox, destructive command warnings, command semantics) → `src/bash_security.py`
|
||||||
- [x] LSP tool integration for code intelligence
|
- [x] LSP tool integration for code intelligence
|
||||||
- [ ] Full AgentTool with built-in agent types (explore, general-purpose, verification, plan)
|
- [x] Full AgentTool with built-in agent types (explore, general-purpose, verification, plan, statusline-setup, claude-code-guide) → `src/builtin_agents.py`, `src/agent_tools.py`, `src/agent_runtime.py`
|
||||||
- [ ] Auto-compact and context collapse from `query.ts`
|
- [ ] Auto-compact and context collapse from `query.ts`
|
||||||
- [ ] Full compact service (autoCompact, microCompact, sessionMemoryCompact)
|
- [x] Microcompact service (time-based tool-result clearing) → `src/microcompact.py`
|
||||||
|
- [x] Compact PTL retry loop and circuit-breaker tracking → `src/compact.py`
|
||||||
|
- [ ] Full compact service (sessionMemoryCompact, cached microcompact)
|
||||||
- [ ] Interactive REPL improvements
|
- [ ] Interactive REPL improvements
|
||||||
|
|
||||||
### Tier 2 — Important Feature Gaps
|
### Tier 2 — Important Feature Gaps
|
||||||
- [ ] SkillTool with bundled skills
|
- [x] SkillTool for slash command execution → `src/agent_tools.py`, `src/agent_runtime.py`
|
||||||
- [ ] Full MCP service parity (auth, permissions, config, registry)
|
- [ ] Full MCP service parity (auth, permissions, config, registry)
|
||||||
- [ ] Plugin discovery, loading, and installation
|
- [ ] Plugin discovery, loading, and installation
|
||||||
- [ ] Real remote session management (WebSocket, bridge)
|
- [ ] Real remote session management (WebSocket, bridge)
|
||||||
|
|||||||
+276
-33
@@ -65,6 +65,15 @@ from .session_store import (
|
|||||||
usage_from_payload,
|
usage_from_payload,
|
||||||
)
|
)
|
||||||
from .token_budget import calculate_token_budget, format_token_budget
|
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,
|
||||||
|
)
|
||||||
|
from .microcompact import microcompact_messages as _microcompact_messages
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -113,6 +122,7 @@ class LocalCodingAgent:
|
|||||||
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
||||||
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
|
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
|
||||||
cumulative_cost_usd: float = field(default=0.0, init=False, repr=False)
|
cumulative_cost_usd: float = field(default=0.0, init=False, repr=False)
|
||||||
|
_compact_consecutive_failures: int = field(default=0, init=False, repr=False)
|
||||||
active_session_id: str | None = field(default=None, init=False, repr=False)
|
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||||||
last_session_path: str | None = field(default=None, init=False, repr=False)
|
last_session_path: str | None = field(default=None, init=False, repr=False)
|
||||||
managed_agent_id: str | None = field(default=None, init=False, repr=False)
|
managed_agent_id: str | None = field(default=None, init=False, repr=False)
|
||||||
@@ -465,7 +475,7 @@ class LocalCodingAgent:
|
|||||||
stream_events: list[dict[str, object]] = []
|
stream_events: list[dict[str, object]] = []
|
||||||
assistant_response_segments: list[str] = []
|
assistant_response_segments: list[str] = []
|
||||||
delegated_tasks = sum(
|
delegated_tasks = sum(
|
||||||
1 for entry in file_history if entry.get('action') == 'delegate_agent'
|
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
||||||
)
|
)
|
||||||
model_calls = starting_model_calls
|
model_calls = starting_model_calls
|
||||||
|
|
||||||
@@ -497,6 +507,11 @@ class LocalCodingAgent:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
for turn_index in range(1, self.runtime_config.max_turns + 1):
|
for turn_index in range(1, self.runtime_config.max_turns + 1):
|
||||||
|
self._microcompact_session_if_needed(
|
||||||
|
session,
|
||||||
|
stream_events,
|
||||||
|
turn_index=turn_index,
|
||||||
|
)
|
||||||
self._snip_session_if_needed(
|
self._snip_session_if_needed(
|
||||||
session,
|
session,
|
||||||
stream_events,
|
stream_events,
|
||||||
@@ -775,7 +790,7 @@ class LocalCodingAgent:
|
|||||||
for tool_call in turn.tool_calls:
|
for tool_call in turn.tool_calls:
|
||||||
assistant_response_segments.clear()
|
assistant_response_segments.clear()
|
||||||
tool_calls += 1
|
tool_calls += 1
|
||||||
if tool_call.name == 'delegate_agent':
|
if tool_call.name in ('Agent', 'delegate_agent'):
|
||||||
delegated_tasks += self._delegated_task_units(tool_call.arguments)
|
delegated_tasks += self._delegated_task_units(tool_call.arguments)
|
||||||
budget_after_tool_request = self._check_budget(
|
budget_after_tool_request = self._check_budget(
|
||||||
total_usage,
|
total_usage,
|
||||||
@@ -907,9 +922,12 @@ class LocalCodingAgent:
|
|||||||
'message': policy_block_message,
|
'message': policy_block_message,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if tool_call.name == 'delegate_agent':
|
if tool_call.name in ('Agent', 'delegate_agent'):
|
||||||
if tool_result is None:
|
if tool_result is None:
|
||||||
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
||||||
|
elif tool_call.name == 'Skill':
|
||||||
|
if tool_result is None:
|
||||||
|
tool_result = self._execute_skill(tool_call.arguments)
|
||||||
elif tool_result is None:
|
elif tool_result is None:
|
||||||
for update in execute_tool_streaming(
|
for update in execute_tool_streaming(
|
||||||
self.tool_registry,
|
self.tool_registry,
|
||||||
@@ -1365,7 +1383,17 @@ class LocalCodingAgent:
|
|||||||
return PromptPreflightResult()
|
return PromptPreflightResult()
|
||||||
snapshot = recovered
|
snapshot = recovered
|
||||||
|
|
||||||
if self._can_auto_compact_with_summary(session):
|
# Circuit-breaker: skip auto-compact after MAX_COMPACT_FAILURES consecutive failures
|
||||||
|
from .compact import MAX_COMPACT_FAILURES
|
||||||
|
if self._compact_consecutive_failures >= MAX_COMPACT_FAILURES:
|
||||||
|
stream_events.append(
|
||||||
|
{
|
||||||
|
'type': 'auto_compact_circuit_breaker',
|
||||||
|
'turn_index': turn_index,
|
||||||
|
'consecutive_failures': self._compact_consecutive_failures,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif self._can_auto_compact_with_summary(session):
|
||||||
compact_result = compact_conversation(
|
compact_result = compact_conversation(
|
||||||
self,
|
self,
|
||||||
custom_instructions=(
|
custom_instructions=(
|
||||||
@@ -1375,6 +1403,7 @@ class LocalCodingAgent:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if compact_result.error is None:
|
if compact_result.error is None:
|
||||||
|
self._compact_consecutive_failures = 0 # Reset on success
|
||||||
recovered = calculate_token_budget(
|
recovered = calculate_token_budget(
|
||||||
session=session,
|
session=session,
|
||||||
model=self.model_config.model,
|
model=self.model_config.model,
|
||||||
@@ -1387,7 +1416,9 @@ class LocalCodingAgent:
|
|||||||
'turn_index': turn_index,
|
'turn_index': turn_index,
|
||||||
'pre_compact_token_count': compact_result.pre_compact_token_count,
|
'pre_compact_token_count': compact_result.pre_compact_token_count,
|
||||||
'post_compact_token_count': compact_result.post_compact_token_count,
|
'post_compact_token_count': compact_result.post_compact_token_count,
|
||||||
|
'true_post_compact_token_count': compact_result.true_post_compact_token_count,
|
||||||
'summary_usage_tokens': compact_result.usage.total_tokens,
|
'summary_usage_tokens': compact_result.usage.total_tokens,
|
||||||
|
'ptl_retries': compact_result.ptl_retries,
|
||||||
'projected_input_tokens': recovered.projected_input_tokens,
|
'projected_input_tokens': recovered.projected_input_tokens,
|
||||||
'soft_input_limit_tokens': recovered.soft_input_limit_tokens,
|
'soft_input_limit_tokens': recovered.soft_input_limit_tokens,
|
||||||
'hard_input_limit_tokens': recovered.hard_input_limit_tokens,
|
'hard_input_limit_tokens': recovered.hard_input_limit_tokens,
|
||||||
@@ -1417,11 +1448,13 @@ class LocalCodingAgent:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
self._compact_consecutive_failures += 1
|
||||||
stream_events.append(
|
stream_events.append(
|
||||||
{
|
{
|
||||||
'type': 'auto_compact_failed',
|
'type': 'auto_compact_failed',
|
||||||
'turn_index': turn_index,
|
'turn_index': turn_index,
|
||||||
'reason': compact_result.error,
|
'reason': compact_result.error,
|
||||||
|
'consecutive_failures': self._compact_consecutive_failures,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1457,6 +1490,39 @@ class LocalCodingAgent:
|
|||||||
f'soft input limit: {snapshot.soft_input_limit_tokens:,}.'
|
f'soft input limit: {snapshot.soft_input_limit_tokens:,}.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _microcompact_session_if_needed(
|
||||||
|
self,
|
||||||
|
session: AgentSessionState,
|
||||||
|
stream_events: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
turn_index: int,
|
||||||
|
) -> None:
|
||||||
|
"""Run time-based microcompaction to clear old tool results.
|
||||||
|
|
||||||
|
Fires when the gap since the last assistant message exceeds the
|
||||||
|
threshold (default 60 minutes), indicating the server-side cache
|
||||||
|
has expired and the full prefix will be rewritten anyway.
|
||||||
|
"""
|
||||||
|
if not session.messages:
|
||||||
|
return
|
||||||
|
result = _microcompact_messages(
|
||||||
|
session.messages,
|
||||||
|
model=self.model_config.model,
|
||||||
|
)
|
||||||
|
if not result.triggered:
|
||||||
|
return
|
||||||
|
session.messages = result.messages
|
||||||
|
stream_events.append(
|
||||||
|
{
|
||||||
|
'type': 'microcompact',
|
||||||
|
'turn_index': turn_index,
|
||||||
|
'cleared_tool_count': result.cleared_tool_count,
|
||||||
|
'kept_tool_count': result.kept_tool_count,
|
||||||
|
'estimated_tokens_saved': result.estimated_tokens_saved,
|
||||||
|
'gap_minutes': round(result.gap_minutes, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _snip_session_if_needed(
|
def _snip_session_if_needed(
|
||||||
self,
|
self,
|
||||||
session: AgentSessionState,
|
session: AgentSessionState,
|
||||||
@@ -1739,7 +1805,7 @@ class LocalCodingAgent:
|
|||||||
if (
|
if (
|
||||||
'path' not in tool_result.metadata
|
'path' not in tool_result.metadata
|
||||||
and 'command' not in tool_result.metadata
|
and 'command' not in tool_result.metadata
|
||||||
and tool_result.metadata.get('action') != 'delegate_agent'
|
and tool_result.metadata.get('action') not in ('delegate_agent', 'Agent')
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
metadata = dict(tool_result.metadata)
|
metadata = dict(tool_result.metadata)
|
||||||
@@ -1766,7 +1832,7 @@ class LocalCodingAgent:
|
|||||||
entry['after_snapshot_id'] = f'{path}:{after_sha256[:12]}'
|
entry['after_snapshot_id'] = f'{path}:{after_sha256[:12]}'
|
||||||
elif isinstance(metadata.get('command'), str):
|
elif isinstance(metadata.get('command'), str):
|
||||||
entry['history_kind'] = 'shell'
|
entry['history_kind'] = 'shell'
|
||||||
elif action == 'delegate_agent':
|
elif action in ('delegate_agent', 'Agent'):
|
||||||
entry['history_kind'] = 'delegation'
|
entry['history_kind'] = 'delegation'
|
||||||
delegate_batches = metadata.get('delegate_batches')
|
delegate_batches = metadata.get('delegate_batches')
|
||||||
if isinstance(delegate_batches, list):
|
if isinstance(delegate_batches, list):
|
||||||
@@ -1972,46 +2038,205 @@ class LocalCodingAgent:
|
|||||||
)
|
)
|
||||||
return any(pattern in text for pattern in patterns)
|
return any(pattern in text for pattern in patterns)
|
||||||
|
|
||||||
|
def _execute_skill(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, object],
|
||||||
|
) -> ToolExecutionResult:
|
||||||
|
"""Execute a skill (slash command) through the Skill tool.
|
||||||
|
|
||||||
|
Maps the ``skill`` parameter to a slash command, invokes it, and
|
||||||
|
returns its output as a tool result.
|
||||||
|
"""
|
||||||
|
from .agent_slash_commands import find_slash_command, get_slash_command_specs
|
||||||
|
|
||||||
|
skill_name = arguments.get('skill')
|
||||||
|
if not isinstance(skill_name, str) or not skill_name.strip():
|
||||||
|
return ToolExecutionResult(
|
||||||
|
name='Skill',
|
||||||
|
ok=False,
|
||||||
|
content='skill must be a non-empty string',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize: strip leading '/' if present
|
||||||
|
skill_name = skill_name.strip().lstrip('/')
|
||||||
|
args = arguments.get('args', '')
|
||||||
|
if not isinstance(args, str):
|
||||||
|
args = str(args) if args is not None else ''
|
||||||
|
|
||||||
|
# Look up the slash command
|
||||||
|
spec = find_slash_command(skill_name)
|
||||||
|
if spec is None:
|
||||||
|
available = sorted(
|
||||||
|
name
|
||||||
|
for spec in get_slash_command_specs()
|
||||||
|
for name in spec.names
|
||||||
|
)
|
||||||
|
return ToolExecutionResult(
|
||||||
|
name='Skill',
|
||||||
|
ok=False,
|
||||||
|
content=(
|
||||||
|
f'Unknown skill: {skill_name}. '
|
||||||
|
f'Available skills: {", ".join(available[:30])}'
|
||||||
|
),
|
||||||
|
metadata={'action': 'skill_not_found', 'skill_name': skill_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invoke the slash command handler
|
||||||
|
input_text = f'/{skill_name} {args}'.strip()
|
||||||
|
result = spec.handler(self, args.strip(), input_text)
|
||||||
|
|
||||||
|
if result.output:
|
||||||
|
content = result.output
|
||||||
|
elif result.prompt:
|
||||||
|
content = result.prompt
|
||||||
|
else:
|
||||||
|
content = f'Skill /{skill_name} completed.'
|
||||||
|
|
||||||
|
return ToolExecutionResult(
|
||||||
|
name='Skill',
|
||||||
|
ok=True,
|
||||||
|
content=content,
|
||||||
|
metadata={
|
||||||
|
'action': 'skill',
|
||||||
|
'skill_name': skill_name,
|
||||||
|
'command_name': spec.names[0],
|
||||||
|
'handled': result.handled,
|
||||||
|
'should_query': result.should_query,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_agent_definition(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, object],
|
||||||
|
) -> AgentDefinition:
|
||||||
|
"""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)
|
||||||
|
if agent_def is not None:
|
||||||
|
return agent_def
|
||||||
|
return GENERAL_PURPOSE_AGENT
|
||||||
|
|
||||||
|
def _resolve_child_model_config(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, object],
|
||||||
|
agent_def: AgentDefinition,
|
||||||
|
) -> ModelConfig:
|
||||||
|
"""Resolve model config for a child agent based on explicit override or agent definition."""
|
||||||
|
model_override = arguments.get('model')
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 self.model_config
|
||||||
|
|
||||||
|
def _filter_tools_for_agent(
|
||||||
|
self,
|
||||||
|
agent_def: AgentDefinition,
|
||||||
|
) -> dict[str, AgentTool]:
|
||||||
|
"""Build the tool registry for a child agent based on its definition."""
|
||||||
|
# Start from parent tools, remove Agent/delegate_agent to prevent recursive spawning
|
||||||
|
base_tools = {
|
||||||
|
name: tool
|
||||||
|
for name, tool in self.tool_registry.items()
|
||||||
|
if name not in ('delegate_agent', 'Agent')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply agent-specific tool allow-list
|
||||||
|
if agent_def.tools is not None:
|
||||||
|
allowed = set(agent_def.tools)
|
||||||
|
base_tools = {
|
||||||
|
name: tool
|
||||||
|
for name, tool in base_tools.items()
|
||||||
|
if name in allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply agent-specific disallowed tools
|
||||||
|
if agent_def.disallowed_tools:
|
||||||
|
denied = set(agent_def.disallowed_tools)
|
||||||
|
base_tools = {
|
||||||
|
name: tool
|
||||||
|
for name, tool in base_tools.items()
|
||||||
|
if name not in denied
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply universal agent disallowed tools
|
||||||
|
base_tools = {
|
||||||
|
name: tool
|
||||||
|
for name, tool in base_tools.items()
|
||||||
|
if name not in ALL_AGENT_DISALLOWED_TOOLS
|
||||||
|
}
|
||||||
|
|
||||||
|
return base_tools
|
||||||
|
|
||||||
def _execute_delegate_agent(
|
def _execute_delegate_agent(
|
||||||
self,
|
self,
|
||||||
arguments: dict[str, object],
|
arguments: dict[str, object],
|
||||||
) -> ToolExecutionResult:
|
) -> ToolExecutionResult:
|
||||||
|
tool_name = 'Agent'
|
||||||
|
agent_def = self._resolve_agent_definition(arguments)
|
||||||
max_turns = arguments.get('max_turns')
|
max_turns = arguments.get('max_turns')
|
||||||
if max_turns is not None and (isinstance(max_turns, bool) or not isinstance(max_turns, int) or max_turns < 1):
|
if max_turns is not None and (isinstance(max_turns, bool) or not isinstance(max_turns, int) or max_turns < 1):
|
||||||
return ToolExecutionResult(
|
return ToolExecutionResult(
|
||||||
name='delegate_agent',
|
name=tool_name,
|
||||||
ok=False,
|
ok=False,
|
||||||
content='max_turns must be an integer >= 1',
|
content='max_turns must be an integer >= 1',
|
||||||
)
|
)
|
||||||
subtasks = self._normalize_delegate_subtasks(arguments)
|
subtasks = self._normalize_delegate_subtasks(arguments)
|
||||||
if not subtasks:
|
if not subtasks:
|
||||||
return ToolExecutionResult(
|
return ToolExecutionResult(
|
||||||
name='delegate_agent',
|
name=tool_name,
|
||||||
ok=False,
|
ok=False,
|
||||||
content='prompt must be a non-empty string or subtasks must contain at least one prompt',
|
content='prompt must be a non-empty string or subtasks must contain at least one prompt',
|
||||||
)
|
)
|
||||||
child_permissions = AgentPermissions(
|
|
||||||
allow_file_write=(
|
# Resolve child permissions — read-only agents get no write/shell
|
||||||
self.runtime_config.permissions.allow_file_write
|
if agent_def.disallowed_tools and (
|
||||||
and bool(arguments.get('allow_write', False))
|
'edit_file' in agent_def.disallowed_tools
|
||||||
),
|
or 'write_file' in agent_def.disallowed_tools
|
||||||
allow_shell_commands=(
|
):
|
||||||
self.runtime_config.permissions.allow_shell_commands
|
# Read-only agent (Explore, Plan, verification)
|
||||||
and bool(arguments.get('allow_shell', False))
|
child_permissions = AgentPermissions(
|
||||||
),
|
allow_file_write=False,
|
||||||
allow_destructive_shell_commands=False,
|
allow_shell_commands=self.runtime_config.permissions.allow_shell_commands,
|
||||||
)
|
allow_destructive_shell_commands=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
child_permissions = AgentPermissions(
|
||||||
|
allow_file_write=(
|
||||||
|
self.runtime_config.permissions.allow_file_write
|
||||||
|
and bool(arguments.get('allow_write', False))
|
||||||
|
),
|
||||||
|
allow_shell_commands=(
|
||||||
|
self.runtime_config.permissions.allow_shell_commands
|
||||||
|
and bool(arguments.get('allow_shell', False))
|
||||||
|
),
|
||||||
|
allow_destructive_shell_commands=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve max_turns — agent definition or explicit param
|
||||||
|
effective_max_turns = max_turns or agent_def.max_turns or min(self.runtime_config.max_turns, 6)
|
||||||
|
|
||||||
child_runtime_config = replace(
|
child_runtime_config = replace(
|
||||||
self.runtime_config,
|
self.runtime_config,
|
||||||
max_turns=max_turns or min(self.runtime_config.max_turns, 6),
|
max_turns=effective_max_turns,
|
||||||
permissions=child_permissions,
|
permissions=child_permissions,
|
||||||
auto_compact_threshold_tokens=self.runtime_config.auto_compact_threshold_tokens,
|
auto_compact_threshold_tokens=self.runtime_config.auto_compact_threshold_tokens,
|
||||||
)
|
)
|
||||||
child_tools = {
|
|
||||||
name: tool
|
child_model_config = self._resolve_child_model_config(arguments, agent_def)
|
||||||
for name, tool in self.tool_registry.items()
|
child_tools = self._filter_tools_for_agent(agent_def)
|
||||||
if name != 'delegate_agent'
|
|
||||||
}
|
|
||||||
include_parent_context = bool(arguments.get('include_parent_context', True))
|
include_parent_context = bool(arguments.get('include_parent_context', True))
|
||||||
continue_on_error = bool(arguments.get('continue_on_error', True))
|
continue_on_error = bool(arguments.get('continue_on_error', True))
|
||||||
max_failures = arguments.get('max_failures')
|
max_failures = arguments.get('max_failures')
|
||||||
@@ -2112,15 +2337,32 @@ class LocalCodingAgent:
|
|||||||
stop_processing = True
|
stop_processing = True
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
# Use agent definition's system prompt if available
|
||||||
|
child_system_prompt = agent_def.system_prompt or self.custom_system_prompt
|
||||||
|
child_override_prompt = None
|
||||||
|
if agent_def.system_prompt:
|
||||||
|
child_override_prompt = agent_def.system_prompt
|
||||||
|
else:
|
||||||
|
child_override_prompt = self.override_system_prompt
|
||||||
|
|
||||||
|
# Inject critical system reminder if agent definition has one
|
||||||
|
child_append_prompt = self.append_system_prompt
|
||||||
|
if agent_def.critical_system_reminder:
|
||||||
|
reminder = f'\n\n<system-reminder>\n{agent_def.critical_system_reminder}\n</system-reminder>'
|
||||||
|
child_append_prompt = (
|
||||||
|
(child_append_prompt or '') + reminder
|
||||||
|
)
|
||||||
|
|
||||||
child_agent = LocalCodingAgent(
|
child_agent = LocalCodingAgent(
|
||||||
model_config=self.model_config,
|
model_config=child_model_config,
|
||||||
runtime_config=replace(
|
runtime_config=replace(
|
||||||
child_runtime_config,
|
child_runtime_config,
|
||||||
max_turns=subtask.get('max_turns', child_runtime_config.max_turns),
|
max_turns=subtask.get('max_turns', child_runtime_config.max_turns),
|
||||||
|
disable_claude_md_discovery=agent_def.omit_claude_md,
|
||||||
),
|
),
|
||||||
custom_system_prompt=self.custom_system_prompt,
|
custom_system_prompt=child_system_prompt if not child_override_prompt else None,
|
||||||
append_system_prompt=self.append_system_prompt,
|
append_system_prompt=child_append_prompt,
|
||||||
override_system_prompt=self.override_system_prompt,
|
override_system_prompt=child_override_prompt,
|
||||||
tool_registry=child_tools,
|
tool_registry=child_tools,
|
||||||
agent_manager=self.agent_manager,
|
agent_manager=self.agent_manager,
|
||||||
parent_agent_id=self.managed_agent_id,
|
parent_agent_id=self.managed_agent_id,
|
||||||
@@ -2329,11 +2571,12 @@ class LocalCodingAgent:
|
|||||||
summary_lines.append('Final delegated output:')
|
summary_lines.append('Final delegated output:')
|
||||||
summary_lines.append(child_result.final_output)
|
summary_lines.append(child_result.final_output)
|
||||||
return ToolExecutionResult(
|
return ToolExecutionResult(
|
||||||
name='delegate_agent',
|
name=tool_name,
|
||||||
ok=True,
|
ok=True,
|
||||||
content='\n'.join(summary_lines).strip(),
|
content='\n'.join(summary_lines).strip(),
|
||||||
metadata={
|
metadata={
|
||||||
'action': 'delegate_agent',
|
'action': 'Agent',
|
||||||
|
'subagent_type': agent_def.agent_type,
|
||||||
'child_session_id': child_result.session_id,
|
'child_session_id': child_result.session_id,
|
||||||
'child_session_ids': child_session_ids,
|
'child_session_ids': child_session_ids,
|
||||||
'child_turns': child_result.turns,
|
'child_turns': child_result.turns,
|
||||||
@@ -2562,7 +2805,7 @@ class LocalCodingAgent:
|
|||||||
'message_count': len(plugin_delegate_after),
|
'message_count': len(plugin_delegate_after),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if tool_call.name != 'delegate_agent':
|
if tool_call.name not in ('Agent', 'delegate_agent'):
|
||||||
return
|
return
|
||||||
delegate_batches = metadata.get('delegate_batches')
|
delegate_batches = metadata.get('delegate_batches')
|
||||||
if isinstance(delegate_batches, list):
|
if isinstance(delegate_batches, list):
|
||||||
@@ -3037,7 +3280,7 @@ class LocalCodingAgent:
|
|||||||
'session_turns': previous_turns + result.turns,
|
'session_turns': previous_turns + result.turns,
|
||||||
'tool_calls': previous_tool_calls + result.tool_calls,
|
'tool_calls': previous_tool_calls + result.tool_calls,
|
||||||
'delegated_tasks': sum(
|
'delegated_tasks': sum(
|
||||||
1 for entry in result.file_history if entry.get('action') == 'delegate_agent'
|
1 for entry in result.file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
stored = StoredAgentSession(
|
stored = StoredAgentSession(
|
||||||
|
|||||||
+115
-4
@@ -1035,9 +1035,82 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
},
|
},
|
||||||
handler=_todo_write,
|
handler=_todo_write,
|
||||||
),
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='Agent',
|
||||||
|
description=(
|
||||||
|
'Launch a new agent to handle complex, multi-step tasks. '
|
||||||
|
'Each agent type has specific capabilities and tools available to it.'
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'description': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'A short (3-5 word) description of the task',
|
||||||
|
},
|
||||||
|
'prompt': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The task for the agent to perform',
|
||||||
|
},
|
||||||
|
'subagent_type': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The type of specialized agent to use for this task',
|
||||||
|
},
|
||||||
|
'model': {
|
||||||
|
'type': 'string',
|
||||||
|
'enum': ['sonnet', 'opus', 'haiku'],
|
||||||
|
'description': 'Optional model override for this agent',
|
||||||
|
},
|
||||||
|
'run_in_background': {
|
||||||
|
'type': 'boolean',
|
||||||
|
'description': 'Set to true to run this agent in the background',
|
||||||
|
},
|
||||||
|
'isolation': {
|
||||||
|
'type': 'string',
|
||||||
|
'enum': ['worktree'],
|
||||||
|
'description': 'Isolation mode. "worktree" creates a temporary git worktree.',
|
||||||
|
},
|
||||||
|
'subtasks': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'oneOf': [
|
||||||
|
{'type': 'string'},
|
||||||
|
{
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'prompt': {'type': 'string'},
|
||||||
|
'label': {'type': 'string'},
|
||||||
|
'max_turns': {'type': 'integer', 'minimum': 1, 'maximum': 20},
|
||||||
|
'resume_session_id': {'type': 'string'},
|
||||||
|
'session_id': {'type': 'string'},
|
||||||
|
'depends_on': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'string'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'required': ['prompt'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'resume_session_id': {'type': 'string'},
|
||||||
|
'session_id': {'type': 'string'},
|
||||||
|
'max_turns': {'type': 'integer', 'minimum': 1, 'maximum': 20},
|
||||||
|
'allow_write': {'type': 'boolean'},
|
||||||
|
'allow_shell': {'type': 'boolean'},
|
||||||
|
'include_parent_context': {'type': 'boolean'},
|
||||||
|
'continue_on_error': {'type': 'boolean'},
|
||||||
|
'max_failures': {'type': 'integer', 'minimum': 0, 'maximum': 20},
|
||||||
|
'strategy': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'required': ['description', 'prompt'],
|
||||||
|
},
|
||||||
|
handler=_agent_tool_placeholder,
|
||||||
|
),
|
||||||
|
# Keep legacy name for backward compatibility
|
||||||
AgentTool(
|
AgentTool(
|
||||||
name='delegate_agent',
|
name='delegate_agent',
|
||||||
description='Delegate a subtask to a nested Python coding agent and return its summary.',
|
description='(Legacy) Delegate a subtask to a nested agent. Prefer using the Agent tool instead.',
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
@@ -1076,7 +1149,29 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
'strategy': {'type': 'string'},
|
'strategy': {'type': 'string'},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler=_delegate_agent_placeholder,
|
handler=_agent_tool_placeholder,
|
||||||
|
),
|
||||||
|
AgentTool(
|
||||||
|
name='Skill',
|
||||||
|
description=(
|
||||||
|
'Execute a skill within the main conversation. '
|
||||||
|
'Skills provide specialized capabilities and domain knowledge.'
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'skill': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The skill name. E.g., "commit", "compact", or "help"',
|
||||||
|
},
|
||||||
|
'args': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'Optional arguments for the skill',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'required': ['skill'],
|
||||||
|
},
|
||||||
|
handler=_execute_skill,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return {tool.name: tool for tool in tools}
|
return {tool.name: tool for tool in tools}
|
||||||
@@ -2754,12 +2849,28 @@ def _stream_bash(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _delegate_agent_placeholder(
|
def _agent_tool_placeholder(
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
context: ToolExecutionContext,
|
context: ToolExecutionContext,
|
||||||
) -> str:
|
) -> str:
|
||||||
raise ToolExecutionError(
|
raise ToolExecutionError(
|
||||||
'delegate_agent must be handled by the runtime and is not available as a standalone tool handler'
|
'Agent/delegate_agent must be handled by the runtime and is not available as a standalone tool handler'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_skill(
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
) -> str | tuple[str, dict[str, Any]]:
|
||||||
|
"""Execute a skill (slash command) from the Skill tool.
|
||||||
|
|
||||||
|
The skill tool must be handled specially by the runtime because it
|
||||||
|
needs access to the agent to invoke slash commands. This handler
|
||||||
|
is a placeholder that raises — the runtime intercepts `Skill` tool
|
||||||
|
calls before they reach this handler.
|
||||||
|
"""
|
||||||
|
raise ToolExecutionError(
|
||||||
|
'Skill must be handled by the runtime and is not available as a standalone tool handler'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""Built-in agent type definitions.
|
||||||
|
|
||||||
|
Mirrors the npm ``src/tools/AgentTool/built-in/`` directory. Each agent
|
||||||
|
type defines its model, tool restrictions, system prompt, and behavioral
|
||||||
|
flags. The runtime uses these definitions to configure child agents when
|
||||||
|
the ``Agent`` tool is invoked with a ``subagent_type`` parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent definition dataclass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentDefinition:
|
||||||
|
"""A single agent type definition (built-in, user, or plugin)."""
|
||||||
|
|
||||||
|
agent_type: str
|
||||||
|
when_to_use: str
|
||||||
|
system_prompt: str = ''
|
||||||
|
model: str | None = None # 'sonnet', 'opus', 'haiku', 'inherit', or None (default)
|
||||||
|
tools: tuple[str, ...] | None = None # Allow-list; None means all
|
||||||
|
disallowed_tools: tuple[str, ...] = () # Deny-list
|
||||||
|
color: str | None = None
|
||||||
|
background: bool = False
|
||||||
|
one_shot: bool = False
|
||||||
|
omit_claude_md: bool = False
|
||||||
|
permission_mode: str | None = None # 'dontAsk', 'plan', etc.
|
||||||
|
max_turns: int | None = None
|
||||||
|
critical_system_reminder: str | None = None
|
||||||
|
source: str = 'built-in'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Disallowed tool sets (mirrors npm constants/tools.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALL_AGENT_DISALLOWED_TOOLS = frozenset({
|
||||||
|
'task_output', 'plan_get', 'update_plan', 'plan_clear',
|
||||||
|
'ask_user_question', 'task_stop',
|
||||||
|
})
|
||||||
|
"""Tools disallowed for all child agents by default."""
|
||||||
|
|
||||||
|
EXPLORE_PLAN_DISALLOWED_TOOLS = frozenset({
|
||||||
|
'delegate_agent', 'Agent',
|
||||||
|
'plan_clear', 'update_plan', 'plan_get',
|
||||||
|
'edit_file', 'write_file', 'notebook_edit',
|
||||||
|
})
|
||||||
|
"""Tools disallowed for read-only agents (Explore, Plan, verification)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System prompts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_READ_ONLY_PREAMBLE = """\
|
||||||
|
CRITICAL: You are in READ-ONLY mode. You are STRICTLY PROHIBITED from:
|
||||||
|
- Using Edit, Write, or NotebookEdit tools
|
||||||
|
- Creating, modifying, or deleting any files
|
||||||
|
- Using Bash for any write operations (no mkdir, touch, rm, cp, mv, \
|
||||||
|
git add, git commit, npm install, pip install, or any file creation/modification)
|
||||||
|
- Using redirect operators (>, >>) or heredocs in Bash
|
||||||
|
- Installing packages or dependencies
|
||||||
|
|
||||||
|
You may ONLY use Bash for read-only operations: ls, git status, git log, \
|
||||||
|
git diff, find, cat, head, tail.
|
||||||
|
|
||||||
|
Any attempt to modify files will fail and waste your limited turns."""
|
||||||
|
|
||||||
|
_GENERAL_PURPOSE_SYSTEM_PROMPT = """\
|
||||||
|
You are an agent for Claw Code Python, a Python reimplementation of a \
|
||||||
|
Claude Code-style coding agent. Given the user's message, you should use \
|
||||||
|
the tools available to complete the task. Complete the task fully — don't \
|
||||||
|
gold-plate, but don't leave it half-done.
|
||||||
|
|
||||||
|
## Strengths
|
||||||
|
|
||||||
|
- Searching code, configs, and patterns across large codebases
|
||||||
|
- Analyzing multiple files to understand architecture
|
||||||
|
- Investigating complex questions that need multi-file context
|
||||||
|
- Multi-step research and implementation tasks
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Search broadly first when the location of relevant code is unknown
|
||||||
|
- Use read_file for specific known paths; use glob_search and grep_search \
|
||||||
|
for discovery
|
||||||
|
- Start broad, then narrow down to specifics
|
||||||
|
- Be thorough — check multiple locations and naming conventions
|
||||||
|
- NEVER create files unless it is absolutely necessary for achieving your goal
|
||||||
|
- NEVER proactively create documentation files (*.md) or README files \
|
||||||
|
unless explicitly requested
|
||||||
|
|
||||||
|
Your response should be a concise report covering what was done and key \
|
||||||
|
findings."""
|
||||||
|
|
||||||
|
_EXPLORE_SYSTEM_PROMPT = f"""\
|
||||||
|
{_READ_ONLY_PREAMBLE}
|
||||||
|
|
||||||
|
You are a file search specialist. Your role is to rapidly find files, \
|
||||||
|
search code, and analyze file contents.
|
||||||
|
|
||||||
|
## Strengths
|
||||||
|
|
||||||
|
- Rapidly finding files using glob patterns
|
||||||
|
- Searching code with regex patterns
|
||||||
|
- Reading and analyzing file contents
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Use glob_search for broad file pattern matching
|
||||||
|
- Use grep_search for content search with regex
|
||||||
|
- Use read_file when you know a specific file path
|
||||||
|
- Use Bash ONLY for read-only operations (ls, git status, git log, \
|
||||||
|
git diff, find, cat, head, tail)
|
||||||
|
- Adapt search approach based on the thoroughness level specified in \
|
||||||
|
the prompt (quick / medium / very thorough)
|
||||||
|
- Make efficient use of tools — spawn multiple parallel tool calls \
|
||||||
|
for grepping and reading files when possible
|
||||||
|
- Communicate your findings as a regular message — do NOT attempt to \
|
||||||
|
create files
|
||||||
|
- Complete the search request efficiently and report findings clearly"""
|
||||||
|
|
||||||
|
_PLAN_SYSTEM_PROMPT = f"""\
|
||||||
|
{_READ_ONLY_PREAMBLE}
|
||||||
|
|
||||||
|
You are a software architect and planning specialist. Your role is to \
|
||||||
|
explore the codebase, understand the architecture, and design \
|
||||||
|
implementation plans.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Understand Requirements**: Focus on the requirements and the \
|
||||||
|
assigned perspective in the prompt.
|
||||||
|
2. **Explore Thoroughly**: Read provided files, find existing patterns, \
|
||||||
|
understand architecture, identify similar features, trace code paths. \
|
||||||
|
Use grep_search for patterns; use Bash ONLY for read-only operations.
|
||||||
|
3. **Design Solution**: Create an approach based on the assigned \
|
||||||
|
perspective. Consider trade-offs and architectural decisions. Follow \
|
||||||
|
existing patterns.
|
||||||
|
4. **Detail the Plan**: Provide a step-by-step strategy. Identify \
|
||||||
|
dependencies and sequencing. Anticipate challenges.
|
||||||
|
|
||||||
|
## Required Output
|
||||||
|
|
||||||
|
End your response with:
|
||||||
|
|
||||||
|
### Critical Files for Implementation
|
||||||
|
List the 3-5 most important files that will need to be created or \
|
||||||
|
modified, with a brief note on the purpose of each change.
|
||||||
|
|
||||||
|
REMINDER: You can ONLY explore and plan. You CANNOT write, edit, or \
|
||||||
|
modify any files. You do NOT have access to file editing tools."""
|
||||||
|
|
||||||
|
_VERIFICATION_SYSTEM_PROMPT = f"""\
|
||||||
|
{_READ_ONLY_PREAMBLE}
|
||||||
|
|
||||||
|
You are a verification specialist. Your ONLY job is to verify that \
|
||||||
|
implementation work is correct.
|
||||||
|
|
||||||
|
## Two Failure Patterns to Avoid
|
||||||
|
|
||||||
|
1. **Verification avoidance**: Don't just read the code and say it \
|
||||||
|
looks correct. Run actual commands — builds, tests, linters, type \
|
||||||
|
checks. Reading is not verifying.
|
||||||
|
2. **Seduction by the first 80%**: The obvious path often works. The \
|
||||||
|
bugs hide in edge cases, error paths, concurrent scenarios, and \
|
||||||
|
boundary conditions. Don't stop after the happy path passes.
|
||||||
|
|
||||||
|
## What You MUST Do
|
||||||
|
|
||||||
|
- Read CLAUDE.md / README for build and test instructions
|
||||||
|
- Run the build: does it compile / type-check?
|
||||||
|
- Run the tests: do they all pass? Are there new/modified tests?
|
||||||
|
- Run linters / formatters if configured
|
||||||
|
- Check edge cases and error paths manually
|
||||||
|
- Look for concurrency issues, boundary values, idempotency problems
|
||||||
|
- You may create ephemeral test scripts in /tmp via Bash redirection
|
||||||
|
|
||||||
|
## What You MUST NOT Do
|
||||||
|
|
||||||
|
- Modify any project files (you are read-only)
|
||||||
|
- Install dependencies
|
||||||
|
- Run git write operations (add, commit, push, etc.)
|
||||||
|
- Skip running actual commands in favor of "it looks correct"
|
||||||
|
|
||||||
|
## Recognized Rationalizations (Don't Fall For These)
|
||||||
|
|
||||||
|
- "The code looks correct so I'll skip running tests"
|
||||||
|
- "I'll just read the file instead of running the command"
|
||||||
|
- "The test suite is comprehensive so edge cases are covered"
|
||||||
|
- "This is a small change so verification isn't needed"
|
||||||
|
|
||||||
|
## Required Output Format
|
||||||
|
|
||||||
|
For each verification step, report:
|
||||||
|
- **Command run**: exact command
|
||||||
|
- **Output observed**: actual output (truncated if long)
|
||||||
|
- **Result**: PASS / FAIL / SKIP (with reason)
|
||||||
|
|
||||||
|
End with exactly one of:
|
||||||
|
- `VERDICT: PASS` — all checks pass, implementation is correct
|
||||||
|
- `VERDICT: FAIL` — critical issues found (list them)
|
||||||
|
- `VERDICT: PARTIAL` — some checks pass, some fail or were skipped"""
|
||||||
|
|
||||||
|
_STATUSLINE_SETUP_SYSTEM_PROMPT = """\
|
||||||
|
You are a status line setup specialist. Your job is to create or update \
|
||||||
|
the statusLine command in the user's Claude Code settings.
|
||||||
|
|
||||||
|
## PS1 Conversion Steps
|
||||||
|
|
||||||
|
1. Read shell config files in order: ~/.zshrc, ~/.bashrc, ~/.bash_profile, \
|
||||||
|
~/.profile
|
||||||
|
2. Extract the PS1 value
|
||||||
|
3. Convert PS1 escape sequences to shell commands:
|
||||||
|
- \\\\u → $(whoami)
|
||||||
|
- \\\\h → $(hostname -s)
|
||||||
|
- \\\\H → $(hostname)
|
||||||
|
- \\\\w → $(pwd)
|
||||||
|
- \\\\W → $(basename "$(pwd)")
|
||||||
|
- \\\\$ → $
|
||||||
|
- \\\\n → \\n
|
||||||
|
- \\\\t → $(date +%H:%M:%S)
|
||||||
|
- \\\\d → $(date "+%a %b %d")
|
||||||
|
- \\\\@ → $(date +%I:%M%p)
|
||||||
|
4. When using ANSI color codes, use printf — don't remove colors
|
||||||
|
5. If PS1 would have a trailing "$" or ">", remove them
|
||||||
|
6. If no PS1 found and no other instructions, ask for further instructions
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Update ~/.claude/settings.json (or the symlink target) with the \
|
||||||
|
statusLine command. Preserve all existing settings when updating.
|
||||||
|
|
||||||
|
At the end, inform the parent agent that the "statusline-setup" agent \
|
||||||
|
must be used for any further status line changes."""
|
||||||
|
|
||||||
|
_CLAUDE_CODE_GUIDE_SYSTEM_PROMPT = """\
|
||||||
|
You are a documentation and feature guidance specialist for Claude Code.
|
||||||
|
|
||||||
|
## Your Three Domains
|
||||||
|
|
||||||
|
1. **Claude Code (the CLI tool)**: features, hooks, slash commands, MCP \
|
||||||
|
servers, settings, IDE integrations, keyboard shortcuts
|
||||||
|
2. **Claude Agent SDK**: building custom agents
|
||||||
|
3. **Claude API (Anthropic API)**: API usage, tool use, SDK usage
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. Determine which domain the question falls into
|
||||||
|
2. Search for relevant documentation in the codebase
|
||||||
|
3. Provide guidance with code snippets where helpful
|
||||||
|
4. Reference specific documentation sections
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Prioritize official documentation and actual codebase behavior
|
||||||
|
- Keep responses concise and actionable
|
||||||
|
- Include code snippets for configuration and usage examples
|
||||||
|
- If unsure, say so rather than guessing"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in agent instances
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GENERAL_PURPOSE_AGENT = AgentDefinition(
|
||||||
|
agent_type='general-purpose',
|
||||||
|
when_to_use=(
|
||||||
|
'General-purpose agent for researching complex questions, searching '
|
||||||
|
'for code, and executing multi-step tasks. When you are searching '
|
||||||
|
'for a keyword or file and are not confident that you will find the '
|
||||||
|
'right match in the first few tries use this agent to perform the '
|
||||||
|
'search for you.'
|
||||||
|
),
|
||||||
|
system_prompt=_GENERAL_PURPOSE_SYSTEM_PROMPT,
|
||||||
|
tools=None, # all tools
|
||||||
|
)
|
||||||
|
|
||||||
|
EXPLORE_AGENT = AgentDefinition(
|
||||||
|
agent_type='Explore',
|
||||||
|
when_to_use=(
|
||||||
|
'Fast agent specialized for exploring codebases. Use this when you '
|
||||||
|
'need to quickly find files by patterns (eg. "src/components/**/*.tsx"), '
|
||||||
|
'search code for keywords (eg. "API endpoints"), or answer questions '
|
||||||
|
'about the codebase (eg. "how do API endpoints work?"). When calling '
|
||||||
|
'this agent, specify the desired thoroughness level: "quick" for basic '
|
||||||
|
'searches, "medium" for moderate exploration, or "very thorough" for '
|
||||||
|
'comprehensive analysis across multiple locations and naming conventions.'
|
||||||
|
),
|
||||||
|
system_prompt=_EXPLORE_SYSTEM_PROMPT,
|
||||||
|
model='haiku',
|
||||||
|
disallowed_tools=tuple(EXPLORE_PLAN_DISALLOWED_TOOLS),
|
||||||
|
one_shot=True,
|
||||||
|
omit_claude_md=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLAN_AGENT = AgentDefinition(
|
||||||
|
agent_type='Plan',
|
||||||
|
when_to_use=(
|
||||||
|
'Software architect agent for designing implementation plans. Use '
|
||||||
|
'this when you need to plan the implementation strategy for a task. '
|
||||||
|
'Returns step-by-step plans, identifies critical files, and considers '
|
||||||
|
'architectural trade-offs.'
|
||||||
|
),
|
||||||
|
system_prompt=_PLAN_SYSTEM_PROMPT,
|
||||||
|
model='inherit',
|
||||||
|
disallowed_tools=tuple(EXPLORE_PLAN_DISALLOWED_TOOLS),
|
||||||
|
one_shot=True,
|
||||||
|
omit_claude_md=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
VERIFICATION_AGENT = AgentDefinition(
|
||||||
|
agent_type='verification',
|
||||||
|
when_to_use=(
|
||||||
|
'Use this agent to verify that implementation work is correct before '
|
||||||
|
'reporting completion. Invoke after non-trivial tasks (3+ file edits, '
|
||||||
|
'backend/API changes, infrastructure changes). Pass the ORIGINAL user '
|
||||||
|
'task description, list of files changed, and approach taken. The '
|
||||||
|
'agent runs builds, tests, linters, and checks to produce a '
|
||||||
|
'PASS/FAIL/PARTIAL verdict with evidence.'
|
||||||
|
),
|
||||||
|
system_prompt=_VERIFICATION_SYSTEM_PROMPT,
|
||||||
|
model='inherit',
|
||||||
|
color='red',
|
||||||
|
background=True,
|
||||||
|
disallowed_tools=tuple(EXPLORE_PLAN_DISALLOWED_TOOLS),
|
||||||
|
critical_system_reminder=(
|
||||||
|
'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, '
|
||||||
|
'or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral '
|
||||||
|
'test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or '
|
||||||
|
'VERDICT: PARTIAL.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUSLINE_SETUP_AGENT = AgentDefinition(
|
||||||
|
agent_type='statusline-setup',
|
||||||
|
when_to_use=(
|
||||||
|
"Use this agent to configure the user's Claude Code status line setting."
|
||||||
|
),
|
||||||
|
system_prompt=_STATUSLINE_SETUP_SYSTEM_PROMPT,
|
||||||
|
model='sonnet',
|
||||||
|
color='orange',
|
||||||
|
tools=('read_file', 'edit_file'),
|
||||||
|
)
|
||||||
|
|
||||||
|
CLAUDE_CODE_GUIDE_AGENT = AgentDefinition(
|
||||||
|
agent_type='claude-code-guide',
|
||||||
|
when_to_use=(
|
||||||
|
'Use this agent when the user asks questions ("Can Claude...", '
|
||||||
|
'"Does Claude...", "How do I...") about: (1) Claude Code (the CLI '
|
||||||
|
'tool) - features, hooks, slash commands, MCP servers, settings, IDE '
|
||||||
|
'integrations, keyboard shortcuts; (2) Claude Agent SDK - building '
|
||||||
|
'custom agents; (3) Claude API (formerly Anthropic API) - API usage, '
|
||||||
|
'tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new '
|
||||||
|
'agent, check if there is already a running or recently completed '
|
||||||
|
'claude-code-guide agent that you can continue via SendMessage.'
|
||||||
|
),
|
||||||
|
system_prompt=_CLAUDE_CODE_GUIDE_SYSTEM_PROMPT,
|
||||||
|
model='haiku',
|
||||||
|
permission_mode='dontAsk',
|
||||||
|
tools=('glob_search', 'grep_search', 'read_file', 'web_fetch', 'web_search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ONE_SHOT_AGENT_TYPES = frozenset({'Explore', 'Plan'})
|
||||||
|
"""Agent types that run once and return a report (no agentId / SendMessage)."""
|
||||||
|
|
||||||
|
_BUILTIN_AGENTS: tuple[AgentDefinition, ...] = (
|
||||||
|
GENERAL_PURPOSE_AGENT,
|
||||||
|
EXPLORE_AGENT,
|
||||||
|
PLAN_AGENT,
|
||||||
|
VERIFICATION_AGENT,
|
||||||
|
STATUSLINE_SETUP_AGENT,
|
||||||
|
CLAUDE_CODE_GUIDE_AGENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin_agents() -> tuple[AgentDefinition, ...]:
|
||||||
|
"""Return all built-in agent definitions."""
|
||||||
|
return _BUILTIN_AGENTS
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_definition(agent_type: str) -> AgentDefinition | None:
|
||||||
|
"""Look up a built-in agent definition by type name (case-sensitive)."""
|
||||||
|
for agent in _BUILTIN_AGENTS:
|
||||||
|
if agent.agent_type == agent_type:
|
||||||
|
return agent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_types() -> list[str]:
|
||||||
|
"""Return sorted list of all available agent type names."""
|
||||||
|
return sorted(agent.agent_type for agent in _BUILTIN_AGENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def format_agent_listing(agents: tuple[AgentDefinition, ...] | None = None) -> str:
|
||||||
|
"""Format agent types for inclusion in the Agent tool prompt.
|
||||||
|
|
||||||
|
Mirrors the npm ``formatAgentLine`` helper.
|
||||||
|
"""
|
||||||
|
if agents is None:
|
||||||
|
agents = _BUILTIN_AGENTS
|
||||||
|
lines: list[str] = []
|
||||||
|
for agent in agents:
|
||||||
|
tools_desc = _describe_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:
|
||||||
|
"""Describe the tool access for an agent definition."""
|
||||||
|
if agent.tools is not None:
|
||||||
|
return ', '.join(agent.tools) if agent.tools else 'none'
|
||||||
|
if agent.disallowed_tools:
|
||||||
|
denied = ', '.join(sorted(agent.disallowed_tools))
|
||||||
|
return f'All tools except {denied}'
|
||||||
|
return '*'
|
||||||
+212
-31
@@ -9,6 +9,9 @@ Mirrors the npm ``src/services/compact/compact.ts`` and
|
|||||||
(``get_compact_user_summary_message``).
|
(``get_compact_user_summary_message``).
|
||||||
- The core ``compact_conversation`` entry point that an
|
- The core ``compact_conversation`` entry point that an
|
||||||
``/compact`` slash command or auto-compact subsystem can call.
|
``/compact`` slash command or auto-compact subsystem can call.
|
||||||
|
- PTL retry loop: drops oldest API-round groups when the compact
|
||||||
|
request itself hits prompt-too-long (up to ``MAX_PTL_RETRIES``).
|
||||||
|
- Circuit-breaker tracking for consecutive failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -38,11 +41,21 @@ ERROR_INCOMPLETE_RESPONSE = (
|
|||||||
'The conversation was not compacted.'
|
'The conversation was not compacted.'
|
||||||
)
|
)
|
||||||
ERROR_USER_ABORT = 'Compaction canceled.'
|
ERROR_USER_ABORT = 'Compaction canceled.'
|
||||||
|
ERROR_PROMPT_TOO_LONG = (
|
||||||
|
'The compact request itself was too long even after retry truncation.'
|
||||||
|
)
|
||||||
|
|
||||||
MAX_COMPACT_FAILURES = 3
|
MAX_COMPACT_FAILURES = 3
|
||||||
"""Circuit-breaker – stop retrying auto-compact after this many consecutive
|
"""Circuit-breaker – stop retrying auto-compact after this many consecutive
|
||||||
failures (mirrors the npm implementation)."""
|
failures (mirrors the npm implementation)."""
|
||||||
|
|
||||||
|
MAX_PTL_RETRIES = 3
|
||||||
|
"""Maximum number of prompt-too-long retry attempts during compaction."""
|
||||||
|
|
||||||
|
PTL_RETRY_MARKER = '[compact_ptl_retry_marker]'
|
||||||
|
"""Synthetic user message prepended when dropping the first API-round group
|
||||||
|
leaves an assistant message at position 0."""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prompt construction (npm ``src/services/compact/prompt.ts``)
|
# Prompt construction (npm ``src/services/compact/prompt.ts``)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -272,6 +285,103 @@ def get_compact_user_summary_message(
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API-round grouping (mirrors npm groupMessagesByApiRound)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def group_messages_by_api_round(
|
||||||
|
messages: list[AgentMessage],
|
||||||
|
) -> list[list[AgentMessage]]:
|
||||||
|
"""Group messages at API-round boundaries.
|
||||||
|
|
||||||
|
A new group starts when an assistant message with a different
|
||||||
|
``message_id`` than the previous assistant message is encountered.
|
||||||
|
"""
|
||||||
|
groups: list[list[AgentMessage]] = []
|
||||||
|
current: list[AgentMessage] = []
|
||||||
|
last_assistant_id: str | None = None
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if (
|
||||||
|
msg.role == 'assistant'
|
||||||
|
and msg.message_id != last_assistant_id
|
||||||
|
and current
|
||||||
|
):
|
||||||
|
groups.append(current)
|
||||||
|
current = [msg]
|
||||||
|
else:
|
||||||
|
current.append(msg)
|
||||||
|
if msg.role == 'assistant':
|
||||||
|
last_assistant_id = msg.message_id
|
||||||
|
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_head_for_ptl_retry(
|
||||||
|
messages: list[AgentMessage],
|
||||||
|
model: str = '',
|
||||||
|
token_gap: int | None = None,
|
||||||
|
) -> list[AgentMessage] | None:
|
||||||
|
"""Drop oldest API-round groups to free space for the compact request.
|
||||||
|
|
||||||
|
If *token_gap* is provided, drops enough groups to cover that many
|
||||||
|
tokens. Otherwise falls back to dropping ~20% of groups.
|
||||||
|
|
||||||
|
Returns ``None`` if nothing can be dropped without emptying the list.
|
||||||
|
"""
|
||||||
|
# Strip a previous PTL_RETRY_MARKER to prevent stalling
|
||||||
|
working = list(messages)
|
||||||
|
if (
|
||||||
|
working
|
||||||
|
and working[0].role == 'user'
|
||||||
|
and working[0].content == PTL_RETRY_MARKER
|
||||||
|
):
|
||||||
|
working = working[1:]
|
||||||
|
|
||||||
|
groups = group_messages_by_api_round(working)
|
||||||
|
if len(groups) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if token_gap is not None and token_gap > 0:
|
||||||
|
accumulated = 0
|
||||||
|
drop_count = 0
|
||||||
|
for group in groups:
|
||||||
|
group_tokens = sum(estimate_tokens(m.content, model) for m in group)
|
||||||
|
accumulated += group_tokens
|
||||||
|
drop_count += 1
|
||||||
|
if accumulated >= token_gap:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Fallback: drop ~20% of groups
|
||||||
|
drop_count = max(1, len(groups) // 5)
|
||||||
|
|
||||||
|
drop_count = min(drop_count, len(groups) - 1)
|
||||||
|
if drop_count < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining: list[AgentMessage] = []
|
||||||
|
for group in groups[drop_count:]:
|
||||||
|
remaining.extend(group)
|
||||||
|
|
||||||
|
if not remaining:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If the first remaining message is an assistant message, prepend a
|
||||||
|
# synthetic user marker so the API contract is satisfied.
|
||||||
|
if remaining[0].role == 'assistant':
|
||||||
|
marker = AgentMessage(
|
||||||
|
role='user',
|
||||||
|
content=PTL_RETRY_MARKER,
|
||||||
|
message_id='ptl_retry_marker',
|
||||||
|
metadata={'kind': 'ptl_retry_marker', 'is_meta': True},
|
||||||
|
)
|
||||||
|
remaining = [marker] + remaining
|
||||||
|
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Compaction result
|
# Compaction result
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -285,15 +395,54 @@ class CompactionResult:
|
|||||||
messages_to_keep: list[AgentMessage] = field(default_factory=list)
|
messages_to_keep: list[AgentMessage] = field(default_factory=list)
|
||||||
pre_compact_token_count: int = 0
|
pre_compact_token_count: int = 0
|
||||||
post_compact_token_count: int = 0
|
post_compact_token_count: int = 0
|
||||||
|
true_post_compact_token_count: int = 0
|
||||||
summary_text: str = ''
|
summary_text: str = ''
|
||||||
usage: UsageStats = field(default_factory=UsageStats)
|
usage: UsageStats = field(default_factory=UsageStats)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
ptl_retries: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Core compaction logic
|
# Core compaction logic
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_prompt_too_long_response(content: str) -> bool:
|
||||||
|
"""Check if a model response indicates a prompt-too-long error.
|
||||||
|
|
||||||
|
Some models embed the error in the response text rather than raising.
|
||||||
|
"""
|
||||||
|
lower = content.lower()
|
||||||
|
return (
|
||||||
|
'prompt is too long' in lower
|
||||||
|
or 'prompt_too_long' in lower
|
||||||
|
or 'context_length_exceeded' in lower
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_compact_model(
|
||||||
|
agent: 'LocalCodingAgent',
|
||||||
|
api_messages: list[dict[str, Any]],
|
||||||
|
) -> tuple[str | None, 'UsageStats', str | None]:
|
||||||
|
"""Call the model for compaction, returning (content, usage, error).
|
||||||
|
|
||||||
|
Returns (None, usage, error_string) on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
turn = agent.client.complete(api_messages, tools=[])
|
||||||
|
except Exception as exc:
|
||||||
|
error_str = str(exc)
|
||||||
|
if 'prompt' in error_str.lower() and 'long' in error_str.lower():
|
||||||
|
return None, UsageStats(), 'prompt_too_long'
|
||||||
|
return None, UsageStats(), error_str
|
||||||
|
|
||||||
|
raw = turn.content or ''
|
||||||
|
if not raw.strip():
|
||||||
|
return None, turn.usage, 'empty_response'
|
||||||
|
if _is_prompt_too_long_response(raw):
|
||||||
|
return None, turn.usage, 'prompt_too_long'
|
||||||
|
return raw, turn.usage, None
|
||||||
|
|
||||||
|
|
||||||
def compact_conversation(
|
def compact_conversation(
|
||||||
agent: 'LocalCodingAgent',
|
agent: 'LocalCodingAgent',
|
||||||
custom_instructions: str | None = None,
|
custom_instructions: str | None = None,
|
||||||
@@ -303,8 +452,10 @@ def compact_conversation(
|
|||||||
1. Build the compact prompt (9-section template).
|
1. Build the compact prompt (9-section template).
|
||||||
2. Collect the session messages to summarise.
|
2. Collect the session messages to summarise.
|
||||||
3. Send them + the compact prompt to the model.
|
3. Send them + the compact prompt to the model.
|
||||||
4. Parse ``<summary>`` from the response.
|
4. On prompt-too-long, retry by dropping oldest API-round groups
|
||||||
5. Replace session messages with:
|
(up to ``MAX_PTL_RETRIES`` attempts).
|
||||||
|
5. Parse ``<summary>`` from the response.
|
||||||
|
6. Replace session messages with:
|
||||||
boundary marker → summary user message → preserved tail.
|
boundary marker → summary user message → preserved tail.
|
||||||
|
|
||||||
Returns a :class:`CompactionResult` with diagnostics.
|
Returns a :class:`CompactionResult` with diagnostics.
|
||||||
@@ -317,13 +468,10 @@ def compact_conversation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---- Determine which messages to compact vs preserve ----
|
# ---- Determine which messages to compact vs preserve ----
|
||||||
# We keep the most recent ``preserve_count`` messages untouched.
|
|
||||||
preserve_count = max(
|
preserve_count = max(
|
||||||
getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1
|
getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1
|
||||||
)
|
)
|
||||||
|
|
||||||
# Identify the prefix count (system-injected messages that precede the
|
|
||||||
# real conversation, e.g. a compaction-replay boundary).
|
|
||||||
prefix_count = 0
|
prefix_count = 0
|
||||||
for msg in session.messages:
|
for msg in session.messages:
|
||||||
if msg.metadata.get('kind') == 'compact_boundary':
|
if msg.metadata.get('kind') == 'compact_boundary':
|
||||||
@@ -341,7 +489,7 @@ def compact_conversation(
|
|||||||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates = session.messages[prefix_count:compact_end]
|
candidates = list(session.messages[prefix_count:compact_end])
|
||||||
preserved_tail = list(session.messages[compact_end:])
|
preserved_tail = list(session.messages[compact_end:])
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -354,39 +502,67 @@ def compact_conversation(
|
|||||||
model = agent.model_config.model
|
model = agent.model_config.model
|
||||||
pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
||||||
|
|
||||||
# ---- Build the compact request messages ----
|
# ---- Build the compact request ----
|
||||||
compact_prompt = get_compact_prompt(custom_instructions)
|
compact_prompt = get_compact_prompt(custom_instructions)
|
||||||
|
|
||||||
# We send the system prompt + candidate messages + the compact prompt as
|
# ---- PTL retry loop ----
|
||||||
# a user message. The model returns the summary.
|
messages_to_summarize = candidates
|
||||||
api_messages: list[dict[str, Any]] = []
|
ptl_retries = 0
|
||||||
|
total_usage = UsageStats()
|
||||||
|
raw_summary: str | None = None
|
||||||
|
|
||||||
# System prompt (from session)
|
for attempt in range(MAX_PTL_RETRIES + 1):
|
||||||
for part in session.system_prompt_parts:
|
api_messages: list[dict[str, Any]] = []
|
||||||
if part.strip():
|
|
||||||
api_messages.append({'role': 'system', 'content': part})
|
|
||||||
|
|
||||||
# Candidate messages (the ones to be summarised)
|
for part in session.system_prompt_parts:
|
||||||
for msg in candidates:
|
if part.strip():
|
||||||
api_messages.append(msg.to_openai_message())
|
api_messages.append({'role': 'system', 'content': part})
|
||||||
|
|
||||||
# The compact prompt as the final user turn
|
for msg in messages_to_summarize:
|
||||||
api_messages.append({'role': 'user', 'content': compact_prompt})
|
api_messages.append(msg.to_openai_message())
|
||||||
|
|
||||||
# ---- Call the model ----
|
api_messages.append({'role': 'user', 'content': compact_prompt})
|
||||||
try:
|
|
||||||
turn = agent.client.complete(api_messages, tools=[])
|
content, usage, error = _call_compact_model(agent, api_messages)
|
||||||
except Exception as exc:
|
total_usage = total_usage + usage
|
||||||
return CompactionResult(
|
|
||||||
boundary_message=_build_boundary(f'Compact API call failed: {exc}'),
|
if error != 'prompt_too_long':
|
||||||
error=str(exc),
|
raw_summary = content
|
||||||
|
break
|
||||||
|
|
||||||
|
# PTL error — try truncating oldest API-round groups
|
||||||
|
ptl_retries += 1
|
||||||
|
if attempt >= MAX_PTL_RETRIES:
|
||||||
|
return CompactionResult(
|
||||||
|
boundary_message=_build_boundary(
|
||||||
|
f'Compact request was too long after {ptl_retries} retries.'
|
||||||
|
),
|
||||||
|
error=ERROR_PROMPT_TOO_LONG,
|
||||||
|
usage=total_usage,
|
||||||
|
ptl_retries=ptl_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
truncated = truncate_head_for_ptl_retry(
|
||||||
|
messages_to_summarize, model=model,
|
||||||
)
|
)
|
||||||
|
if truncated is None:
|
||||||
|
return CompactionResult(
|
||||||
|
boundary_message=_build_boundary(
|
||||||
|
'Cannot truncate further for compact retry.'
|
||||||
|
),
|
||||||
|
error=ERROR_PROMPT_TOO_LONG,
|
||||||
|
usage=total_usage,
|
||||||
|
ptl_retries=ptl_retries,
|
||||||
|
)
|
||||||
|
messages_to_summarize = truncated
|
||||||
|
|
||||||
raw_summary = turn.content or ''
|
if raw_summary is None:
|
||||||
if not raw_summary.strip():
|
error_msg = error or ERROR_INCOMPLETE_RESPONSE
|
||||||
return CompactionResult(
|
return CompactionResult(
|
||||||
boundary_message=_build_boundary('Model returned empty summary.'),
|
boundary_message=_build_boundary(f'Compaction failed: {error_msg}'),
|
||||||
error=ERROR_INCOMPLETE_RESPONSE,
|
error=error_msg,
|
||||||
|
usage=total_usage,
|
||||||
|
ptl_retries=ptl_retries,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Format the summary ----
|
# ---- Format the summary ----
|
||||||
@@ -422,8 +598,13 @@ def compact_conversation(
|
|||||||
messages_to_keep=preserved_tail,
|
messages_to_keep=preserved_tail,
|
||||||
pre_compact_token_count=pre_tokens,
|
pre_compact_token_count=pre_tokens,
|
||||||
post_compact_token_count=post_tokens,
|
post_compact_token_count=post_tokens,
|
||||||
|
true_post_compact_token_count=sum(
|
||||||
|
estimate_tokens(m.content, model)
|
||||||
|
for m in [boundary, summary_msg] + preserved_tail
|
||||||
|
),
|
||||||
summary_text=summary_text,
|
summary_text=summary_text,
|
||||||
usage=turn.usage,
|
usage=total_usage,
|
||||||
|
ptl_retries=ptl_retries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Microcompact service — lightweight tool-result clearing for context efficiency.
|
||||||
|
|
||||||
|
Mirrors the npm ``src/services/compact/microCompact.ts`` module.
|
||||||
|
|
||||||
|
Provides time-based microcompaction: when a significant gap exists since the
|
||||||
|
last assistant message (indicating cache has expired), old tool results are
|
||||||
|
replaced with a short cleared marker to reduce context size on the next
|
||||||
|
API call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .agent_context_usage import estimate_tokens
|
||||||
|
from .agent_session import AgentMessage
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'
|
||||||
|
"""Replacement content for cleared tool results."""
|
||||||
|
|
||||||
|
IMAGE_MAX_TOKEN_SIZE = 2000
|
||||||
|
"""Fixed token estimate for image/document blocks."""
|
||||||
|
|
||||||
|
# Tools whose results can be safely cleared during microcompaction.
|
||||||
|
COMPACTABLE_TOOLS: frozenset[str] = frozenset({
|
||||||
|
'read_file',
|
||||||
|
'bash',
|
||||||
|
'grep_search',
|
||||||
|
'glob_search',
|
||||||
|
'web_search',
|
||||||
|
'web_fetch',
|
||||||
|
'edit_file',
|
||||||
|
'write_file',
|
||||||
|
})
|
||||||
|
|
||||||
|
DEFAULT_GAP_THRESHOLD_MINUTES = 60.0
|
||||||
|
"""Minimum gap (in minutes) since the last assistant message before
|
||||||
|
time-based microcompact fires. Mirrors the npm default."""
|
||||||
|
|
||||||
|
DEFAULT_KEEP_RECENT = 3
|
||||||
|
"""Number of most-recent compactable tool results to always preserve."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MicrocompactResult:
|
||||||
|
"""Outcome of a microcompact pass."""
|
||||||
|
|
||||||
|
messages: list[AgentMessage]
|
||||||
|
cleared_tool_count: int = 0
|
||||||
|
kept_tool_count: int = 0
|
||||||
|
estimated_tokens_saved: int = 0
|
||||||
|
triggered: bool = False
|
||||||
|
gap_minutes: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Time-based microcompact
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_last_assistant_timestamp(messages: list[AgentMessage]) -> float | None:
|
||||||
|
"""Find the timestamp of the last assistant message.
|
||||||
|
|
||||||
|
Returns seconds since epoch, or ``None`` if no assistant message has a
|
||||||
|
``timestamp`` metadata entry.
|
||||||
|
"""
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.role != 'assistant':
|
||||||
|
continue
|
||||||
|
ts = msg.metadata.get('timestamp')
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
return float(ts)
|
||||||
|
if isinstance(ts, str):
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||||
|
return dt.timestamp()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# Fall back to message creation time tracked by the session
|
||||||
|
created = msg.metadata.get('created_at')
|
||||||
|
if isinstance(created, (int, float)):
|
||||||
|
return float(created)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_compactable_tool_ids(
|
||||||
|
messages: list[AgentMessage],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Collect tool_call_ids for tool results that can be safely cleared.
|
||||||
|
|
||||||
|
Walks messages in order and returns IDs for tool-result messages whose
|
||||||
|
name is in :data:`COMPACTABLE_TOOLS`.
|
||||||
|
"""
|
||||||
|
ids: list[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg.role != 'tool':
|
||||||
|
continue
|
||||||
|
if not msg.tool_call_id:
|
||||||
|
continue
|
||||||
|
tool_name = msg.name or msg.metadata.get('tool_name', '')
|
||||||
|
if tool_name in COMPACTABLE_TOOLS:
|
||||||
|
ids.append(msg.tool_call_id)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_time_based_trigger(
|
||||||
|
messages: list[AgentMessage],
|
||||||
|
*,
|
||||||
|
gap_threshold_minutes: float = DEFAULT_GAP_THRESHOLD_MINUTES,
|
||||||
|
) -> float | None:
|
||||||
|
"""Return the gap in minutes since the last assistant message, or ``None``.
|
||||||
|
|
||||||
|
Returns ``None`` when the trigger does not fire (gap below threshold,
|
||||||
|
no assistant messages, etc.).
|
||||||
|
"""
|
||||||
|
ts = _find_last_assistant_timestamp(messages)
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
gap_seconds = time.time() - ts
|
||||||
|
if gap_seconds < 0:
|
||||||
|
return None
|
||||||
|
gap_minutes = gap_seconds / 60.0
|
||||||
|
if gap_minutes < gap_threshold_minutes:
|
||||||
|
return None
|
||||||
|
return gap_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def microcompact_messages(
|
||||||
|
messages: list[AgentMessage],
|
||||||
|
*,
|
||||||
|
model: str = '',
|
||||||
|
gap_threshold_minutes: float = DEFAULT_GAP_THRESHOLD_MINUTES,
|
||||||
|
keep_recent: int = DEFAULT_KEEP_RECENT,
|
||||||
|
) -> MicrocompactResult:
|
||||||
|
"""Run time-based microcompaction on session messages.
|
||||||
|
|
||||||
|
When the gap since the last assistant message exceeds
|
||||||
|
*gap_threshold_minutes*, old tool results (beyond the *keep_recent*
|
||||||
|
most recent) are replaced with a short cleared marker.
|
||||||
|
|
||||||
|
This is useful when the server-side prompt cache has expired and the
|
||||||
|
entire prefix will be rewritten anyway — clearing old tool results
|
||||||
|
shrinks the rewrite payload.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
messages:
|
||||||
|
The session messages to process (not mutated — new list returned).
|
||||||
|
model:
|
||||||
|
Model name for token estimation.
|
||||||
|
gap_threshold_minutes:
|
||||||
|
Minimum idle gap before trigger fires.
|
||||||
|
keep_recent:
|
||||||
|
Number of most-recent compactable tool results to keep.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
MicrocompactResult
|
||||||
|
A result containing the (possibly modified) message list and
|
||||||
|
diagnostic counters.
|
||||||
|
"""
|
||||||
|
gap_minutes = evaluate_time_based_trigger(
|
||||||
|
messages, gap_threshold_minutes=gap_threshold_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if gap_minutes is None:
|
||||||
|
return MicrocompactResult(messages=messages)
|
||||||
|
|
||||||
|
# Collect compactable tool IDs in order
|
||||||
|
compactable_ids = _collect_compactable_tool_ids(messages)
|
||||||
|
if not compactable_ids:
|
||||||
|
return MicrocompactResult(messages=messages, gap_minutes=gap_minutes)
|
||||||
|
|
||||||
|
# Keep the most recent `keep_recent` tools untouched
|
||||||
|
keep_count = max(1, keep_recent)
|
||||||
|
if len(compactable_ids) <= keep_count:
|
||||||
|
return MicrocompactResult(
|
||||||
|
messages=messages,
|
||||||
|
kept_tool_count=len(compactable_ids),
|
||||||
|
gap_minutes=gap_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
clear_ids = set(compactable_ids[:-keep_count])
|
||||||
|
keep_ids = set(compactable_ids[-keep_count:])
|
||||||
|
|
||||||
|
# Build a new message list with cleared tool results
|
||||||
|
new_messages: list[AgentMessage] = []
|
||||||
|
tokens_saved = 0
|
||||||
|
cleared_count = 0
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if (
|
||||||
|
msg.role == 'tool'
|
||||||
|
and msg.tool_call_id
|
||||||
|
and msg.tool_call_id in clear_ids
|
||||||
|
):
|
||||||
|
original_tokens = estimate_tokens(msg.content, model)
|
||||||
|
replacement_tokens = estimate_tokens(TIME_BASED_MC_CLEARED_MESSAGE, model)
|
||||||
|
tokens_saved += max(original_tokens - replacement_tokens, 0)
|
||||||
|
cleared_count += 1
|
||||||
|
|
||||||
|
# Create a new message with cleared content
|
||||||
|
new_msg = AgentMessage(
|
||||||
|
role=msg.role,
|
||||||
|
content=TIME_BASED_MC_CLEARED_MESSAGE,
|
||||||
|
name=msg.name,
|
||||||
|
tool_call_id=msg.tool_call_id,
|
||||||
|
message_id=msg.message_id,
|
||||||
|
metadata={
|
||||||
|
**msg.metadata,
|
||||||
|
'microcompact_cleared': True,
|
||||||
|
'original_token_estimate': original_tokens,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
new_messages.append(new_msg)
|
||||||
|
else:
|
||||||
|
new_messages.append(msg)
|
||||||
|
|
||||||
|
return MicrocompactResult(
|
||||||
|
messages=new_messages,
|
||||||
|
cleared_tool_count=cleared_count,
|
||||||
|
kept_tool_count=len(keep_ids),
|
||||||
|
estimated_tokens_saved=tokens_saved,
|
||||||
|
triggered=cleared_count > 0,
|
||||||
|
gap_minutes=gap_minutes,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user