feat: abort sub-agent jobs (real kill) + runner usage capture into JobRecord.usage
Wave 4 (agent-side observability + control), reconciled with the dock contract (master relay 2026-09): - abortJob in src/server/jobs.ts, exported through the server barrel for POST /api/activity/abort (master's thin route): fires the job's AbortController (real kill — the in-flight model call aborts, not just the next loop check), drops any queued result steer, flips the record to error "Aborted by learner" + activity "aborted" synchronously, and queues a one-line abort steer (buildAbortSteer) through the same wake pipeline so the teacher's promise doesn't hang in the lesson. Shares the result steer's deterministic id space, so abort + racing result can never both deliver (steerDelivered dedupe). - Abort registry (src/agent/subagents/aborts.ts) on globalThis, mirroring the wake pipeline's module-state pattern; runner registers/unregisters per run, so the route's module instance sees the loop's controller. - Runner usage capture: LoopContext (abortSignal + onUsage) threads into all three kind loops; per-step usage folds via accumulateJobUsage into JobRecord.usage (model from config MODELS, absent-never-zero), flushed in runJob's finally so an aborted job's money record survives the kill. - Contracts: JobUsage token fields optional + lastStep (most recent call's accounting, for the dock's per-blob context line vs CONTEXT_LIMITS); SubagentResultMeta.aborted; AbortSteerMessage; buildAbortSteer; SteerMessage union generalized. - demo-builder/visual-maker accept abortSignal + onUsage (visual folds generateText usage per iteration). - stillRunning/finishJob/jobContext guards: an aborted job is never overwritten by the runner (terminal no matter what the wake does); failJob and the pump's finalizeJob keep the flip. - UI: abort steer renders a quiet aborted chip (job-aborted-badge). - Prompt 20-jobs.md: one line about aborted jobs. Tests: accumulateJobUsage folds (undefined-preservation, lastStep), runner usage capture, real-kill abort (sync flip, silent unwind, abort steer wakes), usage-so-far survives the kill, aborting a delivering job drops the queued result steer, outcome reasons, abort-chip render. 330/330, typecheck/lint/build green.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon, CircleX } from "lucide-react";
|
||||||
import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
import type { ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
||||||
import { isSubagentFailure } from "@/src/core";
|
import { isSubagentFailure } from "@/src/core";
|
||||||
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
||||||
@@ -27,9 +27,15 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
|||||||
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
||||||
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
||||||
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
||||||
|
// The abort steer (2026-09 dock semantics): no artifact part, only the
|
||||||
|
// metadata marker — a quiet chip, mirroring the complete-badge idiom.
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
const meta = (
|
||||||
|
message.metadata as { subagentResult?: { aborted?: boolean; kind?: string } } | undefined
|
||||||
|
)?.subagentResult;
|
||||||
|
if (meta?.aborted) return <AbortedBadge kind={meta.kind ?? "job"} />;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
A sub-agent finished, but its result could not be displayed.
|
A sub-agent finished, but its result could not be displayed.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -48,6 +54,19 @@ export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The aborted-job chip: mirrors the complete-badge one-liner — the
|
||||||
|
* teacher's wake turn acknowledges the abort in the lesson itself,
|
||||||
|
* this just marks what happened. */
|
||||||
|
function AbortedBadge({ kind }: { kind: string }) {
|
||||||
|
return (
|
||||||
|
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<CircleX className="size-3.5" />
|
||||||
|
<span className="font-medium uppercase tracking-wide">{kind}</span>
|
||||||
|
<span>aborted by the learner</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the
|
/** Quiet one-liner, the dispatch-settled idiom — the lesson lives in the
|
||||||
* teacher's wake turn; the badge just marks delivery. The persisted brief
|
* teacher's wake turn; the badge just marks delivery. The persisted brief
|
||||||
* rides along in a collapsed <details> (the wave-1 "view full research"
|
* rides along in a collapsed <details> (the wave-1 "view full research"
|
||||||
|
|||||||
@@ -27,3 +27,7 @@ Dispatch as many research jobs as you genuinely need — in one turn or across t
|
|||||||
parallel when the questions are independent — but don't fan out on one question; the
|
parallel when the questions are independent — but don't fan out on one question; the
|
||||||
researcher searches multiple angles on its own. If research fails, you'll get a failure
|
researcher searches multiple angles on its own. If research fails, you'll get a failure
|
||||||
message: say so plainly and continue with what you can establish, flagging the uncertainty.
|
message: say so plainly and continue with what you can establish, flagging the uncertainty.
|
||||||
|
|
||||||
|
A job can also be **aborted by the learner** — you'll get a one-line "was aborted"
|
||||||
|
message instead of the result. Acknowledge it in one sentence and continue teaching
|
||||||
|
without it (offering to re-dispatch is fine if the learner wants the result).
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Job abort registry — WHO can actually kill a running sub-agent.
|
||||||
|
*
|
||||||
|
* The runner creates one AbortController per running job and registers it
|
||||||
|
* here; the abort path (src/server/jobs.ts abortJob, driven by the dock's
|
||||||
|
* END button via POST /api/jobs/[jobId]/abort) fires it through fireJobAbort.
|
||||||
|
* Two module trees meet: the runner (src/agent) and the endpoint
|
||||||
|
* (src/server) — the registry lives on globalThis for the same reason as
|
||||||
|
* the turn registry and the wake pipeline: Next dev gives each route entry
|
||||||
|
* its own module instances, so a module-level Map would be a different
|
||||||
|
* object in each. Placed under src/agent (server→agent imports are an
|
||||||
|
* established direction; agent→server is not — see chat-handler).
|
||||||
|
*
|
||||||
|
* 2026-09 abort semantics (user-decided): REAL KILL, not mark-and-abandon —
|
||||||
|
* the controller aborts the in-flight model call (agent.generate /
|
||||||
|
* generateText abortSignal); headless tool runs are best-effort (the next
|
||||||
|
* model call is where a hung one dies). The route owns the bookkeeping
|
||||||
|
* (status error "Aborted by learner" + the abort steer); the runner only unwinds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface LhAbortGlobal {
|
||||||
|
__lhJobAborts?: Map<string, AbortController>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registry(): Map<string, AbortController> {
|
||||||
|
const g = globalThis as typeof globalThis & LhAbortGlobal;
|
||||||
|
return (g.__lhJobAborts ??= new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The runner registers its controller at job start (idempotent overwrite). */
|
||||||
|
export function registerJobAbort(jobId: string, controller: AbortController): void {
|
||||||
|
registry().set(jobId, controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The runner unregisters in its finally block (job settled or unwound). */
|
||||||
|
export function unregisterJobAbort(jobId: string): void {
|
||||||
|
registry().delete(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abort a running job's loop. Returns false when no controller is live —
|
||||||
|
* the job's loop is not executing (crashed, or in the delivering-result
|
||||||
|
* phase where the runner has already returned); the caller then owns the
|
||||||
|
* whole abort on the record side. */
|
||||||
|
export function fireJobAbort(jobId: string): boolean {
|
||||||
|
const controller = registry().get(jobId);
|
||||||
|
if (!controller) return false;
|
||||||
|
registry().delete(jobId);
|
||||||
|
controller.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
|
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { modelFor } from "../models.ts";
|
import { modelFor } from "../models.ts";
|
||||||
import type { DemoResult } from "../../core/contracts.ts";
|
import type { DemoResult, UsageStepLike } from "../../core/contracts.ts";
|
||||||
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
|
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +32,10 @@ export interface DemoBuilderOptions {
|
|||||||
maxSteps?: number;
|
maxSteps?: number;
|
||||||
/** Live activity-line callback (updates the job record). */
|
/** Live activity-line callback (updates the job record). */
|
||||||
onActivity?: (line: string) => void;
|
onActivity?: (line: string) => void;
|
||||||
|
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
/** Money channel: one call per agent step, folded into JobRecord.usage. */
|
||||||
|
onUsage?: (usage: UsageStepLike) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
|
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
|
||||||
@@ -189,6 +193,8 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
|
|||||||
try {
|
try {
|
||||||
const result = await agent.generate({
|
const result = await agent.generate({
|
||||||
prompt,
|
prompt,
|
||||||
|
abortSignal: opts.abortSignal,
|
||||||
|
onStepFinish: ({ usage }) => opts.onUsage?.(usage),
|
||||||
onToolExecutionStart: ({ toolCall }) => {
|
onToolExecutionStart: ({ toolCall }) => {
|
||||||
opts.onActivity?.(toolCall.toolName);
|
opts.onActivity?.(toolCall.toolName);
|
||||||
},
|
},
|
||||||
|
|||||||
+117
-13
@@ -1,16 +1,21 @@
|
|||||||
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
|
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
|
||||||
import {
|
import {
|
||||||
|
accumulateJobUsage,
|
||||||
buildSubagentSteer,
|
buildSubagentSteer,
|
||||||
type DemoResult,
|
type DemoResult,
|
||||||
type DemoToolInput,
|
type DemoToolInput,
|
||||||
type JobKind,
|
type JobKind,
|
||||||
type JobRecord,
|
type JobRecord,
|
||||||
|
type JobUsage,
|
||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
type ResearchToolInput,
|
type ResearchToolInput,
|
||||||
type SubagentSteerMessage,
|
type SubagentSteerMessage,
|
||||||
|
type UsageStepLike,
|
||||||
type VisualResult,
|
type VisualResult,
|
||||||
type VisualToolInput,
|
type VisualToolInput,
|
||||||
} from "../../core/contracts.ts";
|
} from "../../core/contracts.ts";
|
||||||
|
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
||||||
|
import { MODELS } from "../../../config.ts";
|
||||||
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
||||||
import { runDemoBuilder } from "./demo-builder.ts";
|
import { runDemoBuilder } from "./demo-builder.ts";
|
||||||
import { createResearcherAgent } from "./researcher.ts";
|
import { createResearcherAgent } from "./researcher.ts";
|
||||||
@@ -67,16 +72,70 @@ export interface JobRunner {
|
|||||||
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
|
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One job loop's injected machinery: the kill switch (real abort, 2026-09
|
||||||
|
* semantics) and the usage tap (money channel — every model call folds into
|
||||||
|
* JobRecord.usage as it completes). */
|
||||||
|
interface LoopContext {
|
||||||
|
/** Fires when abortJob kills the run; abortSignal is passed into every
|
||||||
|
* model call so the spend stops mid-call, not at the next loop check. */
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
/** Fold one model call's usage into the job record (progress write). */
|
||||||
|
onUsage: (step: UsageStepLike) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OpenRouter model id for a job kind — JobUsage.model. The kind→role
|
||||||
|
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder,
|
||||||
|
* visual→visualMaker); the id is taken from config so a price-table sync
|
||||||
|
* re-prices jobs for free. Model overrides (tests) still bill under the
|
||||||
|
* configured model — usage is what is being captured, not which instance. */
|
||||||
|
const KIND_MODEL: Record<JobKind, string> = {
|
||||||
|
research: MODELS.researcher.model,
|
||||||
|
demo: MODELS.demoBuilder.model,
|
||||||
|
visual: MODELS.visualMaker.model,
|
||||||
|
};
|
||||||
|
|
||||||
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||||
const { chatStore, jobStore, deliver } = deps;
|
const { chatStore, jobStore, deliver } = deps;
|
||||||
|
|
||||||
|
/** Running usage totals per live job (the loop's onUsage folds here,
|
||||||
|
* then writes through the job store so the poll sees progress). */
|
||||||
|
const usageTotals = new Map<string, JobUsage>();
|
||||||
|
|
||||||
|
function makeLoopContext(jobId: string, kind: JobKind): LoopContext {
|
||||||
|
const model = KIND_MODEL[kind];
|
||||||
|
// Real-kill wiring (2026-09 abort semantics): the controller is what
|
||||||
|
// the abort endpoint fires (agent/subagents/aborts.ts registry); the
|
||||||
|
// signal rides into every model call of the loop.
|
||||||
|
const controller = new AbortController();
|
||||||
|
registerJobAbort(jobId, controller);
|
||||||
|
return {
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
onUsage: (step) => {
|
||||||
|
const next = accumulateJobUsage(usageTotals.get(jobId), model, step);
|
||||||
|
usageTotals.set(jobId, next);
|
||||||
|
jobStore.updateJob(jobId, { usage: next }); // merge patch — status untouched
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the job may still write terminal state (result, error,
|
||||||
|
* steer). False once abortJob has flipped it to error ("Aborted by learner") —
|
||||||
|
* the runner then unwinds silently; the abort path owns the record and
|
||||||
|
* the abort steer. */
|
||||||
|
function stillRunning(jobId: string): boolean {
|
||||||
|
return jobStore.getJob(jobId)?.status === "running";
|
||||||
|
}
|
||||||
|
|
||||||
/** The kind→runner map: one sub-agent loop per job kind. */
|
/** The kind→runner map: one sub-agent loop per job kind. */
|
||||||
// Each kind's runner narrows the union input itself (dispatch-time inputs
|
// Each kind's runner narrows the union input itself (dispatch-time inputs
|
||||||
// are kind-correct by construction; transcript fallbacks are cast after).
|
// are kind-correct by construction; transcript fallbacks are cast after).
|
||||||
const KIND_RUNNERS: Record<JobKind, (jobId: string, input?: JobToolInput) => Promise<void>> = {
|
const KIND_RUNNERS: Record<
|
||||||
research: (jobId, input) => runResearchJob(jobId, researchModelOverride.current, input),
|
JobKind,
|
||||||
demo: (jobId, input) => runDemoJob(jobId, demoModelOverride.current, input),
|
(jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
|
||||||
visual: (jobId, input) => runVisualJob(jobId, visualModelOverride.current, input),
|
> = {
|
||||||
|
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
||||||
|
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop),
|
||||||
|
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
||||||
@@ -92,7 +151,17 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
|
jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await run(jobId, input);
|
try {
|
||||||
|
await run(jobId, input, makeLoopContext(jobId, job.kind));
|
||||||
|
} finally {
|
||||||
|
unregisterJobAbort(jobId);
|
||||||
|
// The last usage write may have been mid-flight when the loop died;
|
||||||
|
// persist what was folded (the abort path patches only status, so
|
||||||
|
// this never resurrects an aborted job — merge patch).
|
||||||
|
const usage = usageTotals.get(jobId);
|
||||||
|
if (usage) jobStore.updateJob(jobId, { usage });
|
||||||
|
usageTotals.delete(jobId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the job's tool input: dispatch passes it directly; only a
|
/** Resolve the job's tool input: dispatch passes it directly; only a
|
||||||
@@ -132,6 +201,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
|
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
|
||||||
const job = jobStore.getJob(jobId);
|
const job = jobStore.getJob(jobId);
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
||||||
|
// the loop unwinds): no result write, no steer — the abort path owns
|
||||||
|
// the record and queues the abort steer itself.
|
||||||
|
if (job.status !== "running") return;
|
||||||
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
||||||
const steer = buildSubagentSteer(job, result);
|
const steer = buildSubagentSteer(job, result);
|
||||||
try {
|
try {
|
||||||
@@ -157,7 +230,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
|
|
||||||
// ─── research ───────────────────────────────────────────────────────────
|
// ─── research ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runResearchJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
async function runResearchJob(
|
||||||
|
jobId: string,
|
||||||
|
model: LanguageModel | undefined,
|
||||||
|
passedInput: JobToolInput | undefined,
|
||||||
|
loop: LoopContext,
|
||||||
|
): Promise<void> {
|
||||||
const ctx = jobContext(jobId);
|
const ctx = jobContext(jobId);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { job } = ctx;
|
const { job } = ctx;
|
||||||
@@ -181,6 +259,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
jobStore.updateJob(jobId, { activity: "researching" });
|
jobStore.updateJob(jobId, { activity: "researching" });
|
||||||
const result = await agent.generate({
|
const result = await agent.generate({
|
||||||
prompt,
|
prompt,
|
||||||
|
abortSignal: loop.abortSignal,
|
||||||
|
// Money channel: each agent step is one model call — fold its
|
||||||
|
// usage into the record as it completes (progress write).
|
||||||
|
onStepFinish: ({ usage }) => loop.onUsage(usage),
|
||||||
onToolExecutionStart: ({ toolCall }) => {
|
onToolExecutionStart: ({ toolCall }) => {
|
||||||
const input = toolCall.input as { query?: string; url?: string } | undefined;
|
const input = toolCall.input as { query?: string; url?: string } | undefined;
|
||||||
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
|
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
|
||||||
@@ -195,15 +277,24 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
|
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
|
||||||
await finishJob(jobId, research);
|
await finishJob(jobId, research);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Real-kill abort (2026-09): the abort path flipped the record and
|
||||||
|
// queued the abort steer — unwind silently, keep the usage
|
||||||
|
// folded so far (flushed by runJob's finally).
|
||||||
|
if (loop.abortSignal.aborted) return;
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
jobStore.updateJob(jobId, { error: reason });
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||||
await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
|
await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── demo + visual ──────────────────────────────────────────────────────
|
// ─── demo + visual ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runDemoJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
async function runDemoJob(
|
||||||
|
jobId: string,
|
||||||
|
model: LanguageModel | undefined,
|
||||||
|
passedInput: JobToolInput | undefined,
|
||||||
|
loop: LoopContext,
|
||||||
|
): Promise<void> {
|
||||||
const ctx = jobContext(jobId);
|
const ctx = jobContext(jobId);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { job } = ctx;
|
const { job } = ctx;
|
||||||
@@ -218,19 +309,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
{ title, brief },
|
{ title, brief },
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
|
abortSignal: loop.abortSignal,
|
||||||
|
onUsage: loop.onUsage,
|
||||||
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (isDemoFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
|
if (isDemoFailure(result) && stillRunning(jobId))
|
||||||
|
jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
|
||||||
await finishJob(jobId, result);
|
await finishJob(jobId, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (loop.abortSignal.aborted) return;
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
jobStore.updateJob(jobId, { error: reason });
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||||
await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
|
await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runVisualJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
async function runVisualJob(
|
||||||
|
jobId: string,
|
||||||
|
model: LanguageModel | undefined,
|
||||||
|
passedInput: JobToolInput | undefined,
|
||||||
|
loop: LoopContext,
|
||||||
|
): Promise<void> {
|
||||||
const ctx = jobContext(jobId);
|
const ctx = jobContext(jobId);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { job } = ctx;
|
const { job } = ctx;
|
||||||
@@ -244,14 +344,18 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
{ brief },
|
{ brief },
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
|
abortSignal: loop.abortSignal,
|
||||||
|
onUsage: loop.onUsage,
|
||||||
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (isVisualFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
|
if (isVisualFailure(result) && stillRunning(jobId))
|
||||||
|
jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
|
||||||
await finishJob(jobId, result);
|
await finishJob(jobId, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (loop.abortSignal.aborted) return;
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
jobStore.updateJob(jobId, { error: reason });
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||||
await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
|
await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
|
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { modelFor } from "../models.ts";
|
import { modelFor } from "../models.ts";
|
||||||
import type { VisualResult } from "../../core/contracts.ts";
|
import type { UsageStepLike, VisualResult } from "../../core/contracts.ts";
|
||||||
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +37,11 @@ export interface VisualMakerOptions {
|
|||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
/** Live activity-line callback (updates the job record). */
|
/** Live activity-line callback (updates the job record). */
|
||||||
onActivity?: (line: string) => void;
|
onActivity?: (line: string) => void;
|
||||||
|
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
/** Money channel: one call per loop iteration (generateText), folded
|
||||||
|
* into JobRecord.usage. */
|
||||||
|
onUsage?: (usage: UsageStepLike) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
|
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
|
||||||
@@ -135,7 +140,15 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||||
const result = await generateText({ model: opts.model ?? modelFor("visualMaker"), system: INSTRUCTIONS, messages, tools });
|
const result = await generateText({
|
||||||
|
model: opts.model ?? modelFor("visualMaker"),
|
||||||
|
system: INSTRUCTIONS,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
abortSignal: opts.abortSignal,
|
||||||
|
});
|
||||||
|
// Money channel: each iteration is one model call.
|
||||||
|
opts.onUsage?.(result.usage);
|
||||||
messages = [...messages, ...result.response.messages];
|
messages = [...messages, ...result.response.messages];
|
||||||
|
|
||||||
const toolCalls = result.toolCalls;
|
const toolCalls = result.toolCalls;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
||||||
*/
|
*/
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
|
import type { CallUsage } from "./chat.ts";
|
||||||
|
|
||||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||||
|
|
||||||
@@ -36,18 +37,64 @@ export interface JobRecord {
|
|||||||
|
|
||||||
/** A sub-agent run's accumulated usage — the same accounting shape as a
|
/** A sub-agent run's accumulated usage — the same accounting shape as a
|
||||||
* chat turn (contracts/chat.ts CallUsage), flattened to one running total
|
* chat turn (contracts/chat.ts CallUsage), flattened to one running total
|
||||||
* plus a step count. */
|
* plus a step count. Token fields are optional exactly like CallUsage: a
|
||||||
|
* field NO model call reported stays undefined — absent is not zero (the
|
||||||
|
* money-channel doctrine; cost renders "—", never $0). */
|
||||||
export interface JobUsage {
|
export interface JobUsage {
|
||||||
/** The OpenRouter model id that ran the sub-agent. */
|
/** The OpenRouter model id that ran the sub-agent. */
|
||||||
model: string;
|
model: string;
|
||||||
/** Running totals summed over the run's model calls so far. */
|
/** Running totals summed over the run's model calls so far. */
|
||||||
inputTokens: number;
|
inputTokens: number | undefined;
|
||||||
outputTokens: number;
|
outputTokens: number | undefined;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number | undefined;
|
||||||
cacheWriteTokens: number;
|
cacheWriteTokens: number | undefined;
|
||||||
reasoningTokens: number;
|
reasoningTokens: number | undefined;
|
||||||
/** How many model calls the run has made (each bills separately). */
|
/** How many model calls the run has made (each bills separately). */
|
||||||
steps: number;
|
steps: number;
|
||||||
|
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||||
|
* per-blob context line reads last-step input+output against the model's
|
||||||
|
* CONTEXT_LIMITS — never the cumulative totals. Absent until the first
|
||||||
|
* call completes; absent provider fields stay absent (never zero). */
|
||||||
|
lastStep?: CallUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-model-call usage shape we fold into JobUsage — the SDK's
|
||||||
|
* LanguageModelUsage narrowed structurally (no runtime "ai" import: any
|
||||||
|
* object with these fields folds, including the real thing and test fakes). */
|
||||||
|
export interface UsageStepLike {
|
||||||
|
inputTokens?: number | undefined;
|
||||||
|
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
||||||
|
outputTokens?: number | undefined;
|
||||||
|
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold one model call's usage into the run's running total (pure). A
|
||||||
|
* field neither the total nor the step reported stays undefined; once
|
||||||
|
* reported, later unreported calls contribute 0. The `steps` counter is
|
||||||
|
* the billing count: one per model call, reported or not. */
|
||||||
|
export function accumulateJobUsage(
|
||||||
|
total: JobUsage | undefined,
|
||||||
|
model: string,
|
||||||
|
step: UsageStepLike,
|
||||||
|
): JobUsage {
|
||||||
|
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||||
|
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||||
|
outputTokens: add(total?.outputTokens, step.outputTokens),
|
||||||
|
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||||
|
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||||
|
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||||
|
steps: (total?.steps ?? 0) + 1,
|
||||||
|
lastStep: {
|
||||||
|
inputTokens: step.inputTokens,
|
||||||
|
outputTokens: step.outputTokens,
|
||||||
|
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||||
|
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||||
|
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +202,11 @@ export interface SubagentResultMeta {
|
|||||||
jobId: string;
|
jobId: string;
|
||||||
kind: JobKind;
|
kind: JobKind;
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
|
/** Present only on an abort steer (2026-09 abort semantics): the
|
||||||
|
* learner aborted the job (it lands as error "Aborted by learner");
|
||||||
|
* the teacher is told so its promise of a result doesn't hang
|
||||||
|
* silently in the lesson. */
|
||||||
|
aborted?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The data-subagent-artifact part payload: what the UI renders. */
|
/** The data-subagent-artifact part payload: what the UI renders. */
|
||||||
@@ -178,6 +230,20 @@ export interface SubagentSteerMessage extends UIMessage {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The abort steer: user-role like any steer, but NO artifact part —
|
||||||
|
* there is no result to render, only the teacher-facing text. Shares the
|
||||||
|
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
|
||||||
|
* matches on metadata jobId, so an abort and a (racing) result steer
|
||||||
|
* can never BOTH deliver — exactly one wakes the teacher. */
|
||||||
|
export interface AbortSteerMessage extends UIMessage {
|
||||||
|
role: "user";
|
||||||
|
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
|
||||||
|
parts: [{ type: "text"; text: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything the wake pipeline may queue and deliver. */
|
||||||
|
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage;
|
||||||
|
|
||||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||||
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
||||||
if (kind === "research") {
|
if (kind === "research") {
|
||||||
@@ -234,6 +300,32 @@ export function buildSubagentSteer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the abort steer for an aborted job (pure — shared by the
|
||||||
|
* abort path in src/server/jobs.ts and tests). Same id space as the result
|
||||||
|
* steer; see AbortSteerMessage. */
|
||||||
|
export function buildAbortSteer(
|
||||||
|
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||||
|
): AbortSteerMessage {
|
||||||
|
return {
|
||||||
|
id: `steer_${job.id}`,
|
||||||
|
role: "user",
|
||||||
|
metadata: {
|
||||||
|
subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: abortText(job.kind) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the teacher reads: one line telling it the result is not coming,
|
||||||
|
* how to acknowledge, and that re-dispatching is allowed. */
|
||||||
|
function abortText(kind: JobKind): string {
|
||||||
|
if (kind === "research")
|
||||||
|
return "Research aborted — the learner aborted the research job, so no brief is coming. Acknowledge it in one line and continue teaching without it (or offer to re-dispatch it).";
|
||||||
|
if (kind === "demo")
|
||||||
|
return "The demo job was aborted by the learner — no demo is coming. Acknowledge it in one line and teach the topic in prose instead (a re-dispatch is fine if the learner wants it).";
|
||||||
|
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the transcript already carries this job's steer (idempotent delivery). */
|
/** True when the transcript already carries this job's steer (idempotent delivery). */
|
||||||
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
|
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
|
||||||
return messages.some(
|
return messages.some(
|
||||||
|
|||||||
+73
-7
@@ -33,13 +33,15 @@
|
|||||||
|
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import {
|
import {
|
||||||
|
buildAbortSteer,
|
||||||
buildSubagentSteer,
|
buildSubagentSteer,
|
||||||
isSubagentFailure,
|
isSubagentFailure,
|
||||||
steerDelivered,
|
steerDelivered,
|
||||||
type JobRecord,
|
type JobRecord,
|
||||||
type SubagentSteerMessage,
|
type SteerMessage,
|
||||||
} from "../core/contracts.ts";
|
} from "../core/contracts.ts";
|
||||||
import { findPendingToolCall } from "../core/pending.ts";
|
import { findPendingToolCall } from "../core/pending.ts";
|
||||||
|
import { fireJobAbort } from "../agent/subagents/aborts.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<numb
|
|||||||
export type JobRespawn = (jobId: string) => void;
|
export type JobRespawn = (jobId: string) => void;
|
||||||
|
|
||||||
interface QueuedSteer {
|
interface QueuedSteer {
|
||||||
steer: SubagentSteerMessage;
|
steer: SteerMessage;
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (reason: Error) => void;
|
reject: (reason: Error) => void;
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobIdOf(steer: SubagentSteerMessage): string {
|
function jobIdOf(steer: SteerMessage): string {
|
||||||
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
|
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ function jobIdOf(steer: SubagentSteerMessage): string {
|
|||||||
* has completed (and the job status flipped); rejects when delivery fails
|
* has completed (and the job status flipped); rejects when delivery fails
|
||||||
* unrecoverably. Never rejects for queueing itself.
|
* unrecoverably. Never rejects for queueing itself.
|
||||||
*/
|
*/
|
||||||
export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
|
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const s = state();
|
const s = state();
|
||||||
const chatId = chatIdOf(steer);
|
const chatId = chatIdOf(steer);
|
||||||
@@ -145,13 +147,13 @@ export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
|
|||||||
* before firing, so a claim racing the pump converges to exactly one
|
* before firing, so a claim racing the pump converges to exactly one
|
||||||
* delivery either way.
|
* delivery either way.
|
||||||
*/
|
*/
|
||||||
export function claimSteers(chatId: string): SubagentSteerMessage[] {
|
export function claimSteers(chatId: string): SteerMessage[] {
|
||||||
const s = state();
|
const s = state();
|
||||||
const queue = s.queues.get(chatId);
|
const queue = s.queues.get(chatId);
|
||||||
if (!queue || queue.length === 0) return [];
|
if (!queue || queue.length === 0) return [];
|
||||||
const record = stores.chatStore.getChat(chatId);
|
const record = stores.chatStore.getChat(chatId);
|
||||||
if (!record) return []; // the handler validated the chat; defensive only
|
if (!record) return []; // the handler validated the chat; defensive only
|
||||||
const claimed: SubagentSteerMessage[] = [];
|
const claimed: SteerMessage[] = [];
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
const jobId = jobIdOf(entry.steer);
|
const jobId = jobIdOf(entry.steer);
|
||||||
if (steerDelivered(record.messages, jobId)) {
|
if (steerDelivered(record.messages, jobId)) {
|
||||||
@@ -167,7 +169,66 @@ export function claimSteers(chatId: string): SubagentSteerMessage[] {
|
|||||||
return claimed;
|
return claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatIdOf(steer: SubagentSteerMessage): string {
|
/** Result of an abort attempt — the endpoint maps these to HTTP. */
|
||||||
|
export interface AbortOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: "not_found" | "not_running";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ABORT (2026-09 dock semantics, user-decided): real kill, error with
|
||||||
|
* reason, the teacher still learns. The dock's END button reaches this
|
||||||
|
* through POST /api/activity/abort (body {kind: "job", id: jobId}); the
|
||||||
|
* route is master's thin composition — this is the job-side mechanism.
|
||||||
|
* Four things happen, in order:
|
||||||
|
*
|
||||||
|
* 1. KILL the spend: fire the job's registered AbortController (the
|
||||||
|
* runner registered it at loop start) — the in-flight model call
|
||||||
|
* aborts, not just the next loop check. The runner unwinds silently
|
||||||
|
* (its catch checks abortSignal.aborted; finishJob checks status).
|
||||||
|
* 2. DROP any queued result steer for this job (the delivering-result
|
||||||
|
* phase): resolve its entry without firing the wake. If the wake
|
||||||
|
* turn is ALREADY in flight (the pump shifted the entry), it's too
|
||||||
|
* late — the result steer lands, and the cancellation steer dedupes
|
||||||
|
* against it via steerDelivered (same jobId metadata).
|
||||||
|
* 3. FLIP the record to error "Aborted by learner" immediately — the
|
||||||
|
* poll reflects the user's action at once (the user chose
|
||||||
|
* error-with-reason over a distinct cancelled status). finalizeJob
|
||||||
|
* never overwrites a non-running status, so the flip is terminal no
|
||||||
|
* matter what the wake does.
|
||||||
|
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
|
||||||
|
* the same wake pipeline, so the teacher's promise of a result
|
||||||
|
* doesn't hang silently in the lesson.
|
||||||
|
*
|
||||||
|
* The runner writes the usage folded so far in its finally (merge patch),
|
||||||
|
* so an aborted job's money record survives the kill.
|
||||||
|
*/
|
||||||
|
export function abortJob(jobId: string): AbortOutcome {
|
||||||
|
const job = stores.jobStore.getJob(jobId);
|
||||||
|
if (!job) return { ok: false, reason: "not_found" };
|
||||||
|
if (job.status !== "running") return { ok: false, reason: "not_running" };
|
||||||
|
|
||||||
|
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
|
||||||
|
|
||||||
|
// Drop any queued steers for this job (their deliver() resolves unfired).
|
||||||
|
const queue = state().queues.get(job.chatId);
|
||||||
|
if (queue) {
|
||||||
|
const remaining = queue.filter((e) => jobIdOf(e.steer) !== jobId);
|
||||||
|
if (remaining.length === 0) state().queues.delete(job.chatId);
|
||||||
|
else state().queues.set(job.chatId, remaining);
|
||||||
|
for (const entry of queue) {
|
||||||
|
if (jobIdOf(entry.steer) === jobId) entry.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
|
||||||
|
void deliverJobResult(buildAbortSteer(job)).catch(() => {
|
||||||
|
/* deliverJobResult marks the job error on unrecoverable failure */
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatIdOf(steer: SteerMessage): string {
|
||||||
// The chat is resolvable from the job record; the steer's metadata
|
// The chat is resolvable from the job record; the steer's metadata
|
||||||
// carries jobId, which indexes the store.
|
// carries jobId, which indexes the store.
|
||||||
const jobId = jobIdOf(steer);
|
const jobId = jobIdOf(steer);
|
||||||
@@ -285,6 +346,11 @@ function finalizeJob(jobId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function failJob(jobId: string, err: Error): void {
|
function failJob(jobId: string, err: Error): void {
|
||||||
|
// An aborted job stays aborted even when its (abort) steer
|
||||||
|
// delivery fails — the user's action is the terminal fact, not the
|
||||||
|
// delivery error. Non-running records are never overwritten here.
|
||||||
|
const job = stores.jobStore.getJob(jobId);
|
||||||
|
if (!job || job.status !== "running") return;
|
||||||
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
|
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -49,7 +49,7 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
|||||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||||
import { buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
||||||
import type { JobRecord } from "../src/core/contracts.ts";
|
import type { JobRecord } from "../src/core/contracts.ts";
|
||||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
@@ -342,6 +342,18 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics)", () => {
|
||||||
|
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
|
||||||
|
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||||
|
assert.equal(steer.parts.length, 1, "no artifact part on an abort");
|
||||||
|
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||||
|
assert.match(html, /job-aborted-badge/);
|
||||||
|
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
|
||||||
|
assert.match(html, /aborted by the learner/);
|
||||||
|
// the teacher-facing protocol text never renders for the learner
|
||||||
|
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => {
|
it("renders a quiet Research-complete one-liner whose collapsed <details> carries the full brief", () => {
|
||||||
const steer = buildSubagentSteer(
|
const steer = buildSubagentSteer(
|
||||||
{ id: "jr", kind: "research", toolCallId: "cr" },
|
{ id: "jr", kind: "research", toolCallId: "cr" },
|
||||||
|
|||||||
+246
-1
@@ -31,18 +31,23 @@ import type { UIMessage } from "ai";
|
|||||||
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
|
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
|
||||||
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
||||||
import {
|
import {
|
||||||
|
accumulateJobUsage,
|
||||||
|
buildAbortSteer,
|
||||||
buildSubagentSteer,
|
buildSubagentSteer,
|
||||||
isSubagentFailure,
|
isSubagentFailure,
|
||||||
steerDelivered,
|
steerDelivered,
|
||||||
type DemoResult,
|
type DemoResult,
|
||||||
|
type JobUsage,
|
||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
|
type SteerMessage,
|
||||||
type SubagentSteerMessage,
|
type SubagentSteerMessage,
|
||||||
type VisualResult,
|
type VisualResult,
|
||||||
} from "../src/core/contracts.ts";
|
} from "../src/core/contracts.ts";
|
||||||
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
import { abortJob, claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
||||||
|
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
@@ -170,6 +175,57 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
assert.deepEqual(data.data.result, research);
|
assert.deepEqual(data.data.result, research);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accumulateJobUsage: sums per model call; fields NO call reported stay undefined (absent is not zero)", () => {
|
||||||
|
let total: JobUsage | undefined = undefined;
|
||||||
|
total = accumulateJobUsage(total, "z-ai/glm-5.3-flash", {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
inputTokenDetails: { cacheReadTokens: 4 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(total, {
|
||||||
|
model: "z-ai/glm-5.3-flash",
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
cacheReadTokens: 4,
|
||||||
|
cacheWriteTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
steps: 1,
|
||||||
|
lastStep: {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
cacheReadTokens: 4,
|
||||||
|
cacheWriteTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
},
|
||||||
|
}, "lastStep is the most recent call's accounting, not cumulative");
|
||||||
|
// a later call that reports nothing new keeps reported fields summing
|
||||||
|
// (unreported contributes 0) and bumps the billing step count.
|
||||||
|
total = accumulateJobUsage(total, "z-ai/glm-5.3-flash", { inputTokens: 20, outputTokens: 0 });
|
||||||
|
assert.equal(total.inputTokens, 30);
|
||||||
|
assert.equal(total.outputTokens, 5);
|
||||||
|
assert.equal(total.cacheReadTokens, 4, "unreported after reported contributes 0");
|
||||||
|
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
|
||||||
|
assert.equal(total.reasoningTokens, undefined);
|
||||||
|
assert.equal(total.steps, 2, "one step per model call, reported or not");
|
||||||
|
assert.deepEqual(total.lastStep, {
|
||||||
|
inputTokens: 20,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: undefined,
|
||||||
|
cacheWriteTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
}, "lastStep always mirrors the LATEST call, never the sums");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildAbortSteer: metadata-aborted, text-only (no artifact), same deterministic id space as the result steer", () => {
|
||||||
|
const steer = buildAbortSteer({ id: "job_c", kind: "demo", toolCallId: "call-c" });
|
||||||
|
assert.equal(steer.id, "steer_job_c", "same id space — steerDelivered treats either as delivered");
|
||||||
|
assert.equal(steer.role, "user");
|
||||||
|
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||||
|
assert.equal(steer.metadata.subagentResult.jobId, "job_c");
|
||||||
|
assert.equal(steer.parts.length, 1);
|
||||||
|
assert.ok(steer.parts[0]!.type === "text" && /aborted/.test(steer.parts[0].text));
|
||||||
|
});
|
||||||
|
|
||||||
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
||||||
assert.equal(isSubagentFailure("research", research), false);
|
assert.equal(isSubagentFailure("research", research), false);
|
||||||
assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [] }), true);
|
assert.equal(isSubagentFailure("research", { brief: "Research failed: x", sources: [] }), true);
|
||||||
@@ -836,3 +892,192 @@ describe("prompt composition", () => {
|
|||||||
assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard");
|
assert.ok(composeInstructions().length <= 60_000, "composed system prompt over the 60k guard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── abort (2026-09 semantics: real kill, cancelled status, teacher told) ───
|
||||||
|
|
||||||
|
describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||||
|
/** A hanging model that only settles when its abort signal fires — the
|
||||||
|
* real-kill path: the in-flight call itself is killed. */
|
||||||
|
function hangingModel(): MockLanguageModelV4 {
|
||||||
|
return new MockLanguageModelV4({
|
||||||
|
doGenerate: ({ abortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
// an ALREADY-aborted signal never fires the event — real
|
||||||
|
// providers reject an aborted fetch at once, the mock must too
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step (with usage) then a hang — proves usage-so-far survives the kill. */
|
||||||
|
function oneStepThenHangModel(): MockLanguageModelV4 {
|
||||||
|
let turn = 0;
|
||||||
|
return new MockLanguageModelV4({
|
||||||
|
doGenerate: ({ abortSignal }) => {
|
||||||
|
turn++;
|
||||||
|
if (turn === 1) {
|
||||||
|
return Promise.resolve({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call" as const,
|
||||||
|
toolCallId: "ws1",
|
||||||
|
toolName: "web_search",
|
||||||
|
input: JSON.stringify({ query: "anything" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
|
||||||
|
usage: usage(),
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChatWithResearchJob(label: string, toolCallId: string): { chatId: string; jobId: string } {
|
||||||
|
const chat = chatStore.createChat(`Test: ${label}`);
|
||||||
|
trackChat(chat.id);
|
||||||
|
chatStore.saveMessages(chat.id, [
|
||||||
|
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: "Checking." },
|
||||||
|
{ type: "tool-research", toolCallId, state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId });
|
||||||
|
trackJob(job.id);
|
||||||
|
return { chatId: chat.id, jobId: job.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("kills the running loop: error \"Aborted by learner\" immediately, loop unwinds silently, teacher gets the abort steer", async () => {
|
||||||
|
const { jobId } = newChatWithResearchJob("abort running job", "call-abort1");
|
||||||
|
const fired: SteerMessage[] = [];
|
||||||
|
setTurnRunner(async (_cid, messages) => {
|
||||||
|
fired.push(messages[messages.length - 1] as SteerMessage);
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
researchModelOverride.current = hangingModel();
|
||||||
|
const runner = createJobRunner({
|
||||||
|
chatStore,
|
||||||
|
jobStore,
|
||||||
|
deliver: async () => {
|
||||||
|
throw new Error("a cancelled job must never deliver a result steer");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const settled = runner.runJob(jobId, RESEARCH_INPUT);
|
||||||
|
await waitFor(() => jobStore.getJob(jobId)!.activity === "researching", 8000, "loop started");
|
||||||
|
|
||||||
|
const outcome = abortJob(jobId);
|
||||||
|
assert.ok(outcome.ok);
|
||||||
|
const killed = jobStore.getJob(jobId)!;
|
||||||
|
assert.equal(killed.status, "error", "flipped synchronously — the poll sees it at once");
|
||||||
|
assert.equal(killed.error, "Aborted by learner", "abort reads as an error with a reason, not a failure");
|
||||||
|
assert.equal(killed.activity, "aborted");
|
||||||
|
|
||||||
|
await settled; // unwinds — no hang
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.status, "error", "the runner never overwrites the flip");
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.error, "Aborted by learner");
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.result, undefined, "no result on an aborted job");
|
||||||
|
|
||||||
|
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
||||||
|
const last = fired[0]!;
|
||||||
|
assert.equal(last.metadata.subagentResult.aborted, true);
|
||||||
|
assert.equal(last.id, `steer_${jobId}`);
|
||||||
|
assert.equal(last.parts.length, 1, "text only — no artifact part");
|
||||||
|
// aborted is terminal: the wake completing must not flip it
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.status, "error", "terminal after the wake");
|
||||||
|
} finally {
|
||||||
|
researchModelOverride.current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the usage folded so far survives the kill (money channel outlives the abort)", async () => {
|
||||||
|
const restoreFetch = stubFetch();
|
||||||
|
try {
|
||||||
|
const { jobId } = newChatWithResearchJob("abort keeps usage", "call-abort2");
|
||||||
|
setTurnRunner(async () => 200);
|
||||||
|
researchModelOverride.current = oneStepThenHangModel();
|
||||||
|
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {} });
|
||||||
|
try {
|
||||||
|
const settled = runner.runJob(jobId, RESEARCH_INPUT);
|
||||||
|
await waitFor(() => (jobStore.getJob(jobId)!.usage?.steps ?? 0) === 1, 8000, "first step folded");
|
||||||
|
abortJob(jobId);
|
||||||
|
await settled;
|
||||||
|
const job = jobStore.getJob(jobId)!;
|
||||||
|
assert.equal(job.status, "error");
|
||||||
|
assert.equal(job.error, "Aborted by learner");
|
||||||
|
assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally");
|
||||||
|
assert.equal(job.usage!.model, "z-ai/glm-5.3-flash", "the config role's OpenRouter id");
|
||||||
|
assert.equal(job.usage!.inputTokens, 10);
|
||||||
|
assert.equal(job.usage!.outputTokens, 5);
|
||||||
|
} finally {
|
||||||
|
researchModelOverride.current = undefined;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restoreFetch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
|
||||||
|
const chat = chatStore.createChat("Test: abort delivering job");
|
||||||
|
trackChat(chat.id);
|
||||||
|
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
||||||
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
|
||||||
|
trackJob(job.id);
|
||||||
|
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
|
||||||
|
jobStore.updateJob(job.id, { result, activity: "delivering result" });
|
||||||
|
|
||||||
|
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
|
||||||
|
assert.ok(beginTurn(chat.id));
|
||||||
|
const fired: SteerMessage[] = [];
|
||||||
|
setTurnRunner(async (_cid, messages) => {
|
||||||
|
fired.push(messages[messages.length - 1] as SteerMessage);
|
||||||
|
return 200;
|
||||||
|
});
|
||||||
|
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
|
||||||
|
/* dropped by the abort below */
|
||||||
|
});
|
||||||
|
await waitFor(() => jobStore.getJob(job.id)!.activity === "delivering result", 8000, "queued");
|
||||||
|
|
||||||
|
const outcome = abortJob(job.id);
|
||||||
|
assert.ok(outcome.ok);
|
||||||
|
assert.equal(jobStore.getJob(job.id)!.status, "error");
|
||||||
|
assert.equal(jobStore.getJob(job.id)!.error, "Aborted by learner");
|
||||||
|
|
||||||
|
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
||||||
|
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
||||||
|
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
||||||
|
assert.equal(fired[0]!.metadata.subagentResult.aborted, true, "the abort steer, not the result");
|
||||||
|
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome reasons: not_found for unknown, not_running for settled jobs", () => {
|
||||||
|
assert.deepEqual(abortJob("job_does_not_exist"), { ok: false, reason: "not_found" });
|
||||||
|
const chat = chatStore.createChat("Test: abort settled job");
|
||||||
|
trackChat(chat.id);
|
||||||
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort4" });
|
||||||
|
trackJob(job.id);
|
||||||
|
jobStore.updateJob(job.id, { status: "done", activity: "delivered" });
|
||||||
|
assert.deepEqual(abortJob(job.id), { ok: false, reason: "not_running" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user