Skip to content

fix(convo): attribute harness-generated turns to the harness actor - #165

Open
akesling wants to merge 1 commit into
mainfrom
akesling/harness-generated-turns
Open

fix(convo): attribute harness-generated turns to the harness actor#165
akesling wants to merge 1 commit into
mainfrom
akesling/harness-generated-turns

Conversation

@akesling

@akesling akesling commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

A deriver bug fix. Independent of the kind work in #164 / #132 — no schema change, no kind version involved.

The bug

A harness sometimes emits an assistant message itself: an API error, a rate-limit notice, a timeout. No model call happens; the accounting is all zeros. Claude Code records a placeholder in message.model for these, and the deriver took that placeholder at face value, producing agent:<placeholder> actors and matching meta.actors entries.

The actor convention never meant that. The agent-coding-session actor table has always defined agent:<model> as "a model reply, named by the recorded model" — a harness notice is not a model reply, and a placeholder is not a recorded model. So this is the deriver contradicting the spec that was already written, not a change to it.

These are not rare: sampling real Claude Code sessions, 14 of 40 contained at least one.

The fix

Turn gains a provider-agnostic harness_generated flag (serde-defaulted, so the wire format is unchanged when false). The shared deriver attributes such turns to tool:<provider> — the same harness actor that system and other roles already take — while the turn's role stays assistant, so the message keeps its place in the transcript.

The placeholder spelling stays where the format knowledge belongs: toolpath-claude owns the constant and maps it to the flag with model: None, so the string never enters the IR. Any other provider can set the flag from whatever signal its own format offers without touching shared code. Repo-wide, the placeholder is now spelled in exactly one file.

agent:unknown is unaffected — "no model recorded" and "no model involved" stay distinct outcomes.

Testing

Full workspace suite, clippy, rustdoc, and the examples gate green. Coverage: a harness-generated turn takes the harness actor with the role preserved and a meta.actors entry carrying provider but no model; a flagged turn's stray model is ignored; an ordinary assistant turn is untouched; a turn with no recorded model still derives as agent:unknown; and the flag round-trips on the wire in both directions.

Versioning follows the deriver-behavior-fix precedent (Derive: resolve duplicate step ids, 2026-07-01): toolpath-convo takes a minor bump because a new public field on Turn breaks struct-literal construction downstream; toolpath-claude takes a patch, its public API being unchanged. No CLI bump — no kind URI changes here.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🔍 Preview deployed: https://9ea5d90c.toolpath.pages.dev

@akesling
akesling force-pushed the akesling/harness-generated-turns branch 3 times, most recently from f8bc127 to b2ca04c Compare August 10, 2026 20:19
…e deriver that defines it

Attribution used to be assembled from a turn's role plus a model name,
which broke for messages a harness writes itself — API errors,
rate-limit notices, timeouts — occupying the assistant slot with no
model call behind them. Where a harness records a placeholder in place
of the model, `derive_path` took it at face value and produced an
`agent:` actor naming a string that is not a model, and `meta.actors`
described the harness as if it were one.

Fixing that needs a type for the actor reference, and that type belongs
to whoever defines the shape. The base format does not: `step.actor` is
an opaque string, and `toolpath` neither parses nor validates it. The
agent-coding-session kind is what constrains an actor to `prefix:id`, so
the type expressing that constraint lives in `toolpath_convo::actor`,
next to the conventions built on it. Producers of other kinds of path —
`toolpath-git` and friends — keep naming actors by their own convention,
unenforced and with no dependency on this crate. `toolpath` is untouched.

`Actor` is a prefix and an id, each non-empty and drawn from
`[A-Za-z0-9_.-]`. Within that the prefix set is open: `human`, `agent`
and `tool` are the prefixes this deriver attributes turns to, not a
closed vocabulary, so `ci:github-actions` and `bot:dependabot` are
equally valid references and the type privileges none of them.
`Actor::new` validates both segments; `Display` renders the document
form and `FromStr` reads it, and serde uses them, so an `Actor` on the
wire is the actor string and every valid suffix-free reference
round-trips unchanged. The `/`-delimited sub-actor suffix is split off
and dropped on parse, as before; `Actor::split_sub_actor` exposes the
split for callers that need it.

The same module holds the conventions: the `human:user` placeholder this
deriver emits for an unnamed person, and the `agent:unknown` placeholder
the agent-coding-session kind spec defines for "a model ran, unnamed".
Its constructors are total — a name the grammar cannot carry is no name
at all and falls back to the placeholder, which keeps every derived
actor string renderable and valid against the base schema whatever a
session file holds.

`Turn.author` is that type and replaces `Turn.model`. `role` keeps its
own meaning — where the turn sits in the conversation — and attribution
no longer consults it: `derive_path` renders the author, and
`extract_conversation` parses the actor back, so derive → extract →
derive is stable and the grammar has exactly one implementation.
Harness-authored turns now take `tool:<provider>`. `agent:unknown` still
means "a model ran, unnamed", distinct from "no model was involved", and
each provider supplies its own provider id when it builds a
harness-authored turn.

`meta.actors` records a `provider` only for the actors this derivation
mints itself; an actor the source supplied under some other prefix is
described by name alone, since the deriver cannot know where it came
from.

Because the prefix set is open, `extract_conversation` reads an actor
whose prefix this crate has no convention for back as itself instead of
collapsing it to `agent:unknown`. Roles are unaffected: only the three
prefixes this crate attributes turns to map to a `Role`.

Turns reach disk nested in a step's `delegations` payload, so `author`
also accepts the bare model name older documents carry in its place — the
two are told apart by parsing, since a model name is not a valid actor
reference.
@akesling
akesling force-pushed the akesling/harness-generated-turns branch from b2ca04c to 769fbf4 Compare August 11, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants