Skip to content

fidelity/thread_id: silent model substitution, empty first-entry preamble, and missing reuse observability #642

Description

@brynary

Found while debugging a run whose implementation stage stopped after 2 of 9 units. Chasing "what was actually in that agent's context" surfaced five separate problems in how fidelity and thread_id resolve. Each item below is backed by a real run or by the code path, not inference.

Related: #639 removes per-stage token accounting from the agent-facing preamble — the root cause of that stall, and the reason item 4 matters.


1. Thread reuse silently overrides the node's model

Session reuse is keyed on thread_id alone, and create_session is skipped entirely on a cache hit (lib/components/fabro-workflow/src/handler/llm/api.rs:1194-1229). A node joining an existing thread therefore inherits the original session's model, provider, profile, and system prompt — silently discarding its own model attribute.

Reproduced in run 01KYCTZKHKPR54YPQVQ94YKN5R:

first  [model="gpt-terra", thread_id="shared", fidelity="full"]
second [model="kimi-k3",   thread_id="shared", fidelity="full"]

Both stages ran in session 4f59215e as gpt-5.6-terra. The second node declared Kimi and never touched it. agent.llm.started reported requested_model: gpt-5.6-terra, and billing agreed — so the run record sides with the session and the graph is simply wrong about what executed. No warning is emitted anywhere.

Suggested fix. This is statically checkable: a fabro-validate rule in the shape of the existing rules/thread_id_requires_fidelity_full.rs requiring that nodes sharing a thread_id agree on model, provider, and reasoning_effort. A runtime warning event when a reused session's model differs from the node's resolved model would backstop the dynamic cases (model stylesheets).

2. full fidelity on first entry gives the agent no context at all

full means "no preamble, continue the conversation." On the first node of a thread there is no conversation, so the agent receives nothing — not the goal, not the run id, not prior stage outcomes. Just the raw node prompt.

In run 01KYCVAVQ5TDGS31QZAHWCGSKH, the stage.prompt event for visit 1 is byte-identical to the node's prompt attribute. Nothing was prepended.

This makes full deliver strictly less context than truncate, which at least supplies goal and run id. The documented canonical example has the bug — in cluster_impl { node [thread_id="impl", fidelity="full"] } the first node (plan) starts blind unless the author hand-wires {{ goal }}.

Suggested fix. On first entry into a thread (no session to resume), fall back to default_fidelity, or to compact. Skip the preamble only on continuation. That keeps the intent — don't repeat what's already in the conversation — without the inversion.

3. No way to continue a session and learn what happened elsewhere

fidelity="full" is all-or-nothing: reuse the thread and hear nothing about intervening stages, or take a preamble and lose the thread. Any loop where another stage does work in between gets neither of the things it needs.

Concrete shape that hits this — an implementation stage that loops through a repair stage:

implement -> unblock [condition="outcome=partially_succeeded"]
unblock   -> implement

With implement threaded, its second visit resumes mid-thought but cannot see anything unblock did or decided, including that stage's context_updates. Information transfer has to route through files on disk, which works but makes the graph's edges misleading about what actually flows along them.

Suggested fix. A full:delta mode: reuse the session, and inject a preamble covering only stages completed since this thread's last turn. The required state already exists — thread.<tid>.current_node records where the thread last was, and completed_nodes / node_outcomes supply the rest.

More design work than the others; filing it here because it is the gap that changes what is expressible.

4. Fidelity and thread resolution are invisible in the run record

Whether a session was reused survives only as tracing::info!(reused = is_reused) — a server log line. From the run projection it can be inferred only from the absence of an agent.session.started event.

Meanwhile three degradations happen silently:

  • resume from a checkpoint degrades full to summary:high
  • a parallel branch degrades explicit full to summary:high, and branch-level thread_id goes inert
  • a stage that errors drops its cached session, so the next visit is cold

Suggested fix. Record resolved fidelity, resolved thread id, and the session-reuse decision on the stage record — including a reason when reuse was expected but did not happen. The investigation behind this issue was entirely "what was in this agent's context," and answering it required reading raw stage.prompt events. That should be a field.

5. Two of thread_id's implicit resolution rungs are surprising

resolve_thread_id (lib/components/fabro-workflow/src/lifecycle/fidelity.rs:364-385) falls through five rungs. The last two are hazards.

Rung 4 — the node's first class becomes the thread id. Classes are the model-stylesheet selector mechanism. A class="coding" node at full fidelity silently shares one session with every other .coding node in the graph. Two orthogonal concerns sharing a namespace — and combined with item 1, sharing a model too.

Rung 5 — fallback to the previous node's id. This populates internal.thread_id and thread.<id>.current_node in every checkpoint of every run, even when threading is entirely unused. It is inert, since reuse requires full, but it reads exactly like a real thread and cost real time to rule out while debugging.

Suggested fix. Do not populate thread keys when fidelity != full. Reconsider the class rung, or require opt-in through default_thread.


Suggested priority

# Item Size Why
1 Model substitution on reuse small fails silently, produces a wrong run rather than an error
2 Empty first-entry preamble small same, and the documented example demonstrates the bug
4 Reuse/fidelity observability medium, mostly plumbing highest leverage for debugging
5 Implicit thread rungs small removes a trap and per-checkpoint noise
3 full:delta needs design the only item that changes what is expressible

1 and 2 are pure bug fixes and would each want a test: a validation-rule test for 1, and a preamble test for 2 asserting first-entry-to-thread receives the default preamble while a continuation receives none.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions