Merge origin/main into refactor/remove-env-interpolation

This commit is contained in:
Bryan Helmkamp 2026-07-28 18:31:16 -04:00
commit f4c09867e5
No known key found for this signature in database
77 changed files with 4786 additions and 683 deletions

View file

@ -6,6 +6,9 @@ GEMINI_API_KEY=
INCEPTION_API_KEY=
KIMI_API_KEY=
MINIMAX_API_KEY=
MODAL_KIMI_K3_BASE_URL=
MODAL_TOKEN_ID=
MODAL_TOKEN_SECRET=
OPENAI_API_KEY=
OPENROUTER_API_KEY=
POOLSIDE_API_KEY=

View file

@ -9,7 +9,7 @@ import { StagePopover } from "./stage-popover";
import { deriveStageSummary } from "./stage-popover-summary";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { makeStage as baseMakeStage } from "../lib/test-utils";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
@ -23,21 +23,13 @@ function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
}
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
billing: makeBilledTokenCounts(),
return baseMakeStage({
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
...overrides,
};
});
}
describe("deriveStageSummary", () => {

View file

@ -1,10 +1,15 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { StageOutcome, StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import { makeBilledTokenCounts } from "../../lib/test-fixtures";
import {
makeEventEnvelope,
makeStage as baseMakeStage,
setupReactTestEnv,
textContent,
} from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import { ParallelChildren } from "./parallel-children";
@ -14,36 +19,88 @@ beforeEach(() => {
});
afterEach(() => teardown());
const parallelStage: Stage = {
function makeStage(overrides: Partial<Stage> = {}): Stage {
return baseMakeStage({
id: "stage@1",
name: "stage",
nodeId: "stage",
graphVisit: 1,
startedAt: "2026-04-09T12:00:00Z",
...overrides,
});
}
const parallelStage = makeStage({
id: "fork@1",
name: "fork",
handler: "parallel",
status: "succeeded",
status: StageState.RUNNING,
duration: "12s",
nodeId: "fork",
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
};
});
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, { event: "parallel.completed", ...partial });
function branchStage(
name: string,
index: number,
status: StageState,
groupId = "fork@1",
visit = 1,
): Stage {
return makeStage({
id: `${name}@${visit}`,
name,
nodeId: name,
visit,
status,
parallelGroupId: groupId,
parallelBranchIndex: index,
});
}
function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer {
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, {
event: "parallel.completed",
stage_id: "fork@1",
...partial,
});
}
function startedEvent(branchCount: number): EventEnvelope {
return event({
event: "parallel.started",
properties: { branch_count: branchCount },
});
}
function completedEvent(results: Array<{ id: string; status: StageOutcome }>): EventEnvelope {
const countOf = (status: StageOutcome) =>
results.filter((result) => result.status === status).length;
return event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: countOf(StageOutcome.SUCCEEDED),
failure_count: countOf(StageOutcome.FAILED),
results: results.map((result) => ({ ...result, context_updates: {} })),
},
});
}
function renderParallel(
events: EventEnvelope[],
allStages: Stage[],
stage = parallelStage,
): TestRenderer.ReactTestRenderer {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
stage={stage}
events={events}
runId="run-1"
allStages={[
{ ...parallelStage, id: "branch-a@1", name: "branch-a", nodeId: "branch-a", handler: "agent" },
{ ...parallelStage, id: "branch-b@1", name: "branch-b", nodeId: "branch-b", handler: "agent", status: "failed" },
]}
allStages={allStages}
/>
</MemoryRouter>,
);
@ -51,37 +108,148 @@ function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer
return renderer;
}
describe("ParallelChildren", () => {
test("renders branch status and stage links without checkout metadata", () => {
const renderer = renderParallel([
event({
event: "parallel.started",
properties: { branch_count: 2 },
}),
event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: 1,
failure_count: 1,
results: [
{ id: "branch-a", status: "succeeded", context_updates: {} },
{ id: "branch-b", status: "failed", context_updates: {} },
],
},
}),
]);
function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("li").map(textContent);
}
const rendered = JSON.stringify(renderer.toJSON());
expect(rendered).toContain("branch-a");
expect(rendered).toContain("Succeeded");
expect(rendered).toContain("branch-b");
expect(rendered).toContain("Failed");
const hrefs = renderer.root.findAllByType("a").map((link) => link.props.href);
expect(hrefs).toEqual([
"/runs/run-1/stages/branch-a@1",
"/runs/run-1/stages/branch-b@1",
function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("a").map((link) => link.props.href);
}
function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): string {
return textContent(renderer.root.findByProps({ "data-stat": label }));
}
describe("ParallelChildren", () => {
test("renders live branch names, statuses, counts, and stage links", () => {
const renderer = renderParallel(
[startedEvent(2)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED),
branchStage("review_opus", 1, StageState.RUNNING),
],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[0]).toContain("review_glm");
expect(rows[1]).toContain("Running");
expect(rows[1]).toContain("review_opus");
expect(hrefs(renderer)).toEqual([
"/runs/run-1/stages/review_glm@1",
"/runs/run-1/stages/review_opus@1",
]);
expect(statValue(renderer, "Succeeded")).toBe("1");
expect(statValue(renderer, "Failed")).toBe("0");
});
test("keeps looped fork links scoped to the selected fork visit", () => {
const renderer = renderParallel(
[startedEvent(1)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1),
branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2),
],
);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]);
});
test("shows a late-starting branch when lower indexes have no stage yet", () => {
// Branches queued behind `max_parallel` reserve no stage identity, so the
// observed indexes are sparse. Sizing the list by entry count would drop
// the only running branch.
const renderer = renderParallel([], [branchStage("review_opus", 2, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"PendingBranch 1",
"PendingBranch 2",
"Runningreview_opus",
]);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_opus@1"]);
expect(statValue(renderer, "Branches")).toBe("3");
});
test("renders branches with no stage or result yet as pending placeholders", () => {
const renderer = renderParallel([startedEvent(3)], [branchStage("review_glm", 0, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"Runningreview_glm",
"PendingBranch 2",
"PendingBranch 3",
]);
expect(statValue(renderer, "Succeeded")).toBe("0");
expect(statValue(renderer, "Failed")).toBe("0");
});
test("labels a re-entered branch with its visit, matching the sidebar", () => {
const renderer = renderParallel(
[startedEvent(1)],
[branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)],
makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }),
);
expect(branchRowText(renderer)).toEqual(["Runningreview_glm@2"]);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@2"]);
});
test("keeps duplicate branch targets in index order and only links recorded stages", () => {
const renderer = renderParallel(
[
startedEvent(2),
completedEvent([
{ id: "review", status: StageOutcome.FAILED },
{ id: "review", status: StageOutcome.FAILED },
]),
],
[branchStage("review", 0, StageState.SUCCEEDED)],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[1]).toContain("Failed");
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review@1"]);
});
test("renders a completed result without a matching stage as an unlinked row", () => {
const renderer = renderParallel(
[
startedEvent(1),
completedEvent([{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]),
],
[],
);
expect(branchRowText(renderer)).toEqual(["Succeededlegacy_branch"]);
expect(hrefs(renderer)).toEqual([]);
});
test("counts partial and skipped branches as neither succeeded nor failed", () => {
const allStages = [
branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED),
branchStage("skipped", 1, StageState.SKIPPED),
];
const running = renderParallel([startedEvent(2)], allStages);
const completed = renderParallel(
[
startedEvent(2),
completedEvent([
{ id: "partial", status: StageOutcome.PARTIALLY_SUCCEEDED },
{ id: "skipped", status: StageOutcome.SKIPPED },
]),
],
allStages,
);
expect([
statValue(running, "Succeeded"),
statValue(running, "Failed"),
]).toEqual(["0", "0"]);
expect([
statValue(completed, "Succeeded"),
statValue(completed, "Failed"),
]).toEqual(["0", "0"]);
});
});

View file

@ -5,15 +5,17 @@ import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatDurationMs } from "../../lib/format";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
/** Branch row view state: completed outcomes plus a synthesized in-flight row. */
/** Branch row view state sourced from a live branch stage or completed result. */
interface BranchRow {
id: string;
label: string;
status: StageState;
/** Null when no stage backs this branch yet, which also means it is unlinkable. */
stageId: string | null;
}
function StatItem({
@ -32,31 +34,33 @@ function StatItem({
<span className="text-[10px] font-medium uppercase tracking-[0.16em] text-fg-muted">
{label}
</span>
<span className={`font-mono text-xl tabular-nums ${toneClass}`}>{value}</span>
<span data-stat={label} className={`font-mono text-xl tabular-nums ${toneClass}`}>
{value}
</span>
</div>
);
}
function ChildRow({
result,
stageHref,
row,
runId,
}: {
result: BranchRow;
stageHref: string | null;
row: BranchRow;
runId: string;
}) {
const tone = stageStatusTone(result.status);
const tone = stageStatusTone(row.status);
const inner = (
<>
<span
className={`inline-flex w-24 shrink-0 justify-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${tone}`}
>
{stageStatusLabel(result.status)}
{stageStatusLabel(row.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-sm text-fg-3">
{result.id}
{row.label}
</span>
{stageHref && (
{row.stageId && (
<ArrowTopRightOnSquareIcon
className="size-3.5 shrink-0 text-fg-muted transition-colors group-hover:text-fg-2"
aria-hidden="true"
@ -67,9 +71,9 @@ function ChildRow({
return (
<li className="flex items-center gap-3 px-4 py-2.5">
{stageHref ? (
{row.stageId ? (
<Link
to={stageHref}
to={`/runs/${runId}/stages/${row.stageId}`}
className="group flex flex-1 items-center gap-3 rounded -m-1 p-1 transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
{inner}
@ -94,40 +98,68 @@ export function ParallelChildren({
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
// Map node_id -> latest stage_id so we can deep-link branches.
const latestStageByNode = useMemo(() => {
const latest = new Map<string, Stage>();
for (const s of allStages) {
const prev = latest.get(s.nodeId);
if (!prev || s.visit > prev.visit) latest.set(s.nodeId, s);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();
for (const candidate of allStages) {
if (
candidate.parallelGroupId === stage.id
&& candidate.parallelBranchIndex != null
) {
byIndex.set(candidate.parallelBranchIndex, candidate);
}
}
return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id]));
}, [allStages]);
return byIndex;
}, [allStages, stage.id]);
const items: BranchRow[] = overview.results.length > 0
? overview.results
: overview.branchCount && overview.branchCount > 0
? Array.from({ length: overview.branchCount }, (_, i) => ({
id: `branch ${i + 1}`,
status: StageState.RUNNING,
}))
: [];
// Branch indexes are sparse: a branch queued behind `max_parallel` has no
// stage identity yet, and one cancelled while queued never gets one. Size the
// list from the highest index seen so a late-starting branch is never hidden.
const branchCount = Math.max(
overview.branchCount ?? 0,
overview.results.length,
...Array.from(stagesByBranchIndex.keys(), (index) => index + 1),
);
const rows = Array.from({ length: branchCount }, (_, index): BranchRow => {
// A live branch stage is the freshest source; fall back to the completed
// event's result for runs whose branches predate parallel identity.
const branchStage = stagesByBranchIndex.get(index);
if (branchStage) {
return {
label: formatStageLabel(branchStage),
status: branchStage.status,
stageId: branchStage.id,
};
}
const result = overview.results[index];
if (result) {
return { label: result.id, status: result.status, stageId: null };
}
return { label: `Branch ${index + 1}`, status: StageState.PENDING, stageId: null };
});
// Count what is on screen, so the tiles can never contradict the rows.
let successCount = 0;
let failureCount = 0;
for (const row of rows) {
if (row.status === StageState.SUCCEEDED) successCount += 1;
else if (row.status === StageState.FAILED) failureCount += 1;
}
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<StatItem label="Branches" value={overview.branchCount ?? "—"} />
<StatItem label="Branches" value={branchCount || "—"} />
<StatItem
label="Succeeded"
value={overview.successCount ?? (overview.isComplete ? 0 : "—")}
value={successCount}
tone="success"
/>
<StatItem
label="Failed"
value={overview.failureCount ?? (overview.isComplete ? 0 : "—")}
tone={overview.failureCount && overview.failureCount > 0 ? "danger" : "default"}
value={failureCount}
tone={failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
@ -139,21 +171,13 @@ export function ParallelChildren({
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Branches
</h3>
{items.length === 0 ? (
{rows.length === 0 ? (
<p className="text-sm text-fg-muted">No branches recorded yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg bg-panel outline-1 -outline-offset-1 outline-line">
{items.map((result, i) => {
const stageId = latestStageByNode.get(result.id);
const href = stageId ? `/runs/${runId}/stages/${stageId}` : null;
return (
<ChildRow
key={`${result.id}-${i}`}
result={result}
stageHref={href}
/>
);
})}
{rows.map((row, index) => (
<ChildRow key={index} row={row} runId={runId} />
))}
</ul>
)}
</section>

View file

@ -2,26 +2,9 @@ import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { makeStage } from "../lib/test-utils";
import { StageSidebar, type Stage } from "./stage-sidebar";
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}
function renderSidebar(stages: Stage[]): string {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

View file

@ -94,6 +94,36 @@ describe("queryKeysForRunEvent", () => {
]);
});
test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => {
// Branches bypass stage.started/stage.completed, so these events are the
// only signal that a branch row's status changed.
expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
});
test("fork lifecycle invalidates run-scoped resources without a stage id", () => {
for (const event of ["parallel.started", "parallel.completed"]) {
expect(queryKeysForRunEvent("run-1", event)).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
]);
}
});
test("cancel requests invalidate the durable run summary", () => {
expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([
queryKeys.runs.detail("run-1"),

View file

@ -88,6 +88,17 @@ export const STAGE_ACTIVITY_EVENT_TYPES = [
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
@ -203,6 +214,19 @@ export function queryKeysForRunEvent(
return keys;
}
if (PARALLEL_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STEERING_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.events(runId, 1000)];
if (AGENT_CONTROL_STATE_EVENTS.has(event)) {

View file

@ -4,22 +4,10 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
import { makeStage as baseMakeStage } from "./test-utils";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
id: `${nodeId}@${visit}`,
name: nodeId,
handler: "agent",
nodeId,
visit,
graphVisit: null,
resumedFromStageId: null,
status,
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
};
return baseMakeStage({ id: `${nodeId}@${visit}`, name: nodeId, nodeId, visit, status });
}
describe("mapRunStagesToSidebarStages", () => {
@ -205,6 +193,28 @@ describe("mapRunStagesToSidebarStages", () => {
expect(result[0].resumedFromStageId).toBeNull();
});
test("maps parallel branch identity without parsing it in the client", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "review_opus@2",
name: "review_opus",
handler: "agent",
status: "running",
node_id: "review_opus",
visit: 2,
parallel_group_id: "review_fork@1",
parallel_branch_index: 3,
},
],
meta: { has_more: false },
};
const result = mapRunStagesToSidebarStages(stages);
expect(result[0].parallelGroupId).toBe("review_fork@1");
expect(result[0].parallelBranchIndex).toBe(3);
});
test("preserves the authoritative handler for renderer dispatch", () => {
const stages: PaginatedRunStageList = {
data: [

View file

@ -26,6 +26,10 @@ export interface Stage {
graphVisit: number | null;
/** StageId of the prior execution superseded by this resumed replay, if any. */
resumedFromStageId: string | null;
/** Exact StageId of the parent parallel execution, if this is a branch. */
parallelGroupId: string | null;
/** Zero-based outgoing-edge index within the parent parallel execution. */
parallelBranchIndex: number | null;
startedAt: string | null;
providerUsed: StageModelUsage | null;
/**
@ -102,6 +106,8 @@ export function mapRunStagesToSidebarStages(
visit: stage.visit,
graphVisit: stage.graph_visit ?? null,
resumedFromStageId: stage.resumed_from_stage_id ?? null,
parallelGroupId: stage.parallel_group_id ?? null,
parallelBranchIndex: stage.parallel_branch_index ?? null,
status: stage.status,
duration: stage.wall_time_ms != null
? formatDurationMs(stage.wall_time_ms)

View file

@ -2,6 +2,9 @@ import { createElement, type ReactNode } from "react";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
/**
@ -55,6 +58,38 @@ export function makeEventEnvelope(
} as EventEnvelope;
}
/** Flatten a rendered subtree to its visible text. */
export function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
/**
* Build a sidebar `Stage` fixture; override any field via `overrides`. Kept
* here so widening `Stage` updates every fixture at once test files are
* excluded from typecheck, so a per-file copy silently goes stale instead.
*/
export function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}
export function renderHook<T>(
hook: () => T,
options: { wrapper: React.ComponentType<{ children: ReactNode }> },

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<path d="M121.683 75.25L149.997 124L91.4816 224.75C90.3128 226.757 88.155 228 85.8174 228H32.9664C31.7976 228 30.6778 227.691 29.697 227.131C28.7161 226.57 27.8906 225.758 27.3021 224.75L0.876625 179.25C-0.292208 177.243 -0.292208 174.765 0.876625 172.75L57.512 75.25C58.0923 74.2425 58.9259 73.43 59.9068 72.8694C60.8876 72.3088 62.0074 72 63.1762 72H116.027C118.365 72 120.523 73.2431 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.2425 241.076 73.43 240.095 72.8694C239.114 72.3088 237.995 72 236.826 72H183.975C181.637 72 179.479 73.2431 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z" fill="#62DE61"/>
</svg>

After

Width:  |  Height:  |  Size: 917 B

View file

@ -387,8 +387,11 @@ fabro secret set GEMINI_API_KEY AI...
| `INCEPTION_API_KEY` | Inception (Mercury) |
| `POOLSIDE_API_KEY` | Poolside (Laguna) |
| `OPENROUTER_API_KEY` | OpenRouter (when enabled) |
| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) |
| `FIREWORKS_API_KEY` | Fireworks AI (when enabled) |
Modal requires both vault tokens. Its provider definition resolves them into the `Modal-Key` and `Modal-Secret` request headers.
### Sandbox and tools
These optional server integrations are vault-only:

View file

@ -7941,8 +7941,13 @@ components:
model:
type: string
description: |
Catalog model ID or alias. The server selects among ready
providers and stores the canonical model ID.
Catalog model ID or alias, optionally qualified as
`provider:selector`. A provider-qualified selector may be a
canonical model ID, alias, or provider API ID. A value counts as
qualified only when the text before the first `:` names a known
provider, so model IDs containing a colon stay whole. Legacy
`provider/model` references remain accepted. The server stores the
canonical model ID.
provider:
$ref: "#/components/schemas/ProviderId"
description: Optional provider pin. Provider-qualified model references remain accepted for compatibility.
@ -9957,10 +9962,9 @@ components:
Durable identity of one execution of a parallel node, formatted as
"{node_id}@{visit}".
parallel_branch_id:
type: ["string", "null"]
description: >
Durable identity of one branch within a parallel execution,
formatted as "{parallel_group_id}:{index}".
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
session_id:
type: ["string", "null"]
parent_session_id:
@ -10658,6 +10662,10 @@ components:
items:
$ref: "#/components/schemas/ParallelBranchResult"
description: Ordered per-branch results produced by a parallel stage.
parallel_branch_id:
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
output:
type: ["string", "null"]
output_bytes:
@ -12694,6 +12702,13 @@ components:
type: string
example: verify@2
ParallelBranchId:
description: >-
Durable identity of one branch within a parallel execution, in
`{parallel_group_id}:{index}` form.
type: string
example: review_fork@3:1
StageState:
description: Lifecycle projection state of a workflow stage.
type: string
@ -12783,6 +12798,22 @@ components:
StageId of the prior post-checkpoint execution superseded by this
replay after the run was resumed.
example: verify@1
parallel_group_id:
allOf:
- $ref: "#/components/schemas/StageId"
description: >-
Exact StageId of the parent parallel execution. Clients can compare
this directly with the `id` of a parallel stage. Omitted for stages
that are not parallel branches.
example: review_fork@1
parallel_branch_index:
type: integer
format: uint32
minimum: 0
description: >-
Zero-based outgoing-edge index within the parent parallel
execution. Omitted for stages that are not parallel branches.
example: 1
provider_used:
oneOf:
- $ref: "#/components/schemas/StageModelUsage"
@ -14253,6 +14284,16 @@ components:
ModelRef:
type: string
description: |
A fallback model reference. Bare values name a provider, canonical
model ID, or alias. Provider-qualified values use
`provider:selector`; the selector may be a canonical model ID, alias,
or provider API ID and may contain `/` or additional colons. A value
is treated as qualified only when the text before the first `:` names
a known provider, so model IDs that contain a colon — ollama
`name:tag` values, Bedrock inference-profile IDs — stay whole. Legacy
`provider/model` references remain accepted.
example: openrouter:moonshotai/kimi-k3
RunModelSettings:
type: object

View file

@ -180,6 +180,23 @@ Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a
enabled = true
```
### Modal
Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disabled by default. Modal assigns the endpoint URL and authenticates requests with a two-part proxy token:
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Store both token values in the Fabro server vault:
```bash
fabro secret set MODAL_TOKEN_ID wk-...
fabro secret set MODAL_TOKEN_SECRET ws-...
```
### Amazon Bedrock
Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with a curated multi-vendor catalog over Bedrock's Converse API, disabled by default. Enable it and authenticate with a Bedrock API key or AWS SigV4 credentials:
@ -277,7 +294,7 @@ Then launch with:
fabro run run.toml
```
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. In this field, qualified references keep their established provider-pin meaning: `"openai/gpt-5.6-sol"` selects the direct OpenAI offering.
The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model ID or alias (like `"gpt-terra"`), or a qualified `"provider:selector"` reference. A qualified selector may be the provider's canonical model ID, alias, or API ID, including API IDs with slashes such as `"openrouter:moonshotai/kimi-k3"`. Fabro tries entries in order when the primary provider is unavailable, and qualified references remain provider pins. Legacy `provider/model` references remain accepted for compatibility.
<Note>
The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.

View file

@ -98,6 +98,7 @@
"integrations/bedrock",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",
"integrations/fireworks",
"integrations/slack",
"integrations/brave-search"

View file

@ -124,10 +124,10 @@ fallbacks = ["gemini", "openai"]
When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware:
- A bare provider token such as `"gemini"` selects that provider's closest compatible model.
- A qualified selector such as `"openrouter/gpt-56-sol"` resolves only within that provider.
- A qualified selector such as `"openrouter:gpt-56-sol"` resolves only within that provider. The selector may be a canonical model ID, alias, or provider API ID such as `"openrouter:moonshotai/kimi-k3"`.
- A bare model slug or alias considers ready providers and uses provider priority.
Qualified fallback references always remain provider pins, including strings that were historical built-in API IDs. For example, `"openai/gpt-5.6-sol"` pins the direct OpenAI offering.
Qualified fallback references always remain provider pins. For example, `"openai:gpt-5.6-sol"` pins the direct OpenAI offering. Legacy `provider/model` fallback references remain accepted for compatibility.
The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure.

View file

@ -140,10 +140,24 @@ name = "claude-sonnet-4-5"
|---|---|
| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |
| `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model IDs or aliases, or qualified `"provider:selector"` references. |
Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.<id>]`.
For a qualified fallback, the selector may be that provider's canonical model ID, alias, or API ID. Fabro splits on the first `:` when the part before it names a known provider, so provider API IDs may contain `/` or additional colons:
```toml title="run.toml"
[run.model]
fallbacks = [
"openrouter:kimi-k3",
"gpt-terra",
]
```
The first entry could equivalently be written as `"openrouter:moonshotai/kimi-k3"` using OpenRouter's API ID; both forms resolve to its canonical `kimi-k3` offering. The unqualified `gpt-terra` alias uses normal ready-provider priority selection. Legacy `provider/model` fallback references remain accepted but are normalized to `provider:model`.
A colon alone does not make a reference qualified. Many model IDs contain one — ollama `name:tag` values, Bedrock inference-profile IDs and ARNs — so Fabro treats the reference as qualified only when the text before the first `:` names a known provider. `"llama3:8b"` stays a single model ID, while `"ollama:llama3:8b"` pins the `ollama` provider and passes `llama3:8b` as the selector.
At run creation, Fabro resolves the primary selector and every node selector against the ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. The configured fallback chain remains available for failures that occur while the materialized run is executing.
Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering.

View file

@ -0,0 +1,173 @@
---
title: "Modal"
description: "Run Kimi K3 through Modal's OpenAI-compatible inference endpoints"
---
[Modal](https://modal.com/) serves Kimi K3 through an OpenAI-compatible Shared API and through dedicated Auto Endpoints. Fabro ships a disabled `modal` provider entry for Kimi K3. Enable it after Modal gives you an endpoint URL.
## Prerequisites
- A [Modal account](https://modal.com/signup)
- A [Kimi K3 Shared API or Auto Endpoint](https://modal.com/library/moonshot/kimi-k3)
- A Modal proxy-token pair
## Create or select an endpoint
Use the Kimi K3 Shared API from the Modal model library, or create a dedicated Auto Endpoint:
```bash
modal endpoint create --model moonshotai/Kimi-K3
```
Find the endpoint URL in the Modal dashboard or with `modal endpoint list`. Modal serves its OpenAI-compatible API under `/v1`.
## Create a proxy token
Modal endpoints are authenticated with two headers. Create a proxy-token pair:
```bash
modal workspace proxy-tokens create
```
The command prints a token ID that starts with `wk-` and a secret that starts with `ws-`. Modal shows the secret only once, so save both values immediately.
If your Modal workspace uses RBAC, allow the token in the endpoint's environment:
```bash
modal workspace proxy-tokens allow wk-... main
```
## Enable the provider
Add the provider override to the settings file used by the Fabro server. Include `/v1` in the endpoint URL and omit a trailing slash.
```toml title="settings.toml"
_version = 1
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint.
## Configure credentials
Store both proxy-token values in the target Fabro server vault:
```bash
fabro secret set MODAL_TOKEN_ID wk-...
fabro secret set MODAL_TOKEN_SECRET ws-...
# For a non-default remote server:
fabro secret --server https://your-fabro.example set MODAL_TOKEN_ID wk-...
fabro secret --server https://your-fabro.example set MODAL_TOKEN_SECRET ws-...
```
<Note>
`fabro provider login --provider modal` is not supported in this release because that command accepts one credential value. Use the two `fabro secret set` commands above.
</Note>
Modal does not use a bearer API key for these endpoints. Fabro sends the vault values as `Modal-Key` and `Modal-Secret` headers and does not send an `Authorization` header.
## Included model
| Fabro model slug | Modal API ID | Context | Input / cached input / output | Estimated speed |
| --- | --- | --- | --- | --- |
| `kimi-k3` | `moonshotai/Kimi-K3` | 1M tokens | $3.00 / $0.30 / $15.00 per MTok | 460 tok/s |
The catalog marks Kimi K3 as supporting tools, vision, reasoning, and prompt caching. Modal's model ID is case-sensitive.
## Use Kimi K3
```bash
fabro model list --provider modal
fabro model test --provider modal --model kimi-k3
fabro run workflow.fabro --provider modal --model kimi-k3
```
When targeting a non-default remote server, pass the same `--server` value:
```bash
fabro model list --server https://your-fabro.example --provider modal
fabro model test --server https://your-fabro.example --provider modal --model kimi-k3
```
In workflow stylesheets:
```dot title="workflow.fabro"
digraph Example {
graph [
model_stylesheet="
* { model: modal/kimi-k3; }
"
]
start [shape=Mdiamond, label="Start"]
work [label="Work", prompt="Use Kimi K3 through Modal."]
exit [shape=Msquare, label="Exit"]
start -> work -> exit
}
```
## Direct SDK environment credentials
The built-in Modal provider reads its two headers from the Fabro vault. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
For direct SDK use, enable Modal and set its endpoint URL in the catalog:
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read both environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
```
## Costs
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`.
Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill.
## Troubleshooting
**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`.
**Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient.
**401 or 403** — Confirm that the token pair belongs to the correct Modal workspace and environment. If the workspace uses RBAC, allow the token in that environment.
**404** — Confirm that the base URL is the endpoint URL followed by `/v1`, with no trailing slash.
**Unknown model** — The built-in API ID is exactly `moonshotai/Kimi-K3`. Run `fabro model test --provider modal --model kimi-k3` to test the configured offering.
## Further reading
<Columns cols={2}>
<Card title="Modal Kimi K3" icon="microchip" href="https://modal.com/library/moonshot/kimi-k3">
Shared API prices, model specifications, and Auto Endpoint setup.
</Card>
<Card title="Modal endpoint authentication" icon="key" href="https://modal.com/docs/guide/endpoints#proxy-tokens">
Proxy-token headers and endpoint calling conventions.
</Card>
</Columns>

View file

@ -375,6 +375,34 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead.
The built-in Modal definition reads two proxy-token headers from the vault, so `EnvCredentialSource` does not configure it automatically. For direct SDK use, enable Modal and set its endpoint URL in the catalog:
```toml
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read the two environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
```
#### Creating manually
```rust

View file

@ -381,12 +381,12 @@ permissions = "read-write"
[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4"]
fallbacks = ["openrouter:kimi-k3", "gpt-terra"]
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `fallbacks` | array<string> | [] | Ordered list of fallback model references. Supports `...` splice marker<br />at layering time — see [`super::splice_array`]. |
| `fallbacks` | array<string> | [] | Ordered fallback references: bare providers, bare model IDs or aliases,<br />or provider-qualified `provider:selector` values. A qualified selector<br />may be a model ID, alias, or provider API ID. Legacy `provider/model`<br />values remain accepted. Supports the `...` splice marker at layering<br />time — see [`super::splice_array`]. |
| `name` | string | None | Model name for workflow runs. |
| `provider` | string | None | Provider name for workflow model selection. |

View file

@ -1144,6 +1144,8 @@ mod runs {
started_at: None,
graph_visit: None,
resumed_from_stage_id: None,
parallel_group_id: None,
parallel_branch_index: None,
}
}

View file

@ -34,6 +34,11 @@ fn run_stage_from_projection(
.and_then(|node| node.handler_type()),
)
});
let (parallel_group_id, parallel_branch_index) = stage
.parallel_branch_id
.as_ref()
.map(|branch_id| (branch_id.group().clone(), branch_id.index()))
.unzip();
RunStage {
id: stage_id.clone(),
name: stage_id.node_id().to_owned(),
@ -48,6 +53,8 @@ fn run_stage_from_projection(
started_at: stage.started_at,
graph_visit: stage.graph_visit.and_then(std::num::NonZeroU32::new),
resumed_from_stage_id: stage.resumed_from_stage_id.clone(),
parallel_group_id,
parallel_branch_index,
}
}

View file

@ -861,9 +861,10 @@ fn canonical_session_model(
}
let model_ref = requested
.parse::<SettingsModelRef>()
.map_err(|err| ApiError::bad_request(err.to_string()))?;
let (qualified_provider, model) = match model_ref {
SettingsModelRef::Qualified { provider, model } => {
.map_err(|err| ApiError::bad_request(err.to_string()))?
.qualify(catalog);
let (qualified_provider, selector) = match model_ref {
SettingsModelRef::Qualified { provider, selector } => {
let requested_provider = ProviderId::new(provider);
let provider = catalog
.provider(&requested_provider)
@ -881,28 +882,30 @@ fn canonical_session_model(
)));
}
}
(Some(provider), model)
(Some(provider), selector)
}
SettingsModelRef::Bare(model) => {
if explicit_provider.is_none() && catalog.provider(&ProviderId::new(&model)).is_some() {
let detail = if catalog.is_model_selector(&model) {
SettingsModelRef::Bare(selector) => {
if explicit_provider.is_none()
&& catalog.provider(&ProviderId::new(&selector)).is_some()
{
let detail = if catalog.is_model_selector(&selector) {
format!(
"Session model reference '{model}' is ambiguous between a provider and a \
model selector; supply `provider` or use `provider/model`."
"Session model reference '{selector}' is ambiguous between a provider and \
a model selector; supply `provider` or use `provider:model`."
)
} else {
format!(
"Session model reference '{model}' names a provider; include a model ID."
"Session model reference '{selector}' names a provider; include a model ID."
)
};
return Err(ApiError::bad_request(detail));
}
(None, model)
(None, selector)
}
};
let provider = qualified_provider.as_ref().or(explicit_provider.as_ref());
let selected = catalog
.resolve_selection(Some(&model), provider, eligible)
.resolve_selection(Some(&selector), provider, eligible)
.map_err(|error| session_selection_error(&error))?;
Ok((selected.provider, selected.model))
}
@ -1603,7 +1606,12 @@ reasoning = false
(openrouter.clone(), "gpt-5.6-sol".to_string())
);
assert_eq!(
canonical_session_model(&catalog, &both, Some("openrouter/gpt-56-sol"), None,).unwrap(),
canonical_session_model(&catalog, &both, Some("openrouter:gpt-56-sol"), None,).unwrap(),
(openrouter.clone(), "gpt-5.6-sol".to_string())
);
assert_eq!(
canonical_session_model(&catalog, &both, Some("openrouter:openai/gpt-5.6-sol"), None,)
.unwrap(),
(openrouter, "gpt-5.6-sol".to_string())
);
}
@ -1626,6 +1634,31 @@ reasoning = false
);
}
/// A colon in a model ID does not make it provider-qualified. Ollama
/// `name:tag` values and Bedrock ARNs must still reach the provider.
#[test]
fn canonical_session_model_passes_through_colon_bearing_model_ids() {
let catalog = portable_session_catalog();
let openai = ProviderId::openai();
let openrouter = ProviderId::new("openrouter");
let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]);
assert_eq!(
canonical_session_model(
&catalog,
&both,
Some("future-model:latest"),
Some(&openrouter),
)
.unwrap(),
(openrouter, "future-model:latest".to_string())
);
assert_eq!(
canonical_session_model(&catalog, &both, Some("future-model:latest"), None).unwrap(),
(openai, "future-model:latest".to_string())
);
}
#[test]
fn canonical_session_model_rejects_an_unavailable_explicit_provider() {
let catalog = portable_session_catalog();
@ -1674,7 +1707,7 @@ reasoning = false
}
#[test]
fn canonical_session_model_still_treats_non_legacy_qualified_model_as_a_pin() {
fn canonical_session_model_treats_colon_qualified_model_as_a_pin() {
let catalog = portable_session_catalog();
let openrouter = ProviderId::new("openrouter");
@ -1682,7 +1715,7 @@ reasoning = false
canonical_session_model(
&catalog,
&catalog.all_provider_ids(),
Some("openrouter/gpt-56-sol"),
Some("openrouter:gpt-56-sol"),
None,
)
.unwrap(),
@ -1696,7 +1729,7 @@ reasoning = false
let error = canonical_session_model(
&catalog,
&catalog.all_provider_ids(),
Some("openrouter/gpt-56-sol"),
Some("openrouter:gpt-56-sol"),
Some(&ProviderId::openai()),
)
.unwrap_err();

View file

@ -26,8 +26,8 @@ use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec,
SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType, RunBlobId, RunId,
RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
WorkflowSettings, fixtures, test_support,
@ -4899,7 +4899,16 @@ async fn append_scoped_stage_event(
parallel_group_id: None,
parallel_branch_id: None,
};
let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(&scope));
append_event_with_scope(state, run_id, event, &scope).await;
}
async fn append_event_with_scope(
state: &Arc<AppState>,
run_id: RunId,
event: &workflow_event::Event,
scope: &fabro_workflow::event::StageScope,
) {
let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(scope));
let payload = fabro_workflow::event::build_redacted_event_payload(&stored, &run_id).unwrap();
let run_store = state.stores.runs.open_run(&run_id).await.unwrap();
run_store.append_event(&payload).await.unwrap();
@ -5555,6 +5564,77 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() {
assert_eq!(second["resumed_from_stage_id"], "work@1");
}
#[tokio::test]
async fn list_run_stages_exposes_parallel_branch_identity() {
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,
"ordinary",
1,
&workflow_event::Event::StageStarted {
graph_visit: Some(1),
resumed_from_stage_id: None,
node_id: "ordinary".to_string(),
name: "Ordinary".to_string(),
index: 0,
handler_type: "agent".to_string(),
attempt: 1,
max_attempts: 1,
},
)
.await;
let parallel_group_id = StageId::new("review_fork", 2);
let parallel_branch_id = ParallelBranchId::new(parallel_group_id.clone(), 4);
let branch_event = workflow_event::Event::ParallelBranchStarted {
parallel_group_id: parallel_group_id.clone(),
parallel_branch_id: parallel_branch_id.clone(),
branch: "review_glm".to_string(),
index: 4,
graph_visit: Some(3),
resumed_from_stage_id: None,
};
let branch_scope = workflow_event::StageScope::for_parallel_branch(
"review_glm",
3,
parallel_group_id,
parallel_branch_id,
);
append_event_with_scope(&state, run_id, &branch_event, &branch_scope).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;
let branch = stage_entry(&body, "review_glm@3");
assert_eq!(branch["parallel_group_id"], "review_fork@2");
assert_eq!(branch["parallel_branch_index"], 4);
let ordinary = stage_entry(&body, "ordinary@1");
assert!(ordinary.get("parallel_group_id").is_none());
assert!(ordinary.get("parallel_branch_index").is_none());
}
#[tokio::test]
async fn run_billing_includes_live_stage_timing_in_rows_and_totals() {
let state = test_app_state_with_isolated_storage();
@ -6693,7 +6773,7 @@ async fn test_model_explicit_provider_alias_returns_canonical_model_id_when_unav
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["model_id"], "claude-sonnet-4-6");
assert_eq!(body["model_id"], "claude-sonnet-5");
assert_eq!(body["provider"], "anthropic");
assert_eq!(body["status"], "skip");
}

View file

@ -130,7 +130,7 @@ impl NativeToolOptions {
// Matched exhaustively so a new profile kind has to state its answer
// rather than silently inheriting the default timeout.
let default_command_timeout_ms = match profile_kind {
AgentProfileKind::Anthropic => 120_000,
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => 120_000,
// Matches the 60s foreground default Kimi Code's Bash tool
// documents, which is what these models are used to budgeting
// against.
@ -333,12 +333,15 @@ mod tests {
fn native_tool_options_have_expected_profile_defaults() {
let openai = NativeToolOptions::for_profile(AgentProfileKind::OpenAi);
let anthropic = NativeToolOptions::for_profile(AgentProfileKind::Anthropic);
let claude5 = NativeToolOptions::for_profile(AgentProfileKind::Claude5);
let kimi = NativeToolOptions::for_profile(AgentProfileKind::Kimi);
assert_eq!(openai.default_command_timeout_ms, 10_000);
assert_eq!(openai.max_command_timeout_ms, 600_000);
assert_eq!(anthropic.default_command_timeout_ms, 120_000);
assert_eq!(anthropic.max_command_timeout_ms, 600_000);
assert_eq!(claude5.default_command_timeout_ms, 120_000);
assert_eq!(claude5.max_command_timeout_ms, 600_000);
assert_eq!(kimi.default_command_timeout_ms, 60_000);
assert_eq!(kimi.max_command_timeout_ms, 600_000);
}

View file

@ -49,7 +49,8 @@ pub use loop_detection::detect_loop;
pub use memory::{MemoryDocument, discover_memory};
pub use native_tool::{NativeTool, ToolVocabulary};
pub use profiles::{
AgentProfileBuilder, AnthropicProfile, EnvContext, GeminiProfile, KimiProfile, OpenAiProfile,
AgentProfileBuilder, AnthropicProfile, Claude5Profile, EnvContext, GeminiProfile, KimiProfile,
OpenAiProfile,
};
pub use question_tools::{
ANTHROPIC_ASK_USER_QUESTION_TOOL, AgentQuestion, AgentQuestionAnswer,

View file

@ -31,7 +31,9 @@ pub async fn discover_memory(
let directories = build_directory_walk(git_root, working_dir);
let candidate_filenames: Vec<&str> = match profile_kind {
AgentProfileKind::Anthropic => vec!["AGENTS.md", "CLAUDE.md"],
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => {
vec!["AGENTS.md", "CLAUDE.md"]
}
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 => {
vec!["AGENTS.md", ".codex/instructions.md"]
}
@ -207,6 +209,23 @@ mod tests {
assert_eq!(anthropic_docs[0].content, "agents");
assert_eq!(anthropic_docs[1].content, "claude");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
files: files.clone(),
..Default::default()
});
let claude5_docs = discover_memory(
env.as_ref(),
"/repo",
"/repo",
AgentProfileKind::Claude5,
&CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(claude5_docs.len(), 2);
assert_eq!(claude5_docs[0].content, "agents");
assert_eq!(claude5_docs[1].content, "claude");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
files: files.clone(),
..Default::default()

View file

@ -9,8 +9,8 @@
//!
//! A [`NativeTool`] is an identity, not a name. The same tool is expressed
//! under different names depending on the [`ToolVocabulary`] a profile speaks:
//! fabro's own names by default, Kimi Code's names for the Kimi profile, and
//! Codex's names for the GPT-5.6 profile.
//! fabro's own names by default, Anthropic's names for Claude 5, Kimi Code's
//! names for the Kimi profile, and Codex's names for the GPT-5.6 profile.
//! Permissions, categories, and telemetry resolve any name back to the
//! identity, so behavior never depends on which vocabulary is in play.
//!
@ -26,6 +26,8 @@ pub enum ToolVocabulary {
/// Fabro's own names, and the canonical identity used internally.
#[default]
Fabro,
/// The names Anthropic's Claude 5 coding harness exposes.
Claude5,
/// The names Kimi Code exposes, for models trained against that harness.
KimiCode,
/// The names Codex exposes, for the GPT-5.6 models trained against it.
@ -57,7 +59,11 @@ pub enum NativeTool {
Shell,
#[strum(to_string = "web_search", serialize = "WebSearch")]
WebSearch,
#[strum(to_string = "web_fetch", serialize = "FetchURL")]
#[strum(
to_string = "web_fetch",
serialize = "FetchURL",
serialize = "WebFetch"
)]
WebFetch,
#[strum(to_string = "spawn_agent")]
SpawnAgent,
@ -67,6 +73,21 @@ pub enum NativeTool {
Wait,
#[strum(to_string = "close_agent")]
CloseAgent,
// Claude 5 drives one background agent through four tools, where fabro's
// own vocabulary uses `spawn_agent`/`wait`/`close_agent`/`send_input`.
// They are separate identities rather than aliases of those because the
// capabilities differ: `Agent` runs in the background or inline depending
// on `run_in_background`, and `TaskOutput` both polls and waits. Mapping
// them onto the fabro four would promise semantics those tools do not
// have -- the same reason Kimi Code's `Agent` is deliberately unmapped.
#[strum(to_string = "background_agent", serialize = "Agent")]
BackgroundAgent,
#[strum(to_string = "agent_output", serialize = "TaskOutput")]
AgentOutput,
#[strum(to_string = "stop_agent", serialize = "TaskStop")]
StopAgent,
#[strum(to_string = "message_agent", serialize = "SendMessage")]
MessageAgent,
#[strum(to_string = "use_skill", serialize = "Skill")]
UseSkill,
#[strum(to_string = "update_plan")]
@ -116,6 +137,25 @@ impl NativeTool {
pub fn name(self, vocabulary: ToolVocabulary) -> &'static str {
match vocabulary {
ToolVocabulary::Fabro => self.canonical_name(),
ToolVocabulary::Claude5 => match self {
Self::ReadFile => "Read",
Self::WriteFile => "Write",
Self::EditFile => "Edit",
Self::Shell => "Bash",
// Named for completeness: this arm describes the vocabulary,
// not the profile's registry, and the Claude 5 profile
// deliberately registers neither.
Self::Grep => "Grep",
Self::Glob => "Glob",
Self::WebSearch => "WebSearch",
Self::WebFetch => "WebFetch",
Self::UseSkill => "Skill",
Self::BackgroundAgent => "Agent",
Self::AgentOutput => "TaskOutput",
Self::StopAgent => "TaskStop",
Self::MessageAgent => "SendMessage",
other => other.canonical_name(),
},
ToolVocabulary::KimiCode => match self {
Self::ReadFile => "Read",
Self::WriteFile => "Write",
@ -177,9 +217,14 @@ impl NativeTool {
}
Self::WriteFile | Self::EditFile | Self::ApplyPatch => Some(AgentToolCategory::Write),
Self::Shell => Some(AgentToolCategory::Shell),
Self::SpawnAgent | Self::SendInput | Self::Wait | Self::CloseAgent => {
Some(AgentToolCategory::Subagent)
}
Self::SpawnAgent
| Self::SendInput
| Self::Wait
| Self::CloseAgent
| Self::BackgroundAgent
| Self::AgentOutput
| Self::StopAgent
| Self::MessageAgent => Some(AgentToolCategory::Subagent),
// Uncategorized today. Giving these a category would change the CLI
// permission gate, which is a behavior change rather than a
// classification cleanup, so they keep their existing answer.
@ -261,6 +306,50 @@ mod tests {
);
}
#[test]
fn claude5_vocabulary_uses_anthropic_harness_names() {
assert_eq!(NativeTool::ReadFile.name(ToolVocabulary::Claude5), "Read");
assert_eq!(NativeTool::Shell.name(ToolVocabulary::Claude5), "Bash");
assert_eq!(
NativeTool::WebFetch.name(ToolVocabulary::Claude5),
"WebFetch"
);
assert_eq!(
NativeTool::BackgroundAgent.name(ToolVocabulary::Claude5),
"Agent"
);
assert_eq!(
NativeTool::AgentOutput.name(ToolVocabulary::Claude5),
"TaskOutput"
);
assert_eq!(
NativeTool::StopAgent.name(ToolVocabulary::Claude5),
"TaskStop"
);
assert_eq!(
NativeTool::MessageAgent.name(ToolVocabulary::Claude5),
"SendMessage"
);
}
/// The harness name is how a tool is expressed, not what it is: the
/// identity keeps a fabro name, and the harness name resolves back to it.
#[test]
fn claude5_subagent_tools_keep_fabro_canonical_names() {
for (tool, canonical, claude5) in [
(NativeTool::BackgroundAgent, "background_agent", "Agent"),
(NativeTool::AgentOutput, "agent_output", "TaskOutput"),
(NativeTool::StopAgent, "stop_agent", "TaskStop"),
(NativeTool::MessageAgent, "message_agent", "SendMessage"),
] {
assert_eq!(tool.canonical_name(), canonical);
assert_eq!(tool.name(ToolVocabulary::Fabro), canonical);
assert_eq!(tool.name(ToolVocabulary::Claude5), claude5);
assert_eq!(NativeTool::from_any_name(canonical), Some(tool));
assert_eq!(NativeTool::from_any_name(claude5), Some(tool));
}
}
#[test]
fn codex_vocabulary_renames_only_the_shell() {
assert_eq!(

View file

@ -5,17 +5,16 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::{
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
};
use crate::tool_registry::ToolRegistry;
use crate::tools::{
WEB_SEARCH_TOOL_NAME, WebFetchSummarizer, make_edit_file_tool, register_core_tools,
};
use crate::tools::{WEB_SEARCH_TOOL_NAME, make_edit_file_tool, register_core_tools};
pub struct AnthropicProfile {
base: BaseProfile,
@ -26,21 +25,20 @@ const CORE_PROMPT: &str = include_str!("prompts/anthropic.md.j2");
impl AnthropicProfile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::Anthropic);
Self::with_native_tools(model, &options, None)
let deps =
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Anthropic));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(
model: impl Into<String>,
options: &NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
) -> Self {
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let mut registry = ToolRegistry::new();
register_core_tools(&mut registry, options, summarizer);
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
registry.register(make_edit_file_tool());
// Anthropic task tools share one runtime per profile instance.
let todo_runtime = Arc::new(TodoRuntime::new());
// Task tools scope their list by `root_session_id`, so a root session
// and its children address one logical list. They must therefore
// resolve it through the one runtime the builder shares between them.
let todo_runtime = Arc::clone(&deps.todo_runtime);
registry.register(make_task_create_tool(todo_runtime.clone()));
registry.register(make_task_update_tool(todo_runtime.clone()));
registry.register(make_task_get_tool(todo_runtime.clone()));
@ -72,29 +70,7 @@ impl AnthropicProfile {
}
impl AgentProfile for AnthropicProfile {
fn profile_kind(&self) -> AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.base.registry
}
impl_base_profile_accessors!();
fn build_system_prompt(
&self,

View file

@ -0,0 +1,225 @@
//! Profile for Claude Fable 5, Opus 5, and Sonnet 5.
use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, claude5_tools, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::todo_tools::{
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
};
use crate::tool_registry::ToolRegistry;
const CORE_PROMPT: &str = include_str!("prompts/claude5.md.j2");
pub struct Claude5Profile {
base: BaseProfile,
}
impl Claude5Profile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let deps =
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Claude5));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let options = &deps.options;
let summarizer = deps.summarizer.clone();
let todo_runtime = Arc::clone(&deps.todo_runtime);
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
registry.register(claude5_tools::make_read_tool());
registry.register(claude5_tools::make_write_tool());
registry.register(claude5_tools::make_edit_tool());
registry.register(claude5_tools::make_bash_tool(options));
registry.register(claude5_tools::make_web_fetch_tool(summarizer));
if let Some(api_key) = &options.secrets.brave_search_api_key {
registry.register(claude5_tools::make_web_search_tool(api_key.clone()));
}
registry.register(claude5_tools::strict_object_tool(make_task_create_tool(
todo_runtime.clone(),
)));
registry.register(claude5_tools::strict_object_tool(make_task_update_tool(
todo_runtime.clone(),
)));
registry.register(claude5_tools::strict_object_tool(make_task_get_tool(
todo_runtime.clone(),
)));
registry.register(claude5_tools::strict_object_tool(make_task_list_tool(
todo_runtime,
)));
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Claude5,
provider_id: ProviderId::anthropic(),
model: model.into(),
catalog: None,
registry,
},
}
}
/// Override the transport provider while retaining Claude 5 harness
/// behavior.
#[must_use]
pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self {
self.base.provider_id = provider_id;
self
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.base.catalog = Some(catalog);
self
}
}
impl AgentProfile for Claude5Profile {
impl_base_profile_accessors!();
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
skills: &[Skill],
) -> String {
let template = EmbeddedPrompt::new("claude5.md.j2", CORE_PROMPT)
.with_vocabulary(self.base.registry.vocabulary())
.with_bool(
"has_agent",
self.base
.registry
.get_native(NativeTool::BackgroundAgent)
.is_some(),
)
.with_bool(
"has_ask_user_question",
self.base
.registry
.get_native(NativeTool::AskUserQuestion)
.is_some(),
)
.with_bool(
"has_web_search",
self.base
.registry
.get_native(NativeTool::WebSearch)
.is_some(),
);
profiles::assemble_system_prompt(
template,
env,
env_context,
memory,
user_instructions,
skills,
)
}
fn register_subagent_tools(
&mut self,
supervisor: SubAgentSupervisor,
session_factory: SessionFactory,
current_depth: usize,
) {
self.base.registry.register(claude5_tools::make_agent_tool(
supervisor.clone(),
session_factory,
current_depth,
));
self.base
.registry
.register(claude5_tools::make_task_output_tool(supervisor.clone()));
self.base
.registry
.register(claude5_tools::make_task_stop_tool(supervisor.clone()));
self.base
.registry
.register(claude5_tools::make_send_message_tool(supervisor));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subagent::SessionFactory;
use crate::test_support::MockSandbox;
#[test]
fn profile_identity() {
let profile = Claude5Profile::new("claude-fable-5");
assert_eq!(profile.profile_kind(), AgentProfileKind::Claude5);
assert_eq!(profile.provider_id(), ProviderId::anthropic());
assert_eq!(profile.model(), "claude-fable-5");
}
#[test]
fn core_tools_match_the_accepted_claude5_surface() {
let profile = Claude5Profile::new("claude-sonnet-5");
let mut names = profile.tool_registry().names();
names.sort();
assert_eq!(names, vec![
"Bash",
"Edit",
"Read",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskUpdate",
"WebFetch",
"Write",
]);
assert!(!names.iter().any(|name| name == "Grep" || name == "Glob"));
}
#[test]
fn root_agent_tools_use_claude_names() {
let mut profile = Claude5Profile::new("claude-opus-5");
let factory: SessionFactory = Arc::new(|| panic!("unused"));
profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0);
for expected in ["Agent", "TaskOutput", "TaskStop", "SendMessage"] {
assert!(
profile.tool_registry().get(expected).is_some(),
"missing {expected}"
);
}
for absent in ["spawn_agent", "wait", "close_agent", "send_input"] {
assert!(
profile.tool_registry().get(absent).is_none(),
"found {absent}"
);
}
}
#[test]
fn prompt_conditionals_follow_registered_tools() {
let env = MockSandbox::linux();
let profile = Claude5Profile::new("claude-fable-5");
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(!prompt.contains("# Background agents"));
assert!(!prompt.contains("# Asking the user"));
assert!(!prompt.contains("Use `WebSearch`"));
let mut profile = profile;
let factory: SessionFactory = Arc::new(|| panic!("unused"));
profile.register_subagent_tools(SubAgentSupervisor::new(3), factory, 0);
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("# Background agents"));
}
}

View file

@ -0,0 +1,721 @@
//! Claude 5 harness adapters.
//!
//! Execution stays shared with Fabro wherever the behavior agrees. This module
//! narrows the model-facing schemas and supplies the few lifecycle semantics
//! that differ from Fabro's native tools.
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::types::ToolDefinition;
use fabro_util::error as util_error;
use serde_json::Value;
use tokio::time;
use crate::config::NativeToolOptions;
use crate::error::{Error, InterruptReason};
use crate::native_tool::NativeTool;
use crate::session::Session;
use crate::subagent::{SessionFactory, SubAgentResult, SubAgentStatus, SubAgentSupervisor};
use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource};
use crate::tools::{self, WebFetchSummarizer};
fn definition(
tool: NativeTool,
description: impl Into<String>,
parameters: Value,
) -> ToolDefinition {
ToolDefinition {
name: tool.canonical_name().to_string(),
description: description.into(),
parameters,
}
}
/// Reject unknown top-level fields while retaining a shared executor.
#[must_use]
pub(crate) fn strict_object_tool(mut tool: RegisteredTool) -> RegisteredTool {
let object = tool
.definition
.parameters
.as_object_mut()
.expect("native JSON-schema tools should use an object schema");
object.insert("additionalProperties".to_string(), Value::Bool(false));
tool
}
#[must_use]
pub(crate) fn make_read_tool() -> RegisteredTool {
strict_object_tool(tools::make_read_file_tool())
}
#[must_use]
pub(crate) fn make_write_tool() -> RegisteredTool {
strict_object_tool(tools::make_write_file_tool())
}
#[must_use]
pub(crate) fn make_edit_tool() -> RegisteredTool {
strict_object_tool(tools::make_edit_file_tool())
}
#[must_use]
pub(crate) fn make_bash_tool(options: &NativeToolOptions) -> RegisteredTool {
let default_timeout_ms = options.default_command_timeout_ms;
let max_timeout_ms = options.max_command_timeout_ms;
RegisteredTool {
definition: definition(
NativeTool::Shell,
format!(
"Execute a Bash command in a fresh foreground non-login shell. Use this for \
searches, git inspection, builds, tests, package managers, and terminal \
operations. Prefer `rg` for content search and `rg --files` for file discovery. \
Working-directory and environment changes do not persist between calls. \
`timeout` is in milliseconds, defaults to {default_timeout_ms}, and is capped at \
{max_timeout_ms}."
),
serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Bash source to evaluate."
},
"timeout": {
"type": "integer",
"minimum": 0,
"maximum": max_timeout_ms,
"description": format!(
"Maximum runtime in milliseconds (default {default_timeout_ms})."
)
},
"description": {
"type": "string",
"description": "Short description of what the command does."
}
},
"required": ["command"],
"additionalProperties": false
}),
),
executor: Arc::new(move |args, ctx| {
Box::pin(async move {
let command = tools::required_str(&args, "command")?;
let timeout_ms = args
.get("timeout")
.and_then(Value::as_u64)
.unwrap_or(default_timeout_ms)
.min(max_timeout_ms);
tools::run_shell_command(&ctx, command, timeout_ms, None).await
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub(crate) fn make_web_search_tool(api_key: String) -> RegisteredTool {
let mut tool = tools::make_web_search_tool_with_api_key(api_key);
tool.definition = definition(
NativeTool::WebSearch,
"Search the web when current external information is needed. Returns result titles, URLs, \
and descriptions; use WebFetch to inspect a specific URL.",
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The web search query."
}
},
"required": ["query"],
"additionalProperties": false
}),
);
tool
}
#[must_use]
pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> RegisteredTool {
let mut tool = tools::make_web_fetch_tool(summarizer);
tool.definition = definition(
NativeTool::WebFetch,
"Fetch an HTTP or HTTPS URL and answer the supplied prompt from its contents.",
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The HTTP or HTTPS URL to fetch."
},
"prompt": {
"type": "string",
"description": "The question or extraction instruction to apply to the page."
}
},
"required": ["url", "prompt"],
"additionalProperties": false
}),
);
tool
}
fn child_session(session_factory: &SessionFactory, ctx: &ToolContext) -> Session {
let mut session = session_factory();
if let Some(root) = ctx.root_session_id.as_ref().or(ctx.session_id.as_ref()) {
session.set_root_session_id(root.clone());
}
session
}
fn format_agent_result(result: &SubAgentResult) -> String {
format!(
"Agent completed (success: {}, turns: {})\n\n{}",
result.success, result.turns_used, result.output
)
}
fn format_error(error: &Error) -> String {
util_error::collect_chain(error).join(": ")
}
#[must_use]
pub(crate) fn make_agent_tool(
supervisor: SubAgentSupervisor,
session_factory: SessionFactory,
current_depth: usize,
) -> RegisteredTool {
RegisteredTool {
definition: definition(
NativeTool::BackgroundAgent,
"Launch a child agent for an independent task. Agents run in the background by \
default and notify the parent when they finish. Set run_in_background to false to \
wait for the result synchronously.",
serde_json::json!({
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short 3-5 word description of the task."
},
"prompt": {
"type": "string",
"description": "The task for the agent to perform."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to return immediately (default true)."
}
},
"required": ["description", "prompt"],
"additionalProperties": false
}),
),
executor: Arc::new(move |args, ctx| {
let supervisor = supervisor.clone();
let session_factory = session_factory.clone();
Box::pin(async move {
let description = tools::required_str(&args, "description")?;
let prompt = tools::required_str(&args, "prompt")?;
let run_in_background = args
.get("run_in_background")
.and_then(Value::as_bool)
.unwrap_or(true);
let session = child_session(&session_factory, &ctx);
if run_in_background {
let task_id = supervisor
.spawn_with_parent_notification(
session,
prompt.to_string(),
description.to_string(),
current_depth,
)
.map_err(|error| format_error(&error))?;
Ok(format!(
"Agent started in the background.\n\nTask ID: {task_id}"
))
} else {
let task_id = supervisor
.spawn(session, prompt.to_string(), current_depth)
.map_err(|error| format_error(&error))?;
match supervisor.wait_with_cancel(&task_id, &ctx.cancel).await {
Ok(result) => Ok(format_agent_result(&result)),
Err(Error::Interrupted(InterruptReason::Cancelled)) => {
Err("Cancelled".to_string())
}
Err(error) => Err(format_error(&error)),
}
}
})
}),
source: ToolSource::Native,
}
}
/// The schema keeps `block` and `timeout` required to match the Claude 5
/// contract, so these defaults only cover a model that omits them anyway.
const TASK_OUTPUT_DEFAULT_BLOCK: bool = true;
const TASK_OUTPUT_DEFAULT_TIMEOUT_MS: u64 = 30_000;
const TASK_OUTPUT_MAX_TIMEOUT_MS: u64 = 600_000;
fn optional_bool(args: &Value, key: &str, default: bool) -> Result<bool, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(default),
Some(value) => value
.as_bool()
.ok_or_else(|| format!("{key} must be a boolean")),
}
}
fn optional_u64(args: &Value, key: &str, default: u64) -> Result<u64, String> {
match args.get(key) {
None | Some(Value::Null) => Ok(default),
Some(value) => value
.as_u64()
.ok_or_else(|| format!("{key} must be a non-negative integer")),
}
}
fn finished_output(
supervisor: &SubAgentSupervisor,
task_id: &str,
result: Result<SubAgentResult, Error>,
) -> Result<String, String> {
supervisor.suppress_parent_notification(task_id);
match result {
Ok(result) => Ok(format_agent_result(&result)),
Err(error) => Err(format_error(&error)),
}
}
#[must_use]
pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: definition(
NativeTool::AgentOutput,
"Get a background agent's current status or wait for its final output. Automatic \
completion notifications make ordinary polling unnecessary.",
serde_json::json!({
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "The background agent task ID."
},
"block": {
"type": "boolean",
"default": TASK_OUTPUT_DEFAULT_BLOCK,
"description": "Whether to wait for completion."
},
"timeout": {
"type": "integer",
"minimum": 0,
"maximum": TASK_OUTPUT_MAX_TIMEOUT_MS,
"default": TASK_OUTPUT_DEFAULT_TIMEOUT_MS,
"description": "Maximum wait time in milliseconds."
}
},
"required": ["task_id", "block", "timeout"],
"additionalProperties": false
}),
),
executor: Arc::new(move |args, ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
let task_id = tools::required_str(&args, "task_id")?;
let block = optional_bool(&args, "block", TASK_OUTPUT_DEFAULT_BLOCK)?;
let timeout_ms = optional_u64(&args, "timeout", TASK_OUTPUT_DEFAULT_TIMEOUT_MS)?;
if timeout_ms > TASK_OUTPUT_MAX_TIMEOUT_MS {
return Err(format!(
"timeout must be between 0 and {TASK_OUTPUT_MAX_TIMEOUT_MS} milliseconds"
));
}
match supervisor.status(task_id) {
Some(SubAgentStatus::Finished(result)) => {
return finished_output(&supervisor, task_id, result);
}
Some(SubAgentStatus::Running) if !block => {
return Ok(format!("Agent {task_id} is still running."));
}
Some(SubAgentStatus::Closing | SubAgentStatus::Closed) => {
return Ok(format!("Agent {task_id} has been stopped."));
}
None => {
return Err(format!(
"No agent found with id: {task_id} (it was never spawned)"
));
}
Some(SubAgentStatus::Running) => {}
}
match time::timeout(
Duration::from_millis(timeout_ms),
supervisor.wait_with_cancel(task_id, &ctx.cancel),
)
.await
{
Ok(Ok(result)) => {
supervisor.suppress_parent_notification(task_id);
Ok(format_agent_result(&result))
}
Ok(Err(Error::Interrupted(InterruptReason::Cancelled))) => {
supervisor.suppress_parent_notification(task_id);
Err("Cancelled".to_string())
}
Ok(Err(error)) => {
supervisor.suppress_parent_notification(task_id);
Err(format_error(&error))
}
Err(_) => Ok(format!(
"Agent {task_id} is still running after waiting {timeout_ms} ms."
)),
}
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: definition(
NativeTool::StopAgent,
"Stop a running background agent by task ID.",
serde_json::json!({
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "The background agent task ID to stop."
}
},
"required": ["task_id"],
"additionalProperties": false
}),
),
executor: Arc::new(move |args, _ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
let task_id = tools::required_str(&args, "task_id")?;
supervisor
.close_agent(task_id)
.await
.map_err(|error| format_error(&error))?;
Ok(format!("Agent {task_id} stopped."))
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub(crate) fn make_send_message_tool(supervisor: SubAgentSupervisor) -> RegisteredTool {
RegisteredTool {
definition: definition(
NativeTool::MessageAgent,
"Send additional instructions to a running background agent by its task ID.",
serde_json::json!({
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "The background agent task ID."
},
"message": {
"type": "string",
"description": "The follow-up message."
},
"summary": {
"type": "string",
"maxLength": 200,
"description": "Optional short preview of the message."
}
},
"required": ["to", "message"],
"additionalProperties": false
}),
),
executor: Arc::new(move |args, _ctx| {
let supervisor = supervisor.clone();
Box::pin(async move {
let recipient = tools::required_str(&args, "to")?;
let message = tools::required_str(&args, "message")?;
supervisor
.send_input(recipient, message)
.map_err(|error| format_error(&error))?;
Ok(format!("Message sent to agent {recipient}."))
})
}),
source: ToolSource::Native,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::sync::Mutex;
use serde_json::json;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::{MockSandbox, make_session, text_response};
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::{
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
};
fn property_names(tool: &RegisteredTool) -> BTreeSet<&str> {
tool.definition.parameters["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect()
}
fn required_names(tool: &RegisteredTool) -> BTreeSet<&str> {
tool.definition.parameters["required"]
.as_array()
.map(|required| {
required
.iter()
.map(|value| value.as_str().unwrap())
.collect()
})
.unwrap_or_default()
}
fn assert_schema(tool: &RegisteredTool, properties: &[&str], required: &[&str]) {
assert_eq!(tool.definition.parameters["type"], "object");
assert_eq!(
tool.definition.parameters["additionalProperties"],
Value::Bool(false)
);
assert_eq!(property_names(tool), properties.iter().copied().collect());
assert_eq!(required_names(tool), required.iter().copied().collect());
}
fn context() -> ToolContext {
ToolContext {
env: Arc::new(MockSandbox::default()) as Arc<dyn Sandbox>,
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("root".to_string()),
root_session_id: Some("root".to_string()),
tool_call_id: Some("call".to_string()),
agent_event_emitter: None,
}
}
#[test]
fn core_adapter_schemas_match_the_claude5_contract() {
let options = NativeToolOptions::for_profile(fabro_model::AgentProfileKind::Claude5);
assert_schema(&make_read_tool(), &["file_path", "limit", "offset"], &[
"file_path",
]);
assert_schema(&make_write_tool(), &["content", "file_path"], &[
"content",
"file_path",
]);
assert_schema(
&make_edit_tool(),
&["file_path", "new_string", "old_string", "replace_all"],
&["file_path", "new_string", "old_string"],
);
let bash = make_bash_tool(&options);
assert_schema(&bash, &["command", "description", "timeout"], &["command"]);
assert_eq!(
bash.definition.parameters["properties"]["timeout"]["maximum"],
600_000
);
assert_schema(&make_web_fetch_tool(None), &["prompt", "url"], &[
"prompt", "url",
]);
assert_schema(&make_web_search_tool("key".to_string()), &["query"], &[
"query",
]);
let todo_runtime = Arc::new(TodoRuntime::new());
assert_schema(
&strict_object_tool(make_task_create_tool(todo_runtime.clone())),
&["activeForm", "description", "metadata", "subject"],
&["description", "subject"],
);
assert_schema(
&strict_object_tool(make_task_update_tool(todo_runtime.clone())),
&[
"activeForm",
"addBlockedBy",
"addBlocks",
"description",
"metadata",
"owner",
"status",
"subject",
"taskId",
],
&["taskId"],
);
assert_schema(
&strict_object_tool(make_task_get_tool(todo_runtime.clone())),
&["taskId"],
&["taskId"],
);
assert_schema(
&strict_object_tool(make_task_list_tool(todo_runtime)),
&[],
&[],
);
}
#[test]
fn lifecycle_adapter_schemas_match_the_claude5_contract() {
let supervisor = SubAgentSupervisor::new(3);
let factory: SessionFactory = Arc::new(|| panic!("unused"));
assert_schema(
&make_agent_tool(supervisor.clone(), factory, 0),
&["description", "prompt", "run_in_background"],
&["description", "prompt"],
);
assert_schema(
&make_task_output_tool(supervisor.clone()),
&["block", "task_id", "timeout"],
&["block", "task_id", "timeout"],
);
assert_schema(&make_task_stop_tool(supervisor.clone()), &["task_id"], &[
"task_id",
]);
assert_schema(
&make_send_message_tool(supervisor),
&["message", "summary", "to"],
&["message", "to"],
);
}
#[tokio::test]
async fn agent_defaults_to_background_and_produces_parent_notification() {
let supervisor = SubAgentSupervisor::new(3);
let session = make_session(vec![text_response("child report")]).await;
let session_slot = Arc::new(Mutex::new(Some(session)));
let factory_slot = Arc::clone(&session_slot);
let factory: SessionFactory = Arc::new(move || {
factory_slot
.lock()
.unwrap()
.take()
.expect("factory should be called once")
});
let tool = make_agent_tool(supervisor.clone(), factory, 0);
let output = (tool.executor)(
json!({
"description": "Inspect child",
"prompt": "Inspect the child task"
}),
context(),
)
.await
.unwrap();
let task_id = output
.strip_prefix("Agent started in the background.\n\nTask ID: ")
.expect("Agent should return a background task ID");
let notifications = supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.unwrap();
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].agent_id, task_id);
assert_eq!(notifications[0].description, "Inspect child");
assert_eq!(
notifications[0].result.as_ref().unwrap().output,
"child report"
);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn task_output_suppresses_a_racing_automatic_notification() {
let supervisor = SubAgentSupervisor::new(3);
let session = make_session(vec![text_response("explicit report")]).await;
let task_id = supervisor
.spawn_with_parent_notification(
session,
"Inspect".to_string(),
"Inspect explicitly".to_string(),
0,
)
.unwrap();
supervisor
.wait_with_cancel(&task_id, &CancellationToken::new())
.await
.unwrap();
let tool = make_task_output_tool(supervisor.clone());
let output = (tool.executor)(
json!({
"task_id": task_id,
"block": false,
"timeout": 0
}),
context(),
)
.await
.unwrap();
assert!(output.contains("explicit report"));
assert!(
supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.is_none()
);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn task_output_applies_the_schema_defaults_when_the_model_omits_them() {
let supervisor = SubAgentSupervisor::new(3);
let session = make_session(vec![text_response("defaulted report")]).await;
let task_id = supervisor.spawn(session, "Inspect".to_string(), 0).unwrap();
supervisor
.wait_with_cancel(&task_id, &CancellationToken::new())
.await
.unwrap();
let tool = make_task_output_tool(supervisor.clone());
let output = (tool.executor)(json!({ "task_id": task_id }), context())
.await
.unwrap();
assert!(output.contains("defaulted report"));
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn task_output_rejects_a_wrongly_typed_optional_parameter() {
let supervisor = SubAgentSupervisor::new(3);
let tool = make_task_output_tool(supervisor);
let error = (tool.executor)(
json!({
"task_id": "agent-1",
"block": "yes"
}),
context(),
)
.await
.unwrap_err();
assert_eq!(error, "block must be a boolean");
}
}

View file

@ -5,13 +5,15 @@ use fabro_model::{AgentProfileKind, Catalog, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::tool_registry::ToolRegistry;
use crate::tools::{
WEB_SEARCH_TOOL_NAME, WebFetchSummarizer, make_edit_file_tool, make_list_dir_tool,
make_read_many_files_tool, register_core_tools,
WEB_SEARCH_TOOL_NAME, make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool,
register_core_tools,
};
const CORE_PROMPT: &str = include_str!("prompts/gemini.md.j2");
@ -23,18 +25,15 @@ pub struct GeminiProfile {
impl GeminiProfile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::Gemini);
Self::with_native_tools(model, &options, None)
let deps =
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Gemini));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(
model: impl Into<String>,
options: &NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
) -> Self {
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let mut registry = ToolRegistry::new();
register_core_tools(&mut registry, options, summarizer);
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
registry.register(make_edit_file_tool());
registry.register(make_read_many_files_tool());
registry.register(make_list_dir_tool());
@ -65,29 +64,7 @@ impl GeminiProfile {
}
impl AgentProfile for GeminiProfile {
fn profile_kind(&self) -> AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.base.registry
}
impl_base_profile_accessors!();
fn build_system_prompt(
&self,

View file

@ -24,7 +24,9 @@ use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{self, BaseProfile, EmbeddedPrompt, FileEditToolKind};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, FileEditToolKind, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
@ -45,11 +47,13 @@ pub struct Gpt56Profile {
impl Gpt56Profile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::Gpt56);
Self::with_native_tools(model, &options)
let deps = ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Gpt56));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(model: impl Into<String>, options: &NativeToolOptions) -> Self {
/// `deps.summarizer` is ignored: this profile exposes no `web_fetch`.
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let options = &deps.options;
// The registry carries the vocabulary, so tools registered later --
// subagent tools, skills -- are named consistently too.
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::Codex);
@ -177,29 +181,7 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool {
}
impl AgentProfile for Gpt56Profile {
fn profile_kind(&self) -> AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.base.registry
}
impl_base_profile_accessors!();
fn build_system_prompt(
&self,
@ -386,7 +368,8 @@ mod tests {
let mut options = NativeToolOptions::for_profile(AgentProfileKind::Gpt56);
options.secrets.brave_search_api_key = Some("configured-key".to_string());
let searching = Gpt56Profile::with_native_tools("gpt-5.6-sol", &options);
let deps = ProfileDeps::standalone(options);
let searching = Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps);
assert!(searching.tool_registry().get("web_search").is_some());
assert!(prompt(&searching).contains("web_search"));
}

View file

@ -6,13 +6,15 @@ use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::config::NativeToolOptions;
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{self, BaseProfile, EmbeddedPrompt, kimi_tools};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors, kimi_tools,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::make_todo_list_tool;
use crate::tool_registry::ToolRegistry;
use crate::tools::{WebFetchSummarizer, register_discovery_and_web_tools};
use crate::tools::register_discovery_and_web_tools;
const CORE_PROMPT: &str = include_str!("prompts/kimi.md.j2");
@ -64,15 +66,12 @@ pub struct KimiProfile {
impl KimiProfile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::Kimi);
Self::with_native_tools(model, &options, None)
let deps = ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::Kimi));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(
model: impl Into<String>,
options: &NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
) -> Self {
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let options = &deps.options;
// The registry carries the vocabulary, so tools registered later
// (subagent tools, skills) are renamed too.
let mut registry = ToolRegistry::with_vocabulary(ToolVocabulary::KimiCode);
@ -80,7 +79,7 @@ impl KimiProfile {
// Glob and the web tools have the same contract in both vocabularies.
// The remaining Kimi tools use adapters for their different schemas,
// while reusing shared execution helpers where their behavior agrees.
register_discovery_and_web_tools(&mut registry, options, summarizer);
register_discovery_and_web_tools(&mut registry, options, deps.summarizer.clone());
registry.register(kimi_tools::make_kimi_read_tool());
registry.register(kimi_tools::make_kimi_write_tool());
registry.register(kimi_tools::make_kimi_edit_tool(EDIT_FILE_DESCRIPTION));
@ -127,29 +126,7 @@ impl KimiProfile {
}
impl AgentProfile for KimiProfile {
fn profile_kind(&self) -> AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.base.registry
}
impl_base_profile_accessors!();
fn build_system_prompt(
&self,

View file

@ -4,6 +4,8 @@ use std::sync::Arc;
use fabro_model::{AgentProfileKind, Catalog, CodecKind, ProviderId};
pub mod anthropic;
pub mod claude5;
pub(crate) mod claude5_tools;
pub mod gemini;
pub mod gpt56;
pub mod kimi;
@ -11,6 +13,7 @@ pub mod kimi_tools;
pub mod openai;
pub use anthropic::AnthropicProfile;
pub use claude5::Claude5Profile;
pub use gemini::GeminiProfile;
pub use gpt56::Gpt56Profile;
pub use kimi::KimiProfile;
@ -22,6 +25,7 @@ use crate::config::{NativeToolOptions, ToolSecrets};
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::sandbox::Sandbox;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::todo_runtime::TodoRuntime;
use crate::tool_registry::ToolRegistry;
use crate::tools::{self, WebFetchSummarizer};
@ -39,6 +43,33 @@ pub struct AgentProfileBuilder {
catalog: Arc<Catalog>,
native_tool_options: NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
todo_runtime: Arc<TodoRuntime>,
}
/// Everything a profile constructor needs from the builder.
///
/// Bundled rather than passed positionally so that adding a dependency does
/// not mean editing every profile's signature -- and, more importantly, so a
/// dependency cannot reach some profiles and silently miss others. The shared
/// `todo_runtime` is exactly that case: task tools scope their list by
/// `root_session_id`, so a root and its children address one logical list and
/// must resolve it through one runtime.
pub(crate) struct ProfileDeps {
pub options: NativeToolOptions,
pub summarizer: Option<WebFetchSummarizer>,
pub todo_runtime: Arc<TodoRuntime>,
}
impl ProfileDeps {
/// Standalone defaults, for `Profile::new` and tests. A profile built this
/// way owns its runtime because it has no children to share one with.
pub(crate) fn standalone(options: NativeToolOptions) -> Self {
Self {
options,
summarizer: None,
todo_runtime: Arc::new(TodoRuntime::new()),
}
}
}
impl AgentProfileBuilder {
@ -56,6 +87,7 @@ impl AgentProfileBuilder {
catalog,
native_tool_options: NativeToolOptions::for_profile(profile_kind),
summarizer: None,
todo_runtime: Arc::new(TodoRuntime::new()),
}
}
@ -78,34 +110,42 @@ impl AgentProfileBuilder {
#[must_use]
pub fn build(&self) -> Box<dyn AgentProfile> {
let model = self.model.as_str();
let options = &self.native_tool_options;
let summarizer = if self.profile_kind == AgentProfileKind::Gpt56 {
None
} else {
self.summarizer.clone()
let deps = ProfileDeps {
options: self.native_tool_options.clone(),
summarizer: if self.profile_kind == AgentProfileKind::Gpt56 {
None
} else {
self.summarizer.clone()
},
todo_runtime: Arc::clone(&self.todo_runtime),
};
match self.profile_kind {
AgentProfileKind::OpenAi => Box::new(
OpenAiProfile::with_native_tools(model, options, summarizer)
OpenAiProfile::with_native_tools(model, &deps)
.with_route(self.provider_id.clone(), Arc::clone(&self.catalog)),
),
AgentProfileKind::Gemini => Box::new(
GeminiProfile::with_native_tools(model, options, summarizer)
GeminiProfile::with_native_tools(model, &deps)
.with_provider_id(self.provider_id.clone())
.with_catalog(Arc::clone(&self.catalog)),
),
AgentProfileKind::Anthropic => Box::new(
AnthropicProfile::with_native_tools(model, options, summarizer)
AnthropicProfile::with_native_tools(model, &deps)
.with_provider_id(self.provider_id.clone())
.with_catalog(Arc::clone(&self.catalog)),
),
AgentProfileKind::Claude5 => Box::new(
Claude5Profile::with_native_tools(model, &deps)
.with_provider_id(self.provider_id.clone())
.with_catalog(Arc::clone(&self.catalog)),
),
AgentProfileKind::Kimi => Box::new(
KimiProfile::with_native_tools(model, options, summarizer)
KimiProfile::with_native_tools(model, &deps)
.with_provider_id(self.provider_id.clone())
.with_catalog(Arc::clone(&self.catalog)),
),
AgentProfileKind::Gpt56 => Box::new(
Gpt56Profile::with_native_tools(model, options)
Gpt56Profile::with_native_tools(model, &deps)
.with_route(self.provider_id.clone(), Arc::clone(&self.catalog)),
),
}
@ -159,6 +199,45 @@ impl FileEditToolKind {
}
}
/// Implement the [`AgentProfile`](crate::agent_profile::AgentProfile)
/// accessors that just delegate to an embedded [`BaseProfile`] named `base`.
///
/// Every profile that owns a `BaseProfile` writes the same six methods; what
/// actually distinguishes them is `build_system_prompt` and, for some,
/// `register_subagent_tools`. Types that implement the trait without a
/// `BaseProfile` -- test doubles, and the server's ask-fabro profile -- write
/// the accessors themselves, which is why this is a macro rather than a set of
/// trait defaults: there is no sensible default for a profile that has no base.
macro_rules! impl_base_profile_accessors {
() => {
fn profile_kind(&self) -> ::fabro_model::AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ::fabro_model::ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&::fabro_model::Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &$crate::tool_registry::ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut $crate::tool_registry::ToolRegistry {
&mut self.base.registry
}
};
}
pub(crate) use impl_base_profile_accessors;
/// Common fields shared by all provider profiles.
///
/// Each concrete profile embeds this struct and delegates `profile_kind()`,
@ -374,11 +453,13 @@ pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> Stri
mod tests {
use fabro_llm::types::ToolDefinition;
use fabro_model::catalog::LlmCatalogSettings;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::question_tools;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::test_support::MockSandbox;
use crate::tools::WEB_SEARCH_TOOL_NAME;
use crate::tool_registry::ToolContext;
fn native_tool_options(
profile_kind: AgentProfileKind,
@ -404,35 +485,60 @@ mod tests {
fn anthropic_profile(has_web_search: bool, has_subagents: bool) -> AnthropicProfile {
let options = native_tool_options(AgentProfileKind::Anthropic, has_web_search);
let mut profile = AnthropicProfile::with_native_tools("claude-haiku-4-5", &options, None);
let deps = ProfileDeps::standalone(options);
let mut profile = AnthropicProfile::with_native_tools("claude-haiku-4-5", &deps);
if has_subagents {
register_test_subagent_tools(&mut profile);
}
profile
}
fn claude5_profile(
has_web_search: bool,
has_subagents: bool,
has_question: bool,
) -> Claude5Profile {
let options = native_tool_options(AgentProfileKind::Claude5, has_web_search);
let deps = ProfileDeps::standalone(options);
let mut profile = Claude5Profile::with_native_tools("claude-sonnet-5", &deps);
if has_subagents {
register_test_subagent_tools(&mut profile);
}
if has_question {
question_tools::register_question_tools(
AgentProfileKind::Claude5,
profile.tool_registry_mut(),
);
}
profile
}
fn gemini_profile(has_web_search: bool) -> GeminiProfile {
let options = native_tool_options(AgentProfileKind::Gemini, has_web_search);
GeminiProfile::with_native_tools("gemini-3-flash-preview", &options, None)
let deps = ProfileDeps::standalone(options);
GeminiProfile::with_native_tools("gemini-3-flash-preview", &deps)
}
fn openai_apply_patch_profile(has_web_search: bool) -> OpenAiProfile {
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
OpenAiProfile::with_native_tools("gpt-5.4-mini", &options, None)
let deps = ProfileDeps::standalone(options);
OpenAiProfile::with_native_tools("gpt-5.4-mini", &deps)
}
fn gpt56_profile(has_web_search: bool) -> Gpt56Profile {
let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search);
Gpt56Profile::with_native_tools("gpt-5.6-sol", &options)
let deps = ProfileDeps::standalone(options);
Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps)
}
/// GPT-5.6 through an OpenAI-compatible gateway, where `apply_patch`
/// cannot be carried and `edit_file` takes its place.
fn gpt56_edit_file_profile(has_web_search: bool) -> Gpt56Profile {
let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search);
let deps = ProfileDeps::standalone(options);
let overrides: LlmCatalogSettings =
toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap();
Gpt56Profile::with_native_tools("gpt-5.6-sol", &options).with_route(
Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps).with_route(
ProviderId::new("openrouter"),
Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()),
)
@ -440,7 +546,8 @@ mod tests {
fn openai_edit_file_profile(has_web_search: bool) -> OpenAiProfile {
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
OpenAiProfile::with_native_tools("kimi-k2.5", &options, None).with_route(
let deps = ProfileDeps::standalone(options);
OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route(
ProviderId::new("kimi"),
Arc::new(Catalog::from_builtin().unwrap()),
)
@ -605,6 +712,11 @@ mod tests {
ProviderId::gemini(),
"gemini-3-flash-preview",
),
(
AgentProfileKind::Claude5,
ProviderId::anthropic(),
"claude-sonnet-5",
),
(AgentProfileKind::Gpt56, ProviderId::openai(), "gpt-5.6-sol"),
];
@ -616,12 +728,13 @@ mod tests {
Arc::clone(&catalog),
)
.build();
let web_search_name = NativeTool::WebSearch.name(profile.tool_registry().vocabulary());
assert_eq!(profile.profile_kind(), profile_kind);
assert_eq!(profile.provider_id(), provider_id);
assert!(profile.tool_registry().get(WEB_SEARCH_TOOL_NAME).is_none());
assert!(profile.tool_registry().get(web_search_name).is_none());
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(
!prompt.contains("web_search"),
!prompt.contains(web_search_name),
"{profile_kind:?} prompt advertised an unavailable tool"
);
@ -637,22 +750,97 @@ mod tests {
// Built twice: one configured builder must outfit both a root
// session and the child sessions it spawns.
for configured in [configured_builder.build(), configured_builder.build()] {
assert!(
configured
.tool_registry()
.get(WEB_SEARCH_TOOL_NAME)
.is_some()
);
assert!(configured.tool_registry().get(web_search_name).is_some());
let prompt =
configured.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(
prompt.contains("web_search"),
prompt.contains(web_search_name),
"{profile_kind:?} prompt omitted guidance for an available tool"
);
}
}
}
/// Task tools scope their list by `root_session_id`, so a root session and
/// every child it spawns address one logical list. `build()` runs once per
/// session, so the runtime behind that list has to come from the builder --
/// a per-profile runtime gives each session its own projection and its own
/// ID counter, and the two sessions then collide on `#1` in the merged
/// projection while neither can see the other's tasks.
async fn assert_builder_shares_tasks_across_root_and_child(
profile_kind: AgentProfileKind,
model: &str,
) {
let builder = AgentProfileBuilder::new(
profile_kind,
ProviderId::anthropic(),
model,
Arc::new(Catalog::from_builtin().unwrap()),
);
let root = builder.build();
let child = builder.build();
let executor = |profile: &dyn AgentProfile, name: &str| {
Arc::clone(
&profile
.tool_registry()
.get(name)
.unwrap_or_else(|| panic!("{profile_kind} should expose {name}"))
.executor,
)
};
let root_create = executor(root.as_ref(), "TaskCreate");
let child_create = executor(child.as_ref(), "TaskCreate");
let child_list = executor(child.as_ref(), "TaskList");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let context = |session_id: &str| ToolContext {
env: Arc::clone(&env),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some(session_id.to_string()),
root_session_id: Some("root-session".to_string()),
tool_call_id: None,
agent_event_emitter: None,
};
root_create(
serde_json::json!({"subject": "Parent task", "description": "Root work"}),
context("root-session"),
)
.await
.unwrap();
child_create(
serde_json::json!({"subject": "Child task", "description": "Child work"}),
context("child-session"),
)
.await
.unwrap();
let tasks = child_list(serde_json::json!({}), context("child-session"))
.await
.unwrap();
assert!(tasks.contains("#1 [pending] Parent task"), "{tasks}");
assert!(tasks.contains("#2 [pending] Child task"), "{tasks}");
}
#[tokio::test]
async fn claude5_builder_shares_tasks_across_root_and_child_profiles() {
assert_builder_shares_tasks_across_root_and_child(
AgentProfileKind::Claude5,
"claude-sonnet-5",
)
.await;
}
#[tokio::test]
async fn anthropic_builder_shares_tasks_across_root_and_child_profiles() {
assert_builder_shares_tasks_across_root_and_child(
AgentProfileKind::Anthropic,
"claude-haiku-4-5",
)
.await;
}
#[test]
fn profile_builder_selects_a_codec_compatible_gpt56_editor() {
let overrides: LlmCatalogSettings =
@ -690,6 +878,47 @@ mod tests {
insta::assert_snapshot!(system_prompt(&anthropic_profile(true, true)));
}
#[test]
fn claude5_default_prompt_snapshot() {
insta::assert_snapshot!(system_prompt(&claude5_profile(false, false, false)));
}
#[test]
fn claude5_all_conditionals_prompt_snapshot() {
insta::assert_snapshot!(system_prompt(&claude5_profile(true, true, true)));
}
/// The two snapshots above pin the wording of every conditional section.
/// This covers the six intermediate combinations, which only need to show
/// that each section appears exactly when its tool is registered -- as
/// snapshots they were six near-identical copies of the same prose, and any
/// edit to the template invalidated all eight at once.
#[test]
fn claude5_prompt_sections_track_registered_tools() {
for web_search in [false, true] {
for subagents in [false, true] {
for question in [false, true] {
let prompt = system_prompt(&claude5_profile(web_search, subagents, question));
assert_eq!(
prompt.contains("Use `WebSearch`"),
web_search,
"web_search={web_search} subagents={subagents} question={question}"
);
assert_eq!(
prompt.contains("# Background agents"),
subagents,
"web_search={web_search} subagents={subagents} question={question}"
);
assert_eq!(
prompt.contains("# Asking the user"),
question,
"web_search={web_search} subagents={subagents} question={question}"
);
}
}
}
}
#[test]
fn gemini_default_prompt_snapshot() {
insta::assert_snapshot!(system_prompt(&gemini_profile(false)));

View file

@ -6,13 +6,15 @@ use super::EnvContext;
use crate::agent_profile::AgentProfile;
use crate::apply_patch;
use crate::config::NativeToolOptions;
use crate::profiles::{self, BaseProfile, EmbeddedPrompt};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::make_update_plan_tool;
use crate::tool_registry::ToolRegistry;
use crate::tools::{self, WebFetchSummarizer, register_core_tools};
use crate::tools::{self, register_core_tools};
const CORE_PROMPT: &str = include_str!("prompts/openai.md.j2");
@ -23,18 +25,15 @@ pub struct OpenAiProfile {
impl OpenAiProfile {
#[must_use]
pub fn new(model: impl Into<String>) -> Self {
let options = NativeToolOptions::for_profile(AgentProfileKind::OpenAi);
Self::with_native_tools(model, &options, None)
let deps =
ProfileDeps::standalone(NativeToolOptions::for_profile(AgentProfileKind::OpenAi));
Self::with_native_tools(model, &deps)
}
pub(crate) fn with_native_tools(
model: impl Into<String>,
options: &NativeToolOptions,
summarizer: Option<WebFetchSummarizer>,
) -> Self {
pub(crate) fn with_native_tools(model: impl Into<String>, deps: &ProfileDeps) -> Self {
let mut registry = ToolRegistry::new();
register_core_tools(&mut registry, options, summarizer);
register_core_tools(&mut registry, &deps.options, deps.summarizer.clone());
registry.register(apply_patch::make_apply_patch_tool());
// Codex-compatible `update_plan` is OpenAI-only.
let todo_runtime = Arc::new(TodoRuntime::new());
@ -62,29 +61,7 @@ impl OpenAiProfile {
}
impl AgentProfile for OpenAiProfile {
fn profile_kind(&self) -> AgentProfileKind {
self.base.profile_kind
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}
fn catalog(&self) -> Option<&Catalog> {
self.base.catalog.as_deref()
}
fn tool_registry(&self) -> &ToolRegistry {
&self.base.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.base.registry
}
impl_base_profile_accessors!();
fn build_system_prompt(
&self,

View file

@ -0,0 +1,80 @@
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
{{ inputs.env_block }}
# Harness
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
- Follow all project and user instructions included in this prompt.
- Reference code with `file_path:line_number` when a precise location helps.
# Delivering work
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
# Working in the codebase
- Read relevant code before proposing or making changes.
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
# Tool use
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
Use `WebFetch` with both a URL and a prompt describing the information to extract.
{% if inputs.has_web_search %}
Use `WebSearch` when current external information is needed. Its input is a search query; use `WebFetch` to inspect a specific result.
{% endif %}
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
{% if inputs.has_agent %}
# Background agents
Use `Agent` for independent, substantial work or to keep broad exploration out of the parent context. Do not delegate a task and duplicate the same work yourself.
Agents run in the background by default. Launch independent agents together in one response so they run concurrently. Set `run_in_background` to false when their result is an immediate prerequisite and there is no useful parent work to do meanwhile.
Background completion and failure notifications arrive automatically. Do not poll `TaskOutput` for ordinary progress. Use it only when you deliberately need to block or inspect status. Use `SendMessage` to add instructions to a running agent and `TaskStop` when a running agent is no longer needed.
An agent's report is evidence, not automatic proof. Inspect relevant changes or run appropriate verification before reporting delegated implementation as complete. Synthesize the useful result for the user; raw agent reports are not a substitute for the final response.
{% endif %}
{% if inputs.has_ask_user_question %}
# Asking the user
Use `AskUserQuestion` only when blocked on a decision that genuinely belongs to the user and cannot be resolved from the request, code, project instructions, or a sensible default. Do not use it for routine permission to continue.
When recommending an option, put it first and mark it as recommended. The interface supplies a free-form alternative automatically.
{% endif %}
# Communicating with the user
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
# Context management
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.

View file

@ -0,0 +1,89 @@
---
source: lib/components/fabro-agent/src/profiles/mod.rs
expression: "system_prompt(&claude5_profile(true, true, true))"
---
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
<environment>
Working directory: /home/test
Is git repository: false
Platform: linux
OS version: Linux 6.1.0
</environment>
# Harness
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
- Follow all project and user instructions included in this prompt.
- Reference code with `file_path:line_number` when a precise location helps.
# Delivering work
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
# Working in the codebase
- Read relevant code before proposing or making changes.
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
# Tool use
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
Use `WebFetch` with both a URL and a prompt describing the information to extract.
Use `WebSearch` when current external information is needed. Its input is a search query; use `WebFetch` to inspect a specific result.
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
# Background agents
Use `Agent` for independent, substantial work or to keep broad exploration out of the parent context. Do not delegate a task and duplicate the same work yourself.
Agents run in the background by default. Launch independent agents together in one response so they run concurrently. Set `run_in_background` to false when their result is an immediate prerequisite and there is no useful parent work to do meanwhile.
Background completion and failure notifications arrive automatically. Do not poll `TaskOutput` for ordinary progress. Use it only when you deliberately need to block or inspect status. Use `SendMessage` to add instructions to a running agent and `TaskStop` when a running agent is no longer needed.
An agent's report is evidence, not automatic proof. Inspect relevant changes or run appropriate verification before reporting delegated implementation as complete. Synthesize the useful result for the user; raw agent reports are not a substitute for the final response.
# Asking the user
Use `AskUserQuestion` only when blocked on a decision that genuinely belongs to the user and cannot be resolved from the request, code, project instructions, or a sensible default. Do not use it for routine permission to continue.
When recommending an option, put it first and mark it as recommended. The interface supplies a free-form alternative automatically.
# Communicating with the user
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
# Context management
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.

View file

@ -0,0 +1,71 @@
---
source: lib/components/fabro-agent/src/profiles/mod.rs
expression: "system_prompt(&claude5_profile(false, false, false))"
---
You are Claude, a software engineering agent running in Fabro. Use the tools available in this session to complete software engineering work and to answer questions about the codebase.
When the request is to explain, diagnose, review, or report status, inspect the relevant evidence and return an assessment. Do not modify files or external state unless the request asks for a change. When the request asks you to build or change something, implement the complete change and verify it.
<environment>
Working directory: /home/test
Is git repository: false
Platform: linux
OS version: Linux 6.1.0
</environment>
# Harness
- Text outside tool calls is shown to the user as GitHub-flavored Markdown.
- The user may not see your reasoning or raw tool output. Make the final response self-contained.
- Independent tool calls can run in parallel in one response. Run dependent operations sequentially.
- Follow all project and user instructions included in this prompt.
- Reference code with `file_path:line_number` when a precise location helps.
# Delivering work
Work on the request actually given. Do not quietly narrow, widen, or transform its scope. Make routine judgment calls yourself. If different interpretations would materially change the result, finish everything independent of that decision and use `AskUserQuestion` when it is available.
Keep going until the requested outcome is complete. Do not stop at a plan, a list of next steps, or a promise to do work that can be performed with the available tools. If one part is blocked, complete the remaining independent work and report the blocker precisely.
Use evidence rather than guesses. When an attempt fails, inspect the error and assumptions before making a focused adjustment. Report outcomes faithfully: state which verification ran, what passed or failed, and what was not checked.
# Working in the codebase
- Read relevant code before proposing or making changes.
- This workspace tracks reads before writes. Before every `Edit` or `Write` to an existing file, call `Read` on its current contents. Re-read after the file changes before making another edit to it.
- Prefer `Edit` for targeted changes. `Write` creates a file or deliberately replaces its entire contents.
- Make the smallest complete change that satisfies the request. Avoid unrelated refactors, speculative abstractions, and compatibility machinery without a real requirement.
- Match the surrounding code's structure, naming, formatting, and comment density. Add comments only for constraints or reasoning the code cannot make evident.
- Validate at real boundaries such as external input and APIs; do not add defensive branches for states excluded by established invariants.
- Run the most targeted useful verification first, then broaden it when the risk warrants it. Exercise user-facing behavior when the environment supports doing so.
# Tool use
Use `Read`, `Edit`, and `Write` instead of shell commands for file inspection and mutation. `Read` handles UTF-8 text and returns numbered lines; it does not render images, PDFs, or notebooks.
Use `Bash` for searches, git inspection, builds, tests, package managers, and other terminal operations. Prefer `rg` for content search and `rg --files` with `-g` filters for file discovery. Narrow commands so their output remains useful.
Each `Bash` call is a fresh foreground, non-login shell. Working-directory and environment changes do not persist between calls; keep dependent `cd` or environment setup in the same command. `timeout` is measured in milliseconds.
Use `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` when meaningful multi-step work benefits from visible tracking. Keep statuses current as work progresses. Skip task tracking when it adds no value.
Use `WebFetch` with both a URL and a prompt describing the information to extract.
Other registered tools may be supplied by MCP servers or the surrounding Fabro workflow. Follow their definitions.
# Communicating with the user
Before the first tool call, state what you are about to do in one concise sentence. While working, give brief updates only at meaningful milestones, such as finding the cause, changing direction, or completing a major phase.
Do not expose internal deliberation. Write for a teammate catching up: use complete sentences, explain only details that affect conclusions or next actions, and avoid private shorthand.
Lead the final response with the outcome. A simple question deserves a direct answer rather than unnecessary sections. Be concise by omitting low-value detail, not by compressing useful explanation into fragments.
# Context management
Long sessions may be summarized and continued in a new context window. Treat the supplied summary as the continuation of the same work. Do not wrap up early merely because the session is long.

View file

@ -2,6 +2,7 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::ops::RangeInclusive;
use std::sync::Arc;
use async_trait::async_trait;
@ -149,6 +150,42 @@ struct AnthropicOption {
preview: Option<String>,
}
/// Contract rules the JSON Schema cannot express, and which differ between
/// the two harnesses sharing one normalizer.
struct QuestionLimits {
questions: RangeInclusive<usize>,
questions_error: &'static str,
/// `None` leaves the option count unbounded.
options: Option<RangeInclusive<usize>>,
options_error: &'static str,
max_header_chars: Option<usize>,
/// Claude 5's schema marks `header` and every option `description`
/// required, so both are validated rather than passed through as given.
require_header_and_descriptions: bool,
/// Claude 5 renders multi-select without a preview pane.
allow_preview_with_multi_select: bool,
}
const ANTHROPIC_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
questions: 1..=usize::MAX,
questions_error: "questions must contain at least one question",
options: None,
options_error: "",
max_header_chars: None,
require_header_and_descriptions: false,
allow_preview_with_multi_select: true,
};
const CLAUDE5_QUESTION_LIMITS: QuestionLimits = QuestionLimits {
questions: 1..=4,
questions_error: "questions must contain between one and four questions",
options: Some(2..=4),
options_error: "each question must contain between two and four options",
max_header_chars: Some(12),
require_header_and_descriptions: true,
allow_preview_with_multi_select: false,
};
#[must_use]
pub fn is_question_tool(name: &str) -> bool {
matches!(
@ -168,6 +205,9 @@ pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut To
AgentProfileKind::Anthropic | AgentProfileKind::Kimi => {
registry.register(make_anthropic_question_tool());
}
AgentProfileKind::Claude5 => {
registry.register(make_claude5_question_tool());
}
AgentProfileKind::Gemini => {}
}
}
@ -260,7 +300,85 @@ fn make_anthropic_question_tool() -> RegisteredTool {
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions = normalize_anthropic_questions(parsed)?;
let questions =
normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
}),
source: ToolSource::Native,
}
}
fn make_claude5_question_tool() -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),
description: "Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"questions": {
"description": "Questions to ask the user (1-4 questions)",
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"question": {
"description": "The complete, clear, and specific question to ask.",
"type": "string"
},
"header": {
"description": "Very short label displayed as a chip/tag (max 12 chars).",
"type": "string"
},
"options": {
"description": "Two to four choices. Do not include Other; the UI adds it automatically.",
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"label": {
"description": "Concise display text for the option.",
"type": "string"
},
"description": {
"description": "What the option means and its relevant trade-offs.",
"type": "string"
},
"preview": {
"description": "Optional Markdown preview for single-select visual comparisons.",
"type": "string"
}
},
"required": ["label", "description"],
"additionalProperties": false
}
},
"multiSelect": {
"description": "Whether the user may select multiple options.",
"default": false,
"type": "boolean"
}
},
"required": ["question", "header", "options", "multiSelect"],
"additionalProperties": false
}
}
},
"required": ["questions"],
"additionalProperties": false
}),
},
executor: Arc::new(|args, ctx| {
Box::pin(async move {
let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;
let questions =
normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?;
let answers = execute_question_tool(ctx, questions).await?;
format_anthropic_answers(&answers)
})
@ -325,25 +443,71 @@ fn normalize_openai_questions(args: OpenAiQuestionToolArgs) -> Result<Vec<AgentQ
fn normalize_anthropic_questions(
args: AnthropicQuestionToolArgs,
limits: &QuestionLimits,
) -> Result<Vec<AgentQuestion>, String> {
if args.questions.is_empty() {
return Err("questions must contain at least one question".to_string());
if !limits.questions.contains(&args.questions.len()) {
return Err(limits.questions_error.to_string());
}
args.questions
.into_iter()
.map(|question| {
let original_question = non_empty(&question.question, "question")?;
let header = if limits.require_header_and_descriptions {
let header = non_empty(
question.header.as_deref().unwrap_or_default(),
"question header",
)?;
if limits
.max_header_chars
.is_some_and(|max| header.chars().count() > max)
{
return Err(format!(
"question header must contain at most {} characters",
limits.max_header_chars.unwrap_or_default()
));
}
Some(header)
} else {
question.header
};
if let Some(bounds) = &limits.options {
if !bounds.contains(&question.options.len()) {
return Err(limits.options_error.to_string());
}
}
if !limits.allow_preview_with_multi_select
&& question.multi_select
&& question
.options
.iter()
.any(|option| option.preview.is_some())
{
return Err(
"option previews are not supported for multi-select questions".to_string(),
);
}
// The lenient contract renders the question and header exactly as
// supplied; the strict one has already trimmed them.
let text = if limits.require_header_and_descriptions {
display_text(header.as_deref(), &original_question)
} else {
display_text(header.as_deref(), &question.question)
};
Ok(AgentQuestion {
original_id: None,
text: display_text(question.header.as_deref(), &question.question),
header: question.header,
text,
header,
original_question,
question_type: if question.multi_select {
QuestionType::MultiSelect
} else {
QuestionType::MultipleChoice
},
options: options_from_anthropic(question.options),
options: options_from_anthropic(question.options, limits)?,
allow_freeform: true,
})
})
@ -365,19 +529,34 @@ fn options_from_openai(options: Vec<OpenAiOption>) -> Vec<InterviewOption> {
.collect()
}
fn options_from_anthropic(options: Vec<AnthropicOption>) -> Vec<InterviewOption> {
fn options_from_anthropic(
options: Vec<AnthropicOption>,
limits: &QuestionLimits,
) -> Result<Vec<InterviewOption>, String> {
options
.into_iter()
.enumerate()
.map(|(idx, option)| InterviewOption {
key: option_key(idx),
label: option.label,
description: option
.description
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
preview: option
.preview
.map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),
.map(|(idx, option)| {
let (label, description) = if limits.require_header_and_descriptions {
(
non_empty(&option.label, "option label")?,
Some(non_empty(
option.description.as_deref().unwrap_or_default(),
"option description",
)?),
)
} else {
(option.label, option.description)
};
Ok(InterviewOption {
key: option_key(idx),
label,
description: description
.map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),
preview: option
.preview
.map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),
})
})
.collect()
}
@ -472,6 +651,7 @@ fn format_anthropic_answers(answers: &[AgentQuestionAnswer]) -> Result<String, S
mod tests {
use super::*;
use crate::native_tool::ToolVocabulary;
use crate::test_support::MockSandbox;
fn answered(
original_id: Option<&str>,
@ -529,7 +709,7 @@ mod tests {
}))
.unwrap();
let questions = normalize_anthropic_questions(args).unwrap();
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
assert_eq!(questions[0].question_type, QuestionType::MultiSelect);
assert_eq!(
@ -601,8 +781,198 @@ mod tests {
assert!(kimi.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some());
assert!(kimi.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
let mut claude5 = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5);
register_question_tools(AgentProfileKind::Claude5, &mut claude5);
let tool = claude5.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).unwrap();
assert_eq!(tool.definition.parameters["additionalProperties"], false);
assert_eq!(
tool.definition.parameters["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
vec!["questions"]
);
assert_eq!(
tool.definition.parameters["properties"]["questions"]["maxItems"],
4
);
assert!(claude5.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());
let mut gemini = ToolRegistry::new();
register_question_tools(AgentProfileKind::Gemini, &mut gemini);
assert!(gemini.names().is_empty());
}
#[test]
fn claude5_question_contract_is_strict_and_preserves_preview() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"header": "Approach",
"question": "Which approach should we use?",
"multiSelect": false,
"options": [
{
"label": "Simple",
"description": "Use the smallest implementation.",
"preview": "fn simple() {}"
},
{
"label": "Flexible",
"description": "Allow future extension."
}
]
}]
}))
.unwrap();
let questions = normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).unwrap();
assert_eq!(questions[0].header.as_deref(), Some("Approach"));
assert_eq!(
questions[0].options[0].preview.as_deref(),
Some("fn simple() {}")
);
assert!(questions[0].allow_freeform);
}
/// The Claude 5 payload is deserialized through the lenient struct now, so
/// the rules its own struct used to enforce are the normalizer's job.
#[test]
fn claude5_limits_reject_what_the_lenient_contract_allows() {
let question = |patch: serde_json::Value| {
let mut base = json!({
"question": "Which approach?",
"header": "Approach",
"multiSelect": false,
"options": [
{"label": "First", "description": "One"},
{"label": "Second", "description": "Two"}
]
});
let object = base.as_object_mut().unwrap();
for (key, value) in patch.as_object().unwrap() {
if value.is_null() {
object.remove(key);
} else {
object.insert(key.clone(), value.clone());
}
}
base
};
let normalize = |questions: serde_json::Value| {
let args: AnthropicQuestionToolArgs =
serde_json::from_value(json!({"questions": questions})).unwrap();
normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS)
};
// A missing header and a missing option description used to be caught
// by serde; the normalizer has to reject them now.
assert!(normalize(json!([question(json!({"header": null}))])).is_err());
assert!(
normalize(json!([question(json!({
"options": [{"label": "First"}, {"label": "Second"}]
}))]))
.is_err()
);
assert!(
normalize(json!([question(json!({"header": "ThirteenChars"}))])).is_err(),
"header longer than 12 characters"
);
assert!(
normalize(json!([question(json!({
"options": [{"label": "Only", "description": "One"}]
}))]))
.is_err(),
"fewer than two options"
);
assert!(
normalize(json!(vec![question(json!({})); 5])).is_err(),
"more than four questions"
);
assert!(normalize(json!([question(json!({}))])).is_ok());
}
/// The same payloads stay acceptable under the lenient contract, so the
/// shared normalizer has not tightened the Anthropic tool.
#[test]
fn anthropic_limits_still_accept_optional_headers_and_descriptions() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"question": "Which approach?",
"options": [{"label": "First"}]
}]
}))
.unwrap();
let questions = normalize_anthropic_questions(args, &ANTHROPIC_QUESTION_LIMITS).unwrap();
assert_eq!(questions.len(), 1);
assert_eq!(questions[0].header, None);
assert_eq!(questions[0].options[0].description, None);
}
#[test]
fn claude5_rejects_previews_for_multi_select_questions() {
let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({
"questions": [{
"header": "Features",
"question": "Which features should we enable?",
"multiSelect": true,
"options": [
{
"label": "Auth",
"description": "Enable authentication.",
"preview": "auth = true"
},
{
"label": "Metrics",
"description": "Enable metrics."
}
]
}]
}))
.unwrap();
assert!(normalize_anthropic_questions(args, &CLAUDE5_QUESTION_LIMITS).is_err());
}
#[tokio::test]
async fn claude5_question_tool_rejects_subagent_sessions() {
let tool = make_claude5_question_tool();
let error = (tool.executor)(
json!({
"questions": [{
"header": "Approach",
"question": "Which approach?",
"multiSelect": false,
"options": [
{
"label": "Simple",
"description": "Use the simple approach."
},
{
"label": "Flexible",
"description": "Use the flexible approach."
}
]
}]
}),
ToolContext {
env: Arc::new(MockSandbox::default()),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("child".to_string()),
root_session_id: Some("root".to_string()),
tool_call_id: Some("call".to_string()),
agent_event_emitter: None,
},
)
.await
.unwrap_err();
assert!(error.contains("only available to the root agent"));
}
}

View file

@ -368,6 +368,18 @@ struct BuiltRequest {
context_window: StageContextWindowProjection,
}
/// Whether an input's `/name` tokens should be treated as skill references.
///
/// Only text the user actually typed can invoke a skill. Harness-synthesized
/// input carries whatever a child agent wrote, where `/tmp` is a path rather
/// than an invocation: expanding it would either fail the parent turn on an
/// unknown name or splice a skill template in place of the envelope.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SkillExpansion {
Apply,
Skip,
}
pub struct Session {
id: String,
/// Root agent session ID for this session's agent tree. A root session
@ -1318,10 +1330,14 @@ impl Session {
})
});
// Process the initial input, then drain any followups
// Process the initial input, then drain followups. Claude-compatible
// background-agent results join this same boundary queue: they never
// interrupt inference or a tool call, and all results already ready at
// a boundary are delivered in one additional parent turn.
let mut result = self
.run_single_input(
input,
SkillExpansion::Apply,
&agent_tool_runtime,
&mut timing,
&mut usage,
@ -1336,10 +1352,34 @@ impl Session {
.lock()
.expect("followup queue lock poisoned")
.pop_front();
let Some(followup) = followup else { break };
let next_input = if let Some(followup) = followup {
Some((followup, SkillExpansion::Apply))
} else if let Some(supervisor) = self.subagent_supervisor.clone() {
match supervisor
.next_parent_notification_turn(&self.cancel_token)
.await
{
Ok(Some(turn)) => Some((turn, SkillExpansion::Skip)),
Ok(None) => None,
Err(Error::Interrupted(InterruptReason::Cancelled)) => {
result = Err(self.interrupted_error());
None
}
Err(error) => {
result = Err(error);
None
}
}
} else {
None
};
let Some((next_input, skill_expansion)) = next_input else {
break;
};
result = self
.run_single_input(
&followup,
&next_input,
skill_expansion,
&agent_tool_runtime,
&mut timing,
&mut usage,
@ -1377,6 +1417,7 @@ impl Session {
async fn run_single_input(
&mut self,
input: &str,
skill_expansion: SkillExpansion,
agent_tool_runtime: &AgentToolRuntime,
timing: &mut SessionInputTiming,
usage_accumulator: &mut TokenCounts,
@ -1391,7 +1432,7 @@ impl Session {
self.transition(SessionState::Thinking);
// Expand skill references in input
let expanded = if self.skills.is_empty() {
let expanded = if self.skills.is_empty() || skill_expansion == SkillExpansion::Skip {
ExpandedInput {
text: input.to_string(),
skill_name: None,
@ -3040,6 +3081,121 @@ mod tests {
);
}
#[tokio::test]
async fn background_agent_notifications_are_batched_into_one_parent_turn() {
let supervisor = SubAgentSupervisor::new(3);
let first = make_session(vec![text_response("first result")]).await;
let second = make_session(vec![text_response("second result")]).await;
let first_id = supervisor
.spawn_with_parent_notification(
first,
"first task".to_string(),
"Inspect first".to_string(),
0,
)
.unwrap();
let second_id = supervisor
.spawn_with_parent_notification(
second,
"second task".to_string(),
"Inspect second".to_string(),
0,
)
.unwrap();
// Make both results ready before the parent reaches its safe turn
// boundary so batching is deterministic.
supervisor
.wait_with_cancel(&first_id, &CancellationToken::new())
.await
.unwrap();
supervisor
.wait_with_cancel(&second_id, &CancellationToken::new())
.await
.unwrap();
let provider = Arc::new(ScriptedStreamProvider::new(vec![
ScriptedStreamCall::Response(Box::new(text_response("Parent is waiting"))),
ScriptedStreamCall::Response(Box::new(text_response("Synthesized both results"))),
]));
let mut parent =
make_session_with_provider_and_manager(provider, Some(supervisor.clone())).await;
let output = parent
.process_input_with_output("Delegate both tasks")
.await
.unwrap();
assert_eq!(output.as_deref(), Some("Synthesized both results"));
let turns = parent.history().turns();
assert_eq!(turns.len(), 4);
let Message::User {
content: notification,
..
} = &turns[2]
else {
panic!("third turn should deliver the background results");
};
assert_eq!(notification.matches("<task-notification>").count(), 2);
assert!(notification.contains(&first_id));
assert!(notification.contains(&second_id));
assert!(notification.contains("first result"));
assert!(notification.contains("second result"));
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn background_agent_output_is_not_parsed_for_skill_references() {
let supervisor = SubAgentSupervisor::new(3);
let child = make_session(vec![text_response("Cleaned up /tmp and exited")]).await;
let child_id = supervisor
.spawn_with_parent_notification(
child,
"clean up".to_string(),
"Clean scratch files".to_string(),
0,
)
.unwrap();
supervisor
.wait_with_cancel(&child_id, &CancellationToken::new())
.await
.unwrap();
let provider = Arc::new(ScriptedStreamProvider::new(vec![
ScriptedStreamCall::Response(Box::new(text_response("Delegated"))),
ScriptedStreamCall::Response(Box::new(text_response("Acknowledged"))),
]));
let mut parent =
make_session_with_provider_and_manager(provider, Some(supervisor.clone())).await;
parent.skills = vec![Skill {
name: "commit".to_string(),
description: "Make a commit".to_string(),
template: "Review changes and commit.".to_string(),
}];
// A child that mentions a bare path must not fail the parent turn on
// `Unknown skill: /tmp`, nor have its report replaced by a skill body.
let output = parent
.process_input_with_output("Delegate the cleanup")
.await
.unwrap();
assert_eq!(output.as_deref(), Some("Acknowledged"));
let turns = parent.history().turns();
let Message::User {
content: notification,
..
} = &turns[2]
else {
panic!("third turn should deliver the background result");
};
assert!(notification.contains("Cleaned up /tmp and exited"));
assert!(!notification.contains("Review changes and commit."));
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn events_emitted() {
let mut session = make_session(vec![text_response("Hello")]).await;

View file

@ -189,6 +189,24 @@ pub fn make_use_skill_tool_for_vocabulary(
"required": ["skill_name"]
}),
),
ToolVocabulary::Claude5 => (
"skill",
serde_json::json!({
"type": "object",
"properties": {
"skill": {
"type": "string",
"description": "Exact name of the skill to invoke"
},
"args": {
"type": "string",
"description": "Optional argument string to pass to the skill"
}
},
"required": ["skill"],
"additionalProperties": false
}),
),
ToolVocabulary::KimiCode => (
"skill",
serde_json::json!({
@ -730,4 +748,46 @@ name: trimmed
.is_none()
);
}
#[tokio::test]
async fn claude5_skill_schema_uses_skill_and_optional_args() {
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool_for_vocabulary(skills, ToolVocabulary::Claude5);
let result = (tool.executor)(
serde_json::json!({"skill": "commit", "args": "only staged files"}),
ToolContext {
env: Arc::new(MockSandbox::default()),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: None,
root_session_id: None,
tool_call_id: None,
agent_event_emitter: None,
},
)
.await
.unwrap();
assert!(result.contains("only staged files"), "{result}");
assert_eq!(
tool.definition.parameters["required"],
serde_json::json!(["skill"])
);
assert_eq!(tool.definition.parameters["additionalProperties"], false);
assert!(
tool.definition.parameters["properties"]
.get("skill")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
.get("args")
.is_some()
);
assert!(
tool.definition.parameters["properties"]
.get("skill_name")
.is_none()
);
}
}

View file

@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use fabro_llm::types::ToolDefinition;
use fabro_util::error as util_error;
use futures::future;
use tokio::sync::{oneshot, watch};
use tokio::task::{AbortHandle, JoinHandle};
@ -32,6 +33,51 @@ pub struct SubAgentResult {
pub turns_used: usize,
}
/// A terminal background-agent result waiting to be delivered to its parent at
/// a safe turn boundary.
#[derive(Debug, Clone)]
pub(crate) struct SubAgentParentNotification {
pub agent_id: String,
pub description: String,
pub result: Result<SubAgentResult, Error>,
}
fn format_parent_notification_batch(notifications: &[SubAgentParentNotification]) -> String {
notifications
.iter()
.map(|notification| {
let (status, result) = match &notification.result {
Ok(result) if result.success => ("completed", result.output.clone()),
Ok(result) => ("failed", result.output.clone()),
Err(error) => ("failed", util_error::collect_chain(error).join(": ")),
};
format!(
"<task-notification>\n <task-id>{}</task-id>\n <status>{status}</status>\n \
<description>{}</description>\n <result>{}</result>\n</task-notification>",
escape_notification_xml(&notification.agent_id),
escape_notification_xml(&notification.description),
escape_notification_xml(&result),
)
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn escape_notification_xml(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(character),
}
}
escaped
}
#[derive(Debug, Clone)]
pub enum SubAgentStatus {
Running,
@ -43,16 +89,27 @@ pub enum SubAgentStatus {
const SUBAGENT_SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
struct SubAgent {
status: watch::Sender<SubAgentStatus>,
cleanup_done: watch::Sender<bool>,
cleanup_started: bool,
monitor_task: Option<JoinHandle<()>>,
event_forwarder: Option<JoinHandle<()>>,
cleanup_task: Option<JoinHandle<()>>,
child_abort_handle: AbortHandle,
followup_queue: Arc<Mutex<VecDeque<String>>>,
cancel_token: CancellationToken,
depth: usize,
status: watch::Sender<SubAgentStatus>,
cleanup_done: watch::Sender<bool>,
cleanup_started: bool,
monitor_task: Option<JoinHandle<()>>,
event_forwarder: Option<JoinHandle<()>>,
cleanup_task: Option<JoinHandle<()>>,
child_abort_handle: AbortHandle,
followup_queue: Arc<Mutex<VecDeque<String>>>,
cancel_token: CancellationToken,
depth: usize,
/// Task description, set when the parent should receive this child's
/// terminal result automatically. Cleared once the result is delivered,
/// the parent retrieves it explicitly, or the agent is shut down.
///
/// Keeping this beside the status it is delivered with means a
/// notification cannot be registered before -- or suppressed after -- the
/// state it describes: there is only one lock and one ordering.
parent_notification: Option<String>,
/// Spawn order, so a batch is delivered oldest-first rather than in
/// whatever order the map happens to iterate.
spawn_seq: u64,
}
impl Drop for SubAgent {
@ -73,7 +130,8 @@ impl Drop for SubAgent {
#[derive(Default)]
struct SupervisorState {
agents: HashMap<String, SubAgent>,
agents: HashMap<String, SubAgent>,
next_spawn_seq: u64,
}
struct ShutdownWork {
@ -115,10 +173,20 @@ impl Drop for CleanupDoneGuard {
}
}
/// Wake anything parked in
/// [`SubAgentSupervisor::next_parent_notification_batch`] so it can re-evaluate
/// which children are deliverable.
fn signal_notifications(changed: &watch::Sender<u64>) {
changed.send_modify(|generation| {
*generation = generation.wrapping_add(1);
});
}
fn spawn_result_monitor(
child_task: JoinHandle<Result<SubAgentResult, Error>>,
status: watch::Sender<SubAgentStatus>,
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
notifications_changed: Arc<watch::Sender<u64>>,
agent_id: String,
depth: usize,
) -> JoinHandle<()> {
@ -140,18 +208,20 @@ fn spawn_result_monitor(
if !committed {
return;
}
// The status this agent will be delivered with is now committed.
signal_notifications(&notifications_changed);
let event = match task_result {
let event = match &task_result {
Ok(result) => AgentEvent::SubAgentCompleted {
agent_id,
agent_id: agent_id.clone(),
depth,
success: result.success,
turns_used: result.turns_used,
},
Err(error) => AgentEvent::SubAgentFailed {
agent_id,
agent_id: agent_id.clone(),
depth,
error,
error: error.clone(),
},
};
let callback = event_callback
@ -171,9 +241,10 @@ fn spawn_result_monitor(
/// happen after the guard has been released.
#[derive(Clone)]
pub struct SubAgentSupervisor {
state: Arc<Mutex<SupervisorState>>,
max_depth: usize,
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
state: Arc<Mutex<SupervisorState>>,
max_depth: usize,
event_callback: Arc<RwLock<Option<SubAgentEventCallback>>>,
notifications_changed: Arc<watch::Sender<u64>>,
}
impl SubAgentSupervisor {
@ -183,6 +254,7 @@ impl SubAgentSupervisor {
state: Arc::new(Mutex::new(SupervisorState::default())),
max_depth,
event_callback: Arc::new(RwLock::new(None)),
notifications_changed: Arc::new(watch::channel(0).0),
}
}
@ -205,10 +277,32 @@ impl SubAgentSupervisor {
}
pub fn spawn(
&self,
session: Session,
task_prompt: String,
depth: usize,
) -> Result<String, Error> {
self.spawn_inner(session, task_prompt, depth, None)
}
/// Spawn a child whose terminal result should automatically be delivered
/// to the parent session.
pub(crate) fn spawn_with_parent_notification(
&self,
session: Session,
task_prompt: String,
description: String,
depth: usize,
) -> Result<String, Error> {
self.spawn_inner(session, task_prompt, depth, Some(description))
}
fn spawn_inner(
&self,
mut session: Session,
task_prompt: String,
depth: usize,
parent_notification_description: Option<String>,
) -> Result<String, Error> {
if depth >= self.max_depth {
return Err(Error::InvalidState(format!(
@ -295,12 +389,15 @@ impl SubAgentSupervisor {
child_task,
status.clone(),
Arc::clone(&self.event_callback),
Arc::clone(&self.notifications_changed),
agent_id.clone(),
child_depth,
);
{
let mut state = self.state.lock().expect("subagent state lock poisoned");
let spawn_seq = state.next_spawn_seq;
state.next_spawn_seq = state.next_spawn_seq.saturating_add(1);
state.agents.insert(agent_id.clone(), SubAgent {
status,
cleanup_done,
@ -312,8 +409,11 @@ impl SubAgentSupervisor {
followup_queue,
cancel_token,
depth: child_depth,
parent_notification: parent_notification_description,
spawn_seq,
});
}
signal_notifications(&self.notifications_changed);
self.emit_event(AgentEvent::SubAgentSpawned {
agent_id: agent_id.clone(),
@ -397,6 +497,108 @@ impl SubAgentSupervisor {
}
}
/// Stop automatic delivery for an agent whose result the parent retrieved
/// explicitly.
pub(crate) fn suppress_parent_notification(&self, agent_id: &str) {
let cleared = {
let mut state = self.state.lock().expect("subagent state lock poisoned");
state
.agents
.get_mut(agent_id)
.and_then(|agent| agent.parent_notification.take())
.is_some()
};
if cleared {
signal_notifications(&self.notifications_changed);
}
}
/// Wait until all currently-ready background results can be delivered in
/// one parent turn, rendered as the text of that turn. Returns `None` once
/// no notifiable agents remain.
///
/// The envelope format is the supervisor's concern, so callers receive a
/// finished turn rather than the notifications behind it.
pub(crate) async fn next_parent_notification_turn(
&self,
cancel: &CancellationToken,
) -> Result<Option<String>, Error> {
Ok(self
.next_parent_notification_batch(cancel)
.await?
.map(|notifications| format_parent_notification_batch(&notifications)))
}
/// The notifications behind [`Self::next_parent_notification_turn`], for
/// tests that assert on delivery semantics rather than on the rendering.
pub(crate) async fn next_parent_notification_batch(
&self,
cancel: &CancellationToken,
) -> Result<Option<Vec<SubAgentParentNotification>>, Error> {
let mut changed = self.notifications_changed.subscribe();
loop {
{
let mut state = self.state.lock().expect("subagent state lock poisoned");
let mut ready = Vec::new();
let mut awaiting_result = false;
for (agent_id, agent) in &state.agents {
let Some(description) = agent.parent_notification.as_ref() else {
continue;
};
let finished = match &*agent.status.borrow() {
SubAgentStatus::Finished(result) => Some(result.clone()),
SubAgentStatus::Running => {
awaiting_result = true;
None
}
// Being torn down, so no result is coming. Ignoring
// these is what keeps a shutdown that races delivery
// from parking the parent forever.
SubAgentStatus::Closing | SubAgentStatus::Closed => None,
};
if let Some(result) = finished {
ready.push((agent.spawn_seq, SubAgentParentNotification {
agent_id: agent_id.clone(),
description: description.clone(),
result,
}));
}
}
if !ready.is_empty() {
ready.sort_by_key(|(spawn_seq, _)| *spawn_seq);
let batch: Vec<_> = ready
.into_iter()
.map(|(_, notification)| notification)
.collect();
for notification in &batch {
if let Some(agent) = state.agents.get_mut(&notification.agent_id) {
agent.parent_notification = None;
}
}
return Ok(Some(batch));
}
if !awaiting_result {
return Ok(None);
}
}
tokio::select! {
biased;
() = cancel.cancelled() => {
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
observed = changed.changed() => {
observed.map_err(|_| {
Error::InvalidState(
"Background-agent notification observer closed unexpectedly".to_string(),
)
})?;
}
}
}
}
#[cfg(test)]
async fn wait(&self, agent_id: &str) -> Result<SubAgentResult, Error> {
self.wait_with_cancel(agent_id, &CancellationToken::new())
@ -444,6 +646,11 @@ impl SubAgentSupervisor {
}
};
// Shutdown is committed, so this child's result will never reach the
// parent. The early returns above leave the notification intact, so a
// rejected shutdown cannot discard a result the parent is owed.
agent.parent_notification = None;
if agent.cleanup_started {
return Ok(ShutdownDisposition::Follow(agent.cleanup_done.subscribe()));
}
@ -549,7 +756,9 @@ impl SubAgentSupervisor {
}
async fn ensure_closed(&self, agent_id: &str) -> Result<(), Error> {
let cleanup_done = match self.begin_shutdown(agent_id, false)? {
let disposition = self.begin_shutdown(agent_id, false)?;
signal_notifications(&self.notifications_changed);
let cleanup_done = match disposition {
ShutdownDisposition::Lead(work) => self.spawn_shutdown(work),
ShutdownDisposition::Follow(cleanup_done) => cleanup_done,
ShutdownDisposition::Done => return Ok(()),
@ -560,7 +769,9 @@ impl SubAgentSupervisor {
/// Strict user-facing close: only a currently running child may be closed.
pub async fn close_agent(&self, agent_id: &str) -> Result<(), Error> {
let cleanup_done = match self.begin_shutdown(agent_id, true)? {
let disposition = self.begin_shutdown(agent_id, true)?;
signal_notifications(&self.notifications_changed);
let cleanup_done = match disposition {
ShutdownDisposition::Lead(work) => self.spawn_shutdown(work),
ShutdownDisposition::Follow(_) | ShutdownDisposition::Done => {
return Err(Error::InvalidState(format!(
@ -628,6 +839,7 @@ impl SubAgentSupervisor {
child_task,
status.clone(),
Arc::clone(&self.event_callback),
Arc::clone(&self.notifications_changed),
agent_id.clone(),
depth,
);
@ -643,6 +855,8 @@ impl SubAgentSupervisor {
event_forwarder,
cleanup_task: None,
child_abort_handle,
parent_notification: None,
spawn_seq: 0,
followup_queue: Arc::new(Mutex::new(VecDeque::new())),
cancel_token,
depth,
@ -842,6 +1056,192 @@ mod tests {
assert!(manager.is_empty());
}
#[test]
fn parent_notification_envelope_escapes_xml() {
let envelope = format_parent_notification_batch(&[SubAgentParentNotification {
agent_id: "agent<&".to_string(),
description: "Review <core> & tests".to_string(),
result: Ok(SubAgentResult {
output: "done <safely> & \"verified\"".to_string(),
success: true,
turns_used: 2,
}),
}]);
assert!(envelope.contains("<status>completed</status>"));
assert!(envelope.contains("<task-id>agent&lt;&amp;</task-id>"));
assert!(envelope.contains("<description>Review &lt;core&gt; &amp; tests</description>"));
assert!(
envelope.contains("<result>done &lt;safely&gt; &amp; &quot;verified&quot;</result>")
);
}
#[tokio::test]
async fn a_finished_agent_is_delivered_to_the_parent_exactly_once() {
let supervisor = SubAgentSupervisor::new(3);
let child = make_session(vec![text_response("child result")]).await;
let agent_id = supervisor
.spawn_with_parent_notification(
child,
"task".to_string(),
"Inspect the module".to_string(),
0,
)
.unwrap();
supervisor
.wait_with_cancel(&agent_id, &CancellationToken::new())
.await
.unwrap();
let batch = supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.expect("the finished child must be delivered");
assert_eq!(batch.len(), 1);
assert_eq!(batch[0].agent_id, agent_id);
assert_eq!(batch[0].description, "Inspect the module");
// The status stays `Finished`, so re-delivery is prevented by clearing
// the registration rather than by consuming the result.
assert!(
supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.is_none()
);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn batches_are_delivered_in_spawn_order() {
let supervisor = SubAgentSupervisor::new(3);
let mut ids = Vec::new();
for index in 0..3 {
let child = make_session(vec![text_response("done")]).await;
ids.push(
supervisor
.spawn_with_parent_notification(
child,
format!("task {index}"),
format!("Task {index}"),
0,
)
.unwrap(),
);
}
for id in &ids {
supervisor
.wait_with_cancel(id, &CancellationToken::new())
.await
.unwrap();
}
let batch = supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.expect("all three children must be delivered together");
let delivered: Vec<_> = batch.iter().map(|n| n.agent_id.clone()).collect();
assert_eq!(delivered, ids);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn suppressing_before_completion_stops_delivery() {
let supervisor = SubAgentSupervisor::new(3);
let child = make_session(vec![text_response("child result")]).await;
let agent_id = supervisor
.spawn_with_parent_notification(
child,
"task".to_string(),
"Inspect the module".to_string(),
0,
)
.unwrap();
supervisor.suppress_parent_notification(&agent_id);
supervisor
.wait_with_cancel(&agent_id, &CancellationToken::new())
.await
.unwrap();
assert!(
supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.is_none()
);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn closing_a_running_agent_stops_delivery_without_parking_the_parent() {
let supervisor = SubAgentSupervisor::new(3);
let child = make_session(vec![text_response("child result")]).await;
let agent_id = supervisor
.spawn_with_parent_notification(
child,
"task".to_string(),
"Inspect the module".to_string(),
0,
)
.unwrap();
supervisor.close_agent(&agent_id).await.unwrap();
// Must resolve rather than wait for a result that will never arrive.
assert!(
supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.is_none()
);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn rejected_stop_of_a_finished_agent_keeps_its_notification() {
let supervisor = SubAgentSupervisor::new(3);
let child = make_session(vec![text_response("child result")]).await;
let agent_id = supervisor
.spawn_with_parent_notification(
child,
"task".to_string(),
"Inspect the module".to_string(),
0,
)
.unwrap();
// Finish the child so its result is queued for automatic delivery.
supervisor
.wait_with_cancel(&agent_id, &CancellationToken::new())
.await
.unwrap();
// Stopping a finished agent is rejected...
let error = supervisor.close_agent(&agent_id).await.unwrap_err();
assert!(matches!(error, Error::InvalidState(_)), "{error:?}");
// ...so it must not have discarded the result the parent is owed.
let batch = supervisor
.next_parent_notification_batch(&CancellationToken::new())
.await
.unwrap()
.expect("a rejected stop must leave the pending result deliverable");
assert_eq!(batch.len(), 1);
assert_eq!(batch[0].agent_id, agent_id);
supervisor.shutdown_all().await;
}
#[tokio::test]
async fn spawn_creates_agent_and_returns_id() {
let manager = SubAgentSupervisor::new(3);

View file

@ -17,27 +17,46 @@ use fabro_types::{
use crate::tool_registry::ToolContext;
use crate::types::AgentEvent;
/// Projections and their ID counters, behind one lock so a list and its
/// counter can never be observed out of step.
#[derive(Debug, Default)]
struct TodoRuntimeState {
lists: BTreeMap<String, TodoListProjection>,
task_counters: BTreeMap<String, u64>,
}
/// Shared, thread-safe todo projection. Wrap it in `Arc` and clone the
/// `Arc` into each tool closure that needs it.
#[derive(Debug, Default)]
pub struct TodoRuntime {
lists: Mutex<BTreeMap<String, TodoListProjection>>,
state: Mutex<TodoRuntimeState>,
}
impl TodoRuntime {
#[must_use]
pub fn new() -> Self {
Self {
lists: Mutex::new(BTreeMap::new()),
state: Mutex::new(TodoRuntimeState::default()),
}
}
/// Allocate the next monotonically increasing Claude task ID for a list.
///
/// Keeping the counter beside the projection lets root and child profiles
/// safely create tasks in the same shared list.
pub(crate) fn next_task_id(&self, list_id: &str) -> u64 {
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
let counter = guard.task_counters.entry(list_id.to_string()).or_default();
*counter = counter.saturating_add(1);
*counter
}
/// Snapshot the projection for `list_id`. Used by tests and by the
/// list-style tools that need a stable view.
#[must_use]
pub fn snapshot(&self, list_id: &str) -> Option<TodoListProjection> {
let guard = self.lists.lock().expect("todo runtime lock poisoned");
guard.get(list_id).cloned()
let guard = self.state.lock().expect("todo runtime lock poisoned");
guard.lists.get(list_id).cloned()
}
/// Insert (or replace) a todo and emit `todo.created`.
@ -63,8 +82,9 @@ impl TodoRuntime {
metadata: todo.metadata.clone(),
};
{
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
guard
.lists
.entry(list_id)
.or_insert_with(|| TodoListProjection::new(kind, props.list_id.clone()))
.upsert(todo);
@ -81,8 +101,8 @@ impl TodoRuntime {
}
let applied = {
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
let Some(list) = guard.get_mut(&props.list_id) else {
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
let Some(list) = guard.lists.get_mut(&props.list_id) else {
return false;
};
list.apply_patch(&props.todo_id, &TodoPatch::from_props(&props))
@ -103,8 +123,8 @@ impl TodoRuntime {
todo_id: String,
) -> bool {
let removed = {
let mut guard = self.lists.lock().expect("todo runtime lock poisoned");
let Some(list) = guard.get_mut(&list_id) else {
let mut guard = self.state.lock().expect("todo runtime lock poisoned");
let Some(list) = guard.lists.get_mut(&list_id) else {
return false;
};
list.remove(&todo_id)

View file

@ -10,8 +10,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps};
@ -395,28 +394,6 @@ pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
}
}
/// Per-list monotonically-increasing task counter for Anthropic
/// `TaskCreate`. Shared state lives inside the tool closure so two parallel
/// `TaskCreate` calls inside one session can never receive the same ID.
#[derive(Debug, Default)]
struct AnthropicTaskCounters {
counters: Mutex<BTreeMap<String, Arc<AtomicU64>>>,
}
impl AnthropicTaskCounters {
fn next(&self, list_id: &str) -> u64 {
let counter = {
let mut guard = self.counters.lock().expect("task counter lock poisoned");
Arc::clone(
guard
.entry(list_id.to_string())
.or_insert_with(|| Arc::new(AtomicU64::new(0))),
)
};
counter.fetch_add(1, Ordering::Relaxed) + 1
}
}
fn optional_string(args: &Value, key: &str) -> Option<String> {
args.get(key)
.and_then(Value::as_str)
@ -471,7 +448,6 @@ fn format_task_details(todo: &TodoProjection) -> String {
#[must_use]
pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
let counters = Arc::new(AnthropicTaskCounters::default());
RegisteredTool {
definition: ToolDefinition {
name: "TaskCreate".into(),
@ -489,7 +465,6 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
let counters = counters.clone();
Box::pin(async move {
let list_id = anthropic_task_scope(&ctx)?;
let subject = args
@ -502,7 +477,7 @@ pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: description".to_string())?
.to_string();
let task_id = counters.next(&list_id);
let task_id = runtime.next_task_id(&list_id);
let id_string = task_id.to_string();
let order = u32::try_from(task_id.saturating_sub(1)).unwrap_or(u32::MAX);

View file

@ -647,7 +647,7 @@ fn format_brave_results(body: &serde_json::Value) -> String {
output
}
fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool {
pub(crate) fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTool {
use std::sync::OnceLock;
static CLIENT: OnceLock<fabro_http::HttpClient> = OnceLock::new();

View file

@ -294,14 +294,15 @@ mod tests {
#[rustfmt::skip]
let expected: &[RouteRow] = &[
// model id deployment_id transport codec billing profile
("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("claude-haiku-4-5", "claude-haiku-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-6", "claude-opus-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-7", "claude-opus-4-7", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-8", "claude-opus-4-8", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-5", "claude-opus-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-5", "claude-opus-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
@ -360,7 +361,7 @@ mod tests {
let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet"))
.expect("alias should resolve");
let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-4-6"))
let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-5"))
.expect("id should resolve");
assert_eq!(by_alias, by_id);

View file

@ -1169,6 +1169,67 @@ output_cost_per_mtok = 20.0
completion.assert_async().await;
}
#[tokio::test]
async fn modal_routes_kimi_k3_with_proxy_headers_and_no_bearer_auth() {
let upstream = httpmock::MockServer::start_async().await;
let completion = upstream
.mock_async(|when, then| {
when.method(httpmock::Method::POST)
.path("/v1/chat/completions")
.header("Modal-Key", "wk-test")
.header("Modal-Secret", "ws-test")
.header_missing("Authorization")
.json_body_includes(r#"{"model":"moonshotai/Kimi-K3"}"#);
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"id": "chatcmpl-modal",
"model": "moonshotai/Kimi-K3",
"choices": [{
"message": {"role": "assistant", "content": "OK"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}));
})
.await;
let catalog = catalog_with(&format!(
r#"
[providers.modal]
enabled = true
base_url = "{}/v1"
"#,
upstream.base_url()
));
let modal = ProviderId::new("modal");
let client = Client::from_credentials(
vec![ApiCredential::with_extra_headers(
modal.clone(),
HashMap::from([
("Modal-Key".to_string(), "wk-test".to_string()),
("Modal-Secret".to_string(), "ws-test".to_string()),
]),
)],
catalog,
)
.await
.unwrap();
let mut request = test_request();
request.model = "kimi-k3".to_string();
request.provider = Some(modal.to_string());
let response = client.complete(&request).await.unwrap();
assert_eq!(response.text(), "OK");
assert_eq!(response.model, "kimi-k3");
assert_eq!(response.provider, "modal");
completion.assert_async().await;
}
#[tokio::test]
async fn complete_stamps_estimated_cost_from_catalog() {
let mut client = Client::new(HashMap::new(), None, vec![]);

View file

@ -3,6 +3,7 @@
reason = "Live provider integration tests read required API keys from process env."
)]
use std::collections::HashMap;
use std::sync::Arc;
use fabro_auth::ApiCredential;
@ -39,6 +40,48 @@ fn make_request(model: &str) -> Request {
}
}
/// Build the built-in catalog with `provider` enabled, plus an operator base
/// URL for providers such as Modal that do not ship one.
fn enabled_provider_catalog(provider: &ProviderId, base_url: Option<String>) -> Arc<Catalog> {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert(provider.to_string(), ProviderCatalogSettings {
enabled: Some(true),
base_url,
..ProviderCatalogSettings::default()
});
Arc::new(
Catalog::from_builtin_with_overrides(&settings)
.unwrap_or_else(|err| panic!("enabled {provider} catalog should build: {err}")),
)
}
/// Drive the shared deep tool round trip for one catalog offering.
async fn assert_deep_tool_round_trip(
catalog: &Arc<Catalog>,
provider: &ProviderId,
model_id: &str,
credential: ApiCredential,
) {
let client = Arc::new(
Client::from_credentials(vec![credential], Arc::clone(catalog))
.await
.unwrap_or_else(|err| panic!("{provider} client should build from the catalog: {err}")),
);
let model = catalog
.get_on_provider(provider, model_id)
.unwrap_or_else(|| panic!("{provider} {model_id} should be present"));
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
assert_eq!(
outcome.status,
ModelTestStatus::Ok,
"{provider} {model_id} deep test failed: {:?}",
outcome.error_message
);
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
async fn anthropic_complete() {
let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set");
@ -317,25 +360,11 @@ async fn bedrock_openai_frontier_complete() {
async fn poolside_laguna_xs_deep_tool_round_trip() {
let api_key = std::env::var(EnvVars::POOLSIDE_API_KEY).expect("POOLSIDE_API_KEY must be set");
let provider = ProviderId::new("poolside");
let catalog = Arc::new(Catalog::from_builtin().expect("built-in catalog should be valid"));
let credential = ApiCredential::from_api_key(provider, api_key, &catalog)
let catalog = enabled_provider_catalog(&provider, None);
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
.expect("Poolside credential should resolve from the catalog");
let client = Arc::new(
Client::from_credentials(vec![credential], Arc::clone(&catalog))
.await
.expect("Poolside client should build from the catalog"),
);
let model = catalog
.get_on_provider(&ProviderId::new("poolside"), "laguna-xs-2.1")
.expect("direct Poolside Laguna XS should be present");
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
assert_eq!(
outcome.status,
ModelTestStatus::Ok,
"direct Poolside Laguna XS deep test failed: {:?}",
outcome.error_message
);
assert_deep_tool_round_trip(&catalog, &provider, "laguna-xs-2.1", credential).await;
}
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
@ -574,35 +603,11 @@ async fn fireworks_complete() {
async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() {
let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set");
let provider = ProviderId::new("fireworks");
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert(provider.to_string(), ProviderCatalogSettings {
enabled: Some(true),
..ProviderCatalogSettings::default()
});
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&settings)
.expect("enabled Fireworks catalog should build"),
);
let credential = ApiCredential::from_api_key(provider, api_key, &catalog)
let catalog = enabled_provider_catalog(&provider, None);
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
.expect("Fireworks credential should resolve from the catalog");
let client = Arc::new(
Client::from_credentials(vec![credential], Arc::clone(&catalog))
.await
.expect("Fireworks client should build from the catalog"),
);
let model = catalog
.get_on_provider(&ProviderId::new("fireworks"), "kimi-k2.7-code")
.expect("Fireworks Kimi K2.7 Code should be present");
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
assert_eq!(
outcome.status,
ModelTestStatus::Ok,
"Fireworks Kimi K2.7 Code deep test failed: {:?}",
outcome.error_message
);
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k2.7-code", credential).await;
}
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
@ -610,35 +615,35 @@ async fn openrouter_kimi_k3_deep_tool_round_trip() {
let api_key =
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
let provider = ProviderId::new("openrouter");
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert(provider.to_string(), ProviderCatalogSettings {
enabled: Some(true),
..ProviderCatalogSettings::default()
});
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&settings)
.expect("enabled OpenRouter catalog should build"),
);
let credential = ApiCredential::from_api_key(provider, api_key, &catalog)
let catalog = enabled_provider_catalog(&provider, None);
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
.expect("OpenRouter credential should resolve from the catalog");
let client = Arc::new(
Client::from_credentials(vec![credential], Arc::clone(&catalog))
.await
.expect("OpenRouter client should build from the catalog"),
);
let model = catalog
.get_on_provider(&ProviderId::new("openrouter"), "kimi-k3")
.expect("OpenRouter Kimi K3 should be present");
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
assert_eq!(
outcome.status,
ModelTestStatus::Ok,
"OpenRouter Kimi K3 deep test failed: {:?}",
outcome.error_message
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await;
}
#[fabro_macros::e2e_test(
live("MODAL_KIMI_K3_BASE_URL"),
live("MODAL_TOKEN_ID"),
live("MODAL_TOKEN_SECRET")
)]
async fn modal_kimi_k3_deep_tool_round_trip() {
let base_url =
std::env::var("MODAL_KIMI_K3_BASE_URL").expect("MODAL_KIMI_K3_BASE_URL must be set");
let token_id = std::env::var(EnvVars::MODAL_TOKEN_ID).expect("MODAL_TOKEN_ID must be set");
let token_secret =
std::env::var(EnvVars::MODAL_TOKEN_SECRET).expect("MODAL_TOKEN_SECRET must be set");
let provider = ProviderId::new("modal");
let catalog = enabled_provider_catalog(&provider, Some(base_url));
let credential = ApiCredential::with_extra_headers(
provider.clone(),
HashMap::from([
("Modal-Key".to_string(), token_id),
("Modal-Secret".to_string(), token_secret),
]),
);
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await;
}
async fn run_multi_turn_cache_test(

View file

@ -627,6 +627,9 @@ impl RunProjectionReducer for RunProjection {
stage
.resumed_from_stage_id
.clone_from(&props.resumed_from_stage_id);
stage
.parallel_branch_id
.clone_from(&stored.parallel_branch_id);
}
stage.state = StageState::Running;
}
@ -1620,9 +1623,9 @@ mod tests {
AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage,
BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination,
EventBody, FailureCategory, FailureDetail, FailureReason, Graph, McpServerStatus, Node,
Outcome, PendingReason, PermissionLevel, PullRequestLink, QuestionType, ReasoningEffort,
RunApprovalState, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
Outcome, ParallelBranchId, PendingReason, PermissionLevel, PullRequestLink, QuestionType,
ReasoningEffort, RunApprovalState, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize,
RunSpec, RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageState,
StageTiming, SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
@ -2156,6 +2159,18 @@ mod tests {
event
}
fn test_branch_event(
seq: u32,
body: EventBody,
stage_id: StageId,
branch_id: ParallelBranchId,
) -> EventEnvelope {
let mut event = test_stage_event(seq, body, stage_id);
event.event.parallel_group_id = Some(branch_id.group().clone());
event.event.parallel_branch_id = Some(branch_id);
event
}
fn test_stage_event_at(
seq: u32,
ts: &str,
@ -2847,6 +2862,53 @@ mod tests {
assert_eq!(stage.prompt.as_deref(), Some("prompt"));
}
#[test]
fn parallel_branch_started_projects_identity_without_churning_it() {
let mut state = initialized_projection();
let group_id = StageId::new("review_fork", 1);
let branch_stage_id = StageId::new("review_glm", 1);
let branch_id = ParallelBranchId::new(group_id.clone(), 0);
let started = test_branch_event(
3,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 0,
graph_visit: Some(1),
resumed_from_stage_id: None,
}),
branch_stage_id.clone(),
branch_id.clone(),
);
state.apply_event(&started).unwrap();
assert_eq!(
state
.stage(&branch_stage_id)
.unwrap()
.parallel_branch_id
.as_ref(),
Some(&branch_id)
);
let reobserved = test_branch_event(
4,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 1,
graph_visit: Some(2),
resumed_from_stage_id: Some(StageId::new("review_glm", 2)),
}),
branch_stage_id.clone(),
ParallelBranchId::new(group_id, 1),
);
state.apply_event(&reobserved).unwrap();
let stage = state.stage(&branch_stage_id).unwrap();
assert_eq!(stage.parallel_branch_id.as_ref(), Some(&branch_id));
assert_eq!(stage.graph_visit, Some(1));
assert!(stage.resumed_from_stage_id.is_none());
}
#[test]
fn parallel_branch_completed_finalizes_branch_stage() {
// A parallel branch never runs through the engine's StageStarted/

View file

@ -8,7 +8,7 @@ use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, Tool
use fabro_agent::{
AgentEvent, AgentProfile, AgentProfileBuilder, CompletionCoordinator, Message as AgentMessage,
Sandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider,
ToolSecrets, canonical_tool_name, register_question_tools,
ToolSecrets, WebFetchSummarizer, canonical_tool_name, register_question_tools,
};
use fabro_auth::CredentialSource;
use fabro_graphviz::graph::{AttrValue, Node};
@ -19,10 +19,10 @@ use fabro_llm::types::{
};
use fabro_mcp::config::McpServerSettings;
#[cfg(test)]
use fabro_model::AgentProfileKind;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, FallbackTarget, ModelRef, ProviderId, UsdMicros};
use fabro_model::{
AgentProfileKind, Catalog, FallbackTarget, ModelHandle, ModelRef, ProviderId, UsdMicros,
};
use fabro_types::settings::run::RunModelControls;
use fabro_types::{PermissionLevel, RunId, SessionCapability, StageId, StageTiming};
use serde::de::DeserializeOwned;
@ -807,6 +807,17 @@ impl AgentApiBackend {
Arc::clone(&catalog),
)
.with_tool_secrets(tool_secrets);
let profile_builder = if provider.profile_kind == AgentProfileKind::Claude5 {
profile_builder.with_web_fetch_summarizer(Some(WebFetchSummarizer {
client: client.clone(),
model_id: ModelHandle::ByName {
provider: provider.provider_id.clone(),
model: model.to_string(),
},
}))
} else {
profile_builder
};
let mut profile = profile_builder.build();
let config = SessionOptions {
@ -2829,6 +2840,25 @@ reasoning = false
assert_eq!(provider.profile_kind, AgentProfileKind::Anthropic);
}
#[test]
fn api_backend_selects_claude5_profile_for_sonnet5() {
let backend = AgentApiBackend::new_with_catalog(
"claude-sonnet-5".to_string(),
ProviderId::anthropic(),
Vec::new(),
auth_test_support::vault_only_credential_source(),
SteeringHub::for_tests(),
Arc::new(Catalog::from_builtin().unwrap()),
);
let provider = backend
.resolve_provider_context("claude-sonnet-5", None)
.unwrap();
assert_eq!(provider.provider_id, ProviderId::anthropic());
assert_eq!(provider.profile_kind, AgentProfileKind::Claude5);
}
#[test]
fn api_backend_preserves_default_provider_for_legacy_model_identifier() {
let settings: LlmCatalogSettings = toml::from_str(

View file

@ -1058,7 +1058,7 @@ reasoning = false
assert_eq!(
validated.graph().nodes["work"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-6".into()))
Some(&AttrValue::String("claude-sonnet-5".into()))
);
}
@ -1460,7 +1460,7 @@ reasoning = false
.model
.name
.as_deref(),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
assert_eq!(
created

View file

@ -15,12 +15,12 @@ use fabro_sandbox::from_environment::{
};
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::ResolvedModelRef;
use fabro_types::settings::run::{
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
ResolvedMcpEntry, RunMode, RunModelSettings as ResolvedRunModelSettings,
RunNamespace as ResolvedRunSettings, RunPrepareSettings as ResolvedRunPrepareSettings,
};
use fabro_types::settings::{ModelRegistry, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};
use fabro_vault::Vault;
use tokio::runtime::Handle;
@ -635,12 +635,11 @@ fn resolve_fallback_chain(
if settings.fallbacks.is_empty() {
return Ok(Vec::new());
}
let registry = CatalogModelRegistry { catalog };
let primary = catalog.get_on_provider(provider, model);
let mut chain = Vec::new();
for model_ref in &settings.fallbacks {
match model_ref.resolve(&registry)? {
match model_ref.resolve(catalog)? {
ResolvedModelRef::Provider(provider_name) => {
let provider_id = canonical_provider_id(catalog, &provider_name);
if !eligible.contains(&provider_id) {
@ -660,14 +659,14 @@ fn resolve_fallback_chain(
}
ResolvedModelRef::Model {
provider: fallback_provider,
model,
selector,
} => {
if let Some(provider) = fallback_provider {
let provider = canonical_provider_id(catalog, &provider);
if !eligible.contains(&provider) {
return Err(ModelSelectionError::ProviderUnavailable { provider }.into());
}
match catalog.resolve_on_provider(&provider, &model) {
match catalog.resolve_on_provider(&provider, &selector) {
Ok(info) => chain.push(FallbackTarget {
provider: info.provider.to_string(),
model: info.id.to_string(),
@ -675,13 +674,13 @@ fn resolve_fallback_chain(
Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {
chain.push(FallbackTarget {
provider: provider.to_string(),
model,
model: selector,
});
}
Err(error) => return Err(error.into()),
}
} else {
match catalog.select(&model, None, eligible) {
match catalog.select(&selector, None, eligible) {
Ok(info) => chain.push(FallbackTarget {
provider: info.provider.to_string(),
model: info.id.to_string(),
@ -689,7 +688,7 @@ fn resolve_fallback_chain(
Err(ModelSelectionError::UnknownSelector { .. }) => {
chain.push(FallbackTarget {
provider: provider.to_string(),
model,
model: selector,
});
}
Err(error) => return Err(error.into()),
@ -708,20 +707,6 @@ fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
.map_or(provider_id, |provider| provider.id.clone())
}
struct CatalogModelRegistry<'a> {
catalog: &'a Catalog,
}
impl ModelRegistry for CatalogModelRegistry<'_> {
fn is_provider(&self, token: &str) -> bool {
self.catalog.provider(&ProviderId::from(token)).is_some()
}
fn is_model(&self, token: &str) -> bool {
self.catalog.is_model_selector(token)
}
}
/// Build the launch-time MCP config from resolved settings. Secret tokens in
/// the transport (`command`/`url`/`env`/`headers`) resolve from the vault at
/// the run boundary. Unsupported tokens fail.
@ -1325,7 +1310,7 @@ reasoning = false
fn resolve_fallback_chain_resolves_explicit_model_fallbacks() {
let catalog = test_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["openai/gpt-5.4-mini".parse::<ModelRef>().unwrap()],
fallbacks: vec!["openai:gpt-5.4-mini".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
@ -1371,7 +1356,7 @@ reasoning = false
fn resolve_fallback_chain_resolves_provider_qualified_shared_alias() {
let catalog = portable_model_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["openrouter/gpt-56-sol".parse::<ModelRef>().unwrap()],
fallbacks: vec!["openrouter:gpt-56-sol".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
@ -1390,6 +1375,85 @@ reasoning = false
}]);
}
/// A qualified fallback resolves to the same offering whether the selector
/// is the canonical model ID or the provider's API ID. The trailing bare
/// alias still goes through ready-provider priority selection.
#[test]
fn resolve_fallback_chain_resolves_qualified_model_id_and_api_id_alike() {
let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str(
r"
[providers.openrouter]
enabled = true
",
)
.unwrap();
let catalog = Catalog::from_builtin_with_overrides(&overrides).unwrap();
for selector in ["openrouter:kimi-k3", "openrouter:moonshotai/kimi-k3"] {
let settings = ResolvedRunModelSettings {
fallbacks: vec![
selector.parse::<ModelRef>().unwrap(),
"gpt-terra".parse::<ModelRef>().unwrap(),
],
..ResolvedRunModelSettings::default()
};
let chain = resolve_fallback_chain(
&catalog,
&ProviderId::new("kimi"),
"kimi-k3",
&settings,
&HashSet::from([
ProviderId::new("kimi"),
ProviderId::new("openrouter"),
ProviderId::openai(),
]),
)
.unwrap();
assert_eq!(
chain,
vec![
FallbackTarget {
provider: "openrouter".to_string(),
model: "kimi-k3".to_string(),
},
FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.6-terra".to_string(),
},
],
"{selector}"
);
}
}
/// A colon in a model ID does not make it provider-qualified, so an
/// unknown colon-bearing selector still passes through to a provider
/// instead of failing the run with an unknown-provider error.
#[test]
fn resolve_fallback_chain_passes_through_colon_bearing_model_ids() {
let catalog = portable_model_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["future-model:latest".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
let chain = resolve_fallback_chain(
&catalog,
&ProviderId::openai(),
"gpt-5.6-sol",
&settings,
&HashSet::from([ProviderId::openai(), ProviderId::new("openrouter")]),
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
provider: ProviderId::openai().to_string(),
model: "future-model:latest".to_string(),
}]);
}
#[test]
fn resolve_fallback_chain_keeps_qualified_legacy_references_as_provider_pins() {
let catalog = test_catalog();

View file

@ -159,7 +159,7 @@ mod tests {
let transformed = transform(parsed, &transform_options()).unwrap();
assert_eq!(
transformed.graph.nodes["work"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-6".into()))
Some(&AttrValue::String("claude-sonnet-5".into()))
);
}
@ -241,7 +241,7 @@ mod tests {
);
assert_eq!(
lint.attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-6".into()))
Some(&AttrValue::String("claude-sonnet-5".into()))
);
}

View file

@ -40,7 +40,7 @@ fn materialize_run_applies_graph_and_catalog_defaults() {
.unwrap();
let resolved = &materialized.run;
assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-4-6"));
assert_eq!(resolved.model.name.as_deref(), Some("claude-sonnet-5"));
assert_eq!(resolved.model.provider.as_deref(), Some("anthropic"));
assert_eq!(
materialized.run.goal.as_ref(),

View file

@ -345,6 +345,7 @@ fn main() {
("Conclusion", "fabro_types::Conclusion", &[]),
("StageOutcome", "fabro_types::StageOutcome", &[]),
("StageId", "fabro_types::StageId", &[]),
("ParallelBranchId", "fabro_types::ParallelBranchId", &[]),
("StageHandler", "fabro_types::StageHandler", &[]),
("StageState", "fabro_types::StageState", &[]),
("AgentControlState", "fabro_types::AgentControlState", &[]),

View file

@ -52,15 +52,16 @@ pub mod types {
McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,
McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,
PairTranscriptResponse, ParallelBranchResult, PendingInterviewRecord, PermissionLevel,
PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
QuestionType, ReasoningOutput, RepositoryRef, ReviewTarget, ReviewTargetKind, Role, Run,
RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind,
RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,
RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,
RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails,
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
PermissionLevel, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails,
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef,
ReviewTarget, ReviewTargetKind, Role, Run, RunApproval, RunApprovalState,
RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse,
RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource,
RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan,
RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo,
SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,

View file

@ -0,0 +1,34 @@
use std::any::{TypeId, type_name};
use fabro_api::types::ParallelBranchId as ApiParallelBranchId;
use fabro_types::{ParallelBranchId, StageId};
use serde_json::json;
#[test]
fn parallel_branch_id_reuses_canonical_type() {
assert_same_type::<ApiParallelBranchId, ParallelBranchId>();
}
#[test]
fn parallel_branch_id_round_trips_openapi_representation() {
let branch_id = ParallelBranchId::new(StageId::new("review_fork", 3), 1);
assert_eq!(
serde_json::to_value(&branch_id).unwrap(),
json!("review_fork@3:1")
);
assert_eq!(
serde_json::from_value::<ApiParallelBranchId>(json!("review_fork@3:1")).unwrap(),
branch_id
);
}
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

@ -27,11 +27,11 @@ use fabro_types::{
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus,
ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow,
ParallelBranchId, ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow,
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageContextWindowWarning, StageInferenceProjection, StageProjection, StageToolBatchProjection,
SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,
StageContextWindowWarning, StageId, StageInferenceProjection, StageProjection,
StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,
};
use serde_json::json;
@ -216,6 +216,7 @@ fn stage_projection_round_trips_representative_json() {
"context_updates": {}
}
],
"parallel_branch_id": "review_fork@3:1",
"output": "ok",
"termination": "exited",
"started_at": "2026-04-29T12:34:00Z",
@ -345,6 +346,10 @@ fn stage_projection_round_trips_representative_json() {
});
let state: StageProjection = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
state.parallel_branch_id,
Some(ParallelBranchId::new(StageId::new("review_fork", 3), 1))
);
assert_eq!(serde_json::to_value(state).unwrap(), value);
}

View file

@ -171,4 +171,41 @@ x-account = "{{ env.ACME_ACCOUNT }}"
)
}));
}
#[tokio::test]
async fn modal_env_vars_do_not_replace_vault_secrets() {
let settings: LlmCatalogSettings = toml::from_str(
r#"
[providers.modal]
enabled = true
base_url = "https://example--kimi-k3.modal.run/v1"
"#,
)
.unwrap();
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let source = test_source(&[
("MODAL_TOKEN_ID", "wk-test"),
("MODAL_TOKEN_SECRET", "ws-test"),
]);
let modal = ProviderId::new("modal");
assert!(!source.configured_providers(&catalog).await.contains(&modal));
let resolved = source.resolve(&catalog).await.unwrap();
assert!(
resolved
.credentials
.iter()
.all(|credential| credential.provider != modal)
);
assert!(resolved.auth_issues.iter().any(|(provider, issue)| {
provider == &modal
&& matches!(
issue,
crate::ResolveError::Interpolation { source, .. }
if source.namespace == Namespace::Secrets
)
}));
}
}

View file

@ -68,6 +68,24 @@ impl ApiCredential {
project_id: None,
})
}
/// Build an `ApiCredential` for a provider that authenticates with request
/// headers instead of an API key, such as Modal's proxy-token pair.
#[must_use]
pub fn with_extra_headers(
provider: impl Into<ProviderId>,
extra_headers: HashMap<String, String>,
) -> Self {
Self {
provider: provider.into(),
auth_header: None,
extra_headers,
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}
}
}
const OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
@ -579,6 +597,16 @@ reasoning_effort = "levels"
))
}
fn modal_catalog() -> Catalog {
catalog_with(
r#"
[providers.modal]
enabled = true
base_url = "https://example--kimi-k3.modal.run/v1"
"#,
)
}
#[tokio::test]
async fn resolve_openai_api_request_prefers_env_when_listed_first() {
let dir = tempfile::tempdir().unwrap();
@ -905,6 +933,77 @@ reasoning = false
);
}
#[tokio::test]
async fn modal_resolves_both_vault_proxy_headers_without_authorization() {
let catalog = modal_catalog();
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let modal = ProviderId::new("modal");
{
let vault = resolver.vault.read().await;
assert!(
resolver
.configured_providers(&vault, &catalog)
.contains(&modal)
);
}
let resolved = resolver
.resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog)
.await
.unwrap();
let ResolvedCredential::Api(api) = resolved;
assert!(api.auth_header.is_none());
assert_eq!(
api.extra_headers,
HashMap::from([
("Modal-Key".to_string(), "wk-test".to_string()),
("Modal-Secret".to_string(), "ws-test".to_string()),
])
);
assert_eq!(
api.base_url.as_deref(),
Some("https://example--kimi-k3.modal.run/v1")
);
}
#[tokio::test]
async fn modal_is_not_configured_with_only_one_vault_proxy_token() {
let catalog = modal_catalog();
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-present").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let modal = ProviderId::new("modal");
{
let vault = resolver.vault.read().await;
assert!(
!resolver
.configured_providers(&vault, &catalog)
.contains(&modal)
);
}
let err = resolver
.resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog)
.await
.unwrap_err();
assert!(matches!(
err,
ResolveError::Interpolation { ref provider, .. } if provider == &modal
));
let message = err.to_string();
assert!(message.contains("MODAL_TOKEN_SECRET"));
assert!(!message.contains("wk-present"));
}
#[tokio::test]
async fn resolve_multi_segment_header_token() {
let catalog = portkey_catalog(r#"authorization = "Bearer {{ secrets.TOKEN }}""#);

View file

@ -148,8 +148,11 @@ pub struct RunModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub name: Option<String>,
/// Ordered list of fallback model references. Supports `...` splice marker
/// at layering time — see [`super::splice_array`].
/// Ordered fallback references: bare providers, bare model IDs or aliases,
/// or provider-qualified `provider:selector` values. A qualified selector
/// may be a model ID, alias, or provider API ID. Legacy `provider/model`
/// values remain accepted. Supports the `...` splice marker at layering
/// time — see [`super::splice_array`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[option(default = "[]", value_type = "array<string>")]
pub fallbacks: Vec<ModelRefOrSplice>,

View file

@ -101,7 +101,7 @@ fn run_model_fallbacks_splice_inserts_inherited() {
let lower = parse(
r#"
[run.model]
fallbacks = ["openai", "gpt-5.4"]
fallbacks = ["openrouter:moonshotai/kimi-k3", "gpt-terra"]
"#,
);
let higher = parse(
@ -112,7 +112,10 @@ fallbacks = ["anthropic", "..."]
);
let merged = higher.combine(lower);
let fallbacks = merged.run.unwrap().model.unwrap().fallbacks;
assert_eq!(fallbacks.len(), 3);
assert_eq!(
serde_json::to_value(&fallbacks).unwrap(),
serde_json::json!(["anthropic", "openrouter:moonshotai/kimi-k3", "gpt-terra",])
);
}
#[test]

View file

@ -109,7 +109,7 @@ permissions = "read-write""#,
r#"[run.model]
provider = "anthropic"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gpt-5.4"]"#,
fallbacks = ["openrouter:kimi-k3", "gpt-terra"]"#,
),
Section::of::<fabro_config::CliLoggingLayer>(
"[cli.logging]",

View file

@ -67,6 +67,12 @@ impl AsRef<str> for AdapterKind {
#[strum(serialize_all = "snake_case")]
pub enum AgentProfileKind {
Anthropic,
/// Claude 5 models trained against Anthropic's current coding-agent
/// harness. This remains model-scoped so older Claude models keep the
/// established Anthropic profile.
#[serde(rename = "claude-5")]
#[strum(to_string = "claude-5")]
Claude5,
#[serde(rename = "openai")]
#[strum(to_string = "openai")]
OpenAi,

View file

@ -747,6 +747,12 @@ impl Catalog {
&model.provider,
)?;
}
register_model_identifier(
identifiers,
resolved_settings.api_id.clone(),
model.id.clone(),
&model.provider,
)?;
if model.default {
defaults_by_provider
@ -793,14 +799,14 @@ impl Catalog {
.then_with(|| left.id.cmp(&right.id))
});
warn_multiple_probe_models(&models_with_settings);
let (offering_index, provider_selector_index, canonical_candidates, alias_candidates) =
build_model_indexes(&models_with_settings);
let mut model_settings_by_offering = HashMap::new();
let mut models = Vec::new();
for (model, settings) in models_with_settings {
model_settings_by_offering.insert((model.provider.clone(), model.id.clone()), settings);
models.push(model);
}
let (offering_index, provider_selector_index, canonical_candidates, alias_candidates) =
build_model_indexes(&models);
Ok(Self {
models,
@ -879,16 +885,20 @@ impl Catalog {
.and_then(|idx| self.models.get(*idx))
}
/// Look up a selector on exactly one provider, without considering
/// provider availability. Historical built-in API identifiers normalize
/// to their canonical model slug before lookup.
/// Look up a canonical ID, alias, or API ID on exactly one provider,
/// without considering provider availability. Exact provider-scoped
/// identifiers win before historical built-in API identifiers normalize
/// to their canonical model slug.
#[must_use]
pub fn get_on_provider(&self, provider: &ProviderId, selector: &str) -> Option<&Model> {
let provider = self.provider(provider)?;
let selector = normalize_legacy_builtin_selector(selector);
self.provider_selector_index
.get(&(provider.id.clone(), selector.into_owned()))
.and_then(|idx| self.models.get(*idx))
let lookup = |selector: &str| {
self.provider_selector_index
.get(&(provider.id.clone(), selector.to_string()))
};
let index =
lookup(selector).or_else(|| lookup(&normalize_legacy_builtin_selector(selector)))?;
self.models.get(*index)
}
/// Look up a canonical offering by its composite identity.
@ -900,7 +910,7 @@ impl Catalog {
.and_then(|idx| self.models.get(*idx))
}
/// Resolve a selector on exactly one provider.
/// Resolve a canonical ID, alias, or API ID on exactly one provider.
pub fn resolve_on_provider(
&self,
provider: &ProviderId,
@ -926,8 +936,9 @@ impl Catalog {
/// Historical built-in API identifiers normalize to their canonical model
/// slug before selection.
///
/// An explicit provider is a pin. Unqualified selection checks canonical
/// IDs before aliases and uses the catalog's provider priority ordering.
/// An explicit provider is a pin and also permits that provider's API IDs.
/// Unqualified selection checks canonical IDs before aliases and uses the
/// catalog's provider priority ordering.
pub fn select<'a>(
&'a self,
selector: &str,
@ -1487,12 +1498,12 @@ type ModelIndexes = (
HashMap<String, Vec<usize>>,
);
fn build_model_indexes(models: &[Model]) -> ModelIndexes {
fn build_model_indexes(models: &[(Model, CatalogModelSettings)]) -> ModelIndexes {
let mut offering_index = HashMap::new();
let mut provider_selector_index = HashMap::new();
let mut canonical_candidates = HashMap::<ModelId, Vec<usize>>::new();
let mut alias_candidates = HashMap::<String, Vec<usize>>::new();
for (idx, model) in models.iter().enumerate() {
for (idx, (model, settings)) in models.iter().enumerate() {
offering_index.insert((model.provider.clone(), model.id.clone()), idx);
provider_selector_index
.insert((model.provider.clone(), model.id.as_str().to_string()), idx);
@ -1504,6 +1515,7 @@ fn build_model_indexes(models: &[Model]) -> ModelIndexes {
provider_selector_index.insert((model.provider.clone(), alias.clone()), idx);
alias_candidates.entry(alias.clone()).or_default().push(idx);
}
provider_selector_index.insert((model.provider.clone(), settings.api_id.clone()), idx);
}
(
offering_index,
@ -2861,7 +2873,7 @@ enabled = true
catalog
.default_for_provider(&bedrock)
.map(|model| model.id.as_str()),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
// Fable 5 ships with sampling params pinned off (the Converse
// encoder drops temperature/top_p for it).
@ -2869,12 +2881,11 @@ enabled = true
.get_on_provider(&bedrock, "claude-fable-5")
.expect("fable row should be present");
assert!(!fable.features.sampling_params);
assert!(
catalog
.settings_for(fable)
.expect("fable settings should be present")
.reasoning_by_default
);
let fable_settings = catalog
.settings_for(fable)
.expect("fable settings should be present");
assert!(fable_settings.reasoning_by_default);
assert_eq!(fable_settings.agent_profile, AgentProfileKind::Claude5);
assert_eq!(
catalog
.model_settings_on_provider(&bedrock, "claude-fable-5")
@ -2882,6 +2893,16 @@ enabled = true
.billing_policy,
BillingPolicy::Anthropic
);
let sonnet = catalog
.get_on_provider(&bedrock, "claude-sonnet-5")
.expect("Sonnet 5 row should be present");
assert_eq!(sonnet.limits.context_window, 1_000_000);
assert_eq!(sonnet.limits.max_output, Some(128_000));
assert!(!sonnet.features.sampling_params);
assert_eq!(
catalog.settings_for(sonnet).unwrap().agent_profile,
AgentProfileKind::Claude5
);
}
#[test]
@ -3028,7 +3049,7 @@ enabled = true
// open-weights rows inherit it.
assert_eq!(
catalog
.model_settings_on_provider(&openrouter, "claude-sonnet-4-6")
.model_settings_on_provider(&openrouter, "claude-sonnet-5")
.unwrap()
.billing_policy,
BillingPolicy::Anthropic
@ -3044,7 +3065,7 @@ enabled = true
catalog
.default_for_provider(&openrouter)
.map(|model| model.id.as_str()),
Some("claude-sonnet-4-6")
Some("claude-sonnet-5")
);
}
@ -3137,6 +3158,19 @@ enabled = true
true,
BillingPolicy::Anthropic,
),
(
"claude-sonnet-5",
"anthropic/claude-sonnet-5",
"claude-5",
1_000_000,
2.0,
10.0,
0.2,
ReasoningEffortFeature::Levels,
false,
true,
BillingPolicy::Anthropic,
),
];
for (
@ -3188,13 +3222,21 @@ enabled = true
ReasoningEffort::VARIANTS,
"{id}"
);
if family == "claude-5" {
assert_eq!(settings.agent_profile, AgentProfileKind::Claude5, "{id}");
}
}
for alias in ["opus", "claude-opus"] {
for (alias, expected) in [
("opus", "claude-opus-5"),
("claude-opus", "claude-opus-5"),
("sonnet", "claude-sonnet-5"),
("claude-sonnet", "claude-sonnet-5"),
] {
let model = catalog
.resolve_on_provider(&ProviderId::new("openrouter"), alias)
.unwrap_or_else(|error| panic!("{alias} should resolve on OpenRouter: {error}"));
assert_eq!(model.id, "claude-opus-5", "{alias}");
assert_eq!(model.id, expected, "{alias}");
}
}
@ -3505,6 +3547,174 @@ enabled = true
]);
}
#[test]
fn builtin_modal_provider_is_opt_in() {
let modal = ProviderId::new("modal");
let builtin = Catalog::builtin();
assert!(builtin.provider(&modal).is_none());
assert!(builtin.list(Some(&modal)).is_empty());
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.modal]
enabled = true
",
))
.expect("enabled Modal override should build from the built-in provider settings");
let provider = catalog
.provider(&modal)
.expect("enabled Modal provider should be present");
assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible);
assert_eq!(provider.codec, CodecKind::OpenAiCompatible);
assert_eq!(provider.agent_profile, AgentProfileKind::Kimi);
assert_eq!(provider.billing_policy, BillingPolicy::OpenAi);
assert_eq!(provider.priority, 30);
assert!(provider.auth.is_none());
assert_eq!(
provider.extra_headers,
HashMap::from([
(
"Modal-Key".to_string(),
"{{ secrets.MODAL_TOKEN_ID }}".to_string(),
),
(
"Modal-Secret".to_string(),
"{{ secrets.MODAL_TOKEN_SECRET }}".to_string(),
),
])
);
// Modal assigns the endpoint URL per deployment, so the built-in entry
// ships without one and the operator supplies it through settings.
assert!(provider.base_url.is_none());
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r#"
[providers.modal]
enabled = true
base_url = "https://example--kimi-k3.modal.run/v1"
"#,
))
.expect("Modal base URL override should build");
assert_eq!(
catalog
.provider(&modal)
.and_then(|provider| provider.base_url.as_deref()),
Some("https://example--kimi-k3.modal.run/v1")
);
}
#[test]
fn builtin_modal_includes_kimi_k3_when_enabled() {
let modal = ProviderId::new("modal");
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.modal]
enabled = true
",
))
.expect("enabled Modal override should build from the built-in provider settings");
assert_eq!(catalog.list(Some(&modal)).len(), 1);
let model = catalog
.get_on_provider(&modal, "kimi-k3")
.expect("Modal Kimi K3 should be present");
insta::assert_debug_snapshot!(model, @r#"
Model {
id: "kimi-k3",
provider: modal,
family: "kimi-k3",
display_name: "Kimi K3 (via Modal)",
limits: ModelLimits {
context_window: 1048576,
max_output: Some(
131072,
),
},
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: true,
reasoning: true,
reasoning_effort: AlwaysAdaptive,
prompt_cache: true,
cache_control_breakpoints: false,
sampling_params: false,
},
controls: ModelControls {
reasoning_effort: [
Low,
High,
Max,
],
},
costs: ModelCosts {
input_cost_per_mtok: Some(
3.0,
),
output_cost_per_mtok: Some(
15.0,
),
cache_input_cost_per_mtok: Some(
0.3,
),
},
estimated_output_tps: Some(
460.0,
),
aliases: [],
default: true,
small_default: false,
configured: false,
}
"#);
let settings = catalog
.model_settings_on_provider(&modal, "kimi-k3")
.expect("Modal Kimi K3 settings should be present");
assert_eq!(settings.api_id, "moonshotai/Kimi-K3");
assert_eq!(settings.agent_profile, AgentProfileKind::Kimi);
assert_eq!(settings.billing_policy, BillingPolicy::OpenAi);
assert_eq!(settings.controls.reasoning_effort, vec![
ReasoningEffort::Low,
ReasoningEffort::High,
ReasoningEffort::Max,
]);
}
#[test]
fn builtin_kimi_k3_selection_prefers_direct_kimi_then_modal_over_openrouter() {
let kimi = ProviderId::new("kimi");
let modal = ProviderId::new("modal");
let openrouter = ProviderId::new("openrouter");
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.modal]
enabled = true
[providers.openrouter]
enabled = true
",
))
.expect("enabled Modal and OpenRouter overrides should build");
let selected = catalog
.select(
"kimi-k3",
None,
&HashSet::from([kimi.clone(), modal.clone(), openrouter.clone()]),
)
.expect("direct Kimi should win portable Kimi K3 selection");
assert_eq!(selected.provider, kimi);
let selected = catalog
.select("kimi-k3", None, &HashSet::from([modal.clone(), openrouter]))
.expect("Modal should win gateway-only Kimi K3 selection");
assert_eq!(selected.provider, modal);
}
#[test]
fn builtin_openrouter_includes_poolside_laguna_when_enabled() {
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
@ -3883,7 +4093,7 @@ enabled = true
let m = Catalog::builtin()
.default_for_provider(&ProviderId::anthropic())
.unwrap();
assert_eq!(m.id, "claude-sonnet-4-6");
assert_eq!(m.id, "claude-sonnet-5");
assert!(m.default);
let m = Catalog::builtin()
@ -4256,9 +4466,15 @@ codec = "anthropic_messages"
}
#[test]
fn catalog_from_settings_rejects_duplicate_model_aliases() {
let layer = minimal_settings(
r#"
/// Canonical IDs, aliases, and API IDs share one identifier namespace per
/// provider, so a collision in any of them is rejected the same way.
fn catalog_from_settings_rejects_duplicate_provider_model_selectors() {
for (declaration, expected) in [
(r#"aliases = ["shared"]"#, "shared"),
(r#"api_id = "vendor/shared""#, "vendor/shared"),
] {
let layer = minimal_settings(&format!(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
@ -4268,7 +4484,7 @@ enabled = true
[providers.test.models.one]
display_name = "One"
family = "test"
aliases = ["shared"]
{declaration}
[providers.test.models.one.limits]
context_window = 1000
@ -4281,7 +4497,7 @@ reasoning = false
[providers.test.models.two]
display_name = "Two"
family = "test"
aliases = ["shared"]
{declaration}
[providers.test.models.two.limits]
context_window = 1000
@ -4290,22 +4506,75 @@ context_window = 1000
tools = false
vision = false
reasoning = false
"#,
);
"#
));
let err = Catalog::from_settings(&layer).unwrap_err();
let err = Catalog::from_settings(&layer).unwrap_err();
assert!(
matches!(
&err,
CatalogBuildError::DuplicateProviderModelSelector {
provider,
selector,
first,
second,
} if provider == &ProviderId::new("test")
&& selector == expected
&& first == "one"
&& second == "two"
),
"{declaration}: {err:?}"
);
}
}
#[test]
fn provider_scoped_lookup_accepts_canonical_alias_and_api_id_selectors() {
let catalog = Catalog::from_settings(&minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
aliases = ["test-alias"]
[providers.test.models.one]
api_id = "vendor/models/one:latest"
display_name = "One"
family = "test"
aliases = ["one-alias"]
default = true
[providers.test.models.one.limits]
context_window = 1000
[providers.test.models.one.features]
tools = false
vision = false
reasoning = false
"#,
))
.expect("provider-scoped selector fixture should build");
for selector in ["one", "one-alias", "vendor/models/one:latest"] {
let model = catalog
.resolve_on_provider(&ProviderId::new("test-alias"), selector)
.unwrap_or_else(|error| {
panic!("selector '{selector}' should resolve on provider alias: {error}")
});
assert_eq!(model.provider, ProviderId::new("test"), "{selector}");
assert_eq!(model.id, "one", "{selector}");
}
assert!(matches!(
err,
CatalogBuildError::DuplicateProviderModelSelector {
provider,
selector,
first,
second,
} if provider == ProviderId::new("test")
&& selector == "shared"
&& first == "one"
&& second == "two"
catalog.select(
"vendor/models/one:latest",
None,
&HashSet::from([ProviderId::new("test")]),
),
Err(ModelSelectionError::UnknownSelector { selector })
if selector == "vendor/models/one:latest"
));
}

View file

@ -13,6 +13,7 @@ header = { custom = "x-api-key" }
display_name = "Claude Fable 5"
family = "claude-5"
aliases = ["fable", "claude-fable"]
agent_profile = "claude-5"
[providers.anthropic.models."claude-fable-5".limits]
context_window = 1000000
@ -37,6 +38,7 @@ family = "claude-5"
training = "2026-05-01"
knowledge_cutoff = "May 2026"
aliases = ["opus", "claude-opus"]
agent_profile = "claude-5"
[providers.anthropic.models."claude-opus-5".limits]
context_window = 1000000
@ -63,6 +65,33 @@ input_cost_per_mtok = 10.0
output_cost_per_mtok = 50.0
cache_input_cost_per_mtok = 1.0
[providers.anthropic.models."claude-sonnet-5"]
display_name = "Claude Sonnet 5"
family = "claude-5"
training = "2026-01-01"
knowledge_cutoff = "Jan 2026"
default = true
aliases = ["sonnet", "claude-sonnet"]
agent_profile = "claude-5"
[providers.anthropic.models."claude-sonnet-5".limits]
context_window = 1000000
max_output = 128000
[providers.anthropic.models."claude-sonnet-5".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
sampling_params = false
# Introductory pricing through August 31, 2026.
[providers.anthropic.models."claude-sonnet-5".costs]
input_cost_per_mtok = 2.0
output_cost_per_mtok = 10.0
cache_input_cost_per_mtok = 0.2
[providers.anthropic.models."claude-opus-4-8"]
display_name = "Claude Opus 4.8"
family = "claude-4"
@ -188,9 +217,7 @@ display_name = "Claude Sonnet 4.6"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
default = true
estimated_output_tps = 50
aliases = ["sonnet", "claude-sonnet"]
[providers.anthropic.models."claude-sonnet-4-6".limits]
context_window = 200000

View file

@ -41,16 +41,15 @@ credentials = [
# ---------- Anthropic Claude ----------
#
# Claude bills Anthropic-style cache reads/writes, so these rows override
# the provider's billing default. Claude Fable 5 appears at the end of this
# file because its Bedrock deployment pins sampling parameters and requires an
# extra data-sharing opt-in.
# the provider's billing default. Claude 5 models appear at the end of this
# file because their Bedrock deployments pin sampling parameters and require
# extra endpoint-specific handling.
[providers.bedrock.models."claude-sonnet-4-6"]
api_id = "us.anthropic.claude-sonnet-4-6"
display_name = "Claude Sonnet 4.6 (Bedrock)"
family = "claude-4"
billing_policy = "anthropic"
default = true
[providers.bedrock.models."claude-sonnet-4-6".limits]
context_window = 1000000
@ -360,6 +359,7 @@ api_id = "us.anthropic.claude-fable-5"
display_name = "Claude Fable 5 (Bedrock)"
family = "claude-5"
billing_policy = "anthropic"
agent_profile = "claude-5"
[providers.bedrock.models."claude-fable-5".limits]
context_window = 1000000
@ -377,3 +377,33 @@ sampling_params = false
input_cost_per_mtok = 10.0
output_cost_per_mtok = 50.0
cache_input_cost_per_mtok = 1.0
# Claude Sonnet 5 uses adaptive thinking by default and rejects non-default
# sampling parameters. Effort-level mapping through
# additionalModelRequestFields is a named follow-up, as for Fable 5.
[providers.bedrock.models."claude-sonnet-5"]
api_id = "us.anthropic.claude-sonnet-5"
display_name = "Claude Sonnet 5 (Bedrock)"
family = "claude-5"
billing_policy = "anthropic"
default = true
agent_profile = "claude-5"
[providers.bedrock.models."claude-sonnet-5".limits]
context_window = 1000000
max_output = 128000
[providers.bedrock.models."claude-sonnet-5".features]
tools = true
vision = true
reasoning = true
reasoning_by_default = true
prompt_cache = true
sampling_params = false
# Introductory pricing through August 31, 2026.
[providers.bedrock.models."claude-sonnet-5".costs]
input_cost_per_mtok = 2.0
output_cost_per_mtok = 10.0
cache_input_cost_per_mtok = 0.2

View file

@ -0,0 +1,53 @@
[providers.modal]
display_name = "Modal"
adapter = "openai_compatible"
agent_profile = "kimi"
api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens"
priority = 30
enabled = false
[providers.modal.extra_headers]
"Modal-Key" = "{{ secrets.MODAL_TOKEN_ID }}"
"Modal-Secret" = "{{ secrets.MODAL_TOKEN_SECRET }}"
# Modal assigns an endpoint URL when the Shared API or an Auto Endpoint is
# created. To enable Modal, add the endpoint URL to ~/.fabro/settings.toml:
#
# [llm.providers.modal]
# enabled = true
# base_url = "https://<your-modal-endpoint>.modal.run/v1"
#
# Then store both proxy-token values in the Fabro server vault:
#
# fabro secret set MODAL_TOKEN_ID wk-...
# fabro secret set MODAL_TOKEN_SECRET ws-...
# Modal serves the Hugging Face repository id, so `api_id` keeps that
# capitalization. OpenRouter routes the same model under its own lowercase
# slug (`moonshotai/kimi-k3`).
[providers.modal.models."kimi-k3"]
api_id = "moonshotai/Kimi-K3"
display_name = "Kimi K3 (via Modal)"
family = "kimi-k3"
default = true
estimated_output_tps = 460
[providers.modal.models."kimi-k3".limits]
context_window = 1048576
max_output = 131072
[providers.modal.models."kimi-k3".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "always_adaptive"
prompt_cache = true
sampling_params = false
[providers.modal.models."kimi-k3".controls]
reasoning_effort = ["low", "high", "max"]
[providers.modal.models."kimi-k3".costs]
input_cost_per_mtok = 3.0
output_cost_per_mtok = 15.0
cache_input_cost_per_mtok = 0.3

View file

@ -39,6 +39,7 @@ display_name = "Claude Fable 5 (via OpenRouter)"
family = "claude-5"
billing_policy = "anthropic"
aliases = ["fable", "claude-fable"]
agent_profile = "claude-5"
[providers.openrouter.models."claude-fable-5".limits]
context_window = 1000000
@ -66,6 +67,7 @@ billing_policy = "anthropic"
training = "2026-05-01"
knowledge_cutoff = "May 2026"
aliases = ["opus", "claude-opus"]
agent_profile = "claude-5"
[providers.openrouter.models."claude-opus-5".limits]
context_window = 1000000
@ -85,6 +87,37 @@ input_cost_per_mtok = 5.0
output_cost_per_mtok = 25.0
cache_input_cost_per_mtok = 0.5
[providers.openrouter.models."claude-sonnet-5"]
api_id = "anthropic/claude-sonnet-5"
display_name = "Claude Sonnet 5 (via OpenRouter)"
family = "claude-5"
billing_policy = "anthropic"
training = "2026-01-01"
knowledge_cutoff = "Jan 2026"
default = true
aliases = ["sonnet", "claude-sonnet"]
agent_profile = "claude-5"
[providers.openrouter.models."claude-sonnet-5".limits]
context_window = 1000000
max_output = 128000
[providers.openrouter.models."claude-sonnet-5".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
cache_control_breakpoints = true
sampling_params = false
# Current introductory rate. OpenRouter's authoritative in-band usage.cost
# supersedes this estimate on completed responses.
[providers.openrouter.models."claude-sonnet-5".costs]
input_cost_per_mtok = 2.0
output_cost_per_mtok = 10.0
cache_input_cost_per_mtok = 0.2
[providers.openrouter.models."claude-opus-4-8"]
api_id = "anthropic/claude-opus-4.8"
display_name = "Claude Opus 4.8 (via OpenRouter)"
@ -138,8 +171,6 @@ api_id = "anthropic/claude-sonnet-4.6"
display_name = "Claude Sonnet 4.6 (via OpenRouter)"
family = "claude-4"
billing_policy = "anthropic"
default = true
aliases = ["sonnet", "claude-sonnet"]
[providers.openrouter.models."claude-sonnet-4-6".limits]
context_window = 1000000

View file

@ -56,6 +56,8 @@ impl EnvVars {
pub const INCEPTION_API_KEY: &'static str = "INCEPTION_API_KEY";
pub const KIMI_API_KEY: &'static str = "KIMI_API_KEY";
pub const MINIMAX_API_KEY: &'static str = "MINIMAX_API_KEY";
pub const MODAL_TOKEN_ID: &'static str = "MODAL_TOKEN_ID";
pub const MODAL_TOKEN_SECRET: &'static str = "MODAL_TOKEN_SECRET";
pub const OPENAI_API_KEY: &'static str = "OPENAI_API_KEY";
pub const OPENAI_BASE_URL: &'static str = "OPENAI_BASE_URL";
pub const OPENAI_ORGANIZATION: &'static str = "OPENAI_ORGANIZATION";

View file

@ -10,9 +10,10 @@ use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
use crate::{
AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AgentToolSummary, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord,
InvalidTransition, LlmOutputKind, ModelRef, PermissionLevel, PullRequestLink, RunApproval,
RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion,
StageHandler, StageId, StageState, StageTiming, StartRecord, TodoListProjection, timing,
InvalidTransition, LlmOutputKind, ModelRef, ParallelBranchId, PermissionLevel, PullRequestLink,
RunApproval, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, RunTiming,
StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord,
TodoListProjection, timing,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -328,6 +329,8 @@ pub struct StageProjection {
pub script_invocation: Option<serde_json::Value>,
pub script_timing: Option<serde_json::Value>,
pub parallel_results: Option<Vec<crate::ParallelBranchResult>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel_branch_id: Option<ParallelBranchId>,
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_bytes: Option<u64>,
@ -581,6 +584,7 @@ impl StageProjection {
script_invocation: None,
script_timing: None,
parallel_results: None,
parallel_branch_id: None,
output: None,
output_bytes: None,
live_streaming: None,

View file

@ -4,12 +4,22 @@
//!
//! - a bare token such as `openai` or `gpt-5.4` — the parser cannot tell alone
//! whether the token is a provider name or a model alias
//! - a qualified reference such as `gemini/gemini-flash`, which names both a
//! provider and a model
//! - a qualified reference such as `gemini:gemini-flash`, which names both a
//! provider and a model selector
//! - a legacy qualified reference such as `gemini/gemini-flash`, which is
//! accepted on input and serialized using the canonical `provider:selector`
//! form
//!
//! The parser produces [`ModelRef`]; ambiguity resolution against a known
//! registry of providers and models happens at consumption time via
//! [`ModelRef::resolve`].
//!
//! That split matters for the `:` form. Model IDs legitimately contain colons —
//! ollama `name:tag` values, Bedrock inference-profile ARNs — so no separator
//! is safe to split on by shape alone. Whichever separator appears first
//! decides the form: a `/` before any `:` is the legacy pin, and its selector
//! may contain colons. Otherwise the token stays bare and
//! [`ModelRef::qualify`] promotes it only when the prefix names a provider.
use std::fmt;
use std::str::FromStr;
@ -23,8 +33,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub enum ModelRef {
/// A bare token. May be a provider name, a model alias, or a model id.
Bare(String),
/// A provider-qualified model reference.
Qualified { provider: String, model: String },
/// A provider-qualified model selector.
Qualified { provider: String, selector: String },
}
/// An error returned when parsing a model reference fails.
@ -32,10 +42,9 @@ pub enum ModelRef {
pub enum ParseModelRefError {
/// The input was empty or whitespace only.
Empty,
/// The input contained more than one `/`, which is not a valid qualified
/// ref.
/// A legacy slash-qualified input contained more than one `/`.
TooManySlashes { input: String },
/// The provider or model side of a qualified reference was empty.
/// The provider or selector side of a qualified reference was empty.
EmptySide { input: String },
}
@ -46,13 +55,13 @@ impl fmt::Display for ParseModelRefError {
Self::TooManySlashes { input } => {
write!(
f,
"model reference {input:?}: expected at most one \"/\" separator between provider and model"
"model reference {input:?}: qualify it as \"provider:selector\" when the selector contains \"/\""
)
}
Self::EmptySide { input } => {
write!(
f,
"model reference {input:?}: provider and model sides must both be non-empty"
"model reference {input:?}: provider and selector sides must both be non-empty"
)
}
}
@ -70,25 +79,34 @@ impl FromStr for ModelRef {
return Err(ParseModelRefError::Empty);
}
let parts: Vec<&str> = trimmed.split('/').collect();
match parts.as_slice() {
[bare] => Ok(Self::Bare((*bare).to_owned())),
[provider, model] => {
if provider.is_empty() || model.is_empty() {
Err(ParseModelRefError::EmptySide {
input: input.to_owned(),
})
} else {
Ok(Self::Qualified {
provider: (*provider).to_owned(),
model: (*model).to_owned(),
})
}
// Whichever separator comes first decides the form.
let (provider, selector) = match (trimmed.find('/'), trimmed.find(':')) {
// A `/` before any `:` is the legacy `provider/model` form. Its
// selector may itself contain colons, as Bedrock API IDs do.
(Some(slash), colon) if colon.is_none_or(|colon| slash < colon) => {
(&trimmed[..slash], &trimmed[slash + 1..])
}
_ => Err(ParseModelRefError::TooManySlashes {
// Otherwise a `:` may separate provider from selector — but model
// IDs legitimately contain colons (ollama `name:tag` values,
// Bedrock ARNs) and only a registry can tell those apart, so leave
// the token bare for [`ModelRef::qualify`] to promote.
_ => return Ok(Self::Bare(trimmed.to_owned())),
};
if selector.contains('/') {
return Err(ParseModelRefError::TooManySlashes {
input: input.to_owned(),
}),
});
}
if provider.is_empty() || selector.is_empty() {
return Err(ParseModelRefError::EmptySide {
input: input.to_owned(),
});
}
Ok(Self::Qualified {
provider: provider.to_owned(),
selector: selector.to_owned(),
})
}
}
@ -96,7 +114,7 @@ impl fmt::Display for ModelRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bare(token) => f.write_str(token),
Self::Qualified { provider, model } => write!(f, "{provider}/{model}"),
Self::Qualified { provider, selector } => write!(f, "{provider}:{selector}"),
}
}
}
@ -113,7 +131,7 @@ impl fmt::Display for AmbiguousModelRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"model reference {:?} is ambiguous: matches provider names {:?} and model names {:?}; qualify it as \"provider/model\"",
"model reference {:?} is ambiguous: matches provider names {:?} and model names {:?}; qualify it as \"provider:model\"",
self.input, self.providers, self.models
)
}
@ -130,7 +148,7 @@ pub enum ResolvedModelRef {
/// The reference named a model (qualified or unambiguously bare).
Model {
provider: Option<String>,
model: String,
selector: String,
},
}
@ -145,9 +163,37 @@ pub trait ModelRegistry {
}
impl ModelRef {
/// Promote a bare `provider:selector` token to [`ModelRef::Qualified`] when
/// the prefix names a known provider.
///
/// Parsing alone cannot do this. A model ID may itself contain a colon —
/// ollama `name:tag` values, Bedrock inference-profile ARNs — and those
/// must stay whole. Anything else is returned unchanged.
#[must_use]
pub fn qualify(self, registry: &dyn ModelRegistry) -> Self {
let Self::Bare(token) = self else {
return self;
};
match token.split_once(':') {
Some((provider, selector))
if !provider.is_empty()
&& !selector.is_empty()
&& registry.is_provider(provider) =>
{
Self::Qualified {
provider: provider.to_owned(),
selector: selector.to_owned(),
}
}
_ => Self::Bare(token),
}
}
/// Resolve this reference against a registry.
///
/// - [`ModelRef::Qualified`] always resolves to a model.
/// - A bare `provider:selector` token is qualified first — see
/// [`ModelRef::qualify`].
/// - [`ModelRef::Bare`] resolves to a provider if the token is only a
/// provider, to a model if the token is only a model, and returns
/// [`AmbiguousModelRef`] if the token matches both a provider and a model
@ -156,25 +202,25 @@ impl ModelRef {
&self,
registry: &dyn ModelRegistry,
) -> Result<ResolvedModelRef, AmbiguousModelRef> {
match self {
Self::Qualified { provider, model } => Ok(ResolvedModelRef::Model {
provider: Some(provider.clone()),
model: model.clone(),
match self.clone().qualify(registry) {
Self::Qualified { provider, selector } => Ok(ResolvedModelRef::Model {
provider: Some(provider),
selector,
}),
Self::Bare(token) => {
let is_provider = registry.is_provider(token);
let is_model = registry.is_model(token);
let is_provider = registry.is_provider(&token);
let is_model = registry.is_model(&token);
match (is_provider, is_model) {
(true, false) => Ok(ResolvedModelRef::Provider(token.clone())),
(true, false) => Ok(ResolvedModelRef::Provider(token)),
(true, true) => Err(AmbiguousModelRef {
input: token.clone(),
providers: vec![token.clone()],
models: vec![token.clone()],
models: vec![token],
}),
// Known and unknown bare models leave provider selection to the runtime.
(false, _) => Ok(ResolvedModelRef::Model {
provider: None,
model: token.clone(),
selector: token,
}),
}
}
@ -182,6 +228,17 @@ impl ModelRef {
}
}
impl ModelRegistry for fabro_model::Catalog {
fn is_provider(&self, token: &str) -> bool {
self.provider(&fabro_model::ProviderId::from(token))
.is_some()
}
fn is_model(&self, token: &str) -> bool {
self.is_model_selector(token)
}
}
impl Serialize for ModelRef {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
@ -197,7 +254,7 @@ impl<'de> Deserialize<'de> for ModelRef {
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
r#"a model reference such as "openai", "gpt-5.4", or "gemini/gemini-flash""#,
r#"a model reference such as "openai", "gpt-5.4", or "gemini:gemini-flash""#,
)
}
@ -240,21 +297,134 @@ mod tests {
);
}
/// Parsing cannot tell a provider prefix from a model ID that contains a
/// colon, so it defers to [`ModelRef::qualify`].
#[test]
fn parses_qualified() {
fn parses_colon_tokens_as_bare() {
for input in [
"gemini:gemini-flash",
"openrouter:moonshotai/kimi-k3",
"bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0",
] {
assert_eq!(
input.parse::<ModelRef>().unwrap(),
ModelRef::Bare(input.into()),
"{input}"
);
}
}
#[test]
fn parses_legacy_slash_qualified() {
assert_eq!(
"gemini/gemini-flash".parse::<ModelRef>().unwrap(),
ModelRef::Qualified {
provider: "gemini".into(),
model: "gemini-flash".into(),
selector: "gemini-flash".into(),
}
);
}
/// A `/` before any `:` keeps the legacy pin, so Bedrock-style API IDs
/// stay on the selector side instead of splitting at the wrong colon.
#[test]
fn legacy_slash_selector_may_contain_colons() {
for (input, provider, selector) in [
(
"bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"bedrock",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
),
("openai/gpt-5.6-sol:0", "openai", "gpt-5.6-sol:0"),
] {
assert_eq!(
input.parse::<ModelRef>().unwrap(),
ModelRef::Qualified {
provider: provider.into(),
selector: selector.into(),
},
"{input}"
);
}
}
/// A `:` before any `/` wins, so a provider-qualified selector keeps its
/// slashes.
#[test]
fn first_separator_decides_the_form() {
assert_eq!(
"openrouter:moonshotai/kimi-k3".parse::<ModelRef>().unwrap(),
ModelRef::Bare("openrouter:moonshotai/kimi-k3".into())
);
assert_eq!(
"bedrock/us.anthropic:0".parse::<ModelRef>().unwrap(),
ModelRef::Qualified {
provider: "bedrock".into(),
selector: "us.anthropic:0".into(),
}
);
}
#[test]
fn qualify_promotes_a_known_provider_prefix() {
let reg = TestRegistry {
providers: &["openrouter", "bedrock"],
models: &[],
};
for (input, selector) in [
("openrouter:kimi-k3", "kimi-k3"),
("openrouter:moonshotai/kimi-k3", "moonshotai/kimi-k3"),
(
"bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
),
] {
let qualified = input.parse::<ModelRef>().unwrap().qualify(&reg);
let provider = input.split_once(':').unwrap().0;
assert_eq!(
qualified,
ModelRef::Qualified {
provider: provider.into(),
selector: selector.into(),
},
"{input}"
);
}
}
/// The regression this guards: a model ID that merely contains a colon —
/// an ollama `name:tag`, a Bedrock ARN — must not be read as qualified.
#[test]
fn qualify_leaves_colon_bearing_model_ids_bare() {
let reg = TestRegistry {
providers: &["ollama", "bedrock"],
models: &[],
};
for input in [
"llama3:8b",
"qwen3.5:latest",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"arn:aws:bedrock:us-east-1:1234:inference-profile/us.anthropic.claude-fable-5",
":foo",
"foo:",
] {
let parsed = input.parse::<ModelRef>().unwrap();
assert_eq!(
parsed.clone().qualify(&reg),
parsed,
"{input} should stay bare"
);
}
}
#[test]
fn rejects_too_many_slashes() {
let err = "a/b/c".parse::<ModelRef>().unwrap_err();
assert!(matches!(err, ParseModelRefError::TooManySlashes { .. }));
assert_eq!(
err.to_string(),
r#"model reference "a/b/c": qualify it as "provider:selector" when the selector contains "/""#
);
}
#[test]
@ -296,7 +466,7 @@ mod tests {
let resolved = ModelRef::Bare("gpt-5.4".into()).resolve(&reg).unwrap();
assert_eq!(resolved, ResolvedModelRef::Model {
provider: None,
model: "gpt-5.4".into(),
selector: "gpt-5.4".into(),
});
}
@ -320,24 +490,35 @@ mod tests {
};
let resolved = ModelRef::Qualified {
provider: "a".into(),
model: "b".into(),
selector: "b".into(),
}
.resolve(&reg)
.unwrap();
assert_eq!(resolved, ResolvedModelRef::Model {
provider: Some("a".into()),
model: "b".into(),
selector: "b".into(),
});
}
#[test]
fn display_round_trip() {
for input in ["openai", "gpt-5.4", "gemini/gemini-flash"] {
for input in [
"openai",
"gpt-5.4",
"gemini:gemini-flash",
"openrouter:moonshotai/kimi-k3",
] {
let parsed: ModelRef = input.parse().unwrap();
assert_eq!(parsed.to_string(), input);
}
}
#[test]
fn display_canonicalizes_legacy_slash_separator() {
let parsed: ModelRef = "gemini/gemini-flash".parse().unwrap();
assert_eq!(parsed.to_string(), "gemini:gemini-flash");
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
@ -345,14 +526,21 @@ mod tests {
m: ModelRef,
}
let input = r#"{"m":"gemini/gemini-flash"}"#;
// Colon tokens stay bare until a registry qualifies them, and survive
// the round trip verbatim either way.
let input = r#"{"m":"openrouter:moonshotai/kimi-k3"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(matches!(
assert_eq!(
parsed.m,
ModelRef::Qualified { ref provider, ref model }
if provider == "gemini" && model == "gemini-flash"
));
ModelRef::Bare("openrouter:moonshotai/kimi-k3".into())
);
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
let legacy: Wrap = serde_json::from_str(r#"{"m":"gemini/gemini-flash"}"#).unwrap();
assert_eq!(
serde_json::to_string(&legacy).unwrap(),
r#"{"m":"gemini:gemini-flash"}"#
);
}
}

View file

@ -17,7 +17,7 @@
export interface CreateRunSessionRequest {
'title'?: string;
/**
* Catalog model ID or alias. The server selects among ready providers and stores the canonical model ID.
* Catalog model ID or alias, optionally qualified as `provider:selector`. A provider-qualified selector may be a canonical model ID, alias, or provider API ID. A value counts as qualified only when the text before the first `:` names a known provider, so model IDs containing a colon stay whole. Legacy `provider/model` references remain accepted. The server stores the canonical model ID.
*/
'model'?: string;
/**

View file

@ -37,7 +37,7 @@ export interface RunEvent {
*/
'parallel_group_id'?: string | null;
/**
* Durable identity of one branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\".
* Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form.
*/
'parallel_branch_id'?: string | null;
'session_id'?: string | null;

View file

@ -60,6 +60,14 @@ export interface RunStage {
* Canonical stage execution identifier in `node_id@visit` form.
*/
'resumed_from_stage_id'?: string | null;
/**
* Exact StageId of the parent parallel execution. Clients can compare this directly with the `id` of a parallel stage. Omitted for stages that are not parallel branches.
*/
'parallel_group_id'?: string;
/**
* Zero-based outgoing-edge index within the parent parallel execution. Omitted for stages that are not parallel branches.
*/
'parallel_branch_index'?: number;
'provider_used'?: StageModelUsage | null;
/**
* Wall-clock time the latest attempt of this stage started, if known.

View file

@ -90,6 +90,10 @@ export interface StageProjection {
* Ordered per-branch results produced by a parallel stage.
*/
'parallel_results'?: Array<ParallelBranchResult> | null;
/**
* Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form.
*/
'parallel_branch_id'?: string | null;
'output'?: string | null;
'output_bytes'?: number | null;
'live_streaming'?: boolean | null;