2026-09-12 02:34:18 +03:00
|
|
|
|
import type { PiEngine } from "../models.ts";
|
2026-09-22 13:55:01 +03:00
|
|
|
|
import type { SearchProvider } from "../../core/search.ts";
|
2026-09-01 19:15:43 +03:00
|
|
|
|
import {
|
2026-09-11 03:25:09 +03:00
|
|
|
|
accumulateJobUsage,
|
2026-09-01 19:15:43 +03:00
|
|
|
|
buildSubagentSteer,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
piUsageToStep,
|
2026-09-01 19:15:43 +03:00
|
|
|
|
type DemoResult,
|
|
|
|
|
|
type DemoToolInput,
|
|
|
|
|
|
type JobKind,
|
|
|
|
|
|
type JobRecord,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
type JobUsage,
|
2026-09-19 15:24:26 +03:00
|
|
|
|
type PdfResult,
|
|
|
|
|
|
type PdfToolInput,
|
2026-09-01 19:15:43 +03:00
|
|
|
|
type ResearchResult,
|
|
|
|
|
|
type ResearchToolInput,
|
2026-09-26 00:37:46 +03:00
|
|
|
|
type SteerMessage,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
type UsageStepLike,
|
2026-09-01 19:15:43 +03:00
|
|
|
|
type VisualResult,
|
|
|
|
|
|
type VisualToolInput,
|
|
|
|
|
|
} from "../../core/contracts.ts";
|
2026-09-11 03:25:09 +03:00
|
|
|
|
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
2026-09-22 16:58:25 +03:00
|
|
|
|
import type { ModelRole } from "../../../config.ts";
|
2026-09-24 22:51:12 +03:00
|
|
|
|
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
|
2026-08-31 22:16:03 +03:00
|
|
|
|
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
2026-08-22 10:24:02 +03:00
|
|
|
|
import { runDemoBuilder } from "./demo-builder.ts";
|
2026-09-19 15:24:26 +03:00
|
|
|
|
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
2026-09-12 02:34:18 +03:00
|
|
|
|
import { runResearcherAgent } from "./researcher.ts";
|
2026-08-22 10:24:02 +03:00
|
|
|
|
import { runVisualMaker } from "./visual-maker.ts";
|
2026-08-22 09:36:15 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-01 19:15:43 +03:00
|
|
|
|
* The background job runner (contracts.ts "THE ASYNC PROTOCOL" §2–3), created
|
|
|
|
|
|
* via createJobRunner({ chatStore, jobStore, deliver }) — the composition root
|
|
|
|
|
|
* (chat-handler) injects the storage ports and the WAKE SEAM (the server-side
|
|
|
|
|
|
* steer delivery, src/server/jobs.ts); this module never imports an adapter.
|
2026-08-22 09:36:15 +03:00
|
|
|
|
*
|
2026-09-01 19:15:43 +03:00
|
|
|
|
* runJob(jobId) — runs the kind's sub-agent loop in the background
|
|
|
|
|
|
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
|
|
|
|
|
|
* updating the job's activity line on every iteration. On completion the
|
|
|
|
|
|
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
|
|
|
|
|
|
* `deliver`; the wake pipeline fires the server-side teacher turn and
|
|
|
|
|
|
* flips the job to done/error when the lesson has actually resumed.
|
2026-08-22 09:36:15 +03:00
|
|
|
|
*
|
2026-09-01 19:15:43 +03:00
|
|
|
|
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
|
|
|
|
|
|
* notes; research: failure brief) — and it STILL steers, because the
|
|
|
|
|
|
* teacher must learn the outcome. If delivery itself fails, the job is
|
|
|
|
|
|
* marked error (the learner's activity card shows it).
|
2026-08-22 09:36:15 +03:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-01 19:15:43 +03:00
|
|
|
|
* Test seam: the spawn wiring has no injection point at the tool layer, so
|
2026-09-12 02:34:18 +03:00
|
|
|
|
* tests swap in a scripted pi engine here to keep job tests off the network.
|
2026-09-01 19:15:43 +03:00
|
|
|
|
* Undefined in production. (The jobs spawn through the module-level KIND
|
|
|
|
|
|
* runners, which read these at call time.)
|
2026-08-22 09:36:15 +03:00
|
|
|
|
*/
|
2026-09-12 02:34:18 +03:00
|
|
|
|
export const researchModelOverride: { current?: PiEngine } = {};
|
|
|
|
|
|
export const demoModelOverride: { current?: PiEngine } = {};
|
|
|
|
|
|
export const visualModelOverride: { current?: PiEngine } = {};
|
2026-09-19 15:24:26 +03:00
|
|
|
|
export const pdfModelOverride: { current?: PiEngine } = {};
|
2026-08-22 09:36:15 +03:00
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
|
2026-09-19 15:24:26 +03:00
|
|
|
|
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput | PdfToolInput;
|
2026-09-01 19:15:43 +03:00
|
|
|
|
|
2026-08-31 22:16:03 +03:00
|
|
|
|
export interface JobRunnerDeps {
|
|
|
|
|
|
chatStore: ChatStore;
|
|
|
|
|
|
jobStore: JobStore;
|
2026-09-01 19:15:43 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Hand a finished job's steer to the wake pipeline — resolves when the
|
|
|
|
|
|
* steer is delivered (the wake turn completed and the job status
|
|
|
|
|
|
* flipped); rejects when delivery fails unrecoverably.
|
|
|
|
|
|
*/
|
2026-09-26 00:37:46 +03:00
|
|
|
|
deliver: (steer: SteerMessage) => Promise<void>;
|
2026-09-22 17:41:07 +03:00
|
|
|
|
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
|
|
|
|
|
* the role-override → slot chain, read FRESH per job (the server
|
|
|
|
|
|
* wiring passes effectiveSettings-based resolution). Test overrides
|
|
|
|
|
|
* (below) still win. BYOK (owner, 2026-09-22): the chain may end
|
|
|
|
|
|
* EMPTY → undefined; runJob then refuses the spawn honestly instead
|
|
|
|
|
|
* of guessing a model. */
|
2026-09-22 16:58:25 +03:00
|
|
|
|
engineFor?: (role: ModelRole) => PiEngine | undefined;
|
2026-09-22 13:55:01 +03:00
|
|
|
|
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
|
|
|
|
|
* provider fresh per job — server/search.ts composition injected by
|
|
|
|
|
|
* the wiring; null = honestly unavailable (the researcher's web_search
|
|
|
|
|
|
* reports it). Absent seam (tests) reads the same. The dispatch-level
|
|
|
|
|
|
* drop (jobTools' searchAvailable) uses the same signal — jobs already
|
|
|
|
|
|
* in flight when a key disappears still fail honestly, never crash. */
|
|
|
|
|
|
searchProvider?: () => SearchProvider | null;
|
2026-09-24 22:51:12 +03:00
|
|
|
|
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
|
|
|
|
|
|
* resolved engine's provider + the kind's slot tier, return the
|
|
|
|
|
|
* refusal envelope when the provider's key is missing (null = run).
|
|
|
|
|
|
* Injected because the agent layer may not read the key store; absent
|
|
|
|
|
|
* in tests that inject engines directly (test engines never owe a key). */
|
|
|
|
|
|
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
|
2026-08-22 10:24:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 22:16:03 +03:00
|
|
|
|
export interface JobRunner {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
/** Run one job's sub-agent loop to completion (background; never throws).
|
|
|
|
|
|
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
|
|
|
|
|
* before the tool part is persisted — only crash-recovery re-spawns
|
|
|
|
|
|
* resolve the input from the transcript). */
|
2026-09-19 15:24:26 +03:00
|
|
|
|
runJob(jobId: string, input?: JobToolInput): Promise<void>;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
2026-08-22 10:24:02 +03:00
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:14:56 +03:00
|
|
|
|
/** The kind→role table (model slots wave, 2026-09-20): which ModelRole's
|
|
|
|
|
|
* engine each sub-agent kind runs — the engineFor injection key. */
|
2026-09-23 15:24:48 +03:00
|
|
|
|
const KIND_ROLE: Record<JobKind, ModelRole> = {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
research: "researcher",
|
|
|
|
|
|
demo: "demoBuilder",
|
|
|
|
|
|
visual: "visualMaker",
|
|
|
|
|
|
pdf: "pdfReader",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-31 22:16:03 +03:00
|
|
|
|
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const { chatStore, jobStore, deliver } = deps;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
/** 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>();
|
|
|
|
|
|
|
2026-09-22 16:58:25 +03:00
|
|
|
|
function makeLoopContext(jobId: string, engine: PiEngine): LoopContext {
|
|
|
|
|
|
// Provider-qualified stamp: the engine's actual model — the only
|
|
|
|
|
|
// thing that ran (BYOK: no config-pin fallback stamps exist).
|
|
|
|
|
|
const model = `${engine.model.provider}:${engine.model.id}`;
|
2026-09-11 03:25:09 +03:00
|
|
|
|
// 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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 15:24:48 +03:00
|
|
|
|
/** The kind→runner map: one sub-agent loop per kind. Each kind's
|
|
|
|
|
|
* runner narrows the union input itself (dispatch-time inputs are
|
|
|
|
|
|
* kind-correct by construction; transcript fallbacks are cast after). */
|
2026-09-22 00:14:56 +03:00
|
|
|
|
/** The engine one job runs on: test override first (the old seam —
|
|
|
|
|
|
* tests swap scripted engines without touching deps), then the injected
|
2026-09-22 16:58:25 +03:00
|
|
|
|
* settings-resolved engineFor. undefined = the BYOK chain came up
|
|
|
|
|
|
* empty (owner, 2026-09-22) — runJob refuses below, honestly. */
|
2026-09-23 15:24:48 +03:00
|
|
|
|
function pickEngine(kind: JobKind): PiEngine | undefined {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
const testOverride: Record<string, { current?: PiEngine } | undefined> = {
|
|
|
|
|
|
researcher: researchModelOverride,
|
|
|
|
|
|
demoBuilder: demoModelOverride,
|
|
|
|
|
|
visualMaker: visualModelOverride,
|
|
|
|
|
|
pdfReader: pdfModelOverride,
|
|
|
|
|
|
};
|
|
|
|
|
|
return testOverride[KIND_ROLE[kind]]?.current ?? deps.engineFor?.(KIND_ROLE[kind]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
const KIND_RUNNERS: Record<
|
2026-09-23 15:24:48 +03:00
|
|
|
|
JobKind,
|
2026-09-22 16:58:25 +03:00
|
|
|
|
(jobId: string, engine: PiEngine, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
|
2026-09-11 03:25:09 +03:00
|
|
|
|
> = {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
research: (jobId, engine, input, loop) => runResearchJob(jobId, engine, input, loop),
|
|
|
|
|
|
demo: (jobId, engine, input, loop) => runDemoJob(jobId, engine, input, loop),
|
|
|
|
|
|
visual: (jobId, engine, input, loop) => runVisualJob(jobId, engine, input, loop),
|
|
|
|
|
|
pdf: (jobId, engine, input, loop) => runPdfJob(jobId, engine, input, loop),
|
2026-08-31 22:16:03 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
|
|
|
|
|
const job = jobStore.getJob(jobId);
|
|
|
|
|
|
if (!job) {
|
|
|
|
|
|
console.error(`[job ${jobId}] not found — nothing to run`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (job.status !== "running") return; // already finished / re-run after crash recovery
|
|
|
|
|
|
const run = KIND_RUNNERS[job.kind];
|
|
|
|
|
|
if (!run) {
|
|
|
|
|
|
console.error(`[job ${jobId}] unknown kind: ${job.kind}`);
|
|
|
|
|
|
jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-22 16:58:25 +03:00
|
|
|
|
const engine = pickEngine(job.kind);
|
2026-09-24 22:51:12 +03:00
|
|
|
|
// The per-need refusal guards (BYOK 2026-09-22 / provider-key
|
|
|
|
|
|
// interlude 2026-09-25): no engine for the kind's tier, or the
|
|
|
|
|
|
// resolved engine's provider key gone — both refuse through the SAME
|
|
|
|
|
|
// convention as every other failed job: record the honest error (no
|
|
|
|
|
|
// status flip: finalizeJob owns the terminal flip after delivery),
|
|
|
|
|
|
// then steer the kind's failure shape through finishJob so the wake
|
|
|
|
|
|
// pipeline resumes the lesson and the teacher reads the refusal
|
|
|
|
|
|
// instead of waiting forever on a job it already dispatched.
|
|
|
|
|
|
const refuse = async (reason: string): Promise<void> => {
|
2026-09-22 17:41:07 +03:00
|
|
|
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
|
|
|
|
|
const resolved = resolveInput(job, input) as
|
|
|
|
|
|
| ResearchToolInput
|
|
|
|
|
|
| DemoToolInput
|
|
|
|
|
|
| VisualToolInput
|
|
|
|
|
|
| PdfToolInput
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
const pdf = resolved as PdfToolInput | undefined;
|
|
|
|
|
|
const failure: ResearchResult | DemoResult | VisualResult | PdfResult =
|
|
|
|
|
|
job.kind === "research"
|
|
|
|
|
|
? { brief: `Research failed: ${reason}`, sources: [] }
|
|
|
|
|
|
: job.kind === "demo"
|
|
|
|
|
|
? {
|
|
|
|
|
|
title: typeof (resolved as DemoToolInput | undefined)?.title === "string" && (resolved as DemoToolInput).title.trim() ? (resolved as DemoToolInput).title : "Interactive demo",
|
|
|
|
|
|
html: "",
|
|
|
|
|
|
tested: true,
|
|
|
|
|
|
notes: `FAILED: ${reason}`,
|
|
|
|
|
|
}
|
|
|
|
|
|
: job.kind === "visual"
|
|
|
|
|
|
? { svg: "", notes: `FAILED: ${reason}` }
|
|
|
|
|
|
: {
|
|
|
|
|
|
path: typeof pdf?.path === "string" ? pdf.path : "",
|
|
|
|
|
|
question: typeof pdf?.question === "string" ? pdf.question : "",
|
|
|
|
|
|
brief: `PDF failed: ${reason}`,
|
|
|
|
|
|
pagesRead: [],
|
|
|
|
|
|
pagesConsulted: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
await finishJob(jobId, failure);
|
2026-09-24 22:51:12 +03:00
|
|
|
|
};
|
|
|
|
|
|
if (!engine) {
|
|
|
|
|
|
const tier = ROLE_SLOT[KIND_ROLE[job.kind]];
|
|
|
|
|
|
const envelope = modelsNotConfigured([tier]);
|
|
|
|
|
|
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
|
|
|
|
|
|
await refuse(`${envelope.error} (missing: ${tier})`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Key presence (2026-09-25): the engine exists, but its provider's
|
|
|
|
|
|
// key may be gone — refuse the spawn BEFORE the loop, same
|
|
|
|
|
|
// convention (the injected check reads the key store; absent in
|
|
|
|
|
|
// engine-injecting tests).
|
|
|
|
|
|
const keyRefusal =
|
|
|
|
|
|
deps.providerKeyMissing?.(engine.model.provider, ROLE_SLOT[KIND_ROLE[job.kind]]) ?? null;
|
|
|
|
|
|
if (keyRefusal) {
|
|
|
|
|
|
console.info(`[job ${jobId}] refused — ${JSON.stringify(keyRefusal)}`);
|
|
|
|
|
|
await refuse(keyRefusal.error);
|
2026-09-22 16:58:25 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-11 03:25:09 +03:00
|
|
|
|
try {
|
2026-09-22 16:58:25 +03:00
|
|
|
|
await run(jobId, engine, input, makeLoopContext(jobId, engine));
|
2026-09-11 03:25:09 +03:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
2026-09-01 19:15:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Resolve the job's tool input: dispatch passes it directly; only a
|
|
|
|
|
|
* crash-recovery re-spawn (no input) falls back to scanning the
|
|
|
|
|
|
* transcript (by then onFinish has long persisted the part). */
|
|
|
|
|
|
function resolveInput(
|
|
|
|
|
|
job: JobRecord,
|
|
|
|
|
|
input: JobToolInput | undefined,
|
2026-09-19 15:24:26 +03:00
|
|
|
|
): JobToolInput | undefined {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
if (input) return input;
|
|
|
|
|
|
const chat = chatStore.getChat(job.chatId);
|
|
|
|
|
|
if (!chat) return undefined;
|
2026-09-26 01:52:47 +03:00
|
|
|
|
// Wave D: the record is native history — the scan reads the native
|
|
|
|
|
|
// assistant blocks directly (a toolCall's arguments are the input).
|
|
|
|
|
|
for (const m of chat.messages) {
|
|
|
|
|
|
if ((m as { role?: string }).role !== "assistant") continue;
|
|
|
|
|
|
const content = (m as { content?: unknown }).content;
|
|
|
|
|
|
if (!Array.isArray(content)) continue;
|
|
|
|
|
|
for (const block of content as Array<{ type?: string; id?: string; arguments?: unknown }>) {
|
|
|
|
|
|
if (block.type !== "toolCall" || block.id !== job.toolCallId) continue;
|
|
|
|
|
|
return block.arguments as JobToolInput | undefined;
|
2026-09-01 19:15:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-26 01:52:47 +03:00
|
|
|
|
return undefined;
|
2026-09-01 19:15:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The tail of every job: store the result, build the steer, hand it to
|
|
|
|
|
|
* the wake pipeline. The pipeline flips the status when the lesson has
|
|
|
|
|
|
* actually resumed — until then the job reads "running" so the client's
|
|
|
|
|
|
* activity card keeps polling and re-syncs once, at the end.
|
|
|
|
|
|
*/
|
2026-09-19 15:24:26 +03:00
|
|
|
|
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const job = jobStore.getJob(jobId);
|
|
|
|
|
|
if (!job) return;
|
2026-09-11 03:25:09 +03:00
|
|
|
|
// 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;
|
2026-09-01 19:15:43 +03:00
|
|
|
|
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
|
|
|
|
|
const steer = buildSubagentSteer(job, result);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deliver(steer);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
|
|
|
|
console.error(`[job ${jobId}] steer delivery failed:`, err);
|
|
|
|
|
|
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
2026-08-22 10:24:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
/** Chat-record + input resolution shared by all three runners. */
|
|
|
|
|
|
function jobContext(jobId: string): { job: JobRecord; chatId: string } | null {
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const job = jobStore.getJob(jobId);
|
2026-09-01 19:15:43 +03:00
|
|
|
|
if (!job || job.status !== "running") return null;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const chat = chatStore.getChat(job.chatId);
|
|
|
|
|
|
if (!chat) {
|
|
|
|
|
|
jobStore.updateJob(jobId, { status: "error", error: `Chat not found: ${job.chatId}`, activity: "failed" });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
return null;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
2026-09-01 19:15:43 +03:00
|
|
|
|
return { job, chatId: job.chatId };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── research ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
async function runResearchJob(
|
|
|
|
|
|
jobId: string,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
model: PiEngine | undefined,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
passedInput: JobToolInput | undefined,
|
|
|
|
|
|
loop: LoopContext,
|
|
|
|
|
|
): Promise<void> {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const ctx = jobContext(jobId);
|
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
const { job } = ctx;
|
|
|
|
|
|
|
|
|
|
|
|
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
|
|
|
|
|
|
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
const prompt = [
|
|
|
|
|
|
`Research question: ${question}`,
|
|
|
|
|
|
context ? `Context (what this is needed for): ${context}` : null,
|
|
|
|
|
|
"Produce your final brief in the required format.",
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join("\n\n");
|
|
|
|
|
|
|
|
|
|
|
|
const fetchedUrls: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
jobStore.updateJob(jobId, { activity: "researching" });
|
2026-09-12 02:34:18 +03:00
|
|
|
|
const brief = await runResearcherAgent(prompt, {
|
|
|
|
|
|
engine: model,
|
2026-09-22 13:55:01 +03:00
|
|
|
|
search: deps.searchProvider,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
abortSignal: loop.abortSignal,
|
|
|
|
|
|
// Money channel: each agent step is one model call — fold its
|
|
|
|
|
|
// usage into the record as it completes (progress write).
|
2026-09-12 02:34:18 +03:00
|
|
|
|
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
|
|
|
|
|
|
onToolStart: (toolName, args) => {
|
|
|
|
|
|
const input = args as { query?: string; url?: string } | undefined;
|
|
|
|
|
|
if (toolName === "web_fetch" && typeof input?.url === "string") {
|
2026-08-31 22:16:03 +03:00
|
|
|
|
fetchedUrls.push(input.url);
|
|
|
|
|
|
}
|
2026-09-12 02:34:18 +03:00
|
|
|
|
jobStore.updateJob(jobId, { activity: activityLine(toolName, input) });
|
2026-08-31 22:16:03 +03:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-12 02:34:18 +03:00
|
|
|
|
if (!brief.trim()) throw new Error("the researcher produced no text");
|
|
|
|
|
|
const research: ResearchResult = { brief: brief.trim(), sources: parseSources(brief.trim(), fetchedUrls) };
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, research);
|
2026-08-31 22:16:03 +03:00
|
|
|
|
} catch (err) {
|
2026-09-11 03:25:09 +03:00
|
|
|
|
// 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;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
// ─── demo + visual ──────────────────────────────────────────────────────
|
2026-08-31 22:16:03 +03:00
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
async function runDemoJob(
|
|
|
|
|
|
jobId: string,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
model: PiEngine | undefined,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
passedInput: JobToolInput | undefined,
|
|
|
|
|
|
loop: LoopContext,
|
|
|
|
|
|
): Promise<void> {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const ctx = jobContext(jobId);
|
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
const { job } = ctx;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const input = (passedInput ?? resolveInput(job, passedInput)) as DemoToolInput | undefined;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const title = typeof input?.title === "string" && input.title.trim() ? input.title : "Interactive demo";
|
|
|
|
|
|
const brief = typeof input?.brief === "string" ? input.brief : "";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
jobStore.updateJob(jobId, { activity: "building demo" });
|
|
|
|
|
|
const result = await runDemoBuilder(
|
|
|
|
|
|
{ title, brief },
|
|
|
|
|
|
{
|
2026-09-12 02:34:18 +03:00
|
|
|
|
engine: model,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
abortSignal: loop.abortSignal,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
|
2026-08-31 22:16:03 +03:00
|
|
|
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (isDemoFailure(result) && stillRunning(jobId))
|
|
|
|
|
|
jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, result);
|
2026-08-31 22:16:03 +03:00
|
|
|
|
} catch (err) {
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (loop.abortSignal.aborted) return;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 03:25:09 +03:00
|
|
|
|
async function runVisualJob(
|
|
|
|
|
|
jobId: string,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
model: PiEngine | undefined,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
passedInput: JobToolInput | undefined,
|
|
|
|
|
|
loop: LoopContext,
|
|
|
|
|
|
): Promise<void> {
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const ctx = jobContext(jobId);
|
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
const { job } = ctx;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
const input = (passedInput ?? resolveInput(job, passedInput)) as VisualToolInput | undefined;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const brief = typeof input?.brief === "string" ? input.brief : "";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
jobStore.updateJob(jobId, { activity: "drawing figure" });
|
|
|
|
|
|
const result = await runVisualMaker(
|
|
|
|
|
|
{ brief },
|
|
|
|
|
|
{
|
2026-09-12 02:34:18 +03:00
|
|
|
|
engine: model,
|
2026-09-11 03:25:09 +03:00
|
|
|
|
abortSignal: loop.abortSignal,
|
2026-09-12 02:34:18 +03:00
|
|
|
|
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
|
2026-08-31 22:16:03 +03:00
|
|
|
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (isVisualFailure(result) && stillRunning(jobId))
|
|
|
|
|
|
jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, result);
|
2026-08-31 22:16:03 +03:00
|
|
|
|
} catch (err) {
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (loop.abortSignal.aborted) return;
|
2026-08-31 22:16:03 +03:00
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
2026-09-11 03:25:09 +03:00
|
|
|
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
2026-09-01 19:15:43 +03:00
|
|
|
|
await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
|
2026-08-31 22:16:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
|
// ─── pdf reading (sources v0) ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async function runPdfJob(
|
|
|
|
|
|
jobId: string,
|
|
|
|
|
|
model: PiEngine | undefined,
|
|
|
|
|
|
passedInput: JobToolInput | undefined,
|
|
|
|
|
|
loop: LoopContext,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const ctx = jobContext(jobId);
|
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
const { job } = ctx;
|
|
|
|
|
|
|
|
|
|
|
|
const input = (passedInput ?? resolveInput(job, passedInput)) as PdfToolInput | undefined;
|
|
|
|
|
|
const path = typeof input?.path === "string" && input.path.trim() ? input.path.trim() : "";
|
|
|
|
|
|
const question = typeof input?.question === "string" ? input.question : "";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
jobStore.updateJob(jobId, { activity: "reading pdf" });
|
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
|
{ path, question },
|
|
|
|
|
|
{
|
|
|
|
|
|
engine: model,
|
|
|
|
|
|
abortSignal: loop.abortSignal,
|
|
|
|
|
|
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
|
|
|
|
|
|
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
if (isPdfFailure(result) && stillRunning(jobId))
|
|
|
|
|
|
jobStore.updateJob(jobId, { error: result.brief.replace(/^PDF failed:\s*/i, "") });
|
|
|
|
|
|
await finishJob(jobId, result);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (loop.abortSignal.aborted) return;
|
|
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
|
|
|
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
2026-09-19 16:52:09 +03:00
|
|
|
|
await finishJob(jobId, { path, question, brief: `PDF failed: ${reason}`, pagesRead: [], pagesConsulted: [] });
|
2026-09-19 15:24:26 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
return { runJob };
|
2026-08-22 09:36:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 22:16:03 +03:00
|
|
|
|
// ─── module-level pure helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
2026-09-01 19:15:43 +03:00
|
|
|
|
/** Failure shape (W6 brief): html === "" with "FAILED: …" notes. */
|
|
|
|
|
|
function isDemoFailure(result: DemoResult): boolean {
|
|
|
|
|
|
return typeof result.html !== "string" || result.html.length === 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isVisualFailure(result: VisualResult): boolean {
|
|
|
|
|
|
return typeof result.svg !== "string" || result.svg.length === 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
|
/** The pdf-reader's failure brief (mirrors the research failure shape). */
|
|
|
|
|
|
function isPdfFailure(result: PdfResult): boolean {
|
|
|
|
|
|
return /^PDF failed:/i.test(result.brief);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 09:36:15 +03:00
|
|
|
|
/** The activity line shown in the UI: `web_search: "query"` / `web_fetch: host`. */
|
|
|
|
|
|
function activityLine(toolName: string, input: { query?: string; url?: string } | undefined): string {
|
|
|
|
|
|
if (toolName === "web_search") return `web_search: "${input?.query ?? ""}"`;
|
|
|
|
|
|
if (toolName === "web_fetch") return `web_fetch: ${hostOf(input?.url)}`;
|
|
|
|
|
|
return toolName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hostOf(url: string | undefined): string {
|
|
|
|
|
|
if (!url) return "(no url)";
|
|
|
|
|
|
try {
|
|
|
|
|
|
return new URL(url).hostname;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return url.slice(0, 40);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Sources for the result: markdown links cited in the brief, else fetched URLs. */
|
|
|
|
|
|
function parseSources(brief: string, fetchedUrls: string[]): ResearchResult["sources"] {
|
|
|
|
|
|
const sources: ResearchResult["sources"] = [];
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
for (const match of brief.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g)) {
|
|
|
|
|
|
const title = (match[1] ?? "").trim();
|
|
|
|
|
|
const url = match[2]!;
|
|
|
|
|
|
if (!title || seen.has(url)) continue;
|
|
|
|
|
|
seen.add(url);
|
|
|
|
|
|
sources.push({ title, url });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sources.length === 0) {
|
|
|
|
|
|
for (const url of fetchedUrls) {
|
|
|
|
|
|
if (seen.has(url)) continue;
|
|
|
|
|
|
seen.add(url);
|
|
|
|
|
|
sources.push({ title: hostOf(url), url });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return sources.slice(0, 10);
|
|
|
|
|
|
}
|