feat(background): add background registry and status tool with tests
This commit is contained in:
42
src/background-registry.test.ts
Normal file
42
src/background-registry.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createBackgroundRegistry } from "./background-registry.ts";
|
||||||
|
|
||||||
|
test("recordLaunch and counts update correctly", () => {
|
||||||
|
const reg = createBackgroundRegistry();
|
||||||
|
reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" });
|
||||||
|
reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" });
|
||||||
|
|
||||||
|
let counts = reg.getCounts();
|
||||||
|
assert.equal(counts.running, 2);
|
||||||
|
assert.equal(counts.total, 2);
|
||||||
|
|
||||||
|
reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" });
|
||||||
|
counts = reg.getCounts();
|
||||||
|
assert.equal(counts.running, 1);
|
||||||
|
assert.equal(counts.completed, 1);
|
||||||
|
assert.equal(counts.total, 2);
|
||||||
|
|
||||||
|
const r1 = reg.getRun("r1");
|
||||||
|
const r2 = reg.getRun("r2");
|
||||||
|
assert.ok(r1);
|
||||||
|
assert.ok(r2);
|
||||||
|
assert.equal(r2?.finalText, "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replay seeds registry and counts", () => {
|
||||||
|
const reg = createBackgroundRegistry();
|
||||||
|
const now = Date.now();
|
||||||
|
reg.replay([
|
||||||
|
{ runId: "a", preset: "p", task: "x", status: "running", startTime: now },
|
||||||
|
{ runId: "b", preset: "p2", task: "y", status: "failed", startTime: now - 1000, endTime: now - 500 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = reg.getCounts();
|
||||||
|
assert.equal(counts.running, 1);
|
||||||
|
assert.equal(counts.failed, 1);
|
||||||
|
assert.equal(counts.total, 2);
|
||||||
|
|
||||||
|
const snap = reg.getSnapshot({ includeCompleted: true });
|
||||||
|
assert.equal(snap.length, 2);
|
||||||
|
});
|
||||||
136
src/background-registry.ts
Normal file
136
src/background-registry.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
export type BackgroundStatus = "running" | "completed" | "failed" | "aborted";
|
||||||
|
|
||||||
|
export interface BackgroundRun {
|
||||||
|
runId: string;
|
||||||
|
preset: string;
|
||||||
|
task: string;
|
||||||
|
requestedModel?: string;
|
||||||
|
resolvedModel?: string;
|
||||||
|
status: BackgroundStatus;
|
||||||
|
startTime: number; // epoch ms
|
||||||
|
endTime?: number; // epoch ms when finished
|
||||||
|
finalText?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
stopReason?: string;
|
||||||
|
paths?: Record<string, string>;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundRegistry {
|
||||||
|
recordLaunch(entry: {
|
||||||
|
runId: string;
|
||||||
|
preset: string;
|
||||||
|
task: string;
|
||||||
|
requestedModel?: string;
|
||||||
|
resolvedModel?: string;
|
||||||
|
paths?: Record<string, string>;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}): void;
|
||||||
|
|
||||||
|
recordUpdate(runId: string, update: Partial<Omit<BackgroundRun, "runId" | "startTime">>): void;
|
||||||
|
|
||||||
|
replay(entries: BackgroundRun[]): void;
|
||||||
|
|
||||||
|
getRun(runId: string): BackgroundRun | undefined;
|
||||||
|
|
||||||
|
getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }): BackgroundRun[];
|
||||||
|
|
||||||
|
getCounts(): { running: number; completed: number; failed: number; aborted: number; total: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackgroundRegistry(): BackgroundRegistry {
|
||||||
|
const map = new Map<string, BackgroundRun>();
|
||||||
|
|
||||||
|
function recordLaunch(entry: {
|
||||||
|
runId: string;
|
||||||
|
preset: string;
|
||||||
|
task: string;
|
||||||
|
requestedModel?: string;
|
||||||
|
resolvedModel?: string;
|
||||||
|
paths?: Record<string, string>;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const run: BackgroundRun = {
|
||||||
|
runId: entry.runId,
|
||||||
|
preset: entry.preset,
|
||||||
|
task: entry.task,
|
||||||
|
requestedModel: entry.requestedModel,
|
||||||
|
resolvedModel: entry.resolvedModel,
|
||||||
|
status: "running",
|
||||||
|
startTime: now,
|
||||||
|
paths: entry.paths,
|
||||||
|
meta: entry.meta,
|
||||||
|
};
|
||||||
|
map.set(entry.runId, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordUpdate(runId: string, update: Partial<Omit<BackgroundRun, "runId" | "startTime">>) {
|
||||||
|
const existing = map.get(runId);
|
||||||
|
if (!existing) return;
|
||||||
|
const merged: BackgroundRun = { ...existing, ...update } as BackgroundRun;
|
||||||
|
if ((update.status && update.status !== "running") || (update.exitCode !== undefined && merged.status === "running")) {
|
||||||
|
// if status moved to finished states or exitCode provided while status still running, set endTime
|
||||||
|
if (!merged.endTime) merged.endTime = Date.now();
|
||||||
|
}
|
||||||
|
// if status explicitly set to completed/failed/aborted and no endTime, set it
|
||||||
|
if ((merged.status === "completed" || merged.status === "failed" || merged.status === "aborted") && !merged.endTime) {
|
||||||
|
merged.endTime = Date.now();
|
||||||
|
}
|
||||||
|
map.set(runId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replay(entries: BackgroundRun[]) {
|
||||||
|
map.clear();
|
||||||
|
for (const e of entries) {
|
||||||
|
// keep provided timestamps and status as-is
|
||||||
|
map.set(e.runId, { ...e });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRun(runId: string) {
|
||||||
|
const r = map.get(runId);
|
||||||
|
return r ? { ...r } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }) {
|
||||||
|
const includeCompleted = Boolean(opts?.includeCompleted);
|
||||||
|
if (opts?.runId) {
|
||||||
|
const r = map.get(opts.runId);
|
||||||
|
if (!r) return [];
|
||||||
|
if (!includeCompleted && (r.status === "completed" || r.status === "failed" || r.status === "aborted")) return [];
|
||||||
|
return [{ ...r }];
|
||||||
|
}
|
||||||
|
const arr = Array.from(map.values()).filter((r) => includeCompleted || r.status === "running");
|
||||||
|
// sort by startTime desc (most recent first)
|
||||||
|
arr.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
|
||||||
|
return arr.map((r) => ({ ...r }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCounts() {
|
||||||
|
let running = 0,
|
||||||
|
completed = 0,
|
||||||
|
failed = 0,
|
||||||
|
aborted = 0;
|
||||||
|
for (const r of map.values()) {
|
||||||
|
switch (r.status) {
|
||||||
|
case "running":
|
||||||
|
running++;
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
completed++;
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
failed++;
|
||||||
|
break;
|
||||||
|
case "aborted":
|
||||||
|
aborted++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = running + completed + failed + aborted;
|
||||||
|
return { running, completed, failed, aborted, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recordLaunch, recordUpdate, replay, getRun, getSnapshot, getCounts };
|
||||||
|
}
|
||||||
35
src/background-schema.ts
Normal file
35
src/background-schema.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { StringEnum } from "@mariozechner/pi-ai";
|
||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export function createBackgroundAgentSchema(availableModels: readonly string[]) {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
preset: Type.String({ description: "Subagent preset name to use for this task" }),
|
||||||
|
task: Type.String({ description: "Task to delegate to the child agent" }),
|
||||||
|
model: Type.Optional(
|
||||||
|
StringEnum(availableModels, {
|
||||||
|
description: "Optional child model override. Must be one of the currently available models.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
|
||||||
|
},
|
||||||
|
{ description: "Parameters for launching a background_agent run" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackgroundAgentSchema = createBackgroundAgentSchema([]);
|
||||||
|
|
||||||
|
export function createBackgroundAgentStatusSchema() {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
runId: Type.Optional(Type.String({ description: "Optional runId to query a single run" })),
|
||||||
|
includeCompleted: Type.Optional(Type.Boolean({ description: "Include completed/finished runs in listing" })),
|
||||||
|
},
|
||||||
|
{ description: "Parameters for background_agent_status tool" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackgroundAgentStatusSchema = createBackgroundAgentStatusSchema();
|
||||||
|
|
||||||
|
export type BackgroundAgentParams = Static<typeof BackgroundAgentSchema>;
|
||||||
|
export type BackgroundAgentStatusParams = Static<typeof BackgroundAgentStatusSchema>;
|
||||||
27
src/background-status-tool.test.ts
Normal file
27
src/background-status-tool.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createBackgroundRegistry } from "./background-registry.ts";
|
||||||
|
import { createBackgroundStatusTool } from "./background-status-tool.ts";
|
||||||
|
|
||||||
|
test("status tool shows active runs by default and can query single run", async () => {
|
||||||
|
const reg = createBackgroundRegistry();
|
||||||
|
reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" });
|
||||||
|
reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" });
|
||||||
|
reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" });
|
||||||
|
|
||||||
|
const tool = createBackgroundStatusTool({ registry: reg });
|
||||||
|
const resDefault: any = await tool.execute("id", {}, undefined, undefined, undefined);
|
||||||
|
// default should show only active (running) runs
|
||||||
|
assert.equal(resDefault.details.runs.length, 1);
|
||||||
|
assert.equal(resDefault.details.runs[0].runId, "r1");
|
||||||
|
assert.match(resDefault.content[0].text, /Active runs: 1/);
|
||||||
|
|
||||||
|
const resSingle: any = await tool.execute("id", { runId: "r2" }, undefined, undefined, undefined);
|
||||||
|
assert.equal(resSingle.details.runs.length, 1);
|
||||||
|
assert.equal(resSingle.details.runs[0].runId, "r2");
|
||||||
|
assert.match(resSingle.content[0].text, /status=completed/);
|
||||||
|
|
||||||
|
const resNotFound: any = await tool.execute("id", { runId: "nope" }, undefined, undefined, undefined);
|
||||||
|
assert.equal(resNotFound.details.runs.length, 0);
|
||||||
|
assert.match(resNotFound.content[0].text, /No run found/);
|
||||||
|
});
|
||||||
54
src/background-status-tool.ts
Normal file
54
src/background-status-tool.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { BackgroundAgentStatusSchema } from "./background-schema.ts";
|
||||||
|
import type { BackgroundRegistry } from "./background-registry.ts";
|
||||||
|
|
||||||
|
export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry } | { registry?: BackgroundRegistry } = {}) {
|
||||||
|
const registry = (deps as any).registry;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "background_agent_status",
|
||||||
|
label: "Background Agent Status",
|
||||||
|
description: "Query background agent runs and their status.",
|
||||||
|
parameters: BackgroundAgentStatusSchema,
|
||||||
|
async execute(_toolCallId: string, params: any = {}, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) {
|
||||||
|
if (!registry) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Background registry not available" }],
|
||||||
|
details: { runs: [] },
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = typeof params.runId === "string" ? params.runId : undefined;
|
||||||
|
const includeCompleted = Boolean(params.includeCompleted);
|
||||||
|
|
||||||
|
// when querying a single run by id, allow completed runs regardless of includeCompleted flag
|
||||||
|
const runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted });
|
||||||
|
|
||||||
|
if (runId) {
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }],
|
||||||
|
details: { runs: [] },
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r = runs[0];
|
||||||
|
const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text }],
|
||||||
|
details: { runs },
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: active runs only (unless includeCompleted)
|
||||||
|
const active = runs;
|
||||||
|
const text = `Active runs: ${active.length}`;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text }],
|
||||||
|
details: { runs: active },
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
12
tmp-debug.mjs
Normal file
12
tmp-debug.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createBackgroundRegistry } from './src/background-registry.js';
|
||||||
|
import { createBackgroundStatusTool } from './src/background-status-tool.js';
|
||||||
|
(async()=>{
|
||||||
|
const reg = createBackgroundRegistry();
|
||||||
|
reg.recordLaunch({ runId: 'r1', preset: 'p1', task: 't1' });
|
||||||
|
reg.recordLaunch({ runId: 'r2', preset: 'p2', task: 't2' });
|
||||||
|
reg.recordUpdate('r2', { status: 'completed', exitCode: 0, finalText: 'done' });
|
||||||
|
const tool = createBackgroundStatusTool({ registry: reg });
|
||||||
|
const resDefault = await tool.execute('id', {}, undefined, undefined, undefined);
|
||||||
|
console.log('default details', resDefault.details);
|
||||||
|
console.log('default content', resDefault.content);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user