mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-05-01 01:20:17 +08:00
#163: run_turn_loop no longer injects f'{prompt} [turn N]' into follow-up prompts. The suffix was never defined or interpreted anywhere — not by the engine, not by the system prompt, not by any LLM. It looked like a real user-typed annotation in the transcript and made replay/analysis fragile. New behaviour: - turn 0 submits the original prompt (unchanged) - turn > 0 submits caller-supplied continuation_prompt if provided, else the loop stops cleanly — no fabricated user turn - added continuation_prompt: str | None = None parameter to run_turn_loop - added --continuation-prompt CLI flag for claws scripting multi-turn loops - zero '[turn' strings ever appear in mutable_messages or stdout now Behaviour change for existing callers: - Before: run_turn_loop(prompt, max_turns=3) submitted 3 turns ('prompt', 'prompt [turn 2]', 'prompt [turn 3]') - After: run_turn_loop(prompt, max_turns=3) submits 1 turn ('prompt') - To preserve old multi-turn behaviour, pass continuation_prompt='Continue.' or any structured follow-up text One existing timeout test (test_budget_is_cumulative_across_turns) updated to pass continuation_prompt so the cumulative-budget contract is actually exercised across turns instead of trivially satisfied by a one-turn loop. #164 filed: addresses reviewer feedback on #161. The wall-clock timeout bounds the caller-facing wait, but the underlying submit_message worker thread keeps running and can mutate engine state after the timeout TurnResult is returned. A cooperative cancel_event pattern is sketched in the pinpoint; real asyncio.Task.cancel() support will come once provider IO is async-native (larger refactor). Tests (tests/test_run_turn_loop_continuation.py, 8 tests): - TestNoTurnSuffixInjection (2): zero '[turn' strings in any submitted prompt, both default and explicit-continuation paths - TestContinuationDefaultStopsAfterTurnZero (2): default loops run exactly one turn; engine.submit_message called exactly once despite max_turns=10 - TestExplicitContinuationBehaviour (2): turn 0 = original, turn N = continuation verbatim; max_turns still respected - TestCLIContinuationFlag (2): CLI default emits only '## Turn 1'; --continuation-prompt wires through to multi-turn behaviour Full suite: 67/67 passing. Closes ROADMAP #163. Files #164.
325 lines
14 KiB
Python
325 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
|
|
from .bootstrap_graph import build_bootstrap_graph
|
|
from .command_graph import build_command_graph
|
|
from .commands import execute_command, get_command, get_commands, render_command_index
|
|
from .direct_modes import run_deep_link, run_direct_connect
|
|
from .parity_audit import run_parity_audit
|
|
from .permissions import ToolPermissionContext
|
|
from .port_manifest import build_port_manifest
|
|
from .query_engine import QueryEnginePort
|
|
from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
|
|
from .runtime import PortRuntime
|
|
from .session_store import (
|
|
SessionDeleteError,
|
|
SessionNotFoundError,
|
|
delete_session,
|
|
list_sessions,
|
|
load_session,
|
|
session_exists,
|
|
)
|
|
from .setup import run_setup
|
|
from .tool_pool import assemble_tool_pool
|
|
from .tools import execute_tool, get_tool, get_tools, render_tool_index
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description='Python porting workspace for the Claude Code rewrite effort')
|
|
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
subparsers.add_parser('summary', help='render a Markdown summary of the Python porting workspace')
|
|
subparsers.add_parser('manifest', help='print the current Python workspace manifest')
|
|
subparsers.add_parser('parity-audit', help='compare the Python workspace against the local ignored TypeScript archive when available')
|
|
subparsers.add_parser('setup-report', help='render the startup/prefetch setup report')
|
|
subparsers.add_parser('command-graph', help='show command graph segmentation')
|
|
subparsers.add_parser('tool-pool', help='show assembled tool pool with default settings')
|
|
subparsers.add_parser('bootstrap-graph', help='show the mirrored bootstrap/runtime graph stages')
|
|
list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace')
|
|
list_parser.add_argument('--limit', type=int, default=32)
|
|
|
|
commands_parser = subparsers.add_parser('commands', help='list mirrored command entries from the archived snapshot')
|
|
commands_parser.add_argument('--limit', type=int, default=20)
|
|
commands_parser.add_argument('--query')
|
|
commands_parser.add_argument('--no-plugin-commands', action='store_true')
|
|
commands_parser.add_argument('--no-skill-commands', action='store_true')
|
|
|
|
tools_parser = subparsers.add_parser('tools', help='list mirrored tool entries from the archived snapshot')
|
|
tools_parser.add_argument('--limit', type=int, default=20)
|
|
tools_parser.add_argument('--query')
|
|
tools_parser.add_argument('--simple-mode', action='store_true')
|
|
tools_parser.add_argument('--no-mcp', action='store_true')
|
|
tools_parser.add_argument('--deny-tool', action='append', default=[])
|
|
tools_parser.add_argument('--deny-prefix', action='append', default=[])
|
|
|
|
route_parser = subparsers.add_parser('route', help='route a prompt across mirrored command/tool inventories')
|
|
route_parser.add_argument('prompt')
|
|
route_parser.add_argument('--limit', type=int, default=5)
|
|
|
|
bootstrap_parser = subparsers.add_parser('bootstrap', help='build a runtime-style session report from the mirrored inventories')
|
|
bootstrap_parser.add_argument('prompt')
|
|
bootstrap_parser.add_argument('--limit', type=int, default=5)
|
|
|
|
loop_parser = subparsers.add_parser('turn-loop', help='run a small stateful turn loop for the mirrored runtime')
|
|
loop_parser.add_argument('prompt')
|
|
loop_parser.add_argument('--limit', type=int, default=5)
|
|
loop_parser.add_argument('--max-turns', type=int, default=3)
|
|
loop_parser.add_argument('--structured-output', action='store_true')
|
|
loop_parser.add_argument(
|
|
'--timeout-seconds',
|
|
type=float,
|
|
default=None,
|
|
help='total wall-clock budget across all turns (#161). Default: unbounded.',
|
|
)
|
|
loop_parser.add_argument(
|
|
'--continuation-prompt',
|
|
default=None,
|
|
help=(
|
|
'prompt to submit on turns after the first (#163). Default: None '
|
|
'(loop stops after turn 0). Replaces the deprecated implicit "[turn N]" '
|
|
'suffix that used to pollute the transcript.'
|
|
),
|
|
)
|
|
|
|
flush_parser = subparsers.add_parser('flush-transcript', help='persist and flush a temporary session transcript')
|
|
flush_parser.add_argument('prompt')
|
|
|
|
load_session_parser = subparsers.add_parser('load-session', help='load a previously persisted session')
|
|
load_session_parser.add_argument('session_id')
|
|
|
|
list_sessions_parser = subparsers.add_parser(
|
|
'list-sessions',
|
|
help='enumerate stored session IDs (#160: claw-native session API)',
|
|
)
|
|
list_sessions_parser.add_argument(
|
|
'--directory', help='session storage directory (default: .port_sessions)'
|
|
)
|
|
list_sessions_parser.add_argument(
|
|
'--output-format',
|
|
choices=['text', 'json'],
|
|
default='text',
|
|
help='output format',
|
|
)
|
|
|
|
delete_session_parser = subparsers.add_parser(
|
|
'delete-session',
|
|
help='delete a persisted session (#160: idempotent, race-safe)',
|
|
)
|
|
delete_session_parser.add_argument('session_id')
|
|
delete_session_parser.add_argument(
|
|
'--directory', help='session storage directory (default: .port_sessions)'
|
|
)
|
|
delete_session_parser.add_argument(
|
|
'--output-format',
|
|
choices=['text', 'json'],
|
|
default='text',
|
|
help='output format',
|
|
)
|
|
|
|
remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching')
|
|
remote_parser.add_argument('target')
|
|
ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching')
|
|
ssh_parser.add_argument('target')
|
|
teleport_parser = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching')
|
|
teleport_parser.add_argument('target')
|
|
direct_parser = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching')
|
|
direct_parser.add_argument('target')
|
|
deep_link_parser = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching')
|
|
deep_link_parser.add_argument('target')
|
|
|
|
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
|
|
show_command.add_argument('name')
|
|
show_tool = subparsers.add_parser('show-tool', help='show one mirrored tool entry by exact name')
|
|
show_tool.add_argument('name')
|
|
|
|
exec_command_parser = subparsers.add_parser('exec-command', help='execute a mirrored command shim by exact name')
|
|
exec_command_parser.add_argument('name')
|
|
exec_command_parser.add_argument('prompt')
|
|
|
|
exec_tool_parser = subparsers.add_parser('exec-tool', help='execute a mirrored tool shim by exact name')
|
|
exec_tool_parser.add_argument('name')
|
|
exec_tool_parser.add_argument('payload')
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
manifest = build_port_manifest()
|
|
if args.command == 'summary':
|
|
print(QueryEnginePort(manifest).render_summary())
|
|
return 0
|
|
if args.command == 'manifest':
|
|
print(manifest.to_markdown())
|
|
return 0
|
|
if args.command == 'parity-audit':
|
|
print(run_parity_audit().to_markdown())
|
|
return 0
|
|
if args.command == 'setup-report':
|
|
print(run_setup().as_markdown())
|
|
return 0
|
|
if args.command == 'command-graph':
|
|
print(build_command_graph().as_markdown())
|
|
return 0
|
|
if args.command == 'tool-pool':
|
|
print(assemble_tool_pool().as_markdown())
|
|
return 0
|
|
if args.command == 'bootstrap-graph':
|
|
print(build_bootstrap_graph().as_markdown())
|
|
return 0
|
|
if args.command == 'subsystems':
|
|
for subsystem in manifest.top_level_modules[: args.limit]:
|
|
print(f'{subsystem.name}\t{subsystem.file_count}\t{subsystem.notes}')
|
|
return 0
|
|
if args.command == 'commands':
|
|
if args.query:
|
|
print(render_command_index(limit=args.limit, query=args.query))
|
|
else:
|
|
commands = get_commands(include_plugin_commands=not args.no_plugin_commands, include_skill_commands=not args.no_skill_commands)
|
|
output_lines = [f'Command entries: {len(commands)}', '']
|
|
output_lines.extend(f'- {module.name} — {module.source_hint}' for module in commands[: args.limit])
|
|
print('\n'.join(output_lines))
|
|
return 0
|
|
if args.command == 'tools':
|
|
if args.query:
|
|
print(render_tool_index(limit=args.limit, query=args.query))
|
|
else:
|
|
permission_context = ToolPermissionContext.from_iterables(args.deny_tool, args.deny_prefix)
|
|
tools = get_tools(simple_mode=args.simple_mode, include_mcp=not args.no_mcp, permission_context=permission_context)
|
|
output_lines = [f'Tool entries: {len(tools)}', '']
|
|
output_lines.extend(f'- {module.name} — {module.source_hint}' for module in tools[: args.limit])
|
|
print('\n'.join(output_lines))
|
|
return 0
|
|
if args.command == 'route':
|
|
matches = PortRuntime().route_prompt(args.prompt, limit=args.limit)
|
|
if not matches:
|
|
print('No mirrored command/tool matches found.')
|
|
return 0
|
|
for match in matches:
|
|
print(f'{match.kind}\t{match.name}\t{match.score}\t{match.source_hint}')
|
|
return 0
|
|
if args.command == 'bootstrap':
|
|
print(PortRuntime().bootstrap_session(args.prompt, limit=args.limit).as_markdown())
|
|
return 0
|
|
if args.command == 'turn-loop':
|
|
results = PortRuntime().run_turn_loop(
|
|
args.prompt,
|
|
limit=args.limit,
|
|
max_turns=args.max_turns,
|
|
structured_output=args.structured_output,
|
|
timeout_seconds=args.timeout_seconds,
|
|
continuation_prompt=args.continuation_prompt,
|
|
)
|
|
for idx, result in enumerate(results, start=1):
|
|
print(f'## Turn {idx}')
|
|
print(result.output)
|
|
print(f'stop_reason={result.stop_reason}')
|
|
# Exit 2 when a timeout terminated the loop so claws can distinguish
|
|
# 'ran to completion' from 'hit wall-clock budget'.
|
|
if results and results[-1].stop_reason == 'timeout':
|
|
return 2
|
|
return 0
|
|
if args.command == 'flush-transcript':
|
|
engine = QueryEnginePort.from_workspace()
|
|
engine.submit_message(args.prompt)
|
|
path = engine.persist_session()
|
|
print(path)
|
|
print(f'flushed={engine.transcript_store.flushed}')
|
|
return 0
|
|
if args.command == 'load-session':
|
|
session = load_session(args.session_id)
|
|
print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}')
|
|
return 0
|
|
if args.command == 'list-sessions':
|
|
from pathlib import Path as _Path
|
|
directory = _Path(args.directory) if args.directory else None
|
|
ids = list_sessions(directory)
|
|
if args.output_format == 'json':
|
|
import json as _json
|
|
print(_json.dumps({'sessions': ids, 'count': len(ids)}))
|
|
else:
|
|
if not ids:
|
|
print('(no sessions)')
|
|
else:
|
|
for sid in ids:
|
|
print(sid)
|
|
return 0
|
|
if args.command == 'delete-session':
|
|
from pathlib import Path as _Path
|
|
directory = _Path(args.directory) if args.directory else None
|
|
try:
|
|
deleted = delete_session(args.session_id, directory)
|
|
except SessionDeleteError as exc:
|
|
if args.output_format == 'json':
|
|
import json as _json
|
|
print(_json.dumps({
|
|
'session_id': args.session_id,
|
|
'deleted': False,
|
|
'error': {
|
|
'kind': 'session_delete_failed',
|
|
'message': str(exc),
|
|
'retryable': True,
|
|
},
|
|
}))
|
|
else:
|
|
print(f'error: {exc}')
|
|
return 1
|
|
if args.output_format == 'json':
|
|
import json as _json
|
|
print(_json.dumps({
|
|
'session_id': args.session_id,
|
|
'deleted': deleted,
|
|
'status': 'deleted' if deleted else 'not_found',
|
|
}))
|
|
else:
|
|
if deleted:
|
|
print(f'deleted: {args.session_id}')
|
|
else:
|
|
print(f'not found: {args.session_id}')
|
|
# Exit 0 for both cases — delete_session is idempotent,
|
|
# not-found is success from a cleanup perspective
|
|
return 0
|
|
if args.command == 'remote-mode':
|
|
print(run_remote_mode(args.target).as_text())
|
|
return 0
|
|
if args.command == 'ssh-mode':
|
|
print(run_ssh_mode(args.target).as_text())
|
|
return 0
|
|
if args.command == 'teleport-mode':
|
|
print(run_teleport_mode(args.target).as_text())
|
|
return 0
|
|
if args.command == 'direct-connect-mode':
|
|
print(run_direct_connect(args.target).as_text())
|
|
return 0
|
|
if args.command == 'deep-link-mode':
|
|
print(run_deep_link(args.target).as_text())
|
|
return 0
|
|
if args.command == 'show-command':
|
|
module = get_command(args.name)
|
|
if module is None:
|
|
print(f'Command not found: {args.name}')
|
|
return 1
|
|
print('\n'.join([module.name, module.source_hint, module.responsibility]))
|
|
return 0
|
|
if args.command == 'show-tool':
|
|
module = get_tool(args.name)
|
|
if module is None:
|
|
print(f'Tool not found: {args.name}')
|
|
return 1
|
|
print('\n'.join([module.name, module.source_hint, module.responsibility]))
|
|
return 0
|
|
if args.command == 'exec-command':
|
|
result = execute_command(args.name, args.prompt)
|
|
print(result.message)
|
|
return 0 if result.handled else 1
|
|
if args.command == 'exec-tool':
|
|
result = execute_tool(args.name, args.payload)
|
|
print(result.message)
|
|
return 0 if result.handled else 1
|
|
parser.error(f'unknown command: {args.command}')
|
|
return 2
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|