Skip to content
Merged
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
146 changes: 128 additions & 18 deletions packages/agent-sdk/src/managers/aiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { microcompactMessages } from "../utils/microcompact.js";
import { parseTaskNotificationXml } from "../utils/notificationXml.js";
import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
import * as fs from "node:fs/promises";
import { existsSync } from "node:fs";
import type {
GatewayConfig,
ModelConfig,
Expand All @@ -23,6 +24,12 @@ import type { PermissionManager } from "./permissionManager.js";
import type { SubagentManager } from "./subagentManager.js";
import type { SkillManager } from "./skillManager.js";
import { buildSystemPrompt } from "../prompts/index.js";
import {
buildPlanModeReminder,
buildPlanModeSparseReminder,
buildPlanModeReEntryReminder,
buildExitedPlanModeReminder,
} from "../prompts/planModeReminders.js";
import { Container } from "../utils/container.js";
import { recoverTruncatedJson } from "../utils/stringUtils.js";
import { ConfigurationService } from "../services/configurationService.js";
Expand Down Expand Up @@ -217,6 +224,119 @@ export class AIManager {
});
}

/**
* Build plan mode system-reminder messages to inject into the API message stream.
* These are transient messages not stored in the message history.
* This preserves prompt caching by keeping the system prompt constant.
*/
private buildPlanModeMessages(
currentMode: PermissionMode | undefined,
): import("openai/resources.js").ChatCompletionMessageParam[] {
const messages: import("openai/resources.js").ChatCompletionMessageParam[] =
[];
if (!this.permissionManager) return messages;

// Handle exit notification (one-time after leaving plan mode)
if (this.permissionManager.getNeedsPlanModeExitAttachment()) {
const planFilePath = this.permissionManager.getPlanFilePath();
const planExists = planFilePath ? existsSync(planFilePath) : false;
messages.push({
role: "user",
content: buildExitedPlanModeReminder(planFilePath, planExists),
});
this.permissionManager.setNeedsPlanModeExitAttachment(false);
}

// Handle plan mode reminders
if (currentMode !== "plan") return messages;

const planFilePath = this.permissionManager.getPlanFilePath();
if (!planFilePath) return messages;

const planExists = existsSync(planFilePath);

// Check for re-entry: flag is set AND plan file exists
if (this.permissionManager.hasExitedPlanModeInSession() && planExists) {
messages.push({
role: "user",
content: buildPlanModeReEntryReminder(planFilePath),
});
this.permissionManager.setHasExitedPlanMode(false); // One-time
}

// Count plan_mode system-reminders in existing messages to determine full vs sparse
// and count human turns since last reminder for throttling
const recentApiMessages = this.messageManager.getMessages();
let planModeReminderCount = 0;
let humanTurnsSinceLastReminder = 0;
let foundLastReminder = false;

for (let i = recentApiMessages.length - 1; i >= 0; i--) {
const msg = recentApiMessages[i];
if (msg.role === "user" && !msg.isMeta) {
// Count human turns (non-meta user messages without tool results)
const hasToolResult = msg.blocks?.some(
(b: { type: string }) => b.type === "tool",
);
if (!hasToolResult) {
if (!foundLastReminder) {
humanTurnsSinceLastReminder++;
}
}
}
// Check for existing plan mode system-reminders
if (msg.role === "user" && msg.isMeta) {
const textContent = msg.blocks
?.filter((b) => b.type === "text")
.map((b) => ("content" in b ? b.content : ""))
.join("");
if (
textContent?.includes("Plan mode is active") ||
textContent?.includes("Plan mode still active")
) {
planModeReminderCount++;
if (!foundLastReminder) {
foundLastReminder = true;
}
}
}
}

// Throttle: only inject every 5 human turns (but always inject on first turn)
const TURNS_BETWEEN_REMINDERS = 5;
const FULL_REMINDER_EVERY_N = 5;

if (
foundLastReminder &&
humanTurnsSinceLastReminder < TURNS_BETWEEN_REMINDERS
) {
return messages; // Throttled — skip reminder
}

// Determine full vs sparse
// Every 5th reminder is full; rest are sparse
const reminderNumber = planModeReminderCount + 1;
const isFull = reminderNumber % FULL_REMINDER_EVERY_N === 1;

if (isFull) {
messages.push({
role: "user",
content: buildPlanModeReminder(
planFilePath,
planExists,
!!this.subagentType,
),
});
} else {
messages.push({
role: "user",
content: buildPlanModeSparseReminder(planFilePath),
});
}

return messages;
}

public setIsLoading(isLoading: boolean): void {
this.isLoading = isLoading;
this.onLoadingChange?.(isLoading);
Expand Down Expand Up @@ -377,8 +497,10 @@ export class AIManager {
} catch {
// Plan file doesn't exist yet
}
// Inject full plan mode system-reminder after compaction
// so the model retains plan mode constraints and instructions
contextParts.push(
`\n\n[Plan Mode]\nYou are in plan mode. Plan file: ${planFilePath} (exists: ${planExists})`,
`\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`,
);
}
}
Expand Down Expand Up @@ -685,22 +807,11 @@ export class AIManager {
.getTools()
.filter((t) => toolNames.has(t.name));

let planModeOptions:
| { planFilePath: string; planExists: boolean }
| undefined;

if (currentMode === "plan") {
const planFilePath = this.permissionManager?.getPlanFilePath();
if (planFilePath) {
let planExists = false;
try {
await fs.access(planFilePath);
planExists = true;
} catch {
planExists = false;
}
planModeOptions = { planFilePath, planExists };
}
// Inject plan mode system-reminder messages (not system prompt)
// This preserves prompt caching by keeping the system prompt constant
const planModeMessages = this.buildPlanModeMessages(currentMode);
if (planModeMessages.length > 0) {
recentMessages.push(...planModeMessages);
}

let autoMemoryOptions: { directory: string; content: string } | undefined;
Expand Down Expand Up @@ -733,7 +844,6 @@ export class AIManager {
memory: combinedMemory,
language: this.getLanguage(),
isSubagent: !!this.subagentType,
planMode: planModeOptions,
autoMemory: autoMemoryOptions,
permissionMode: currentMode,
},
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-sdk/src/managers/permissionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export class PermissionManager {
private additionalDirectories: string[] = [];
private systemAdditionalDirectories: string[] = [];
private planFilePath?: string;
private hasExitedPlanMode: boolean = false;
private needsPlanModeExitAttachment: boolean = false;
private workdir?: string;
private worktreeName?: string;
private mainRepoRoot?: string;
Expand Down Expand Up @@ -315,6 +317,22 @@ export class PermissionManager {
return this.planFilePath;
}

public setHasExitedPlanMode(value: boolean): void {
this.hasExitedPlanMode = value;
}

public hasExitedPlanModeInSession(): boolean {
return this.hasExitedPlanMode;
}

public setNeedsPlanModeExitAttachment(value: boolean): void {
this.needsPlanModeExitAttachment = value;
}

public getNeedsPlanModeExitAttachment(): boolean {
return this.needsPlanModeExitAttachment;
}

/**
* Public wrapper for isInsideSafeZone to check if a path is in the safe zone
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-sdk/src/managers/planManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ export class PlanManager {
this.container.get<PermissionManager>("PermissionManager");
const messageManager = this.container.get<MessageManager>("MessageManager");

const previousMode = this.container.get<PermissionMode>("PermissionMode");

if (mode === "plan") {
// Entering plan mode: clear any pending exit attachment
// (prevents sending both plan_mode and plan_mode_exit on rapid toggle)
permissionManager?.setNeedsPlanModeExitAttachment(false);

this.getOrGeneratePlanFilePath(messageManager?.getRootSessionId())
.then(({ path }) => {
logger?.debug("Plan file path generated", { path });
Expand All @@ -83,6 +89,11 @@ export class PlanManager {
.catch((error) => {
logger?.error("Failed to generate plan file path", error);
});
} else if (previousMode === "plan") {
// Leaving plan mode: set flags for exit notification and re-entry detection
permissionManager?.setHasExitedPlanMode(true);
permissionManager?.setNeedsPlanModeExitAttachment(true);
permissionManager?.setPlanFilePath(undefined);
} else {
permissionManager?.setPlanFilePath(undefined);
}
Expand Down
8 changes: 0 additions & 8 deletions packages/agent-sdk/src/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,6 @@ export function buildSystemPrompt(
memory?: string;
language?: string;
isSubagent?: boolean;
planMode?: {
planFilePath: string;
planExists: boolean;
};
autoMemory?: {
directory: string;
content: string;
Expand All @@ -269,10 +265,6 @@ export function buildSystemPrompt(
prompt += `\n\n# Language\nAlways respond in ${options.language}. Use ${options.language} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`;
}

if (options.planMode) {
prompt += `\n\n${buildPlanModePrompt(options.planMode.planFilePath, options.planMode.planExists, options.isSubagent)}`;
}

if (options.workdir) {
const isGitRepo = isGitRepository(options.workdir);
const platform = os.platform();
Expand Down
Loading