Files
oh-my-pi/packages/coding-agent/src/tools/await-tool.ts
T
can1357 d3575c752d feat(async): added job delivery acknowledgment
- Introduced mechanism to suppress future delivery attempts for acknowledged jobs.
- The AwaitTool acknowledges deliveries for jobs it finished awaiting.
- Prevents unnecessary retries and clears delivery queue for handled jobs.
2026-02-23 15:00:11 +01:00

166 lines
4.7 KiB
TypeScript

import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import awaitDescription from "../prompts/tools/await.md" with { type: "text" };
import type { ToolSession } from "./index";
const awaitSchema = Type.Object({
jobs: Type.Optional(
Type.Array(Type.String(), {
description: "Specific job IDs to wait for. If omitted, waits for any running job.",
}),
),
});
type AwaitParams = Static<typeof awaitSchema>;
interface AwaitResult {
id: string;
type: "bash" | "task";
status: "running" | "completed" | "failed" | "cancelled";
label: string;
durationMs: number;
resultText?: string;
errorText?: string;
}
export interface AwaitToolDetails {
jobs: AwaitResult[];
}
export class AwaitTool implements AgentTool<typeof awaitSchema, AwaitToolDetails> {
readonly name = "await";
readonly label = "Await";
readonly description: string;
readonly parameters = awaitSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = renderPromptTemplate(awaitDescription);
}
static createIf(session: ToolSession): AwaitTool | null {
if (!session.settings.get("async.enabled")) return null;
return new AwaitTool(session);
}
async execute(
_toolCallId: string,
params: AwaitParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<AwaitToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<AwaitToolDetails>> {
const manager = this.session.asyncJobManager;
if (!manager) {
return {
content: [{ type: "text", text: "Async execution is disabled; no background jobs to poll." }],
details: { jobs: [] },
};
}
const requestedIds = params.jobs;
// Resolve which jobs to watch
const jobsToWatch = requestedIds?.length
? requestedIds.map(id => manager.getJob(id)).filter(j => j != null)
: manager.getRunningJobs();
if (jobsToWatch.length === 0) {
const message = requestedIds?.length
? `No matching jobs found for IDs: ${requestedIds.join(", ")}`
: "No running background jobs to wait for.";
return {
content: [{ type: "text", text: message }],
details: { jobs: [] },
};
}
// If all watched jobs are already done, return immediately
const runningJobs = jobsToWatch.filter(j => j.status === "running");
if (runningJobs.length === 0) {
return this.#buildResult(manager, jobsToWatch);
}
// Block until at least one running job finishes or the call is aborted
const racePromises: Promise<unknown>[] = runningJobs.map(j => j.promise);
if (signal) {
const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers<void>();
const onAbort = () => abortResolve();
signal.addEventListener("abort", onAbort, { once: true });
racePromises.push(abortPromise);
try {
await Promise.race(racePromises);
} finally {
signal.removeEventListener("abort", onAbort);
}
} else {
await Promise.race(racePromises);
}
if (signal?.aborted) {
return this.#buildResult(manager, jobsToWatch);
}
return this.#buildResult(manager, jobsToWatch);
}
#buildResult(
manager: NonNullable<ToolSession["asyncJobManager"]>,
jobs: {
id: string;
type: "bash" | "task";
status: string;
label: string;
startTime: number;
resultText?: string;
errorText?: string;
}[],
): AgentToolResult<AwaitToolDetails> {
const now = Date.now();
const jobResults: AwaitResult[] = jobs.map(j => ({
id: j.id,
type: j.type,
status: j.status as AwaitResult["status"],
label: j.label,
durationMs: Math.max(0, now - j.startTime),
...(j.resultText ? { resultText: j.resultText } : {}),
...(j.errorText ? { errorText: j.errorText } : {}),
}));
manager.acknowledgeDeliveries(jobResults.filter(j => j.status !== "running").map(j => j.id));
const completed = jobResults.filter(j => j.status !== "running");
const running = jobResults.filter(j => j.status === "running");
const lines: string[] = [];
if (completed.length > 0) {
lines.push(`## Completed (${completed.length})\n`);
for (const j of completed) {
lines.push(`### ${j.id} [${j.type}] — ${j.status}`);
lines.push(`Label: ${j.label}`);
if (j.resultText) {
lines.push("```", j.resultText, "```");
}
if (j.errorText) {
lines.push(`Error: ${j.errorText}`);
}
lines.push("");
}
}
if (running.length > 0) {
lines.push(`## Still Running (${running.length})\n`);
for (const j of running) {
lines.push(`- \`${j.id}\` [${j.type}] — ${j.label}`);
}
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { jobs: jobResults },
};
}
}