wave-agent-sdk 1.0.0 → 1.0.1

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 (41) hide show
  1. package/dist/agent.d.ts +9 -20
  2. package/dist/agent.js +23 -97
  3. package/dist/managers/aiManager.d.ts +63 -8
  4. package/dist/managers/aiManager.js +270 -80
  5. package/dist/managers/messageManager.d.ts +9 -5
  6. package/dist/managers/messageManager.js +36 -12
  7. package/dist/managers/subagentManager.d.ts +6 -0
  8. package/dist/managers/subagentManager.js +33 -22
  9. package/dist/prompts/index.d.ts +0 -1
  10. package/dist/prompts/index.js +0 -4
  11. package/dist/services/aiService.d.ts +1 -34
  12. package/dist/services/aiService.js +18 -130
  13. package/dist/services/autoMemoryService.d.ts +27 -2
  14. package/dist/services/autoMemoryService.js +124 -36
  15. package/dist/services/configurationService.js +14 -1
  16. package/dist/services/session.d.ts +13 -0
  17. package/dist/services/session.js +64 -0
  18. package/dist/types/agent.d.ts +0 -2
  19. package/dist/types/config.d.ts +7 -0
  20. package/dist/types/core.d.ts +1 -1
  21. package/dist/utils/containerSetup.js +12 -3
  22. package/package.json +1 -1
  23. package/src/agent.ts +36 -110
  24. package/src/managers/aiManager.ts +366 -105
  25. package/src/managers/messageManager.ts +51 -23
  26. package/src/managers/subagentManager.ts +36 -25
  27. package/src/prompts/index.ts +0 -4
  28. package/src/services/aiService.ts +25 -203
  29. package/src/services/autoMemoryService.ts +145 -39
  30. package/src/services/configurationService.ts +16 -1
  31. package/src/services/session.ts +68 -0
  32. package/src/types/agent.ts +0 -6
  33. package/src/types/config.ts +7 -0
  34. package/src/types/core.ts +1 -1
  35. package/src/utils/containerSetup.ts +12 -4
  36. package/dist/constants/goalPrompts.d.ts +0 -1
  37. package/dist/constants/goalPrompts.js +0 -10
  38. package/dist/managers/goalManager.d.ts +0 -42
  39. package/dist/managers/goalManager.js +0 -177
  40. package/src/constants/goalPrompts.ts +0 -10
  41. package/src/managers/goalManager.ts +0 -232
@@ -2,14 +2,32 @@ import * as path from "node:path";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { Container } from "../utils/container.js";
4
4
  import { MessageManager } from "../managers/messageManager.js";
5
- import { ForkedAgentManager } from "../managers/forkedAgentManager.js";
5
+ import { AIManager } from "../managers/aiManager.js";
6
6
  import { MemoryService } from "./memory.js";
7
7
  import { ConfigurationService } from "./configurationService.js";
8
8
  import { logger } from "../utils/globalLogger.js";
9
9
  import { isPathInside } from "../utils/pathSafety.js";
10
10
  import { buildAutoMemoryExtractionPrompt } from "../prompts/autoMemoryExtraction.js";
11
+ import {
12
+ READ_ONLY_COMMANDS,
13
+ splitBashCommand,
14
+ hasWriteRedirections,
15
+ hasCommandSubstitution,
16
+ hasProcessSubstitution,
17
+ hasSedInPlace,
18
+ isDangerousFind,
19
+ } from "../utils/bashParser.js";
11
20
  import type { Message } from "../types/index.js";
12
21
 
22
+ /**
23
+ * Message fed back to the extraction fork when the model requests a tool
24
+ * outside the allowed set (Bash rm, MCP tools, Agent, out-of-dir writes...).
25
+ */
26
+ const DENIED_TOOL_MESSAGE =
27
+ "This tool call was denied during auto-memory extraction. Available tools: " +
28
+ "Read, Grep, Glob, read-only Bash commands, and Write/Edit inside the " +
29
+ "memory directory only.";
30
+
13
31
  /**
14
32
  * Service responsible for managing the auto-memory background agent lifecycle.
15
33
  * Extracts and updates persistent project-level memory from conversation history.
@@ -17,6 +35,8 @@ import type { Message } from "../types/index.js";
17
35
  export class AutoMemoryService {
18
36
  private lastMemoryMessageId: string | null = null;
19
37
  private turnsSinceLastExtraction: number = 0;
38
+ private extractionInProgress: boolean = false;
39
+ private pendingExtraction: Promise<void> | null = null;
20
40
 
21
41
  constructor(private container: Container) {}
22
42
 
@@ -24,8 +44,8 @@ export class AutoMemoryService {
24
44
  return this.container.get<MessageManager>("MessageManager")!;
25
45
  }
26
46
 
27
- private get forkedAgentManager(): ForkedAgentManager {
28
- return this.container.get<ForkedAgentManager>("ForkedAgentManager")!;
47
+ private get aiManager(): AIManager {
48
+ return this.container.get<AIManager>("AIManager")!;
29
49
  }
30
50
 
31
51
  private get memoryService(): MemoryService {
@@ -71,7 +91,14 @@ export class AutoMemoryService {
71
91
  (m) =>
72
92
  m.role === "assistant" &&
73
93
  m.blocks.some((b) => {
74
- if (b.type === "tool" && (b.name === "Write" || b.name === "Edit")) {
94
+ if (
95
+ b.type === "tool" &&
96
+ (b.name === "Write" || b.name === "Edit") &&
97
+ // Only a successful manual write counts as a manual update. A
98
+ // denied/failed write didn't touch memory, so the extraction fork
99
+ // must still run or the information is lost.
100
+ b.success !== false
101
+ ) {
75
102
  try {
76
103
  const params = b.parameters ? JSON.parse(b.parameters) : null;
77
104
  const filePath = params?.file_path || params?.path;
@@ -98,22 +125,61 @@ export class AutoMemoryService {
98
125
  return;
99
126
  }
100
127
 
101
- // 3. Trigger background extraction using a forked subagent
102
- try {
103
- await this.runExtraction(workdir, messages);
104
- this.lastMemoryMessageId = messages[messages.length - 1].id || null;
105
- this.turnsSinceLastExtraction = 0;
106
- } catch (error) {
107
- logger.error("Auto-memory extraction failed to trigger:", error);
128
+ // 3. Concurrency guard: if an extraction is already in flight, skip this
129
+ // turn. turnsSinceLastExtraction is intentionally NOT reset so the next
130
+ // eligible turn retriggers the extraction.
131
+ if (this.extractionInProgress) {
132
+ logger.debug(
133
+ "Skipping auto-memory extraction: another extraction is still in progress.",
134
+ );
135
+ return;
108
136
  }
137
+
138
+ // 4. Trigger the perfect-fork extraction fire-and-forget. The message
139
+ // snapshot and new-message count are computed now; lastMemoryMessageId
140
+ // advances at trigger time so the next extraction starts from a later
141
+ // window even while this one runs.
142
+ const lastExtractedIndex = this.lastMemoryMessageId
143
+ ? messages.findIndex((m) => m.id === this.lastMemoryMessageId)
144
+ : -1;
145
+ const newMessageCount =
146
+ lastExtractedIndex === -1
147
+ ? messages.length
148
+ : messages.length - 1 - lastExtractedIndex;
149
+
150
+ this.turnsSinceLastExtraction = 0;
151
+ this.lastMemoryMessageId = messages[messages.length - 1].id || null;
152
+ this.extractionInProgress = true;
153
+ const extraction = this.runExtraction(workdir, messages, newMessageCount)
154
+ .catch((error) => {
155
+ logger.error("Auto-memory extraction failed:", error);
156
+ })
157
+ .finally(() => {
158
+ this.extractionInProgress = false;
159
+ this.pendingExtraction = null;
160
+ });
161
+ this.pendingExtraction = extraction;
162
+ }
163
+
164
+ /**
165
+ * Wait for an in-flight extraction to settle. Called from Agent.dispose so
166
+ * the process doesn't exit while the extraction fork is mid-flight.
167
+ */
168
+ async drain(): Promise<void> {
169
+ await this.pendingExtraction;
109
170
  }
110
171
 
111
172
  /**
112
- * Initialize and execute the background extraction subagent.
173
+ * Initialize and execute the extraction in a perfect fork: same system
174
+ * prompt, tools, model, and message prefix as the main conversation, so the
175
+ * prompt cache is reused. A tool gate confines the fork to read-only
176
+ * inspection and memory-directory writes. Runs in-process; callers treat it
177
+ * as fire-and-forget.
113
178
  */
114
179
  private async runExtraction(
115
180
  workdir: string,
116
181
  messages: Message[],
182
+ newMessageCount: number,
117
183
  ): Promise<void> {
118
184
  const memoryDir = this.memoryService.getAutoMemoryDirectory(workdir);
119
185
 
@@ -132,43 +198,83 @@ export class AutoMemoryService {
132
198
  // Ignore if directory doesn't exist yet
133
199
  }
134
200
 
135
- // Calculate how many new messages to analyze
136
- let newMessageCount = messages.length;
137
- if (this.lastMemoryMessageId) {
138
- const lastIndex = messages.findIndex(
139
- (m) => m.id === this.lastMemoryMessageId,
140
- );
141
- if (lastIndex !== -1) {
142
- newMessageCount = messages.length - 1 - lastIndex;
143
- }
144
- }
145
-
146
201
  const prompt = buildAutoMemoryExtractionPrompt(
147
202
  newMessageCount,
148
203
  existingMemoriesManifest,
149
204
  );
150
205
 
151
- // Execute the forked agent in background (fire-and-forget, decoupled from BackgroundTaskManager)
152
- await this.forkedAgentManager.forkAndExecute(
153
- "general-purpose",
206
+ await this.aiManager.runAutoMemoryFork(
154
207
  messages,
208
+ `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`,
155
209
  {
156
- description: "Auto-memory extraction background agent",
157
- allowedTools: [
158
- "Read",
159
- "Glob",
160
- "Grep",
161
- `Write(${memoryDir}/**/*)`,
162
- `Edit(${memoryDir}/**/*)`,
163
- `Bash(rm ${memoryDir}/**/*)`,
164
- ],
165
- model: "fastModel", // Use fast model for background tasks to reduce latency and cost
166
- permissionModeOverride: "dontAsk", // Auto-deny out-of-scope writes without prompting user
167
210
  maxTurns: 5, // Limit turns to prevent verification rabbit-holes
211
+ canUseTool: (name, args) =>
212
+ this.isAllowedForkTool(name, args, memoryDir, workdir),
213
+ deniedToolMessage: DENIED_TOOL_MESSAGE,
168
214
  },
169
- `${prompt}\n\nThe memory directory for this project is: ${memoryDir}`,
170
215
  );
171
216
 
172
- logger.debug("Auto-memory extraction started in background.");
217
+ logger.debug("Auto-memory extraction completed.");
218
+ }
219
+
220
+ /**
221
+ * Tool gate for the extraction fork: Read/Grep/Glob are always allowed;
222
+ * Write/Edit only when the target path is inside the memory directory; Bash
223
+ * only for read-only commands (aligned with the permission manager's
224
+ * read-only bash classification). Everything else — Bash rm, MCP tools,
225
+ * Agent, out-of-dir writes — is denied.
226
+ */
227
+ private isAllowedForkTool(
228
+ name: string,
229
+ args: Record<string, unknown>,
230
+ memoryDir: string,
231
+ workdir: string,
232
+ ): boolean {
233
+ if (name === "Read" || name === "Grep" || name === "Glob") {
234
+ return true;
235
+ }
236
+
237
+ if (name === "Write" || name === "Edit") {
238
+ const filePath = args.file_path ?? args.path;
239
+ if (typeof filePath !== "string" || !filePath) return false;
240
+ const absolutePath = path.isAbsolute(filePath)
241
+ ? filePath
242
+ : path.resolve(workdir, filePath);
243
+ return isPathInside(absolutePath, memoryDir);
244
+ }
245
+
246
+ if (name === "Bash") {
247
+ const command = typeof args.command === "string" ? args.command : "";
248
+ return this.isReadOnlyBashCommand(command);
249
+ }
250
+
251
+ return false;
252
+ }
253
+
254
+ /**
255
+ * A bash command is read-only when every part is a READ_ONLY_COMMANDS entry
256
+ * without write redirections, command/process substitution, sed -i, or
257
+ * dangerous find flags. Mirrors PermissionManager.isAutoAllowedPart.
258
+ */
259
+ private isReadOnlyBashCommand(command: string): boolean {
260
+ if (!command.trim()) return false;
261
+ if (hasWriteRedirections(command)) return false;
262
+ if (hasCommandSubstitution(command)) return false;
263
+ if (hasProcessSubstitution(command)) return false;
264
+ if (hasSedInPlace(command)) return false;
265
+
266
+ const parts = splitBashCommand(command);
267
+ if (parts.length === 0) return false;
268
+
269
+ return parts.every((part) => {
270
+ const trimmed = part.trim();
271
+ if (!trimmed) return true;
272
+ const commandMatch = trimmed.match(/^(\w+)(\s+.*)?$/);
273
+ if (!commandMatch) return false;
274
+ const cmd = commandMatch[1];
275
+ if (!READ_ONLY_COMMANDS.includes(cmd)) return false;
276
+ if (cmd === "find" && isDangerousFind(part)) return false;
277
+ return true;
278
+ });
173
279
  }
174
280
  }
@@ -619,16 +619,31 @@ export class ConfigurationService {
619
619
  baseConfig.fastModelOptions = fastModelSource.options;
620
620
  }
621
621
 
622
+ // Resolve fast-model disable-thinking params from models[fastModel].disableThinkingOptions
623
+ const fastModelDisableThinking =
624
+ fastModelSource && fastModelSource.disableThinkingOptions
625
+ ? fastModelSource.disableThinkingOptions
626
+ : undefined;
627
+ if (fastModelDisableThinking) {
628
+ baseConfig.disableThinkingOptions = fastModelDisableThinking;
629
+ }
630
+
622
631
  // Merge model-specific settings from configuration
623
632
  const modelSpecificConfig =
624
633
  resolvedAgentModel &&
625
634
  this.currentConfiguration?.models?.[resolvedAgentModel];
626
635
 
627
636
  if (modelSpecificConfig) {
628
- return {
637
+ const resolved: ModelConfig = {
629
638
  ...baseConfig,
630
639
  ...modelSpecificConfig,
631
640
  };
641
+ // Re-apply after the spread so the agent model's own
642
+ // disableThinkingOptions cannot clobber the fast-model value.
643
+ if (fastModelDisableThinking) {
644
+ resolved.disableThinkingOptions = fastModelDisableThinking;
645
+ }
646
+ return resolved;
632
647
  }
633
648
 
634
649
  return baseConfig;
@@ -612,6 +612,74 @@ export async function cleanupEmptyProjectDirectories(): Promise<void> {
612
612
  }
613
613
  }
614
614
 
615
+ /**
616
+ * Clean up "ghost" session files that contain only meta messages
617
+ * (isMeta: true) — e.g. sessions where a SessionStart hook injected a
618
+ * system-reminder but no real user/assistant message was ever sent.
619
+ *
620
+ * Such sessions are no longer created thanks to lazy materialization in
621
+ * saveSession(); this one-time sweep removes files that predate that change
622
+ * so they stop showing up as "0 tokens / No content" entries in the resume
623
+ * list.
624
+ *
625
+ * @returns Promise that resolves to the number of files deleted
626
+ */
627
+ export async function cleanupMetaOnlySessions(): Promise<number> {
628
+ // Do not perform cleanup operations in test environment
629
+ if (process.env.NODE_ENV === "test") {
630
+ return 0;
631
+ }
632
+
633
+ let deletedCount = 0;
634
+ try {
635
+ const projectDirs = await fs.readdir(SESSION_DIR);
636
+
637
+ for (const projectDirName of projectDirs) {
638
+ const projectPath = join(SESSION_DIR, projectDirName);
639
+ try {
640
+ const stat = await fs.stat(projectPath);
641
+ if (!stat.isDirectory()) {
642
+ continue;
643
+ }
644
+
645
+ const files = await fs.readdir(projectPath);
646
+ for (const file of files) {
647
+ if (!file.endsWith(".jsonl")) {
648
+ continue;
649
+ }
650
+
651
+ const filePath = join(projectPath, file);
652
+ try {
653
+ // Fast path: a file whose last message is not meta contains a
654
+ // real message, so it can never be meta-only.
655
+ const jsonlHandler = new JsonlHandler();
656
+ const lastMessage = await jsonlHandler.getLastMessage(filePath);
657
+ if (!lastMessage?.isMeta) {
658
+ continue;
659
+ }
660
+
661
+ const messages = await jsonlHandler.read(filePath);
662
+ if (messages.length > 0 && messages.every((m) => m.isMeta)) {
663
+ await fs.unlink(filePath);
664
+ deletedCount++;
665
+ }
666
+ } catch {
667
+ // Skip corrupted or unreadable files
668
+ continue;
669
+ }
670
+ }
671
+ } catch {
672
+ // Skip directories we can't access
673
+ continue;
674
+ }
675
+ }
676
+ } catch {
677
+ // Ignore errors if base directory doesn't exist or can't be accessed
678
+ }
679
+
680
+ return deletedCount;
681
+ }
682
+
615
683
  /**
616
684
  * Check if a session exists in JSONL storage (new approach)
617
685
  *
@@ -119,10 +119,4 @@ export interface AgentCallbacks
119
119
  onCommandRunningChange?: (running: boolean) => void;
120
120
  onWorkdirChange?: (newCwd: string) => void;
121
121
  onQueuedMessagesChange?: (messages: QueuedMessage[]) => void;
122
- onGoalStateChange?: (
123
- active: boolean,
124
- condition?: string,
125
- elapsed?: string,
126
- ) => void;
127
- onGoalEvaluating?: (evaluating: boolean) => void;
128
122
  }
@@ -33,4 +33,11 @@ export interface ModelConfig {
33
33
  options?: Record<string, unknown>;
34
34
  /** Fast model generation params (resolved from models[fastModel].options) */
35
35
  fastModelOptions?: Record<string, unknown>;
36
+ /**
37
+ * Fast-model-only disable-thinking params passed through verbatim
38
+ * (e.g. `{ thinking: { type: "disabled" } }`). Applied only in fast-model
39
+ * scenarios (webFetch content processing, `model: fastModel` subagents),
40
+ * never in the agent loop. `{}` clears the default.
41
+ */
42
+ disableThinkingOptions?: Record<string, unknown>;
36
43
  }
package/src/types/core.ts CHANGED
@@ -25,7 +25,7 @@ export interface Usage {
25
25
  completion_tokens: number; // Tokens generated in completions
26
26
  total_tokens: number; // Sum of prompt + completion tokens
27
27
  model?: string; // Model used for the operation (e.g., "gpt-4", "gpt-3.5-turbo")
28
- operation_type?: "agent" | "compact" | "goal_evaluation"; // Type of operation that generated usage
28
+ operation_type?: "agent" | "compact"; // Type of operation that generated usage
29
29
 
30
30
  // Cache-related tokens (Claude top-level + OpenAI prompt_tokens_details)
31
31
  cache_read_input_tokens?: number; // Tokens read from cache (Claude) or cached_tokens (OpenAI prompt_tokens_details)
@@ -16,7 +16,6 @@ import { SlashCommandManager } from "../managers/slashCommandManager.js";
16
16
  import { PluginManager } from "../managers/pluginManager.js";
17
17
  import { BangManager } from "../managers/bangManager.js";
18
18
  import { CronManager } from "../managers/cronManager.js";
19
- import { GoalManager } from "../managers/goalManager.js";
20
19
  import { WorkflowManager } from "../managers/workflowManager.js";
21
20
  import { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
22
21
  import { ReversionManager } from "../managers/reversionManager.js";
@@ -25,6 +24,7 @@ import { ForkedAgentManager } from "../managers/forkedAgentManager.js";
25
24
  import { LiveConfigManager } from "../managers/liveConfigManager.js";
26
25
  import { ConfigurationService } from "../services/configurationService.js";
27
26
  import { ReversionService } from "../services/reversionService.js";
27
+ import { cleanupMetaOnlySessions } from "../services/session.js";
28
28
  import { MemoryService } from "../services/memory.js";
29
29
  import { AutoMemoryService } from "../services/autoMemoryService.js";
30
30
  import { USER_MEMORY_FILE } from "./constants.js";
@@ -210,6 +210,17 @@ export function setupAgentContainer(
210
210
  reversionService.cleanupOldSessions(30).catch((error) => {
211
211
  logger.error("Failed to cleanup old file history:", error);
212
212
  });
213
+ // Remove pre-existing meta-only session files (SessionStart hook context
214
+ // with no real messages) that were persisted before lazy materialization.
215
+ cleanupMetaOnlySessions()
216
+ .then((count) => {
217
+ if (count > 0) {
218
+ logger.debug(`Removed ${count} meta-only session file(s)`);
219
+ }
220
+ })
221
+ .catch((error) => {
222
+ logger.error("Failed to cleanup meta-only session files:", error);
223
+ });
213
224
  const reversionManager = new ReversionManager(container);
214
225
  container.register("ReversionManager", reversionManager);
215
226
 
@@ -352,9 +363,6 @@ export function setupAgentContainer(
352
363
  container.register("CronManager", cronManager);
353
364
  cronManager.start();
354
365
 
355
- const goalManager = new GoalManager(container);
356
- container.register("GoalManager", goalManager);
357
-
358
366
  const workflowManager = new WorkflowManager(container);
359
367
  container.register("WorkflowManager", workflowManager);
360
368
 
@@ -1 +0,0 @@
1
- export declare const GOAL_EVALUATION_SYSTEM_PROMPT = "You are a goal evaluator. Given a goal condition and a conversation, determine whether the goal has been achieved.\n\nRules:\n- Judge ONLY based on what the conversation shows \u2014 tool outputs, test results, file contents, etc.\n- Do NOT assume work is done without evidence in the conversation.\n- Be strict and conservative: when uncertain, return met: false.\n- Be concise: your reason should be 1-2 sentences.\n\nRespond with JSON only, no other text:\n{\"met\": boolean, \"reason\": \"short explanation\"}";
@@ -1,10 +0,0 @@
1
- export const GOAL_EVALUATION_SYSTEM_PROMPT = `You are a goal evaluator. Given a goal condition and a conversation, determine whether the goal has been achieved.
2
-
3
- Rules:
4
- - Judge ONLY based on what the conversation shows — tool outputs, test results, file contents, etc.
5
- - Do NOT assume work is done without evidence in the conversation.
6
- - Be strict and conservative: when uncertain, return met: false.
7
- - Be concise: your reason should be 1-2 sentences.
8
-
9
- Respond with JSON only, no other text:
10
- {"met": boolean, "reason": "short explanation"}`;
@@ -1,42 +0,0 @@
1
- import { Container } from "../utils/container.js";
2
- export interface GoalState {
3
- condition: string;
4
- startedAt: number;
5
- turnCount: number;
6
- tokenBaseline: number;
7
- lastReason?: string;
8
- consecutiveEvalFailures: number;
9
- }
10
- export declare class GoalManager {
11
- private container;
12
- private state;
13
- private onGoalStateChange?;
14
- private onGoalEvaluating?;
15
- constructor(container: Container);
16
- private get messageManager();
17
- private get aiManager();
18
- setOnGoalStateChange(callback: (active: boolean, condition?: string, elapsed?: string) => void): void;
19
- setOnGoalEvaluating(callback: (evaluating: boolean) => void): void;
20
- setGoal(condition: string): void;
21
- clearGoal(): void;
22
- getGoal(): GoalState | null;
23
- isGoalActive(): boolean;
24
- incrementTurnCount(): void;
25
- getStatusString(): string;
26
- /**
27
- * Check circuit breakers. Returns a clear reason if goal should be force-cleared, null otherwise.
28
- */
29
- checkCircuitBreakers(): string | null;
30
- /**
31
- * Evaluate whether the goal has been met using the fast model.
32
- */
33
- evaluateGoal(abortSignal?: AbortSignal): Promise<{
34
- isMet: boolean;
35
- reason: string;
36
- }>;
37
- /**
38
- * Parse the evaluation response from the fast model.
39
- */
40
- private parseEvaluationResponse;
41
- private formatElapsed;
42
- }
@@ -1,177 +0,0 @@
1
- import { evaluateGoal as aiEvaluateGoal } from "../services/aiService.js";
2
- import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
3
- import { logger } from "../utils/globalLogger.js";
4
- const MAX_GOAL_TURNS = 50;
5
- const MAX_GOAL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
6
- const MAX_CONSECUTIVE_EVAL_FAILURES = 3;
7
- const MAX_CONDITION_LENGTH = 4000;
8
- export class GoalManager {
9
- constructor(container) {
10
- this.container = container;
11
- this.state = null;
12
- }
13
- get messageManager() {
14
- return this.container.get("MessageManager");
15
- }
16
- get aiManager() {
17
- return this.container.get("AIManager");
18
- }
19
- setOnGoalStateChange(callback) {
20
- this.onGoalStateChange = callback;
21
- }
22
- setOnGoalEvaluating(callback) {
23
- this.onGoalEvaluating = callback;
24
- }
25
- setGoal(condition) {
26
- if (condition.length > MAX_CONDITION_LENGTH) {
27
- throw new Error(`Goal condition exceeds maximum length of ${MAX_CONDITION_LENGTH} characters`);
28
- }
29
- const totalTokens = this.messageManager.getLatestTotalTokens?.() ?? 0;
30
- this.state = {
31
- condition,
32
- startedAt: Date.now(),
33
- turnCount: 0,
34
- tokenBaseline: totalTokens,
35
- consecutiveEvalFailures: 0,
36
- };
37
- this.onGoalStateChange?.(true, condition, "0m");
38
- logger?.info(`[Goal] Set goal: ${condition}`);
39
- }
40
- clearGoal() {
41
- if (this.state) {
42
- logger?.info(`[Goal] Cleared goal: ${this.state.condition}`);
43
- this.state = null;
44
- this.onGoalStateChange?.(false);
45
- }
46
- }
47
- getGoal() {
48
- return this.state;
49
- }
50
- isGoalActive() {
51
- return this.state !== null;
52
- }
53
- incrementTurnCount() {
54
- if (this.state) {
55
- this.state.turnCount++;
56
- }
57
- }
58
- getStatusString() {
59
- if (!this.state)
60
- return "No active goal";
61
- const elapsed = this.formatElapsed(Date.now() - this.state.startedAt);
62
- let status = `Goal: ${this.state.condition}\nElapsed: ${elapsed}\nTurns: ${this.state.turnCount}`;
63
- if (this.state.lastReason) {
64
- status += `\nLast evaluation: ${this.state.lastReason}`;
65
- }
66
- return status;
67
- }
68
- /**
69
- * Check circuit breakers. Returns a clear reason if goal should be force-cleared, null otherwise.
70
- */
71
- checkCircuitBreakers() {
72
- if (!this.state)
73
- return null;
74
- if (this.state.turnCount >= MAX_GOAL_TURNS) {
75
- return `Goal cancelled: maximum turns (${MAX_GOAL_TURNS}) exceeded`;
76
- }
77
- if (Date.now() - this.state.startedAt >= MAX_GOAL_DURATION_MS) {
78
- return `Goal cancelled: time limit (${MAX_GOAL_DURATION_MS / 60000} minutes) exceeded`;
79
- }
80
- if (this.state.consecutiveEvalFailures >= MAX_CONSECUTIVE_EVAL_FAILURES) {
81
- return `Goal cancelled: ${MAX_CONSECUTIVE_EVAL_FAILURES} consecutive evaluation failures`;
82
- }
83
- return null;
84
- }
85
- /**
86
- * Evaluate whether the goal has been met using the fast model.
87
- */
88
- async evaluateGoal(abortSignal) {
89
- if (!this.state) {
90
- return { isMet: false, reason: "No active goal" };
91
- }
92
- try {
93
- const messages = this.messageManager.getMessages();
94
- const apiMessages = convertMessagesForAPI(messages);
95
- const gatewayConfig = this.aiManager.getGatewayConfig();
96
- const modelConfig = this.aiManager.getModelConfig();
97
- const fastModel = modelConfig.fastModel || modelConfig.model;
98
- if (!fastModel) {
99
- return {
100
- isMet: false,
101
- reason: "No model configured for goal evaluation",
102
- };
103
- }
104
- this.onGoalEvaluating?.(true);
105
- const result = await aiEvaluateGoal({
106
- gatewayConfig,
107
- modelConfig,
108
- model: fastModel,
109
- goalCondition: this.state.condition,
110
- messages: apiMessages,
111
- abortSignal,
112
- });
113
- // Track evaluation tokens separately
114
- if (result.usage) {
115
- const usage = {
116
- ...result.usage,
117
- operation_type: "goal_evaluation",
118
- model: fastModel,
119
- };
120
- this.messageManager.addUsage(usage);
121
- }
122
- // Reset failure counter on success
123
- this.state.consecutiveEvalFailures = 0;
124
- this.onGoalEvaluating?.(false);
125
- // Parse the response
126
- return this.parseEvaluationResponse(result.content);
127
- }
128
- catch (error) {
129
- this.onGoalEvaluating?.(false);
130
- this.state.consecutiveEvalFailures++;
131
- logger?.warn(`[Goal] Evaluation failed (${this.state.consecutiveEvalFailures}/${MAX_CONSECUTIVE_EVAL_FAILURES}): ${error.message}`);
132
- return {
133
- isMet: false,
134
- reason: `Evaluation failed: ${error.message}`,
135
- };
136
- }
137
- }
138
- /**
139
- * Parse the evaluation response from the fast model.
140
- */
141
- parseEvaluationResponse(content) {
142
- // Try direct JSON parse
143
- try {
144
- const parsed = JSON.parse(content);
145
- if (typeof parsed.met === "boolean") {
146
- return {
147
- isMet: parsed.met,
148
- reason: parsed.reason || "No reason provided",
149
- };
150
- }
151
- }
152
- catch {
153
- // Fall through to regex
154
- }
155
- // Try regex extraction
156
- const metMatch = content.match(/"met"\s*:\s*(true|false)/);
157
- const reasonMatch = content.match(/"reason"\s*:\s*"([^"]*)"/);
158
- if (metMatch) {
159
- return {
160
- isMet: metMatch[1] === "true",
161
- reason: reasonMatch?.[1] || "No reason provided",
162
- };
163
- }
164
- // Default: not met
165
- return { isMet: false, reason: "Could not parse evaluation response" };
166
- }
167
- formatElapsed(ms) {
168
- const minutes = Math.floor(ms / 60000);
169
- if (minutes < 1)
170
- return "<1m";
171
- if (minutes < 60)
172
- return `${minutes}m`;
173
- const hours = Math.floor(minutes / 60);
174
- const remainingMin = minutes % 60;
175
- return `${hours}h${remainingMin}m`;
176
- }
177
- }
@@ -1,10 +0,0 @@
1
- export const GOAL_EVALUATION_SYSTEM_PROMPT = `You are a goal evaluator. Given a goal condition and a conversation, determine whether the goal has been achieved.
2
-
3
- Rules:
4
- - Judge ONLY based on what the conversation shows — tool outputs, test results, file contents, etc.
5
- - Do NOT assume work is done without evidence in the conversation.
6
- - Be strict and conservative: when uncertain, return met: false.
7
- - Be concise: your reason should be 1-2 sentences.
8
-
9
- Respond with JSON only, no other text:
10
- {"met": boolean, "reason": "short explanation"}`;