Surface reasoning_effort + speed in stage badge end-to-end (#363)

## Summary

The run page stage badge showed only the model name. This PR plumbs
`reasoning_effort` and `speed` from the LLM call site all the way
through the event stream, store projection, API, and UI so the badge now
renders `gpt-5.5 · high`.

### Plan Summary

- **Event props** — `AgentSessionActivatedProps` and `StagePromptProps`
gain `reasoning_effort: Option<ReasoningEffort>` and `speed:
Option<Speed>` with `serde(default, skip_serializing_if)` for
back-compat.
- **Typed projection** — `provider_used: Option<serde_json::Value>` is
replaced by `Option<StageModelUsage>`, a proper struct in `fabro-types`
with factory methods (`from_prompt_props`,
`from_agent_session_activated`). The freeform JSON bag is gone.
- **Emission sites** — `ActivationLeaseOptions` carries the new fields;
`emit_stage_prompt()` (new shared helper) resolves
`EffectiveRequestControls` via the backend and stamps them on
`Event::Prompt`. `AgentHandler` and `PromptHandler` both call this
helper instead of building the event inline.
- **ACP path** — `AgentAcpStarted` no longer writes `provider_used`; the
canonical source is the later `AgentSessionActivated` event, which is
already emitted for ACP steering sessions. Runs without a hub
legitimately leave `provider_used` unset.
- **OpenAPI** — new `StageModelUsage` and `ReasoningEffort` schemas
replace the `object | null` bag; `build.rs` maps both to the canonical
Rust types; a new `stage_model_usage_round_trip` integration test
enforces the parity requirement.
- **UI** — `extractStageModel` (event-scanning heuristic) is deleted;
replaced by `formatStageModelUsageLabel` and `stageModelUsageTitle` that
read directly off `selectedStage.providerUsed`. `parseFanInOutcome` now
sources the reducer model from `stage.prompt` instead of
`prompt.completed`.

### Key design decisions

**No type sprawl**: `fabro_model::ReasoningEffort` and `Speed` are
reused verbatim via `with_replacement` in `build.rs` — no parallel
enums.

**ACP behavior change**: previously `AgentAcpStarted` wrote a bespoke
`provider_used` blob and a later `AgentSessionActivated` would be
ignored for ACP sessions. Now `AgentSessionActivated` is the single
write path for all modes; ACP runs that never activate a steering hub
correctly leave `provider_used = null`. The integration test (`acp.rs`)
is updated to assert the new shape, and the unit test is renamed
`agent_acp_started_alone_leaves_stage_provider_used_unset` to document
intent.

**`emit_stage_prompt` helper**: both `AgentHandler` and the existing
prompt path share one function to avoid the two call sites drifting
apart again.


### Fabro Details

<details>
<summary>Ran 9 stages in 98m 44s for $65.70</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 2s | – | 0 |
| preflight_compile | 2m 15s | – | 0 |
| preflight_lint | 2m 30s | – | 0 |
| implement | 42m 19s | $26.85 | 0 |
| simplify_opus | 38m 3s | $35.17 | 0 |
| simplify_gpt | 9m 20s | $3.68 | 0 |
| verify | 3m 38s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **98m 44s** | **$65.70** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Bryan Helmkamp <bhelmkamp@users.noreply.github.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-23 13:14:55 -04:00 committed by GitHub
parent 651eae6b34
commit 7e6052bec3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 882 additions and 414 deletions

View file

@ -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");

View file

@ -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 {

View file

@ -14,6 +14,7 @@ function makeStage(overrides: Partial<Stage> = {}): Stage {
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
...overrides,
};
}

View file

@ -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<StageState, { icon: ComponentType<{ className?: string }>; color: string }> = {

View file

@ -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",
});
});
});
});

View file

@ -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;
}
}

View file

@ -732,8 +732,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<div
className={
fullHeight
? "pt-3.5 flex min-h-0 flex-1 flex-col"
: "pt-3.5 pb-[var(--fabro-interview-dock-clearance)]"
? "pt-3 flex min-h-0 flex-1 flex-col"
: "pt-3 pb-[var(--fabro-interview-dock-clearance)]"
}
>
<Outlet />

View file

@ -5,9 +5,10 @@ import {
buildThreadDnaItems,
eventsTabLabel,
eventsToActivity,
extractStageModel,
formatStageModelUsageLabel,
groupConsecutiveTools,
selectStageRenderer,
stageModelUsageTitle,
} from "./run-stages";
function envelope(seq: number, partial: Partial<EventEnvelope>): 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", () => {

View file

@ -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 (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<EventsTabToggle
@ -1309,15 +1318,15 @@ function EventsToolbar({
: `${totalCount.toLocaleString()} events`}
</span>
)}
{model && (
{modelUsage && (
<span
className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${
showFilters ? "" : "ml-auto"
}`}
title="LLM model used for this stage"
title={modelUsage.title}
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{model}</span>
<span className="font-mono">{modelUsage.label}</span>
</span>
)}
{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<EventEnvelope[]>(() => {
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" && (
<div className="pb-3">

View file

@ -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

View file

@ -458,6 +458,16 @@ impl Session {
self.provider_profile.model()
}
#[must_use]
pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
self.config.reasoning_effort
}
#[must_use]
pub fn speed(&self) -> Option<Speed> {
self.config.speed
}
/// Initialize session by discovering project docs and capturing environment
/// context. Call before `process_input`.
///

View file

@ -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", &[]),

View file

@ -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::*;

View file

@ -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::<ApiReasoningEffort, ReasoningEffort>();
}
#[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::<ReasoningEffort>(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::<ApiStageModelUsage, StageModelUsage>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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 },

View file

@ -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:?}"
);
}

View file

@ -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")
);
}

View file

@ -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(

View file

@ -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<u64>,
started_at: Option<chrono::DateTime<chrono::Utc>>,
handler: StageHandler,
provider_used: Option<StageModelUsage>,
) -> 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,
}
}

View file

@ -57,6 +57,7 @@ async fn list_run_stages(
stage.live_wall_time_ms(now),
stage.started_at,
handler,
stage.provider_used.clone(),
)
})
.collect::<Vec<_>>();

View file

@ -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);

View file

@ -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<Value> {
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]

View file

@ -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,

View file

@ -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::{

View file

@ -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<String>,
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub capabilities: Vec<SessionCapability>,
pub visit: u32,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
pub capabilities: Vec<SessionCapability>,
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -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<String>,
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
}
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<Self> {
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<String>,
pub response: Option<String>,
pub completion: Option<StageCompletion>,
pub provider_used: Option<serde_json::Value>,
pub provider_used: Option<StageModelUsage>,
pub diff: Option<String>,
pub script_invocation: Option<serde_json::Value>,
pub script_timing: Option<serde_json::Value>,

View file

@ -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),

View file

@ -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<String>,
mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
speed: Option<Speed>,
},
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<String>,
thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
capabilities: Vec<fabro_types::SessionCapability>,
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
speed: Option<Speed>,
capabilities: Vec<fabro_types::SessionCapability>,
},
/// A stage's steerable live session binding ended.
AgentSessionDeactivated {

View file

@ -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();

View file

@ -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<StageScope, Error> {
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<Emitter>) {}
fn effective_request_controls(&self, _node: &Node) -> Result<EffectiveRequestControls, Error> {
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]

View file

@ -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,
);

View file

@ -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<dyn ActiveControlHandle>),
)

View file

@ -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<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub capabilities: Vec<SessionCapability>,
pub hub: Arc<SteeringHub>,
pub emitter: Arc<Emitter>,
pub stage_id: StageId,
pub session_id: String,
pub thread_id: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
pub speed: Option<Speed>,
pub capabilities: Vec<SessionCapability>,
pub hub: Arc<SteeringHub>,
pub emitter: Arc<Emitter>,
}
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,

View file

@ -108,10 +108,10 @@ enum AgentApiErrorDisposition {
Terminal(Error),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct EffectiveRequestControls {
pub(super) reasoning_effort: Option<ReasoningEffort>,
pub(super) speed: Option<Speed>,
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EffectiveRequestControls {
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) speed: Option<Speed>,
}
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<EffectiveRequestControls, Error> {
@ -611,7 +611,10 @@ impl AgentApiBackend {
self
}
fn effective_request_controls(&self, node: &Node) -> Result<EffectiveRequestControls, Error> {
fn resolve_effective_request_controls(
&self,
node: &Node,
) -> Result<EffectiveRequestControls, Error> {
effective_request_controls(&self.run_model_controls, node)
}
@ -770,14 +773,16 @@ impl AgentApiBackend {
let handle = Arc::new(session.control_handle()) as Arc<dyn ActiveControlHandle>;
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<EffectiveRequestControls, Error> {
self.resolve_effective_request_controls(node)
}
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
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);
}

View file

@ -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<EffectiveRequestControls, Error> {
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<EffectiveRequestControls, Error> {
Ok(EffectiveRequestControls {
reasoning_effort: Some(ReasoningEffort::High),
speed: Some(Speed::Fast),
})
}
}
}

View file

@ -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<crate::handler::llm::api::EffectiveRequestControls, Error> {
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<crate::handler::llm::api::EffectiveRequestControls, Error> {
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 {

View file

@ -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(

View file

@ -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())

View file

@ -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';

View file

@ -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];

View file

@ -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.
*/

View file

@ -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;
}

View file

@ -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.