Wire end-to-end steering for running agents (#209)

## Summary
This makes the advertised mid-run steering path real: users can send
append or interrupt steering messages through the API, CLI, and web UI,
and the worker delivers them to live API-mode agent sessions or buffers
them for the next session. The change adds the control protocol, session
interrupt machinery, workflow hub, server route/OpenAPI/client updates,
and UI feedback needed for the whole path.

### Plan Summary
- Add `SteerKind`/`run.steer` wire protocol and `POST /runs/{id}/steer`
- Deliver steers through subprocess JSONL or the in-process
`SteeringHub`
- Support append and interrupt behavior in agent sessions, with bounded
buffering and events
- Expose steering in the CLI/web UI and surface SSE toasts

## Flow

```mermaid
flowchart TB
  UI["CLI / Web UI"] --> API["POST /runs/{id}/steer"]
  API -->|"subprocess transport"| Control["Worker control JSONL"]
  API -->|"in-process transport"| Hub["SteeringHub"]
  Control --> Hub
  Hub -->|"active API sessions"| Session["SessionControlHandle"]
  Hub -->|"no active session"| Pending["Pending buffer"]
  Pending -->|"first future API session"| Session
  Session --> Agent["Session round loop"]
  Agent --> Events["RunEvent stream"]
  Events --> UI
```

## What changed and why

- Agent sessions now expose a lightweight `SessionControlHandle`, drain
steering at the top of each round, and use a replaceable round
cancellation token for interrupts. LLM waits are cancelled promptly,
while tool execution observes cancellation cooperatively so every
committed `tool_use` still gets a matching `tool_result`.
- `SteeringHub` owns active API session registration, broadcast
delivery, pending buffering, FIFO queue caps, and steering
lifecycle/drop events. A completion coordinator closes the
final-response race without introducing a workflow dependency into the
agent crate.
- The server route replaces the 501 stub, validates run state and
best-effort CLI-only steerability, and forwards through either
subprocess control JSONL or the in-process hub. OpenAPI and generated
clients now include the request type.
- The CLI and web UI can send append or interrupt steers. Run detail and
board views open the new composer, and shared SSE subscriptions now
support per-subscriber event callbacks so invalidation and steering
toasts can coexist on one EventSource.

## Review notes

- Steering actors stay on top-level `RunEvent.actor`; event props only
carry steering kind/drop metadata.
- Buffered steers replay as append messages to the first API session
that registers after an empty-active period. Per-stage targeting remains
out of scope.
- CLI-mode agent stages are still not steerable; the server returns a
best-effort 409 when all active agent stages are CLI-mode, while the
worker hub remains the authoritative safety net.
- No persistence or schema migration is required; active and pending
steering state is in memory.
- New tests focus on protocol round-trips, hub buffering/bounds, session
steering-loop behavior, SSE fanout, and basic server rejection paths.

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

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-05 15:34:16 -04:00 committed by GitHub
parent e40dc7d9ad
commit 79f89165f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 4118 additions and 241 deletions

View file

@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import { ApiError } from "../lib/api-client";
import { useSteerRun } from "../lib/mutations";
import { ErrorMessage } from "./ui";
interface SteerComposerProps {
runId: string;
open: boolean;
onClose: () => void;
}
export function SteerComposer({ runId, open, onClose }: SteerComposerProps) {
const [text, setText] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const { trigger, isMutating } = useSteerRun(runId);
useEffect(() => {
if (open) {
requestAnimationFrame(() => textareaRef.current?.focus());
} else {
setText("");
setErrorMessage(null);
}
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCloseRef.current();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
if (!open) return null;
const trimmed = text.trim();
const canSubmit = trimmed.length > 0 && !isMutating;
async function send(interrupt: boolean) {
if (!canSubmit) return;
setErrorMessage(null);
try {
await trigger({ text: trimmed, interrupt });
onClose();
} catch (err) {
if (err instanceof ApiError) {
// Try to surface the well-known 409 codes inline.
const body = err.body as { code?: string; detail?: string } | null;
if (body?.code === "cli_agent_not_steerable") {
setErrorMessage(
"All running agent stages are CLI-mode and can't be steered.",
);
} else if (body?.code === "use_answer_endpoint") {
setErrorMessage(
"Run is blocked on a question; answer the question first.",
);
} else {
setErrorMessage(body?.detail ?? err.message ?? "Steer failed.");
}
} else {
setErrorMessage("Steer failed; try again.");
}
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Steer running agent"
className="w-full max-w-md rounded-lg border border-line bg-bg-elevated p-4 shadow-lg"
>
<div className="mb-2 text-sm font-semibold text-fg">Steer agent</div>
<textarea
ref={textareaRef}
rows={4}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a steering message…"
className="w-full resize-none rounded-md border border-line bg-bg p-2 text-sm text-fg outline-none focus:border-teal-500/50"
maxLength={8192}
/>
{errorMessage && (
<div className="mt-2">
<ErrorMessage message={errorMessage} />
</div>
)}
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-[11px] text-fg-muted">
Enter to send · Shift+Enter for newline
</span>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md border border-line px-3 py-1 text-xs text-fg-2 hover:border-line-strong"
>
Cancel
</button>
<button
type="button"
onClick={() => void send(true)}
disabled={!canSubmit}
className="rounded-md border border-amber/30 px-3 py-1 text-xs text-amber hover:border-amber/60 disabled:cursor-not-allowed disabled:opacity-50"
>
Interrupt
</button>
<button
type="button"
onClick={() => void send(false)}
disabled={!canSubmit}
className="rounded-md bg-teal-500 px-3 py-1 text-xs font-medium text-white hover:bg-teal-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import { useToast } from "../components/toast";
import { subscribeToRunEvents, type RunEventPayload } from "../lib/run-events";
import type { MutateFn } from "../lib/sse";
const NOOP_MUTATE = (() => undefined) as MutateFn;
const DEDUPE_WINDOW = 256;
export function useRunToasts(runId: string | undefined) {
const { push } = useToast();
const seenEventIdsRef = useRef(new Set<string>());
useEffect(() => {
if (!runId) return;
const seen = new Set<string>();
seenEventIdsRef.current = seen;
return subscribeToRunEvents(runId, NOOP_MUTATE, undefined, {
onEvent: (payload) => {
const dedupeId = eventDedupeId(payload);
if (dedupeId) {
if (seen.has(dedupeId)) return;
seen.add(dedupeId);
if (seen.size > DEDUPE_WINDOW) {
// Set iteration order is insertion order; drop the oldest.
const oldest = seen.values().next().value;
if (oldest !== undefined) seen.delete(oldest);
}
}
const message = steeringToastMessage(payload);
if (message) {
push({ message });
}
},
});
}, [push, runId]);
}
function eventDedupeId(payload: RunEventPayload): string | null {
if (typeof payload.id === "string") return payload.id;
if (typeof payload.seq === "number") return `seq:${payload.seq}`;
return null;
}
function steeringToastMessage(payload: RunEventPayload): string | null {
const props = payload.properties ?? {};
switch (payload.event) {
case "run.interrupt":
return "Agent interrupted.";
case "run.steer":
return "Steer accepted.";
case "agent.steering.injected":
return "Steer delivered.";
case "agent.steer.buffered":
return "Steer queued — will apply when an agent stage runs.";
case "agent.steer.dropped": {
const reason = props.reason;
if (reason === "queue_full") {
return "Steer rate limit reached; oldest queued steer dropped.";
}
if (reason === "run_ended") {
return "Run ended before queued steer(s) could apply.";
}
return null;
}
default:
return null;
}
}

View file

@ -3,6 +3,7 @@ import { useSWRConfig } from "swr";
import type {
PreviewUrlResponse,
RunStatusResponse,
SteerRunRequest,
SubmitAnswerRequest,
} from "@qltysh/fabro-api-client";
@ -116,6 +117,26 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
);
}
export type SteerRunArg = SteerRunRequest;
export function useSteerRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `steer-run:${runId}` : null,
async (_key: string, { arg }: { arg: SteerRunArg }) => {
if (!runId) throw new Error("runId is required");
const path = `/api/v1/runs/${encodeURIComponent(runId)}/steer`;
await apiJsonMutation<void, SteerRunRequest>(path, { arg });
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useToggleDemoMode() {
const { mutate } = useSWRConfig();
return useSWRMutation(

View file

@ -61,6 +61,13 @@ describe("queryKeysForRunEvent", () => {
queryKeys.runs.stageEvents("run-1", "verify@2"),
]);
});
test("stage-scoped steering events invalidate run events and stage events", () => {
expect(queryKeysForRunEvent("run-1", "agent.session.activated", "agent@1")).toEqual([
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "agent@1"),
]);
});
});
describe("subscribeToRunEvents", () => {
@ -160,6 +167,41 @@ describe("subscribeToRunEvents", () => {
coordinator.close();
});
test("fallback runs payload callbacks for later subscribers on a shared source", () => {
const source = new FakeEventSource();
const seen: string[] = [];
const keys: string[] = [];
const coordinator = createFallbackCoordinator();
const mutate = (key: string) => {
keys.push(key);
return Promise.resolve();
};
const callbackMutate = () => Promise.resolve();
const firstCleanup = subscribeToRunEvents("run-shared-payload", mutate, () => source, {
debounceMs: 0,
coordinator,
});
const secondCleanup = subscribeToRunEvents("run-shared-payload", callbackMutate, () => {
throw new Error("source should be reused");
}, {
debounceMs: 0,
coordinator,
onEvent: (payload) => {
if (payload.event) seen.push(payload.event);
},
});
source.emit({ id: "evt-1", event: "agent.steer.buffered", properties: {} });
expect(seen).toEqual(["agent.steer.buffered"]);
expect(keys).toEqual([queryKeys.runs.events("run-shared-payload", 1000)]);
firstCleanup();
secondCleanup();
coordinator.close();
});
test("fallback terminal events close the source after invalidating keys", () => {
const source = new FakeEventSource();
const keys: string[] = [];

View file

@ -15,7 +15,9 @@ import {
type SharedEventSubscription,
} from "./sse";
interface RunEventPayload extends EventPayload {
export interface RunEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
run_id?: string;
node_id?: string;
@ -26,6 +28,7 @@ interface RunEventPayload extends EventPayload {
interface RunEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
onEvent?: (payload: RunEventPayload) => void;
}
const subscriptions = new Map<string, SharedEventSubscription>();
@ -74,6 +77,15 @@ const INTERVIEW_EVENTS = new Set([
"interview.timeout",
"interview.interrupted",
]);
const STEERING_EVENTS = new Set([
"run.interrupt",
"run.steer",
"agent.steering.injected",
"agent.session.activated",
"agent.session.deactivated",
"agent.steer.buffered",
"agent.steer.dropped",
]);
export function queryKeysForRunEvent(
runId: string,
@ -125,6 +137,14 @@ export function queryKeysForRunEvent(
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
}
if (STEERING_EVENTS.has(event)) {
const keys = [queryKeys.runs.events(runId, 1000)];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
return [];
}
@ -132,7 +152,7 @@ export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300, coordinator }: RunEventOptions = {},
{ debounceMs = 300, coordinator, onEvent }: RunEventOptions = {},
): () => void {
return subscribeToCrossTabSse<RunEventPayload>({
coordinator,
@ -142,6 +162,7 @@ export function subscribeToRunEvents(
resyncKeys: () => resyncKeysForRun(runId),
resolveInvalidation: (payload) => {
if (payload.run_id !== runId) return { keys: [] };
onEvent?.(payload);
return runInvalidation(runId, payload);
},
fallbackSubscribe: () =>
@ -153,6 +174,7 @@ export function subscribeToRunEvents(
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
onEvent?.(payload);
const result = runInvalidation(runId, payload);
return { ...result, close: result.immediate };
},

View file

@ -18,10 +18,13 @@ export interface EventInvalidation {
immediate?: boolean;
}
type EventResolver = (payload: EventPayload) => EventInvalidation;
export interface SharedEventSubscription {
source: EventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
resolvers: Map<symbol, EventResolver>;
pendingKeys: Set<string>;
debounceTimer: ReturnType<typeof setTimeout> | null;
}
@ -54,6 +57,7 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
source,
refcount: 0,
mutators: new Map(),
resolvers: new Map(),
pendingKeys: new Set(),
debounceTimer: null,
};
@ -70,18 +74,29 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
return;
}
const invalidation = resolveInvalidation(payload);
queueInvalidations(current, invalidation.keys, {
debounceMs,
immediate: invalidation.immediate,
});
const keys = new Set<string>();
let close = false;
let immediate = false;
for (const resolver of current.resolvers.values()) {
const invalidation = resolver(payload);
for (const key of invalidation.keys) keys.add(key);
close ||= Boolean(invalidation.close);
immediate ||= Boolean(invalidation.immediate);
}
if (invalidation.close) {
queueInvalidations(current, [...keys], { debounceMs, immediate });
if (close) {
closeSharedEventSource(subscriptions, subscriptionKey, { flushPending: true });
}
};
}
const resolverId = Symbol(subscriptionKey);
subscription.resolvers.set(
resolverId,
resolveInvalidation as EventResolver,
);
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
@ -89,6 +104,8 @@ export function subscribeToSharedEventSource<TPayload extends EventPayload>({
const current = subscriptions.get(subscriptionKey);
if (!current) return;
current.resolvers.delete(resolverId);
const mutateCount = current.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
current.mutators.delete(mutate);

View file

@ -1,8 +1,9 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation } from "react-router";
import { InterviewDock } from "../components/interview-dock";
import { SteerComposer } from "../components/steer-composer";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import { PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS } from "../components/ui";
@ -22,6 +23,7 @@ import {
type PreviewMutationResult,
} from "../lib/mutations";
import { useRunEvents } from "../lib/run-events";
import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions } from "../lib/queries";
import {
canArchive,
@ -115,8 +117,10 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const { push, dismiss } = useToast();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
const [steerOpen, setSteerOpen] = useState(false);
useRunEvents(params.id);
useRunToasts(params.id);
useEffect(() => {
if (previewMutation.data?.intent === "preview") {
@ -204,6 +208,18 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{statusKind === "running" && (
<div>
<button
type="button"
onClick={() => setSteerOpen(true)}
className={MUTATION_BUTTON_CLASS}
>
Steer
</button>
</div>
)}
{visibility.showPrimaryCancel && (
<div>
<button
@ -300,6 +316,12 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<Outlet />
</div>
<SteerComposer
runId={params.id}
open={steerOpen}
onClose={() => setSteerOpen(false)}
/>
{isBlocked && pendingQuestions.length > 0 && (
<>
<div aria-hidden="true" className="h-72" />

View file

@ -21,8 +21,8 @@ import { CSS } from "@dnd-kit/utilities";
import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { EmptyState } from "../components/state";
import { SteerComposer } from "../components/steer-composer";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useDemoMode } from "../lib/demo-mode";
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
@ -298,8 +298,15 @@ function PrCard({
actions?: string[];
}) {
const lifecycleLabel = boardLifecycleStatusLabel(pr);
const [steerOpen, setSteerOpen] = useState(false);
return (
<>
<SteerComposer
runId={pr.id}
open={steerOpen}
onClose={() => setSteerOpen(false)}
/>
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
<div className="mb-2 flex items-center gap-1.5">
<Icon className={`size-3.5 shrink-0 ${iconColor}`} />
@ -362,6 +369,13 @@ function PrCard({
key={label}
type="button"
disabled={pr.actionDisabled}
onClick={(e) => {
if (label === "Steer") {
e.preventDefault();
e.stopPropagation();
setSteerOpen(true);
}
}}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:text-fg-muted disabled:border-line ${
label === "Merge"
? "border-mint/20 text-mint hover:border-mint/50 hover:text-fg"
@ -403,6 +417,7 @@ function PrCard({
</div>
)}
</Link>
</>
);
}
@ -448,10 +463,7 @@ function SortablePrCard({
function BoardColumn({ column }: { column: Column }) {
const Icon = iconMap[column.iconType];
const demoMode = useDemoMode();
const actions = demoMode
? column.actions
: column.actions.filter((label) => label !== "Steer");
const actions = column.actions;
return (
<div className="flex min-w-0 flex-col">
<div className="mb-3 flex items-center gap-3">

View file

@ -200,6 +200,40 @@ Informational, warning, or error notice emitted during the run.
| `code` | string | Machine-readable notice code |
| `message` | string | Human-readable message |
### `run.interrupt`
Emitted after a live worker accepts a run interrupt control operation. The
actor is stored in the top-level `actor` envelope field. Properties are empty.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.interrupt",
"actor": { "kind": "user", "login": "octocat" },
"properties": {}
}
```
### `run.steer`
Emitted after a live worker accepts run steering text. The actor is stored in
the top-level `actor` envelope field.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "run.steer",
"actor": { "kind": "user", "login": "octocat" },
"properties": {
"text": "Remember to run tests after changes"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `text` | string | Accepted steering text |
### `metadata.snapshot.started`
Emitted when Fabro begins a durable metadata snapshot operation. These are product events for Fabro metadata snapshots, not tracing spans for the underlying git or filesystem work.
@ -863,7 +897,7 @@ Emitted when execution loops back to an earlier node.
## Agent events
All agent events have `node_id` (the workflow stage), `node_label`, `session_id`, and `parent_session_id` in the envelope. The `properties` contain the inner agent event fields.
Most agent activity events are stage-scoped and carry `node_id` (the workflow stage), `node_label`, `stage_id`, `session_id`, and `parent_session_id` in the envelope. Session object lifecycle events are the exception: `agent.session.started` and `agent.session.ended` are not stage-scoped and intentionally omit `node_id`, `node_label`, `stage_id`, and `visit`.
### `agent.session.started`
@ -871,13 +905,49 @@ All agent events have `node_id` (the workflow stage), `node_label`, `session_id`
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.started",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc", "parent_session_id": null,
"properties": {}
"properties": {
"provider": "openai",
"model": "gpt-5.4"
}
}
```
No properties.
Object-lifecycle event. `session_id` and `parent_session_id` are envelope fields. `properties.provider` and `properties.model` are optional.
### `agent.session.activated`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.activated",
"node_id": "code", "node_label": "code", "stage_id": "code@1",
"session_id": "ses_abc",
"properties": {
"thread_id": "main",
"provider": "openai",
"model": "gpt-5.4",
"capabilities": ["steer"],
"visit": 1
}
}
```
Stage-scoped lease event. A stage is steerable while the latest matching `agent.session.activated` lease is active.
### `agent.session.deactivated`
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.deactivated",
"node_id": "code", "node_label": "code", "stage_id": "code@1",
"session_id": "ses_abc",
"properties": { "visit": 1 }
}
```
Stage-scoped lease event. Consumers should pair it by `stage_id` and `session_id` so stale deactivations cannot clear a newer active lease.
### `agent.session.ended`
@ -885,13 +955,12 @@ No properties.
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.session.ended",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {}
}
```
No properties.
Object-lifecycle event. `session_id` and `parent_session_id` are envelope fields. No properties.
### `agent.processing.end`

View file

@ -878,6 +878,114 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/steer:
post:
operationId: steerRun
tags: [Human-in-the-Loop]
summary: Steer Run
description: |
Send a mid-run steering message to the live agent session(s) of a
running run. Set `interrupt=true` to atomically interrupt the active
API-mode agent round first, then deliver this message as the next
user turn. Without `interrupt=true`, the message is appended to the
steering queue and may buffer until the next API-mode agent session.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SteerRunRequest"
responses:
"202":
description: Steer accepted and forwarded to the worker
"400":
description: Invalid request body
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: |
Run is not currently steerable. Returned when the run is in a
terminal state, blocked (use the answer endpoint instead), or
all currently running agent stages are CLI-mode.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Worker control channel unavailable
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/interrupt:
post:
operationId: interruptRun
tags: [Human-in-the-Loop]
summary: Interrupt Run
description: |
Interrupt the active API-mode agent round without sending steering
text. The agent keeps its steering lease and waits for a later steer
message before starting another LLM round.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"202":
description: Interrupt accepted and forwarded to the worker
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: |
Run is not currently interruptible. Returned when the run is in a
terminal state, blocked (use the answer endpoint instead), has no
active API-mode agent session, or all currently running agent
stages are CLI-mode.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Worker control channel unavailable
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/start:
post:
operationId: startRun
@ -4532,6 +4640,27 @@ components:
warn:
type: boolean
SteerRunRequest:
description: Request body for steering a running run mid-execution.
type: object
required:
- text
properties:
text:
type: string
description: The steering message text to deliver as a user turn.
minLength: 1
maxLength: 8192
example: Try a different approach
interrupt:
type: boolean
description: |
When true, apply a worker-control interrupt first, then deliver
this text as steering in the same control operation. When false
(default), append to the steering queue and let the agent pick it
up at the next turn boundary.
default: false
StartRunRequest:
description: Request body for starting or resuming a run.
type: object

View file

@ -7,7 +7,7 @@ Steering lets you send guidance to an agent while it's working — without waiti
## How steering works
A steering message is injected into the agent's conversation as a user-role message. The agent sees it on its next LLM turn — after the current tool call finishes — and can adjust its approach immediately.
A steering message is injected into the agent's conversation as a user-role message. The agent sees it on its next LLM turn and can adjust its approach immediately.
The delivery flow:
@ -16,16 +16,18 @@ The delivery flow:
3. Before the next LLM call, Fabro drains the queue and injects each message as a `Steering` turn in the conversation history
4. The LLM sees the guidance alongside its existing context and adjusts accordingly
Steering is **asynchronous** — the agent picks up the message at its next natural pause point (between tool calls), not mid-execution.
Steering is **asynchronous** — the agent picks up the message at its next natural pause point.
When you send steering with `interrupt=true`, Fabro first cancels the active API-mode agent round, then queues the steering text in the same worker-control operation. The agent resumes with that steering text as the next user turn. A standalone interrupt through the API cancels the active round without text and keeps the session waiting until a later steer arrives.
## When steering is delivered
Steering messages are drained from the queue at two points during the agent loop:
1. **Before the first LLM call** — any messages queued before the agent starts its first turn
2. **After each tool execution round** — between tool results being collected and the next LLM call
2. **After each interrupted or completed round** — before the next LLM call
This means there is a natural latency between sending a steering message and the agent seeing it. If the agent is in the middle of a long-running shell command, the message waits until that command finishes and the next LLM turn begins.
This means there is a natural latency between sending a plain steering message and the agent seeing it. If the agent is in the middle of a long-running shell command, the message waits until that command finishes and the next LLM turn begins. Use interrupting steering when the current round should stop before the message is delivered.
Multiple steering messages sent in quick succession are all delivered together at the next drain point.

View file

@ -92,6 +92,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro server` | Server operations |
| `fabro settings` | Inspect effective settings |
| `fabro start` | Start a created workflow run on the server |
| `fabro steer` | Steer a running agent mid-execution |
| `fabro system` | System maintenance commands |
| `fabro unarchive` | Restore archived runs to their prior terminal status |
| `fabro uninstall` | Uninstall Fabro from this machine |
@ -1108,6 +1109,29 @@ fabro start [OPTIONS] <RUN>
| --- | --- |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro steer`
Steer a running agent mid-execution
```bash
fabro steer [OPTIONS] <RUN> [TEXT]
```
#### Arguments
| Name | Description |
| --- | --- |
| `RUN` | Run ID prefix to steer |
| `TEXT` | Steer message text (omit when --text-stdin is used) |
#### Options
| Option | Description |
| --- | --- |
| `--interrupt` | Cancel the in-flight LLM stream / tool calls and deliver the message as the next user turn (default: append to the steering queue) |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--text-stdin` | Read steer text from stdin instead of a positional arg |
### `fabro system`
System maintenance commands

View file

@ -0,0 +1,111 @@
# Decouple Interrupts From Steering Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split run interruption from steering message delivery while keeping `steer interrupt=true` as ergonomic sugar.
**Architecture:** `run.interrupt` becomes a standalone control/event path that cancels the active API-mode agent round and waits for later steering. `run.steer` becomes a plain message-injection path with no delivery kind. The existing combined user flow applies interrupt first, then steer, preserving user convenience without coupling the concepts in queue/event types.
**Tech Stack:** Rust workspace crates (`fabro-agent`, `fabro-workflow`, `fabro-server`, `fabro-interview`, `fabro-api`, `fabro-client`), OpenAPI, generated TypeScript API client, React web app, SSE run events.
---
## Summary
Split steering message delivery from agent interruption. `run.steer` becomes a plain "user injected guidance" event with no `kind`; `run.interrupt` becomes a separate control/event path that cancels the active agent round. `POST /runs/{id}/steer { interrupt: true }` remains ergonomic sugar for "interrupt, then steer," but there is no standalone CLI interrupt command.
Standalone interrupts cancel the current active API-mode agent round, keep the stage's steering lease active, and wait at a steerable point until a later steer resumes the agent.
## Key Changes
- Public API:
- Add `POST /api/v1/runs/{id}/interrupt` with `202`, `404`, `409`, and `503` behavior matching steer/cancel conventions.
- Keep `POST /api/v1/runs/{id}/steer`; keep `interrupt?: boolean` as request convenience.
- For `steer interrupt=true`, require an active API-mode agent session and send one atomic worker-control operation that applies interrupt first, then enqueues the steer.
- Non-interrupt steer may still buffer when no API session is active and no CLI agent is running.
- Regenerate Rust and TypeScript API clients after OpenAPI updates.
- Control protocol and types:
- Replace `WorkerControlMessage::Steer { text, kind, actor }` with `Steer { text, actor }`.
- Add `WorkerControlMessage::Interrupt { actor }`.
- Add `WorkerControlMessage::InterruptThenSteer { text, actor }` for the combined convenience path. The server must send this as one control envelope; do not implement `steer interrupt=true` as two independent enqueue operations.
- Remove `SteerKind` from `fabro-types`, `fabro-agent`, workflow event payloads, and web client assumptions.
- Keep existing CLI `fabro steer --interrupt`; implement it through the existing steer API, not a new CLI command.
- Workflow/session behavior:
- Split `SessionControlHandle` into plain `steer(text, actor)` and plain `interrupt(actor)`.
- Add an explicit agent-side `waiting_for_steer` state, guarded by the same control state as the steering queue.
- Steering enqueues text, clears `waiting_for_steer`, and wakes the session.
- Interrupt cancels the current round token and, when the steering queue is empty, sets `waiting_for_steer` so the session cannot immediately start another LLM round with unchanged context.
- Duplicate pure interrupts while already `waiting_for_steer` are idempotent: accept them, emit another `run.interrupt`, keep the session waiting, and do not enqueue synthetic steering.
- If an interrupt cancels an LLM stream or tool round without queued steering, the session waits without closing/deactivating its steering lease; terminal run cancellation still wins immediately.
- If interrupt and steer are applied together, the steer resumes the waiting/next round immediately.
- Terminal run cancellation must wake/break any `waiting_for_steer` wait and return the existing cancellation error path.
- Update the natural-completion close-the-door path so the activation lease is kept alive when the steering queue is nonempty or `waiting_for_steer` is true. `CompletionCoordinator`, `ActivationLease::release_if_queue_empty`, and `SessionControlHandle` should expose/use a single "has pending control work" predicate instead of checking queue emptiness alone.
- Events:
- Add top-level persisted `run.interrupt` with `actor` in the envelope and empty properties.
- Add top-level persisted `run.steer` with `actor` in the envelope and `properties.text`.
- Keep `agent.steering.injected`, but remove `kind`; it means the steer actually entered agent history.
- Keep `agent.steer.buffered` and `agent.steer.dropped`, but remove `kind` from buffered.
- Update run-event conversion, stored fields, docs, SSE invalidation, and toast logic for the simplified payloads.
- Event ownership and ordering:
- The worker-side control handler / `SteeringHub` is the single source of truth for persisted `run.interrupt` and `run.steer`; API handlers only emit these events indirectly after the live worker accepts the control envelope.
- A failed or timed-out control-channel request must not emit `run.interrupt` or `run.steer`.
- For `InterruptThenSteer`, persisted order must be `run.interrupt`, then `run.steer`, then later `agent.steering.injected` only when the text is drained into agent history.
## Protocol vs Events
Worker-control messages are transport commands, not persisted `RunEvent` names. `run.interrupt_then_steer` exists only as a worker-control envelope for atomic delivery of the combined convenience path and must never be emitted as a persisted run event. The only persisted run-level event names introduced here are `run.interrupt` and `run.steer`.
## API Response Matrix
| Run state | `POST /steer` | `POST /steer { interrupt: true }` | `POST /interrupt` |
| --- | --- | --- | --- |
| Active API-mode session | `202` accepted; emits `run.steer`; later `agent.steering.injected` | `202` accepted atomically; emits `run.interrupt`, then `run.steer` | `202` accepted; emits `run.interrupt` |
| Active API-mode session already `waiting_for_steer` | `202` accepted; emits `run.steer`; clears wait and later emits `agent.steering.injected` | `202` accepted atomically; emits `run.interrupt`, then `run.steer`; clears wait | `202` accepted idempotently; emits `run.interrupt`; remains waiting |
| No active API session, no active CLI agent | `202` accepted; emits `run.steer`, then `agent.steer.buffered` | `409` `no_active_api_session` | `409` `no_active_api_session` |
| Active CLI-only agent stages | `409` `cli_agent_not_steerable` | `409` `cli_agent_not_steerable` | `409` `cli_agent_not_steerable` |
| Blocked on interview/question | `409` `use_answer_endpoint` | `409` `use_answer_endpoint` | `409` `use_answer_endpoint` |
| Terminal run | `409` `run_not_steerable` | `409` `run_not_steerable` | `409` `run_not_interruptible` |
| Missing live worker channel | `503` `worker_control_unavailable` | `503` `worker_control_unavailable` | `503` `worker_control_unavailable` |
| Archived run | Existing archived-run rejection response from `reject_if_archived` | Existing archived-run rejection response from `reject_if_archived` | Existing archived-run rejection response from `reject_if_archived` |
## Implementation Checklist
- [x] OpenAPI/API clients: add `/runs/{id}/interrupt`, keep `SteerRunRequest.interrupt`, regenerate `fabro-api`, `fabro-client` usage, and TypeScript API client models.
- [x] Server route and transport: add interrupt handler, add `RunAnswerTransport::interrupt`, add `RunAnswerTransport::interrupt_then_steer`, enforce the API response matrix, and ensure combined steer uses one control operation.
- [x] Worker protocol: simplify `run.steer`, add `run.interrupt`, add `run.interrupt_then_steer`, and update subprocess/in-process dispatch.
- [x] Agent session state: replace `SteerKind` queue items with plain text+actor, add `waiting_for_steer`, wake on steer, block after pure interrupt, make duplicate interrupts idempotent while waiting, and break wait on terminal cancellation.
- [x] Natural-completion lease safety: update `CompletionCoordinator`, `ActivationLease::release_if_queue_empty`, and `SessionControlHandle` so close-the-door keeps the lease alive when either the queue is nonempty or `waiting_for_steer` is true.
- [x] Steering hub behavior: expose plain `deliver_steer`, `interrupt`, and `interrupt_then_steer`; emit accepted control events in the required order; keep buffering/dropping only for steering text.
- [x] Events/schema/store/web: add `run.interrupt` and `run.steer`, simplify `agent.steering.injected` and `agent.steer.buffered`, update stored fields, docs, run-state projections, SSE invalidation, and toasts.
- [x] Docs: update internal event docs, public API reference, CLI docs for `fabro steer --interrupt`, and steering docs to explain interrupt as separate control flow.
- [x] Verification: run the Rust and web test commands listed below, plus formatting and clippy.
## Test Plan
- `fabro-interview` worker-control envelope tests: JSON round-trip tests for transport-only `run.interrupt`, simplified `run.steer`, and transport-only `run.interrupt_then_steer` preserving `text` and `actor`.
- `fabro-types` / `fabro-api` persisted event tests: `run.interrupt` and `run.steer` serialize as persisted `RunEvent`s; no persisted event named `run.interrupt_then_steer` exists.
- `fabro-agent`: unit tests for plain steer injection, LLM-stream interrupt entering `waiting_for_steer`, tool-round interrupt entering `waiting_for_steer`, pure interrupt racing with a no-tool natural completion without releasing the lease, later steer wake-up, duplicate interrupt while waiting, interrupt-plus-steer resuming without an extra wait, and terminal cancel breaking the wait.
- `fabro-workflow`: steering hub tests for buffering plain steers, dropping plain steers, broadcasting interrupts to active sessions, and preserving interrupt-before-steer ordering.
- `fabro-server`: handler tests for `/interrupt`, `steer interrupt=true`, the full API response matrix, missing worker channel, CLI-only conflict, terminal/blocked conflicts, and stale activation/deactivation behavior.
- `fabro-cli` runner/subprocess bridge: worker-control line handler tests for simplified `run.steer`, `run.interrupt`, and `run.interrupt_then_steer`.
- `fabro-store` and run-event tests: projection ignores top-level `run.interrupt`/`run.steer` except where explicitly tracked; `agent.session.activated` remains the provider-used source.
- Web tests: update run-event invalidation and toast assertions for `run.interrupt`, `run.steer`, simplified `agent.steering.injected`, and simplified `agent.steer.buffered`.
- Verification commands:
- `cargo build -p fabro-api`
- `cd lib/packages/fabro-api-client && bun run generate`
- `cargo nextest run --workspace`
- `cd apps/fabro-web && bun test && bun run typecheck`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
## Assumptions
- Event names are exactly `run.interrupt` and `run.steer`.
- Standalone interrupt is API-only for now; no `fabro interrupt` CLI command and no new standalone web button are required.
- A pure interrupt is not a failure, pause, or cancellation of the run; it is a mid-stage wait point that resumes only when steering arrives or the run is terminally cancelled.
- Non-interrupt steering keeps the current buffering behavior, but interrupt steering does not buffer the interrupt portion when no active API session exists.
- Duplicate pure interrupts while already waiting are idempotent `202` responses that emit another persisted `run.interrupt` and leave the wait state unchanged.

View file

@ -44,7 +44,7 @@ pub use sandbox::{
SandboxEvent, SandboxEventCallback, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,
WorktreeSandbox, format_lines_numbered, shell_quote,
};
pub use session::Session;
pub use session::{CompletionCoordinator, Session, SessionControlHandle, SteeringItem};
pub use skills::Skill;
pub use subagent::{
SubAgent, SubAgentEventCallback, SubAgentManager, SubAgentResult, SubAgentStatus,

View file

@ -1,5 +1,5 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use fabro_auth::CredentialSource;
@ -13,8 +13,10 @@ use fabro_llm::types::{
use fabro_llm::{Error as LlmError, retry};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_mcp::connection_manager::McpConnectionManager;
use fabro_model::Provider;
use fabro_types::Principal;
use futures::StreamExt;
use tokio::sync::{Mutex as AsyncMutex, broadcast};
use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast};
use tokio::time;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
@ -38,26 +40,217 @@ use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentMana
use crate::tool_execution::execute_tool_calls;
use crate::types::{AgentEvent, SessionEvent, SessionState, Turn};
/// One queued steering message: text + the principal that authored it (None
/// for direct internal callers like loop-detection).
pub type SteeringItem = (String, Option<Principal>);
#[derive(Default)]
struct ControlState {
queue: VecDeque<SteeringItem>,
waiting_for_steer: bool,
}
/// Trait that lets the workflow layer keep an agent in `process_input` when a
/// natural completion (no tool calls) coincides with an unconsumed steering
/// message. The implementation must coordinate with the steering source so
/// that, once it returns `false`, no further steers can race into the queue
/// for this session.
pub trait CompletionCoordinator: Send + Sync {
/// Called inside the agent loop when the assistant finishes a turn with
/// no tool calls. Return `true` to continue (the session will iterate
/// once more and drain pending steering messages); `false` to break out
/// of the loop normally.
fn on_natural_completion(&self) -> bool;
}
/// Cheap clone of the parts of a `Session` that an external coordinator
/// (e.g. the workflow `SteeringHub`) needs to deliver steering messages and
/// interrupt the current round without holding the session itself.
#[derive(Clone)]
pub struct SessionControlHandle {
control: Arc<Mutex<ControlState>>,
round_token: Arc<RwLock<CancellationToken>>,
notify: Arc<Notify>,
}
impl Default for SessionControlHandle {
fn default() -> Self {
Self::new()
}
}
impl SessionControlHandle {
/// Build an unattached handle for testing or direct construction by
/// callers that want to wire a queue into something other than a live
/// `Session`. Both pieces are independent `Arc` values; cloning the
/// handle clones the `Arc`s.
#[must_use]
pub fn new() -> Self {
Self {
control: Arc::new(Mutex::new(ControlState::default())),
round_token: Arc::new(RwLock::new(CancellationToken::new())),
notify: Arc::new(Notify::new()),
}
}
/// Push a steering message onto the queue and wake a session waiting
/// after a pure interrupt.
pub fn steer(&self, text: String, actor: Option<Principal>) {
self.enqueue((text, actor));
}
/// Cancel the current round and, if no steering text is queued, park the
/// session at a steerable wait point.
pub fn interrupt(&self, _actor: Option<Principal>) {
{
let mut control = self.control.lock().expect("control state lock poisoned");
if control.queue.is_empty() {
control.waiting_for_steer = true;
}
}
self.cancel_round();
self.notify.notify_waiters();
}
/// Atomically apply interrupt semantics, then enqueue steering text.
pub fn interrupt_then_steer(&self, text: String, actor: Option<Principal>) {
self.interrupt_then_enqueue((text, actor));
}
/// Direct enqueue used by callers such as the hub flushing buffered
/// steers.
pub fn enqueue(&self, item: SteeringItem) {
{
let mut control = self.control.lock().expect("control state lock poisoned");
control.waiting_for_steer = false;
control.queue.push_back(item);
}
self.notify.notify_waiters();
}
/// Push `item` while enforcing a FIFO cap: if the queue is at or above
/// `cap`, the oldest entry is evicted and returned. Atomic under a
/// single lock acquisition.
#[must_use]
pub fn enqueue_bounded(&self, item: SteeringItem, cap: usize) -> Option<SteeringItem> {
let evicted = {
let mut control = self.control.lock().expect("control state lock poisoned");
let evicted = if control.queue.len() >= cap {
control.queue.pop_front()
} else {
None
};
control.queue.push_back(item);
control.waiting_for_steer = false;
evicted
};
self.notify.notify_waiters();
evicted
}
/// Interrupt the current round and push `item` while enforcing a FIFO cap.
#[must_use]
pub fn interrupt_then_enqueue_bounded(
&self,
item: SteeringItem,
cap: usize,
) -> Option<SteeringItem> {
let evicted = {
let mut control = self.control.lock().expect("control state lock poisoned");
let evicted = if control.queue.len() >= cap {
control.queue.pop_front()
} else {
None
};
control.waiting_for_steer = true;
control.queue.push_back(item);
control.waiting_for_steer = false;
evicted
};
self.cancel_round();
self.notify.notify_waiters();
evicted
}
fn interrupt_then_enqueue(&self, item: SteeringItem) {
{
let mut control = self.control.lock().expect("control state lock poisoned");
control.waiting_for_steer = true;
control.queue.push_back(item);
control.waiting_for_steer = false;
}
self.cancel_round();
self.notify.notify_waiters();
}
fn cancel_round(&self) {
self.round_token
.read()
.expect("round token lock poisoned")
.cancel();
}
/// Whether the steering queue currently has no unconsumed messages.
#[must_use]
pub fn queue_is_empty(&self) -> bool {
self.control
.lock()
.expect("control state lock poisoned")
.queue
.is_empty()
}
/// Whether queue work or an interrupt-induced wait is still pending.
#[must_use]
pub fn has_pending_control_work(&self) -> bool {
let control = self.control.lock().expect("control state lock poisoned");
!control.queue.is_empty() || control.waiting_for_steer
}
#[must_use]
pub fn is_waiting_for_steer(&self) -> bool {
self.control
.lock()
.expect("control state lock poisoned")
.waiting_for_steer
}
/// Current queue length. Production callers should generally prefer
/// `queue_is_empty` or `enqueue_bounded`'s atomic eviction; this is
/// kept for tests and diagnostics.
#[must_use]
pub fn queue_len(&self) -> usize {
self.control
.lock()
.expect("control state lock poisoned")
.queue
.len()
}
}
pub struct Session {
id: String,
config: SessionOptions,
history: History,
event_emitter: Emitter,
state: SessionState,
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
steering_queue: Arc<Mutex<VecDeque<String>>>,
followup_queue: Arc<Mutex<VecDeque<String>>>,
cancel_token: CancellationToken,
interrupt_reason: Arc<Mutex<Option<InterruptReason>>>,
memory: Vec<String>,
env_context: EnvContext,
skills: Vec<Skill>,
system_prompt: String,
file_tracker: FileTracker,
tool_env: Option<HashMap<String, String>>,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
id: String,
config: SessionOptions,
history: History,
event_emitter: Emitter,
state: SessionState,
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
control_state: Arc<Mutex<ControlState>>,
control_notify: Arc<Notify>,
followup_queue: Arc<Mutex<VecDeque<String>>>,
cancel_token: CancellationToken,
round_token: Arc<RwLock<CancellationToken>>,
interrupt_reason: Arc<Mutex<Option<InterruptReason>>>,
memory: Vec<String>,
env_context: EnvContext,
skills: Vec<Skill>,
system_prompt: String,
file_tracker: FileTracker,
tool_env: Option<HashMap<String, String>>,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
completion_coordinator: Option<Arc<dyn CompletionCoordinator>>,
}
impl Session {
@ -78,9 +271,11 @@ impl Session {
llm_client,
provider_profile,
sandbox,
steering_queue: Arc::new(Mutex::new(VecDeque::new())),
control_state: Arc::new(Mutex::new(ControlState::default())),
control_notify: Arc::new(Notify::new()),
followup_queue: Arc::new(Mutex::new(VecDeque::new())),
cancel_token: CancellationToken::new(),
round_token: Arc::new(RwLock::new(CancellationToken::new())),
interrupt_reason: Arc::new(Mutex::new(None)),
memory: Vec::new(),
env_context: EnvContext::default(),
@ -89,6 +284,7 @@ impl Session {
file_tracker: FileTracker::default(),
tool_env: None,
subagent_manager,
completion_coordinator: None,
}
}
@ -128,6 +324,16 @@ impl Session {
&self.id
}
#[must_use]
pub fn provider(&self) -> Provider {
self.provider_profile.provider()
}
#[must_use]
pub fn model(&self) -> &str {
self.provider_profile.model()
}
/// Initialize session by discovering project docs and capturing environment
/// context. Call before `process_input`.
///
@ -522,11 +728,40 @@ impl Session {
self.event_emitter.subscribe()
}
/// Push a steer onto the queue (no actor — internal callers like
/// loop-detection use this).
pub fn steer(&self, message: String) {
self.steering_queue
.lock()
.expect("steering queue lock poisoned")
.push_back(message);
self.control_handle().steer(message, None);
}
/// Cancel the current round and wait for later steering before starting
/// another LLM round.
pub fn control_interrupt(&self, actor: Option<Principal>) {
self.control_handle().interrupt(actor);
}
/// Cancel the current round and deliver the message as the next steer.
pub fn interrupt_then_steer(&self, message: String, actor: Option<Principal>) {
self.control_handle().interrupt_then_steer(message, actor);
}
/// Cheap, cloneable handle that lets external coordinators deliver
/// steers and trigger interrupts without owning the `Session` itself.
#[must_use]
pub fn control_handle(&self) -> SessionControlHandle {
SessionControlHandle {
control: self.control_state.clone(),
round_token: self.round_token.clone(),
notify: self.control_notify.clone(),
}
}
/// Install a coordinator that decides whether `process_input` should
/// keep iterating after a no-tool turn. Used by the workflow layer to
/// race-safely include any steers that arrived during the final
/// response.
pub fn set_completion_coordinator(&mut self, coordinator: Arc<dyn CompletionCoordinator>) {
self.completion_coordinator = Some(coordinator);
}
pub fn follow_up(&self, message: String) {
@ -601,11 +836,6 @@ impl Session {
self.followup_queue.clone()
}
#[must_use]
pub fn steering_queue_handle(&self) -> Arc<Mutex<VecDeque<String>>> {
self.steering_queue.clone()
}
#[must_use]
pub fn cancel_token(&self) -> CancellationToken {
self.cancel_token.clone()
@ -683,8 +913,10 @@ impl Session {
self.state = to;
}
pub fn close(&mut self) {
pub fn close(&mut self) -> bool {
let was_open = self.state != SessionState::Closed;
self.transition(SessionState::Closed);
was_open
}
pub fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffort>) {
@ -799,12 +1031,40 @@ impl Session {
text: expanded_input.clone(),
});
// Drain steering queue before first LLM call
self.drain_steering();
let mut round_count: usize = 0;
loop {
// Top-of-loop: if the previous round's interrupt token fired,
// swap in a fresh one before draining and rebuilding state.
// (Terminal cancel via `cancel_token` is handled by the explicit
// check below and by `interrupted_error()`.)
{
let needs_refresh = self
.round_token
.read()
.expect("round token lock poisoned")
.is_cancelled();
if needs_refresh {
*self.round_token.write().expect("round token lock poisoned") =
CancellationToken::new();
}
}
// Terminal cancellation wins even when a control interrupt has
// parked the session waiting for steering.
if self.cancel_token.is_cancelled() {
self.close();
return Err(self.interrupted_error());
}
// Drain pending steering messages at the top of every iteration
// so steering pushed mid-round is delivered as the first turn of
// the next round. A pure interrupt with no queued steer parks the
// session here until a later steer arrives.
self.drain_steering();
self.wait_for_steer_if_needed().await?;
self.drain_steering();
// Check max_tool_rounds_per_input
if self.config.max_tool_rounds_per_input > 0
&& round_count >= self.config.max_tool_rounds_per_input
@ -825,11 +1085,12 @@ impl Session {
break;
}
// Check cancellation
if self.cancel_token.is_cancelled() {
self.close();
return Err(self.interrupted_error());
}
// Snapshot the per-round token; it stays stable for this iteration.
let round_token = self
.round_token
.read()
.expect("round token lock poisoned")
.clone();
// Pre-turn compaction: trim context before building the request
self.compact_if_needed().await;
@ -860,21 +1121,54 @@ impl Session {
..Default::default()
};
let client = self.llm_client.clone();
let mut event_stream = self
.open_stream_with_retry(&client, &request, &retry_policy)
.await?;
let cancel_token_for_select = self.cancel_token.clone();
let stream_outcome: Option<Result<StreamEventStream, Error>> = tokio::select! {
biased;
() = round_token.cancelled() => None,
() = cancel_token_for_select.cancelled() => None,
stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream),
};
let mut event_stream = if let Some(stream) = stream_outcome {
stream?
} else {
if self.cancel_token.is_cancelled() {
self.close();
return Err(self.interrupted_error());
}
// Round-only cancel before stream opened — re-iterate to
// pick up the steer.
continue;
};
// Consume the stream, retrying up to 3 times if the provider
// closes the stream without sending a Finish event. If visible
// output was already emitted, clear it before replaying the turn.
let mut response = None;
// Set true if a steer-interrupt cancelled the round mid-stream so
// we can clear partial output and `continue` after the loop.
let mut steer_interrupted = false;
let mut emitted_anything = false;
for stream_attempt in 0..=STREAM_CONSUME_RETRIES {
'streamattempts: for stream_attempt in 0..=STREAM_CONSUME_RETRIES {
let mut accumulator = StreamAccumulator::new();
let mut emitted_text = String::new();
let mut emitted_reasoning = String::new();
while let Some(event_result) = event_stream.next().await {
loop {
let chunk = tokio::select! {
biased;
() = round_token.cancelled() => None,
() = self.cancel_token.cancelled() => None,
next = event_stream.next() => Some(next),
};
let Some(event_opt) = chunk else {
// One of the cancellation tokens fired.
break;
};
let Some(event_result) = event_opt else {
// Stream ended normally.
break;
};
match event_result {
Ok(event) => {
match &event {
@ -904,21 +1198,28 @@ impl Session {
return Err(self.emit_llm_error(err));
}
}
// Check cancellation between chunks
if self.cancel_token.is_cancelled() {
break;
}
}
// If interrupted during streaming, drop the stream to cancel the HTTP
// connection, then close the session before returning.
// Track whether anything was rendered this attempt.
if !emitted_text.is_empty() || !emitted_reasoning.is_empty() {
emitted_anything = true;
}
// If terminal cancel fired, drop the stream and bail out.
if self.cancel_token.is_cancelled() {
drop(event_stream);
self.close();
return Err(self.interrupted_error());
}
// If only the round token fired (steer interrupt), drop the
// stream now; we'll clear partial output and continue below.
if round_token.is_cancelled() {
drop(event_stream);
steer_interrupted = true;
break 'streamattempts;
}
if let Some(resp) = accumulator.response().cloned() {
response = Some(resp);
break;
@ -940,12 +1241,37 @@ impl Session {
},
);
}
event_stream = self
.open_stream_with_retry(&client, &request, &retry_policy)
.await?;
let cancel_token_for_select = self.cancel_token.clone();
let retry_outcome: Option<Result<StreamEventStream, Error>> = tokio::select! {
biased;
() = round_token.cancelled() => None,
() = cancel_token_for_select.cancelled() => None,
stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream),
};
event_stream = if let Some(stream) = retry_outcome {
stream?
} else {
steer_interrupted =
round_token.is_cancelled() && !self.cancel_token.is_cancelled();
break 'streamattempts;
};
}
}
// Mid-LLM steer interrupt: drop the unrecorded turn, clear any
// partial visible output, and re-iterate. The next turn's
// top-of-loop drain delivers the steer as the next user message.
if steer_interrupted {
if emitted_anything {
self.event_emitter
.emit(self.id.clone(), AgentEvent::AssistantOutputReplace {
text: String::new(),
reasoning: None,
});
}
continue;
}
let Some(response) = response else {
return Err(self.emit_llm_error(LlmError::Stream {
message: "Stream ended without a Finish event (after retries)".into(),
@ -986,13 +1312,38 @@ impl Session {
// Post-response compaction: trim context after appending assistant turn
self.compact_if_needed().await;
// If no tool calls, natural completion
// If no tool calls, natural completion. Consult the optional
// completion coordinator: it can return `true` to force one more
// iteration when a steer arrived during the final response.
if tool_calls.is_empty() {
let should_continue = self
.completion_coordinator
.as_ref()
.is_some_and(|c| c.on_natural_completion());
if should_continue {
continue;
}
break;
}
round_count += 1;
// Build a composite cancellation token covering both terminal
// cancel and round (steer) interrupt. Tools observe it
// cooperatively — they synthesize "Cancelled" results rather
// than being dropped mid-flight, which preserves the
// tool_use ↔ tool_result invariant.
let composite_token = CancellationToken::new();
let composite_for_cancel = composite_token.clone();
let cancel_token_clone = self.cancel_token.clone();
let round_token_clone = round_token.clone();
let composite_watcher = tokio::spawn(async move {
tokio::select! {
() = cancel_token_clone.cancelled() => composite_for_cancel.cancel(),
() = round_token_clone.cancelled() => composite_for_cancel.cancel(),
}
});
// Execute tool calls (parallel or sequential based on provider)
self.transition(SessionState::Executing);
let results = execute_tool_calls(
@ -1001,36 +1352,39 @@ impl Session {
self.provider_profile.tool_registry(),
self.sandbox.clone(),
self.config.tool_hooks.as_ref(),
&self.cancel_token,
&composite_token,
&self.config,
&self.event_emitter,
&self.id,
self.tool_env.as_ref(),
)
.await;
composite_watcher.abort();
// Track file operations from tool calls
self.file_tracker
.record_from_tool_calls(&tool_calls, &results);
// Check cancellation after tool execution
if self.cancel_token.is_cancelled() {
self.history.push(Turn::ToolResults {
results,
timestamp: SystemTime::now(),
});
self.close();
return Err(self.interrupted_error());
}
// Record tool results turn
// Always append tool_results so the tool_use ↔ tool_result
// invariant holds, regardless of which token fired.
self.history.push(Turn::ToolResults {
results,
timestamp: SystemTime::now(),
});
// Drain steering after tool execution
self.drain_steering();
// Terminal cancel takes precedence: close and return.
if self.cancel_token.is_cancelled() {
self.close();
return Err(self.interrupted_error());
}
// Round-only cancel (steer interrupt mid-tool): re-iterate;
// the next top-of-loop drain delivers the steer.
if round_token.is_cancelled() {
self.transition(SessionState::Thinking);
continue;
}
self.transition(SessionState::Thinking);
// Loop detection
@ -1079,20 +1433,48 @@ impl Session {
}
fn drain_steering(&mut self) {
let messages: Vec<String> = self
.steering_queue
.lock()
.expect("steering queue lock poisoned")
.drain(..)
.collect();
for msg in messages {
let text = msg.clone();
let messages: Vec<SteeringItem> = {
let mut control = self
.control_state
.lock()
.expect("control state lock poisoned");
control.queue.drain(..).collect()
};
for (text, actor) in messages {
self.history.push(Turn::Steering {
content: msg,
content: text.clone(),
timestamp: SystemTime::now(),
});
self.event_emitter
.emit(self.id.clone(), AgentEvent::SteeringInjected { text });
.emit(self.id.clone(), AgentEvent::SteeringInjected {
text,
actor,
});
}
}
async fn wait_for_steer_if_needed(&mut self) -> Result<(), Error> {
loop {
let notified = self.control_notify.notified();
let should_wait = {
let control = self
.control_state
.lock()
.expect("control state lock poisoned");
control.waiting_for_steer && control.queue.is_empty()
};
if !should_wait {
return Ok(());
}
tokio::select! {
biased;
() = self.cancel_token.cancelled() => {
self.close();
return Err(self.interrupted_error());
}
() = notified => {}
}
}
}
@ -1161,6 +1543,7 @@ async fn kill_mcp_pid(sandbox: &dyn Sandbox, pid: &str) {
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
@ -1168,6 +1551,7 @@ mod tests {
ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, ToolDefinition,
};
use futures::stream;
use tokio::time::{sleep, timeout};
use super::*;
use crate::config::ToolApprovalAdapter;
@ -1392,6 +1776,126 @@ mod tests {
assert!(matches!(&turns[2], Turn::Assistant { .. }));
}
#[tokio::test]
async fn steer_event_carries_text() {
let mut session = make_session(vec![text_response("OK")]).await;
let mut rx = session.subscribe();
session.steer("hi there".to_string());
session.process_input("Do something").await.unwrap();
let mut found_text = None;
while let Ok(ev) = rx.try_recv() {
if let AgentEvent::SteeringInjected { text, .. } = ev.event {
found_text = Some(text);
break;
}
}
assert_eq!(found_text.as_deref(), Some("hi there"));
}
#[tokio::test]
async fn pure_interrupt_enters_waiting_for_steer_without_queueing_text() {
let handle = SessionControlHandle::new();
handle.interrupt(None);
handle.interrupt(None);
assert!(handle.is_waiting_for_steer());
assert_eq!(handle.queue_len(), 0);
assert!(handle.has_pending_control_work());
}
#[tokio::test]
async fn pure_interrupt_waits_until_later_steer() {
let mut session = make_session(vec![text_response("OK")]).await;
let handle = session.control_handle();
handle.interrupt(None);
let wake_handle = handle.clone();
tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
wake_handle.steer("resume now".to_string(), None);
});
timeout(Duration::from_secs(1), session.process_input("start"))
.await
.expect("session should wake when steering arrives")
.unwrap();
let turns = session.history().turns();
assert!(matches!(&turns[1], Turn::Steering { content, .. } if content == "resume now"));
assert!(!handle.is_waiting_for_steer());
}
#[tokio::test]
async fn interrupt_then_steer_injects_steering_text() {
let mut session = make_session(vec![text_response("OK")]).await;
let mut rx = session.subscribe();
let handle = session.control_handle();
handle.interrupt_then_steer("stop now".to_string(), None);
session.process_input("start").await.unwrap();
let mut found_text = None;
while let Ok(ev) = rx.try_recv() {
if let AgentEvent::SteeringInjected { text, .. } = ev.event {
found_text = Some(text);
break;
}
}
assert_eq!(found_text.as_deref(), Some("stop now"));
}
#[tokio::test]
async fn append_during_final_response_triggers_extra_round_when_coordinator_returns_true() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct OnceCoordinator {
calls: AtomicUsize,
handle: SessionControlHandle,
}
impl CompletionCoordinator for OnceCoordinator {
fn on_natural_completion(&self) -> bool {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
// Simulate a steer that arrived during the first
// completion: enqueue and report "keep going".
self.handle
.steer("after-completion steer".to_string(), None);
true
} else {
false
}
}
}
// First scripted response is a no-tool natural completion; second
// also natural completion. The completion coordinator forces the
// loop to iterate once more — that iteration must drain the queued
// steer and produce a second Assistant turn.
let responses = vec![
text_response("First reply"),
text_response("Second reply, after steer"),
];
let mut session = make_session(responses).await;
let handle = session.control_handle();
session.set_completion_coordinator(Arc::new(OnceCoordinator {
calls: AtomicUsize::new(0),
handle,
}));
session.process_input("hi").await.unwrap();
let turns = session.history().turns();
// User + Assistant + Steering + Assistant = 4
assert_eq!(turns.len(), 4);
assert!(matches!(&turns[0], Turn::User { .. }));
assert!(matches!(&turns[1], Turn::Assistant { content, .. } if content == "First reply"));
assert!(matches!(&turns[2], Turn::Steering { content, .. }
if content == "after-completion steer"));
assert!(matches!(&turns[3], Turn::Assistant { content, .. }
if content == "Second reply, after steer"));
}
#[tokio::test]
async fn follow_up_triggers_new_cycle() {
let responses = vec![
@ -1713,6 +2217,24 @@ mod tests {
assert!(matches!(result.unwrap_err(), Error::SessionClosed));
}
#[tokio::test]
async fn close_reports_whether_it_transitioned_to_closed() {
let mut session = make_session(vec![]).await;
let mut rx = session.subscribe();
assert!(session.close());
assert!(!session.close());
let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(
events
.iter()
.filter(|event| matches!(event.event, AgentEvent::SessionEnded))
.count(),
1
);
}
#[tokio::test]
async fn closed_session_does_not_emit_session_start() {
let mut session = make_session(vec![]).await;

View file

@ -157,7 +157,12 @@ pub enum AgentEvent {
skill_name: String,
},
SteeringInjected {
text: String,
text: String,
/// Principal that authored the steer. Lifted to top-level
/// `RunEvent.actor` by the workflow event-conversion layer; never
/// serialized into event props.
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<fabro_types::Principal>,
},
CompactionStarted {
estimated_tokens: usize,
@ -309,7 +314,7 @@ impl AgentEvent {
Self::SkillExpanded { skill_name } => {
debug!(session_id, skill = skill_name.as_str(), "Skill expanded");
}
Self::SteeringInjected { text } => {
Self::SteeringInjected { text, .. } => {
debug!(session_id, text_len = text.len(), "Steering injected");
}
Self::CompactionStarted {

View file

@ -591,8 +591,8 @@ async fn scenario_steering_mid_task(session: &mut Session, dir: &Path) {
// Setup: create a file the LLM will read (triggering a tool call)
std::fs::write(dir.join("task.txt"), "read me first").expect("write task.txt");
// Grab handles before process_input borrows &mut self
let steering_queue = session.steering_queue_handle();
// Grab handle before process_input borrows &mut self
let control = session.control_handle();
let mut rx = session.subscribe();
// Spawn a task that waits for the first tool call, then injects steering
@ -602,12 +602,10 @@ async fn scenario_steering_mid_task(session: &mut Session, dir: &Path) {
event.event,
fabro_agent::AgentEvent::ToolCallCompleted { .. }
) {
steering_queue
.lock()
.expect("steering queue lock")
.push_back(
"Stop what you are doing. Create a file called steered.txt containing 'steered' and do nothing else.".to_string(),
);
control.steer(
"Stop what you are doing. Create a file called steered.txt containing 'steered' and do nothing else.".to_string(),
None,
);
break;
}
}

View file

@ -48,6 +48,36 @@ fn run_event_round_trips_run_created_with_web_url() {
assert_run_event_round_trip(value);
}
#[test]
fn run_event_round_trips_run_interrupt() {
let value = json!({
"id": "evt_run_interrupt",
"ts": "2026-04-29T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": "run.interrupt",
"actor": { "kind": "system", "system_kind": "engine" },
"properties": {}
});
assert_run_event_round_trip(value);
}
#[test]
fn run_event_round_trips_run_steer() {
let value = json!({
"id": "evt_run_steer",
"ts": "2026-04-29T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": "run.steer",
"actor": { "kind": "system", "system_kind": "engine" },
"properties": {
"text": "try another approach"
}
});
assert_run_event_round_trip(value);
}
#[test]
fn run_event_round_trips_stage_started() {
let value = json!({

View file

@ -670,6 +670,27 @@ pub(crate) struct WaitArgs {
pub(crate) interval: u64,
}
#[derive(Args)]
pub(crate) struct SteerArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
/// Run ID prefix to steer
pub(crate) run: String,
/// Steer message text (omit when --text-stdin is used)
pub(crate) text: Option<String>,
/// Read steer text from stdin instead of a positional arg
#[arg(long, conflicts_with = "text")]
pub(crate) text_stdin: bool,
/// Cancel the in-flight LLM stream / tool calls and deliver the message
/// as the next user turn (default: append to the steering queue).
#[arg(long)]
pub(crate) interrupt: bool,
}
#[derive(Args)]
pub(crate) struct WorkflowListArgs;
@ -944,6 +965,8 @@ pub(crate) enum RunCommands {
Fork(ForkArgs),
/// Block until a workflow run completes
Wait(WaitArgs),
/// Steer a running agent mid-execution
Steer(SteerArgs),
}
impl RunCommands {
@ -958,6 +981,7 @@ impl RunCommands {
Self::Logs(_) => "logs",
Self::Resume(_) => "resume",
Self::Rewind(_) => "rewind",
Self::Steer(_) => "steer",
Self::Fork(_) => "fork",
Self::Wait(_) => "wait",
}

View file

@ -25,6 +25,7 @@ pub(crate) mod run_progress;
pub(crate) mod runner;
pub(crate) mod ssh;
pub(crate) mod start;
pub(crate) mod steer;
pub(crate) mod wait;
pub(crate) async fn dispatch(
@ -123,5 +124,6 @@ pub(crate) async fn dispatch(
let styles = Styles::detect_stderr();
wait::run(&args, &styles, base_ctx).await
}
RunCommands::Steer(args) => steer::run(args, base_ctx).await,
}
}

View file

@ -86,7 +86,13 @@ pub(crate) async fn execute(
)));
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
spawn_worker_control_stream(Arc::clone(&interviewer), cancel_token.clone())?;
let emitter = Arc::new(Emitter::new(run_id));
let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(Arc::clone(&emitter)));
spawn_worker_control_stream(
Arc::clone(&interviewer),
cancel_token.clone(),
Arc::clone(&steering_hub),
)?;
let run_control = RunControlState::new();
install_signal_handlers(Arc::clone(&run_control), cancel_token.clone())?;
let vault = load_worker_vault(storage_dir.as_deref())?;
@ -100,8 +106,9 @@ pub(crate) async fn execute(
let services = StartServices {
run_id,
cancel_token: cancel_token.clone(),
emitter: Arc::new(Emitter::new(run_id)),
emitter,
interviewer,
steering_hub,
run_store: run_store.clone(),
event_sink: RunEventSink::map(
stamp_system_worker,
@ -167,11 +174,13 @@ enum WorkerControlStreamEvent {
fn spawn_worker_control_stream(
interviewer: Arc<ControlInterviewer>,
cancel_token: CancellationToken,
steering_hub: Arc<fabro_workflow::SteeringHub>,
) -> Result<()> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
tokio::spawn(handle_worker_control_stream_events(
interviewer,
cancel_token,
steering_hub,
event_rx,
));
std::thread::Builder::new()
@ -210,12 +219,13 @@ fn read_worker_control_stream_blocking<R>(
async fn handle_worker_control_stream_events(
interviewer: Arc<ControlInterviewer>,
cancel_token: CancellationToken,
steering_hub: Arc<fabro_workflow::SteeringHub>,
mut event_rx: mpsc::UnboundedReceiver<WorkerControlStreamEvent>,
) {
while let Some(event) = event_rx.recv().await {
match event {
WorkerControlStreamEvent::Line(line) => {
apply_worker_control_line(&interviewer, &cancel_token, &line).await;
apply_worker_control_line(&interviewer, &cancel_token, &steering_hub, &line).await;
}
WorkerControlStreamEvent::Eof => {
interviewer.interrupt_all().await;
@ -230,6 +240,7 @@ async fn handle_worker_control_stream_events(
async fn apply_worker_control_line(
interviewer: &ControlInterviewer,
cancel_token: &CancellationToken,
steering_hub: &fabro_workflow::SteeringHub,
line: &str,
) {
if line.trim().is_empty() {
@ -250,6 +261,15 @@ async fn apply_worker_control_line(
cancel_token.cancel();
interviewer.interrupt_all().await;
}
WorkerControlMessage::Steer { text, actor } => {
steering_hub.deliver_steer(text, Some(actor));
}
WorkerControlMessage::Interrupt { actor } => {
steering_hub.interrupt(Some(&actor));
}
WorkerControlMessage::InterruptThenSteer { text, actor } => {
steering_hub.interrupt_then_steer(&text, Some(&actor));
}
}
}
@ -649,6 +669,11 @@ mod tests {
};
use crate::args::RunWorkerMode;
fn test_steering_hub() -> Arc<fabro_workflow::SteeringHub> {
let emitter = Arc::new(fabro_workflow::event::Emitter::new(fixtures::RUN_1));
Arc::new(fabro_workflow::SteeringHub::new(emitter))
}
#[test]
fn clone_sandbox_credentials_are_required_for_clone_based_providers() {
assert!(super::clone_sandbox_requires_github_credentials("docker"));
@ -846,9 +871,11 @@ mod tests {
let ask_interviewer = Arc::clone(&interviewer);
let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await });
let hub = test_steering_hub();
apply_worker_control_line(
&interviewer,
&cancel_token,
&hub,
r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"},"actor":{"kind":"system","system_kind":"engine"}}"#,
)
.await;
@ -868,9 +895,11 @@ mod tests {
let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await });
tokio::task::yield_now().await;
let hub = test_steering_hub();
apply_worker_control_line(
&interviewer,
&cancel_token,
&hub,
r#"{"v":1,"type":"run.cancel"}"#,
)
.await;
@ -920,9 +949,11 @@ mod tests {
event_tx.send(WorkerControlStreamEvent::Eof).unwrap();
drop(event_tx);
let hub = test_steering_hub();
handle_worker_control_stream_events(
Arc::clone(&interviewer),
cancel_token.clone(),
hub,
event_rx,
)
.await;

View file

@ -0,0 +1,32 @@
use anyhow::{Result, bail};
use tokio::io::{AsyncReadExt as _, stdin};
use tracing::info;
use crate::args::SteerArgs;
use crate::command_context::CommandContext;
pub(crate) async fn run(args: SteerArgs, base_ctx: &CommandContext) -> Result<()> {
let ctx = base_ctx.with_target(&args.server)?;
let client = ctx.server().await?;
let run_id = client.resolve_run(&args.run).await?.run_id;
let text = match (args.text_stdin, args.text.clone()) {
(true, _) => {
let mut buf = String::new();
stdin().read_to_string(&mut buf).await?;
buf
}
(false, Some(text)) => text,
(false, None) => {
bail!("missing steer text — pass it as a positional argument or use --text-stdin")
}
};
let text = text.trim().to_string();
if text.is_empty() {
bail!("steer text must not be empty");
}
info!(run_id = %run_id, interrupt = args.interrupt, "Sending steer");
client.steer_run(&run_id, text, args.interrupt).await?;
Ok(())
}

View file

@ -21,6 +21,7 @@ fn help() {
rewind Rewind a workflow run to an earlier checkpoint
fork Fork a workflow run from an earlier checkpoint into a new run
wait Block until a workflow run completes
steer Steer a running agent mid-execution
preflight Validate run configuration without executing
validate Validate a workflow
graph Render a workflow graph as SVG

View file

@ -805,6 +805,35 @@ impl Client {
Ok(())
}
pub async fn interrupt_run(&self, run_id: &RunId) -> Result<()> {
self.send_api(|client| async move {
client.interrupt_run().id(run_id.to_string()).send().await
})
.await?;
Ok(())
}
pub async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> Result<()> {
let body: types::SteerRunRequest = types::SteerRunRequest::builder()
.text(text)
.interrupt(interrupt)
.try_into()
.map_err(|e| anyhow!("failed to build SteerRunRequest: {e}"))?;
self.send_api(|client| {
let body = body.clone();
async move {
client
.steer_run()
.id(run_id.to_string())
.body(body)
.send()
.await
}
})
.await?;
Ok(())
}
pub async fn archive_run(&self, run_id: &RunId) -> Result<()> {
self.send_api(
|client| async move { client.archive_run().id(run_id.to_string()).send().await },

View file

@ -32,6 +32,36 @@ impl WorkerControlEnvelope {
message: WorkerControlMessage::RunCancel,
}
}
#[must_use]
pub fn steer(text: impl Into<String>, actor: Principal) -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::Steer {
text: text.into(),
actor,
},
}
}
#[must_use]
pub fn interrupt(actor: Principal) -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::Interrupt { actor },
}
}
#[must_use]
pub fn interrupt_then_steer(text: impl Into<String>, actor: Principal) -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::InterruptThenSteer {
text: text.into(),
actor,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -45,6 +75,12 @@ pub enum WorkerControlMessage {
},
#[serde(rename = "run.cancel")]
RunCancel,
#[serde(rename = "run.steer")]
Steer { text: String, actor: Principal },
#[serde(rename = "run.interrupt")]
Interrupt { actor: Principal },
#[serde(rename = "run.interrupt_then_steer")]
InterruptThenSteer { text: String, actor: Principal },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -129,4 +165,49 @@ mod tests {
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn steer_append_round_trips_through_json() {
let envelope = WorkerControlEnvelope::steer("try again", fabro_types::Principal::System {
system_kind: fabro_types::SystemActorKind::Engine,
});
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(
json,
r#"{"v":1,"type":"run.steer","text":"try again","actor":{"kind":"system","system_kind":"engine"}}"#
);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn interrupt_round_trips_through_json() {
let envelope = WorkerControlEnvelope::interrupt(fabro_types::Principal::System {
system_kind: fabro_types::SystemActorKind::Engine,
});
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(
json,
r#"{"v":1,"type":"run.interrupt","actor":{"kind":"system","system_kind":"engine"}}"#
);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn interrupt_then_steer_round_trips_through_json() {
let envelope = WorkerControlEnvelope::interrupt_then_steer(
"stop, do X instead",
fabro_types::Principal::System {
system_kind: fabro_types::SystemActorKind::Engine,
},
);
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(
json,
r#"{"v":1,"type":"run.interrupt_then_steer","text":"stop, do X instead","actor":{"kind":"system","system_kind":"engine"}}"#
);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
}

View file

@ -80,7 +80,7 @@ use fabro_types::settings::server::{
use fabro_types::settings::{InterpString, RunNamespace};
use fabro_types::{
EventBody, InterviewQuestionRecord, Principal, PullRequestRecord, QuestionType, RunBlobId,
RunControlAction, RunEvent, RunId, ServerSettings,
RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability,
};
use fabro_util::error::{SharedError, collect_causes, render_with_causes};
use fabro_util::version::FABRO_VERSION;
@ -196,6 +196,14 @@ struct ManagedRun {
// Populated when running:
answer_transport: Option<RunAnswerTransport>,
accepted_questions: HashSet<String>,
/// Stage IDs of currently steerable API-mode (SDK) agent sessions,
/// keyed to the session id that owns the active lease. Used by the
/// steerability predicate.
active_api_stages: HashMap<StageId, String>,
/// Stage IDs of currently running CLI-mode agent sessions, observed
/// from `agent.cli.started/completed` plus `stage.completed`/
/// `stage.failed` backstops.
active_cli_stages: HashSet<StageId>,
event_tx: Option<broadcast::Sender<RunEvent>>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<oneshot::Sender<()>>,
@ -245,7 +253,8 @@ enum RunAnswerTransport {
control_tx: mpsc::Sender<WorkerControlEnvelope>,
},
InProcess {
interviewer: Arc<ControlInterviewer>,
interviewer: Arc<ControlInterviewer>,
steering_hub: Arc<fabro_workflow::SteeringHub>,
},
}
@ -269,7 +278,7 @@ impl RunAnswerTransport {
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
}
Self::InProcess { interviewer } => interviewer
Self::InProcess { interviewer, .. } => interviewer
.submit(qid, submission)
.await
.map_err(|_| AnswerTransportError::Closed),
@ -285,12 +294,66 @@ impl RunAnswerTransport {
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
}
Self::InProcess { interviewer } => {
Self::InProcess { interviewer, .. } => {
interviewer.cancel_all().await;
Ok(())
}
}
}
/// Forward a steer to the worker (subprocess) or directly into the
/// in-process steering hub.
async fn steer(&self, text: String, actor: Principal) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
let message = WorkerControlEnvelope::steer(text, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
}
Self::InProcess { steering_hub, .. } => {
steering_hub.deliver_steer(text, Some(actor));
Ok(())
}
}
}
async fn interrupt(&self, actor: Principal) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
let message = WorkerControlEnvelope::interrupt(actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
}
Self::InProcess { steering_hub, .. } => {
steering_hub.interrupt(Some(&actor));
Ok(())
}
}
}
async fn interrupt_then_steer(
&self,
text: String,
actor: Principal,
) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
let message = WorkerControlEnvelope::interrupt_then_steer(text, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
}
Self::InProcess { steering_hub, .. } => {
steering_hub.interrupt_then_steer(&text, Some(&actor));
Ok(())
}
}
}
}
#[derive(Debug, Clone)]
@ -1787,6 +1850,8 @@ fn octet_stream_response(bytes: Bytes) -> Response {
fn clear_live_run_state(run: &mut ManagedRun) {
run.answer_transport = None;
run.accepted_questions.clear();
run.active_api_stages.clear();
run.active_cli_stages.clear();
run.event_tx = None;
run.cancel_tx = None;
run.cancel_token = None;
@ -2114,6 +2179,8 @@ fn managed_run(
enqueued_at: Instant::now(),
answer_transport: None,
accepted_questions: HashSet::new(),
active_api_stages: HashMap::new(),
active_cli_stages: HashSet::new(),
event_tx: None,
checkpoint: None,
cancel_tx: None,
@ -2209,12 +2276,16 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
reason: props.reason,
};
managed_run.error = None;
managed_run.active_api_stages.clear();
managed_run.active_cli_stages.clear();
}
EventBody::RunFailed(props) => {
managed_run.status = RunStatus::Failed {
reason: props.reason,
};
managed_run.error = Some(props.error.clone());
managed_run.active_api_stages.clear();
managed_run.active_cli_stages.clear();
}
EventBody::RunArchived(_) => {
if let Some(prior) = managed_run.status.terminal_status() {
@ -2226,6 +2297,54 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
managed_run.status = prior.into();
}
}
// Track API-mode steerable sessions. Activated/deactivated are
// leased by session id so stale deactivations cannot clear a newer
// binding for the same stage.
EventBody::AgentSessionActivated(props)
if props.capabilities.contains(&SessionCapability::Steer) =>
{
if let (Some(stage_id), Some(session_id)) =
(event.stage_id.as_ref(), event.session_id.as_ref())
{
managed_run
.active_api_stages
.insert(stage_id.clone(), session_id.clone());
}
}
EventBody::AgentSessionDeactivated(_) => {
if let (Some(stage_id), Some(session_id)) =
(event.stage_id.as_ref(), event.session_id.as_ref())
{
if managed_run
.active_api_stages
.get(stage_id)
.is_some_and(|current| current == session_id)
{
managed_run.active_api_stages.remove(stage_id);
}
}
}
// Track CLI-mode agent stages. CLI started/completed are coarser
// and sometimes fail to emit `completed` on error paths — the
// stage.completed/stage.failed handler below is the backstop.
EventBody::AgentCliStarted(_) => {
if let Some(stage_id) = event.stage_id.as_ref() {
managed_run.active_cli_stages.insert(stage_id.clone());
}
}
EventBody::AgentCliCompleted(_) => {
if let Some(stage_id) = &event.stage_id {
managed_run.active_cli_stages.remove(stage_id);
}
}
// Stage lifecycle backstop: cover both completion and failure
// paths so a failing CLI stage doesn't strand its entry.
EventBody::StageCompleted(_) | EventBody::StageFailed(_) => {
if let Some(stage_id) = &event.stage_id {
managed_run.active_api_stages.remove(stage_id);
managed_run.active_cli_stages.remove(stage_id);
}
}
_ => {}
}
}
@ -2639,6 +2758,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
.as_ref()
.map(|factory| Arc::new(factory(Arc::clone(&interview_runtime))));
let emitter = Arc::new(emitter);
let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(Arc::clone(&emitter)));
// Transition to Running, populate interviewer
let cancelled_during_setup = {
@ -2647,7 +2767,8 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
if managed_run.status == RunStatus::Starting {
managed_run.status = RunStatus::Running;
managed_run.answer_transport = Some(RunAnswerTransport::InProcess {
interviewer: Arc::clone(&interviewer),
interviewer: Arc::clone(&interviewer),
steering_hub: Arc::clone(&steering_hub),
});
false
} else {
@ -2766,6 +2887,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
cancel_token: cancel_token.clone(),
emitter: Arc::clone(&emitter),
interviewer: Arc::clone(&interview_runtime),
steering_hub: Arc::clone(&steering_hub),
run_store: run_store.clone().into(),
event_sink: workflow_event::RunEventSink::store(run_store.clone()),
artifact_sink: Some(ArtifactSink::Store(state.artifact_store.clone())),

View file

@ -16,6 +16,7 @@ mod pull_requests;
mod runs;
mod sandbox;
mod secrets;
mod steer;
pub(in crate::server) mod system;
pub(super) use system::{health, openapi_spec};
@ -113,7 +114,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
pub(super) fn real_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs/{id}/steer", post(not_implemented))
.route("/runs/{id}/stages/{stageId}/turns", get(not_implemented))
.route("/workflows", get(not_implemented))
.route("/workflows/{name}", get(not_implemented))
.route("/workflows/{name}/runs", get(not_implemented))
@ -136,6 +137,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(artifacts::routes())
.merge(sandbox::routes())
.merge(lifecycle::routes())
.merge(steer::routes())
.merge(graph::manifest_routes())
.merge(graph::run_routes())
.merge(models::routes())

View file

@ -0,0 +1,181 @@
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use fabro_api::types::SteerRunRequest;
use fabro_types::Principal;
use fabro_workflow::run_status::RunStatus;
use super::super::{AnswerTransportError, AppState, parse_run_id_path, reject_if_archived};
use crate::error::ApiError;
use crate::principal_middleware::RequiredUser;
pub(super) fn routes() -> axum::Router<Arc<AppState>> {
axum::Router::new()
.route("/runs/{id}/steer", post(steer_run))
.route("/runs/{id}/interrupt", post(interrupt_run))
}
enum RunControlRequest {
Steer { text: String },
Interrupt,
InterruptThenSteer { text: String },
}
impl RunControlRequest {
const fn requires_active_api_session(&self) -> bool {
matches!(self, Self::Interrupt | Self::InterruptThenSteer { .. })
}
}
async fn steer_run(
auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<SteerRunRequest>,
) -> Response {
// OpenAPI enforces minLength=1/maxLength=8192 already; only whitespace-only
// payloads can slip through.
let SteerRunRequest { text, interrupt } = req;
let text: String = text.into();
if text.trim().is_empty() {
return ApiError::bad_request("Steer text must not be empty.").into_response();
}
let control = if interrupt {
RunControlRequest::InterruptThenSteer { text }
} else {
RunControlRequest::Steer { text }
};
control_run(auth, state, id, control).await
}
async fn interrupt_run(
auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
control_run(auth, state, id, RunControlRequest::Interrupt).await
}
async fn control_run(
auth: RequiredUser,
state: Arc<AppState>,
id: String,
control: RunControlRequest,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
// Status + steerability gate. Take the answer_transport snapshot under
// the same lock so we can hand it off without further state races.
let answer_transport = {
let runs = state.runs.lock().expect("runs lock poisoned");
let Some(managed_run) = runs.get(&id) else {
return ApiError::not_found("Run not found.").into_response();
};
match managed_run.status {
RunStatus::Blocked { .. } => {
return ApiError::with_code(
StatusCode::CONFLICT,
"Run is blocked on a question; use the interview-answer endpoint instead.",
"use_answer_endpoint",
)
.into_response();
}
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Starting
| RunStatus::Paused { .. } => {
return ApiError::with_code(
StatusCode::CONFLICT,
"Run is not currently running.",
"run_not_steerable",
)
.into_response();
}
RunStatus::Failed { .. }
| RunStatus::Succeeded { .. }
| RunStatus::Removing
| RunStatus::Dead
| RunStatus::Archived { .. } => {
let code = if matches!(&control, RunControlRequest::Interrupt) {
"run_not_interruptible"
} else {
"run_not_steerable"
};
return ApiError::with_code(
StatusCode::CONFLICT,
"Run is no longer steerable.",
code,
)
.into_response();
}
RunStatus::Running => {}
}
// Steerability predicate. Best-effort, target-oriented:
// - If at least one API-mode session is active → forward.
// - Else if no agent stages are active at all → forward (worker hub buffers
// for the next session).
// - Else (active agents exist but all are CLI-mode) → 409.
if managed_run.active_api_stages.is_empty() && !managed_run.active_cli_stages.is_empty() {
return ApiError::with_code(
StatusCode::CONFLICT,
"All currently running agent stages are CLI-mode and cannot be steered.",
"cli_agent_not_steerable",
)
.into_response();
}
if managed_run.active_api_stages.is_empty() && control.requires_active_api_session() {
return ApiError::with_code(
StatusCode::CONFLICT,
"Run has no active API-mode agent session.",
"no_active_api_session",
)
.into_response();
}
managed_run.answer_transport.clone()
};
let Some(answer_transport) = answer_transport else {
return ApiError::with_code(
StatusCode::SERVICE_UNAVAILABLE,
"Run has no live worker control channel.",
"worker_control_unavailable",
)
.into_response();
};
let actor = Principal::User(auth.0);
let result = match control {
RunControlRequest::Steer { text } => answer_transport.steer(text, actor).await,
RunControlRequest::Interrupt => answer_transport.interrupt(actor).await,
RunControlRequest::InterruptThenSteer { text } => {
answer_transport.interrupt_then_steer(text, actor).await
}
};
match result {
Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(AnswerTransportError::Timeout) => ApiError::with_code(
StatusCode::SERVICE_UNAVAILABLE,
"Worker control channel timed out.",
"worker_control_unavailable",
)
.into_response(),
Err(AnswerTransportError::Closed) => ApiError::with_code(
StatusCode::SERVICE_UNAVAILABLE,
"Worker control channel is closed.",
"worker_control_unavailable",
)
.into_response(),
}
}

View file

@ -12,14 +12,16 @@ use chrono::{Duration as ChronoDuration, Utc};
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::ServerSettingsBuilder;
use fabro_config::bind::Bind;
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question};
use fabro_interview::{
AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlMessage,
};
use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest};
use fabro_model::Provider;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{
AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
InterviewQuestionRecord, Outcome, QuestionType, RunBlobId, RunId, RunSpec, SystemActorKind,
fixtures,
InterviewQuestionRecord, Outcome, QuestionType, RunBlobId, RunId, RunSpec, SuccessReason,
SystemActorKind, fixtures,
};
use fabro_util::check_report::CheckStatus;
use httpmock::Method::{GET, POST};
@ -1875,11 +1877,73 @@ async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() {
);
}
#[tokio::test]
async fn subprocess_answer_transport_steer_enqueues_plain_steer_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
transport
.steer("try again".to_string(), actor.clone())
.await
.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::steer("try again", actor))
);
}
#[tokio::test]
async fn subprocess_answer_transport_interrupt_enqueues_interrupt_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
transport.interrupt(actor.clone()).await.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::interrupt(actor))
);
}
#[tokio::test]
async fn subprocess_answer_transport_interrupt_then_steer_enqueues_single_combined_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
transport
.interrupt_then_steer("try again".to_string(), actor.clone())
.await
.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::interrupt_then_steer(
"try again",
actor
))
);
}
#[tokio::test]
async fn in_process_answer_transport_cancel_run_cancels_pending_interviews() {
let interviewer = Arc::new(ControlInterviewer::new());
let emitter = Arc::new(fabro_workflow::event::Emitter::new(
fabro_types::RunId::new(),
));
let steering_hub = Arc::new(fabro_workflow::SteeringHub::new(emitter));
let transport = RunAnswerTransport::InProcess {
interviewer: Arc::clone(&interviewer),
interviewer: Arc::clone(&interviewer),
steering_hub: Arc::clone(&steering_hub),
};
let mut question = Question::new("Approve?", QuestionType::YesNo);
question.id = "q-1".to_string();
@ -6249,6 +6313,293 @@ async fn cancel_nonexistent_run_returns_not_found() {
assert_status!(response, StatusCode::NOT_FOUND).await;
}
#[tokio::test]
async fn steer_nonexistent_run_returns_not_found() {
let app = test_app_with();
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{missing_run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again"}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::NOT_FOUND).await;
}
#[tokio::test]
async fn steer_empty_text_returns_bad_request() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = create_and_start_run(&app, MINIMAL_DOT)
.await
.parse::<RunId>()
.unwrap();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":" "}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
// 400 (whitespace-only text) or 409 (run not yet `running` when the
// handler checks status) are both acceptable; the only outcome we
// want to rule out is a successful enqueue.
let status = response.status();
assert!(
matches!(status, StatusCode::BAD_REQUEST | StatusCode::CONFLICT),
"expected 400 or 409, got {status}"
);
}
fn insert_running_control_run(
state: &Arc<AppState>,
run_id: RunId,
answer_transport: Option<RunAnswerTransport>,
) -> tempfile::TempDir {
let temp_dir = tempfile::tempdir().unwrap();
let mut run = managed_run(
String::new(),
RunStatus::Running,
chrono::Utc::now(),
temp_dir.path().join(run_id.to_string()),
RunExecutionMode::Start,
);
run.answer_transport = answer_transport;
state
.runs
.lock()
.expect("runs lock poisoned")
.insert(run_id, run);
temp_dir
}
#[tokio::test]
async fn steer_without_active_api_session_forwards_plain_steer_for_buffering() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again"}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
assert!(matches!(
envelope.message,
WorkerControlMessage::Steer { ref text, .. } if text == "try again"
));
}
#[tokio::test]
async fn steer_interrupt_without_active_api_session_returns_conflict() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, _control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again","interrupt":true}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = body_json(response.into_body()).await;
assert_eq!(body["errors"][0]["code"], "no_active_api_session");
}
#[tokio::test]
async fn interrupt_with_active_api_session_forwards_interrupt() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
.unwrap()
.active_api_stages
.insert(stage_id, "session-a".to_string());
}
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/interrupt")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
assert!(matches!(
envelope.message,
WorkerControlMessage::Interrupt {
actor: Principal::User(_),
}
));
}
#[tokio::test]
async fn steer_interrupt_with_active_api_session_forwards_combined_control_message() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
.unwrap()
.active_api_stages
.insert(stage_id, "session-a".to_string());
}
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again","interrupt":true}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
assert!(matches!(
envelope.message,
WorkerControlMessage::InterruptThenSteer { ref text, .. } if text == "try again"
));
}
#[tokio::test]
async fn interrupt_terminal_run_returns_run_not_interruptible() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let temp_dir = tempfile::tempdir().unwrap();
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.insert(
run_id,
managed_run(
String::new(),
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
chrono::Utc::now(),
temp_dir.path().join(run_id.to_string()),
RunExecutionMode::Start,
),
);
}
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/interrupt")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = body_json(response.into_body()).await;
assert_eq!(body["errors"][0]["code"], "run_not_interruptible");
}
#[test]
fn active_api_stage_projection_ignores_stale_deactivation() {
let state = test_app_state();
let run_id = fixtures::RUN_1;
let temp_dir = tempfile::tempdir().unwrap();
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.insert(
run_id,
managed_run(
String::new(),
RunStatus::Running,
chrono::Utc::now(),
temp_dir.path().join(run_id.to_string()),
RunExecutionMode::Start,
),
);
}
let stage_id = StageId::new("agent", 1);
let activated_a =
workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated {
node_id: "agent".to_string(),
visit: 1,
session_id: "session-a".to_string(),
thread_id: None,
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![SessionCapability::Steer],
});
update_live_run_from_event(&state, run_id, &activated_a);
let deactivated_a =
workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionDeactivated {
node_id: "agent".to_string(),
visit: 1,
session_id: "session-a".to_string(),
});
update_live_run_from_event(&state, run_id, &deactivated_a);
let activated_b =
workflow_event::to_run_event(&run_id, &workflow_event::Event::AgentSessionActivated {
node_id: "agent".to_string(),
visit: 1,
session_id: "session-b".to_string(),
thread_id: None,
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![SessionCapability::Steer],
});
update_live_run_from_event(&state, run_id, &activated_b);
update_live_run_from_event(&state, run_id, &deactivated_a);
let runs = state.runs.lock().expect("runs lock poisoned");
let run = runs.get(&run_id).unwrap();
assert_eq!(
run.active_api_stages.get(&stage_id).map(String::as_str),
Some("session-b")
);
}
#[tokio::test]
async fn get_graph_returns_svg() {
let state = test_app_state();

View file

@ -3,7 +3,7 @@ use std::str::FromStr;
use chrono::{DateTime, Utc};
use fabro_types::run_event::{
AgentCliStartedProps, AgentSessionStartedProps, CheckpointCompletedProps, RunCompletedProps,
AgentCliStartedProps, AgentSessionActivatedProps, CheckpointCompletedProps, RunCompletedProps,
RunFailedProps, StageCompletedProps, StagePromptProps,
};
use fabro_types::{
@ -351,12 +351,12 @@ impl RunProjectionReducer for RunProjection {
stage.usage.clone_from(&props.billing);
stage.state = Some(StageState::from(outcome));
}
EventBody::AgentSessionStarted(props) => {
EventBody::AgentSessionActivated(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
stage.provider_used = Some(provider_used_from_agent_session_started(props));
stage.provider_used = Some(provider_used_from_agent_session_activated(props));
}
EventBody::AgentCliStarted(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
@ -684,7 +684,7 @@ fn provider_used_from_prompt(props: &StagePromptProps) -> Option<Value> {
(!provider_used.is_empty()).then_some(Value::Object(provider_used))
}
fn provider_used_from_agent_session_started(props: &AgentSessionStartedProps) -> Value {
fn provider_used_from_agent_session_activated(props: &AgentSessionActivatedProps) -> Value {
let mut provider_used = serde_json::Map::new();
provider_used.insert("mode".to_string(), Value::String("agent".to_string()));
if let Some(provider) = props.provider.clone() {
@ -732,6 +732,7 @@ mod tests {
use fabro_types::run_event::run::RunFailedProps;
use fabro_types::run_event::{
AgentCliCancelledProps, AgentCliCompletedProps, AgentCliTimedOutProps,
AgentSessionActivatedProps, AgentSessionEndedProps, AgentSessionStartedProps,
CheckpointCompletedProps, InterviewCompletedProps, InterviewOption, InterviewStartedProps,
RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps,
StageRetryingProps, StageStartedProps,
@ -1024,6 +1025,65 @@ mod tests {
.unwrap();
}
#[test]
fn agent_session_activated_updates_stage_provider_used() {
let mut state = RunProjection::default();
let stage_id = StageId::new("code", 1);
start_stage(&mut state, &stage_id);
state
.apply_event(&test_stage_event(
4,
EventBody::AgentSessionActivated(AgentSessionActivatedProps {
thread_id: Some("thread-1".to_string()),
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![fabro_types::SessionCapability::Steer],
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(
stage.provider_used.as_ref().unwrap(),
&json!({
"mode": "agent",
"provider": "openai",
"model": "gpt-5.4"
})
);
}
#[test]
fn object_lifecycle_session_events_do_not_update_stage_provider_used() {
let mut state = RunProjection::default();
let stage_id = StageId::new("code", 1);
start_stage(&mut state, &stage_id);
state
.apply_event(&test_event(
4,
EventBody::AgentSessionStarted(AgentSessionStartedProps {
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
}),
None,
))
.unwrap();
state
.apply_event(&test_event(
5,
EventBody::AgentSessionEnded(AgentSessionEndedProps {}),
None,
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert!(stage.provider_used.is_none());
}
#[test]
fn agent_cli_completed_updates_stage_output_projection() {
let mut state = RunProjection::default();

View file

@ -72,7 +72,7 @@ pub use run::{
pub use run_blob_id::RunBlobId;
pub use run_event::{
EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase,
RunEvent, RunNoticeCode, RunNoticeLevel,
RunEvent, RunNoticeCode, RunNoticeLevel, SessionCapability,
};
pub use run_id::{RunId, fixtures};
pub use run_projection::{PendingInterviewRecord, RunProjection, StageProjection, first_event_seq};

View file

@ -9,11 +9,35 @@ pub struct AgentSessionStartedProps {
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub visit: u32,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionEndedProps {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionCapability {
Steer,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionEndedProps {
pub struct AgentSessionActivatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub capabilities: Vec<SessionCapability>,
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionDeactivatedProps {
pub visit: u32,
}
@ -85,6 +109,26 @@ pub struct AgentSteeringInjectedProps {
pub visit: u32,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AgentSteerBufferedProps {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentSteerDroppedReason {
QueueFull,
RunEnded,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSteerDroppedProps {
pub reason: AgentSteerDroppedReason,
pub count: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentCompactionStartedProps {
pub estimated_tokens: usize,

View file

@ -62,6 +62,10 @@ pub enum EventBody {
RunStarting(RunStatusTransitionProps),
#[serde(rename = "run.running")]
RunRunning(RunStatusTransitionProps),
#[serde(rename = "run.interrupt")]
RunInterrupt(RunInterruptProps),
#[serde(rename = "run.steer")]
RunSteer(RunSteerProps),
#[serde(rename = "run.blocked")]
RunBlocked(RunBlockedProps),
#[serde(rename = "run.unblocked")]
@ -148,6 +152,10 @@ pub enum EventBody {
PromptCompleted(PromptCompletedProps),
#[serde(rename = "agent.session.started")]
AgentSessionStarted(AgentSessionStartedProps),
#[serde(rename = "agent.session.activated")]
AgentSessionActivated(AgentSessionActivatedProps),
#[serde(rename = "agent.session.deactivated")]
AgentSessionDeactivated(AgentSessionDeactivatedProps),
#[serde(rename = "agent.session.ended")]
AgentSessionEnded(AgentSessionEndedProps),
#[serde(rename = "agent.processing.end")]
@ -170,6 +178,10 @@ pub enum EventBody {
AgentTurnLimitReached(AgentTurnLimitReachedProps),
#[serde(rename = "agent.steering.injected")]
AgentSteeringInjected(AgentSteeringInjectedProps),
#[serde(rename = "agent.steer.buffered")]
AgentSteerBuffered(AgentSteerBufferedProps),
#[serde(rename = "agent.steer.dropped")]
AgentSteerDropped(AgentSteerDroppedProps),
#[serde(rename = "agent.compaction.started")]
AgentCompactionStarted(AgentCompactionStartedProps),
#[serde(rename = "agent.compaction.completed")]
@ -342,6 +354,8 @@ impl EventBody {
Self::RunQueued(_) => "run.queued",
Self::RunStarting(_) => "run.starting",
Self::RunRunning(_) => "run.running",
Self::RunInterrupt(_) => "run.interrupt",
Self::RunSteer(_) => "run.steer",
Self::RunBlocked(_) => "run.blocked",
Self::RunUnblocked(_) => "run.unblocked",
Self::RunRemoving(_) => "run.removing",
@ -385,6 +399,8 @@ impl EventBody {
Self::StagePrompt(_) => "stage.prompt",
Self::PromptCompleted(_) => "prompt.completed",
Self::AgentSessionStarted(_) => "agent.session.started",
Self::AgentSessionActivated(_) => "agent.session.activated",
Self::AgentSessionDeactivated(_) => "agent.session.deactivated",
Self::AgentSessionEnded(_) => "agent.session.ended",
Self::AgentProcessingEnd(_) => "agent.processing.end",
Self::AgentInput(_) => "agent.input",
@ -396,6 +412,8 @@ impl EventBody {
Self::AgentLoopDetected(_) => "agent.loop.detected",
Self::AgentTurnLimitReached(_) => "agent.turn.limit",
Self::AgentSteeringInjected(_) => "agent.steering.injected",
Self::AgentSteerBuffered(_) => "agent.steer.buffered",
Self::AgentSteerDropped(_) => "agent.steer.dropped",
Self::AgentCompactionStarted(_) => "agent.compaction.started",
Self::AgentCompactionCompleted(_) => "agent.compaction.completed",
Self::AgentLlmRetry(_) => "agent.llm.retry",
@ -481,6 +499,8 @@ fn is_known_event_name(event: &str) -> bool {
| "run.queued"
| "run.starting"
| "run.running"
| "run.interrupt"
| "run.steer"
| "run.blocked"
| "run.unblocked"
| "run.removing"
@ -519,6 +539,8 @@ fn is_known_event_name(event: &str) -> bool {
| "stage.prompt"
| "prompt.completed"
| "agent.session.started"
| "agent.session.activated"
| "agent.session.deactivated"
| "agent.session.ended"
| "agent.processing.end"
| "agent.input"
@ -530,6 +552,8 @@ fn is_known_event_name(event: &str) -> bool {
| "agent.loop.detected"
| "agent.turn.limit"
| "agent.steering.injected"
| "agent.steer.buffered"
| "agent.steer.dropped"
| "agent.compaction.started"
| "agent.compaction.completed"
| "agent.llm.retry"
@ -905,6 +929,58 @@ mod tests {
assert_eq!(body.event_name(), "interview.interrupted");
}
#[test]
fn run_interrupt_round_trips_with_empty_properties_and_actor() {
let line = json!({
"id": "evt_interrupt",
"ts": "2026-04-04T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": "run.interrupt",
"actor": { "kind": "system", "system_kind": "engine" },
"properties": {}
});
let parsed = RunEvent::from_value(line.clone()).unwrap();
assert!(matches!(parsed.body, EventBody::RunInterrupt(_)));
assert_eq!(parsed.to_value().unwrap(), line);
}
#[test]
fn run_steer_round_trips_with_text_and_actor() {
let line = json!({
"id": "evt_steer",
"ts": "2026-04-04T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": "run.steer",
"actor": { "kind": "system", "system_kind": "engine" },
"properties": { "text": "try another approach" }
});
let parsed = RunEvent::from_value(line.clone()).unwrap();
assert!(matches!(
&parsed.body,
EventBody::RunSteer(props) if props.text == "try another approach"
));
assert_eq!(parsed.to_value().unwrap(), line);
}
#[test]
fn run_interrupt_then_steer_is_not_a_known_persisted_event() {
let line = json!({
"id": "evt_combined",
"ts": "2026-04-04T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.interrupt_then_steer",
"properties": { "text": "try another approach" }
});
let parsed = RunEvent::from_value(line).unwrap();
assert!(matches!(
parsed.body,
EventBody::Unknown { ref name, .. } if name == "run.interrupt_then_steer"
));
}
#[test]
fn run_submitted_round_trip_preserves_definition_blob() {
let line = json!({
@ -1030,6 +1106,35 @@ mod tests {
assert_eq!(serialized["actor"], value["actor"]);
}
#[test]
fn agent_session_ended_serializes_empty_properties() {
let event = RunEvent {
id: "evt_session_ended".to_string(),
ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: Some("ses_abc".to_string()),
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::AgentSessionEnded(AgentSessionEndedProps {}),
};
let serialized = event.to_value().unwrap();
assert_eq!(serialized["event"], "agent.session.ended");
assert_eq!(serialized["session_id"], "ses_abc");
assert_eq!(serialized["properties"], json!({}));
assert!(serialized.get("node_id").is_none());
assert!(serialized.get("stage_id").is_none());
}
#[test]
fn run_event_omits_absent_envelope_fields() {
let event = RunEvent {

View file

@ -69,6 +69,18 @@ pub struct RunStatusTransitionProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunStatusEffectProps {}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunInterruptProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSteerProps {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSubmittedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -100,6 +100,12 @@ fn event_body_from_event(event: &Event) -> EventBody {
Event::RunRunning => {
EventBody::RunRunning(fabro_types::RunStatusTransitionProps::default())
}
Event::RunInterrupt { .. } => {
EventBody::RunInterrupt(fabro_types::RunInterruptProps::default())
}
Event::RunSteer { text, .. } => {
EventBody::RunSteer(fabro_types::RunSteerProps { text: text.clone() })
}
Event::RunBlocked { blocked_reason } => {
EventBody::RunBlocked(fabro_types::RunBlockedProps {
blocked_reason: *blocked_reason,
@ -531,16 +537,6 @@ fn event_body_from_event(event: &Event) -> EventBody {
billing: billing.clone(),
}),
Event::Agent { visit, event, .. } => match event {
AgentEvent::SessionStarted { provider, model } => {
EventBody::AgentSessionStarted(fabro_types::AgentSessionStartedProps {
provider: provider.clone(),
model: model.clone(),
visit: *visit,
})
}
AgentEvent::SessionEnded => {
EventBody::AgentSessionEnded(fabro_types::AgentSessionEndedProps { visit: *visit })
}
AgentEvent::ProcessingEnd => {
EventBody::AgentProcessingEnd(fabro_types::AgentProcessingEndProps {
visit: *visit,
@ -607,7 +603,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
visit: *visit,
})
}
AgentEvent::SteeringInjected { text } => {
AgentEvent::SteeringInjected { text, .. } => {
EventBody::AgentSteeringInjected(fabro_types::AgentSteeringInjectedProps {
text: text.clone(),
visit: *visit,
@ -706,9 +702,11 @@ fn event_body_from_event(event: &Event) -> EventBody {
| AgentEvent::TextDelta { .. }
| AgentEvent::ReasoningDelta { .. }
| AgentEvent::ToolCallOutputDelta { .. }
| AgentEvent::SkillExpanded { .. } => {
panic!("streaming-noise agent event should not be converted to RunEvent")
}
| AgentEvent::SkillExpanded { .. }
| AgentEvent::SessionStarted { .. }
| AgentEvent::SessionEnded => panic!(
"agent event should not be converted through the stage-scoped Event::Agent wrapper"
),
},
Event::SubgraphStarted { start_node, .. } => {
EventBody::SubgraphStarted(fabro_types::SubgraphStartedProps {
@ -1008,6 +1006,43 @@ fn event_body_from_event(event: &Event) -> EventBody {
exit_code: *exit_code,
duration_ms: *duration_ms,
}),
Event::AgentSessionStarted {
provider, model, ..
} => EventBody::AgentSessionStarted(fabro_types::AgentSessionStartedProps {
provider: provider.clone(),
model: model.clone(),
}),
Event::AgentSessionActivated {
thread_id,
provider,
model,
capabilities,
visit,
..
} => EventBody::AgentSessionActivated(fabro_types::AgentSessionActivatedProps {
thread_id: thread_id.clone(),
provider: provider.clone(),
model: model.clone(),
capabilities: capabilities.clone(),
visit: *visit,
}),
Event::AgentSessionDeactivated { visit, .. } => {
EventBody::AgentSessionDeactivated(fabro_types::AgentSessionDeactivatedProps {
visit: *visit,
})
}
Event::AgentSessionEnded { .. } => {
EventBody::AgentSessionEnded(fabro_types::AgentSessionEndedProps {})
}
Event::AgentSteerBuffered { .. } => {
EventBody::AgentSteerBuffered(fabro_types::AgentSteerBufferedProps::default())
}
Event::AgentSteerDropped { reason, count, .. } => {
EventBody::AgentSteerDropped(fabro_types::AgentSteerDroppedProps {
reason: *reason,
count: *count,
})
}
Event::AgentCliCancelled {
stdout,
stderr,

View file

@ -68,6 +68,15 @@ pub enum Event {
RunQueued,
RunStarting,
RunRunning,
RunInterrupt {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunSteer {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunBlocked {
blocked_reason: BlockedReason,
},
@ -524,6 +533,59 @@ pub enum Event {
model: String,
command: String,
},
/// A top-level agent session object started its lifecycle.
AgentSessionStarted {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
/// A stage has a currently steerable API-mode session binding.
AgentSessionActivated {
node_id: String,
visit: u32,
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
capabilities: Vec<fabro_types::SessionCapability>,
},
/// A stage's steerable API-mode session binding ended.
AgentSessionDeactivated {
node_id: String,
visit: u32,
session_id: String,
},
/// A top-level agent session object ended its lifecycle.
AgentSessionEnded {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_session_id: Option<String>,
},
/// A steer arrived with no active session and was parked in the run-wide
/// pending buffer. The actor (steer author) is lifted to top-level.
AgentSteerBuffered {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
/// One or more buffered/queued steers were dropped because a cap was
/// reached or the run ended before they could be delivered.
AgentSteerDropped {
reason: fabro_types::AgentSteerDroppedReason,
count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
visit: Option<u32>,
},
AgentCliCompleted {
node_id: String,
stdout: String,
@ -651,6 +713,12 @@ impl Event {
Self::RunRunning => {
info!("Run running");
}
Self::RunInterrupt { .. } => {
info!("Run interrupt accepted");
}
Self::RunSteer { text, .. } => {
info!(text_len = text.len(), "Run steer accepted");
}
Self::RunBlocked { blocked_reason } => {
info!(?blocked_reason, "Run blocked");
}
@ -1260,6 +1328,38 @@ impl Event {
} => {
debug!(node_id, exit_code, duration_ms, "Agent CLI completed");
}
Self::AgentSessionStarted {
session_id,
provider,
model,
..
} => {
debug!(session_id, ?provider, ?model, "Agent session started");
}
Self::AgentSessionActivated {
node_id,
visit,
session_id,
..
} => {
debug!(node_id, visit, session_id, "Agent session activated");
}
Self::AgentSessionDeactivated {
node_id,
visit,
session_id,
} => {
debug!(node_id, visit, session_id, "Agent session deactivated");
}
Self::AgentSessionEnded { session_id, .. } => {
debug!(session_id, "Agent session ended");
}
Self::AgentSteerBuffered { .. } => {
debug!("Steer buffered (no active session)");
}
Self::AgentSteerDropped { reason, count, .. } => {
warn!(?reason, count, "Steer dropped");
}
Self::AgentCliCancelled {
node_id,
duration_ms,

View file

@ -11,6 +11,8 @@ pub fn event_name(event: &Event) -> &'static str {
Event::RunQueued => "run.queued",
Event::RunStarting => "run.starting",
Event::RunRunning => "run.running",
Event::RunInterrupt { .. } => "run.interrupt",
Event::RunSteer { .. } => "run.steer",
Event::RunBlocked { .. } => "run.blocked",
Event::RunUnblocked => "run.unblocked",
Event::RunRemoving => "run.removing",
@ -116,6 +118,12 @@ pub fn event_name(event: &Event) -> &'static str {
Event::CommandCompleted { .. } => "command.completed",
Event::AgentCliStarted { .. } => "agent.cli.started",
Event::AgentCliCompleted { .. } => "agent.cli.completed",
Event::AgentSessionStarted { .. } => "agent.session.started",
Event::AgentSessionActivated { .. } => "agent.session.activated",
Event::AgentSessionDeactivated { .. } => "agent.session.deactivated",
Event::AgentSessionEnded { .. } => "agent.session.ended",
Event::AgentSteerBuffered { .. } => "agent.steer.buffered",
Event::AgentSteerDropped { .. } => "agent.steer.dropped",
Event::AgentCliCancelled { .. } => "agent.cli.cancelled",
Event::AgentCliTimedOut { .. } => "agent.cli.timed_out",
Event::PullRequestCreated { .. } => "pull_request.created",

View file

@ -63,9 +63,12 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
Event::RunCancelRequested { actor }
| Event::RunPauseRequested { actor }
| Event::RunUnpauseRequested { actor }
| Event::RunInterrupt { actor }
| Event::RunSteer { actor, .. }
| Event::RunArchived { actor }
| Event::RunUnarchived { actor, .. }
| Event::InterviewCompleted { actor, .. } => StoredEventFields {
| Event::InterviewCompleted { actor, .. }
| Event::AgentSteerBuffered { actor, .. } => StoredEventFields {
actor: actor.clone(),
..StoredEventFields::default()
},
@ -119,6 +122,62 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
| Event::AgentCliCompleted { node_id, .. }
| Event::AgentCliCancelled { node_id, .. }
| Event::AgentCliTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())),
Event::AgentSessionStarted {
session_id,
parent_session_id,
..
}
| Event::AgentSessionEnded {
session_id,
parent_session_id,
} => StoredEventFields {
session_id: Some(session_id.clone()),
parent_session_id: parent_session_id.clone(),
..StoredEventFields::default()
},
Event::AgentSessionActivated {
node_id,
visit,
session_id,
..
}
| Event::AgentSessionDeactivated {
node_id,
visit,
session_id,
} => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
StoredEventFields {
session_id: Some(session_id.clone()),
node_id: Some(node_id_str.clone()),
node_label,
stage_id: Some(StageId::new(node_id_str, *visit)),
..StoredEventFields::default()
}
}
Event::AgentSteerDropped {
actor,
node_id,
visit,
..
} => {
let node_id_str = node_id.clone();
let node_label = node_id_str
.as_ref()
.and_then(|n| default_node_label(Some(n), None));
let stage_id = match (node_id.clone(), visit) {
(Some(n), Some(v)) => Some(StageId::new(n, *v)),
_ => None,
};
StoredEventFields {
node_id: node_id_str,
node_label,
stage_id,
actor: actor.clone(),
..StoredEventFields::default()
}
}
Event::Agent {
stage,
visit,
@ -215,6 +274,7 @@ fn agent_actor_for_event(
parent_session_id: parent_session_id.map(str::to_string),
model: None,
}),
AgentEvent::SteeringInjected { actor, .. } => actor.clone(),
_ => None,
}
}

View file

@ -61,6 +61,8 @@ pub trait CodergenBackend: Send + Sync {
"one_shot mode not supported by this backend".into(),
))
}
async fn shutdown(&self, _emitter: &Arc<Emitter>) {}
}
/// The default handler for LLM task nodes.
@ -223,6 +225,12 @@ pub(crate) fn simulate_llm_handler(node: &Node) -> Outcome {
#[async_trait]
impl Handler for AgentHandler {
async fn shutdown(&self, emitter: &Arc<Emitter>) {
if let Some(backend) = self.backend.as_ref() {
backend.shutdown(emitter).await;
}
}
async fn simulate(
&self,
node: &Node,
@ -748,15 +756,14 @@ mod tests {
) -> Result<CodergenResult, Error> {
let scope = StageScope::for_handler(context, &node.id);
emitter.emit_scoped(
&crate::event::Event::Agent {
stage: node.id.clone(),
visit: scope.visit,
event: fabro_agent::AgentEvent::SessionStarted {
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
},
session_id: Some("session_123".to_string()),
parent_session_id: None,
&crate::event::Event::AgentSessionActivated {
node_id: node.id.clone(),
visit: scope.visit,
session_id: "session_123".to_string(),
thread_id: None,
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![fabro_types::SessionCapability::Steer],
},
&scope,
);

View file

@ -29,6 +29,12 @@ impl FanInHandler {
#[async_trait]
impl Handler for FanInHandler {
async fn shutdown(&self, emitter: &Arc<Emitter>) {
if let Some(backend) = self.backend.as_ref() {
backend.shutdown(emitter).await;
}
}
async fn simulate(
&self,
node: &Node,

View file

@ -0,0 +1,249 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use fabro_agent::SessionControlHandle;
use fabro_types::{SessionCapability, StageId};
use crate::error::Error;
use crate::event::{Emitter, Event};
use crate::steering_hub::SteeringHub;
pub struct ActivationLease {
stage_id: StageId,
session_id: String,
hub: Arc<SteeringHub>,
emitter: Arc<Emitter>,
released: AtomicBool,
}
pub struct ActivationLeaseOptions {
pub stage_id: StageId,
pub session_id: String,
pub thread_id: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub capabilities: Vec<SessionCapability>,
pub hub: Arc<SteeringHub>,
pub emitter: Arc<Emitter>,
}
impl ActivationLease {
pub fn activate(
options: ActivationLeaseOptions,
handle: &SessionControlHandle,
) -> Result<Arc<Self>, Error> {
if !options
.hub
.attach_handle(&options.stage_id, &options.session_id, handle)
{
return Err(Error::Precondition(format!(
"stage {} already has a different active agent session",
options.stage_id
)));
}
options.emitter.emit(&Event::AgentSessionActivated {
node_id: options.stage_id.node_id().to_string(),
visit: options.stage_id.visit(),
session_id: options.session_id.clone(),
thread_id: options.thread_id,
provider: options.provider,
model: options.model,
capabilities: options.capabilities,
});
options.hub.drain_pending_into(&options.stage_id, handle);
Ok(Arc::new(Self {
stage_id: options.stage_id,
session_id: options.session_id,
hub: options.hub,
emitter: options.emitter,
released: AtomicBool::new(false),
}))
}
pub fn release(&self) {
if !self.mark_released() {
return;
}
self.hub.detach(&self.stage_id, &self.session_id);
}
pub fn release_if_no_pending_control_work(&self, handle: &SessionControlHandle) -> bool {
if self.released.load(Ordering::Acquire) {
return true;
}
if !self
.hub
.detach_if_no_pending_control_work(&self.stage_id, &self.session_id, handle)
{
return false;
}
self.mark_released();
true
}
fn mark_released(&self) -> bool {
if self
.released
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return false;
}
self.emitter.emit(&Event::AgentSessionDeactivated {
node_id: self.stage_id.node_id().to_string(),
visit: self.stage_id.visit(),
session_id: self.session_id.clone(),
});
true
}
}
impl Drop for ActivationLease {
fn drop(&mut self) {
self.release();
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use fabro_agent::SessionControlHandle;
use fabro_types::RunId;
use super::*;
fn collect_event_names(emitter: &Arc<Emitter>) -> Arc<Mutex<Vec<String>>> {
let names = Arc::new(Mutex::new(Vec::new()));
let names_for_listener = Arc::clone(&names);
emitter.on_event(move |event| {
names_for_listener
.lock()
.unwrap()
.push(event.event_name().to_string());
});
names
}
fn options(
stage_id: StageId,
session_id: &str,
hub: Arc<SteeringHub>,
emitter: Arc<Emitter>,
) -> ActivationLeaseOptions {
ActivationLeaseOptions {
stage_id,
session_id: session_id.to_string(),
thread_id: None,
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![SessionCapability::Steer],
hub,
emitter,
}
}
#[test]
fn activate_emits_activated_before_draining_pending() {
let emitter = Arc::new(Emitter::new(RunId::new()));
let names = collect_event_names(&emitter);
let hub = Arc::new(SteeringHub::new(Arc::clone(&emitter)));
let stage_id = StageId::new("agent", 1);
let handle = SessionControlHandle::new();
hub.deliver_steer("queued".to_string(), None);
let _lease = ActivationLease::activate(
options(
stage_id.clone(),
"session-a",
Arc::clone(&hub),
Arc::clone(&emitter),
),
&handle,
)
.unwrap();
assert_eq!(handle.queue_len(), 1);
assert_eq!(names.lock().unwrap().as_slice(), [
"run.steer",
"agent.steer.buffered",
"agent.session.activated"
]);
}
#[test]
fn activate_rejects_mismatched_existing_session() {
let emitter = Arc::new(Emitter::new(RunId::new()));
let names = collect_event_names(&emitter);
let hub = Arc::new(SteeringHub::new(Arc::clone(&emitter)));
let stage_id = StageId::new("agent", 1);
let handle_a = SessionControlHandle::new();
let handle_b = SessionControlHandle::new();
let _lease = ActivationLease::activate(
options(
stage_id.clone(),
"session-a",
Arc::clone(&hub),
Arc::clone(&emitter),
),
&handle_a,
)
.unwrap();
let result = ActivationLease::activate(
options(
stage_id,
"session-b",
Arc::clone(&hub),
Arc::clone(&emitter),
),
&handle_b,
);
assert!(result.is_err());
assert_eq!(handle_b.queue_len(), 0);
assert_eq!(
names
.lock()
.unwrap()
.iter()
.filter(|name| name.as_str() == "agent.session.activated")
.count(),
1
);
}
#[test]
fn release_is_idempotent() {
let emitter = Arc::new(Emitter::new(RunId::new()));
let names = collect_event_names(&emitter);
let hub = Arc::new(SteeringHub::new(Arc::clone(&emitter)));
let stage_id = StageId::new("agent", 1);
let handle = SessionControlHandle::new();
let lease = ActivationLease::activate(
options(
stage_id,
"session-a",
Arc::clone(&hub),
Arc::clone(&emitter),
),
&handle,
)
.unwrap();
lease.release();
lease.release();
assert_eq!(
names
.lock()
.unwrap()
.iter()
.filter(|name| name.as_str() == "agent.session.deactivated")
.count(),
1
);
}
}

View file

@ -4,8 +4,8 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::subagent::{SessionFactory, SubAgentManager};
use fabro_agent::{
AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile, Sandbox, Session,
SessionOptions, Turn,
AgentEvent, AgentProfile, AnthropicProfile, CompletionCoordinator, GeminiProfile,
OpenAiProfile, Sandbox, Session, SessionControlHandle, SessionOptions, Turn,
};
use fabro_auth::{CredentialSource, EnvCredentialSource};
use fabro_graphviz::graph::Node;
@ -13,16 +13,19 @@ use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, TokenCounts};
use fabro_mcp::config::McpServerSettings;
use fabro_model::{FallbackTarget, Provider};
use fabro_types::{SessionCapability, StageId};
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::super::agent::{CodergenBackend, CodergenResult};
use super::activation_lease::{ActivationLease, ActivationLeaseOptions};
use crate::context::keys::Fidelity;
use crate::context::{Context, WorkflowContext};
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::outcome::billed_model_usage_from_llm;
use crate::steering_hub::SteeringHub;
/// Spawn a task that, when the run-level token cancels, sets the agent
/// `Session`'s interrupt reason to `Cancelled` and cancels the session token.
@ -116,6 +119,36 @@ fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentA
}
}
fn begin_session_lifecycle(
session: &Session,
emitter: &Arc<Emitter>,
parent_session_id: Option<String>,
) {
emitter.emit(&Event::AgentSessionStarted {
session_id: session.id().to_string(),
parent_session_id,
provider: Some(session.provider().to_string()),
model: Some(session.model().to_string()),
});
}
fn discard_session(
session: &mut Session,
lease: &mut Option<Arc<ActivationLease>>,
emitter: &Arc<Emitter>,
) {
if let Some(lease) = lease.take() {
lease.release();
}
let session_id = session.id().to_string();
if session.close() {
emitter.emit(&Event::AgentSessionEnded {
session_id,
parent_session_id: None,
});
}
}
fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
match provider {
Provider::OpenAi => Box::new(OpenAiProfile::new(model)),
@ -188,6 +221,10 @@ fn spawn_event_forwarder(
// Forward non-streaming agent events to pipeline
if !event.event.is_streaming_noise()
&& !matches!(&event.event, AgentEvent::ProcessingEnd)
&& !matches!(
&event.event,
AgentEvent::SessionStarted { .. } | AgentEvent::SessionEnded
)
{
emitter.emit_scoped(
&Event::Agent {
@ -216,6 +253,7 @@ pub struct AgentApiBackend {
env: HashMap<String, String>,
mcp_servers: Vec<McpServerSettings>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
}
impl AgentApiBackend {
@ -225,6 +263,7 @@ impl AgentApiBackend {
provider: Provider,
fallback_chain: Vec<FallbackTarget>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
) -> Self {
Self {
model,
@ -234,6 +273,7 @@ impl AgentApiBackend {
env: HashMap::new(),
mcp_servers: Vec::new(),
source,
steering_hub,
}
}
@ -242,12 +282,14 @@ impl AgentApiBackend {
model: String,
provider: Provider,
fallback_chain: Vec<FallbackTarget>,
steering_hub: Arc<SteeringHub>,
) -> Self {
Self::new(
model,
provider,
fallback_chain,
Arc::new(EnvCredentialSource::new()),
steering_hub,
)
}
@ -370,10 +412,63 @@ impl AgentApiBackend {
Ok(session)
}
/// Activate `session` with the steering hub under `stage_id` and wire up
/// the completion coordinator.
fn attach_session_to_hub(
&self,
session: &mut Session,
stage_id: &StageId,
thread_id: Option<&str>,
emitter: &Arc<Emitter>,
) -> Result<Arc<ActivationLease>, Error> {
let handle = session.control_handle();
let lease = ActivationLease::activate(
ActivationLeaseOptions {
stage_id: stage_id.clone(),
session_id: session.id().to_string(),
thread_id: thread_id.map(str::to_string),
provider: Some(session.provider().to_string()),
model: Some(session.model().to_string()),
capabilities: vec![SessionCapability::Steer],
hub: Arc::clone(&self.steering_hub),
emitter: Arc::clone(emitter),
},
&handle,
)?;
session.set_completion_coordinator(Arc::new(SteeringCompletionCoordinator {
handle,
lease: Mutex::new(Some(Arc::clone(&lease))),
}));
Ok(lease)
}
fn shutdown_cached_sessions(&self, emitter: &Arc<Emitter>) {
let sessions: Vec<Session> = self
.sessions
.lock()
.unwrap()
.drain()
.map(|(_, s)| s)
.collect();
for mut session in sessions {
let session_id = session.id().to_string();
if session.close() {
emitter.emit(&Event::AgentSessionEnded {
session_id,
parent_session_id: None,
});
}
}
}
}
#[async_trait]
impl CodergenBackend for AgentApiBackend {
async fn shutdown(&self, emitter: &Arc<Emitter>) {
self.shutdown_cached_sessions(emitter);
}
async fn one_shot(
&self,
node: &Node,
@ -595,21 +690,31 @@ impl CodergenBackend for AgentApiBackend {
);
// Record turn count before processing so we only aggregate new usage.
let turns_before = session.history().turns().len();
let mut turns_before = session.history().turns().len();
// Activate with the steering hub after initialization so HTTP
// `POST /runs/{id}/steer` calls reach this session. The activation
// lease is shared with the natural-completion coordinator and is
// released on every exit path.
let stage_id = stage_scope.stage_id();
let mut lease: Option<Arc<ActivationLease>> = None;
let allow_failover_primary = !self.fallback_chain.is_empty();
let init_result = if is_reused {
Ok(())
} else {
begin_session_lifecycle(&session, emitter, None);
match session.initialize().await {
Ok(()) => Ok(()),
Err(err) => match classify_agent_error(err, allow_failover_primary) {
AgentApiErrorDisposition::Cancelled => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(Error::Cancelled);
}
AgentApiErrorDisposition::Terminal(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
@ -622,7 +727,17 @@ impl CodergenBackend for AgentApiBackend {
// If initialize failed with a failover-eligible error, treat as a
// process_input failover trigger; otherwise run process_input.
let result = match init_result {
Ok(()) => session.process_input(prompt).await,
Ok(()) => {
match self.attach_session_to_hub(&mut session, &stage_id, thread_id, emitter) {
Ok(active_lease) => lease = Some(active_lease),
Err(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
}
session.process_input(prompt).await
}
Err(err) => Err(err),
};
@ -632,10 +747,12 @@ impl CodergenBackend for AgentApiBackend {
Err(err) => match classify_agent_error(err, allow_failover_primary) {
AgentApiErrorDisposition::Cancelled => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(Error::Cancelled);
}
AgentApiErrorDisposition::Terminal(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
@ -646,6 +763,9 @@ impl CodergenBackend for AgentApiBackend {
let mut last_err = Error::Llm(sdk_err);
let mut succeeded = false;
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
for (index, target) in self.fallback_chain.iter().enumerate() {
emitter.emit_scoped(
&Event::Failover {
@ -664,9 +784,6 @@ impl CodergenBackend for AgentApiBackend {
Err(_) => continue,
};
// Detach the bridge from the failing session before
// refreshing credentials and building a new one.
bridge.abort();
if cancel_token.is_cancelled() {
return Err(Error::Cancelled);
}
@ -693,6 +810,7 @@ impl CodergenBackend for AgentApiBackend {
};
session = new_session;
bridge.replace(cancel_token.clone(), &session);
turns_before = session.history().turns().len();
// Re-subscribe to forward events + track files from the new session
spawn_event_forwarder(
@ -704,22 +822,40 @@ impl CodergenBackend for AgentApiBackend {
);
let allow_failover_next = index + 1 < self.fallback_chain.len();
begin_session_lifecycle(&session, emitter, None);
if let Err(err) = session.initialize().await {
match classify_agent_error(err, allow_failover_next) {
AgentApiErrorDisposition::Cancelled => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(Error::Cancelled);
}
AgentApiErrorDisposition::Terminal(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
last_err = Error::Llm(sdk_err);
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
continue;
}
}
}
match self.attach_session_to_hub(
&mut session,
&stage_id,
thread_id,
emitter,
) {
Ok(active_lease) => lease = Some(active_lease),
Err(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
}
match session.process_input(prompt).await {
Ok(()) => {
succeeded = true;
@ -728,14 +864,18 @@ impl CodergenBackend for AgentApiBackend {
Err(err) => match classify_agent_error(err, allow_failover_next) {
AgentApiErrorDisposition::Cancelled => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(Error::Cancelled);
}
AgentApiErrorDisposition::Terminal(err) => {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
last_err = Error::Llm(sdk_err);
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
}
},
}
@ -746,9 +886,13 @@ impl CodergenBackend for AgentApiBackend {
},
};
// On error, drop the session (don't cache failed state). The bridge's
// `Drop` will abort the spawned task on early return.
result?;
// On error, discard the session (don't cache failed state). The
// bridge's `Drop` will abort the spawned task on early return.
if let Err(err) = result {
bridge.abort();
discard_session(&mut session, &mut lease, emitter);
return Err(err);
}
// Aggregate token usage only from new turns (prevents double-counting on
// reuse).
@ -790,11 +934,23 @@ impl CodergenBackend for AgentApiBackend {
(v, s.last.clone())
};
if let Some(lease) = lease.take() {
lease.release();
}
// Cache session back for reuse on success. Detach the bridge first so
// the cached session is not left wired to this run's cancel token.
if let Some(key) = reuse_key {
bridge.abort();
self.sessions.lock().unwrap().insert(key, session);
} else {
let session_id = session.id().to_string();
if session.close() {
emitter.emit(&Event::AgentSessionEnded {
session_id,
parent_session_id: None,
});
}
}
Ok(CodergenResult::Text {
@ -806,22 +962,111 @@ impl CodergenBackend for AgentApiBackend {
}
}
/// Coordinator that lets the agent loop ask the workflow layer whether to
/// keep iterating after a no-tool natural completion. Implements the
/// "close-the-door" pattern: detach only if the queue is empty, otherwise
/// report `true` so the loop drains.
struct SteeringCompletionCoordinator {
handle: SessionControlHandle,
lease: Mutex<Option<Arc<ActivationLease>>>,
}
impl CompletionCoordinator for SteeringCompletionCoordinator {
fn on_natural_completion(&self) -> bool {
let mut lease = self.lease.lock().expect("activation lease lock poisoned");
let Some(active_lease) = lease.as_ref() else {
return false;
};
if active_lease.release_if_no_pending_control_work(&self.handle) {
lease.take();
false
} else {
true
}
}
}
#[cfg(test)]
mod tests {
use fabro_agent::subagent::SessionFactory;
use fabro_agent::{AgentProfile, ToolRegistry};
use fabro_auth::{AuthCredential, AuthDetails, VaultCredentialSource};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
use fabro_vault::{SecretType, Vault};
use futures::stream;
use tokio::sync::RwLock as AsyncRwLock;
use super::*;
struct ShutdownTestProfile {
registry: ToolRegistry,
}
impl ShutdownTestProfile {
fn new() -> Self {
Self {
registry: ToolRegistry::new(),
}
}
}
impl AgentProfile for ShutdownTestProfile {
fn provider(&self) -> Provider {
Provider::OpenAi
}
fn model(&self) -> &str {
"gpt-5.4"
}
fn tool_registry(&self) -> &ToolRegistry {
&self.registry
}
fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.registry
}
fn build_system_prompt(
&self,
_env: &dyn fabro_agent::Sandbox,
_env_context: &fabro_agent::EnvContext,
_memory: &[String],
_user_instructions: Option<&str>,
_skills: &[fabro_agent::Skill],
) -> String {
"test".to_string()
}
}
struct ShutdownTestProvider;
#[async_trait]
impl ProviderAdapter for ShutdownTestProvider {
fn name(&self) -> &str {
"openai"
}
async fn complete(
&self,
_request: &Request,
) -> Result<fabro_llm::types::Response, LlmError> {
unreachable!("shutdown test never calls LLM completion")
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
Ok(Box::pin(stream::empty()))
}
}
#[test]
fn agent_backend_stores_config() {
let backend = AgentApiBackend::new_from_env(
"claude-opus-4-6".to_string(),
Provider::OpenAi,
Vec::new(),
SteeringHub::for_tests(),
);
assert_eq!(backend.model, "claude-opus-4-6");
assert_eq!(backend.provider, Provider::OpenAi);
@ -833,6 +1078,7 @@ mod tests {
"claude-opus-4-6".to_string(),
Provider::Anthropic,
Vec::new(),
SteeringHub::for_tests(),
);
assert!(backend.sessions.lock().unwrap().is_empty());
}
@ -985,6 +1231,7 @@ mod tests {
Arc::new(AsyncRwLock::new(vault)),
|_| None,
)),
SteeringHub::for_tests(),
);
let client = Client::from_source(backend.source.as_ref()).await.unwrap();
@ -992,6 +1239,56 @@ mod tests {
assert_eq!(client.provider_names(), vec!["anthropic"]);
}
#[tokio::test]
async fn api_backend_shutdown_closes_cached_sessions_once() {
let backend = AgentApiBackend::new_from_env(
"gpt-5.4".to_string(),
Provider::OpenAi,
Vec::new(),
SteeringHub::for_tests(),
);
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let event_names = Arc::new(Mutex::new(Vec::new()));
let event_names_for_listener = Arc::clone(&event_names);
emitter.on_event(move |event| {
event_names_for_listener
.lock()
.unwrap()
.push(event.event_name().to_string());
});
let mut providers = HashMap::new();
providers.insert(
"openai".to_string(),
Arc::new(ShutdownTestProvider) as Arc<dyn ProviderAdapter>,
);
let client = Client::new(providers, Some("openai".to_string()), Vec::new());
let session = Session::new(
client,
Arc::new(ShutdownTestProfile::new()),
Arc::new(fabro_agent::LocalSandbox::new(
tempfile::tempdir().unwrap().path().to_path_buf(),
)),
SessionOptions::default(),
None,
);
begin_session_lifecycle(&session, &emitter, None);
backend
.sessions
.lock()
.unwrap()
.insert("thread-1".to_string(), session);
backend.shutdown(&emitter).await;
backend.shutdown(&emitter).await;
assert_eq!(event_names.lock().unwrap().as_slice(), [
"agent.session.started",
"agent.session.ended"
]);
assert!(backend.sessions.lock().unwrap().is_empty());
}
// --- Bridge guard tests ---
fn failover_eligible_llm_error() -> LlmError {

View file

@ -909,6 +909,10 @@ impl CodergenBackend for BackendRouter {
.one_shot(node, prompt, system_prompt, emitter, stage_scope)
.await
}
async fn shutdown(&self, emitter: &Arc<Emitter>) {
self.api_backend.shutdown(emitter).await;
}
}
#[cfg(test)]

View file

@ -1,3 +1,4 @@
pub mod activation_lease;
pub mod api;
pub mod cli;
pub mod preamble;

View file

@ -22,6 +22,7 @@ use fabro_interview::Interviewer;
use crate::context::Context;
use crate::error::Error;
use crate::event::Emitter;
use crate::outcome::{Outcome, OutcomeExt};
pub use crate::services::{EngineServices, RunServices};
@ -55,6 +56,8 @@ pub trait Handler: Send + Sync {
fn should_retry(&self, err: &Error) -> bool {
err.is_retryable()
}
async fn shutdown(&self, _emitter: &Arc<Emitter>) {}
}
/// Extract a human-readable message from a panic payload.
@ -130,6 +133,13 @@ impl HandlerRegistry {
// 3. Default
self.default_handler.as_ref()
}
pub async fn shutdown_all(&self, emitter: &Arc<Emitter>) {
self.default_handler.shutdown(emitter).await;
for handler in self.handlers.values() {
handler.shutdown(emitter).await;
}
}
}
/// Build a [`HandlerRegistry`] with all built-in handler types registered.

View file

@ -1,4 +1,5 @@
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
@ -10,7 +11,7 @@ use super::agent::{
use super::{EngineServices, Handler};
use crate::context::{Context, WorkflowContext, keys};
use crate::error::Error;
use crate::event::{Event, StageScope};
use crate::event::{Emitter, Event, StageScope};
use crate::outcome::Outcome;
/// Handler for single-shot LLM calls (no tools, no agent loop).
@ -27,6 +28,12 @@ impl PromptHandler {
#[async_trait]
impl Handler for PromptHandler {
async fn shutdown(&self, emitter: &Arc<Emitter>) {
if let Some(backend) = self.backend.as_ref() {
backend.shutdown(emitter).await;
}
}
async fn simulate(
&self,
node: &Node,

View file

@ -305,6 +305,7 @@ pub use billing_rollup::{
};
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
pub use manifest_path::ManifestPath;
pub use steering_hub::SteeringHub;
pub mod run_materialization;
pub(crate) mod run_metadata;
pub mod run_options;
@ -314,6 +315,7 @@ pub mod sandbox_git;
pub(crate) mod sandbox_git_runtime;
pub mod services;
mod stage_scope;
pub mod steering_hub;
#[doc(hidden)]
pub mod test_support;
#[doc(hidden)]

View file

@ -248,7 +248,7 @@ fn replay_event_for_fork_projection(body: &EventBody) -> bool {
| EventBody::InterviewCompleted(_)
| EventBody::InterviewTimeout(_)
| EventBody::InterviewInterrupted(_)
| EventBody::AgentSessionStarted(_)
| EventBody::AgentSessionActivated(_)
| EventBody::AgentCliStarted(_)
| EventBody::AgentCliCancelled(_)
| EventBody::AgentCliTimedOut(_)
@ -314,6 +314,28 @@ mod tests {
)
}
#[test]
fn fork_replay_keeps_stage_scoped_session_activation_only() {
assert!(replay_event_for_fork_projection(
&EventBody::AgentSessionActivated(fabro_types::run_event::AgentSessionActivatedProps {
thread_id: None,
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
capabilities: vec![fabro_types::SessionCapability::Steer],
visit: 1,
})
));
assert!(!replay_event_for_fork_projection(
&EventBody::AgentSessionStarted(fabro_types::run_event::AgentSessionStartedProps {
provider: Some("openai".to_string()),
model: Some("gpt-5.4".to_string()),
})
));
assert!(!replay_event_for_fork_projection(
&EventBody::AgentSessionEnded(fabro_types::run_event::AgentSessionEndedProps {})
));
}
#[tokio::test]
async fn fork_persists_historical_node_projection_through_target_checkpoint() {
let store = test_store();

View file

@ -51,6 +51,7 @@ use crate::run_metadata::metadata_branch_name;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{FailureReason, RunStatus};
use crate::runtime_store::RunStoreHandle;
use crate::steering_hub::SteeringHub;
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
struct RunSession {
@ -59,6 +60,7 @@ struct RunSession {
sandbox: SandboxSpec,
llm: LlmSpec,
interviewer: Arc<dyn Interviewer>,
steering_hub: Arc<SteeringHub>,
on_node: crate::OnNodeCallback,
lifecycle: LifecycleOptions,
hooks: fabro_hooks::HookSettings,
@ -89,6 +91,7 @@ pub struct StartServices {
pub cancel_token: CancellationToken,
pub emitter: Arc<Emitter>,
pub interviewer: Arc<dyn Interviewer>,
pub steering_hub: Arc<SteeringHub>,
pub run_store: RunStoreHandle,
pub event_sink: RunEventSink,
pub artifact_sink: Option<ArtifactSink>,
@ -426,6 +429,7 @@ impl RunSession {
dry_run: resolved.execution.mode == RunMode::DryRun,
},
interviewer,
steering_hub: services.steering_hub,
on_node: services.on_node,
lifecycle: LifecycleOptions {
setup_commands: resolved.prepare.commands.clone(),
@ -744,6 +748,7 @@ impl RunSession {
sandbox: self.sandbox,
llm: self.llm,
interviewer: self.interviewer,
steering_hub: Arc::clone(&self.steering_hub),
lifecycle: self.lifecycle,
run_options,
workflow_path: self.workflow_path,
@ -775,6 +780,14 @@ impl RunSession {
}
});
// Drain any unconsumed pending steers on every exit path
// (success, error, panic). The emit lands in the progress log via
// the explicit flush below; the scopeguard is a panic-only fallback.
let steering_hub_for_drain = Arc::clone(&self.steering_hub);
let _drain_guard = scopeguard::guard((), move |()| {
steering_hub_for_drain.drain_pending_at_run_end();
});
let executed = pipeline::execute(initialized).await;
store_progress_logger.flush().await;
let final_context = Some(executed.final_context.clone());
@ -813,8 +826,20 @@ impl RunSession {
};
let retro = retroed.retro.clone();
let concluded = Box::pin(pipeline::finalize(retroed, &finalize_opts)).await?;
let concluded = match Box::pin(pipeline::finalize(retroed, &finalize_opts)).await {
Ok(concluded) => concluded,
Err(err) => {
self.steering_hub.drain_pending_at_run_end();
store_progress_logger.flush().await;
return Err(err);
}
};
let finalized = Box::pin(pipeline::pull_request(concluded, &pr_opts)).await;
// Emit `agent.steer.dropped { reason: run_ended }` for any
// unconsumed pending steers on the success path, then flush. The
// scopeguard above re-runs as a no-op (drain is idempotent on an
// already-empty buffer) on the way out of scope.
self.steering_hub.drain_pending_at_run_end();
store_progress_logger.flush().await;
scopeguard::ScopeGuard::into_inner(cleanup_guard);
@ -1100,11 +1125,13 @@ mod tests {
emitter: Arc<Emitter>,
registry: Arc<HandlerRegistry>,
) -> StartServices {
let steering_hub = Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone()));
StartServices {
run_id: fixtures::RUN_1,
cancel_token: CancellationToken::new(),
emitter,
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()),
steering_hub,
run_store: store.open_run(&fixtures::RUN_1).await.unwrap().into(),
event_sink: RunEventSink::store(store.open_run(&fixtures::RUN_1).await.unwrap()),
artifact_sink: None,

View file

@ -291,6 +291,8 @@ pub async fn execute(init: Initialized) -> Executed {
Err(e) => (Err(Error::engine(e.to_string())), initial_context),
};
engine.registry.shutdown_all(&engine.run.emitter).await;
let duration_ms = crate::millis_u64(start.elapsed());
Executed {

View file

@ -201,7 +201,7 @@ async fn execute_test_run_with_options(
run_id: run_id_value,
run_store: run_store.into(),
dry_run: false,
emitter,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
@ -213,6 +213,7 @@ async fn execute_test_run_with_options(
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -271,6 +272,9 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(test_emitter_arc(
"run-test",
))),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -331,7 +335,7 @@ async fn run_with_lifecycle(
run_id,
run_store: test_run_store(&run_id).await.into(),
dry_run: false,
emitter,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: PathBuf::from(sandbox.working_directory()),
},
@ -343,6 +347,7 @@ async fn run_with_lifecycle(
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle,
run_options,
workflow_path: None,

View file

@ -38,6 +38,7 @@ use crate::run_options::{GitCheckpointOptions, RunOptions};
use crate::sandbox_git::GIT_REMOTE;
use crate::sandbox_git_runtime::SandboxGitRuntime;
use crate::services::{EngineServices, RunServices};
use crate::steering_hub::SteeringHub;
struct WorktreePlan {
branch_name: String,
@ -266,6 +267,7 @@ async fn build_sandbox_env(
async fn build_registry(
spec: &LlmSpec,
interviewer: Arc<dyn fabro_interview::Interviewer>,
steering_hub: Arc<SteeringHub>,
sandbox_env: &HashMap<String, String>,
graph: &graph::Graph,
llm_source: Arc<dyn CredentialSource>,
@ -310,12 +312,14 @@ async fn build_registry(
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let llm_source_for_api = Arc::clone(&llm_source);
let steering_hub_for_api = Arc::clone(&steering_hub);
let registry = Arc::new(default_registry(interviewer, move || {
let api = AgentApiBackend::new(
model.clone(),
provider,
fallback_chain.clone(),
Arc::clone(&llm_source_for_api),
Arc::clone(&steering_hub_for_api),
)
.with_env(env.clone())
.with_mcp_servers(mcp_servers.clone());
@ -589,6 +593,7 @@ pub async fn initialize(
build_registry(
&options.llm,
Arc::clone(&options.interviewer),
Arc::clone(&options.steering_hub),
&env,
&graph,
Arc::clone(&llm_source),
@ -929,49 +934,50 @@ mod tests {
});
let result = initialize(persisted, InitOptions {
run_id: test_run_id(),
run_store: {
run_id: test_run_id(),
run_store: {
let store = memory_store();
let inner = store.create_run(&test_run_id()).await.unwrap();
inner.into()
},
dry_run: false,
emitter,
sandbox: SandboxSpec::Local {
dry_run: false,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
llm: LlmSpec {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![command.to_string()],
setup_command_timeout_ms: 1_000,
devcontainer_phases: vec![],
},
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
devcontainer_env: HashMap::new(),
toml_env: HashMap::new(),
github_permissions: None,
origin_url: None,
},
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
})
.await;
let events = seen.lock().unwrap().clone();
@ -984,6 +990,7 @@ mod tests {
let run_dir = temp.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let store = memory_store();
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let mut options = InitOptions {
run_id: test_run_id(),
run_store: {
@ -991,7 +998,7 @@ mod tests {
inner.into()
},
dry_run: false,
emitter: Arc::new(crate::event::Emitter::new(test_run_id())),
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
@ -1003,6 +1010,7 @@ mod tests {
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -1057,49 +1065,50 @@ mod tests {
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let initialized = initialize(persisted, InitOptions {
run_id: test_run_id(),
run_store: {
run_id: test_run_id(),
run_store: {
let store = memory_store();
let inner = store.create_run(&test_run_id()).await.unwrap();
inner.into()
},
dry_run: false,
emitter,
sandbox: SandboxSpec::Local {
dry_run: false,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
llm: LlmSpec {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
devcontainer_phases: vec![],
},
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
devcontainer_env: HashMap::new(),
toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]),
github_permissions: None,
origin_url: None,
},
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
})
.await
.unwrap();
@ -1151,6 +1160,7 @@ mod tests {
let (graph, _) = llm_graph();
let vault = Arc::new(AsyncRwLock::new(vault));
let test_emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let (_registry, effective_dry_run) = build_registry(
&LlmSpec {
model: "claude-opus-4-6".to_string(),
@ -1160,6 +1170,7 @@ mod tests {
dry_run: false,
},
Arc::new(AutoApproveInterviewer::engine()),
Arc::new(crate::steering_hub::SteeringHub::new(test_emitter)),
&HashMap::new(),
&graph,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
@ -1190,45 +1201,46 @@ mod tests {
store_logger.register(&emitter);
let initialized = initialize(persisted, InitOptions {
run_id: test_run_id(),
run_store: run_store.into(),
dry_run: false,
emitter,
sandbox: SandboxSpec::Local {
run_id: test_run_id(),
run_store: run_store.into(),
dry_run: false,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
llm: LlmSpec {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
lifecycle: crate::run_options::LifecycleOptions {
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["true".to_string()],
setup_command_timeout_ms: 1_000,
devcontainer_phases: vec![],
},
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
devcontainer_env: HashMap::new(),
toml_env: HashMap::new(),
github_permissions: None,
origin_url: None,
},
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
vault: None,
devcontainer: None,
git: None,
worktree_mode: None,
run_control: None,
registry_override: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
})
.await
.unwrap();
@ -1298,6 +1310,7 @@ mod tests {
let mut run_options = test_settings(&run_dir);
run_options.cancel_token = cancel_token;
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let result = initialize(persisted, InitOptions {
run_id: test_run_id(),
run_store: {
@ -1306,7 +1319,7 @@ mod tests {
inner.into()
},
dry_run: false,
emitter: Arc::new(crate::event::Emitter::new(test_run_id())),
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
@ -1318,6 +1331,7 @@ mod tests {
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["sleep 5".to_string()],
setup_command_timeout_ms: 5_000,
@ -1360,6 +1374,7 @@ mod tests {
let mut run_options = test_settings(&run_dir);
run_options.cancel_token = cancel_token;
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let result = initialize(persisted, InitOptions {
run_id: test_run_id(),
run_store: {
@ -1368,7 +1383,7 @@ mod tests {
inner.into()
},
dry_run: false,
emitter: Arc::new(crate::event::Emitter::new(test_run_id())),
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
@ -1380,6 +1395,7 @@ mod tests {
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 5_000,

View file

@ -29,6 +29,7 @@ use crate::run_control::RunControlState;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::runtime_store::RunStoreHandle;
use crate::services::{EngineServices, RunServices};
use crate::steering_hub::SteeringHub;
use crate::transforms::Transform;
use crate::workflow_bundle::WorkflowBundle;
@ -240,6 +241,7 @@ pub struct InitOptions {
pub sandbox: SandboxSpec,
pub llm: LlmSpec,
pub interviewer: Arc<dyn Interviewer>,
pub steering_hub: Arc<SteeringHub>,
pub lifecycle: LifecycleOptions,
pub run_options: RunOptions,
pub workflow_path: Option<ManifestPath>,

View file

@ -0,0 +1,511 @@
//! Bridge between the worker's HTTP control plane and live agent
//! `Session`s. The hub owns:
//!
//! - A map of currently steerable API-mode sessions, keyed by `StageId` →
//! active `(session_id, SessionControlHandle)` entries.
//! - A bounded run-wide pending buffer for steers that arrive when no session
//! is registered (between stages, before the first agent stage, or after a
//! session ends but before the next registers).
//!
//! Lock discipline (race safety):
//! - `active` is `std::sync::RwLock`; deliver takes the read lock for the
//! entire decide-and-push step.
//! - `pending` is `std::sync::Mutex` taken under the active read lock.
//! - All methods are sync — no `.await` while holding any lock — so the
//! `CompletionCoordinator::on_natural_completion` close-the-door dance can
//! call `detach_if_no_pending_control_work(...)` synchronously from the
//! agent loop.
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, RwLock};
use fabro_agent::{SessionControlHandle, SteeringItem};
use fabro_types::run_event::AgentSteerDroppedReason;
use fabro_types::{Principal, StageId};
use crate::event::{Emitter, Event};
/// Cap on the steering queue length kept per active session. Overflow
/// evicts the oldest entry (FIFO) and emits `agent.steer.dropped`.
pub const PER_SESSION_QUEUE_CAP: usize = 32;
/// Cap on the run-wide pending buffer used when no session is registered.
/// Overflow evicts the oldest entry (FIFO) and emits `agent.steer.dropped`.
pub const PER_RUN_PENDING_CAP: usize = 32;
#[derive(Debug, Clone)]
struct PendingSteer {
text: String,
actor: Option<Principal>,
}
#[derive(Clone)]
struct ActiveEntry {
handle: SessionControlHandle,
session_id: String,
}
#[allow(
clippy::module_name_repetitions,
reason = "external callers refer to it as SteeringHub"
)]
pub struct SteeringHub {
active: RwLock<HashMap<StageId, ActiveEntry>>,
pending: Mutex<VecDeque<PendingSteer>>,
emitter: Arc<Emitter>,
}
impl SteeringHub {
#[must_use]
pub fn new(emitter: Arc<Emitter>) -> Self {
Self {
active: RwLock::new(HashMap::new()),
pending: Mutex::new(VecDeque::new()),
emitter,
}
}
/// Test-only constructor with an isolated emitter.
#[cfg(test)]
#[must_use]
pub fn for_tests() -> Arc<Self> {
use fabro_types::RunId;
Arc::new(Self::new(Arc::new(Emitter::new(RunId::new()))))
}
/// Test-only: snapshot of pending buffer length.
#[cfg(test)]
#[must_use]
pub fn pending_len(&self) -> usize {
self.pending.lock().expect("pending lock poisoned").len()
}
/// Test-only: snapshot of registered stage count.
#[cfg(test)]
#[must_use]
pub fn active_count(&self) -> usize {
self.active.read().expect("active lock poisoned").len()
}
/// Attach an API-mode session as steerable for this stage. Returns
/// `false` when a different session is already active for the stage.
pub fn attach_handle(
&self,
stage_id: &StageId,
session_id: &str,
handle: &SessionControlHandle,
) -> bool {
let mut active = self.active.write().expect("active lock poisoned");
match active.get_mut(stage_id) {
Some(entry) if entry.session_id != session_id => false,
Some(entry) => {
entry.handle = handle.clone();
true
}
None => {
active.insert(stage_id.clone(), ActiveEntry {
handle: handle.clone(),
session_id: session_id.to_string(),
});
true
}
}
}
/// Drain pending run-wide steers into `handle`.
pub fn drain_pending_into(&self, stage_id: &StageId, handle: &SessionControlHandle) {
let pending: Vec<PendingSteer> = {
let mut pending = self.pending.lock().expect("pending lock poisoned");
pending.drain(..).collect()
};
for item in pending {
Self::enqueue_into_session_queue(
handle,
(item.text, item.actor),
&self.emitter,
Some(stage_id),
);
}
}
/// Detach the session for this stage. Stale session ids are ignored.
pub fn detach(&self, stage_id: &StageId, session_id: &str) -> bool {
let mut active = self.active.write().expect("active lock poisoned");
let Some(entry) = active.get(stage_id) else {
return false;
};
if entry.session_id != session_id {
return false;
}
active.remove(stage_id);
true
}
/// Atomic close-the-door check used by the agent loop's natural-
/// completion path. Under the `active` write lock: if `handle`'s queue
/// is empty and the active session id matches, remove the stage and
/// return `true`. If the queue is non-empty, leave the registration
/// intact and return `false`.
pub fn detach_if_no_pending_control_work(
&self,
stage_id: &StageId,
session_id: &str,
handle: &SessionControlHandle,
) -> bool {
let mut active = self.active.write().expect("active lock poisoned");
let Some(entry) = active.get(stage_id) else {
return false;
};
if entry.session_id != session_id || handle.has_pending_control_work() {
return false;
}
active.remove(stage_id);
true
}
/// Deliver a steer from the HTTP control plane. Broadcasts to every
/// active session if any are registered, otherwise parks the message
/// in the run-wide pending buffer.
pub fn deliver_steer(&self, text: String, actor: Option<Principal>) {
self.emitter.emit(&Event::RunSteer {
text: text.clone(),
actor: actor.clone(),
});
// Hold the active read lock for the entire decide-and-dispatch
// step so register/unregister cannot race with this push.
let active = self.active.read().expect("active lock poisoned");
if active.is_empty() {
let dropped_actor = {
let mut pending = self.pending.lock().expect("pending lock poisoned");
let dropped_actor = if pending.len() >= PER_RUN_PENDING_CAP {
Some(pending.pop_front().and_then(|d| d.actor))
} else {
None
};
pending.push_back(PendingSteer {
text,
actor: actor.clone(),
});
dropped_actor
};
if let Some(dropped_actor) = dropped_actor {
self.emitter.emit(&Event::AgentSteerDropped {
reason: AgentSteerDroppedReason::QueueFull,
count: 1,
actor: dropped_actor,
node_id: None,
visit: None,
});
}
self.emitter.emit(&Event::AgentSteerBuffered { actor });
drop(active);
return;
}
// Broadcast to every active session.
for (stage_id, entry) in active.iter() {
Self::enqueue_into_session_queue(
&entry.handle,
(text.clone(), actor.clone()),
&self.emitter,
Some(stage_id),
);
}
}
/// Interrupt every active API-mode session. Does not buffer when no
/// active session exists.
pub fn interrupt(&self, actor: Option<&Principal>) {
let active = self.active.read().expect("active lock poisoned");
if active.is_empty() {
return;
}
self.emitter.emit(&Event::RunInterrupt {
actor: actor.cloned(),
});
for entry in active.values() {
entry.handle.interrupt(actor.cloned());
}
}
/// Atomically apply interrupt semantics, then deliver steering text to
/// every active API-mode session. Emits persisted run events in the same
/// order.
pub fn interrupt_then_steer(&self, text: &str, actor: Option<&Principal>) {
let active = self.active.read().expect("active lock poisoned");
if active.is_empty() {
return;
}
self.emitter.emit(&Event::RunInterrupt {
actor: actor.cloned(),
});
self.emitter.emit(&Event::RunSteer {
text: text.to_string(),
actor: actor.cloned(),
});
for (stage_id, entry) in active.iter() {
if let Some((_, evicted_actor)) = entry.handle.interrupt_then_enqueue_bounded(
(text.to_string(), actor.cloned()),
PER_SESSION_QUEUE_CAP,
) {
self.emitter.emit(&Event::AgentSteerDropped {
reason: AgentSteerDroppedReason::QueueFull,
count: 1,
actor: evicted_actor,
node_id: Some(stage_id.node_id().to_string()),
visit: Some(stage_id.visit()),
});
}
}
}
/// Drain any unconsumed pending steers and emit a single
/// `agent.steer.dropped` event with `reason: run_ended`. Called from
/// `operations::start` after the pipeline finishes (success or
/// failure) but before the emitter is flushed.
pub fn drain_pending_at_run_end(&self) {
let count: u32 = {
let mut pending = self.pending.lock().expect("pending lock poisoned");
let n = u32::try_from(pending.len()).unwrap_or(u32::MAX);
pending.clear();
n
};
if count > 0 {
self.emitter.emit(&Event::AgentSteerDropped {
reason: AgentSteerDroppedReason::RunEnded,
count,
actor: None,
node_id: None,
visit: None,
});
}
}
/// Push an item into a session's queue, evicting the oldest entry and
/// emitting `agent.steer.dropped { queue_full }` if the cap is hit.
/// The push + eviction are atomic under the per-session queue lock.
fn enqueue_into_session_queue(
handle: &SessionControlHandle,
item: SteeringItem,
emitter: &Emitter,
stage_id: Option<&StageId>,
) {
if let Some((_, evicted_actor)) = handle.enqueue_bounded(item, PER_SESSION_QUEUE_CAP) {
emitter.emit(&Event::AgentSteerDropped {
reason: AgentSteerDroppedReason::QueueFull,
count: 1,
actor: evicted_actor,
node_id: stage_id.map(|s| s.node_id().to_string()),
visit: stage_id.map(StageId::visit),
});
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use fabro_agent::SessionControlHandle;
use fabro_types::{Principal, RunId, StageId, SystemActorKind};
use super::SteeringHub;
use crate::event::Emitter;
fn hub_with_event_names() -> (Arc<SteeringHub>, Arc<Mutex<Vec<String>>>) {
let emitter = Arc::new(Emitter::new(RunId::new()));
let names = Arc::new(Mutex::new(Vec::new()));
let names_for_listener = Arc::clone(&names);
emitter.on_event(move |event| {
names_for_listener
.lock()
.unwrap()
.push(event.event_name().to_string());
});
(Arc::new(SteeringHub::new(emitter)), names)
}
#[test]
fn deliver_with_no_active_buffers_message() {
let (hub, names) = hub_with_event_names();
hub.deliver_steer(
"hi".into(),
Some(Principal::System {
system_kind: SystemActorKind::Engine,
}),
);
assert_eq!(hub.pending_len(), 1);
assert_eq!(names.lock().unwrap().as_slice(), [
"run.steer",
"agent.steer.buffered"
]);
}
#[test]
fn drain_pending_at_run_end_clears_buffer() {
let hub = SteeringHub::for_tests();
hub.deliver_steer("a".into(), None);
hub.deliver_steer("b".into(), None);
assert_eq!(hub.pending_len(), 2);
hub.drain_pending_at_run_end();
assert_eq!(hub.pending_len(), 0);
}
#[test]
fn pending_buffer_evicts_oldest_at_cap() {
let hub = SteeringHub::for_tests();
for i in 0..(super::PER_RUN_PENDING_CAP + 5) {
hub.deliver_steer(format!("msg{i}"), None);
}
assert_eq!(hub.pending_len(), super::PER_RUN_PENDING_CAP);
}
#[test]
fn unregister_is_idempotent() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("agent-node", 1);
hub.detach(&stage, "session-a");
hub.detach(&stage, "session-a");
}
#[test]
fn attach_and_drain_pending_into_first_session() {
let hub = SteeringHub::for_tests();
hub.deliver_steer("queued1".into(), None);
hub.deliver_steer("queued2".into(), None);
assert_eq!(hub.pending_len(), 2);
let stage = StageId::new("agent-node", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
hub.drain_pending_into(&stage, &handle);
assert_eq!(handle.queue_len(), 2);
assert_eq!(hub.pending_len(), 0);
assert_eq!(hub.active_count(), 1);
}
#[test]
fn deliver_broadcasts_to_active_sessions() {
let hub = SteeringHub::for_tests();
let stage_a = StageId::new("a", 1);
let stage_b = StageId::new("b", 1);
let handle_a = SessionControlHandle::new();
let handle_b = SessionControlHandle::new();
assert!(hub.attach_handle(&stage_a, "session-a", &handle_a));
assert!(hub.attach_handle(&stage_b, "session-b", &handle_b));
hub.deliver_steer("hello".into(), None);
assert_eq!(handle_a.queue_len(), 1);
assert_eq!(handle_b.queue_len(), 1);
assert_eq!(hub.pending_len(), 0);
}
#[test]
fn attach_rejects_different_session_for_same_stage() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("a", 1);
let handle1 = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle1));
hub.deliver_steer("x".into(), None);
assert_eq!(handle1.queue_len(), 1);
let handle2 = SessionControlHandle::new();
assert!(!hub.attach_handle(&stage, "session-b", &handle2));
assert_eq!(handle2.queue_len(), 0);
}
#[test]
fn stale_detach_does_not_remove_active_session() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
assert!(!hub.detach(&stage, "session-b"));
hub.deliver_steer("still-active".into(), None);
assert_eq!(handle.queue_len(), 1);
assert_eq!(hub.active_count(), 1);
}
#[test]
fn detach_if_no_pending_control_work_respects_session_id_and_queue_state() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
assert!(!hub.detach_if_no_pending_control_work(&stage, "session-b", &handle));
hub.deliver_steer("queued".into(), None);
assert!(!hub.detach_if_no_pending_control_work(&stage, "session-a", &handle));
assert_eq!(hub.active_count(), 1);
}
#[test]
fn detach_if_no_pending_control_work_removes_matching_empty_session() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
assert!(hub.detach_if_no_pending_control_work(&stage, "session-a", &handle));
assert_eq!(hub.active_count(), 0);
}
#[test]
fn pure_interrupt_marks_active_sessions_waiting_without_queueing_text() {
let (hub, names) = hub_with_event_names();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
hub.interrupt(None);
hub.interrupt(None);
assert!(handle.is_waiting_for_steer());
assert_eq!(handle.queue_len(), 0);
assert_eq!(hub.pending_len(), 0);
assert_eq!(names.lock().unwrap().as_slice(), [
"run.interrupt",
"run.interrupt"
]);
}
#[test]
fn interrupt_then_steer_cancels_and_queues_text() {
let (hub, names) = hub_with_event_names();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
hub.interrupt_then_steer("stop", None);
assert!(!handle.is_waiting_for_steer());
assert_eq!(handle.queue_len(), 1);
assert_eq!(hub.pending_len(), 0);
assert_eq!(names.lock().unwrap().as_slice(), [
"run.interrupt",
"run.steer"
]);
}
#[test]
fn per_session_queue_evicts_oldest_at_cap() {
let hub = SteeringHub::for_tests();
let stage = StageId::new("a", 1);
let handle = SessionControlHandle::new();
assert!(hub.attach_handle(&stage, "session-a", &handle));
for i in 0..(super::PER_SESSION_QUEUE_CAP + 5) {
hub.deliver_steer(format!("m{i}"), None);
}
assert_eq!(handle.queue_len(), super::PER_SESSION_QUEUE_CAP);
}
}

View file

@ -301,6 +301,7 @@ models/stage-outcome.ts
models/stage-projection.ts
models/stage-state.ts
models/start-run-request.ts
models/steer-run-request.ts
models/submit-answer-request.ts
models/success-reason.ts
models/system-actor-kind.ts

View file

@ -36,6 +36,8 @@ import type { SshAccessRequest } from '../models';
// @ts-ignore
import type { SshAccessResponse } from '../models';
// @ts-ignore
import type { SteerRunRequest } from '../models';
// @ts-ignore
import type { SubmitAnswerRequest } from '../models';
/**
* HumanInTheLoopApi - axios parameter creator
@ -179,6 +181,46 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf
options: localVarRequestOptions,
};
},
/**
* Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round.
* @summary Interrupt Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
interruptRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('interruptRun', 'id', id)
const localVarPath = `/api/v1/runs/{id}/interrupt`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed.
* @summary List Run Questions
@ -333,6 +375,51 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf
options: localVarRequestOptions,
};
},
/**
* Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session.
* @summary Steer Run
* @param {string} id Unique run identifier (ULID).
* @param {SteerRunRequest} steerRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
steerRun: async (id: string, steerRunRequest: SteerRunRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('steerRun', 'id', id)
// verify required parameter 'steerRunRequest' is not null or undefined
assertParamExists('steerRun', 'steerRunRequest', steerRunRequest)
const localVarPath = `/api/v1/runs/{id}/steer`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(steerRunRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Submits an answer to a pending question. The answer can be freeform text or a selected option key, depending on the question type.
* @summary Submit Run Answer
@ -433,6 +520,19 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.getSandboxFile']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round.
* @summary Interrupt Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async interruptRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.interruptRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.interruptRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed.
* @summary List Run Questions
@ -478,6 +578,20 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.putSandboxFile']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session.
* @summary Steer Run
* @param {string} id Unique run identifier (ULID).
* @param {SteerRunRequest} steerRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async steerRun(id: string, steerRunRequest: SteerRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.steerRun(id, steerRunRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.steerRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Submits an answer to a pending question. The answer can be freeform text or a selected option key, depending on the question type.
* @summary Submit Run Answer
@ -535,6 +649,16 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration,
getSandboxFile(id: string, path: string, options?: RawAxiosRequestConfig): AxiosPromise<File> {
return localVarFp.getSandboxFile(id, path, options).then((request) => request(axios, basePath));
},
/**
* Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round.
* @summary Interrupt Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
interruptRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.interruptRun(id, options).then((request) => request(axios, basePath));
},
/**
* Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed.
* @summary List Run Questions
@ -571,6 +695,17 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration,
putSandboxFile(id: string, path: string, body: File, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.putSandboxFile(id, path, body, options).then((request) => request(axios, basePath));
},
/**
* Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session.
* @summary Steer Run
* @param {string} id Unique run identifier (ULID).
* @param {SteerRunRequest} steerRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
steerRun(id: string, steerRunRequest: SteerRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.steerRun(id, steerRunRequest, options).then((request) => request(axios, basePath));
},
/**
* Submits an answer to a pending question. The answer can be freeform text or a selected option key, depending on the question type.
* @summary Submit Run Answer
@ -626,6 +761,17 @@ export class HumanInTheLoopApi extends BaseAPI {
return HumanInTheLoopApiFp(this.configuration).getSandboxFile(id, path, options).then((request) => request(this.axios, this.basePath));
}
/**
* Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round.
* @summary Interrupt Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public interruptRun(id: string, options?: RawAxiosRequestConfig) {
return HumanInTheLoopApiFp(this.configuration).interruptRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed.
* @summary List Run Questions
@ -665,6 +811,18 @@ export class HumanInTheLoopApi extends BaseAPI {
return HumanInTheLoopApiFp(this.configuration).putSandboxFile(id, path, body, options).then((request) => request(this.axios, this.basePath));
}
/**
* Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session.
* @summary Steer Run
* @param {string} id Unique run identifier (ULID).
* @param {SteerRunRequest} steerRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public steerRun(id: string, steerRunRequest: SteerRunRequest, options?: RawAxiosRequestConfig) {
return HumanInTheLoopApiFp(this.configuration).steerRun(id, steerRunRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Submits an answer to a pending question. The answer can be freeform text or a selected option key, depending on the question type.
* @summary Submit Run Answer
@ -678,4 +836,3 @@ export class HumanInTheLoopApi extends BaseAPI {
return HumanInTheLoopApiFp(this.configuration).submitRunAnswer(id, qid, submitAnswerRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -280,6 +280,7 @@ export * from './stage-outcome';
export * from './stage-projection';
export * from './stage-state';
export * from './start-run-request';
export * from './steer-run-request';
export * from './submit-answer-request';
export * from './success-reason';
export * from './system-actor-kind';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request body for steering a running run mid-execution.
*/
export interface SteerRunRequest {
/**
* The steering message text to deliver as a user turn.
*/
'text': string;
/**
* When true, apply a worker-control interrupt first, then deliver this text as steering in the same control operation. When false (default), append to the steering queue and let the agent pick it up at the next turn boundary.
*/
'interrupt'?: boolean;
}