Skip to content

Agent Architecture

Pythinker Code is organized around a small agent runtime called the soul. The CLI and UI layers prepare a Runtime, load an agent specification, restore persisted conversation context, then hand each user prompt to PythinkerSoul. During a turn, the soul repeatedly calls the LLM through pythinker_core.step, streams Wire events to the active UI, executes tool calls through PythinkerToolset, and appends the assistant and tool messages back into Context until the model stops requesting tools.

This page maps the agent architecture from the code tree so the same graph can later be exported as an image.

Source map

The main files involved in the agent loop are:

AreaFilesResponsibility
CLI assemblysrc/pythinker_code/app.py, src/pythinker_code/cli/__init__.pyLoad config, select model/provider, create runtime, load agent, restore context, connect UI
Agent definitionsrc/pythinker_code/agentspec.py, src/pythinker_code/soul/agent.py, src/pythinker_code/agents/*/agent.yamlResolve agent YAML, render system prompts, register tools and subagent types
Main loopsrc/pythinker_code/soul/pythinkersoul.py, src/pythinker_code/soul/__init__.pyRun turns and steps, call LLM, handle slash commands, compaction, steers, hooks, D-Mail, status
Conversation storagesrc/pythinker_code/soul/context.pyPersist system prompt, user messages, assistant messages, tool messages, usage records, checkpoints
Tool orchestrationsrc/pythinker_code/soul/toolset.py, src/pythinker_code/tools/Load and execute built-in, plugin, MCP, and Wire external tools
Wire transportsrc/pythinker_code/wire/Stream typed events between soul and UI, persist wire.jsonl, bridge subagent events
Subagentssrc/pythinker_code/tools/agent/, src/pythinker_code/subagents/Launch, resume, persist, and summarize isolated subagent souls
UI frontendssrc/pythinker_code/ui/shell/, src/pythinker_code/ui/print/, src/pythinker_code/acp/Convert user input into soul turns and render Wire events
Runtime servicessrc/pythinker_code/approval_runtime/, src/pythinker_code/background/, src/pythinker_code/hooks/, src/pythinker_code/notifications.pyApprovals, background work, hook execution, out-of-turn notifications

Component graph

The graph has two important boundaries. PythinkerSoul owns the turn and step lifecycle, but delegates actual LLM/tool orchestration to pythinker_core.step. Wire owns presentation and persistence of runtime events, so the same soul can be rendered by shell mode, print mode, ACP, web, visualization, or Wire mode.

Startup graph

Startup creates all long-lived objects before a user turn begins.

PythinkerCLI.create is the composition root. It constructs the model and runtime, calls load_agent, restores Context, creates PythinkerSoul, then attaches hooks and telemetry. Runtime.create gathers workspace inputs for the system prompt and creates shared session services. load_agent renders the agent prompt, registers subagent type definitions, builds the toolset, and loads plugin and MCP tools.

Turn and step loop

A turn is one user prompt. A step is one LLM call plus any tool calls requested by that LLM response. A turn may contain many steps.

PythinkerSoul.run is responsible for turn-level controls: OAuth freshness, prompt submit hooks, slash command dispatch, optional Ralph Loop execution, the Stop hook, and cleanup of approval requests if the turn exits early.

PythinkerSoul._agent_loop is responsible for step-level controls. It starts deferred MCP loading, enforces max_steps_per_turn, emits StepBegin, runs automatic compaction, creates checkpoints, executes _step, consumes queued steers, and handles D-Mail rewind by restoring an earlier checkpoint and injecting a future-self message.

PythinkerSoul._step builds the effective prompt context for one LLM call. It delivers pending notifications, adds dynamic system reminders as user messages, normalizes adjacent user messages, calls pythinker_core.step, records usage with StatusUpdate, waits for tool results, and appends the assistant and tool messages to Context.

Tool execution graph

The toolset is both a registry and an execution boundary. It hides tools from the LLM when needed, validates tool names, parses JSON arguments, triggers hooks, converts exceptions to ToolRuntimeError, and returns async ToolResult tasks to pythinker_core.step. The advertised tool list is filtered by the active execution profile, subagent/root role, plan-mode state, and hard permission profile before each model call; tool-specific execution guards still run even if a hidden tool is somehow called. MCP tools are registered as local wrappers. Wire external tools are sent to the active Wire client as ToolCallRequest messages and wait for a client-provided result.

Subagent graph

The Agent tool lets only the root agent create or resume a single subagent, and the RunAgents tool launches a batch of subagents (up to 8) in one call. Both are root-only. Subagents get isolated context and Wire files, but share session-level services such as approval state, notification infrastructure, background task management, and the root Wire hub.

Foreground subagents run through ForegroundSubagentRunner. It creates or resumes a stored instance, prepares a new PythinkerSoul, routes subagent Wire events back to the parent as SubagentEvent, and returns a summary as the Agent tool result. Background subagents are created as background tasks and can notify the root session when complete.

Subagent state is stored under session/subagents/<agent_id>/:

FilePurpose
meta.jsonAgent ID, type, status, description, model selection, timestamps
context.jsonlIsolated conversation history for that subagent
wire.jsonlPersisted event stream for that subagent run
prompt.txtLast prompt snapshot used to run the subagent
outputHuman-readable status and summary output

Runtime state and persistence

Context persists conversation state as JSONL records. It stores the system prompt, messages, _usage records, and _checkpoint records. Checkpoints allow D-Mail and error recovery paths to rewind to a known point. When compaction runs, the context file is rotated, a compacted message set is written, the system prompt is restored, and token counts are updated.

WireFile persists a separate JSONL event log for UI/runtime events. It starts with protocol metadata and appends timestamped WireMessageEnvelope records. UI code can subscribe to raw or merged Wire queues; the merged queue coalesces streaming content parts before recording or rendering.

Hook and approval paths

Hooks are integrated at both turn and tool boundaries:

HookTrigger point
UserPromptSubmitBefore a user prompt starts a turn
PreToolUseBefore a tool executes
PostToolUseAfter a tool succeeds
PostToolUseFailureAfter a tool raises an exception
StopAfter a turn finishes, with one re-trigger guard
StopFailureAfter an agent step fails
SessionStart and SessionEndWhen a session is created/resumed and when it closes
PreCompact and PostCompactAround context compaction
NotificationWhen pending notifications are delivered into LLM context
SubagentStart and SubagentStopAround foreground subagent execution

Approvals flow through ApprovalRuntime. The runtime binds approval state to RootWireHub, so foreground turns, subagents, and background agents can publish approval requests back to the root UI. PythinkerSoul.run creates an ApprovalSource for each foreground turn and cancels unresolved approvals from that source when the turn exits.

Behavioral invariants

InvariantWhy it matters
Tool visibility is advisory, execution guards are authoritativeThe model should not see tools that the current role/profile will reject, but every tool call still passes through hard guards and approvals.
Permission profiles are snapshotted per stepA tool in the same assistant response cannot relax plan/read-only rules by changing session mode before another tool executes.
Root orchestration stays root-onlySubagents cannot launch other subagents; child work must be visible to and coordinated by the root session.
Subagents have isolated contextParent agents receive summaries and status, not wholesale child histories, keeping delegation boundaries explicit.
Compaction rewrites context through the normal context APIThe system prompt, checkpoints, active skills, background task hints, hook context, and token estimate must be restored consistently.
Wire is the observation boundaryUI, ACP, web, visualization, and subagent bridges should consume typed Wire events rather than reaching into soul internals.

Stop conditions

A normal turn stops when the latest assistant message contains no tool calls. Other stop paths are:

Stop pathBehavior
Tool rejection without feedbackRoot agent stops the turn with tool_rejected; subagents continue so they can try alternatives
Max step limitMaxStepsReached is raised from _agent_loop
Step exceptionEmits StepInterrupted, triggers StopFailure, and re-raises
Slash commandExecutes command and ends the turn without entering the normal step loop
D-MailReverts context to a checkpoint, injects a future-self message, then continues stepping
Queued steerAppends follow-up user input and forces another LLM step

Mental model

The agent architecture is easiest to read as four concentric layers:

  1. PythinkerCLI.create is the composition layer. It wires config, model, runtime services, agent spec, context, hooks, and telemetry.
  2. PythinkerSoul is the lifecycle layer. It owns turns, steps, checkpoints, compaction, slash commands, dynamic injections, and stop conditions.
  3. pythinker_core.step is the model/tool orchestration layer. It asks the provider for an assistant message, streams output, executes requested tools through the toolset, and exposes tool results back to the soul.
  4. Wire is the observation layer. It records and broadcasts what happened so shell, print, ACP, web, dashboard, and external Wire clients can render or control the run without owning agent logic.