Merge remote-tracking branch 'origin/main' into feat/telegram-channel
# Conflicts: # apps/desktop/electron/electron-env.d.ts # apps/desktop/electron/ipc/index.ts # apps/desktop/electron/preload.ts # apps/desktop/src/App.tsx # apps/desktop/src/pages/layout.tsx # src/agent/async-agent.ts # src/agent/runner.ts # src/hub/hub.ts
This commit is contained in:
commit
23905daaa1
85 changed files with 7368 additions and 470 deletions
264
src/agent/async-agent.test.ts
Normal file
264
src/agent/async-agent.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AsyncAgent } from "./async-agent.js";
|
||||
|
||||
const subscribeCallbacks: Array<(event: any) => void> = [];
|
||||
const internalRunState = { value: false };
|
||||
|
||||
const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
|
||||
const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
|
||||
const flushSessionMock = vi.fn(async () => {});
|
||||
const persistAssistantSummaryMock = vi.fn();
|
||||
const subscribeAllMock = vi.fn((fn: (event: any) => void) => {
|
||||
subscribeCallbacks.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
vi.mock("./runner.js", () => ({
|
||||
Agent: class MockAgent {
|
||||
sessionId = "test-session";
|
||||
subscribeAll = subscribeAllMock;
|
||||
run = runMock;
|
||||
runInternal = runInternalMock;
|
||||
flushSession = flushSessionMock;
|
||||
persistAssistantSummary = persistAssistantSummaryMock;
|
||||
get isInternalRun() {
|
||||
return internalRunState.value;
|
||||
}
|
||||
getMessages() {
|
||||
return [];
|
||||
}
|
||||
loadSessionMessages() {
|
||||
return [];
|
||||
}
|
||||
async ensureInitialized() {}
|
||||
getActiveTools() {
|
||||
return [];
|
||||
}
|
||||
reloadTools() {
|
||||
return [];
|
||||
}
|
||||
getSkillsWithStatus() {
|
||||
return [];
|
||||
}
|
||||
getEligibleSkills() {
|
||||
return [];
|
||||
}
|
||||
reloadSkills() {}
|
||||
setToolStatus() {
|
||||
return undefined;
|
||||
}
|
||||
getProfileId() {
|
||||
return undefined;
|
||||
}
|
||||
getAgentName() {
|
||||
return undefined;
|
||||
}
|
||||
setAgentName() {}
|
||||
getUserContent() {
|
||||
return undefined;
|
||||
}
|
||||
setUserContent() {}
|
||||
getAgentStyle() {
|
||||
return undefined;
|
||||
}
|
||||
setAgentStyle() {}
|
||||
reloadSystemPrompt() {}
|
||||
getProviderInfo() {
|
||||
return { provider: "test", model: "test-model" };
|
||||
}
|
||||
setProvider() {
|
||||
return { provider: "test", model: "test-model" };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
async function nextWithTimeout<T>(iter: AsyncIterator<T>, timeoutMs = 40): Promise<"timeout" | T> {
|
||||
return await Promise.race([
|
||||
iter.next().then((result) => (result.done ? "timeout" : result.value)),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("AsyncAgent internal flow", () => {
|
||||
afterEach(() => {
|
||||
subscribeCallbacks.length = 0;
|
||||
internalRunState.value = false;
|
||||
runMock.mockReset();
|
||||
runInternalMock.mockReset();
|
||||
flushSessionMock.mockReset();
|
||||
persistAssistantSummaryMock.mockReset();
|
||||
subscribeAllMock.mockClear();
|
||||
runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
|
||||
runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
|
||||
flushSessionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("filters internal events in direct subscribe stream", () => {
|
||||
const agent = new AsyncAgent();
|
||||
const events: Array<{ type: string }> = [];
|
||||
|
||||
const unsubscribe = agent.subscribe((event) => {
|
||||
events.push(event as { type: string });
|
||||
});
|
||||
|
||||
// subscribeAll is called twice:
|
||||
// 1) constructor for read() channel forwarding
|
||||
// 2) subscribe() for direct callback forwarding
|
||||
const subscribeCallback = subscribeCallbacks[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
|
||||
internalRunState.value = true;
|
||||
subscribeCallback!({ type: "message_end" });
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
internalRunState.value = false;
|
||||
subscribeCallback!({ type: "message_end" });
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
unsubscribe();
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not leak internal run errors to read() stream", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "", thinking: undefined, error: "internal failed" });
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
|
||||
agent.writeInternal("test internal");
|
||||
await agent.waitForIdle();
|
||||
|
||||
const value = await nextWithTimeout(iter);
|
||||
expect(value).toBe("timeout");
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not leak internal run exceptions to read() stream", async () => {
|
||||
runInternalMock.mockRejectedValueOnce(new Error("internal exception"));
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
|
||||
agent.writeInternal("test internal");
|
||||
await agent.waitForIdle();
|
||||
|
||||
const value = await nextWithTimeout(iter);
|
||||
expect(value).toBe("timeout");
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("forwards assistant message stream (start/update/end) when writeInternal opts in", async () => {
|
||||
let resolveRunInternal: ((value: { text: string; thinking: undefined; error: undefined }) => void) | undefined;
|
||||
runInternalMock.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveRunInternal = resolve as typeof resolveRunInternal;
|
||||
}),
|
||||
);
|
||||
|
||||
const agent = new AsyncAgent();
|
||||
const iter = agent.read()[Symbol.asyncIterator]();
|
||||
const streamCallback = subscribeCallbacks[0];
|
||||
expect(streamCallback).toBeDefined();
|
||||
|
||||
agent.writeInternal("announce", { forwardAssistant: true });
|
||||
await Promise.resolve();
|
||||
|
||||
internalRunState.value = true;
|
||||
streamCallback!({
|
||||
type: "message_start",
|
||||
message: { role: "assistant", content: [] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "partial" }] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_end",
|
||||
message: { role: "user", content: [{ type: "text", text: "hidden internal prompt" }] },
|
||||
});
|
||||
streamCallback!({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "visible summary" }] },
|
||||
});
|
||||
|
||||
const first = await nextWithTimeout(iter);
|
||||
expect(first).not.toBe("timeout");
|
||||
if (first !== "timeout") {
|
||||
expect((first as { type: string }).type).toBe("message_start");
|
||||
expect((first as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const second = await nextWithTimeout(iter);
|
||||
expect(second).not.toBe("timeout");
|
||||
if (second !== "timeout") {
|
||||
expect((second as { type: string }).type).toBe("message_update");
|
||||
expect((second as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const third = await nextWithTimeout(iter);
|
||||
expect(third).not.toBe("timeout");
|
||||
if (third !== "timeout") {
|
||||
expect((third as { type: string }).type).toBe("message_end");
|
||||
expect((third as { message: { role: string } }).message.role).toBe("assistant");
|
||||
}
|
||||
|
||||
const fourth = await nextWithTimeout(iter);
|
||||
expect(fourth).toBe("timeout");
|
||||
|
||||
resolveRunInternal!({ text: "", thinking: undefined, error: undefined });
|
||||
await agent.waitForIdle();
|
||||
internalRunState.value = false;
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("persists assistant summary when persistResponse is true and result has text", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).toHaveBeenCalledOnce();
|
||||
expect(persistAssistantSummaryMock).toHaveBeenCalledWith("Summary of findings");
|
||||
// flushSession called twice: once after runInternal, once after persistAssistantSummary
|
||||
expect(flushSessionMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when result text is NO_REPLY", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when result text is empty", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
|
||||
it("does not persist assistant summary when persistResponse is not set", async () => {
|
||||
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
|
||||
const agent = new AsyncAgent();
|
||||
|
||||
agent.writeInternal("announce findings", { forwardAssistant: true });
|
||||
await agent.waitForIdle();
|
||||
|
||||
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
|
||||
|
||||
agent.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,12 +10,21 @@ const devNull = { write: () => true } as unknown as NodeJS.WritableStream;
|
|||
/** Discriminated union of legacy Message, raw AgentEvent, and MulticaEvent */
|
||||
export type ChannelItem = Message | AgentEvent | MulticaEvent;
|
||||
|
||||
export interface WriteInternalOptions {
|
||||
/** Forward assistant message_end events to realtime stream during internal runs */
|
||||
forwardAssistant?: boolean | undefined;
|
||||
/** After internal run completes, persist the LLM's summary as a non-internal assistant message */
|
||||
persistResponse?: boolean | undefined;
|
||||
}
|
||||
|
||||
export class AsyncAgent {
|
||||
private readonly agent: Agent;
|
||||
private readonly channel = new Channel<ChannelItem>();
|
||||
private _closed = false;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private pendingWrites = 0;
|
||||
private closeCallbacks: Array<() => void> = [];
|
||||
private forwardInternalAssistant = false;
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(options?: AgentOptions) {
|
||||
|
|
@ -25,8 +34,11 @@ export class AsyncAgent {
|
|||
});
|
||||
this.sessionId = this.agent.sessionId;
|
||||
|
||||
// Forward raw AgentEvent and MulticaEvent into the channel
|
||||
// Forward raw AgentEvent and MulticaEvent into the channel.
|
||||
// Suppress forwarding during internal runs to avoid leaking
|
||||
// orchestration messages to the frontend/real-time stream.
|
||||
this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => {
|
||||
if (!this.shouldForwardEvent(event)) return;
|
||||
this.channel.send(event);
|
||||
});
|
||||
}
|
||||
|
|
@ -43,6 +55,7 @@ export class AsyncAgent {
|
|||
/** Enqueue an agent run, handling errors and session flush */
|
||||
private enqueue(runFn: () => ReturnType<Agent["run"]>): void {
|
||||
if (this._closed) throw new Error("Agent is closed");
|
||||
this.pendingWrites += 1;
|
||||
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
|
|
@ -63,6 +76,47 @@ export class AsyncAgent {
|
|||
console.error(`[AsyncAgent] Agent run exception: ${message}`);
|
||||
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
|
||||
this.agent.emitMulticaEvent({ type: "agent_error", error: message });
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an internal message to agent (non-blocking, serialized queue).
|
||||
* Messages are persisted with `internal: true` and rolled back from
|
||||
* in-memory state. Events are suppressed from the real-time stream by default.
|
||||
*/
|
||||
writeInternal(content: string, options?: WriteInternalOptions): void {
|
||||
if (this._closed) throw new Error("Agent is closed");
|
||||
const forwardAssistant = options?.forwardAssistant === true;
|
||||
const persistResponse = options?.persistResponse === true;
|
||||
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
if (this._closed) return;
|
||||
const prevForward = this.forwardInternalAssistant;
|
||||
this.forwardInternalAssistant = forwardAssistant;
|
||||
try {
|
||||
const result = await this.agent.runInternal(content);
|
||||
await this.agent.flushSession();
|
||||
if (result.error) {
|
||||
// Internal run errors are for diagnostics only; do not leak to user stream.
|
||||
console.error(`[AsyncAgent] Internal run error: ${result.error}`);
|
||||
}
|
||||
// Persist the LLM summary so it remains in parent context for future turns
|
||||
if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") {
|
||||
this.agent.persistAssistantSummary(result.text.trim());
|
||||
await this.agent.flushSession();
|
||||
}
|
||||
} finally {
|
||||
this.forwardInternalAssistant = prevForward;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Internal run exceptions are for diagnostics only; do not leak to user stream.
|
||||
console.error(`[AsyncAgent] Internal run failed: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +133,7 @@ export class AsyncAgent {
|
|||
subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void {
|
||||
console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`);
|
||||
const unsubscribe = this.agent.subscribeAll((event) => {
|
||||
if (!this.shouldForwardEvent(event)) return;
|
||||
console.log(`[AsyncAgent] Event received: ${event.type}`);
|
||||
callback(event);
|
||||
});
|
||||
|
|
@ -93,6 +148,18 @@ export class AsyncAgent {
|
|||
return this.queue;
|
||||
}
|
||||
|
||||
private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean {
|
||||
if (!this.agent.isInternalRun) return true;
|
||||
if (!this.forwardInternalAssistant) return false;
|
||||
if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybeMessage = (event as { message?: unknown }).message;
|
||||
if (!maybeMessage || typeof maybeMessage !== "object") return false;
|
||||
return (maybeMessage as { role?: unknown }).role === "assistant";
|
||||
}
|
||||
|
||||
/** Register a callback to be invoked when the agent is closed */
|
||||
onClose(callback: () => void): void {
|
||||
if (this._closed) {
|
||||
|
|
@ -179,6 +246,34 @@ export class AsyncAgent {
|
|||
return this.agent.getProfileId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile directory path, if profile is enabled.
|
||||
*/
|
||||
getProfileDir(): string | undefined {
|
||||
return this.agent.getProfileDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat configuration from profile config.
|
||||
*/
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
return this.agent.getHeartbeatConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of queued/in-flight writes.
|
||||
*/
|
||||
getPendingWrites(): number {
|
||||
return this.pendingWrites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
|
|
@ -235,12 +330,20 @@ export class AsyncAgent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all messages from the current session.
|
||||
* Get all messages from the current session (in-memory state).
|
||||
*/
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.getMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from session storage with filtering.
|
||||
* By default, internal messages are excluded.
|
||||
*/
|
||||
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
return this.agent.loadSessionMessages(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider and model information.
|
||||
*/
|
||||
|
|
|
|||
466
src/agent/cli/commands/cron.ts
Normal file
466
src/agent/cli/commands/cron.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
/**
|
||||
* Cron command - Manage scheduled tasks
|
||||
*
|
||||
* Usage:
|
||||
* multica cron status Show cron service status
|
||||
* multica cron list List all jobs
|
||||
* multica cron add <options> Add a new job
|
||||
* multica cron run <id> Run a job immediately
|
||||
* multica cron enable <id> Enable a job
|
||||
* multica cron disable <id> Disable a job
|
||||
* multica cron remove <id> Remove a job
|
||||
* multica cron logs <id> Show job run logs
|
||||
*/
|
||||
|
||||
import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js";
|
||||
import {
|
||||
getCronService,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
isValidCronExpr,
|
||||
type CronSchedule,
|
||||
type CronJobInput,
|
||||
} from "../../../cron/index.js";
|
||||
|
||||
type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${brightCyan("Cron")} - Scheduled Task Management
|
||||
|
||||
${cyan("Usage:")} multica cron <command> [options]
|
||||
|
||||
${cyan("Commands:")}
|
||||
${yellow("status")} Show cron service status
|
||||
${yellow("list")} List all scheduled jobs
|
||||
${yellow("add")} [options] Create a new scheduled job
|
||||
${yellow("run")} <id> Run a job immediately
|
||||
${yellow("enable")} <id> Enable a disabled job
|
||||
${yellow("disable")} <id> Disable a job (keeps schedule)
|
||||
${yellow("remove")} <id> Delete a job
|
||||
${yellow("logs")} <id> Show run history for a job
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Add Options:")}
|
||||
${yellow("-n, --name")} <name> Job name (required)
|
||||
${yellow("--at")} <time> One-time at ISO timestamp or relative (e.g., "10m", "2h")
|
||||
${yellow("--every")} <interval> Repeat interval (e.g., "30m", "1h", "1d")
|
||||
${yellow("--cron")} <expr> Cron expression (5-field, e.g., "0 9 * * *")
|
||||
${yellow("--tz")} <timezone> Timezone for cron expression (e.g., "Asia/Shanghai")
|
||||
${yellow("--message")} <text> Message to inject or prompt for agent
|
||||
${yellow("--isolated")} Run in isolated session (default: main)
|
||||
${yellow("--delete-after-run")} Delete after one-time run completes
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Show service status")}
|
||||
multica cron status
|
||||
|
||||
${dim("# 10 minutes from now (one-shot)")}
|
||||
multica cron add -n "Reminder" --at "10m" --message "Time to take a break!"
|
||||
|
||||
${dim("# Every day at 9am Beijing time")}
|
||||
multica cron add -n "Morning check" --cron "0 9 * * *" --tz "Asia/Shanghai" \\
|
||||
--message "Good morning! Check your tasks."
|
||||
|
||||
${dim("# Every 30 minutes")}
|
||||
multica cron add -n "Health check" --every "30m" --message "System health check"
|
||||
|
||||
${dim("# Run a job now")}
|
||||
multica cron run abc12345
|
||||
|
||||
${dim("# View job logs")}
|
||||
multica cron logs abc12345
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdStatus() {
|
||||
const service = getCronService();
|
||||
const status = service.status();
|
||||
|
||||
console.log(`\n${brightCyan("Cron Service Status")}\n`);
|
||||
console.log(` ${cyan("Running:")} ${status.running ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Enabled:")} ${status.enabled ? green("Yes") : red("No")}`);
|
||||
console.log(` ${cyan("Jobs:")} ${status.jobCount} total, ${status.enabledJobCount} enabled`);
|
||||
if (status.nextWakeAtMs) {
|
||||
const nextWake = new Date(status.nextWakeAtMs);
|
||||
const relativeMs = status.nextWakeAtMs - Date.now();
|
||||
console.log(` ${cyan("Next wake:")} ${nextWake.toLocaleString()} (in ${formatDuration(relativeMs)})`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next wake:")} ${dim("none scheduled")}`);
|
||||
}
|
||||
console.log(` ${cyan("Store:")} ${dim(status.storePath)}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function cmdList(args: string[]) {
|
||||
const service = getCronService();
|
||||
const showEnabled = args.includes("--enabled");
|
||||
const showDisabled = args.includes("--disabled");
|
||||
|
||||
let filter: { enabled?: boolean } | undefined;
|
||||
if (showEnabled) filter = { enabled: true };
|
||||
else if (showDisabled) filter = { enabled: false };
|
||||
|
||||
const jobs = service.list(filter);
|
||||
|
||||
if (jobs.length === 0) {
|
||||
console.log("\nNo cron jobs found.");
|
||||
console.log(`${dim("Create one with:")} multica cron add -n "Name" --at "10m" --message "Hello"`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${brightCyan("Scheduled Jobs")}\n`);
|
||||
|
||||
for (const job of jobs) {
|
||||
const statusIcon = job.enabled ? green("✓") : red("✗");
|
||||
const shortId = job.id.slice(0, 8);
|
||||
|
||||
console.log(`${statusIcon} ${yellow(job.name)} ${dim(`(${shortId})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
console.log(` ${cyan("Target:")} ${job.sessionTarget}`);
|
||||
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
const relativeMs = job.state.nextRunAtMs - Date.now();
|
||||
if (relativeMs > 0) {
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()} ${dim(`(in ${formatDuration(relativeMs)})`)}`);
|
||||
} else {
|
||||
console.log(` ${cyan("Next run:")} ${dim("pending execution")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.state.lastRunAtMs) {
|
||||
const lastRun = new Date(job.state.lastRunAtMs);
|
||||
const statusColor = job.state.lastStatus === "ok" ? green : job.state.lastStatus === "error" ? red : yellow;
|
||||
console.log(` ${cyan("Last run:")} ${lastRun.toLocaleString()} ${statusColor(`[${job.state.lastStatus}]`)} ${dim(`(${formatDuration(job.state.lastDurationMs ?? 0)})`)}`);
|
||||
if (job.state.lastError) {
|
||||
console.log(` ${red("Error:")} ${job.state.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(dim(`Total: ${jobs.length} job(s)`));
|
||||
}
|
||||
|
||||
function cmdAdd(args: string[]) {
|
||||
const service = getCronService();
|
||||
|
||||
// Parse arguments
|
||||
let name: string | undefined;
|
||||
let at: string | undefined;
|
||||
let every: string | undefined;
|
||||
let cronExpr: string | undefined;
|
||||
let tz: string | undefined;
|
||||
let message: string | undefined;
|
||||
let isolated = false;
|
||||
let deleteAfterRun = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case "-n":
|
||||
case "--name":
|
||||
name = args[++i];
|
||||
break;
|
||||
case "--at":
|
||||
at = args[++i];
|
||||
break;
|
||||
case "--every":
|
||||
every = args[++i];
|
||||
break;
|
||||
case "--cron":
|
||||
cronExpr = args[++i];
|
||||
break;
|
||||
case "--tz":
|
||||
tz = args[++i];
|
||||
break;
|
||||
case "--message":
|
||||
message = args[++i];
|
||||
break;
|
||||
case "--isolated":
|
||||
isolated = true;
|
||||
break;
|
||||
case "--delete-after-run":
|
||||
deleteAfterRun = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate
|
||||
if (!name) {
|
||||
console.error(`${red("Error:")} --name is required`);
|
||||
console.error(`${dim("Usage:")} multica cron add -n "Job name" --at "10m" --message "Hello"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
console.error(`${red("Error:")} --message is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse schedule
|
||||
let schedule: CronSchedule;
|
||||
if (at) {
|
||||
const atMs = parseTimeInput(at);
|
||||
if (!atMs) {
|
||||
console.error(`${red("Error:")} Invalid time format: ${at}`);
|
||||
console.error(`${dim("Examples:")} "10m", "2h", "2024-12-31T23:59:00Z"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "at", atMs };
|
||||
} else if (every) {
|
||||
const everyMs = parseIntervalInput(every);
|
||||
if (!everyMs) {
|
||||
console.error(`${red("Error:")} Invalid interval format: ${every}`);
|
||||
console.error(`${dim("Examples:")} "30s", "5m", "2h", "1d"`);
|
||||
process.exit(1);
|
||||
}
|
||||
schedule = { kind: "every", everyMs };
|
||||
} else if (cronExpr) {
|
||||
if (!isValidCronExpr(cronExpr, tz)) {
|
||||
console.error(`${red("Error:")} Invalid cron expression: ${cronExpr}`);
|
||||
console.error(`${dim("Format:")} "minute hour day month weekday" (e.g., "0 9 * * *")`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Only include tz if it's defined (exactOptionalPropertyTypes)
|
||||
schedule = tz ? { kind: "cron", expr: cronExpr, tz } : { kind: "cron", expr: cronExpr };
|
||||
} else {
|
||||
console.error(`${red("Error:")} Must specify --at, --every, or --cron`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create job
|
||||
const input: CronJobInput = {
|
||||
name,
|
||||
enabled: true,
|
||||
deleteAfterRun,
|
||||
schedule,
|
||||
sessionTarget: isolated ? "isolated" : "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "system-event",
|
||||
text: message,
|
||||
},
|
||||
};
|
||||
|
||||
const job = service.add(input);
|
||||
|
||||
console.log(`\n${green("Created job:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}`);
|
||||
console.log(` ${cyan("Schedule:")} ${formatSchedule(job.schedule)}`);
|
||||
if (job.state.nextRunAtMs) {
|
||||
const nextRun = new Date(job.state.nextRunAtMs);
|
||||
console.log(` ${cyan("Next run:")} ${nextRun.toLocaleString()}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function cmdRun(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const force = args.includes("--force");
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron run <id> [--force]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
console.error("Please provide a more specific ID.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
console.log(`Running job: ${job.name} (${job.id.slice(0, 8)})...`);
|
||||
|
||||
const result = await service.run(job.id, force);
|
||||
if (result.ok) {
|
||||
console.log(`${green("Success:")} Job executed`);
|
||||
} else {
|
||||
console.error(`${red("Error:")} ${result.reason}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdEnableDisable(args: string[], enabled: boolean) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.update(job.id, { enabled });
|
||||
|
||||
const action = enabled ? "Enabled" : "Disabled";
|
||||
console.log(`${green(action + ":")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdRemove(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
service.remove(job.id);
|
||||
console.log(`${green("Removed:")} ${job.name} (${job.id.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
function cmdLogs(args: string[]) {
|
||||
const service = getCronService();
|
||||
const jobId = args[0];
|
||||
const limitArg = args.indexOf("--limit");
|
||||
const limitStr = limitArg !== -1 ? args[limitArg + 1] : undefined;
|
||||
const limit = limitStr ? parseInt(limitStr, 10) : 20;
|
||||
|
||||
if (!jobId) {
|
||||
console.error(`${red("Error:")} Job ID is required`);
|
||||
console.error(`${dim("Usage:")} multica cron logs <id> [--limit N]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find job by partial ID
|
||||
const jobs = service.list();
|
||||
const matches = jobs.filter((j) => j.id.startsWith(jobId));
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error(`${red("Error:")} Job not found: ${jobId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`${red("Error:")} Multiple jobs match "${jobId}":`);
|
||||
for (const j of matches) {
|
||||
console.error(` ${j.id.slice(0, 8)} - ${j.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const job = matches[0]!;
|
||||
const logs = service.getRunLogs(job.id, limit);
|
||||
|
||||
console.log(`\n${brightCyan("Run Logs:")} ${job.name} ${dim(`(${job.id.slice(0, 8)})`)}\n`);
|
||||
|
||||
if (logs.length === 0) {
|
||||
console.log(dim("No run logs found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = new Date(log.ts).toLocaleString();
|
||||
const statusColor = log.status === "ok" ? green : log.status === "error" ? red : yellow;
|
||||
const duration = log.durationMs ? formatDuration(log.durationMs) : "-";
|
||||
|
||||
console.log(` ${dim(timestamp)} ${statusColor(`[${log.status}]`)} ${dim(`(${duration})`)}`);
|
||||
if (log.error) {
|
||||
console.log(` ${red("Error:")} ${log.error}`);
|
||||
}
|
||||
if (log.summary) {
|
||||
console.log(` ${dim(log.summary)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim(`Showing ${logs.length} most recent entries`)}`);
|
||||
}
|
||||
|
||||
export async function cronCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const restArgs = args.slice(1);
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure service is started
|
||||
const service = getCronService();
|
||||
await service.start();
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
cmdStatus();
|
||||
break;
|
||||
case "list":
|
||||
cmdList(restArgs);
|
||||
break;
|
||||
case "add":
|
||||
cmdAdd(restArgs);
|
||||
break;
|
||||
case "run":
|
||||
await cmdRun(restArgs);
|
||||
break;
|
||||
case "enable":
|
||||
cmdEnableDisable(restArgs, true);
|
||||
break;
|
||||
case "disable":
|
||||
cmdEnableDisable(restArgs, false);
|
||||
break;
|
||||
case "remove":
|
||||
cmdRemove(restArgs);
|
||||
break;
|
||||
case "logs":
|
||||
cmdLogs(restArgs);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ ${cyan("Usage:")} multica session <command> [options]
|
|||
|
||||
${cyan("Commands:")}
|
||||
${yellow("list")} List all sessions
|
||||
${yellow("show")} <id> Show session details
|
||||
${yellow("show")} <id> Show session details (use --show-internal to include internal messages)
|
||||
${yellow("delete")} <id> Delete a session
|
||||
${yellow("help")} Show this help
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ function cmdList() {
|
|||
console.log(`${dim("Resume with:")} multica --session <id>`);
|
||||
}
|
||||
|
||||
function cmdShow(sessionId: string | undefined) {
|
||||
function cmdShow(sessionId: string | undefined, showInternal = false) {
|
||||
if (!sessionId) {
|
||||
console.error("Error: Session ID is required");
|
||||
console.error("Usage: multica session show <id>");
|
||||
|
|
@ -160,14 +160,25 @@ function cmdShow(sessionId: string | undefined) {
|
|||
console.log(cyan("─".repeat(60)));
|
||||
console.log("");
|
||||
|
||||
// Parse and display messages
|
||||
// Parse and display messages as SessionEntry objects
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Only display message entries
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Skip internal messages unless --show-internal
|
||||
if (entry.internal && !showInternal) continue;
|
||||
|
||||
const msg = entry.message;
|
||||
if (!msg) continue;
|
||||
|
||||
const role = msg.role || "unknown";
|
||||
const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim;
|
||||
const internalTag = entry.internal ? dim(" [internal]") : "";
|
||||
|
||||
console.log(`${roleColor(`[${role}]`)}`);
|
||||
console.log(`${roleColor(`[${role}]`)}${internalTag}`);
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
// Truncate long content
|
||||
|
|
@ -238,6 +249,7 @@ function cmdDelete(sessionId: string | undefined) {
|
|||
export async function sessionCommand(args: string[]): Promise<void> {
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
const showInternal = args.includes("--show-internal");
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
|
|
@ -249,7 +261,7 @@ export async function sessionCommand(args: string[]): Promise<void> {
|
|||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1);
|
||||
cmdShow(arg1, showInternal);
|
||||
break;
|
||||
case "delete":
|
||||
cmdDelete(arg1);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
* multica skills <cmd> Skills management
|
||||
* multica tools <cmd> Tool policy inspection
|
||||
* multica credentials <cmd> Credentials management
|
||||
* multica cron <cmd> Scheduled task management
|
||||
* multica dev [service] Development servers
|
||||
* multica help Show help
|
||||
*/
|
||||
|
|
@ -29,6 +30,7 @@ const subcommands: Record<string, () => Promise<SubcommandHandler>> = {
|
|||
tools: async () => (await import("./commands/tools.js")).toolsCommand,
|
||||
credentials: async () => (await import("./commands/credentials.js")).credentialsCommand,
|
||||
dev: async () => (await import("./commands/dev.js")).devCommand,
|
||||
cron: async () => (await import("./commands/cron.js")).cronCommand,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
|
|
@ -44,6 +46,7 @@ ${cyan("Usage:")}
|
|||
${yellow("multica skills")} <command> Manage skills
|
||||
${yellow("multica tools")} <command> Inspect tool policies
|
||||
${yellow("multica credentials")} <command> Manage credentials
|
||||
${yellow("multica cron")} <command> Manage scheduled tasks
|
||||
${yellow("multica dev")} [service] Start development servers
|
||||
${yellow("multica help")} Show this help
|
||||
|
||||
|
|
@ -85,6 +88,16 @@ ${cyan("Commands:")}
|
|||
show Show credential paths
|
||||
edit Open credentials in editor
|
||||
|
||||
${green("cron")}
|
||||
status Show cron service status
|
||||
list List all scheduled jobs
|
||||
add [options] Create a new scheduled job
|
||||
run <id> Run a job immediately
|
||||
enable <id> Enable a job
|
||||
disable <id> Disable a job
|
||||
remove <id> Delete a job
|
||||
logs <id> Show job run logs
|
||||
|
||||
${green("dev")}
|
||||
${dim("(default)")} Start all services (gateway + console + web)
|
||||
gateway Start gateway only (:3000)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function createAgentProfile(
|
|||
profile.user = DEFAULT_TEMPLATES.user;
|
||||
profile.workspace = DEFAULT_TEMPLATES.workspace;
|
||||
profile.memory = DEFAULT_TEMPLATES.memory;
|
||||
profile.heartbeat = DEFAULT_TEMPLATES.heartbeat;
|
||||
|
||||
// 保存到文件
|
||||
saveProfile(profile, { baseDir });
|
||||
|
|
@ -150,6 +151,7 @@ export class ProfileManager {
|
|||
user: profile.user,
|
||||
workspace: profile.workspace,
|
||||
memory: profile.memory,
|
||||
heartbeat: profile.heartbeat,
|
||||
config: profile.config,
|
||||
},
|
||||
profileDir: this.getProfileDir(),
|
||||
|
|
@ -168,6 +170,19 @@ export class ProfileManager {
|
|||
return profile?.config;
|
||||
}
|
||||
|
||||
/** Get heartbeat configuration from profile config */
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
const profile = this.getProfile();
|
||||
return profile?.config?.heartbeat;
|
||||
}
|
||||
|
||||
/** 更新 tools 配置 */
|
||||
updateToolsConfig(toolsConfig: ToolsConfig): void {
|
||||
const profile = this.getOrCreateProfile(false);
|
||||
|
|
|
|||
|
|
@ -95,13 +95,14 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
|
|||
user: readProfileFile(profileId, PROFILE_FILES.user, options),
|
||||
workspace: readProfileFile(profileId, PROFILE_FILES.workspace, options),
|
||||
memory: readProfileFile(profileId, PROFILE_FILES.memory, options),
|
||||
heartbeat: readProfileFile(profileId, PROFILE_FILES.heartbeat, options),
|
||||
config: readProfileConfig(profileId, options),
|
||||
};
|
||||
}
|
||||
|
||||
/** 保存 AgentProfile(只写入非空字段) */
|
||||
export function saveProfile(profile: AgentProfile, options?: StorageOptions): void {
|
||||
const { id, soul, user, workspace, memory, config } = profile;
|
||||
const { id, soul, user, workspace, memory, heartbeat, config } = profile;
|
||||
|
||||
if (soul !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.soul, soul, options);
|
||||
|
|
@ -115,6 +116,9 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo
|
|||
if (memory !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.memory, memory, options);
|
||||
}
|
||||
if (heartbeat !== undefined) {
|
||||
writeProfileFile(id, PROFILE_FILES.heartbeat, heartbeat, options);
|
||||
}
|
||||
if (config !== undefined) {
|
||||
writeProfileConfig(id, config, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ Your profile directory contains these files (use \`edit\` or \`write\` to update
|
|||
| \`user.md\` | About your human | As you learn about them |
|
||||
| \`workspace.md\` | This file — your rules | When you discover better conventions |
|
||||
| \`memory.md\` | Long-term knowledge | Regularly — capture what matters |
|
||||
| \`heartbeat.md\` | Background check instructions | When heartbeat behavior should change |
|
||||
|
||||
## Every Session
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ You wake up fresh each session. These files are your continuity:
|
|||
|
||||
- **Long-term:** \`MEMORY.md\` — your curated memories, lessons learned
|
||||
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs of what happened (optional)
|
||||
- **Heartbeat:** \`heartbeat.md\` — periodic check loop instructions
|
||||
|
||||
Capture what matters. Decisions, context, things to remember.
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ Capture what matters. Decisions, context, things to remember.
|
|||
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
|
||||
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
|
||||
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
|
||||
- \`heartbeat.md\` — Periodic background checks and alert rules
|
||||
|
||||
**Rules:**
|
||||
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
|
||||
|
|
@ -148,5 +151,11 @@ _(Persistent knowledge will be stored here. Update this as you learn.)_
|
|||
## Lessons Learned
|
||||
|
||||
## Important Context
|
||||
`,
|
||||
|
||||
heartbeat: `# heartbeat.md
|
||||
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
`,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const PROFILE_FILES = {
|
|||
user: "user.md",
|
||||
workspace: "workspace.md",
|
||||
memory: "memory.md",
|
||||
heartbeat: "heartbeat.md",
|
||||
config: "config.json",
|
||||
} as const;
|
||||
|
||||
|
|
@ -42,6 +43,17 @@ export interface ProfileConfig {
|
|||
reasoningMode?: "off" | "on" | "stream" | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
/** Heartbeat configuration */
|
||||
heartbeat?: {
|
||||
/** Global heartbeat enable switch */
|
||||
enabled?: boolean | undefined;
|
||||
/** Interval, e.g. "30m", "1h" */
|
||||
every?: string | undefined;
|
||||
/** Optional prompt override */
|
||||
prompt?: string | undefined;
|
||||
/** Max chars after HEARTBEAT_OK to still treat as ack */
|
||||
ackMaxChars?: number | undefined;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
/** Agent Profile configuration */
|
||||
|
|
@ -56,6 +68,8 @@ export interface AgentProfile {
|
|||
workspace?: string | undefined;
|
||||
/** Persistent memory - long-term knowledge base */
|
||||
memory?: string | undefined;
|
||||
/** Periodic heartbeat instructions */
|
||||
heartbeat?: string | undefined;
|
||||
/** Profile configuration (from config.json) */
|
||||
config?: ProfileConfig | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
|
||||
import type { MulticaEvent } from "./events.js";
|
||||
|
|
@ -85,6 +84,10 @@ export class Agent {
|
|||
private readonly stderr: NodeJS.WritableStream;
|
||||
private initialized = false;
|
||||
|
||||
// Internal run state
|
||||
private _internalRun = false;
|
||||
private _runMutex: Promise<void> = Promise.resolve();
|
||||
|
||||
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
|
||||
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
|
||||
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
|
||||
|
|
@ -353,7 +356,49 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
|
||||
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
|
||||
async run(prompt: string): Promise<AgentRunResult> {
|
||||
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
|
||||
return this.withRunMutex(() => this._run(prompt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a prompt as an internal turn.
|
||||
* Messages are persisted with `internal: true` and rolled back from
|
||||
* in-memory state after the turn completes, so they do not pollute
|
||||
* the main conversation context.
|
||||
*/
|
||||
async runInternal(prompt: string): Promise<AgentRunResult> {
|
||||
return this.withRunMutex(async () => {
|
||||
const messageCountBefore = this.agent.state.messages.length;
|
||||
this._internalRun = true;
|
||||
try {
|
||||
const result = await this._run(prompt);
|
||||
return result;
|
||||
} finally {
|
||||
this._internalRun = false;
|
||||
// Roll back internal messages from in-memory state
|
||||
const current = this.agent.state.messages;
|
||||
if (current.length > messageCountBefore) {
|
||||
this.agent.replaceMessages(current.slice(0, messageCountBefore));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Chain on the mutex so only one run executes at a time
|
||||
const prev = this._runMutex;
|
||||
let resolve: () => void;
|
||||
this._runMutex = new Promise<void>((r) => { resolve = r; });
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
|
||||
private async _run(prompt: string): Promise<AgentRunResult> {
|
||||
await this.ensureInitialized();
|
||||
this.output.state.lastAssistantText = "";
|
||||
|
||||
|
|
@ -363,7 +408,7 @@ export class Agent {
|
|||
// Loop to exhaust all candidate profiles on rotatable errors
|
||||
while (true) {
|
||||
try {
|
||||
await this.agent.prompt(prompt, images);
|
||||
await this.agent.prompt(prompt);
|
||||
break; // success — exit loop
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
|
@ -443,8 +488,10 @@ export class Agent {
|
|||
private handleSessionEvent(event: AgentEvent) {
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as AgentMessage;
|
||||
this.session.saveMessage(message);
|
||||
if (message.role === "assistant") {
|
||||
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
|
||||
// Skip compaction during internal runs — internal messages will be
|
||||
// rolled back from memory afterwards, so compacting now would be incorrect.
|
||||
if (message.role === "assistant" && !this._internalRun) {
|
||||
void this.maybeCompact();
|
||||
}
|
||||
}
|
||||
|
|
@ -511,6 +558,40 @@ export class Agent {
|
|||
return this.agent.state.tools?.map(t => t.name) ?? [];
|
||||
}
|
||||
|
||||
/** Whether the agent is currently executing an internal run */
|
||||
get isInternalRun(): boolean {
|
||||
return this._internalRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a synthetic assistant message into both in-memory state and session JSONL.
|
||||
* Used after an internal run to keep the LLM summary visible in future turns
|
||||
* while the internal prompt stays hidden.
|
||||
*/
|
||||
persistAssistantSummary(text: string): void {
|
||||
const model = this.agent.state.model;
|
||||
const message = {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
api: model?.api ?? "openai-completions",
|
||||
provider: model?.provider ?? "internal",
|
||||
model: model?.id ?? "unknown",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.agent.appendMessage(message);
|
||||
this.session.saveMessage(message);
|
||||
}
|
||||
|
||||
/** Ensure session messages are loaded from disk (idempotent) */
|
||||
async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
|
@ -522,11 +603,19 @@ export class Agent {
|
|||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Get all messages from the current session */
|
||||
/** Get all messages from the current session (in-memory state) */
|
||||
getMessages(): AgentMessage[] {
|
||||
return this.agent.state.messages.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from session storage with filtering.
|
||||
* By default, internal messages are excluded.
|
||||
*/
|
||||
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
return this.session.loadMessages(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all skills with their eligibility status.
|
||||
* Returns empty array if skills are disabled.
|
||||
|
|
@ -596,6 +685,27 @@ export class Agent {
|
|||
return this.profile?.getProfile()?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile directory path, if profile is enabled.
|
||||
*/
|
||||
getProfileDir(): string | undefined {
|
||||
return this.profile?.getProfileDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heartbeat configuration from profile config.
|
||||
*/
|
||||
getHeartbeatConfig():
|
||||
| {
|
||||
enabled?: boolean | undefined;
|
||||
every?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
ackMaxChars?: number | undefined;
|
||||
}
|
||||
| undefined {
|
||||
return this.profile?.getHeartbeatConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
|
|
@ -771,6 +881,7 @@ export class Agent {
|
|||
user: profile.user,
|
||||
workspace: profile.workspace,
|
||||
memory: profile.memory,
|
||||
heartbeat: profile.heartbeat,
|
||||
config: profile.config,
|
||||
},
|
||||
profileDir: this.profile!.getProfileDir(),
|
||||
|
|
|
|||
|
|
@ -167,11 +167,15 @@ export class SessionManager {
|
|||
return repairSessionFileIfNeeded({ sessionFile: filePath, warn });
|
||||
}
|
||||
|
||||
loadMessages(): AgentMessage[] {
|
||||
loadMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
|
||||
const entries = this.loadEntries();
|
||||
let messages = entries
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message);
|
||||
.filter((entry) => {
|
||||
if (entry.type !== "message") return false;
|
||||
if (!options?.includeInternal && entry.internal) return false;
|
||||
return true;
|
||||
})
|
||||
.map((entry) => (entry as { type: "message"; message: AgentMessage }).message);
|
||||
messages = sanitizeToolCallInputs(messages);
|
||||
messages = sanitizeToolUseResultPairing(messages);
|
||||
return messages;
|
||||
|
|
@ -203,11 +207,16 @@ export class SessionManager {
|
|||
);
|
||||
}
|
||||
|
||||
saveMessage(message: AgentMessage) {
|
||||
saveMessage(message: AgentMessage, options?: { internal?: boolean }) {
|
||||
void this.enqueue(() =>
|
||||
appendEntry(
|
||||
this.sessionId,
|
||||
{ type: "message", message, timestamp: Date.now() },
|
||||
{
|
||||
type: "message",
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
...(options?.internal ? { internal: true } : {}),
|
||||
},
|
||||
{ baseDir: this.baseDir },
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type SessionMeta = {
|
|||
};
|
||||
|
||||
export type SessionEntry =
|
||||
| { type: "message"; message: AgentMessage; timestamp: number }
|
||||
| { type: "message"; message: AgentMessage; timestamp: number; internal?: boolean }
|
||||
| { type: "meta"; meta: SessionMeta; timestamp: number }
|
||||
| {
|
||||
type: "compaction";
|
||||
|
|
|
|||
67
src/agent/subagent/announce-findings.test.ts
Normal file
67
src/agent/subagent/announce-findings.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const readEntriesMock = vi.fn();
|
||||
|
||||
vi.mock("../session/storage.js", () => ({
|
||||
readEntries: (sessionId: string) => readEntriesMock(sessionId),
|
||||
}));
|
||||
|
||||
import { readLatestAssistantReply } from "./announce.js";
|
||||
|
||||
describe("readLatestAssistantReply", () => {
|
||||
beforeEach(() => {
|
||||
readEntriesMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns the latest non-empty assistant text when the last assistant message is tool-only", () => {
|
||||
readEntriesMock.mockReturnValue([
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "南京天气:晴,12°C。" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 2,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "weather", arguments: { city: "Nanjing" } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = readLatestAssistantReply("child-session");
|
||||
expect(result).toBe("南京天气:晴,12°C。");
|
||||
});
|
||||
|
||||
it("falls back to latest toolResult text when no assistant text exists", () => {
|
||||
readEntriesMock.mockReturnValue([
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-2", name: "weather", arguments: { city: "Nanjing" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: 2,
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-2",
|
||||
toolName: "weather",
|
||||
content: [{ type: "text", text: "{\"city\":\"Nanjing\",\"tempC\":12,\"condition\":\"Sunny\"}" }],
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = readLatestAssistantReply("child-session");
|
||||
expect(result).toContain("\"city\":\"Nanjing\"");
|
||||
expect(result).toContain("\"condition\":\"Sunny\"");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildSubagentSystemPrompt, formatAnnouncementMessage } from "./announce.js";
|
||||
import { buildSubagentSystemPrompt, formatAnnouncementMessage, formatCoalescedAnnouncementMessage } from "./announce.js";
|
||||
import type { FormatAnnouncementParams } from "./announce.js";
|
||||
import type { SubagentRunRecord } from "./types.js";
|
||||
|
||||
describe("buildSubagentSystemPrompt", () => {
|
||||
it("includes task and session context", () => {
|
||||
|
|
@ -126,3 +127,128 @@ describe("formatAnnouncementMessage", () => {
|
|||
expect(msg).toContain("NO_REPLY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCoalescedAnnouncementMessage", () => {
|
||||
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Default task",
|
||||
cleanup: "delete",
|
||||
createdAt: 1000000,
|
||||
startedAt: 1000000,
|
||||
endedAt: 1030000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Some findings",
|
||||
findingsCaptured: true,
|
||||
announced: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("delegates to formatAnnouncementMessage for a single record", () => {
|
||||
const record = makeRecord({ label: "Code Analysis" });
|
||||
const coalesced = formatCoalescedAnnouncementMessage([record]);
|
||||
const direct = formatAnnouncementMessage({
|
||||
runId: record.runId,
|
||||
childSessionId: record.childSessionId,
|
||||
requesterSessionId: record.requesterSessionId,
|
||||
task: record.task,
|
||||
label: record.label,
|
||||
cleanup: record.cleanup,
|
||||
outcome: record.outcome,
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
findings: record.findings,
|
||||
});
|
||||
|
||||
expect(coalesced).toBe(direct);
|
||||
});
|
||||
|
||||
it("formats multiple records with all task findings and stats", () => {
|
||||
const records = [
|
||||
makeRecord({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
label: "Task A",
|
||||
findings: "Found issue A",
|
||||
startedAt: 1000000,
|
||||
endedAt: 1030000,
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-2",
|
||||
childSessionId: "child-2",
|
||||
label: "Task B",
|
||||
findings: "Found issue B",
|
||||
startedAt: 1000000,
|
||||
endedAt: 1045000, // 45 seconds
|
||||
}),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("All 2 background tasks have completed");
|
||||
expect(msg).toContain('Task 1: "Task A"');
|
||||
expect(msg).toContain("Found issue A");
|
||||
expect(msg).toContain('Task 2: "Task B"');
|
||||
expect(msg).toContain("Found issue B");
|
||||
expect(msg).toContain("Total wall time: 45s");
|
||||
expect(msg).toContain("2 succeeded, 0 failed");
|
||||
});
|
||||
|
||||
it("reports mixed outcomes correctly", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", label: "OK Task", outcome: { status: "ok" } }),
|
||||
makeRecord({ runId: "run-2", label: "Failed Task", outcome: { status: "error", error: "crash" } }),
|
||||
makeRecord({ runId: "run-3", label: "Timeout Task", outcome: { status: "timeout" } }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("completed successfully");
|
||||
expect(msg).toContain("failed: crash");
|
||||
expect(msg).toContain("timed out");
|
||||
expect(msg).toContain("1 succeeded, 2 failed");
|
||||
});
|
||||
|
||||
it("shows (no output) for missing findings", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", findings: undefined }),
|
||||
makeRecord({ runId: "run-2", findings: "Has output" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("(no output)");
|
||||
expect(msg).toContain("Has output");
|
||||
});
|
||||
|
||||
it("includes combined summary instruction for multi-record", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1" }),
|
||||
makeRecord({ runId: "run-2" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("MUST include findings from every task item above");
|
||||
expect(msg).toContain("NO_REPLY");
|
||||
});
|
||||
|
||||
it("includes raw findings for every task in coalesced payload", () => {
|
||||
const records = [
|
||||
makeRecord({ runId: "run-1", label: "南京天气", findings: "南京:晴,12°C" }),
|
||||
makeRecord({ runId: "run-2", label: "上海天气", findings: "上海:多云,9°C" }),
|
||||
];
|
||||
|
||||
const msg = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
expect(msg).toContain("Raw findings from each task (MUST cover all items):");
|
||||
expect(msg).toContain("[1] 南京天气:");
|
||||
expect(msg).toContain("南京:晴,12°C");
|
||||
expect(msg).toContain("[2] 上海天气:");
|
||||
expect(msg).toContain("上海:多云,9°C");
|
||||
expect(msg).toContain("MUST include findings from every task item above");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { buildSystemPrompt } from "../system-prompt/index.js";
|
|||
import type {
|
||||
SubagentAnnounceParams,
|
||||
SubagentRunOutcome,
|
||||
SubagentRunRecord,
|
||||
SubagentSystemPromptParams,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
@ -38,19 +39,29 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
|
|||
*/
|
||||
export function readLatestAssistantReply(sessionId: string): string | undefined {
|
||||
const entries = readEntries(sessionId);
|
||||
let latestToolResultText: string | undefined;
|
||||
|
||||
// Walk backwards to find last assistant message
|
||||
// Walk backwards to find the last non-empty assistant reply.
|
||||
// If no assistant text exists (e.g. run ended after tool execution),
|
||||
// fall back to the latest non-empty toolResult content.
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i]!;
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const message = entry.message;
|
||||
if (message.role !== "assistant") continue;
|
||||
if (message.role === "assistant") {
|
||||
const text = extractAssistantText(message);
|
||||
if (text) return text;
|
||||
continue;
|
||||
}
|
||||
|
||||
return extractAssistantText(message);
|
||||
if (message.role === "toolResult" && !latestToolResultText) {
|
||||
const text = extractToolResultText(message);
|
||||
if (text) latestToolResultText = text;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return latestToolResultText;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +69,17 @@ export function readLatestAssistantReply(sessionId: string): string | undefined
|
|||
* AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[].
|
||||
*/
|
||||
function extractAssistantText(message: { role: string; content: unknown }): string {
|
||||
const content = message.content;
|
||||
return extractTextLikeContent(message.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from a toolResult message.
|
||||
*/
|
||||
function extractToolResultText(message: { role: string; content: unknown }): string {
|
||||
return extractTextLikeContent(message.content);
|
||||
}
|
||||
|
||||
function extractTextLikeContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return sanitizeText(content);
|
||||
}
|
||||
|
|
@ -67,8 +88,9 @@ function extractAssistantText(message: { role: string; content: unknown }): stri
|
|||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
|
||||
textParts.push(String(block.text));
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ("text" in block) {
|
||||
textParts.push(String((block as { text: unknown }).text));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,11 +189,126 @@ export function formatAnnouncementMessage(params: FormatAnnouncementParams): str
|
|||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a coalesced announcement message from multiple completed subagent runs.
|
||||
* When only one record is provided, delegates to formatAnnouncementMessage.
|
||||
*/
|
||||
export function formatCoalescedAnnouncementMessage(
|
||||
records: SubagentRunRecord[],
|
||||
): string {
|
||||
// Single record: delegate to existing format for backward-compatible behavior
|
||||
if (records.length === 1) {
|
||||
const r = records[0]!;
|
||||
return formatAnnouncementMessage({
|
||||
runId: r.runId,
|
||||
childSessionId: r.childSessionId,
|
||||
requesterSessionId: r.requesterSessionId,
|
||||
task: r.task,
|
||||
label: r.label,
|
||||
cleanup: r.cleanup,
|
||||
outcome: r.outcome,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
findings: r.findings,
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple records: build combined message.
|
||||
// Include a strict raw-findings section so parent can reliably cover every task result.
|
||||
const parts: string[] = [
|
||||
`All ${records.length} background tasks have completed. Here are the combined results:`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i]!;
|
||||
const displayName = r.label || r.task.slice(0, 60);
|
||||
const statusLabel = formatStatusLabel(r.outcome);
|
||||
const durationStr = (r.startedAt && r.endedAt)
|
||||
? ` (${formatDuration(r.startedAt, r.endedAt)})`
|
||||
: "";
|
||||
|
||||
parts.push(
|
||||
`### Task ${i + 1}: "${displayName}"`,
|
||||
`Status: ${statusLabel}${durationStr}`,
|
||||
"",
|
||||
"Findings:",
|
||||
r.findings || "(no output)",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Overall stats
|
||||
const allStartTimes = records.map(r => r.startedAt).filter(Boolean) as number[];
|
||||
const allEndTimes = records.map(r => r.endedAt).filter(Boolean) as number[];
|
||||
if (allStartTimes.length > 0 && allEndTimes.length > 0) {
|
||||
const wallTime = formatDuration(Math.min(...allStartTimes), Math.max(...allEndTimes));
|
||||
parts.push(`Total wall time: ${wallTime}`);
|
||||
}
|
||||
|
||||
const okCount = records.filter(r => r.outcome?.status === "ok").length;
|
||||
const failCount = records.length - okCount;
|
||||
parts.push(`Results: ${okCount} succeeded, ${failCount} failed/timed out`);
|
||||
|
||||
parts.push("", "Raw findings from each task (MUST cover all items):", "");
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i]!;
|
||||
const displayName = r.label || r.task.slice(0, 60);
|
||||
parts.push(
|
||||
`[${i + 1}] ${displayName}:`,
|
||||
r.findings || "(no output)",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"",
|
||||
"Summarize these results naturally for the user.",
|
||||
"You MUST include findings from every task item above, without omission.",
|
||||
"Keep it concise, but preserve concrete findings from each task.",
|
||||
"Do not mention technical details like session IDs or that these were background tasks.",
|
||||
"You can respond with NO_REPLY if no announcement is needed.",
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the coalesced announcement flow for all completed runs of a requester.
|
||||
* Formats a single combined message and delivers it to the parent agent.
|
||||
*/
|
||||
export function runCoalescedAnnounceFlow(
|
||||
requesterSessionId: string,
|
||||
records: SubagentRunRecord[],
|
||||
): boolean {
|
||||
const message = formatCoalescedAnnouncementMessage(records);
|
||||
|
||||
try {
|
||||
const hub = getHub();
|
||||
const parentAgent = hub.getAgent(requesterSessionId);
|
||||
if (!parentAgent || parentAgent.closed) {
|
||||
console.warn(
|
||||
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full subagent announcement flow:
|
||||
* 1. Read child's last assistant reply
|
||||
* 2. Format announcement message
|
||||
* 3. Send to parent agent via Hub
|
||||
*
|
||||
* @deprecated Use runCoalescedAnnounceFlow instead, which supports
|
||||
* batching multiple completed runs into a single announcement.
|
||||
*/
|
||||
export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean {
|
||||
const { requesterSessionId, childSessionId } = params;
|
||||
|
|
@ -204,7 +341,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean
|
|||
return false;
|
||||
}
|
||||
|
||||
parentAgent.write(message);
|
||||
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export {
|
|||
readLatestAssistantReply,
|
||||
formatAnnouncementMessage,
|
||||
runSubagentAnnounceFlow,
|
||||
formatCoalescedAnnouncementMessage,
|
||||
runCoalescedAnnounceFlow,
|
||||
} from "./announce.js";
|
||||
export type { FormatAnnouncementParams } from "./announce.js";
|
||||
|
||||
|
|
|
|||
75
src/agent/subagent/registry-recovery.test.ts
Normal file
75
src/agent/subagent/registry-recovery.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SubagentRunRecord } from "./types.js";
|
||||
|
||||
const loadSubagentRunsMock = vi.fn<() => Map<string, SubagentRunRecord>>();
|
||||
const saveSubagentRunsMock = vi.fn();
|
||||
const readLatestAssistantReplyMock = vi.fn();
|
||||
const runCoalescedAnnounceFlowMock = vi.fn(() => false);
|
||||
const resolveSessionDirMock = vi.fn((sessionId: string) => `/tmp/${sessionId}`);
|
||||
const closeAgentMock = vi.fn();
|
||||
const getHubMock = vi.fn(() => ({ closeAgent: closeAgentMock }));
|
||||
const rmSyncMock = vi.fn();
|
||||
|
||||
vi.mock("./registry-store.js", () => ({
|
||||
loadSubagentRuns: loadSubagentRunsMock,
|
||||
saveSubagentRuns: saveSubagentRunsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./announce.js", () => ({
|
||||
readLatestAssistantReply: readLatestAssistantReplyMock,
|
||||
runCoalescedAnnounceFlow: runCoalescedAnnounceFlowMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session/storage.js", () => ({
|
||||
resolveSessionDir: resolveSessionDirMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../hub/hub-singleton.js", () => ({
|
||||
getHub: getHubMock,
|
||||
isHubInitialized: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
rmSync: rmSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe("subagent registry recovery cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
loadSubagentRunsMock.mockReturnValue(new Map());
|
||||
runCoalescedAnnounceFlowMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("deletes child session on recovery even when findings were already captured", async () => {
|
||||
const now = Date.now();
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "task",
|
||||
cleanup: "delete",
|
||||
createdAt: now - 1000,
|
||||
startedAt: now - 900,
|
||||
endedAt: now - 100,
|
||||
outcome: { status: "ok" },
|
||||
findings: "done",
|
||||
findingsCaptured: true,
|
||||
cleanupHandled: false,
|
||||
announced: false,
|
||||
};
|
||||
|
||||
loadSubagentRunsMock.mockReturnValue(new Map([["run-1", record]]));
|
||||
|
||||
const registry = await import("./registry.js");
|
||||
registry.initSubagentRegistry();
|
||||
|
||||
expect(readLatestAssistantReplyMock).not.toHaveBeenCalled();
|
||||
expect(resolveSessionDirMock).toHaveBeenCalledWith("child-1");
|
||||
expect(rmSyncMock).toHaveBeenCalledWith("/tmp/child-1", { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -78,4 +78,50 @@ describe("registry-store serialization", () => {
|
|||
expect(parsed.outcome?.status).toBe("error");
|
||||
expect(parsed.outcome?.error).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("round-trips coalescing fields (findings, findingsCaptured, announced)", () => {
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-coalesce",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Coalesce test",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now(),
|
||||
endedAt: Date.now() + 5000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Found 3 issues in auth module.",
|
||||
findingsCaptured: true,
|
||||
announced: true,
|
||||
};
|
||||
|
||||
const json = JSON.stringify({ version: 1, runs: { "run-coalesce": record } });
|
||||
const parsed = JSON.parse(json);
|
||||
const restored = parsed.runs["run-coalesce"] as SubagentRunRecord;
|
||||
|
||||
expect(restored.findings).toBe("Found 3 issues in auth module.");
|
||||
expect(restored.findingsCaptured).toBe(true);
|
||||
expect(restored.announced).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips record with undefined coalescing fields", () => {
|
||||
const record: SubagentRunRecord = {
|
||||
runId: "run-old",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Old record",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now(),
|
||||
cleanupHandled: true,
|
||||
// No findings, findingsCaptured, or announced fields (old format)
|
||||
};
|
||||
|
||||
const json = JSON.stringify({ version: 1, runs: { "run-old": record } });
|
||||
const parsed = JSON.parse(json);
|
||||
const restored = parsed.runs["run-old"] as SubagentRunRecord;
|
||||
|
||||
expect(restored.findings).toBeUndefined();
|
||||
expect(restored.findingsCaptured).toBeUndefined();
|
||||
expect(restored.announced).toBeUndefined();
|
||||
expect(restored.cleanupHandled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
registerSubagentRun,
|
||||
listSubagentRuns,
|
||||
|
|
@ -159,3 +159,118 @@ describe("subagent registry", () => {
|
|||
expect(getSubagentRun("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent registry — coalescing", () => {
|
||||
// Without Hub, watchChildAgent ends runs immediately with "Hub not initialized".
|
||||
// This allows us to test the coalescing state transitions.
|
||||
|
||||
it("captures findings when a run completes (no Hub)", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 1",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-1");
|
||||
// Run ended immediately due to no Hub
|
||||
expect(record?.endedAt).toBeGreaterThan(0);
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
});
|
||||
|
||||
it("does not announce while sibling runs are still pending", () => {
|
||||
// Register first run — ends immediately (no Hub)
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 1",
|
||||
});
|
||||
|
||||
const record1 = getSubagentRun("run-1");
|
||||
expect(record1?.findingsCaptured).toBe(true);
|
||||
|
||||
// Register second run — also ends immediately
|
||||
registerSubagentRun({
|
||||
runId: "run-2",
|
||||
childSessionId: "child-2",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task 2",
|
||||
});
|
||||
|
||||
const record2 = getSubagentRun("run-2");
|
||||
expect(record2?.findingsCaptured).toBe(true);
|
||||
|
||||
// Both ended, but announce fails because no Hub for parent agent.
|
||||
// The key check: both records should have findings captured.
|
||||
// announced will be false because runCoalescedAnnounceFlow fails (no Hub).
|
||||
expect(record1?.announced).toBeUndefined();
|
||||
expect(record2?.announced).toBeUndefined();
|
||||
});
|
||||
|
||||
it("single run captures findings immediately", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-solo",
|
||||
childSessionId: "child-solo",
|
||||
requesterSessionId: "parent-solo",
|
||||
task: "Solo task",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-solo");
|
||||
expect(record?.endedAt).toBeGreaterThan(0);
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
expect(record?.outcome?.status).toBe("error");
|
||||
expect(record?.outcome?.error).toContain("Hub not initialized");
|
||||
});
|
||||
|
||||
it("shutdownSubagentRegistry captures findings for ended-but-uncaptured runs", () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionId: "child-1",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task",
|
||||
});
|
||||
|
||||
const record = getSubagentRun("run-1");
|
||||
if (record) {
|
||||
// Simulate: run ended but findings not yet captured
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "ok" };
|
||||
record.findingsCaptured = undefined;
|
||||
}
|
||||
|
||||
shutdownSubagentRegistry();
|
||||
|
||||
expect(record?.findingsCaptured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent registry — post-announce cleanup", () => {
|
||||
it("removes runs from registry after successful announcement", async () => {
|
||||
// Mock runCoalescedAnnounceFlow to succeed
|
||||
const announceModule = await import("./announce.js");
|
||||
const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true);
|
||||
|
||||
// Register two runs for the same parent — both end immediately (no Hub)
|
||||
registerSubagentRun({
|
||||
runId: "run-a",
|
||||
childSessionId: "child-a",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task A",
|
||||
});
|
||||
registerSubagentRun({
|
||||
runId: "run-b",
|
||||
childSessionId: "child-b",
|
||||
requesterSessionId: "parent-1",
|
||||
task: "Task B",
|
||||
});
|
||||
|
||||
// Both runs should have been announced and removed from registry
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(getSubagentRun("run-a")).toBeUndefined();
|
||||
expect(getSubagentRun("run-b")).toBeUndefined();
|
||||
expect(listSubagentRuns("parent-1")).toHaveLength(0);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { getHub, isHubInitialized } from "../../hub/hub-singleton.js";
|
||||
import { loadSubagentRuns, saveSubagentRuns } from "./registry-store.js";
|
||||
import { runSubagentAnnounceFlow } from "./announce.js";
|
||||
import { readLatestAssistantReply, runCoalescedAnnounceFlow } from "./announce.js";
|
||||
import type {
|
||||
RegisterSubagentRunParams,
|
||||
SubagentRunRecord,
|
||||
|
|
@ -27,7 +27,7 @@ const SWEEP_INTERVAL_MS = 60 * 1000;
|
|||
|
||||
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const resumedRuns = new Set<string>();
|
||||
const resumedRequesters = new Set<string>();
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
|
|
@ -39,25 +39,45 @@ export function initSubagentRegistry(): void {
|
|||
for (const [runId, record] of persisted) {
|
||||
subagentRuns.set(runId, record);
|
||||
|
||||
// Resume incomplete runs
|
||||
if (!record.cleanupHandled) {
|
||||
if (record.endedAt) {
|
||||
// Completed but cleanup not done — run announce flow
|
||||
if (!resumedRuns.has(runId)) {
|
||||
resumedRuns.add(runId);
|
||||
handleRunCompletion(record);
|
||||
}
|
||||
} else {
|
||||
// If not ended, the child agent session is lost on restart —
|
||||
// mark as ended with unknown outcome
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "unknown" };
|
||||
persist();
|
||||
if (!resumedRuns.has(runId)) {
|
||||
resumedRuns.add(runId);
|
||||
handleRunCompletion(record);
|
||||
}
|
||||
}
|
||||
// Backward compat: old records with cleanupHandled but no announced field
|
||||
if (record.cleanupHandled && record.announced === undefined) {
|
||||
record.announced = true;
|
||||
record.findingsCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process incomplete runs
|
||||
const affectedRequesters = new Set<string>();
|
||||
|
||||
for (const record of subagentRuns.values()) {
|
||||
if (record.announced && record.cleanupHandled) continue; // Already fully done
|
||||
|
||||
if (!record.endedAt) {
|
||||
// Child was running when process crashed — mark as ended/unknown
|
||||
record.endedAt = Date.now();
|
||||
record.outcome = { status: "unknown" };
|
||||
}
|
||||
|
||||
if (!record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
}
|
||||
|
||||
// Recovery cleanup must be independent from findings capture:
|
||||
// the process may crash after captureFindings() persisted but before deletion.
|
||||
if (record.cleanup === "delete" && !record.cleanupHandled) {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
|
||||
affectedRequesters.add(record.requesterSessionId);
|
||||
}
|
||||
|
||||
persist();
|
||||
|
||||
// For each affected requester, check if coalesced announcement is needed
|
||||
for (const requesterId of affectedRequesters) {
|
||||
if (!resumedRequesters.has(requesterId)) {
|
||||
resumedRequesters.add(requesterId);
|
||||
checkAndAnnounce(requesterId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,11 +158,17 @@ export function shutdownSubagentRegistry(): void {
|
|||
record.outcome = { status: "unknown" };
|
||||
updated++;
|
||||
}
|
||||
|
||||
// Opportunistically capture findings for ended-but-uncaptured runs
|
||||
if (record.endedAt && !record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
persist();
|
||||
console.log(`[SubagentRegistry] Marked ${updated} active run(s) as ended during shutdown`);
|
||||
console.log(`[SubagentRegistry] Processed ${updated} run(s) during shutdown`);
|
||||
}
|
||||
|
||||
stopSweeper();
|
||||
|
|
@ -151,7 +177,7 @@ export function shutdownSubagentRegistry(): void {
|
|||
/** Reset all state (for testing). */
|
||||
export function resetSubagentRegistryForTests(): void {
|
||||
subagentRuns.clear();
|
||||
resumedRuns.clear();
|
||||
resumedRequesters.clear();
|
||||
stopSweeper();
|
||||
}
|
||||
|
||||
|
|
@ -222,44 +248,76 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): vo
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup + Announce
|
||||
// Cleanup + Announce (two-phase: capture findings, then coalesced announce)
|
||||
// ============================================================================
|
||||
|
||||
function handleRunCompletion(record: SubagentRunRecord): void {
|
||||
if (record.cleanupHandled) return;
|
||||
record.cleanupHandled = true;
|
||||
/** Phase 1: Capture child's findings before session deletion. */
|
||||
function captureFindings(record: SubagentRunRecord): void {
|
||||
try {
|
||||
const findings = readLatestAssistantReply(record.childSessionId);
|
||||
record.findings = findings ?? undefined;
|
||||
} catch {
|
||||
record.findings = "(failed to read findings)";
|
||||
}
|
||||
record.findingsCaptured = true;
|
||||
persist();
|
||||
}
|
||||
|
||||
// Run announce flow
|
||||
const announced = runSubagentAnnounceFlow({
|
||||
runId: record.runId,
|
||||
childSessionId: record.childSessionId,
|
||||
requesterSessionId: record.requesterSessionId,
|
||||
task: record.task,
|
||||
label: record.label,
|
||||
cleanup: record.cleanup,
|
||||
outcome: record.outcome,
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
});
|
||||
/**
|
||||
* Phase 2: Check if all unannounced runs for this requester have completed.
|
||||
* If so, send a single coalesced announcement to the parent.
|
||||
*/
|
||||
function checkAndAnnounce(requesterSessionId: string): void {
|
||||
const allRuns = listSubagentRuns(requesterSessionId);
|
||||
|
||||
if (!announced) {
|
||||
console.warn(`[SubagentRegistry] Announce flow failed for run ${record.runId}`);
|
||||
// Allow retry on next restart if announce failed.
|
||||
record.cleanupHandled = false;
|
||||
// Only consider unannounced runs
|
||||
const pending = allRuns.filter(r => !r.announced);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// Are all unannounced runs done?
|
||||
const allDone = pending.every(r => r.endedAt !== undefined);
|
||||
if (!allDone) return;
|
||||
|
||||
// Have all had findings captured?
|
||||
const allCaptured = pending.every(r => r.findingsCaptured);
|
||||
if (!allCaptured) return;
|
||||
|
||||
// All done — send coalesced announcement
|
||||
const announced = runCoalescedAnnounceFlow(requesterSessionId, pending);
|
||||
|
||||
if (announced) {
|
||||
for (const r of pending) {
|
||||
r.announced = true;
|
||||
r.cleanupHandled = true;
|
||||
// Remove from registry immediately — findings already delivered to parent
|
||||
subagentRuns.delete(r.runId);
|
||||
}
|
||||
persist();
|
||||
return;
|
||||
if (subagentRuns.size === 0) {
|
||||
stopSweeper();
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`,
|
||||
);
|
||||
// Leave announced=false so initSubagentRegistry() can retry on restart
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry point: called when a child completes. */
|
||||
function handleRunCompletion(record: SubagentRunRecord): void {
|
||||
// Phase 1: capture findings (before session deletion)
|
||||
if (!record.findingsCaptured) {
|
||||
captureFindings(record);
|
||||
|
||||
// Session cleanup (safe now that findings are persisted)
|
||||
if (record.cleanup === "delete") {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session cleanup
|
||||
if (record.cleanup === "delete") {
|
||||
deleteChildSession(record.childSessionId);
|
||||
}
|
||||
|
||||
// Schedule archive
|
||||
record.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS;
|
||||
record.cleanupCompletedAt = Date.now();
|
||||
persist();
|
||||
// Phase 2: coalesced announce check
|
||||
checkAndAnnounce(record.requesterSessionId);
|
||||
}
|
||||
|
||||
function deleteChildSession(sessionId: string): void {
|
||||
|
|
@ -305,7 +363,6 @@ function sweep(): void {
|
|||
for (const [runId, record] of subagentRuns) {
|
||||
if (record.archiveAtMs !== undefined && record.archiveAtMs <= now) {
|
||||
subagentRuns.delete(runId);
|
||||
resumedRuns.delete(runId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ export type SubagentRunRecord = {
|
|||
cleanupHandled?: boolean | undefined;
|
||||
/** Timestamp when cleanup completed */
|
||||
cleanupCompletedAt?: number | undefined;
|
||||
/** Captured findings from the child session's last assistant reply */
|
||||
findings?: string | undefined;
|
||||
/** Whether findings have been captured (safe to delete session after this) */
|
||||
findingsCaptured?: boolean | undefined;
|
||||
/** Whether the coalesced announcement has been sent to parent */
|
||||
announced?: boolean | undefined;
|
||||
};
|
||||
|
||||
/** Parameters for registering a new subagent run */
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
SystemPromptReport,
|
||||
} from "./types.js";
|
||||
import {
|
||||
buildHeartbeatSection,
|
||||
buildConditionalToolSections,
|
||||
buildExtraPromptSection,
|
||||
buildIdentitySection,
|
||||
|
|
@ -58,6 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
|
|||
{ name: "user", lines: buildUserSection(profile, mode) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
|
||||
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
|
||||
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
|
||||
{ name: "safety", lines: buildSafetySection(includeSafety) },
|
||||
{ name: "tooling", lines: buildToolingSummary(tools, mode) },
|
||||
{ name: "tool-call-style", lines: buildToolCallStyleSection(mode) },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { SAFETY_CONSTITUTION } from "./constitution.js";
|
||||
import { formatRuntimeLine } from "./runtime-info.js";
|
||||
import { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.js";
|
||||
import type {
|
||||
ProfileContent,
|
||||
RuntimeInfo,
|
||||
|
|
@ -97,13 +98,14 @@ export function buildWorkspaceSection(
|
|||
"## Profile",
|
||||
"",
|
||||
`Your profile directory: \`${profileDir}\``,
|
||||
"Use this as the base path for profile files (soul.md, user.md, memory.md, memory/*.md).",
|
||||
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
|
||||
"",
|
||||
"Profile files:",
|
||||
"- `soul.md` — Your identity and values",
|
||||
"- `user.md` — Information about your user",
|
||||
"- `workspace.md` — Guidelines and conventions (below)",
|
||||
"- `memory.md` — Persistent knowledge",
|
||||
"- `heartbeat.md` — Background heartbeat loop instructions",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
|
@ -128,6 +130,26 @@ export function buildMemoryFileSection(
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat section — full mode only.
|
||||
* Keeps heartbeat protocol explicit in the agent instructions.
|
||||
*/
|
||||
export function buildHeartbeatSection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode !== "full") return [];
|
||||
const prompt = resolveHeartbeatPrompt(profile?.config?.heartbeat?.prompt);
|
||||
return [
|
||||
"## Heartbeats",
|
||||
`Heartbeat prompt: ${prompt}`,
|
||||
'If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:',
|
||||
"HEARTBEAT_OK",
|
||||
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety constitution — always included.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface ProfileContent {
|
|||
user?: string | undefined;
|
||||
workspace?: string | undefined;
|
||||
memory?: string | undefined;
|
||||
heartbeat?: string | undefined;
|
||||
config?: ProfileConfig | undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { createProcessTool } from "./tools/process.js";
|
|||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list.js";
|
||||
import { createMemorySearchTool } from "./tools/memory-search.js";
|
||||
import { createCronTool } from "./tools/cron/index.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
|
||||
|
|
@ -107,6 +109,8 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
const webFetchTool = createWebFetchTool();
|
||||
const webSearchTool = createWebSearchTool();
|
||||
|
||||
const cronTool = createCronTool();
|
||||
|
||||
const tools: AgentTool<any>[] = [
|
||||
...baseTools,
|
||||
execTool as AgentTool<any>,
|
||||
|
|
@ -114,6 +118,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
globTool as AgentTool<any>,
|
||||
webFetchTool as AgentTool<any>,
|
||||
webSearchTool as AgentTool<any>,
|
||||
cronTool as AgentTool<any>,
|
||||
];
|
||||
|
||||
// Add memory_search tool if profileDir is provided
|
||||
|
|
@ -129,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
});
|
||||
tools.push(sessionsSpawnTool as AgentTool<any>);
|
||||
|
||||
// Add sessions_list tool
|
||||
const sessionsListTool = createSessionsListTool({ sessionId });
|
||||
tools.push(sessionsListTool as AgentTool<any>);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
|
|
|||
422
src/agent/tools/cron/cron-tool.ts
Normal file
422
src/agent/tools/cron/cron-tool.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/**
|
||||
* Cron Tool for Agent
|
||||
*
|
||||
* Allows agents to create, manage, and execute scheduled tasks.
|
||||
* Based on OpenClaw's implementation (MIT License)
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
getCronService,
|
||||
formatSchedule,
|
||||
formatDuration,
|
||||
parseTimeInput,
|
||||
parseIntervalInput,
|
||||
isValidCronExpr,
|
||||
type CronSchedule,
|
||||
type CronJobInput,
|
||||
} from "../../../cron/index.js";
|
||||
|
||||
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
|
||||
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
|
||||
function stringEnum<T extends readonly string[]>(values: T, options: { description?: string } = {}) {
|
||||
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
||||
}
|
||||
|
||||
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "logs"] as const;
|
||||
|
||||
// Flattened schema: runtime validates per-action requirements.
|
||||
const CronSchema = Type.Object({
|
||||
action: stringEnum(CRON_ACTIONS),
|
||||
enabled: Type.Optional(Type.Boolean({ description: "Filter by enabled status (for list action)" })),
|
||||
name: Type.Optional(Type.String({ description: "Job name (for add action)" })),
|
||||
description: Type.Optional(Type.String({ description: "Job description (for add action)" })),
|
||||
schedule: Type.Optional(Type.Object({
|
||||
kind: stringEnum(["at", "every", "cron"] as const),
|
||||
at: Type.Optional(Type.String({ description: "Time for one-shot (ISO 8601 or relative like '10m')" })),
|
||||
every: Type.Optional(Type.String({ description: "Interval (e.g., '30m', '2h')" })),
|
||||
expr: Type.Optional(Type.String({ description: "Cron expression (5-field)" })),
|
||||
tz: Type.Optional(Type.String({ description: "Timezone for cron expression" })),
|
||||
})),
|
||||
sessionTarget: stringEnum(["main", "isolated"] as const, { description: "Where to run: main session or isolated" }),
|
||||
payload: Type.Optional(Type.Object({
|
||||
kind: stringEnum(["system-event", "agent-turn"] as const),
|
||||
text: Type.Optional(Type.String({ description: "Text for system-event" })),
|
||||
message: Type.Optional(Type.String({ description: "Prompt for agent-turn" })),
|
||||
timeoutSeconds: Type.Optional(Type.Number({ description: "Timeout for agent-turn" })),
|
||||
})),
|
||||
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after one-time run" })),
|
||||
wakeMode: stringEnum(["next-heartbeat", "now"] as const, { description: "When to wake after job execution" }),
|
||||
jobId: Type.Optional(Type.String({ description: "Job ID (for update/remove/run/logs actions)" })),
|
||||
force: Type.Optional(Type.Boolean({ description: "Force run even if disabled (for run action)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Number of log entries to return (for logs action)" })),
|
||||
});
|
||||
|
||||
type CronArgs = {
|
||||
action: "status" | "list" | "add" | "update" | "remove" | "run" | "logs";
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
schedule?: {
|
||||
kind: "at" | "every" | "cron";
|
||||
at?: string;
|
||||
every?: string;
|
||||
expr?: string;
|
||||
tz?: string;
|
||||
};
|
||||
sessionTarget?: "main" | "isolated";
|
||||
payload?: {
|
||||
kind: "system-event" | "agent-turn";
|
||||
text?: string;
|
||||
message?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
deleteAfterRun?: boolean;
|
||||
wakeMode?: "next-heartbeat" | "now";
|
||||
jobId?: string;
|
||||
force?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type CronResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
/** Parse schedule from tool parameters */
|
||||
function parseSchedule(schedule: CronArgs["schedule"]): CronSchedule | { error: string } {
|
||||
if (!schedule) {
|
||||
return { error: "schedule is required" };
|
||||
}
|
||||
|
||||
switch (schedule.kind) {
|
||||
case "at": {
|
||||
const at = schedule.at;
|
||||
if (!at) {
|
||||
return { error: "schedule.at is required for kind='at'" };
|
||||
}
|
||||
const atMs = parseTimeInput(at);
|
||||
if (!atMs) {
|
||||
return { error: `Invalid time format: ${at}` };
|
||||
}
|
||||
return { kind: "at", atMs };
|
||||
}
|
||||
|
||||
case "every": {
|
||||
const every = schedule.every;
|
||||
if (!every) {
|
||||
return { error: "schedule.every is required for kind='every'" };
|
||||
}
|
||||
const everyMs = parseIntervalInput(every);
|
||||
if (!everyMs) {
|
||||
return { error: `Invalid interval format: ${every}` };
|
||||
}
|
||||
return { kind: "every", everyMs };
|
||||
}
|
||||
|
||||
case "cron": {
|
||||
const expr = schedule.expr;
|
||||
if (!expr) {
|
||||
return { error: "schedule.expr is required for kind='cron'" };
|
||||
}
|
||||
const tz = schedule.tz;
|
||||
if (!isValidCronExpr(expr, tz)) {
|
||||
return { error: `Invalid cron expression: ${expr}` };
|
||||
}
|
||||
// Only include tz if defined (exactOptionalPropertyTypes)
|
||||
if (tz) {
|
||||
return { kind: "cron", expr, tz };
|
||||
}
|
||||
return { kind: "cron", expr };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `Unknown schedule kind: ${schedule.kind}` };
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_DESCRIPTION = `Manage cron jobs (status/list/add/update/remove/run/logs).
|
||||
|
||||
ACTIONS:
|
||||
- status: Check cron scheduler status
|
||||
- list: List jobs (use enabled:true/false to filter)
|
||||
- add: Create job (requires name, schedule, payload, sessionTarget)
|
||||
- update: Modify job (requires jobId, plus fields to update)
|
||||
- remove: Delete job (requires jobId)
|
||||
- run: Trigger job immediately (requires jobId, optional force:true)
|
||||
- logs: Get job run history (requires jobId, optional limit)
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
- "at": One-shot at time
|
||||
{ "kind": "at", "at": "10m" } or { "kind": "at", "at": "2024-12-31T23:59:00Z" }
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "every": "30m" }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }
|
||||
|
||||
PAYLOAD TYPES (payload.kind):
|
||||
- "system-event": Injects text into main session (like a reminder, triggers main agent to respond)
|
||||
{ "kind": "system-event", "text": "<message>" }
|
||||
- "agent-turn": Spawns an isolated agent that can use ALL tools (exec, write, web_fetch, etc.) to autonomously complete a task
|
||||
{ "kind": "agent-turn", "message": "<prompt>", "timeoutSeconds": 300 }
|
||||
|
||||
USE "agent-turn" when the job needs to perform actions (run commands, write files, fetch data, etc.).
|
||||
USE "system-event" when the job only needs to remind/notify the user in the current chat.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="system-event"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agent-turn"
|
||||
- Default sessionTarget is "main", default wakeMode is "now"`;
|
||||
|
||||
/** Create the cron tool */
|
||||
export function createCronTool(): AgentTool<typeof CronSchema, CronResult> {
|
||||
return {
|
||||
name: "cron",
|
||||
label: "Cron",
|
||||
description: TOOL_DESCRIPTION,
|
||||
parameters: CronSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const { action } = args as CronArgs;
|
||||
const service = getCronService();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "status": {
|
||||
const status = service.status();
|
||||
const output = JSON.stringify({
|
||||
running: status.running,
|
||||
enabled: status.enabled,
|
||||
jobCount: status.jobCount,
|
||||
enabledJobCount: status.enabledJobCount,
|
||||
nextWakeAt: status.nextWakeAtMs ? new Date(status.nextWakeAtMs).toISOString() : null,
|
||||
storePath: status.storePath,
|
||||
}, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: "Status retrieved", data: status },
|
||||
};
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const params = args as CronArgs;
|
||||
const filter = params.enabled !== undefined ? { enabled: params.enabled } : undefined;
|
||||
const jobs = service.list(filter);
|
||||
const formatted = jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
enabled: job.enabled,
|
||||
schedule: formatSchedule(job.schedule),
|
||||
sessionTarget: job.sessionTarget,
|
||||
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
|
||||
lastStatus: job.state.lastStatus,
|
||||
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
|
||||
}));
|
||||
const output = JSON.stringify(formatted, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: `Found ${jobs.length} job(s)`, data: formatted },
|
||||
};
|
||||
}
|
||||
|
||||
case "add": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.name) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: name is required" }],
|
||||
details: { success: false, message: "name is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = parseSchedule(params.schedule);
|
||||
if ("error" in schedule) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${schedule.error}` }],
|
||||
details: { success: false, message: schedule.error },
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.payload) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload is required" }],
|
||||
details: { success: false, message: "payload is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const { payload } = params;
|
||||
let jobPayload;
|
||||
if (payload.kind === "system-event") {
|
||||
if (!payload.text) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload.text is required for system-event" }],
|
||||
details: { success: false, message: "payload.text is required for system-event" },
|
||||
};
|
||||
}
|
||||
jobPayload = { kind: "system-event" as const, text: payload.text };
|
||||
} else if (payload.kind === "agent-turn") {
|
||||
if (!payload.message) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: payload.message is required for agent-turn" }],
|
||||
details: { success: false, message: "payload.message is required for agent-turn" },
|
||||
};
|
||||
}
|
||||
const agentPayload: { kind: "agent-turn"; message: string; timeoutSeconds?: number } = {
|
||||
kind: "agent-turn",
|
||||
message: payload.message,
|
||||
};
|
||||
if (payload.timeoutSeconds !== undefined) {
|
||||
agentPayload.timeoutSeconds = payload.timeoutSeconds;
|
||||
}
|
||||
jobPayload = agentPayload;
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown payload kind` }],
|
||||
details: { success: false, message: "Unknown payload kind" },
|
||||
};
|
||||
}
|
||||
|
||||
const input: CronJobInput = {
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule,
|
||||
sessionTarget: params.sessionTarget ?? "main",
|
||||
wakeMode: params.wakeMode ?? "now",
|
||||
payload: jobPayload,
|
||||
};
|
||||
if (params.description !== undefined) {
|
||||
input.description = params.description;
|
||||
}
|
||||
if (params.deleteAfterRun !== undefined) {
|
||||
input.deleteAfterRun = params.deleteAfterRun;
|
||||
}
|
||||
|
||||
const job = service.add(input);
|
||||
const output = `Created job: ${job.name} (${job.id})\nSchedule: ${formatSchedule(job.schedule)}\nNext run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`;
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: "Job created", data: job },
|
||||
};
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (params.name !== undefined) patch.name = params.name;
|
||||
if (params.description !== undefined) patch.description = params.description;
|
||||
if (params.enabled !== undefined) patch.enabled = params.enabled;
|
||||
if (params.schedule !== undefined) {
|
||||
const schedule = parseSchedule(params.schedule);
|
||||
if ("error" in schedule) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${schedule.error}` }],
|
||||
details: { success: false, message: schedule.error },
|
||||
};
|
||||
}
|
||||
patch.schedule = schedule;
|
||||
}
|
||||
|
||||
const updated = service.update(params.jobId, patch);
|
||||
if (!updated) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
|
||||
details: { success: false, message: "Job not found" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Updated job: ${updated.name} (${updated.id})` }],
|
||||
details: { success: true, message: "Job updated", data: updated },
|
||||
};
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const removed = service.remove(params.jobId);
|
||||
if (!removed) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }],
|
||||
details: { success: false, message: "Job not found" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Removed job: ${params.jobId}` }],
|
||||
details: { success: true, message: "Job removed" },
|
||||
};
|
||||
}
|
||||
|
||||
case "run": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await service.run(params.jobId, params.force);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${result.reason}` }],
|
||||
details: { success: false, message: result.reason ?? "Run failed" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: "Job executed successfully" }],
|
||||
details: { success: true, message: "Job executed" },
|
||||
};
|
||||
}
|
||||
|
||||
case "logs": {
|
||||
const params = args as CronArgs;
|
||||
if (!params.jobId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: jobId is required" }],
|
||||
details: { success: false, message: "jobId is required" },
|
||||
};
|
||||
}
|
||||
|
||||
const logs = service.getRunLogs(params.jobId, params.limit);
|
||||
const formatted = logs.map((log) => ({
|
||||
timestamp: new Date(log.ts).toISOString(),
|
||||
status: log.status,
|
||||
duration: log.durationMs ? formatDuration(log.durationMs) : undefined,
|
||||
error: log.error,
|
||||
summary: log.summary,
|
||||
}));
|
||||
const output = JSON.stringify(formatted, null, 2);
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: { success: true, message: `Found ${logs.length} log entries`, data: formatted },
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
|
||||
details: { success: false, message: `Unknown action: ${action}` },
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
details: { success: false, message },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
5
src/agent/tools/cron/index.ts
Normal file
5
src/agent/tools/cron/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Cron Tools
|
||||
*/
|
||||
|
||||
export { createCronTool } from "./cron-tool.js";
|
||||
|
|
@ -50,8 +50,8 @@ export function createCliApprovalCallback(
|
|||
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
|
||||
|
||||
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
|
||||
const security = runtimeConfig.security ?? "allowlist";
|
||||
const ask = runtimeConfig.ask ?? "on-miss";
|
||||
const security = runtimeConfig.security ?? "full";
|
||||
const ask = runtimeConfig.ask ?? "off";
|
||||
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
|
||||
// Security: deny blocks everything
|
||||
|
|
@ -137,13 +137,15 @@ function promptTerminal(
|
|||
rl.close();
|
||||
};
|
||||
|
||||
// Timeout: auto-deny
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs);
|
||||
// Timeout: auto-deny (skip if timeoutMs is -1 for no timeout)
|
||||
const timer = timeoutMs >= 0
|
||||
? setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
// Display approval prompt
|
||||
process.stderr.write("\n");
|
||||
|
|
@ -161,7 +163,7 @@ function promptTerminal(
|
|||
rl.question(
|
||||
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
|
||||
(answer) => {
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
cleanup();
|
||||
|
||||
const trimmed = answer.trim();
|
||||
|
|
@ -177,7 +179,7 @@ function promptTerminal(
|
|||
|
||||
// Handle Ctrl+C gracefully
|
||||
rl.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve("deny");
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface ExecApprovalRequest {
|
|||
riskLevel: "safe" | "needs-review" | "dangerous";
|
||||
/** Reasons for the risk assessment */
|
||||
riskReasons: string[];
|
||||
/** When this approval expires (ms since epoch) */
|
||||
/** When this approval expires (ms since epoch). -1 means no timeout. */
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export interface ExecApprovalConfig {
|
|||
security?: ExecSecurity;
|
||||
/** Ask mode: "off" never asks, "on-miss" asks when allowlist misses, "always" always asks */
|
||||
ask?: ExecAsk;
|
||||
/** Timeout before auto-deny in milliseconds (default: 60_000) */
|
||||
/** Timeout before auto-deny in milliseconds (default: 60_000). Set to -1 for no timeout. */
|
||||
timeoutMs?: number;
|
||||
/** Fallback security level on timeout (default: "deny" — fail-closed) */
|
||||
askFallback?: ExecSecurity;
|
||||
|
|
@ -58,8 +58,8 @@ export interface ExecApprovalConfig {
|
|||
allowlist?: ExecAllowlistEntry[];
|
||||
}
|
||||
|
||||
/** Default timeout for approval requests (60 seconds) */
|
||||
export const DEFAULT_APPROVAL_TIMEOUT_MS = 60_000;
|
||||
/** Default timeout for approval requests (-1 = no timeout, wait indefinitely) */
|
||||
export const DEFAULT_APPROVAL_TIMEOUT_MS = -1;
|
||||
|
||||
// ============ Allowlist ============
|
||||
|
||||
|
|
|
|||
|
|
@ -164,9 +164,22 @@ export function createExecTool(
|
|||
// Don't reject, let close event handle
|
||||
});
|
||||
|
||||
// Signal handling: don't kill if already backgrounded
|
||||
const onAbort = signal ? () => {
|
||||
if (yielded) return; // Already backgrounded, ignore abort
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
child.kill("SIGTERM");
|
||||
} : undefined;
|
||||
|
||||
if (signal && onAbort) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
// If already backgrounded, don't resolve again
|
||||
if (yielded) return;
|
||||
|
|
@ -202,16 +215,6 @@ export function createExecTool(
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Signal handling: don't kill if already backgrounded
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
if (yielded) return; // Already backgrounded, ignore abort
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
"group:memory": ["memory_search"],
|
||||
|
||||
// Subagent tools
|
||||
"group:subagent": ["sessions_spawn"],
|
||||
"group:subagent": ["sessions_spawn", "sessions_list"],
|
||||
|
||||
// Cron/scheduling tools
|
||||
"group:cron": ["cron"],
|
||||
|
||||
// All core tools
|
||||
"group:core": [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export { createExecTool } from "./exec.js";
|
|||
export { createProcessTool } from "./process.js";
|
||||
export { createGlobTool } from "./glob.js";
|
||||
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
|
||||
export { createCronTool } from "./cron/index.js";
|
||||
export { createSessionsListTool } from "./sessions-list.js";
|
||||
|
||||
// Tool groups
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function createProcessTool(defaultCwd?: string): AgentTool<typeof Process
|
|||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
resolve({ success: true });
|
||||
|
|
|
|||
169
src/agent/tools/sessions-list.test.ts
Normal file
169
src/agent/tools/sessions-list.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
// Mock the registry module before importing the tool
|
||||
vi.mock("../subagent/registry.js", () => ({
|
||||
listSubagentRuns: vi.fn(),
|
||||
getSubagentRun: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSessionsListTool } from "./sessions-list.js";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
|
||||
const mockListSubagentRuns = vi.mocked(listSubagentRuns);
|
||||
const mockGetSubagentRun = vi.mocked(getSubagentRun);
|
||||
|
||||
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-001",
|
||||
childSessionId: "child-001",
|
||||
requesterSessionId: "parent-001",
|
||||
task: "Test task",
|
||||
cleanup: "delete",
|
||||
createdAt: 1700000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessions_list tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty message when no runs exist", async () => {
|
||||
mockListSubagentRuns.mockReturnValue([]);
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: "No subagent runs for this session.",
|
||||
});
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("lists multiple runs with correct status mapping", async () => {
|
||||
const now = Date.now();
|
||||
const runs: SubagentRunRecord[] = [
|
||||
makeRecord({
|
||||
runId: "run-aaa",
|
||||
label: "Code Review",
|
||||
startedAt: now - 45000,
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-bbb",
|
||||
label: "Test Analysis",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now - 30000,
|
||||
outcome: { status: "ok" },
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-ccc",
|
||||
label: "Lint Check",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now,
|
||||
outcome: { status: "error", error: "timeout" },
|
||||
}),
|
||||
];
|
||||
mockListSubagentRuns.mockReturnValue(runs);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = result.content[0]!;
|
||||
expect(text.type).toBe("text");
|
||||
expect((text as { text: string }).text).toContain("3 total");
|
||||
expect((text as { text: string }).text).toContain("[running]");
|
||||
expect((text as { text: string }).text).toContain("[ok]");
|
||||
expect((text as { text: string }).text).toContain("[error]");
|
||||
expect((text as { text: string }).text).toContain("Code Review");
|
||||
expect((text as { text: string }).text).toContain("Test Analysis");
|
||||
expect((text as { text: string }).text).toContain("Lint Check");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(3);
|
||||
expect(result.details!.runs[0]!.status).toBe("running");
|
||||
expect(result.details!.runs[1]!.status).toBe("ok");
|
||||
expect(result.details!.runs[2]!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("returns detail for a specific runId", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-detail",
|
||||
label: "Deep Analysis",
|
||||
task: "Analyze the authentication module thoroughly",
|
||||
startedAt: now - 90000,
|
||||
endedAt: now - 10000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Found 2 potential issues in token validation.",
|
||||
findingsCaptured: true,
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-detail" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run: run-detail");
|
||||
expect(text).toContain("Label: Deep Analysis");
|
||||
expect(text).toContain("Status: ok");
|
||||
expect(text).toContain("Found 2 potential issues");
|
||||
expect(text).toContain("Duration:");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(1);
|
||||
expect(result.details!.runs[0]!.runId).toBe("run-detail");
|
||||
});
|
||||
|
||||
it("returns not found for unknown runId", async () => {
|
||||
mockGetSubagentRun.mockReturnValue(undefined);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "nonexistent" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("rejects runId belonging to a different requester", async () => {
|
||||
const record = makeRecord({
|
||||
runId: "run-other",
|
||||
requesterSessionId: "other-parent",
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-other" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("handles missing sessionId gracefully", async () => {
|
||||
const tool = createSessionsListTool({});
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("No session ID available");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("shows findings status for running task", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-running",
|
||||
label: "Still Running",
|
||||
startedAt: now - 30000,
|
||||
// no endedAt
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-running" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Status: running");
|
||||
expect(text).toContain("Findings: (still running)");
|
||||
});
|
||||
});
|
||||
187
src/agent/tools/sessions-list.ts
Normal file
187
src/agent/tools/sessions-list.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* sessions_list tool — allows an agent to view its spawned subagent runs.
|
||||
*
|
||||
* Lists all subagent runs for the current session, or shows details for a
|
||||
* specific run when a runId is provided.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
const SessionsListSchema = Type.Object({
|
||||
runId: Type.Optional(
|
||||
Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }),
|
||||
),
|
||||
});
|
||||
|
||||
type SessionsListArgs = {
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
runs: Array<{
|
||||
runId: string;
|
||||
label?: string;
|
||||
task: string;
|
||||
status: "running" | "ok" | "error" | "timeout" | "unknown";
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
findings?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface CreateSessionsListToolOptions {
|
||||
/** Session ID of the current (requester) agent */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" {
|
||||
if (!record.endedAt) return "running";
|
||||
return record.outcome?.status ?? "unknown";
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const displayName = record.label || record.task.slice(0, 60);
|
||||
const statusTag = `[${status}]`.padEnd(10);
|
||||
|
||||
let timing = "";
|
||||
if (status === "running" && record.startedAt) {
|
||||
timing = `started ${formatElapsed(now - record.startedAt)} ago`;
|
||||
} else if (record.startedAt && record.endedAt) {
|
||||
timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`;
|
||||
}
|
||||
|
||||
const parts = [`#${index + 1} ${statusTag} "${displayName}"`];
|
||||
if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`);
|
||||
else parts.push(`(${record.runId.slice(0, 8)}…)`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatRunDetail(record: SubagentRunRecord, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const lines: string[] = [
|
||||
`Run: ${record.runId}`,
|
||||
];
|
||||
|
||||
if (record.label) lines.push(`Label: ${record.label}`);
|
||||
lines.push(`Task: ${record.task}`);
|
||||
lines.push(`Status: ${status}${record.outcome?.error ? ` — ${record.outcome.error}` : ""}`);
|
||||
lines.push(`Child Session: ${record.childSessionId}`);
|
||||
lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`);
|
||||
|
||||
if (record.startedAt) {
|
||||
lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`);
|
||||
}
|
||||
if (record.endedAt) {
|
||||
lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`);
|
||||
if (record.startedAt) {
|
||||
lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.findingsCaptured) {
|
||||
lines.push(`Findings: ${record.findings || "(no output)"}`);
|
||||
} else if (record.endedAt) {
|
||||
lines.push("Findings: (not yet captured)");
|
||||
} else {
|
||||
lines.push("Findings: (still running)");
|
||||
}
|
||||
|
||||
if (record.announced) lines.push("Announced: yes");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toResultRun(record: SubagentRunRecord) {
|
||||
return {
|
||||
runId: record.runId,
|
||||
label: record.label,
|
||||
task: record.task,
|
||||
status: resolveStatus(record),
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
findings: record.findings,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionsListTool(
|
||||
options: CreateSessionsListToolOptions,
|
||||
): AgentTool<typeof SessionsListSchema, SessionsListResult> {
|
||||
return {
|
||||
name: "sessions_list",
|
||||
label: "List Subagent Runs",
|
||||
description:
|
||||
"List all subagent runs spawned by this session and their current status. " +
|
||||
"Optionally pass a runId to get detailed information about a specific run.",
|
||||
parameters: SessionsListSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const { runId } = args as SessionsListArgs;
|
||||
const requesterSessionId = options.sessionId;
|
||||
|
||||
if (!requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Detail mode: specific run
|
||||
if (runId) {
|
||||
const record = getSubagentRun(runId);
|
||||
if (!record) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
if (record.requesterSessionId !== requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: formatRunDetail(record, now) }],
|
||||
details: { runs: [toResultRun(record)] },
|
||||
};
|
||||
}
|
||||
|
||||
// List mode: all runs for this session
|
||||
const runs = listSubagentRuns(requesterSessionId);
|
||||
|
||||
if (runs.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No subagent runs for this session." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [`Subagent runs for this session: ${runs.length} total`, ""];
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
lines.push(formatRunSummary(runs[i]!, i, now));
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { runs: runs.map(toResultRun) },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue