diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts index 6c03541c0..4178c78d3 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts @@ -173,11 +173,11 @@ describe("parseFanInOutcome", () => { const events: EventEnvelope[] = [ envelope(1, { event: "stage.prompt", - properties: { mode: "fan_in", text: "rank these" }, + properties: { mode: "fan_in", text: "rank these", model: "claude-sonnet-4-6" }, }), envelope(2, { event: "prompt.completed", - properties: { response: "branch-a wins", model: "claude-sonnet-4-6" }, + properties: { response: "branch-a wins", model: "ignored-downstream-model" }, }), ]; const outcome = parseFanInOutcome(events, "Selected best candidate: branch-a"); diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index 561779a55..35af48b31 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -215,12 +215,14 @@ export function parseFanInOutcome(events: EventEnvelope[], notes: string | null) for (const event of events) { if (event.event === "stage.prompt") { const mode = getString(event.properties ?? {}, "mode"); - if (mode === "fan_in") hasReducerTranscript = true; + if (mode === "fan_in") { + hasReducerTranscript = true; + const model = getString(event.properties ?? {}, "model"); + if (model) reducerModel = model; + } } if (event.event === "prompt.completed") { hasReducerTranscript = true; - const model = getString(event.properties ?? {}, "model"); - if (model) reducerModel = model; } } return { diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx index 1f25de404..b25ba4d25 100644 --- a/apps/fabro-web/app/components/stage-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.test.tsx @@ -14,6 +14,7 @@ function makeStage(overrides: Partial = {}): Stage { status: "running", duration: "--", startedAt: null, + providerUsed: null, ...overrides, }; } diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index f9718077e..6e83ce076 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -1,6 +1,6 @@ import { type ComponentType, type ReactNode, useCallback, useState } from "react"; import { Link } from "react-router"; -import type { StageHandler, StageState } from "@qltysh/fabro-api-client"; +import type { StageHandler, StageModelUsage, StageState } from "@qltysh/fabro-api-client"; import { ArrowPathIcon, CheckCircleIcon, @@ -31,6 +31,7 @@ export interface Stage { nodeId: string; visit: number; startedAt: string | null; + providerUsed: StageModelUsage | null; } export const statusConfig: Record; color: string }> = { diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index a9a6cad04..f6f64b298 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -14,6 +14,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage { status, duration: "--", startedAt: null, + providerUsed: null, }; } @@ -29,6 +30,12 @@ describe("mapRunStagesToSidebarStages", () => { wall_time_ms: 12500, node_id: "apply", visit: 1, + provider_used: { + mode: "prompt", + provider: "openai", + model: "gpt-5.5", + reasoning_effort: "high", + }, }, { id: "apply-changes@2", @@ -49,6 +56,12 @@ describe("mapRunStagesToSidebarStages", () => { expect(result[0].handler).toBe("command"); expect(result[0].nodeId).toBe("apply"); expect(result[0].visit).toBe(1); + expect(result[0].providerUsed).toEqual({ + mode: "prompt", + provider: "openai", + model: "gpt-5.5", + reasoning_effort: "high", + }); expect(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); @@ -197,4 +210,4 @@ describe("aggregateGraphNodeStatus", () => { latestStageId: "apply@1", }); }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index e7cd62757..d224768ef 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -74,6 +74,7 @@ export function mapRunStagesToSidebarStages( ? formatDurationMs(stage.wall_time_ms) : "--", startedAt: stage.started_at ?? null, + providerUsed: stage.provider_used ?? null, })); } @@ -112,4 +113,4 @@ export function aggregateGraphNodeStatus(stages: readonly Stage[]): Map< result.set(nodeId, { displayStatus: display.status, latestStageId: latestStage.id }); } return result; -} \ No newline at end of file +} diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 86cb6f7e4..e75af2206 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -732,8 +732,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 20a36c9cd..3fa5bf3ee 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -5,9 +5,10 @@ import { buildThreadDnaItems, eventsTabLabel, eventsToActivity, - extractStageModel, + formatStageModelUsageLabel, groupConsecutiveTools, selectStageRenderer, + stageModelUsageTitle, } from "./run-stages"; function envelope(seq: number, partial: Partial): EventEnvelope { @@ -381,60 +382,37 @@ describe("eventsToActivity", () => { ]); }); - test("extractStageModel pulls model from agent.session.activated, ignoring other stages", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.session.activated", - stage_id: "simplify@1", - node_id: "simplify", - properties: { provider: "anthropic", model: "claude-sonnet-4-5" }, + test("formatStageModelUsageLabel includes reasoning effort when present", () => { + expect( + formatStageModelUsageLabel({ + mode: "agent", + provider: "openai", + model: "gpt-5.5", + reasoning_effort: "high", + speed: "fast", }), - envelope(2, { - event: "agent.session.activated", - stage_id: "verify@1", - node_id: "verify", - properties: { provider: "openai", model: "gpt-5" }, - }), - ]; - - expect(extractStageModel(events, "simplify@1")).toBe("claude-sonnet-4-5"); - expect(extractStageModel(events, "verify@1")).toBe("gpt-5"); - expect(extractStageModel(events, "fmt@1")).toBe(null); + ).toBe("gpt-5.5 · high"); }); - test("extractStageModel uses latest stage event with a model", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "stage.prompt", - stage_id: "agent@1", - node_id: "agent", - properties: { model: "claude-opus-4-5" }, + test("stageModelUsageTitle includes provider and speed details", () => { + expect( + stageModelUsageTitle({ + mode: "prompt", + provider: "anthropic", + model: "claude-sonnet-4-6", + speed: "standard", }), - envelope(2, { - event: "agent.session.activated", - stage_id: "agent@1", - node_id: "agent", - properties: { - provider: "anthropic", - model: "claude-sonnet-4-6", - }, - }), - ]; - - expect(extractStageModel(events, "agent@1")).toBe("claude-sonnet-4-6"); + ).toBe("Provider: anthropic\nModel: claude-sonnet-4-6\nSpeed: standard"); }); - test("extractStageModel ignores model from unrelated event types", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.message", - stage_id: "agent@1", - node_id: "agent", - properties: { text: "hi", model: "should-be-ignored" }, + test("formatStageModelUsageLabel returns null when the projection has no model", () => { + expect( + formatStageModelUsageLabel({ + mode: "acp", + provider: null, + model: null, }), - ]; - - expect(extractStageModel(events, "agent@1")).toBe(null); + ).toBe(null); }); test("ignores unknown event types and events for other stages", () => { diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index b5728edd0..5974601d5 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -61,7 +61,7 @@ import { import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; -import type { EventEnvelope, StageHandler } from "@qltysh/fabro-api-client"; +import type { EventEnvelope, StageHandler, StageModelUsage } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; @@ -539,23 +539,27 @@ export function buildThreadDnaItems( return out; } -const STAGE_MODEL_EVENT_NAMES = new Set([ - "stage.prompt", - "agent.session.activated", -]); - -export function extractStageModel( - events: EventEnvelope[], - stageId: string, +export function formatStageModelUsageLabel( + providerUsed: StageModelUsage | null | undefined, ): string | null { - let model: string | null = null; - for (const e of events) { - if (activityEventStageId(e) !== stageId) continue; - if (!e.event || !STAGE_MODEL_EVENT_NAMES.has(e.event)) continue; - const candidate = getString(e.properties ?? {}, "model"); - if (candidate) model = candidate; + const model = providerUsed?.model; + if (!model) return null; + const effort = providerUsed.reasoning_effort; + return effort ? `${model} · ${effort}` : model; +} + +export function stageModelUsageTitle( + providerUsed: StageModelUsage | null | undefined, +): string { + if (!providerUsed) return "LLM model used for this stage"; + const parts: string[] = []; + if (providerUsed.provider) parts.push(`Provider: ${providerUsed.provider}`); + if (providerUsed.model) parts.push(`Model: ${providerUsed.model}`); + if (providerUsed.reasoning_effort) { + parts.push(`Reasoning effort: ${providerUsed.reasoning_effort}`); } - return model; + if (providerUsed.speed) parts.push(`Speed: ${providerUsed.speed}`); + return parts.length ? parts.join("\n") : "LLM model used for this stage"; } function turnLabel(turn: TurnType): string { @@ -1226,7 +1230,7 @@ function EventsToolbar({ onSearchChange, filteredCount, totalCount, - model, + providerUsed, }: { tab: EventsTab; renderer: StageRenderer; @@ -1242,7 +1246,7 @@ function EventsToolbar({ onSearchChange: (value: string) => void; filteredCount: number; totalCount: number; - model: string | null; + providerUsed: StageModelUsage | null; }) { // Filters apply to: the agent transcript (filter event kinds) and the Debug // tab (filter event categories). Specialized renderers (human, parallel, @@ -1264,6 +1268,11 @@ function EventsToolbar({ onSearchChange(""); } + const modelUsage = useMemo(() => { + const label = formatStageModelUsageLabel(providerUsed); + return label ? { label, title: stageModelUsageTitle(providerUsed) } : null; + }, [providerUsed]); + return (
)} - {model && ( + {modelUsage && ( )} {tab === "primary" && renderer === "command" && commandTurn && ( @@ -1433,14 +1442,6 @@ export default function RunStages() { } return Array.from(set).sort(); }, [debugEvents]); - const stageModel = useMemo( - () => - selectedStageId - ? extractStageModel(stageEventsQuery.data ?? [], selectedStageId) - : null, - [stageEventsQuery.data, selectedStageId], - ); - const filteredDebugEvents = useMemo(() => { const useCategoryFilter = selectedDebugCategories.length > 0; const cats = new Set(selectedDebugCategories); @@ -1510,7 +1511,7 @@ export default function RunStages() { onSearchChange={setSearch} filteredCount={effectiveTab === "primary" ? filteredTurns.length : filteredDebugEvents.length} totalCount={effectiveTab === "primary" ? turns.length : debugEvents.length} - model={stageModel} + providerUsed={selectedStage.providerUsed} /> {effectiveTab === "debug" && (
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 93297cb25..3668b4230 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -5806,6 +5806,16 @@ components: - levels - none + ReasoningEffort: + description: Native reasoning-effort level requested for an LLM call. + type: string + enum: + - low + - medium + - high + - xhigh + - max + ModelFeatures: description: Capability flags for a model. type: object @@ -7703,7 +7713,9 @@ components: - $ref: "#/components/schemas/StageCompletion" - type: "null" provider_used: - type: ["object", "null"] + oneOf: + - $ref: "#/components/schemas/StageModelUsage" + - type: "null" description: Provider and model metadata recorded for the stage attempt. diff: type: ["string", "null"] @@ -7750,6 +7762,31 @@ components: $ref: "#/components/schemas/StageState" description: Lifecycle state of the stage projection. + StageModelUsage: + description: Provider, model, and request-control metadata recorded for a stage attempt. + type: object + required: + - mode + properties: + mode: + type: string + description: Source of the stage's model usage metadata. + example: agent + provider: + type: ["string", "null"] + example: openai + model: + type: ["string", "null"] + example: gpt-5.5 + reasoning_effort: + oneOf: + - $ref: "#/components/schemas/ReasoningEffort" + - type: "null" + speed: + oneOf: + - $ref: "#/components/schemas/BillingSpeed" + - type: "null" + InterviewOption: description: Option stored with an interview question in the event log. type: object @@ -9190,6 +9227,11 @@ components: minimum: 1 description: 1-based visit count; bumped each time the workflow re-enters this node. example: 2 + provider_used: + oneOf: + - $ref: "#/components/schemas/StageModelUsage" + - type: "null" + description: Provider, model, and request controls recorded for the latest stage attempt. started_at: type: ["string", "null"] format: date-time diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index d999666e4..71977df82 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -458,6 +458,16 @@ impl Session { self.provider_profile.model() } + #[must_use] + pub fn reasoning_effort(&self) -> Option { + self.config.reasoning_effort + } + + #[must_use] + pub fn speed(&self) -> Option { + self.config.speed + } + /// Initialize session by discovering project docs and capturing environment /// context. Call before `process_input`. /// diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 3c24932b4..bceed7000 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -342,6 +342,7 @@ fn main() { ("StageHandler", "fabro_types::StageHandler", &[]), ("StageState", "fabro_types::StageState", &[]), ("CommandTermination", "fabro_types::CommandTermination", &[]), + ("StageModelUsage", "fabro_types::StageModelUsage", &[]), ("StageProjection", "fabro_types::StageProjection", &[]), ("SecretMetadata", "fabro_types::SecretMetadata", &[]), ("InterviewOption", "fabro_types::InterviewOption", &[]), @@ -370,6 +371,7 @@ fn main() { "fabro_model::ReasoningEffortFeature", &[], ), + ("ReasoningEffort", "fabro_model::ReasoningEffort", &[]), ("ModelFeatures", "fabro_model::ModelFeatures", &[]), ("ModelCosts", "fabro_model::ModelCosts", &[]), ("ModelTestMode", "fabro_model::ModelTestMode", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 51da80f82..4767d21d6 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -16,7 +16,7 @@ mod generated { pub mod types { pub use fabro_model::{ Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, - Provider, ReasoningEffortFeature, Speed as BillingSpeed, + Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed, }; pub use fabro_types::settings::ServerNamespace; pub use fabro_types::settings::server::{ @@ -47,8 +47,8 @@ pub mod types { SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, StageCompletion, StageHandler, - StageOutcome, StageProjection, StageState, SystemActorKind, TurnId, UserPrincipal, - WorkflowSettings, + StageModelUsage, StageOutcome, StageProjection, StageState, SystemActorKind, TurnId, + UserPrincipal, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs b/lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs new file mode 100644 index 000000000..1d541cdd7 --- /dev/null +++ b/lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs @@ -0,0 +1,62 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::{ + ReasoningEffort as ApiReasoningEffort, StageModelUsage as ApiStageModelUsage, +}; +use fabro_model::{ReasoningEffort, Speed}; +use fabro_types::StageModelUsage; +use serde_json::json; + +#[test] +fn reasoning_effort_reuses_canonical_type() { + assert_same_type::(); +} + +#[test] +fn reasoning_effort_round_trips_openapi_values() { + for (value, effort) in [ + ("low", ReasoningEffort::Low), + ("medium", ReasoningEffort::Medium), + ("high", ReasoningEffort::High), + ("xhigh", ReasoningEffort::XHigh), + ("max", ReasoningEffort::Max), + ] { + assert_eq!( + serde_json::from_value::(json!(value)).unwrap(), + effort + ); + assert_eq!(serde_json::to_value(effort).unwrap(), json!(value)); + } +} + +#[test] +fn stage_model_usage_reuses_canonical_type() { + assert_same_type::(); +} + +#[test] +fn stage_model_usage_round_trips_representative_json() { + let value = json!({ + "mode": "agent", + "provider": "openai", + "model": "gpt-5.5", + "reasoning_effort": "high", + "speed": "fast" + }); + + let usage: StageModelUsage = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(usage.mode, StageModelUsage::MODE_AGENT); + assert_eq!(usage.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(usage.speed, Some(Speed::Fast)); + assert_eq!(serde_json::to_value(usage).unwrap(), value); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs index 762bb9120..bc746d15e 100644 --- a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs @@ -21,7 +21,13 @@ fn stage_projection_round_trips_representative_json() { "failure_reason": null, "timestamp": "2026-04-29T12:34:56Z" }, - "provider_used": { "provider": "openai", "model": "gpt-5.2" }, + "provider_used": { + "mode": "prompt", + "provider": "openai", + "model": "gpt-5.2", + "reasoning_effort": "high", + "speed": "fast" + }, "diff": "diff --git a/file b/file", "script_invocation": { "command": "cargo test" }, "script_timing": { "duration_ms": 42 }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/acp.rs b/lib/crates/fabro-cli/tests/it/workflow/acp.rs index bf841a557..9cd0845ae 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/acp.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/acp.rs @@ -78,10 +78,12 @@ fn acp_backend_workflow() { assert!( stages.values().any(|stage| { stage["provider_used"]["mode"] == "acp" - && stage["provider_used"]["config_name"] == "fake" - && stage["provider_used"].get("provider").is_none() + && stage["provider_used"]["provider"] == "acp" + && stage["provider_used"]["model"] == "fake" + && stage["provider_used"].get("reasoning_effort").is_none() + && stage["provider_used"].get("speed").is_none() }), - "run projection should include ACP process metadata without provider: {stages:?}" + "run projection should include ACP model usage metadata: {stages:?}" ); } diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index 8345b84a4..3c7b5a070 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -100,10 +100,11 @@ impl RunDump { push_json_entry_path(&mut entries, &base.join("status.json"), completion)?; } if let Some(provider_used) = stage.provider_used.as_ref() { - entries.push(RunDumpEntry::json_path( + push_json_entry_path( + &mut entries, &base.join("provider_used.json"), - provider_used.clone(), - )); + provider_used, + )?; } if let Some(diff) = stage.diff.as_ref() { entries.push(RunDumpEntry::text_path( @@ -473,8 +474,8 @@ mod tests { use fabro_types::run::RunSpec; use fabro_types::{ Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, SandboxProvider, - StageCompletion, StageOutcome, StartRecord, SuccessReason, WorkflowSettings, - first_event_seq, fixtures, + StageCompletion, StageModelUsage, StageOutcome, StartRecord, SuccessReason, + WorkflowSettings, first_event_seq, fixtures, }; use futures::executor; @@ -585,7 +586,13 @@ mod tests { .single() .unwrap(), }); - stage.provider_used = Some(serde_json::json!({ "provider": "openai" })); + stage.provider_used = Some(StageModelUsage { + mode: StageModelUsage::MODE_PROMPT.to_string(), + provider: Some("openai".to_string()), + model: None, + reasoning_effort: None, + speed: None, + }); stage.diff = Some("diff --git a/a b/a".to_string()); stage.script_invocation = Some(serde_json::json!({ "command": "cargo test" })); stage.script_timing = Some(serde_json::json!({ "duration_ms": 10 })); @@ -638,8 +645,14 @@ mod tests { assert_eq!(node.diff, None); assert_eq!(node.output, None); assert_eq!( - node.provider_used, - Some(serde_json::json!({ "provider": "openai" })) + node.provider_used.as_ref().map(|usage| usage.mode.as_str()), + Some(StageModelUsage::MODE_PROMPT) + ); + assert_eq!( + node.provider_used + .as_ref() + .and_then(|usage| usage.provider.as_deref()), + Some("openai") ); } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index c5814e2b7..c3f2befa1 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1354,6 +1354,7 @@ mod runs { Some(72_000), None, StageHandler::Command, + None, ), run_stage_from_stage_id( &StageId::new("propose-changes", 1), @@ -1362,6 +1363,7 @@ mod runs { Some(154_000), None, StageHandler::Agent, + None, ), run_stage_from_stage_id( &StageId::new("review-changes", 1), @@ -1370,6 +1372,7 @@ mod runs { Some(45_000), None, StageHandler::Agent, + None, ), run_stage_from_stage_id( &StageId::new("apply-changes", 1), @@ -1378,6 +1381,7 @@ mod runs { Some(118_000), None, StageHandler::Command, + None, ), run_stage_from_stage_id( &StageId::new("apply-changes", 2), @@ -1386,6 +1390,7 @@ mod runs { None, None, StageHandler::Command, + None, ), ] } @@ -1432,6 +1437,8 @@ mod runs { mode: None, provider: None, model: None, + reasoning_effort: None, + speed: None, }), ), make_envelope( diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 42759a84c..9fd3b990f 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -83,7 +83,7 @@ use fabro_types::settings::{InterpString, RunNamespace}; use fabro_types::{ AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId, PairMessageId, PairTarget, Principal, PullRequestLink, QuestionType, RunBlobId, - RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability, + RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability, StageModelUsage, }; use fabro_util::error::{ SharedError, collect_causes, render_compact_with_causes, render_with_causes, @@ -1082,6 +1082,7 @@ pub(crate) fn run_stage_from_stage_id( wall_time_ms: Option, started_at: Option>, handler: StageHandler, + provider_used: Option, ) -> RunStage { RunStage { id: stage_id.to_string(), @@ -1092,6 +1093,7 @@ pub(crate) fn run_stage_from_stage_id( node_id: stage_id.node_id().to_string(), visit: std::num::NonZeroU32::new(stage_id.visit()) .expect("StageId stores a non-zero visit"), + provider_used, started_at, } } diff --git a/lib/crates/fabro-server/src/server/handler/billing.rs b/lib/crates/fabro-server/src/server/handler/billing.rs index c384287cd..a40377c3c 100644 --- a/lib/crates/fabro-server/src/server/handler/billing.rs +++ b/lib/crates/fabro-server/src/server/handler/billing.rs @@ -57,6 +57,7 @@ async fn list_run_stages( stage.live_wall_time_ms(now), stage.started_at, handler, + stage.provider_used.clone(), ) }) .collect::>(); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 548c65c63..ee3cf5780 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -16,12 +16,12 @@ use fabro_interview::{ }; use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest}; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelRef, ProviderId, Speed}; +use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed}; use fabro_types::settings::ServerAuthMethod; use fabro_types::{ AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec, - SandboxProvider, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, + SandboxProvider, StageModelUsage, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, }; use fabro_util::check_report::CheckStatus; use httpmock::Method::{GET, POST}; @@ -3528,6 +3528,76 @@ fn stage_entry<'a>(body: &'a serde_json::Value, id: &str) -> &'a serde_json::Val .unwrap_or_else(|| panic!("stage {id} not found in {body:#?}")) } +#[tokio::test] +async fn list_run_stages_includes_stage_model_usage() { + let state = test_app_state_with_isolated_storage(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let run_id = RunId::new(); + + create_durable_run_with_events(&state, run_id, &[ + workflow_event::Event::RunSubmitted { + definition_blob: None, + }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, + ]) + .await; + append_scoped_stage_event( + &state, + run_id, + "prompt", + 1, + &workflow_event::Event::StageStarted { + node_id: "prompt".to_string(), + name: "Prompt".to_string(), + index: 0, + handler_type: "prompt".to_string(), + attempt: 1, + max_attempts: 1, + }, + ) + .await; + append_scoped_stage_event( + &state, + run_id, + "prompt", + 1, + &workflow_event::Event::Prompt { + stage: "prompt".to_string(), + visit: 1, + text: "Summarize".to_string(), + mode: Some(StageModelUsage::MODE_PROMPT.to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.5".to_string()), + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/stages"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + assert_eq!( + stage_entry(&body, "prompt@1")["provider_used"], + json!({ + "mode": "prompt", + "provider": "openai", + "model": "gpt-5.5", + "reasoning_effort": "high", + "speed": "fast" + }) + ); +} + fn test_billed_usage( model_id: &str, input_tokens: i64, @@ -8782,13 +8852,15 @@ fn active_steerable_stage_projection_ignores_stale_deactivation() { let stage_id = StageId::new("agent", 1); let activated_a = workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated { - node_id: "agent".to_string(), - visit: 1, - session_id: "session-a".to_string(), - thread_id: None, - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - capabilities: vec![SessionCapability::Steer], + node_id: "agent".to_string(), + visit: 1, + session_id: "session-a".to_string(), + thread_id: None, + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: None, + speed: None, + capabilities: vec![SessionCapability::Steer], }); update_live_run_from_event(&state, run_id, &activated_a); @@ -8802,13 +8874,15 @@ fn active_steerable_stage_projection_ignores_stale_deactivation() { let activated_b = workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated { - node_id: "agent".to_string(), - visit: 1, - session_id: "session-b".to_string(), - thread_id: None, - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - capabilities: vec![SessionCapability::Steer], + node_id: "agent".to_string(), + visit: 1, + session_id: "session-b".to_string(), + thread_id: None, + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: None, + speed: None, + capabilities: vec![SessionCapability::Steer], }); update_live_run_from_event(&state, run_id, &activated_b); update_live_run_from_event(&state, run_id, &deactivated_a); @@ -8858,13 +8932,15 @@ async fn steer_with_active_acp_session_forwards_to_worker() { update_live_run_from_event(&state, run_id, &started); let activated = workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated { - node_id: "agent".to_string(), - visit: 1, - session_id: "acp-session".to_string(), - thread_id: None, - provider: Some(AgentBackend::Acp.to_string()), - model: None, - capabilities: vec![SessionCapability::Steer], + node_id: "agent".to_string(), + visit: 1, + session_id: "acp-session".to_string(), + thread_id: None, + provider: Some(AgentBackend::Acp.to_string()), + model: None, + reasoning_effort: None, + speed: None, + capabilities: vec![SessionCapability::Steer], }); update_live_run_from_event(&state, run_id, &activated); @@ -8959,13 +9035,15 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() { update_live_run_from_event(&state, run_id, &started); let activated = workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated { - node_id: "agent".to_string(), - visit: 1, - session_id: "acp-session".to_string(), - thread_id: None, - provider: Some(AgentBackend::Acp.to_string()), - model: None, - capabilities: vec![SessionCapability::Steer], + node_id: "agent".to_string(), + visit: 1, + session_id: "acp-session".to_string(), + thread_id: None, + provider: Some(AgentBackend::Acp.to_string()), + model: None, + reasoning_effort: None, + speed: None, + capabilities: vec![SessionCapability::Steer], }); update_live_run_from_event(&state, run_id, &activated); let terminal = acp_event_for_stage(&run_id, &terminal_event); diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 0d8dfaf7d..cf6e0bb97 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -3,22 +3,20 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use fabro_types::run_event::{ - AgentAcpStartedProps, AgentSessionActivatedProps, CheckpointCompletedProps, RunCompletedProps, - RunFailedProps, StageCompletedProps, StagePromptProps, TodoCreatedProps, TodoDeletedProps, - TodoUpdatedProps, + CheckpointCompletedProps, RunCompletedProps, RunFailedProps, StageCompletedProps, + TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps, }; use fabro_types::settings::run::{EnvironmentProvider, RunEnvironmentSettings}; use fabro_types::{ - AgentBackend, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, - Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, Outcome, - PendingInterviewRecord, PullRequestLink, RepositoryRef, Run, RunBillingSummary, - RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, - RunProjection, RunSandbox, RunSandboxRuntime, RunSpec, RunStatus, RunTimestamps, - SandboxProvider, StageCompletion, StageHandler, StageId, StageOutcome, StageProjection, - StageState, StartRecord, TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, + AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, + EventBody, FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord, + PullRequestLink, RepositoryRef, Run, RunBillingSummary, RunControlAction, RunDiff, RunEvent, + RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, + RunSandboxRuntime, RunSpec, RunStatus, RunTimestamps, SandboxProvider, StageCompletion, + StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection, StageState, StartRecord, + TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, }; use fabro_util::error::render_compact_with_causes; -use serde_json::Value; use crate::{Error, EventEnvelope, Result}; @@ -311,7 +309,7 @@ impl RunProjectionReducer for RunProjection { return Ok(()); }; stage.prompt = Some(props.text.clone()); - stage.provider_used = provider_used_from_prompt(props); + stage.provider_used = StageModelUsage::from_prompt_props(props); } EventBody::PromptCompleted(props) => { let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { @@ -375,17 +373,14 @@ impl RunProjectionReducer for RunProjection { else { return Ok(()); }; - if !is_acp_session_activation(props) { - stage.provider_used = Some(provider_used_from_agent_session_activated(props)); - } - } - EventBody::AgentAcpStarted(props) => { - let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) - else { - return Ok(()); - }; - stage.provider_used = Some(provider_used_from_agent_acp_started(props)); + stage.provider_used = Some(StageModelUsage::from_agent_session_activated(props)); } + // `AgentAcpStarted` is the start-of-process signal for an external + // ACP agent. `provider_used` is intentionally sourced from the + // subsequent `AgentSessionActivated` event, which carries the + // canonical provider/model. ACP runs without a steering hub never + // emit activation and so legitimately leave `provider_used` + // unset — matching legacy ACP behavior. EventBody::CommandStarted(props) => { let script_invocation = serde_json::to_value(props).map_err(|err| { Error::InvalidEvent(format!("invalid command.started payload: {err}")) @@ -864,50 +859,6 @@ fn stage_completion_from_outcome( } } -fn provider_used_from_prompt(props: &StagePromptProps) -> Option { - let mut provider_used = serde_json::Map::new(); - if let Some(mode) = props.mode.clone() { - provider_used.insert("mode".to_string(), Value::String(mode)); - } - if let Some(provider) = props.provider.clone() { - provider_used.insert("provider".to_string(), Value::String(provider)); - } - if let Some(model) = props.model.clone() { - provider_used.insert("model".to_string(), Value::String(model)); - } - (!provider_used.is_empty()).then_some(Value::Object(provider_used)) -} - -fn provider_used_from_agent_session_activated(props: &AgentSessionActivatedProps) -> Value { - let mut provider_used = serde_json::Map::new(); - provider_used.insert("mode".to_string(), Value::String("agent".to_string())); - if let Some(provider) = props.provider.clone() { - provider_used.insert("provider".to_string(), Value::String(provider)); - } - if let Some(model) = props.model.clone() { - provider_used.insert("model".to_string(), Value::String(model)); - } - Value::Object(provider_used) -} - -fn is_acp_session_activation(props: &AgentSessionActivatedProps) -> bool { - let acp: &'static str = AgentBackend::Acp.into(); - props.provider.as_deref() == Some(acp) -} - -fn provider_used_from_agent_acp_started(props: &AgentAcpStartedProps) -> Value { - let mut provider_used = serde_json::Map::new(); - provider_used.insert( - "mode".to_string(), - Value::String(AgentBackend::Acp.to_string()), - ); - provider_used.insert("command".to_string(), Value::String(props.command.clone())); - if let Some(config_name) = props.config_name.clone() { - provider_used.insert("config_name".to_string(), Value::String(config_name)); - } - Value::Object(provider_used) -} - fn apply_agent_terminal( event_prefix: &str, stage: &mut StageProjection, @@ -950,9 +901,9 @@ mod tests { use fabro_types::{ AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, - FailureReason, Graph, Outcome, PullRequestLink, QuestionType, RunBlobId, RunControlAction, - RunDiff, RunEvent, RunSpec, RunStatus, StageOutcome, StageState, SuccessReason, - WorkflowSettings, first_event_seq, fixtures, + FailureReason, Graph, Outcome, PullRequestLink, QuestionType, ReasoningEffort, RunBlobId, + RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus, Speed, StageModelUsage, + StageOutcome, StageState, SuccessReason, WorkflowSettings, first_event_seq, fixtures, }; use serde_json::json; @@ -1260,11 +1211,13 @@ mod tests { .apply_event(&test_event( 4, EventBody::StagePrompt(StagePromptProps { - visit: 1, - text: "prompt".to_string(), - mode: None, - provider: None, - model: None, + visit: 1, + text: "prompt".to_string(), + mode: None, + provider: None, + model: None, + reasoning_effort: None, + speed: None, }), Some("build"), )) @@ -1300,25 +1253,25 @@ mod tests { .apply_event(&test_stage_event( 4, EventBody::AgentSessionActivated(AgentSessionActivatedProps { - thread_id: Some("thread-1".to_string()), - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, + thread_id: Some("thread-1".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + capabilities: vec![fabro_types::SessionCapability::Steer], + visit: 1, }), stage_id.clone(), )) .unwrap(); let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.provider_used.as_ref().unwrap(), - &json!({ - "mode": "agent", - "provider": "openai", - "model": "gpt-5.4" - }) - ); + let provider_used = stage.provider_used.as_ref().unwrap(); + assert_eq!(provider_used.mode, StageModelUsage::MODE_AGENT); + assert_eq!(provider_used.provider.as_deref(), Some("openai")); + assert_eq!(provider_used.model.as_deref(), Some("gpt-5.4")); + assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(provider_used.speed, Some(Speed::Fast)); } #[test] @@ -1350,7 +1303,11 @@ mod tests { } #[test] - fn agent_acp_started_updates_stage_provider_used() { + fn agent_acp_started_alone_leaves_stage_provider_used_unset() { + // `agent.acp.started` no longer writes `provider_used`; the canonical + // source is the subsequent `agent.session.activated` event. ACP runs + // without a steering hub never activate and so legitimately leave + // `provider_used` unset. let mut state = initialized_projection(); let stage_id = StageId::new("code", 1); start_stage(&mut state, &stage_id); @@ -1368,18 +1325,11 @@ mod tests { .unwrap(); let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.provider_used.as_ref().unwrap(), - &json!({ - "mode": "acp", - "command": "python fake_agent.py", - "config_name": "fake" - }) - ); + assert!(stage.provider_used.is_none()); } #[test] - fn acp_session_activation_preserves_agent_acp_started_provider_used() { + fn acp_session_activation_records_provider_used_with_acp_mode() { let mut state = initialized_projection(); let stage_id = StageId::new("code", 1); start_stage(&mut state, &stage_id); @@ -1399,25 +1349,23 @@ mod tests { .apply_event(&test_stage_event( 5, EventBody::AgentSessionActivated(AgentSessionActivatedProps { - thread_id: None, - provider: Some(AgentBackend::Acp.to_string()), - model: Some("fake".to_string()), - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, + thread_id: None, + provider: Some(AgentBackend::Acp.to_string()), + model: Some("fake".to_string()), + reasoning_effort: None, + speed: None, + capabilities: vec![fabro_types::SessionCapability::Steer], + visit: 1, }), stage_id.clone(), )) .unwrap(); let stage = state.stage(&stage_id).unwrap(); - assert_eq!( - stage.provider_used.as_ref().unwrap(), - &json!({ - "mode": "acp", - "command": "python fake_agent.py", - "config_name": "fake" - }) - ); + let provider_used = stage.provider_used.as_ref().unwrap(); + assert_eq!(provider_used.mode, StageModelUsage::MODE_ACP); + assert_eq!(provider_used.provider.as_deref(), Some("acp")); + assert_eq!(provider_used.model.as_deref(), Some("fake")); } #[test] diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index eb8e9bd4d..2707ca353 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -7,7 +7,8 @@ use fabro_types::run::RunSpec; use fabro_types::{ BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord, QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProvider, - StageCompletion, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, + StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, + fixtures, }; use serde_json::json; @@ -126,7 +127,13 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { .single() .expect("timestamp should be representable"), }); - stage.provider_used = Some(json!({ "provider": "openai", "model": "gpt-5.4" })); + stage.provider_used = Some(StageModelUsage { + mode: StageModelUsage::MODE_PROMPT.to_string(), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: None, + speed: None, + }); stage.diff = Some("diff --git a/a b/a".to_string()); stage.script_invocation = Some(json!({ "command": "cargo test" })); stage.script_timing = Some(json!({ "duration_ms": 10 })); @@ -174,8 +181,16 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { Some(StageOutcome::Succeeded) ); assert_eq!( - node.provider_used, - Some(json!({ "provider": "openai", "model": "gpt-5.4" })) + node.provider_used + .as_ref() + .and_then(|usage| usage.provider.as_deref()), + Some("openai") + ); + assert_eq!( + node.provider_used + .as_ref() + .and_then(|usage| usage.model.as_deref()), + Some("gpt-5.4") ); assert_eq!( node.script_invocation, diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 643c22c2e..c41d64b24 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -61,6 +61,7 @@ pub use conclusion::{Conclusion, StageSummary}; pub use dense::{ServerSettings, UserSettings, WorkflowSettings}; pub use diff::{DiffStats, DiffSummary, RunDiff}; pub use event_envelope::EventEnvelope; +pub use fabro_model::ReasoningEffort; pub use failure_signature::FailureSignature; pub use graph::{ AttrValue, Edge, Graph, KNOWN_HANDLER_TYPES, Node, is_known_handler_type, is_llm_handler_type, @@ -102,7 +103,8 @@ pub use run_event::{ pub use run_failure::RunFailure; pub use run_id::{RunId, fixtures}; pub use run_projection::{ - CheckpointRecord, PendingInterviewRecord, RunProjection, StageProjection, first_event_seq, + CheckpointRecord, PendingInterviewRecord, RunProjection, StageModelUsage, StageProjection, + first_event_seq, }; pub use run_sandbox::{RunSandbox, RunSandboxRuntime}; pub use run_summary::{ diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs index e3a57c5a4..cbf865496 100644 --- a/lib/crates/fabro-types/src/run_event/agent.rs +++ b/lib/crates/fabro-types/src/run_event/agent.rs @@ -1,3 +1,4 @@ +use fabro_model::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -29,13 +30,17 @@ pub enum SessionCapability { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentSessionActivatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] - pub thread_id: Option, + pub thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, + pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - pub capabilities: Vec, - pub visit: u32, + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + pub capabilities: Vec, + pub visit: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_event/stage.rs b/lib/crates/fabro-types/src/run_event/stage.rs index a16e792fb..c3ba68318 100644 --- a/lib/crates/fabro-types/src/run_event/stage.rs +++ b/lib/crates/fabro-types/src/run_event/stage.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use fabro_model::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -73,14 +74,18 @@ pub struct StageRetryingProps { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StagePromptProps { - pub visit: u32, - pub text: String, + pub visit: u32, + pub text: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub mode: Option, + pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, + pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 0dafd1da4..80d5141e8 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -3,12 +3,14 @@ use std::collections::{BTreeMap, HashMap}; use std::num::NonZeroU32; use chrono::{DateTime, Utc}; +use fabro_model::{ReasoningEffort, Speed}; +use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; use crate::{ - BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, - ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, - StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord, - TodoListProjection, + AgentBackend, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, + InvalidTransition, ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, + RunSpec, RunStatus, StageCompletion, StageHandler, StageId, StageState, StageTiming, + StartRecord, TodoListProjection, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -55,13 +57,73 @@ pub struct CheckpointRecord { pub diff: RunDiff, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct StageModelUsage { + pub mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, +} + +impl StageModelUsage { + pub const MODE_PROMPT: &'static str = "prompt"; + pub const MODE_AGENT: &'static str = "agent"; + pub const MODE_ACP: &'static str = "acp"; + pub const MODE_FAN_IN: &'static str = "fan_in"; + + /// Build the usage record from a `stage.prompt` event, returning `None` + /// when the event carried no model metadata. + #[must_use] + pub fn from_prompt_props(props: &StagePromptProps) -> Option { + let has_metadata = props.provider.is_some() + || props.model.is_some() + || props.reasoning_effort.is_some() + || props.speed.is_some(); + has_metadata.then(|| Self { + mode: props + .mode + .clone() + .unwrap_or_else(|| Self::MODE_PROMPT.to_string()), + provider: props.provider.clone(), + model: props.model.clone(), + reasoning_effort: props.reasoning_effort, + speed: props.speed, + }) + } + + /// Build the usage record from an `agent.session.activated` event. The + /// mode is `Acp` when the activation came from an ACP control session and + /// `Agent` otherwise. + #[must_use] + pub fn from_agent_session_activated(props: &AgentSessionActivatedProps) -> Self { + let acp: &'static str = AgentBackend::Acp.into(); + let mode = if props.provider.as_deref() == Some(acp) { + Self::MODE_ACP + } else { + Self::MODE_AGENT + }; + Self { + mode: mode.to_string(), + provider: props.provider.clone(), + model: props.model.clone(), + reasoning_effort: props.reasoning_effort, + speed: props.speed, + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct StageProjection { pub first_event_seq: NonZeroU32, pub prompt: Option, pub response: Option, pub completion: Option, - pub provider_used: Option, + pub provider_used: Option, pub diff: Option, pub script_invocation: Option, pub script_timing: Option, diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 3fbb3adf2..9dab429c5 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -550,13 +550,17 @@ fn event_body_from_event(event: &Event) -> EventBody { mode, provider, model, + reasoning_effort, + speed, .. } => EventBody::StagePrompt(fabro_types::StagePromptProps { - visit: *visit, - text: text.clone(), - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), + visit: *visit, + text: text.clone(), + mode: mode.clone(), + provider: provider.clone(), + model: model.clone(), + reasoning_effort: *reasoning_effort, + speed: *speed, }), Event::PromptCompleted { response, @@ -1126,15 +1130,19 @@ fn event_body_from_event(event: &Event) -> EventBody { thread_id, provider, model, + reasoning_effort, + speed, capabilities, visit, .. } => EventBody::AgentSessionActivated(fabro_types::AgentSessionActivatedProps { - thread_id: thread_id.clone(), - provider: provider.clone(), - model: model.clone(), - capabilities: capabilities.clone(), - visit: *visit, + thread_id: thread_id.clone(), + provider: provider.clone(), + model: model.clone(), + reasoning_effort: *reasoning_effort, + speed: *speed, + capabilities: capabilities.clone(), + visit: *visit, }), Event::AgentSessionDeactivated { visit, .. } => { EventBody::AgentSessionDeactivated(fabro_types::AgentSessionDeactivatedProps { @@ -1851,12 +1859,14 @@ mod tests { let prompt = to_run_event_at( &fixtures::RUN_1, &Event::Prompt { - stage: "build".to_string(), - visit: 2, - text: "do it".to_string(), - mode: None, - provider: None, - model: None, + stage: "build".to_string(), + visit: 2, + text: "do it".to_string(), + mode: None, + provider: None, + model: None, + reasoning_effort: None, + speed: None, }, Utc::now(), Some(&scope), diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index daf48556e..d4b2adee9 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -8,6 +8,7 @@ use ::fabro_types::{ StageTiming, SuccessReason, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; +use fabro_model::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use crate::error::{Error, run_failure_from_error}; @@ -427,15 +428,19 @@ pub enum Event { to_node: String, }, Prompt { - stage: String, - visit: u32, - text: String, + stage: String, + visit: u32, + text: String, #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, + mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, + provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + speed: Option, }, PromptCompleted { node_id: String, @@ -570,16 +575,20 @@ pub enum Event { }, /// A stage has a currently steerable live session binding. AgentSessionActivated { - node_id: String, - visit: u32, - session_id: String, + node_id: String, + visit: u32, + session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - thread_id: Option, + thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider: Option, + provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - capabilities: Vec, + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + speed: Option, + capabilities: Vec, }, /// A stage's steerable live session binding ended. AgentSessionDeactivated { diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 98c916d9b..1d7c0c15b 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -343,7 +343,7 @@ mod tests { use fabro_dump::RunDump; use fabro_store::Database; - use fabro_types::{CommandTermination, fixtures}; + use fabro_types::{CommandTermination, StageModelUsage, fixtures}; use object_store::memory::InMemory; use super::*; @@ -478,12 +478,14 @@ mod tests { .await .unwrap(); append_event(&run, &fixtures::RUN_1, &Event::Prompt { - stage: "work".into(), - visit: 2, - text: "hello".into(), - mode: Some("prompt".into()), - provider: Some("openai".into()), - model: Some("gpt-5.4".into()), + stage: "work".into(), + visit: 2, + text: "hello".into(), + mode: Some(StageModelUsage::MODE_PROMPT.to_string()), + provider: Some("openai".into()), + model: Some("gpt-5.4".into()), + reasoning_effort: None, + speed: None, }) .await .unwrap(); diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 24feb5650..a9acc2e9f 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -4,9 +4,10 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_agent::Sandbox; use fabro_graphviz::graph::{Graph, Node}; -use fabro_types::RunId; +use fabro_types::{RunId, StageModelUsage}; use tokio_util::sync::CancellationToken; +use super::llm::api::EffectiveRequestControls; use super::{EngineServices, Handler, NodeTimeoutPolicy}; use crate::context::{Context, WorkflowContext, keys}; use crate::error::Error; @@ -47,6 +48,52 @@ pub struct OneShotRequest<'a> { pub cancel_token: CancellationToken, } +/// Emit the canonical `Event::Prompt` for a stage prompt and return the +/// resolved [`StageScope`] so the caller can keep building events scoped to +/// the same stage. +/// +/// Both `AgentHandler` and `PromptHandler` build the same payload, so the +/// per-emit fallback rules — node-provided +/// `provider`/`model` overrides over run-level defaults, and the backend's +/// `EffectiveRequestControls` (or `Default::default()` when no backend is +/// attached) — live in one place. +pub(crate) fn emit_stage_prompt( + services: &EngineServices, + context: &Context, + node: &Node, + prompt: &str, + mode: &str, + backend: Option<&dyn CodergenBackend>, +) -> Result { + let prompt_provider = node + .provider() + .map(String::from) + .or_else(|| Some(services.run.provider_id.to_string())); + let prompt_model = node + .model() + .map(String::from) + .or_else(|| Some(services.run.model.clone())); + let stage_scope = StageScope::for_handler(context, &node.id); + let request_controls = backend + .map(|b| b.effective_request_controls(node)) + .transpose()? + .unwrap_or_default(); + services.run.emitter.emit_scoped( + &Event::Prompt { + stage: node.id.clone(), + visit: stage_scope.visit, + text: prompt.to_string(), + mode: Some(mode.to_string()), + provider: prompt_provider, + model: prompt_model, + reasoning_effort: request_controls.reasoning_effort, + speed: request_controls.speed, + }, + &stage_scope, + ); + Ok(stage_scope) +} + /// Backend interface for LLM execution in codergen nodes. #[async_trait] pub trait CodergenBackend: Send + Sync { @@ -62,6 +109,10 @@ pub trait CodergenBackend: Send + Sync { async fn shutdown(&self, _emitter: &Arc) {} + fn effective_request_controls(&self, _node: &Node) -> Result { + Ok(EffectiveRequestControls::default()) + } + fn node_timeout_policy(&self, _node: &Node) -> NodeTimeoutPolicy { NodeTimeoutPolicy::ExecutorEnforced } @@ -252,23 +303,14 @@ impl Handler for AgentHandler { format!("{preamble}\n\n{raw_prompt}") }; - let prompt_provider = node - .provider() - .map(String::from) - .or_else(|| Some(services.run.provider_id.to_string())); - let prompt_model = node.model().map(String::from); - let stage_scope = StageScope::for_handler(context, &node.id); - services.run.emitter.emit_scoped( - &Event::Prompt { - stage: node.id.clone(), - visit: stage_scope.visit, - text: prompt.clone(), - mode: Some("agent".to_string()), - provider: prompt_provider, - model: prompt_model, - }, - &stage_scope, - ); + let stage_scope = emit_stage_prompt( + services, + context, + node, + &prompt, + StageModelUsage::MODE_AGENT, + self.backend.as_deref(), + )?; // 3. Call LLM backend (agent loop) let thread_id = context.thread_id(); @@ -420,6 +462,7 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::AttrValue; + use fabro_model::{ReasoningEffort, Speed}; use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; @@ -749,13 +792,15 @@ mod tests { let scope = StageScope::for_handler(request.context, &request.node.id); request.emitter.emit_scoped( &crate::event::Event::AgentSessionActivated { - node_id: request.node.id.clone(), - visit: scope.visit, - session_id: "session_123".to_string(), - thread_id: None, - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - capabilities: vec![fabro_types::SessionCapability::Steer], + node_id: request.node.id.clone(), + visit: scope.visit, + session_id: "session_123".to_string(), + thread_id: None, + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + capabilities: vec![fabro_types::SessionCapability::Steer], }, &scope, ); @@ -783,10 +828,10 @@ mod tests { let state = run_store.state().await.unwrap(); let node_state = state.stage(&StageId::new("step", 1)).unwrap(); - assert_eq!( - node_state.provider_used.as_ref().unwrap()["provider"], - "openai" - ); + let provider_used = node_state.provider_used.as_ref().unwrap(); + assert_eq!(provider_used.provider.as_deref(), Some("openai")); + assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(provider_used.speed, Some(Speed::Fast)); } #[test] diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs index 455b27b26..690479516 100644 --- a/lib/crates/fabro-workflow/src/handler/fan_in.rs +++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_agent::Sandbox; use fabro_graphviz::graph::{Graph, Node}; +use fabro_types::StageModelUsage; use tokio_util::sync::CancellationToken; use super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest}; @@ -245,12 +246,14 @@ async fn llm_evaluate( emitter.emit_scoped( &Event::Prompt { - stage: node_id.to_string(), - visit: stage_scope.visit, - text: full_prompt.clone(), - mode: Some("fan_in".to_string()), - provider: None, - model: None, + stage: node_id.to_string(), + visit: stage_scope.visit, + text: full_prompt.clone(), + mode: Some(StageModelUsage::MODE_FAN_IN.to_string()), + provider: None, + model: None, + reasoning_effort: None, + speed: None, }, &stage_scope, ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/crates/fabro-workflow/src/handler/llm/acp.rs index 40929cc97..6dfbaaff4 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/acp.rs @@ -269,14 +269,16 @@ impl AgentAcpBackend { }; ActivationLease::activate( ActivationLeaseOptions { - stage_id: StageId::new(node.id.clone(), stage_scope.visit), - session_id: session_id.to_string(), - thread_id: None, - provider: Some(AgentBackend::Acp.to_string()), - model: config_name.map(str::to_string), - capabilities: vec![SessionCapability::Steer], - hub: Arc::clone(steering_hub), - emitter: Arc::clone(emitter), + stage_id: StageId::new(node.id.clone(), stage_scope.visit), + session_id: session_id.to_string(), + thread_id: None, + provider: Some(AgentBackend::Acp.to_string()), + model: config_name.map(str::to_string), + reasoning_effort: None, + speed: None, + capabilities: vec![SessionCapability::Steer], + hub: Arc::clone(steering_hub), + emitter: Arc::clone(emitter), }, &(Arc::new(handle.clone()) as Arc), ) diff --git a/lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs b/lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs index 6a359e0fc..0c6b45454 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/activation_lease.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use fabro_model::{ReasoningEffort, Speed}; use fabro_types::{SessionCapability, StageId}; use crate::error::Error; @@ -16,14 +17,16 @@ pub struct ActivationLease { } pub struct ActivationLeaseOptions { - pub stage_id: StageId, - pub session_id: String, - pub thread_id: Option, - pub provider: Option, - pub model: Option, - pub capabilities: Vec, - pub hub: Arc, - pub emitter: Arc, + pub stage_id: StageId, + pub session_id: String, + pub thread_id: Option, + pub provider: Option, + pub model: Option, + pub reasoning_effort: Option, + pub speed: Option, + pub capabilities: Vec, + pub hub: Arc, + pub emitter: Arc, } impl ActivationLease { @@ -48,13 +51,15 @@ impl ActivationLease { } options.emitter.emit(&Event::AgentSessionActivated { - node_id: options.stage_id.node_id().to_string(), - visit: options.stage_id.visit(), - session_id: options.session_id.clone(), - thread_id: options.thread_id, - provider: options.provider, - model: options.model, - capabilities: options.capabilities, + node_id: options.stage_id.node_id().to_string(), + visit: options.stage_id.visit(), + session_id: options.session_id.clone(), + thread_id: options.thread_id, + provider: options.provider, + model: options.model, + reasoning_effort: options.reasoning_effort, + speed: options.speed, + capabilities: options.capabilities, }); options .hub @@ -153,6 +158,8 @@ mod tests { thread_id: None, provider: Some("openai".to_string()), model: Some("gpt-5.4".to_string()), + reasoning_effort: None, + speed: None, capabilities: vec![SessionCapability::Steer], hub, emitter, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 38f20a5d9..a01baa2f1 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -108,10 +108,10 @@ enum AgentApiErrorDisposition { Terminal(Error), } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct EffectiveRequestControls { - pub(super) reasoning_effort: Option, - pub(super) speed: Option, +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct EffectiveRequestControls { + pub(crate) reasoning_effort: Option, + pub(crate) speed: Option, } fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition { @@ -361,7 +361,7 @@ where Ok(format!("{summary}\n{json}")) } -pub(super) fn effective_request_controls( +pub(crate) fn effective_request_controls( run_model_controls: &RunModelControls, node: &Node, ) -> Result { @@ -611,7 +611,10 @@ impl AgentApiBackend { self } - fn effective_request_controls(&self, node: &Node) -> Result { + fn resolve_effective_request_controls( + &self, + node: &Node, + ) -> Result { effective_request_controls(&self.run_model_controls, node) } @@ -770,14 +773,16 @@ impl AgentApiBackend { let handle = Arc::new(session.control_handle()) as Arc; let lease = ActivationLease::activate( ActivationLeaseOptions { - stage_id: stage_id.clone(), - session_id: session.id().to_string(), - thread_id: thread_id.map(str::to_string), - provider: Some(session.provider_id().to_string()), - model: Some(session.model().to_string()), - capabilities: vec![SessionCapability::Steer], - hub: Arc::clone(&self.steering_hub), - emitter: Arc::clone(emitter), + stage_id: stage_id.clone(), + session_id: session.id().to_string(), + thread_id: thread_id.map(str::to_string), + provider: Some(session.provider_id().to_string()), + model: Some(session.model().to_string()), + reasoning_effort: session.reasoning_effort(), + speed: session.speed(), + capabilities: vec![SessionCapability::Steer], + hub: Arc::clone(&self.steering_hub), + emitter: Arc::clone(emitter), }, &handle, )?; @@ -814,6 +819,10 @@ impl CodergenBackend for AgentApiBackend { self.shutdown_cached_sessions(emitter); } + fn effective_request_controls(&self, node: &Node) -> Result { + self.resolve_effective_request_controls(node) + } + async fn one_shot(&self, request: OneShotRequest<'_>) -> Result { let node = request.node; let prompt = request.prompt; @@ -828,7 +837,7 @@ impl CodergenBackend for AgentApiBackend { let model = node.model().unwrap_or(&self.model); let provider = self.resolve_provider_context(model, node.provider())?; let provider_id = provider.provider_id.to_string(); - let controls = self.effective_request_controls(node)?; + let controls = self.resolve_effective_request_controls(node)?; let max_tokens = node .max_tokens() @@ -908,14 +917,13 @@ impl CodergenBackend for AgentApiBackend { .get(&target.model) .and_then(|m| m.limits.max_output) }); - let fallback_controls = self.effective_request_controls(node)?; let fallback_request = Request { model: target.model.clone(), provider: Some(target.provider.clone()), max_tokens, - reasoning_effort: fallback_controls.reasoning_effort, - speed: fallback_controls.speed, + reasoning_effort: controls.reasoning_effort, + speed: controls.speed, ..request.clone() }; @@ -925,7 +933,7 @@ impl CodergenBackend for AgentApiBackend { resp, target.model.clone(), target.provider.clone(), - fallback_controls.speed, + controls.speed, )); break; } @@ -1252,7 +1260,7 @@ impl CodergenBackend for AgentApiBackend { } } - let billing_controls = self.effective_request_controls(node)?; + let billing_controls = self.resolve_effective_request_controls(node)?; let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), &ModelRef { @@ -2068,7 +2076,7 @@ reasoning = false }); let node = Node::new("work"); - let controls = backend.effective_request_controls(&node).unwrap(); + let controls = backend.resolve_effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::Low)); assert_eq!(controls.speed, Some(Speed::Fast)); @@ -2096,7 +2104,7 @@ reasoning = false fabro_graphviz::graph::AttrValue::String("standard".to_string()), ); - let controls = backend.effective_request_controls(&node).unwrap(); + let controls = backend.resolve_effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::High)); assert_eq!(controls.speed, Some(Speed::Standard)); @@ -2112,7 +2120,7 @@ reasoning = false ); let node = Node::new("work"); - let controls = backend.effective_request_controls(&node).unwrap(); + let controls = backend.resolve_effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, None); } diff --git a/lib/crates/fabro-workflow/src/handler/llm/router.rs b/lib/crates/fabro-workflow/src/handler/llm/router.rs index e7b3db09a..821bd8136 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/router.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/router.rs @@ -6,6 +6,7 @@ use fabro_types::AgentBackend; use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; use super::acp::AgentAcpBackend; +use super::api::EffectiveRequestControls; use super::routing; use crate::error::Error; use crate::event::Emitter; @@ -57,6 +58,13 @@ impl CodergenBackend for BackendRouter { self.api.shutdown(emitter).await; } + fn effective_request_controls(&self, node: &Node) -> Result { + match Self::select_backend(node)? { + AgentBackend::Api => self.api.effective_request_controls(node), + AgentBackend::Acp => self.acp.effective_request_controls(node), + } + } + fn node_timeout_policy(&self, node: &Node) -> NodeTimeoutPolicy { match Self::select_backend(node) { Ok(AgentBackend::Api) => self.api.node_timeout_policy(node), @@ -73,6 +81,7 @@ mod tests { use async_trait::async_trait; use fabro_agent::{LocalSandbox, Sandbox}; use fabro_graphviz::graph::{AttrValue, Node}; + use fabro_model::{ReasoningEffort, Speed}; use tokio_util::sync::CancellationToken; use super::*; @@ -132,6 +141,16 @@ mod tests { assert_eq!(text, "api one-shot"); } + #[test] + fn router_delegates_effective_request_controls_to_api_backend() { + let node = Node::new("test"); + let router = BackendRouter::new(Box::new(StubBackend), AgentAcpBackend::new()); + + let controls = router.effective_request_controls(&node).unwrap(); + assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(controls.speed, Some(Speed::Fast)); + } + struct StubBackend; #[async_trait] @@ -153,5 +172,15 @@ mod tests { last_file_touched: None, }) } + + fn effective_request_controls( + &self, + _node: &Node, + ) -> Result { + Ok(EffectiveRequestControls { + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + }) + } } } diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 625e4597f..a5324f87b 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -3,15 +3,17 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; +use fabro_types::StageModelUsage; use super::agent::{ - CodergenBackend, CodergenResult, OneShotRequest, extract_status_fields, truncate, + CodergenBackend, CodergenResult, OneShotRequest, emit_stage_prompt, extract_status_fields, + truncate, }; use super::llm::routing; use super::{EngineServices, Handler}; use crate::context::{Context, WorkflowContext, keys}; use crate::error::Error; -use crate::event::{Emitter, Event, StageScope}; +use crate::event::{Emitter, Event}; use crate::outcome::Outcome; /// Handler for single-shot LLM calls (no tools, no agent loop). @@ -105,23 +107,14 @@ impl Handler for PromptHandler { None }; - let prompt_provider = node - .provider() - .map(String::from) - .or_else(|| Some(services.run.provider_id.to_string())); - let prompt_model = node.model().map(String::from); - let stage_scope = StageScope::for_handler(context, &node.id); - services.run.emitter.emit_scoped( - &Event::Prompt { - stage: node.id.clone(), - visit: stage_scope.visit, - text: prompt.clone(), - mode: Some("prompt".to_string()), - provider: prompt_provider.clone(), - model: prompt_model.clone(), - }, - &stage_scope, - ); + let stage_scope = emit_stage_prompt( + services, + context, + node, + &prompt, + StageModelUsage::MODE_PROMPT, + self.backend.as_deref(), + )?; // 3. Call LLM backend (one_shot) let (response_text, stage_usage, backend_files_touched) = @@ -212,6 +205,7 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::AttrValue; + use fabro_model::{ReasoningEffort, Speed}; use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; @@ -337,6 +331,16 @@ mod tests { last_file_touched: None, }) } + + fn effective_request_controls( + &self, + _node: &Node, + ) -> Result { + Ok(crate::handler::llm::api::EffectiveRequestControls { + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + }) + } } let handler = PromptHandler::new(Some(Box::new(OneShotBackend))); @@ -384,6 +388,16 @@ mod tests { last_file_touched: None, }) } + + fn effective_request_controls( + &self, + _node: &Node, + ) -> Result { + Ok(crate::handler::llm::api::EffectiveRequestControls { + reasoning_effort: Some(ReasoningEffort::High), + speed: Some(Speed::Fast), + }) + } } let handler = PromptHandler::new(Some(Box::new(ProviderOneShotBackend))); @@ -405,7 +419,10 @@ mod tests { let state = run_store.state().await.unwrap(); let node_state = state.stage(&StageId::new("classify", 1)).unwrap(); - assert_eq!(node_state.provider_used.as_ref().unwrap()["mode"], "prompt"); + let provider_used = node_state.provider_used.as_ref().unwrap(); + assert_eq!(provider_used.mode, StageModelUsage::MODE_PROMPT); + assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(provider_used.speed, Some(Speed::Fast)); } struct OneShotCapturingBackend { diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 3731e6145..2229920b4 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -299,11 +299,13 @@ mod tests { fn fork_replay_keeps_stage_scoped_session_activation_only() { assert!(replay_event_for_fork_projection( &EventBody::AgentSessionActivated(fabro_types::run_event::AgentSessionActivatedProps { - thread_id: None, - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), - capabilities: vec![fabro_types::SessionCapability::Steer], - visit: 1, + thread_id: None, + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning_effort: None, + speed: None, + capabilities: vec![fabro_types::SessionCapability::Steer], + visit: 1, }) )); assert!(!replay_event_for_fork_projection( diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 86dab5cd7..609b8c60b 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -11818,12 +11818,14 @@ impl Handler for KeepaliveHandler { while start.elapsed() < std::time::Duration::from_millis(self.total_ms) { tokio::time::sleep(std::time::Duration::from_millis(self.interval_ms)).await; services.run.emitter.emit(&Event::Prompt { - stage: node.id.clone(), - visit: 1, - text: "keepalive".to_string(), - mode: None, - provider: None, - model: None, + stage: node.id.clone(), + visit: 1, + text: "keepalive".to_string(), + mode: None, + provider: None, + model: None, + reasoning_effort: None, + speed: None, }); } Ok(Outcome::success()) diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index ec0aaf216..ae17c6ae8 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -228,6 +228,7 @@ export * from './pull-request-response'; export * from './pull-request-settings'; export * from './pull-request-user'; export * from './question-type'; +export * from './reasoning-effort'; export * from './reasoning-effort-feature'; export * from './related-workflow-diagnostic'; export * from './render-workflow-graph-direction'; @@ -358,6 +359,7 @@ export * from './ssh-access-request'; export * from './ssh-access-response'; export * from './stage-completion'; export * from './stage-handler'; +export * from './stage-model-usage'; export * from './stage-outcome'; export * from './stage-projection'; export * from './stage-state'; diff --git a/lib/packages/fabro-api-client/src/models/reasoning-effort.ts b/lib/packages/fabro-api-client/src/models/reasoning-effort.ts new file mode 100644 index 000000000..66644a073 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/reasoning-effort.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.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Native reasoning-effort level requested for an LLM call. + */ + +export const ReasoningEffort = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + XHIGH: 'xhigh', + MAX: 'max' +} as const; + +export type ReasoningEffort = typeof ReasoningEffort[keyof typeof ReasoningEffort]; diff --git a/lib/packages/fabro-api-client/src/models/run-stage.ts b/lib/packages/fabro-api-client/src/models/run-stage.ts index 7c95a4853..4fa5dce2e 100644 --- a/lib/packages/fabro-api-client/src/models/run-stage.ts +++ b/lib/packages/fabro-api-client/src/models/run-stage.ts @@ -18,6 +18,9 @@ import type { StageHandler } from './stage-handler'; // May contain unused imports in some cases // @ts-ignore +import type { StageModelUsage } from './stage-model-usage'; +// May contain unused imports in some cases +// @ts-ignore import type { StageState } from './stage-state'; /** @@ -46,6 +49,10 @@ export interface RunStage { * 1-based visit count; bumped each time the workflow re-enters this node. */ 'visit': number; + /** + * Provider, model, and request controls recorded for the latest stage attempt. + */ + 'provider_used'?: StageModelUsage | null; /** * Wall-clock time the latest attempt of this stage started, if known. */ diff --git a/lib/packages/fabro-api-client/src/models/stage-model-usage.ts b/lib/packages/fabro-api-client/src/models/stage-model-usage.ts new file mode 100644 index 000000000..56461f455 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/stage-model-usage.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.1.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 { BillingSpeed } from './billing-speed'; +// May contain unused imports in some cases +// @ts-ignore +import type { ReasoningEffort } from './reasoning-effort'; + +/** + * Provider, model, and request-control metadata recorded for a stage attempt. + */ +export interface StageModelUsage { + 'mode': string; + 'provider'?: string | null; + 'model'?: string | null; + 'reasoning_effort'?: ReasoningEffort | null; + 'speed'?: BillingSpeed | null; +} 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 f982af2d6..b915b2861 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -27,6 +27,9 @@ import type { CommandTermination } from './command-termination'; import type { StageCompletion } from './stage-completion'; // May contain unused imports in some cases // @ts-ignore +import type { StageModelUsage } from './stage-model-usage'; +// May contain unused imports in some cases +// @ts-ignore import type { StageState } from './stage-state'; // May contain unused imports in some cases // @ts-ignore @@ -43,7 +46,7 @@ export interface StageProjection { /** * Provider and model metadata recorded for the stage attempt. */ - 'provider_used'?: object | null; + 'provider_used'?: StageModelUsage | null; 'diff'?: string | null; /** * Command and environment recorded when the stage script ran.