From ecafe1e8073e534dea0fbd47540c36cc79723f39 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:25:38 -0600 Subject: [PATCH 01/11] Store ProcessingEnd and the mirrored pebble events in the run log Pebble's SessionProjection reads ProcessingEnd to complete a prompt and mark the session idle, so a projection rebuilt from the run's log needs it: one small event per prompt. The four pebble events the sink mirrored onto fabro's own agent.failover and agent.mcp.* are now stored verbatim as well, so the fold sees the route moves and the MCP outcomes; the mirrors stay until every reader is on the projection. Co-Authored-By: Claude Fable 5.1 --- docs/internal/events.md | 28 +++++++++++++++++-- .../fabro-workflow/src/handler/llm/pebble.rs | 23 +++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/internal/events.md b/docs/internal/events.md index 1f0747002..bcab1a858 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -951,7 +951,12 @@ Object-lifecycle event. `session_id` and `parent_session_id` are envelope fields } ``` -No properties. +No properties. One per prompt, when the agent has nothing more to do for +it. Pebble's `SessionProjection`, embedded in `StageProjection.agent`, reads +it to mark the prompt complete and the session idle, so the projection +rebuilt from the run's log needs it. Runs recorded before fabro stored it +never have it; their `agent.activity` stays `running`, and +`StageProjection.state` is the authority on whether the stage is done. ### `agent.input` @@ -1402,6 +1407,10 @@ Emitted when a sub-agent is spawned. ### `agent.mcp.ready` +Fabro's mirror of pebble's `McpServerReady`. The pebble event itself is +also stored, as `agent.mcp.server.ready`; see the section on stored pebble +events below. + ```json { "id": "...", "ts": "...", "run_id": "...", @@ -1605,7 +1614,10 @@ Emitted whenever a skill is activated in the running session. Sources: ### `agent.failover` -Emitted when the agent fails over to a different LLM provider/model. +Emitted when the agent fails over to a different LLM provider/model. On an +agent stage this is fabro's mirror of pebble's `RouteFailover`, which is +also stored as `agent.route.failover`; a one-shot prompt stage, which walks +the fallback plan without pebble, emits only this event. ```json { @@ -1633,10 +1645,20 @@ Emitted when the agent fails over to a different LLM provider/model. | `error` | string | Error that triggered failover | | `continuation` | string? | How the new route carried the prompt on, as pebble reported it: `replay_prompt` (nothing the prompt committed was in the conversation, so the new route was asked the prompt again) or `continue_turn` (the conversation held assistant output or tool results, so the new route continued from there). Absent on events written before pebble reported it and on one-shot prompt stages, which re-send their request themselves | +### `agent.route.failover`, `agent.mcp.server.ready`, `agent.mcp.server.failed`, `agent.mcp.server.disconnected` + +Pebble's `RouteFailover`, `McpServerReady`, `McpServerFailed`, and +`McpServerDisconnected` events, stored verbatim with pebble's envelope in +`properties` like every other pebble event. Fabro also mirrors each onto +its own `agent.failover`, `agent.mcp.ready`, `agent.mcp.failed`, and +`agent.mcp.disconnected`, which the store folds into `StageProjection`'s +`mcp_servers`; the pebble events feed `StageProjection.agent`. The mirrors +go once every reader is on `agent`. + ### `agent.route.failover.stopped` Pebble's `RouteFailoverStopped` event, stored verbatim like every other -pebble event fabro does not mirror. An agent stage with fallback routes +pebble event. An agent stage with fallback routes publishes it when a model failure ends the prompt on its current route anyway: the failure does not qualify for failover (`reason: "ineligible"`) or every route has been taken (`reason: "exhausted"`). It follows the diff --git a/lib/components/fabro-workflow/src/handler/llm/pebble.rs b/lib/components/fabro-workflow/src/handler/llm/pebble.rs index b21e3e60b..07f4231f0 100644 --- a/lib/components/fabro-workflow/src/handler/llm/pebble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/pebble.rs @@ -163,13 +163,12 @@ fn classify_agent_error(error: pebble_coding_agent::Error) -> AgentErrorDisposit // --- Event sink ----------------------------------------------------------- /// Pebble's durable event sink for one stage: every agent event becomes a -/// run event in the run's log before the agent goes on. A route failover and -/// an MCP server's outcome or disconnect are facts the run already has -/// events for, so those are mirrored onto the run's own `agent.failover`, -/// `agent.mcp.ready`, `agent.mcp.failed`, and `agent.mcp.disconnected` -/// events instead of being stored twice. A failover that stops short, with -/// the chain exhausted or the error ineligible, has no event of fabro's own -/// and is stored as pebble's `agent.route.failover.stopped`. +/// run event in the run's log before the agent goes on, so the stage's +/// `SessionProjection` rebuilt from the log sees what the live one saw. A +/// route failover and an MCP server's outcome or disconnect are also +/// mirrored onto the run's own `agent.failover`, `agent.mcp.ready`, +/// `agent.mcp.failed`, and `agent.mcp.disconnected` events, which the store +/// still folds; those mirrors go once every reader is on the projection. struct WorkflowEventSink { emitter: Arc, node_id: String, @@ -214,7 +213,6 @@ impl EventSink for WorkflowEventSink { }, &self.scope, ); - return Ok(()); } CodingEvent::McpServerReady { server, @@ -238,7 +236,6 @@ impl EventSink for WorkflowEventSink { }, &self.scope, ); - return Ok(()); } CodingEvent::McpServerFailed { server, @@ -255,7 +252,6 @@ impl EventSink for WorkflowEventSink { }, &self.scope, ); - return Ok(()); } CodingEvent::McpServerDisconnected { server, error } => { self.emitter.emit_scoped( @@ -267,12 +263,13 @@ impl EventSink for WorkflowEventSink { }, &self.scope, ); - return Ok(()); } _ => {} } - // Deltas and the prompt's own durability barrier are not run history. - if event.event.is_streaming_noise() || matches!(event.event, CodingEvent::ProcessingEnd) { + // Streaming deltas are not run history. `ProcessingEnd` is: pebble's + // `SessionProjection` reads it to complete the prompt and mark the + // session idle, so a projection rebuilt from the run's log needs it. + if event.event.is_streaming_noise() { return Ok(()); } self.emitter From 245d2c411ddf143e17aaacbd2cb4aa6a22d72a08 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:25:38 -0600 Subject: [PATCH 02/11] Embed pebble's SessionProjection in StageProjection StageProjection.agent is pebble's fold of the stage's agent events, fed every stored agent event before the fabro-only arms run. Every existing field and arm stays for now. The parity tests prove each old field is derivable from the embedded fold: the tree's usage, the route as the model, the context window without fabro's stamped seq, the root's todo list, the subagent rows, the skills, and the MCP servers under the disconnected, error, ready rule. Co-Authored-By: Claude Fable 5.1 --- lib/components/fabro-store/src/run_state.rs | 432 +++++++++++++++++- .../fabro-types/src/run_projection.rs | 15 + 2 files changed, 437 insertions(+), 10 deletions(-) diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index 7f3aef83e..e0cedb00f 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -759,6 +759,11 @@ fn apply_agent_event( ts: DateTime, ) { let visit = props.visit; + // Pebble's own fold sees every agent event the stage stored, before the + // fabro-only arms below read the same event. + if let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) { + stage.agent.get_or_insert_default().apply(&props.event); + } #[expect( clippy::wildcard_enum_match_arm, reason = "pebble's event vocabulary is non-exhaustive and only some events project" @@ -8037,13 +8042,20 @@ mod tests { } /// Fabro's stage fold and pebble's `SessionProjection` read the same - /// stored events. The stage projection stays fabro's: it is the wire - /// contract the API serves and is applied to incrementally, so pebble's - /// value cannot stand in for it. These tests pin the two folds to each - /// other for a retained session that spans two stages, so a stage's live - /// account is the prompt delta pebble reports and the two never drift. + /// stored events, and every stage now carries pebble's fold of its own + /// events as `StageProjection.agent`. These tests pin the two folds to + /// each other: a stage's live account is the prompt delta pebble + /// reports, and every field the stage projection still keeps its own + /// arms for is derivable from `agent` under a stated rule. They are the + /// safety net for reading `agent.*` instead and deleting the old fields. mod session_projection_parity { - use pebble_coding_agent::events::{InputSource, McpToolSummary}; + use fabro_types::{ModelRef, TodoListKind}; + use lithos_llm::catalog::{ModelId, ProviderId}; + use pebble_coding_agent::events::{ + ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, + ErrorData as AgentErrorData, ErrorKind as AgentErrorKind, InputSource, McpToolSummary, + SkillActivationSource, SkillSummary, TodoCreatedProps, TodoStatus, + }; use pebble_coding_agent::projection::{ SessionActivity, SessionProjection, SubagentStatus as PebbleSubagentStatus, }; @@ -8185,6 +8197,25 @@ mod tests { assert_eq!(projection.usage.input, 170); assert_eq!(projection.descendant_usage().0.input, 7); assert_eq!(projection.prompts, 2); + + // Each stage's embedded fold is fed that stage's events only, so + // its lifetime totals are the stage's own prompts: the delta the + // whole-session fold reports for them. + let code_agent = code_stage + .agent + .as_ref() + .expect("the code stage carries a fold"); + assert_eq!(code_agent.usage, code_delta.usage); + assert_eq!(code_agent.descendant_usage(), code_delta.descendant_usage()); + assert_eq!(code_agent.prompts, 1); + assert!(code_agent.prompt.completed); + let review_agent = review_stage + .agent + .as_ref() + .expect("the review stage carries a fold"); + assert_eq!(review_agent.usage, review_delta.usage); + assert!(review_agent.descendants.is_empty()); + assert_eq!(review_agent.prompts, 1); } #[test] @@ -8233,6 +8264,15 @@ mod tests { AgentControlState::Running, "fabro moves control to idle on its own stage events, not pebble's" ); + for stage in [&code, &review] { + let agent = run.stage(stage).unwrap().agent.as_ref().unwrap(); + assert_eq!( + agent.activity, + SessionActivity::Idle, + "the stored agent.processing.end completes the stage's own fold" + ); + assert_eq!(agent.root_session_id.as_deref(), Some(ROOT)); + } } #[test] @@ -8259,10 +8299,13 @@ mod tests { assert_eq!(resumed, replayed); } - /// Pebble folds its own `McpServer*` events; fabro folds the - /// `agent.mcp.*` events the workflow sink mirrors them onto, since - /// the raw pebble event is not stored. The mirrored events are built - /// here the way the sink builds them. + /// Pebble folds its own `McpServer*` events; fabro's `mcp_servers` + /// arms fold the `agent.mcp.*` events the workflow sink mirrors them + /// onto. The mirrored events are built here the way the sink builds + /// them. The sink stores the pebble event as well, which is what + /// feeds `StageProjection.agent`; + /// `the_old_stage_fields_are_derived_from_the_embedded_fold` + /// drives both from one stream. #[test] fn mcp_servers_agree_across_the_two_folds() { let code = StageId::new("code", 1); @@ -8344,5 +8387,374 @@ mod tests { .expect("pebble recorded the disconnect"), }); } + + fn assistant_message_with_window( + input: u64, + output: u64, + window: ContextWindowSnapshot, + ) -> CodingEvent { + CodingEvent::AssistantMessage { + text: "assistant text".to_string(), + model: billed_usage().model().model_id.to_string(), + usage: TokenUsage { + input, + output, + ..TokenUsage::default() + }, + cost_usd_micros: None, + cost_source: None, + tool_call_count: 0, + context_window: Some(window), + reasoning: None, + } + } + + fn todo(list_id: &str, list_kind: TodoListKind, todo_id: &str) -> TodoCreatedProps { + TodoCreatedProps { + list_id: list_id.to_string(), + list_kind, + todo_id: todo_id.to_string(), + status: TodoStatus::Pending, + order: 0, + subject: "write tests".to_string(), + description: String::new(), + active_form: None, + owner: None, + blocks: Vec::new(), + blocked_by: Vec::new(), + metadata: std::collections::BTreeMap::new(), + } + } + + fn mirrored_tools(tools: &[McpToolSummary]) -> Vec { + tools + .iter() + .map(|tool| AgentMcpToolSummary { + name: tool.name.clone(), + original_name: tool.original_name.clone(), + }) + .collect() + } + + /// Every field the stage projection keeps its own fold for is + /// derivable from `stage.agent`, under the rule each assertion + /// states. The stream is what the sink stores for one agent stage: + /// fabro's own `agent.session.activated` and the `agent.mcp.*` + /// mirrors next to pebble's events. + #[test] + fn the_old_stage_fields_are_derived_from_the_embedded_fold() { + let code = StageId::new("code", 1); + let model = billed_usage().model().clone(); + let provider = model.provider.to_string(); + let model_id = model.model_id.to_string(); + let tools = vec![McpToolSummary { + name: "mcp__github__list_issues".to_string(), + original_name: "list_issues".to_string(), + }]; + let root_list = TodoListKind::AnthropicTasks.list_id(ROOT); + let child_list = TodoListKind::OpenAiPlan.list_id(CHILD); + let window = ContextWindowSnapshot { + provider: provider.clone(), + model: model_id.clone(), + context_window_tokens: 400_000, + input_tokens: 123_456, + usage_percent: 30.864, + count_method: ContextWindowCountMethod::LocalEstimate, + staleness: ContextWindowStaleness::Live, + generated_at: SystemTime::UNIX_EPOCH, + event_seq: None, + breakdown: Vec::new(), + warnings: Vec::new(), + }; + let events = vec![ + test_stage_event(1, activated(&provider, &model_id), code.clone()), + stored( + 2, + &code, + root(CodingEvent::SessionStarted { + provider: Some(provider.clone()), + model: Some(model_id.clone()), + }), + ), + stored( + 3, + &code, + root(CodingEvent::McpServerReady { + server: "github".to_string(), + tools: tools.clone(), + startup_ms: 842, + }), + ), + test_stage_event( + 4, + EventBody::AgentMcpReady(AgentMcpReadyProps { + server_name: "github".to_string(), + tool_count: tools.len(), + tools: mirrored_tools(&tools), + startup_ms: 842, + visit: 1, + }), + code.clone(), + ), + stored( + 5, + &code, + root(CodingEvent::McpServerFailed { + server: "broken".to_string(), + error: "could not launch".to_string(), + startup_ms: 3, + }), + ), + test_stage_event( + 6, + EventBody::AgentMcpFailed(AgentMcpFailedProps { + server_name: "broken".to_string(), + error: "could not launch".to_string(), + startup_ms: 3, + visit: 1, + }), + code.clone(), + ), + stored( + 7, + &code, + root(CodingEvent::SkillsDiscovered { + profile: "anthropic".to_string(), + source_dirs: Vec::new(), + skills: vec![SkillSummary { + name: "rust".to_string(), + description: "Rust workflow help".to_string(), + }], + skipped: Vec::new(), + }), + ), + stored(8, &code, root(prompt())), + stored( + 9, + &code, + root(assistant_message_with_window(100, 10, window)), + ), + stored( + 10, + &code, + root(CodingEvent::ToolCallStarted { + tool_name: "mcp__github__list_issues".to_string(), + tool_call_id: "call_1".to_string(), + arguments: json!({}), + }), + ), + stored( + 11, + &code, + root(CodingEvent::SkillActivated { + skill_name: "rust".to_string(), + source: SkillActivationSource::Tool, + }), + ), + stored( + 12, + &code, + root(CodingEvent::TodoCreated(todo( + &root_list, + TodoListKind::AnthropicTasks, + "t1", + ))), + ), + stored( + 13, + &code, + root(CodingEvent::SubAgentSpawned { + agent_id: "sub-1".to_string(), + depth: 1, + task: "look around".to_string(), + generation: 1, + }), + ), + stored( + 14, + &code, + child(CodingEvent::TodoCreated(todo( + &child_list, + TodoListKind::OpenAiPlan, + "p1", + ))), + ), + stored(15, &code, child(assistant_message(7, 1))), + stored( + 16, + &code, + root(CodingEvent::SubAgentCompleted { + agent_id: "sub-1".to_string(), + depth: 1, + generation: 1, + success: true, + turns_used: 1, + }), + ), + stored( + 17, + &code, + root(CodingEvent::SubAgentSpawned { + agent_id: "sub-2".to_string(), + depth: 1, + task: "check the tests".to_string(), + generation: 1, + }), + ), + stored( + 18, + &code, + root(CodingEvent::SubAgentFailed { + agent_id: "sub-2".to_string(), + depth: 1, + generation: 1, + error: AgentErrorData::new(AgentErrorKind::Agent, "boom"), + }), + ), + stored( + 19, + &code, + child(CodingEvent::McpServerDisconnected { + server: "github".to_string(), + error: "transport closed".to_string(), + }), + ), + test_stage_event( + 20, + EventBody::AgentMcpDisconnected(AgentMcpDisconnectedProps { + server_name: "github".to_string(), + error: "transport closed".to_string(), + visit: 1, + }), + code.clone(), + ), + stored(21, &code, root(assistant_message(50, 5))), + stored(22, &code, root(CodingEvent::ProcessingEnd)), + ]; + + let mut run = initialized_projection(); + for event in &events { + run.apply_event(event).unwrap(); + } + let stage = run.stage(&code).unwrap(); + let agent = stage + .agent + .as_ref() + .expect("an agent stage carries pebble's fold"); + assert_eq!(agent.root_session_id.as_deref(), Some(ROOT)); + assert!(agent.prompt.completed); + assert_eq!(agent.activity, SessionActivity::Idle); + + // Usage: the stage's live account is the tree's spend, the root's + // own plus every descendant's. (At completion fabro's billing + // replaces it with the root-only report; that rule goes next.) + let (descendants, _) = agent.descendant_usage(); + assert_eq!( + stage.usage.input_tokens, + tokens(agent.usage.input + descendants.input) + ); + assert_eq!( + stage.usage.output_tokens, + tokens(agent.usage.output + descendants.output) + ); + assert_eq!( + stage.usage.total_tokens, + tokens(agent.usage.total() + descendants.total()) + ); + assert_eq!(stage.usage.input_tokens, 157, "100 + 7 + 50"); + + // Model: the route the session reported. + let route_provider = agent + .route + .provider + .as_deref() + .expect("route names a provider"); + let route_model = agent.route.model.as_deref().expect("route names a model"); + assert_eq!( + stage.model, + Some(ModelRef::new( + ProviderId::new(route_provider), + ModelId::new(route_model) + )) + ); + + // Context window: the same snapshot, except that fabro stamps the + // run event seq into `event_seq` and pebble keeps the event's own. + let mut fabro_window = stage + .context_window + .clone() + .expect("fabro kept the latest window"); + assert_eq!(fabro_window.event_seq, Some(9)); + fabro_window.event_seq = None; + assert_eq!(Some(fabro_window), agent.context_window); + + // Todos: fabro keeps the root agent's list; pebble keeps every + // list in the tree, and the root's is the one keyed by its id. + let root_todos = agent + .todos + .values() + .find(|list| list.list_id == list.kind.list_id(ROOT)); + assert_eq!(stage.root_agent_todos.as_ref(), root_todos); + assert!(root_todos.is_some()); + assert_eq!(agent.todos.len(), 2, "the child's plan is only pebble's"); + assert!(agent.todos.contains_key(&child_list)); + + // Subagents: the same rows; the status tag is `status`, not + // `kind`, and a failure carries pebble's `ErrorData`. + assert_eq!(stage.subagents.len(), agent.subagents.len()); + assert_eq!(agent.subagents.len(), 2); + for (fabro, pebble) in stage.subagents.iter().zip(&agent.subagents) { + assert_eq!(fabro.agent_id, pebble.agent_id); + assert_eq!(fabro.depth, pebble.depth); + assert_eq!(fabro.task, pebble.task); + let mut pebble_status = serde_json::to_value(&pebble.status).unwrap(); + let tag = pebble_status + .as_object_mut() + .unwrap() + .remove("status") + .expect("pebble tags the status"); + pebble_status["kind"] = tag; + assert_eq!(serde_json::to_value(&fabro.status).unwrap(), pebble_status); + } + assert_eq!( + serde_json::to_value(&agent.subagents[1].status).unwrap()["status"], + "failed" + ); + + // Skills: the same shape. + assert_eq!(stage.skills.available, agent.skills.available); + assert_eq!(stage.skills.activated.len(), agent.skills.activated.len()); + for (fabro, pebble) in stage.skills.activated.iter().zip(&agent.skills.activated) { + assert_eq!(fabro.name, pebble.name); + assert_eq!(fabro.source, pebble.source); + } + + // MCP servers: `disconnected` set is Disconnected, else `error` + // set is Failed, else Ready; the tool count is `tools.len()`. + assert_eq!(stage.mcp_servers.len(), agent.mcp_servers.len()); + assert_eq!(agent.mcp_servers.len(), 2); + for server in &stage.mcp_servers { + let pebble = &agent.mcp_servers[&server.server_name]; + assert_eq!(server.invoked, pebble.invoked); + assert_eq!(server.tool_count, pebble.tools.len()); + let expected = if let Some(error) = &pebble.disconnected { + McpServerStatus::Disconnected { + error: error.clone(), + } + } else if let Some(error) = &pebble.error { + McpServerStatus::Failed { + error: error.clone(), + } + } else { + McpServerStatus::Ready { + tools: mirrored_tools(&pebble.tools), + } + }; + assert_eq!(server.status, expected, "{}", server.server_name); + } + assert!(agent.mcp_servers["github"].invoked); + assert_eq!(agent.mcp_servers["github"].startup_ms, Some(842)); + assert_eq!(agent.mcp_servers["broken"].startup_ms, Some(3)); + } } } diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index f4aa80b63..9f8e446b8 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -9,6 +9,7 @@ use pebble_coding_agent::events::{ ContextWindowStaleness, ContextWindowWarning, LlmOutputKind, PermissionLevel, SkillActivationSource, SkillSummary, TodoListProjection, ToolSummary, }; +use pebble_coding_agent::projection::SessionProjection; use strum::{Display, EnumString, IntoStaticStr}; use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; @@ -332,6 +333,19 @@ pub struct StageProjection { pub acp_started_at: Option>, #[serde(default)] pub agent_control: AgentControlState, + /// Pebble's fold of this stage's agent events: the one agent projection, + /// fed every `agent.*` and `todo.*` event stored on the stage. Present + /// for pebble-backed agent stages once their first agent event is + /// stored; `None` for prompt, command, ACP, human, parallel, and + /// conditional stages. + /// + /// Its lifetime fields are the stage's totals across every prompt the + /// stage ran, because each stage gets its own fold over its own events. + /// `activity` reads `running` on stages stored before + /// `agent.processing.end` was kept; `state` is the authority on whether + /// a stage is done. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, pub state: StageState, } @@ -499,6 +513,7 @@ impl StageProjection { inference: None, acp_started_at: None, agent_control: AgentControlState::default(), + agent: None, provider_used: None, diff: None, script_invocation: None, From 5d2b7cc4aa65eca140d796b87315717433896bbb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:25:38 -0600 Subject: [PATCH 03/11] Describe StageProjection.agent on the API as pebble's own types AgentSessionProjection and the schemas nested in it reuse pebble's types through with_replacement; the AgentSession prefix marks the projection's own types where fabro already has a schema of that name, and pebble's event-level types keep their names. The round-trip test builds a projection over a scripted stream, validates it against the spec with the spec as the root document, checks every serialized key is declared, and validates every enum variant this build knows. The TypeScript client is regenerated. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 + docs/public/api-reference/fabro-api.yaml | 785 ++++++++++++++++++ lib/foundation/fabro-api/Cargo.toml | 3 + lib/foundation/fabro-api/build.rs | 116 +++ lib/foundation/fabro-api/src/lib.rs | 23 +- .../agent_session_projection_round_trip.rs | 629 ++++++++++++++ .../src/.openapi-generator/FILES | 30 + .../src/models/agent-error-data.ts | 64 ++ .../src/models/agent-error-kind.ts | 35 + .../models/agent-session-activated-skill.ts | 26 + .../src/models/agent-session-activity.ts | 28 + .../src/models/agent-session-compaction.ts | 40 + .../agent-session-descendant-account.ts | 46 + .../src/models/agent-session-failover-stop.ts | 40 + .../src/models/agent-session-mcp-server.ts | 41 + .../src/models/agent-session-projection.ts | 131 +++ .../src/models/agent-session-prompt-delta.ts | 79 ++ .../models/agent-session-route-failover.ts | 60 ++ .../src/models/agent-session-route.ts | 23 + .../src/models/agent-session-skills.ts | 29 + .../models/agent-session-subagent-counts.ts | 26 + .../agent-session-subagent-status-closed.ts | 25 + ...agent-session-subagent-status-completed.ts | 27 + .../agent-session-subagent-status-failed.ts | 29 + .../agent-session-subagent-status-running.ts | 25 + .../models/agent-session-subagent-status.ts | 36 + .../src/models/agent-session-subagent.ts | 28 + .../src/models/agent-session-tool-activity.ts | 33 + .../src/models/compaction-reason.ts | 27 + .../src/models/failover-continuation.ts | 26 + .../src/models/failover-stop.ts | 26 + .../fabro-api-client/src/models/index.ts | 30 + .../models/llm-retry-classification-after.ts | 32 + .../models/llm-retry-classification-never.ts | 28 + .../models/llm-retry-classification-safe.ts | 28 + .../src/models/llm-retry-classification.ts | 30 + .../src/models/mcp-tool-summary.ts | 29 + .../src/models/stage-projection.ts | 4 + .../src/models/token-usage.ts | 41 + 39 files changed, 2758 insertions(+), 2 deletions(-) create mode 100644 lib/foundation/fabro-api/tests/agent_session_projection_round_trip.rs create mode 100644 lib/packages/fabro-api-client/src/models/agent-error-data.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-error-kind.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-activated-skill.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-activity.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-compaction.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-descendant-account.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-failover-stop.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-mcp-server.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-projection.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-prompt-delta.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-route-failover.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-route.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-skills.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-counts.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-status-closed.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-status-completed.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-status-failed.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-status-running.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent-status.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-subagent.ts create mode 100644 lib/packages/fabro-api-client/src/models/agent-session-tool-activity.ts create mode 100644 lib/packages/fabro-api-client/src/models/compaction-reason.ts create mode 100644 lib/packages/fabro-api-client/src/models/failover-continuation.ts create mode 100644 lib/packages/fabro-api-client/src/models/failover-stop.ts create mode 100644 lib/packages/fabro-api-client/src/models/llm-retry-classification-after.ts create mode 100644 lib/packages/fabro-api-client/src/models/llm-retry-classification-never.ts create mode 100644 lib/packages/fabro-api-client/src/models/llm-retry-classification-safe.ts create mode 100644 lib/packages/fabro-api-client/src/models/llm-retry-classification.ts create mode 100644 lib/packages/fabro-api-client/src/models/mcp-tool-summary.ts create mode 100644 lib/packages/fabro-api-client/src/models/token-usage.ts diff --git a/Cargo.lock b/Cargo.lock index dfc9f6234..b7d2b6f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2273,8 +2273,10 @@ dependencies = [ "fabro-config", "fabro-environment", "fabro-types", + "jsonschema", "lithos-llm", "openapiv3", + "pebble-coding-agent", "prettyplease", "progenitor", "progenitor-client", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 5241666de..aa06923ed 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -11227,6 +11227,18 @@ components: agent_control: $ref: "#/components/schemas/AgentControlState" description: Whether the agent is executing normally or waiting for steering after an interrupt. + agent: + oneOf: + - $ref: "#/components/schemas/AgentSessionProjection" + - type: "null" + description: >- + The coding agent's own fold of this stage's `agent.*` and `todo.*` + events, present for agent stages once their first agent event is + stored and absent for every other stage kind. Each stage owns one + fold over its own events, so the lifetime fields are the stage's + totals across every prompt it ran. `activity` reads `running` on + stages recorded before `agent.processing.end` was kept; `state` is + the authority on whether the stage is done. state: $ref: "#/components/schemas/StageState" description: Lifecycle state of the stage projection. @@ -11617,6 +11629,779 @@ components: original_name: type: string + AgentSessionProjection: + description: >- + The coding agent's fold of one stage's event stream: token counts and + provider-reported cost for the root session and each descendant, the + route and where it moved, the context window, tools, MCP servers, + skills, todo lists, subagents, compactions, files touched, and the + prompt in progress. Counts only; pricing a count from the catalog is + fabro's, and lives in `StageProjection.usage`. + type: object + required: + - root_session_id + - route + - activity + - usage + - cost_usd_micros + - messages + - descendants + - context_window + - tools + - mcp_servers + - skills + - subagent_counts + - todos + - subagents + - compactions + - files_touched + - last_file_touched + - prompts + - prompt + properties: + root_session_id: + type: ["string", "null"] + description: The root session, once an event named it. + route: + $ref: "#/components/schemas/AgentSessionRoute" + activity: + $ref: "#/components/schemas/AgentSessionActivity" + usage: + $ref: "#/components/schemas/TokenUsage" + description: The root session's usage over the stage. + cost_usd_micros: + type: ["integer", "null"] + format: uint64 + minimum: 0 + description: The root session's provider-reported cost, when a provider reported one. + messages: + type: integer + format: uint64 + minimum: 0 + description: Committed assistant messages from the root session. + descendants: + type: object + additionalProperties: + $ref: "#/components/schemas/AgentSessionDescendantAccount" + description: Every descendant session's account, by session id. + context_window: + oneOf: + - $ref: "#/components/schemas/ContextWindowSnapshot" + - type: "null" + description: >- + The root session's latest context window. `event_seq` is the + agent's own sequence when it carried one, not the run event seq. + tools: + type: object + additionalProperties: + $ref: "#/components/schemas/AgentSessionToolActivity" + description: Every tool called anywhere in the tree, by the name the model used. + retries: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Model calls retried after a failed attempt, across the tree. + mcp_servers: + type: object + additionalProperties: + $ref: "#/components/schemas/AgentSessionMcpServer" + description: Every MCP server the root configured, by name. + skills: + $ref: "#/components/schemas/AgentSessionSkills" + subagent_counts: + $ref: "#/components/schemas/AgentSessionSubagentCounts" + todos: + type: object + additionalProperties: + $ref: "#/components/schemas/TodoListProjection" + description: >- + Every todo list in the tree, by list id. The root agent's own list + is the one whose id ends with `root_session_id`. + subagents: + type: array + items: + $ref: "#/components/schemas/AgentSessionSubagent" + compactions: + type: array + items: + $ref: "#/components/schemas/AgentSessionCompaction" + description: The root session's compactions, in order. + failovers: + type: array + items: + $ref: "#/components/schemas/AgentSessionRouteFailover" + default: [] + description: Every move the root made to a fallback route, in order. + failover_stopped: + oneOf: + - $ref: "#/components/schemas/AgentSessionFailoverStop" + - type: "null" + description: >- + Why the prompt in progress, or the last one, stayed on its route + and ended there although fallback routes were named. Cleared when + a prompt starts. + files_touched: + type: array + items: + type: string + description: Files written or edited across the tree, sorted. + last_file_touched: + type: ["string", "null"] + prompts: + type: integer + format: uint64 + minimum: 0 + description: How many prompts have started. + prompt: + $ref: "#/components/schemas/AgentSessionPromptDelta" + pending_writes: + type: object + additionalProperties: + type: array + items: + type: string + description: >- + In-flight bookkeeping, not a fact about the session: the paths a + write or edit tool call named, by tool call id, between its start + and its completion. Present only while such a call is open; a + settled projection has no such member. + + AgentSessionActivity: + description: Where a session stands, as its events tell it. + type: string + enum: [idle, running, waiting_for_steer, ended] + + AgentSessionRoute: + description: The route a session runs on, as it reported it. + type: object + required: + - provider + - model + properties: + provider: + type: ["string", "null"] + model: + type: ["string", "null"] + + AgentSessionDescendantAccount: + description: What one descendant session spent, as its own events reported it. + type: object + required: + - parent + - usage + - cost_usd_micros + - messages + - compactions + properties: + parent: + type: string + description: The session that spawned it. + provider: + type: string + description: The provider it runs on, as its `SessionStarted` reported it. + model: + type: string + description: >- + The model it runs on, from its `SessionStarted`; when the start + was not seen, the model of its first answer. + usage: + $ref: "#/components/schemas/TokenUsage" + cost_usd_micros: + type: ["integer", "null"] + format: uint64 + minimum: 0 + messages: + type: integer + format: uint64 + minimum: 0 + description: Committed assistant messages. + compactions: + type: integer + format: uint64 + minimum: 0 + description: Compactions it completed. + + AgentSessionToolActivity: + description: How one tool has been used across the tree. + type: object + required: + - calls + - errors + - open + properties: + calls: + type: integer + format: uint64 + minimum: 0 + description: Calls started. + errors: + type: integer + format: uint64 + minimum: 0 + description: Calls that completed as errors. + open: + type: integer + format: uint64 + minimum: 0 + description: Calls started and not yet completed. + + AgentSessionSubagentCounts: + description: How many child lifecycle events the tree recorded. + type: object + required: + - spawned + - turns_started + - completed + - failed + - closed + properties: + spawned: + type: integer + format: uint64 + minimum: 0 + turns_started: + type: integer + format: uint64 + minimum: 0 + completed: + type: integer + format: uint64 + minimum: 0 + failed: + type: integer + format: uint64 + minimum: 0 + closed: + type: integer + format: uint64 + minimum: 0 + + AgentSessionMcpServer: + description: >- + One MCP server the session configured, and whether it has been called. + `disconnected` set means the server came up and its connection then + closed; otherwise `error` set means it did not start; otherwise it is + ready with `tools`. + type: object + required: + - tools + - error + - invoked + properties: + tools: + type: array + items: + $ref: "#/components/schemas/McpToolSummary" + error: + type: ["string", "null"] + description: Why it did not start, when it did not. + invoked: + type: boolean + description: Whether any of its tools has been called. + disconnected: + type: string + description: What closed its connection during the session, when it closed. + startup_ms: + type: integer + format: uint64 + minimum: 0 + description: >- + Milliseconds from launch to its outcome: to its tools being + listed, or to the failure. Absent until either has been seen. + + AgentSessionSkills: + description: The skills the root session found and the ones activated anywhere in the tree. + type: object + required: + - available + - activated + properties: + available: + type: array + items: + $ref: "#/components/schemas/SkillSummary" + activated: + type: array + items: + $ref: "#/components/schemas/AgentSessionActivatedSkill" + + AgentSessionActivatedSkill: + description: A skill the session activated. + type: object + required: + - name + - source + properties: + name: + type: string + source: + $ref: "#/components/schemas/SkillActivationSource" + + AgentSessionSubagent: + description: One child the root spawned. A reused child stays one row; every event after the spawn moves its status. + type: object + required: + - agent_id + - depth + - task + - status + properties: + agent_id: + type: string + depth: + type: integer + minimum: 0 + task: + type: string + status: + $ref: "#/components/schemas/AgentSessionSubagentStatus" + + AgentSessionSubagentStatus: + description: Where a child stands. + oneOf: + - $ref: "#/components/schemas/AgentSessionSubagentStatusRunning" + - $ref: "#/components/schemas/AgentSessionSubagentStatusCompleted" + - $ref: "#/components/schemas/AgentSessionSubagentStatusFailed" + - $ref: "#/components/schemas/AgentSessionSubagentStatusClosed" + discriminator: + propertyName: status + mapping: + running: "#/components/schemas/AgentSessionSubagentStatusRunning" + completed: "#/components/schemas/AgentSessionSubagentStatusCompleted" + failed: "#/components/schemas/AgentSessionSubagentStatusFailed" + closed: "#/components/schemas/AgentSessionSubagentStatusClosed" + + AgentSessionSubagentStatusRunning: + type: object + required: + - status + properties: + status: + type: string + enum: [running] + + AgentSessionSubagentStatusCompleted: + type: object + required: + - status + - success + - turns_used + properties: + status: + type: string + enum: [completed] + success: + type: boolean + turns_used: + type: integer + minimum: 0 + + AgentSessionSubagentStatusFailed: + type: object + required: + - status + - error + properties: + status: + type: string + enum: [failed] + error: + $ref: "#/components/schemas/AgentErrorData" + + AgentSessionSubagentStatusClosed: + type: object + required: + - status + properties: + status: + type: string + enum: [closed] + + AgentSessionCompaction: + description: One compaction the root session completed. + type: object + required: + - reason + - original_turn_count + - preserved_turn_count + - summary_token_estimate + - tracked_file_count + properties: + reason: + $ref: "#/components/schemas/CompactionReason" + original_turn_count: + type: integer + minimum: 0 + preserved_turn_count: + type: integer + minimum: 0 + summary_token_estimate: + type: integer + minimum: 0 + tracked_file_count: + type: integer + minimum: 0 + usage: + $ref: "#/components/schemas/TokenUsage" + description: >- + The summary call's tokens: a breakdown of the session's and the + prompt's usage, which already include them. Zero on compactions + recorded before it was kept. + cost_usd_micros: + type: integer + format: uint64 + minimum: 0 + description: The summary call's provider-reported cost, included in the totals the same way. + + AgentSessionRouteFailover: + description: One move the root session made to a fallback route, as the stream reported it from the route it moved to. + type: object + required: + - from + - to + - attempt + - error + - usage + - inference_ms + - tool_ms + - continuation + properties: + from: + type: string + description: The `provider/model` that failed. + to: + type: string + description: The `provider/model` the prompt continued on. + attempt: + type: integer + format: uint32 + minimum: 0 + description: How many routes the prompt had moved through, this one included. + error: + $ref: "#/components/schemas/AgentErrorData" + description: The failure that ended the previous route. + usage: + $ref: "#/components/schemas/TokenUsage" + description: >- + What the prompt spent on the failed route. Already in the + session's and the prompt's totals through that route's committed + answers: a breakdown, not an addition. + cost_usd_micros: + type: integer + format: uint64 + minimum: 0 + inference_ms: + type: integer + format: uint64 + minimum: 0 + description: Milliseconds the prompt spent waiting on the failed route's model. + tool_ms: + type: integer + format: uint64 + minimum: 0 + description: Milliseconds the prompt spent running tools on the failed route. + continuation: + $ref: "#/components/schemas/FailoverContinuation" + + AgentSessionFailoverStop: + description: Why a prompt stayed on its route and ended there although fallback routes were named. + type: object + required: + - route + - attempt + - reason + - error + properties: + route: + type: string + description: The `provider/model` the prompt ended on. + attempt: + type: integer + format: uint32 + minimum: 0 + description: How many fallback routes the prompt had moved through; `0` on the route it started on. + reason: + $ref: "#/components/schemas/FailoverStop" + error: + $ref: "#/components/schemas/AgentErrorData" + description: The failure that ended the prompt. + + AgentSessionPromptDelta: + description: >- + What the prompt in progress, or the last one, did: reset when a prompt + starts, complete once `completed` is set. + type: object + required: + - completed + - usage + - cost_usd_micros + - messages + - context_window + - tool_calls + - descendants + - subagents + - compactions + - files_touched + - last_file_touched + properties: + completed: + type: boolean + description: Whether the prompt reached its end. + usage: + $ref: "#/components/schemas/TokenUsage" + description: The root session's usage over the prompt. + cost_usd_micros: + type: ["integer", "null"] + format: uint64 + minimum: 0 + messages: + type: integer + format: uint64 + minimum: 0 + description: Committed assistant messages. + context_window: + oneOf: + - $ref: "#/components/schemas/ContextWindowSnapshot" + - type: "null" + description: The latest context window the prompt reported. + tool_calls: + type: integer + format: uint64 + minimum: 0 + description: Tool calls started, across the tree. + retries: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Model calls retried after a failed attempt, across the tree. + failovers: + type: integer + format: uint32 + minimum: 0 + default: 0 + description: Moves the root made to a fallback route during the prompt. + descendants: + type: object + additionalProperties: + $ref: "#/components/schemas/AgentSessionDescendantAccount" + description: What each descendant spent during the prompt, by session id. + subagents: + $ref: "#/components/schemas/AgentSessionSubagentCounts" + description: Child lifecycle events during the prompt. + compactions: + type: array + items: + $ref: "#/components/schemas/AgentSessionCompaction" + description: Compactions the root completed during the prompt. + files_touched: + type: array + items: + type: string + description: Files written or edited during the prompt, across the tree, sorted. + last_file_touched: + type: ["string", "null"] + + TokenUsage: + description: >- + Token accounting as the coding agent counts it. The five buckets are + disjoint: every token is counted in exactly one, so their plain sum + is the total. A bucket that is absent reads as zero. + type: object + properties: + input: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Prompt tokens that were neither read from nor written to a cache. + output: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Completion tokens that are not reasoning tokens. + reasoning: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Completion tokens spent on reasoning, billed at the output rate. + cache_read: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Prompt tokens served from a provider cache. + cache_write: + type: integer + format: uint64 + minimum: 0 + default: 0 + description: Prompt tokens written into a provider cache. + + McpToolSummary: + description: One tool an MCP server advertised, as the coding agent's registry named it. + type: object + required: + - name + - original_name + properties: + name: + type: string + description: "The name the model calls: `mcp__{server}__{tool}`." + original_name: + type: string + description: The server's own name for the tool. + + CompactionReason: + description: Why a conversation compaction ran. + type: string + enum: [threshold, manual, overflow] + + FailoverContinuation: + description: >- + How a prompt carries on after a failover. `replay_prompt` when nothing + the prompt committed is in the conversation, so the new route is asked + the prompt again; `continue_turn` when the conversation holds output + or tool results this prompt committed, so the new route continues the + turn from where it stood. + type: string + enum: [replay_prompt, continue_turn] + + FailoverStop: + description: >- + Why a model failure ends a prompt on its route when fallback routes + were named. `ineligible` when the failure follows the request, so + another route would fail the same way; `exhausted` when every named + route has been taken. + type: string + enum: [ineligible, exhausted] + + AgentErrorData: + description: >- + A failure as the coding agent's event stream carries it: category, + safe message, retry advice, provider and model context, and the + rendered source chain. Never a raw provider response body. + type: object + required: + - kind + - message + properties: + kind: + $ref: "#/components/schemas/AgentErrorKind" + message: + type: string + description: The whole failure rendered for a person, cause included. + llm_kind: + $ref: "#/components/schemas/LlmErrorKind" + description: The model-layer category, when a model call failed. + retry: + $ref: "#/components/schemas/LlmRetryClassification" + description: Whether repeating the same model call is safe, when a model call failed. + provider: + type: string + description: The provider that produced the failure, when one was selected. + model: + type: string + description: The model that produced the failure, when one was selected. + status: + type: integer + format: uint16 + minimum: 0 + description: The HTTP status, when the failure came from an HTTP response. + provider_code: + type: string + description: The provider's own error code, as reported on the wire. + provider_retry_after_millis: + type: integer + format: uint64 + minimum: 0 + description: The provider's advised wait in milliseconds. + source_chain: + type: array + items: + type: string + description: The text of each cause below `message`, outermost first. + + AgentErrorKind: + description: The stable category of a coding agent failure. + type: string + enum: + - llm + - compaction + - agent + - invalid_input + - session_closed + - invalid_state + - tool_execution + - interrupted + - tool_rounds_exhausted + - task + - event_stream + + LlmErrorKind: + description: >- + The model layer's stable failure category. Known values are + `configuration`, `model_selection`, `authentication`, + `access_denied`, `not_found`, `invalid_request`, `context_length`, + `rate_limit`, `quota_exceeded`, `content_filter`, `server`, + `provider`, `network`, `timeout`, `stream_decode`, `response_decode`, + `resource_limit`, `middleware`, and `cancelled`. A category written + by a newer model layer is carried as its own spelling. + type: string + example: rate_limit + + LlmRetryClassification: + description: Whether repeating the same resolved model call is safe. + oneOf: + - $ref: "#/components/schemas/LlmRetryClassificationNever" + - $ref: "#/components/schemas/LlmRetryClassificationSafe" + - $ref: "#/components/schemas/LlmRetryClassificationAfter" + discriminator: + propertyName: type + mapping: + never: "#/components/schemas/LlmRetryClassificationNever" + safe: "#/components/schemas/LlmRetryClassificationSafe" + after: "#/components/schemas/LlmRetryClassificationAfter" + + LlmRetryClassificationNever: + description: Repeating the call cannot succeed. + type: object + required: + - type + properties: + type: + type: string + enum: [never] + + LlmRetryClassificationSafe: + description: Repeating the call is safe on the caller's own schedule. + type: object + required: + - type + properties: + type: + type: string + enum: [safe] + + LlmRetryClassificationAfter: + description: Repeating the call is safe after the given delay. + type: object + required: + - type + - after_millis + properties: + type: + type: string + enum: [after] + after_millis: + type: integer + format: uint64 + minimum: 0 + description: The delay in milliseconds. + StageModelUsage: description: Provider, model, and request-control metadata recorded for a stage attempt. type: object diff --git a/lib/foundation/fabro-api/Cargo.toml b/lib/foundation/fabro-api/Cargo.toml index ec0ef3535..874532f9d 100644 --- a/lib/foundation/fabro-api/Cargo.toml +++ b/lib/foundation/fabro-api/Cargo.toml @@ -20,6 +20,7 @@ fabro-config = { path = "../fabro-config" } fabro-environment.workspace = true fabro-types = { path = "../fabro-types" } lithos-llm = { workspace = true, features = ["runtime"] } +pebble-coding-agent.workspace = true progenitor-client = "0.13" regress = "0.10" reqwest.workspace = true @@ -38,3 +39,5 @@ syn = "2" [dev-dependencies] fabro-types = { path = "../fabro-types", features = ["test-support"] } +jsonschema.workspace = true +serde_yaml = "0.9" diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 6630080f7..d5c207a1c 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -433,6 +433,122 @@ fn main() { "fabro_types::AgentMcpToolSummary", &[], ), + // Pebble's own fold of a stage's agent events, embedded in + // `StageProjection.agent`. Every nested type is pebble's; the schema + // names carry an `AgentSession` prefix where fabro already has a + // schema of the same name for its own projection. + ( + "AgentSessionProjection", + "pebble_coding_agent::projection::SessionProjection", + &[], + ), + ( + "AgentSessionActivity", + "pebble_coding_agent::projection::SessionActivity", + &[], + ), + ( + "AgentSessionRoute", + "pebble_coding_agent::projection::RouteProjection", + &[], + ), + ( + "AgentSessionDescendantAccount", + "pebble_coding_agent::projection::DescendantAccount", + &[], + ), + ( + "AgentSessionToolActivity", + "pebble_coding_agent::projection::ToolActivity", + &[], + ), + ( + "AgentSessionSubagentCounts", + "pebble_coding_agent::projection::SubagentCounts", + &[], + ), + ( + "AgentSessionMcpServer", + "pebble_coding_agent::projection::McpServerProjection", + &[], + ), + ( + "AgentSessionSkills", + "pebble_coding_agent::projection::SkillsProjection", + &[], + ), + ( + "AgentSessionActivatedSkill", + "pebble_coding_agent::projection::ActivatedSkill", + &[], + ), + ( + "AgentSessionSubagent", + "pebble_coding_agent::projection::SubagentProjection", + &[], + ), + ( + "AgentSessionSubagentStatus", + "pebble_coding_agent::projection::SubagentStatus", + &[], + ), + ( + "AgentSessionCompaction", + "pebble_coding_agent::projection::CompactionProjection", + &[], + ), + ( + "AgentSessionRouteFailover", + "pebble_coding_agent::projection::RouteFailoverProjection", + &[], + ), + ( + "AgentSessionFailoverStop", + "pebble_coding_agent::projection::FailoverStopProjection", + &[], + ), + ( + "AgentSessionPromptDelta", + "pebble_coding_agent::projection::PromptDelta", + &[], + ), + ("TokenUsage", "pebble_coding_agent::events::TokenUsage", &[]), + ( + "McpToolSummary", + "pebble_coding_agent::events::McpToolSummary", + &[], + ), + ( + "CompactionReason", + "pebble_coding_agent::events::CompactionReason", + &[], + ), + ( + "FailoverContinuation", + "pebble_coding_agent::events::FailoverContinuation", + &[], + ), + ( + "FailoverStop", + "pebble_coding_agent::events::FailoverStop", + &[], + ), + ( + "AgentErrorData", + "pebble_coding_agent::events::ErrorData", + &[], + ), + ( + "AgentErrorKind", + "pebble_coding_agent::events::ErrorKind", + &[], + ), + ("LlmErrorKind", "lithos_llm::types::ErrorKind", &[]), + ( + "LlmRetryClassification", + "lithos_llm::types::RetryClassification", + &[], + ), ( "McpHttpProtocol", "fabro_types::settings::run::McpHttpProtocol", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 3ac034c73..485c72e20 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -75,12 +75,31 @@ pub mod types { }; pub use lithos_llm::catalog::{ModelHandle, ProviderId}; pub use lithos_llm::types::{ - ContentPart, Cost as CompletionCost, CostSource, Message, ReasoningEffort, ReasoningOutput, - ResponseFormat as CompletionResponseFormat, Role, Speed as BillingSpeed, + ContentPart, Cost as CompletionCost, CostSource, ErrorKind as LlmErrorKind, Message, + ReasoningEffort, ReasoningOutput, ResponseFormat as CompletionResponseFormat, + RetryClassification as LlmRetryClassification, Role, Speed as BillingSpeed, TokenCounts as CompletionUsage, ToolChoice as CompletionToolChoice, ToolDefinition as CompletionToolDefinition, ToolDefinitionKind as CompletionToolDefinitionKind, }; + /// `StageProjection.agent` is the coding agent's own fold of the stage's + /// events; the API reuses pebble's types under the schema names. + pub use pebble_coding_agent::events::{ + CompactionReason, ErrorData as AgentErrorData, ErrorKind as AgentErrorKind, + FailoverContinuation, FailoverStop, McpToolSummary, TokenUsage, + }; + pub use pebble_coding_agent::projection::{ + ActivatedSkill as AgentSessionActivatedSkill, + CompactionProjection as AgentSessionCompaction, + DescendantAccount as AgentSessionDescendantAccount, + FailoverStopProjection as AgentSessionFailoverStop, + McpServerProjection as AgentSessionMcpServer, PromptDelta as AgentSessionPromptDelta, + RouteFailoverProjection as AgentSessionRouteFailover, RouteProjection as AgentSessionRoute, + SessionActivity as AgentSessionActivity, SessionProjection as AgentSessionProjection, + SkillsProjection as AgentSessionSkills, SubagentCounts as AgentSessionSubagentCounts, + SubagentProjection as AgentSessionSubagent, SubagentStatus as AgentSessionSubagentStatus, + ToolActivity as AgentSessionToolActivity, + }; /// A sandbox's status on the API is the sandbox driver's own type. pub use sandbox_driver::{ NetworkPolicy as SandboxNetworkPolicy, Resources as SandboxResources, SandboxId, diff --git a/lib/foundation/fabro-api/tests/agent_session_projection_round_trip.rs b/lib/foundation/fabro-api/tests/agent_session_projection_round_trip.rs new file mode 100644 index 000000000..1c6a5ba87 --- /dev/null +++ b/lib/foundation/fabro-api/tests/agent_session_projection_round_trip.rs @@ -0,0 +1,629 @@ +//! `StageProjection.agent` is pebble's `SessionProjection`, reused verbatim +//! along with every type nested in it. These tests prove the API types are +//! pebble's own and that the OpenAPI schemas describe pebble's serde shape: +//! a populated projection validates against `AgentSessionProjection`, every +//! key it serializes is declared, and every enum variant this build knows is +//! in the spec. A pebble re-pin that adds a field or a variant fails here +//! until the spec is updated. + +use std::any::{TypeId, type_name}; +use std::collections::BTreeMap; +use std::num::NonZeroU32; +use std::time::{Duration, SystemTime}; + +use fabro_api::types::{ + AgentErrorData as ApiAgentErrorData, AgentErrorKind as ApiAgentErrorKind, + AgentSessionActivatedSkill as ApiAgentSessionActivatedSkill, + AgentSessionActivity as ApiAgentSessionActivity, + AgentSessionCompaction as ApiAgentSessionCompaction, + AgentSessionDescendantAccount as ApiAgentSessionDescendantAccount, + AgentSessionFailoverStop as ApiAgentSessionFailoverStop, + AgentSessionMcpServer as ApiAgentSessionMcpServer, + AgentSessionProjection as ApiAgentSessionProjection, + AgentSessionPromptDelta as ApiAgentSessionPromptDelta, + AgentSessionRoute as ApiAgentSessionRoute, + AgentSessionRouteFailover as ApiAgentSessionRouteFailover, + AgentSessionSkills as ApiAgentSessionSkills, AgentSessionSubagent as ApiAgentSessionSubagent, + AgentSessionSubagentCounts as ApiAgentSessionSubagentCounts, + AgentSessionSubagentStatus as ApiAgentSessionSubagentStatus, + AgentSessionToolActivity as ApiAgentSessionToolActivity, + CompactionReason as ApiCompactionReason, FailoverContinuation as ApiFailoverContinuation, + FailoverStop as ApiFailoverStop, LlmErrorKind as ApiLlmErrorKind, + LlmRetryClassification as ApiLlmRetryClassification, McpToolSummary as ApiMcpToolSummary, + StageProjection as ApiStageProjection, TokenUsage as ApiTokenUsage, +}; +use fabro_types::StageProjection; +use lithos_llm::types::{ErrorKind as LlmErrorKind, RetryClassification}; +use pebble_coding_agent::events::{ + CodingAgentEvent, CodingEvent, CompactionReason, ContextWindowCountMethod, + ContextWindowSnapshot, ContextWindowStaleness, ErrorData, ErrorKind, FailoverContinuation, + FailoverStop, InputSource, LlmRetryPhase, McpToolSummary, SkillActivationSource, SkillSummary, + TodoCreatedProps, TodoListKind, TodoStatus, TokenUsage, +}; +use pebble_coding_agent::projection::{ + ActivatedSkill, CompactionProjection, DescendantAccount, FailoverStopProjection, + McpServerProjection, PromptDelta, RouteFailoverProjection, RouteProjection, SessionActivity, + SessionProjection, SkillsProjection, SubagentCounts, SubagentProjection, SubagentStatus, + ToolActivity, +}; +use pebble_coding_agent::tools::ToolOutputMetadata; +use serde_json::{Value, json}; + +#[test] +fn agent_session_projection_reuses_pebbles_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn a_populated_projection_matches_its_openapi_schema() { + let projection = scripted_projection(); + // The script reached every section of the projection. + assert_eq!(projection.root_session_id.as_deref(), Some("ses_root")); + assert_eq!(projection.activity, SessionActivity::Idle); + assert_eq!(projection.route.model.as_deref(), Some("claude-fable-5")); + assert_eq!(projection.prompts, 1); + assert!(projection.prompt.completed); + assert_eq!(projection.messages, 2); + assert_eq!(projection.retries, 1); + assert_eq!(projection.descendants.len(), 1); + assert_eq!(projection.tools.len(), 2); + assert_eq!(projection.mcp_servers.len(), 2); + assert_eq!(projection.skills.activated.len(), 1); + assert_eq!(projection.todos.len(), 1); + assert_eq!(projection.subagents.len(), 2); + assert_eq!(projection.compactions.len(), 1); + assert_eq!(projection.failovers.len(), 1); + assert!(projection.failover_stopped.is_some()); + assert_eq!(projection.files_touched, ["/workspace/src/lib.rs"]); + assert!(projection.context_window.is_some()); + + let value = serde_json::to_value(&projection).unwrap(); + assert!( + value.get("pending_writes").is_none(), + "a settled projection keeps its bookkeeping off the wire: {value}" + ); + assert_valid("AgentSessionProjection", &value); + assert_declared( + &spec(), + &json!({ "$ref": "#/components/schemas/AgentSessionProjection" }), + &value, + "agent", + ); + + let api: ApiAgentSessionProjection = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(api, projection); + assert_eq!(serde_json::to_value(&api).unwrap(), value); +} + +#[test] +fn a_projection_taken_mid_write_declares_its_open_write() { + let mut projection = scripted_projection(); + projection.apply(&root(CodingEvent::ToolCallStarted { + tool_name: "write_file".to_string(), + tool_call_id: "call_open".to_string(), + arguments: json!({"file_path": "/workspace/README.md", "content": "x"}), + })); + let value = serde_json::to_value(&projection).unwrap(); + assert_eq!( + value["pending_writes"]["call_open"], + json!(["/workspace/README.md"]) + ); + assert_valid("AgentSessionProjection", &value); + assert_declared( + &spec(), + &json!({ "$ref": "#/components/schemas/AgentSessionProjection" }), + &value, + "agent", + ); +} + +#[test] +fn a_stage_projection_carrying_the_fold_matches_its_openapi_schema() { + let mut stage = StageProjection::new(NonZeroU32::new(1).unwrap()); + stage.agent = Some(scripted_projection()); + let value = serde_json::to_value(&stage).unwrap(); + assert!(value["agent"].is_object()); + assert_valid("StageProjection", &value); + assert_declared( + &spec(), + &json!({ "$ref": "#/components/schemas/StageProjection" }), + &value, + "stage", + ); + let api: ApiStageProjection = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(&api).unwrap(), value); + + let without: StageProjection = serde_json::from_value(json!({ + "first_event_seq": 1, + "prompt": null, + "response": null, + "completion": null, + "provider_used": null, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "agent_control": "running", + "state": "running" + })) + .unwrap(); + assert!( + without.agent.is_none(), + "a stage written before the fold existed has none" + ); + assert!( + serde_json::to_value(&without) + .unwrap() + .get("agent") + .is_none(), + "a stage without a fold serializes none" + ); +} + +#[test] +fn every_enum_variant_this_build_knows_is_in_the_spec() { + for activity in [ + SessionActivity::Idle, + SessionActivity::Running, + SessionActivity::WaitingForSteer, + SessionActivity::Ended, + ] { + assert_valid( + "AgentSessionActivity", + &serde_json::to_value(activity).unwrap(), + ); + } + for reason in [ + CompactionReason::Threshold, + CompactionReason::Manual, + CompactionReason::Overflow, + ] { + assert_valid("CompactionReason", &serde_json::to_value(reason).unwrap()); + } + for continuation in [ + FailoverContinuation::ReplayPrompt, + FailoverContinuation::ContinueTurn, + ] { + assert_valid( + "FailoverContinuation", + &serde_json::to_value(continuation).unwrap(), + ); + } + for stop in [FailoverStop::Ineligible, FailoverStop::Exhausted] { + assert_valid("FailoverStop", &serde_json::to_value(stop).unwrap()); + } + for kind in [ + ErrorKind::Llm, + ErrorKind::Compaction, + ErrorKind::Agent, + ErrorKind::InvalidInput, + ErrorKind::SessionClosed, + ErrorKind::InvalidState, + ErrorKind::ToolExecution, + ErrorKind::Interrupted, + ErrorKind::ToolRoundsExhausted, + ErrorKind::Task, + ErrorKind::EventStream, + ] { + assert_valid("AgentErrorKind", &serde_json::to_value(kind).unwrap()); + } + for retry in [ + RetryClassification::Never, + RetryClassification::Safe, + RetryClassification::after(Duration::from_millis(1500)), + ] { + let value = serde_json::to_value(retry).unwrap(); + assert_valid("LlmRetryClassification", &value); + assert_declared( + &spec(), + &json!({ "$ref": "#/components/schemas/LlmRetryClassification" }), + &value, + "retry", + ); + } + for kind in [ + LlmErrorKind::RateLimit, + LlmErrorKind::ContextLength, + LlmErrorKind::Unknown("later".to_string()), + ] { + assert_valid("LlmErrorKind", &serde_json::to_value(kind).unwrap()); + } + for status in [ + SubagentStatus::Running, + SubagentStatus::Completed { + success: true, + turns_used: 3, + }, + SubagentStatus::Failed { error: llm_error() }, + SubagentStatus::Closed, + ] { + let value = serde_json::to_value(&status).unwrap(); + assert_valid("AgentSessionSubagentStatus", &value); + assert_declared( + &spec(), + &json!({ "$ref": "#/components/schemas/AgentSessionSubagentStatus" }), + &value, + "status", + ); + } +} + +// --- The scripted session ------------------------------------------------- + +fn scripted_projection() -> SessionProjection { + let mut projection = SessionProjection::new(); + projection.apply_all(&scripted_events()); + projection +} + +/// One prompt that touches every section: MCP servers up and down, skills, +/// a todo, a completed and a failed child, a retry, a failover with a +/// compaction on the new route, and a stop. +fn scripted_events() -> Vec { + vec![ + root(CodingEvent::SessionStarted { + provider: Some("openai".to_string()), + model: Some("gpt-5.2".to_string()), + }), + root(CodingEvent::McpServerReady { + server: "github".to_string(), + tools: vec![McpToolSummary { + name: "mcp__github__list_issues".to_string(), + original_name: "list_issues".to_string(), + }], + startup_ms: 842, + }), + root(CodingEvent::McpServerFailed { + server: "broken".to_string(), + error: "could not launch".to_string(), + startup_ms: 3, + }), + root(CodingEvent::SkillsDiscovered { + profile: "anthropic".to_string(), + source_dirs: Vec::new(), + skills: vec![SkillSummary { + name: "rust".to_string(), + description: "Rust workflow help".to_string(), + }], + skipped: Vec::new(), + }), + root(CodingEvent::UserInput { + text: "build it".to_string(), + content: None, + source: InputSource::Prompt, + }), + root(message("openai", "gpt-5.2", 100, 10, Some(500))), + root(CodingEvent::ToolCallStarted { + tool_name: "mcp__github__list_issues".to_string(), + tool_call_id: "call_1".to_string(), + arguments: json!({}), + }), + root(completed("mcp__github__list_issues", "call_1", false)), + root(CodingEvent::ToolCallStarted { + tool_name: "write_file".to_string(), + tool_call_id: "call_2".to_string(), + arguments: json!({"file_path": "/workspace/src/lib.rs", "content": "pub fn x() {}"}), + }), + root(completed("write_file", "call_2", false)), + root(CodingEvent::SkillActivated { + skill_name: "rust".to_string(), + source: SkillActivationSource::Tool, + }), + root(CodingEvent::TodoCreated(TodoCreatedProps { + list_id: TodoListKind::AnthropicTasks.list_id("ses_root"), + list_kind: TodoListKind::AnthropicTasks, + todo_id: "t1".to_string(), + status: TodoStatus::InProgress, + order: 0, + subject: "write tests".to_string(), + description: String::new(), + active_form: Some("Writing tests".to_string()), + owner: None, + blocks: Vec::new(), + blocked_by: Vec::new(), + metadata: BTreeMap::new(), + })), + root(CodingEvent::SubAgentSpawned { + agent_id: "sub-1".to_string(), + depth: 1, + task: "review".to_string(), + generation: 1, + }), + child(CodingEvent::SessionStarted { + provider: Some("openai".to_string()), + model: Some("gpt-5.2-mini".to_string()), + }), + child(message("openai", "gpt-5.2-mini", 7, 1, None)), + root(CodingEvent::SubAgentCompleted { + agent_id: "sub-1".to_string(), + depth: 1, + generation: 1, + success: true, + turns_used: 1, + }), + root(CodingEvent::SubAgentSpawned { + agent_id: "sub-2".to_string(), + depth: 1, + task: "check the tests".to_string(), + generation: 1, + }), + root(CodingEvent::SubAgentFailed { + agent_id: "sub-2".to_string(), + depth: 1, + generation: 1, + error: ErrorData::new(ErrorKind::Agent, "boom"), + }), + root(CodingEvent::LlmRetry { + provider: "openai".to_string(), + model: "gpt-5.2".to_string(), + attempt: 0, + delay_secs: 0.1, + error: ErrorData::new(ErrorKind::Llm, "slow down"), + phase: LlmRetryPhase::Open, + }), + root(CodingEvent::RouteFailover { + from: "openai/gpt-5.2".to_string(), + to: "anthropic/claude-fable-5".to_string(), + attempt: 1, + error: llm_error(), + usage: TokenUsage { + input: 100, + output: 10, + ..TokenUsage::default() + }, + cost_usd_micros: Some(500), + inference_ms: 120, + tool_ms: 30, + continuation: FailoverContinuation::ContinueTurn, + }), + root(CodingEvent::CompactionCompleted { + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 1, + reason: CompactionReason::Threshold, + usage: TokenUsage { + input: 30, + ..TokenUsage::default() + }, + cost_usd_micros: Some(2), + }), + root(message("anthropic", "claude-fable-5", 50, 5, Some(300))), + root(CodingEvent::RouteFailoverStopped { + route: "anthropic/claude-fable-5".to_string(), + attempt: 1, + reason: FailoverStop::Exhausted, + error: llm_error(), + }), + root(CodingEvent::ProcessingEnd), + ] +} + +fn root(event: CodingEvent) -> CodingAgentEvent { + CodingAgentEvent::new("ses_root".to_string(), event, SystemTime::UNIX_EPOCH) +} + +fn child(event: CodingEvent) -> CodingAgentEvent { + CodingAgentEvent::new("ses_child".to_string(), event, SystemTime::UNIX_EPOCH) + .with_parent_session_id("ses_root".to_string()) +} + +fn message(provider: &str, model: &str, input: u64, output: u64, cost: Option) -> CodingEvent { + CodingEvent::AssistantMessage { + text: "ok".to_string(), + model: model.to_string(), + usage: TokenUsage { + input, + output, + ..TokenUsage::default() + }, + cost_usd_micros: cost, + cost_source: None, + tool_call_count: 0, + context_window: Some(ContextWindowSnapshot { + provider: provider.to_string(), + model: model.to_string(), + context_window_tokens: 400_000, + input_tokens: 123_456, + usage_percent: 30.864, + count_method: ContextWindowCountMethod::LocalEstimate, + staleness: ContextWindowStaleness::Live, + generated_at: SystemTime::UNIX_EPOCH, + event_seq: Some(9), + breakdown: Vec::new(), + warnings: Vec::new(), + }), + reasoning: None, + } +} + +fn completed(tool_name: &str, tool_call_id: &str, is_error: bool) -> CodingEvent { + CodingEvent::ToolCallCompleted { + tool_name: tool_name.to_string(), + tool_call_id: tool_call_id.to_string(), + output: json!("done"), + metadata: ToolOutputMetadata::default(), + is_error, + error_kind: None, + output_bytes_observed: 4, + output_bytes_retained: 4, + output_bytes_omitted: 0, + } +} + +/// A model-layer failure with every optional member set. +fn llm_error() -> ErrorData { + let mut error = ErrorData::new(ErrorKind::Llm, "rate limited: 429 Too Many Requests") + .with_provider("openai") + .with_model("gpt-5.2"); + error.llm_kind = Some(LlmErrorKind::RateLimit); + error.retry = Some(RetryClassification::after(Duration::from_millis(1500))); + error.status = Some(429); + error.provider_code = Some("rate_limit_exceeded".to_string()); + error.provider_retry_after_millis = Some(1500); + error.source_chain = vec!["429 Too Many Requests".to_string()]; + error +} + +// --- The spec as a JSON schema -------------------------------------------- + +#[expect( + clippy::disallowed_methods, + reason = "a synchronous test reads the spec from the repository once" +)] +fn spec() -> Value { + let text = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../docs/public/api-reference/fabro-api.yaml" + )) + .expect("the OpenAPI spec is in the repository"); + let yaml: serde_yaml::Value = serde_yaml::from_str(&text).expect("the spec parses"); + serde_json::to_value(yaml).expect("the spec is JSON-compatible") +} + +/// Validates `value` against one component schema, with the whole document +/// as the root so `$ref`s resolve. +fn assert_valid(schema_name: &str, value: &Value) { + let mut root = spec(); + root["$ref"] = json!(format!("#/components/schemas/{schema_name}")); + let validator = jsonschema::validator_for(&root).expect("the spec compiles as a JSON schema"); + let errors: Vec = validator + .iter_errors(value) + .map(|error| format!("{error} at {}", error.instance_path())) + .collect(); + assert!( + errors.is_empty(), + "{schema_name} rejects {value:#}:\n{}", + errors.join("\n") + ); +} + +fn resolve<'a>(spec: &'a Value, schema: &'a Value) -> &'a Value { + match schema.get("$ref").and_then(Value::as_str) { + Some(reference) => resolve( + spec, + spec.pointer(reference.trim_start_matches('#')) + .unwrap_or_else(|| panic!("unresolved {reference}")), + ), + None => schema, + } +} + +/// Every key `value` serializes is a declared property of `schema`, and +/// every required property is present, recursively. This is what catches a +/// pebble field the spec does not know yet, since the schemas do not forbid +/// additional properties. +fn assert_declared(spec: &Value, schema: &Value, value: &Value, path: &str) { + let schema = resolve(spec, schema); + if let Some(variants) = schema.get("oneOf").and_then(Value::as_array) { + if value.is_null() { + return; + } + let chosen = match schema.get("discriminator") { + Some(discriminator) => { + let property = discriminator["propertyName"] + .as_str() + .expect("a discriminator names its property"); + let tag = value[property] + .as_str() + .unwrap_or_else(|| panic!("{path}: no `{property}` tag in {value}")); + let target = discriminator["mapping"][tag] + .as_str() + .unwrap_or_else(|| panic!("{path}: `{tag}` is not a mapped variant")); + spec.pointer(target.trim_start_matches('#')) + .unwrap_or_else(|| panic!("unresolved {target}")) + } + None => variants + .iter() + .map(|variant| resolve(spec, variant)) + .find(|variant| variant.get("type").and_then(Value::as_str) != Some("null")) + .unwrap_or_else(|| panic!("{path}: no non-null variant")), + }; + assert_declared(spec, chosen, value, path); + return; + } + match value { + Value::Object(object) => { + if let Some(additional) = schema.get("additionalProperties") { + if additional.is_object() { + for (key, member) in object { + assert_declared(spec, additional, member, &format!("{path}.{key}")); + } + } + return; + } + let properties = schema + .get("properties") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("{path}: the schema declares no properties")); + for (key, member) in object { + let property = properties + .get(key) + .unwrap_or_else(|| panic!("{path}.{key} is serialized but not declared")); + assert_declared(spec, property, member, &format!("{path}.{key}")); + } + for required in schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let key = required.as_str().expect("required names are strings"); + assert!( + object.contains_key(key), + "{path}.{key} is required but not serialized" + ); + } + } + Value::Array(items) => { + if let Some(item_schema) = schema.get("items") { + for (index, item) in items.iter().enumerate() { + assert_declared(spec, item_schema, item, &format!("{path}[{index}]")); + } + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 88e1306a8..1c312fcf2 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -29,9 +29,30 @@ configuration.ts index.ts models/activated-skill.ts models/agent-control-state.ts +models/agent-error-data.ts +models/agent-error-kind.ts models/agent-event-props.ts models/agent-mcp-tool-summary.ts models/agent-session-activated-props.ts +models/agent-session-activated-skill.ts +models/agent-session-activity.ts +models/agent-session-compaction.ts +models/agent-session-descendant-account.ts +models/agent-session-failover-stop.ts +models/agent-session-mcp-server.ts +models/agent-session-projection.ts +models/agent-session-prompt-delta.ts +models/agent-session-route-failover.ts +models/agent-session-route.ts +models/agent-session-skills.ts +models/agent-session-subagent-counts.ts +models/agent-session-subagent-status-closed.ts +models/agent-session-subagent-status-completed.ts +models/agent-session-subagent-status-failed.ts +models/agent-session-subagent-status-running.ts +models/agent-session-subagent-status.ts +models/agent-session-subagent.ts +models/agent-session-tool-activity.ts models/agent-tools-available-props.ts models/aggregate-billing-totals.ts models/aggregate-billing.ts @@ -80,6 +101,7 @@ models/close-run-pull-request-response.ts models/code-location.ts models/command-log-response.ts models/command-termination.ts +models/compaction-reason.ts models/completion-content-part.ts models/completion-cost.ts models/completion-message.ts @@ -147,6 +169,8 @@ models/exec-output-tail.ts models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts models/execute-query-response.ts +models/failover-continuation.ts +models/failover-stop.ts models/failure-category.ts models/failure-detail.ts models/failure-reason.ts @@ -203,6 +227,10 @@ models/interview-provider-settings.ts models/interview-question-record.ts models/link-run-pull-request-request.ts models/llm-output-kind.ts +models/llm-retry-classification-after.ts +models/llm-retry-classification-never.ts +models/llm-retry-classification-safe.ts +models/llm-retry-classification.ts models/log-destination.ts models/manifest-args.ts models/manifest-config.ts @@ -222,6 +250,7 @@ models/mcp-server-status-failed.ts models/mcp-server-status-ready.ts models/mcp-server-status.ts models/mcp-server.ts +models/mcp-tool-summary.ts models/mcp-transport-http.ts models/mcp-transport-sandbox.ts models/mcp-transport-stdio.ts @@ -531,6 +560,7 @@ models/todo-list-kind.ts models/todo-list-projection.ts models/todo-projection.ts models/todo-status.ts +models/token-usage.ts models/tool-category.ts models/tool-source-application.ts models/tool-source-mcp.ts diff --git a/lib/packages/fabro-api-client/src/models/agent-error-data.ts b/lib/packages/fabro-api-client/src/models/agent-error-data.ts new file mode 100644 index 000000000..3172dd416 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-error-data.ts @@ -0,0 +1,64 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentErrorKind } from './agent-error-kind'; +// May contain unused imports in some cases +// @ts-ignore +import type { LlmRetryClassification } from './llm-retry-classification'; + +/** + * A failure as the coding agent\'s event stream carries it: category, safe message, retry advice, provider and model context, and the rendered source chain. Never a raw provider response body. + */ +export interface AgentErrorData { + 'kind': AgentErrorKind; + /** + * The whole failure rendered for a person, cause included. + */ + 'message': string; + /** + * The model-layer category, when a model call failed. + */ + 'llm_kind'?: string; + /** + * Whether repeating the same model call is safe, when a model call failed. + */ + 'retry'?: LlmRetryClassification; + /** + * The provider that produced the failure, when one was selected. + */ + 'provider'?: string; + /** + * The model that produced the failure, when one was selected. + */ + 'model'?: string; + /** + * The HTTP status, when the failure came from an HTTP response. + */ + 'status'?: number; + /** + * The provider\'s own error code, as reported on the wire. + */ + 'provider_code'?: string; + /** + * The provider\'s advised wait in milliseconds. + */ + 'provider_retry_after_millis'?: number; + /** + * The text of each cause below `message`, outermost first. + */ + 'source_chain'?: Array; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-error-kind.ts b/lib/packages/fabro-api-client/src/models/agent-error-kind.ts new file mode 100644 index 000000000..771f9d41b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-error-kind.ts @@ -0,0 +1,35 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * The stable category of a coding agent failure. + */ + +export const AgentErrorKind = { + LLM: 'llm', + COMPACTION: 'compaction', + AGENT: 'agent', + INVALID_INPUT: 'invalid_input', + SESSION_CLOSED: 'session_closed', + INVALID_STATE: 'invalid_state', + TOOL_EXECUTION: 'tool_execution', + INTERRUPTED: 'interrupted', + TOOL_ROUNDS_EXHAUSTED: 'tool_rounds_exhausted', + TASK: 'task', + EVENT_STREAM: 'event_stream' +} as const; + +export type AgentErrorKind = typeof AgentErrorKind[keyof typeof AgentErrorKind]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-activated-skill.ts b/lib/packages/fabro-api-client/src/models/agent-session-activated-skill.ts new file mode 100644 index 000000000..f98711cec --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-activated-skill.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SkillActivationSource } from './skill-activation-source'; + +/** + * A skill the session activated. + */ +export interface AgentSessionActivatedSkill { + 'name': string; + 'source': SkillActivationSource; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-activity.ts b/lib/packages/fabro-api-client/src/models/agent-session-activity.ts new file mode 100644 index 000000000..ddbe4363f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-activity.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Where a session stands, as its events tell it. + */ + +export const AgentSessionActivity = { + IDLE: 'idle', + RUNNING: 'running', + WAITING_FOR_STEER: 'waiting_for_steer', + ENDED: 'ended' +} as const; + +export type AgentSessionActivity = typeof AgentSessionActivity[keyof typeof AgentSessionActivity]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-compaction.ts b/lib/packages/fabro-api-client/src/models/agent-session-compaction.ts new file mode 100644 index 000000000..e1c74598d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-compaction.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { CompactionReason } from './compaction-reason'; +// May contain unused imports in some cases +// @ts-ignore +import type { TokenUsage } from './token-usage'; + +/** + * One compaction the root session completed. + */ +export interface AgentSessionCompaction { + 'reason': CompactionReason; + 'original_turn_count': number; + 'preserved_turn_count': number; + 'summary_token_estimate': number; + 'tracked_file_count': number; + /** + * The summary call\'s tokens: a breakdown of the session\'s and the prompt\'s usage, which already include them. Zero on compactions recorded before it was kept. + */ + 'usage'?: TokenUsage; + /** + * The summary call\'s provider-reported cost, included in the totals the same way. + */ + 'cost_usd_micros'?: number; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-descendant-account.ts b/lib/packages/fabro-api-client/src/models/agent-session-descendant-account.ts new file mode 100644 index 000000000..8b21d712c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-descendant-account.ts @@ -0,0 +1,46 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { TokenUsage } from './token-usage'; + +/** + * What one descendant session spent, as its own events reported it. + */ +export interface AgentSessionDescendantAccount { + /** + * The session that spawned it. + */ + 'parent': string; + /** + * The provider it runs on, as its `SessionStarted` reported it. + */ + 'provider'?: string; + /** + * The model it runs on, from its `SessionStarted`; when the start was not seen, the model of its first answer. + */ + 'model'?: string; + 'usage': TokenUsage; + 'cost_usd_micros': number | null; + /** + * Committed assistant messages. + */ + 'messages': number; + /** + * Compactions it completed. + */ + 'compactions': number; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-failover-stop.ts b/lib/packages/fabro-api-client/src/models/agent-session-failover-stop.ts new file mode 100644 index 000000000..90ae80cd4 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-failover-stop.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentErrorData } from './agent-error-data'; +// May contain unused imports in some cases +// @ts-ignore +import type { FailoverStop } from './failover-stop'; + +/** + * Why a prompt stayed on its route and ended there although fallback routes were named. + */ +export interface AgentSessionFailoverStop { + /** + * The `provider/model` the prompt ended on. + */ + 'route': string; + /** + * How many fallback routes the prompt had moved through; `0` on the route it started on. + */ + 'attempt': number; + 'reason': FailoverStop; + /** + * The failure that ended the prompt. + */ + 'error': AgentErrorData; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-mcp-server.ts b/lib/packages/fabro-api-client/src/models/agent-session-mcp-server.ts new file mode 100644 index 000000000..5e6d91bed --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-mcp-server.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { McpToolSummary } from './mcp-tool-summary'; + +/** + * One MCP server the session configured, and whether it has been called. `disconnected` set means the server came up and its connection then closed; otherwise `error` set means it did not start; otherwise it is ready with `tools`. + */ +export interface AgentSessionMcpServer { + 'tools': Array; + /** + * Why it did not start, when it did not. + */ + 'error': string | null; + /** + * Whether any of its tools has been called. + */ + 'invoked': boolean; + /** + * What closed its connection during the session, when it closed. + */ + 'disconnected'?: string; + /** + * Milliseconds from launch to its outcome: to its tools being listed, or to the failure. Absent until either has been seen. + */ + 'startup_ms'?: number; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-projection.ts b/lib/packages/fabro-api-client/src/models/agent-session-projection.ts new file mode 100644 index 000000000..2ec0ff6e7 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-projection.ts @@ -0,0 +1,131 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionActivity } from './agent-session-activity'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionCompaction } from './agent-session-compaction'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionDescendantAccount } from './agent-session-descendant-account'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionFailoverStop } from './agent-session-failover-stop'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionMcpServer } from './agent-session-mcp-server'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionPromptDelta } from './agent-session-prompt-delta'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionRoute } from './agent-session-route'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionRouteFailover } from './agent-session-route-failover'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSkills } from './agent-session-skills'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagent } from './agent-session-subagent'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentCounts } from './agent-session-subagent-counts'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionToolActivity } from './agent-session-tool-activity'; +// May contain unused imports in some cases +// @ts-ignore +import type { ContextWindowSnapshot } from './context-window-snapshot'; +// May contain unused imports in some cases +// @ts-ignore +import type { TodoListProjection } from './todo-list-projection'; +// May contain unused imports in some cases +// @ts-ignore +import type { TokenUsage } from './token-usage'; + +/** + * The coding agent\'s fold of one stage\'s event stream: token counts and provider-reported cost for the root session and each descendant, the route and where it moved, the context window, tools, MCP servers, skills, todo lists, subagents, compactions, files touched, and the prompt in progress. Counts only; pricing a count from the catalog is fabro\'s, and lives in `StageProjection.usage`. + */ +export interface AgentSessionProjection { + /** + * The root session, once an event named it. + */ + 'root_session_id': string | null; + 'route': AgentSessionRoute; + 'activity': AgentSessionActivity; + /** + * The root session\'s usage over the stage. + */ + 'usage': TokenUsage; + /** + * The root session\'s provider-reported cost, when a provider reported one. + */ + 'cost_usd_micros': number | null; + /** + * Committed assistant messages from the root session. + */ + 'messages': number; + /** + * Every descendant session\'s account, by session id. + */ + 'descendants': { [key: string]: AgentSessionDescendantAccount; }; + 'context_window': ContextWindowSnapshot | null; + /** + * Every tool called anywhere in the tree, by the name the model used. + */ + 'tools': { [key: string]: AgentSessionToolActivity; }; + /** + * Model calls retried after a failed attempt, across the tree. + */ + 'retries'?: number; + /** + * Every MCP server the root configured, by name. + */ + 'mcp_servers': { [key: string]: AgentSessionMcpServer; }; + 'skills': AgentSessionSkills; + 'subagent_counts': AgentSessionSubagentCounts; + /** + * Every todo list in the tree, by list id. The root agent\'s own list is the one whose id ends with `root_session_id`. + */ + 'todos': { [key: string]: TodoListProjection; }; + 'subagents': Array; + /** + * The root session\'s compactions, in order. + */ + 'compactions': Array; + /** + * Every move the root made to a fallback route, in order. + */ + 'failovers'?: Array; + 'failover_stopped'?: AgentSessionFailoverStop | null; + /** + * Files written or edited across the tree, sorted. + */ + 'files_touched': Array; + 'last_file_touched': string | null; + /** + * How many prompts have started. + */ + 'prompts': number; + 'prompt': AgentSessionPromptDelta; + /** + * In-flight bookkeeping, not a fact about the session: the paths a write or edit tool call named, by tool call id, between its start and its completion. Present only while such a call is open; a settled projection has no such member. + */ + 'pending_writes'?: { [key: string]: Array; }; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-prompt-delta.ts b/lib/packages/fabro-api-client/src/models/agent-session-prompt-delta.ts new file mode 100644 index 000000000..ce0467016 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-prompt-delta.ts @@ -0,0 +1,79 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionCompaction } from './agent-session-compaction'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionDescendantAccount } from './agent-session-descendant-account'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentCounts } from './agent-session-subagent-counts'; +// May contain unused imports in some cases +// @ts-ignore +import type { ContextWindowSnapshot } from './context-window-snapshot'; +// May contain unused imports in some cases +// @ts-ignore +import type { TokenUsage } from './token-usage'; + +/** + * What the prompt in progress, or the last one, did: reset when a prompt starts, complete once `completed` is set. + */ +export interface AgentSessionPromptDelta { + /** + * Whether the prompt reached its end. + */ + 'completed': boolean; + /** + * The root session\'s usage over the prompt. + */ + 'usage': TokenUsage; + 'cost_usd_micros': number | null; + /** + * Committed assistant messages. + */ + 'messages': number; + 'context_window': ContextWindowSnapshot | null; + /** + * Tool calls started, across the tree. + */ + 'tool_calls': number; + /** + * Model calls retried after a failed attempt, across the tree. + */ + 'retries'?: number; + /** + * Moves the root made to a fallback route during the prompt. + */ + 'failovers'?: number; + /** + * What each descendant spent during the prompt, by session id. + */ + 'descendants': { [key: string]: AgentSessionDescendantAccount; }; + /** + * Child lifecycle events during the prompt. + */ + 'subagents': AgentSessionSubagentCounts; + /** + * Compactions the root completed during the prompt. + */ + 'compactions': Array; + /** + * Files written or edited during the prompt, across the tree, sorted. + */ + 'files_touched': Array; + 'last_file_touched': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-route-failover.ts b/lib/packages/fabro-api-client/src/models/agent-session-route-failover.ts new file mode 100644 index 000000000..31c868f7d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-route-failover.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentErrorData } from './agent-error-data'; +// May contain unused imports in some cases +// @ts-ignore +import type { FailoverContinuation } from './failover-continuation'; +// May contain unused imports in some cases +// @ts-ignore +import type { TokenUsage } from './token-usage'; + +/** + * One move the root session made to a fallback route, as the stream reported it from the route it moved to. + */ +export interface AgentSessionRouteFailover { + /** + * The `provider/model` that failed. + */ + 'from': string; + /** + * The `provider/model` the prompt continued on. + */ + 'to': string; + /** + * How many routes the prompt had moved through, this one included. + */ + 'attempt': number; + /** + * The failure that ended the previous route. + */ + 'error': AgentErrorData; + /** + * What the prompt spent on the failed route. Already in the session\'s and the prompt\'s totals through that route\'s committed answers: a breakdown, not an addition. + */ + 'usage': TokenUsage; + 'cost_usd_micros'?: number; + /** + * Milliseconds the prompt spent waiting on the failed route\'s model. + */ + 'inference_ms': number; + /** + * Milliseconds the prompt spent running tools on the failed route. + */ + 'tool_ms': number; + 'continuation': FailoverContinuation; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-route.ts b/lib/packages/fabro-api-client/src/models/agent-session-route.ts new file mode 100644 index 000000000..6b1ae3d7d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-route.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * The route a session runs on, as it reported it. + */ +export interface AgentSessionRoute { + 'provider': string | null; + 'model': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-skills.ts b/lib/packages/fabro-api-client/src/models/agent-session-skills.ts new file mode 100644 index 000000000..42fb6d875 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-skills.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionActivatedSkill } from './agent-session-activated-skill'; +// May contain unused imports in some cases +// @ts-ignore +import type { SkillSummary } from './skill-summary'; + +/** + * The skills the root session found and the ones activated anywhere in the tree. + */ +export interface AgentSessionSkills { + 'available': Array; + 'activated': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-counts.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-counts.ts new file mode 100644 index 000000000..b52d062f6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-counts.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * How many child lifecycle events the tree recorded. + */ +export interface AgentSessionSubagentCounts { + 'spawned': number; + 'turns_started': number; + 'completed': number; + 'failed': number; + 'closed': number; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-closed.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-closed.ts new file mode 100644 index 000000000..a5396a395 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-closed.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface AgentSessionSubagentStatusClosed { + 'status': AgentSessionSubagentStatusClosedStatusEnum; +} + +export const AgentSessionSubagentStatusClosedStatusEnum = { + CLOSED: 'closed' +} as const; + +export type AgentSessionSubagentStatusClosedStatusEnum = typeof AgentSessionSubagentStatusClosedStatusEnum[keyof typeof AgentSessionSubagentStatusClosedStatusEnum]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-completed.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-completed.ts new file mode 100644 index 000000000..869af8c88 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-completed.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface AgentSessionSubagentStatusCompleted { + 'status': AgentSessionSubagentStatusCompletedStatusEnum; + 'success': boolean; + 'turns_used': number; +} + +export const AgentSessionSubagentStatusCompletedStatusEnum = { + COMPLETED: 'completed' +} as const; + +export type AgentSessionSubagentStatusCompletedStatusEnum = typeof AgentSessionSubagentStatusCompletedStatusEnum[keyof typeof AgentSessionSubagentStatusCompletedStatusEnum]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-failed.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-failed.ts new file mode 100644 index 000000000..b45d826c8 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-failed.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentErrorData } from './agent-error-data'; + +export interface AgentSessionSubagentStatusFailed { + 'status': AgentSessionSubagentStatusFailedStatusEnum; + 'error': AgentErrorData; +} + +export const AgentSessionSubagentStatusFailedStatusEnum = { + FAILED: 'failed' +} as const; + +export type AgentSessionSubagentStatusFailedStatusEnum = typeof AgentSessionSubagentStatusFailedStatusEnum[keyof typeof AgentSessionSubagentStatusFailedStatusEnum]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-running.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-running.ts new file mode 100644 index 000000000..645cb74ba --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status-running.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface AgentSessionSubagentStatusRunning { + 'status': AgentSessionSubagentStatusRunningStatusEnum; +} + +export const AgentSessionSubagentStatusRunningStatusEnum = { + RUNNING: 'running' +} as const; + +export type AgentSessionSubagentStatusRunningStatusEnum = typeof AgentSessionSubagentStatusRunningStatusEnum[keyof typeof AgentSessionSubagentStatusRunningStatusEnum]; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent-status.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status.ts new file mode 100644 index 000000000..5a0341f49 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent-status.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentErrorData } from './agent-error-data'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentStatusClosed } from './agent-session-subagent-status-closed'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentStatusCompleted } from './agent-session-subagent-status-completed'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentStatusFailed } from './agent-session-subagent-status-failed'; +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentStatusRunning } from './agent-session-subagent-status-running'; + +/** + * @type AgentSessionSubagentStatus + * Where a child stands. + */ +export type AgentSessionSubagentStatus = { status: 'closed' } & AgentSessionSubagentStatusClosed | { status: 'completed' } & AgentSessionSubagentStatusCompleted | { status: 'failed' } & AgentSessionSubagentStatusFailed | { status: 'running' } & AgentSessionSubagentStatusRunning; diff --git a/lib/packages/fabro-api-client/src/models/agent-session-subagent.ts b/lib/packages/fabro-api-client/src/models/agent-session-subagent.ts new file mode 100644 index 000000000..6710e1f66 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-subagent.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AgentSessionSubagentStatus } from './agent-session-subagent-status'; + +/** + * One child the root spawned. A reused child stays one row; every event after the spawn moves its status. + */ +export interface AgentSessionSubagent { + 'agent_id': string; + 'depth': number; + 'task': string; + 'status': AgentSessionSubagentStatus; +} diff --git a/lib/packages/fabro-api-client/src/models/agent-session-tool-activity.ts b/lib/packages/fabro-api-client/src/models/agent-session-tool-activity.ts new file mode 100644 index 000000000..1a08d6996 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/agent-session-tool-activity.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * How one tool has been used across the tree. + */ +export interface AgentSessionToolActivity { + /** + * Calls started. + */ + 'calls': number; + /** + * Calls that completed as errors. + */ + 'errors': number; + /** + * Calls started and not yet completed. + */ + 'open': number; +} diff --git a/lib/packages/fabro-api-client/src/models/compaction-reason.ts b/lib/packages/fabro-api-client/src/models/compaction-reason.ts new file mode 100644 index 000000000..26fb5035a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/compaction-reason.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Why a conversation compaction ran. + */ + +export const CompactionReason = { + THRESHOLD: 'threshold', + MANUAL: 'manual', + OVERFLOW: 'overflow' +} as const; + +export type CompactionReason = typeof CompactionReason[keyof typeof CompactionReason]; diff --git a/lib/packages/fabro-api-client/src/models/failover-continuation.ts b/lib/packages/fabro-api-client/src/models/failover-continuation.ts new file mode 100644 index 000000000..397341e47 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/failover-continuation.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * How a prompt carries on after a failover. `replay_prompt` when nothing the prompt committed is in the conversation, so the new route is asked the prompt again; `continue_turn` when the conversation holds output or tool results this prompt committed, so the new route continues the turn from where it stood. + */ + +export const FailoverContinuation = { + REPLAY_PROMPT: 'replay_prompt', + CONTINUE_TURN: 'continue_turn' +} as const; + +export type FailoverContinuation = typeof FailoverContinuation[keyof typeof FailoverContinuation]; diff --git a/lib/packages/fabro-api-client/src/models/failover-stop.ts b/lib/packages/fabro-api-client/src/models/failover-stop.ts new file mode 100644 index 000000000..50412cf28 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/failover-stop.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Why a model failure ends a prompt on its route when fallback routes were named. `ineligible` when the failure follows the request, so another route would fail the same way; `exhausted` when every named route has been taken. + */ + +export const FailoverStop = { + INELIGIBLE: 'ineligible', + EXHAUSTED: 'exhausted' +} as const; + +export type FailoverStop = typeof FailoverStop[keyof typeof FailoverStop]; diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index a0e1730fa..5771fc062 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -1,8 +1,29 @@ export * from './activated-skill'; export * from './agent-control-state'; +export * from './agent-error-data'; +export * from './agent-error-kind'; export * from './agent-event-props'; export * from './agent-mcp-tool-summary'; export * from './agent-session-activated-props'; +export * from './agent-session-activated-skill'; +export * from './agent-session-activity'; +export * from './agent-session-compaction'; +export * from './agent-session-descendant-account'; +export * from './agent-session-failover-stop'; +export * from './agent-session-mcp-server'; +export * from './agent-session-projection'; +export * from './agent-session-prompt-delta'; +export * from './agent-session-route'; +export * from './agent-session-route-failover'; +export * from './agent-session-skills'; +export * from './agent-session-subagent'; +export * from './agent-session-subagent-counts'; +export * from './agent-session-subagent-status'; +export * from './agent-session-subagent-status-closed'; +export * from './agent-session-subagent-status-completed'; +export * from './agent-session-subagent-status-failed'; +export * from './agent-session-subagent-status-running'; +export * from './agent-session-tool-activity'; export * from './agent-tools-available-props'; export * from './aggregate-billing'; export * from './aggregate-billing-totals'; @@ -51,6 +72,7 @@ export * from './close-run-pull-request-response'; export * from './code-location'; export * from './command-log-response'; export * from './command-termination'; +export * from './compaction-reason'; export * from './completion-content-part'; export * from './completion-cost'; export * from './completion-message'; @@ -118,6 +140,8 @@ export * from './exec-output-tail'; export * from './execute-query-request'; export * from './execute-query-response'; export * from './execute-query-response-rows-inner-inner'; +export * from './failover-continuation'; +export * from './failover-stop'; export * from './failure-category'; export * from './failure-detail'; export * from './failure-reason'; @@ -173,6 +197,10 @@ export * from './interview-provider-settings'; export * from './interview-question-record'; export * from './link-run-pull-request-request'; export * from './llm-output-kind'; +export * from './llm-retry-classification'; +export * from './llm-retry-classification-after'; +export * from './llm-retry-classification-never'; +export * from './llm-retry-classification-safe'; export * from './log-destination'; export * from './manifest-args'; export * from './manifest-config'; @@ -192,6 +220,7 @@ export * from './mcp-server-status'; export * from './mcp-server-status-disconnected'; export * from './mcp-server-status-failed'; export * from './mcp-server-status-ready'; +export * from './mcp-tool-summary'; export * from './mcp-transport'; export * from './mcp-transport-http'; export * from './mcp-transport-sandbox'; @@ -501,6 +530,7 @@ export * from './todo-list-kind'; export * from './todo-list-projection'; export * from './todo-projection'; export * from './todo-status'; +export * from './token-usage'; export * from './tool-category'; export * from './tool-source'; export * from './tool-source-application'; diff --git a/lib/packages/fabro-api-client/src/models/llm-retry-classification-after.ts b/lib/packages/fabro-api-client/src/models/llm-retry-classification-after.ts new file mode 100644 index 000000000..a05044dcd --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/llm-retry-classification-after.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Repeating the call is safe after the given delay. + */ +export interface LlmRetryClassificationAfter { + 'type': LlmRetryClassificationAfterTypeEnum; + /** + * The delay in milliseconds. + */ + 'after_millis': number; +} + +export const LlmRetryClassificationAfterTypeEnum = { + AFTER: 'after' +} as const; + +export type LlmRetryClassificationAfterTypeEnum = typeof LlmRetryClassificationAfterTypeEnum[keyof typeof LlmRetryClassificationAfterTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/llm-retry-classification-never.ts b/lib/packages/fabro-api-client/src/models/llm-retry-classification-never.ts new file mode 100644 index 000000000..0729e7f9b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/llm-retry-classification-never.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Repeating the call cannot succeed. + */ +export interface LlmRetryClassificationNever { + 'type': LlmRetryClassificationNeverTypeEnum; +} + +export const LlmRetryClassificationNeverTypeEnum = { + NEVER: 'never' +} as const; + +export type LlmRetryClassificationNeverTypeEnum = typeof LlmRetryClassificationNeverTypeEnum[keyof typeof LlmRetryClassificationNeverTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/llm-retry-classification-safe.ts b/lib/packages/fabro-api-client/src/models/llm-retry-classification-safe.ts new file mode 100644 index 000000000..d3f8da578 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/llm-retry-classification-safe.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Repeating the call is safe on the caller\'s own schedule. + */ +export interface LlmRetryClassificationSafe { + 'type': LlmRetryClassificationSafeTypeEnum; +} + +export const LlmRetryClassificationSafeTypeEnum = { + SAFE: 'safe' +} as const; + +export type LlmRetryClassificationSafeTypeEnum = typeof LlmRetryClassificationSafeTypeEnum[keyof typeof LlmRetryClassificationSafeTypeEnum]; diff --git a/lib/packages/fabro-api-client/src/models/llm-retry-classification.ts b/lib/packages/fabro-api-client/src/models/llm-retry-classification.ts new file mode 100644 index 000000000..a75037097 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/llm-retry-classification.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { LlmRetryClassificationAfter } from './llm-retry-classification-after'; +// May contain unused imports in some cases +// @ts-ignore +import type { LlmRetryClassificationNever } from './llm-retry-classification-never'; +// May contain unused imports in some cases +// @ts-ignore +import type { LlmRetryClassificationSafe } from './llm-retry-classification-safe'; + +/** + * @type LlmRetryClassification + * Whether repeating the same resolved model call is safe. + */ +export type LlmRetryClassification = { type: 'after' } & LlmRetryClassificationAfter | { type: 'never' } & LlmRetryClassificationNever | { type: 'safe' } & LlmRetryClassificationSafe; diff --git a/lib/packages/fabro-api-client/src/models/mcp-tool-summary.ts b/lib/packages/fabro-api-client/src/models/mcp-tool-summary.ts new file mode 100644 index 000000000..21d752a04 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/mcp-tool-summary.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * One tool an MCP server advertised, as the coding agent\'s registry named it. + */ +export interface McpToolSummary { + /** + * The name the model calls: `mcp__{server}__{tool}`. + */ + 'name': string; + /** + * The server\'s own name for the tool. + */ + 'original_name': string; +} diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index aa97b10b4..ccf1d548f 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -18,6 +18,9 @@ import type { AgentControlState } from './agent-control-state'; // May contain unused imports in some cases // @ts-ignore +import type { AgentSessionProjection } from './agent-session-projection'; +// May contain unused imports in some cases +// @ts-ignore import type { BilledTokenCounts } from './billed-token-counts'; // May contain unused imports in some cases // @ts-ignore @@ -142,6 +145,7 @@ export interface StageProjection { * Whether the agent is executing normally or waiting for steering after an interrupt. */ 'agent_control': AgentControlState; + 'agent'?: AgentSessionProjection | null; /** * Lifecycle state of the stage projection. */ diff --git a/lib/packages/fabro-api-client/src/models/token-usage.ts b/lib/packages/fabro-api-client/src/models/token-usage.ts new file mode 100644 index 000000000..1a9946f89 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/token-usage.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Token accounting as the coding agent counts it. The five buckets are disjoint: every token is counted in exactly one, so their plain sum is the total. A bucket that is absent reads as zero. + */ +export interface TokenUsage { + /** + * Prompt tokens that were neither read from nor written to a cache. + */ + 'input'?: number; + /** + * Completion tokens that are not reasoning tokens. + */ + 'output'?: number; + /** + * Completion tokens spent on reasoning, billed at the output rate. + */ + 'reasoning'?: number; + /** + * Prompt tokens served from a provider cache. + */ + 'cache_read'?: number; + /** + * Prompt tokens written into a provider cache. + */ + 'cache_write'?: number; +} From df8762663b6ac2b2799661a7804c75295b74fbfb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:49:27 -0600 Subject: [PATCH 04/11] Bill an agent stage's whole session tree from one fold One usage rule: a stage's usage is its session tree's, the root and every subagent, live and at completion. The worker's event sink folds pebble's SessionProjection over the events it records and the stage's billing and files come from that fold at stage end, so the completed values are what the run showed live. The store's live usage is the fold's tree usage, and completion brings the catalog's price for the same tokens instead of resetting them to the root's. Fabro keeps catalog pricing: the root at its route, each descendant at its own route where the catalog knows it and at the root's otherwise, a provider-reported cost standing in where pebble has one. The rows travel as billing_by_model on stage.completed and the stage projection, and the billing rollup splits by_model by them. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/run/run_progress/event.rs | 1 + .../src/commands/run/run_progress/mod.rs | 1 + lib/apps/fabro-server/src/server/tests.rs | 10 + lib/components/fabro-store/src/run_state.rs | 202 +++++++- .../fabro-workflow/src/billing_rollup.rs | 43 ++ .../fabro-workflow/src/event/convert.rs | 4 + .../fabro-workflow/src/event/events.rs | 2 + lib/components/fabro-workflow/src/git.rs | 1 + .../fabro-workflow/src/handler/agent.rs | 113 +++-- .../fabro-workflow/src/handler/fan_in.rs | 1 + .../fabro-workflow/src/handler/llm/acp.rs | 1 + .../fabro-workflow/src/handler/llm/pebble.rs | 434 +++++++++++++++--- .../fabro-workflow/src/handler/llm/router.rs | 2 + .../fabro-workflow/src/handler/prompt.rs | 6 + lib/components/fabro-workflow/src/lib.rs | 2 + .../fabro-workflow/src/lifecycle/event.rs | 2 + .../fabro-workflow/src/operations/fork.rs | 1 + .../fabro-workflow/src/operations/start.rs | 1 + .../src/pipeline/pull_request.rs | 2 + .../fabro-workflow/tests/it/integration.rs | 2 + .../fabro-workflow/tests/it/pebble_agent.rs | 51 ++ .../fabro-types/src/billing_rollup.rs | 38 +- lib/foundation/fabro-types/src/outcome.rs | 9 +- .../fabro-types/src/run_event/mod.rs | 2 + .../fabro-types/src/run_event/stage.rs | 8 + .../fabro-types/src/run_projection.rs | 17 +- 26 files changed, 812 insertions(+), 144 deletions(-) diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 895494d78..7d9377dde 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -651,6 +651,7 @@ mod tests { status: "succeeded".into(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index 94c6ce88c..ca3f5a020 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -650,6 +650,7 @@ mod tests { status: "succeeded".into(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: Some( billed_model_usage_from_llm( &fabro_llm::test_support::test_catalog(), diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 1b86f0c90..8f9ef998b 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -6215,6 +6215,7 @@ fn stage_completed_event(node_id: &str) -> workflow_event::Event { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -7063,6 +7064,7 @@ async fn list_run_stages_projects_retrying_until_completion() { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -7157,6 +7159,7 @@ async fn list_run_stages_projects_retrying_until_completion() { status: "partially_succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -7394,6 +7397,7 @@ async fn create_billed_retry_run(state: &Arc, run_id: RunId) { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: Some(test_billed_usage("gpt-new", 200, 20)), failure: None, notes: None, @@ -7482,6 +7486,7 @@ async fn list_run_stages_distinguishes_visits() { status: "failed".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -7804,6 +7809,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() { status: "failed".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -7835,6 +7841,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -8278,6 +8285,7 @@ async fn run_billing_retried_node_then_succeeded_emits_one_row_with_final_attemp status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -8349,6 +8357,7 @@ fn revisit_test_completed_with_visit( status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -15402,6 +15411,7 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() { status: "success".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index e0cedb00f..afe270b99 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -25,7 +25,8 @@ use fabro_types::{ use fabro_util::error::render_compact_with_causes; use lithos_llm::catalog::{ModelId, ProviderId}; use lithos_llm::types::TokenCounts; -use pebble_coding_agent::events::{CodingEvent, TokenUsage}; +use pebble_coding_agent::events::CodingEvent; +use pebble_coding_agent::projection::SessionProjection; use crate::{Error, EventEnvelope, Result}; @@ -524,6 +525,7 @@ impl RunProjectionReducer for RunProjection { stage.usage.replace_with_billed_usage(billing); stage.model = Some(billing.model().clone()); } + stage.billing_by_model.clone_from(&props.billing_by_model); stage.state = StageState::from(outcome.status); stage.agent_control = AgentControlState::Running; } @@ -760,9 +762,16 @@ fn apply_agent_event( ) { let visit = props.visit; // Pebble's own fold sees every agent event the stage stored, before the - // fabro-only arms below read the same event. + // fabro-only arms below read the same event. While the stage runs, its + // usage is that fold's: the tree's tokens, the root's and every + // subagent's, with whatever cost the provider reported. The terminal + // billing then brings the catalog's price for the same tokens. if let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) { - stage.agent.get_or_insert_default().apply(&props.event); + let agent = stage.agent.get_or_insert_default(); + agent.apply(&props.event); + if stage.completion.is_none() { + stage.usage = live_usage(agent); + } } #[expect( clippy::wildcard_enum_match_arm, @@ -771,17 +780,12 @@ fn apply_agent_event( match props.coding_event() { CodingEvent::AssistantMessage { model, - usage, - cost_usd_micros, context_window, .. } => { let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { return; }; - stage - .usage - .add_counts(&billed_counts(*usage, *cost_usd_micros)); if let Some(model) = stage_model_ref(stage, model) { stage.model = Some(model); } @@ -984,11 +988,17 @@ fn apply_agent_event( } } -/// Token accounting for one assistant message, in fabro's billing shape. -fn billed_counts(usage: TokenUsage, cost_usd_micros: Option) -> BilledTokenCounts { +/// A running stage's usage, from its agent's fold: the tree's tokens and the +/// cost the provider reported for them, `None` when it reported none. +fn live_usage(agent: &SessionProjection) -> BilledTokenCounts { + let (descendants, descendant_cost) = agent.descendant_usage(); + let mut cost = agent.cost_usd_micros; + if let Some(descendant_cost) = descendant_cost { + cost = Some(cost.unwrap_or(0).saturating_add(descendant_cost)); + } BilledTokenCounts::from_token_counts( - TokenCounts::from(usage), - cost_usd_micros.map(|cost| i64::try_from(cost).unwrap_or(i64::MAX)), + TokenCounts::from(agent.usage.saturating_add(descendants)), + cost.map(|cost| i64::try_from(cost).unwrap_or(i64::MAX)), ) } @@ -1779,6 +1789,7 @@ fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome EventBody { + EventBody::Agent(AgentEventProps::new( + "code", + 1, + CodingAgentEvent::new( + "ses_child", + assistant_message(input, output), + SystemTime::UNIX_EPOCH, + ) + .with_parent_session_id("ses_test"), + )) + } + + /// One usage rule: a stage's usage is its session tree's, live and at + /// completion. The terminal billing carries the tokens the fold already + /// showed plus the catalog's price, so completion changes the cost, not + /// the tokens, and keeps the split by model. #[test] - fn stage_completed_replaces_live_usage_with_terminal_billing() { + fn stage_completed_keeps_the_trees_live_usage_and_prices_it() { let mut state = initialized_projection(); let stage_id = StageId::new("build", 1); - let usage = billed_usage(); + let model = billed_usage().model().clone(); state .apply_event(&test_stage_event( @@ -5663,23 +5695,145 @@ mod tests { state .apply_event(&test_stage_event( 2, + activated(model.provider.as_str(), model.model_id.as_str()), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 3, agent_message_body(100, 50), stage_id.clone(), )) .unwrap(); - let mut props = completed_props(42, StageOutcome::Succeeded); - props.billing = Some(usage.clone()); state .apply_event(&test_stage_event( - 3, + 4, + child_message_body(7, 1), + stage_id.clone(), + )) + .unwrap(); + let live = state.stage(&stage_id).unwrap().usage.clone(); + assert_eq!( + live, + live_counts(107, 51), + "the subagent's tokens are the stage's too" + ); + + let tree = BilledModelUsage { + model: model.clone(), + tokens: TokenCounts { + input: 107, + output: 51, + ..TokenCounts::default() + }, + total_usd_micros: Some(321), + }; + let mut props = completed_props(42, StageOutcome::Succeeded); + props.billing = Some(tree.clone()); + props.billing_by_model = vec![tree.clone()]; + state + .apply_event(&test_stage_event( + 5, EventBody::StageCompleted(props), stage_id.clone(), )) .unwrap(); let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.usage, usage_counts(&usage)); - assert_eq!(stage.model.as_ref(), Some(usage.model())); + assert_eq!( + stage.usage.token_counts(), + live.token_counts(), + "completion keeps the tokens the fold showed" + ); + assert_eq!( + stage.usage.total_usd_micros, + Some(321), + "and brings the catalog's price" + ); + assert_eq!(stage.model.as_ref(), Some(&model)); + assert_eq!(stage.billing_by_model, vec![tree]); + } + + #[test] + fn live_usage_is_the_trees_with_compactions_and_the_reported_cost() { + let mut state = initialized_projection(); + let stage_id = StageId::new("build", 1); + let priced_message = |input: u64, output: u64, cost: u64| { + let CodingEvent::AssistantMessage { + text, + model, + usage, + cost_source, + tool_call_count, + context_window, + reasoning, + .. + } = assistant_message(input, output) + else { + unreachable!("assistant_message builds an assistant message") + }; + agent_body(CodingEvent::AssistantMessage { + text, + model, + usage, + cost_usd_micros: Some(cost), + cost_source, + tool_call_count, + context_window, + reasoning, + }) + }; + + state + .apply_event(&test_stage_event( + 1, + EventBody::StageStarted(started_props()), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 2, + priced_message(10, 5, 5), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 3, + child_message_body(7, 1), + stage_id.clone(), + )) + .unwrap(); + state + .apply_event(&test_stage_event( + 4, + agent_body(CodingEvent::CompactionCompleted { + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 1, + reason: CompactionReason::Threshold, + usage: TokenUsage { + input: 30, + ..TokenUsage::default() + }, + cost_usd_micros: Some(2), + }), + stage_id.clone(), + )) + .unwrap(); + + let stage = state.stage(&stage_id).unwrap(); + assert_eq!( + stage.usage, + BilledTokenCounts { + total_usd_micros: Some(7), + ..live_counts(47, 6) + }, + "the root's messages and compaction, the child's message, and the provider's cost" + ); } #[test] diff --git a/lib/components/fabro-workflow/src/billing_rollup.rs b/lib/components/fabro-workflow/src/billing_rollup.rs index 5dc6ca164..2c3a56b34 100644 --- a/lib/components/fabro-workflow/src/billing_rollup.rs +++ b/lib/components/fabro-workflow/src/billing_rollup.rs @@ -22,6 +22,49 @@ mod tests { ) } + #[test] + fn by_model_splits_a_completed_stage_by_its_billing_rows() { + let mut projection = test_projection(); + let root = test_usage("gpt-root", 100, 10); + let child = test_usage("gpt-child", 7, 1); + let stage = projection.stage_entry("work", 1, first_event_seq(1)); + stage.timing = Some(fabro_types::StageTiming::wall_only(100)); + stage.usage = BilledTokenCounts::from_billed_usage(&[root.clone(), child.clone()]); + stage.model = Some(root.model().clone()); + stage.billing_by_model = vec![root.clone(), child.clone()]; + stage.completion = Some(StageCompletion { + outcome: StageOutcome::Succeeded, + notes: None, + failure_reason: None, + timestamp: chrono::Utc::now(), + }); + + let rollup = billing_rollup_from_projection(&projection); + + assert_eq!(rollup.totals.input_tokens, 107); + assert_eq!(rollup.stages[0].model.as_ref(), Some(root.model())); + assert_eq!(rollup.by_model.len(), 2, "{:?}", rollup.by_model); + let entry = |model_id: &str| { + rollup + .by_model + .iter() + .find(|entry| entry.model.model_id.as_str() == model_id) + .unwrap_or_else(|| panic!("a row for {model_id}")) + }; + assert_eq!(entry("gpt-root").stages, 1); + assert_eq!(entry("gpt-root").billing.input_tokens, 100); + assert_eq!( + entry("gpt-root").billing.total_usd_micros, + root.total_usd_micros + ); + assert_eq!(entry("gpt-child").stages, 1); + assert_eq!(entry("gpt-child").billing.input_tokens, 7); + assert_eq!( + entry("gpt-child").billing.total_usd_micros, + child.total_usd_micros + ); + } + #[test] fn rollup_groups_stage_rows_by_node_and_sums_retry_visit_usage() { let mut projection = test_projection(); diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index dbbf259c3..fa0bb6d61 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -346,6 +346,7 @@ fn event_body_from_event(event: &Event) -> EventBody { preferred_label, suggested_next_ids, billing, + billing_by_model, failure, notes, files_touched, @@ -366,6 +367,7 @@ fn event_body_from_event(event: &Event) -> EventBody { preferred_label: preferred_label.clone(), suggested_next_ids: suggested_next_ids.clone(), billing: billing.clone(), + billing_by_model: billing_by_model.clone(), failure: failure.clone(), notes: notes.clone(), files_touched: files_touched.clone(), @@ -1097,6 +1099,7 @@ mod tests { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -1142,6 +1145,7 @@ mod tests { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index 01ee3eba7..57a639a38 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -272,6 +272,8 @@ pub enum Event { preferred_label: Option, suggested_next_ids: Vec, billing: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + billing_by_model: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] failure: Option, notes: Option, diff --git a/lib/components/fabro-workflow/src/git.rs b/lib/components/fabro-workflow/src/git.rs index 5bc514a19..c169215f2 100644 --- a/lib/components/fabro-workflow/src/git.rs +++ b/lib/components/fabro-workflow/src/git.rs @@ -585,6 +585,7 @@ mod tests { status: "succeeded".into(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs index da9c912b5..e2b0c4a09 100644 --- a/lib/components/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -31,7 +31,12 @@ const LAST_FILE_ROUTING_EXTENSIONS: &[&str] = &["json", "md"]; pub enum CodergenResult { Text { text: String, + /// The stage's billing: for an agent, the whole session tree's + /// tokens under the root's route. usage: Option, + /// `usage` split by model, when the backend billed subagents at + /// their own models. Empty when `usage` is the one row. + usage_by_model: Vec, files_touched: Vec, last_file_touched: Option, /// Active timing observed by the backend. The wall field is ignored by @@ -302,47 +307,62 @@ impl Handler for AgentHandler { node_id: node.id.clone(), }) as Arc }); - let (response_text, stage_usage, backend_files_touched, last_file_touched, timing) = - if let Some(backend) = &self.backend { - let result = backend - .run(CodergenRunRequest { - node, - prompt: &prompt, - context, - thread_id: thread_id.as_deref(), - emitter: &services.run.emitter, - sandbox: &services.run.sandbox, - tool_middleware, - cancel_token: services.run.cancel_token(), - human_input: Some(human_input), - }) - .await; - match result { - Ok(CodergenResult::Full(outcome)) => return Ok(*outcome), - Ok(CodergenResult::Text { - text, - usage, - files_touched, - last_file_touched, - timing, - }) => (text, usage, files_touched, last_file_touched, timing), - Err(Error::Cancelled) => return Err(Error::Cancelled), - Err(e) if e.is_retryable() => { - return Err(e); - } - Err(e) => { - return Ok(e.to_fail_outcome()); - } + let ( + response_text, + stage_usage, + stage_usage_by_model, + backend_files_touched, + last_file_touched, + timing, + ) = if let Some(backend) = &self.backend { + let result = backend + .run(CodergenRunRequest { + node, + prompt: &prompt, + context, + thread_id: thread_id.as_deref(), + emitter: &services.run.emitter, + sandbox: &services.run.sandbox, + tool_middleware, + cancel_token: services.run.cancel_token(), + human_input: Some(human_input), + }) + .await; + match result { + Ok(CodergenResult::Full(outcome)) => return Ok(*outcome), + Ok(CodergenResult::Text { + text, + usage, + usage_by_model, + files_touched, + last_file_touched, + timing, + }) => ( + text, + usage, + usage_by_model, + files_touched, + last_file_touched, + timing, + ), + Err(Error::Cancelled) => return Err(Error::Cancelled), + Err(e) if e.is_retryable() => { + return Err(e); } - } else { - ( - format!("[Simulated] Response for stage: {}", node.id), - None, - Vec::new(), - None, - StageTiming::default(), - ) - }; + Err(e) => { + return Ok(e.to_fail_outcome()); + } + } + } else { + ( + format!("[Simulated] Response for stage: {}", node.id), + None, + Vec::new(), + Vec::new(), + None, + StageTiming::default(), + ) + }; let response_model = stage_usage .as_ref() @@ -395,6 +415,7 @@ impl Handler for AgentHandler { structured_output::exhausted_failure_outcome(node.output_retries()); failed.timing = Some(timing); failed.usage = stage_usage; + failed.usage_by_model = stage_usage_by_model; failed.files_touched = backend_files_touched; return Ok(failed); } @@ -422,6 +443,7 @@ impl Handler for AgentHandler { } } outcome.usage = stage_usage; + outcome.usage_by_model = stage_usage_by_model; outcome.files_touched = backend_files_touched; outcome.timing = Some(timing); @@ -535,6 +557,7 @@ mod tests { async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { Ok(CodergenResult::Text { text: "Done writing results.".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: vec![self.path.clone()], last_file_touched: Some(self.path.clone()), @@ -741,6 +764,7 @@ mod tests { text: r#"Done. {"outcome": "succeeded", "preferred_next_label": "approve"}"# .to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -788,6 +812,7 @@ mod tests { async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { Ok(CodergenResult::Text { text: "done".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -945,6 +970,7 @@ All checks passed. async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { Ok(CodergenResult::Text { text: r#"{"suggested_next_ids": [1]}"#.to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1003,6 +1029,7 @@ All checks passed. async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { Ok(CodergenResult::Text { text: r#"{"passed": true}"#.to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1049,6 +1076,7 @@ All checks passed. *self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string()); Ok(CodergenResult::Text { text: r#"{"passed": true}"#.to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1133,6 +1161,7 @@ All checks passed. ); Ok(CodergenResult::Text { text: "done".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1188,6 +1217,7 @@ All checks passed. Some(request.thread_id.map(String::from)); Ok(CodergenResult::Text { text: "ok".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1233,6 +1263,7 @@ All checks passed. Some(request.thread_id.map(String::from)); Ok(CodergenResult::Text { text: "ok".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1441,6 +1472,7 @@ Some text in between. *self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string()); Ok(CodergenResult::Text { text: "ok".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -1502,6 +1534,7 @@ Some text in between. *self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string()); Ok(CodergenResult::Text { text: "ok".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, diff --git a/lib/components/fabro-workflow/src/handler/fan_in.rs b/lib/components/fabro-workflow/src/handler/fan_in.rs index 2665744fc..410409b10 100644 --- a/lib/components/fabro-workflow/src/handler/fan_in.rs +++ b/lib/components/fabro-workflow/src/handler/fan_in.rs @@ -208,6 +208,7 @@ mod tests { assert!(request.prompt.contains("Synthesize every result")); Ok(CodergenResult::Text { text: "combined result".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, diff --git a/lib/components/fabro-workflow/src/handler/llm/acp.rs b/lib/components/fabro-workflow/src/handler/llm/acp.rs index 04983a443..444e754ae 100644 --- a/lib/components/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/components/fabro-workflow/src/handler/llm/acp.rs @@ -449,6 +449,7 @@ impl AgentAcpBackend { Ok(CodergenResult::Text { text: result.text, + usage_by_model: Vec::new(), usage: None, files_touched, last_file_touched, diff --git a/lib/components/fabro-workflow/src/handler/llm/pebble.rs b/lib/components/fabro-workflow/src/handler/llm/pebble.rs index 07f4231f0..fffb259db 100644 --- a/lib/components/fabro-workflow/src/handler/llm/pebble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/pebble.rs @@ -9,8 +9,8 @@ //! next route to continue it, and this module mirrors each move as the run's //! `agent.failover` event. -use std::collections::{BTreeSet, HashMap, HashSet}; -use std::sync::{Arc, Mutex}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use async_trait::async_trait; @@ -24,8 +24,8 @@ use fabro_mcp::pebble::pebble_servers; use fabro_sandbox::{RunSandbox, SecretRedactor}; use fabro_types::settings::run::RunModelControls; use fabro_types::{ - AgentMcpToolSummary, AgentProfileKind, ModelRef, PermissionLevel, SessionCapability, StageId, - StageTiming, UsdMicros, billing, + AgentMcpToolSummary, AgentProfileKind, BilledModelUsage, ModelRef, PermissionLevel, + SessionCapability, StageId, StageTiming, UsdMicros, billing, }; use fabro_util::home::Home; use lithos_llm::catalog::{ModelId, ProviderId}; @@ -34,6 +34,7 @@ use pebble_agent::ToolMiddleware; use pebble_coding_agent::environment::Environment; use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, EventSink, EventSinkError}; use pebble_coding_agent::extensions::HumanInputProvider; +use pebble_coding_agent::projection::{DescendantAccount, SessionProjection}; use pebble_coding_agent::state::Message; use pebble_coding_agent::steering::SteerableSession; use pebble_coding_agent::subagents::SubagentOptions; @@ -170,12 +171,27 @@ fn classify_agent_error(error: pebble_coding_agent::Error) -> AgentErrorDisposit /// `agent.mcp.failed`, and `agent.mcp.disconnected` events, which the store /// still folds; those mirrors go once every reader is on the projection. struct WorkflowEventSink { - emitter: Arc, - node_id: String, - scope: StageScope, + emitter: Arc, + node_id: String, + scope: StageScope, /// The stage's resolved plan, for the controls and origin the mirrored /// failover event names. - plan: FallbackPlan, + plan: FallbackPlan, + /// Pebble's fold of every event this sink recorded: the stage's one + /// account of what its agent and subagents spent, wrote, and ran. The + /// store folds the same events the same way, so the stage's billing at + /// its end is the usage the run showed live. + projection: Mutex, +} + +impl WorkflowEventSink { + /// The account as it stands. + fn snapshot(&self) -> SessionProjection { + self.projection + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } } #[async_trait] @@ -272,6 +288,10 @@ impl EventSink for WorkflowEventSink { if event.event.is_streaming_noise() { return Ok(()); } + self.projection + .lock() + .unwrap_or_else(PoisonError::into_inner) + .apply(event); self.emitter .emit_durable( &Event::Agent { @@ -291,57 +311,47 @@ impl EventSink for WorkflowEventSink { // --- Live invocation ------------------------------------------------------ -/// One stage invocation's live agent and its accounting. +/// One stage invocation's live agent, its timing, and the sink that +/// accounts for it. /// /// A stage may run several prompts on one agent (the prompt, output repairs, -/// late steering); the usage, cost, timing, and files of every one of them -/// are summed here, across whatever routes pebble moved through. +/// late steering). What every one of them spent and wrote, subagents +/// included and across whatever routes pebble moved through, is the sink's +/// fold of the events it recorded; the prompt reports here contribute their +/// timing and the route the prompt ended on. struct LiveAgent { agent: CodingAgent, handle: CodingAgentControlHandle, lease: Option>, - total_usage: TokenCounts, - total_cost: Option, + sink: Arc, inference_duration: Duration, tool_duration: Duration, - /// Every file the stage's prompts wrote or edited, subagents included. - files_touched: BTreeSet, - /// The most recently written path. - last_file_touched: Option, } impl LiveAgent { - fn new(agent: CodingAgent, handle: CodingAgentControlHandle) -> Self { + fn new( + agent: CodingAgent, + handle: CodingAgentControlHandle, + sink: Arc, + ) -> Self { Self { agent, handle, lease: None, - total_usage: TokenCounts::default(), - total_cost: None, + sink, inference_duration: Duration::ZERO, tool_duration: Duration::ZERO, - files_touched: BTreeSet::new(), - last_file_touched: None, } } fn record_report(&mut self, report: &pebble_coding_agent::PromptReport) { - billing::add_usage(&mut self.total_usage, TokenCounts::from(report.usage)); - UsdMicros::accumulate( - &mut self.total_cost, - report - .cost_usd_micros - .map(|micros| UsdMicros(i64::try_from(micros).unwrap_or(i64::MAX))), - ); self.inference_duration = self .inference_duration .saturating_add(report.timing.inference); self.tool_duration = self.tool_duration.saturating_add(report.timing.tool); - self.files_touched - .extend(report.files_touched.iter().cloned()); for compaction in &report.compactions { - // The summary call's usage is already in `report.usage`; this is - // the breakdown, for anyone asking why a stage cost what it did. + // The summary call's usage is already in the stage's account; this + // is the breakdown, for anyone asking why a stage cost what it did. tracing::debug!( reason = ?compaction.reason, original_turns = compaction.original_turn_count, @@ -351,9 +361,21 @@ impl LiveAgent { "agent stage compacted its conversation" ); } - if report.last_file_touched.is_some() { - self.last_file_touched.clone_from(&report.last_file_touched); - } + } + + /// What the stage's prompts have spent and written so far. + fn account(&self) -> SessionProjection { + self.sink.snapshot() + } + + /// The path written or edited most recently, when any was. + fn last_file_touched(&self) -> Option { + self.sink + .projection + .lock() + .unwrap_or_else(PoisonError::into_inner) + .last_file_touched + .clone() } fn release_lease(&mut self) { @@ -385,6 +407,101 @@ impl LiveAgent { } } +/// A stage's billing from its account: the whole tree under the root's +/// route, and the rows that split it by model. +struct StageBilling { + total: BilledModelUsage, + by_model: Vec, +} + +/// Bills the stage's account from the catalog: the root session at +/// `root_model`, its route, and each descendant at its own route where the +/// catalog knows it and at the root's otherwise, so a subagent on a cheaper +/// or dearer model is priced as what it ran. A descendant on the root's +/// route joins the root's row. Where pebble carried a provider-reported +/// cost, that cost stands in for the catalog's estimate. +fn stage_billing( + catalog: &Catalog, + root_model: &ModelRef, + account: &SessionProjection, +) -> Result { + let mut groups: Vec<(ModelRef, TokenCounts, Option)> = vec![( + root_model.clone(), + TokenCounts::from(account.usage), + account.cost_usd_micros, + )]; + for descendant in account.descendants.values() { + let model = descendant_model(catalog, root_model, descendant); + match groups.iter_mut().find(|(grouped, _, _)| *grouped == model) { + Some((_, tokens, cost)) => { + billing::add_usage(tokens, TokenCounts::from(descendant.usage)); + add_reported_cost(cost, descendant.cost_usd_micros); + } + None => groups.push(( + model, + TokenCounts::from(descendant.usage), + descendant.cost_usd_micros, + )), + } + } + // The root's row first, then the others by model. + groups[1..].sort_by(|left, right| left.0.sort_key().cmp(&right.0.sort_key())); + + let mut by_model = Vec::with_capacity(groups.len()); + let mut total_tokens = TokenCounts::default(); + let mut total_cost = None; + for (model, tokens, reported) in groups { + let row = billed_model_usage_from_llm(catalog, &model, tokens)? + .with_reported_cost(reported.map(usd_micros)); + billing::add_usage(&mut total_tokens, row.tokens); + UsdMicros::accumulate(&mut total_cost, row.total_usd_micros.map(UsdMicros)); + by_model.push(row); + } + Ok(StageBilling { + total: BilledModelUsage { + model: root_model.clone(), + tokens: total_tokens, + total_usd_micros: total_cost.map(|cost| cost.0), + }, + by_model, + }) +} + +/// The route a descendant is billed at: its own where its start named one +/// the catalog knows, else the root's. A descendant whose start was not seen +/// names only its answers' model, taken to be on the root's provider. +fn descendant_model( + catalog: &Catalog, + root_model: &ModelRef, + account: &DescendantAccount, +) -> ModelRef { + let Some(model) = account.model.as_deref() else { + return root_model.clone(); + }; + let provider = account + .provider + .as_deref() + .unwrap_or(root_model.provider.as_str()); + if provider == root_model.provider.as_str() && model == root_model.model_id.as_str() { + return root_model.clone(); + } + if catalog.enabled_provider(provider).is_none() { + return root_model.clone(); + } + ModelRef::new(ProviderId::new(provider), ModelId::new(model)) +} + +/// Folds a reported cost into a total that stays `None` until one is seen. +fn add_reported_cost(total: &mut Option, cost: Option) { + if let Some(cost) = cost { + *total = Some(total.unwrap_or(0).saturating_add(cost)); + } +} + +fn usd_micros(micros: u64) -> UsdMicros { + UsdMicros(i64::try_from(micros).unwrap_or(i64::MAX)) +} + /// Everything one stage binds to an agent it builds or resumes. struct StageBindings<'a> { node_id: &'a str, @@ -587,21 +704,24 @@ impl PebbleBackend { plan: &FallbackPlan, provider: &ProviderContext, bindings: &StageBindings<'_>, - ) -> CodingAgentBuilder { + ) -> (CodingAgentBuilder, Arc) { let route = plan.current(); let max_tokens = node_max_output_tokens(node).map(i64::from); + let sink = Arc::new(WorkflowEventSink { + emitter: Arc::clone(bindings.emitter), + node_id: bindings.node_id.to_string(), + scope: bindings.stage_scope.clone(), + plan: plan.clone(), + projection: Mutex::new(SessionProjection::new()), + }); + let event_sink = Arc::clone(&sink) as Arc; builder = builder .tools(self.stage_tools()) .mcp_servers(pebble_servers(&self.mcp_servers)) .permission_level(PermissionLevel::Full) .options(self.agent_options(node, route.controls)) .fallback_routes(plan.pebble_routes(max_tokens)) - .event_sink(Arc::new(WorkflowEventSink { - emitter: Arc::clone(bindings.emitter), - node_id: bindings.node_id.to_string(), - scope: bindings.stage_scope.clone(), - plan: plan.clone(), - })) + .event_sink(event_sink) .redactor(Arc::new(SecretRedactor)) .subagents(SubagentOptions::enabled()); if let Some(routes) = bindings.sandbox.port_routes() { @@ -622,7 +742,7 @@ impl PebbleBackend { if provider.profile_kind == AgentProfileKind::Claude5 { builder = builder.web_fetch_summarizer(route.selector()); } - builder + (builder, sink) } /// A new agent on the plan's current route. @@ -632,15 +752,17 @@ impl PebbleBackend { plan: &FallbackPlan, provider: &ProviderContext, bindings: &StageBindings<'_>, - ) -> Result { + ) -> Result<(CodingAgent, Arc), Error> { let client = self.build_llm_client().await?; let environment: Arc = Arc::clone(bindings.sandbox) as Arc; let builder = CodingAgent::builder(client, environment).model(plan.current().selector()); - self.bind_builder(builder, node, plan, provider, bindings) + let (builder, sink) = self.bind_builder(builder, node, plan, provider, bindings); + let agent = builder .build() .await - .map_err(|error| Error::handler_with_source("Failed to start agent session", error)) + .map_err(|error| Error::handler_with_source("Failed to start agent session", error))?; + Ok((agent, sink)) } /// The exported conversation of an earlier stage, continued on the @@ -652,15 +774,17 @@ impl PebbleBackend { plan: &FallbackPlan, provider: &ProviderContext, bindings: &StageBindings<'_>, - ) -> Result { + ) -> Result<(CodingAgent, Arc), Error> { let client = self.build_llm_client().await?; let environment: Arc = Arc::clone(bindings.sandbox) as Arc; let builder = CodingAgent::resume_from_export(client, environment, export); - self.bind_builder(builder, node, plan, provider, bindings) + let (builder, sink) = self.bind_builder(builder, node, plan, provider, bindings); + let agent = builder .build() .await - .map_err(|error| Error::handler_with_source("Failed to resume agent session", error)) + .map_err(|error| Error::handler_with_source("Failed to resume agent session", error))?; + Ok((agent, sink)) } /// Register `live` with the steering hub so steers reach it, and tell @@ -986,6 +1110,7 @@ impl CodergenBackend for PebbleBackend { return Ok(CodergenResult::Text { text: response_text, + usage_by_model: Vec::new(), usage: Some(stage_usage), files_touched: Vec::new(), last_file_touched: None, @@ -1026,13 +1151,13 @@ impl CodergenBackend for PebbleBackend { let cached = reuse_key.as_ref().and_then(|key| self.take_thread(key)); let is_reused = cached.is_some(); - let (agent, mut fallback_plan) = if let Some(thread) = cached { + let ((agent, sink), mut fallback_plan) = if let Some(thread) = cached { let route = thread.fallback_plan.current().clone(); let provider = self.resolve_provider_context( route.target.model.as_str(), Some(route.target.provider.as_str()), )?; - let agent = self + let session = self .resume_exported_agent( thread.export, node, @@ -1041,7 +1166,7 @@ impl CodergenBackend for PebbleBackend { &bindings, ) .await?; - (agent, thread.fallback_plan) + (session, thread.fallback_plan) } else { let model = node.model().unwrap_or(&self.model); let provider = routing::resolve_node_provider_context( @@ -1059,10 +1184,10 @@ impl CodergenBackend for PebbleBackend { route.target.model.as_str(), Some(route.target.provider.as_str()), )?; - let agent = self + let session = self .build_agent(node, &fallback_plan, &route_provider, &bindings) .await?; - (agent, fallback_plan) + (session, fallback_plan) }; if cancel_token.is_cancelled() { let mut agent = agent; @@ -1078,7 +1203,7 @@ impl CodergenBackend for PebbleBackend { ); let handle = agent.control_handle(); - let mut live = LiveAgent::new(agent, handle); + let mut live = LiveAgent::new(agent, handle, sink); let route = fallback_plan.current().clone(); if let Err(error) = self.activate(&mut live, &route, &stage_id, request.thread_id, &bindings) @@ -1104,7 +1229,7 @@ impl CodergenBackend for PebbleBackend { let mut repair_attempts = 0_i64; let mut previous_validation_error = None; loop { - let last_file_touched = live.last_file_touched.clone(); + let last_file_touched = live.last_file_touched(); match validate_agent_output_sources( schema, &response, @@ -1171,16 +1296,13 @@ impl CodergenBackend for PebbleBackend { }; let route = fallback_plan.current().clone(); - let stage_usage = billed_model_usage_from_llm( - self.catalog.as_ref(), - &ModelRef::new( - route.target.provider.clone(), - ModelId::new(route.target.model.as_str()), - ) - .with_speed(route.controls.speed), - live.total_usage, - )? - .with_reported_cost(live.total_cost); + let root_model = ModelRef::new( + route.target.provider.clone(), + ModelId::new(route.target.model.as_str()), + ) + .with_speed(route.controls.speed); + let account = live.account(); + let billing = stage_billing(self.catalog.as_ref(), &root_model, &account)?; live.release_lease(); match reuse_key { @@ -1204,9 +1326,10 @@ impl CodergenBackend for PebbleBackend { Ok(CodergenResult::Text { text: response, - usage: Some(stage_usage), - files_touched: live.files_touched.into_iter().collect(), - last_file_touched: live.last_file_touched, + usage: Some(billing.total), + usage_by_model: billing.by_model, + files_touched: account.files_touched, + last_file_touched: account.last_file_touched, timing: StageTiming::active_only( crate::millis_u64(live.inference_duration), crate::millis_u64(live.tool_duration), @@ -1214,3 +1337,174 @@ impl CodergenBackend for PebbleBackend { }) } } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use fabro_llm::test_support::test_catalog; + use lithos_llm::catalog::builtin; + use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, InputSource, TokenUsage}; + + use super::*; + + fn root(event: CodingEvent) -> CodingAgentEvent { + CodingAgentEvent::new("ses_root".to_string(), event, SystemTime::UNIX_EPOCH) + } + + fn child(session_id: &str, event: CodingEvent) -> CodingAgentEvent { + CodingAgentEvent::new(session_id.to_string(), event, SystemTime::UNIX_EPOCH) + .with_parent_session_id("ses_root".to_string()) + } + + fn started(provider: &str, model: &str) -> CodingEvent { + CodingEvent::SessionStarted { + provider: Some(provider.to_string()), + model: Some(model.to_string()), + } + } + + fn message(model: &str, input: u64, output: u64, cost: Option) -> CodingEvent { + CodingEvent::AssistantMessage { + text: "ok".to_string(), + model: model.to_string(), + usage: TokenUsage { + input, + output, + ..TokenUsage::default() + }, + cost_usd_micros: cost, + cost_source: None, + tool_call_count: 0, + context_window: None, + reasoning: None, + } + } + + fn root_model() -> ModelRef { + ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")) + } + + fn account(events: &[CodingAgentEvent]) -> SessionProjection { + let mut account = SessionProjection::new(); + account.apply_all(events); + account + } + + #[test] + fn stage_billing_prices_the_root_at_its_route_and_each_descendant_at_its_own() { + let catalog = test_catalog(); + let account = account(&[ + root(started("openai", "gpt-5.4")), + root(CodingEvent::UserInput { + text: "go".to_string(), + content: None, + source: InputSource::Prompt, + }), + root(message("gpt-5.4", 100_000, 25_000, None)), + // A child on the parent's route joins the parent's row. + child("ses_same", started("openai", "gpt-5.4")), + child("ses_same", message("gpt-5.4", 10_000, 1_000, None)), + // A child on another route is its own row, at that route's rate. + child("ses_other", started("anthropic", "claude-sonnet-5")), + child("ses_other", message("claude-sonnet-5", 20_000, 2_000, None)), + // A child on a route the catalog does not know bills at the root's. + child("ses_unknown", started("nowhere", "mystery")), + child("ses_unknown", message("mystery", 1_000, 100, None)), + root(CodingEvent::ProcessingEnd), + ]); + + let billing = stage_billing(&catalog, &root_model(), &account).unwrap(); + + assert_eq!(billing.by_model.len(), 2, "{:?}", billing.by_model); + let root_row = &billing.by_model[0]; + assert_eq!(root_row.model, root_model()); + assert_eq!( + root_row.tokens.input, 111_000, + "the root, the same-route child, and the unknown-route child" + ); + assert_eq!(root_row.tokens.output, 26_100); + let root_priced = + billed_model_usage_from_llm(&catalog, &root_model(), root_row.tokens).unwrap(); + assert_eq!(root_row.total_usd_micros, root_priced.total_usd_micros); + + let other_model = ModelRef::new( + ProviderId::new("anthropic"), + ModelId::new("claude-sonnet-5"), + ); + let other_row = &billing.by_model[1]; + assert_eq!(other_row.model, other_model); + assert_eq!(other_row.tokens.input, 20_000); + assert_eq!(other_row.tokens.output, 2_000); + let other_priced = + billed_model_usage_from_llm(&catalog, &other_model, other_row.tokens).unwrap(); + assert_eq!(other_row.total_usd_micros, other_priced.total_usd_micros); + assert_ne!( + other_row.total_usd_micros, + billed_model_usage_from_llm(&catalog, &root_model(), other_row.tokens) + .unwrap() + .total_usd_micros, + "priced at its own rate, not the root's" + ); + + // The total is the tree's tokens under the root's route, at the rows' summed + // cost. + assert_eq!(billing.total.model, root_model()); + assert_eq!(billing.total.tokens.input, 131_000); + assert_eq!(billing.total.tokens.output, 28_100); + assert_eq!( + billing.total.total_usd_micros, + Some(root_priced.total_usd_micros.unwrap() + other_priced.total_usd_micros.unwrap()) + ); + } + + #[test] + fn a_provider_reported_cost_stands_in_for_the_catalogs_estimate() { + let catalog = test_catalog(); + let account = account(&[ + root(started("openai", "gpt-5.4")), + root(message("gpt-5.4", 1_000, 100, Some(4_321))), + child("ses_child", started("anthropic", "claude-sonnet-5")), + child("ses_child", message("claude-sonnet-5", 500, 50, None)), + ]); + + let billing = stage_billing(&catalog, &root_model(), &account).unwrap(); + + assert_eq!(billing.by_model[0].total_usd_micros, Some(4_321)); + let child_priced = billed_model_usage_from_llm( + &catalog, + &billing.by_model[1].model, + billing.by_model[1].tokens, + ) + .unwrap(); + assert_eq!( + billing.by_model[1].total_usd_micros, + child_priced.total_usd_micros + ); + assert_eq!( + billing.total.total_usd_micros, + Some(4_321 + child_priced.total_usd_micros.unwrap()) + ); + } + + #[test] + fn a_descendant_seen_only_through_its_answers_bills_on_the_roots_provider() { + let catalog = test_catalog(); + let mut account = account(&[root(started("openai", "gpt-5.4"))]); + // No `SessionStarted` for the child: only its answer names a model. + account.apply(&child( + "ses_quiet", + message("gpt-5.4-mini", 1_000, 100, None), + )); + + let billing = stage_billing(&catalog, &root_model(), &account).unwrap(); + + let child_row = billing + .by_model + .iter() + .find(|row| row.model.model_id.as_str() == "gpt-5.4-mini") + .expect("the child is billed as its answers' model on the root's provider"); + assert_eq!(child_row.model.provider, root_model().provider); + assert_eq!(child_row.tokens.input, 1_000); + } +} diff --git a/lib/components/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs index 45e2250d8..6ecbd7a01 100644 --- a/lib/components/fabro-workflow/src/handler/llm/router.rs +++ b/lib/components/fabro-workflow/src/handler/llm/router.rs @@ -160,6 +160,7 @@ mod tests { async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { Ok(CodergenResult::Text { text: "api run".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -170,6 +171,7 @@ mod tests { async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result { Ok(CodergenResult::Text { text: "api one-shot".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 85dd74c45..1f5137c3e 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -325,6 +325,7 @@ mod tests { ) -> Result { Ok(CodergenResult::Text { text: "one-shot response".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -383,6 +384,7 @@ mod tests { ) -> Result { Ok(CodergenResult::Text { text: "one-shot response".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -421,6 +423,7 @@ mod tests { ) -> Result { Ok(CodergenResult::Text { text: r#"{"passed": true}"#.to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -469,6 +472,7 @@ mod tests { ) -> Result { Ok(CodergenResult::Text { text: r#"{"outcome": 123}"#.to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -519,6 +523,7 @@ mod tests { ) -> Result { Ok(CodergenResult::Text { text: "one-shot response".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -579,6 +584,7 @@ mod tests { Some(request.system_prompt.map(String::from)); Ok(CodergenResult::Text { text: "classified".to_string(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, diff --git a/lib/components/fabro-workflow/src/lib.rs b/lib/components/fabro-workflow/src/lib.rs index 918d08973..4b66dea59 100644 --- a/lib/components/fabro-workflow/src/lib.rs +++ b/lib/components/fabro-workflow/src/lib.rs @@ -125,6 +125,7 @@ mod duration_tests { status: StageOutcome::Succeeded, preferred_label: None, suggested_next_ids: vec![], + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -250,6 +251,7 @@ mod duration_tests { status: StageOutcome::Succeeded, preferred_label: None, suggested_next_ids: vec![], + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-workflow/src/lifecycle/event.rs b/lib/components/fabro-workflow/src/lifecycle/event.rs index 6303ea1f3..278479006 100644 --- a/lib/components/fabro-workflow/src/lifecycle/event.rs +++ b/lib/components/fabro-workflow/src/lifecycle/event.rs @@ -215,6 +215,7 @@ impl RunLifecycle for EventLifecycle { status: StageOutcome::Succeeded.to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -357,6 +358,7 @@ impl RunLifecycle for EventLifecycle { preferred_label: outcome.preferred_label.clone(), suggested_next_ids: outcome.suggested_next_ids.clone(), billing: outcome.usage.clone(), + billing_by_model: outcome.usage_by_model.clone(), failure: outcome.failure.clone(), notes: outcome.notes.clone(), files_touched: outcome.files_touched.clone(), diff --git a/lib/components/fabro-workflow/src/operations/fork.rs b/lib/components/fabro-workflow/src/operations/fork.rs index 00c2e4af4..d601cc1bc 100644 --- a/lib/components/fabro-workflow/src/operations/fork.rs +++ b/lib/components/fabro-workflow/src/operations/fork.rs @@ -429,6 +429,7 @@ mod tests { status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: Vec::new(), + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 83b5fbe1e..76e1f9d4c 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -2568,6 +2568,7 @@ mod tests { preferred_label: None, suggested_next_ids: Vec::new(), billing, + billing_by_model: Vec::new(), failure: None, notes: None, files_touched: Vec::new(), diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index 879d91eaa..ffcf4e88e 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -1222,6 +1222,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: vec![], + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, @@ -1645,6 +1646,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr status: "succeeded".to_string(), preferred_label: None, suggested_next_ids: vec![], + billing_by_model: Vec::new(), billing: None, failure: None, notes: None, diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index fb2df9eaf..118fc20ff 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -2202,6 +2202,7 @@ impl CodergenBackend for MockCodergenBackend { request.node.id, &request.prompt[..request.prompt.len().min(50)] ), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, @@ -7425,6 +7426,7 @@ mod real_llm { .map_err(|e| Error::handler(e.to_string()))?; Ok(CodergenResult::Text { text: response.text(), + usage_by_model: Vec::new(), usage: None, files_touched: Vec::new(), last_file_touched: None, diff --git a/lib/components/fabro-workflow/tests/it/pebble_agent.rs b/lib/components/fabro-workflow/tests/it/pebble_agent.rs index d8d88a466..b749ad120 100644 --- a/lib/components/fabro-workflow/tests/it/pebble_agent.rs +++ b/lib/components/fabro-workflow/tests/it/pebble_agent.rs @@ -873,6 +873,57 @@ async fn a_subagent_runs_under_its_parent_session() { assert_eq!(work_stage(&state).response.as_deref(), Some("Parent done")); assert_eq!(count(&stage.events, "agent.sub.spawned"), 1); + // One usage rule: the stage bills its whole session tree, live and at + // completion. Four model calls answered: the parent's three and the + // child's one. + let work = work_stage(&state); + assert_eq!( + work.usage.input_tokens, + 4 * INPUT_TOKENS_PER_CALL, + "the child's call is the stage's too" + ); + assert_eq!(work.usage.output_tokens, 4 * OUTPUT_TOKENS_PER_CALL); + assert_eq!( + work.usage.total_usd_micros, + Some(4 * (INPUT_TOKENS_PER_CALL + 2 * OUTPUT_TOKENS_PER_CALL)), + "priced from the catalog for every call" + ); + let agent = work + .agent + .as_ref() + .expect("the stage carries pebble's fold"); + let (descendants, _) = agent.descendant_usage(); + assert_eq!( + u64::try_from(work.usage.input_tokens).unwrap(), + agent.usage.input + descendants.input, + "the completed usage is what the live fold showed" + ); + assert_eq!( + descendants.input, + u64::try_from(INPUT_TOKENS_PER_CALL).unwrap() + ); + // The child ran on its parent's model, so the split is one row carrying + // the tree. + assert_eq!( + work.billing_by_model.len(), + 1, + "{:?}", + work.billing_by_model + ); + assert_eq!( + work.billing_by_model[0].tokens.input, + u64::try_from(4 * INPUT_TOKENS_PER_CALL).unwrap() + ); + assert_eq!( + Some(&work.billing_by_model[0].model), + work.model.as_ref(), + "billed under the root's route" + ); + assert_eq!( + work.billing_by_model[0].total_usd_micros, + work.usage.total_usd_micros + ); + let agent_events = coding_events(&stage.events); let root_session = agent_events .iter() diff --git a/lib/foundation/fabro-types/src/billing_rollup.rs b/lib/foundation/fabro-types/src/billing_rollup.rs index 9ec5aee23..01b4a31e2 100644 --- a/lib/foundation/fabro-types/src/billing_rollup.rs +++ b/lib/foundation/fabro-types/src/billing_rollup.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use crate::{BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageSummary, StageTiming}; +use crate::{ + BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageProjection, StageSummary, + StageTiming, +}; #[derive(Debug, Clone, PartialEq)] pub struct ProjectionBillingStage { @@ -159,16 +162,21 @@ pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionB if let Some(model) = &stage.model { row.model = Some(model.clone()); + } + // A completed agent stage says which model billed which tokens: + // the root's route and each subagent's own. Until then, and for + // a stage without a coding agent, `usage` bills to `model`. + for (model, billing) in model_rows(stage) { let model_entry = by_model .entry(model.clone()) .or_insert_with(|| ProjectionBillingByModel { - model: model.clone(), - stages: 0, + model, + stages: 0, billing: BilledTokenCounts::default(), }); model_entry.stages += 1; - model_entry.billing.add_counts(usage); + model_entry.billing.add_counts(&billing); } } } @@ -185,6 +193,28 @@ pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionB } } +/// The stage's usage by model: its `billing_by_model` rows when the stage +/// completed with them, else its `usage` under its `model`. +fn model_rows(stage: &StageProjection) -> Vec<(ModelRef, BilledTokenCounts)> { + if stage.billing_by_model.is_empty() { + return stage + .model + .iter() + .map(|model| (model.clone(), stage.usage.clone())) + .collect(); + } + stage + .billing_by_model + .iter() + .map(|row| { + ( + row.model.clone(), + BilledTokenCounts::from_token_counts(row.tokens, row.total_usd_micros), + ) + }) + .collect() +} + fn stage_projection_order(state: &RunProjection) -> HashMap { let mut order = HashMap::new(); for (stage_id, stage) in state.iter_stages() { diff --git a/lib/foundation/fabro-types/src/outcome.rs b/lib/foundation/fabro-types/src/outcome.rs index 4aadc9aac..02810714a 100644 --- a/lib/foundation/fabro-types/src/outcome.rs +++ b/lib/foundation/fabro-types/src/outcome.rs @@ -9,7 +9,8 @@ use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; use crate::{ - ExecOutputTail, FailureSignature, OnFailure, ResolvedOnFailure, StageTiming, SystemActorKind, + BilledModelUsage, ExecOutputTail, FailureSignature, OnFailure, ResolvedOnFailure, StageTiming, + SystemActorKind, }; pub trait OutcomeMeta: @@ -274,6 +275,11 @@ pub struct Outcome { pub failure: Option, #[serde(default)] pub usage: M, + /// The stage's billing split by model, for a stage whose agent ran + /// subagents: the root's route and each subagent's own model. Empty + /// otherwise; `usage` is then the one row. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub usage_by_model: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub files_touched: Vec, /// Stage timing breakdown captured by the workflow engine. @@ -296,6 +302,7 @@ impl Default for Outcome { notes: None, failure: None, usage: M::default(), + usage_by_model: Vec::new(), files_touched: Vec::new(), timing: None, } diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 048823c6a..642d43d12 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -1202,6 +1202,7 @@ mod tests { status: crate::StageOutcome::Succeeded, preferred_label: None, suggested_next_ids: vec!["next".to_string()], + billing_by_model: Vec::new(), billing: None, failure: None, notes: Some("done".to_string()), @@ -1677,6 +1678,7 @@ mod tests { status: crate::StageOutcome::Succeeded, preferred_label: None, suggested_next_ids: vec!["next".to_string()], + billing_by_model: Vec::new(), billing: None, failure: None, notes: Some("done".to_string()), diff --git a/lib/foundation/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs index 422780c4d..524fff69f 100644 --- a/lib/foundation/fabro-types/src/run_event/stage.rs +++ b/lib/foundation/fabro-types/src/run_event/stage.rs @@ -36,8 +36,16 @@ pub struct StageCompletedProps { pub preferred_label: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub suggested_next_ids: Vec, + /// The stage's billing: for an agent stage, the whole session tree's + /// tokens (the root session and every subagent) under the root's route. #[serde(default, skip_serializing_if = "Option::is_none")] pub billing: Option, + /// `billing` split by model: the root session's route and each + /// subagent's own model, a subagent whose model the catalog does not know + /// billed at the root's. Sums to `billing`. Empty for stages without a + /// coding agent and on events written before it existed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub billing_by_model: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub failure: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index 9f8e446b8..5e0540d78 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -14,11 +14,11 @@ use strum::{Display, EnumString, IntoStaticStr}; use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; use crate::{ - AgentBackend, AgentMcpToolSummary, BilledTokenCounts, Checkpoint, Conclusion, GitIdentity, - InterviewQuestionRecord, InvalidTransition, ModelRef, ParallelBranchId, PullRequestCreation, - PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, - RunTiming, StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord, - timing, + AgentBackend, AgentMcpToolSummary, BilledModelUsage, BilledTokenCounts, Checkpoint, Conclusion, + GitIdentity, InterviewQuestionRecord, InvalidTransition, ModelRef, ParallelBranchId, + PullRequestCreation, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, + RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState, + StageTiming, StartRecord, timing, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -297,6 +297,12 @@ pub struct StageProjection { pub usage: BilledTokenCounts, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// The completed stage's billing split by model, as `stage.completed` + /// reported it: the root session's route and each subagent's own model. + /// Sums to `usage`. Empty while the stage runs and for stages without a + /// coding agent; the billing rollup then bills `usage` to `model`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub billing_by_model: Vec, /// Todo/task list owned by the stage's root agent session. /// /// OpenAI child sessions own separate per-session plans and do not appear @@ -514,6 +520,7 @@ impl StageProjection { acp_started_at: None, agent_control: AgentControlState::default(), agent: None, + billing_by_model: Vec::new(), provider_used: None, diff: None, script_invocation: None, From 9101a904710ff6416301b3ec528e4f15e16c7a4f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:49:28 -0600 Subject: [PATCH 05/11] Describe billing_by_model on the API and the Billing tab StageProjection.billing_by_model and a BilledModelUsage schema that reuses fabro's type; the TypeScript client regenerated; the Billing tab's token tooltip says subagent tokens are included and priced at each subagent's model; the stage.completed docs describe the rows and the one usage rule. Co-Authored-By: Claude Fable 5.1 --- apps/fabro-web/app/routes/run-billing.tsx | 3 + docs/internal/events.md | 7 ++ docs/public/api-reference/fabro-api.yaml | 32 ++++++++ lib/foundation/fabro-api/build.rs | 1 + lib/foundation/fabro-api/src/lib.rs | 66 ++++++++-------- .../tests/stage_projection_round_trip.rs | 79 ++++++++++++++++++- .../src/.openapi-generator/FILES | 1 + .../src/models/billed-model-usage.ts | 33 ++++++++ .../fabro-api-client/src/models/index.ts | 1 + .../src/models/stage-projection.ts | 7 ++ 10 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/billed-model-usage.ts diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 648b1796b..70fe0e5b2 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -97,6 +97,9 @@ function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) { ))} +

+ Includes subagent tokens, priced at each subagent's model. +

); } diff --git a/docs/internal/events.md b/docs/internal/events.md index bcab1a858..b419d9396 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -422,6 +422,7 @@ Emitted when a workflow node finishes execution. | `usage.reasoning_tokens` | number? | Reasoning/thinking tokens | | `usage.speed` | string? | Speed tier | | `usage.cost` | number? | Estimated cost in USD | +| `billing_by_model` | array? | For an agent stage, the stage's billing split by model: the root session's route and each subagent's own model, a subagent whose model the catalog does not know billed at the root's. Each row has `model`, `tokens`, and `total_usd_micros`, and the rows sum to the stage's billing. Empty for stages without a coding agent and on events written before it existed | | `error` | string? | Error message (flattened from failure detail) | | `failure_class` | string? | `"transient_infra"`, `"deterministic"`, `"budget_exhausted"`, `"compilation_loop"`, `"canceled"`, `"structural"` | | `failure_signature` | string? | Dedup key for repeated failures | @@ -433,6 +434,12 @@ Emitted when a workflow node finishes execution. | `restart_failure_signatures` | object? | Restart failure signature counts | | `response` | string? | Full LLM or agent response text when produced by the stage | | `notes` | string? | Free-text notes | + +An agent stage's usage is its whole session tree's: the root session and +every subagent, live in `StageProjection.usage` and here at completion, both +read from the same fold of the stage's agent events. The root is priced at +its route and each subagent at its own model; where the provider reported a +cost, that cost stands. | `files_touched` | string[] | File paths modified | | `attempt` | number | Attempt number (1-based) | | `max_attempts` | number | Maximum attempts allowed | diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index aa06923ed..3757ae924 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -11227,6 +11227,18 @@ components: agent_control: $ref: "#/components/schemas/AgentControlState" description: Whether the agent is executing normally or waiting for steering after an interrupt. + billing_by_model: + type: array + items: + $ref: "#/components/schemas/BilledModelUsage" + default: [] + description: >- + The completed stage's `usage` split by model, as `stage.completed` + reported it: the root session's route and each subagent's own + model, a subagent whose model the catalog does not know billed at + the root's. Sums to `usage`. Empty while the stage runs and for + stages without a coding agent; the billing rollup then bills + `usage` to `model`. agent: oneOf: - $ref: "#/components/schemas/AgentSessionProjection" @@ -13355,6 +13367,26 @@ components: description: Billed USD amount in micros. example: 720000 + BilledModelUsage: + description: >- + Usage and cost billed to one model: one response, or one model's share + of a stage. + type: object + required: + - model + - tokens + properties: + model: + $ref: "#/components/schemas/BillingModelRef" + tokens: + $ref: "#/components/schemas/CompletionUsage" + total_usd_micros: + type: integer + format: int64 + description: >- + Cost for `tokens`, when the provider reported one or the catalog + could price them. Absent means no cost data, not zero. + BillingModelRef: description: Provider-qualified billing model identity used for cost estimates. type: object diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index d5c207a1c..72c597aaa 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -385,6 +385,7 @@ fn main() { &[], ), ("StageProjection", "fabro_types::StageProjection", &[]), + ("BilledModelUsage", "fabro_types::BilledModelUsage", &[]), ( "StageInferenceProjection", "fabro_types::StageInferenceProjection", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 485c72e20..c93eff100 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -39,39 +39,39 @@ pub mod types { }; pub use fabro_types::{ ActivatedSkill, AgentControlState, AgentEventProps, AgentMcpToolSummary, - AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash, - CommandTermination, Conclusion, ContextWindowBreakdownItem, ContextWindowCategory, - ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, - ContextWindowWarning, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, - EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, - GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, - IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, - IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord, - LlmOutputKind, McpServerDraft as CreateMcpServerRequest, McpServerProjection, - McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer, - McpTransportView, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, - ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, PairMessageRecord, - PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, - PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, - PendingInterviewRecord, PermissionLevel, Principal, Provider, PullRequest, - PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, - PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, - PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget, - ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, - RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, - RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, - RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, - RunServerProvenance, RunSessionMetadata, RunSize, RunTarget, SandboxDetails, SandboxInfo, - SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, - SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, - SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn, - SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion, StageContextWindow, - StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection, - StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection, - SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus, - SystemIntegrationsResponse, TodoListProjection, ToolCategory, ToolSource, ToolSummary, - TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath, - WorkflowSettings, WorkflowVersion, WorkflowVersionId, + AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledModelUsage, + BilledTokenCounts, BlobHash, CommandTermination, Conclusion, ContextWindowBreakdownItem, + ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, + ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, DiffStats, + DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, + FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, + IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, + IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, + InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest, + McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, + McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts, + ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, PairId, + PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, + PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, + ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, Provider, + PullRequest, PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, + PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, + PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, + ReviewTarget, ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, + RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent, + RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, + RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, + RunSandboxRuntime, RunServerProvenance, RunSessionMetadata, RunSize, RunTarget, + SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, + SandboxProviderLookupError, SandboxService, SandboxServiceListResponse, SecretMetadata, + SecretType, ServerSettings, SessionDetail, SessionId, SessionStatus, SessionSummary, + SessionTurn, SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion, + StageContextWindow, StageContextWindowUnavailableReason, StageHandler, StageId, + StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, + StageToolBatchProjection, SubAgentProjection, SubAgentStatus, SystemActorKind, + SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, ToolCategory, + ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal, Variable, + VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, WorkflowVersionId, }; pub use lithos_llm::catalog::{ModelHandle, ProviderId}; pub use lithos_llm::types::{ diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index 416c27b56..de27a4811 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -4,6 +4,7 @@ use fabro_api::types::{ ActivatedSkill as ApiActivatedSkill, AgentControlState as ApiAgentControlState, AgentMcpToolSummary as ApiAgentMcpToolSummary, AgentToolsAvailableProps as ApiAgentToolsAvailableProps, + BilledModelUsage as ApiBilledModelUsage, ContextWindowBreakdownItem as ApiContextWindowBreakdownItem, ContextWindowCategory as ApiContextWindowCategory, ContextWindowCountMethod as ApiContextWindowCountMethod, @@ -23,19 +24,91 @@ use fabro_api::types::{ }; use fabro_types::{ ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentToolsAvailableProps, - ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, + BilledModelUsage, ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, LlmOutputKind, - McpServerProjection, McpServerStatus, ParallelBranchId, ParallelBranchResult, PermissionLevel, - SkillActivationSource, SkillSummary, SkillsProjection, StageContextWindow, + McpServerProjection, McpServerStatus, ModelRef, ParallelBranchId, ParallelBranchResult, + PermissionLevel, SkillActivationSource, SkillSummary, SkillsProjection, StageContextWindow, StageContextWindowUnavailableReason, StageId, StageInferenceProjection, StageProjection, StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, ToolCategory, ToolSource, ToolSummary, }; +use lithos_llm::catalog::{ModelId, ProviderId}; +use lithos_llm::types::TokenCounts; use serde_json::json; #[test] fn stage_projection_reuses_canonical_type() { assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn billing_by_model_rows_match_openapi_json_shape() { + let row = BilledModelUsage { + model: ModelRef::new(ProviderId::new("openai"), ModelId::new("gpt-5.4")), + tokens: TokenCounts { + input: 107, + output: 51, + ..TokenCounts::default() + }, + total_usd_micros: Some(321), + }; + let value = serde_json::to_value(&row).unwrap(); + assert_eq!( + value, + json!({ + "model": { "provider": "openai", "model_id": "gpt-5.4" }, + "tokens": { + "input": 107, + "output": 51, + "reasoning": 0, + "cache_read": 0, + "cache_write": 0 + }, + "total_usd_micros": 321 + }) + ); + let api_row: ApiBilledModelUsage = serde_json::from_value(value).unwrap(); + assert_eq!(api_row, row); + + let mut stage = StageProjection::new(std::num::NonZeroU32::new(1).unwrap()); + stage.billing_by_model = vec![row.clone()]; + let stage_json = serde_json::to_value(&stage).unwrap(); + assert_eq!( + stage_json["billing_by_model"], + json!([serde_json::to_value(&row).unwrap()]) + ); + let without: StageProjection = serde_json::from_value(json!({ + "first_event_seq": 1, + "prompt": null, + "response": null, + "completion": null, + "provider_used": null, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "agent_control": "running", + "state": "running" + })) + .unwrap(); + assert!(without.billing_by_model.is_empty()); + assert!( + serde_json::to_value(&without) + .unwrap() + .get("billing_by_model") + .is_none(), + "no rows, nothing on the wire" + ); } #[test] diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 1c312fcf2..a98114497 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -87,6 +87,7 @@ models/batch-run-lifecycle-request.ts models/batch-run-lifecycle-response.ts models/batch-run-lifecycle-result.ts models/batch-run-lifecycle-summary.ts +models/billed-model-usage.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-model-ref.ts diff --git a/lib/packages/fabro-api-client/src/models/billed-model-usage.ts b/lib/packages/fabro-api-client/src/models/billed-model-usage.ts new file mode 100644 index 000000000..3d4964992 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/billed-model-usage.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { BillingModelRef } from './billing-model-ref'; +// May contain unused imports in some cases +// @ts-ignore +import type { CompletionUsage } from './completion-usage'; + +/** + * Usage and cost billed to one model: one response, or one model\'s share of a stage. + */ +export interface BilledModelUsage { + 'model': BillingModelRef; + 'tokens': CompletionUsage; + /** + * Cost for `tokens`, when the provider reported one or the catalog could price them. Absent means no cost data, not zero. + */ + 'total_usd_micros'?: number; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 5771fc062..d707783e3 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -58,6 +58,7 @@ export * from './batch-run-lifecycle-request'; export * from './batch-run-lifecycle-response'; export * from './batch-run-lifecycle-result'; export * from './batch-run-lifecycle-summary'; +export * from './billed-model-usage'; export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-model-ref'; diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index ccf1d548f..9834299d1 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -21,6 +21,9 @@ import type { AgentControlState } from './agent-control-state'; import type { AgentSessionProjection } from './agent-session-projection'; // May contain unused imports in some cases // @ts-ignore +import type { BilledModelUsage } from './billed-model-usage'; +// May contain unused imports in some cases +// @ts-ignore import type { BilledTokenCounts } from './billed-token-counts'; // May contain unused imports in some cases // @ts-ignore @@ -145,6 +148,10 @@ export interface StageProjection { * Whether the agent is executing normally or waiting for steering after an interrupt. */ 'agent_control': AgentControlState; + /** + * The completed stage\'s `usage` split by model, as `stage.completed` reported it: the root session\'s route and each subagent\'s own model, a subagent whose model the catalog does not know billed at the root\'s. Sums to `usage`. Empty while the stage runs and for stages without a coding agent; the billing rollup then bills `usage` to `model`. + */ + 'billing_by_model'?: Array; 'agent'?: AgentSessionProjection | null; /** * Lifecycle state of the stage projection. From 0c0e589a78124fbb4ea85d7b6125664e617aadd9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 07:58:34 -0600 Subject: [PATCH 06/11] Bill a failed agent stage what it spent An agent stage that failed billed nothing: the backend returned a bare error and the outcome built from it carried no usage. A terminal failure now becomes the stage's failed outcome from the same fold that bills a completed stage, with the tree's usage, the rows by model, the files it wrote, and its active time; stage.failed carries billing and billing_by_model and the store keeps both. Cancellation and retryable failures still go up as the error. Co-Authored-By: Claude Fable 5.1 --- docs/internal/events.md | 2 + lib/apps/fabro-server/src/server/tests.rs | 102 +++++++------- lib/components/fabro-store/src/run_state.rs | 25 ++-- lib/components/fabro-workflow/src/error.rs | 17 +-- .../fabro-workflow/src/event/convert.rs | 19 +-- .../fabro-workflow/src/event/events.rs | 18 +-- .../fabro-workflow/src/handler/llm/pebble.rs | 68 ++++++++-- lib/components/fabro-workflow/src/lib.rs | 11 +- .../fabro-workflow/src/lifecycle/event.rs | 2 + .../fabro-workflow/tests/it/pebble_agent.rs | 128 ++++++++++++++++++ .../fabro-types/src/run_event/stage.rs | 15 +- .../fabro-types/src/run_projection.rs | 9 +- 12 files changed, 311 insertions(+), 105 deletions(-) diff --git a/docs/internal/events.md b/docs/internal/events.md index b419d9396..add8b9411 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -473,6 +473,8 @@ Emitted when a stage fails (before retry decision). | `failure_class` | string | Failure category | | `failure_signature` | string? | Dedup key for repeated failures | | `will_retry` | boolean | Whether the stage will be retried | +| `billing` | object? | What the stage spent before it failed, in the shape `stage.completed` uses. An agent stage that fails for good after answering model calls bills its whole session tree, as it would have on completion; a retried attempt and a cancelled stage carry none | +| `billing_by_model` | array? | `billing` split by model, as on `stage.completed` | ### `stage.retrying` diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 8f9ef998b..2fd594239 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -7104,14 +7104,15 @@ async fn list_run_stages_projects_retrying_until_completion() { "work", 1, &workflow_event::Event::StageFailed { - node_id: "work".to_string(), - name: "Work".to_string(), - index: 1, - failure: FailureDetail::new("try again", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(10), - billing: None, - actor: None, + node_id: "work".to_string(), + name: "Work".to_string(), + index: 1, + failure: FailureDetail::new("try again", FailureCategory::TransientInfra), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(10), + billing_by_model: Vec::new(), + billing: None, + actor: None, }, ) .await; @@ -7373,14 +7374,15 @@ async fn create_billed_retry_run(state: &Arc, run_id: RunId) { "verify", 1, &workflow_event::Event::StageFailed { - node_id: "verify".to_string(), - name: "Verify".to_string(), - index: 1, - failure: FailureDetail::new("try again", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(1200), - billing: Some(test_billed_usage("gpt-old", 100, 10)), - actor: None, + node_id: "verify".to_string(), + name: "Verify".to_string(), + index: 1, + failure: FailureDetail::new("try again", FailureCategory::TransientInfra), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(1200), + billing_by_model: Vec::new(), + billing: Some(test_billed_usage("gpt-old", 100, 10)), + actor: None, }, ) .await; @@ -8118,14 +8120,15 @@ async fn list_run_stages_shows_retrying_after_failed_event() { "work", 1, &workflow_event::Event::StageFailed { - node_id: "work".to_string(), - name: "Work".to_string(), - index: 0, - failure: FailureDetail::new("flake", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(5), - billing: None, - actor: None, + node_id: "work".to_string(), + name: "Work".to_string(), + index: 0, + failure: FailureDetail::new("flake", FailureCategory::TransientInfra), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(5), + billing_by_model: Vec::new(), + billing: None, + actor: None, }, ) .await; @@ -8200,14 +8203,15 @@ async fn list_run_stages_shows_retrying_when_failed_will_retry() { "work", 1, &workflow_event::Event::StageFailed { - node_id: "work".to_string(), - name: "Work".to_string(), - index: 0, - failure: FailureDetail::new("flake", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(5), - billing: None, - actor: None, + node_id: "work".to_string(), + name: "Work".to_string(), + index: 0, + failure: FailureDetail::new("flake", FailureCategory::TransientInfra), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(5), + billing_by_model: Vec::new(), + billing: None, + actor: None, }, ) .await; @@ -8250,14 +8254,15 @@ async fn run_billing_retried_node_then_succeeded_emits_one_row_with_final_attemp max_attempts: 3, }, workflow_event::Event::StageFailed { - node_id: "work".to_string(), - name: "Work".to_string(), - index: 0, - failure: FailureDetail::new("transient", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(10), - billing: None, - actor: None, + node_id: "work".to_string(), + name: "Work".to_string(), + index: 0, + failure: FailureDetail::new("transient", FailureCategory::TransientInfra), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(10), + billing_by_model: Vec::new(), + billing: None, + actor: None, }, workflow_event::Event::StageRetrying { node_id: "work".to_string(), @@ -15427,14 +15432,15 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() { max_attempts: 1, }, workflow_event::Event::StageFailed { - node_id: "agent".to_string(), - name: "agent".to_string(), - index: 0, - failure: FailureDetail::new("failed", FailureCategory::Deterministic), - will_retry: false, - timing: fabro_types::StageTiming::wall_only(1), - billing: None, - actor: None, + node_id: "agent".to_string(), + name: "agent".to_string(), + index: 0, + failure: FailureDetail::new("failed", FailureCategory::Deterministic), + will_retry: false, + timing: fabro_types::StageTiming::wall_only(1), + billing_by_model: Vec::new(), + billing: None, + actor: None, }, ]; diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index afe270b99..df6d0184b 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -549,6 +549,7 @@ impl RunProjectionReducer for RunProjection { stage.usage.replace_with_billed_usage(billing); stage.model = Some(billing.model().clone()); } + stage.billing_by_model.clone_from(&props.billing_by_model); stage.state = stage_state_from_failure(props.will_retry, failure_category, stage.termination); stage.agent_control = AgentControlState::Running; @@ -3841,14 +3842,15 @@ mod tests { .apply_event(&test_stage_event( 3, EventBody::StageFailed(StageFailedProps { - index: 0, - failure: Some(fabro_types::FailureDetail::new( + index: 0, + failure: Some(fabro_types::FailureDetail::new( "try again", fabro_types::FailureCategory::TransientInfra, )), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(444), - billing: Some(usage.clone()), + will_retry: true, + timing: fabro_types::StageTiming::wall_only(444), + billing_by_model: Vec::new(), + billing: Some(usage.clone()), }), scoped_stage_id.clone(), )) @@ -5467,6 +5469,7 @@ mod tests { failure: Some(FailureDetail::new("boom", FailureCategory::TransientInfra)), will_retry, timing: fabro_types::StageTiming::wall_only(duration_ms), + billing_by_model: Vec::new(), billing: None, } } @@ -5477,6 +5480,7 @@ mod tests { failure: Some(FailureDetail::new("cancelled", FailureCategory::Canceled)), will_retry, timing: fabro_types::StageTiming::wall_only(duration_ms), + billing_by_model: Vec::new(), billing: None, } } @@ -6088,14 +6092,15 @@ mod tests { .apply_event(&test_event( 3, EventBody::StageFailed(StageFailedProps { - index: 0, - failure: Some(FailureDetail::new( + index: 0, + failure: Some(FailureDetail::new( "Script failed with exit code: 100\n\nCancelling due to test failure", FailureCategory::Canceled, )), - will_retry: false, - timing: fabro_types::StageTiming::wall_only(10), - billing: None, + will_retry: false, + timing: fabro_types::StageTiming::wall_only(10), + billing_by_model: Vec::new(), + billing: None, }), Some("build"), )) diff --git a/lib/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index 40f271ad1..a0e85035b 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -2128,14 +2128,15 @@ mod tests { // 3. Outcome → StageFailed event let failure = outcome.failure.clone().unwrap(); let event = Event::StageFailed { - node_id: "code".into(), - name: "code".into(), - index: 0, - failure: failure.clone(), - will_retry: false, - timing: fabro_types::StageTiming::wall_only(0), - billing: None, - actor: None, + node_id: "code".into(), + name: "code".into(), + index: 0, + failure: failure.clone(), + will_retry: false, + timing: fabro_types::StageTiming::wall_only(0), + billing_by_model: Vec::new(), + billing: None, + actor: None, }; // 4. Verify classification survived all the way through diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index fa0bb6d61..d2f733f2c 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -387,6 +387,7 @@ fn event_body_from_event(event: &Event) -> EventBody { will_retry, timing, billing, + billing_by_model, .. } => EventBody::StageFailed(fabro_types::StageFailedProps { index: *index, @@ -394,6 +395,7 @@ fn event_body_from_event(event: &Event) -> EventBody { will_retry: *will_retry, timing: *timing, billing: billing.clone(), + billing_by_model: billing_by_model.clone(), }), Event::StageRetrying { index, @@ -1171,17 +1173,18 @@ mod tests { fn run_event_stage_failure_keeps_failure_detail() { let usage = test_usage("gpt-5.2", 321, 54); let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed { - node_id: "code".to_string(), - name: "Code".to_string(), - index: 1, - failure: FailureDetail::new( + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + failure: FailureDetail::new( "lint failed", crate::outcome::FailureCategory::Deterministic, ), - will_retry: true, - timing: ::fabro_types::StageTiming::wall_only(5000), - billing: Some(usage.clone()), - actor: None, + will_retry: true, + timing: ::fabro_types::StageTiming::wall_only(5000), + billing_by_model: Vec::new(), + billing: Some(usage.clone()), + actor: None, }); assert_eq!(stored.event_name(), "stage.failed"); diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index 57a639a38..68618d764 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -296,15 +296,17 @@ pub enum Event { max_attempts: usize, }, StageFailed { - node_id: String, - name: String, - index: usize, - failure: FailureDetail, - will_retry: bool, - timing: StageTiming, - billing: Option, + node_id: String, + name: String, + index: usize, + failure: FailureDetail, + will_retry: bool, + timing: StageTiming, + billing: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + billing_by_model: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, StageRetrying { node_id: String, diff --git a/lib/components/fabro-workflow/src/handler/llm/pebble.rs b/lib/components/fabro-workflow/src/handler/llm/pebble.rs index fffb259db..6d7686fba 100644 --- a/lib/components/fabro-workflow/src/handler/llm/pebble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/pebble.rs @@ -63,7 +63,7 @@ use crate::context::keys::Fidelity; use crate::error::Error; use crate::event::{Emitter, Event, StageScope}; use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy}; -use crate::outcome::billed_model_usage_from_llm; +use crate::outcome::{Outcome, billed_model_usage_from_llm}; use crate::services::FabroRunToolServices; use crate::steering_hub::SteeringHub; use crate::web_search::{self, SearchSecrets}; @@ -407,6 +407,16 @@ impl LiveAgent { } } +/// The route as billing names it: provider, model, and the speed tier the +/// stage asked for. +fn route_model(route: &LlmRoute) -> ModelRef { + ModelRef::new( + route.target.provider.clone(), + ModelId::new(route.target.model.as_str()), + ) + .with_speed(route.controls.speed) +} + /// A stage's billing from its account: the whole tree under the root's /// route, and the rows that split it by model. struct StageBilling { @@ -856,6 +866,37 @@ impl PebbleBackend { } } + /// The failed outcome of an agent stage that spent before it failed: the + /// failure itself, with the session tree's usage, the files it wrote, and + /// its active time, so the run bills what the stage spent. A billing the + /// catalog cannot price is logged and left off. + fn failed_outcome(&self, error: &Error, live: &LiveAgent, plan: &FallbackPlan) -> Outcome { + let mut outcome = error.to_fail_outcome(); + let account = live.account(); + match stage_billing( + self.catalog.as_ref(), + &route_model(plan.current()), + &account, + ) { + Ok(billing) => { + outcome.usage = Some(billing.total); + outcome.usage_by_model = billing.by_model; + } + Err(billing_error) => { + tracing::debug!( + error = %billing_error, + "failed agent stage could not be billed" + ); + } + } + outcome.files_touched = account.files_touched; + outcome.timing = Some(StageTiming::active_only( + crate::millis_u64(live.inference_duration), + crate::millis_u64(live.tool_duration), + )); + outcome + } + /// Steers that landed between the answer and the hub's close-the-door /// check run as further prompts, so the stage never ends with a steer /// nobody saw. @@ -1291,18 +1332,27 @@ impl CodergenBackend for PebbleBackend { ShutdownReason::Error }; live.discard(reason).await; - return Err(error); + // Cancellation and a retryable failure go up as the error, so + // the engine cancels or retries as before. A terminal failure + // becomes the stage's failed outcome, carrying what the + // session tree spent and wrote before it failed. + if matches!(error, Error::Cancelled) || error.is_retryable() { + return Err(error); + } + return Ok(CodergenResult::Full(Box::new(self.failed_outcome( + &error, + &live, + &fallback_plan, + )))); } }; - let route = fallback_plan.current().clone(); - let root_model = ModelRef::new( - route.target.provider.clone(), - ModelId::new(route.target.model.as_str()), - ) - .with_speed(route.controls.speed); let account = live.account(); - let billing = stage_billing(self.catalog.as_ref(), &root_model, &account)?; + let billing = stage_billing( + self.catalog.as_ref(), + &route_model(fallback_plan.current()), + &account, + )?; live.release_lease(); match reuse_key { diff --git a/lib/components/fabro-workflow/src/lib.rs b/lib/components/fabro-workflow/src/lib.rs index 4b66dea59..94f822240 100644 --- a/lib/components/fabro-workflow/src/lib.rs +++ b/lib/components/fabro-workflow/src/lib.rs @@ -159,11 +159,12 @@ mod duration_tests { tool_call_id: None, actor: None, body: EventBody::StageFailed(StageFailedProps { - index: 0, - failure: None, - will_retry: true, - timing: StageTiming::wall_only(wall_time_ms), - billing: None, + index: 0, + failure: None, + will_retry: true, + timing: StageTiming::wall_only(wall_time_ms), + billing_by_model: Vec::new(), + billing: None, }), }; EventEnvelope { seq, event } diff --git a/lib/components/fabro-workflow/src/lifecycle/event.rs b/lib/components/fabro-workflow/src/lifecycle/event.rs index 278479006..cf0bda9c3 100644 --- a/lib/components/fabro-workflow/src/lifecycle/event.rs +++ b/lib/components/fabro-workflow/src/lifecycle/event.rs @@ -291,6 +291,7 @@ impl RunLifecycle for EventLifecycle { will_retry: true, timing, billing: outcome.usage.clone(), + billing_by_model: outcome.usage_by_model.clone(), actor, }, &scope, @@ -343,6 +344,7 @@ impl RunLifecycle for EventLifecycle { will_retry: false, timing, billing: outcome.usage.clone(), + billing_by_model: outcome.usage_by_model.clone(), actor, }, &scope, diff --git a/lib/components/fabro-workflow/tests/it/pebble_agent.rs b/lib/components/fabro-workflow/tests/it/pebble_agent.rs index b749ad120..7eae5bd9b 100644 --- a/lib/components/fabro-workflow/tests/it/pebble_agent.rs +++ b/lib/components/fabro-workflow/tests/it/pebble_agent.rs @@ -742,6 +742,134 @@ async fn the_stage_timeout_fails_a_slow_agent() { ); } +/// A stage whose agent fails for good after answering model calls bills +/// those calls: the failed outcome carries the session tree's usage from the +/// same fold the completed outcome would have, and the files it wrote. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_stage_that_fails_after_spending_bills_what_it_spent() { + let stage = Stage::new().await; + let first = stage.file("first.txt"); + let second = stage.file("second.txt"); + // Two answered calls, each writing a file; the third is refused for good. + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes(TOOL_RESULT_MARKER); + sse_headers( + then, + sse_tool_call( + "call-1", + "write_file", + &serde_json::json!({ "file_path": first, "content": "one" }), + ), + ); + }) + .await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("call-1") + .body_excludes("call-2"); + sse_headers( + then, + sse_tool_call( + "call-2", + "write_file", + &serde_json::json!({ "file_path": second, "content": "two" }), + ), + ); + }) + .await; + stage + .server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH).body_includes("call-2"); + then.status(400) + .header("content-type", "application/json") + .body(r#"{"error":{"message":"the request was rejected","type":"invalid_request_error"}}"#); + }) + .await; + + let mut graph = agent_graph("Spent", "Write two files"); + let work = graph.nodes.get_mut("work").unwrap(); + work.attrs + .insert("max_retries".to_string(), AttrValue::Integer(0)); + graph.edges.retain(|edge| edge.from != "work"); + let mut fail_edge = Edge::new("work", "exit"); + fail_edge.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=failed".to_string()), + ); + graph.edges.push(fail_edge); + + let backend = stage.backend("openai"); + let (_, state) = stage + .run(backend, &graph, CancellationToken::new()) + .await + .expect("the fail edge carries the run to exit"); + + let work = work_stage(&state); + assert_eq!( + work.completion + .as_ref() + .expect("the stage finished") + .outcome, + StageOutcome::Failed { + retry_requested: false, + } + ); + assert_eq!( + work.usage.input_tokens, + 2 * INPUT_TOKENS_PER_CALL, + "the two answered calls are billed" + ); + assert_eq!(work.usage.output_tokens, 2 * OUTPUT_TOKENS_PER_CALL); + assert_eq!( + work.usage.total_usd_micros, + Some(2 * (INPUT_TOKENS_PER_CALL + 2 * OUTPUT_TOKENS_PER_CALL)), + "priced from the catalog like a completed stage" + ); + assert_eq!( + work.billing_by_model.len(), + 1, + "{:?}", + work.billing_by_model + ); + assert_eq!( + work.billing_by_model[0].tokens.input, + u64::try_from(2 * INPUT_TOKENS_PER_CALL).unwrap() + ); + assert!( + tokio::fs::try_exists(&second).await.unwrap(), + "the second write landed before the failure" + ); + + let failed = stage + .events + .lock() + .unwrap() + .iter() + .find(|event| { + event.event_name() == "stage.failed" && event.node_id.as_deref() == Some("work") + }) + .cloned() + .expect("the stage failure is emitted"); + let EventBody::StageFailed(props) = &failed.body else { + panic!("stage.failed carries its props: {failed:?}"); + }; + assert!(!props.will_retry); + let billing = props.billing.as_ref().expect("the failed stage is billed"); + assert_eq!( + billing.tokens.input, + u64::try_from(2 * INPUT_TOKENS_PER_CALL).unwrap() + ); + assert_eq!(props.billing_by_model, vec![billing.clone()]); +} + // --- Questions, subagents, MCP // -------------------------------------------------- diff --git a/lib/foundation/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs index 524fff69f..af97b9299 100644 --- a/lib/foundation/fabro-types/src/run_event/stage.rs +++ b/lib/foundation/fabro-types/src/run_event/stage.rs @@ -72,15 +72,20 @@ pub struct StageCompletedProps { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StageFailedProps { - pub index: usize, + pub index: usize, #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, - pub will_retry: bool, + pub failure: Option, + pub will_retry: bool, /// Per-attempt timing breakdown for this stage visit. #[serde(default)] - pub timing: StageTiming, + pub timing: StageTiming, + /// The stage's billing: for an agent stage that failed after spending, + /// the whole session tree's tokens under the root's route. #[serde(default, skip_serializing_if = "Option::is_none")] - pub billing: Option, + pub billing: Option, + /// `billing` split by model, as on `stage.completed`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub billing_by_model: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index 5e0540d78..ed7ccb9ad 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -297,10 +297,11 @@ pub struct StageProjection { pub usage: BilledTokenCounts, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - /// The completed stage's billing split by model, as `stage.completed` - /// reported it: the root session's route and each subagent's own model. - /// Sums to `usage`. Empty while the stage runs and for stages without a - /// coding agent; the billing rollup then bills `usage` to `model`. + /// The finished stage's billing split by model, as `stage.completed` or + /// `stage.failed` reported it: the root session's route and each + /// subagent's own model. Sums to `usage`. Empty while the stage runs and + /// for stages without a coding agent; the billing rollup then bills + /// `usage` to `model`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub billing_by_model: Vec, /// Todo/task list owned by the stage's root agent session. From 318fdf720683dd6921ef4cd875e36ffd1323d945 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 08:13:10 -0600 Subject: [PATCH 07/11] Read the stage view from the agent's fold StageProjection loses todos, subagents, skills, mcp_servers, and context_window, the types behind them, their fold arms and helpers, and their OpenAPI schemas: every one of those facts is pebble's fold in StageProjection.agent now. The context-window endpoint reads the fold's snapshot, whose event_seq is the agent's own sequence. The parity module keeps its assertions on the surviving own fields, usage and model, and checks that what the stage view reads from agent is the whole-session fold's for the stage's events. The TypeScript client is regenerated and its stale models removed. Co-Authored-By: Claude Fable 5.1 --- docs/internal/events.md | 6 +- docs/public/api-reference/fabro-api.yaml | 227 +- .../fabro-server/src/server/handler/runs.rs | 12 +- lib/components/fabro-store/src/run_state.rs | 1825 +---------------- lib/foundation/fabro-api/build.rs | 15 - lib/foundation/fabro-api/src/lib.rs | 35 +- .../tests/stage_projection_round_trip.rs | 226 +- lib/foundation/fabro-types/src/lib.rs | 5 +- .../fabro-types/src/run_projection.rs | 104 +- .../src/.openapi-generator/FILES | 14 - .../src/models/activated-skill.ts | 26 - .../src/models/agent-mcp-tool-summary.ts | 23 - .../fabro-api-client/src/models/index.ts | 14 - .../src/models/mcp-server-projection.ts | 31 - .../models/mcp-server-status-disconnected.ts | 32 - .../src/models/mcp-server-status-failed.ts | 26 - .../src/models/mcp-server-status-ready.ts | 29 - .../src/models/mcp-server-status.ts | 33 - .../src/models/skills-projection.ts | 29 - .../src/models/stage-context-window.ts | 3 + .../src/models/stage-projection.ts | 29 - .../src/models/sub-agent-projection.ts | 28 - .../src/models/sub-agent-status-closed.ts | 25 - .../src/models/sub-agent-status-completed.ts | 27 - .../src/models/sub-agent-status-failed.ts | 26 - .../src/models/sub-agent-status-running.ts | 25 - .../src/models/sub-agent-status.ts | 33 - 27 files changed, 149 insertions(+), 2759 deletions(-) delete mode 100644 lib/packages/fabro-api-client/src/models/activated-skill.ts delete mode 100644 lib/packages/fabro-api-client/src/models/agent-mcp-tool-summary.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-projection.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-status-disconnected.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-status-failed.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-status-ready.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-status.ts delete mode 100644 lib/packages/fabro-api-client/src/models/skills-projection.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-projection.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-status-closed.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-status-completed.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-status-failed.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-status-running.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sub-agent-status.ts diff --git a/docs/internal/events.md b/docs/internal/events.md index add8b9411..833c66fc0 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -1660,9 +1660,9 @@ Pebble's `RouteFailover`, `McpServerReady`, `McpServerFailed`, and `McpServerDisconnected` events, stored verbatim with pebble's envelope in `properties` like every other pebble event. Fabro also mirrors each onto its own `agent.failover`, `agent.mcp.ready`, `agent.mcp.failed`, and -`agent.mcp.disconnected`, which the store folds into `StageProjection`'s -`mcp_servers`; the pebble events feed `StageProjection.agent`. The mirrors -go once every reader is on `agent`. +`agent.mcp.disconnected`. The stage view reads MCP state from +`StageProjection.agent`, which the pebble events feed; the mirrors change +nothing on the stage any more and go next. ### `agent.route.failover.stopped` diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 3757ae924..12616a832 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -11029,8 +11029,11 @@ components: example: "2026-05-23T12:34:56Z" event_seq: type: ["integer", "null"] - format: uint32 - minimum: 1 + format: uint64 + minimum: 0 + description: >- + The coding agent's own event sequence for the snapshot, when it + carried one; not the run event sequence. example: 42 breakdown: type: array @@ -11170,23 +11173,6 @@ components: oneOf: - $ref: "#/components/schemas/BillingModelRef" - type: "null" - todos: - oneOf: - - $ref: "#/components/schemas/TodoListProjection" - - type: "null" - description: | - Todo / task list owned by this stage's root agent session. OpenAI - child sessions have separate per-session plans that do not appear - here. Anthropic task lists are root-scoped and shared with child - sessions, so child mutations of that shared list do appear here. - subagents: - type: array - description: Subagents spawned by this stage, in replay/insertion order. - items: - $ref: "#/components/schemas/SubAgentProjection" - skills: - $ref: "#/components/schemas/SkillsProjection" - description: Agent skills discovered and activated during this stage. permission_level: oneOf: - $ref: "#/components/schemas/PermissionLevel" @@ -11199,16 +11185,6 @@ components: Tool parameter schemas are intentionally omitted from this projection. items: $ref: "#/components/schemas/ToolSummary" - mcp_servers: - type: array - description: MCP servers observed by this stage. - items: - $ref: "#/components/schemas/McpServerProjection" - context_window: - oneOf: - - $ref: "#/components/schemas/ContextWindowSnapshot" - - type: "null" - description: Latest content-free context-window snapshot for this agent stage. inference: oneOf: - $ref: "#/components/schemas/StageInferenceProjection" @@ -11340,102 +11316,6 @@ components: - text - tool_call - SubAgentProjection: - description: Current projected state for one subagent spawned by an agent stage. - type: object - required: - - agent_id - - depth - - task - - status - properties: - agent_id: - type: string - depth: - type: integer - minimum: 0 - task: - type: string - status: - $ref: "#/components/schemas/SubAgentStatus" - - SubAgentStatus: - description: Projected lifecycle status for a subagent. - oneOf: - - $ref: "#/components/schemas/SubAgentStatusRunning" - - $ref: "#/components/schemas/SubAgentStatusCompleted" - - $ref: "#/components/schemas/SubAgentStatusFailed" - - $ref: "#/components/schemas/SubAgentStatusClosed" - discriminator: - propertyName: kind - mapping: - running: "#/components/schemas/SubAgentStatusRunning" - completed: "#/components/schemas/SubAgentStatusCompleted" - failed: "#/components/schemas/SubAgentStatusFailed" - closed: "#/components/schemas/SubAgentStatusClosed" - - SubAgentStatusRunning: - type: object - required: - - kind - properties: - kind: - type: string - enum: [running] - - SubAgentStatusCompleted: - type: object - required: - - kind - - success - - turns_used - properties: - kind: - type: string - enum: [completed] - success: - type: boolean - turns_used: - type: integer - minimum: 0 - - SubAgentStatusFailed: - type: object - required: - - kind - - error - properties: - kind: - type: string - enum: [failed] - error: - description: Provider/tool error payload captured by the subagent event. - - SubAgentStatusClosed: - type: object - required: - - kind - properties: - kind: - type: string - enum: [closed] - - SkillsProjection: - description: Agent skills discovered and activated during a stage. - type: object - required: - - available - - activated - properties: - available: - type: array - items: - $ref: "#/components/schemas/SkillSummary" - activated: - type: array - items: - $ref: "#/components/schemas/ActivatedSkill" - SkillSummary: description: Summary of an available agent skill. type: object @@ -11448,18 +11328,6 @@ components: description: type: string - ActivatedSkill: - description: One observed agent skill activation. - type: object - required: - - name - - source - properties: - name: - type: string - source: - $ref: "#/components/schemas/SkillActivationSource" - SkillActivationSource: description: Source that activated an agent skill. type: string @@ -11556,91 +11424,6 @@ components: type: string enum: [read, write, shell, subagent, other] - McpServerProjection: - description: Projected state for one MCP server observed by an agent stage. - type: object - required: - - server_name - - tool_count - - status - - invoked - properties: - server_name: - type: string - tool_count: - type: integer - minimum: 0 - status: - $ref: "#/components/schemas/McpServerStatus" - invoked: - type: boolean - description: True once the agent has invoked at least one tool from this server during the stage. - - McpServerStatus: - description: Projected MCP server readiness status. - oneOf: - - $ref: "#/components/schemas/McpServerStatusReady" - - $ref: "#/components/schemas/McpServerStatusFailed" - - $ref: "#/components/schemas/McpServerStatusDisconnected" - discriminator: - propertyName: kind - mapping: - ready: "#/components/schemas/McpServerStatusReady" - failed: "#/components/schemas/McpServerStatusFailed" - disconnected: "#/components/schemas/McpServerStatusDisconnected" - - McpServerStatusReady: - type: object - required: - - kind - - tools - properties: - kind: - type: string - enum: [ready] - tools: - type: array - items: - $ref: "#/components/schemas/AgentMcpToolSummary" - - McpServerStatusFailed: - type: object - required: - - kind - - error - properties: - kind: - type: string - enum: [failed] - error: - type: string - - McpServerStatusDisconnected: - description: The server was ready and then its connection closed during the stage; its tools fail until the session ends. - type: object - required: - - kind - - error - properties: - kind: - type: string - enum: [disconnected] - error: - type: string - description: What closed the connection, as the client observed it. - - AgentMcpToolSummary: - description: Summary of one tool exposed by an MCP server. - type: object - required: - - name - - original_name - properties: - name: - type: string - original_name: - type: string - AgentSessionProjection: description: >- The coding agent's fold of one stage's event stream: token counts and diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index b2de8d3c6..4020d29eb 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -1772,7 +1772,11 @@ async fn get_run_stage_context_window( .into_response(); } - let Some(snapshot) = stage.context_window.as_ref() else { + let Some(snapshot) = stage + .agent + .as_ref() + .and_then(|agent| agent.context_window.as_ref()) + else { return Json(StageContextWindow::unavailable( stage_id, StageContextWindowUnavailableReason::NotObserved, @@ -1789,7 +1793,11 @@ async fn get_run_stage_context_window( } fn is_agent_context_window_stage(stage: &StageProjection) -> bool { - if stage.context_window.is_some() { + if stage + .agent + .as_ref() + .is_some_and(|agent| agent.context_window.is_some()) + { return true; } if stage.handler == Some(StageHandler::Agent) { diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index df6d0184b..e9cbf1390 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -9,18 +9,16 @@ use fabro_types::run_event::{ }; use fabro_types::settings::run::RunEnvironmentSettings; use fabro_types::{ - ActivatedSkill, AgentControlState, AskFabro, BilledModelUsage, BilledTokenCounts, Checkpoint, - CheckpointRecord, CommandTermination, Conclusion, EventBody, FailureCategory, FailureSignature, - InterviewQuestionRecord, McpServerProjection, McpServerStatus, ModelRef, Outcome, - PendingInterviewRecord, PendingReason, PullRequestCreation, PullRequestCreationStatus, - PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, - RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, - RunProjection, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxPlan, - RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProviderKind, - StageCompletion, StageHandler, StageId, StageInferenceProjection, StageModelUsage, - StageOutcome, StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, - TodoCreatedProps, TodoDeletedProps, TodoListKind, TodoListProjection, TodoProjection, - TodoUpdatedProps, WorkflowRef, billing_rollup, first_event_seq, timing, + AgentControlState, AskFabro, BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, + CommandTermination, Conclusion, EventBody, FailureCategory, FailureSignature, + InterviewQuestionRecord, ModelRef, Outcome, PendingInterviewRecord, PendingReason, + PullRequestCreation, PullRequestCreationStatus, PullRequestLink, RepositoryRef, Run, + RunApproval, RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, + RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxFailure, + RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, RunSize, RunSpec, RunStatus, + RunTimestamps, SandboxProviderKind, StageCompletion, StageHandler, StageId, + StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, + StartRecord, WorkflowRef, billing_rollup, first_event_seq, timing, }; use fabro_util::error::render_compact_with_causes; use lithos_llm::catalog::{ModelId, ProviderId}; @@ -707,41 +705,6 @@ impl RunProjectionReducer for RunProjection { stage.state = StageState::from(props.status); stage.agent_control = AgentControlState::Running; } - EventBody::AgentMcpReady(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - upsert_mcp_server(stage, McpServerProjection { - server_name: props.server_name.clone(), - tool_count: props.tool_count, - status: McpServerStatus::Ready { - tools: props.tools.clone(), - }, - invoked: false, - }); - } - EventBody::AgentMcpFailed(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - upsert_mcp_server(stage, McpServerProjection { - server_name: props.server_name.clone(), - tool_count: 0, - status: McpServerStatus::Failed { - error: props.error.clone(), - }, - invoked: false, - }); - } - EventBody::AgentMcpDisconnected(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - mark_mcp_server_disconnected(stage, &props.server_name, &props.error); - } _ => {} } @@ -779,22 +742,13 @@ fn apply_agent_event( reason = "pebble's event vocabulary is non-exhaustive and only some events project" )] match props.coding_event() { - CodingEvent::AssistantMessage { - model, - context_window, - .. - } => { + CodingEvent::AssistantMessage { model, .. } => { let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { return; }; if let Some(model) = stage_model_ref(stage, model) { stage.model = Some(model); } - if let Some(context_window) = context_window { - let mut context_window = context_window.clone(); - context_window.event_seq = Some(u64::from(seq)); - stage.context_window = Some(context_window); - } close_inference_bracket(state, stored, visit, seq, ts); } CodingEvent::LlmRequestStarted { requested_model } => { @@ -838,105 +792,6 @@ fn apply_agent_event( }; stage.agent_control = AgentControlState::Running; } - CodingEvent::TodoCreated(todo) => { - if !should_project_root_agent_todo_event(stored, todo.list_kind) { - return; - } - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - apply_todo_created(stage, todo); - } - CodingEvent::TodoUpdated(todo) => { - if !should_project_root_agent_todo_event(stored, todo.list_kind) { - return; - } - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - apply_todo_updated(stage, todo); - } - CodingEvent::TodoDeleted(todo) => { - if !should_project_root_agent_todo_event(stored, todo.list_kind) { - return; - } - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - apply_todo_deleted(stage, todo); - } - CodingEvent::SubAgentSpawned { - agent_id, - depth, - task, - .. - } => { - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - stage.subagents.push(SubAgentProjection { - agent_id: agent_id.clone(), - depth: *depth, - task: task.clone(), - status: SubAgentStatus::Running, - }); - } - // A reused subagent stays one projected row: the spawn task and - // generation 1 identify it, and every later generation only moves - // its status. The per-turn task and generation stay in the event - // log for consumers that need each turn. - CodingEvent::SubAgentTurnStarted { agent_id, .. } => { - set_subagent_status(state, stored, visit, seq, agent_id, SubAgentStatus::Running); - } - CodingEvent::SubAgentCompleted { - agent_id, - success, - turns_used, - .. - } => { - set_subagent_status( - state, - stored, - visit, - seq, - agent_id, - SubAgentStatus::Completed { - success: *success, - turns_used: *turns_used, - }, - ); - } - CodingEvent::SubAgentFailed { - agent_id, error, .. - } => { - let error = serde_json::to_value(error).unwrap_or_default(); - set_subagent_status( - state, - stored, - visit, - seq, - agent_id, - SubAgentStatus::Failed { error }, - ); - } - CodingEvent::SubAgentClosed { agent_id, .. } => { - set_subagent_status(state, stored, visit, seq, agent_id, SubAgentStatus::Closed); - } - CodingEvent::SkillsDiscovered { skills, .. } => { - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - stage.skills.available.clone_from(skills); - } - CodingEvent::SkillActivated { skill_name, source } => { - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - stage.skills.activated.push(ActivatedSkill { - name: skill_name.clone(), - source: *source, - }); - } CodingEvent::ToolCallStarted { tool_name, tool_call_id, @@ -957,15 +812,6 @@ fn apply_agent_event( { tool.invoked = true; } - if let Some(server) = mcp_server_from_tool_name(tool_name) { - if let Some(projection) = stage - .mcp_servers - .iter_mut() - .find(|p| mcp_name_eq(&p.server_name, server)) - { - projection.invoked = true; - } - } // A subagent's tools run inside the root session's tool call, // so the root batch already covers them. Timing them again // would double-count that span. @@ -1022,172 +868,6 @@ fn stage_provider(stage: &StageProjection) -> Option { .or_else(|| stage.model.as_ref().map(|model| model.provider.clone())) } -/// Decide whether a TODO event should mutate -/// `StageProjection.root_agent_todos`. -/// -/// OpenAI plan lists are scoped per agent session (`openai_plan:`), -/// so a child/subagent session emits its own list events on the same stage. -/// The root-agent projection excludes those child plans, while the underlying -/// events remain in the run event log. Kimi todo lists -/// (`kimi_todos:`) are scoped the same way. Anthropic task lists -/// are root-scoped (`anthropic_tasks:`) and intentionally -/// shared with subagents, so they always project. -fn should_project_root_agent_todo_event(stored: &RunEvent, list_kind: TodoListKind) -> bool { - // `TodoListKind` is non-exhaustive: a list kind this build does not know - // is treated as session-scoped, the conservative reading. - matches!(list_kind, TodoListKind::AnthropicTasks) || stored.parent_session_id.is_none() -} - -fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) { - if stage - .root_agent_todos - .as_ref() - .is_none_or(|list| list.list_id != props.list_id || list.kind != props.list_kind) - { - stage.root_agent_todos = Some(TodoListProjection::new( - props.list_kind, - props.list_id.clone(), - )); - } - let list = stage - .root_agent_todos - .as_mut() - .expect("todo list was just inserted"); - list.upsert(TodoProjection { - id: props.todo_id.clone(), - status: props.status, - order: props.order, - subject: props.subject.clone(), - description: props.description.clone(), - active_form: props.active_form.clone(), - owner: props.owner.clone(), - blocks: props.blocks.clone(), - blocked_by: props.blocked_by.clone(), - metadata: props.metadata.clone(), - }); -} - -fn apply_todo_updated(stage: &mut StageProjection, props: &TodoUpdatedProps) { - if let Some(list) = stage - .root_agent_todos - .as_mut() - .filter(|list| list.list_id == props.list_id) - { - list.apply_patch(&props.todo_id, props); - } -} - -fn apply_todo_deleted(stage: &mut StageProjection, props: &TodoDeletedProps) { - let Some(list) = stage - .root_agent_todos - .as_mut() - .filter(|list| list.list_id == props.list_id) - else { - return; - }; - list.remove(&props.todo_id); - if list.items.is_empty() { - stage.root_agent_todos = None; - } -} - -/// Move an already-projected subagent to a new lifecycle status. Every -/// subagent event after the spawn updates the same row, so reuse shows one -/// agent returning to running rather than a second agent appearing. -fn set_subagent_status( - state: &mut RunProjection, - stored: &RunEvent, - visit: u32, - seq: u32, - agent_id: &str, - status: SubAgentStatus, -) { - let Some(stage) = stage_at_stored_or_visit(state, stored, visit, seq) else { - return; - }; - if let Some(subagent) = subagent_mut(stage, agent_id) { - subagent.status = status; - } -} - -fn subagent_mut<'a>( - stage: &'a mut StageProjection, - agent_id: &str, -) -> Option<&'a mut SubAgentProjection> { - stage - .subagents - .iter_mut() - .find(|subagent| subagent.agent_id == agent_id) -} - -fn upsert_mcp_server(stage: &mut StageProjection, mut server: McpServerProjection) { - if let Some(existing) = stage - .mcp_servers - .iter_mut() - .find(|existing| existing.server_name == server.server_name) - { - // Status/tool-count may flip (Ready → Failed across reconnects); keep - // the sticky `invoked` flag so a server still reads as "used" after - // its ready/failed state changes. - server.invoked = server.invoked || existing.invoked; - *existing = server; - } else { - stage.mcp_servers.push(server); - } -} - -/// Move a server the stage saw come up to `Disconnected`. Its tool count and -/// sticky `invoked` flag stay: the tools existed and may have been used, they -/// only fail from here on. A disconnect for a server the stage never saw come -/// up is still recorded, without tools. -fn mark_mcp_server_disconnected(stage: &mut StageProjection, server_name: &str, error: &str) { - let status = McpServerStatus::Disconnected { - error: error.to_string(), - }; - if let Some(existing) = stage - .mcp_servers - .iter_mut() - .find(|existing| existing.server_name == server_name) - { - existing.status = status; - } else { - stage.mcp_servers.push(McpServerProjection { - server_name: server_name.to_string(), - tool_count: 0, - status, - invoked: false, - }); - } -} - -/// Extract the `` segment from an `mcp____` qualified -/// tool name. Returns `None` for non-MCP tools or malformed names. -fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> { - let rest = tool_name.strip_prefix("mcp__")?; - let idx = rest.find("__")?; - let server = &rest[..idx]; - (!server.is_empty()).then_some(server) -} - -/// Match an MCP server projection name against a server segment parsed from a -/// qualified tool name. Tool names use `fabro_mcp::qualified_tool_name`, which -/// sanitizes non-alphanumeric characters in the server name; normalize the -/// stored projection name the same way before comparing. -fn mcp_name_eq(projection_name: &str, parsed_from_tool: &str) -> bool { - fn normalize(s: &str) -> String { - s.chars() - .map(|c| { - if c.is_alphanumeric() || c == '_' { - c - } else { - '_' - } - }) - .collect() - } - normalize(projection_name) == parsed_from_tool -} - fn projection_from_created(event: &EventEnvelope) -> Result { let stored = &event.event; let EventBody::RunCreated(props) = &stored.body else { @@ -1849,29 +1529,25 @@ mod tests { AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps, AgentAcpTimedOutProps, AgentEventProps, AgentMcpDisconnectedProps, AgentMcpFailedProps, AgentMcpReadyProps, AgentMcpToolSummary, AgentSessionActivatedProps, - AgentSessionDeactivatedProps, AgentToolsAvailableProps, CheckpointCompletedProps, - InterviewCompletedProps, InterviewOption, InterviewStartedProps, - ParallelBranchCompletedProps, ParallelBranchStartedProps, RunCompletedProps, - RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps, - StageRetryingProps, StageStartedProps, + AgentSessionDeactivatedProps, CheckpointCompletedProps, InterviewCompletedProps, + InterviewOption, InterviewStartedProps, ParallelBranchCompletedProps, + ParallelBranchStartedProps, RunCompletedProps, RunControlEffectProps, StageCompletedProps, + StageFailedProps, StagePromptProps, StageRetryingProps, StageStartedProps, }; use fabro_types::settings::run::DockerfileSource; use fabro_types::{ AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage, BilledTokenCounts, BlobHash, BlockedReason, Checkpoint, CheckpointRecord, - CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, - McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel, - PullRequestCreationStatus, PullRequestLink, QuestionType, RunApprovalState, - RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, - SandboxProviderKind, StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, - SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support, + CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, Node, + Outcome, ParallelBranchId, PendingReason, PullRequestCreationStatus, PullRequestLink, + QuestionType, RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, + RunSize, RunSpec, RunStatus, SandboxProviderKind, StageHandler, StageModelUsage, + StageOutcome, StageState, StageTiming, SuccessReason, WorkflowSettings, first_event_seq, + fixtures, test_support, }; use lithos_llm::types::{ReasoningEffort, Speed, TokenCounts}; use pebble_coding_agent::events::{ - CodingAgentEvent, CodingEvent, CompactionReason, ContextWindowBreakdownItem, - ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, - ContextWindowStaleness, ContextWindowWarning, ErrorData, ErrorKind, SkillActivationSource, - SkillSummary, TokenUsage, ToolCategory, ToolSource, ToolSummary, + CodingAgentEvent, CodingEvent, CompactionReason, ErrorData, ErrorKind, TokenUsage, }; use pebble_coding_agent::tools::ToolOutputMetadata; use serde_json::json; @@ -6694,1283 +6370,6 @@ mod tests { assert_eq!(stage.timing.map(|t| t.wall_time_ms), None); } - mod todo_reducer { - use fabro_types::{ - TodoCreatedProps, TodoDeletedProps, TodoListKind, TodoListProjection, TodoStatus, - TodoUpdatedProps, - }; - - use super::*; - - fn stage_id() -> StageId { - StageId::new("code", 1) - } - - fn root_agent_todos<'a>( - state: &'a RunProjection, - stage_id: &StageId, - ) -> &'a TodoListProjection { - state - .stage(stage_id) - .and_then(|stage| stage.root_agent_todos.as_ref()) - .expect("root agent todos present") - } - - fn child_stage_event(seq: u32, body: EventBody, stage_id: StageId) -> EventEnvelope { - let mut event = test_stage_event(seq, body, stage_id); - event.event.session_id = Some(format!("child-session-{seq}")); - event.event.parent_session_id = Some("root-session".to_string()); - event - } - - fn created( - list: &str, - list_kind: TodoListKind, - id: &str, - order: u32, - subject: &str, - ) -> EventBody { - agent_body(CodingEvent::TodoCreated(TodoCreatedProps { - list_id: list.to_string(), - list_kind, - todo_id: id.to_string(), - status: TodoStatus::Pending, - order, - subject: subject.to_string(), - description: String::new(), - active_form: None, - owner: None, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: BTreeMap::new(), - })) - } - - fn updated_status( - list: &str, - list_kind: TodoListKind, - id: &str, - status: TodoStatus, - ) -> EventBody { - agent_body(CodingEvent::TodoUpdated(TodoUpdatedProps { - list_id: list.to_string(), - list_kind, - todo_id: id.to_string(), - status: Some(status), - order: None, - subject: None, - description: None, - active_form: None, - owner: None, - add_blocks: None, - add_blocked_by: None, - metadata_patch: BTreeMap::new(), - })) - } - - fn deleted(list: &str, list_kind: TodoListKind, id: &str) -> EventBody { - agent_body(CodingEvent::TodoDeleted(TodoDeletedProps { - list_id: list.to_string(), - list_kind, - todo_id: id.to_string(), - })) - } - - #[test] - fn replay_reconstructs_current_list() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let list = "openai_plan:ses_a"; - state - .apply_event(&test_stage_event( - 1, - created(list, TodoListKind::OpenAiPlan, "a", 0, "first"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - created(list, TodoListKind::OpenAiPlan, "b", 1, "second"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - updated_status(list, TodoListKind::OpenAiPlan, "a", TodoStatus::InProgress), - stage_id.clone(), - )) - .unwrap(); - - let projection = root_agent_todos(&state, &stage_id); - assert_eq!(projection.list_id, list); - assert_eq!(projection.items.len(), 2); - assert_eq!(projection.items[0].id, "a"); - assert_eq!(projection.items[0].status, TodoStatus::InProgress); - assert_eq!(projection.items[1].id, "b"); - } - - #[test] - fn deleted_todos_are_absent() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let list = "openai_plan:ses_a"; - state - .apply_event(&test_stage_event( - 1, - created(list, TodoListKind::OpenAiPlan, "a", 0, "first"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - created(list, TodoListKind::OpenAiPlan, "b", 1, "second"), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - deleted(list, TodoListKind::OpenAiPlan, "a"), - stage_id.clone(), - )) - .unwrap(); - - let projection = root_agent_todos(&state, &stage_id); - assert_eq!(projection.items.len(), 1); - assert_eq!(projection.items[0].id, "b"); - } - - #[test] - fn stage_todo_lists_stay_isolated() { - let mut state = initialized_projection(); - let plan_one = StageId::new("plan_one", 1); - let plan_two = StageId::new("plan_two", 1); - let claude = StageId::new("claude", 1); - state - .apply_event(&test_stage_event( - 1, - created("openai_plan:s1", TodoListKind::OpenAiPlan, "a", 0, "p1"), - plan_one.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - created("openai_plan:s2", TodoListKind::OpenAiPlan, "a", 0, "p2"), - plan_two.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - created( - "anthropic_tasks:s_root", - TodoListKind::AnthropicTasks, - "1", - 0, - "claude task", - ), - claude.clone(), - )) - .unwrap(); - - assert_eq!(root_agent_todos(&state, &plan_one).items[0].subject, "p1"); - assert_eq!(root_agent_todos(&state, &plan_two).items[0].subject, "p2"); - assert_eq!( - root_agent_todos(&state, &claude).items[0].subject, - "claude task" - ); - } - - #[test] - fn root_openai_plan_remains_projected_after_child_plan_events() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let root_list = "openai_plan:root_session"; - let child_list = "openai_plan:child_session"; - state - .apply_event(&test_stage_event( - 1, - created( - root_list, - TodoListKind::OpenAiPlan, - "root-a", - 0, - "root first", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - created( - root_list, - TodoListKind::OpenAiPlan, - "root-b", - 1, - "root second", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 3, - created( - child_list, - TodoListKind::OpenAiPlan, - "child-a", - 0, - "child first", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 4, - created( - child_list, - TodoListKind::OpenAiPlan, - "child-b", - 1, - "child second", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 5, - updated_status( - root_list, - TodoListKind::OpenAiPlan, - "root-a", - TodoStatus::Completed, - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 6, - updated_status( - root_list, - TodoListKind::OpenAiPlan, - "root-b", - TodoStatus::Completed, - ), - stage_id.clone(), - )) - .unwrap(); - - let projection = root_agent_todos(&state, &stage_id); - assert_eq!(projection.list_id, root_list); - assert_eq!(projection.kind, TodoListKind::OpenAiPlan); - assert_eq!(projection.items.len(), 2); - assert_eq!(projection.items[0].id, "root-a"); - assert_eq!(projection.items[0].status, TodoStatus::Completed); - assert_eq!(projection.items[1].id, "root-b"); - assert_eq!(projection.items[1].status, TodoStatus::Completed); - } - - #[test] - fn child_session_whole_lists_do_not_project_when_root_has_no_list() { - for (kind, child_list) in [ - (TodoListKind::OpenAiPlan, "openai_plan:child_session"), - (TodoListKind::KimiTodos, "kimi_todos:child_session"), - ] { - let mut state = initialized_projection(); - let stage_id = stage_id(); - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 2, - created(child_list, kind, "c-a", 0, "child work"), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).expect("stage projection present"); - assert!( - stage.root_agent_todos.is_none(), - "a child session's {kind} list must not become the stage's root list" - ); - } - } - - #[test] - fn root_openai_plan_projects_after_earlier_child_plan_event() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let root_list = "openai_plan:root_session"; - state - .apply_event(&test_stage_event( - 1, - EventBody::StageStarted(started_props()), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 2, - created( - "openai_plan:child_session", - TodoListKind::OpenAiPlan, - "child-a", - 0, - "child work", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - created( - root_list, - TodoListKind::OpenAiPlan, - "root-a", - 0, - "root work", - ), - stage_id.clone(), - )) - .unwrap(); - - let projection = root_agent_todos(&state, &stage_id); - assert_eq!(projection.list_id, root_list); - assert_eq!(projection.items.len(), 1); - assert_eq!(projection.items[0].subject, "root work"); - } - - #[test] - fn anthropic_child_session_task_events_still_project() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let list = "anthropic_tasks:root_session"; - state - .apply_event(&child_stage_event( - 1, - created( - list, - TodoListKind::AnthropicTasks, - "task-a", - 0, - "task first", - ), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&child_stage_event( - 2, - updated_status( - list, - TodoListKind::AnthropicTasks, - "task-a", - TodoStatus::Completed, - ), - stage_id.clone(), - )) - .unwrap(); - - let projection = root_agent_todos(&state, &stage_id); - assert_eq!(projection.list_id, list); - assert_eq!(projection.kind, TodoListKind::AnthropicTasks); - assert_eq!(projection.items.len(), 1); - assert_eq!(projection.items[0].id, "task-a"); - assert_eq!(projection.items[0].status, TodoStatus::Completed); - } - - #[test] - fn metadata_patch_merges_and_null_deletes() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let list = "anthropic_tasks:r"; - state - .apply_event(&test_stage_event( - 1, - created(list, TodoListKind::AnthropicTasks, "1", 0, "t"), - stage_id.clone(), - )) - .unwrap(); - let mut meta = BTreeMap::new(); - meta.insert("k1".to_string(), serde_json::json!("v1")); - meta.insert("k2".to_string(), serde_json::json!("v2")); - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::TodoUpdated(TodoUpdatedProps { - list_id: list.to_string(), - list_kind: TodoListKind::AnthropicTasks, - todo_id: "1".to_string(), - status: None, - order: None, - subject: None, - description: None, - active_form: None, - owner: None, - add_blocks: None, - add_blocked_by: None, - metadata_patch: meta, - })), - stage_id.clone(), - )) - .unwrap(); - let mut delete = BTreeMap::new(); - delete.insert("k1".to_string(), serde_json::Value::Null); - state - .apply_event(&test_stage_event( - 3, - agent_body(CodingEvent::TodoUpdated(TodoUpdatedProps { - list_id: list.to_string(), - list_kind: TodoListKind::AnthropicTasks, - todo_id: "1".to_string(), - status: None, - order: None, - subject: None, - description: None, - active_form: None, - owner: None, - add_blocks: None, - add_blocked_by: None, - metadata_patch: delete, - })), - stage_id.clone(), - )) - .unwrap(); - - let todo = &root_agent_todos(&state, &stage_id).items[0]; - assert!(!todo.metadata.contains_key("k1")); - assert_eq!(todo.metadata.get("k2"), Some(&serde_json::json!("v2"))); - } - } - - mod agent_state_reducer { - use super::*; - - fn stage_id() -> StageId { - StageId::new("code", 1) - } - - #[test] - fn interrupt_settlement_and_steering_update_agent_control_projection() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - agent_body(CodingEvent::RoundInterrupted { generation: 1 }), - stage_id.clone(), - )) - .unwrap(); - assert_eq!( - state.stage(&stage_id).unwrap().agent_control, - AgentControlState::WaitingForSteer - ); - - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::SteeringInjected { - text: "continue".to_string(), - content: None, - actor: None, - }), - stage_id.clone(), - )) - .unwrap(); - assert_eq!( - state.stage(&stage_id).unwrap().agent_control, - AgentControlState::Running - ); - - state - .apply_event(&test_stage_event( - 3, - agent_body(CodingEvent::RoundInterrupted { generation: 2 }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 4, - EventBody::AgentSessionDeactivated(AgentSessionDeactivatedProps { visit: 1 }), - stage_id.clone(), - )) - .unwrap(); - assert_eq!( - state.stage(&stage_id).unwrap().agent_control, - AgentControlState::Running - ); - - state - .apply_event(&test_stage_event( - 5, - agent_body(CodingEvent::RoundInterrupted { generation: 3 }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 6, - EventBody::StageFailed(failed_props(10, false)), - stage_id.clone(), - )) - .unwrap(); - assert_eq!( - state.stage(&stage_id).unwrap().agent_control, - AgentControlState::Running - ); - } - - #[test] - fn subagent_events_update_stage_projection() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - agent_body(CodingEvent::SubAgentSpawned { - agent_id: "sub-1".to_string(), - depth: 1, - task: "write tests".to_string(), - generation: 1, - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents.len(), 1); - assert_eq!(stage.subagents[0].agent_id, "sub-1"); - assert_eq!(stage.subagents[0].depth, 1); - assert_eq!(stage.subagents[0].task, "write tests"); - assert_eq!(stage.subagents[0].status, SubAgentStatus::Running); - - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::SubAgentCompleted { - agent_id: "sub-1".to_string(), - depth: 1, - generation: 1, - success: true, - turns_used: 3, - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents[0].status, SubAgentStatus::Completed { - success: true, - turns_used: 3, - }); - - state - .apply_event(&test_stage_event( - 3, - agent_body(CodingEvent::SubAgentTurnStarted { - agent_id: "sub-1".to_string(), - depth: 1, - task: "fix the review findings".to_string(), - generation: 2, - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents.len(), 1); - assert_eq!(stage.subagents[0].task, "write tests"); - assert_eq!(stage.subagents[0].status, SubAgentStatus::Running); - - state - .apply_event(&test_stage_event( - 4, - agent_body(CodingEvent::SubAgentCompleted { - agent_id: "sub-1".to_string(), - depth: 1, - generation: 2, - success: true, - turns_used: 5, - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents.len(), 1); - assert_eq!(stage.subagents[0].status, SubAgentStatus::Completed { - success: true, - turns_used: 5, - }); - - state - .apply_event(&test_stage_event( - 5, - agent_body(CodingEvent::SubAgentSpawned { - agent_id: "sub-2".to_string(), - depth: 2, - task: "debug failure".to_string(), - generation: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 6, - agent_body(CodingEvent::SubAgentFailed { - agent_id: "sub-2".to_string(), - depth: 2, - generation: 1, - error: ErrorData::new(ErrorKind::Agent, "boom"), - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents[1].status, SubAgentStatus::Failed { - error: json!({ "kind": "agent", "message": "boom" }), - }); - - state - .apply_event(&test_stage_event( - 7, - agent_body(CodingEvent::SubAgentClosed { - agent_id: "sub-2".to_string(), - depth: 2, - generation: 1, - }), - stage_id.clone(), - )) - .unwrap(); - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.subagents[1].status, SubAgentStatus::Closed); - } - - #[test] - fn skill_events_update_stage_projection() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - agent_body(CodingEvent::SkillsDiscovered { - profile: "claude".to_string(), - source_dirs: vec![".claude/skills".to_string()], - skills: vec![ - SkillSummary { - name: "rust".to_string(), - description: "Rust help".to_string(), - }, - SkillSummary { - name: "docs".to_string(), - description: "Docs help".to_string(), - }, - ], - skipped: Vec::new(), - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::SkillActivated { - skill_name: "rust".to_string(), - source: SkillActivationSource::Slash, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - agent_body(CodingEvent::SkillActivated { - skill_name: "rust".to_string(), - source: SkillActivationSource::Tool, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.skills.available.len(), 2); - assert_eq!(stage.skills.available[0].name, "rust"); - assert_eq!(stage.skills.activated.len(), 2); - assert_eq!(stage.skills.activated[0].name, "rust"); - assert_eq!( - stage.skills.activated[0].source, - SkillActivationSource::Slash - ); - assert_eq!( - stage.skills.activated[1].source, - SkillActivationSource::Tool - ); - } - - #[test] - fn agent_session_activation_updates_stage_permission_level_projection() { - fn activated_props( - permission_level: Option, - ) -> AgentSessionActivatedProps { - AgentSessionActivatedProps { - thread_id: None, - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - reasoning_effort: None, - speed: None, - permission_level, - capabilities: vec![], - visit: 1, - } - } - - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentSessionActivated(activated_props(Some( - PermissionLevel::ReadOnly, - ))), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.permission_level, Some(PermissionLevel::ReadOnly)); - - let mut legacy_state = initialized_projection(); - legacy_state - .apply_event(&test_stage_event( - 1, - EventBody::AgentSessionActivated(activated_props(None)), - stage_id.clone(), - )) - .unwrap(); - - let legacy_stage = legacy_state.stage(&stage_id).unwrap(); - assert_eq!(legacy_stage.permission_level, None); - } - - fn agent_tool(name: &str, category: ToolCategory, invoked: bool) -> ToolSummary { - ToolSummary { - name: name.to_string(), - description: format!("{name} description"), - source: ToolSource::Native, - category, - invoked, - } - } - - #[test] - fn agent_tools_available_replaces_stage_agent_tools() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentToolsAvailable(AgentToolsAvailableProps { - tools: vec![ - agent_tool("read_file", ToolCategory::Read, false), - agent_tool("apply_patch", ToolCategory::Write, false), - ], - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - EventBody::AgentToolsAvailable(AgentToolsAvailableProps { - tools: vec![agent_tool("grep", ToolCategory::Read, false)], - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.agent_tools, vec![agent_tool( - "grep", - ToolCategory::Read, - false - )]); - } - - #[test] - fn agent_tool_started_marks_only_matching_available_tool_invoked() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentToolsAvailable(AgentToolsAvailableProps { - tools: vec![ - agent_tool("read_file", ToolCategory::Read, false), - agent_tool("apply_patch", ToolCategory::Write, false), - ], - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "apply_patch".to_string(), - tool_call_id: "call_patch".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert!(!stage.agent_tools[0].invoked); - assert!(stage.agent_tools[1].invoked); - } - - #[test] - fn legacy_tool_started_without_available_tools_does_not_synthesize_tool_list() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "apply_patch".to_string(), - tool_call_id: "call_patch".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert!(stage.agent_tools.is_empty()); - } - - #[test] - fn mcp_server_events_update_stage_projection() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "filesystem".to_string(), - tool_count: 2, - tools: vec![ - AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }, - AgentMcpToolSummary { - name: "write_file".to_string(), - original_name: "write_file".to_string(), - }, - ], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - EventBody::AgentMcpFailed(AgentMcpFailedProps { - server_name: "github".to_string(), - error: "missing token".to_string(), - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "filesystem".to_string(), - tool_count: 1, - tools: vec![AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.mcp_servers.len(), 2); - assert_eq!(stage.mcp_servers[0].server_name, "filesystem"); - assert_eq!(stage.mcp_servers[0].tool_count, 1); - assert_eq!(stage.mcp_servers[0].status, McpServerStatus::Ready { - tools: vec![AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }], - }); - assert!(!stage.mcp_servers[0].invoked); - assert_eq!(stage.mcp_servers[1].server_name, "github"); - assert_eq!(stage.mcp_servers[1].tool_count, 0); - assert_eq!(stage.mcp_servers[1].status, McpServerStatus::Failed { - error: "missing token".to_string(), - }); - assert!(!stage.mcp_servers[1].invoked); - } - - #[test] - fn mcp_server_disconnect_keeps_tool_count_and_invoked() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "github".to_string(), - tool_count: 1, - tools: vec![AgentMcpToolSummary { - name: "mcp__github__list_issues".to_string(), - original_name: "list_issues".to_string(), - }], - startup_ms: 842, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "mcp__github__list_issues".to_string(), - tool_call_id: "call_gh".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 3, - EventBody::AgentMcpDisconnected(AgentMcpDisconnectedProps { - server_name: "github".to_string(), - error: "transport closed".to_string(), - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.mcp_servers.len(), 1); - let github = &stage.mcp_servers[0]; - assert_eq!(github.server_name, "github"); - assert_eq!(github.status, McpServerStatus::Disconnected { - error: "transport closed".to_string(), - }); - assert_eq!(github.tool_count, 1, "the tools existed; they now fail"); - assert!(github.invoked, "the server was used before it dropped"); - } - - #[test] - fn mcp_server_disconnect_without_a_ready_is_recorded_without_tools() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentMcpDisconnected(AgentMcpDisconnectedProps { - server_name: "github".to_string(), - error: "transport closed".to_string(), - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.mcp_servers.len(), 1); - assert_eq!(stage.mcp_servers[0].tool_count, 0); - assert!(!stage.mcp_servers[0].invoked); - assert_eq!(stage.mcp_servers[0].status, McpServerStatus::Disconnected { - error: "transport closed".to_string(), - }); - } - - #[test] - fn agent_tool_started_marks_matching_mcp_server_as_invoked() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "filesystem".to_string(), - tool_count: 1, - tools: vec![AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "other".to_string(), - tool_count: 0, - tools: vec![], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - // Native (non-MCP) tool call: should not touch any MCP server. - state - .apply_event(&test_stage_event( - 3, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "Bash".to_string(), - tool_call_id: "call_bash".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - // Qualified MCP tool call: flips matching server's `invoked`. - state - .apply_event(&test_stage_event( - 4, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "mcp__filesystem__read_file".to_string(), - tool_call_id: "call_fs".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - let filesystem = stage - .mcp_servers - .iter() - .find(|s| s.server_name == "filesystem") - .unwrap(); - assert!(filesystem.invoked, "filesystem should be marked invoked"); - let other = stage - .mcp_servers - .iter() - .find(|s| s.server_name == "other") - .unwrap(); - assert!(!other.invoked, "unused MCP server should stay un-invoked"); - } - - #[test] - fn mcp_invoked_flag_survives_status_reread() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 1, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "filesystem".to_string(), - tool_count: 1, - tools: vec![AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 2, - agent_body(CodingEvent::ToolCallStarted { - tool_name: "mcp__filesystem__read_file".to_string(), - tool_call_id: "call_fs".to_string(), - arguments: serde_json::json!({}), - }), - stage_id.clone(), - )) - .unwrap(); - // Server re-reports Ready (e.g. tool registry refresh): invoked - // must remain true, not get clobbered back to false. - state - .apply_event(&test_stage_event( - 3, - EventBody::AgentMcpReady(AgentMcpReadyProps { - server_name: "filesystem".to_string(), - tool_count: 2, - tools: vec![ - AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }, - AgentMcpToolSummary { - name: "stat".to_string(), - original_name: "stat".to_string(), - }, - ], - startup_ms: 0, - visit: 1, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert!(stage.mcp_servers[0].invoked); - assert_eq!(stage.mcp_servers[0].tool_count, 2); - } - - #[test] - fn agent_messages_replace_latest_context_window_for_matching_stage() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - let first = context_window_snapshot(10); - let second = context_window_snapshot(20); - - state - .apply_event(&test_stage_event( - 7, - agent_message_with_context_window(first), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 8, - agent_message_with_context_window(second), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - let snapshot = stage.context_window.as_ref().unwrap(); - assert_eq!(snapshot.input_tokens, 20); - assert_eq!(snapshot.event_seq, Some(8)); - } - - #[test] - fn agent_message_without_context_window_preserves_existing_context_window() { - let mut state = initialized_projection(); - let stage_id = stage_id(); - - state - .apply_event(&test_stage_event( - 7, - agent_message_with_context_window(context_window_snapshot(10)), - stage_id.clone(), - )) - .unwrap(); - state - .apply_event(&test_stage_event( - 8, - agent_message_body(1, 1), - stage_id.clone(), - )) - .unwrap(); - - let snapshot = state - .stage(&stage_id) - .unwrap() - .context_window - .as_ref() - .unwrap(); - assert_eq!(snapshot.input_tokens, 10); - assert_eq!(snapshot.event_seq, Some(7)); - } - - fn agent_message_with_context_window(context_window: ContextWindowSnapshot) -> EventBody { - let CodingEvent::AssistantMessage { - text, - model, - usage, - cost_usd_micros, - cost_source, - tool_call_count, - reasoning, - .. - } = assistant_message(1, 1) - else { - unreachable!("assistant_message builds an assistant message"); - }; - agent_body(CodingEvent::AssistantMessage { - text, - model, - usage, - cost_usd_micros, - cost_source, - tool_call_count, - context_window: Some(context_window), - reasoning, - }) - } - - fn context_window_snapshot(input_tokens: u64) -> ContextWindowSnapshot { - ContextWindowSnapshot { - provider: "openai".to_string(), - model: "gpt-5.4".to_string(), - context_window_tokens: 400_000, - input_tokens, - usage_percent: input_tokens as f64 * 100.0 / 400_000.0, - count_method: ContextWindowCountMethod::LocalEstimate, - staleness: ContextWindowStaleness::Live, - generated_at: SystemTime::now(), - event_seq: None, - breakdown: vec![ContextWindowBreakdownItem { - category: ContextWindowCategory::Conversation, - tokens: input_tokens, - usage_percent: input_tokens as f64 * 100.0 / 400_000.0, - }], - warnings: vec![ContextWindowWarning { - code: "local_token_estimate".to_string(), - message: "input token count is a local estimate".to_string(), - }], - } - } - } - mod inference_bracket_reducer { use fabro_types::{LlmOutputKind, LlmRetryPhase, StageInferenceProjection}; @@ -8201,12 +6600,12 @@ mod tests { } /// Fabro's stage fold and pebble's `SessionProjection` read the same - /// stored events, and every stage now carries pebble's fold of its own + /// stored events, and every stage carries pebble's fold of its own /// events as `StageProjection.agent`. These tests pin the two folds to /// each other: a stage's live account is the prompt delta pebble - /// reports, and every field the stage projection still keeps its own - /// arms for is derivable from `agent` under a stated rule. They are the - /// safety net for reading `agent.*` instead and deleting the old fields. + /// reports, the stage's own `usage` and `model` follow from `agent` + /// under a stated rule, and the facts the stage view reads from `agent` + /// are the whole-session fold's for that stage's events. mod session_projection_parity { use fabro_types::{ModelRef, TodoListKind}; use lithos_llm::catalog::{ModelId, ProviderId}; @@ -8390,13 +6789,8 @@ mod tests { projection.apply(coding_event(event)); } - let code_stage = run.stage(&code).unwrap(); - assert_eq!(code_stage.subagents.len(), 1); - assert_eq!(code_stage.subagents[0].agent_id, "sub-1"); - assert_eq!(code_stage.subagents[0].status, SubAgentStatus::Completed { - success: true, - turns_used: 1, - }); + let code_agent = run.stage(&code).unwrap().agent.as_ref().unwrap(); + assert_eq!(code_agent.subagents, projection.subagents); assert_eq!(projection.subagents.len(), 1); assert_eq!(projection.subagents[0].agent_id, "sub-1"); assert_eq!( @@ -8407,7 +6801,13 @@ mod tests { } ); assert!( - run.stage(&review).unwrap().subagents.is_empty(), + run.stage(&review) + .unwrap() + .agent + .as_ref() + .unwrap() + .subagents + .is_empty(), "the child was the code stage's" ); assert_eq!(projection.subagent_counts.spawned, 1); @@ -8458,13 +6858,10 @@ mod tests { assert_eq!(resumed, replayed); } - /// Pebble folds its own `McpServer*` events; fabro's `mcp_servers` - /// arms fold the `agent.mcp.*` events the workflow sink mirrors them - /// onto. The mirrored events are built here the way the sink builds - /// them. The sink stores the pebble event as well, which is what - /// feeds `StageProjection.agent`; - /// `the_old_stage_fields_are_derived_from_the_embedded_fold` - /// drives both from one stream. + /// The stage's embedded fold sees the pebble `McpServer*` events the + /// sink stores, so its MCP view is the whole-session fold's; the + /// `agent.mcp.*` mirrors the sink still emits change nothing on the + /// stage. #[test] fn mcp_servers_agree_across_the_two_folds() { let code = StageId::new("code", 1); @@ -8500,27 +6897,23 @@ mod tests { assert_eq!(resumed, projection); let mut run = initialized_projection(); + run.apply_event(&stored(1, &code, ready)).unwrap(); run.apply_event(&test_stage_event( - 1, + 2, EventBody::AgentMcpReady(AgentMcpReadyProps { server_name: "github".to_string(), tool_count: tools.len(), - tools: tools - .iter() - .map(|tool| AgentMcpToolSummary { - name: tool.name.clone(), - original_name: tool.original_name.clone(), - }) - .collect(), + tools: mirrored_tools(&tools), startup_ms: 842, visit: 1, }), code.clone(), )) .unwrap(); - run.apply_event(&stored(2, &code, call)).unwrap(); + run.apply_event(&stored(3, &code, call)).unwrap(); + run.apply_event(&stored(4, &code, disconnected)).unwrap(); run.apply_event(&test_stage_event( - 3, + 5, EventBody::AgentMcpDisconnected(AgentMcpDisconnectedProps { server_name: "github".to_string(), error: "transport closed".to_string(), @@ -8530,21 +6923,13 @@ mod tests { )) .unwrap(); - let stage = run.stage(&code).unwrap(); - assert_eq!(stage.mcp_servers.len(), projection.mcp_servers.len()); - let server = &stage.mcp_servers[0]; - let pebble = &projection.mcp_servers["github"]; - assert_eq!(server.server_name, "github"); - assert_eq!(server.tool_count, pebble.tools.len()); - assert_eq!(server.invoked, pebble.invoked); - assert!(server.invoked); - assert_eq!(pebble.error, None, "a disconnect is not a failed start"); - assert_eq!(server.status, McpServerStatus::Disconnected { - error: pebble - .disconnected - .clone() - .expect("pebble recorded the disconnect"), - }); + let agent = run.stage(&code).unwrap().agent.as_ref().unwrap(); + assert_eq!(agent.mcp_servers, projection.mcp_servers); + let github = &agent.mcp_servers["github"]; + assert!(github.invoked); + assert_eq!(github.tools.len(), 1); + assert_eq!(github.disconnected.as_deref(), Some("transport closed")); + assert_eq!(github.error, None, "a disconnect is not a failed start"); } fn assistant_message_with_window( @@ -8595,13 +6980,14 @@ mod tests { .collect() } - /// Every field the stage projection keeps its own fold for is - /// derivable from `stage.agent`, under the rule each assertion - /// states. The stream is what the sink stores for one agent stage: - /// fabro's own `agent.session.activated` and the `agent.mcp.*` - /// mirrors next to pebble's events. + /// The stage keeps `usage` and `model` as its own, derived from + /// `stage.agent` under the rule each assertion states; everything + /// else the stage view shows is read from `agent` directly. The + /// stream is what the sink stores for one agent stage: fabro's own + /// `agent.session.activated` and the `agent.mcp.*` mirrors next to + /// pebble's events. #[test] - fn the_old_stage_fields_are_derived_from_the_embedded_fold() { + fn the_stage_view_reads_the_embedded_fold() { let code = StageId::new("code", 1); let model = billed_usage().model().clone(); let provider = model.provider.to_string(); @@ -8837,80 +7223,45 @@ mod tests { )) ); - // Context window: the same snapshot, except that fabro stamps the - // run event seq into `event_seq` and pebble keeps the event's own. - let mut fabro_window = stage - .context_window - .clone() - .expect("fabro kept the latest window"); - assert_eq!(fabro_window.event_seq, Some(9)); - fabro_window.event_seq = None; - assert_eq!(Some(fabro_window), agent.context_window); - - // Todos: fabro keeps the root agent's list; pebble keeps every - // list in the tree, and the root's is the one keyed by its id. + // Everything else the stage view shows is the fold's. + assert_eq!( + agent + .context_window + .as_ref() + .map(|window| window.input_tokens), + Some(123_456) + ); let root_todos = agent .todos .values() - .find(|list| list.list_id == list.kind.list_id(ROOT)); - assert_eq!(stage.root_agent_todos.as_ref(), root_todos); - assert!(root_todos.is_some()); - assert_eq!(agent.todos.len(), 2, "the child's plan is only pebble's"); + .find(|list| list.list_id == list.kind.list_id(ROOT)) + .expect("the root's list is keyed by its session id"); + assert_eq!(root_todos.items.len(), 1); + assert_eq!(agent.todos.len(), 2, "the child's plan is kept apart"); assert!(agent.todos.contains_key(&child_list)); - - // Subagents: the same rows; the status tag is `status`, not - // `kind`, and a failure carries pebble's `ErrorData`. - assert_eq!(stage.subagents.len(), agent.subagents.len()); assert_eq!(agent.subagents.len(), 2); - for (fabro, pebble) in stage.subagents.iter().zip(&agent.subagents) { - assert_eq!(fabro.agent_id, pebble.agent_id); - assert_eq!(fabro.depth, pebble.depth); - assert_eq!(fabro.task, pebble.task); - let mut pebble_status = serde_json::to_value(&pebble.status).unwrap(); - let tag = pebble_status - .as_object_mut() - .unwrap() - .remove("status") - .expect("pebble tags the status"); - pebble_status["kind"] = tag; - assert_eq!(serde_json::to_value(&fabro.status).unwrap(), pebble_status); - } + assert_eq!( + serde_json::to_value(&agent.subagents[0].status).unwrap()["status"], + "completed" + ); assert_eq!( serde_json::to_value(&agent.subagents[1].status).unwrap()["status"], "failed" ); - - // Skills: the same shape. - assert_eq!(stage.skills.available, agent.skills.available); - assert_eq!(stage.skills.activated.len(), agent.skills.activated.len()); - for (fabro, pebble) in stage.skills.activated.iter().zip(&agent.skills.activated) { - assert_eq!(fabro.name, pebble.name); - assert_eq!(fabro.source, pebble.source); - } - - // MCP servers: `disconnected` set is Disconnected, else `error` - // set is Failed, else Ready; the tool count is `tools.len()`. - assert_eq!(stage.mcp_servers.len(), agent.mcp_servers.len()); + assert_eq!(agent.skills.available.len(), 1); + assert_eq!(agent.skills.activated[0].name, "rust"); + assert_eq!( + agent.skills.activated[0].source, + SkillActivationSource::Tool + ); assert_eq!(agent.mcp_servers.len(), 2); - for server in &stage.mcp_servers { - let pebble = &agent.mcp_servers[&server.server_name]; - assert_eq!(server.invoked, pebble.invoked); - assert_eq!(server.tool_count, pebble.tools.len()); - let expected = if let Some(error) = &pebble.disconnected { - McpServerStatus::Disconnected { - error: error.clone(), - } - } else if let Some(error) = &pebble.error { - McpServerStatus::Failed { - error: error.clone(), - } - } else { - McpServerStatus::Ready { - tools: mirrored_tools(&pebble.tools), - } - }; - assert_eq!(server.status, expected, "{}", server.server_name); - } + let github = &agent.mcp_servers["github"]; + assert_eq!(github.tools.len(), 1); + assert_eq!(github.disconnected.as_deref(), Some("transport closed")); + assert_eq!(github.error, None); + let broken = &agent.mcp_servers["broken"]; + assert_eq!(broken.error.as_deref(), Some("could not launch")); + assert!(broken.tools.is_empty()); assert!(agent.mcp_servers["github"].invoked); assert_eq!(agent.mcp_servers["github"].startup_ms, Some(842)); assert_eq!(agent.mcp_servers["broken"].startup_ms, Some(3)); diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 72c597aaa..1e9a4389a 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -404,10 +404,6 @@ fn main() { &[], ), ("TodoListProjection", "fabro_types::TodoListProjection", &[]), - ("SubAgentProjection", "fabro_types::SubAgentProjection", &[]), - ("SubAgentStatus", "fabro_types::SubAgentStatus", &[]), - ("SkillsProjection", "fabro_types::SkillsProjection", &[]), - ("ActivatedSkill", "fabro_types::ActivatedSkill", &[]), ("SkillSummary", "fabro_types::SkillSummary", &[]), ( "SkillActivationSource", @@ -423,17 +419,6 @@ fn main() { "fabro_types::AgentToolsAvailableProps", &[], ), - ( - "McpServerProjection", - "fabro_types::McpServerProjection", - &[], - ), - ("McpServerStatus", "fabro_types::McpServerStatus", &[]), - ( - "AgentMcpToolSummary", - "fabro_types::AgentMcpToolSummary", - &[], - ), // Pebble's own fold of a stage's agent events, embedded in // `StageProjection.agent`. Every nested type is pebble's; the schema // names carry an `AgentSession` prefix where fabro already has a diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index c93eff100..8ded5c107 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -38,17 +38,16 @@ pub mod types { BlockedReason, FailureReason, PendingReason, RunControlAction, RunStatus, SuccessReason, }; pub use fabro_types::{ - ActivatedSkill, AgentControlState, AgentEventProps, AgentMcpToolSummary, - AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledModelUsage, - BilledTokenCounts, BlobHash, CommandTermination, Conclusion, ContextWindowBreakdownItem, - ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, - ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, DiffStats, - DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, - FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, - IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, - IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, - InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest, - McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, + AgentControlState, AgentEventProps, AgentToolsAvailableProps, AskFabro, AuthMethod, + AutomationRef, BilledModelUsage, BilledTokenCounts, BlobHash, CommandTermination, + Conclusion, ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, + ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, + DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, + FailureDetail, FailureSignature, GitContext, GitRunTarget, + GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind, + IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, + IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind, + McpServerDraft as CreateMcpServerRequest, McpServerReplace as ReplaceMcpServerRequest, McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, @@ -65,13 +64,13 @@ pub mod types { SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionStatus, SessionSummary, - SessionTurn, SkillActivationSource, SkillSummary, SkillsProjection, StageCompletion, - StageContextWindow, StageContextWindowUnavailableReason, StageHandler, StageId, - StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, - StageToolBatchProjection, SubAgentProjection, SubAgentStatus, SystemActorKind, - SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, ToolCategory, - ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal, Variable, - VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, WorkflowVersionId, + SessionTurn, SkillActivationSource, SkillSummary, StageCompletion, StageContextWindow, + StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection, + StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection, + SystemActorKind, SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, + ToolCategory, ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal, + Variable, VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion, + WorkflowVersionId, }; pub use lithos_llm::catalog::{ModelHandle, ProviderId}; pub use lithos_llm::types::{ diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index de27a4811..0b7ed2e74 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -1,8 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ - ActivatedSkill as ApiActivatedSkill, AgentControlState as ApiAgentControlState, - AgentMcpToolSummary as ApiAgentMcpToolSummary, + AgentControlState as ApiAgentControlState, AgentToolsAvailableProps as ApiAgentToolsAvailableProps, BilledModelUsage as ApiBilledModelUsage, ContextWindowBreakdownItem as ApiContextWindowBreakdownItem, @@ -11,26 +10,23 @@ use fabro_api::types::{ ContextWindowSnapshot as ApiContextWindowSnapshot, ContextWindowStaleness as ApiContextWindowStaleness, ContextWindowWarning as ApiContextWindowWarning, LlmOutputKind as ApiLlmOutputKind, - McpServerProjection as ApiMcpServerProjection, McpServerStatus as ApiMcpServerStatus, ParallelBranchResult as ApiParallelBranchResult, PermissionLevel as ApiPermissionLevel, SkillActivationSource as ApiSkillActivationSource, SkillSummary as ApiSkillSummary, - SkillsProjection as ApiSkillsProjection, StageContextWindow as ApiStageContextWindow, + StageContextWindow as ApiStageContextWindow, StageContextWindowUnavailableReason as ApiStageContextWindowUnavailableReason, StageInferenceProjection as ApiStageInferenceProjection, StageProjection as ApiStageProjection, StageToolBatchProjection as ApiStageToolBatchProjection, - SubAgentProjection as ApiSubAgentProjection, SubAgentStatus as ApiSubAgentStatus, TodoListProjection as ApiTodoListProjection, ToolCategory as ApiToolCategory, ToolSource as ApiToolSource, ToolSummary as ApiToolSummary, }; use fabro_types::{ - ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentToolsAvailableProps, - BilledModelUsage, ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, - ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, LlmOutputKind, - McpServerProjection, McpServerStatus, ModelRef, ParallelBranchId, ParallelBranchResult, - PermissionLevel, SkillActivationSource, SkillSummary, SkillsProjection, StageContextWindow, + AgentControlState, AgentToolsAvailableProps, BilledModelUsage, ContextWindowBreakdownItem, + ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, + ContextWindowWarning, LlmOutputKind, ModelRef, ParallelBranchId, ParallelBranchResult, + PermissionLevel, SkillActivationSource, SkillSummary, StageContextWindow, StageContextWindowUnavailableReason, StageId, StageInferenceProjection, StageProjection, - StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, - ToolCategory, ToolSource, ToolSummary, + StageToolBatchProjection, TodoListKind, TodoListProjection, ToolCategory, ToolSource, + ToolSummary, }; use lithos_llm::catalog::{ModelId, ProviderId}; use lithos_llm::types::TokenCounts; @@ -116,19 +112,12 @@ fn stage_projection_reuses_nested_agent_state_types() { assert_same_type::(); assert_same_type::(); assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); - assert_same_type::(); - assert_same_type::(); - assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); @@ -300,45 +289,6 @@ fn stage_projection_round_trips_representative_json() { "cache_read_tokens": 0, "cache_write_tokens": 0 }, - "todos": { - "kind": "openai_plan", - "list_id": "openai_plan:ses_root", - "items": [ - { - "id": "todo-1", - "status": "in_progress", - "order": 0, - "subject": "Write tests", - "active_form": "Writing tests" - } - ] - }, - "subagents": [ - { - "agent_id": "sub-1", - "depth": 1, - "task": "Investigate failing test", - "status": { - "kind": "completed", - "success": true, - "turns_used": 3 - } - } - ], - "skills": { - "available": [ - { - "name": "rust", - "description": "Rust workflow help" - } - ], - "activated": [ - { - "name": "rust", - "source": "slash" - } - ] - }, "permission_level": "read-only", "agent_tools": [ { @@ -360,41 +310,6 @@ fn stage_projection_round_trips_representative_json() { "invoked": false } ], - "mcp_servers": [ - { - "server_name": "filesystem", - "tool_count": 1, - "status": { - "kind": "ready", - "tools": [ - { - "name": "read_file", - "original_name": "read_file" - } - ] - }, - "invoked": true - } - ], - "context_window": { - "provider": "openai", - "model": "gpt-5.4", - "context_window_tokens": 400000, - "input_tokens": 123456, - "usage_percent": 30.864, - "count_method": "provider_api_scaled_breakdown", - "staleness": "live", - "generated_at": "2026-05-23T12:34:56.000Z", - "event_seq": 42, - "breakdown": [ - { - "category": "system_prompt", - "tokens": 30000, - "usage_percent": 7.5 - } - ], - "warnings": [] - }, "inference": { "session_id": "ses_root", "started_at": "2026-04-29T12:34:00Z", @@ -461,7 +376,7 @@ fn permission_level_matches_openapi_json_shape() { } #[test] -fn nested_agent_state_types_match_openapi_json_shape() { +fn todo_list_and_skill_types_match_openapi_json_shape() { for (kind, list_id, wire_kind) in [ ( TodoListKind::OpenAiPlan, @@ -484,32 +399,6 @@ fn nested_agent_state_types_match_openapi_json_shape() { assert_eq!(api_todo_list, todo_list); } - let subagent = SubAgentProjection { - agent_id: "sub-1".to_string(), - depth: 1, - task: "Investigate failing test".to_string(), - status: SubAgentStatus::Completed { - success: true, - turns_used: 3, - }, - }; - let subagent_json = serde_json::to_value(&subagent).unwrap(); - assert_eq!( - subagent_json, - json!({ - "agent_id": "sub-1", - "depth": 1, - "task": "Investigate failing test", - "status": { - "kind": "completed", - "success": true, - "turns_used": 3 - } - }) - ); - let api_subagent: ApiSubAgentProjection = serde_json::from_value(subagent_json).unwrap(); - assert_eq!(api_subagent, subagent); - let skill = SkillSummary { name: "rust".to_string(), description: "Rust workflow help".to_string(), @@ -529,103 +418,6 @@ fn nested_agent_state_types_match_openapi_json_shape() { assert_eq!(source_json, json!("slash")); let api_source: ApiSkillActivationSource = serde_json::from_value(source_json).unwrap(); assert_eq!(api_source, SkillActivationSource::Slash); - - let activated = ActivatedSkill { - name: "rust".to_string(), - source: SkillActivationSource::Slash, - }; - let skills = SkillsProjection { - available: vec![skill], - activated: vec![activated], - }; - let skills_json = serde_json::to_value(&skills).unwrap(); - assert_eq!( - skills_json, - json!({ - "available": [ - { - "name": "rust", - "description": "Rust workflow help" - } - ], - "activated": [ - { - "name": "rust", - "source": "slash" - } - ] - }) - ); - let api_skills: ApiSkillsProjection = serde_json::from_value(skills_json).unwrap(); - assert_eq!(api_skills, skills); - - let tool = AgentMcpToolSummary { - name: "read_file".to_string(), - original_name: "read_file".to_string(), - }; - let tool_json = serde_json::to_value(&tool).unwrap(); - assert_eq!( - tool_json, - json!({ - "name": "read_file", - "original_name": "read_file" - }) - ); - let api_tool: ApiAgentMcpToolSummary = serde_json::from_value(tool_json).unwrap(); - assert_eq!(api_tool, tool); - - let mcp_server = McpServerProjection { - server_name: "filesystem".to_string(), - tool_count: 1, - status: McpServerStatus::Ready { tools: vec![tool] }, - invoked: true, - }; - let mcp_json = serde_json::to_value(&mcp_server).unwrap(); - assert_eq!( - mcp_json, - json!({ - "server_name": "filesystem", - "tool_count": 1, - "status": { - "kind": "ready", - "tools": [ - { - "name": "read_file", - "original_name": "read_file" - } - ] - }, - "invoked": true, - }) - ); - let api_mcp: ApiMcpServerProjection = serde_json::from_value(mcp_json).unwrap(); - assert_eq!(api_mcp, mcp_server); - assert_eq!(mcp_server.tool_count, 1); - - let disconnected = McpServerProjection { - server_name: "filesystem".to_string(), - tool_count: 1, - status: McpServerStatus::Disconnected { - error: "transport closed".to_string(), - }, - invoked: true, - }; - let disconnected_json = serde_json::to_value(&disconnected).unwrap(); - assert_eq!( - disconnected_json, - json!({ - "server_name": "filesystem", - "tool_count": 1, - "status": { - "kind": "disconnected", - "error": "transport closed" - }, - "invoked": true, - }) - ); - let api_disconnected: ApiMcpServerProjection = - serde_json::from_value(disconnected_json).unwrap(); - assert_eq!(api_disconnected, disconnected); } #[test] diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 9c0005449..1feb98b78 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -143,10 +143,9 @@ pub use run_intent::{ TargetValidationError, ValidatedGitRunTarget, ValidatedRunTarget, }; pub use run_projection::{ - ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus, - PendingInterviewRecord, RunProjection, SkillsProjection, StageContextWindow, + AgentControlState, CheckpointRecord, PendingInterviewRecord, RunProjection, StageContextWindow, StageContextWindowUnavailableReason, StageInferenceProjection, StageModelUsage, - StageProjection, StageToolBatchProjection, SubAgentProjection, SubAgentStatus, first_event_seq, + StageProjection, StageToolBatchProjection, first_event_seq, }; pub use run_sandbox::{ RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index ed7ccb9ad..24afa8142 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -6,19 +6,18 @@ use chrono::{DateTime, Utc}; use lithos_llm::types::{ReasoningEffort, Speed}; use pebble_coding_agent::events::{ ContextWindowBreakdownItem, ContextWindowCountMethod, ContextWindowSnapshot, - ContextWindowStaleness, ContextWindowWarning, LlmOutputKind, PermissionLevel, - SkillActivationSource, SkillSummary, TodoListProjection, ToolSummary, + ContextWindowStaleness, ContextWindowWarning, LlmOutputKind, PermissionLevel, ToolSummary, }; use pebble_coding_agent::projection::SessionProjection; use strum::{Display, EnumString, IntoStaticStr}; use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; use crate::{ - AgentBackend, AgentMcpToolSummary, BilledModelUsage, BilledTokenCounts, Checkpoint, Conclusion, - GitIdentity, InterviewQuestionRecord, InvalidTransition, ModelRef, ParallelBranchId, - PullRequestCreation, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, - RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState, - StageTiming, StartRecord, timing, + AgentBackend, BilledModelUsage, BilledTokenCounts, Checkpoint, Conclusion, GitIdentity, + InterviewQuestionRecord, InvalidTransition, ModelRef, ParallelBranchId, PullRequestCreation, + PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, + RunTiming, StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord, + timing, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -304,25 +303,10 @@ pub struct StageProjection { /// `usage` to `model`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub billing_by_model: Vec, - /// Todo/task list owned by the stage's root agent session. - /// - /// OpenAI child sessions own separate per-session plans and do not appear - /// here. Anthropic task lists are root-scoped and shared with child - /// sessions, so child mutations of that shared list do appear here. - #[serde(default, rename = "todos", skip_serializing_if = "Option::is_none")] - pub root_agent_todos: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subagents: Vec, - #[serde(default, skip_serializing_if = "SkillsProjection::is_empty")] - pub skills: SkillsProjection, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_level: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agent_tools: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mcp_servers: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_window: Option, /// Open inference bracket for this stage, if the event log contains one. /// /// `Some` means exactly *"an `agent.llm.started` was recorded and no @@ -341,10 +325,12 @@ pub struct StageProjection { #[serde(default)] pub agent_control: AgentControlState, /// Pebble's fold of this stage's agent events: the one agent projection, - /// fed every `agent.*` and `todo.*` event stored on the stage. Present - /// for pebble-backed agent stages once their first agent event is - /// stored; `None` for prompt, command, ACP, human, parallel, and - /// conditional stages. + /// fed every `agent.*` and `todo.*` event stored on the stage, and what + /// the stage view reads for todos, subagents, skills, MCP servers, files, + /// failovers, compactions, and the context window. Present for + /// pebble-backed agent stages once their first agent event is stored; + /// `None` for prompt, command, ACP, human, parallel, and conditional + /// stages. /// /// Its lifetime fields are the stage's totals across every prompt the /// stage ran, because each stage gets its own fold over its own events. @@ -427,67 +413,6 @@ pub enum AgentControlState { WaitingForSteer, } -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SubAgentProjection { - pub agent_id: String, - pub depth: usize, - pub task: String, - pub status: SubAgentStatus, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum SubAgentStatus { - Running, - Completed { success: bool, turns_used: usize }, - Failed { error: serde_json::Value }, - Closed, -} - -#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SkillsProjection { - pub available: Vec, - pub activated: Vec, -} - -impl SkillsProjection { - #[must_use] - pub fn is_empty(&self) -> bool { - self.available.is_empty() && self.activated.is_empty() - } -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ActivatedSkill { - pub name: String, - pub source: SkillActivationSource, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct McpServerProjection { - pub server_name: String, - pub tool_count: usize, - pub status: McpServerStatus, - /// True once any tool from this server has been invoked during the stage. - pub invoked: bool, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum McpServerStatus { - Ready { - tools: Vec, - }, - Failed { - error: String, - }, - /// The server was ready and then its connection closed during the - /// stage; its tools fail until the session ends. - Disconnected { - error: String, - }, -} - /// Convert a 1-based event sequence number into the `NonZeroU32` form used for /// `StageProjection::first_event_seq`. Run event seqs always start at 1. #[must_use] @@ -510,13 +435,8 @@ impl StageProjection { tool_batch: None, usage: BilledTokenCounts::default(), model: None, - root_agent_todos: None, - subagents: Vec::new(), - skills: SkillsProjection::default(), permission_level: None, agent_tools: Vec::new(), - mcp_servers: Vec::new(), - context_window: None, inference: None, acp_started_at: None, agent_control: AgentControlState::default(), diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index a98114497..8a724e582 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -27,12 +27,10 @@ base.ts common.ts configuration.ts index.ts -models/activated-skill.ts models/agent-control-state.ts models/agent-error-data.ts models/agent-error-kind.ts models/agent-event-props.ts -models/agent-mcp-tool-summary.ts models/agent-session-activated-props.ts models/agent-session-activated-skill.ts models/agent-session-activity.ts @@ -244,12 +242,7 @@ models/manifest-workflow.ts models/mcp-http-protocol.ts models/mcp-server-list-meta.ts models/mcp-server-list-response.ts -models/mcp-server-projection.ts models/mcp-server-settings.ts -models/mcp-server-status-disconnected.ts -models/mcp-server-status-failed.ts -models/mcp-server-status-ready.ts -models/mcp-server-status.ts models/mcp-server.ts models/mcp-tool-summary.ts models/mcp-transport-http.ts @@ -508,7 +501,6 @@ models/session-summary.ts models/session-turn.ts models/skill-activation-source.ts models/skill-summary.ts -models/skills-projection.ts models/slack-integration-settings.ts models/ssh-access-request.ts models/ssh-access-response.ts @@ -527,12 +519,6 @@ models/stage-tool-batch-projection.ts models/start-record.ts models/start-run-request.ts models/steer-run-request.ts -models/sub-agent-projection.ts -models/sub-agent-status-closed.ts -models/sub-agent-status-completed.ts -models/sub-agent-status-failed.ts -models/sub-agent-status-running.ts -models/sub-agent-status.ts models/submit-answer-multi-selected-request.ts models/submit-answer-no-request.ts models/submit-answer-request.ts diff --git a/lib/packages/fabro-api-client/src/models/activated-skill.ts b/lib/packages/fabro-api-client/src/models/activated-skill.ts deleted file mode 100644 index 2ba86c9f4..000000000 --- a/lib/packages/fabro-api-client/src/models/activated-skill.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { SkillActivationSource } from './skill-activation-source'; - -/** - * One observed agent skill activation. - */ -export interface ActivatedSkill { - 'name': string; - 'source': SkillActivationSource; -} diff --git a/lib/packages/fabro-api-client/src/models/agent-mcp-tool-summary.ts b/lib/packages/fabro-api-client/src/models/agent-mcp-tool-summary.ts deleted file mode 100644 index e5510ca18..000000000 --- a/lib/packages/fabro-api-client/src/models/agent-mcp-tool-summary.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Summary of one tool exposed by an MCP server. - */ -export interface AgentMcpToolSummary { - 'name': string; - 'original_name': string; -} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index d707783e3..c099cd2d3 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -1,9 +1,7 @@ -export * from './activated-skill'; export * from './agent-control-state'; export * from './agent-error-data'; export * from './agent-error-kind'; export * from './agent-event-props'; -export * from './agent-mcp-tool-summary'; export * from './agent-session-activated-props'; export * from './agent-session-activated-skill'; export * from './agent-session-activity'; @@ -215,12 +213,7 @@ export * from './mcp-http-protocol'; export * from './mcp-server'; export * from './mcp-server-list-meta'; export * from './mcp-server-list-response'; -export * from './mcp-server-projection'; export * from './mcp-server-settings'; -export * from './mcp-server-status'; -export * from './mcp-server-status-disconnected'; -export * from './mcp-server-status-failed'; -export * from './mcp-server-status-ready'; export * from './mcp-tool-summary'; export * from './mcp-transport'; export * from './mcp-transport-http'; @@ -478,7 +471,6 @@ export * from './session-summary'; export * from './session-turn'; export * from './skill-activation-source'; export * from './skill-summary'; -export * from './skills-projection'; export * from './slack-integration-settings'; export * from './ssh-access-request'; export * from './ssh-access-response'; @@ -497,12 +489,6 @@ export * from './stage-tool-batch-projection'; export * from './start-record'; export * from './start-run-request'; export * from './steer-run-request'; -export * from './sub-agent-projection'; -export * from './sub-agent-status'; -export * from './sub-agent-status-closed'; -export * from './sub-agent-status-completed'; -export * from './sub-agent-status-failed'; -export * from './sub-agent-status-running'; export * from './submit-answer-multi-selected-request'; export * from './submit-answer-no-request'; export * from './submit-answer-request'; diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-projection.ts b/lib/packages/fabro-api-client/src/models/mcp-server-projection.ts deleted file mode 100644 index 6d6f01393..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-projection.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerStatus } from './mcp-server-status'; - -/** - * Projected state for one MCP server observed by an agent stage. - */ -export interface McpServerProjection { - 'server_name': string; - 'tool_count': number; - 'status': McpServerStatus; - /** - * True once the agent has invoked at least one tool from this server during the stage. - */ - 'invoked': boolean; -} diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-status-disconnected.ts b/lib/packages/fabro-api-client/src/models/mcp-server-status-disconnected.ts deleted file mode 100644 index e684c9d01..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-status-disconnected.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * The server was ready and then its connection closed during the stage; its tools fail until the session ends. - */ -export interface McpServerStatusDisconnected { - 'kind': McpServerStatusDisconnectedKindEnum; - /** - * What closed the connection, as the client observed it. - */ - 'error': string; -} - -export const McpServerStatusDisconnectedKindEnum = { - DISCONNECTED: 'disconnected' -} as const; - -export type McpServerStatusDisconnectedKindEnum = typeof McpServerStatusDisconnectedKindEnum[keyof typeof McpServerStatusDisconnectedKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-status-failed.ts b/lib/packages/fabro-api-client/src/models/mcp-server-status-failed.ts deleted file mode 100644 index c644eba1d..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-status-failed.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface McpServerStatusFailed { - 'kind': McpServerStatusFailedKindEnum; - 'error': string; -} - -export const McpServerStatusFailedKindEnum = { - FAILED: 'failed' -} as const; - -export type McpServerStatusFailedKindEnum = typeof McpServerStatusFailedKindEnum[keyof typeof McpServerStatusFailedKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-status-ready.ts b/lib/packages/fabro-api-client/src/models/mcp-server-status-ready.ts deleted file mode 100644 index 58dbbd421..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-status-ready.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AgentMcpToolSummary } from './agent-mcp-tool-summary'; - -export interface McpServerStatusReady { - 'kind': McpServerStatusReadyKindEnum; - 'tools': Array; -} - -export const McpServerStatusReadyKindEnum = { - READY: 'ready' -} as const; - -export type McpServerStatusReadyKindEnum = typeof McpServerStatusReadyKindEnum[keyof typeof McpServerStatusReadyKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-status.ts b/lib/packages/fabro-api-client/src/models/mcp-server-status.ts deleted file mode 100644 index 45e63f255..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-status.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AgentMcpToolSummary } from './agent-mcp-tool-summary'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerStatusDisconnected } from './mcp-server-status-disconnected'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerStatusFailed } from './mcp-server-status-failed'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerStatusReady } from './mcp-server-status-ready'; - -/** - * @type McpServerStatus - * Projected MCP server readiness status. - */ -export type McpServerStatus = { kind: 'disconnected' } & McpServerStatusDisconnected | { kind: 'failed' } & McpServerStatusFailed | { kind: 'ready' } & McpServerStatusReady; diff --git a/lib/packages/fabro-api-client/src/models/skills-projection.ts b/lib/packages/fabro-api-client/src/models/skills-projection.ts deleted file mode 100644 index 470a90f59..000000000 --- a/lib/packages/fabro-api-client/src/models/skills-projection.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ActivatedSkill } from './activated-skill'; -// May contain unused imports in some cases -// @ts-ignore -import type { SkillSummary } from './skill-summary'; - -/** - * Agent skills discovered and activated during a stage. - */ -export interface SkillsProjection { - 'available': Array; - 'activated': Array; -} diff --git a/lib/packages/fabro-api-client/src/models/stage-context-window.ts b/lib/packages/fabro-api-client/src/models/stage-context-window.ts index 6770f4ed8..e2e82f321 100644 --- a/lib/packages/fabro-api-client/src/models/stage-context-window.ts +++ b/lib/packages/fabro-api-client/src/models/stage-context-window.ts @@ -50,6 +50,9 @@ export interface StageContextWindow { 'count_method': ContextWindowCountMethod | null; 'staleness': ContextWindowStaleness; 'generated_at': string | null; + /** + * The coding agent\'s own event sequence for the snapshot, when it carried one; not the run event sequence. + */ 'event_seq': number | null; 'breakdown': Array; 'warnings': Array; diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index 9834299d1..8e141edd2 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -33,21 +33,12 @@ import type { BillingModelRef } from './billing-model-ref'; import type { CommandTermination } from './command-termination'; // May contain unused imports in some cases // @ts-ignore -import type { ContextWindowSnapshot } from './context-window-snapshot'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerProjection } from './mcp-server-projection'; -// May contain unused imports in some cases -// @ts-ignore import type { ParallelBranchResult } from './parallel-branch-result'; // May contain unused imports in some cases // @ts-ignore import type { PermissionLevel } from './permission-level'; // May contain unused imports in some cases // @ts-ignore -import type { SkillsProjection } from './skills-projection'; -// May contain unused imports in some cases -// @ts-ignore import type { StageCompletion } from './stage-completion'; // May contain unused imports in some cases // @ts-ignore @@ -66,12 +57,6 @@ import type { StageTiming } from './stage-timing'; import type { StageToolBatchProjection } from './stage-tool-batch-projection'; // May contain unused imports in some cases // @ts-ignore -import type { SubAgentProjection } from './sub-agent-projection'; -// May contain unused imports in some cases -// @ts-ignore -import type { TodoListProjection } from './todo-list-projection'; -// May contain unused imports in some cases -// @ts-ignore import type { ToolSummary } from './tool-summary'; /** @@ -120,25 +105,11 @@ export interface StageProjection { 'tool_batch'?: StageToolBatchProjection | null; 'usage': BilledTokenCounts; 'model'?: BillingModelRef | null; - 'todos'?: TodoListProjection | null; - /** - * Subagents spawned by this stage, in replay/insertion order. - */ - 'subagents'?: Array; - /** - * Agent skills discovered and activated during this stage. - */ - 'skills'?: SkillsProjection; 'permission_level'?: PermissionLevel | null; /** * Effective model-callable tools exposed to this agent stage session. Tool parameter schemas are intentionally omitted from this projection. */ 'agent_tools'?: Array; - /** - * MCP servers observed by this stage. - */ - 'mcp_servers'?: Array; - 'context_window'?: ContextWindowSnapshot | null; 'inference'?: StageInferenceProjection | null; /** * Start of an external ACP agent process, if one is running. ACP agents do not expose Fabro\'s internal LLM brackets, so the process lifetime supplies their live inference estimate. diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-projection.ts b/lib/packages/fabro-api-client/src/models/sub-agent-projection.ts deleted file mode 100644 index 4d1b22a0f..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-projection.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { SubAgentStatus } from './sub-agent-status'; - -/** - * Current projected state for one subagent spawned by an agent stage. - */ -export interface SubAgentProjection { - 'agent_id': string; - 'depth': number; - 'task': string; - 'status': SubAgentStatus; -} diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-status-closed.ts b/lib/packages/fabro-api-client/src/models/sub-agent-status-closed.ts deleted file mode 100644 index 114cb14ca..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-status-closed.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface SubAgentStatusClosed { - 'kind': SubAgentStatusClosedKindEnum; -} - -export const SubAgentStatusClosedKindEnum = { - CLOSED: 'closed' -} as const; - -export type SubAgentStatusClosedKindEnum = typeof SubAgentStatusClosedKindEnum[keyof typeof SubAgentStatusClosedKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-status-completed.ts b/lib/packages/fabro-api-client/src/models/sub-agent-status-completed.ts deleted file mode 100644 index b5c186fb3..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-status-completed.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface SubAgentStatusCompleted { - 'kind': SubAgentStatusCompletedKindEnum; - 'success': boolean; - 'turns_used': number; -} - -export const SubAgentStatusCompletedKindEnum = { - COMPLETED: 'completed' -} as const; - -export type SubAgentStatusCompletedKindEnum = typeof SubAgentStatusCompletedKindEnum[keyof typeof SubAgentStatusCompletedKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-status-failed.ts b/lib/packages/fabro-api-client/src/models/sub-agent-status-failed.ts deleted file mode 100644 index cc08b4a3a..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-status-failed.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface SubAgentStatusFailed { - 'kind': SubAgentStatusFailedKindEnum; - 'error': any; -} - -export const SubAgentStatusFailedKindEnum = { - FAILED: 'failed' -} as const; - -export type SubAgentStatusFailedKindEnum = typeof SubAgentStatusFailedKindEnum[keyof typeof SubAgentStatusFailedKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-status-running.ts b/lib/packages/fabro-api-client/src/models/sub-agent-status-running.ts deleted file mode 100644 index 9f47c69d1..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-status-running.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface SubAgentStatusRunning { - 'kind': SubAgentStatusRunningKindEnum; -} - -export const SubAgentStatusRunningKindEnum = { - RUNNING: 'running' -} as const; - -export type SubAgentStatusRunningKindEnum = typeof SubAgentStatusRunningKindEnum[keyof typeof SubAgentStatusRunningKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/sub-agent-status.ts b/lib/packages/fabro-api-client/src/models/sub-agent-status.ts deleted file mode 100644 index 1d88f804b..000000000 --- a/lib/packages/fabro-api-client/src/models/sub-agent-status.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { SubAgentStatusClosed } from './sub-agent-status-closed'; -// May contain unused imports in some cases -// @ts-ignore -import type { SubAgentStatusCompleted } from './sub-agent-status-completed'; -// May contain unused imports in some cases -// @ts-ignore -import type { SubAgentStatusFailed } from './sub-agent-status-failed'; -// May contain unused imports in some cases -// @ts-ignore -import type { SubAgentStatusRunning } from './sub-agent-status-running'; - -/** - * @type SubAgentStatus - * Projected lifecycle status for a subagent. - */ -export type SubAgentStatus = { kind: 'closed' } & SubAgentStatusClosed | { kind: 'completed' } & SubAgentStatusCompleted | { kind: 'failed' } & SubAgentStatusFailed | { kind: 'running' } & SubAgentStatusRunning; From 393424b05ec70f45c0fae42ea4f7595b55b04bbe Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 13 Sep 2026 08:13:10 -0600 Subject: [PATCH 08/11] Show the agent sidebar from stage.agent The stage insights sidebar reads every session fact from the coding agent's fold: the root agent's todo list with subagent lists counted apart, MCP status derived from disconnected, error, and tools, skills, and new Files and Subagents sections, a failover badge naming the route the session moved to and why it stopped, and a compactions row under the context window. The run state refreshes on the agent's own events those sections read. Co-Authored-By: Claude Fable 5.1 --- .../stage-insights-sidebar.test.tsx | 254 +++++++++++--- .../app/components/stage-insights-sidebar.tsx | 318 ++++++++++++++++-- apps/fabro-web/app/lib/run-events.test.tsx | 24 ++ apps/fabro-web/app/lib/run-events.ts | 22 +- 4 files changed, 534 insertions(+), 84 deletions(-) diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx index fff871494..c69811c92 100644 --- a/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx @@ -3,17 +3,25 @@ import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; import { + AgentSessionActivity, + CompactionReason, ContextWindowCategory, ContextWindowCountMethod, ContextWindowStaleness, + FailoverContinuation, + FailoverStop, SkillActivationSource, TodoListKind, TodoStatus, ToolCategory, } from "@qltysh/fabro-api-client"; import type { + AgentErrorData, + AgentSessionProjection, + McpToolSummary, StageContextWindow, StageProjection, + TokenUsage, } from "@qltysh/fabro-api-client"; import { StageInsightsSidebar } from "./stage-insights-sidebar"; @@ -33,6 +41,58 @@ function makeStage(overrides: Partial = {}): StageProjection { }; } +const NO_TOKENS: TokenUsage = { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }; + +/** The coding agent's fold of a stage that has seen nothing yet. */ +function makeAgent(overrides: Partial = {}): AgentSessionProjection { + return { + root_session_id: "ses_root", + route: { provider: "anthropic", model: "claude-opus-4-7" }, + activity: AgentSessionActivity.RUNNING, + usage: NO_TOKENS, + cost_usd_micros: null, + messages: 0, + descendants: {}, + context_window: null, + tools: {}, + mcp_servers: {}, + skills: { available: [], activated: [] }, + subagent_counts: { spawned: 0, turns_started: 0, completed: 0, failed: 0, closed: 0 }, + todos: {}, + subagents: [], + compactions: [], + failovers: [], + files_touched: [], + last_file_touched: null, + prompts: 1, + prompt: { + completed: false, + usage: NO_TOKENS, + cost_usd_micros: null, + messages: 0, + context_window: null, + tool_calls: 0, + descendants: {}, + subagents: { spawned: 0, turns_started: 0, completed: 0, failed: 0, closed: 0 }, + compactions: [], + files_touched: [], + last_file_touched: null, + }, + ...overrides, + }; +} + +function mcpTools(count: number): McpToolSummary[] { + return Array.from({ length: count }, (_, i) => ({ + name: `mcp__server__tool_${i}`, + original_name: `tool_${i}`, + })); +} + +function agentError(message: string): AgentErrorData { + return { kind: "llm", message }; +} + function makeContextWindow(overrides: Partial = {}): StageContextWindow { return { stage_id: "implement@1", @@ -60,13 +120,13 @@ function makeContextWindow(overrides: Partial = {}): StageCo // bun:test runs in a node-like env without a DOM, so shim `window.localStorage` // once — the sidebar feature-detects `typeof window` to decide whether to // persist collapse state. Seeding the shim lets us open default-collapsed -// sections (Skills, MCPs) in the assertions below. Other test files (e.g. +// sections in the assertions below. Other test files (e.g. // services-panel.test.tsx) install their own window and rely on // `delete globalThis.window` cleanup, so this descriptor stays configurable. let restoreWindow: (() => void) | null = null; beforeAll(() => { const store = new Map(); - for (const key of ["todos", "context", "tools", "skills", "mcps"]) { + for (const key of ["todos", "context", "files", "subagents", "tools", "skills", "mcps"]) { store.set(`fabro:stage-insights-section:${key}`, "1"); } const stub = { @@ -108,23 +168,34 @@ function render(stage: StageProjection | undefined, contextWindow: StageContextW } describe("StageInsightsSidebar", () => { - test("renders todo done/total ratio", () => { + test("renders the root agent's todo list and counts the subagent lists apart", () => { const stage = makeStage({ - todos: { - kind: TodoListKind.ANTHROPIC_TASKS, - list_id: "anthropic_tasks:root", - items: [ - { id: "1", status: TodoStatus.COMPLETED, order: 0, subject: "Plan refactor" }, - { id: "2", status: TodoStatus.COMPLETED, order: 1, subject: "Add tests" }, - { id: "3", status: TodoStatus.IN_PROGRESS, order: 2, subject: "Land migration" }, - { id: "4", status: TodoStatus.PENDING, order: 3, subject: "Review with Kieran" }, - ], - }, + agent: makeAgent({ + todos: { + "anthropic_tasks:ses_root": { + kind: TodoListKind.ANTHROPIC_TASKS, + list_id: "anthropic_tasks:ses_root", + items: [ + { id: "1", status: TodoStatus.COMPLETED, order: 0, subject: "Plan refactor" }, + { id: "2", status: TodoStatus.COMPLETED, order: 1, subject: "Add tests" }, + { id: "3", status: TodoStatus.IN_PROGRESS, order: 2, subject: "Land migration" }, + { id: "4", status: TodoStatus.PENDING, order: 3, subject: "Review with Kieran" }, + ], + }, + "openai_plan:ses_child": { + kind: TodoListKind.OPENAI_PLAN, + list_id: "openai_plan:ses_child", + items: [{ id: "c1", status: TodoStatus.PENDING, order: 0, subject: "Child plan step" }], + }, + }, + }), }); const dom = render(stage, null); expect(dom).toContain("2/4"); expect(dom).toContain("Plan refactor"); expect(dom).toContain("Land migration"); + expect(dom).not.toContain("Child plan step"); + expect(dom).toContain("+1 subagent list"); }); test("renders context window percent and breakdown labels", () => { @@ -147,6 +218,24 @@ describe("StageInsightsSidebar", () => { expect(dom).not.toContain("31%"); }); + test("renders the compactions row under the context window", () => { + const stage = makeStage({ + agent: makeAgent({ + compactions: [ + { + reason: CompactionReason.THRESHOLD, + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 3, + }, + ], + }), + }); + const dom = render(stage, makeContextWindow()); + expect(dom).toContain("1 compaction · last kept 6 of 20 turns"); + }); + test("renders projected agent tool names and invoked state", () => { const dom = render( makeStage({ @@ -179,29 +268,16 @@ describe("StageInsightsSidebar", () => { expect(dom).toContain("Used"); }); - test("renders mcp server used/total count, marks invoked servers as 'used'", () => { + test("derives mcp server status from the fold and marks invoked servers as 'used'", () => { const dom = render( makeStage({ - mcp_servers: [ - { - server_name: "context7", - tool_count: 12, - status: { kind: "ready", tools: [] }, - invoked: true, + agent: makeAgent({ + mcp_servers: { + context7: { tools: mcpTools(12), error: null, invoked: true }, + filesystem: { tools: mcpTools(3), error: null, invoked: false }, + atlassian: { tools: [], error: "auth failed", invoked: false }, }, - { - server_name: "filesystem", - tool_count: 3, - status: { kind: "ready", tools: [] }, - invoked: false, - }, - { - server_name: "atlassian", - tool_count: 0, - status: { kind: "failed", error: "auth failed" }, - invoked: false, - }, - ], + }), }), null, ); @@ -213,19 +289,17 @@ describe("StageInsightsSidebar", () => { expect(dom).toContain("3 tools"); expect(dom).toContain("atlassian"); expect(dom).toContain("Failed"); + expect(dom).toContain("auth failed"); }); test("renders a disconnected mcp server as disconnected, still counted as used", () => { const dom = render( makeStage({ - mcp_servers: [ - { - server_name: "github", - tool_count: 4, - status: { kind: "disconnected", error: "transport closed" }, - invoked: true, + agent: makeAgent({ + mcp_servers: { + github: { tools: mcpTools(4), error: null, invoked: true, disconnected: "transport closed" }, }, - ], + }), }), null, ); @@ -238,18 +312,20 @@ describe("StageInsightsSidebar", () => { test("shows skill activated/available ratio with source label", () => { const dom = render( makeStage({ - skills: { - activated: [ - { name: "frontend-design", source: SkillActivationSource.SLASH }, - { name: "debug", source: SkillActivationSource.TOOL }, - ], - available: [ - { name: "frontend-design", description: "" }, - { name: "debug", description: "" }, - { name: "tdd", description: "" }, - { name: "ce-review", description: "" }, - ], - }, + agent: makeAgent({ + skills: { + activated: [ + { name: "frontend-design", source: SkillActivationSource.SLASH }, + { name: "debug", source: SkillActivationSource.TOOL }, + ], + available: [ + { name: "frontend-design", description: "" }, + { name: "debug", description: "" }, + { name: "tdd", description: "" }, + { name: "ce-review", description: "" }, + ], + }, + }), }), null, ); @@ -259,9 +335,83 @@ describe("StageInsightsSidebar", () => { expect(dom).toContain("+2 more available"); }); + test("lists the files the session tree wrote and marks the last one", () => { + const dom = render( + makeStage({ + agent: makeAgent({ + files_touched: ["/workspace/src/lib.rs", "/workspace/src/main.rs"], + last_file_touched: "/workspace/src/main.rs", + }), + }), + null, + ); + expect(dom).toContain("src/lib.rs"); + expect(dom).toContain("src/main.rs"); + expect(dom).toContain("last"); + // Full paths stay in the tooltip. + expect(dom).toContain("/workspace/src/lib.rs"); + }); + + test("renders subagents with their status", () => { + const dom = render( + makeStage({ + agent: makeAgent({ + subagents: [ + { agent_id: "sub-1", depth: 1, task: "Review the module", status: { status: "completed", success: true, turns_used: 3 } }, + { agent_id: "sub-2", depth: 1, task: "Check the tests", status: { status: "failed", error: agentError("boom") } }, + { agent_id: "sub-3", depth: 1, task: "Still looking", status: { status: "running" } }, + ], + }), + }), + null, + ); + expect(dom).toContain("2/3"); + expect(dom).toContain("Review the module"); + expect(dom).toContain("3 turns"); + expect(dom).toContain("Check the tests"); + expect(dom).toContain("Failed"); + expect(dom).toContain("boom"); + expect(dom).toContain("Still looking"); + expect(dom).toContain("running"); + }); + + test("shows a failover badge with the route the session moved to and why it stopped", () => { + const dom = render( + makeStage({ + agent: makeAgent({ + route: { provider: "openai", model: "gpt-5.4" }, + failovers: [ + { + from: "anthropic/claude-opus-4-7", + to: "openai/gpt-5.4", + attempt: 1, + error: agentError("rate limited"), + usage: NO_TOKENS, + inference_ms: 120, + tool_ms: 30, + continuation: FailoverContinuation.CONTINUE_TURN, + }, + ], + failover_stopped: { + route: "openai/gpt-5.4", + attempt: 1, + reason: FailoverStop.EXHAUSTED, + error: agentError("key revoked"), + }, + }), + }), + null, + ); + expect(dom).toContain("Moved to openai/gpt-5.4 after 1 attempt"); + expect(dom).toContain("Stopped: routes exhausted"); + expect(dom).toContain("rate limited"); + expect(dom).toContain("key revoked"); + }); + test("renders empty-friendly content when stage projection is missing", () => { const dom = render(undefined, null); // sidebar still renders even with no data expect(dom).toContain("Agent"); + expect(dom).not.toContain("Moved to"); }); }); diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx index 65323135e..f8b0b5428 100644 --- a/apps/fabro-web/app/components/stage-insights-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx @@ -11,24 +11,33 @@ import { XCircleIcon, } from "@heroicons/react/24/solid"; import { + ArrowsRightLeftIcon, CheckBadgeIcon, CommandLineIcon, + DocumentTextIcon, ListBulletIcon, PuzzlePieceIcon, ServerStackIcon, Squares2X2Icon, + UserGroupIcon, WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; import { ContextWindowCategory, ContextWindowStaleness, + FailoverStop, SkillActivationSource, TodoStatus, } from "@qltysh/fabro-api-client"; import type { - ActivatedSkill, + AgentSessionActivatedSkill, + AgentSessionCompaction, + AgentSessionFailoverStop, + AgentSessionMcpServer, + AgentSessionProjection, + AgentSessionRouteFailover, + AgentSessionSubagent, ContextWindowBreakdownItem, - McpServerProjection, SkillSummary, StageContextWindow, StageProjection, @@ -41,14 +50,16 @@ import { formatTokenCount } from "../lib/format"; const COLLAPSED_STORAGE_KEY = "fabro:stage-insights-sidebar-collapsed"; const SECTION_STORAGE_PREFIX = "fabro:stage-insights-section:"; -type SectionKey = "todos" | "context" | "tools" | "skills" | "mcps"; +type SectionKey = "todos" | "context" | "files" | "subagents" | "tools" | "skills" | "mcps"; const SECTIONS_DEFAULT_OPEN: Record = { - todos: true, - context: false, - tools: false, - skills: false, - mcps: false, + todos: true, + context: false, + files: false, + subagents: false, + tools: false, + skills: false, + mcps: false, }; export interface StageInsightsSidebarProps { @@ -58,6 +69,11 @@ export interface StageInsightsSidebarProps { contextWindow: StageContextWindow | null | undefined; } +/** + * The agent stage's sidebar. Everything about the agent's session comes from + * `stage.agent`, the coding agent's own fold of the stage's events; the + * stage itself contributes the tool catalog it was handed. + */ export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSidebarProps) { const [collapsed, setCollapsed] = useState(loadStoredCollapsed); const toggleCollapsed = useCallback(() => { @@ -68,14 +84,24 @@ export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSide }); }, []); - const todos = stage?.todos ?? null; - const skills = stage?.skills ?? { activated: [], available: [] }; + const agent = stage?.agent ?? null; + const todoLists = agent ? Object.values(agent.todos) : []; + const todos = rootTodoList(agent); + const otherTodoLists = todos ? todoLists.length - 1 : todoLists.length; + const skills = agent?.skills ?? { activated: [], available: [] }; const agentTools = stage?.agent_tools ?? []; - const mcpServers = stage?.mcp_servers ?? []; + const mcpServers = agent ? mcpServerRows(agent.mcp_servers) : []; + const files = agent?.files_touched ?? []; + const lastFile = agent?.last_file_touched ?? null; + const subagents = agent?.subagents ?? []; + const failovers = agent?.failovers ?? []; + const failoverStopped = agent?.failover_stopped ?? null; + const compactions = agent?.compactions ?? []; const todoStats = countTodoStats(todos); const activatedSkillNames = new Set(skills.activated.map((s) => s.name)); const invokedToolCount = agentTools.filter((tool) => tool.invoked).length; + const finishedSubagents = subagents.filter((s) => s.status.status !== "running").length; return (