Merge pull request #24 from HarnessLab/agent_update
8 new commands, Bundled Skills , Plan/Task Tools and Session Memory Compact
This commit is contained in:
+25
-20
@@ -2,7 +2,7 @@
|
||||
|
||||
This document tracks what is already implemented in Python and what is still missing compared with the upstream npm runtime.
|
||||
|
||||
This is a functionality-oriented checklist, not a line-by-line source equivalence claim. Large parts of the mirrored Python workspace still act as inventory or scaffolding, while the working Python runtime currently lives mainly in [`src/agent_runtime.py`](src/agent_runtime.py), [`src/query_engine.py`](src/query_engine.py), [`src/agent_tools.py`](src/agent_tools.py), [`src/agent_prompting.py`](src/agent_prompting.py), [`src/agent_context.py`](src/agent_context.py), [`src/agent_manager.py`](src/agent_manager.py), [`src/plugin_runtime.py`](src/plugin_runtime.py), [`src/agent_slash_commands.py`](src/agent_slash_commands.py), and [`src/openai_compat.py`](src/openai_compat.py).
|
||||
This is a functionality-oriented checklist, not a line-by-line source equivalence claim. Large parts of the mirrored Python workspace still act as inventory or scaffolding, while the working Python runtime currently lives mainly in [`src/agent_runtime.py`](src/agent_runtime.py), [`src/query_engine.py`](src/query_engine.py), [`src/agent_tools.py`](src/agent_tools.py), [`src/agent_prompting.py`](src/agent_prompting.py), [`src/agent_context.py`](src/agent_context.py), [`src/agent_manager.py`](src/agent_manager.py), [`src/plugin_runtime.py`](src/plugin_runtime.py), [`src/agent_slash_commands.py`](src/agent_slash_commands.py), [`src/openai_compat.py`](src/openai_compat.py), [`src/builtin_agents.py`](src/builtin_agents.py), [`src/microcompact.py`](src/microcompact.py), [`src/compact.py`](src/compact.py), [`src/bundled_skills.py`](src/bundled_skills.py), and [`src/session_memory_compact.py`](src/session_memory_compact.py).
|
||||
|
||||
---
|
||||
|
||||
@@ -254,7 +254,7 @@ Missing:
|
||||
|
||||
- [ ] Full tokenizer/chat-message framing parity beyond the current model-aware text token counters
|
||||
- [ ] Full parity with `utils/queryContext.ts` (context analysis, suggestions, cache shaping)
|
||||
- [ ] Rich memory prompt loading (`services/SessionMemory/`)
|
||||
- [x] Session memory compact (`services/SessionMemory/` partial) → `src/session_memory_compact.py` — remaining: background LLM extraction, full template handling
|
||||
- [ ] Internal permission-aware memory handling
|
||||
- [ ] Resume-aware prompt cache shaping used upstream
|
||||
- [ ] More exact context cache invalidation rules
|
||||
@@ -269,7 +269,7 @@ Missing:
|
||||
|
||||
## 5. Slash Commands
|
||||
|
||||
Done (37 slash command names in 29 specs):
|
||||
Done (53 slash command names in 37 specs):
|
||||
|
||||
- [x] `/help`, `/commands`
|
||||
- [x] `/context`, `/usage`
|
||||
@@ -311,7 +311,7 @@ Done (37 slash command names in 29 specs):
|
||||
|
||||
Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
||||
|
||||
- [ ] `/add-dir` — Add a new working directory
|
||||
- [x] `/add-dir` — Add a new working directory
|
||||
- [ ] `/agents` — Manage agent configurations
|
||||
- [x] `/branch` — Create a branch of the current conversation
|
||||
- [ ] `/bridge` — Connect for remote-control sessions
|
||||
@@ -328,7 +328,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
||||
- [x] `/exit` — Exit the REPL
|
||||
- [x] `/export` — Export conversation to file or clipboard
|
||||
- [ ] `/extra-usage` — Configure extra usage for rate limits
|
||||
- [ ] `/fast` — Toggle fast mode
|
||||
- [x] `/fast` — Toggle fast mode
|
||||
- [ ] `/feedback` — Submit feedback
|
||||
- [x] `/files` — List all files currently in context
|
||||
- [ ] `/ide` — Manage IDE integrations and show status
|
||||
@@ -339,7 +339,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
||||
- [ ] `/output-style` — Change output style
|
||||
- [ ] `/passes` — Passes management
|
||||
- [ ] `/plugin` — Plugin management
|
||||
- [ ] `/pr_comments` — Get comments from a GitHub PR
|
||||
- [x] `/pr-comments`, `/pr_comments` — Get comments from a GitHub PR (prompt-type)
|
||||
- [ ] `/privacy-settings` — View/update privacy settings
|
||||
- [ ] `/rate-limit-options` — Show options when rate limited
|
||||
- [ ] `/release-notes` — View release notes
|
||||
@@ -347,23 +347,24 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
||||
- [ ] `/remote-env` — Configure default remote environment
|
||||
- [ ] `/remote-setup` — Remote setup configuration
|
||||
- [x] `/rename` — Rename current conversation
|
||||
- [ ] `/resume` — Resume a previous conversation
|
||||
- [ ] `/rewind` — Restore code/conversation to a previous point
|
||||
- [x] `/resume`, `/continue` — Resume a previous conversation
|
||||
- [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point
|
||||
- [ ] `/sandbox-toggle` — Toggle sandbox mode
|
||||
- [ ] `/skills` — List available skills
|
||||
- [x] `/skills` — List available skills
|
||||
- [x] `/stats` — Usage statistics and activity
|
||||
- [ ] `/stickers` — Order stickers
|
||||
- [x] `/tag` — Toggle a searchable tag on the session
|
||||
- [ ] `/theme` — Change the theme
|
||||
- [ ] `/upgrade` — Upgrade to Max
|
||||
- [ ] `/vim` — Toggle Vim/Normal editing modes
|
||||
- [x] `/vim` — Toggle Vim/Normal editing modes
|
||||
- [ ] `/voice` — Toggle voice mode
|
||||
- [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc.
|
||||
- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc.
|
||||
- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc.
|
||||
- [x] `/commit` — Create a git commit (prompt-type with injected git context)
|
||||
|
||||
## 6. Built-in Tools
|
||||
|
||||
### Tools implemented in Python (61 tools):
|
||||
### Tools implemented in Python (65 tools):
|
||||
|
||||
- [x] `list_dir`
|
||||
- [x] `read_file`
|
||||
@@ -424,12 +425,16 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
|
||||
- [x] `team_delete`
|
||||
- [x] `send_message`
|
||||
- [x] `team_messages`
|
||||
- [x] `EnterPlanMode`
|
||||
- [x] `ExitPlanMode`
|
||||
- [x] `TaskOutput`
|
||||
- [x] `TaskStop`
|
||||
|
||||
### Tools in npm `tools.ts` not yet ported with full fidelity (40 tool dirs):
|
||||
|
||||
Core tools needing full port:
|
||||
- [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
|
||||
- [x] `SkillTool` — Skill execution via slash commands → `src/agent_tools.py`, `src/agent_runtime.py` — remaining: bundled skill definitions, forked skill execution
|
||||
- [x] `SkillTool` — Skill execution via slash commands and bundled skills → `src/agent_tools.py`, `src/agent_runtime.py`, `src/bundled_skills.py` — remaining: forked skill execution
|
||||
- [ ] `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)
|
||||
- [ ] `PowerShellTool` — Full PowerShell execution with security, path validation, CLM types, git safety
|
||||
@@ -438,12 +443,12 @@ Core tools needing full port:
|
||||
- [ ] `McpAuthTool` — MCP authentication handling
|
||||
- [ ] `ConfigTool` — Full config management with supported settings list
|
||||
- [ ] `SyntheticOutputTool` — Synthetic output injection
|
||||
- [ ] `EnterPlanModeTool` — Enter plan mode with UI
|
||||
- [ ] `ExitPlanModeTool` — Exit plan mode with V2 flow
|
||||
- [x] `EnterPlanModeTool` — Enter plan mode → `src/agent_tools.py`
|
||||
- [x] `ExitPlanModeTool` — Exit plan mode → `src/agent_tools.py`
|
||||
- [ ] `EnterWorktreeTool` — Full worktree enter with UI
|
||||
- [ ] `ExitWorktreeTool` — Full worktree exit with UI
|
||||
- [ ] `TaskOutputTool` — Task output display
|
||||
- [ ] `TaskStopTool` — Stop a running task
|
||||
- [x] `TaskOutputTool` — Task output display → `src/agent_tools.py`
|
||||
- [x] `TaskStopTool` — Stop a running task → `src/agent_tools.py`
|
||||
|
||||
Feature-gated tools:
|
||||
- [ ] `CronCreateTool` / `CronDeleteTool` / `CronListTool` — Cron scheduling (AGENT_TRIGGERS)
|
||||
@@ -546,8 +551,8 @@ Missing:
|
||||
- [ ] Bundled plugin support (`plugins/bundledPlugins.ts`, `plugins/bundled/`)
|
||||
- [ ] Plugin lifecycle management
|
||||
- [ ] Plugin update/cache behavior
|
||||
- [ ] Skill discovery and execution (`skills/bundledSkills.ts`, `skills/loadSkillsDir.ts`, `skills/mcpSkillBuilders.ts`, `skills/bundled/`)
|
||||
- [ ] Bundled skill support
|
||||
- [x] Skill discovery and execution → `src/bundled_skills.py` (simplify, verify, debug, update-config) — remaining: loadSkillsDir, mcpSkillBuilders, disk-based SKILL.md loading
|
||||
- [x] Bundled skill support → `src/bundled_skills.py` — remaining: skillify, batch, loop, schedule, claude-api, chrome, and feature-gated skills
|
||||
- [ ] Full plugin and skill parity
|
||||
|
||||
## 10. Interactive UI / REPL / TUI
|
||||
@@ -629,7 +634,7 @@ Missing:
|
||||
- [ ] API service (`services/api/` — 20+ files: claude client, dumpPrompts, errorUtils, filesApi, firstTokenDate, grove, logging, metricsOptOut, promptCacheBreakDetection, sessionIngress, usage, withRetry, etc.)
|
||||
- [ ] LSP service (`services/lsp/` — 7 files: LSPClient, LSPDiagnosticRegistry, LSPServerInstance, LSPServerManager, config, manager, passiveFeedback)
|
||||
- [ ] Tools service (`services/tools/` — 4 files: StreamingToolExecutor, toolExecution, toolHooks, toolOrchestration)
|
||||
- [ ] Compact service (`services/compact/` — 6 files: compact, autoCompact, microCompact, apiMicrocompact, sessionMemoryCompact, compactWarningHook)
|
||||
- [ ] Compact service (`services/compact/` — 6 files) — partially ported: compact → `src/compact.py`, microCompact → `src/microcompact.py`, sessionMemoryCompact → `src/session_memory_compact.py`; remaining: autoCompact trigger, apiMicrocompact, compactWarningHook
|
||||
- [ ] Auto-dream service (`services/autoDream/` — 4 files: autoDream, config, consolidationLock, consolidationPrompt)
|
||||
- [ ] Agent summary service (`services/AgentSummary/`)
|
||||
- [ ] Magic docs service (`services/MagicDocs/`)
|
||||
|
||||
+26
-8
@@ -2042,12 +2042,12 @@ class LocalCodingAgent:
|
||||
self,
|
||||
arguments: dict[str, object],
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute a skill (slash command) through the Skill tool.
|
||||
"""Execute a skill through the Skill tool.
|
||||
|
||||
Maps the ``skill`` parameter to a slash command, invokes it, and
|
||||
returns its output as a tool result.
|
||||
Checks bundled skills first, then falls back to slash commands.
|
||||
"""
|
||||
from .agent_slash_commands import find_slash_command, get_slash_command_specs
|
||||
from .bundled_skills import find_bundled_skill, get_bundled_skills
|
||||
|
||||
skill_name = arguments.get('skill')
|
||||
if not isinstance(skill_name, str) or not skill_name.strip():
|
||||
@@ -2063,20 +2063,38 @@ class LocalCodingAgent:
|
||||
if not isinstance(args, str):
|
||||
args = str(args) if args is not None else ''
|
||||
|
||||
# Look up the slash command
|
||||
# 1. Check bundled skills first
|
||||
bundled = find_bundled_skill(skill_name)
|
||||
if bundled is not None:
|
||||
prompt = bundled.get_prompt(self, args.strip())
|
||||
return ToolExecutionResult(
|
||||
name='Skill',
|
||||
ok=True,
|
||||
content=prompt,
|
||||
metadata={
|
||||
'action': 'skill',
|
||||
'skill_name': skill_name,
|
||||
'source': 'bundled',
|
||||
'should_query': True,
|
||||
},
|
||||
)
|
||||
|
||||
# 2. Fall back to slash commands
|
||||
spec = find_slash_command(skill_name)
|
||||
if spec is None:
|
||||
available = sorted(
|
||||
available_cmds = sorted(
|
||||
name
|
||||
for spec in get_slash_command_specs()
|
||||
for name in spec.names
|
||||
for s in get_slash_command_specs()
|
||||
for name in s.names
|
||||
)
|
||||
available_skills = [sk.name for sk in get_bundled_skills()]
|
||||
all_available = sorted(set(available_cmds + available_skills))
|
||||
return ToolExecutionResult(
|
||||
name='Skill',
|
||||
ok=False,
|
||||
content=(
|
||||
f'Unknown skill: {skill_name}. '
|
||||
f'Available skills: {", ".join(available[:30])}'
|
||||
f'Available skills: {", ".join(all_available[:30])}'
|
||||
),
|
||||
metadata={'action': 'skill_not_found', 'skill_name': skill_name},
|
||||
)
|
||||
|
||||
@@ -369,6 +369,46 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Diagnose and verify the claw-code installation and settings.',
|
||||
handler=_handle_doctor,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('commit',),
|
||||
description='Create a git commit.',
|
||||
handler=_handle_commit,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('pr-comments', 'pr_comments'),
|
||||
description='Get comments from a GitHub pull request.',
|
||||
handler=_handle_pr_comments,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('resume', 'continue'),
|
||||
description='Resume a previous conversation.',
|
||||
handler=_handle_resume,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('add-dir',),
|
||||
description='Add a new working directory.',
|
||||
handler=_handle_add_dir,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('skills',),
|
||||
description='List available skills.',
|
||||
handler=_handle_skills,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('fast',),
|
||||
description='Toggle fast mode.',
|
||||
handler=_handle_fast,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('vim',),
|
||||
description='Toggle between Vim and Normal editing modes.',
|
||||
handler=_handle_vim,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('rewind', 'checkpoint'),
|
||||
description='Restore the conversation to a previous point.',
|
||||
handler=_handle_rewind,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1185,6 +1225,286 @@ def _handle_doctor(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sl
|
||||
return _local_result(input_text, output)
|
||||
|
||||
|
||||
def _handle_commit(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Create a git commit — prompt-type command injecting git context."""
|
||||
import subprocess
|
||||
|
||||
cwd = str(agent.runtime_config.cwd)
|
||||
|
||||
def _git(cmd_args: list[str]) -> str:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
['git'] + cmd_args,
|
||||
cwd=cwd, capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
status = _git(['status', '--short'])
|
||||
staged = _git(['diff', '--staged'])
|
||||
unstaged = _git(['diff'])
|
||||
branch = _git(['branch', '--show-current'])
|
||||
recent = _git(['log', '--oneline', '-10'])
|
||||
|
||||
sections: list[str] = []
|
||||
if branch:
|
||||
sections.append(f'Current branch: {branch}')
|
||||
if status:
|
||||
sections.append(f'### Status\n```\n{status}\n```')
|
||||
if staged:
|
||||
sections.append(f'### Staged diff\n```\n{staged}\n```')
|
||||
if unstaged:
|
||||
sections.append(f'### Unstaged diff\n```\n{unstaged}\n```')
|
||||
if recent:
|
||||
sections.append(f'### Recent commits\n```\n{recent}\n```')
|
||||
|
||||
git_context = '\n\n'.join(sections) if sections else 'No git changes detected.'
|
||||
|
||||
prompt = f"""Create a git commit for the current changes.
|
||||
|
||||
{git_context}
|
||||
|
||||
Instructions:
|
||||
1. Review the changes above
|
||||
2. Stage appropriate files with `git add <file>` (prefer specific files over `git add -A`)
|
||||
3. Draft a concise commit message following the repo's conventions
|
||||
4. Use a HEREDOC for multi-line messages:
|
||||
git commit -m "$(cat <<'EOF'
|
||||
message here
|
||||
EOF
|
||||
)"
|
||||
|
||||
Safety:
|
||||
- Always create NEW commits (never --amend)
|
||||
- Never skip hooks (--no-verify)
|
||||
- Never commit secrets (.env, credentials, etc.)
|
||||
- No interactive flags (-i)"""
|
||||
|
||||
if args.strip():
|
||||
prompt += f'\n\nAdditional instructions: {args.strip()}'
|
||||
|
||||
return _prompt_result(input_text, prompt)
|
||||
|
||||
|
||||
def _handle_pr_comments(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Fetch PR comments — prompt-type command using gh CLI."""
|
||||
pr_ref = args.strip()
|
||||
|
||||
prompt = f"""Fetch and summarize comments from a GitHub pull request.
|
||||
|
||||
{f'PR reference: {pr_ref}' if pr_ref else 'Find the current PR for this branch.'}
|
||||
|
||||
Steps:
|
||||
1. Get PR info: `gh pr view {pr_ref or ''} --json number,headRefName,headRepository`
|
||||
2. Fetch PR-level comments: `gh api /repos/{{owner}}/{{repo}}/issues/{{number}}/comments --jq '.[] | {{body, user: .user.login, created_at}}'`
|
||||
3. Fetch review comments: `gh api /repos/{{owner}}/{{repo}}/pulls/{{number}}/comments --jq '.[] | {{body, path, line, diff_hunk, user: .user.login}}'`
|
||||
4. Present all comments with file/line context
|
||||
5. Do not add explanatory text — just show the comments"""
|
||||
|
||||
return _prompt_result(input_text, prompt)
|
||||
|
||||
|
||||
def _handle_resume(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Resume a previous conversation."""
|
||||
import json as _json
|
||||
|
||||
session_dir = agent.runtime_config.session_directory
|
||||
if not session_dir.exists():
|
||||
return _local_result(input_text, 'No session directory found.')
|
||||
|
||||
search_term = args.strip()
|
||||
|
||||
# If a session ID is provided, show its info
|
||||
if search_term:
|
||||
session_path = session_dir / f'{search_term}.json'
|
||||
if session_path.exists():
|
||||
try:
|
||||
data = _json.loads(session_path.read_text(encoding='utf-8'))
|
||||
msg_count = len(data.get('messages', []))
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Found session `{search_term}` with {msg_count} messages.\n'
|
||||
f'To resume, restart with: `--resume {search_term}`',
|
||||
)
|
||||
except Exception as exc:
|
||||
return _local_result(input_text, f'Error reading session: {exc}')
|
||||
|
||||
# List recent sessions
|
||||
session_files = sorted(
|
||||
session_dir.glob('*.json'),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)[:20]
|
||||
|
||||
if not session_files:
|
||||
return _local_result(input_text, 'No previous sessions found.')
|
||||
|
||||
lines = ['## Recent Sessions', '']
|
||||
for sf in session_files:
|
||||
try:
|
||||
data = _json.loads(sf.read_text(encoding='utf-8'))
|
||||
sid = sf.stem
|
||||
msg_count = len(data.get('messages', []))
|
||||
model = data.get('model', 'unknown')
|
||||
first_user = ''
|
||||
for msg in data.get('messages', []):
|
||||
if msg.get('role') == 'user' and msg.get('content', '').strip():
|
||||
first_user = msg['content'].strip()[:80]
|
||||
if len(msg['content'].strip()) > 80:
|
||||
first_user += '...'
|
||||
break
|
||||
line = f'- `{sid}` ({msg_count} msgs, {model})'
|
||||
if first_user:
|
||||
line += f': {first_user}'
|
||||
lines.append(line)
|
||||
except Exception:
|
||||
lines.append(f'- `{sf.stem}` (error reading)')
|
||||
|
||||
if search_term:
|
||||
# Filter by search term
|
||||
filtered = [
|
||||
l for l in lines
|
||||
if search_term.lower() in l.lower() or l.startswith('#')
|
||||
]
|
||||
if len(filtered) <= 2:
|
||||
filtered.append(f'\nNo sessions matching "{search_term}".')
|
||||
lines = filtered
|
||||
|
||||
lines.append('\nTo resume: restart with `--resume <session-id>`')
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_add_dir(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Add a new working directory."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
path_str = args.strip()
|
||||
if not path_str:
|
||||
return _local_result(input_text, 'Usage: /add-dir <path>')
|
||||
|
||||
path = _Path(path_str).resolve()
|
||||
|
||||
if not path.exists():
|
||||
return _local_result(input_text, f'Path not found: {path}')
|
||||
if not path.is_dir():
|
||||
return _local_result(input_text, f'Not a directory: {path}')
|
||||
|
||||
cwd = agent.runtime_config.cwd
|
||||
if path == cwd or str(path).startswith(str(cwd) + '/'):
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Path is already within the working directory: {cwd}',
|
||||
)
|
||||
|
||||
if not hasattr(agent, '_additional_directories'):
|
||||
agent._additional_directories = []
|
||||
if path in agent._additional_directories:
|
||||
return _local_result(input_text, f'Directory already added: {path}')
|
||||
|
||||
agent._additional_directories.append(path)
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Added working directory: {path}\n'
|
||||
f'Tools can now access files in this directory.',
|
||||
)
|
||||
|
||||
|
||||
def _handle_skills(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
"""List available skills."""
|
||||
lines = ['## Available Skills', '']
|
||||
for spec in get_slash_command_specs():
|
||||
primary = f'/{spec.names[0]}'
|
||||
lines.append(f'- `{primary}`: {spec.description}')
|
||||
lines.extend(['', 'Use the Skill tool to invoke skills programmatically.'])
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_fast(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Toggle fast mode."""
|
||||
arg = args.strip().lower()
|
||||
current = getattr(agent, '_fast_mode', False)
|
||||
|
||||
if arg == 'on':
|
||||
agent._fast_mode = True
|
||||
return _local_result(input_text, 'Fast mode enabled.')
|
||||
if arg == 'off':
|
||||
agent._fast_mode = False
|
||||
return _local_result(input_text, 'Fast mode disabled.')
|
||||
if not arg:
|
||||
agent._fast_mode = not current
|
||||
state = 'enabled' if agent._fast_mode else 'disabled'
|
||||
return _local_result(input_text, f'Fast mode {state}.')
|
||||
return _local_result(input_text, 'Usage: /fast [on|off]')
|
||||
|
||||
|
||||
def _handle_vim(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Toggle between Vim and Normal editing modes."""
|
||||
current = getattr(agent, '_editor_mode', 'normal')
|
||||
if current == 'emacs':
|
||||
current = 'normal'
|
||||
|
||||
new_mode = 'vim' if current == 'normal' else 'normal'
|
||||
agent._editor_mode = new_mode
|
||||
|
||||
if new_mode == 'vim':
|
||||
hint = 'Use Escape key to toggle between INSERT and NORMAL modes.'
|
||||
else:
|
||||
hint = 'Using standard (readline) keyboard bindings.'
|
||||
|
||||
return _local_result(input_text, f'Switched to {new_mode} mode. {hint}')
|
||||
|
||||
|
||||
def _handle_rewind(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Rewind conversation to a previous message."""
|
||||
session = agent.last_session
|
||||
if session is None:
|
||||
return _local_result(input_text, 'No active session.')
|
||||
|
||||
n_str = args.strip()
|
||||
|
||||
if not n_str:
|
||||
msgs = session.messages
|
||||
lines = ['## Conversation History', '']
|
||||
for i, msg in enumerate(msgs):
|
||||
role = msg.role.upper()
|
||||
preview = msg.content[:60].replace('\n', ' ')
|
||||
if len(msg.content) > 60:
|
||||
preview += '...'
|
||||
lines.append(f' {i}: [{role}] {preview}')
|
||||
lines.append('')
|
||||
lines.append('Usage: /rewind <message-number> to truncate to that point.')
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
try:
|
||||
target = int(n_str)
|
||||
except ValueError:
|
||||
return _local_result(input_text, 'Usage: /rewind <message-number>')
|
||||
|
||||
if target < 0 or target >= len(session.messages):
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Invalid message number. Valid range: 0-{len(session.messages) - 1}',
|
||||
)
|
||||
|
||||
removed_count = len(session.messages) - target - 1
|
||||
session.messages[:] = session.messages[:target + 1]
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Rewound conversation to message {target}. Removed {removed_count} messages.',
|
||||
)
|
||||
|
||||
|
||||
def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult:
|
||||
"""Return a prompt-type result — the prompt gets sent to the model."""
|
||||
return SlashCommandResult(
|
||||
handled=True,
|
||||
should_query=True,
|
||||
prompt=prompt,
|
||||
transcript=({'role': 'user', 'content': input_text},),
|
||||
)
|
||||
|
||||
|
||||
def _local_result(input_text: str, output: str) -> SlashCommandResult:
|
||||
transcript = (
|
||||
{'role': 'user', 'content': input_text},
|
||||
|
||||
@@ -1005,6 +1005,73 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_task_cancel,
|
||||
),
|
||||
AgentTool(
|
||||
name='EnterPlanMode',
|
||||
description=(
|
||||
'Enter plan mode. In plan mode, focus on exploring the codebase '
|
||||
'and creating a plan rather than making changes. Use read-only '
|
||||
'tools (Read, Grep, Glob) to investigate, then create a plan.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
},
|
||||
handler=_enter_plan_mode,
|
||||
),
|
||||
AgentTool(
|
||||
name='ExitPlanMode',
|
||||
description=(
|
||||
'Exit plan mode and return to normal execution mode. '
|
||||
'Call this after you have finished exploring and have a plan ready.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
},
|
||||
handler=_exit_plan_mode,
|
||||
),
|
||||
AgentTool(
|
||||
name='TaskOutput',
|
||||
description=(
|
||||
'Get the output of a background task by its ID. '
|
||||
'Use block=true (default) to wait for completion, '
|
||||
'or block=false to check current status without waiting.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {
|
||||
'type': 'string',
|
||||
'description': 'The task ID to get output from.',
|
||||
},
|
||||
'block': {
|
||||
'type': 'boolean',
|
||||
'description': 'Whether to wait for completion (default true).',
|
||||
},
|
||||
'timeout': {
|
||||
'type': 'number',
|
||||
'description': 'Max wait time in ms (0-600000, default 30000).',
|
||||
},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=_task_output,
|
||||
),
|
||||
AgentTool(
|
||||
name='TaskStop',
|
||||
description='Stop a running background task by its ID.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {
|
||||
'type': 'string',
|
||||
'description': 'The task ID to stop.',
|
||||
},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=_task_stop,
|
||||
),
|
||||
AgentTool(
|
||||
name='todo_write',
|
||||
description='Replace the current local runtime task list with a structured todo list.',
|
||||
@@ -2724,6 +2791,107 @@ def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str
|
||||
)
|
||||
|
||||
|
||||
def _enter_plan_mode(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
"""Enter plan mode — focus on exploration and planning, not execution."""
|
||||
if getattr(context, '_plan_mode', False):
|
||||
return 'Already in plan mode.'
|
||||
context._plan_mode = True
|
||||
return (
|
||||
'Entered plan mode. Focus on exploring the codebase and creating a plan. '
|
||||
'Use read-only tools (Read, Grep, Glob) to investigate. '
|
||||
'When your plan is ready, call ExitPlanMode to return to normal mode.'
|
||||
)
|
||||
|
||||
|
||||
def _exit_plan_mode(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
"""Exit plan mode and return to normal execution."""
|
||||
if not getattr(context, '_plan_mode', False):
|
||||
return 'Not currently in plan mode.'
|
||||
context._plan_mode = False
|
||||
return 'Exited plan mode. You can now execute changes based on your plan.'
|
||||
|
||||
|
||||
def _task_output(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
"""Get output from a background task."""
|
||||
runtime = _require_task_runtime(context)
|
||||
task_id = _require_string(arguments, 'task_id')
|
||||
block = arguments.get('block', True)
|
||||
timeout_ms = arguments.get('timeout', 30000)
|
||||
if not isinstance(timeout_ms, (int, float)):
|
||||
timeout_ms = 30000
|
||||
timeout_ms = max(0, min(timeout_ms, 600000))
|
||||
|
||||
task = runtime.get_task(task_id)
|
||||
if task is None:
|
||||
raise ToolExecutionError(f'Task not found: {task_id}')
|
||||
|
||||
if task.status in ('completed', 'cancelled', 'failed'):
|
||||
output = getattr(task, 'output', '') or getattr(task, 'result', '') or ''
|
||||
return (
|
||||
f'<retrieval_status>success</retrieval_status>\n'
|
||||
f'<task_id>{task_id}</task_id>\n'
|
||||
f'<status>{task.status}</status>\n'
|
||||
f'<output>{output}</output>'
|
||||
)
|
||||
|
||||
if not block:
|
||||
return (
|
||||
f'<retrieval_status>not_ready</retrieval_status>\n'
|
||||
f'<task_id>{task_id}</task_id>\n'
|
||||
f'<status>{task.status}</status>'
|
||||
)
|
||||
|
||||
# Blocking wait — poll until completion or timeout
|
||||
import time as _time
|
||||
deadline = _time.monotonic() + (timeout_ms / 1000.0)
|
||||
while _time.monotonic() < deadline:
|
||||
task = runtime.get_task(task_id)
|
||||
if task is None or task.status in ('completed', 'cancelled', 'failed'):
|
||||
break
|
||||
_time.sleep(0.1)
|
||||
|
||||
if task is None:
|
||||
raise ToolExecutionError(f'Task disappeared: {task_id}')
|
||||
|
||||
if task.status in ('completed', 'cancelled', 'failed'):
|
||||
output = getattr(task, 'output', '') or getattr(task, 'result', '') or ''
|
||||
return (
|
||||
f'<retrieval_status>success</retrieval_status>\n'
|
||||
f'<task_id>{task_id}</task_id>\n'
|
||||
f'<status>{task.status}</status>\n'
|
||||
f'<output>{output}</output>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<retrieval_status>timeout</retrieval_status>\n'
|
||||
f'<task_id>{task_id}</task_id>\n'
|
||||
f'<status>{task.status}</status>'
|
||||
)
|
||||
|
||||
|
||||
def _task_stop(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
"""Stop a running background task."""
|
||||
runtime = _require_task_runtime(context)
|
||||
task_id = _require_string(arguments, 'task_id')
|
||||
|
||||
task = runtime.get_task(task_id)
|
||||
if task is None:
|
||||
raise ToolExecutionError(f'Task not found: {task_id}')
|
||||
|
||||
if task.status not in ('pending', 'in_progress', 'running'):
|
||||
raise ToolExecutionError(
|
||||
f'Task {task_id} is not running (status: {task.status})'
|
||||
)
|
||||
|
||||
# Cancel the task via the runtime
|
||||
mutation = runtime.cancel_task(task_id, reason='Stopped by TaskStop tool')
|
||||
description = getattr(task, 'title', '') or getattr(task, 'description', '') or task_id
|
||||
return (
|
||||
f'Successfully stopped task: {task_id} ({description})',
|
||||
{'action': 'task_stop', 'task_id': task_id},
|
||||
)
|
||||
|
||||
|
||||
def _stream_bash(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Bundled skill definitions — prompt-type skills invocable via the Skill tool.
|
||||
|
||||
Mirrors the npm ``src/skills/bundled/`` module.
|
||||
|
||||
Bundled skills differ from slash commands:
|
||||
- They generate AI prompts sent to the model (prompt-type).
|
||||
- They carry ``when_to_use`` guidance for model auto-invocation.
|
||||
- They can restrict ``allowed_tools`` during execution.
|
||||
- They appear in system-reminder skill listings for model discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent_runtime import LocalCodingAgent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundledSkill:
|
||||
"""A bundled skill definition."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
when_to_use: str = ''
|
||||
aliases: tuple[str, ...] = ()
|
||||
allowed_tools: tuple[str, ...] = ()
|
||||
user_invocable: bool = True
|
||||
get_prompt: Callable[['LocalCodingAgent', str], str] = lambda _a, _args: ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill prompt generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _simplify_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the simplify review prompt."""
|
||||
cwd = str(agent.runtime_config.cwd)
|
||||
diff = ''
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
['git', 'diff', 'HEAD'],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
diff = proc.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not diff:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
['git', 'diff'],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
diff = proc.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
diff_section = f'```diff\n{diff}\n```' if diff else 'No changes detected — run `git diff` to verify.'
|
||||
|
||||
return f"""Review the changed code for reuse, quality, and efficiency, then fix any issues found.
|
||||
|
||||
## Changed Code
|
||||
|
||||
{diff_section}
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Code Reuse
|
||||
- Duplicated functions or logic that should be consolidated
|
||||
- Redundant utilities or helpers
|
||||
- Patterns that should use existing abstractions
|
||||
|
||||
### Code Quality
|
||||
- Redundant state or unnecessary variables
|
||||
- Parameter sprawl (too many function arguments)
|
||||
- Copy-paste code that should be refactored
|
||||
- Leaky abstractions or wrong abstraction level
|
||||
- Stringly-typed code that should use enums/types
|
||||
- Overly nested conditionals
|
||||
|
||||
### Efficiency
|
||||
- N+1 query or call patterns
|
||||
- Missed concurrency opportunities (parallel I/O)
|
||||
- Hot-path bloat (unnecessary work in tight loops)
|
||||
- Memory leaks or unbounded growth
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Read the full diff above
|
||||
2. For each category, identify concrete issues with file paths and line numbers
|
||||
3. Fix each issue directly — do not just report them
|
||||
4. After fixing, verify the changes compile/run correctly
|
||||
|
||||
{f'Additional context: {args}' if args.strip() else ''}"""
|
||||
|
||||
|
||||
def _verify_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the verify prompt."""
|
||||
return f"""Verify that the recent code changes work correctly.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Identify what was changed (check `git diff` and `git status`)
|
||||
2. Determine the appropriate verification strategy:
|
||||
- **Unit tests**: Run existing tests, check for failures
|
||||
- **Integration tests**: Run broader test suites if available
|
||||
- **Manual verification**: Start the app/server and test the feature
|
||||
3. Run the verification
|
||||
4. Report the result clearly:
|
||||
- PASS: All checks passed, feature works as expected
|
||||
- FAIL: Describe what failed and why
|
||||
- PARTIAL: Some checks passed, some need attention
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
- For CLI tools: Run the command with test inputs
|
||||
- For servers: Start the server, make test requests
|
||||
- For libraries: Run the test suite
|
||||
- For config changes: Validate the config loads correctly
|
||||
|
||||
{f'Specific focus: {args}' if args.strip() else 'Verify the most recent changes.'}"""
|
||||
|
||||
|
||||
def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the debug prompt."""
|
||||
import os
|
||||
|
||||
lines = ['## Debug Session Info', '']
|
||||
lines.append(f'Working directory: {agent.runtime_config.cwd}')
|
||||
lines.append(f'Model: {agent.model_config.model}')
|
||||
lines.append(f'Base URL: {agent.model_config.base_url}')
|
||||
|
||||
session = agent.last_session
|
||||
if session:
|
||||
lines.append(f'Messages in session: {len(session.messages)}')
|
||||
tool_count = sum(1 for m in session.messages if m.role == 'tool')
|
||||
lines.append(f'Tool results: {tool_count}')
|
||||
|
||||
usage = agent.cumulative_usage
|
||||
lines.append(f'Total tokens used: {usage.total_tokens:,}')
|
||||
lines.append(f'Cost: ${agent.cumulative_cost_usd:.4f}')
|
||||
|
||||
# Check for debug log
|
||||
debug_log = os.environ.get('CLAUDE_CODE_DEBUG_LOG', '')
|
||||
if debug_log:
|
||||
lines.append(f'Debug log: {debug_log}')
|
||||
else:
|
||||
lines.append('Debug logging: not enabled (set CLAUDE_CODE_DEBUG_LOG to enable)')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _update_config_prompt(agent: 'LocalCodingAgent', args: str) -> str:
|
||||
"""Generate the update-config prompt."""
|
||||
return f"""Help configure the agent settings.
|
||||
|
||||
## Settings File Locations
|
||||
|
||||
- **Global**: `~/.claude/settings.json` — applies to all projects
|
||||
- **Project**: `.claude/settings.json` — project-specific, committed to git
|
||||
- **Local**: `.claude/settings.local.json` — project-specific, gitignored
|
||||
|
||||
## Configurable Settings
|
||||
|
||||
### Hooks
|
||||
Event-driven shell commands that run on tool use or lifecycle events:
|
||||
- `PreToolUse` — runs before a tool executes (can block with exit code 2)
|
||||
- `PostToolUse` — runs after a tool completes
|
||||
- `PreCompact` — runs before conversation compaction
|
||||
|
||||
Hook format:
|
||||
```json
|
||||
{{
|
||||
"hooks": {{
|
||||
"PreToolUse": [
|
||||
{{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{{
|
||||
"type": "command",
|
||||
"command": "echo 'tool: $TOOL_NAME'"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
### Permissions
|
||||
Tool permission rules:
|
||||
```json
|
||||
{{
|
||||
"permissions": {{
|
||||
"allow": ["Read", "Grep", "Glob"],
|
||||
"deny": ["Bash(rm:*)"]
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```json
|
||||
{{
|
||||
"env": {{
|
||||
"MY_VAR": "value"
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
{f'User request: {args}' if args.strip() else 'What would you like to configure?'}"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUNDLED_SKILLS: tuple[BundledSkill, ...] = (
|
||||
BundledSkill(
|
||||
name='simplify',
|
||||
description='Review changed code for reuse, quality, and efficiency, then fix issues.',
|
||||
when_to_use='When the user asks to review, simplify, or clean up recent code changes.',
|
||||
allowed_tools=('read_file', 'edit_file', 'write_file', 'bash', 'grep_search', 'glob_search'),
|
||||
get_prompt=_simplify_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='verify',
|
||||
description='Verify a code change works by running the app and tests.',
|
||||
when_to_use='When the user asks to verify, test, or check that recent changes work.',
|
||||
allowed_tools=('read_file', 'bash', 'grep_search', 'glob_search'),
|
||||
get_prompt=_verify_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='debug',
|
||||
description='Debug the current session — show diagnostics, token usage, and config.',
|
||||
when_to_use='When the user asks to debug the agent session or check diagnostics.',
|
||||
user_invocable=True,
|
||||
get_prompt=_debug_prompt,
|
||||
),
|
||||
BundledSkill(
|
||||
name='update-config',
|
||||
description='Configure settings via settings.json — hooks, permissions, env vars.',
|
||||
when_to_use='When the user wants to configure hooks, permissions, or settings.',
|
||||
aliases=('config-help',),
|
||||
allowed_tools=('read_file',),
|
||||
get_prompt=_update_config_prompt,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_bundled_skills() -> tuple[BundledSkill, ...]:
|
||||
"""Return all registered bundled skills."""
|
||||
return BUNDLED_SKILLS
|
||||
|
||||
|
||||
def find_bundled_skill(name: str) -> BundledSkill | None:
|
||||
"""Look up a bundled skill by name or alias."""
|
||||
lowered = name.lower()
|
||||
for skill in BUNDLED_SKILLS:
|
||||
if lowered == skill.name or lowered in skill.aliases:
|
||||
return skill
|
||||
return None
|
||||
|
||||
|
||||
def format_skills_for_system_prompt(max_chars: int = 8000) -> str:
|
||||
"""Format bundled skills for inclusion in system-reminder messages.
|
||||
|
||||
The model discovers available skills through this listing.
|
||||
"""
|
||||
lines = ['Available skills (invoke via Skill tool):']
|
||||
char_count = len(lines[0])
|
||||
|
||||
for skill in BUNDLED_SKILLS:
|
||||
if not skill.user_invocable:
|
||||
continue
|
||||
entry = f'- {skill.name}: {skill.description}'
|
||||
if skill.when_to_use:
|
||||
entry += f' When to use: {skill.when_to_use}'
|
||||
if len(entry) > 250:
|
||||
entry = entry[:247] + '...'
|
||||
if char_count + len(entry) + 1 > max_chars:
|
||||
break
|
||||
lines.append(entry)
|
||||
char_count += len(entry) + 1
|
||||
|
||||
return '\n'.join(lines)
|
||||
+39
-7
@@ -447,15 +447,19 @@ def compact_conversation(
|
||||
agent: 'LocalCodingAgent',
|
||||
custom_instructions: str | None = None,
|
||||
) -> CompactionResult:
|
||||
"""Perform an LLM-backed conversation compaction.
|
||||
"""Perform conversation compaction.
|
||||
|
||||
1. Build the compact prompt (9-section template).
|
||||
2. Collect the session messages to summarise.
|
||||
3. Send them + the compact prompt to the model.
|
||||
4. On prompt-too-long, retry by dropping oldest API-round groups
|
||||
Tries session-memory-based compaction first (free, no API call),
|
||||
then falls back to LLM-backed compaction.
|
||||
|
||||
1. If no custom instructions, try session memory compact.
|
||||
2. Otherwise, build the compact prompt (9-section template).
|
||||
3. Collect the session messages to summarise.
|
||||
4. Send them + the compact prompt to the model.
|
||||
5. On prompt-too-long, retry by dropping oldest API-round groups
|
||||
(up to ``MAX_PTL_RETRIES`` attempts).
|
||||
5. Parse ``<summary>`` from the response.
|
||||
6. Replace session messages with:
|
||||
6. Parse ``<summary>`` from the response.
|
||||
7. Replace session messages with:
|
||||
boundary marker → summary user message → preserved tail.
|
||||
|
||||
Returns a :class:`CompactionResult` with diagnostics.
|
||||
@@ -467,6 +471,34 @@ def compact_conversation(
|
||||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||||
)
|
||||
|
||||
# --- Try session-memory-based compact first (no API call) ---
|
||||
if custom_instructions is None:
|
||||
from .session_memory_compact import try_session_memory_compaction
|
||||
|
||||
last_summarized_id = getattr(agent, '_last_summarized_message_id', None)
|
||||
sm_result = try_session_memory_compaction(
|
||||
messages=list(session.messages),
|
||||
model=agent.model_config.model,
|
||||
last_summarized_message_id=last_summarized_id,
|
||||
)
|
||||
if sm_result is not None:
|
||||
# Apply the session-memory compaction to the session
|
||||
prefix_count = 0
|
||||
for msg in session.messages:
|
||||
if msg.metadata.get('kind') == 'compact_boundary':
|
||||
prefix_count += 1
|
||||
else:
|
||||
break
|
||||
session.messages = (
|
||||
session.messages[:prefix_count]
|
||||
+ [sm_result.boundary_message]
|
||||
+ sm_result.summary_messages
|
||||
+ sm_result.messages_to_keep
|
||||
)
|
||||
# Reset the summarized ID
|
||||
agent._last_summarized_message_id = None
|
||||
return sm_result
|
||||
|
||||
# ---- Determine which messages to compact vs preserve ----
|
||||
preserve_count = max(
|
||||
getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
"""Session-memory-based compaction — uses an on-disk session memory summary
|
||||
instead of an API call to compact the conversation.
|
||||
|
||||
Mirrors the npm ``src/services/compact/sessionMemoryCompact.ts`` module.
|
||||
|
||||
When session memory is available and up-to-date, this avoids the cost
|
||||
of an API round-trip by reusing the background-maintained summary as
|
||||
the compaction text. Falls back to ``None`` so the caller can use
|
||||
the legacy API-based compact instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .agent_context_usage import estimate_tokens
|
||||
from .agent_session import AgentMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .compact import CompactionResult
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SessionMemoryCompactConfig:
|
||||
"""Configuration for session-memory compaction thresholds."""
|
||||
|
||||
min_tokens: int = 10_000
|
||||
"""Minimum tokens to preserve after compaction."""
|
||||
|
||||
min_text_block_messages: int = 5
|
||||
"""Minimum number of messages with text content to keep."""
|
||||
|
||||
max_tokens: int = 40_000
|
||||
"""Hard cap — never preserve more than this many tokens."""
|
||||
|
||||
max_section_tokens: int = 2_000
|
||||
"""Maximum tokens per section in the session memory."""
|
||||
|
||||
max_total_tokens: int = 12_000
|
||||
"""Maximum total tokens for the session memory summary."""
|
||||
|
||||
|
||||
DEFAULT_CONFIG = SessionMemoryCompactConfig()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session memory file management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SESSION_MEMORY_TEMPLATE_SECTIONS = (
|
||||
'## User Profile',
|
||||
'## Project Context',
|
||||
'## Key Decisions & Rationale',
|
||||
'## Current Task Context',
|
||||
'## Important Patterns & Preferences',
|
||||
'## Learned Corrections',
|
||||
'## Tool Usage Patterns',
|
||||
'## Conversation Flow',
|
||||
'## Open Questions & Uncertainties',
|
||||
)
|
||||
"""Section headers used in the session memory template."""
|
||||
|
||||
|
||||
def get_session_memory_dir() -> Path:
|
||||
"""Return the directory where session memory files are stored."""
|
||||
home = Path.home()
|
||||
return home / '.claude' / 'session-memory'
|
||||
|
||||
|
||||
def get_session_memory_path() -> Path:
|
||||
"""Return the path to the session memory file."""
|
||||
return get_session_memory_dir() / 'session.md'
|
||||
|
||||
|
||||
def load_session_memory() -> str | None:
|
||||
"""Load session memory from disk, returning None if absent or empty."""
|
||||
path = get_session_memory_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
content = path.read_text(encoding='utf-8').strip()
|
||||
if not content:
|
||||
return None
|
||||
return content
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def save_session_memory(content: str) -> None:
|
||||
"""Save session memory to disk."""
|
||||
path = get_session_memory_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding='utf-8')
|
||||
|
||||
|
||||
def is_template_only(content: str) -> bool:
|
||||
"""Check if the session memory is just the empty template.
|
||||
|
||||
Returns True if all sections are present but contain no real content
|
||||
beyond the template headers and italic descriptions.
|
||||
"""
|
||||
lines = content.strip().splitlines()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
# Skip section headers
|
||||
if stripped.startswith('##'):
|
||||
continue
|
||||
# Skip italic template descriptions
|
||||
if stripped.startswith('*') and stripped.endswith('*'):
|
||||
continue
|
||||
if stripped.startswith('_') and stripped.endswith('_'):
|
||||
continue
|
||||
# Found non-template content
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message analysis helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_text_content(msg: AgentMessage) -> bool:
|
||||
"""Check if a message has meaningful text content."""
|
||||
content = msg.content.strip()
|
||||
if not content:
|
||||
return False
|
||||
if content == '[Old tool result content cleared]':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_tool_result_ids(msg: AgentMessage) -> list[str]:
|
||||
"""Extract tool_call_ids from a tool-result message."""
|
||||
if msg.role == 'tool' and msg.tool_call_id:
|
||||
return [msg.tool_call_id]
|
||||
return []
|
||||
|
||||
|
||||
def _has_tool_use_with_ids(msg: AgentMessage, ids: set[str]) -> bool:
|
||||
"""Check if an assistant message contains tool_use blocks matching any of the given IDs."""
|
||||
if msg.role != 'assistant':
|
||||
return False
|
||||
tool_calls = msg.metadata.get('tool_calls') or msg.tool_calls
|
||||
if not tool_calls:
|
||||
return False
|
||||
for tc in tool_calls:
|
||||
tc_id = tc.get('id', '') if isinstance(tc, dict) else ''
|
||||
if tc_id in ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index calculation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def adjust_index_to_preserve_api_invariants(
|
||||
messages: list[AgentMessage],
|
||||
keep_from: int,
|
||||
) -> int:
|
||||
"""Walk backwards to ensure kept messages maintain API-valid structure.
|
||||
|
||||
Specifically:
|
||||
1. All tool_result messages must have corresponding tool_use messages.
|
||||
2. Assistant messages sharing the same message_id (thinking blocks)
|
||||
must be kept together.
|
||||
"""
|
||||
if keep_from <= 0:
|
||||
return 0
|
||||
|
||||
# Step 1: Ensure tool_use/tool_result pairs
|
||||
kept_tool_result_ids: set[str] = set()
|
||||
for msg in messages[keep_from:]:
|
||||
for tid in _get_tool_result_ids(msg):
|
||||
kept_tool_result_ids.add(tid)
|
||||
|
||||
if kept_tool_result_ids:
|
||||
idx = keep_from - 1
|
||||
while idx >= 0:
|
||||
msg = messages[idx]
|
||||
if _has_tool_use_with_ids(msg, kept_tool_result_ids):
|
||||
keep_from = idx
|
||||
# Include any new tool_results this brings in
|
||||
for m in messages[idx:keep_from]:
|
||||
for tid in _get_tool_result_ids(m):
|
||||
kept_tool_result_ids.add(tid)
|
||||
idx -= 1
|
||||
|
||||
# Step 2: Ensure thinking block continuity (same message_id)
|
||||
kept_msg_ids: set[str] = set()
|
||||
for msg in messages[keep_from:]:
|
||||
if msg.role == 'assistant' and msg.message_id:
|
||||
kept_msg_ids.add(msg.message_id)
|
||||
|
||||
if kept_msg_ids:
|
||||
idx = keep_from - 1
|
||||
while idx >= 0:
|
||||
msg = messages[idx]
|
||||
if msg.role == 'assistant' and msg.message_id in kept_msg_ids:
|
||||
keep_from = idx
|
||||
idx -= 1
|
||||
|
||||
return keep_from
|
||||
|
||||
|
||||
def calculate_messages_to_keep_index(
|
||||
messages: list[AgentMessage],
|
||||
last_summarized_index: int,
|
||||
model: str = '',
|
||||
config: SessionMemoryCompactConfig | None = None,
|
||||
) -> int:
|
||||
"""Calculate the index from which to preserve messages.
|
||||
|
||||
Starts from ``last_summarized_index + 1`` and expands backwards
|
||||
to meet the configured minimums (token count, text block count).
|
||||
Stops if the hard max_tokens cap is reached.
|
||||
"""
|
||||
if config is None:
|
||||
config = DEFAULT_CONFIG
|
||||
|
||||
start_index = last_summarized_index + 1
|
||||
if start_index >= len(messages):
|
||||
return len(messages)
|
||||
|
||||
keep_from = start_index
|
||||
token_count = 0
|
||||
text_block_count = 0
|
||||
|
||||
# Count forward from keep_from to end
|
||||
for msg in messages[keep_from:]:
|
||||
token_count += estimate_tokens(msg.content, model)
|
||||
if _has_text_content(msg):
|
||||
text_block_count += 1
|
||||
|
||||
# Expand backwards if minimums not met
|
||||
idx = keep_from - 1
|
||||
while idx >= 0:
|
||||
# Check if we've reached a compact boundary — don't go past it
|
||||
if messages[idx].metadata.get('kind') == 'compact_boundary':
|
||||
break
|
||||
|
||||
msg_tokens = estimate_tokens(messages[idx].content, model)
|
||||
|
||||
# Hard cap: stop if adding this would exceed max_tokens
|
||||
if token_count + msg_tokens > config.max_tokens:
|
||||
break
|
||||
|
||||
# Expand backwards
|
||||
keep_from = idx
|
||||
token_count += msg_tokens
|
||||
if _has_text_content(messages[idx]):
|
||||
text_block_count += 1
|
||||
|
||||
# Check if minimums are met
|
||||
if (token_count >= config.min_tokens
|
||||
and text_block_count >= config.min_text_block_messages):
|
||||
break
|
||||
|
||||
idx -= 1
|
||||
|
||||
# Ensure API invariants
|
||||
keep_from = adjust_index_to_preserve_api_invariants(messages, keep_from)
|
||||
|
||||
return keep_from
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session memory truncation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def truncate_session_memory(
|
||||
content: str,
|
||||
config: SessionMemoryCompactConfig | None = None,
|
||||
) -> tuple[str, bool]:
|
||||
"""Truncate session memory sections to fit within token limits.
|
||||
|
||||
Returns ``(truncated_content, was_truncated)``.
|
||||
"""
|
||||
if config is None:
|
||||
config = DEFAULT_CONFIG
|
||||
|
||||
total_tokens = estimate_tokens(content, '')
|
||||
if total_tokens <= config.max_total_tokens:
|
||||
return content, False
|
||||
|
||||
# Split by section headers and truncate each
|
||||
lines = content.splitlines(keepends=True)
|
||||
sections: list[list[str]] = []
|
||||
current_section: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith('## ') and current_section:
|
||||
sections.append(current_section)
|
||||
current_section = [line]
|
||||
else:
|
||||
current_section.append(line)
|
||||
if current_section:
|
||||
sections.append(current_section)
|
||||
|
||||
truncated_sections: list[str] = []
|
||||
was_truncated = False
|
||||
|
||||
for section in sections:
|
||||
section_text = ''.join(section)
|
||||
section_tokens = estimate_tokens(section_text, '')
|
||||
if section_tokens > config.max_section_tokens:
|
||||
# Truncate to fit within section limit
|
||||
truncated_lines: list[str] = []
|
||||
running_tokens = 0
|
||||
for line in section:
|
||||
line_tokens = estimate_tokens(line, '')
|
||||
if running_tokens + line_tokens > config.max_section_tokens:
|
||||
truncated_lines.append('...(truncated)\n')
|
||||
was_truncated = True
|
||||
break
|
||||
truncated_lines.append(line)
|
||||
running_tokens += line_tokens
|
||||
truncated_sections.append(''.join(truncated_lines))
|
||||
else:
|
||||
truncated_sections.append(section_text)
|
||||
|
||||
result = ''.join(truncated_sections)
|
||||
|
||||
# Check total again
|
||||
if estimate_tokens(result, '') > config.max_total_tokens:
|
||||
was_truncated = True
|
||||
|
||||
return result, was_truncated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core session memory compaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def try_session_memory_compaction(
|
||||
messages: list[AgentMessage],
|
||||
model: str = '',
|
||||
last_summarized_message_id: str | None = None,
|
||||
auto_compact_threshold: int | None = None,
|
||||
config: SessionMemoryCompactConfig | None = None,
|
||||
) -> 'CompactionResult | None':
|
||||
"""Attempt session-memory-based compaction.
|
||||
|
||||
Returns a :class:`CompactionResult` if session memory is available
|
||||
and the compaction succeeds, or ``None`` to signal the caller should
|
||||
fall back to API-based compaction.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
messages:
|
||||
The current session messages.
|
||||
model:
|
||||
Model name for token estimation.
|
||||
last_summarized_message_id:
|
||||
The message_id of the last message that was included in the
|
||||
session memory summary. Messages after this are preserved.
|
||||
auto_compact_threshold:
|
||||
If provided, return None if post-compact tokens exceed this.
|
||||
config:
|
||||
Compaction configuration thresholds.
|
||||
"""
|
||||
from .compact import CompactionResult
|
||||
|
||||
if config is None:
|
||||
config = DEFAULT_CONFIG
|
||||
|
||||
# Check environment gates
|
||||
if os.environ.get('DISABLE_CLAUDE_CODE_SM_COMPACT'):
|
||||
return None
|
||||
|
||||
# Load session memory
|
||||
session_memory = load_session_memory()
|
||||
if session_memory is None:
|
||||
return None
|
||||
|
||||
if is_template_only(session_memory):
|
||||
return None
|
||||
|
||||
# Find the boundary message
|
||||
last_summarized_index: int | None = None
|
||||
|
||||
if last_summarized_message_id:
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.message_id == last_summarized_message_id:
|
||||
last_summarized_index = i
|
||||
break
|
||||
|
||||
if last_summarized_index is None:
|
||||
# No boundary found — can't determine what's already summarized
|
||||
# Fall back to legacy compact
|
||||
return None
|
||||
|
||||
# Calculate which messages to preserve
|
||||
keep_from = calculate_messages_to_keep_index(
|
||||
messages, last_summarized_index, model=model, config=config,
|
||||
)
|
||||
|
||||
messages_to_keep = list(messages[keep_from:])
|
||||
|
||||
# Filter out old compact boundaries from kept messages
|
||||
messages_to_keep = [
|
||||
m for m in messages_to_keep
|
||||
if m.metadata.get('kind') != 'compact_boundary'
|
||||
]
|
||||
|
||||
# Truncate session memory if needed
|
||||
truncated_memory, was_truncated = truncate_session_memory(
|
||||
session_memory, config=config,
|
||||
)
|
||||
|
||||
# Build the compaction result
|
||||
pre_tokens = sum(estimate_tokens(m.content, model) for m in messages)
|
||||
|
||||
boundary = AgentMessage(
|
||||
role='user',
|
||||
content=(
|
||||
'<system-reminder>\n'
|
||||
f'Earlier conversation was compacted using session memory. '
|
||||
f'{len(messages) - len(messages_to_keep)} messages summarized.\n'
|
||||
'</system-reminder>'
|
||||
),
|
||||
message_id='compact_boundary',
|
||||
metadata={
|
||||
'kind': 'compact_boundary',
|
||||
'source': 'session_memory',
|
||||
'pre_compact_token_count': pre_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
summary_content = (
|
||||
'Here is a summary of our conversation so far:\n\n'
|
||||
f'{truncated_memory}'
|
||||
)
|
||||
if was_truncated:
|
||||
memory_path = get_session_memory_path()
|
||||
summary_content += (
|
||||
f'\n\n(Session memory was truncated. '
|
||||
f'Full version at: {memory_path})'
|
||||
)
|
||||
|
||||
summary_msg = AgentMessage(
|
||||
role='user',
|
||||
content=summary_content,
|
||||
message_id='compact_summary',
|
||||
metadata={
|
||||
'kind': 'compact_summary',
|
||||
'is_compact_summary': True,
|
||||
'source': 'session_memory',
|
||||
},
|
||||
)
|
||||
|
||||
post_messages = [boundary, summary_msg] + messages_to_keep
|
||||
post_tokens = sum(estimate_tokens(m.content, model) for m in post_messages)
|
||||
|
||||
# Check threshold
|
||||
if auto_compact_threshold is not None and post_tokens > auto_compact_threshold:
|
||||
return None
|
||||
|
||||
return CompactionResult(
|
||||
boundary_message=boundary,
|
||||
summary_messages=[summary_msg],
|
||||
messages_to_keep=messages_to_keep,
|
||||
pre_compact_token_count=pre_tokens,
|
||||
post_compact_token_count=post_tokens,
|
||||
true_post_compact_token_count=post_tokens,
|
||||
summary_text=truncated_memory,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session memory extraction (lightweight background summary updater)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SESSION_MEMORY_EXTRACTION_PROMPT = """Analyze the conversation so far and update the session memory.
|
||||
Extract key information into these sections:
|
||||
|
||||
## User Profile
|
||||
Who the user is, their role, expertise level, and preferences.
|
||||
|
||||
## Project Context
|
||||
What project/codebase is being worked on, its structure, and tech stack.
|
||||
|
||||
## Key Decisions & Rationale
|
||||
Important decisions made during this session and why.
|
||||
|
||||
## Current Task Context
|
||||
What the user is currently working on and the state of that work.
|
||||
|
||||
## Important Patterns & Preferences
|
||||
Coding style, conventions, or preferences observed.
|
||||
|
||||
## Learned Corrections
|
||||
Mistakes made and corrections applied — things to avoid repeating.
|
||||
|
||||
## Tool Usage Patterns
|
||||
Which tools work well, preferred approaches for common tasks.
|
||||
|
||||
## Conversation Flow
|
||||
Major topic transitions and how the conversation has progressed.
|
||||
|
||||
## Open Questions & Uncertainties
|
||||
Unresolved questions or areas of ambiguity.
|
||||
|
||||
Write concisely. Focus on information that would be useful for continuing
|
||||
this conversation after a context reset. Omit sections with no content."""
|
||||
|
||||
|
||||
def extract_session_memory_from_messages(
|
||||
messages: list[AgentMessage],
|
||||
model: str = '',
|
||||
) -> str:
|
||||
"""Build a session memory summary from conversation messages.
|
||||
|
||||
This is a lightweight local extraction — it walks the messages and
|
||||
builds a structured summary without an API call. For richer
|
||||
summaries, the full LLM-based extraction should be used.
|
||||
"""
|
||||
user_messages: list[str] = []
|
||||
tool_names_used: set[str] = set()
|
||||
file_paths: set[str] = set()
|
||||
corrections: list[str] = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == 'user' and _has_text_content(msg):
|
||||
user_messages.append(msg.content[:200])
|
||||
elif msg.role == 'tool' and msg.name:
|
||||
tool_names_used.add(msg.name)
|
||||
path = msg.metadata.get('path')
|
||||
if isinstance(path, str):
|
||||
file_paths.add(path)
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
if user_messages:
|
||||
sections.append('## Current Task Context')
|
||||
# Use recent user messages as task context
|
||||
recent = user_messages[-5:]
|
||||
for um in recent:
|
||||
sections.append(f'- {um[:100]}')
|
||||
|
||||
if tool_names_used:
|
||||
sections.append('\n## Tool Usage Patterns')
|
||||
sections.append(f'Tools used: {", ".join(sorted(tool_names_used))}')
|
||||
|
||||
if file_paths:
|
||||
sections.append('\n## Project Context')
|
||||
sections.append(f'Files accessed: {", ".join(sorted(list(file_paths)[:20]))}')
|
||||
|
||||
sections.append('\n## Conversation Flow')
|
||||
sections.append(f'Total messages: {len(messages)}')
|
||||
sections.append(
|
||||
f'User messages: {sum(1 for m in messages if m.role == "user")}'
|
||||
)
|
||||
|
||||
return '\n'.join(sections)
|
||||
Reference in New Issue
Block a user