This document explains the Python port's design and how it maps onto the
original TypeScript. It assumes familiarity with the original
ARCHITECTURE_ANALYSIS.md; read that
first for the system as a whole.
Exactly as in the original, everything hangs off two contracts:
-
Tool(claude_code/tool.py) — anything the model can invoke. The TS uses a structural type +buildTool({...defaults, ...def})to spread fail-closed defaults. The idiomatic Python equivalent is an abstract base class whose default methods are those fail-closed defaults (is_concurrency_safe → False,is_read_only → False,check_permissions → passthrough); concrete tools subclass and override.ToolUseContextis the dependency-injection bag threaded through every tool, permission check and hook. -
Task(claude_code/task.py) — anything that runs in the background (subagents, shells, remote sessions, teammates, dream). Phase-1 ports the type/ID machinery; concrete task types are Phase-4.
QueryEngine.submit_message(prompt) query_engine.py
├─ record user message → transcript (.jsonl)
├─ fetch_system_prompt_parts() context.py
└─ query(QueryParams) query/loop.py
while True: ← the state machine
1. compaction pipeline (no-op P1) services/compact/*
2. call_model (stream) model/anthropic_client.py
└─ withhold recoverable errors
3. needs_follow_up?
├─ no → recovery/completion tree → Terminal
└─ yes → run_tools services/tools/orchestration.py
└─ run_tool_use services/tools/execution.py
└─ can_use_tool permissions/permissions.py
4. state = prev + assistant + tool_results; recurse
The TS queryLoop is an async generator that returns a Terminal. Python
async generators cannot return a value, so the loop yields the Terminal
as its final item and the consumer (QueryEngine) stops on it. This is the one
structural divergence forced by the language; everything else mirrors the TS
control flow.
- tool_use/tool_result pairing — every emitted
tool_useblock is answered by exactly onetool_result. On abort/fallback/exception we synthesize the missing results (_yield_missing_tool_result_blocks) so the next API call is legal. (src/query.tsyieldMissingToolResultBlocks.) - withhold-then-recover — recoverable streaming errors (
max_output_tokens,prompt_too_long) are withheld from consumers until the loop decides whether recovery can succeed, then retried or surfaced. Max-output-tokens escalates 8k → 64k once, then injects up to 3 recovery nudges. - single mutable
staterewritten wholesale at each continue site, so compaction/recovery/budget slot in as cross-cutting concerns. - concurrency partitioning —
run_toolsgroups consecutive concurrency-safe tools to run in parallel (capped) and runs everything else serially; context modifiers from a concurrent batch are collected and applied in order afterward so concurrent tools never race the shared context. - fail-closed permissions — four phases: deny rules → tool-specific
check_permissions→ allow rules → mode default → interactive prompt (or auto-deny in headless/background contexts).
| TypeScript | Python |
|---|---|
zod schemas (inputSchema.safeParse) |
JSON-Schema dicts + utils/schema.parse (the schema doubles as the API tools[].input_schema) |
AbortController / signal |
asyncio.Event (ctx.aborted) |
async generators + all() merge |
async def generators + utils/generators.merge (semaphore + queue) |
setAppState(prev => next) immutable snapshots |
AppStateStore.update(fn) over a frozen-ish AppState dataclass |
| React/Ink terminal UI | rich (Phase-1 REPL); textual is an option for later phases |
bun:bundle feature('X') dead-code gates |
runtime flags / optional subsystems, scaffolded as stubs |
content-block params (@anthropic-ai/sdk) |
plain dicts in Anthropic Messages API shape — handed straight to the SDK |
Internal Message envelopes (types/message.py) carry their content as plain
dicts in the exact Anthropic Messages API shape ({"type": "tool_use", "id": ..., "name": ..., "input": ...} etc.). This lets model/anthropic_client.py
pass them straight to client.messages.create/.stream with no translation —
the same "blocks flow through unchanged" property the TS relies on for prompt
caching.
query/deps.py mirrors src/query/deps.ts: QueryDeps bundles call_model,
microcompact, autocompact, uuid. The whole loop — and QueryEngine via its
deps= parameter — can be driven by fakes, which is how the test suite exercises
the entire agentic core offline.
Everything beyond Phase 1 is scaffolded as a documented structural mirror of the
original src/ tree. Each stub package's __init__.py names its TypeScript
source, summarizes its purpose, and states its roadmap phase. See
PORTING_STATUS.md and the per-package docstrings.
The roadmap (from the original analysis doc):
- Phase 2 — robustness:
StreamingToolExecutor, hooks lifecycle, model fallback retry plumbing, auto-mode classifier, token budget. - Phase 3 — long-running context: three-tier compaction (microcompact / snip / context-collapse / autocompact), reactive compact, tool-result budgets, prompt-cache-stable assembly.
- Phase 4 — multi-agent & memory:
AgentTool+ subagent fork, task-control tools, coordinator/swarm, persistent memory + Dream consolidation. - Phase 5 — ecosystem: MCP, LSP, skills, slash commands, plugins, remote sessions (CCR/ULTRAPLAN), the full Ink-equivalent UI.