Merge PR #2682: feat(coding-agent): re-inject eager task/todo nudges after compaction

Re-inject eager task/todo reminders after compaction (reminder-only, no forced tool_choice; issue #2681). Supersedes #2686. Known follow-up: shake-strategy compaction may duplicate a surviving prelude.
This commit is contained in:
can1357
2026-06-16 14:52:42 +02:00
3 changed files with 415 additions and 15 deletions
+4
View File
@@ -176,6 +176,10 @@
- Registered the `Advisor` group in the `model` settings tab so advisor settings render correctly in the settings panel.
- Fixed Windows bash path handling so MSYS/Git-Bash drive aliases like `/d/project` and WSL-style `/mnt/d/project` normalize to native drive paths consistently across the bash tool cwd validation and brush filesystem builtins ([#2634](https://github.com/can1357/oh-my-pi/issues/2634)).
### Changed
- The eager `task` (`task.eager: always`) and eager `todo` (`todo.eager: preferred`/`always`) hidden reminders now re-fire on the auto-continuation turn after a compaction (context-full / snapcompact / handoff / shake). Compaction summarizes away the first-message prelude, so the agent would otherwise silently lose the delegate-via-tasks / phased-todo guidance mid-work; the post-compaction todo nudge is reminder-only and never forces the `todo` tool onto the resumed turn.
## [15.13.3] - 2026-06-15
### Added
@@ -2566,6 +2566,11 @@ export class AgentSession {
#scheduleAutoContinuePrompt(generation: number): void {
const continuePrompt = async () => {
// Compaction summarizes away the first-message eager preludes, so re-assert the
// delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
// at invocation (past the abort check below), so an aborted continuation queues
// nothing; scoped to this request via prependMessages, never the shared queue.
const eagerNudges = this.#buildPostCompactionEagerNudges();
await this.#promptWithMessage(
{
role: "developer",
@@ -2574,7 +2579,10 @@ export class AgentSession {
timestamp: Date.now(),
},
autoContinuePrompt,
{ skipPostPromptRecoveryWait: true },
{
skipPostPromptRecoveryWait: true,
prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined,
},
);
};
this.#schedulePostPromptTask(
@@ -7774,7 +7782,9 @@ export class AgentSession {
};
}
#createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
#createEagerTodoPrelude(
promptText: string | undefined,
): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
const mode = this.settings.get("todo.eager");
const todosEnabled = this.settings.get("todo.enabled");
if (mode === "default" || !todosEnabled) {
@@ -7791,14 +7801,18 @@ export class AgentSession {
// Only inject on the first user message of the conversation. Subsequent user
// turns must not receive the eager todo reminder — they often correct, clarify,
// or redirect the prior task, and forcing a brand-new todo list there is wrong.
const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
if (hasPriorUserMessage) {
return undefined;
}
// When `promptText` is undefined (post-compaction re-injection) there is no fresh
// user message to gate on, so skip the first-message and prompt-suffix checks.
if (promptText !== undefined) {
const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
if (hasPriorUserMessage) {
return undefined;
}
const trimmedPromptText = promptText.trimEnd();
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
return undefined;
const trimmedPromptText = promptText.trimEnd();
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
return undefined;
}
}
// Must check the active tool set, not just the registry: tool discovery
@@ -7821,8 +7835,10 @@ export class AgentSession {
timestamp: Date.now(),
};
// `preferred` suggests a todo list (reminder only); `always` also forces the
// `todo` tool on the first turn — the previous boolean-on behavior.
if (mode === "preferred") {
// `todo` tool on the first turn — the previous boolean-on behavior. Post-compaction
// re-injection (`promptText === undefined`) is always reminder-only: forcing a tool
// onto the auto-resumed turn would override the agent's in-flight action.
if (promptText === undefined || mode === "preferred") {
return { message };
}
const todoToolChoice = buildNamedToolChoice("todo", this.model);
@@ -7840,7 +7856,7 @@ export class AgentSession {
return { message, toolChoice: todoToolChoice };
}
#createEagerTaskPrelude(promptText: string): AgentMessage | undefined {
#createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
if (this.settings.get("task.eager") !== "always") return undefined;
// Main agent only: subagents keep `task` active (the parent only filters `todo`),
// so a salient delegate-reminder there would amplify nested fan-out. Gate on the
@@ -7848,9 +7864,13 @@ export class AgentSession {
// still gets the reminder.
if (this.#agentKind === "sub") return undefined;
if (this.#planModeState?.enabled) return undefined;
if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
const trimmed = promptText.trimEnd();
if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
// First-message-only gates are skipped post-compaction (`promptText === undefined`),
// where there is no fresh user message to suppress the reminder for.
if (promptText !== undefined) {
if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
const trimmed = promptText.trimEnd();
if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
}
if (!this.getActiveToolNames().includes("task")) return undefined;
return {
role: "custom",
@@ -7861,6 +7881,24 @@ export class AgentSession {
timestamp: Date.now(),
};
}
/**
* Build the eager task/todo reminders to re-inject on the auto-continuation turn that
* follows a compaction. The first-message preludes are the oldest messages, so
* compaction summarizes them away and the agent silently loses the delegate-via-tasks
* and phased-todo guidance mid-work; this re-asserts them, reminder-only (the todo
* builder drops its forced tool_choice when `promptText` is undefined). Each builder
* still applies its own mode / agent-kind / plan-mode / tool-active / surviving-todo
* gates, so an empty array means nothing currently warrants a nudge.
*/
#buildPostCompactionEagerNudges(): AgentMessage[] {
const nudges: AgentMessage[] = [];
const todo = this.#createEagerTodoPrelude(undefined);
if (todo) nudges.push(todo.message);
const task = this.#createEagerTaskPrelude(undefined);
if (task) nudges.push(task);
return nudges;
}
/**
* Check if agent stopped with incomplete todos and prompt to continue.
*/
@@ -0,0 +1,358 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import type { TextContent } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TodoTool, type ToolSession, USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools";
import { TempDir } from "@oh-my-pi/pi-utils";
import { z } from "zod/v4";
// Re-injecting eager preludes after compaction: the first-message preludes are the
// oldest messages, so compaction summarizes them away and the agent silently loses
// the delegate-via-tasks / phased-todo guidance. The post-compaction auto-continuation
// turn must carry the gated reminders again (reminder-only — never a forced tool_choice).
const CONTINUE_MARKER = "Resume work on the user's most recent intent";
type ObservedPromptCall = {
toolChoice: string | undefined;
messageTexts: string[];
};
type WaitForCall = (predicate: (call: ObservedPromptCall) => boolean) => Promise<ObservedPromptCall>;
type Harness = {
session: AgentSession;
observedCalls: ObservedPromptCall[];
sessionManager: SessionManager;
// Resolves with the first provider call (already-seen or future) matching the predicate.
// Awaiting the real streamFn call avoids wall-clock polling for the async compaction path.
waitForCall: WaitForCall;
};
function isTextContentBlock(value: unknown): value is TextContent {
if (!value || typeof value !== "object") return false;
return (value as TextContent).type === "text" && typeof (value as TextContent).text === "string";
}
function getToolChoiceName(choice: unknown): string | undefined {
if (!choice) return undefined;
if (typeof choice === "string") return choice;
if (typeof choice !== "object" || !("type" in choice)) return undefined;
const toolChoice = choice as { type?: string; name?: string; function?: { name?: string } };
if (toolChoice.type === "tool") return toolChoice.name;
if (toolChoice.type === "function") return toolChoice.name ?? toolChoice.function?.name;
return undefined;
}
function getMessageText(message: AgentMessage): string {
if (!("content" in message)) return "";
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.filter(isTextContentBlock)
.map(content => content.text)
.join("\n");
}
function createAssistantResponse(text: string) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
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(),
};
}
/** Short-circuit the LLM summary so compaction completes without a network call. */
function stubCompaction(firstKeptEntryId?: string): void {
vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "compacted",
shortSummary: undefined,
firstKeptEntryId: firstKeptEntryId ?? preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
}
/** Emit a high-usage assistant turn to drive threshold (context-full) auto-compaction. */
function emitHighUsageTurn(session: AgentSession): void {
const assistantMsg = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Done." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 190_000,
output: 1_000,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 191_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
}
describe("AgentSession eager prelude re-injection after compaction", () => {
let tempDir: TempDir;
const cleanups: Array<() => Promise<void>> = [];
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-session-eager-compaction-");
cleanups.length = 0;
});
afterEach(async () => {
for (const cleanup of cleanups) await cleanup();
cleanups.length = 0;
tempDir.removeSync();
vi.restoreAllMocks();
});
async function createHarness(
settingsOverride: Record<string, unknown> = {},
opts: { agentId?: string; agentKind?: "main" | "sub" } = {},
): Promise<Harness> {
const observedCalls: ObservedPromptCall[] = [];
const waiters: Array<{
predicate: (call: ObservedPromptCall) => boolean;
resolve: (call: ObservedPromptCall) => void;
}> = [];
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": true,
"compaction.strategy": "context-full",
"task.eager": "always",
"todo.enabled": false,
"todo.eager": "default",
"todo.reminders": false,
...settingsOverride,
});
const sessionManager = SessionManager.inMemory(tempDir.path());
const mockTaskTool: AgentTool = {
name: "task",
label: "Task",
description: "Mock task tool",
parameters: z.object({}),
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
const mockBashTool: AgentTool = {
name: "bash",
label: "Bash",
description: "Mock bash tool",
parameters: z.object({}),
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
const todoEnabled = settings.get("todo.enabled") === true;
const toolSession: ToolSession = {
cwd: tempDir.path(),
hasUI: false,
getSessionFile: () => sessionManager.getSessionFile() ?? null,
getSessionSpawns: () => "*",
settings,
};
const todoTool = todoEnabled ? new TodoTool(toolSession) : undefined;
const tools: AgentTool[] = todoTool
? [todoTool as unknown as AgentTool, mockTaskTool, mockBashTool]
: [mockTaskTool, mockBashTool];
let session: AgentSession;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools, messages: [] },
convertToLlm,
getToolChoice: () => session?.nextToolChoice(),
streamFn: (_model, context, options) => {
const call: ObservedPromptCall = {
toolChoice: getToolChoiceName(options?.toolChoice),
messageTexts: context.messages.map(message => getMessageText(message)),
};
observedCalls.push(call);
for (let i = waiters.length - 1; i >= 0; i--) {
const waiter = waiters[i];
if (waiter?.predicate(call)) {
waiter.resolve(call);
waiters.splice(i, 1);
}
}
const response = createAssistantResponse("done");
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: response });
stream.push({ type: "done", reason: "stop", message: response });
});
return stream;
},
});
const toolRegistry = new Map<string, AgentTool>([
[mockTaskTool.name, mockTaskTool],
[mockBashTool.name, mockBashTool],
]);
if (todoTool) toolRegistry.set(todoTool.name, todoTool as unknown as AgentTool);
session = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry,
toolRegistry,
agentId: opts.agentId,
agentKind: opts.agentKind,
});
const waitForCall: WaitForCall = predicate => {
const existing = observedCalls.find(predicate);
if (existing) return Promise.resolve(existing);
const { promise, resolve } = Promise.withResolvers<ObservedPromptCall>();
waiters.push({ predicate, resolve });
return promise;
};
cleanups.push(async () => {
await session.dispose();
authStorage.close();
});
return { session, observedCalls, sessionManager, waitForCall };
}
/** Run the first prompt, drive a compaction, and resolve with the auto-continuation provider call. */
async function runToContinuation(session: AgentSession, waitForCall: WaitForCall): Promise<ObservedPromptCall> {
await session.prompt("refactor the parser across modules");
emitHighUsageTurn(session);
return waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER)));
}
it("re-injects the eager task reminder on the auto-continuation turn (task.eager always)", async () => {
const { session, waitForCall } = await createHarness();
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
const reminder = continuation.messageTexts.find(text => text.includes("delegation is enabled"));
expect(reminder).toBeDefined();
expect(reminder).toContain("`task`");
// Reminder-only: the post-compaction nudge never forces a tool on the resumed turn.
expect(continuation.toolChoice).toBeUndefined();
});
it("does not re-inject the eager task reminder when task.eager is default", async () => {
const { session, waitForCall } = await createHarness({ "task.eager": "default" });
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false);
});
it("does not re-inject the eager task reminder when task.eager is preferred", async () => {
const { session, waitForCall } = await createHarness({ "task.eager": "preferred" });
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false);
});
it("does not re-inject the eager task reminder for subagent sessions", async () => {
const { session, waitForCall } = await createHarness({}, { agentId: "SubAgent", agentKind: "sub" });
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false);
});
it("does not re-inject the eager task reminder in plan mode", async () => {
const { session, waitForCall } = await createHarness();
session.setPlanModeState({ enabled: true, planFilePath: path.join(tempDir.path(), "plan.md") });
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false);
});
it("re-injects the eager todo reminder on the auto-continuation turn (todo.eager preferred)", async () => {
const { session, waitForCall } = await createHarness({
"task.eager": "default",
"todo.enabled": true,
"todo.eager": "preferred",
});
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
expect(continuation.messageTexts.some(text => text.includes("Consider calling"))).toBe(true);
expect(continuation.toolChoice).toBeUndefined();
});
it("re-injects the eager todo reminder reminder-only for todo.eager always (no forced tool)", async () => {
const { session, waitForCall } = await createHarness({
"task.eager": "default",
"todo.enabled": true,
"todo.eager": "always",
});
stubCompaction();
const continuation = await runToContinuation(session, waitForCall);
// `always` keeps the strong forced wording in the reminder text...
expect(continuation.messageTexts.some(text => text.includes("You MUST call"))).toBe(true);
// ...but post-compaction never attaches the forced todo tool_choice.
expect(continuation.toolChoice).toBeUndefined();
});
it("does not re-inject the eager todo reminder when todos survived compaction", async () => {
const { session, sessionManager, waitForCall } = await createHarness({
"task.eager": "default",
"todo.enabled": true,
"todo.eager": "preferred",
});
await session.prompt("refactor the parser across modules");
// A surviving todo entry; pin firstKeptEntryId so compaction preserves it in the branch.
const todoEntryId = sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, {
phases: [{ name: "Work", tasks: [{ content: "do the thing", status: "pending" }] }],
});
stubCompaction(todoEntryId);
const continuationPromise = waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER)));
emitHighUsageTurn(session);
const continuation = await continuationPromise;
expect(session.getTodoPhases().length).toBeGreaterThan(0);
expect(continuation.messageTexts.some(text => text.includes("Consider calling"))).toBe(false);
expect(continuation.messageTexts.some(text => text.includes("You MUST call"))).toBe(false);
});
});