Reasoning Traces Storage
Status: Draft | Owner: vineeth | Last updated: 2026-03-25
1. Problem Statement
Honcho’s three background agents (Deriver, Dialectic, Dreamer) and the Summarizer all make LLM calls that produce reasoning traces — the full input/output of each call including thinking content, tool calls, token counts, and model parameters. Today these traces are logged to a JSONL file via log_reasoning_trace() in src/telemetry/reasoning_traces.py and then effectively discarded:
-
Not queryable. The JSONL file is append-only with file-level locking. There is no way to search, filter, or retrieve specific traces.
-
No database storage. Traces live on local disk, are lost on container restart (Fly.io machines are ephemeral), and cannot be accessed by other processes.
-
No provenance chain. When a Deriver call produces observations (documents), there is no link from those observations back to the LLM call that created them. Developers cannot answer “why did Honcho conclude X?” without manually correlating timestamps across logs.
-
Not accessible to agents. The Dialectic and Dreamer agents cannot inspect past reasoning. When the Dreamer consolidates observations, it cannot see what reasoning led to an existing deductive observation. When the Dialectic answers questions, it cannot reference the reasoning chain behind a conclusion.
-
No retention management. Traces accumulate indefinitely on disk with no cleanup mechanism.
This spec designs a PostgreSQL-backed reasoning trace storage system with full provenance linking, developer-facing API, agent tool integration, size-based storage tiering, configurable retention, and CloudEvents billing support.
2. Goals / Non-Goals
Goals
- G1: Store reasoning traces in PostgreSQL with a dedicated
reasoning_tracestable, replacing the JSONL file approach. - G2: Establish a bidirectional provenance chain: Message → Trace → Observation. Given any observation, a developer can retrieve the trace that produced it. Given any message, they can see all traces and observations it triggered.
- G3: Expose traces via a read-only REST API with filtering by agent type, task type, time range, workspace, and peer.
- G4: Add a
get_reasoning_tracetool to the Dialectic and Dreamer agents so they can inspect past reasoning when making decisions. - G5: Implement size-based storage tiering — inline JSONB for traces under 50KB, S3 reference for larger traces.
- G6: Provide configurable retention with indefinite as the default, plus options for time-based and count-based pruning.
- G7: Emit CloudEvents for trace storage operations to support billing in the managed SaaS platform.
- G8: Maintain backward compatibility — the JSONL file logging continues to work as a fallback when database storage is disabled.
Non-Goals
- Exposing traces as a writable API (traces are system-generated, read-only to developers).
- Real-time streaming of traces as they are produced (traces are written after LLM calls complete).
- Full-text search over trace content (JSONB containment queries and provenance-chain lookups are sufficient).
- Storing intermediate tool call results separately — these are captured within the trace’s
contentJSONB. - Modifying the LLM client interface (
honcho_llm_call) beyond adding context parameters for trace linking.
3. Design
3.1 Schema: reasoning_traces Table
3.1.1 DDL
CREATE TABLE {schema}.reasoning_traces (
-- Primary key: nanoid, consistent with all other Honcho tables
id TEXT PRIMARY KEY,
-- Workspace scoping (foreign key to workspaces.name)
workspace_name TEXT NOT NULL REFERENCES {schema}.workspaces(name),
-- Session context (nullable -- dialectic global queries have no session)
session_name TEXT,
-- Peer context: which peer relationship produced this trace
observer TEXT NOT NULL,
observed TEXT NOT NULL,
-- Agent identification
agent_type TEXT NOT NULL, -- 'deriver', 'dialectic', 'dreamer_deduction', 'dreamer_induction', 'summarizer'
task_type TEXT NOT NULL, -- 'minimal_deriver', 'dialectic_chat', 'dreamer_deduction', 'dreamer_induction', 'short_summary', 'long_summary'
-- Model metadata
provider TEXT NOT NULL, -- 'anthropic', 'google', 'openai', 'groq', 'custom', 'vllm'
model TEXT NOT NULL, -- e.g. 'claude-haiku-4-5', 'gemini-2.5-flash-lite'
-- Token accounting
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
-- Execution metadata
duration_ms DOUBLE PRECISION, -- wall-clock time for the full LLM call (including tool loops)
iteration_count INTEGER NOT NULL DEFAULT 1, -- number of LLM call iterations (1 = single call, 2+ = tool loop)
-- Trace content: inline JSONB for small traces, S3 reference for large ones
-- Structure when inline:
-- {
-- "settings": { "max_tokens": 4096, "thinking_budget_tokens": 1024, ... },
-- "input": { "tokens": 1234, "prompt": "..." | "messages": [...] },
-- "output": { "content": "...", "thinking_content": "...", "tool_calls": [...], "finish_reasons": [...] }
-- }
-- Structure when externalized:
-- { "_ref": "s3", "bucket": "honcho-traces", "key": "ws/session/trace_id.json.gz", "size_bytes": 123456 }
content JSONB NOT NULL,
-- Content size tracking (always set, regardless of storage location)
content_size_bytes INTEGER NOT NULL DEFAULT 0,
-- Provenance linkage: observations produced by this trace
-- Array of document IDs (nanoid format). Updated after observations are created.
observation_ids JSONB DEFAULT '[]'::jsonb,
-- Provenance linkage: source message IDs that triggered this trace
-- For deriver: the message IDs from the queue batch
-- For dialectic: empty (triggered by API call, not by specific messages)
-- For dreamer: empty (triggered by schedule/threshold)
-- For summarizer: the message IDs covered by the summary
message_ids JSONB DEFAULT '[]'::jsonb,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
-- Composite foreign keys for peer validation
CONSTRAINT fk_reasoning_traces_observer
FOREIGN KEY (observer, workspace_name)
REFERENCES {schema}.peers(name, workspace_name),
CONSTRAINT fk_reasoning_traces_observed
FOREIGN KEY (observed, workspace_name)
REFERENCES {schema}.peers(name, workspace_name),
-- Session foreign key (nullable)
CONSTRAINT fk_reasoning_traces_session
FOREIGN KEY (session_name, workspace_name)
REFERENCES {schema}.sessions(name, workspace_name),
-- Constraints
CONSTRAINT ck_reasoning_traces_id_length CHECK (length(id) = 21),
CONSTRAINT ck_reasoning_traces_id_format CHECK (id ~ '^[A-Za-z0-9_-]+$'),
CONSTRAINT ck_reasoning_traces_agent_type CHECK (agent_type IN (
'deriver', 'dialectic', 'dreamer_deduction', 'dreamer_induction', 'summarizer'
)),
CONSTRAINT ck_reasoning_traces_content_size CHECK (content_size_bytes >= 0)
);
-- Indexes
CREATE INDEX ix_reasoning_traces_workspace_name ON {schema}.reasoning_traces(workspace_name);
CREATE INDEX ix_reasoning_traces_created_at ON {schema}.reasoning_traces(created_at);
CREATE INDEX ix_reasoning_traces_agent_type ON {schema}.reasoning_traces(agent_type);
CREATE INDEX ix_reasoning_traces_observer_observed ON {schema}.reasoning_traces(observer, observed);
CREATE INDEX ix_reasoning_traces_session_name ON {schema}.reasoning_traces(session_name)
WHERE session_name IS NOT NULL;
-- GIN index for observation_ids lookups ("find trace by observation ID")
CREATE INDEX ix_reasoning_traces_observation_ids_gin ON {schema}.reasoning_traces
USING gin (observation_ids);
-- GIN index for message_ids lookups ("find traces triggered by message")
CREATE INDEX ix_reasoning_traces_message_ids_gin ON {schema}.reasoning_traces
USING gin (message_ids);
-- Composite index for common query pattern: workspace + agent_type + time ordering
CREATE INDEX ix_reasoning_traces_workspace_agent_created
ON {schema}.reasoning_traces(workspace_name, agent_type, created_at DESC);3.1.2 SQLAlchemy Model
Add to src/models.py:
@final
class ReasoningTrace(Base):
__tablename__: str = "reasoning_traces"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
workspace_name: Mapped[str] = mapped_column(
ForeignKey("workspaces.name"), nullable=False, index=True
)
session_name: Mapped[str | None] = mapped_column(TEXT, nullable=True, index=True)
observer: Mapped[str] = mapped_column(TEXT, nullable=False)
observed: Mapped[str] = mapped_column(TEXT, nullable=False)
agent_type: Mapped[str] = mapped_column(TEXT, nullable=False, index=True)
task_type: Mapped[str] = mapped_column(TEXT, nullable=False)
provider: Mapped[str] = mapped_column(TEXT, nullable=False)
model: Mapped[str] = mapped_column(TEXT, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cache_creation_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
cache_read_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
duration_ms: Mapped[float | None] = mapped_column(
sa.Float, nullable=True
)
iteration_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
content_size_bytes: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
observation_ids: Mapped[list[str]] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb")
)
message_ids: Mapped[list[str]] = mapped_column(
JSONB, default=list, server_default=text("'[]'::jsonb")
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
__table_args__ = (
CheckConstraint("length(id) = 21", name="id_length"),
CheckConstraint("id ~ '^[A-Za-z0-9_-]+$'", name="id_format"),
CheckConstraint(
"agent_type IN ('deriver', 'dialectic', 'dreamer_deduction', 'dreamer_induction', 'summarizer')",
name="agent_type_valid",
),
CheckConstraint("content_size_bytes >= 0", name="content_size_non_negative"),
ForeignKeyConstraint(
["observer", "workspace_name"],
["peers.name", "peers.workspace_name"],
),
ForeignKeyConstraint(
["observed", "workspace_name"],
["peers.name", "peers.workspace_name"],
),
ForeignKeyConstraint(
["session_name", "workspace_name"],
["sessions.name", "sessions.workspace_name"],
),
Index(
"ix_reasoning_traces_observer_observed",
"observer", "observed",
),
Index(
"ix_reasoning_traces_observation_ids_gin",
"observation_ids",
postgresql_using="gin",
),
Index(
"ix_reasoning_traces_message_ids_gin",
"message_ids",
postgresql_using="gin",
),
Index(
"ix_reasoning_traces_workspace_agent_created",
"workspace_name", "agent_type", created_at.desc(),
),
)
def __repr__(self) -> str:
return (
f"ReasoningTrace(id={self.id}, agent_type={self.agent_type}, "
f"task_type={self.task_type}, workspace_name={self.workspace_name})"
)3.1.3 content JSONB Structure
When stored inline (content_size_bytes < 50KB):
{
"settings": {
"max_tokens": 4096,
"thinking_budget_tokens": 1024,
"reasoning_effort": null,
"json_mode": true,
"stop_seqs": [" \n", "\n\n\n\n"],
"temperature": null
},
"input": {
"tokens": 5432,
"prompt": "You are a minimal deriver..."
},
"output": {
"content": "{\"explicit\": [...], \"deductive\": [...]}",
"tokens": 892,
"thinking_content": "Let me analyze these messages...",
"finish_reasons": ["end_turn"],
"tool_calls": []
}
}For multi-turn/agentic calls (dialectic, dreamer), input.messages replaces input.prompt:
{
"settings": { ... },
"input": {
"tokens": 12345,
"messages": [
{"role": "system", "content": "You are a dialectic agent..."},
{"role": "user", "content": "Query: What does Alice prefer?"},
{"role": "assistant", "content": "...", "tool_calls": [...]},
{"role": "tool", "content": "..."},
...
]
},
"output": {
"content": "Based on the observations...",
"tokens": 1543,
"thinking_content": "I should search for...",
"finish_reasons": ["end_turn"],
"tool_calls": [
{"name": "search_memory", "input": {"query": "Alice preferences"}, "iteration": 1},
{"name": "get_observation_context", "input": {"message_ids": ["abc"]}, "iteration": 2}
]
}
}When externalized to S3 (content_size_bytes >= 50KB):
{
"_ref": "s3",
"bucket": "honcho-traces",
"key": "traces/my_workspace/2026/03/25/abc123XYZ.json.gz",
"size_bytes": 156789,
"compressed_bytes": 34567
}The externalized file contains the same structure as the inline version, gzip-compressed.
3.2 Provenance Chain
The provenance chain connects three entities:
Message(s) ---> ReasoningTrace ---> Observation(s) (Documents)
^ | |
| v v
message_ids[] observation_ids[] internal_metadata.trace_id
3.2.1 Forward Links (Trace → Observations)
When the Deriver, Dialectic, or Dreamer creates observations, the trace’s observation_ids array is updated with the IDs of the created documents.
Deriver flow:
honcho_llm_call()completes, producingHonchoLLMCallResponsesave_reasoning_trace()writes the trace to the database, returnstrace_idRepresentationManager.save_representation()creates documents- After document creation,
link_trace_to_observations(trace_id, document_ids)updates the trace
Dialectic flow:
DialecticAgent.answer()callshoncho_llm_call()with tools- During tool execution,
create_observationstool may create documents - Tool executor accumulates created document IDs
- After the agent loop completes,
save_reasoning_trace()is called with accumulated observation IDs
Dreamer flow:
- Each specialist calls
honcho_llm_call()with tools - Tool executor accumulates created/deleted document IDs
- After the specialist completes,
save_reasoning_trace()is called with accumulated observation IDs
3.2.2 Backward Links (Observation → Trace)
Each document’s internal_metadata gains a trace_id field pointing back to the reasoning trace that created it.
Updated DocumentMetadata in src/schemas/internal.py:
class DocumentMetadata(BaseModel):
message_ids: list[int] = Field(...)
message_created_at: str = Field(...)
source_ids: list[str] | None = Field(default=None, ...)
premises: list[str] | None = Field(default=None, ...)
sources: list[str] | None = Field(default=None, ...)
pattern_type: str | None = Field(default=None, ...)
confidence: str | None = Field(default=None, ...)
# NEW: Link back to the reasoning trace that produced this observation
trace_id: str | None = Field(
default=None,
description="ID of the reasoning trace that produced this observation",
)For observations created by the tool-based agents (dialectic, dreamer), the trace_id is set at document creation time by the tool executor. For deriver observations, it is set in RepresentationManager.save_representation() after the trace is persisted.
3.2.3 Provenance Queries
“Given this observation, show me the trace that produced it”:
-- Option A: via internal_metadata.trace_id on the document
SELECT rt.*
FROM reasoning_traces rt
JOIN documents d ON d.internal_metadata->>'trace_id' = rt.id
WHERE d.id = :document_id;
-- Option B: via observation_ids GIN index on the trace
SELECT rt.*
FROM reasoning_traces rt
WHERE rt.observation_ids @> :document_id_jsonb;
-- where :document_id_jsonb = '["abc123XYZ"]'::jsonb“Given this message, show me all traces and observations”:
-- Find all traces triggered by a message (deriver traces)
SELECT rt.*, rt.observation_ids
FROM reasoning_traces rt
WHERE rt.message_ids @> :message_public_id_jsonb;
-- where :message_public_id_jsonb = '["msg_abc123"]'::jsonb“Show the full provenance chain for an observation”:
-- 1. Get the observation
SELECT d.id, d.content, d.internal_metadata, d.source_ids
FROM documents d WHERE d.id = :observation_id;
-- 2. Get the trace that produced it
SELECT rt.*
FROM reasoning_traces rt
WHERE rt.observation_ids @> to_jsonb(ARRAY[:observation_id]);
-- 3. Get the source messages from the trace
-- (message_ids in the trace are internal IDs; join to get public_ids and content)
SELECT m.public_id, m.content, m.peer_name, m.created_at
FROM messages m
WHERE m.public_id = ANY(
SELECT jsonb_array_elements_text(rt.message_ids)
FROM reasoning_traces rt
WHERE rt.observation_ids @> to_jsonb(ARRAY[:observation_id])
);3.3 API Surface
All trace endpoints are read-only. Traces are created by the system, not by developers.
3.3.1 Router: src/routers/traces.py
router = APIRouter(
prefix="/workspaces/{workspace_id}/traces",
tags=["traces"],
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
)3.3.2 Endpoints
List traces with filters:
POST /v3/workspaces/{workspace_id}/traces/list
Request body:
{
"filters": {
"agent_type": "deriver",
"observer": "honcho",
"observed": "alice",
"session_id": "my_session",
"created_after": "2026-03-01T00:00:00Z",
"created_before": "2026-03-25T23:59:59Z"
}
}Response (paginated via fastapi_pagination):
{
"items": [
{
"id": "abc123XYZ...",
"workspace_id": "my_workspace",
"session_id": "my_session",
"observer_id": "honcho",
"observed_id": "alice",
"agent_type": "deriver",
"task_type": "minimal_deriver",
"provider": "google",
"model": "gemini-2.5-flash-lite",
"input_tokens": 5432,
"output_tokens": 892,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 3200,
"duration_ms": 1243.5,
"iteration_count": 1,
"content_size_bytes": 12345,
"observation_ids": ["obs_001", "obs_002"],
"message_ids": ["msg_abc", "msg_def"],
"created_at": "2026-03-25T10:30:00Z"
}
],
"total": 150,
"page": 1,
"size": 50,
"pages": 3
}Note: The list endpoint does NOT include content to keep response sizes manageable. Use the detail endpoint to retrieve content.
Get full trace (with content):
GET /v3/workspaces/{workspace_id}/traces/{trace_id}
Response:
{
"id": "abc123XYZ...",
"workspace_id": "my_workspace",
"session_id": "my_session",
"observer_id": "honcho",
"observed_id": "alice",
"agent_type": "deriver",
"task_type": "minimal_deriver",
"provider": "google",
"model": "gemini-2.5-flash-lite",
"input_tokens": 5432,
"output_tokens": 892,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 3200,
"duration_ms": 1243.5,
"iteration_count": 1,
"content_size_bytes": 12345,
"content": {
"settings": { "max_tokens": 4096, "thinking_budget_tokens": 1024 },
"input": { "tokens": 5432, "prompt": "..." },
"output": { "content": "...", "thinking_content": "...", "finish_reasons": ["end_turn"] }
},
"observation_ids": ["obs_001", "obs_002"],
"message_ids": ["msg_abc", "msg_def"],
"created_at": "2026-03-25T10:30:00Z"
}If the trace content is externalized to S3, the server fetches it transparently and returns the full content. The content._ref field is never exposed to the API consumer.
Get trace for a specific conclusion:
GET /v3/workspaces/{workspace_id}/conclusions/{conclusion_id}/trace
Response: Same shape as the single trace response, or 404 if no trace is linked.
Implementation: Query documents.internal_metadata->>'trace_id' then fetch the trace by ID. Falls back to the GIN index query on reasoning_traces.observation_ids.
Get traces for a message:
GET /v3/workspaces/{workspace_id}/messages/{message_id}/traces
Response: Array of trace summaries (without content), same shape as list items.
Implementation: Query reasoning_traces.message_ids @> '["<message_public_id>"]'::jsonb.
3.3.3 Pydantic Schemas
Add to src/schemas/api.py:
# ---------------------------------------------------------------------------
# Trace schemas
# ---------------------------------------------------------------------------
class TraceListFilters(BaseModel):
"""Filters for listing reasoning traces."""
agent_type: str | None = Field(default=None, description="Filter by agent type")
task_type: str | None = Field(default=None, description="Filter by task type")
observer: str | None = Field(default=None, alias="observer_id")
observed: str | None = Field(default=None, alias="observed_id")
session_name: str | None = Field(default=None, alias="session_id")
created_after: datetime.datetime | None = None
created_before: datetime.datetime | None = None
model_config = ConfigDict(populate_by_name=True)
class TraceListRequest(BaseModel):
"""Request body for listing traces."""
filters: TraceListFilters | None = None
class TraceSummary(BaseModel):
"""Trace summary returned in list responses (no content)."""
id: str
workspace_name: str = Field(serialization_alias="workspace_id")
session_name: str | None = Field(default=None, serialization_alias="session_id")
observer: str = Field(serialization_alias="observer_id")
observed: str = Field(serialization_alias="observed_id")
agent_type: str
task_type: str
provider: str
model: str
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int
cache_read_input_tokens: int
duration_ms: float | None
iteration_count: int
content_size_bytes: int
observation_ids: list[str]
message_ids: list[str]
created_at: datetime.datetime
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
class TraceDetail(TraceSummary):
"""Full trace with content, returned by detail endpoints."""
content: dict[str, Any]3.4 Agent Tool Integration
3.4.1 New Tool: get_reasoning_trace
Add to TOOLS dict in src/utils/agent_tools.py:
TOOLS["get_reasoning_trace"] = {
"name": "get_reasoning_trace",
"description": (
"Retrieve the reasoning trace that produced a specific observation. "
"Use this to understand WHY an observation exists -- what evidence "
"the agent considered and what reasoning it applied. Pass the observation ID "
"from search results (the [id:xxx] field)."
),
"input_schema": {
"type": "object",
"properties": {
"observation_id": {
"type": "string",
"description": "The ID of the observation to get the reasoning trace for",
},
},
"required": ["observation_id"],
},
}3.4.2 Tool Handler
Add to src/utils/agent_tools.py:
async def _handle_get_reasoning_trace(
ctx: ToolContext, tool_input: dict[str, Any]
) -> str:
"""Handle get_reasoning_trace tool."""
observation_id = tool_input.get("observation_id", "")
if not observation_id:
return "Error: observation_id is required"
try:
trace = await crud.get_trace_for_observation(
ctx.db,
workspace_name=ctx.workspace_name,
observation_id=observation_id,
)
except Exception as e:
logger.warning(f"Failed to get reasoning trace for {observation_id}: {e}")
return f"No reasoning trace found for observation {observation_id}"
if trace is None:
return f"No reasoning trace found for observation {observation_id}"
# Format trace for the agent -- include thinking content and key metadata
parts = [
f"## Reasoning Trace for Observation [{observation_id}]",
f"Agent: {trace.agent_type} ({trace.task_type})",
f"Model: {trace.provider}/{trace.model}",
f"Created: {trace.created_at.isoformat()}",
f"Iterations: {trace.iteration_count}",
]
# Extract thinking content from trace
content = trace.content
if isinstance(content, dict) and "_ref" not in content:
output = content.get("output", {})
thinking = output.get("thinking_content")
if thinking:
parts.append(f"\n### Reasoning:\n{thinking}")
# Include tool calls if present
tool_calls = output.get("tool_calls", [])
if tool_calls:
tool_names = [tc.get("name", "unknown") for tc in tool_calls]
parts.append(f"\n### Tools Used: {', '.join(tool_names)}")
# Include the output content (the actual conclusion/response)
output_content = output.get("content", "")
if output_content:
# Truncate if very long
truncated = _truncate_tool_output(str(output_content), max_chars=3000)
parts.append(f"\n### Output:\n{truncated}")
else:
parts.append("\n(Trace content stored externally -- summary not available)")
# Include sibling observations from same trace
sibling_ids = [
oid for oid in (trace.observation_ids or []) if oid != observation_id
]
if sibling_ids:
parts.append(f"\n### Other observations from this trace: {', '.join(sibling_ids)}")
return _truncate_tool_output("\n".join(parts))Register in _TOOL_HANDLERS:
_TOOL_HANDLERS["get_reasoning_trace"] = _handle_get_reasoning_trace3.4.3 Tool Set Updates
Add get_reasoning_trace to the tool sets that should have access:
# Dialectic tools -- add to DIALECTIC_TOOLS (not DIALECTIC_TOOLS_MINIMAL)
DIALECTIC_TOOLS = [
TOOLS["get_recent_history"],
TOOLS["get_recent_observations"],
TOOLS["get_most_derived_observations"],
TOOLS["search_memory"],
TOOLS["search_messages"],
TOOLS["get_observation_context"],
TOOLS["grep_messages"],
TOOLS["get_messages_by_date_range"],
TOOLS["search_messages_temporal"],
TOOLS["get_session_summary"],
TOOLS["get_peer_card"],
TOOLS["get_reasoning_trace"], # NEW
]
# Dreamer deduction tools
DEDUCTION_SPECIALIST_TOOLS = [
TOOLS["get_recent_observations"],
TOOLS["get_most_derived_observations"],
TOOLS["search_memory"],
TOOLS["get_peer_card"],
TOOLS["create_observations"],
TOOLS["delete_observations"],
TOOLS["update_peer_card"],
TOOLS["search_messages"],
TOOLS["get_observation_context"],
TOOLS["get_reasoning_trace"], # NEW
]
# Dreamer induction tools
INDUCTION_SPECIALIST_TOOLS = [
TOOLS["get_recent_observations"],
TOOLS["get_most_derived_observations"],
TOOLS["search_memory"],
TOOLS["search_messages"],
TOOLS["create_observations"],
TOOLS["update_peer_card"],
TOOLS["get_reasoning_trace"], # NEW
]The DIALECTIC_TOOLS_MINIMAL set does NOT include this tool (minimal reasoning should stay cheap).
3.5 Storage Tiering
3.5.1 Size Threshold
The threshold for externalizing trace content is 50KB (51,200 bytes), measured as the byte length of the JSON-serialized content before compression. This is configurable via settings.
Rationale: A typical deriver trace is 5-15KB. Dialectic traces with multi-turn tool loops can reach 30-80KB. Dreamer traces with many tool iterations can exceed 100KB. The 50KB threshold keeps the majority of traces inline while preventing PostgreSQL row bloat for the largest traces.
3.5.2 Configuration
Add to src/config.py:
class TraceStorageSettings(HonchoSettings):
"""Settings for reasoning trace storage."""
model_config = SettingsConfigDict(env_prefix="TRACE_", extra="ignore")
# Master toggle for database storage of traces
ENABLED: bool = False
# Size threshold (bytes) for externalizing content to S3
# Traces smaller than this are stored inline as JSONB
EXTERNALIZE_THRESHOLD_BYTES: Annotated[
int, Field(default=51200, gt=0, le=10_000_000)
] = 51200 # 50KB
# S3 configuration for externalized traces
S3_BUCKET: str | None = None
S3_PREFIX: str = "traces"
S3_REGION: str | None = None
S3_ENDPOINT_URL: str | None = None # For S3-compatible stores (MinIO, R2, etc.)
S3_ACCESS_KEY_ID: str | None = None
S3_SECRET_ACCESS_KEY: str | None = None
# Retention policy
RETENTION_POLICY: Literal["indefinite", "time_based", "count_based"] = "indefinite"
RETENTION_DAYS: Annotated[int, Field(default=90, gt=0, le=3650)] = 90 # only used if time_based
RETENTION_MAX_PER_WORKSPACE: Annotated[
int, Field(default=10000, gt=0, le=1_000_000)
] = 10000 # only used if count_based
# Cleanup interval
CLEANUP_INTERVAL_HOURS: Annotated[int, Field(default=24, gt=0, le=168)] = 24
@model_validator(mode="after")
def _require_s3_for_externalization(self) -> "TraceStorageSettings":
"""Warn if externalization is implicitly disabled due to missing S3 config."""
# S3 is optional -- traces that exceed the threshold will still be stored
# inline with a warning if S3 is not configured
return selfAdd to AppSettings:
class AppSettings(HonchoSettings):
...
TRACE: TraceStorageSettings = Field(default_factory=TraceStorageSettings)Add "TRACE": "trace" to TomlConfigSettingsSource.SECTION_MAP.
3.5.3 TOML Configuration Example
[trace]
enabled = true
externalize_threshold_bytes = 51200
s3_bucket = "honcho-traces"
s3_region = "us-east-1"
s3_prefix = "traces"
retention_policy = "indefinite"3.5.4 S3 Client
Add src/telemetry/trace_storage.py:
"""
Reasoning trace storage backend.
Handles writing traces to PostgreSQL with optional S3 externalization
for large trace content.
"""
import gzip
import json
import logging
from typing import Any
import boto3
from botocore.config import Config as BotoConfig
from nanoid import generate as generate_nanoid
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.models import ReasoningTrace
logger = logging.getLogger(__name__)
# Lazy-initialized S3 client
_s3_client = None
def _get_s3_client():
"""Get or create S3 client."""
global _s3_client
if _s3_client is not None:
return _s3_client
trace_settings = settings.TRACE
if not trace_settings.S3_BUCKET:
return None
kwargs: dict[str, Any] = {}
if trace_settings.S3_REGION:
kwargs["region_name"] = trace_settings.S3_REGION
if trace_settings.S3_ENDPOINT_URL:
kwargs["endpoint_url"] = trace_settings.S3_ENDPOINT_URL
if trace_settings.S3_ACCESS_KEY_ID:
kwargs["aws_access_key_id"] = trace_settings.S3_ACCESS_KEY_ID
if trace_settings.S3_SECRET_ACCESS_KEY:
kwargs["aws_secret_access_key"] = trace_settings.S3_SECRET_ACCESS_KEY
_s3_client = boto3.client(
"s3",
config=BotoConfig(
retries={"max_attempts": 3, "mode": "adaptive"},
connect_timeout=5,
read_timeout=10,
),
**kwargs,
)
return _s3_client
def _build_s3_key(workspace_name: str, trace_id: str) -> str:
"""Build S3 object key for a trace."""
from datetime import datetime, UTC
now = datetime.now(UTC)
prefix = settings.TRACE.S3_PREFIX.rstrip("/")
return f"{prefix}/{workspace_name}/{now.year}/{now.month:02d}/{now.day:02d}/{trace_id}.json.gz"
def _externalize_content(
content: dict[str, Any],
workspace_name: str,
trace_id: str,
) -> tuple[dict[str, Any], int]:
"""
Upload trace content to S3 and return a reference object.
Returns:
Tuple of (reference_dict, original_size_bytes)
"""
s3 = _get_s3_client()
if s3 is None:
# S3 not configured -- store inline with warning
logger.warning(
"Trace %s exceeds externalize threshold (%d bytes) but S3 is not configured. "
"Storing inline.",
trace_id,
len(json.dumps(content).encode()),
)
return content, len(json.dumps(content).encode())
# Serialize and compress
content_bytes = json.dumps(content).encode("utf-8")
original_size = len(content_bytes)
compressed = gzip.compress(content_bytes, compresslevel=6)
key = _build_s3_key(workspace_name, trace_id)
bucket = settings.TRACE.S3_BUCKET
s3.put_object(
Bucket=bucket,
Key=key,
Body=compressed,
ContentType="application/json",
ContentEncoding="gzip",
)
ref = {
"_ref": "s3",
"bucket": bucket,
"key": key,
"size_bytes": original_size,
"compressed_bytes": len(compressed),
}
return ref, original_size
def fetch_externalized_content(content: dict[str, Any]) -> dict[str, Any]:
"""
Fetch externalized trace content from S3.
If content is not a reference (no _ref key), returns it as-is.
"""
if not isinstance(content, dict) or "_ref" not in content:
return content
if content["_ref"] != "s3":
raise ValueError(f"Unknown content reference type: {content['_ref']}")
s3 = _get_s3_client()
if s3 is None:
raise RuntimeError("S3 client not configured but trace references S3 storage")
response = s3.get_object(Bucket=content["bucket"], Key=content["key"])
compressed = response["Body"].read()
decompressed = gzip.decompress(compressed)
return json.loads(decompressed)
async def save_reasoning_trace(
db: AsyncSession,
*,
workspace_name: str,
session_name: str | None,
observer: str,
observed: str,
agent_type: str,
task_type: str,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int = 0,
cache_read_input_tokens: int = 0,
duration_ms: float | None = None,
iteration_count: int = 1,
content: dict[str, Any],
observation_ids: list[str] | None = None,
message_ids: list[str] | None = None,
) -> str | None:
"""
Save a reasoning trace to the database.
Handles size-based tiering: if the serialized content exceeds the
configured threshold, it is externalized to S3 and a reference
is stored in the JSONB column instead.
Returns:
The trace ID if storage is enabled, None otherwise.
"""
if not settings.TRACE.ENABLED:
return None
trace_id = generate_nanoid()
# Calculate content size
content_bytes = json.dumps(content).encode("utf-8")
content_size = len(content_bytes)
# Externalize if over threshold
stored_content = content
if content_size >= settings.TRACE.EXTERNALIZE_THRESHOLD_BYTES:
stored_content, content_size = _externalize_content(
content, workspace_name, trace_id
)
trace = ReasoningTrace(
id=trace_id,
workspace_name=workspace_name,
session_name=session_name,
observer=observer,
observed=observed,
agent_type=agent_type,
task_type=task_type,
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
duration_ms=duration_ms,
iteration_count=iteration_count,
content=stored_content,
content_size_bytes=content_size,
observation_ids=observation_ids or [],
message_ids=message_ids or [],
)
db.add(trace)
await db.commit()
return trace_id
async def link_trace_to_observations(
db: AsyncSession,
trace_id: str,
observation_ids: list[str],
) -> None:
"""
Update a trace's observation_ids after observations are created.
This is called after the deriver creates observations, since the trace
is persisted before the observations exist.
"""
if not settings.TRACE.ENABLED or not trace_id or not observation_ids:
return
stmt = (
update(ReasoningTrace)
.where(ReasoningTrace.id == trace_id)
.values(observation_ids=observation_ids)
)
await db.execute(stmt)
await db.commit()3.6 Retention Policy
3.6.1 Policy Types
| Policy | Behavior | Configuration |
|---|---|---|
indefinite | Traces are never automatically deleted | Default |
time_based | Traces older than N days are deleted | TRACE_RETENTION_DAYS=90 |
count_based | Keep at most N traces per workspace; delete oldest first | TRACE_RETENTION_MAX_PER_WORKSPACE=10000 |
3.6.2 Cleanup Job
The cleanup job runs as a reconciler task, reusing the existing queue infrastructure. It is enqueued periodically by the deriver process (which already runs the reconciler).
Add to src/telemetry/trace_storage.py:
async def cleanup_expired_traces(db: AsyncSession) -> int:
"""
Delete traces according to the configured retention policy.
Returns the number of traces deleted.
"""
trace_settings = settings.TRACE
if not trace_settings.ENABLED or trace_settings.RETENTION_POLICY == "indefinite":
return 0
deleted = 0
if trace_settings.RETENTION_POLICY == "time_based":
from datetime import datetime, timedelta, UTC
cutoff = datetime.now(UTC) - timedelta(days=trace_settings.RETENTION_DAYS)
# Find traces to delete (need to clean up S3 objects first)
stmt = (
select(ReasoningTrace)
.where(ReasoningTrace.created_at < cutoff)
.limit(1000) # Process in batches to avoid long transactions
)
result = await db.execute(stmt)
traces = result.scalars().all()
for trace in traces:
_delete_externalized_content(trace.content)
await db.delete(trace)
deleted += 1
await db.commit()
elif trace_settings.RETENTION_POLICY == "count_based":
# For each workspace, keep only the most recent N traces
from sqlalchemy import func, text
# Get workspaces that exceed the limit
count_stmt = (
select(
ReasoningTrace.workspace_name,
func.count().label("trace_count"),
)
.group_by(ReasoningTrace.workspace_name)
.having(func.count() > trace_settings.RETENTION_MAX_PER_WORKSPACE)
)
result = await db.execute(count_stmt)
over_limit = result.all()
for workspace_name, count in over_limit:
excess = count - trace_settings.RETENTION_MAX_PER_WORKSPACE
# Delete oldest traces for this workspace
delete_stmt = text(f"""
DELETE FROM {settings.DB.SCHEMA}.reasoning_traces
WHERE id IN (
SELECT id FROM {settings.DB.SCHEMA}.reasoning_traces
WHERE workspace_name = :workspace_name
ORDER BY created_at ASC
LIMIT :excess
)
""")
await db.execute(
delete_stmt,
{"workspace_name": workspace_name, "excess": excess},
)
deleted += excess
await db.commit()
if deleted > 0:
logger.info("Cleaned up %d expired reasoning traces", deleted)
return deleted
def _delete_externalized_content(content: dict[str, Any]) -> None:
"""Delete externalized content from S3 if applicable."""
if not isinstance(content, dict) or content.get("_ref") != "s3":
return
s3 = _get_s3_client()
if s3 is None:
return
try:
s3.delete_object(Bucket=content["bucket"], Key=content["key"])
except Exception as e:
logger.warning("Failed to delete S3 object %s: %s", content.get("key"), e)3.6.3 Reconciler Integration
Add trace_cleanup to the ReconcilerType enum in src/schemas/internal.py:
class ReconcilerType(str, Enum):
SYNC_VECTORS = "sync_vectors"
CLEANUP_QUEUE = "cleanup_queue"
CLEANUP_TRACES = "cleanup_traces" # NEWThe deriver’s reconciler loop enqueues a cleanup_traces task every TRACE_CLEANUP_INTERVAL_HOURS hours. The task handler calls cleanup_expired_traces().
3.7 CloudEvents
3.7.1 New Event Type
Add src/telemetry/events/trace.py:
"""CloudEvents for reasoning trace operations."""
from pydantic import Field
from src.telemetry.events.base import BaseEvent
class TraceStoredEvent(BaseEvent):
"""Emitted when a reasoning trace is stored to the database."""
_event_type = "honcho.work.trace.stored"
_schema_version = 1
_category = "work"
workspace_name: str
trace_id: str
agent_type: str
task_type: str
provider: str
model: str
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int = 0
cache_read_input_tokens: int = 0
content_size_bytes: int
externalized: bool = Field(
default=False,
description="Whether content was externalized to S3",
)
observation_count: int = Field(
default=0,
description="Number of observations produced by this trace",
)
def get_resource_id(self) -> str:
return f"{self.workspace_name}:{self.trace_id}"Register in src/telemetry/events/__init__.py:
from src.telemetry.events.trace import TraceStoredEvent
__all__ = [
...
"TraceStoredEvent",
]3.7.2 Event Emission
In save_reasoning_trace(), emit the event after successful database commit:
from src.telemetry.events import TraceStoredEvent, emit
# After db.commit():
emit(
TraceStoredEvent(
workspace_name=workspace_name,
trace_id=trace_id,
agent_type=agent_type,
task_type=task_type,
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
content_size_bytes=content_size,
externalized=stored_content is not content,
observation_count=len(observation_ids or []),
)
)This event flows through Xatu for billing: each trace stored counts as a billable operation, and the token counts allow per-token billing if desired.
4. Migration Plan
4.1 Alembic Migration
Create migrations/versions/XXXX_add_reasoning_traces_table.py:
"""add reasoning_traces table
Revision ID: <auto-generated>
Revises: <current-head>
Create Date: 2026-XX-XX
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from migrations.utils import table_exists, index_exists
from src.config import settings
revision: str = "<auto-generated>"
down_revision: str | None = "<current-head>"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = settings.DB.SCHEMA
def upgrade() -> None:
op.create_table(
"reasoning_traces",
sa.Column("id", sa.TEXT(), primary_key=True, nullable=False),
sa.Column(
"workspace_name",
sa.TEXT(),
sa.ForeignKey(f"{schema}.workspaces.name"),
nullable=False,
),
sa.Column("session_name", sa.TEXT(), nullable=True),
sa.Column("observer", sa.TEXT(), nullable=False),
sa.Column("observed", sa.TEXT(), nullable=False),
sa.Column("agent_type", sa.TEXT(), nullable=False),
sa.Column("task_type", sa.TEXT(), nullable=False),
sa.Column("provider", sa.TEXT(), nullable=False),
sa.Column("model", sa.TEXT(), nullable=False),
sa.Column(
"input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"cache_creation_input_tokens",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"cache_read_input_tokens",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column("duration_ms", sa.Float(), nullable=True),
sa.Column(
"iteration_count",
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column(
"content",
sa.dialects.postgresql.JSONB(),
nullable=False,
),
sa.Column(
"content_size_bytes",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"observation_ids",
sa.dialects.postgresql.JSONB(),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
sa.Column(
"message_ids",
sa.dialects.postgresql.JSONB(),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
# Check constraints
sa.CheckConstraint("length(id) = 21", name="ck_reasoning_traces_id_length"),
sa.CheckConstraint(
"id ~ '^[A-Za-z0-9_-]+$'", name="ck_reasoning_traces_id_format"
),
sa.CheckConstraint(
"agent_type IN ('deriver', 'dialectic', 'dreamer_deduction', 'dreamer_induction', 'summarizer')",
name="ck_reasoning_traces_agent_type_valid",
),
sa.CheckConstraint(
"content_size_bytes >= 0",
name="ck_reasoning_traces_content_size_non_negative",
),
# Composite foreign keys
sa.ForeignKeyConstraint(
["observer", "workspace_name"],
[f"{schema}.peers.name", f"{schema}.peers.workspace_name"],
name="fk_reasoning_traces_observer",
),
sa.ForeignKeyConstraint(
["observed", "workspace_name"],
[f"{schema}.peers.name", f"{schema}.peers.workspace_name"],
name="fk_reasoning_traces_observed",
),
sa.ForeignKeyConstraint(
["session_name", "workspace_name"],
[f"{schema}.sessions.name", f"{schema}.sessions.workspace_name"],
name="fk_reasoning_traces_session",
),
schema=schema,
)
# Indexes
op.create_index(
"ix_reasoning_traces_workspace_name",
"reasoning_traces",
["workspace_name"],
schema=schema,
)
op.create_index(
"ix_reasoning_traces_created_at",
"reasoning_traces",
["created_at"],
schema=schema,
)
op.create_index(
"ix_reasoning_traces_agent_type",
"reasoning_traces",
["agent_type"],
schema=schema,
)
op.create_index(
"ix_reasoning_traces_observer_observed",
"reasoning_traces",
["observer", "observed"],
schema=schema,
)
op.create_index(
"ix_reasoning_traces_session_name",
"reasoning_traces",
["session_name"],
schema=schema,
postgresql_where=sa.text("session_name IS NOT NULL"),
)
op.create_index(
"ix_reasoning_traces_observation_ids_gin",
"reasoning_traces",
["observation_ids"],
schema=schema,
postgresql_using="gin",
)
op.create_index(
"ix_reasoning_traces_message_ids_gin",
"reasoning_traces",
["message_ids"],
schema=schema,
postgresql_using="gin",
)
op.create_index(
"ix_reasoning_traces_workspace_agent_created",
"reasoning_traces",
["workspace_name", "agent_type", sa.text("created_at DESC")],
schema=schema,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if table_exists("reasoning_traces", inspector):
# Drop all indexes first
for idx_name in [
"ix_reasoning_traces_workspace_name",
"ix_reasoning_traces_created_at",
"ix_reasoning_traces_agent_type",
"ix_reasoning_traces_observer_observed",
"ix_reasoning_traces_session_name",
"ix_reasoning_traces_observation_ids_gin",
"ix_reasoning_traces_message_ids_gin",
"ix_reasoning_traces_workspace_agent_created",
]:
if index_exists("reasoning_traces", idx_name, inspector):
op.drop_index(idx_name, table_name="reasoning_traces", schema=schema)
op.drop_table("reasoning_traces", schema=schema)4.2 Zero-Downtime Deployment
The migration is purely additive (new table, no existing table changes). It can be run with the existing application version still serving traffic. The feature is gated behind TRACE_ENABLED=false by default, so the migration can be deployed before the application code that writes traces.
The only change to existing tables is adding trace_id to DocumentMetadata (the internal_metadata JSONB on documents). This is a Pydantic schema change, not a database column change — the field is simply written into the existing JSONB column when present. Old documents without trace_id continue to work because the field defaults to None.
5. Implementation Phases
Phase 1: Database Storage (Foundation)
Scope: Replace JSONL logging with PostgreSQL storage. No API, no agent tools, no S3.
Files to modify:
| File | Change |
|---|---|
src/models.py | Add ReasoningTrace model |
src/config.py | Add TraceStorageSettings, add to AppSettings and SECTION_MAP |
src/telemetry/trace_storage.py | New file: save_reasoning_trace(), link_trace_to_observations() (S3 stubs that store inline) |
src/telemetry/reasoning_traces.py | Modify log_reasoning_trace() to call save_reasoning_trace() in addition to JSONL |
src/schemas/internal.py | Add trace_id field to DocumentMetadata |
migrations/versions/XXXX_add_reasoning_traces_table.py | New file: Migration |
Deliverables:
- Traces written to database when
TRACE_ENABLED=true - JSONL logging still works as before (dual-write)
trace_idnot yet populated on documents (no provenance linkage yet)
Phase 2: Provenance Linking
Scope: Wire up the Message → Trace → Observation provenance chain in all agent paths.
Files to modify:
| File | Change |
|---|---|
src/utils/clients.py | Add trace_context parameter to honcho_llm_call(): workspace_name, observer, observed, session_name, agent_type. After logging trace, return trace_id on the response object. |
src/deriver/deriver.py | After honcho_llm_call(), capture trace_id. Pass to RepresentationManager.save_representation(). After observations are created, call link_trace_to_observations(). |
src/crud/representation.py | Accept trace_id parameter in save_representation() and _create_documents(). Set trace_id in DocumentMetadata. |
src/dialectic/core.py | After honcho_llm_call() completes, save trace with observation IDs accumulated by tool executor. |
src/dreamer/specialists.py | After specialist honcho_llm_call() completes, save trace with observation IDs accumulated by tool executor. |
src/utils/summarizer.py | After summarizer honcho_llm_call(), save trace (summarizer traces have no observation_ids). |
src/utils/agent_tools.py | Add accumulated_observation_ids: list[str] to ToolContext. Append created document IDs in _handle_create_observations(). |
Key integration pattern for honcho_llm_call():
The trace context is passed alongside existing parameters. A new TraceContext dataclass bundles the fields needed to write a trace:
@dataclass
class TraceContext:
"""Context for saving a reasoning trace after an LLM call."""
workspace_name: str
session_name: str | None
observer: str
observed: str
agent_type: str
# In honcho_llm_call, after the call completes:
# trace_id is set on the HonchoLLMCallResponse as a new optional fieldAdd to HonchoLLMCallResponse:
class HonchoLLMCallResponse(BaseModel, Generic[T]):
...
trace_id: str | None = None # Set when trace storage is enabledDeliverables:
- All LLM calls produce traces with correct context
- Deriver traces linked to their observations (bidirectional)
- Dialectic/Dreamer traces linked to observations created by tools
- Summarizer traces stored (no observation linkage needed)
- Documents have
trace_idininternal_metadata
Phase 3: API & Agent Tools
Scope: Developer-facing API and agent tool integration.
Files to create/modify:
| File | Change |
|---|---|
src/routers/traces.py | New file: API endpoints |
src/schemas/api.py | Add TraceListFilters, TraceListRequest, TraceSummary, TraceDetail |
src/schemas/__init__.py | Re-export new schemas |
src/crud/trace.py | New file: CRUD operations for traces |
src/crud/__init__.py | Export new functions |
src/main.py | Register traces router |
src/utils/agent_tools.py | Add get_reasoning_trace tool, handler, and register in tool sets |
CRUD operations in src/crud/trace.py:
async def get_trace(
db: AsyncSession,
workspace_name: str,
trace_id: str,
) -> ReasoningTrace | None:
"""Get a single trace by ID."""
...
async def list_traces(
db: AsyncSession,
workspace_name: str,
*,
agent_type: str | None = None,
task_type: str | None = None,
observer: str | None = None,
observed: str | None = None,
session_name: str | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
) -> Select:
"""Build a query for listing traces with filters."""
...
async def get_trace_for_observation(
db: AsyncSession,
workspace_name: str,
observation_id: str,
) -> ReasoningTrace | None:
"""Get the trace that produced a specific observation."""
# First try internal_metadata.trace_id on the document
# Fall back to GIN index query on reasoning_traces.observation_ids
...
async def get_traces_for_message(
db: AsyncSession,
workspace_name: str,
message_public_id: str,
) -> list[ReasoningTrace]:
"""Get all traces triggered by a specific message."""
...Deliverables:
- All API endpoints functional with pagination
- Agent tools available to dialectic and dreamer
- Full provenance chain queryable via API
Phase 4: S3 Tiering & Retention
Scope: S3 externalization for large traces, retention policy enforcement.
Files to modify:
| File | Change |
|---|---|
src/telemetry/trace_storage.py | Implement _externalize_content(), fetch_externalized_content(), cleanup_expired_traces(), _delete_externalized_content() |
src/routers/traces.py | Transparently fetch externalized content in detail endpoints |
src/reconciler/ | Add cleanup_traces task handler |
src/schemas/internal.py | Add CLEANUP_TRACES to ReconcilerType |
src/telemetry/events/trace.py | New file: TraceStoredEvent CloudEvent |
src/telemetry/events/__init__.py | Register new event |
pyproject.toml | Add boto3 dependency (for S3) |
Deliverables:
- Large traces externalized to S3
- Retention policies enforced by background job
- CloudEvents emitted for billing
6. Files to Modify (Complete Index)
New Files
| Path | Purpose |
|---|---|
src/telemetry/trace_storage.py | Trace storage backend (save, link, fetch, cleanup, S3) |
src/routers/traces.py | API router for trace endpoints |
src/crud/trace.py | CRUD operations for traces |
src/telemetry/events/trace.py | CloudEvents event type for trace operations |
migrations/versions/XXXX_add_reasoning_traces_table.py | Database migration |
Modified Files
| Path | Change Summary |
|---|---|
src/models.py | Add ReasoningTrace model class |
src/config.py | Add TraceStorageSettings class, add to AppSettings, add to SECTION_MAP |
src/schemas/api.py | Add TraceListFilters, TraceListRequest, TraceSummary, TraceDetail |
src/schemas/internal.py | Add trace_id field to DocumentMetadata; add CLEANUP_TRACES to ReconcilerType |
src/schemas/__init__.py | Re-export new schemas |
src/main.py | Register traces.router with /v3 prefix |
src/telemetry/reasoning_traces.py | Add DB write path alongside JSONL; accept and forward trace context |
src/telemetry/events/__init__.py | Import and export TraceStoredEvent |
src/utils/clients.py | Add `trace_context: TraceContext |
src/utils/agent_tools.py | Add get_reasoning_trace tool definition, handler, and dispatch; add accumulated_observation_ids to ToolContext; update _handle_create_observations to accumulate IDs; add tool to DIALECTIC_TOOLS, DEDUCTION_SPECIALIST_TOOLS, INDUCTION_SPECIALIST_TOOLS |
src/deriver/deriver.py | Pass trace context to honcho_llm_call(); capture trace_id; pass to save_representation(); call link_trace_to_observations() |
src/crud/representation.py | Accept trace_id in save_representation() and _create_documents(); set trace_id in DocumentMetadata |
src/dialectic/core.py | Save trace after honcho_llm_call() with accumulated observation IDs from tool executor |
src/dreamer/specialists.py | Save trace after specialist honcho_llm_call() with accumulated observation IDs |
src/utils/summarizer.py | Save trace after summarizer LLM call |
src/crud/__init__.py | Export new trace CRUD functions |
pyproject.toml | Add boto3 as optional dependency (for S3 tiering) |
7. Risk Assessment
7.1 Database Size Growth
Risk: Traces are written for every LLM call. A busy workspace might produce thousands of traces per day.
Mitigation:
- Traces are opt-in (
TRACE_ENABLED=falseby default). - Large content externalized to S3 (cheap, unbounded storage).
- Configurable retention with time-based and count-based pruning.
- The list API excludes
contentto avoid accidental large reads. - Monitoring: the
content_size_bytescolumn allows easy reporting on storage consumption.
Estimate: A typical deriver trace is ~10KB of JSONB. At 100 traces/day/workspace, that is ~1MB/day/workspace inline, or ~30MB/month. For managed SaaS with 1000 workspaces, that is ~30GB/month before retention kicks in. With 90-day retention, steady-state is ~90GB — manageable for PostgreSQL with proper vacuuming.
7.2 Write Latency
Risk: Adding a database write after every LLM call increases latency in the deriver pipeline.
Mitigation:
- The trace write is a single INSERT with no joins or subqueries.
- The
link_trace_to_observations()UPDATE is a simple indexed update. - Both operations are fast (sub-millisecond for typical sizes).
- S3 writes happen synchronously but only for traces >50KB (rare for deriver, occasional for dreamer).
- If latency becomes an issue, trace writes can be made asynchronous via a background task queue.
7.3 S3 Dependency
Risk: S3 adds a cloud dependency that may not be available in all self-hosted deployments.
Mitigation:
- S3 is entirely optional. Without S3 configuration, all traces are stored inline.
- The
S3_ENDPOINT_URLsetting supports S3-compatible stores (MinIO, Cloudflare R2, Backblaze B2). - A warning is logged when traces exceed the threshold but S3 is not configured.
7.4 Schema Migration
Risk: Adding a new table with foreign keys to workspaces, peers, and sessions requires those tables to exist.
Mitigation:
- The migration only adds a new table — no existing table modifications.
- Foreign keys reference existing tables and columns with well-established data.
- The migration is idempotent (uses
table_existsguard in downgrade). - Zero-downtime: the table can be created while the old code is still running; the old code simply does not write to it.
7.5 Backward Compatibility
Risk: Changing log_reasoning_trace() could break existing JSONL workflows.
Mitigation:
- The JSONL path is preserved. The function writes to JSONL first (if configured), then to the database.
- The
TRACE_ENABLEDflag is independent ofREASONING_TRACES_FILE. Both can be active simultaneously. - New parameters use keyword-only arguments with defaults, so existing callers are unaffected.
8. Verification Plan
8.1 Unit Tests
| Test | File | What it validates |
|---|---|---|
test_save_reasoning_trace | tests/test_trace_storage.py | Trace saved to DB with correct fields |
test_save_trace_disabled | tests/test_trace_storage.py | Returns None when TRACE_ENABLED=false |
test_link_trace_to_observations | tests/test_trace_storage.py | observation_ids updated after linking |
test_externalize_threshold | tests/test_trace_storage.py | Content externalized when over threshold |
test_externalize_no_s3 | tests/test_trace_storage.py | Inline storage with warning when S3 not configured |
test_fetch_externalized_content | tests/test_trace_storage.py | S3 content fetched and decompressed correctly |
test_cleanup_time_based | tests/test_trace_storage.py | Old traces deleted by time-based retention |
test_cleanup_count_based | tests/test_trace_storage.py | Excess traces deleted per workspace |
test_cleanup_indefinite | tests/test_trace_storage.py | No deletion when policy is indefinite |
8.2 Integration Tests
| Test | File | What it validates |
|---|---|---|
test_deriver_produces_trace | tests/test_trace_integration.py | Deriver LLM call creates a trace with correct agent_type, message_ids |
test_deriver_trace_linked_to_observations | tests/test_trace_integration.py | After deriver runs, trace.observation_ids contains created document IDs and documents have trace_id in internal_metadata |
test_dialectic_produces_trace | tests/test_trace_integration.py | Dialectic chat creates a trace with tool call data |
test_dreamer_produces_trace | tests/test_trace_integration.py | Dreamer specialist creates a trace with correct specialist type |
test_summarizer_produces_trace | tests/test_trace_integration.py | Summarizer creates a trace without observation_ids |
8.3 API Tests
| Test | File | What it validates |
|---|---|---|
test_list_traces | tests/test_trace_api.py | Paginated list with filters returns correct results |
test_list_traces_agent_type_filter | tests/test_trace_api.py | Filtering by agent_type works |
test_list_traces_date_range | tests/test_trace_api.py | created_after/created_before filtering |
test_get_trace_detail | tests/test_trace_api.py | Full trace returned with content |
test_get_trace_404 | tests/test_trace_api.py | 404 for non-existent trace |
test_get_trace_for_conclusion | tests/test_trace_api.py | Conclusion → trace lookup works |
test_get_traces_for_message | tests/test_trace_api.py | Message → traces lookup works |
test_trace_content_excluded_from_list | tests/test_trace_api.py | List endpoint does not include content field |
8.4 Manual Verification
- Deploy with
TRACE_ENABLED=trueon a staging instance. - Send messages via the API and verify traces appear in the database.
- Use the dialectic API and verify traces include tool calls and iteration data.
- Trigger a dream and verify specialist traces are created.
- Query the
/conclusions/{id}/traceendpoint and verify the provenance chain is complete. - Verify that
REASONING_TRACES_FILEstill produces JSONL output when configured. - For S3: configure a MinIO instance, send a large multi-turn dialectic query, verify the trace content is externalized and retrievable.
9. Open Questions
Q1: Should the message_ids on the trace use public_id (nanoid) or internal id (bigint)?
Recommendation: Use public_id (nanoid strings). This is what the API exposes, what DocumentMetadata.message_ids currently stores as internal IDs (which should arguably be migrated), and what developers will use when querying. The GIN index on message_ids will use JSONB containment which works with string arrays.
Note: The existing DocumentMetadata.message_ids field uses internal integer IDs. This is an existing inconsistency that should be addressed separately. For traces, we use public IDs from the start for API consistency.
Q2: Should traces capture the full message history for multi-turn agents?
The full message history can be very large (100KB+ for long dialectic sessions). The current log_reasoning_trace() captures it. Recommendation: Capture it in the content.input.messages field. The S3 tiering handles the size concern. For the get_reasoning_trace agent tool, only the thinking content and tool calls are surfaced (not the full message history), keeping the tool output concise.
Q3: Should the agent_type CHECK constraint be enforced at the database level?
Recommendation: Yes, for data integrity. The set of agent types is small and stable. If a new agent type is added, a migration adds it to the constraint. This prevents garbage data from entering the table.
Q4: Should trace creation be synchronous or asynchronous?
Phase 1-3: Synchronous (write in the same request/task context). This is simpler, ensures traces are available immediately for provenance linking, and the latency impact is minimal (single INSERT).
If profiling in production shows trace writes adding unacceptable latency, Phase 5 can introduce an async write path using the existing queue infrastructure.
Q5: boto3 dependency — should it be optional?
Recommendation: Yes. Add it as an optional dependency group: pip install honcho[s3] or uv sync --extra s3. The S3 client initialization checks for the import and logs a clear error if S3 is configured but boto3 is not installed. This avoids adding a heavy dependency for deployments that do not need S3.
# pyproject.toml
[project.optional-dependencies]
s3 = ["boto3>=1.34"]Q6: Should we add a run_id column for correlating traces across a multi-step operation?
For example, a dream run produces two specialist traces (deduction + induction). Today these are correlated via the DreamRunEvent.run_id in CloudEvents. Adding a run_id TEXT column (nullable) to reasoning_traces would allow easy correlation queries: “show me all traces from this dream run.” This is low-cost and high-value. Recommendation: add it in Phase 2.
-- Additional column
run_id TEXT, -- Nullable. Correlates traces within a multi-step operation (dream run, etc.)# Additional index
Index("ix_reasoning_traces_run_id", "run_id", postgresql_where=text("run_id IS NOT NULL")),