Files
oh-my-pi/packages/coding-agent/src/session/todo-tracker.ts
T
can1357 7eeaba0471 refactor(coding-agent/session): restructured monolithic agent session
- Extracted internal handlers and logic from AgentSession into dedicated runner, guard, and coordinator modules.
- Created standalone modules for bash execution, evaluation runners, IRC bridging, and prewalk coordination.
- Established dedicated session components for tracking stats, todos, streams, and retry fallback chains.
- Preserved existing session behavior while significantly reducing monolithic class size and complexity.
2026-07-24 01:24:44 +02:00

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Agent, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Message, Model, TextContent, ToolChoice } from "@oh-my-pi/pi-ai";
import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
import midRunTodoNudgePrompt from "../prompts/system/mid-run-todo-nudge.md" with { type: "text" };
import { getLatestTodoPhasesFromEntries, isTodoPhase, type TodoItem, type TodoPhase } from "../tools/todo";
import { buildNamedToolChoice } from "../utils/tool-choice";
import type { AgentSessionEvent } from "./agent-session-events";
import type { SessionManager } from "./session-manager";
const MID_RUN_NUDGE_MUTATION_THRESHOLD = 12;
const MID_RUN_NUDGE_MAX_PER_CYCLE = 2;
const MUTATING_TOOLS: Record<string, true> = {
bash: true,
eval: true,
edit: true,
write: true,
ast_edit: true,
};
const MID_RUN_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
const QUESTION_PROMPT_RE =
/^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
const USER_RESPONSE_CUE_RE =
/^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
interface PromptLine {
text: string;
hadPromptLabel: boolean;
}
/** Capabilities the todo tracker borrows from its owning session. */
export interface TodoTrackerHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
model(): Model | undefined;
agentKind(): "main" | "sub";
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
scheduleAgentContinue(options: { generation?: number }): void;
promptGeneration(): number;
hasPendingAsyncWake(): boolean;
getActiveToolNames(): string[];
toolRegistry(): Map<string, AgentTool>;
planModeEnabled(): boolean;
consumeLastServedToolChoiceLabel(): string | undefined;
}
/** Owns canonical todo state, eager preludes, and completion reminders. */
export class TodoTracker {
readonly #host: TodoTrackerHost;
#phases: TodoPhase[] = [];
#reminderCount = 0;
#reminderAwaitingProgress = false;
#mutationsSinceLastTouch = 0;
#midRunNudgeCount = 0;
constructor(host: TodoTrackerHost) {
this.#host = host;
}
/** Returns a defensive clone of the current todo phases. */
get phases(): TodoPhase[] {
return this.#clonePhases(this.#phases);
}
/** Replaces todo phases with a defensive clone. */
setPhases(phases: TodoPhase[]): void {
this.#phases = this.#clonePhases(phases);
}
/** Rehydrates todo phases from the current transcript branch. */
syncFromBranch(): void {
this.setPhases(getLatestTodoPhasesFromEntries(this.#host.sessionManager.getBranch()));
}
/** Returns a defensive clone suitable for snapshots and branch state. */
clonePhases(phases: TodoPhase[]): TodoPhase[] {
return this.#clonePhases(phases);
}
/** Resets per-prompt reminder and mutation budgets. */
resetCycle(): void {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
this.#mutationsSinceLastTouch = 0;
this.#midRunNudgeCount = 0;
}
/** Records a completed tool result before asynchronous event processing begins. */
onToolResult(toolName: string, isError: boolean): void {
if (toolName === "todo") {
this.#mutationsSinceLastTouch = 0;
} else if (!isError && MUTATING_TOOLS[toolName]) {
this.#mutationsSinceLastTouch++;
}
this.#reminderAwaitingProgress = false;
}
/** Detects whether a successful todo result came from an init operation. */
onTodoResultDetails(details: Record<string, unknown>, toolCallId: string | undefined): boolean {
const phases = details.phases;
if (!Array.isArray(phases) || !phases.every(isTodoPhase)) return false;
const detailOp = stringProperty(details, "op");
if (detailOp) return detailOp === "init";
if (!toolCallId) return false;
for (let index = this.#host.agent.state.messages.length - 1; index >= 0; index--) {
const message = this.#host.agent.state.messages[index];
if (!message) continue;
const op = toolCallOpFromMessage(message, toolCallId);
if (op) return op === "init";
}
return false;
}
/** Builds the first-turn eager todo prelude and optional forced tool choice. */
createEagerTodoPrelude(
promptText: string | undefined,
): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
const mode = this.#host.settings.get("todo.eager");
if (mode === "default" || !this.#host.settings.get("todo.enabled")) return undefined;
if (this.#host.planModeEnabled() || this.#phases.length > 0) return undefined;
if (promptText !== undefined) {
if (this.#host.agent.state.messages.some(message => message.role === "user")) return undefined;
const trimmedPromptText = promptText.trimEnd();
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) return undefined;
}
const activeToolNames = this.#host.getActiveToolNames();
if (!activeToolNames.includes("todo")) {
logger.warn("Eager todo enforcement skipped because todo is not active", { activeToolNames });
return undefined;
}
const message: AgentMessage = {
role: "custom",
customType: "eager-todo-prelude",
content: prompt.render(eagerTodoPrompt, { ...this.#buildEagerPreludeContext(), forced: mode === "always" }),
display: false,
attribution: "agent",
timestamp: Date.now(),
};
if (promptText === undefined || mode === "preferred") return { message };
const model = this.#host.model();
const toolChoice = buildNamedToolChoice("todo", model);
if (!toolChoice) {
logger.warn(
"Eager todo proceeding with the reminder only because the current model does not support a forced todo tool_choice",
{ modelApi: model?.api, modelId: model?.id },
);
return { message };
}
return { message, toolChoice };
}
/** Builds the first-turn eager task-delegation prelude. */
createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
if (this.#host.settings.get("task.eager") !== "always") return undefined;
if (this.#host.agentKind() === "sub" || this.#host.planModeEnabled()) return undefined;
if (promptText !== undefined) {
if (this.#host.agent.state.messages.some(message => message.role === "user")) return undefined;
const trimmed = promptText.trimEnd();
if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
}
if (!this.#host.getActiveToolNames().includes("task")) return undefined;
return {
role: "custom",
customType: "eager-task-prelude",
content: prompt.render(eagerTaskPrompt, this.#buildEagerPreludeContext()),
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
/** Builds reminder-only eager preludes after compaction. */
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;
}
/** Checks a terminal assistant turn and schedules continuation for incomplete todos. */
async checkCompletion(message: AssistantMessage): Promise<boolean> {
if (this.#host.consumeLastServedToolChoiceLabel() === "user-force") return false;
if (this.#host.planModeEnabled()) return false;
if (this.#reminderAwaitingProgress) {
logger.debug("Todo completion: prior reminder still awaiting agent action; staying silent", {
attempt: this.#reminderCount,
});
return false;
}
if (!this.#host.settings.get("todo.reminders") || !this.#host.settings.get("todo.enabled")) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
const remindersMax = this.#host.settings.get("todo.remindersMax");
if (this.#reminderCount >= remindersMax) {
logger.debug("Todo completion: max reminders reached", { count: this.#reminderCount });
return false;
}
const phases = this.phases;
if (phases.length === 0) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
const incompleteByPhase = phases
.map(phase => ({
name: phase.name,
tasks: phase.tasks
.filter(
(task): task is TodoItem & { status: "pending" | "in_progress" } =>
task.status === "pending" || task.status === "in_progress",
)
.map(task => ({ content: task.content, status: task.status })),
}))
.filter(phase => phase.tasks.length > 0);
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
if (incomplete.length === 0) {
this.#reminderCount = 0;
this.#reminderAwaitingProgress = false;
return false;
}
if (isAwaitingUserAnswer(message)) {
logger.debug("Todo completion: assistant is waiting for user input; skipping reminder", {
incomplete: incomplete.length,
});
return false;
}
if (this.#host.hasPendingAsyncWake()) {
logger.debug("Todo completion: async jobs in flight will re-wake the loop; skipping reminder", {
incomplete: incomplete.length,
});
return false;
}
this.#reminderCount++;
const todoList = incompleteByPhase
.map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
.join("\n");
const reminder =
`<system-reminder>\n` +
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
`Please continue working on these tasks or mark them complete if finished.\n` +
`(Reminder ${this.#reminderCount}/${remindersMax})\n` +
`</system-reminder>`;
logger.debug("Todo completion: sending reminder", {
incomplete: incomplete.length,
attempt: this.#reminderCount,
});
await this.#host.emitSessionEvent({
type: "todo_reminder",
todos: incomplete,
attempt: this.#reminderCount,
maxAttempts: remindersMax,
});
const reminderMessage: Message = {
role: "developer",
content: [{ type: "text", text: reminder }],
attribution: "agent",
timestamp: Date.now(),
};
this.#mutationsSinceLastTouch = 0;
this.#reminderAwaitingProgress = true;
this.#host.agent.appendMessage(reminderMessage);
this.#host.sessionManager.appendMessage(reminderMessage);
this.#host.scheduleAgentContinue({ generation: this.#host.promptGeneration() });
return true;
}
/** Takes the next hidden mid-run reconciliation nudge, if its budget and guards allow. */
takeMidRunNudge(): AgentMessage | null {
if (this.#mutationsSinceLastTouch < MID_RUN_NUDGE_MUTATION_THRESHOLD) return null;
if (this.#midRunNudgeCount >= MID_RUN_NUDGE_MAX_PER_CYCLE) return null;
if (!this.#host.settings.get("todo.enabled") || !this.#host.settings.get("todo.reminders")) return null;
if (this.#host.planModeEnabled() || !this.#host.getActiveToolNames().includes("todo")) return null;
const incomplete = this.#phases
.flatMap(phase => phase.tasks)
.filter(task => task.status === "pending" || task.status === "in_progress");
if (incomplete.length === 0) return null;
this.#mutationsSinceLastTouch = 0;
this.#midRunNudgeCount++;
const { toolRefs } = this.#buildEagerPreludeContext();
const reminder = prompt.render(midRunTodoNudgePrompt, {
toolRefs,
incompleteCount: incomplete.length,
plural: incomplete.length !== 1,
});
logger.debug("Mid-run todo nudge fired", {
incomplete: incomplete.length,
nudge: this.#midRunNudgeCount,
});
return {
role: "custom",
customType: MID_RUN_NUDGE_MESSAGE_TYPE,
content: reminder,
display: false,
attribution: "agent",
timestamp: Date.now(),
};
}
#buildEagerPreludeContext(): { toolRefs: Record<string, string>; taskBatch: boolean } {
const wireName = (name: string): string => {
const tool = this.#host.toolRegistry().get(name);
return typeof tool?.customWireName === "string" ? tool.customWireName : name;
};
return {
toolRefs: { task: wireName("task"), todo: wireName("todo") },
taskBatch: this.#host.settings.get("task.batch"),
};
}
#clonePhases(phases: TodoPhase[]): TodoPhase[] {
return phases.map(phase => ({
name: phase.name,
tasks: phase.tasks.map(task =>
task.blocker !== undefined
? { content: task.content, status: task.status, blocker: task.blocker }
: { content: task.content, status: task.status },
),
}));
}
}
function toolCallOpFromMessage(message: AgentMessage, toolCallId: string): string | undefined {
if (message.role !== "assistant" || !Array.isArray(message.content)) return undefined;
for (const block of message.content) {
if (!isRecord(block) || block.type !== "toolCall" || block.id !== toolCallId) continue;
return isRecord(block.arguments) ? stringProperty(block.arguments, "op") : undefined;
}
return undefined;
}
function assistantText(message: AssistantMessage): string {
return message.content
.filter((content): content is TextContent => content.type === "text")
.map(content => content.text)
.join("\n")
.trim();
}
function promptLine(line: string): PromptLine {
const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
return {
text: withoutPromptLabel,
hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
};
}
function isQuestionPromptLine(line: string): boolean {
const candidate = promptLine(line);
if (!/[??]\s*$/.test(candidate.text)) return false;
return (
candidate.hadPromptLabel ||
QUESTION_PROMPT_RE.test(candidate.text) ||
USER_DIRECTED_PROMPT_RE.test(candidate.text)
);
}
function isResponseCueLine(line: string): boolean {
const candidate = promptLine(line)
.text.replace(/[.!?。!?]+$/, "")
.trim();
return USER_RESPONSE_CUE_RE.test(candidate);
}
function isAwaitingUserAnswer(message: AssistantMessage): boolean {
const text = assistantText(message);
if (!text) return false;
const lastLine = text.split(/\r?\n/).at(-1)?.trim();
return lastLine !== undefined && (isQuestionPromptLine(lastLine) || isResponseCueLine(lastLine));
}