mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
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:
parent
651eae6b34
commit
7e6052bec3
44 changed files with 882 additions and 414 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function makeStage(overrides: Partial<Stage> = {}): Stage {
|
|||
status: "running",
|
||||
duration: "--",
|
||||
startedAt: null,
|
||||
providerUsed: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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", &[]),
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
62
lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs
Normal file
62
lib/crates/fabro-api/tests/stage_model_usage_round_trip.rs
Normal 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>()
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<_>>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
29
lib/packages/fabro-api-client/src/models/reasoning-effort.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/reasoning-effort.ts
generated
Normal 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];
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
32
lib/packages/fabro-api-client/src/models/stage-model-usage.ts
generated
Normal file
32
lib/packages/fabro-api-client/src/models/stage-model-usage.ts
generated
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue