Defer passive goal context until ordinary prompt preparation

This commit is contained in:
wassname2
2026-09-11 13:38:13 +08:00
parent 2565e6bbf6
commit 82b739bb0b
3 changed files with 82 additions and 19 deletions
+6
View File
@@ -146,6 +146,12 @@ pi -e ./src/index.ts
`/goals` opens the action menu. New plan enters plan mode and starts a conversation;
## Context delivery
Startup and successful compaction mark the plan for a fresh read at the next ordinary prompt (`before_agent_start`). Upkeep becomes due after eight unchanged turns, but waits for that same prompt boundary. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress.
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause/exit notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed.
## Prompts
You can read all the prompts in conversation order in [`src/prompts.ts`](src/prompts.ts).
+23 -9
View File
@@ -91,6 +91,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
};
let turnsStale = 0;
let lastWorkingSet = "";
let pendingUpkeep: { generation: number; workingSet: string } | undefined;
const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? "");
const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_prompt");
const notedPlanValue = (prefix: string) => {
@@ -169,6 +170,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
notice = true;
turnsStale = 0;
lastWorkingSet = "";
pendingUpkeep = undefined;
refresh(ctx);
watchPlan(ctx);
}
@@ -181,7 +183,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
// sendMessage(triggerTurn:true) bypasses before_agent_start in Pi 0.85.1.
// A normal saved prompt prepares the current role before starting the turn.
if (triggerTurn) pi.sendUserMessage(`[pi-goals]\n${content}`, { deliverAs: "followUp" });
else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "followUp", triggerTurn: false });
else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" });
}
async function confirmOwnership(ctx: ExtensionContext, target: string, text: string, solo = true): Promise<boolean> {
if (pendingLaunches > 0) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
@@ -227,6 +229,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
pi.on("session_start", (_e, ctx) => restore(ctx));
pi.on("session_tree", (_e, ctx) => restore(ctx));
pi.on("session_shutdown", () => { generation++; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; });
// Only successful compaction needs resync; failed/cancelled attempts leave pending context alone.
// Defer to prompt preparation: same-run continuation retains Pi's current role/context.
pi.on("session_compact", () => { notice = true; });
pi.on("turn_end", (_event, ctx) => {
if (!["supervising", "solo"].includes(state.mode)) return;
@@ -237,9 +241,9 @@ export default function mainSupervisor(pi: ExtensionAPI) {
lastWorkingSet = workingSet;
refresh(ctx);
if (turnsStale === 8 && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")) {
// Pi queues context-only messages until tool results are appended at turn_end.
// This reaches the next model call in a long run without triggering another run.
pi.sendMessage({ customType: "pi-goals-upkeep", content: upkeep(state.plan!), display: false }, { triggerTurn: false });
// In Pi 0.85.1 triggerTurn:false updates saved history, not the live loop snapshot.
// Queue intent locally until ordinary prompt preparation, never force another turn.
pendingUpkeep = { generation, workingSet };
}
});
pi.on("agent_end", (_e, ctx) => { refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); });
@@ -268,11 +272,18 @@ export default function mainSupervisor(pi: ExtensionAPI) {
const role = state.child ? childPlanRole : state.mode === "supervising"
? supervisor(WORKER, state.plan!, ctx.sessionManager.getSessionId())
: state.mode === "planning" ? planning(state.plan!) : state.mode === "paused" ? pausedRole : soloRole;
const content = notice ? planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text)
: undefined;
if (content) turnsStale = 0;
// Returned messages enter both Pi's prompt snapshot and saved history together.
// Unlike nextTurn, retaining intent here lets a fresh plan resync supersede upkeep,
// and drops obsolete reminders after edits, takeover, pause or session navigation.
const message = notice
? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text), display: false }
: pendingUpkeep?.generation === generation && pendingUpkeep.workingSet === foldPlan(snapshot.text)
&& ["supervising", "solo"].includes(state.mode) && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")
? { customType: "pi-goals-upkeep", content: upkeep(state.plan!), display: false } : undefined;
if (notice) turnsStale = 0;
notice = false;
return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(content ? { message: { customType: "pi-goals-plan", content, display: false } } : {}) };
pendingUpkeep = undefined;
return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) };
});
pi.on("tool_call", (event, ctx) => {
if (event.toolName === "subagent" && event.input) {
@@ -382,7 +393,10 @@ export default function mainSupervisor(pi: ExtensionAPI) {
return;
}
state.mode = command === "stop" ? "paused" : "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pauseExitNotice(state.worker, command === "exit")}`, Boolean(state.worker) || hasScheduleTool());
const pause = pauseExitNotice(state.worker, command === "exit");
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt.
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup);
return;
}
if (command === "resume") {
+53 -10
View File
@@ -525,7 +525,7 @@ it.each(["solo", "supervising"])("%s widget omits long tasks without altering th
expect(readFileSync(f.path, "utf8")).toBe(text);
});
it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on working-set edits, and never starts a turn", async mode => {
it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins the next ordinary prompt once", async mode => {
const f = fixture(); await f.draft();
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
else await f.command("ready");
@@ -538,20 +538,21 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on
}
expect(reminders()).toHaveLength(0);
f.hooks.get("turn_end")({}, f.ctx);
expect(reminders()).toHaveLength(1);
expect(reminders()[0].options).toEqual({ triggerTurn: false });
expect(reminders()[0].message.content).toContain(f.path);
expect(reminders()[0].message.content).not.toContain("first output");
for (let i = 0; i < 16; i++) f.hooks.get("turn_end")({}, f.ctx);
expect(reminders()).toHaveLength(1);
expect(reminders()[0].message.content).not.toContain("historical recap");
for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx);
expect(reminders()).toHaveLength(0); // No direct send, even after the run would finish.
const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message;
expect(reminder.customType).toBe("pi-goals-upkeep");
expect(reminder.content).toContain(f.path);
expect(reminder.content).not.toContain("first output");
expect(reminder.content).not.toContain("historical recap");
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
writeFileSync(f.path, f.plan.replace("first output", "refined output"));
f.hooks.get("turn_end")({}, f.ctx);
expect(reminders()).toHaveLength(1);
for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx);
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
await f.command("stop");
for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx);
expect(reminders()).toHaveLength(1);
expect(reminders()).toHaveLength(0);
});
it("extra subagent launches are recorded as helpers and never steal the implementation identity", async () => {
@@ -651,3 +652,45 @@ it("keeps interactive workers open and supplies the supervisor identity for Inte
expect(role).toContain("stop workers before /reload");
expect(role).not.toContain("Reports arrive automatically");
});
it.each(["stop", "exit", "edit", "session_tree"])("discards pending upkeep after %s instead of reviving stale work", async change => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
if (change === "edit") writeFileSync(f.path, f.plan.replace("first output", "changed requirement"));
else if (change === "session_tree") f.hooks.get("session_tree")({}, f.ctx);
else await f.command(change);
const prepared = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
expect(prepared?.message?.customType).not.toBe("pi-goals-upkeep");
if (change === "stop") expect(prepared.systemPrompt).toContain("Goal work is paused");
if (change === "exit") expect(prepared).toBeUndefined();
expect(f.messages.filter(m => m.message.customType === "pi-goals-upkeep")).toHaveLength(0);
});
it("coalesces pending upkeep with a repaired post-compaction plan, retaining the user's latest requirements", async () => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
f.hooks.get("session_compact")();
rmSync(f.path);
const unavailable = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
expect(unavailable.message).toBeUndefined();
expect(unavailable.systemPrompt).toContain("unavailable");
const repaired = f.plan.replace("first output", "the human's latest exact result");
writeFileSync(f.path, repaired);
const ready = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
expect(ready.message).toMatchObject({ customType: "pi-goals-plan" });
expect(ready.message.content).toContain(repaired);
expect(ready.message.content).not.toContain("Plan upkeep:");
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
});
it.each(["stop", "exit"])("passive %s is visible immediately while its model notice waits safely for the next prompt", async command => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
await f.command(command);
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Remote stop is NOT yet confirmed"), "info");
});