Skip to content

Latest commit

 

History

History
116 lines (95 loc) · 6.17 KB

File metadata and controls

116 lines (95 loc) · 6.17 KB

Architecture — claude-code-python

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.

The two load-bearing abstractions

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. ToolUseContext is 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.

The agentic core (Phase 1)

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

Why an async generator that yields Terminal

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.

Invariants preserved from the original

  1. tool_use/tool_result pairing — every emitted tool_use block is answered by exactly one tool_result. On abort/fallback/exception we synthesize the missing results (_yield_missing_tool_result_blocks) so the next API call is legal. (src/query.ts yieldMissingToolResultBlocks.)
  2. 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.
  3. single mutable state rewritten wholesale at each continue site, so compaction/recovery/budget slot in as cross-cutting concerns.
  4. concurrency partitioningrun_tools groups 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.
  5. fail-closed permissions — four phases: deny rules → tool-specific check_permissions → allow rules → mode default → interactive prompt (or auto-deny in headless/background contexts).

Idiomatic-Python choices

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

Content blocks are dicts

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.

Deps injection

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.

What is not yet implemented

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.