mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-20 13:10:47 +08:00
Defer passive goal context until ordinary prompt preparation
This commit is contained in:
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user