diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f143f2d..9b825914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ 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`, with + `Human { id }`, `Agent { id }`, and `Tool { id }` mirroring the + grammar's three prefixes rather than any semantic label. `tool:` is the + general machine prefix (the spec's own example is `tool:rustfmt`), so a + harness is one kind of tool actor. `Display` renders the document form + and `FromStr` reads it — including the `human:user` / `agent:unknown` + placeholders and the `/`-delimited sub-actor suffix — and serde uses + the same grammar, so an `Actor` on the wire is the actor string. `Tool` + takes a required id because the grammar defines no placeholder for an + unnamed one, which is what makes `Display` total. 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. + + `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 must be 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. + + 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. Derived documents +are byte-identical to those the previous release produced, apart from the +misattributed harness turns this fixes. + ## 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..f2a0e0dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,7 @@ 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` — `Human { id }` / `Agent { id }` / `Tool { id }`, one variant per prefix. `Display` + `FromStr` are the *only* implementation of the grammar; serde uses them too, so an `Actor` on the wire is the actor string. `human:user` and `agent:unknown` are placeholders that parse to `None` and render back from it; `tool:` has none, so `Tool.id` is required and `Display` is total. A `/`-suffix (`agent:m/tool:Write`, `tool:rustfmt/1.5.0`) is a sub-actor qualifier: parsing keeps the segment before it (`Actor::split_sub_actor` exposes the split). Prefixes outside the three — `ci:` is in the schema pattern — fail to parse. Build or read an actor through this type; don't `format!("agent:{}")` or `starts_with("human:")`. `toolpath-convo`'s `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..990df296 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -5,6 +5,7 @@ //! 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, @@ -12,7 +13,7 @@ use crate::types::{ use serde_json::json; use std::collections::HashMap; use toolpath_convo::{ - ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, + Actor, ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; // ── ClaudeProjector ─────────────────────────────────────────────────── @@ -367,6 +368,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 { + match &turn.author { + Actor::Tool { .. } => Some(SYNTHETIC_MODEL.to_string()), + author => author.model_name().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 +412,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 +1052,7 @@ mod tests { text: text.to_string(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1061,7 +1072,7 @@ mod tests { text: text.to_string(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1421,7 +1432,9 @@ 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 { + id: Some("claude-opus-4-6".to_string()), + }; 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..45368711 100644 --- a/crates/toolpath-claude/src/provider.rs +++ b/crates/toolpath-claude/src/provider.rs @@ -12,12 +12,29 @@ use crate::types::{Conversation, ConversationEntry, Message, MessageContent, Mes #[cfg(any(feature = "watcher", test))] use toolpath_convo::WatcherEvent; 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::Tool { + id: PROVIDER_ID.to_string(), + } +} + fn claude_role_to_role(role: &MessageRole) -> Role { match role { MessageRole::User => Role::User, @@ -26,6 +43,22 @@ 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::Human { id: None }, + MessageRole::System => harness(), + MessageRole::Assistant => match msg.model.as_deref() { + Some(SYNTHETIC_MODEL) => harness(), + _ => Actor::Agent { + id: msg.model.clone(), + }, + }, + } +} + /// Classify a Claude Code tool into toolpath's category ontology. /// /// Returns `None` for unrecognized tools. When Claude Code adds or @@ -129,11 +162,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 +477,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 +893,7 @@ mod tests { text: String::new(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1061,7 +1094,7 @@ 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!(view.turns[1].author.model_name(), 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,47 @@ 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!(turn.author.model_name(), 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 { + id: Some("claude-opus-4-8".to_string()) + } + ); + } + + #[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::Agent { id: None }); + } + #[test] fn test_to_turn_without_message() { let entry: ConversationEntry = serde_json::from_str( @@ -1454,7 +1528,7 @@ mod tests { category: Some(ToolCategory::FileWrite), }, ], - model: None, + author: Actor::Agent { id: None }, 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..f3226c9c 100644 --- a/crates/toolpath-codex/src/project.rs +++ b/crates/toolpath-codex/src/project.rs @@ -125,7 +125,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| t.author.model_name().map(str::to_string)) + }) .unwrap_or_else(|| "unknown".to_string()); let session_timestamp = view @@ -707,7 +711,7 @@ fn convo_usage_to_codex_json(u: &toolpath_convo::TokenUsage) -> Value { #[cfg(test)] mod tests { use super::*; - use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult}; + use toolpath_convo::{Actor, TokenUsage, ToolCategory, ToolInvocation, ToolResult}; fn user_turn(id: &str, text: &str) -> Turn { Turn { @@ -719,7 +723,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -739,7 +743,9 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("gpt-5.4".into()), + author: Actor::Agent { + id: Some("gpt-5.4".into()), + }, 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..9e621473 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -41,11 +41,15 @@ use crate::types::{ }; use serde_json::Value; use toolpath_convo::{ - ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError, + Actor, 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 +366,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 +822,19 @@ fn message_to_turn( parent_id: None, group_id: None, role: role.clone(), + author: match &role { + Role::User => Actor::Human { id: None }, + Role::Assistant => Actor::Agent { + id: model.map(str::to_string), + }, + Role::System | Role::Other(_) => Actor::Tool { + id: PROVIDER_ID.to_string(), + }, + }, 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 +854,13 @@ fn synthetic_assistant_turn( parent_id: None, group_id: None, role: Role::Assistant, + author: Actor::Agent { + id: model.map(str::to_string), + }, 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 +1099,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!(view.turns[1].author.model_name(), 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..c3ad7b69 100644 --- a/crates/toolpath-convo/README.md +++ b/crates/toolpath-convo/README.md @@ -14,8 +14,9 @@ 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, re-exported from `toolpath`: `Human`, `Agent`, or `Tool` (a harness writing for itself). `Turn.author` holds one, and a derived step's `actor` string is that value rendered | | `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/derive.rs b/crates/toolpath-convo/src/derive.rs index 1463b74f..2d94fac8 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}; /// 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,12 @@ 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::Tool { + id: provider.to_string(), + }; + 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 +517,34 @@ 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 only a machine actor +/// carries a provider. 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() + let mut def = ActorDefinition { + name: Some(author.id().to_string()), + ..Default::default() + }; + match author { + Actor::Human { .. } => {} + Actor::Agent { id } => { + def.provider = Some(provider.to_string()); + def.model = id.clone(); } - } 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() + Actor::Tool { .. } => { + def.provider = Some(provider.to_string()); } - }; + } actors.insert(actor.to_string(), def); } @@ -715,19 +701,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::Human { id: None }, + Role::Assistant => Actor::Agent { id: None }, + Role::System | Role::Other(_) => Actor::Tool { id: "pi".into() }, + }; 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 +872,9 @@ 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 { + id: Some("claude-opus-4-7".into()), + }; turn.token_usage = Some(TokenUsage { input_tokens: Some(100), output_tokens: Some(900), @@ -938,20 +935,128 @@ 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 { + id: Some("claude-opus-4-7".into()), + }; 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::Agent { id: None }); 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 { + id: Some("ada".into()), + }; + 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::Tool { + id: "some-gateway".into(), + }; + 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::Tool { id: "pi".into() }; + 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::Tool { id: "pi".into() }; + let mut model = base_turn("t2", Role::Other("tool".into())); + model.author = Actor::Agent { + id: Some("claude-opus-4-8".into()), + }; + + 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::Tool { id: "pi".into() }; + 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::Tool { id: "pi".into() }); + } + + #[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 { + id: Some("claude-opus-4-7".into()) + } + ); + 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::Agent { id: None }); + } + #[test] fn test_system_role() { let turn = base_turn("t1", Role::System); @@ -973,7 +1078,9 @@ 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 { + id: Some("m".into()), + }; 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,7 +1091,9 @@ 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 { + id: Some("gpt-5.5".into()), + }; let system = base_turn("t3", Role::System); let other = base_turn("t4", Role::Other("bash".into())); @@ -1028,7 +1137,9 @@ 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 { + id: Some("gpt-5.5".into()), + }; assistant.text = "on it".into(); assistant.thinking = Some("plan the edit".into()); assistant.stop_reason = Some("tool_use".into()); @@ -1494,7 +1605,9 @@ 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 { + id: Some("claude-opus-4-7".into()), + }; let view = view_with(vec![u, a]); let path = derive_path(&view, &DeriveConfig::default()); let actors = path.meta.unwrap().actors.unwrap(); @@ -1692,7 +1805,9 @@ 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 { + id: Some("m".into()), + }; 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..38af3380 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,31 @@ 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, including the `agent:unknown` / `human:user` +/// placeholders and the sub-actor form `agent:{model}/tool:…` (which only +/// appears on non-turn tool steps, but is handled for robustness). +/// +/// A reference `Actor` cannot represent — no prefix, an unknown one such as +/// `ci:`, or a bare `tool:` — 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(Actor::Agent { id: None }) } 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(Actor::Tool { .. }))) { // 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()); + } + match base.parse() { + Ok(Actor::Human { .. }) => Role::User, + Ok(Actor::Agent { .. }) => Role::Assistant, + Ok(Actor::Tool { .. }) => Role::System, + Err(_) => Role::Other(actor.to_string()), } } @@ -545,30 +535,149 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange}; + fn agent(name: &str) -> Actor { + Actor::Agent { + id: Some(name.to_string()), + } + } + + #[test] + fn test_author_from_actor_variants() { + // The generic user and a named one. + assert_eq!(author_from_actor("human:user"), Actor::Human { id: None }); + assert_eq!( + author_from_actor("human:ada"), + Actor::Human { + id: Some("ada".to_string()) + } + ); + // 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"), + Actor::Agent { id: None } + ); + // 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!( + author_from_actor("tool:gemini-cli"), + Actor::Tool { + id: "gemini-cli".to_string() + } + ); + // Anything the grammar can't represent reads as an unnamed agent. + assert_eq!(author_from_actor(""), Actor::Agent { id: None }); + assert_eq!(author_from_actor("agent:"), Actor::Agent { id: None }); + assert_eq!( + author_from_actor("system:gemini-cli"), + Actor::Agent { id: None } + ); + assert_eq!(author_from_actor("tool:"), Actor::Agent { id: None }); + } + #[test] - fn test_model_from_actor_variants() { + 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!( - model_from_actor("agent:claude-opus-4-7"), - Some("claude-opus-4-7".to_string()) + 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!( - model_from_actor("agent:gemini-3-flash-preview"), - Some("gemini-3-flash-preview".to_string()) + role_from_actor("ci:github-actions"), + Role::Other("ci:github-actions".to_string()) ); - // Sub-actor form (Claude tool steps): model is the part before "/". + } + + #[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, Actor::Human { id: None }), + base( + "t2", + Role::Assistant, + Actor::Agent { + id: Some("claude-opus-4-7".to_string()), + }, + ), + base("t3", Role::Assistant, Actor::Agent { id: None }), + // The harness speaking in the assistant slot. + base( + "t4", + Role::Assistant, + Actor::Tool { + id: "pi".to_string(), + }, + ), + base( + "t5", + Role::System, + Actor::Tool { + id: "pi".to_string(), + }, + ), + ], + ..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!( - model_from_actor("agent:claude-code/tool:Write"), - Some("claude-code".to_string()) + actors(&first), + vec![ + "human:user", + "agent:claude-opus-4-7", + "agent:unknown", + "tool:pi", + "tool:pi", + ] ); - // `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); + 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 +815,7 @@ 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!(view.turns[1].author.model_name(), Some("claude-opus-4-6")); } #[test] diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index dcf3c3e2..c8031d45 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -11,6 +11,11 @@ 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. +pub use toolpath::v1::Actor; + // ── Error ──────────────────────────────────────────────────────────── /// Errors from conversation provider operations. @@ -54,6 +59,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 +/// (`agent:…`, `human:…`, `tool:…`), 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::Agent { id: None }, + Some(s) => s.parse().unwrap_or(Actor::Agent { id: Some(s) }), + }) +} + /// Token usage for a single turn. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TokenUsage { @@ -263,9 +292,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::Tool`], 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 +315,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 +617,7 @@ mod tests { text: "Fix the authentication bug in login.rs".into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -609,7 +643,9 @@ mod tests { }), category: Some(ToolCategory::FileRead), }], - model: Some("claude-opus-4-6".into()), + author: Actor::Agent { + id: Some("claude-opus-4-6".into()), + }, 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::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -734,7 +770,7 @@ 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!(back.author.model_name(), 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 +1008,7 @@ mod tests { text: "Delegating...".into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Agent { id: None }, 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..c7d589c3 100644 --- a/crates/toolpath-convo/src/project.rs +++ b/crates/toolpath-convo/src/project.rs @@ -134,7 +134,7 @@ impl AnyProjector { #[cfg(test)] mod tests { use super::*; - use crate::{Role, TokenUsage, ToolInvocation, ToolResult, Turn}; + use crate::{Actor, Role, TokenUsage, ToolInvocation, ToolResult, Turn}; // ── helpers ────────────────────────────────────────────────────── @@ -154,16 +154,23 @@ mod tests { } fn make_turn(id: &str, role: Role, text: &str) -> Turn { + let author = match &role { + Role::User => Actor::Human { id: None }, + Role::Assistant => Actor::Agent { id: None }, + Role::System | Role::Other(_) => Actor::Tool { + id: "test-provider".into(), + }, + }; 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 +370,7 @@ mod tests { category: None, }, ], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -421,7 +428,7 @@ mod tests { text: "turn 1".into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: Some(TokenUsage { input_tokens: Some(100), @@ -444,7 +451,7 @@ mod tests { text: "turn 2".into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Agent { id: None }, 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..20032352 100644 --- a/crates/toolpath-copilot/src/project.rs +++ b/crates/toolpath-copilot/src/project.rs @@ -208,7 +208,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) = turn.author.model_name() { data.insert("model".into(), json!(m)); } if let Some(th) = &turn.thinking { @@ -660,6 +660,7 @@ fn iso_or(s: &str, fallback: &str) -> String { mod tests { use super::*; use crate::provider::to_view; + use toolpath_convo::Actor; #[test] fn round_trips_a_view() { @@ -697,7 +698,7 @@ 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!(view2.turns[1].author.model_name(), Some("claude-haiku-4.5")); assert_eq!( view2.turns[1].token_usage.as_ref().unwrap().output_tokens, Some(42) @@ -783,7 +784,7 @@ mod tests { }), category: Some(ToolCategory::Shell), }], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -837,7 +838,7 @@ mod tests { text: String::new(), thinking: None, tool_uses: vec![tool], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -988,7 +989,7 @@ mod tests { json!({"file_path": "/p/b.rs", "offset": 10, "limit": 5}), ), ], - model: None, + author: Actor::Agent { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -1053,7 +1054,7 @@ mod tests { }), category: Some(ToolCategory::Shell), }], - model: None, + author: Actor::Agent { id: None }, 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..bace5859 100644 --- a/crates/toolpath-copilot/src/provider.rs +++ b/crates/toolpath-copilot/src/provider.rs @@ -11,7 +11,7 @@ use crate::types::{CopilotEvent, Session}; use serde_json::Value; use std::collections::HashMap; use toolpath_convo::{ - ConversationEvent, ConversationView, DelegatedWork, FileMutation, ProducerInfo, Role, + Actor, ConversationEvent, ConversationView, DelegatedWork, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -178,7 +178,9 @@ 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 { + id: default_model.clone(), + }; current = Some(t); } CopilotEvent::AssistantMessage(m) => { @@ -195,8 +197,10 @@ 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 cur.author.model_name().is_none() { + cur.author = Actor::Agent { + id: m.model.clone().or_else(|| default_model.clone()), + }; } } CopilotEvent::AssistantTurnEnd => { @@ -383,16 +387,23 @@ 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::Human { id: None }, + Role::Assistant => Actor::Agent { id: None }, + Role::System | Role::Other(_) => Actor::Tool { + id: PROVIDER_ID.to_string(), + }, + }; 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 +422,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 { id: model.clone() }; *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..70719a13 100644 --- a/crates/toolpath-cursor/examples/dump_fixture.rs +++ b/crates/toolpath-cursor/examples/dump_fixture.rs @@ -26,8 +26,8 @@ use std::path::PathBuf; use serde_json::Value; use toolpath_convo::{ - ConversationProjector, ConversationView, EnvironmentSnapshot, ProducerInfo, Role, SessionBase, - ToolInvocation, Turn, + Actor, ConversationProjector, ConversationView, EnvironmentSnapshot, ProducerInfo, Role, + SessionBase, ToolInvocation, Turn, }; use toolpath_cursor::project::CursorProjector; use toolpath_cursor::provider::tool_category; @@ -273,6 +273,10 @@ fn view_from_jsonl( id: turn_id.clone(), parent_id: prev_id.clone(), group_id: None, + author: match &role { + Role::User => Actor::Human { id: None }, + _ => Actor::Agent { id: None }, + }, role, // Synthesize plausible monotonic timestamps; the // transcript carries no real ones. @@ -280,7 +284,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..2bf5cb0e 100644 --- a/crates/toolpath-cursor/src/project.rs +++ b/crates/toolpath-cursor/src/project.rs @@ -289,8 +289,8 @@ fn build_bubble(turn: &Turn, content_blobs: &mut HashMap) -> Bub let model_info = if is_tool_bubble { None } else { - turn.model - .as_deref() + turn.author + .model_name() .filter(|m| !m.is_empty()) .map(|m| ModelInfo { model_name: Some(m.to_string()), @@ -904,8 +904,8 @@ mod tests { use super::*; use serde_json::json; use toolpath_convo::{ - EnvironmentSnapshot, ProducerInfo, SessionBase, TokenUsage, ToolCategory, ToolInvocation, - ToolResult, + Actor, EnvironmentSnapshot, ProducerInfo, SessionBase, TokenUsage, ToolCategory, + ToolInvocation, ToolResult, }; fn user_turn(id: &str, text: &str) -> Turn { @@ -918,7 +918,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -942,7 +942,9 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-opus-4-7".into()), + author: Actor::Agent { + id: Some("claude-opus-4-7".into()), + }, 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..f664baa5 100644 --- a/crates/toolpath-cursor/src/provider.rs +++ b/crates/toolpath-cursor/src/provider.rs @@ -39,7 +39,7 @@ use crate::types::{ TOOL_EDIT_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2, ToolFormerData, tool_name_for_id, }; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, + Actor, ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, unified_diff, }; @@ -373,7 +373,7 @@ impl<'a> Builder<'a> { text: bubble.text.clone(), thinking: None, tool_uses: Vec::new(), - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -464,7 +464,7 @@ impl<'a> Builder<'a> { text: bubble.text.clone(), thinking, tool_uses, - model, + author: Actor::Agent { id: model }, stop_reason: None, token_usage, attributed_token_usage: None, @@ -755,7 +755,7 @@ 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!(view.turns[1].author.model_name(), 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..81e5691a 100644 --- a/crates/toolpath-cursor/tests/projection_roundtrip.rs +++ b/crates/toolpath-cursor/tests/projection_roundtrip.rs @@ -214,7 +214,7 @@ fn projector_serializes_to_disk_readable_shape() { fn projector_accepts_foreign_view_shape() { use serde_json::json; use toolpath_convo::{ - EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, + Actor, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -239,7 +239,7 @@ fn projector_accepts_foreign_view_shape() { text: "rename main".into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -268,7 +268,9 @@ fn projector_accepts_foreign_view_shape() { }), category: Some(ToolCategory::FileWrite), }], - model: Some("claude-opus-4-7".into()), + author: Actor::Agent { + id: Some("claude-opus-4-7".into()), + }, 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..1ade9969 100644 --- a/crates/toolpath-gemini/src/project.rs +++ b/crates/toolpath-gemini/src/project.rs @@ -148,7 +148,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: turn.author.model_name().map(str::to_string), tool_calls: build_tool_calls(turn, &gemini_extras), extra: msg_extras, } @@ -563,7 +563,7 @@ fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile { mod tests { use super::*; use std::collections::BTreeMap; - use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult}; + use toolpath_convo::{Actor, EnvironmentSnapshot, ToolCategory, ToolResult}; #[test] fn tokens_from_common_unfolds_reasoning_out_of_output() { @@ -605,7 +605,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -625,7 +625,9 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("gemini-3-flash-preview".into()), + author: Actor::Agent { + id: Some("gemini-3-flash-preview".into()), + }, 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..2f1fdcb3 100644 --- a/crates/toolpath-gemini/src/provider.rs +++ b/crates/toolpath-gemini/src/provider.rs @@ -13,7 +13,7 @@ use crate::GeminiConvo; use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, Thought, Tokens, ToolCall}; use serde_json::Value; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, + Actor, ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -28,6 +28,22 @@ 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::Human { id: None }, + GeminiRole::Gemini => Actor::Agent { id: model }, + GeminiRole::Info | GeminiRole::Other(_) => Actor::Tool { + id: PROVIDER_ID.to_string(), + }, + } +} + /// Classify a Gemini CLI tool name into toolpath's category ontology. /// /// Returns `None` for unrecognized tools. Keep this table in sync with @@ -116,11 +132,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 +479,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 +735,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(), + view.turns[1].author.model_name(), Some("gemini-3-flash-preview") ); } diff --git a/crates/toolpath-opencode/src/project.rs b/crates/toolpath-opencode/src/project.rs index 0f34ae45..0c980cba 100644 --- a/crates/toolpath-opencode/src/project.rs +++ b/crates/toolpath-opencode/src/project.rs @@ -315,8 +315,9 @@ fn build_assistant_message( .map(str::to_string) .unwrap_or_else(|| default_provider.to_string()); let model_id = turn - .model - .clone() + .author + .model_name() + .map(str::to_string) .or_else(|| { extras .as_ref() @@ -748,7 +749,7 @@ fn stable_hex40(seed: &[u8]) -> String { mod tests { use super::*; use serde_json::json; - use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult}; + use toolpath_convo::{Actor, ToolCategory, ToolInvocation, ToolResult}; fn user_turn(text: &str) -> Turn { Turn { @@ -760,7 +761,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -780,7 +781,9 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-sonnet-4-6".into()), + author: Actor::Agent { + id: Some("claude-sonnet-4-6".into()), + }, 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..acd9757c 100644 --- a/crates/toolpath-opencode/src/provider.rs +++ b/crates/toolpath-opencode/src/provider.rs @@ -39,7 +39,7 @@ use crate::types::{ ToolState, UserMessage, }; use toolpath_convo::{ - ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, + Actor, ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, DelegatedWork, EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn, }; @@ -292,7 +292,7 @@ impl<'a> Builder<'a> { text, thinking: None, tool_uses: Vec::new(), - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -457,6 +457,13 @@ impl<'a> Builder<'a> { }, group_id: None, role: Role::Assistant, + author: Actor::Agent { + id: if a.model_id.is_empty() { + None + } else { + Some(a.model_id.clone()) + }, + }, timestamp: millis_to_iso(msg.time_created), text: text_chunks.join("\n\n"), thinking: if thinking_chunks.is_empty() { @@ -465,11 +472,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..3e8f5a3d 100644 --- a/crates/toolpath-pi/src/project.rs +++ b/crates/toolpath-pi/src/project.rs @@ -393,7 +393,7 @@ fn emit_assistant( .clone() .unwrap_or_else(|| "anthropic".to_string()) }); - let model = turn.model.clone().unwrap_or_default(); + let model = turn.author.model_name().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 @@ -754,7 +754,7 @@ fn extra_map_from(v: Option<&Value>) -> HashMap { #[cfg(test)] mod tests { use super::*; - use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult}; + use toolpath_convo::{Actor, TokenUsage, ToolCategory, ToolInvocation, ToolResult}; fn user_turn(id: &str, text: &str) -> Turn { Turn { @@ -766,7 +766,7 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: None, + author: Actor::Human { id: None }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -786,7 +786,9 @@ mod tests { text: text.into(), thinking: None, tool_uses: vec![], - model: Some("claude-sonnet-4-5".into()), + author: Actor::Agent { + id: Some("claude-sonnet-4-5".into()), + }, 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..7a4a7e3d 100644 --- a/crates/toolpath-pi/src/provider.rs +++ b/crates/toolpath-pi/src/provider.rs @@ -20,11 +20,23 @@ use chrono::{DateTime, Utc}; use serde_json::{Value, json}; use std::collections::HashMap; 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::Tool { + id: PROVIDER_ID.to_string(), + } +} + // ── Classification helpers ─────────────────────────────────────────── /// Classify a Pi tool name into toolpath's category ontology. @@ -260,7 +272,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 +292,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 +312,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 +337,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 +468,23 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { } } + let author = match &role { + Role::User => Actor::Human { id: None }, + Role::Assistant => Actor::Agent { id: model }, + // 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 +577,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 +830,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!(v.turns[0].author.model_name(), 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..ae5635ea 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -315,6 +315,207 @@ pub struct Ref { pub href: String, } +/// The id an [`Actor::Human`] renders when it names no one. +const GENERIC_HUMAN_ID: &str = "user"; + +/// The id an [`Actor::Agent`] renders when it names no model. +const UNNAMED_AGENT_ID: &str = "unknown"; + +/// A parsed actor reference — the `type:id` string that [`StepIdentity::actor`] +/// holds and [`PathMeta::actors`] keys on. +/// +/// [`Display`](std::fmt::Display) writes the document form and +/// [`FromStr`](std::str::FromStr) reads it; they are the only place the +/// grammar is implemented. Serde uses the same grammar, so an `Actor` on the +/// wire is the actor string, not a nested object. +/// +/// # Prefixes +/// +/// - `human:` — a person. +/// - `agent:` — a model or agent, the thing that produces text and decisions. +/// - `tool:` — the general machine prefix, for anything that is not a model: +/// `tool:rustfmt`, a CI job, or an agent harness writing on its own behalf. +/// A harness is one *kind* of tool actor, which is why there is no separate +/// harness variant. +/// +/// # Placeholders +/// +/// `human:user` and `agent:unknown` are sentinels, not ids: they say "a +/// person" and "a model ran, unnamed". They parse to `None` and an id of +/// `None` renders back to them, so `Human { id: Some("user") }` and +/// `Human { id: None }` are the same actor. `tool:` defines no such +/// sentinel — an unnamed tool actor has no representation, which is why +/// [`Actor::Tool`] takes a required `id` and `Display` is total. +/// +/// # 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: parsing keeps the segment before the first `/` and drops the +/// suffix, so an id never contains `/` and a sub-actor string does not +/// round-trip. [`Actor::split_sub_actor`] exposes the split for callers that +/// need the suffix. +/// +/// ``` +/// use toolpath::v1::Actor; +/// +/// let actor: Actor = "agent:gpt-5.5".parse().unwrap(); +/// assert_eq!(actor, Actor::Agent { id: Some("gpt-5.5".into()) }); +/// assert_eq!(actor.to_string(), "agent:gpt-5.5"); +/// +/// assert_eq!(Actor::Human { id: None }.to_string(), "human:user"); +/// assert_eq!(Actor::Agent { id: None }.to_string(), "agent:unknown"); +/// assert_eq!(Actor::Tool { id: "rustfmt".into() }.to_string(), "tool:rustfmt"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Actor { + /// A person. `None` names no one in particular and renders `human:user`. + Human { id: Option }, + /// A model or agent. `None` means one acted but the source did not name + /// it, and renders the `agent:unknown` sentinel — a different claim from + /// "no model was involved", which is a [`Actor::Tool`]. + Agent { id: Option }, + /// A machine actor that is not a model. Required `id`: the grammar + /// defines no placeholder for an unnamed one. + Tool { id: String }, +} + +impl Actor { + /// The actor's prefix: `"human"`, `"agent"`, or `"tool"`. + pub fn prefix(&self) -> &'static str { + match self { + Actor::Human { .. } => "human", + Actor::Agent { .. } => "agent", + Actor::Tool { .. } => "tool", + } + } + + /// The id segment as it is rendered, with the placeholder substituted for + /// an unnamed `Human` or `Agent`. This is the value that belongs in + /// [`ActorDefinition::name`]. + pub fn id(&self) -> &str { + match self { + Actor::Human { id } => id.as_deref().unwrap_or(GENERIC_HUMAN_ID), + Actor::Agent { id } => id.as_deref().unwrap_or(UNNAMED_AGENT_ID), + Actor::Tool { id } => id, + } + } + + /// The model an [`Actor::Agent`] names, if it names one — the value that + /// belongs in [`ActorDefinition::model`]. `None` for every other variant, + /// and for an agent the source left unnamed. + pub fn model_name(&self) -> Option<&str> { + match self { + Actor::Agent { id } => id.as_deref(), + _ => None, + } + } + + /// 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 string splits on its first `:`. + /// An empty or placeholder id (`human:`, `human:user`, `agent:`, + /// `agent:unknown`) yields `None`. `tool:` with an empty id and any + /// prefix outside `human`/`agent`/`tool` — `ci:` among them — are + /// errors: this type only represents what it can render back. + fn from_str(s: &str) -> Result { + let (actor, _sub) = Actor::split_sub_actor(s); + let (prefix, id) = actor + .split_once(':') + .ok_or(ParseActorError::MissingPrefix)?; + let named = |placeholder: &str| { + if id.is_empty() || id == placeholder { + None + } else { + Some(id.to_string()) + } + }; + match prefix { + "human" => Ok(Actor::Human { + id: named(GENERIC_HUMAN_ID), + }), + "agent" => Ok(Actor::Agent { + id: named(UNNAMED_AGENT_ID), + }), + "tool" if id.is_empty() => Err(ParseActorError::EmptyToolId), + "tool" => Ok(Actor::Tool { id: id.to_string() }), + other => Err(ParseActorError::UnknownPrefix(other.to_string())), + } + } +} + +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. See [`Actor`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseActorError { + /// The string carries no `type:` prefix. + MissingPrefix, + /// The prefix is not one of `human`, `agent`, `tool`. + UnknownPrefix(String), + /// A `tool:` reference with no id. Tool actors have no placeholder, so an + /// unnamed one cannot be represented. + EmptyToolId, +} + +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 `type:` prefix"), + ParseActorError::UnknownPrefix(p) => { + write!( + f, + "unknown actor prefix `{p}`, expected human, agent or tool" + ) + } + ParseActorError::EmptyToolId => f.write_str("`tool:` actor has an empty id"), + } + } +} + +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 +1058,146 @@ mod tests { assert!(!json.contains("kind")); } + /// Every shape an `Actor` can hold, for the round-trip properties below. + fn actor_shapes() -> Vec { + vec![ + Actor::Human { id: None }, + Actor::Human { + id: Some("alex".into()), + }, + Actor::Agent { id: None }, + Actor::Agent { + id: Some("gpt-5.5".into()), + }, + Actor::Agent { + id: Some("claude-code".into()), + }, + Actor::Tool { + id: "rustfmt".into(), + }, + Actor::Tool { + id: "claude-code".into(), + }, + ] + } + + #[test] + fn test_actor_parse_of_display_is_identity() { + for actor in actor_shapes() { + let rendered = actor.to_string(); + assert_eq!( + rendered.parse::().unwrap(), + actor, + "round trip through {rendered}" + ); + } + } + + #[test] + fn test_actor_display_of_parse_is_identity() { + for canonical in [ + "human:user", + "human:alex", + "agent:unknown", + "agent:gpt-5.5", + "tool:rustfmt", + ] { + assert_eq!(canonical.parse::().unwrap().to_string(), canonical); + } + } + + #[test] + fn test_actor_serde_is_the_actor_string() { + for actor in actor_shapes() { + let json = serde_json::to_string(&actor).unwrap(); + assert_eq!(json, format!("\"{actor}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), actor); + } + assert!(serde_json::from_str::("\"ci:github-actions\"").is_err()); + } + + #[test] + fn test_actor_placeholders_collapse_to_none() { + // The sentinel spellings and an empty id all mean "unnamed", so they + // are the same actor as `None` — the one place `Actor` is lossy. + for s in ["human:user", "human:"] { + assert_eq!(s.parse::().unwrap(), Actor::Human { id: None }); + } + for s in ["agent:unknown", "agent:"] { + assert_eq!(s.parse::().unwrap(), Actor::Agent { id: None }); + } + assert_eq!( + Actor::Human { + id: Some("user".into()) + } + .to_string(), + "human:user" + ); + } + + #[test] + fn test_actor_parse_drops_the_sub_actor_suffix() { + assert_eq!( + "agent:claude-code/tool:Write".parse::().unwrap(), + Actor::Agent { + id: Some("claude-code".into()) + } + ); + assert_eq!( + "tool:rustfmt/1.5.0".parse::().unwrap(), + Actor::Tool { + id: "rustfmt".into() + } + ); + 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::EmptyToolId)); + assert_eq!( + "ci:github-actions".parse::(), + Err(ParseActorError::UnknownPrefix("ci".into())) + ); + } + + #[test] + fn test_actor_accessors() { + assert_eq!(Actor::Human { id: None }.id(), "user"); + assert_eq!(Actor::Agent { id: None }.id(), "unknown"); + assert_eq!( + Actor::Tool { + id: "rustfmt".into() + } + .id(), + "rustfmt" + ); + assert_eq!(Actor::Agent { id: None }.prefix(), "agent"); + + assert_eq!( + Actor::Agent { + id: Some("gpt-5.5".into()) + } + .model_name(), + Some("gpt-5.5") + ); + assert_eq!(Actor::Agent { id: None }.model_name(), None); + assert_eq!( + Actor::Tool { + id: "claude-code".into() + } + .model_name(), + None + ); + } + #[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..225877a1 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 { id: 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..f32f457a 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 { id: 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..9025dc0d 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 { id: 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",