From b2ca04c2b88e9ac7efc28e0c4bea1fb4be41997d Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 6 Aug 2026 14:21:50 -0400 Subject: [PATCH] feat(toolpath): give the actor grammar a type, and turns an author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `step.actor` is a grammar — `human:alex`, `agent:gpt-5.5`, `tool:rustfmt` — and nothing owned it. `toolpath-convo` built the string with `format!` and read it back with a hand-rolled parser; other crates re-derived pieces of it with `starts_with`. `toolpath`, which defines `step.actor` and `meta.actors`, had no actor type at all. Add `toolpath::v1::Actor`: a prefix and an id, each non-empty and drawn from `[A-Za-z0-9_.-]`, and nothing else. The prefix set is open — `human`, `agent`, `tool` and `ci` are conventions the format's users adopt, not a vocabulary the format enforces, so `bot:dependabot` is as much an actor reference as `human:alex` 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. What a prefix means is not the base format's business, so the conventions live with the code that holds them. `toolpath_convo::actor` owns the `human:`/`agent:`/`tool:` prefixes, 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. That fixes a misattribution. Attribution used to be assembled from role plus 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. Those turns now take `tool:`. `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. 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. --- CHANGELOG.md | 103 ++++++ CLAUDE.md | 2 + Cargo.lock | 6 +- Cargo.toml | 6 +- crates/path-cli/tests/cross_harness_matrix.rs | 10 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/project.rs | 20 +- crates/toolpath-claude/src/provider.rs | 81 ++++- crates/toolpath-codex/src/project.rs | 11 +- crates/toolpath-codex/src/provider.rs | 21 +- crates/toolpath-convo/Cargo.toml | 2 +- crates/toolpath-convo/README.md | 6 +- crates/toolpath-convo/src/actor.rs | 146 +++++++++ crates/toolpath-convo/src/derive.rs | 217 +++++++++---- crates/toolpath-convo/src/extract.rs | 205 ++++++++---- crates/toolpath-convo/src/lib.rs | 57 +++- crates/toolpath-convo/src/project.rs | 13 +- crates/toolpath-copilot/src/project.rs | 16 +- crates/toolpath-copilot/src/provider.rs | 16 +- crates/toolpath-cursor/README.md | 4 +- .../toolpath-cursor/examples/dump_fixture.rs | 6 +- crates/toolpath-cursor/src/project.rs | 8 +- crates/toolpath-cursor/src/provider.rs | 10 +- .../tests/projection_roundtrip.rs | 5 +- crates/toolpath-gemini/src/project.rs | 7 +- crates/toolpath-gemini/src/provider.rs | 23 +- crates/toolpath-opencode/src/project.rs | 10 +- crates/toolpath-opencode/src/provider.rs | 13 +- crates/toolpath-pi/src/project.rs | 9 +- crates/toolpath-pi/src/provider.rs | 34 +- crates/toolpath/Cargo.toml | 2 +- crates/toolpath/src/lib.rs | 10 +- crates/toolpath/src/types.rs | 293 ++++++++++++++++++ docs/agents/formats/codex.md | 4 +- docs/agents/formats/cursor.md | 4 +- docs/agents/formats/opencode.md | 2 +- site/_data/crates.json | 6 +- 37 files changed, 1167 insertions(+), 223 deletions(-) create mode 100644 crates/toolpath-convo/src/actor.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f143f2d..64590bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,109 @@ All notable changes to the Toolpath workspace are documented here. +## The actor grammar gets a type, and turns record their author — 2026-08-06 + +Every Toolpath step names an actor: `human:alex`, `agent:gpt-5.5`, +`tool:rustfmt`. That string is a grammar, and until now nothing owned it. +`toolpath-convo` formatted it with `format!`, parsed it back with a +hand-rolled reader, and other crates re-derived pieces of it with +`starts_with` — while `toolpath`, the crate that *defines* `step.actor` +and `meta.actors`, had no actor type at all. + +Nothing enforced what the prefixes meant, and a real misattribution grew +in the gap. `toolpath-convo` built a turn's actor from its role plus a +model name: assistant plus a model name meant a model reply. That worked +until a harness wrote an assistant message itself — an API error, a +rate-limit notice, a timeout — with no model call behind it. Some +harnesses record a placeholder where the model name goes, and +`derive_path` took it at face value: the step landed on an `agent:` actor +naming a string that is not a model, and `meta.actors` described the +harness as if it were one. `agent:` never meant that. + +- **`toolpath`** (0.7.1): new `Actor` — the parsed `step.actor`. It is a + prefix and an id, each non-empty and drawn from `[A-Za-z0-9_.-]`, and + nothing else. The prefix set is **open**: `human`, `agent`, `tool` and + `ci` are conventions the format's users adopt, not a vocabulary the + format enforces, so `bot:dependabot` is as much an actor reference as + `human:alex` and this type gives none of them special meaning. + `Actor::new(prefix, id)` validates and returns `ParseActorError` on + anything the grammar cannot render back; `Display` and `FromStr` are + the only implementation of the grammar, and serde uses them, so an + `Actor` on the wire is the actor string. Every valid suffix-free + reference parses and renders back unchanged, and every `Actor` renders + a reference that parses back to itself. The `/`-delimited sub-actor + suffix (`agent:claude-code/tool:Write`, `tool:rustfmt/1.5.0`) is split + off and dropped on parse, as before; `Actor::split_sub_actor` exposes + the split for callers that need it. Additive: patch bump. +- **`toolpath-convo`** (0.12.0): `Turn.author` is an `Actor` and replaces + `Turn.model`. `role` keeps its own meaning: where the turn sits in the + conversation. The two were never the same question, and a harness + notice is the case that separates them — it occupies the assistant slot + without being model output. + + A new `actor` module holds what the base format deliberately leaves + open: the `human:` / `agent:` / `tool:` prefixes this deriver uses, + the `human:user` placeholder it emits for an unnamed person, and the + `agent:unknown` placeholder the `agent-coding-session` kind spec + defines for "a model ran, unnamed". `agent:unknown` belongs to that + spec and `human:user` is this deriver's own habit — neither is a + property of the base grammar, so neither lives in `toolpath` any more. + Constructors (`generic_human`, `human`, `unnamed_agent`, `agent`, + `harness`) 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 schema-valid whatever a session file + holds. Readers (`is_human`, `is_agent`, `is_tool`, `model_name`) + replace the prefix-specific accessors the base type used to carry. + + `derive_path` now attributes a step by rendering its turn's author, + with no role matching and no string building of its own, and + `extract_conversation` recovers the author by parsing the actor back — + so derive → extract → derive is stable and the grammar lives in exactly + one place. Harness-authored turns take `tool:` — the actor + system turns and provider-specific roles already take — instead of an + `agent:` actor, which is the misattribution this fixes. `agent:unknown` + still means "a model ran, unnamed": distinct from "no model was + involved". Because a tool actor is always named, each provider now + supplies its own provider id when it builds a harness-authored turn, + rather than leaving the deriver to fill it in. + + `meta.actors` records a `provider` only for the actors this derivation + mints itself. An actor the source supplied under some other prefix — + `ci:github-actions`, or anything else the open grammar allows — is + described by name alone: the deriver does not know where it came from, + and naming the harness there would assert provenance it cannot know. + + One consequence of the open prefix set shows up on the way back in: + `extract_conversation` used to collapse any actor whose prefix was not + `human`, `agent` or `tool` to `agent:unknown`, because the type could + not hold it. It now reads such an actor back as itself, so a document + written by something with its own prefix survives derive → extract → + derive instead of being relabelled. Roles are unaffected — only the + three prefixes this crate attributes turns to map to a `Role`, and + anything else still lands in `Role::Other` carrying its reference. + + Turns reach disk nested in a step's `delegations` payload, so `author` + also accepts the bare model name that older documents carry in its + place; the two are told apart by parsing, since a model name is not a + valid actor reference. Minor bump — replacing a public field on `Turn` + is breaking. +- **`toolpath-claude`** (0.13.0): maps Claude Code's placeholder model to + the harness's own tool actor and every other assistant message to an + agent actor, so the placeholder no longer reaches the IR. It is one + harness's format detail and stays owned by the crate that reads that + format; other providers set an author from whatever their own format + gives them. The projector writes the placeholder back when it + reserializes a harness-authored turn, so a session survives the round + trip. Minor bump — the crate now pins `toolpath-convo` 0.12. + +No schema change: this is a deriver fix and a representation change, +valid under the existing `agent-coding-session` kind, and the base +schema's `actorRef` pattern is untouched. Derived documents are +byte-identical to those the previous release produced, apart from the +misattributed harness turns this fixes — checked by deriving every +checked-in harness fixture on both trees and diffing the canonicalized +JSON. + ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/CLAUDE.md b/CLAUDE.md index 7fb2d744..6bbd4ec4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,8 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Gemini CLI conversation data lives in `~/.gemini/tmp//chats/`. Main sessions sit at the top (`session--.json`, `kind: "main"`); sub-agents live in sibling `/` directories (`kind: "subagent"`). The `` slot is either a friendly name from `~/.gemini/projects.json` or the SHA-256 hex of the absolute project path; `toolpath-gemini` resolves both. - `toolpath-gemini` treats main file + sibling sub-agent UUID dir as one conversation. Sub-agent files are folded into `DelegatedWork` with populated `turns` (unlike `toolpath-claude`, whose sub-agent turns live in separate session files and stay empty). See `docs/agents/formats/gemini.md` for the full format reference. - Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`, `extra["gemini"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra`; `toolpath-gemini` populates `Turn.extra["gemini"]` with the full `tokens` struct, per-thought metadata, and tool-call status. This lets trait-only consumers access provider metadata without importing provider types. +- Actor grammar: `toolpath::v1::Actor` is the parsed `step.actor` — an opaque struct holding a prefix and an id, each non-empty and drawn from `[A-Za-z0-9_.-]`. The **prefix set is open**: `human`/`agent`/`tool`/`ci` are conventions, not a vocabulary, and the base crate privileges none of them (`bot:dependabot` parses). `Actor::new(prefix, id)` validates; `Display` + `FromStr` are the *only* implementation of the grammar and serde uses them, so an `Actor` on the wire is the actor string, and every valid suffix-free reference round-trips unchanged. A `/`-suffix (`agent:m/tool:Write`, `tool:rustfmt/1.5.0`) is a sub-actor qualifier: parsing keeps the segment before it and drops the suffix (`Actor::split_sub_actor` exposes the split). Build or read an actor through this type; don't `format!("agent:{}")` or `starts_with("human:")`. +- Actor conventions live in `toolpath_convo::actor`, not in the base crate: the `human:`/`agent:`/`tool:` prefixes, the `human:user` and `agent:unknown` placeholders (the latter defined by the agent-coding-session kind spec), constructors `generic_human`/`human`/`unnamed_agent`/`agent`/`harness` (all total — a name the grammar can't carry falls back to the placeholder) and readers `is_human`/`is_agent`/`is_tool`/`model_name`. `Turn.author` is an `Actor` and `derive_path` attributes a step by rendering it, so a harness-authored assistant message (the model placeholder Claude Code writes in that slot) lands on `tool:`, not a fake `agent:`. `toolpath-dot`, `toolpath-md`, path-cli's resume gate and `toolpath-git` still hand-roll prefix checks — a pending cleanup. - Shared derivation: `toolpath-convo` provides a provider-agnostic `ConversationView → Path` mapping via `toolpath_convo::derive_path`. New conversation providers should build on it rather than re-implementing the mapping. - Path kinds: `toolpath::v1::PathMeta.kind` is an optional URI naming a hosted kind spec; URIs are immutable and semver-versioned. The only one defined so far is `https://toolpath.net/kinds/agent-coding-session/v1.1.0` (constant `toolpath::v1::PATH_KIND_AGENT_CODING_SESSION`; `…_V1_0_0` names the superseded URI); every conversation → `Path` derivation sets it via the shared `toolpath_convo::derive_path` or each provider crate's own. Carried through the JSONL form via `PathOpen.meta` and `PathMeta` patch lines. Spec sources live in `site/kinds///{index.md,schema.json}` (schema.json is a symlink into `crates/path-cli/kinds/`, which `path p validate` bundles — both versions) and publish under `https://toolpath.net/kinds/`; the registry index is `site/kinds/index.md`. RFC: "Document Kind". JSON Schema: `$defs/pathMeta`. - Token accounting (kind v1.1.0): two keys on `conversation.append`/`Turn`, both optional. `token_usage` = "the total for a message" (on the group's final step; `Σ` over a path = session total). `attributed_token_usage` = "this step's own attributed spend", populated only where the source genuinely reports per-step spend (its own key, so the sum is unaffected; remainder = group total − Σ attributed, computed not stored). One provider message can span several steps (Claude writes one JSONL line per content block); `Turn.group_id` groups them. `toolpath-claude` fills `group_id` from `message.id` and takes the **field-wise-max** group total (line order not trusted). Claude's per-line `usage` is a cumulative *streaming snapshot* (Anthropic streaming API: `message_start` seeds output near 0, `message_delta` is cumulative), NOT a per-block cost — so Claude emits no `attributed_token_usage`; the projector re-expands the total onto every line. `toolpath-codex` differences the cumulative `total_token_usage` (dedup-safe: never sum `last_token_usage` — Codex re-emits it stale; openai/codex #14489), attributes each per-call delta to the step it follows, and derives the round total from those attributions. pi/opencode decode all-zero wire counters as `None`. Never stamp a cumulative counter, a repeated message total, or zero-filled placeholders onto a step; never derive attribution from Claude's streaming snapshots. diff --git a/Cargo.lock b/Cargo.lock index d9b355b7..e622ce20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4065,7 +4065,7 @@ dependencies = [ [[package]] name = "toolpath" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde", "serde_json", @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.2" +version = "0.13.0" dependencies = [ "anyhow", "chrono", @@ -4104,7 +4104,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index ec3e2606..29bca944 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,10 @@ edition = "2024" license = "Apache-2.0" [workspace.dependencies] -toolpath = { version = "0.7.0", path = "crates/toolpath" } -toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } +toolpath = { version = "0.7.1", path = "crates/toolpath" } +toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/path-cli/tests/cross_harness_matrix.rs b/crates/path-cli/tests/cross_harness_matrix.rs index 340ad177..a1d0e713 100644 --- a/crates/path-cli/tests/cross_harness_matrix.rs +++ b/crates/path-cli/tests/cross_harness_matrix.rs @@ -767,7 +767,7 @@ mod invariants { } } - pub fn model_field( + pub fn author_field( original: &ConversationView, final_: &ConversationView, failures: &mut Vec, @@ -775,10 +775,10 @@ mod invariants { let o = meaningful_turns(original); let f = meaningful_turns(final_); for (i, (a, b)) in o.iter().zip(f.iter()).enumerate() { - if a.model != b.model { + if a.author != b.author { failures.push(format!( - "model at turn {} diverged: first={:?} second={:?}", - i, a.model, b.model + "author at turn {} diverged: first={:?} second={:?}", + i, a.author, b.author )); } } @@ -1011,7 +1011,7 @@ fn run_cell( invariants::token_usage_survives(&view_after_source, &view_first, &mut failures); invariants::thinking(&view_first, &view_second, &mut failures); invariants::thinking_survives(&view_after_source, &view_first, &mut failures); - invariants::model_field(&view_first, &view_second, &mut failures); + invariants::author_field(&view_first, &view_second, &mut failures); invariants::stop_reason(&view_first, &view_second, &mut failures); invariants::parent_id_graph(&view_first, &view_second, &mut failures); invariants::environment(&view_first, &view_second, &mut failures); diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index acb66c2f..a0aaf7ab 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.2" +version = "0.13.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 5435649a..5eaa50fa 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -5,12 +5,14 @@ //! reads a Claude JSONL conversation into a provider-agnostic view, //! `ClaudeProjector` serializes that view back into the Claude wire format. +use crate::provider::SYNTHETIC_MODEL; use crate::types::{ ContentPart, Conversation, ConversationEntry, Message, MessageContent, MessageRole, ToolResultContent, Usage, }; use serde_json::json; use std::collections::HashMap; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; @@ -367,6 +369,16 @@ fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry { } } +/// What belongs in `message.model` for an assistant entry: the model that +/// ran, or — for a message the harness produced itself — the placeholder +/// Claude Code writes in that slot, so a session survives the round trip. +fn model_field(turn: &Turn) -> Option { + if actor::is_tool(&turn.author) { + return Some(SYNTHETIC_MODEL.to_string()); + } + actor::model_name(&turn.author).map(str::to_string) +} + /// Build a `ConversationEntry` for an assistant turn. `wire_usage` is the /// usage to write on the JSONL line: the IR carries a message's total only /// on the group's final turn, but real Claude Code repeats `message.usage` @@ -401,7 +413,7 @@ fn assistant_turn_to_entry_with_usage( message: Some(Message { role: MessageRole::Assistant, content: Some(content), - model: turn.model.clone(), + model: model_field(turn), id: turn.group_id.clone(), message_type: None, stop_reason: turn.stop_reason.clone(), @@ -1041,7 +1053,7 @@ mod tests { text: text.to_string(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1061,7 +1073,7 @@ mod tests { text: text.to_string(), thinking: None, tool_uses: vec![], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1421,7 +1433,7 @@ mod tests { #[test] fn test_stop_reason_and_model_preserved() { let mut turn = assistant_turn("a1", "Done."); - turn.model = Some("claude-opus-4-6".to_string()); + turn.author = actor::agent(Some("claude-opus-4-6")); turn.stop_reason = Some("end_turn".to_string()); let view = make_view("sess-1", vec![turn]); diff --git a/crates/toolpath-claude/src/provider.rs b/crates/toolpath-claude/src/provider.rs index 0e544f96..8f3b3bea 100644 --- a/crates/toolpath-claude/src/provider.rs +++ b/crates/toolpath-claude/src/provider.rs @@ -11,13 +11,29 @@ use crate::ClaudeConvo; use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole}; #[cfg(any(feature = "watcher", test))] use toolpath_convo::WatcherEvent; +use toolpath_convo::actor; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, + Actor, ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; // ── Conversion helpers ─────────────────────────────────────────────── +/// The placeholder Claude Code writes to `message.model` on assistant +/// messages it generated itself — API errors, rate-limit notices, timeouts — +/// where no model ran. It is a marker, not a model identifier, so it maps to +/// the harness's own tool actor and never into a model name. +pub(crate) const SYNTHETIC_MODEL: &str = ""; + +/// This crate's provider id — `ConversationView::provider_id`, and the tool +/// actor a harness-authored turn is attributed to. +pub(crate) const PROVIDER_ID: &str = "claude-code"; + +/// The harness itself, as an actor. +fn harness() -> Actor { + actor::harness(PROVIDER_ID) +} + fn claude_role_to_role(role: &MessageRole) -> Role { match role { MessageRole::User => Role::User, @@ -26,6 +42,20 @@ fn claude_role_to_role(role: &MessageRole) -> Role { } } +/// Who wrote a message. An assistant message carrying the harness +/// placeholder in place of a model is the harness speaking, so it is +/// attributed to the harness rather than to a model that never ran. +fn claude_author(msg: &Message) -> Actor { + match msg.role { + MessageRole::User => actor::generic_human(), + MessageRole::System => harness(), + MessageRole::Assistant => match msg.model.as_deref() { + Some(SYNTHETIC_MODEL) => harness(), + _ => actor::agent(msg.model.as_deref()), + }, + } +} + /// Classify a Claude Code tool into toolpath's category ontology. /// /// Returns `None` for unrecognized tools. When Claude Code adds or @@ -129,11 +159,11 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn { // (sum_usage, derive_path) counts a message group once. group_id: msg.id.clone(), role: claude_role_to_role(&msg.role), + author: claude_author(msg), timestamp: entry.timestamp.clone(), text, thinking, tool_uses, - model: msg.model.clone(), stop_reason: msg.stop_reason.clone(), token_usage, attributed_token_usage: None, @@ -444,7 +474,7 @@ fn conversation_to_view(convo: &Conversation) -> ConversationView { last_activity: convo.last_activity, turns, total_usage, - provider_id: Some("claude-code".into()), + provider_id: Some(PROVIDER_ID.into()), files_changed, session_ids: vec![], events, @@ -860,7 +890,7 @@ mod tests { text: String::new(), thinking: None, tool_uses: vec![], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1061,7 +1091,10 @@ mod tests { let result = view.turns[1].tool_uses[0].result.as_ref().unwrap(); assert!(!result.is_error); assert!(result.content.contains("fn main()")); - assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + actor::model_name(&view.turns[1].author), + Some("claude-opus-4-6") + ); assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use")); assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1")); @@ -1253,6 +1286,42 @@ mod tests { assert_eq!(turn.role, Role::User); } + #[test] + fn test_to_turn_attributes_a_placeholder_model_to_the_harness() { + // The placeholder marks a message the harness produced itself; it + // is not a model identifier, so the turn is authored by the harness + // and no model name reaches the IR. + let entry: ConversationEntry = serde_json::from_str(&format!( + r#"{{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"assistant","model":"{SYNTHETIC_MODEL}","content":[{{"type":"text","text":"API Error: Connection reset"}}]}}}}"# + )) + .unwrap(); + let turn = to_turn(&entry).unwrap(); + assert_eq!(turn.author, harness()); + assert_eq!(actor::model_name(&turn.author), None); + // The message keeps its place in the transcript. + assert_eq!(turn.role, Role::Assistant); + } + + #[test] + fn test_to_turn_keeps_a_real_model_as_the_author() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"on it"}]}}"#, + ) + .unwrap(); + let turn = to_turn(&entry).unwrap(); + assert_eq!(turn.author, actor::agent(Some("claude-opus-4-8"))); + } + + #[test] + fn test_to_turn_without_a_recorded_model_is_still_a_model_call() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"on it"}]}}"#, + ) + .unwrap(); + let turn = to_turn(&entry).unwrap(); + assert_eq!(turn.author, actor::unnamed_agent()); + } + #[test] fn test_to_turn_without_message() { let entry: ConversationEntry = serde_json::from_str( @@ -1454,7 +1523,7 @@ mod tests { category: Some(ToolCategory::FileWrite), }, ], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-codex/src/project.rs b/crates/toolpath-codex/src/project.rs index c2c26b82..25454c5b 100644 --- a/crates/toolpath-codex/src/project.rs +++ b/crates/toolpath-codex/src/project.rs @@ -29,6 +29,7 @@ use std::collections::HashMap; use std::path::PathBuf; use serde_json::{Map, Value, json}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; @@ -125,7 +126,11 @@ fn project_view( let model = cfg .model .clone() - .or_else(|| view.turns.iter().find_map(|t| t.model.clone())) + .or_else(|| { + view.turns + .iter() + .find_map(|t| actor::model_name(&t.author).map(str::to_string)) + }) .unwrap_or_else(|| "unknown".to_string()); let session_timestamp = view @@ -719,7 +724,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -739,7 +744,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("gpt-5.4".into()), + author: actor::agent(Some("gpt-5.4")), stop_reason: Some("stop".into()), token_usage: Some(TokenUsage { input_tokens: Some(100), diff --git a/crates/toolpath-codex/src/provider.rs b/crates/toolpath-codex/src/provider.rs index 5b915ce2..e783a1c9 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -40,12 +40,17 @@ use crate::types::{ Session, TokenCountInfo, }; use serde_json::Value; +use toolpath_convo::actor; use toolpath_convo::{ ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; +/// This crate's provider id — `ConversationView::provider_id`, and the tool +/// actor a turn the CLI wrote itself is attributed to. +pub(crate) const PROVIDER_ID: &str = "codex"; + /// Provider for Codex sessions. #[derive(Debug, Clone, Default)] pub struct CodexConvo { @@ -362,7 +367,7 @@ impl<'a> Builder<'a> { } else { None }, - provider_id: Some("codex".into()), + provider_id: Some(PROVIDER_ID.into()), files_changed: self.files_changed_order, session_ids: vec![], events: self.events, @@ -818,15 +823,15 @@ fn message_to_turn( parent_id: None, group_id: None, role: role.clone(), + author: match &role { + Role::User => actor::generic_human(), + Role::Assistant => actor::agent(model), + Role::System | Role::Other(_) => actor::harness(PROVIDER_ID), + }, timestamp: timestamp.to_string(), text, thinking: None, tool_uses: Vec::new(), - model: if role == Role::Assistant { - model.map(str::to_string) - } else { - None - }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -846,11 +851,11 @@ fn synthetic_assistant_turn( parent_id: None, group_id: None, role: Role::Assistant, + author: actor::agent(model), timestamp: timestamp.to_string(), text: String::new(), thinking: None, tool_uses: Vec::new(), - model: model.map(str::to_string), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1089,7 +1094,7 @@ mod tests { assert_eq!(view.turns[0].text, "please do a thing"); assert_eq!(view.turns[1].role, Role::Assistant); assert_eq!(view.turns[1].text, "working on it"); - assert_eq!(view.turns[1].model.as_deref(), Some("gpt-5.4")); + assert_eq!(actor::model_name(&view.turns[1].author), Some("gpt-5.4")); } /// Two API rounds. Codex's `token_count` events carry cumulative diff --git a/crates/toolpath-convo/Cargo.toml b/crates/toolpath-convo/Cargo.toml index 43652540..6ec4f1d0 100644 --- a/crates/toolpath-convo/Cargo.toml +++ b/crates/toolpath-convo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-convo" -version = "0.11.1" +version = "0.12.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/README.md b/crates/toolpath-convo/README.md index 90de3d57..dcdec318 100644 --- a/crates/toolpath-convo/README.md +++ b/crates/toolpath-convo/README.md @@ -14,8 +14,10 @@ Write your conversation analysis once, swap providers without changing a line. | Type | What it represents | |---|---| -| `Turn` | A single conversational turn (text, thinking, tool uses, model, tokens, environment, delegations) | -| `Role` | Who produced the turn: `User`, `Assistant`, `System`, `Other(String)` | +| `Turn` | A single conversational turn (text, thinking, tool uses, author, tokens, environment, delegations) | +| `Role` | Where the turn sits in the conversation: `User`, `Assistant`, `System`, `Other(String)` | +| `Actor` | Who produced the turn's content — a `prefix:id` reference re-exported from `toolpath`, which owns the grammar and leaves the prefix open. `Turn.author` holds one, and a derived step's `actor` string is that value rendered | +| `actor` (module) | This crate's actor conventions, which the base format deliberately does not define: the `human:` / `agent:` / `tool:` prefixes, the `human:user` and `agent:unknown` placeholders, and constructors (`generic_human`, `human`, `unnamed_agent`, `agent`, `harness`) and readers (`is_human`, `is_agent`, `is_tool`, `model_name`) built on them | | `ConversationView` | A complete conversation: ordered turns, timestamps, aggregate usage, files changed | | `ConversationMeta` | Lightweight metadata (no turns loaded) | | `ToolInvocation` | A tool call within a turn, with optional `ToolCategory` classification | diff --git a/crates/toolpath-convo/src/actor.rs b/crates/toolpath-convo/src/actor.rs new file mode 100644 index 00000000..cea3c1ac --- /dev/null +++ b/crates/toolpath-convo/src/actor.rs @@ -0,0 +1,146 @@ +//! The actor conventions of an agent coding session. +//! +//! [`toolpath::v1::Actor`] owns the grammar and only the grammar: a prefix, an +//! id, any spelling within the character set. Which prefixes a conversation +//! uses, and what stands in for a person or a model the source did not name, +//! are conventions of the `agent-coding-session` path kind and of this +//! deriver — so they live here rather than in the base format. +//! +//! - `human:` — a person. `human:user` is what this deriver emits when the +//! source names no one. +//! - `agent:` — a model or agent, the thing that produces text and decisions. +//! `agent:unknown` is the kind spec's id for "a model ran, unnamed". +//! - `tool:` — the general machine prefix, for anything that is not a model: +//! a formatter, a CI job, or an agent harness writing on its own behalf. A +//! harness is one *kind* of tool actor, so there is no separate prefix for +//! it, and it is always named: `tool:claude-code`. +//! +//! Every constructor here is total. A name the actor grammar cannot carry is +//! no name at all, so it falls back to the same placeholder an absent name +//! does; that keeps derived documents renderable and schema-valid whatever a +//! session file happens to hold. + +use toolpath::v1::Actor; + +/// Prefix for a person. +pub const HUMAN_PREFIX: &str = "human"; +/// Prefix for a model or agent. +pub const AGENT_PREFIX: &str = "agent"; +/// Prefix for a machine actor that is not a model. +pub const TOOL_PREFIX: &str = "tool"; + +/// The id [`generic_human`] renders — "a person", not an identifier. +pub const GENERIC_HUMAN_ID: &str = "user"; +/// The id [`unnamed_agent`] renders — "a model ran, unnamed". Defined by the +/// `agent-coding-session` kind spec, and a different claim from "no model was +/// involved", which is a [`harness`] actor. +pub const UNNAMED_AGENT_ID: &str = "unknown"; +/// The id [`harness`] renders when the provider is not identified — the same +/// string `derive_path` already uses for an unidentified provider elsewhere +/// in a derived document. +pub const UNKNOWN_PROVIDER_ID: &str = "unknown"; + +/// Build an actor from segments this module knows are within the grammar. +fn constant(prefix: &str, id: &str) -> Actor { + // Both arguments are module constants or already-validated ids; the + // `is_actor_reference_grammar_holds_for_constants` test pins that. + Actor::new(prefix, id).expect("constant actor reference is within the grammar") +} + +/// The person a source names no more precisely than "the user". +pub fn generic_human() -> Actor { + constant(HUMAN_PREFIX, GENERIC_HUMAN_ID) +} + +/// A person, named where the source names one. +pub fn human(name: Option<&str>) -> Actor { + name.and_then(|n| Actor::new(HUMAN_PREFIX, n).ok()) + .unwrap_or_else(generic_human) +} + +/// A model ran, but the source did not name it. +pub fn unnamed_agent() -> Actor { + constant(AGENT_PREFIX, UNNAMED_AGENT_ID) +} + +/// A model, named where the source names one. +pub fn agent(model: Option<&str>) -> Actor { + model + .and_then(|m| Actor::new(AGENT_PREFIX, m).ok()) + .unwrap_or_else(unnamed_agent) +} + +/// The harness itself, as an actor — the author of anything a provider wrote +/// on its own behalf: API errors, rate-limit notices, its own bookkeeping. +pub fn harness(provider: &str) -> Actor { + Actor::new(TOOL_PREFIX, provider).unwrap_or_else(|_| constant(TOOL_PREFIX, UNKNOWN_PROVIDER_ID)) +} + +/// Whether `actor` is a person. +pub fn is_human(actor: &Actor) -> bool { + actor.prefix() == HUMAN_PREFIX +} + +/// Whether `actor` is a model or agent. +pub fn is_agent(actor: &Actor) -> bool { + actor.prefix() == AGENT_PREFIX +} + +/// Whether `actor` is a machine actor that is not a model — a harness among +/// them. +pub fn is_tool(actor: &Actor) -> bool { + actor.prefix() == TOOL_PREFIX +} + +/// The model `actor` names, if it names one — the value that belongs in +/// `ActorDefinition::model`. `None` for anything that is not an agent, and +/// for the unnamed-agent placeholder, which is a sentinel rather than a name. +pub fn model_name(actor: &Actor) -> Option<&str> { + if is_agent(actor) && actor.id() != UNNAMED_AGENT_ID { + Some(actor.id()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_actor_reference_grammar_holds_for_constants() { + assert_eq!(generic_human().to_string(), "human:user"); + assert_eq!(unnamed_agent().to_string(), "agent:unknown"); + assert_eq!(harness("claude-code").to_string(), "tool:claude-code"); + assert_eq!(harness("").to_string(), "tool:unknown"); + } + + #[test] + fn names_the_grammar_cannot_carry_fall_back_to_the_placeholder() { + assert_eq!(human(Some("alex")).to_string(), "human:alex"); + assert_eq!(human(None).to_string(), "human:user"); + assert_eq!(human(Some("")).to_string(), "human:user"); + assert_eq!(human(Some("Ada Lovelace")).to_string(), "human:user"); + + assert_eq!(agent(Some("gpt-5.5")).to_string(), "agent:gpt-5.5"); + assert_eq!(agent(None).to_string(), "agent:unknown"); + assert_eq!(agent(Some("vendor/model")).to_string(), "agent:unknown"); + } + + #[test] + fn model_name_is_the_agent_id_unless_it_is_the_placeholder() { + assert_eq!(model_name(&agent(Some("gpt-5.5"))), Some("gpt-5.5")); + assert_eq!(model_name(&unnamed_agent()), None); + assert_eq!(model_name(&generic_human()), None); + assert_eq!(model_name(&harness("codex")), None); + } + + #[test] + fn predicates_read_the_prefix() { + assert!(is_human(&generic_human())); + assert!(is_agent(&unnamed_agent())); + assert!(is_tool(&harness("pi"))); + let novel: Actor = "bot:dependabot".parse().unwrap(); + assert!(!is_human(&novel) && !is_agent(&novel) && !is_tool(&novel)); + } +} diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index 1463b74f..c19e003c 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -13,7 +13,7 @@ use toolpath::v1::{ PathMeta, Step, StepIdentity, StructuralChange, }; -use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn}; +use crate::{Actor, ConversationView, ToolCategory, ToolInvocation, actor}; /// Configuration for [`derive_path`]. #[derive(Debug, Clone)] @@ -117,8 +117,11 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { turn.id.clone() }; - let actor = actor_for_turn(turn, provider); - record_actor(&mut actors, &actor, turn, provider, view); + // Attribution is the turn's author, rendered. `role` plays no part: a + // harness notice sitting in the assistant slot is still the harness + // speaking. + let actor = turn.author.to_string(); + record_actor(&mut actors, &actor, &turn.author, provider); let mut step = Step { step: StepIdentity { @@ -352,14 +355,10 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { } else { event.id.clone() }; - let actor = format!("tool:{}", provider); - actors - .entry(actor.clone()) - .or_insert_with(|| ActorDefinition { - name: Some(provider.to_string()), - provider: Some(provider.to_string()), - ..Default::default() - }); + // Events are the harness's own record-keeping, not a turn by anyone. + let author = actor::harness(provider); + let actor = author.to_string(); + record_actor(&mut actors, &actor, &author, provider); // event.data is flattened into StructuralChange.extra. Strip keys // that collide with the typed fields on StructuralChange itself — @@ -516,49 +515,30 @@ fn serde_value_eq(a: &Step, b: &Step) -> bool { serde_json::to_value(a).ok() == serde_json::to_value(b).ok() } -fn actor_for_turn(turn: &Turn, provider: &str) -> String { - match &turn.role { - Role::User => "human:user".to_string(), - Role::Assistant => { - let model = turn.model.as_deref().unwrap_or("unknown"); - format!("agent:{}", model) - } - Role::System => format!("tool:{}", provider), - Role::Other(_) => format!("tool:{}", provider), - } -} - +/// Describe `actor` in `meta.actors`, unless it is already described. +/// +/// `name` is the actor's id segment — the same value the actor string was +/// rendered from. Only an agent carries a model, and `provider` is recorded +/// only for the actors this derivation mints itself: an actor reference the +/// source supplied under some other prefix has provenance this crate does +/// not know. fn record_actor( actors: &mut HashMap, actor: &str, - turn: &Turn, + author: &Actor, provider: &str, - _view: &ConversationView, ) { if actors.contains_key(actor) { return; } - let def = if let Some(rest) = actor.strip_prefix("agent:") { - ActorDefinition { - name: Some(rest.to_string()), - provider: Some(provider.to_string()), - model: turn.model.clone(), - identities: vec![], - keys: vec![], - } - } else if let Some(rest) = actor.strip_prefix("human:") { - ActorDefinition { - name: Some(rest.to_string()), - ..Default::default() - } - } else { - let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string(); - ActorDefinition { - name: Some(name), - provider: Some(provider.to_string()), - ..Default::default() - } + let mut def = ActorDefinition { + name: Some(author.id().to_string()), + ..Default::default() }; + if actor::is_agent(author) || actor::is_tool(author) { + def.provider = Some(provider.to_string()); + def.model = actor::model_name(author).map(str::to_string); + } actors.insert(actor.to_string(), def); } @@ -715,19 +695,28 @@ pub fn unified_diff(path: &str, before: &str, after: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult}; + use crate::{ + DelegatedWork, EnvironmentSnapshot, Role, TokenUsage, ToolInvocation, ToolResult, Turn, + }; fn base_turn(id: &str, role: Role) -> Turn { + // The mapping every provider applies when its format carries no + // authorship signal beyond the role. + let author = match &role { + Role::User => actor::generic_human(), + Role::Assistant => actor::unnamed_agent(), + Role::System | Role::Other(_) => actor::harness("pi"), + }; Turn { id: id.to_string(), parent_id: None, group_id: None, role, + author, timestamp: "2026-01-01T00:00:00Z".to_string(), text: String::new(), thinking: None, tool_uses: vec![], - model: None, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -877,7 +866,7 @@ mod tests { BTreeMap::from([("reasoning".to_string(), 450u32)]), ); let mut turn = base_turn("t1", Role::Assistant); - turn.model = Some("claude-opus-4-7".into()); + turn.author = actor::agent(Some("claude-opus-4-7")); turn.token_usage = Some(TokenUsage { input_tokens: Some(100), output_tokens: Some(900), @@ -938,20 +927,115 @@ mod tests { #[test] fn test_single_assistant_turn() { let mut turn = base_turn("t1", Role::Assistant); - turn.model = Some("claude-opus-4-7".into()); + turn.author = actor::agent(Some("claude-opus-4-7")); let view = view_with(vec![turn]); let path = derive_path(&view, &DeriveConfig::default()); assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7"); } #[test] - fn test_assistant_without_model() { + fn test_model_author_without_a_name() { let turn = base_turn("t1", Role::Assistant); + assert_eq!(turn.author, actor::unnamed_agent()); let view = view_with(vec![turn]); let path = derive_path(&view, &DeriveConfig::default()); + // "A model ran but the source didn't name it" is its own outcome, + // distinct from "no model was involved". assert_eq!(path.steps[0].step.actor, "agent:unknown"); } + #[test] + fn test_named_human_author() { + let mut turn = base_turn("t1", Role::User); + turn.author = actor::human(Some("ada")); + let view = view_with(vec![turn]); + let path = derive_path(&view, &DeriveConfig::default()); + assert_eq!(path.steps[0].step.actor, "human:ada"); + + let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap(); + assert_eq!(actors["human:ada"].name.as_deref(), Some("ada")); + } + + #[test] + fn test_named_harness_author() { + let mut turn = base_turn("t1", Role::System); + turn.author = actor::harness("some-gateway"); + let view = view_with(vec![turn]); + let path = derive_path(&view, &DeriveConfig::default()); + assert_eq!(path.steps[0].step.actor, "tool:some-gateway"); + } + + #[test] + fn test_harness_authored_assistant_turn_is_attributed_to_the_harness() { + let mut turn = base_turn("t1", Role::Assistant); + turn.author = actor::harness("pi"); + let view = view_with(vec![turn]); + let path = derive_path(&view, &DeriveConfig::default()); + + // The harness wrote the message; no model produced it. + assert_eq!(path.steps[0].step.actor, "tool:pi"); + // Only attribution changes — the message keeps the assistant slot. + assert_eq!( + conv_change(&path.steps[0]).extra["role"], + serde_json::json!("assistant") + ); + + let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap(); + let actor = &actors["tool:pi"]; + assert_eq!(actor.provider.as_deref(), Some("pi")); + assert_eq!(actor.model, None); + } + + #[test] + fn test_attribution_does_not_consult_the_role() { + // A harness-authored turn takes the harness actor wherever it sits + // in the conversation, and a model-authored one takes the model + // actor even outside the assistant slot. + let mut harness = base_turn("t1", Role::User); + harness.author = actor::harness("pi"); + let mut model = base_turn("t2", Role::Other("tool".into())); + model.author = actor::agent(Some("claude-opus-4-8")); + + let view = view_with(vec![harness, model]); + let path = derive_path(&view, &DeriveConfig::default()); + + assert_eq!(path.steps[0].step.actor, "tool:pi"); + assert_eq!(path.steps[1].step.actor, "agent:claude-opus-4-8"); + } + + #[test] + fn test_author_round_trips_through_serde() { + let mut turn = base_turn("t1", Role::Assistant); + turn.author = actor::harness("pi"); + let json = serde_json::to_value(&turn).unwrap(); + // The author is written as the actor string it renders to, which is + // the same string the step it derives to carries. + assert_eq!(json["author"], serde_json::json!("tool:pi")); + let back: Turn = serde_json::from_value(json).unwrap(); + assert_eq!(back.author, actor::harness("pi")); + } + + #[test] + fn test_author_reads_the_model_field_of_older_documents() { + // Turns reach disk nested in a step's `delegations` payload, so + // documents written before authorship was modeled are still read. + let turn = base_turn("t1", Role::Assistant); + let mut json = serde_json::to_value(&turn).unwrap(); + let obj = json.as_object_mut().unwrap(); + obj.remove("author"); + obj.insert("model".into(), serde_json::json!("claude-opus-4-7")); + let back: Turn = serde_json::from_value(json).unwrap(); + assert_eq!(back.author, actor::agent(Some("claude-opus-4-7"))); + assert_eq!(back.role, Role::Assistant); + + let mut json = serde_json::to_value(&turn).unwrap(); + let obj = json.as_object_mut().unwrap(); + obj.remove("author"); + obj.insert("model".into(), serde_json::Value::Null); + let back: Turn = serde_json::from_value(json).unwrap(); + assert_eq!(back.author, actor::unnamed_agent()); + } + #[test] fn test_system_role() { let turn = base_turn("t1", Role::System); @@ -973,7 +1057,7 @@ mod tests { let t1 = base_turn("t1", Role::User); let mut t2 = base_turn("t2", Role::Assistant); t2.parent_id = Some("t1".into()); - t2.model = Some("m".into()); + t2.author = actor::agent(Some("m")); let view = view_with(vec![t1, t2]); let path = derive_path(&view, &DeriveConfig::default()); assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]); @@ -984,11 +1068,17 @@ mod tests { let user = base_turn("t1", Role::User); let mut assistant = base_turn("t2", Role::Assistant); assistant.parent_id = Some("t1".into()); - assistant.model = Some("gpt-5.5".into()); + assistant.author = actor::agent(Some("gpt-5.5")); let system = base_turn("t3", Role::System); let other = base_turn("t4", Role::Other("bash".into())); - - let mut view = view_with(vec![user, assistant, system, other]); + // A source name the actor grammar cannot carry: the constructor + // falls back to the placeholder rather than emitting a reference + // the schema would reject. + let mut unnameable = base_turn("t5", Role::Assistant); + unnameable.author = actor::agent(Some("vendor/model:v2")); + assert_eq!(unnameable.author.to_string(), "agent:unknown"); + + let mut view = view_with(vec![user, assistant, system, other, unnameable]); view.events.push(crate::ConversationEvent { id: "e1".into(), timestamp: "2026-01-01T00:00:00Z".into(), @@ -1028,7 +1118,7 @@ mod tests { let mut assistant = base_turn("t2", Role::Assistant); assistant.parent_id = Some("t1".into()); assistant.group_id = Some("msg_t2".into()); - assistant.model = Some("gpt-5.5".into()); + assistant.author = actor::agent(Some("gpt-5.5")); assistant.text = "on it".into(); assistant.thinking = Some("plan the edit".into()); assistant.stop_reason = Some("tool_use".into()); @@ -1494,7 +1584,7 @@ mod tests { fn test_actors_in_meta() { let u = base_turn("t1", Role::User); let mut a = base_turn("t2", Role::Assistant); - a.model = Some("claude-opus-4-7".into()); + a.author = actor::agent(Some("claude-opus-4-7")); let view = view_with(vec![u, a]); let path = derive_path(&view, &DeriveConfig::default()); let actors = path.meta.unwrap().actors.unwrap(); @@ -1507,6 +1597,19 @@ mod tests { assert_eq!(human.name.as_deref(), Some("user")); } + #[test] + fn test_foreign_prefix_actor_gets_no_fabricated_provenance() { + let mut t = base_turn("t1", Role::User); + t.author = Actor::new("dog", "sparky").unwrap(); + let view = view_with(vec![t]); + let path = derive_path(&view, &DeriveConfig::default()); + let actors = path.meta.unwrap().actors.unwrap(); + let dog = &actors["dog:sparky"]; + assert_eq!(dog.name.as_deref(), Some("sparky")); + assert_eq!(dog.provider, None, "the deriver did not mint this actor"); + assert_eq!(dog.model, None); + } + #[test] fn test_head_is_last_step_id() { let turns = vec![ @@ -1692,7 +1795,7 @@ mod tests { }); let mut t2 = base_turn("t2", Role::Assistant); t2.parent_id = Some("t1".into()); - t2.model = Some("m".into()); + t2.author = actor::agent(Some("m")); t2.tool_uses = vec![fw_tool( "Write", "tu1", diff --git a/crates/toolpath-convo/src/extract.rs b/crates/toolpath-convo/src/extract.rs index c1d83800..8571bf18 100644 --- a/crates/toolpath-convo/src/extract.rs +++ b/crates/toolpath-convo/src/extract.rs @@ -13,7 +13,7 @@ use chrono::DateTime; use toolpath::v1::{Path, Step}; use crate::{ - ConversationEvent, ConversationView, DelegatedWork, EnvironmentSnapshot, FileMutation, + Actor, ConversationEvent, ConversationView, DelegatedWork, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -288,8 +288,8 @@ fn build_turn(step: &Step, extra: &HashMap) -> Turn { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - // Model is attributed via the step actor (`agent:{model}`). - let model = model_from_actor(&step.step.actor); + // Authorship is attributed via the step actor. + let author = author_from_actor(&step.step.actor); let stop_reason = extra .get("stop_reason") @@ -320,11 +320,11 @@ fn build_turn(step: &Step, extra: &HashMap) -> Turn { parent_id, group_id, role, + author, timestamp: step.step.timestamp.clone(), text, thinking, tool_uses, - model, stop_reason, token_usage, attributed_token_usage, @@ -492,41 +492,35 @@ fn parse_role(s: &str) -> Role { } } -/// Pull the model name out of a step actor string like `agent:claude-opus-4-7`. +/// Recover a turn's author from its step actor string — the inverse of the +/// attribution the deriver writes, so derive → extract → derive is stable. /// -/// Conventions: -/// - `agent:{model}` → `Some("{model}")` (the standard attribution shape) -/// - `agent:{model}/tool:…` → model is the part before the `/` (Claude's -/// sub-actor style; only appears on non-turn tool steps, but handled for -/// robustness) -/// - `agent:unknown` → `None` — "unknown" is the sentinel the deriver writes -/// when the source has no model -/// - anything else (`human:…`, `system:…`, empty) → `None` -fn model_from_actor(actor: &str) -> Option { - let rest = actor.strip_prefix("agent:")?; - let model = match rest.split_once('/') { - Some((m, _)) => m, - None => rest, - }; - if model.is_empty() || model == "unknown" { - None - } else { - Some(model.to_string()) - } +/// [`Actor`] owns the grammar, whatever the prefix, and drops the sub-actor +/// suffix of the form `agent:{model}/tool:…` (which only appears on non-turn +/// tool steps, but is handled for robustness). +/// +/// A string that is not an actor reference at all — no prefix, an empty +/// segment, characters outside the grammar — decodes to an unnamed agent, +/// which is what an actor of no recognizable shape has always meant here. +fn author_from_actor(actor: &str) -> Actor { + actor + .parse() + .unwrap_or_else(|_| crate::actor::unnamed_agent()) } fn role_from_actor(actor: &str) -> Role { - if actor.contains("/tool:") { + let (base, sub) = Actor::split_sub_actor(actor); + if matches!(sub.map(str::parse::), Some(Ok(ref a)) if crate::actor::is_tool(a)) { // Tool step — shouldn't be a turn, but if it is, treat as Other. - Role::Other("tool".to_string()) - } else if actor.starts_with("human:") { - Role::User - } else if actor.starts_with("agent:") { - Role::Assistant - } else if actor.starts_with("tool:") { - Role::System - } else { - Role::Other(actor.to_string()) + return Role::Other("tool".to_string()); + } + // Only the prefixes this crate attributes turns to map to a role; any + // other actor is something else's, and keeps its reference as the label. + match base.parse::() { + Ok(a) if crate::actor::is_human(&a) => Role::User, + Ok(a) if crate::actor::is_agent(&a) => Role::Assistant, + Ok(a) if crate::actor::is_tool(&a) => Role::System, + _ => Role::Other(actor.to_string()), } } @@ -545,30 +539,134 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange}; + fn agent(name: &str) -> Actor { + crate::actor::agent(Some(name)) + } + #[test] - fn test_model_from_actor_variants() { + fn test_author_from_actor_variants() { + // The generic user and a named one. + assert_eq!( + author_from_actor("human:user"), + crate::actor::generic_human() + ); + assert_eq!( + author_from_actor("human:ada"), + crate::actor::human(Some("ada")) + ); + // Model calls, named and not. + assert_eq!( + author_from_actor("agent:claude-opus-4-7"), + agent("claude-opus-4-7") + ); + assert_eq!( + author_from_actor("agent:gemini-3-flash-preview"), + agent("gemini-3-flash-preview") + ); + assert_eq!( + author_from_actor("agent:unknown"), + crate::actor::unnamed_agent() + ); + // Sub-actor form (tool steps): model is the part before "/". + assert_eq!( + author_from_actor("agent:claude-code/tool:Write"), + agent("claude-code") + ); + // The harness itself. assert_eq!( - model_from_actor("agent:claude-opus-4-7"), - Some("claude-opus-4-7".to_string()) + author_from_actor("tool:gemini-cli"), + crate::actor::harness("gemini-cli") ); + // The grammar is open, so a prefix this crate has no convention for + // still reads back as the actor it is. assert_eq!( - model_from_actor("agent:gemini-3-flash-preview"), - Some("gemini-3-flash-preview".to_string()) + author_from_actor("system:gemini-cli").to_string(), + "system:gemini-cli" ); - // Sub-actor form (Claude tool steps): model is the part before "/". + // Anything that is not an actor reference reads as an unnamed agent. + assert_eq!(author_from_actor(""), crate::actor::unnamed_agent()); + assert_eq!(author_from_actor("agent:"), crate::actor::unnamed_agent()); + assert_eq!(author_from_actor("tool:"), crate::actor::unnamed_agent()); assert_eq!( - model_from_actor("agent:claude-code/tool:Write"), - Some("claude-code".to_string()) + author_from_actor("no-prefix"), + crate::actor::unnamed_agent() ); - // `unknown` is the deriver's sentinel for "no model"; decode to None. - assert_eq!(model_from_actor("agent:unknown"), None); - // Non-agent actors carry no model. - assert_eq!(model_from_actor("human:user"), None); - assert_eq!(model_from_actor("system:gemini-cli"), None); - assert_eq!(model_from_actor("tool:rustfmt"), None); - // Malformed / empty. - assert_eq!(model_from_actor(""), None); - assert_eq!(model_from_actor("agent:"), None); + } + + #[test] + fn test_role_from_actor_variants() { + assert_eq!(role_from_actor("human:alex"), Role::User); + assert_eq!(role_from_actor("agent:claude-opus-4-7"), Role::Assistant); + assert_eq!(role_from_actor("tool:pi"), Role::System); + // A sub-actor naming a tool is a tool step, not a turn. + assert_eq!( + role_from_actor("agent:claude-code/tool:Write"), + Role::Other("tool".to_string()) + ); + // A non-tool suffix qualifies the actor and doesn't change its kind. + assert_eq!(role_from_actor("tool:rustfmt/1.5.0"), Role::System); + assert_eq!( + role_from_actor("ci:github-actions"), + Role::Other("ci:github-actions".to_string()) + ); + } + + #[test] + fn test_author_survives_derive_extract_derive() { + use crate::{ConversationView, DeriveConfig, derive_path}; + + let base = |id: &str, role: Role, author: Actor| Turn { + id: id.to_string(), + parent_id: None, + group_id: None, + role, + author, + timestamp: "2026-01-01T00:00:00Z".to_string(), + text: "t".to_string(), + thinking: None, + tool_uses: vec![], + stop_reason: None, + token_usage: None, + attributed_token_usage: None, + environment: None, + delegations: vec![], + file_mutations: vec![], + }; + + let view = ConversationView { + id: "s1".to_string(), + provider_id: Some("pi".to_string()), + turns: vec![ + base("t1", Role::User, crate::actor::generic_human()), + base("t2", Role::Assistant, agent("claude-opus-4-7")), + base("t3", Role::Assistant, crate::actor::unnamed_agent()), + // The harness speaking in the assistant slot. + base("t4", Role::Assistant, crate::actor::harness("pi")), + base("t5", Role::System, crate::actor::harness("pi")), + ], + ..Default::default() + }; + + let first = derive_path(&view, &DeriveConfig::default()); + let back = extract_conversation(&first); + let second = derive_path(&back, &DeriveConfig::default()); + + let actors = + |p: &Path| -> Vec { p.steps.iter().map(|s| s.step.actor.clone()).collect() }; + assert_eq!( + actors(&first), + vec![ + "human:user", + "agent:claude-opus-4-7", + "agent:unknown", + "tool:pi", + "tool:pi", + ] + ); + assert_eq!(actors(&first), actors(&second)); + // Roles ride in the payload and are unaffected by attribution. + assert_eq!(back.turns[3].role, Role::Assistant); + assert_eq!(back.turns[4].role, Role::System); } fn make_path(steps: Vec) -> Path { @@ -706,7 +804,10 @@ mod tests { assert_eq!(view.turns[0].id, "step-002"); assert_eq!(view.turns[1].role, Role::Assistant); assert_eq!(view.turns[1].text, "I'll fix that."); - assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + crate::actor::model_name(&view.turns[1].author), + Some("claude-opus-4-6") + ); } #[test] diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index dcf3c3e2..7b4a9904 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +pub mod actor; pub mod derive; pub mod extract; pub mod project; @@ -11,6 +12,12 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; +/// The actor grammar, re-exported so conversation code has one place to name +/// it. `Turn.author` is an [`Actor`], and a derived step's `actor` string is +/// that value rendered. The grammar is open; the prefixes and placeholders a +/// conversation uses live in [`actor`]. +pub use toolpath::v1::Actor; + // ── Error ──────────────────────────────────────────────────────────── /// Errors from conversation provider operations. @@ -54,6 +61,30 @@ impl std::fmt::Display for Role { } } +/// Read [`Turn::author`], accepting the field a turn carried before +/// authorship was modeled. +/// +/// `Turn` reaches disk nested inside [`DelegatedWork::turns`], which is +/// serialized wholesale into a derived path's `delegations` payload, so +/// documents written earlier are still read back. Those turns carry a bare +/// model name (or `null`) where the actor reference now sits; that is exactly +/// what an agent with — or without — a recorded name means. The role those +/// turns record is unaffected and still deserializes as itself. +/// +/// The two forms are told apart by parsing: a model name is not a valid actor +/// reference. That is exact for every model name any provider records — none +/// contains a `:` — and misreads only a legacy name spelled like an actor +/// reference, which no provider produces. +fn de_author<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + Ok(match Option::::deserialize(deserializer)? { + None => actor::unnamed_agent(), + Some(s) => s.parse().unwrap_or_else(|_| actor::agent(Some(&s))), + }) +} + /// Token usage for a single turn. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TokenUsage { @@ -263,9 +294,17 @@ pub struct Turn { #[serde(default, skip_serializing_if = "Option::is_none")] pub group_id: Option, - /// Who produced this turn. + /// The turn's position in the conversation. pub role: Role, + /// Who produced the turn's content — a person, a model, or the harness + /// itself ([`actor::harness`], named for the provider). Independent of + /// [`Turn::role`]: a harness notice occupies the assistant slot without + /// being model output. This is the actor the derived step is attributed + /// to. + #[serde(alias = "model", deserialize_with = "de_author")] + pub author: Actor, + /// When this turn occurred (ISO 8601). pub timestamp: String, @@ -278,9 +317,6 @@ pub struct Turn { /// Tool invocations in this turn. pub tool_uses: Vec, - /// Model identifier (e.g. "claude-opus-4-6", "gpt-4o"). - pub model: Option, - /// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens"). pub stop_reason: Option, @@ -583,7 +619,7 @@ mod tests { text: "Fix the authentication bug in login.rs".into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -609,7 +645,7 @@ mod tests { }), category: Some(ToolCategory::FileRead), }], - model: Some("claude-opus-4-6".into()), + author: actor::agent(Some("claude-opus-4-6")), stop_reason: Some("end_turn".into()), token_usage: Some(TokenUsage { input_tokens: Some(100), @@ -632,7 +668,7 @@ mod tests { text: "Thanks!".into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -734,7 +770,10 @@ mod tests { let json = serde_json::to_string(turn).unwrap(); let back: Turn = serde_json::from_str(&json).unwrap(); assert_eq!(back.id, "t2"); - assert_eq!(back.model, Some("claude-opus-4-6".into())); + assert_eq!( + crate::actor::model_name(&back.author), + Some("claude-opus-4-6") + ); assert_eq!(back.tool_uses.len(), 1); assert_eq!(back.tool_uses[0].name, "Read"); assert!(back.tool_uses[0].result.is_some()); @@ -972,7 +1011,7 @@ mod tests { text: "Delegating...".into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-convo/src/project.rs b/crates/toolpath-convo/src/project.rs index 3a0a0511..dc78328a 100644 --- a/crates/toolpath-convo/src/project.rs +++ b/crates/toolpath-convo/src/project.rs @@ -154,16 +154,21 @@ mod tests { } fn make_turn(id: &str, role: Role, text: &str) -> Turn { + let author = match &role { + Role::User => crate::actor::generic_human(), + Role::Assistant => crate::actor::unnamed_agent(), + Role::System | Role::Other(_) => crate::actor::harness("test-provider"), + }; Turn { id: id.into(), parent_id: None, group_id: None, role, + author, timestamp: "2026-01-01T00:00:00Z".into(), text: text.into(), thinking: None, tool_uses: vec![], - model: None, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -363,7 +368,7 @@ mod tests { category: None, }, ], - model: None, + author: crate::actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -421,7 +426,7 @@ mod tests { text: "turn 1".into(), thinking: None, tool_uses: vec![], - model: None, + author: crate::actor::unnamed_agent(), stop_reason: None, token_usage: Some(TokenUsage { input_tokens: Some(100), @@ -444,7 +449,7 @@ mod tests { text: "turn 2".into(), thinking: None, tool_uses: vec![], - model: None, + author: crate::actor::unnamed_agent(), stop_reason: None, token_usage: Some(TokenUsage { input_tokens: Some(200), diff --git a/crates/toolpath-copilot/src/project.rs b/crates/toolpath-copilot/src/project.rs index ffd8b654..3699fa13 100644 --- a/crates/toolpath-copilot/src/project.rs +++ b/crates/toolpath-copilot/src/project.rs @@ -14,6 +14,7 @@ use crate::provider::native_name; use crate::types::{EventLine, Session, Workspace}; use serde_json::{Map, Value, json}; use std::collections::HashMap; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, Result, Role, TokenUsage, ToolInvocation, Turn, }; @@ -208,7 +209,7 @@ impl CopilotProjector { data.insert("content".into(), json!(turn.text)); data.insert("turnId".into(), json!(turn_id)); data.insert("messageId".into(), json!(message_id)); - if let Some(m) = &turn.model { + if let Some(m) = actor::model_name(&turn.author) { data.insert("model".into(), json!(m)); } if let Some(th) = &turn.thinking { @@ -697,7 +698,10 @@ mod tests { assert_eq!(view2.turns[1].text, "listing"); // Thinking + model + per-turn tokens survive. assert_eq!(view2.turns[1].thinking.as_deref(), Some("think")); - assert_eq!(view2.turns[1].model.as_deref(), Some("claude-haiku-4.5")); + assert_eq!( + actor::model_name(&view2.turns[1].author), + Some("claude-haiku-4.5") + ); assert_eq!( view2.turns[1].token_usage.as_ref().unwrap().output_tokens, Some(42) @@ -783,7 +787,7 @@ mod tests { }), category: Some(ToolCategory::Shell), }], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -837,7 +841,7 @@ mod tests { text: String::new(), thinking: None, tool_uses: vec![tool], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -988,7 +992,7 @@ mod tests { json!({"file_path": "/p/b.rs", "offset": 10, "limit": 5}), ), ], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1053,7 +1057,7 @@ mod tests { }), category: Some(ToolCategory::Shell), }], - model: None, + author: actor::unnamed_agent(), stop_reason: None, token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-copilot/src/provider.rs b/crates/toolpath-copilot/src/provider.rs index 430ef668..39a804b0 100644 --- a/crates/toolpath-copilot/src/provider.rs +++ b/crates/toolpath-copilot/src/provider.rs @@ -10,6 +10,7 @@ use crate::paths::PathResolver; use crate::types::{CopilotEvent, Session}; use serde_json::Value; use std::collections::HashMap; +use toolpath_convo::actor; use toolpath_convo::{ ConversationEvent, ConversationView, DelegatedWork, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, @@ -178,7 +179,7 @@ pub fn to_view(session: &Session) -> ConversationView { flush(&mut turns, &mut current); seq += 1; let mut t = empty_turn(format!("a{seq}"), Role::Assistant, ts); - t.model = default_model.clone(); + t.author = actor::agent(default_model.as_deref()); current = Some(t); } CopilotEvent::AssistantMessage(m) => { @@ -195,8 +196,8 @@ pub fn to_view(session: &Session) -> ConversationView { None => cur.thinking = Some(r.clone()), } } - if cur.model.is_none() { - cur.model = m.model.clone().or_else(|| default_model.clone()); + if actor::model_name(&cur.author).is_none() { + cur.author = actor::agent(m.model.as_deref().or(default_model.as_deref())); } } CopilotEvent::AssistantTurnEnd => { @@ -383,16 +384,21 @@ pub fn to_view(session: &Session) -> ConversationView { // ── Helpers ────────────────────────────────────────────────────────── fn empty_turn(id: String, role: Role, timestamp: String) -> Turn { + let author = match &role { + Role::User => actor::generic_human(), + Role::Assistant => actor::unnamed_agent(), + Role::System | Role::Other(_) => actor::harness(PROVIDER_ID), + }; Turn { id, parent_id: None, group_id: None, role, + author, timestamp, text: String::new(), thinking: None, tool_uses: Vec::new(), - model: None, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -411,7 +417,7 @@ fn ensure_assistant<'a>( if current.is_none() { *seq += 1; let mut t = empty_turn(format!("a{seq}"), Role::Assistant, ts.to_string()); - t.model = model.clone(); + t.author = actor::agent(model.as_deref()); *current = Some(t); } current.as_mut().unwrap() diff --git a/crates/toolpath-cursor/README.md b/crates/toolpath-cursor/README.md index 04ab2f9b..b6553839 100644 --- a/crates/toolpath-cursor/README.md +++ b/crates/toolpath-cursor/README.md @@ -33,7 +33,7 @@ with content-blob lookup) and maps it to Toolpath documents. | Cursor source | Toolpath destination | |---|---| | `composerData.composerId` | `ConversationView.id`, `path.id = path-cursor-` | -| `composerData.modelConfig.modelName` | Default `Turn.model` | +| `composerData.modelConfig.modelName` | Default `Turn.author` model name | | `composerData.name` | `path.meta.title` | | `composerHeader.workspaceIdentifier.uri.fsPath` | `path.base.uri` (as `file://…`) | | `composerData.agentBackend` (`"cursor-agent"`) | `producer.name` (`"cursor"`) | @@ -46,7 +46,7 @@ with content-blob lookup) and maps it to Toolpath documents. | `toolFormerData.tool == 40` (read_file_v2) | `ToolCategory::FileRead` | | `toolFormerData.tool == 42` (glob_file_search) | `ToolCategory::FileSearch` | | `bubble.tokenCount` | `Turn.token_usage` | -| `bubble.modelInfo.modelName` | `Turn.model` (overrides composer default) | +| `bubble.modelInfo.modelName` | `Turn.author` model name (overrides composer default) | | Unknown `toolFormerData.tool` ids | preserved as `ToolInvocation { category: None }` — `name` + `input` still carry the call | Provider-specific UI metadata (`checkpointId`, `requestId`, diff --git a/crates/toolpath-cursor/examples/dump_fixture.rs b/crates/toolpath-cursor/examples/dump_fixture.rs index 6fc8ee73..da594725 100644 --- a/crates/toolpath-cursor/examples/dump_fixture.rs +++ b/crates/toolpath-cursor/examples/dump_fixture.rs @@ -25,6 +25,7 @@ use std::fs; use std::path::PathBuf; use serde_json::Value; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, EnvironmentSnapshot, ProducerInfo, Role, SessionBase, ToolInvocation, Turn, @@ -273,6 +274,10 @@ fn view_from_jsonl( id: turn_id.clone(), parent_id: prev_id.clone(), group_id: None, + author: match &role { + Role::User => actor::generic_human(), + _ => actor::unnamed_agent(), + }, role, // Synthesize plausible monotonic timestamps; the // transcript carries no real ones. @@ -280,7 +285,6 @@ fn view_from_jsonl( text, thinking: None, tool_uses, - model: None, stop_reason: None, token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-cursor/src/project.rs b/crates/toolpath-cursor/src/project.rs index 97934fba..5d89461c 100644 --- a/crates/toolpath-cursor/src/project.rs +++ b/crates/toolpath-cursor/src/project.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, FileMutation, Result, Role, ToolInvocation, Turn, @@ -289,8 +290,7 @@ fn build_bubble(turn: &Turn, content_blobs: &mut HashMap) -> Bub let model_info = if is_tool_bubble { None } else { - turn.model - .as_deref() + actor::model_name(&turn.author) .filter(|m| !m.is_empty()) .map(|m| ModelInfo { model_name: Some(m.to_string()), @@ -918,7 +918,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -942,7 +942,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-opus-4-7".into()), + author: actor::agent(Some("claude-opus-4-7")), stop_reason: None, token_usage: Some(TokenUsage { input_tokens: Some(10), diff --git a/crates/toolpath-cursor/src/provider.rs b/crates/toolpath-cursor/src/provider.rs index d1276f39..9a1fe9bb 100644 --- a/crates/toolpath-cursor/src/provider.rs +++ b/crates/toolpath-cursor/src/provider.rs @@ -38,6 +38,7 @@ use crate::types::{ BUBBLE_TYPE_ASSISTANT, BUBBLE_TYPE_USER, Bubble, CursorSession, CursorSessionMetadata, TOOL_EDIT_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2, ToolFormerData, tool_name_for_id, }; +use toolpath_convo::actor; use toolpath_convo::{ ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, @@ -373,7 +374,7 @@ impl<'a> Builder<'a> { text: bubble.text.clone(), thinking: None, tool_uses: Vec::new(), - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -464,7 +465,7 @@ impl<'a> Builder<'a> { text: bubble.text.clone(), thinking, tool_uses, - model, + author: actor::agent(model.as_deref()), stop_reason: None, token_usage, attributed_token_usage: None, @@ -755,7 +756,10 @@ mod tests { assert_eq!(view.turns[1].role, Role::Assistant); assert_eq!(view.turns[1].text, "hi back"); - assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-7")); + assert_eq!( + actor::model_name(&view.turns[1].author), + Some("claude-opus-4-7") + ); assert_eq!( view.turns[1].token_usage.as_ref().unwrap().input_tokens, Some(10) diff --git a/crates/toolpath-cursor/tests/projection_roundtrip.rs b/crates/toolpath-cursor/tests/projection_roundtrip.rs index c4317a0a..306bef91 100644 --- a/crates/toolpath-cursor/tests/projection_roundtrip.rs +++ b/crates/toolpath-cursor/tests/projection_roundtrip.rs @@ -12,6 +12,7 @@ use rusqlite::Connection; use tempfile::TempDir; use toolpath::v1::{Graph, Path}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, DeriveConfig, derive_path, extract_conversation, }; @@ -239,7 +240,7 @@ fn projector_accepts_foreign_view_shape() { text: "rename main".into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -268,7 +269,7 @@ fn projector_accepts_foreign_view_shape() { }), category: Some(ToolCategory::FileWrite), }], - model: Some("claude-opus-4-7".into()), + author: actor::agent(Some("claude-opus-4-7")), stop_reason: Some("end_turn".into()), token_usage: Some(TokenUsage { input_tokens: Some(20), diff --git a/crates/toolpath-gemini/src/project.rs b/crates/toolpath-gemini/src/project.rs index 5d487f3f..e62af22c 100644 --- a/crates/toolpath-gemini/src/project.rs +++ b/crates/toolpath-gemini/src/project.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use serde_json::{Map, Value}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, DelegatedWork, Result, Role, TokenUsage, ToolCategory, ToolInvocation, Turn, @@ -148,7 +149,7 @@ fn turn_to_message(turn: &Turn) -> GeminiMessage { content: build_content(turn), thoughts: build_thoughts(turn, &gemini_extras), tokens: build_tokens(turn, &gemini_extras), - model: turn.model.clone(), + model: actor::model_name(&turn.author).map(str::to_string), tool_calls: build_tool_calls(turn, &gemini_extras), extra: msg_extras, } @@ -605,7 +606,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -625,7 +626,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("gemini-3-flash-preview".into()), + author: actor::agent(Some("gemini-3-flash-preview")), stop_reason: None, token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-gemini/src/provider.rs b/crates/toolpath-gemini/src/provider.rs index ead4700a..9140003b 100644 --- a/crates/toolpath-gemini/src/provider.rs +++ b/crates/toolpath-gemini/src/provider.rs @@ -12,8 +12,9 @@ use crate::GeminiConvo; use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, Thought, Tokens, ToolCall}; use serde_json::Value; +use toolpath_convo::actor; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, + Actor, ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -28,6 +29,20 @@ fn gemini_role_to_role(role: &GeminiRole) -> Role { } } +/// This crate's provider id — `ConversationView::provider_id`, and the tool +/// actor a turn the CLI wrote itself is attributed to. +pub(crate) const PROVIDER_ID: &str = "gemini-cli"; + +/// Who wrote a message. Gemini CLI names a model only on model replies; +/// `info` and provider-specific roles are the CLI speaking for itself. +fn gemini_author(role: &GeminiRole, model: Option) -> Actor { + match role { + GeminiRole::User => actor::generic_human(), + GeminiRole::Gemini => actor::agent(model.as_deref()), + GeminiRole::Info | GeminiRole::Other(_) => actor::harness(PROVIDER_ID), + } +} + /// Classify a Gemini CLI tool name into toolpath's category ontology. /// /// Returns `None` for unrecognized tools. Keep this table in sync with @@ -116,11 +131,11 @@ fn message_to_turn(msg: &GeminiMessage, working_dir: Option<&str>) -> Turn { parent_id: None, group_id: None, role: gemini_role_to_role(&msg.role), + author: gemini_author(&msg.role, msg.model.clone()), timestamp: msg.timestamp.clone(), text, thinking, tool_uses, - model: msg.model.clone(), stop_reason: None, token_usage, attributed_token_usage: None, @@ -463,7 +478,7 @@ fn conversation_to_view(convo: &Conversation) -> ConversationView { last_activity: convo.last_activity, turns, total_usage, - provider_id: Some("gemini-cli".into()), + provider_id: Some(PROVIDER_ID.into()), files_changed, session_ids: vec![], events: vec![], @@ -719,7 +734,7 @@ mod tests { assert_eq!(view.turns[1].role, Role::Assistant); assert_eq!(view.turns[1].text, "I'll delegate."); assert_eq!( - view.turns[1].model.as_deref(), + actor::model_name(&view.turns[1].author), Some("gemini-3-flash-preview") ); } diff --git a/crates/toolpath-opencode/src/project.rs b/crates/toolpath-opencode/src/project.rs index 0f34ae45..b70b8510 100644 --- a/crates/toolpath-opencode/src/project.rs +++ b/crates/toolpath-opencode/src/project.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::path::PathBuf; use serde_json::{Map, Value}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; @@ -314,9 +315,8 @@ fn build_assistant_message( .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| default_provider.to_string()); - let model_id = turn - .model - .clone() + let model_id = actor::model_name(&turn.author) + .map(str::to_string) .or_else(|| { extras .as_ref() @@ -760,7 +760,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -780,7 +780,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-sonnet-4-6".into()), + author: actor::agent(Some("claude-sonnet-4-6")), stop_reason: Some("stop".into()), token_usage: None, attributed_token_usage: None, diff --git a/crates/toolpath-opencode/src/provider.rs b/crates/toolpath-opencode/src/provider.rs index 9f05349f..a550666f 100644 --- a/crates/toolpath-opencode/src/provider.rs +++ b/crates/toolpath-opencode/src/provider.rs @@ -38,6 +38,7 @@ use crate::types::{ AssistantMessage, Message, MessageData, Part, PartData, Session, SessionMetadata, Tokens, ToolState, UserMessage, }; +use toolpath_convo::actor; use toolpath_convo::{ ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, DelegatedWork, EnvironmentSnapshot, FileMutation, ProducerInfo, @@ -292,7 +293,7 @@ impl<'a> Builder<'a> { text, thinking: None, tool_uses: Vec::new(), - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -457,6 +458,11 @@ impl<'a> Builder<'a> { }, group_id: None, role: Role::Assistant, + author: actor::agent(if a.model_id.is_empty() { + None + } else { + Some(&a.model_id) + }), timestamp: millis_to_iso(msg.time_created), text: text_chunks.join("\n\n"), thinking: if thinking_chunks.is_empty() { @@ -465,11 +471,6 @@ impl<'a> Builder<'a> { Some(thinking_chunks.join("\n\n")) }, tool_uses, - model: if a.model_id.is_empty() { - None - } else { - Some(a.model_id.clone()) - }, stop_reason: stop_reason.or_else(|| a.finish.clone()), token_usage, attributed_token_usage: None, diff --git a/crates/toolpath-pi/src/project.rs b/crates/toolpath-pi/src/project.rs index a82b15d1..61fb55e4 100644 --- a/crates/toolpath-pi/src/project.rs +++ b/crates/toolpath-pi/src/project.rs @@ -21,6 +21,7 @@ use std::collections::HashMap; use serde_json::{Map, Value, json}; +use toolpath_convo::actor; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; @@ -393,7 +394,9 @@ fn emit_assistant( .clone() .unwrap_or_else(|| "anthropic".to_string()) }); - let model = turn.model.clone().unwrap_or_default(); + let model = actor::model_name(&turn.author) + .unwrap_or_default() + .to_string(); let usage = build_usage(turn); let stop_reason = parse_stop_reason(turn.stop_reason.as_deref(), pi.get("stopReason")); let error_message = pi @@ -766,7 +769,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: actor::generic_human(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -786,7 +789,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-sonnet-4-5".into()), + author: actor::agent(Some("claude-sonnet-4-5")), stop_reason: Some("stop".into()), token_usage: Some(TokenUsage { input_tokens: Some(100), diff --git a/crates/toolpath-pi/src/provider.rs b/crates/toolpath-pi/src/provider.rs index f536b952..c365e44b 100644 --- a/crates/toolpath-pi/src/provider.rs +++ b/crates/toolpath-pi/src/provider.rs @@ -19,12 +19,23 @@ use crate::types::{ use chrono::{DateTime, Utc}; use serde_json::{Value, json}; use std::collections::HashMap; +use toolpath_convo::actor; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, + Actor, ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, EnvironmentSnapshot, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; +/// This crate's provider id — `ConversationView::provider_id`, and the tool +/// actor a turn Pi wrote itself is attributed to. +pub(crate) const PROVIDER_ID: &str = "pi"; + +/// Pi itself, as an actor: compactions, branch summaries, bash runs and +/// custom entries are the harness speaking, not a model reply. +fn harness() -> Actor { + actor::harness(PROVIDER_ID) +} + // ── Classification helpers ─────────────────────────────────────────── /// Classify a Pi tool name into toolpath's category ontology. @@ -260,7 +271,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { text: format!("Compacted (summary): {}", summary), thinking: None, tool_uses: vec![], - model: None, + author: harness(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -280,7 +291,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { text: format!("Branch summary: {}", summary), thinking: None, tool_uses: vec![], - model: None, + author: harness(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -300,7 +311,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { text: String::new(), thinking: None, tool_uses: vec![], - model: None, + author: harness(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -325,7 +336,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { text: extract_user_text(content), thinking: None, tool_uses: vec![], - model: None, + author: harness(), stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -456,16 +467,23 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { } } + let author = match &role { + Role::User => actor::generic_human(), + Role::Assistant => actor::agent(model.as_deref()), + // Bash runs, custom entries and summaries are Pi + // speaking, not a model reply. + Role::System | Role::Other(_) => harness(), + }; turns.push(Turn { id: base.id.clone(), parent_id: base.parent_id.clone(), group_id: None, role, + author, timestamp: base.timestamp.clone(), text, thinking, tool_uses, - model, stop_reason: stop_reason_s, token_usage, attributed_token_usage: None, @@ -558,7 +576,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { last_activity, turns, total_usage, - provider_id: Some("pi".to_string()), + provider_id: Some(PROVIDER_ID.to_string()), files_changed, session_ids, events: vec![], @@ -811,7 +829,7 @@ mod tests { ); let v = session_to_view(&session_from(vec![entry], "/tmp/p")); assert_eq!(v.turns[0].role, Role::Assistant); - assert_eq!(v.turns[0].model.as_deref(), Some("claude-opus")); + assert_eq!(actor::model_name(&v.turns[0].author), Some("claude-opus")); assert_eq!(v.turns[0].stop_reason.as_deref(), Some("stop")); let u = v.turns[0].token_usage.as_ref().unwrap(); assert_eq!(u.input_tokens, Some(10)); diff --git a/crates/toolpath/Cargo.toml b/crates/toolpath/Cargo.toml index 77f38495..7d3fe8f3 100644 --- a/crates/toolpath/Cargo.toml +++ b/crates/toolpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath" -version = "0.7.0" +version = "0.7.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 423ab3f1..6c3daf9b 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -51,6 +51,8 @@ pub mod v1 { //! //! - [`StepMeta`], [`PathMeta`], [`GraphMeta`] — metadata containers //! - [`PATH_KIND_AGENT_CODING_SESSION`] — value for [`PathMeta::kind`] on conversation-derived paths + //! - [`Actor`] — a parsed `step.actor` reference, and the grammar it renders + //! - [`ParseActorError`] — why an actor reference failed to parse //! - [`ActorDefinition`] — full actor details (name, provider, keys) //! - [`Identity`] — external identity reference //! - [`Key`] — cryptographic key reference @@ -146,9 +148,9 @@ pub mod v1 { } pub use crate::types::{ - ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Key, - PATH_KIND_AGENT_CODING_SESSION, PATH_KIND_AGENT_CODING_SESSION_V1_0_0, Path, PathIdentity, - PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, StepMeta, - StructuralChange, VcsSource, + Actor, ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, + Key, PATH_KIND_AGENT_CODING_SESSION, PATH_KIND_AGENT_CODING_SESSION_V1_0_0, + ParseActorError, Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, Step, + StepIdentity, StepMeta, StructuralChange, VcsSource, }; } diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index 0d84d534..29c06bf5 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -315,6 +315,178 @@ pub struct Ref { pub href: String, } +/// Whether `s` is a legal actor segment: non-empty, and drawn from +/// `A`–`Z`, `a`–`z`, `0`–`9`, `_`, `.`, `-`. +fn is_actor_segment(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) +} + +/// A parsed actor reference — the `prefix:id` string that +/// [`StepIdentity::actor`] holds and [`PathMeta::actors`] keys on. +/// +/// # Grammar +/// +/// `prefix ":" id`, where each segment is one or more of `A`–`Z`, `a`–`z`, +/// `0`–`9`, `_`, `.`, `-`. +/// +/// The prefix set is **open**. `human:alex`, `agent:gpt-5.5`, `tool:rustfmt`, +/// `ci:github-actions` and `bot:dependabot` are all actor references, and +/// this type gives none of them special meaning: it validates the shape and +/// renders it back. What a prefix *means*, and which ids stand in for an +/// unnamed actor, belong to whatever writes the document — `toolpath-convo`, +/// for instance, owns the conventions of an agent coding session. +/// +/// # Sub-actors +/// +/// The grammar admits a `/`-delimited suffix qualifying an actor — +/// `tool:rustfmt/1.5.0`, `agent:claude-code/tool:Write`. `Actor` models the +/// actor proper: [`FromStr`](std::str::FromStr) keeps the segment before the +/// first `/` and drops the suffix, so an id never contains `/`. +/// [`Actor::split_sub_actor`] exposes the split for callers that need it. +/// +/// # Round-trip +/// +/// [`Display`](std::fmt::Display) writes the document form and +/// [`FromStr`](std::str::FromStr) reads it; they are the only place the +/// grammar is implemented, and serde uses them, so an `Actor` on the wire is +/// the actor string rather than a nested object. Every `Actor` renders to a +/// reference that parses back to itself, and every suffix-free reference +/// parses to an `Actor` that renders back to it unchanged. +/// +/// ``` +/// use toolpath::v1::Actor; +/// +/// let actor: Actor = "agent:gpt-5.5".parse().unwrap(); +/// assert_eq!(actor.prefix(), "agent"); +/// assert_eq!(actor.id(), "gpt-5.5"); +/// assert_eq!(actor.to_string(), "agent:gpt-5.5"); +/// +/// // Any prefix in the character set is an actor reference. +/// assert_eq!("bot:dependabot".parse::().unwrap().prefix(), "bot"); +/// +/// // Constructing from parts validates the same grammar. +/// assert_eq!(Actor::new("tool", "rustfmt").unwrap().to_string(), "tool:rustfmt"); +/// assert!(Actor::new("tool", "").is_err()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Actor { + prefix: String, + id: String, +} + +impl Actor { + /// Build an actor reference from its two segments, rejecting anything the + /// grammar cannot render back. + pub fn new(prefix: impl Into, id: impl Into) -> Result { + let prefix = prefix.into(); + if !is_actor_segment(&prefix) { + return Err(ParseActorError::InvalidPrefix(prefix)); + } + let id = id.into(); + if !is_actor_segment(&id) { + return Err(ParseActorError::InvalidId(id)); + } + Ok(Self { prefix, id }) + } + + /// The segment before the `:` — `"human"`, `"agent"`, `"tool"`, `"ci"`, + /// or anything else the writer chose. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// The segment after the `:`. This is the value that belongs in + /// [`ActorDefinition::name`]. + pub fn id(&self) -> &str { + &self.id + } + + /// Split an actor string into the actor proper and its optional + /// `/`-delimited sub-actor suffix. + /// + /// ``` + /// use toolpath::v1::Actor; + /// + /// assert_eq!( + /// Actor::split_sub_actor("agent:claude-code/tool:Write"), + /// ("agent:claude-code", Some("tool:Write")) + /// ); + /// assert_eq!(Actor::split_sub_actor("tool:rustfmt"), ("tool:rustfmt", None)); + /// ``` + pub fn split_sub_actor(actor: &str) -> (&str, Option<&str>) { + match actor.split_once('/') { + Some((base, sub)) => (base, Some(sub)), + None => (actor, None), + } + } +} + +impl std::fmt::Display for Actor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.prefix, self.id) + } +} + +impl std::str::FromStr for Actor { + type Err = ParseActorError; + + /// Parse an actor reference. + /// + /// Any sub-actor suffix is dropped first (see + /// [`Actor::split_sub_actor`]), then the remainder splits on its first + /// `:`. Both segments must be non-empty and within the grammar's + /// character set; the prefix is otherwise unconstrained. + fn from_str(s: &str) -> Result { + let (actor, _sub) = Actor::split_sub_actor(s); + let (prefix, id) = actor + .split_once(':') + .ok_or(ParseActorError::MissingPrefix)?; + Actor::new(prefix, id) + } +} + +impl Serialize for Actor { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Actor { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// Why an actor reference could not be parsed or built. See [`Actor`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseActorError { + /// The string carries no `prefix:` separator. + MissingPrefix, + /// The prefix is empty or holds a character outside the grammar's set. + InvalidPrefix(String), + /// The id is empty or holds a character outside the grammar's set. + InvalidId(String), +} + +impl std::fmt::Display for ParseActorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseActorError::MissingPrefix => f.write_str("actor has no `prefix:` separator"), + ParseActorError::InvalidPrefix(p) => { + write!(f, "actor prefix `{p}` is empty or has illegal characters") + } + ParseActorError::InvalidId(id) => { + write!(f, "actor id `{id}` is empty or has illegal characters") + } + } + } +} + +impl std::error::Error for ParseActorError {} + /// Full actor definition with identity and key information #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ActorDefinition { @@ -857,6 +1029,127 @@ mod tests { assert!(!json.contains("kind")); } + /// Every reference the round-trip properties below run over. The prefix + /// set is open, so novel prefixes belong in the corpus alongside the + /// conventional ones. + fn actor_refs() -> Vec<&'static str> { + vec![ + "human:user", + "human:alex", + "agent:unknown", + "agent:gpt-5.5", + "agent:claude-opus-4-6", + "tool:rustfmt", + "tool:claude-code", + "ci:github-actions", + "bot:dependabot", + "SERVICE:Some_Thing.v2-beta", + ] + } + + #[test] + fn test_actor_display_of_parse_is_identity() { + for canonical in actor_refs() { + assert_eq!(canonical.parse::().unwrap().to_string(), canonical); + } + } + + #[test] + fn test_actor_parse_of_display_is_identity() { + for reference in actor_refs() { + let actor: Actor = reference.parse().unwrap(); + let rendered = actor.to_string(); + assert_eq!( + rendered.parse::().unwrap(), + actor, + "round trip through {rendered}" + ); + } + } + + #[test] + fn test_actor_serde_is_the_actor_string() { + for reference in actor_refs() { + let actor: Actor = reference.parse().unwrap(); + let json = serde_json::to_string(&actor).unwrap(); + assert_eq!(json, format!("\"{reference}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), actor); + } + assert!(serde_json::from_str::("\"no-prefix\"").is_err()); + } + + #[test] + fn test_actor_prefix_set_is_open() { + // No prefix is privileged: an unconventional one parses, keeps its + // spelling, and renders back unchanged. + let actor: Actor = "bot:dependabot".parse().unwrap(); + assert_eq!(actor.prefix(), "bot"); + assert_eq!(actor.id(), "dependabot"); + assert_eq!(actor.to_string(), "bot:dependabot"); + assert_eq!(Actor::new("bot", "dependabot").unwrap(), actor); + } + + #[test] + fn test_actor_parse_drops_the_sub_actor_suffix() { + let actor: Actor = "agent:claude-code/tool:Write".parse().unwrap(); + assert_eq!(actor, Actor::new("agent", "claude-code").unwrap()); + let actor: Actor = "tool:rustfmt/1.5.0".parse().unwrap(); + assert_eq!(actor, Actor::new("tool", "rustfmt").unwrap()); + assert_eq!( + Actor::split_sub_actor("agent:claude-code/tool:Write"), + ("agent:claude-code", Some("tool:Write")) + ); + assert_eq!(Actor::split_sub_actor("human:alex"), ("human:alex", None)); + } + + #[test] + fn test_actor_parse_errors() { + use crate::types::ParseActorError; + assert_eq!("".parse::(), Err(ParseActorError::MissingPrefix)); + assert_eq!("alex".parse::(), Err(ParseActorError::MissingPrefix)); + assert_eq!( + "tool:".parse::(), + Err(ParseActorError::InvalidId(String::new())) + ); + assert_eq!( + ":alex".parse::(), + Err(ParseActorError::InvalidPrefix(String::new())) + ); + assert_eq!( + "hu man:alex".parse::(), + Err(ParseActorError::InvalidPrefix("hu man".into())) + ); + assert_eq!( + "human:a/b".parse::(), + // The suffix splits off first, so this is `human:a`. + Ok(Actor::new("human", "a").unwrap()) + ); + assert_eq!( + "human:al ex".parse::(), + Err(ParseActorError::InvalidId("al ex".into())) + ); + assert_eq!( + "human:a:b".parse::(), + Err(ParseActorError::InvalidId("a:b".into())) + ); + } + + #[test] + fn test_actor_new_validates_both_segments() { + assert!(Actor::new("tool", "rustfmt").is_ok()); + assert!(Actor::new("", "alex").is_err()); + assert!(Actor::new("human", "").is_err()); + assert!(Actor::new("human", "a/b").is_err()); + assert!(Actor::new("human", "a:b").is_err()); + } + + #[test] + fn test_actor_accessors() { + let actor = Actor::new("agent", "gpt-5.5").unwrap(); + assert_eq!(actor.prefix(), "agent"); + assert_eq!(actor.id(), "gpt-5.5"); + } + #[test] fn test_identity_serialization() { let id = super::Identity { diff --git a/docs/agents/formats/codex.md b/docs/agents/formats/codex.md index 9734c359..12357d9a 100644 --- a/docs/agents/formats/codex.md +++ b/docs/agents/formats/codex.md @@ -848,10 +848,10 @@ The mapping below is what the provider actually emits. Source: | `session_meta.cwd` | `Turn.environment.working_dir`, `path.base.uri` | | `session_meta.git.commit_hash` | `path.base.ref_str` | | `session_meta` (full) | `path.meta.extra["codex"]` (originator, cli_version, model_provider, git block, forked_from_id) | -| `turn_context.model` | `Turn.model` on subsequent assistant turns | +| `turn_context.model` | `Turn.author` model name on subsequent assistant turns | | `turn_context` (full) | `ConversationEvent` (round-trip preservation) | | `message` role `user` | `Turn { role: User }` → Step with `actor: "human:user"` | -| `message` role `assistant` | `Turn { role: Assistant, model }` → Step with `actor: "agent:"` | +| `message` role `assistant` | `Turn { role: Assistant, author: agent(model) }` → Step with `actor: "agent:"` | | `message` role `developer` | `Turn { role: System }` → Step with `actor: "tool:codex"` | | `reasoning.encrypted_content` | `Turn.extra["codex"]["reasoning_encrypted"]` (**not** `Turn.thinking` — it would render as ciphertext) | | `reasoning.summary[].text` / `reasoning.content[].text` (plaintext) | `Turn.thinking` on the next assistant turn | diff --git a/docs/agents/formats/cursor.md b/docs/agents/formats/cursor.md index 20d12125..1e61742c 100644 --- a/docs/agents/formats/cursor.md +++ b/docs/agents/formats/cursor.md @@ -827,11 +827,11 @@ source. Suggested mapping into `ConversationView` + `toolpath::v1::Path`: | `composerData.composerId` | `ConversationView.id`, `path.id = path-cursor-` | | `composerData.name` / `subtitle` | `path.meta.title` (with `subtitle` falling back when `name` is missing) | | `composerData.createdAt` | `Turn.timestamp` on the first user turn, `path.meta.created_at` | -| `composerData.modelConfig.modelName` | Default `Turn.model`; per-bubble `modelInfo.modelName` wins | +| `composerData.modelConfig.modelName` | Default model name on `Turn.author`; per-bubble `modelInfo.modelName` wins | | `composerData.agentBackend` (`"cursor-agent"`) | `path.meta.source = "cursor"` + `path.meta.extra["cursor"]["backend"]` | | `workspaceIdentifier.uri.fsPath` | `Turn.environment.working_dir`, `path.base.uri` | | `bubbleId` with `type: 1` | `Turn { role: User }` → Step with `actor: "human:user"` | -| `bubbleId` with `type: 2`, no `toolFormerData`, `capabilityType: null` | `Turn { role: Assistant, model }` → Step with `actor: "agent:"` | +| `bubbleId` with `type: 2`, no `toolFormerData`, `capabilityType: null` | `Turn { role: Assistant, author: agent(model) }` → Step with `actor: "agent:"` | | `bubbleId` with `capabilityType: 30`, `allThinkingBlocks: [...]` | `Turn.thinking` on the next assistant turn (consistent with other providers) | | `bubbleId` with `toolFormerData` | `Turn.tool_uses[]` with `tool_call_id = toolFormerData.toolCallId`, `name = toolFormerData.name`, `input = parse(params)`, `result = parse(result)`, `status` mirrored | | `toolFormerData.result.{beforeContentId, afterContentId}` (edits) | `ArtifactChange` on the tool-call's turn, with `raw` perspective synthesized from `additionalData.precomputedDiff.lines` and the blob bodies looked up via `composer.content.` | diff --git a/docs/agents/formats/opencode.md b/docs/agents/formats/opencode.md index f4c7f84d..d599027c 100644 --- a/docs/agents/formats/opencode.md +++ b/docs/agents/formats/opencode.md @@ -688,7 +688,7 @@ Minimum viable mapping, if we follow the Pi-style approach (build a | `session.directory` + `project.worktree` | `Turn.environment.working_dir`, `path.base.uri` | | `project.id` (first-root-commit SHA) | `path.base.ref_str` (stable-enough) | | User `message` | `Turn { role: User }` | -| Assistant `message` | `Turn { role: Assistant, model: modelID }` | +| Assistant `message` | `Turn { role: Assistant, author: agent(modelID) }` | | `user.system` | `Turn { role: System }` or `ConversationEvent` | | `reasoning` part | `Turn.thinking` (plaintext — safe to render) | | `text` part | appended to `Turn.text` | diff --git a/site/_data/crates.json b/site/_data/crates.json index 4d282a97..7cf359b8 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -1,7 +1,7 @@ [ { "name": "toolpath", - "version": "0.7.0", + "version": "0.7.1", "description": "Core types, builders, and query API", "docs": "https://docs.rs/toolpath", "crate": "https://crates.io/crates/toolpath", @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.11.1", + "version": "0.12.0", "description": "Provider-agnostic conversation types, traits, and Toolpath-Path derivation", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo", @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.2", + "version": "0.13.0", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude",