Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
import { createIdleWakeSender } from "../lib/idle-wake.ts";
import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
Expand Down Expand Up @@ -315,6 +316,9 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
}

export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
// Every triggerTurn wake-up goes through this sender so an idle parent is
// started via prompt() and keeps its before_agent_start harness (#1528).
const wakeSend = createIdleWakeSender(pi);
const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
if (env.GENTLE_PI_AGENTS_CHILD === "1") {
// A stale managed-SDD child must never inherit unrestricted ordinary tools.
Expand Down Expand Up @@ -479,7 +483,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
listener = sessionTransport.createListener(registry, sessionId, async (notification) => {
const active = activeSessionTransport;
if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport");
pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true });
wakeSend({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true });
});
client = sessionTransport.createClient(registry, sessionId);
if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) {
Expand Down Expand Up @@ -588,7 +592,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
// branch, so a parent that keeps calling tools would see the completion
// only when the whole run ends — the original #867 delay. When the parent
// is idle, triggerTurn runs the prompt immediately, preserving wake-up.
pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
wakeSend({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
};

// A stale completion must not re-enter the LLM conversation, so it is
Expand All @@ -601,7 +605,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =

const deliverMessage = (msg: PendingAgentMessage) => {
if (activeSessionId() !== msg.parentSessionId) return;
pi.sendMessage(
wakeSend(
{ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details },
{ deliverAs: "steer", triggerTurn: true },
);
Expand Down
75 changes: 75 additions & 0 deletions lib/idle-wake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Idle wake-up that keeps the harness (issue #1528).
//
// Pi starts an idle `pi.sendMessage(..., { triggerTurn: true })` turn through
// `_runAgentPrompt` directly, without `before_agent_start` (earendil-works/pi#5581,
// still open). Everything an extension adds to the system prompt in
// `before_agent_start` — the whole Gentle harness — is missing on that turn:
// claude-bridge refuses to run it (prompt-capture miss), and other providers
// silently run a turn without the harness.
//
// When the host is idle, the message is queued with `deliverAs: "nextTurn"` and
// the turn is started through `pi.sendUserMessage(" ")`, i.e. the normal
// `prompt()` path: `before_agent_start` runs and the queued message is injected
// alongside that prompt. When the host is running, the original
// `sendMessage(message, options)` is kept unchanged.
//
// Idleness is read from the host (`ctx.isIdle()`, the same run flag
// `sendCustomMessage` routes by). Counting `agent_start`/`agent_end` does not
// work: the host emits that pair once per inner `prompt()`/`continue()` call and
// chains several of them in one run, so a counter reads 0 in the gap before the
// next `continue()` — exactly where completions are flushed on `agent_end`.
// Taking the idle path there queues the message as `nextTurn` while
// `sendUserMessage(" ")` throws "Agent is already processing", stranding the
// message until the next user prompt.
//
// One wake at a time. `prompt()` awaits input handlers, auth, compaction and
// `before_agent_start` before it marks the run active, so the host still reads
// idle while a wake prompt is being prepared. A second `sendUserMessage(" ")` in
// that window would reach `Agent.prompt()` while the first runs and be rejected,
// and its settlement would clear the active-run flag under the first run. The
// first wake therefore reserves the turn synchronously; later idle messages are
// only queued as `nextTurn`. They are carried by the pending prompt: the host
// takes the `nextTurn` queue and marks the run active in one synchronous step,
// so while the host still reads idle, the pending prompt has not taken it yet.
// The reservation ends when the run starts or the session is replaced. The host
// reports a failed extension prompt only to its error channel, so a reservation
// also expires after WAKE_RESERVATION_MS; the queued messages stay in `nextTurn`
// and the next wake (or user prompt) carries them.

import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";

type WakeMessage = Parameters<ExtensionAPI["sendMessage"]>[0];
export type WakeOptions = { deliverAs: "steer" | "followUp"; triggerTurn: true };
export type WakeHost = Pick<ExtensionAPI, "on" | "sendMessage" | "sendUserMessage">;

/** How long a submitted wake prompt may stay unstarted before another wake may submit a new one. */
export const WAKE_RESERVATION_MS = 60_000;

export function createIdleWakeSender(pi: WakeHost, now: () => number = Date.now): (message: WakeMessage, options: WakeOptions) => void {
let latest: Pick<ExtensionContext, "isIdle"> | undefined;
let wakeReservedAt: number | undefined;
const track = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; };
const trackAndRelease = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; wakeReservedAt = undefined; };
pi.on("session_start", trackAndRelease);
pi.on("agent_start", trackAndRelease);
pi.on("turn_end", track);
pi.on("agent_end", track);

// A missing or stale ctx (after session replacement or reload) is not proof of
// idleness: fall back to the host's own delivery path, never to "nextTurn",
// which could strand the message.
const hostIdle = (): boolean => {
try { return latest?.isIdle() === true; } catch { return false; }
};

return (message, options) => {
if (!hostIdle()) {
pi.sendMessage(message, options);
return;
}
pi.sendMessage(message, { deliverAs: "nextTurn" });
if (wakeReservedAt !== undefined && now() - wakeReservedAt < WAKE_RESERVATION_MS) return;
wakeReservedAt = now();
pi.sendUserMessage(" ");
};
}
25 changes: 24 additions & 1 deletion tests/gentle-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function fakePi() {
const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
const userMessages: unknown[] = [];
const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
const entryRenderers = new Map<string, (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
const entries: Array<{ type: string; customType: string; data: unknown }> = [];
Expand All @@ -125,6 +126,7 @@ function fakePi() {
},
},
sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
sendUserMessage: (content: unknown) => userMessages.push(content),
registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer),
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
Expand Down Expand Up @@ -153,7 +155,7 @@ function fakePi() {
}
}
};
return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
return { pi, tools, shortcuts, commands, fire, sent, userMessages, renderers, entryRenderers, entries, events, listeners };
}

function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise<boolean> = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise<string | undefined> = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }, selectResult: (title: string, options: string[]) => Promise<string | undefined> = async (_title, options) => options[0]) {
Expand Down Expand Up @@ -4331,3 +4333,24 @@ test("children receive the child-context extension, and a missing file is omitte
}
}
});

test("a completion for an idle parent starts the turn through prompt(), not a bare triggerTurn (#1528)", async () => {
// pi#5581: an idle sendMessage(..., { triggerTurn: true }) skips
// before_agent_start, so the woken turn would run without the harness. The
// completion is queued as nextTurn and the turn is started with a prompt.
const { pi, tools, fire, sent, userMessages } = fakePi();
const harness = deps();
gentleAgents(pi, {}, harness.deps);
const { ctx } = fakeContext();
const idleCtx = { ...ctx, isIdle: () => true } as unknown as ExtensionContext;
await fire("session_start", idleCtx);
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle job", mode: "background" }, undefined, undefined, idleCtx);
await tick();
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done while idle." }] }] });
harness.children[0].emit({ type: "agent_settled" });
await tick();
assert.equal(sent.length, 1, "the completion is delivered once");
assert.equal(sent[0].message.customType, "gentle-agents.result");
assert.deepEqual(sent[0].options, { deliverAs: "nextTurn" });
assert.deepEqual(userMessages, [" "], "the idle parent is started through the prompt path");
});
136 changes: 136 additions & 0 deletions tests/idle-wake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createIdleWakeSender, WAKE_RESERVATION_MS, type WakeHost } from "../lib/idle-wake.ts";

// An idle wake-up must go through prompt() so before_agent_start runs
// (issue #1528, earendil-works/pi#5581); a running host keeps the original
// sendMessage call.

type Call = [string, unknown];

function host(now: () => number = () => 0) {
const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => void>>();
const calls: Call[] = [];
const pi = {
on: (event: string, handler: (event: unknown, ctx: unknown) => void) => { handlers.set(event, [...(handlers.get(event) ?? []), handler]); },
sendMessage: (_message: unknown, options: unknown) => { calls.push(["sendMessage", options]); },
sendUserMessage: (content: unknown) => { calls.push(["sendUserMessage", content]); },
} as unknown as WakeHost;
const emit = (event: string, ctx: unknown) => { for (const handler of handlers.get(event) ?? []) handler({ type: event }, ctx); };
return { send: createIdleWakeSender(pi, now), emit, calls };
}

const ctx = (idle: boolean) => ({ isIdle: () => idle });
const message = { customType: "gentle-agents.result", content: "done", display: true };

test("an idle host is woken through prompt() so before_agent_start runs", () => {
const h = host();
h.emit("session_start", ctx(true));
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "nextTurn" }], ["sendUserMessage", " "]]);
});

test("a running host keeps the original steer/followUp delivery", () => {
const h = host();
h.emit("session_start", ctx(false));
h.send(message, { deliverAs: "steer", triggerTurn: true });
h.send(message, { deliverAs: "followUp", triggerTurn: true });
assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }], ["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]);
});

test("a run that is still active after an inner agent_end is not treated as idle", () => {
// The host emits agent_start/agent_end per inner prompt()/continue(); a
// counter would read 0 here and strand the message as nextTurn.
const h = host();
const running = ctx(false);
h.emit("session_start", running);
h.emit("agent_start", running);
h.emit("turn_end", running);
h.emit("agent_end", running);
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]);
});

test("idleness is read live, not frozen at the last event", () => {
const h = host();
let idle = false;
h.emit("agent_end", { isIdle: () => idle });
h.send(message, { deliverAs: "steer", triggerTurn: true });
idle = true;
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls, [
["sendMessage", { deliverAs: "steer", triggerTurn: true }],
["sendMessage", { deliverAs: "nextTurn" }],
["sendUserMessage", " "],
]);
});

test("two wakes while the first prompt is still being prepared submit one prompt", () => {
// prompt() awaits input and before_agent_start handlers before the run is
// active, so the host keeps reading idle; a second prompt would be rejected
// and its settlement would clear the active-run flag under the first run.
const h = host();
h.emit("session_start", ctx(true));
h.send(message, { deliverAs: "steer", triggerTurn: true });
h.send(message, { deliverAs: "followUp", triggerTurn: true });
assert.deepEqual(h.calls, [
["sendMessage", { deliverAs: "nextTurn" }],
["sendUserMessage", " "],
["sendMessage", { deliverAs: "nextTurn" }],
], "the second message rides the pending prompt's nextTurn queue");
});

test("the reservation ends when the woken run starts", () => {
const h = host();
let idle = true;
const live = { isIdle: () => idle };
h.emit("session_start", live);
h.send(message, { deliverAs: "steer", triggerTurn: true });
idle = false;
h.emit("agent_start", live);
h.send(message, { deliverAs: "steer", triggerTurn: true });
idle = true;
h.emit("agent_end", live);
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls, [
["sendMessage", { deliverAs: "nextTurn" }],
["sendUserMessage", " "],
["sendMessage", { deliverAs: "steer", triggerTurn: true }],
["sendMessage", { deliverAs: "nextTurn" }],
["sendUserMessage", " "],
]);
});

test("session replacement releases a pending reservation", () => {
const h = host();
h.emit("session_start", ctx(true));
h.send(message, { deliverAs: "steer", triggerTurn: true });
h.emit("session_start", ctx(true));
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls.filter(([name]) => name === "sendUserMessage").length, 2);
});

test("a wake prompt that never starts stops blocking after WAKE_RESERVATION_MS", () => {
// The host reports a failed extension prompt only to its error channel; the
// queued messages stay in nextTurn and the next wake submits a new prompt.
let clock = 0;
const h = host(() => clock);
h.emit("session_start", ctx(true));
h.send(message, { deliverAs: "steer", triggerTurn: true });
clock = WAKE_RESERVATION_MS - 1;
h.send(message, { deliverAs: "steer", triggerTurn: true });
clock = WAKE_RESERVATION_MS;
h.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(h.calls.map(([name]) => name), ["sendMessage", "sendUserMessage", "sendMessage", "sendMessage", "sendUserMessage"]);
});

test("a missing or stale ctx falls back to the host path, never to nextTurn", () => {
const noCtx = host();
noCtx.send(message, { deliverAs: "steer", triggerTurn: true });
assert.deepEqual(noCtx.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]);

const stale = host();
stale.emit("session_start", { isIdle: () => { throw new Error("This extension ctx is stale after session replacement or reload."); } });
stale.send(message, { deliverAs: "followUp", triggerTurn: true });
assert.deepEqual(stale.calls, [["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]);
});