wave-agent-sdk 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +0 -2
  3. package/dist/managers/MemoryRuleManager.d.ts +9 -0
  4. package/dist/managers/MemoryRuleManager.d.ts.map +1 -1
  5. package/dist/managers/MemoryRuleManager.js +18 -0
  6. package/dist/managers/aiManager.d.ts +1 -1
  7. package/dist/managers/aiManager.d.ts.map +1 -1
  8. package/dist/managers/aiManager.js +370 -345
  9. package/dist/managers/messageManager.d.ts +32 -13
  10. package/dist/managers/messageManager.d.ts.map +1 -1
  11. package/dist/managers/messageManager.js +82 -76
  12. package/dist/prompts/index.d.ts +10 -4
  13. package/dist/prompts/index.d.ts.map +1 -1
  14. package/dist/prompts/index.js +21 -20
  15. package/dist/services/aiService.d.ts +2 -1
  16. package/dist/services/aiService.d.ts.map +1 -1
  17. package/dist/services/aiService.js +37 -10
  18. package/dist/services/memory.d.ts +1 -0
  19. package/dist/services/memory.d.ts.map +1 -1
  20. package/dist/services/memory.js +35 -17
  21. package/dist/tools/agentTool.d.ts.map +1 -1
  22. package/dist/tools/agentTool.js +24 -2
  23. package/dist/tools/editTool.js +3 -3
  24. package/dist/tools/readTool.js +2 -2
  25. package/dist/tools/writeTool.js +2 -2
  26. package/dist/utils/containerSetup.d.ts.map +1 -1
  27. package/dist/utils/containerSetup.js +1 -0
  28. package/dist/utils/openaiClient.d.ts.map +1 -1
  29. package/dist/utils/openaiClient.js +28 -7
  30. package/dist/utils/taskReminder.d.ts +2 -3
  31. package/dist/utils/taskReminder.d.ts.map +1 -1
  32. package/dist/utils/taskReminder.js +7 -18
  33. package/package.json +1 -1
  34. package/scripts/install_ripgrep.js +21 -1
  35. package/src/agent.ts +0 -5
  36. package/src/managers/MemoryRuleManager.ts +23 -0
  37. package/src/managers/aiManager.ts +473 -434
  38. package/src/managers/messageManager.ts +99 -82
  39. package/src/prompts/index.ts +33 -24
  40. package/src/services/aiService.ts +39 -12
  41. package/src/services/memory.ts +34 -16
  42. package/src/tools/agentTool.ts +24 -2
  43. package/src/tools/editTool.ts +3 -3
  44. package/src/tools/readTool.ts +2 -2
  45. package/src/tools/writeTool.ts +2 -2
  46. package/src/utils/containerSetup.ts +1 -0
  47. package/src/utils/openaiClient.ts +35 -7
  48. package/src/utils/taskReminder.ts +7 -19
  49. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  50. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  51. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  52. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  53. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
@@ -28,8 +28,10 @@ import {
28
28
  } from "../services/session.js";
29
29
  import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
30
30
  import type { MemoryRuleManager } from "./MemoryRuleManager.js";
31
+ import type { MemoryRule } from "../types/memoryRule.js";
31
32
  import type { MemoryService } from "../services/memory.js";
32
33
  import { pathEncoder } from "../utils/pathEncoder.js";
34
+ import { READ_TOOL_NAME } from "../constants/tools.js";
33
35
 
34
36
  import { Container } from "../utils/container.js";
35
37
 
@@ -100,7 +102,8 @@ export class MessageManager {
100
102
  private callbacks: MessageManagerCallbacks;
101
103
  private transcriptPath: string; // Cached transcript path
102
104
  private savedMessageCount: number; // Track how many messages have been saved to prevent duplication
103
- private filesInContext: Set<string> = new Set(); // Track files mentioned in the conversation
105
+ private pendingFileReadTriggers: Set<string> = new Set(); // File paths read via Read tool, awaiting rule matching
106
+ private loadedRuleIds: Set<string> = new Set(); // IDs of conditional rules already injected as meta messages
104
107
  private recentFileReads: Map<string, { content: string; timestamp: number }> =
105
108
  new Map(); // Track file read contents
106
109
  private invokedSkills: Map<string, { skillName: string; timestamp: number }> =
@@ -172,10 +175,27 @@ export class MessageManager {
172
175
  }
173
176
 
174
177
  /**
175
- * Returns all files mentioned in the current conversation context.
178
+ * Process pending file read triggers: match against conditional rules,
179
+ * return rules not yet loaded (dedup via loadedRuleIds), mark them as loaded.
180
+ * Clears triggers after processing.
176
181
  */
177
- public getFilesInContext(): string[] {
178
- return Array.from(this.filesInContext);
182
+ public processTriggeredRules(): MemoryRule[] {
183
+ if (!this.memoryRuleManager || this.pendingFileReadTriggers.size === 0) {
184
+ this.pendingFileReadTriggers.clear();
185
+ return [];
186
+ }
187
+ const filePaths = Array.from(this.pendingFileReadTriggers);
188
+ this.pendingFileReadTriggers.clear();
189
+
190
+ const { conditional } =
191
+ this.memoryRuleManager.getActiveRulesSplit(filePaths);
192
+ const newRules = conditional.filter(
193
+ (rule) => !this.loadedRuleIds.has(rule.id),
194
+ );
195
+ for (const rule of newRules) {
196
+ this.loadedRuleIds.add(rule.id);
197
+ }
198
+ return newRules;
179
199
  }
180
200
 
181
201
  public getSessionDir(): string {
@@ -187,7 +207,8 @@ export class MessageManager {
187
207
  }
188
208
 
189
209
  /**
190
- * Get combined memory content (project memory + user memory + modular rules)
210
+ * Get combined memory content (project memory + user memory + unconditional rules)
211
+ * Note: conditional rules are handled separately via processTriggeredRules().
191
212
  */
192
213
  public async getCombinedMemory(): Promise<string> {
193
214
  let combined = await this.memoryService.getCombinedMemoryContent(
@@ -195,17 +216,41 @@ export class MessageManager {
195
216
  );
196
217
 
197
218
  if (this.memoryRuleManager) {
198
- const filesInContext = this.getFilesInContext();
199
- const activeRules = this.memoryRuleManager.getActiveRules(filesInContext);
200
- if (activeRules.length > 0) {
219
+ const { unconditional } = this.memoryRuleManager.getActiveRulesSplit([]);
220
+ if (unconditional.length > 0) {
201
221
  combined += "\n\n";
202
- combined += activeRules.map((r) => r.content).join("\n\n");
222
+ combined += unconditional.map((r) => r.content).join("\n\n");
203
223
  }
204
224
  }
205
225
 
206
226
  return combined;
207
227
  }
208
228
 
229
+ /**
230
+ * Get memory content for message-array injection:
231
+ * - prependContent: AGENTS.md + user memory + unconditional rules (for head injection)
232
+ * Conditional rules are handled separately via processTriggeredRules().
233
+ */
234
+ public async getMemoryForInjection(): Promise<{
235
+ prependContent: string;
236
+ }> {
237
+ const baseMemory = await this.memoryService.getCombinedMemoryContent(
238
+ this.workdir,
239
+ );
240
+
241
+ let prependContent = baseMemory;
242
+
243
+ if (this.memoryRuleManager) {
244
+ const { unconditional } = this.memoryRuleManager.getActiveRulesSplit([]);
245
+ if (unconditional.length > 0) {
246
+ prependContent += "\n\n";
247
+ prependContent += unconditional.map((r) => r.content).join("\n\n");
248
+ }
249
+ }
250
+
251
+ return { prependContent };
252
+ }
253
+
209
254
  /**
210
255
  * Compute the transcript path using cached encoded workdir
211
256
  * Called during construction and when sessionId changes
@@ -243,62 +288,43 @@ export class MessageManager {
243
288
  }
244
289
 
245
290
  /**
246
- * Adds a file path to the set of files in context, normalizing it if necessary.
291
+ * Record a file path read via the Read tool, to be matched against
292
+ * conditional memory rules before the next AI call.
247
293
  */
248
- public touchFile(filePath: string): void {
294
+ public triggerFileRead(filePath: string): void {
249
295
  const normalizedPath = isAbsolute(filePath)
250
296
  ? relative(this.workdir, filePath)
251
297
  : filePath;
252
- this.filesInContext.add(normalizedPath);
298
+ this.pendingFileReadTriggers.add(normalizedPath);
253
299
  }
254
300
 
255
301
  /**
256
- * Checks if a file has been read or edited in this conversation.
302
+ * Check if a file has been read (for read-before-edit enforcement).
303
+ * Uses recentFileReads Map populated from Read tool execution.
257
304
  */
258
- public hasFileInContext(filePath: string): boolean {
305
+ public hasFileBeenRead(filePath: string): boolean {
259
306
  const normalizedPath = isAbsolute(filePath)
260
307
  ? relative(this.workdir, filePath)
261
308
  : filePath;
262
- return this.filesInContext.has(normalizedPath);
263
- }
264
-
265
- /**
266
- * Extracts and adds file paths from a message's tool blocks.
267
- */
268
- private addPathsFromMessage(message: Message): void {
269
- for (const block of message.blocks) {
270
- if (block.type === "tool" && block.parameters) {
271
- try {
272
- const params = JSON.parse(block.parameters) as Record<
273
- string,
274
- unknown
275
- >;
276
- const paths = this.extractPathsFromParams(params);
277
- for (const p of paths) {
278
- this.touchFile(p);
279
- }
280
- } catch {
281
- // Ignore parse errors
282
- }
283
- }
284
- }
309
+ return (
310
+ this.recentFileReads.has(normalizedPath) ||
311
+ this.recentFileReads.has(filePath)
312
+ );
285
313
  }
286
314
 
287
315
  public setMessages(messages: Message[]): void {
288
316
  const oldLength = this.messages.length;
289
317
  this.messages = [...messages];
290
318
 
291
- // Incrementally add paths from new messages
319
+ // Incrementally extract metadata from new messages
292
320
  const newMessages = messages.slice(oldLength);
293
321
  for (const message of newMessages) {
294
- this.addPathsFromMessage(message);
295
322
  this.extractFileReadsFromMessage(message);
296
323
  this.extractSkillInvocationsFromMessage(message);
297
324
  }
298
325
 
299
326
  // Also check if the last message was updated (common for tool blocks)
300
327
  if (messages.length > 0 && messages.length === oldLength) {
301
- this.addPathsFromMessage(messages[messages.length - 1]);
302
328
  this.extractFileReadsFromMessage(messages[messages.length - 1]);
303
329
  this.extractSkillInvocationsFromMessage(messages[messages.length - 1]);
304
330
  }
@@ -377,6 +403,9 @@ export class MessageManager {
377
403
  this.setMessages([...sessionData.messages]);
378
404
  this.setlatestTotalTokens(sessionData.metadata.latestTotalTokens);
379
405
 
406
+ // Rebuild loadedRuleIds from persisted meta messages (session restore)
407
+ this.rebuildLoadedRuleIds();
408
+
380
409
  // Set saved message count to the number of loaded messages since they're already saved
381
410
  // This must be done after setSessionId which resets it to 0
382
411
  this.savedMessageCount = sessionData.messages.length;
@@ -568,18 +597,9 @@ export class MessageManager {
568
597
  // Set new message list
569
598
  this.setMessages(newMessages);
570
599
 
571
- // Reset and re-populate filesInContext
572
- this.filesInContext.clear();
573
- for (const message of this.messages) {
574
- this.addPathsFromMessage(message);
575
- }
576
-
577
- // Scan compactedContent for file mentions
578
- const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
579
- let match;
580
- while ((match = fileMentionRegex.exec(compactedContent)) !== null) {
581
- this.touchFile(match[1]);
582
- }
600
+ // Clear and rebuild loaded rule IDs from remaining meta messages
601
+ this.clearLoadedRuleIds();
602
+ this.rebuildLoadedRuleIds();
583
603
 
584
604
  // Trigger compaction callback
585
605
  this.callbacks.onCompactBlockAdded?.(compactedContent);
@@ -1008,35 +1028,6 @@ export class MessageManager {
1008
1028
  }
1009
1029
  }
1010
1030
 
1011
- private extractPathsFromParams(params: Record<string, unknown>): string[] {
1012
- const paths: string[] = [];
1013
- if (typeof params !== "object" || params === null) return paths;
1014
-
1015
- // Common parameter names for file paths
1016
- const pathKeys = [
1017
- "path",
1018
- "filePath",
1019
- "file_path",
1020
- "target_file",
1021
- "targetFile",
1022
- ];
1023
- for (const key of pathKeys) {
1024
- if (typeof params[key] === "string") {
1025
- paths.push(params[key]);
1026
- }
1027
- }
1028
-
1029
- // Handle arrays of paths (e.g. in Glob or Grep results if we ever track those,
1030
- // but here we track inputs to tools)
1031
- if (Array.isArray(params.files)) {
1032
- for (const f of params.files) {
1033
- if (typeof f === "string") paths.push(f);
1034
- }
1035
- }
1036
-
1037
- return paths;
1038
- }
1039
-
1040
1031
  /**
1041
1032
  * Extract file read contents from tool result blocks in a message.
1042
1033
  */
@@ -1044,7 +1035,7 @@ export class MessageManager {
1044
1035
  for (const block of message.blocks) {
1045
1036
  if (
1046
1037
  block.type === "tool" &&
1047
- block.name === "read" &&
1038
+ block.name === READ_TOOL_NAME &&
1048
1039
  block.stage === "end" &&
1049
1040
  block.result &&
1050
1041
  block.parameters
@@ -1143,4 +1134,30 @@ export class MessageManager {
1143
1134
  public clearInvokedSkills(): void {
1144
1135
  this.invokedSkills.clear();
1145
1136
  }
1137
+
1138
+ /**
1139
+ * Clear the loaded rule IDs set (e.g., after compaction resets context).
1140
+ */
1141
+ public clearLoadedRuleIds(): void {
1142
+ this.loadedRuleIds.clear();
1143
+ }
1144
+
1145
+ /**
1146
+ * Rebuild loadedRuleIds from persisted messages (session restore / post-compaction).
1147
+ * Scans for <!-- rule: {ruleId} --> markers in user meta message content.
1148
+ */
1149
+ public rebuildLoadedRuleIds(): void {
1150
+ for (const message of this.messages) {
1151
+ if (message.role !== "user" || !message.isMeta) continue;
1152
+ for (const block of message.blocks) {
1153
+ if (block.type === "text") {
1154
+ const content = (block as { content: string }).content;
1155
+ const match = content.match(/<!-- rule: (.+?) -->/);
1156
+ if (match) {
1157
+ this.loadedRuleIds.add(match[1]);
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1146
1163
  }
@@ -3,7 +3,6 @@ import { ToolPlugin } from "../tools/types.js";
3
3
  import { isGitRepository } from "../utils/gitUtils.js";
4
4
  import { getCurrentWorktreeSession } from "../utils/worktreeSession.js";
5
5
  import { buildAutoMemoryPrompt } from "./autoMemory.js";
6
- import { PermissionMode } from "../types/permissions.js";
7
6
  import {
8
7
  EXPLORE_SUBAGENT_TYPE,
9
8
  PLAN_SUBAGENT_TYPE,
@@ -96,12 +95,12 @@ export function buildPlanModePrompt(
96
95
  : `No plan file exists yet. You should create your plan at ${planFilePath} using the ${WRITE_TOOL_NAME} tool if you need to.`;
97
96
 
98
97
  if (isSubagent) {
99
- return `Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should:
98
+ return `Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make tasks). Instead, you should:
100
99
 
101
100
  ## Plan File Info:
102
101
  ${planFileInfo}
103
102
  You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
104
- Answer the user's query comprehensively, using the ${ASK_USER_QUESTION_TOOL_NAME} tool if you need to ask the user clarifying questions. If you do use the ${ASK_USER_QUESTION_TOOL_NAME}, make sure to ask all clarifying questions you need to fully understand the user's intent before proceeding.`;
103
+ Answer the user's query comprehensively, using the ${ASK_USER_QUESTION_TOOL_NAME} tool if you need to ask the user clarifying questions. If you use the ${ASK_USER_QUESTION_TOOL_NAME}, make sure to ask all clarifying questions you need to fully understand the user's intent before proceeding.`;
105
104
  }
106
105
 
107
106
  return `Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
@@ -154,14 +153,14 @@ In the agent prompt:
154
153
  ### Phase 3: Review
155
154
  Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
156
155
  1. Read the critical files identified by agents to deepen your understanding
157
- 2. Ensure that the plans align with the user's original request
156
+ 2. Ensure the plans align with the user's original request
158
157
  3. Use ${ASK_USER_QUESTION_TOOL_NAME} to clarify any remaining questions with the user
159
158
 
160
159
  ### Phase 4: Final Plan
161
160
  Goal: Write your final plan to the plan file (the only file you can edit).
162
161
  - Begin with a **Context** section: explain why this change is being made — the problem or need it addresses, what prompted it, and the intended outcome
163
162
  - Include only your recommended approach, not all alternatives
164
- - Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
163
+ - Ensure the the plan file is concise enough to scan quickly, but detailed enough to execute effectively
165
164
  - Include the paths of critical files to be modified
166
165
  - Reference existing functions and utilities you found that should be reused, with their file paths
167
166
  - Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
@@ -177,6 +176,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
177
176
 
178
177
  export const DEFAULT_SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
179
178
 
179
+ /**
180
+ * A block of the system prompt with cacheability metadata.
181
+ * Static blocks (cacheable: true) get cache_control markers for Claude models.
182
+ * Dynamic blocks (cacheable: false) change per-turn and must not invalidate the cache.
183
+ */
184
+ export interface SystemPromptBlock {
185
+ text: string;
186
+ cacheable: boolean;
187
+ }
188
+
180
189
  export const COMPACT_MESSAGES_SYSTEM_PROMPT = `You are continuing work on a software engineering task. Write a detailed continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary.
181
190
 
182
191
  First, write your analysis in <analysis> tags as a thinking scratchpad:
@@ -237,33 +246,33 @@ export function buildSystemPrompt(
237
246
  options: {
238
247
  workdir?: string;
239
248
  originalWorkdir?: string;
240
- memory?: string;
241
249
  language?: string;
242
250
  isSubagent?: boolean;
243
251
  autoMemory?: {
244
252
  directory: string;
245
253
  content: string;
246
254
  };
247
- permissionMode?: PermissionMode;
248
255
  } = {},
249
- ): string {
250
- let prompt = basePrompt || DEFAULT_SYSTEM_PROMPT;
251
- prompt += `\n\n${DOING_TASKS_PROMPT}`;
252
- prompt += `\n\n${EXECUTING_ACTIONS_PROMPT}`;
256
+ ): SystemPromptBlock[] {
257
+ // --- Static block (cacheable) ---
258
+ let staticText = basePrompt || DEFAULT_SYSTEM_PROMPT;
259
+ staticText += `\n\n${DOING_TASKS_PROMPT}`;
260
+ staticText += `\n\n${EXECUTING_ACTIONS_PROMPT}`;
253
261
 
254
262
  if (tools.length > 0) {
255
- prompt += `\n\n${TOOL_POLICY}`;
263
+ staticText += `\n\n${TOOL_POLICY}`;
256
264
  }
257
265
 
258
- prompt += `\n\n${OUTPUT_EFFICIENCY_PROMPT}`;
259
- prompt += `\n\n${TONE_AND_STYLE_PROMPT}`;
266
+ staticText += `\n\n${OUTPUT_EFFICIENCY_PROMPT}`;
267
+ staticText += `\n\n${TONE_AND_STYLE_PROMPT}`;
260
268
 
261
- if (options.permissionMode === "dontAsk") {
262
- prompt += `\n\n# Permission Mode\nThe user has selected the 'dontAsk' permission mode. In this mode, any restricted tool call that does not match a pre-approved rule in 'permissions.allow' or 'temporaryRules' will be automatically denied without prompting the user. You will receive a 'Permission denied' error for such calls. This is intended to prevent interruptions for untrusted tools while allowing pre-approved ones to run seamlessly.`;
263
- }
269
+ const blocks: SystemPromptBlock[] = [{ text: staticText, cacheable: true }];
270
+
271
+ // --- Dynamic block (not cacheable) ---
272
+ let dynamicText = "";
264
273
 
265
274
  if (options.language) {
266
- 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.`;
275
+ dynamicText += `\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.`;
267
276
  }
268
277
 
269
278
  if (options.workdir) {
@@ -280,7 +289,7 @@ export function buildSystemPrompt(
280
289
 
281
290
  const worktreeSession = getCurrentWorktreeSession();
282
291
 
283
- prompt += `
292
+ dynamicText += `
284
293
 
285
294
  Here is useful information about the environment you are running in:
286
295
  <env>
@@ -295,17 +304,17 @@ Today's date: ${today}
295
304
  }
296
305
 
297
306
  if (options.autoMemory) {
298
- prompt += `\n\n${buildAutoMemoryPrompt(options.autoMemory.directory)}`;
307
+ dynamicText += `\n\n${buildAutoMemoryPrompt(options.autoMemory.directory)}`;
299
308
  if (options.autoMemory.content.trim()) {
300
- prompt += `\n\n## MEMORY.md\n\n${options.autoMemory.content}`;
309
+ dynamicText += `\n\n## MEMORY.md\n\n${options.autoMemory.content}`;
301
310
  }
302
311
  }
303
312
 
304
- if (options.memory && options.memory.trim()) {
305
- prompt += `\n## Memory Context\n\nThe following is important context and memory from previous interactions:\n\n${options.memory}`;
313
+ if (dynamicText.trim()) {
314
+ blocks.push({ text: dynamicText, cacheable: false });
306
315
  }
307
316
 
308
- return prompt;
317
+ return blocks;
309
318
  }
310
319
 
311
320
  export function enhanceSystemPromptWithEnvDetails(
@@ -16,6 +16,7 @@ import {
16
16
  supportsPromptCaching,
17
17
  extendUsageWithCacheMetrics,
18
18
  type ClaudeUsage,
19
+ type ClaudeChatCompletionContentPartText,
19
20
  } from "../utils/cacheControlUtils.js";
20
21
 
21
22
  import * as os from "os";
@@ -26,6 +27,7 @@ import {
26
27
  COMPACT_MESSAGES_SYSTEM_PROMPT,
27
28
  WEB_CONTENT_SYSTEM_PROMPT,
28
29
  BTW_SYSTEM_PROMPT,
30
+ type SystemPromptBlock,
29
31
  } from "../prompts/index.js";
30
32
  import { GOAL_EVALUATION_SYSTEM_PROMPT } from "../constants/goalPrompts.js";
31
33
 
@@ -159,7 +161,7 @@ export interface CallAgentOptions {
159
161
  workdir: string; // Current working directory
160
162
  tools?: ChatCompletionFunctionTool[]; // Tool configuration
161
163
  model?: string; // Custom model
162
- systemPrompt?: string; // Custom system prompt
164
+ systemPrompt?: string | SystemPromptBlock[]; // Custom system prompt (string or structured blocks)
163
165
  maxTokens?: number; // Maximum output tokens
164
166
  toolChoice?:
165
167
  | "auto"
@@ -262,22 +264,47 @@ export async function callAgent(
262
264
  fetch: gatewayConfig.fetch,
263
265
  });
264
266
 
265
- // Build system prompt content
266
- const systemContent = systemPrompt || "";
267
+ // Determine model early (needed for system prompt construction)
268
+ const currentModel = model || modelConfig.model;
269
+ const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens;
267
270
 
268
- // Add system prompt
269
- const systemMessage: ChatCompletionMessageParam = {
270
- role: "system",
271
- content: systemContent,
272
- };
271
+ // Build system message content
272
+ let systemMessage: ChatCompletionMessageParam;
273
+ if (Array.isArray(systemPrompt)) {
274
+ if (supportsPromptCaching(currentModel)) {
275
+ // For Claude models, map blocks to content parts with cache_control on cacheable blocks
276
+ const contentParts: ClaudeChatCompletionContentPartText[] =
277
+ systemPrompt.map((block) => {
278
+ const part: ClaudeChatCompletionContentPartText = {
279
+ type: "text",
280
+ text: block.text,
281
+ };
282
+ if (block.cacheable) {
283
+ part.cache_control = { type: "ephemeral" };
284
+ }
285
+ return part;
286
+ });
287
+ systemMessage = {
288
+ role: "system",
289
+ content: contentParts,
290
+ } as ChatCompletionMessageParam;
291
+ } else {
292
+ // For non-Claude models, join blocks into a single string
293
+ systemMessage = {
294
+ role: "system",
295
+ content: systemPrompt.map((b) => b.text).join("\n\n"),
296
+ };
297
+ }
298
+ } else {
299
+ systemMessage = {
300
+ role: "system",
301
+ content: systemPrompt || "",
302
+ };
303
+ }
273
304
 
274
305
  // ChatCompletionMessageParam[] is already in OpenAI format, add system prompt to the beginning
275
306
  openaiMessages = [systemMessage, ...messages];
276
307
 
277
- // Apply cache control for Claude models
278
- const currentModel = model || modelConfig.model;
279
- const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens;
280
-
281
308
  processedTools = tools;
282
309
 
283
310
  if (supportsPromptCaching(currentModel)) {
@@ -8,24 +8,26 @@ import { getGitCommonDir } from "../utils/gitUtils.js";
8
8
  import { pathEncoder } from "../utils/pathEncoder.js";
9
9
 
10
10
  export class MemoryService {
11
- private _cachedProjectMemory: string = "";
12
- private _cachedUserMemory: string = "";
11
+ private _cachedProjectMemory: string | null = null;
12
+ private _cachedUserMemory: string | null = null;
13
13
  private _cachedCombinedMemory: string | null = null;
14
+ private _cachedAutoMemoryContent: string | null = null;
14
15
 
15
16
  constructor(private container: Container) {}
16
17
 
17
18
  public get cachedProjectMemory(): string {
18
- return this._cachedProjectMemory;
19
+ return this._cachedProjectMemory ?? "";
19
20
  }
20
21
 
21
22
  public get cachedUserMemory(): string {
22
- return this._cachedUserMemory;
23
+ return this._cachedUserMemory ?? "";
23
24
  }
24
25
 
25
26
  public clearCache(): void {
26
- this._cachedProjectMemory = "";
27
- this._cachedUserMemory = "";
27
+ this._cachedProjectMemory = null;
28
+ this._cachedUserMemory = null;
28
29
  this._cachedCombinedMemory = null;
30
+ this._cachedAutoMemoryContent = null;
29
31
  }
30
32
 
31
33
  /**
@@ -76,15 +78,20 @@ export class MemoryService {
76
78
  * Get the first 200 lines of MEMORY.md.
77
79
  */
78
80
  async getAutoMemoryContent(workdir: string): Promise<string> {
81
+ if (this._cachedAutoMemoryContent !== null) {
82
+ return this._cachedAutoMemoryContent;
83
+ }
79
84
  const memoryDir = this.getAutoMemoryDirectory(workdir);
80
85
  const memoryFile = path.join(memoryDir, "MEMORY.md");
81
86
 
82
87
  try {
83
88
  const content = await fs.readFile(memoryFile, "utf-8");
84
89
  const lines = content.split("\n").slice(0, 200);
85
- return lines.join("\n");
90
+ this._cachedAutoMemoryContent = lines.join("\n");
91
+ return this._cachedAutoMemoryContent;
86
92
  } catch (error) {
87
93
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
94
+ this._cachedAutoMemoryContent = "";
88
95
  return "";
89
96
  }
90
97
  logger.error("Failed to read auto-memory content:", error);
@@ -123,6 +130,9 @@ export class MemoryService {
123
130
  }
124
131
 
125
132
  async getUserMemoryContent(): Promise<string> {
133
+ if (this._cachedUserMemory !== null) {
134
+ return this._cachedUserMemory;
135
+ }
126
136
  try {
127
137
  await this.ensureUserMemoryFile();
128
138
  const content = await fs.readFile(USER_MEMORY_FILE, "utf-8");
@@ -145,13 +155,15 @@ export class MemoryService {
145
155
  contentLength: claudeContent.length,
146
156
  },
147
157
  );
148
- return claudeContent;
158
+ this._cachedUserMemory = claudeContent;
159
+ return this._cachedUserMemory;
149
160
  } catch {
150
161
  // CLAUDE.md doesn't exist or can't be read, return the default template
151
162
  }
152
163
  }
153
164
 
154
- return content;
165
+ this._cachedUserMemory = content;
166
+ return this._cachedUserMemory;
155
167
  } catch (error) {
156
168
  logger.error("Failed to read user memory:", error);
157
169
  return "";
@@ -159,6 +171,9 @@ export class MemoryService {
159
171
  }
160
172
 
161
173
  async readMemoryFile(workdir: string): Promise<string> {
174
+ if (this._cachedProjectMemory !== null) {
175
+ return this._cachedProjectMemory;
176
+ }
162
177
  const memoryFilePath = path.join(workdir, "AGENTS.md");
163
178
  try {
164
179
  const content = await fs.readFile(memoryFilePath, "utf-8");
@@ -166,7 +181,8 @@ export class MemoryService {
166
181
  memoryFilePath,
167
182
  contentLength: content.length,
168
183
  });
169
- return content;
184
+ this._cachedProjectMemory = content;
185
+ return this._cachedProjectMemory;
170
186
  } catch (error) {
171
187
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
172
188
  // Fallback to CLAUDE.md
@@ -177,12 +193,14 @@ export class MemoryService {
177
193
  memoryFilePath: claudeMemoryPath,
178
194
  contentLength: content.length,
179
195
  });
180
- return content;
196
+ this._cachedProjectMemory = content;
197
+ return this._cachedProjectMemory;
181
198
  } catch (claudeError) {
182
199
  if ((claudeError as NodeJS.ErrnoException).code === "ENOENT") {
183
200
  logger.debug("Neither AGENTS.md nor CLAUDE.md found", {
184
201
  memoryFilePath,
185
202
  });
203
+ this._cachedProjectMemory = "";
186
204
  return "";
187
205
  }
188
206
  logger.error("Failed to read CLAUDE.md fallback", {
@@ -201,14 +219,14 @@ export class MemoryService {
201
219
  if (this._cachedCombinedMemory !== null) {
202
220
  return this._cachedCombinedMemory;
203
221
  }
204
- this._cachedProjectMemory = await this.readMemoryFile(workdir);
205
- this._cachedUserMemory = await this.getUserMemoryContent();
222
+ const projectMemory = await this.readMemoryFile(workdir);
223
+ const userMemory = await this.getUserMemoryContent();
206
224
 
207
225
  let combined = "";
208
- if (this._cachedProjectMemory.trim()) combined += this._cachedProjectMemory;
209
- if (this._cachedUserMemory.trim()) {
226
+ if (projectMemory.trim()) combined += projectMemory;
227
+ if (userMemory.trim()) {
210
228
  if (combined) combined += "\n\n";
211
- combined += this._cachedUserMemory;
229
+ combined += userMemory;
212
230
  }
213
231
  this._cachedCombinedMemory = combined;
214
232
  return combined;
@@ -266,10 +266,32 @@ When using the Agent tool, you must specify a subagent_type parameter to select
266
266
  });
267
267
  } catch (error) {
268
268
  if (!isBackgrounded) {
269
+ // Extract content from the subagent's last assistant message.
270
+ // aiManager.sendAIMessage() catch block already added an error
271
+ // block via addErrorBlock(), so the error info is in the message
272
+ // history — just return it directly.
273
+ let content = "";
274
+ try {
275
+ const messages = instance.messageManager.getMessages();
276
+ const lastAssistant = messages
277
+ .filter((m) => m.role === "assistant")
278
+ .pop();
279
+ if (lastAssistant) {
280
+ content = lastAssistant.blocks
281
+ .filter((b) => b.type === "text" || b.type === "error")
282
+ .map((b) => (b as { content: string }).content)
283
+ .join("\n")
284
+ .trim();
285
+ }
286
+ } catch {
287
+ // Ignore errors when extracting messages
288
+ }
289
+
290
+ const errorMsg = `Agent delegation failed: ${error instanceof Error ? error.message : String(error)}`;
269
291
  resolve({
270
292
  success: false,
271
- content: "",
272
- error: `Agent delegation failed: ${error instanceof Error ? error.message : String(error)}`,
293
+ content: content || errorMsg,
294
+ error: errorMsg,
273
295
  shortResult: "Delegation error",
274
296
  });
275
297
  }