Pydantic Removal Contract
Summary
This document defines the minimal internal contracts that must remain stable while replacing pydantic-ai: request loop responsibilities, canonical message shapes, tool lifecycle handling, persistence format, and orchestration IDs. It is the checklist for removing pydantic-ai without losing state, history, or tool behavior.
Context
pydantic-ai currently owns the request loop and its message types. A prior runtime removal attempt failed because the agent loop expects ModelRequest/ModelResponse objects; when history was stored as wire dicts, _clean_message_history dropped prior turns and context vanished. Streaming is no longer used, so it is explicitly out of scope for this contract.
Changes
- Mapped request loop responsibilities and node expectations that must be preserved.
- Documented canonical message and tool call shapes, including serialization formats.
- Enumerated orchestration IDs and invariants that must survive persistence and loop replacement.
- Captured retry/usage/tool lifecycle semantics that are currently pydantic-shaped.
Contract Surface Map
| Surface |
Files |
Contract Summary |
| Request loop |
src/tunacode/core/agents/main.py |
Request lifecycle, cleanup, iteration, abort handling, message persistence. |
| Node processing |
src/tunacode/core/agents/agent_components/orchestrator/ |
Response parsing, tool dispatch, usage updates, empty response detection. |
| Canonical types |
src/tunacode/types/canonical.py |
Authoritative message + part definitions and tool call record shape. |
| Message adapter |
src/tunacode/utils/messaging/adapter.py |
Conversion to/from canonical forms; parts are single source of truth. |
| Persistence |
src/tunacode/core/state.py |
Session file schema and message serialization/deserialization. |
| Tool lifecycle |
src/tunacode/core/types/tool_registry.py, tools/decorators.py, agent_components/tool_executor.py |
Tool call tracking, retries, and error contracts. |
| Provider + tools |
src/tunacode/core/agents/agent_components/agent_config.py |
Provider selection + tool schema registration. |
| UI dependencies |
src/tunacode/ui/app.py, src/tunacode/ui/headless/output.py |
Current UI assumes ModelResponse for latest assistant message. |
Request Loop Contract (Core Orchestrator)
Owner: RequestOrchestrator in src/tunacode/core/agents/main.py.
Required sequence (must be preserved or replaced 1:1):
- Context + ID
- Generate
request_id (uuid4 prefix length 8) and assign to session.runtime.request_id.
- Runtime reset
- Clear runtime counters (
current_iteration, iteration_count, batch_counter).
- Clear
runtime.tool_registry.
- Reset
consecutive_empty_responses.
- Reset
task.original_query unless already set.
- History preparation
- Prune old tool outputs via
prune_old_tool_outputs (mutates ToolReturnPart.content).
- Run
run_cleanup_loop to remove dangling tool calls, empty responses, and consecutive requests.
- If history ends with a request, drop it before adding a new request.
- Run
sanitize_history_for_resume (strip system prompts, clear run_id).
- Iterative loop
agent.iter(message, message_history=sanitized_history).
- For each node:
- Update iteration counters.
- Process node via
process_node(...) (usage updates, tool dispatch, thought capture).
- Empty response detection + intervention (
EmptyResponseHandler).
- Break when
response_state.task_completed is true.
- Finalize
- Flush buffered tool tasks (
_finalize_buffered_tasks).
- Persist authoritative run messages (
agent_run.all_messages() plus external additions).
- Abort/timeout handling
- On abort: append an
[INTERRUPTED] ModelResponse containing partial stream text.
- Clean dangling tool calls, empty responses, consecutive requests.
- Invalidate agent cache after abort or timeout.
Critical side effects:
session.conversation.messages must be updated with authoritative run history.
session.update_token_count() must run after message mutations.
Node Shape Contract (Process Node)
process_node() expects a node object with the following attributes:
| Attribute |
Used By |
Purpose |
node.request |
_emit_tool_returns |
Extracts ToolReturnPart parts; updates tool registry + UI callbacks. |
node.model_response |
dispatch_tools + usage tracker |
Reads parts for tool calls; reads usage for token/cost. |
node.thought |
record_thought |
Captures assistant reasoning into session thoughts. |
node.result.output |
request loop |
Indicates visible response to set response_state.has_user_response. |
Any replacement loop must either keep this shape or adapt process_node to the new shape.
Canonical Message Contract
Authoritative types: CanonicalMessage, TextPart, ToolCallPart, ToolReturnPart, RetryPromptPart, SystemPromptPart, ThoughtPart in src/tunacode/types/canonical.py.
Rules:
parts is the single source of truth. Never rely on a separate tool_calls list.
tool_call_id is required on tool call/return parts.
- Order of
parts must be preserved.
- Legacy dict messages (
{"content": ...} and {"thought": ...}) remain loadable until session migration is complete.
Wire format for persistence (current default):
{
"kind": "request|response",
"parts": [
{"part_kind": "text", "content": "..."},
{"part_kind": "tool-call", "tool_call_id": "tc_1", "tool_name": "bash", "args": {"command": "ls"}},
{"part_kind": "tool-return", "tool_call_id": "tc_1", "content": "..."}
]
}
Sanitization rules (resume safety):
- Strip
system-prompt parts.
- Clear
run_id attribute when present (pydantic-specific guard).
Tool Call Lifecycle Contract
Registry: ToolCallRegistry (runtime) is the single source of truth for tool call status.
Lifecycle steps:
- Register on tool call parsing (
record_tool_call_args).
- Start when tool execution begins (
_mark_tool_calls_running).
- Complete when a tool return part is emitted (
_emit_tool_returns).
- Fail/Cancel on error or abort (
_record_tool_failure).
Execution semantics:
execute_tools_parallel retries up to TOOL_MAX_RETRIES.
ToolRetryError is converted to pydantic_ai.ModelRetry by decorators; retry signaling is part of the tool contract.
NON_RETRYABLE_ERRORS include ModelRetry, ToolExecutionError, UserAbortError.
Fallback parsing:
- If no structured tool calls exist,
_extract_fallback_tool_calls parses text parts and builds tool calls. This requires a ToolCallPart-compatible shape (tool_call_id, tool_name, args).
Persistence Contract
Session file keys (must remain stable):
version, session_id, project_id, created_at, last_modified, working_directory
current_model, total_tokens, session_total_usage
thoughts, messages
Current serializer: StateManager._serialize_messages() uses pydantic.TypeAdapter(ModelMessage).
Removal requirement: new serializer must preserve:
kind, parts, part_kind, tool_call_id, tool_name, args, content
- Legacy dict forms (
content, thought) for backward compatibility
Usage Tracking Contract
update_usage() expects a response usage object with attributes:
request_tokens, response_tokens, cached_tokens
Providers must expose equivalent fields or be normalized to this shape.
Orchestration IDs & Invariants
| ID |
Stored In |
Used By |
Notes |
session_id |
SessionState.session_id |
Persistence |
Part of session filename. |
request_id |
RuntimeState.request_id |
Logging |
Per-request correlation. |
tool_call_id |
ToolCallPart + ToolReturnPart + registry |
Tool lifecycle |
Required for pairing calls/returns. |
run_id |
Message attribute (pydantic) |
Resume sanitization |
Cleared before reuse; new loop may drop it. |
batch_counter |
RuntimeState.batch_counter |
Logs/UI |
Increments per tool batch. |
Invariants:
- No dangling tool calls before sending a request.
- No consecutive request messages in history.
- System prompt parts are never persisted in history.
- Tool registry and message parts must reference the same
tool_call_id values.
Out of Scope
- Streaming protocol (
agent_components/streaming.py) is not used and is excluded from this contract.
Verification Checklist
- History survives round-trip serialization with identical
parts and tool_call_id values.
- Session load preserves
thoughts, messages, and session_total_usage.
- Tool registry records complete/fail/cancel updates in sync with tool return parts.
- Request loop still prunes and sanitizes history before a run.
- Abort path appends an
[INTERRUPTED] response and cleans dangling tool calls.
- UI can still extract the latest assistant response without depending on
ModelResponse.
Behavioral Impact
What readers gain:
- A single, testable contract for replacing pydantic-ai without losing orchestration state.
What doesn’t change:
- Existing user-visible behavior (tool calls, history, persistence) remains the baseline.
Related Cards
1---2name: pydantic-removal-contract3description: This document defines the minimal internal contracts that must remain stable while replacing pydantic-ai: request loop responsibilities, canonical message shapes, tool lifecycle handling, persistence format, and…4---56# Pydantic Removal Contract78## Summary9This document defines the minimal internal contracts that must remain stable while replacing pydantic-ai: request loop responsibilities, canonical message shapes, tool lifecycle handling, persistence format, and orchestration IDs. It is the checklist for removing pydantic-ai without losing state, history, or tool behavior.1011## Context12pydantic-ai currently owns the request loop and its message types. A prior runtime removal attempt failed because the agent loop expects `ModelRequest`/`ModelResponse` objects; when history was stored as wire dicts, `_clean_message_history` dropped prior turns and context vanished. Streaming is no longer used, so it is explicitly out of scope for this contract.1314## Changes15- Mapped request loop responsibilities and node expectations that must be preserved.16- Documented canonical message and tool call shapes, including serialization formats.17- Enumerated orchestration IDs and invariants that must survive persistence and loop replacement.18- Captured retry/usage/tool lifecycle semantics that are currently pydantic-shaped.1920## Contract Surface Map2122| Surface | Files | Contract Summary |23| --- | --- | --- |24| Request loop | `src/tunacode/core/agents/main.py` | Request lifecycle, cleanup, iteration, abort handling, message persistence. |25| Node processing | `src/tunacode/core/agents/agent_components/orchestrator/` | Response parsing, tool dispatch, usage updates, empty response detection. |26| Canonical types | `src/tunacode/types/canonical.py` | Authoritative message + part definitions and tool call record shape. |27| Message adapter | `src/tunacode/utils/messaging/adapter.py` | Conversion to/from canonical forms; parts are single source of truth. |28| Persistence | `src/tunacode/core/state.py` | Session file schema and message serialization/deserialization. |29| Tool lifecycle | `src/tunacode/core/types/tool_registry.py`, `tools/decorators.py`, `agent_components/tool_executor.py` | Tool call tracking, retries, and error contracts. |30| Provider + tools | `src/tunacode/core/agents/agent_components/agent_config.py` | Provider selection + tool schema registration. |31| UI dependencies | `src/tunacode/ui/app.py`, `src/tunacode/ui/headless/output.py` | Current UI assumes `ModelResponse` for latest assistant message. |3233## Request Loop Contract (Core Orchestrator)3435**Owner:** `RequestOrchestrator` in `src/tunacode/core/agents/main.py`.3637**Required sequence (must be preserved or replaced 1:1):**38391. **Context + ID**40 - Generate `request_id` (`uuid4` prefix length 8) and assign to `session.runtime.request_id`.412. **Runtime reset**42 - Clear runtime counters (`current_iteration`, `iteration_count`, `batch_counter`).43 - Clear `runtime.tool_registry`.44 - Reset `consecutive_empty_responses`.45 - Reset `task.original_query` unless already set.463. **History preparation**47 - Prune old tool outputs via `prune_old_tool_outputs` (mutates `ToolReturnPart.content`).48 - Run `run_cleanup_loop` to remove dangling tool calls, empty responses, and consecutive requests.49 - If history ends with a request, drop it before adding a new request.50 - Run `sanitize_history_for_resume` (strip system prompts, clear `run_id`).514. **Iterative loop**52 - `agent.iter(message, message_history=sanitized_history)`.53 - For each node:54 - Update iteration counters.55 - Process node via `process_node(...)` (usage updates, tool dispatch, thought capture).56 - Empty response detection + intervention (`EmptyResponseHandler`).57 - Break when `response_state.task_completed` is true.585. **Finalize**59 - Flush buffered tool tasks (`_finalize_buffered_tasks`).60 - Persist authoritative run messages (`agent_run.all_messages()` plus external additions).616. **Abort/timeout handling**62 - On abort: append an `[INTERRUPTED]` `ModelResponse` containing partial stream text.63 - Clean dangling tool calls, empty responses, consecutive requests.64 - Invalidate agent cache after abort or timeout.6566**Critical side effects:**67- `session.conversation.messages` must be updated with authoritative run history.68- `session.update_token_count()` must run after message mutations.6970## Node Shape Contract (Process Node)7172`process_node()` expects a node object with the following attributes:7374| Attribute | Used By | Purpose |75| --- | --- | --- |76| `node.request` | `_emit_tool_returns` | Extracts `ToolReturnPart` parts; updates tool registry + UI callbacks. |77| `node.model_response` | `dispatch_tools` + usage tracker | Reads `parts` for tool calls; reads `usage` for token/cost. |78| `node.thought` | `record_thought` | Captures assistant reasoning into session thoughts. |79| `node.result.output` | request loop | Indicates visible response to set `response_state.has_user_response`. |8081Any replacement loop must either keep this shape or adapt `process_node` to the new shape.8283## Canonical Message Contract8485**Authoritative types:** `CanonicalMessage`, `TextPart`, `ToolCallPart`, `ToolReturnPart`, `RetryPromptPart`, `SystemPromptPart`, `ThoughtPart` in `src/tunacode/types/canonical.py`.8687**Rules:**88- `parts` is the single source of truth. **Never** rely on a separate `tool_calls` list.89- `tool_call_id` is required on tool call/return parts.90- Order of `parts` must be preserved.91- Legacy dict messages (`{"content": ...}` and `{"thought": ...}`) remain loadable until session migration is complete.9293**Wire format for persistence (current default):**94```json95{96 "kind": "request|response",97 "parts": [98 {"part_kind": "text", "content": "..."},99 {"part_kind": "tool-call", "tool_call_id": "tc_1", "tool_name": "bash", "args": {"command": "ls"}},100 {"part_kind": "tool-return", "tool_call_id": "tc_1", "content": "..."}101 ]102}103```104105**Sanitization rules (resume safety):**106- Strip `system-prompt` parts.107- Clear `run_id` attribute when present (pydantic-specific guard).108109## Tool Call Lifecycle Contract110111**Registry:** `ToolCallRegistry` (runtime) is the single source of truth for tool call status.112113**Lifecycle steps:**1141. **Register** on tool call parsing (`record_tool_call_args`).1152. **Start** when tool execution begins (`_mark_tool_calls_running`).1163. **Complete** when a tool return part is emitted (`_emit_tool_returns`).1174. **Fail/Cancel** on error or abort (`_record_tool_failure`).118119**Execution semantics:**120- `execute_tools_parallel` retries up to `TOOL_MAX_RETRIES`.121- `ToolRetryError` is converted to `pydantic_ai.ModelRetry` by decorators; retry signaling is part of the tool contract.122- `NON_RETRYABLE_ERRORS` include `ModelRetry`, `ToolExecutionError`, `UserAbortError`.123124**Fallback parsing:**125- If no structured tool calls exist, `_extract_fallback_tool_calls` parses text parts and builds tool calls. This requires a `ToolCallPart`-compatible shape (`tool_call_id`, `tool_name`, `args`).126127## Persistence Contract128129**Session file keys (must remain stable):**130- `version`, `session_id`, `project_id`, `created_at`, `last_modified`, `working_directory`131- `current_model`, `total_tokens`, `session_total_usage`132- `thoughts`, `messages`133134**Current serializer:** `StateManager._serialize_messages()` uses `pydantic.TypeAdapter(ModelMessage)`.135136**Removal requirement:** new serializer must preserve:137- `kind`, `parts`, `part_kind`, `tool_call_id`, `tool_name`, `args`, `content`138- Legacy dict forms (`content`, `thought`) for backward compatibility139140## Usage Tracking Contract141142`update_usage()` expects a response `usage` object with attributes:143- `request_tokens`, `response_tokens`, `cached_tokens`144145Providers must expose equivalent fields or be normalized to this shape.146147## Orchestration IDs & Invariants148149| ID | Stored In | Used By | Notes |150| --- | --- | --- | --- |151| `session_id` | `SessionState.session_id` | Persistence | Part of session filename. |152| `request_id` | `RuntimeState.request_id` | Logging | Per-request correlation. |153| `tool_call_id` | `ToolCallPart` + `ToolReturnPart` + registry | Tool lifecycle | Required for pairing calls/returns. |154| `run_id` | Message attribute (pydantic) | Resume sanitization | Cleared before reuse; new loop may drop it. |155| `batch_counter` | `RuntimeState.batch_counter` | Logs/UI | Increments per tool batch. |156157**Invariants:**158- No dangling tool calls before sending a request.159- No consecutive request messages in history.160- System prompt parts are never persisted in history.161- Tool registry and message parts must reference the same `tool_call_id` values.162163## Out of Scope164- **Streaming protocol** (`agent_components/streaming.py`) is not used and is excluded from this contract.165166## Verification Checklist167168- History survives round-trip serialization with identical `parts` and `tool_call_id` values.169- Session load preserves `thoughts`, `messages`, and `session_total_usage`.170- Tool registry records complete/fail/cancel updates in sync with tool return parts.171- Request loop still prunes and sanitizes history before a run.172- Abort path appends an `[INTERRUPTED]` response and cleans dangling tool calls.173- UI can still extract the latest assistant response without depending on `ModelResponse`.174175## Behavioral Impact176177**What readers gain:**178- A single, testable contract for replacing pydantic-ai without losing orchestration state.179180**What doesn’t change:**181- Existing user-visible behavior (tool calls, history, persistence) remains the baseline.182183## Related Cards184- [[message-flow-map]]