wave-agent-sdk 1.0.4 → 1.0.6
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.
- package/dist/agent.d.ts +23 -1
- package/dist/agent.js +34 -4
- package/dist/managers/aiManager.d.ts +36 -0
- package/dist/managers/aiManager.js +240 -8
- package/dist/managers/messageManager.d.ts +1 -3
- package/dist/managers/messageManager.js +19 -9
- package/dist/managers/slashCommandManager.js +28 -0
- package/dist/managers/subagentManager.d.ts +0 -2
- package/dist/services/session.js +2 -9
- package/dist/telemetry/instrumentation.d.ts +1 -1
- package/dist/telemetry/instrumentation.js +57 -28
- package/dist/tools/bashTool.js +16 -6
- package/dist/tools/grepTool.js +11 -3
- package/dist/types/index.d.ts +3 -0
- package/dist/types/messaging.d.ts +2 -0
- package/dist/utils/containerSetup.js +0 -3
- package/dist/utils/messageOperations.d.ts +10 -1
- package/dist/utils/messageOperations.js +19 -1
- package/dist/utils/notificationXml.js +5 -0
- package/dist/utils/ripgrep.d.ts +4 -4
- package/dist/utils/ripgrep.js +4 -27
- package/package.json +20 -8
- package/bin/rg +0 -79
- package/dist/managers/forkedAgentManager.d.ts +0 -50
- package/dist/managers/forkedAgentManager.js +0 -130
- package/scripts/install_ripgrep.js +0 -111
- package/scripts/postinstall.js +0 -38
- package/src/agent.ts +0 -1271
- package/src/constants/subagents.ts +0 -4
- package/src/constants/toolLimits.ts +0 -15
- package/src/constants/tools.ts +0 -24
- package/src/core/plugin.ts +0 -237
- package/src/core/session.ts +0 -9
- package/src/index.ts +0 -38
- package/src/managers/MemoryRuleManager.ts +0 -198
- package/src/managers/aiManager.ts +0 -2429
- package/src/managers/backgroundTaskManager.ts +0 -480
- package/src/managers/bangManager.ts +0 -119
- package/src/managers/cronManager.ts +0 -401
- package/src/managers/foregroundTaskManager.ts +0 -30
- package/src/managers/forkedAgentManager.ts +0 -216
- package/src/managers/hookManager.ts +0 -1089
- package/src/managers/liveConfigManager.ts +0 -445
- package/src/managers/lspManager.ts +0 -470
- package/src/managers/mcpManager.ts +0 -1010
- package/src/managers/messageManager.ts +0 -1180
- package/src/managers/messageQueue.ts +0 -157
- package/src/managers/permissionManager.ts +0 -1140
- package/src/managers/planManager.ts +0 -112
- package/src/managers/pluginManager.ts +0 -330
- package/src/managers/pluginScopeManager.ts +0 -124
- package/src/managers/reversionManager.ts +0 -178
- package/src/managers/skillManager.ts +0 -578
- package/src/managers/slashCommandManager.ts +0 -564
- package/src/managers/subagentManager.ts +0 -915
- package/src/managers/toolManager.ts +0 -489
- package/src/managers/workflowManager.ts +0 -493
- package/src/prompts/autoMemory.ts +0 -33
- package/src/prompts/autoMemoryExtraction.ts +0 -146
- package/src/prompts/index.ts +0 -506
- package/src/prompts/planModeReminders.ts +0 -126
- package/src/services/GitService.ts +0 -131
- package/src/services/MarketplaceService.ts +0 -1052
- package/src/services/MemoryRuleService.ts +0 -71
- package/src/services/aiService.ts +0 -919
- package/src/services/authService.ts +0 -565
- package/src/services/autoMemoryService.ts +0 -280
- package/src/services/configurationService.ts +0 -1520
- package/src/services/fileWatcher.ts +0 -319
- package/src/services/hook.ts +0 -348
- package/src/services/initializationService.ts +0 -357
- package/src/services/interactionService.ts +0 -229
- package/src/services/jsonlHandler.ts +0 -295
- package/src/services/memory.ts +0 -234
- package/src/services/pluginLoader.ts +0 -252
- package/src/services/remoteSettingsService.ts +0 -366
- package/src/services/reversionService.ts +0 -122
- package/src/services/session.ts +0 -922
- package/src/services/taskManager.ts +0 -321
- package/src/telemetry/events.ts +0 -65
- package/src/telemetry/instrumentation.ts +0 -499
- package/src/telemetry/sessionTracing.ts +0 -348
- package/src/tools/agentTool.ts +0 -322
- package/src/tools/askUserQuestion.ts +0 -160
- package/src/tools/bashTool.ts +0 -621
- package/src/tools/buildTool.ts +0 -61
- package/src/tools/cronCreateTool.ts +0 -161
- package/src/tools/cronDeleteTool.ts +0 -51
- package/src/tools/cronListTool.ts +0 -47
- package/src/tools/editTool.ts +0 -313
- package/src/tools/enterPlanMode.ts +0 -124
- package/src/tools/enterWorktreeTool.ts +0 -190
- package/src/tools/exitPlanMode.ts +0 -139
- package/src/tools/exitWorktreeTool.ts +0 -236
- package/src/tools/globTool.ts +0 -167
- package/src/tools/grepTool.ts +0 -399
- package/src/tools/lspTool.ts +0 -883
- package/src/tools/readTool.ts +0 -426
- package/src/tools/skillTool.ts +0 -248
- package/src/tools/taskManagementTools.ts +0 -574
- package/src/tools/taskStopTool.ts +0 -78
- package/src/tools/types.ts +0 -137
- package/src/tools/webFetchTool.ts +0 -373
- package/src/tools/workflowTool.ts +0 -205
- package/src/tools/writeTool.ts +0 -267
- package/src/types/agent.ts +0 -128
- package/src/types/auth.ts +0 -19
- package/src/types/commands.ts +0 -30
- package/src/types/config.ts +0 -43
- package/src/types/configuration.ts +0 -168
- package/src/types/core.ts +0 -105
- package/src/types/cron.ts +0 -13
- package/src/types/environment.ts +0 -106
- package/src/types/fileSearch.ts +0 -4
- package/src/types/history.ts +0 -7
- package/src/types/hooks.ts +0 -256
- package/src/types/index.ts +0 -41
- package/src/types/lsp.ts +0 -98
- package/src/types/marketplace.ts +0 -71
- package/src/types/mcp.ts +0 -43
- package/src/types/memoryRule.ts +0 -31
- package/src/types/messaging.ts +0 -114
- package/src/types/permissions.ts +0 -80
- package/src/types/plugins.ts +0 -39
- package/src/types/processes.ts +0 -99
- package/src/types/reversion.ts +0 -29
- package/src/types/session.ts +0 -12
- package/src/types/skills.ts +0 -108
- package/src/types/tasks.ts +0 -13
- package/src/types/telemetry.ts +0 -98
- package/src/types/tools.ts +0 -44
- package/src/types/workflow.ts +0 -6
- package/src/utils/abortUtils.ts +0 -118
- package/src/utils/atomicWrite.ts +0 -61
- package/src/utils/bashParser.ts +0 -899
- package/src/utils/cacheControlUtils.ts +0 -426
- package/src/utils/commandArgumentParser.ts +0 -161
- package/src/utils/commandPathResolver.ts +0 -88
- package/src/utils/configPaths.ts +0 -220
- package/src/utils/configValidator.ts +0 -100
- package/src/utils/constants.ts +0 -39
- package/src/utils/container.ts +0 -92
- package/src/utils/containerSetup.ts +0 -371
- package/src/utils/convertMessagesForAPI.ts +0 -326
- package/src/utils/cronTasks.ts +0 -128
- package/src/utils/cronTasksLock.ts +0 -182
- package/src/utils/cronToHuman.ts +0 -99
- package/src/utils/customCommands.ts +0 -95
- package/src/utils/editUtils.ts +0 -20
- package/src/utils/fileFormat.ts +0 -40
- package/src/utils/fileSearch.ts +0 -151
- package/src/utils/fileUtils.ts +0 -223
- package/src/utils/gitUtils.ts +0 -315
- package/src/utils/globalLogger.ts +0 -128
- package/src/utils/groupMessagesByApiRound.ts +0 -121
- package/src/utils/hookMatcher.ts +0 -176
- package/src/utils/markdownParser.ts +0 -317
- package/src/utils/mcpUtils.ts +0 -138
- package/src/utils/messageOperations.ts +0 -641
- package/src/utils/modelCapabilities.ts +0 -30
- package/src/utils/nameGenerator.ts +0 -99
- package/src/utils/notificationXml.ts +0 -57
- package/src/utils/openaiClient.ts +0 -251
- package/src/utils/parseCronExpression.ts +0 -78
- package/src/utils/path.ts +0 -72
- package/src/utils/pathEncoder.ts +0 -400
- package/src/utils/pathSafety.ts +0 -40
- package/src/utils/promptHistory.ts +0 -168
- package/src/utils/ripgrep.ts +0 -35
- package/src/utils/shellResolver.ts +0 -190
- package/src/utils/skillParser.ts +0 -246
- package/src/utils/stringUtils.ts +0 -156
- package/src/utils/subagentParser.ts +0 -279
- package/src/utils/taskReminder.ts +0 -96
- package/src/utils/tokenCalculation.ts +0 -43
- package/src/utils/tokenEstimate.ts +0 -34
- package/src/utils/toolResultStorage.ts +0 -117
- package/src/utils/worktreeSession.ts +0 -26
- package/src/utils/worktreeUtils.ts +0 -786
- package/src/workflow/budgetTracker.ts +0 -34
- package/src/workflow/concurrencyLimiter.ts +0 -47
- package/src/workflow/journal.ts +0 -95
- package/src/workflow/progressReporter.ts +0 -141
- package/src/workflow/runState.ts +0 -65
- package/src/workflow/scriptRuntime.ts +0 -274
- package/src/workflow/structuredOutput.ts +0 -123
- package/src/workflow/types.ts +0 -101
- package/src/workflow/workflowApis.ts +0 -410
- package/vendor/ripgrep/linux-aarch64/rg +0 -0
- package/vendor/ripgrep/linux-x86_64/rg +0 -0
- package/vendor/ripgrep/macos-aarch64/rg +0 -0
- package/vendor/ripgrep/macos-x86_64/rg +0 -0
- package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
- package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
|
@@ -1,2429 +0,0 @@
|
|
|
1
|
-
import { type CallAgentOptions } from "../services/aiService.js";
|
|
2
|
-
import * as aiService from "../services/aiService.js";
|
|
3
|
-
import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
|
|
4
|
-
import { supportsVision } from "../utils/modelCapabilities.js";
|
|
5
|
-
import { parseTaskNotificationXml } from "../utils/notificationXml.js";
|
|
6
|
-
import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
|
|
7
|
-
import { estimateTokens } from "../utils/tokenEstimate.js";
|
|
8
|
-
import {
|
|
9
|
-
getTaskReminderTurnCounts,
|
|
10
|
-
maybeInjectTaskReminder,
|
|
11
|
-
TASK_REMINDER_CONFIG,
|
|
12
|
-
} from "../utils/taskReminder.js";
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
14
|
-
import type {
|
|
15
|
-
GatewayConfig,
|
|
16
|
-
ModelConfig,
|
|
17
|
-
Usage,
|
|
18
|
-
PermissionMode,
|
|
19
|
-
Message,
|
|
20
|
-
} from "../types/index.js";
|
|
21
|
-
import type { ToolManager } from "./toolManager.js";
|
|
22
|
-
import type { ToolContext, ToolResult } from "../tools/types.js";
|
|
23
|
-
import type { MessageManager } from "./messageManager.js";
|
|
24
|
-
import type { BackgroundTaskManager } from "./backgroundTaskManager.js";
|
|
25
|
-
import {
|
|
26
|
-
ChatCompletionMessageFunctionToolCall,
|
|
27
|
-
type ChatCompletionMessageParam,
|
|
28
|
-
} from "openai/resources.js";
|
|
29
|
-
|
|
30
|
-
import type { HookManager } from "./hookManager.js";
|
|
31
|
-
import type { ExtendedHookExecutionContext } from "../types/hooks.js";
|
|
32
|
-
import type { BackgroundTaskInfo, SessionCronInfo } from "../types/hooks.js";
|
|
33
|
-
import type { PermissionManager } from "./permissionManager.js";
|
|
34
|
-
import type { SubagentManager } from "./subagentManager.js";
|
|
35
|
-
import type { CronManager } from "./cronManager.js";
|
|
36
|
-
import type { SkillManager } from "./skillManager.js";
|
|
37
|
-
import {
|
|
38
|
-
buildSystemPrompt,
|
|
39
|
-
formatCompactSummary,
|
|
40
|
-
getCompactPrompt,
|
|
41
|
-
} from "../prompts/index.js";
|
|
42
|
-
import {
|
|
43
|
-
buildPlanModeReminder,
|
|
44
|
-
buildPlanModeReEntryReminder,
|
|
45
|
-
buildExitedPlanModeReminder,
|
|
46
|
-
wrapInSystemReminder,
|
|
47
|
-
} from "../prompts/planModeReminders.js";
|
|
48
|
-
import { Container } from "../utils/container.js";
|
|
49
|
-
import type { WorktreeSession } from "../utils/worktreeSession.js";
|
|
50
|
-
import { recoverTruncatedJson } from "../utils/stringUtils.js";
|
|
51
|
-
import { ConfigurationService } from "../services/configurationService.js";
|
|
52
|
-
import type { MessageQueue } from "./messageQueue.js";
|
|
53
|
-
|
|
54
|
-
import { logger } from "../utils/globalLogger.js";
|
|
55
|
-
import {
|
|
56
|
-
startInteractionSpan,
|
|
57
|
-
endInteractionSpan,
|
|
58
|
-
startLLMRequestSpan,
|
|
59
|
-
endLLMRequestSpan,
|
|
60
|
-
resetTracingState,
|
|
61
|
-
} from "../telemetry/sessionTracing.js";
|
|
62
|
-
import { logOTelEvent } from "../telemetry/events.js";
|
|
63
|
-
import type { BackgroundTask } from "../types/processes.js";
|
|
64
|
-
|
|
65
|
-
/** Result of a fork-path agent loop (compaction or auto-memory extraction). */
|
|
66
|
-
interface ForkLoopResult {
|
|
67
|
-
content?: string;
|
|
68
|
-
usage?: {
|
|
69
|
-
prompt_tokens: number;
|
|
70
|
-
completion_tokens: number;
|
|
71
|
-
total_tokens: number;
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Max turns for the compaction fork: the model should summarize, not act. */
|
|
76
|
-
const MAX_FORK_TURNS = 3;
|
|
77
|
-
|
|
78
|
-
/** Max turns for the auto-memory extraction fork. */
|
|
79
|
-
const MAX_AUTO_MEMORY_FORK_TURNS = 5;
|
|
80
|
-
|
|
81
|
-
// Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
|
|
82
|
-
// Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
|
|
83
|
-
function truncateWithMarker(text: string, max: number): string {
|
|
84
|
-
if (text.length <= max) return text;
|
|
85
|
-
return text.slice(0, max) + `… [+${text.length - max} chars]`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Map a BackgroundTask (from BackgroundTaskManager) to the Stop hook's
|
|
89
|
-
// background_tasks array element shape (aligned with Claude Code v2.1.145+).
|
|
90
|
-
// Conditional fields are populated based on task type:
|
|
91
|
-
// - shell: `command`
|
|
92
|
-
// - subagent: `agent_type` (looked up via SubagentManager by subagentId)
|
|
93
|
-
// - workflow: `name` (extracted from "Workflow: <name>" description)
|
|
94
|
-
function mapBackgroundTask(
|
|
95
|
-
task: BackgroundTask,
|
|
96
|
-
subagentManager: SubagentManager | undefined,
|
|
97
|
-
): BackgroundTaskInfo {
|
|
98
|
-
const base: BackgroundTaskInfo = {
|
|
99
|
-
id: task.id,
|
|
100
|
-
type: task.type,
|
|
101
|
-
status: task.status,
|
|
102
|
-
description: truncateWithMarker(task.description ?? "", 1000),
|
|
103
|
-
};
|
|
104
|
-
if (task.type === "shell") {
|
|
105
|
-
return { ...base, command: truncateWithMarker(task.command ?? "", 1000) };
|
|
106
|
-
}
|
|
107
|
-
if (task.type === "subagent") {
|
|
108
|
-
const instance = subagentManager
|
|
109
|
-
?.getActiveInstances()
|
|
110
|
-
.find((i) => i.subagentId === task.subagentId);
|
|
111
|
-
return { ...base, agent_type: instance?.subagentType ?? "" };
|
|
112
|
-
}
|
|
113
|
-
// workflow
|
|
114
|
-
const prefix = "Workflow: ";
|
|
115
|
-
const name = task.description?.startsWith(prefix)
|
|
116
|
-
? task.description.slice(prefix.length)
|
|
117
|
-
: (task.description ?? "");
|
|
118
|
-
return { ...base, name };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface AIManagerCallbacks {
|
|
122
|
-
onCompactionStateChange?: (isCompacting: boolean) => void;
|
|
123
|
-
onUsageAdded?: (usage: Usage) => void;
|
|
124
|
-
onCwdChange?: (newCwd: string) => void;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export interface AIManagerOptions {
|
|
128
|
-
callbacks?: AIManagerCallbacks;
|
|
129
|
-
workdir: string;
|
|
130
|
-
systemPrompt?: string;
|
|
131
|
-
subagentType?: string; // Optional subagent type for hook context
|
|
132
|
-
/**Whether to use streaming mode for AI responses - defaults to true */
|
|
133
|
-
stream?: boolean;
|
|
134
|
-
/**Optional model override (e.g. for subagents) */
|
|
135
|
-
modelOverride?: string;
|
|
136
|
-
/**Optional max turns limit to prevent runaway recursion (e.g. for auto-memory extraction) */
|
|
137
|
-
maxTurns?: number;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export class AIManager {
|
|
141
|
-
public isLoading: boolean = false;
|
|
142
|
-
private turnGeneration = 0; // Guards against concurrent sendAIMessage turns
|
|
143
|
-
private abortController: AbortController | null = null;
|
|
144
|
-
onLoadingChange?: (loading: boolean) => void;
|
|
145
|
-
private toolAbortController: AbortController | null = null;
|
|
146
|
-
private systemPrompt?: string;
|
|
147
|
-
private subagentType?: string; // Store subagent type for hook context
|
|
148
|
-
private stream: boolean; // Streaming mode flag
|
|
149
|
-
private modelOverride?: string;
|
|
150
|
-
private _onCwdChange?: (newCwd: string) => void; // Store callback for CWD changes
|
|
151
|
-
private originalWorkdir: string;
|
|
152
|
-
private consecutiveCompactionFailures: number = 0;
|
|
153
|
-
private readonly maxTurns?: number;
|
|
154
|
-
/** Tracks file mtime/hash at read time for staleness detection on Edit/Write */
|
|
155
|
-
private readFileState = new Map<
|
|
156
|
-
string,
|
|
157
|
-
{ mtime: number; hash: string; offset?: number; limit?: number }
|
|
158
|
-
>();
|
|
159
|
-
/** Override tool_choice for this AI manager (e.g. for structured output) */
|
|
160
|
-
public toolChoiceOverride?:
|
|
161
|
-
| "auto"
|
|
162
|
-
| "none"
|
|
163
|
-
| "required"
|
|
164
|
-
| { type: "function"; function: { name: string } };
|
|
165
|
-
|
|
166
|
-
// Service overrides
|
|
167
|
-
constructor(
|
|
168
|
-
private container: Container,
|
|
169
|
-
options: AIManagerOptions,
|
|
170
|
-
) {
|
|
171
|
-
this.systemPrompt = options.systemPrompt;
|
|
172
|
-
this.subagentType = options.subagentType; // Store subagent type
|
|
173
|
-
this.stream = options.stream ?? true; // Default to true if not specified
|
|
174
|
-
this.callbacks = options.callbacks ?? {};
|
|
175
|
-
this.modelOverride = options.modelOverride;
|
|
176
|
-
this._onCwdChange = options.callbacks?.onCwdChange; // Initialize onCwdChange
|
|
177
|
-
this.originalWorkdir = options.workdir;
|
|
178
|
-
this.maxTurns = options.maxTurns;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private get toolManager(): ToolManager {
|
|
182
|
-
return this.container.get<ToolManager>("ToolManager")!;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private get messageManager(): MessageManager {
|
|
186
|
-
return this.container.get<MessageManager>("MessageManager")!;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private get memoryService(): import("../services/memory.js").MemoryService {
|
|
190
|
-
return this.container.get<import("../services/memory.js").MemoryService>(
|
|
191
|
-
"MemoryService",
|
|
192
|
-
)!;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private get taskManager(): import("../services/taskManager.js").TaskManager {
|
|
196
|
-
return this.container.get<import("../services/taskManager.js").TaskManager>(
|
|
197
|
-
"TaskManager",
|
|
198
|
-
)!;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private get backgroundTaskManager(): BackgroundTaskManager | undefined {
|
|
202
|
-
return this.container.get<BackgroundTaskManager>("BackgroundTaskManager");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private get hookManager(): HookManager | undefined {
|
|
206
|
-
return this.container.get<HookManager>("HookManager");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
private get reversionManager():
|
|
210
|
-
| import("./reversionManager.js").ReversionManager
|
|
211
|
-
| undefined {
|
|
212
|
-
return this.container.get<import("./reversionManager.js").ReversionManager>(
|
|
213
|
-
"ReversionManager",
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
private get permissionManager(): PermissionManager | undefined {
|
|
218
|
-
return this.container.get<PermissionManager>("PermissionManager");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private get planManager():
|
|
222
|
-
| import("./planManager.js").PlanManager
|
|
223
|
-
| undefined {
|
|
224
|
-
return this.container.get<import("./planManager.js").PlanManager>(
|
|
225
|
-
"PlanManager",
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private get configurationService(): ConfigurationService {
|
|
230
|
-
return this.container.get<ConfigurationService>("ConfigurationService")!;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* OS env merged with the per-session env snapshot. Falls back to process.env
|
|
235
|
-
* when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
|
|
236
|
-
* unit tests with partial mocks), so hook-context env construction never
|
|
237
|
-
* throws. Use this (not the non-null `configurationService` getter) when
|
|
238
|
-
* building hook context env.
|
|
239
|
-
*/
|
|
240
|
-
private get mergedEnv(): Record<string, string> {
|
|
241
|
-
return (
|
|
242
|
-
this.container
|
|
243
|
-
.get<ConfigurationService>("ConfigurationService")
|
|
244
|
-
?.getMergedEnv?.() ?? (process.env as Record<string, string>)
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Getter methods for accessing dynamic configuration
|
|
249
|
-
public getGatewayConfig(): GatewayConfig {
|
|
250
|
-
return {
|
|
251
|
-
...this.configurationService.resolveGatewayConfig(),
|
|
252
|
-
sessionId: this.messageManager.getSessionId(),
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
public getModelConfig(): ModelConfig {
|
|
257
|
-
const permissionMode = this.container.has("PermissionMode")
|
|
258
|
-
? this.container.get<PermissionMode>("PermissionMode")
|
|
259
|
-
: undefined;
|
|
260
|
-
|
|
261
|
-
const parentModelConfig = this.configurationService.resolveModelConfig(
|
|
262
|
-
undefined,
|
|
263
|
-
undefined,
|
|
264
|
-
undefined,
|
|
265
|
-
permissionMode,
|
|
266
|
-
);
|
|
267
|
-
let modelToUse: string | undefined;
|
|
268
|
-
|
|
269
|
-
if (this.modelOverride) {
|
|
270
|
-
if (this.modelOverride === "fastModel") {
|
|
271
|
-
modelToUse = parentModelConfig.fastModel;
|
|
272
|
-
} else if (this.modelOverride !== "inherit") {
|
|
273
|
-
modelToUse = this.modelOverride;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return this.configurationService.resolveModelConfig(
|
|
278
|
-
modelToUse,
|
|
279
|
-
undefined,
|
|
280
|
-
undefined,
|
|
281
|
-
permissionMode,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
public getMaxInputTokens(): number {
|
|
286
|
-
return this.configurationService.resolveMaxInputTokens();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
public getLanguage(): string | undefined {
|
|
290
|
-
return this.configurationService.resolveLanguage();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
public getAutoMemoryEnabled(): boolean {
|
|
294
|
-
return this.configurationService.resolveAutoMemoryEnabled();
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
public getWorktreeBaseRef(): "fresh" | "head" {
|
|
298
|
-
return this.configurationService.resolveWorktreeBaseRef();
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
public getWorkdir(): string {
|
|
302
|
-
return this.container.get<string>("Workdir") ?? process.cwd();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
public getOriginalWorkdir(): string {
|
|
306
|
-
return this.originalWorkdir;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Update the working directory mid-session (e.g., when entering/exiting a worktree).
|
|
311
|
-
* Only updates this session's DI container; it does NOT change the process-level
|
|
312
|
-
* process.cwd(), so concurrent sessions in the same stdio process are unaffected.
|
|
313
|
-
* Triggers `_onCwdChange` (wired by Agent to `onWorkdirChange`) so the host is
|
|
314
|
-
* notified of worktree switches. Unlike bash `cd`, this does NOT run CwdChanged
|
|
315
|
-
* hooks (worktree has its own WorktreeCreate/WorktreeRemove hooks).
|
|
316
|
-
*/
|
|
317
|
-
public setWorkdir(newWorkdir: string): void {
|
|
318
|
-
this.container.register("Workdir", newWorkdir);
|
|
319
|
-
this._onCwdChange?.(newWorkdir);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Get this session's worktree session state (null if not in a worktree).
|
|
324
|
-
*/
|
|
325
|
-
public getWorktreeSession(): WorktreeSession | null {
|
|
326
|
-
return (
|
|
327
|
-
this.container.get<WorktreeSession | null>("WorktreeSession") ?? null
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Set this session's worktree session state.
|
|
333
|
-
*/
|
|
334
|
-
public setWorktreeSession(session: WorktreeSession | null): void {
|
|
335
|
-
this.container.register("WorktreeSession", session);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
public setOnCwdChange(callback: (newCwd: string) => void): void {
|
|
339
|
-
this._onCwdChange = callback;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private isCompacting: boolean = false;
|
|
343
|
-
private callbacks: AIManagerCallbacks;
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Get filtered tool configuration based on tools list
|
|
347
|
-
*/
|
|
348
|
-
private getFilteredToolsConfig() {
|
|
349
|
-
// Get available subagents and skills for dynamic prompts
|
|
350
|
-
const availableSubagents = this.subagentManager?.getConfigurations();
|
|
351
|
-
const availableSkills = this.skillManager
|
|
352
|
-
?.getAvailableSkills()
|
|
353
|
-
.filter((skill) => !skill.disableModelInvocation);
|
|
354
|
-
|
|
355
|
-
return this.toolManager.getToolsConfig({
|
|
356
|
-
availableSubagents,
|
|
357
|
-
availableSkills,
|
|
358
|
-
workdir: this.getWorkdir(),
|
|
359
|
-
isSubagent: !!this.subagentType,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Add plan mode reminder as a persistent meta message to the message manager.
|
|
365
|
-
* Called before getMessages() so the stored message is included in the API call.
|
|
366
|
-
*/
|
|
367
|
-
private maybeAddPlanModeMessage(
|
|
368
|
-
currentMode: PermissionMode | undefined,
|
|
369
|
-
): void {
|
|
370
|
-
if (!this.permissionManager) return;
|
|
371
|
-
|
|
372
|
-
// Handle exit notification (one-time after leaving plan mode)
|
|
373
|
-
if (this.permissionManager.getNeedsPlanModeExitAttachment()) {
|
|
374
|
-
const planFilePath = this.permissionManager.getPlanFilePath();
|
|
375
|
-
const planExists = planFilePath ? existsSync(planFilePath) : false;
|
|
376
|
-
this.messageManager.addUserMessage({
|
|
377
|
-
content: buildExitedPlanModeReminder(planFilePath, planExists),
|
|
378
|
-
isMeta: true,
|
|
379
|
-
});
|
|
380
|
-
this.permissionManager.setNeedsPlanModeExitAttachment(false);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (currentMode !== "plan") return;
|
|
384
|
-
|
|
385
|
-
const planFilePath = this.permissionManager.getPlanFilePath();
|
|
386
|
-
if (!planFilePath) return;
|
|
387
|
-
|
|
388
|
-
const planExists = existsSync(planFilePath);
|
|
389
|
-
|
|
390
|
-
// One-time plan entry reminder
|
|
391
|
-
const planMgr = this.planManager;
|
|
392
|
-
if (planMgr?.isPlanEntryReminderPending()) {
|
|
393
|
-
if (this.permissionManager.hasExitedPlanModeInSession() && planExists) {
|
|
394
|
-
// Re-entry: use small reminder
|
|
395
|
-
this.messageManager.addUserMessage({
|
|
396
|
-
content: buildPlanModeReEntryReminder(planFilePath),
|
|
397
|
-
isMeta: true,
|
|
398
|
-
});
|
|
399
|
-
} else {
|
|
400
|
-
// First entry: use full reminder
|
|
401
|
-
this.messageManager.addUserMessage({
|
|
402
|
-
content: buildPlanModeReminder(
|
|
403
|
-
planFilePath,
|
|
404
|
-
planExists,
|
|
405
|
-
!!this.subagentType,
|
|
406
|
-
),
|
|
407
|
-
isMeta: true,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
planMgr.consumePlanEntryReminder();
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
private async maybeGetTaskReminderText(
|
|
415
|
-
toolNames: Set<string>,
|
|
416
|
-
): Promise<string | null> {
|
|
417
|
-
// Guard: no task tools available
|
|
418
|
-
if (!toolNames.has("TaskUpdate")) return null;
|
|
419
|
-
|
|
420
|
-
const internalMessages = this.messageManager.getMessages();
|
|
421
|
-
const turnCounts = getTaskReminderTurnCounts(internalMessages);
|
|
422
|
-
|
|
423
|
-
if (
|
|
424
|
-
turnCounts.turnsSinceLastTaskManagement <
|
|
425
|
-
TASK_REMINDER_CONFIG.TURNS_SINCE_WRITE ||
|
|
426
|
-
turnCounts.turnsSinceLastReminder <
|
|
427
|
-
TASK_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
|
|
428
|
-
) {
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const tasks = await this.taskManager.listTasks();
|
|
433
|
-
return maybeInjectTaskReminder(turnCounts, tasks);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
public setIsLoading(isLoading: boolean): void {
|
|
437
|
-
this.isLoading = isLoading;
|
|
438
|
-
this.onLoadingChange?.(isLoading);
|
|
439
|
-
const options =
|
|
440
|
-
this.container.get<import("../types/agent.js").AgentOptions>(
|
|
441
|
-
"AgentOptions",
|
|
442
|
-
);
|
|
443
|
-
options?.callbacks?.onLoadingChange?.(isLoading);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
public abortAIMessage(): void {
|
|
447
|
-
// Bump generation so the in-flight turn's end-of-turn setIsLoading(false)
|
|
448
|
-
// is skipped (generation mismatch), preventing it from racing with this abort.
|
|
449
|
-
this.turnGeneration++;
|
|
450
|
-
|
|
451
|
-
// Interrupt AI service
|
|
452
|
-
if (this.abortController) {
|
|
453
|
-
try {
|
|
454
|
-
this.abortController.abort();
|
|
455
|
-
} catch (error) {
|
|
456
|
-
logger?.error("Failed to abort AI service:", error);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Interrupt tool execution
|
|
461
|
-
if (this.toolAbortController) {
|
|
462
|
-
try {
|
|
463
|
-
this.toolAbortController.abort();
|
|
464
|
-
} catch (error) {
|
|
465
|
-
logger?.error("Failed to abort tool execution:", error);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
this.setIsLoading(false);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Helper method to generate compactParams
|
|
473
|
-
private generateCompactParams(
|
|
474
|
-
toolName: string,
|
|
475
|
-
toolArgs: Record<string, unknown>,
|
|
476
|
-
): string {
|
|
477
|
-
try {
|
|
478
|
-
const toolPlugin = this.toolManager
|
|
479
|
-
.list()
|
|
480
|
-
.find((plugin) => plugin.name === toolName);
|
|
481
|
-
if (toolPlugin?.formatCompactParams) {
|
|
482
|
-
const context: ToolContext = {
|
|
483
|
-
workdir: this.getWorkdir(),
|
|
484
|
-
originalWorkdir: this.originalWorkdir,
|
|
485
|
-
taskManager: this.taskManager,
|
|
486
|
-
};
|
|
487
|
-
return toolPlugin.formatCompactParams(toolArgs, context);
|
|
488
|
-
}
|
|
489
|
-
} catch (error) {
|
|
490
|
-
logger?.warn("Failed to generate compactParams", error);
|
|
491
|
-
}
|
|
492
|
-
return "";
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Private method to handle token statistics and message compaction
|
|
496
|
-
private async handleTokenUsageAndCompaction(
|
|
497
|
-
usage: Usage | undefined,
|
|
498
|
-
abortController: AbortController,
|
|
499
|
-
): Promise<void> {
|
|
500
|
-
if (!usage) return;
|
|
501
|
-
|
|
502
|
-
// Update token statistics - display comprehensive token usage including cache tokens
|
|
503
|
-
const comprehensiveTotalTokens = calculateComprehensiveTotalTokens(usage);
|
|
504
|
-
this.messageManager.setlatestTotalTokens(comprehensiveTotalTokens);
|
|
505
|
-
|
|
506
|
-
// Check if token limit exceeded - use injected configuration
|
|
507
|
-
if (
|
|
508
|
-
usage.total_tokens +
|
|
509
|
-
(usage.cache_read_input_tokens || 0) +
|
|
510
|
-
(usage.cache_creation_input_tokens || 0) >
|
|
511
|
-
this.getMaxInputTokens()
|
|
512
|
-
) {
|
|
513
|
-
logger?.debug(
|
|
514
|
-
`Token usage exceeded ${this.getMaxInputTokens()}, compacting messages...`,
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
const messagesToCompact = this.messageManager.getMessages();
|
|
518
|
-
if (messagesToCompact.length === 0) return;
|
|
519
|
-
|
|
520
|
-
// Circuit breaker: skip compaction after 3 consecutive failures
|
|
521
|
-
if (this.consecutiveCompactionFailures >= 3) {
|
|
522
|
-
logger?.warn(
|
|
523
|
-
`Skipping compaction: ${this.consecutiveCompactionFailures} consecutive failures`,
|
|
524
|
-
);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
await this.compactConversation({
|
|
529
|
-
abortSignal: abortController.signal,
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Manually compact the conversation history.
|
|
536
|
-
* Called by /compact slash command or auto-compaction trigger.
|
|
537
|
-
*/
|
|
538
|
-
public async compactConversation(
|
|
539
|
-
options: {
|
|
540
|
-
customInstructions?: string;
|
|
541
|
-
abortSignal?: AbortSignal;
|
|
542
|
-
} = {},
|
|
543
|
-
): Promise<void> {
|
|
544
|
-
const messagesToCompact = this.messageManager.getMessages();
|
|
545
|
-
if (messagesToCompact.length === 0) {
|
|
546
|
-
logger?.debug("No messages to compact");
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Circuit breaker: skip if already compacting
|
|
551
|
-
if (this.isCompacting) {
|
|
552
|
-
logger?.warn("Compaction already in progress");
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 1. Run PreCompact hooks
|
|
557
|
-
let hookInstructions: string | undefined;
|
|
558
|
-
if (this.hookManager) {
|
|
559
|
-
try {
|
|
560
|
-
const preResult = await this.hookManager.executePreCompactHooks(
|
|
561
|
-
this.messageManager.getSessionId(),
|
|
562
|
-
this.messageManager.getTranscriptPath(),
|
|
563
|
-
options.customInstructions,
|
|
564
|
-
);
|
|
565
|
-
hookInstructions = preResult.additionalInstructions;
|
|
566
|
-
} catch (error) {
|
|
567
|
-
logger?.warn(`PreCompact hooks failed: ${(error as Error).message}`);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// 2. Merge custom instructions
|
|
572
|
-
const mergedInstructions =
|
|
573
|
-
[options.customInstructions, hookInstructions]
|
|
574
|
-
.filter(Boolean)
|
|
575
|
-
.join("\n") || undefined;
|
|
576
|
-
|
|
577
|
-
// 3. Save session before compaction
|
|
578
|
-
await this.messageManager.saveSession();
|
|
579
|
-
|
|
580
|
-
this.setIsCompacting(true);
|
|
581
|
-
try {
|
|
582
|
-
const modelConfig = this.getModelConfig();
|
|
583
|
-
const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
|
|
584
|
-
supportsVision: supportsVision(modelConfig.capabilities),
|
|
585
|
-
});
|
|
586
|
-
const compactPrompt = getCompactPrompt(mergedInstructions);
|
|
587
|
-
|
|
588
|
-
// 4. Fork path: fork the conversation with the same system prompt,
|
|
589
|
-
// tools, model, and generation params as the main loop so the forked
|
|
590
|
-
// request prefix matches exactly and the prompt cache is reused.
|
|
591
|
-
const forkResult = await this.runCompactFork(
|
|
592
|
-
recentChatMessages,
|
|
593
|
-
compactPrompt,
|
|
594
|
-
options.abortSignal,
|
|
595
|
-
);
|
|
596
|
-
const summaryContent = forkResult.content;
|
|
597
|
-
const compactTokens = forkResult.usage;
|
|
598
|
-
if (!summaryContent) {
|
|
599
|
-
throw new Error(
|
|
600
|
-
"Compaction failed: the model produced no summary output",
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
const compactModel = modelConfig.model;
|
|
604
|
-
|
|
605
|
-
// 5. Handle usage tracking
|
|
606
|
-
let compactUsage: Usage | undefined;
|
|
607
|
-
if (compactTokens) {
|
|
608
|
-
compactUsage = {
|
|
609
|
-
...compactTokens,
|
|
610
|
-
model: compactModel,
|
|
611
|
-
operation_type: "compact",
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// 6. Strip the <analysis> scratchpad and extract the <summary> body
|
|
616
|
-
const formattedSummary = formatCompactSummary(summaryContent);
|
|
617
|
-
|
|
618
|
-
// 7. Build post-compact context restoration
|
|
619
|
-
const enhancedSummary =
|
|
620
|
-
await this.buildPostCompactContext(formattedSummary);
|
|
621
|
-
|
|
622
|
-
// 8. Execute message reconstruction
|
|
623
|
-
await this.messageManager.compactMessagesAndUpdateSession(
|
|
624
|
-
enhancedSummary,
|
|
625
|
-
compactUsage,
|
|
626
|
-
);
|
|
627
|
-
|
|
628
|
-
// Re-add plan mode reminder as persistent meta message after compaction
|
|
629
|
-
const postCompactMode = this.permissionManager?.getCurrentEffectiveMode(
|
|
630
|
-
this.getModelConfig().permissionMode,
|
|
631
|
-
);
|
|
632
|
-
if (postCompactMode === "plan") {
|
|
633
|
-
const planFilePath = this.permissionManager?.getPlanFilePath();
|
|
634
|
-
if (planFilePath) {
|
|
635
|
-
const planExists = existsSync(planFilePath);
|
|
636
|
-
this.messageManager.addUserMessage({
|
|
637
|
-
content: buildPlanModeReminder(
|
|
638
|
-
planFilePath,
|
|
639
|
-
planExists,
|
|
640
|
-
!!this.subagentType,
|
|
641
|
-
),
|
|
642
|
-
isMeta: true,
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// 9. Track usage
|
|
648
|
-
if (compactUsage && this.callbacks?.onUsageAdded) {
|
|
649
|
-
this.callbacks.onUsageAdded(compactUsage);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
this.consecutiveCompactionFailures = 0;
|
|
653
|
-
|
|
654
|
-
// Reset incremental tracing state after compaction
|
|
655
|
-
resetTracingState();
|
|
656
|
-
|
|
657
|
-
// 10. Log OTEL event
|
|
658
|
-
logOTelEvent("compaction", {
|
|
659
|
-
beforeTokens: String(messagesToCompact.length),
|
|
660
|
-
afterTokens: "1",
|
|
661
|
-
model: compactModel,
|
|
662
|
-
}).catch(() => {});
|
|
663
|
-
|
|
664
|
-
// 11. Run SessionStart hooks (existing behavior)
|
|
665
|
-
if (this.hookManager) {
|
|
666
|
-
try {
|
|
667
|
-
const newSessionId = this.messageManager.getSessionId();
|
|
668
|
-
const sessionStartResult =
|
|
669
|
-
await this.hookManager.executeSessionStartHooks(
|
|
670
|
-
"compact",
|
|
671
|
-
newSessionId,
|
|
672
|
-
this.messageManager.getTranscriptPath(),
|
|
673
|
-
this.subagentType,
|
|
674
|
-
);
|
|
675
|
-
if (sessionStartResult.additionalContext) {
|
|
676
|
-
this.messageManager.addUserMessage({
|
|
677
|
-
content: `<system-reminder>\nSessionStart hook additional context: ${sessionStartResult.additionalContext}\n</system-reminder>`,
|
|
678
|
-
isMeta: true,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
if (sessionStartResult.initialUserMessage) {
|
|
682
|
-
this.messageManager.addUserMessage({
|
|
683
|
-
content: sessionStartResult.initialUserMessage,
|
|
684
|
-
isMeta: true,
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
} catch (error) {
|
|
688
|
-
logger?.warn(
|
|
689
|
-
`SessionStart hooks on compact failed: ${(error as Error).message}`,
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// 12. Run PostCompact hooks
|
|
695
|
-
if (this.hookManager) {
|
|
696
|
-
try {
|
|
697
|
-
await this.hookManager.executePostCompactHooks(
|
|
698
|
-
this.messageManager.getSessionId(),
|
|
699
|
-
this.messageManager.getTranscriptPath(),
|
|
700
|
-
formattedSummary,
|
|
701
|
-
);
|
|
702
|
-
} catch (error) {
|
|
703
|
-
logger?.warn(`PostCompact hooks failed: ${(error as Error).message}`);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
logger?.debug(
|
|
708
|
-
`Successfully compacted ${messagesToCompact.length} messages`,
|
|
709
|
-
);
|
|
710
|
-
} catch (compactError) {
|
|
711
|
-
this.consecutiveCompactionFailures++;
|
|
712
|
-
logger?.error(
|
|
713
|
-
`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`,
|
|
714
|
-
);
|
|
715
|
-
this.messageManager.addErrorBlock(
|
|
716
|
-
`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
|
|
717
|
-
);
|
|
718
|
-
} finally {
|
|
719
|
-
this.setIsCompacting(false);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Build the system prompt used by the main agent loop. Extracted so the
|
|
725
|
-
* compaction fork can mirror it exactly — the forked request prefix must
|
|
726
|
-
* match the main conversation's for the prompt cache to be reused.
|
|
727
|
-
*/
|
|
728
|
-
private async buildMainSystemPrompt(
|
|
729
|
-
filteredToolPlugins: ReturnType<ToolManager["getTools"]>,
|
|
730
|
-
) {
|
|
731
|
-
let autoMemoryOptions: { directory: string; content: string } | undefined;
|
|
732
|
-
|
|
733
|
-
if (this.getAutoMemoryEnabled()) {
|
|
734
|
-
const directory = this.memoryService.getAutoMemoryDirectory(
|
|
735
|
-
this.getWorkdir(),
|
|
736
|
-
);
|
|
737
|
-
const content = await this.memoryService.getAutoMemoryContent(
|
|
738
|
-
this.getWorkdir(),
|
|
739
|
-
);
|
|
740
|
-
autoMemoryOptions = { directory, content };
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
|
|
744
|
-
workdir: this.getWorkdir(),
|
|
745
|
-
originalWorkdir: this.getOriginalWorkdir(),
|
|
746
|
-
language: this.getLanguage(),
|
|
747
|
-
isSubagent: !!this.subagentType,
|
|
748
|
-
worktreeSession: this.getWorktreeSession(),
|
|
749
|
-
additionalWorkingDirectories:
|
|
750
|
-
this.permissionManager?.getEffectiveAdditionalDirectories?.() ?? [],
|
|
751
|
-
autoMemory: autoMemoryOptions,
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
private resolveFilteredTools() {
|
|
756
|
-
const toolsConfig = this.getFilteredToolsConfig();
|
|
757
|
-
const toolNames = new Set(toolsConfig.map((t) => t.function.name));
|
|
758
|
-
const filteredToolPlugins = this.toolManager
|
|
759
|
-
.getTools()
|
|
760
|
-
.filter((t) => toolNames.has(t.name));
|
|
761
|
-
return { toolsConfig, toolNames, filteredToolPlugins };
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Fork-path loop: run a bounded agent loop over a copy of the conversation
|
|
766
|
-
* using the same system prompt, tools, model, and generation params as the
|
|
767
|
-
* main loop, so the forked request prefix matches exactly and the prompt
|
|
768
|
-
* cache is reused. A `canUseTool` gate decides whether each tool call
|
|
769
|
-
* executes locally (with a stripped context) or is denied and fed back to
|
|
770
|
-
* the model for another turn. Returns undefined content when the model never
|
|
771
|
-
* produces text; the caller treats that as a failure.
|
|
772
|
-
*/
|
|
773
|
-
private async runForkLoop(
|
|
774
|
-
historyMessages: ChatCompletionMessageParam[],
|
|
775
|
-
prompt: string,
|
|
776
|
-
options: {
|
|
777
|
-
maxTurns: number;
|
|
778
|
-
/** Gate deciding which tool calls execute locally. When undefined, every tool call is denied. */
|
|
779
|
-
canUseTool?: (name: string, args: Record<string, unknown>) => boolean;
|
|
780
|
-
/** Message fed back to the model when a tool call is denied. */
|
|
781
|
-
deniedToolMessage?: string;
|
|
782
|
-
},
|
|
783
|
-
abortSignal?: AbortSignal,
|
|
784
|
-
): Promise<ForkLoopResult> {
|
|
785
|
-
const modelConfig = this.getModelConfig();
|
|
786
|
-
const gatewayConfig = this.getGatewayConfig();
|
|
787
|
-
const sessionId = this.messageManager.getSessionId();
|
|
788
|
-
const workdir = this.getWorkdir();
|
|
789
|
-
|
|
790
|
-
const forkMessages: ChatCompletionMessageParam[] = [...historyMessages];
|
|
791
|
-
|
|
792
|
-
// Mirror the main loop's memory injection so the request prefix matches.
|
|
793
|
-
const { prependContent } =
|
|
794
|
-
await this.messageManager.getMemoryForInjection();
|
|
795
|
-
if (prependContent.trim()) {
|
|
796
|
-
forkMessages.unshift({
|
|
797
|
-
role: "user",
|
|
798
|
-
content: wrapInSystemReminder(prependContent),
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
forkMessages.push({ role: "user", content: prompt });
|
|
803
|
-
|
|
804
|
-
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
805
|
-
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
806
|
-
|
|
807
|
-
// Fresh read-state map so Read/Edit state built up inside the fork never
|
|
808
|
-
// leaks into the main session's dedup and staleness tracking.
|
|
809
|
-
const forkReadFileState = new Map<
|
|
810
|
-
string,
|
|
811
|
-
{ mtime: number; hash: string; offset?: number; limit?: number }
|
|
812
|
-
>();
|
|
813
|
-
|
|
814
|
-
let totalUsage: ForkLoopResult["usage"];
|
|
815
|
-
let content: string | undefined;
|
|
816
|
-
|
|
817
|
-
for (let turn = 0; turn < options.maxTurns; turn++) {
|
|
818
|
-
const result = await aiService.callAgent({
|
|
819
|
-
gatewayConfig,
|
|
820
|
-
modelConfig,
|
|
821
|
-
messages: forkMessages,
|
|
822
|
-
sessionId,
|
|
823
|
-
abortSignal,
|
|
824
|
-
workdir,
|
|
825
|
-
tools: toolsConfig,
|
|
826
|
-
systemPrompt,
|
|
827
|
-
toolChoice: this.toolChoiceOverride,
|
|
828
|
-
// Stream so a slow reasoning model emits first bytes before the
|
|
829
|
-
// gateway's idle timeout fires (non-streaming waits for the full
|
|
830
|
-
// response, which exceeds the timeout on large contexts).
|
|
831
|
-
stream: true,
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
if (result.usage) {
|
|
835
|
-
totalUsage = {
|
|
836
|
-
prompt_tokens:
|
|
837
|
-
(totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
|
|
838
|
-
completion_tokens:
|
|
839
|
-
(totalUsage?.completion_tokens ?? 0) +
|
|
840
|
-
result.usage.completion_tokens,
|
|
841
|
-
total_tokens:
|
|
842
|
-
(totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
if (result.content?.trim()) {
|
|
847
|
-
content = result.content;
|
|
848
|
-
break;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
852
|
-
const functionCalls = result.tool_calls.filter(
|
|
853
|
-
(tc) => tc.type === "function",
|
|
854
|
-
);
|
|
855
|
-
if (functionCalls.length === 0) break;
|
|
856
|
-
|
|
857
|
-
forkMessages.push({
|
|
858
|
-
role: "assistant",
|
|
859
|
-
content: result.content ?? null,
|
|
860
|
-
tool_calls: functionCalls,
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
for (const toolCall of functionCalls) {
|
|
864
|
-
const name = toolCall.function?.name || "";
|
|
865
|
-
const args = this.parseForkToolArgs(toolCall.function?.arguments);
|
|
866
|
-
let toolContent: string;
|
|
867
|
-
if (options.canUseTool && options.canUseTool(name, args)) {
|
|
868
|
-
toolContent = await this.executeForkTool(
|
|
869
|
-
name,
|
|
870
|
-
args,
|
|
871
|
-
workdir,
|
|
872
|
-
sessionId,
|
|
873
|
-
abortSignal,
|
|
874
|
-
forkReadFileState,
|
|
875
|
-
);
|
|
876
|
-
} else {
|
|
877
|
-
toolContent =
|
|
878
|
-
options.deniedToolMessage ??
|
|
879
|
-
"Tool use is not allowed in this context";
|
|
880
|
-
}
|
|
881
|
-
forkMessages.push({
|
|
882
|
-
role: "tool",
|
|
883
|
-
tool_call_id: toolCall.id,
|
|
884
|
-
content: toolContent,
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Neither text nor tool calls: retrying the identical request is
|
|
891
|
-
// pointless, bail out and let the caller fail.
|
|
892
|
-
break;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return { content, usage: totalUsage };
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Fork-path compaction: deny all tool calls locally (the model is told to
|
|
900
|
-
* summarize, not act) and feed the rejections back for another turn.
|
|
901
|
-
*/
|
|
902
|
-
private async runCompactFork(
|
|
903
|
-
historyMessages: ChatCompletionMessageParam[],
|
|
904
|
-
compactPrompt: string,
|
|
905
|
-
abortSignal?: AbortSignal,
|
|
906
|
-
): Promise<ForkLoopResult> {
|
|
907
|
-
return this.runForkLoop(
|
|
908
|
-
historyMessages,
|
|
909
|
-
compactPrompt,
|
|
910
|
-
{
|
|
911
|
-
maxTurns: MAX_FORK_TURNS,
|
|
912
|
-
deniedToolMessage: "Tool use is not allowed during compaction",
|
|
913
|
-
},
|
|
914
|
-
abortSignal,
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Auto-memory extraction via the perfect fork: the extraction prompt is run
|
|
920
|
-
* against the same request prefix as the main conversation (same system
|
|
921
|
-
* prompt, tools, model, and message history) so the prompt cache is reused.
|
|
922
|
-
* Gate-approved tools execute locally in a stripped context; everything else
|
|
923
|
-
* is denied. Usage is reported with operation_type "agent" so extraction
|
|
924
|
-
* token costs stay visible in session accounting.
|
|
925
|
-
*/
|
|
926
|
-
public async runAutoMemoryFork(
|
|
927
|
-
messages: Message[],
|
|
928
|
-
prompt: string,
|
|
929
|
-
options: {
|
|
930
|
-
canUseTool: (name: string, args: Record<string, unknown>) => boolean;
|
|
931
|
-
deniedToolMessage?: string;
|
|
932
|
-
maxTurns?: number;
|
|
933
|
-
},
|
|
934
|
-
abortSignal?: AbortSignal,
|
|
935
|
-
): Promise<ForkLoopResult> {
|
|
936
|
-
const modelConfig = this.getModelConfig();
|
|
937
|
-
const historyMessages = convertMessagesForAPI(messages, {
|
|
938
|
-
supportsVision: supportsVision(modelConfig.capabilities),
|
|
939
|
-
});
|
|
940
|
-
// Give the fork a real signal even when the caller has none, so tools
|
|
941
|
-
// (e.g. Bash's foreground path) always receive a well-formed context.
|
|
942
|
-
const signal = abortSignal ?? new AbortController().signal;
|
|
943
|
-
const result = await this.runForkLoop(
|
|
944
|
-
historyMessages,
|
|
945
|
-
prompt,
|
|
946
|
-
{
|
|
947
|
-
maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
|
|
948
|
-
canUseTool: options.canUseTool,
|
|
949
|
-
deniedToolMessage: options.deniedToolMessage,
|
|
950
|
-
},
|
|
951
|
-
signal,
|
|
952
|
-
);
|
|
953
|
-
|
|
954
|
-
if (result.usage && this.callbacks?.onUsageAdded) {
|
|
955
|
-
this.callbacks.onUsageAdded({
|
|
956
|
-
...result.usage,
|
|
957
|
-
model: modelConfig.model,
|
|
958
|
-
operation_type: "agent",
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
return result;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* Parse a fork tool call's JSON arguments, recovering truncated JSON the
|
|
966
|
-
* same way the main loop does. Unparseable arguments fall back to `{}` and
|
|
967
|
-
* are rejected by the gate or the tool's own parameter validation.
|
|
968
|
-
*/
|
|
969
|
-
private parseForkToolArgs(
|
|
970
|
-
argsString: string | undefined,
|
|
971
|
-
): Record<string, unknown> {
|
|
972
|
-
if (!argsString?.trim()) return {};
|
|
973
|
-
try {
|
|
974
|
-
return JSON.parse(argsString) as Record<string, unknown>;
|
|
975
|
-
} catch {
|
|
976
|
-
try {
|
|
977
|
-
return JSON.parse(recoverTruncatedJson(argsString)) as Record<
|
|
978
|
-
string,
|
|
979
|
-
unknown
|
|
980
|
-
>;
|
|
981
|
-
} catch {
|
|
982
|
-
return {};
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/**
|
|
988
|
-
* Execute a single tool call inside a fork with a stripped context: no
|
|
989
|
-
* permission manager (never prompts the user), no message manager (no
|
|
990
|
-
* conditional-rule triggering), no messageId (no file-history snapshots),
|
|
991
|
-
* and no background task manager (commands run in the foreground). Only
|
|
992
|
-
* gate-approved tool names reach this path.
|
|
993
|
-
*/
|
|
994
|
-
private async executeForkTool(
|
|
995
|
-
name: string,
|
|
996
|
-
args: Record<string, unknown>,
|
|
997
|
-
workdir: string,
|
|
998
|
-
sessionId: string | undefined,
|
|
999
|
-
abortSignal: AbortSignal | undefined,
|
|
1000
|
-
readFileState: ToolContext["readFileState"],
|
|
1001
|
-
): Promise<string> {
|
|
1002
|
-
const plugin = this.toolManager.getTools().find((t) => t.name === name);
|
|
1003
|
-
if (!plugin) {
|
|
1004
|
-
return `Tool '${name}' not found`;
|
|
1005
|
-
}
|
|
1006
|
-
const context: ToolContext = {
|
|
1007
|
-
abortSignal,
|
|
1008
|
-
workdir,
|
|
1009
|
-
originalWorkdir: this.originalWorkdir,
|
|
1010
|
-
sessionId,
|
|
1011
|
-
taskManager: this.taskManager,
|
|
1012
|
-
readFileState,
|
|
1013
|
-
onShortResultUpdate: () => {},
|
|
1014
|
-
onResultUpdate: () => {},
|
|
1015
|
-
onCwdChange: () => {},
|
|
1016
|
-
};
|
|
1017
|
-
try {
|
|
1018
|
-
const result = await plugin.execute(args, context);
|
|
1019
|
-
if (result.content) return result.content;
|
|
1020
|
-
if (result.error) return `Error: ${result.error}`;
|
|
1021
|
-
return "";
|
|
1022
|
-
} catch (error) {
|
|
1023
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1024
|
-
logger?.error(`Fork tool execution failed for ${name}:`, error);
|
|
1025
|
-
return `Tool execution failed: ${message}`;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
/**
|
|
1030
|
-
* Fork-path side question ("/btw"): run a single-turn fork of the
|
|
1031
|
-
* conversation using the same system prompt, tools, model, and generation
|
|
1032
|
-
* params as the main loop, so the forked request prefix matches exactly and
|
|
1033
|
-
* the prompt cache is reused. The in-progress assistant message (if any) is
|
|
1034
|
-
* stripped so the fork starts from the last completed request prefix. Tools
|
|
1035
|
-
* are never executed — the wrapped question instructs the model to answer
|
|
1036
|
-
* directly; an attempted tool call is surfaced as an error string.
|
|
1037
|
-
*/
|
|
1038
|
-
async runBtwFork(
|
|
1039
|
-
question: string,
|
|
1040
|
-
abortSignal?: AbortSignal,
|
|
1041
|
-
onContent?: (content: string) => void,
|
|
1042
|
-
onReasoning?: (content: string) => void,
|
|
1043
|
-
): Promise<{ content?: string; error?: string }> {
|
|
1044
|
-
const modelConfig = this.getModelConfig();
|
|
1045
|
-
const gatewayConfig = this.getGatewayConfig();
|
|
1046
|
-
const sessionId = this.messageManager.getSessionId();
|
|
1047
|
-
const workdir = this.getWorkdir();
|
|
1048
|
-
|
|
1049
|
-
const rawMessages = this.messageManager.getMessages();
|
|
1050
|
-
|
|
1051
|
-
// Strip the in-progress assistant message (a block still in "streaming"
|
|
1052
|
-
// stage) so the fork's request prefix matches the last completed
|
|
1053
|
-
// main-loop request and the prompt cache is reused.
|
|
1054
|
-
const lastMessage = rawMessages[rawMessages.length - 1];
|
|
1055
|
-
const hasInProgressMessage =
|
|
1056
|
-
lastMessage?.role === "assistant" &&
|
|
1057
|
-
lastMessage.blocks.some(
|
|
1058
|
-
(b) => "stage" in b && (b as { stage?: string }).stage === "streaming",
|
|
1059
|
-
);
|
|
1060
|
-
|
|
1061
|
-
const forkMessages: ChatCompletionMessageParam[] = convertMessagesForAPI(
|
|
1062
|
-
hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages,
|
|
1063
|
-
{ supportsVision: supportsVision(modelConfig.capabilities) },
|
|
1064
|
-
);
|
|
1065
|
-
|
|
1066
|
-
// Mirror the main loop's memory injection so the request prefix matches.
|
|
1067
|
-
const { prependContent } =
|
|
1068
|
-
await this.messageManager.getMemoryForInjection();
|
|
1069
|
-
if (prependContent.trim()) {
|
|
1070
|
-
forkMessages.unshift({
|
|
1071
|
-
role: "user",
|
|
1072
|
-
content: wrapInSystemReminder(prependContent),
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// Wrap the question with the side-question instructions (verbatim
|
|
1077
|
-
// Claude Code sideQuestion.ts) so the model answers directly.
|
|
1078
|
-
const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
|
|
1079
|
-
|
|
1080
|
-
IMPORTANT CONTEXT:
|
|
1081
|
-
- You are a separate, lightweight agent spawned to answer this one question
|
|
1082
|
-
- The main agent is NOT interrupted - it continues working independently in the background
|
|
1083
|
-
- You share the conversation context but are a completely separate instance
|
|
1084
|
-
- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
|
|
1085
|
-
|
|
1086
|
-
CRITICAL CONSTRAINTS:
|
|
1087
|
-
- You have NO tools available - you cannot read files, run commands, search, or take any actions
|
|
1088
|
-
- This is a one-off response - there will be no follow-up turns
|
|
1089
|
-
- You can ONLY provide information based on what you already know from the conversation context
|
|
1090
|
-
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
|
|
1091
|
-
- If you don't know the answer, say so - do not offer to look it up or investigate
|
|
1092
|
-
|
|
1093
|
-
Simply answer the question with the information you have.</system-reminder>
|
|
1094
|
-
|
|
1095
|
-
${question}`;
|
|
1096
|
-
forkMessages.push({ role: "user", content: wrappedQuestion });
|
|
1097
|
-
|
|
1098
|
-
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
1099
|
-
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
1100
|
-
|
|
1101
|
-
try {
|
|
1102
|
-
// Surface partial output to the caller (e.g. the /btw overlay's
|
|
1103
|
-
// streaming display) as it arrives. Reasoning chunks from thinking
|
|
1104
|
-
// models stream through a separate channel when the caller supplies
|
|
1105
|
-
// one (webview hosts distinguish thinking from content so the panel
|
|
1106
|
-
// can drop thinking text once content starts); otherwise they fall
|
|
1107
|
-
// back to the content channel (CLI overlay mixes both).
|
|
1108
|
-
const streamToOverlay = (text: string) => {
|
|
1109
|
-
if (text.trim()) {
|
|
1110
|
-
onContent?.(text);
|
|
1111
|
-
}
|
|
1112
|
-
};
|
|
1113
|
-
const streamReasoning = (text: string) => {
|
|
1114
|
-
if (text.trim()) {
|
|
1115
|
-
if (onReasoning) {
|
|
1116
|
-
onReasoning(text);
|
|
1117
|
-
} else {
|
|
1118
|
-
onContent?.(text);
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
const result = await aiService.callAgent({
|
|
1123
|
-
gatewayConfig,
|
|
1124
|
-
modelConfig,
|
|
1125
|
-
messages: forkMessages,
|
|
1126
|
-
sessionId,
|
|
1127
|
-
abortSignal,
|
|
1128
|
-
workdir,
|
|
1129
|
-
tools: toolsConfig,
|
|
1130
|
-
systemPrompt,
|
|
1131
|
-
toolChoice: this.toolChoiceOverride,
|
|
1132
|
-
// Stream so a slow reasoning model emits first bytes before the
|
|
1133
|
-
// gateway's idle timeout fires (same rationale as runCompactFork).
|
|
1134
|
-
stream: true,
|
|
1135
|
-
onContentUpdate: streamToOverlay,
|
|
1136
|
-
onReasoningUpdate: streamReasoning,
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
if (result.content?.trim()) {
|
|
1140
|
-
return { content: result.content };
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// A thinking model may emit only reasoning content (e.g. the stream
|
|
1144
|
-
// is truncated before the final answer); surface that instead of
|
|
1145
|
-
// falling through to "No response received".
|
|
1146
|
-
if (result.reasoning_content?.trim()) {
|
|
1147
|
-
return { content: result.reasoning_content };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
1151
|
-
const firstFunctionCall = result.tool_calls.find(
|
|
1152
|
-
(call) => call.type === "function",
|
|
1153
|
-
);
|
|
1154
|
-
const toolName = firstFunctionCall?.function?.name ?? "a tool";
|
|
1155
|
-
return {
|
|
1156
|
-
error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
|
|
1157
|
-
};
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
// Neither text nor tool calls.
|
|
1161
|
-
return { error: "No response received" };
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
// aiService.callAgent converts AbortError into a plain Error, so the
|
|
1164
|
-
// abort is detected via the signal itself; rethrow so the UI can
|
|
1165
|
-
// silently dismiss instead of showing an error.
|
|
1166
|
-
if (abortSignal?.aborted) {
|
|
1167
|
-
throw error;
|
|
1168
|
-
}
|
|
1169
|
-
return {
|
|
1170
|
-
error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Build post-compact context restoration content.
|
|
1177
|
-
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
1178
|
-
*/
|
|
1179
|
-
private async buildPostCompactContext(summary: string): Promise<string> {
|
|
1180
|
-
const POST_COMPACT_TOKEN_BUDGET = 50_000;
|
|
1181
|
-
const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000;
|
|
1182
|
-
const POST_COMPACT_MAX_FILES_TO_RESTORE = 5;
|
|
1183
|
-
const contextParts: string[] = [];
|
|
1184
|
-
|
|
1185
|
-
// 1. File context restoration
|
|
1186
|
-
const recentFiles = this.messageManager.getRecentFileReads(
|
|
1187
|
-
POST_COMPACT_MAX_FILES_TO_RESTORE,
|
|
1188
|
-
POST_COMPACT_MAX_TOKENS_PER_FILE,
|
|
1189
|
-
);
|
|
1190
|
-
let usedTokens = 0;
|
|
1191
|
-
for (const file of recentFiles) {
|
|
1192
|
-
const fileTokens = estimateTokens(file.content);
|
|
1193
|
-
if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) continue;
|
|
1194
|
-
if (fileTokens > 0) usedTokens += fileTokens;
|
|
1195
|
-
contextParts.push(`\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``);
|
|
1196
|
-
if (contextParts.length >= POST_COMPACT_MAX_FILES_TO_RESTORE) break;
|
|
1197
|
-
if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// 4. Invoked skills context (with token budget, matching Claude Code)
|
|
1201
|
-
const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000;
|
|
1202
|
-
const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000;
|
|
1203
|
-
const invokedSkillNames = this.messageManager.getInvokedSkillNames(10);
|
|
1204
|
-
if (invokedSkillNames.length > 0 && this.skillManager) {
|
|
1205
|
-
const invokedSkillParts: string[] = [];
|
|
1206
|
-
let skillsUsedTokens = 0;
|
|
1207
|
-
for (const skillName of invokedSkillNames) {
|
|
1208
|
-
try {
|
|
1209
|
-
const skill = await this.skillManager.loadSkill(skillName);
|
|
1210
|
-
if (!skill) continue;
|
|
1211
|
-
|
|
1212
|
-
const contentMatch = skill.content.match(
|
|
1213
|
-
/^---\n[\s\S]*?\n---\n([\s\S]*)$/,
|
|
1214
|
-
);
|
|
1215
|
-
let skillContent = contentMatch
|
|
1216
|
-
? contentMatch[1].trim()
|
|
1217
|
-
: skill.content;
|
|
1218
|
-
|
|
1219
|
-
const maxSkillChars = POST_COMPACT_MAX_TOKENS_PER_SKILL * 4;
|
|
1220
|
-
if (skillContent.length > maxSkillChars) {
|
|
1221
|
-
skillContent =
|
|
1222
|
-
skillContent.slice(0, maxSkillChars) + "\n\n...[truncated]...";
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
const skillTokens = estimateTokens(skillContent);
|
|
1226
|
-
if (skillsUsedTokens + skillTokens > POST_COMPACT_SKILLS_TOKEN_BUDGET)
|
|
1227
|
-
break;
|
|
1228
|
-
skillsUsedTokens += skillTokens;
|
|
1229
|
-
|
|
1230
|
-
invokedSkillParts.push(
|
|
1231
|
-
`\n\n## ${skill.name}\n${skill.description ? `*${skill.description}*\n\n` : ""}\`\`\`\n${skillContent}\n\`\`\``,
|
|
1232
|
-
);
|
|
1233
|
-
} catch {
|
|
1234
|
-
// Skip skills that can't be loaded
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
if (invokedSkillParts.length > 0) {
|
|
1238
|
-
contextParts.push(
|
|
1239
|
-
`\n\n[Invoked Skills]\n${invokedSkillParts.join("")}`,
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// 5. Background subagent status (shell tasks excluded, matching Claude Code's createAsyncAgentAttachmentsIfNeeded)
|
|
1245
|
-
const agents =
|
|
1246
|
-
this.backgroundTaskManager
|
|
1247
|
-
?.getAllTasks()
|
|
1248
|
-
.filter((a) => a.type === "subagent") || [];
|
|
1249
|
-
if (agents.length > 0) {
|
|
1250
|
-
const agentParts: string[] = [];
|
|
1251
|
-
for (const a of agents) {
|
|
1252
|
-
if (a.status === "killed") {
|
|
1253
|
-
agentParts.push(
|
|
1254
|
-
`Task "${a.description}" (${a.id}) was stopped by the user.`,
|
|
1255
|
-
);
|
|
1256
|
-
} else if (a.status === "running") {
|
|
1257
|
-
const parts = [
|
|
1258
|
-
`Background agent "${a.description}" (${a.id}) is still running.`,
|
|
1259
|
-
`Do NOT spawn a duplicate. You will be notified when it completes.`,
|
|
1260
|
-
];
|
|
1261
|
-
if (a.outputPath) {
|
|
1262
|
-
parts.push(`You can read partial output at ${a.outputPath}.`);
|
|
1263
|
-
}
|
|
1264
|
-
agentParts.push(parts.join(" "));
|
|
1265
|
-
} else {
|
|
1266
|
-
// completed or failed
|
|
1267
|
-
const parts = [
|
|
1268
|
-
`Task ${a.id} (status: ${a.status}) (description: ${a.description}).`,
|
|
1269
|
-
];
|
|
1270
|
-
const deltaText = a.status === "failed" ? a.stderr : a.stdout;
|
|
1271
|
-
if (deltaText && deltaText.length > 0) {
|
|
1272
|
-
const summary =
|
|
1273
|
-
deltaText.length > 500
|
|
1274
|
-
? deltaText.slice(0, 500) + "..."
|
|
1275
|
-
: deltaText;
|
|
1276
|
-
parts.push(`Delta: ${summary}`);
|
|
1277
|
-
}
|
|
1278
|
-
if (a.outputPath) {
|
|
1279
|
-
parts.push(
|
|
1280
|
-
`Read the output file to retrieve the result: ${a.outputPath}.`,
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1283
|
-
agentParts.push(parts.join(" "));
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
if (agentParts.length > 0) {
|
|
1287
|
-
contextParts.push(`\n\n[Background Tasks]\n${agentParts.join("\n")}`);
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
return (
|
|
1292
|
-
summary +
|
|
1293
|
-
(contextParts.length > 0
|
|
1294
|
-
? `\n\n[Context Restoration]` + contextParts.join("")
|
|
1295
|
-
: "")
|
|
1296
|
-
);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
public getIsCompacting(): boolean {
|
|
1300
|
-
return this.isCompacting;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
public setIsCompacting(isCompacting: boolean): void {
|
|
1304
|
-
if (this.isCompacting !== isCompacting) {
|
|
1305
|
-
this.isCompacting = isCompacting;
|
|
1306
|
-
this.callbacks.onCompactionStateChange?.(isCompacting);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
private get subagentManager(): SubagentManager | undefined {
|
|
1311
|
-
return this.container.get<SubagentManager>("SubagentManager");
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
private get cronManager(): CronManager | undefined {
|
|
1315
|
-
return this.container.get<CronManager>("CronManager");
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
private get skillManager(): SkillManager | undefined {
|
|
1319
|
-
return this.container.get<SkillManager>("SkillManager");
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
public async sendAIMessage(
|
|
1323
|
-
options: {
|
|
1324
|
-
recursionDepth?: number;
|
|
1325
|
-
model?: string;
|
|
1326
|
-
/** Rules for automatic tool approval (e.g., "Bash(git status*)") */
|
|
1327
|
-
allowedRules?: string[];
|
|
1328
|
-
maxTokens?: number;
|
|
1329
|
-
} = {},
|
|
1330
|
-
): Promise<void> {
|
|
1331
|
-
const { recursionDepth = 0, model, allowedRules, maxTokens } = options;
|
|
1332
|
-
let turnOffset = recursionDepth;
|
|
1333
|
-
|
|
1334
|
-
// Reserve this turn synchronously before any async work. Bumping the
|
|
1335
|
-
// generation invalidates any in-flight turn's end-of-turn cleanup, and
|
|
1336
|
-
// recording myGeneration lets us skip cleanup if a newer turn (or abort)
|
|
1337
|
-
// has superseded us. setIsLoading(true) here also closes the "idle but
|
|
1338
|
-
// previous turn not finished" race window by marking us busy immediately.
|
|
1339
|
-
this.turnGeneration++;
|
|
1340
|
-
let myGeneration = this.turnGeneration;
|
|
1341
|
-
this.setIsLoading(true);
|
|
1342
|
-
|
|
1343
|
-
outer: while (true) {
|
|
1344
|
-
let shouldRestart = false;
|
|
1345
|
-
|
|
1346
|
-
// OpenTelemetry: start interaction span for this turn (initial call only)
|
|
1347
|
-
let turnSequence = 0;
|
|
1348
|
-
if (turnOffset === 0) {
|
|
1349
|
-
const messages = this.messageManager.getMessages();
|
|
1350
|
-
turnSequence = messages.filter((m) => m.role === "user").length;
|
|
1351
|
-
const lastUserMessage = [...messages]
|
|
1352
|
-
.reverse()
|
|
1353
|
-
.find((m) => m.role === "user");
|
|
1354
|
-
const userPromptText =
|
|
1355
|
-
lastUserMessage?.blocks.find((b) => b.type === "text")?.content || "";
|
|
1356
|
-
startInteractionSpan(userPromptText, turnSequence);
|
|
1357
|
-
|
|
1358
|
-
// Log user_prompt event
|
|
1359
|
-
logOTelEvent("user_prompt", {
|
|
1360
|
-
prompt_length: String(userPromptText.length),
|
|
1361
|
-
}).catch(() => {}); // Non-blocking
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// Apply allowed rules for the initial call
|
|
1365
|
-
if (turnOffset === 0) {
|
|
1366
|
-
if (allowedRules && allowedRules.length > 0) {
|
|
1367
|
-
this.permissionManager?.addTemporaryRules(allowedRules);
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// Scan for file mentions in the last user message to trigger conditional rules
|
|
1372
|
-
if (turnOffset === 0) {
|
|
1373
|
-
const messages = this.messageManager.getMessages();
|
|
1374
|
-
const lastMessage = messages[messages.length - 1];
|
|
1375
|
-
if (lastMessage && lastMessage.role === "user") {
|
|
1376
|
-
for (const block of lastMessage.blocks) {
|
|
1377
|
-
if (block.type === "text") {
|
|
1378
|
-
const content = block.content;
|
|
1379
|
-
const fileMentionRegex = /(?:^|\s)@([\w.\-/]+)/g;
|
|
1380
|
-
let match;
|
|
1381
|
-
while ((match = fileMentionRegex.exec(content)) !== null) {
|
|
1382
|
-
const filePath = match[1];
|
|
1383
|
-
this.messageManager.triggerFileRead(filePath);
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// Only create new AbortControllers for the initial call (turnOffset === 0)
|
|
1391
|
-
// For restarts, reuse existing controllers to maintain abort signal
|
|
1392
|
-
let abortController: AbortController;
|
|
1393
|
-
let toolAbortController: AbortController;
|
|
1394
|
-
|
|
1395
|
-
if (turnOffset === 0) {
|
|
1396
|
-
// Create new AbortControllers for initial call
|
|
1397
|
-
abortController = new AbortController();
|
|
1398
|
-
this.abortController = abortController;
|
|
1399
|
-
|
|
1400
|
-
toolAbortController = new AbortController();
|
|
1401
|
-
this.toolAbortController = toolAbortController;
|
|
1402
|
-
} else {
|
|
1403
|
-
// Reuse existing controllers
|
|
1404
|
-
abortController = this.abortController!;
|
|
1405
|
-
toolAbortController = this.toolAbortController!;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
let turnDepth = turnOffset;
|
|
1409
|
-
|
|
1410
|
-
inner: while (true) {
|
|
1411
|
-
let llmSpan: import("@opentelemetry/api").Span | undefined;
|
|
1412
|
-
try {
|
|
1413
|
-
// Save session in each iteration to ensure message persistence
|
|
1414
|
-
await this.messageManager.saveSession();
|
|
1415
|
-
|
|
1416
|
-
// Get current permission mode
|
|
1417
|
-
const currentMode = this.permissionManager?.getCurrentEffectiveMode(
|
|
1418
|
-
this.getModelConfig().permissionMode,
|
|
1419
|
-
);
|
|
1420
|
-
|
|
1421
|
-
// Add plan mode reminder as persistent meta message before getting messages
|
|
1422
|
-
this.maybeAddPlanModeMessage(currentMode);
|
|
1423
|
-
|
|
1424
|
-
// Process conditional rules triggered by file reads (persist as meta messages)
|
|
1425
|
-
const triggeredRules = this.messageManager.processTriggeredRules();
|
|
1426
|
-
for (const rule of triggeredRules) {
|
|
1427
|
-
this.messageManager.addUserMessage({
|
|
1428
|
-
content: `<!-- rule: ${rule.id} -->\n<system-reminder>\nThe following rules from .wave/rules apply to your work. Be sure to adhere to these instructions.\n\n${rule.content}\n</system-reminder>`,
|
|
1429
|
-
isMeta: true,
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// Get recent message history
|
|
1434
|
-
const rawMessages = this.messageManager.getMessages();
|
|
1435
|
-
const currentModelConfig = this.getModelConfig();
|
|
1436
|
-
const recentMessages = convertMessagesForAPI(rawMessages, {
|
|
1437
|
-
supportsVision: supportsVision(currentModelConfig.capabilities),
|
|
1438
|
-
});
|
|
1439
|
-
|
|
1440
|
-
// Track if assistant message has been created
|
|
1441
|
-
let assistantMessageCreated = false;
|
|
1442
|
-
|
|
1443
|
-
logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
|
|
1444
|
-
|
|
1445
|
-
const { toolsConfig, toolNames, filteredToolPlugins } =
|
|
1446
|
-
this.resolveFilteredTools();
|
|
1447
|
-
|
|
1448
|
-
// Get memory for message-array injection (not system prompt)
|
|
1449
|
-
const { prependContent } =
|
|
1450
|
-
await this.messageManager.getMemoryForInjection();
|
|
1451
|
-
|
|
1452
|
-
const mainSystemPrompt =
|
|
1453
|
-
await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
1454
|
-
|
|
1455
|
-
// Call AI service with streaming callbacks if enabled
|
|
1456
|
-
const callAgentOptions: CallAgentOptions = {
|
|
1457
|
-
gatewayConfig: this.getGatewayConfig(),
|
|
1458
|
-
modelConfig: this.getModelConfig(),
|
|
1459
|
-
messages: recentMessages,
|
|
1460
|
-
sessionId: this.messageManager.getSessionId(),
|
|
1461
|
-
abortSignal: abortController.signal,
|
|
1462
|
-
workdir: this.getWorkdir(), // Pass working directory
|
|
1463
|
-
tools: toolsConfig, // Pass filtered tool configuration
|
|
1464
|
-
model: model, // Use passed model
|
|
1465
|
-
systemPrompt: mainSystemPrompt, // Pass custom system prompt
|
|
1466
|
-
maxTokens: maxTokens, // Pass max tokens override
|
|
1467
|
-
toolChoice: this.toolChoiceOverride, // Pass tool_choice override
|
|
1468
|
-
// Fast-model subagents send disable-thinking params only when
|
|
1469
|
-
// explicitly configured (never in the agent loop).
|
|
1470
|
-
disableThinkingOptions:
|
|
1471
|
-
this.modelOverride === "fastModel"
|
|
1472
|
-
? this.getModelConfig().disableThinkingOptions
|
|
1473
|
-
: undefined,
|
|
1474
|
-
};
|
|
1475
|
-
|
|
1476
|
-
// Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
|
|
1477
|
-
if (prependContent.trim()) {
|
|
1478
|
-
callAgentOptions.messages.unshift({
|
|
1479
|
-
role: "user",
|
|
1480
|
-
content: wrapInSystemReminder(prependContent),
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// Task reminder: persist as meta message (conditional rules already persisted above)
|
|
1485
|
-
const taskReminderText =
|
|
1486
|
-
await this.maybeGetTaskReminderText(toolNames);
|
|
1487
|
-
if (taskReminderText) {
|
|
1488
|
-
this.messageManager.addUserMessage({
|
|
1489
|
-
content: taskReminderText,
|
|
1490
|
-
isMeta: true,
|
|
1491
|
-
});
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
// Add streaming callbacks only if streaming is enabled
|
|
1495
|
-
if (this.stream) {
|
|
1496
|
-
callAgentOptions.onContentUpdate = (content: string) => {
|
|
1497
|
-
// Create assistant message on first chunk if not already created
|
|
1498
|
-
if (!assistantMessageCreated) {
|
|
1499
|
-
this.messageManager.addAssistantMessage();
|
|
1500
|
-
assistantMessageCreated = true;
|
|
1501
|
-
}
|
|
1502
|
-
this.messageManager.updateCurrentMessageContent(content);
|
|
1503
|
-
};
|
|
1504
|
-
callAgentOptions.onToolUpdate = (toolCall) => {
|
|
1505
|
-
// Create assistant message on first tool update if not already created
|
|
1506
|
-
if (!assistantMessageCreated) {
|
|
1507
|
-
this.messageManager.addAssistantMessage();
|
|
1508
|
-
assistantMessageCreated = true;
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
// Use parametersChunk as compact param for better performance
|
|
1512
|
-
// No need to extract params or generate compact params during streaming
|
|
1513
|
-
|
|
1514
|
-
// Update tool block with streaming parameters using parametersChunk as compact param
|
|
1515
|
-
this.messageManager.updateToolBlock({
|
|
1516
|
-
id: toolCall.id,
|
|
1517
|
-
name: toolCall.name,
|
|
1518
|
-
parameters: toolCall.parameters,
|
|
1519
|
-
parametersChunk: toolCall.parametersChunk,
|
|
1520
|
-
stage: toolCall.stage || "streaming", // Default to streaming if stage not provided
|
|
1521
|
-
});
|
|
1522
|
-
};
|
|
1523
|
-
callAgentOptions.onReasoningUpdate = (reasoning: string) => {
|
|
1524
|
-
// Create assistant message on first reasoning update if not already created
|
|
1525
|
-
if (!assistantMessageCreated) {
|
|
1526
|
-
this.messageManager.addAssistantMessage();
|
|
1527
|
-
assistantMessageCreated = true;
|
|
1528
|
-
}
|
|
1529
|
-
this.messageManager.updateCurrentMessageReasoning(reasoning);
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
llmSpan = startLLMRequestSpan(
|
|
1534
|
-
model || this.getModelConfig().model || "",
|
|
1535
|
-
{
|
|
1536
|
-
context: "interaction",
|
|
1537
|
-
systemPrompt:
|
|
1538
|
-
typeof callAgentOptions.systemPrompt === "string"
|
|
1539
|
-
? callAgentOptions.systemPrompt
|
|
1540
|
-
: undefined,
|
|
1541
|
-
inputMessages: callAgentOptions.messages,
|
|
1542
|
-
toolsSchema: callAgentOptions.tools
|
|
1543
|
-
? JSON.stringify(callAgentOptions.tools)
|
|
1544
|
-
: undefined,
|
|
1545
|
-
},
|
|
1546
|
-
);
|
|
1547
|
-
|
|
1548
|
-
const result = await aiService.callAgent(callAgentOptions);
|
|
1549
|
-
|
|
1550
|
-
// End LLM span with usage data
|
|
1551
|
-
endLLMRequestSpan(llmSpan, {
|
|
1552
|
-
model: model || this.getModelConfig().model || "",
|
|
1553
|
-
success: true,
|
|
1554
|
-
hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
|
|
1555
|
-
inputTokens: result.usage?.prompt_tokens,
|
|
1556
|
-
outputTokens: result.usage?.completion_tokens,
|
|
1557
|
-
cacheReadTokens: result.usage?.cache_read_input_tokens,
|
|
1558
|
-
cacheCreationTokens: result.usage?.cache_creation_input_tokens,
|
|
1559
|
-
modelOutput: result.content,
|
|
1560
|
-
});
|
|
1561
|
-
|
|
1562
|
-
const createdByStreaming = assistantMessageCreated;
|
|
1563
|
-
|
|
1564
|
-
// For non-streaming mode, create assistant message after callAgent returns
|
|
1565
|
-
// Also create if streaming mode but no streaming callbacks were called (e.g., when content comes directly in result)
|
|
1566
|
-
if (
|
|
1567
|
-
!this.stream ||
|
|
1568
|
-
(!assistantMessageCreated &&
|
|
1569
|
-
(result.content || result.tool_calls || result.reasoning_content))
|
|
1570
|
-
) {
|
|
1571
|
-
this.messageManager.addAssistantMessage();
|
|
1572
|
-
assistantMessageCreated = true;
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
// Log finish reason and response headers if available
|
|
1576
|
-
if (result.finish_reason) {
|
|
1577
|
-
// Log warning headers when finish reason is length
|
|
1578
|
-
if (result.finish_reason === "length") {
|
|
1579
|
-
logger?.warn(
|
|
1580
|
-
"AI response truncated due to length limit. Response headers:",
|
|
1581
|
-
result.response_headers,
|
|
1582
|
-
);
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
if (
|
|
1587
|
-
result.additionalFields &&
|
|
1588
|
-
Object.keys(result.additionalFields).length > 0
|
|
1589
|
-
) {
|
|
1590
|
-
this.messageManager.mergeAssistantAdditionalFields(
|
|
1591
|
-
result.additionalFields,
|
|
1592
|
-
);
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
// Handle result reasoning content from non-streaming mode
|
|
1596
|
-
if (result.reasoning_content && !createdByStreaming) {
|
|
1597
|
-
this.messageManager.updateCurrentMessageReasoning(
|
|
1598
|
-
result.reasoning_content,
|
|
1599
|
-
);
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
// Handle result content from non-streaming mode
|
|
1603
|
-
if (result.content && !createdByStreaming) {
|
|
1604
|
-
this.messageManager.updateCurrentMessageContent(result.content);
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
// Handle usage tracking for agent operations
|
|
1608
|
-
let usage: Usage | undefined;
|
|
1609
|
-
if (result.usage) {
|
|
1610
|
-
usage = {
|
|
1611
|
-
prompt_tokens: result.usage.prompt_tokens,
|
|
1612
|
-
completion_tokens: result.usage.completion_tokens,
|
|
1613
|
-
total_tokens: result.usage.total_tokens,
|
|
1614
|
-
model: model || this.getModelConfig().model,
|
|
1615
|
-
operation_type: "agent",
|
|
1616
|
-
// Preserve cache fields if present
|
|
1617
|
-
...(result.usage.cache_read_input_tokens !== undefined && {
|
|
1618
|
-
cache_read_input_tokens: result.usage.cache_read_input_tokens,
|
|
1619
|
-
}),
|
|
1620
|
-
...(result.usage.cache_creation_input_tokens !== undefined && {
|
|
1621
|
-
cache_creation_input_tokens:
|
|
1622
|
-
result.usage.cache_creation_input_tokens,
|
|
1623
|
-
}),
|
|
1624
|
-
...(result.usage.cache_creation && {
|
|
1625
|
-
cache_creation: result.usage.cache_creation,
|
|
1626
|
-
}),
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
// Set usage on the assistant message if available
|
|
1631
|
-
if (usage) {
|
|
1632
|
-
const messages = this.messageManager.getMessages();
|
|
1633
|
-
const lastMessage = messages[messages.length - 1];
|
|
1634
|
-
if (lastMessage && lastMessage.role === "assistant") {
|
|
1635
|
-
lastMessage.usage = usage;
|
|
1636
|
-
this.messageManager.setMessages(messages);
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
// Notify Agent to add to usage tracking
|
|
1640
|
-
if (this.callbacks?.onUsageAdded) {
|
|
1641
|
-
this.callbacks.onUsageAdded(usage);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
// Collect tool calls for processing
|
|
1646
|
-
const toolCalls: ChatCompletionMessageFunctionToolCall[] = [];
|
|
1647
|
-
if (result.tool_calls) {
|
|
1648
|
-
for (const toolCall of result.tool_calls) {
|
|
1649
|
-
if (toolCall.type === "function") {
|
|
1650
|
-
toolCalls.push(toolCall);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
if (toolCalls.length > 0) {
|
|
1656
|
-
// Partition tool calls into batches: consecutive concurrency-safe
|
|
1657
|
-
// tools run in parallel, non-safe tools (Edit, Write, MCP) run one
|
|
1658
|
-
// at a time to prevent read-modify-write races on the same file.
|
|
1659
|
-
const batches: {
|
|
1660
|
-
calls: ChatCompletionMessageFunctionToolCall[];
|
|
1661
|
-
safe: boolean;
|
|
1662
|
-
}[] = [];
|
|
1663
|
-
for (const call of toolCalls) {
|
|
1664
|
-
const toolName = call.function?.name || "";
|
|
1665
|
-
const safe = this.toolManager.isConcurrencySafe(toolName);
|
|
1666
|
-
const lastBatch = batches[batches.length - 1];
|
|
1667
|
-
if (lastBatch && lastBatch.safe && safe) {
|
|
1668
|
-
lastBatch.calls.push(call);
|
|
1669
|
-
} else {
|
|
1670
|
-
batches.push({ calls: [call], safe });
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
// Execute batches sequentially; within a safe batch, tools run in parallel
|
|
1675
|
-
for (const batch of batches) {
|
|
1676
|
-
if (
|
|
1677
|
-
abortController.signal.aborted ||
|
|
1678
|
-
toolAbortController.signal.aborted
|
|
1679
|
-
) {
|
|
1680
|
-
break;
|
|
1681
|
-
}
|
|
1682
|
-
if (batch.calls.length === 1) {
|
|
1683
|
-
await this.executeToolCall(
|
|
1684
|
-
batch.calls[0],
|
|
1685
|
-
abortController,
|
|
1686
|
-
toolAbortController,
|
|
1687
|
-
result.finish_reason,
|
|
1688
|
-
);
|
|
1689
|
-
} else {
|
|
1690
|
-
await Promise.all(
|
|
1691
|
-
batch.calls.map((call) =>
|
|
1692
|
-
this.executeToolCall(
|
|
1693
|
-
call,
|
|
1694
|
-
abortController,
|
|
1695
|
-
toolAbortController,
|
|
1696
|
-
result.finish_reason,
|
|
1697
|
-
),
|
|
1698
|
-
),
|
|
1699
|
-
);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
// Handle token statistics and message compaction
|
|
1705
|
-
await this.handleTokenUsageAndCompaction(
|
|
1706
|
-
result.usage,
|
|
1707
|
-
abortController,
|
|
1708
|
-
);
|
|
1709
|
-
|
|
1710
|
-
// Finalize text/reasoning blocks for the final response (no tools)
|
|
1711
|
-
this.messageManager.finalizeStreamingBlocks();
|
|
1712
|
-
|
|
1713
|
-
// Check if there are tool operations or response was truncated, if so automatically initiate next AI service call
|
|
1714
|
-
if (toolCalls.length > 0 || result.finish_reason === "length") {
|
|
1715
|
-
// Check maxTurns limit before continuing
|
|
1716
|
-
if (this.maxTurns && turnDepth + 1 >= this.maxTurns) {
|
|
1717
|
-
logger?.debug(`Max turns (${this.maxTurns}) reached, stopping.`);
|
|
1718
|
-
} else {
|
|
1719
|
-
// Record committed snapshots to message history
|
|
1720
|
-
if (this.reversionManager) {
|
|
1721
|
-
const snapshots =
|
|
1722
|
-
this.reversionManager.getAndClearCommittedSnapshots();
|
|
1723
|
-
if (snapshots.length > 0) {
|
|
1724
|
-
this.messageManager.addFileHistoryBlock(snapshots);
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
// Check interruption status
|
|
1729
|
-
const isCurrentlyAborted =
|
|
1730
|
-
abortController.signal.aborted ||
|
|
1731
|
-
toolAbortController.signal.aborted;
|
|
1732
|
-
|
|
1733
|
-
// Check if all tools were manually backgrounded
|
|
1734
|
-
const lastMessage =
|
|
1735
|
-
this.messageManager.getMessages()[
|
|
1736
|
-
this.messageManager.getMessages().length - 1
|
|
1737
|
-
];
|
|
1738
|
-
const toolBlocks =
|
|
1739
|
-
lastMessage?.blocks.filter(
|
|
1740
|
-
(block): block is import("../types/messaging.js").ToolBlock =>
|
|
1741
|
-
block.type === "tool",
|
|
1742
|
-
) || [];
|
|
1743
|
-
const hasBackgrounded =
|
|
1744
|
-
toolBlocks.length > 0 &&
|
|
1745
|
-
toolBlocks.some((block) => block.isManuallyBackgrounded);
|
|
1746
|
-
|
|
1747
|
-
if (hasBackgrounded) {
|
|
1748
|
-
logger?.info(
|
|
1749
|
-
"Some tools were manually backgrounded, stopping.",
|
|
1750
|
-
);
|
|
1751
|
-
} else if (!isCurrentlyAborted) {
|
|
1752
|
-
// If response was truncated, add a hidden continuation message
|
|
1753
|
-
if (result.finish_reason === "length") {
|
|
1754
|
-
this.messageManager.addUserMessage({
|
|
1755
|
-
content:
|
|
1756
|
-
"Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.",
|
|
1757
|
-
isMeta: true,
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
// Duplicate Tool Call Detection
|
|
1762
|
-
if (toolCalls.length > 0) {
|
|
1763
|
-
const messages = this.messageManager.getMessages();
|
|
1764
|
-
// Find the most recent assistant message BEFORE the current one that has tool blocks
|
|
1765
|
-
// The current assistant message is messages[messages.length - 1]
|
|
1766
|
-
let previousAssistantWithTools: Message | undefined;
|
|
1767
|
-
for (let i = messages.length - 2; i >= 0; i--) {
|
|
1768
|
-
const msg = messages[i];
|
|
1769
|
-
if (
|
|
1770
|
-
msg.role === "assistant" &&
|
|
1771
|
-
msg.blocks.some((b) => b.type === "tool")
|
|
1772
|
-
) {
|
|
1773
|
-
previousAssistantWithTools = msg;
|
|
1774
|
-
break;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
if (previousAssistantWithTools) {
|
|
1779
|
-
const previousToolBlocks =
|
|
1780
|
-
previousAssistantWithTools.blocks.filter(
|
|
1781
|
-
(b): b is import("../types/messaging.js").ToolBlock =>
|
|
1782
|
-
b.type === "tool",
|
|
1783
|
-
);
|
|
1784
|
-
|
|
1785
|
-
for (const currentToolCall of toolCalls) {
|
|
1786
|
-
const currentName = currentToolCall.function?.name;
|
|
1787
|
-
const currentArgs = currentToolCall.function?.arguments;
|
|
1788
|
-
|
|
1789
|
-
const isDuplicate = previousToolBlocks.some(
|
|
1790
|
-
(prevBlock) =>
|
|
1791
|
-
prevBlock.name === currentName &&
|
|
1792
|
-
prevBlock.parameters === currentArgs,
|
|
1793
|
-
);
|
|
1794
|
-
|
|
1795
|
-
if (isDuplicate && currentName) {
|
|
1796
|
-
const toolId = currentToolCall.id;
|
|
1797
|
-
const lastMessage = messages[messages.length - 1];
|
|
1798
|
-
const toolBlock = lastMessage.blocks.find(
|
|
1799
|
-
(b): b is import("../types/messaging.js").ToolBlock =>
|
|
1800
|
-
b.type === "tool" && b.id === toolId,
|
|
1801
|
-
);
|
|
1802
|
-
if (toolBlock) {
|
|
1803
|
-
const warning = `\n\nNote: You just called this tool with the same arguments in the previous turn. Please ensure you are not in a loop and consider if you need to change your approach.`;
|
|
1804
|
-
this.messageManager.updateToolBlock({
|
|
1805
|
-
id: toolId,
|
|
1806
|
-
result: (toolBlock.result || "") + warning,
|
|
1807
|
-
stage: "end",
|
|
1808
|
-
});
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
// Yield to the event loop so macrotasks (abort timers,
|
|
1816
|
-
// signals) can be processed between turns. Without this,
|
|
1817
|
-
// mocked async operations (microtasks) would starve the
|
|
1818
|
-
// event loop and abort timers would never fire.
|
|
1819
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
1820
|
-
|
|
1821
|
-
// Re-check abort status after yielding — the signal may
|
|
1822
|
-
// have fired during the setImmediate gap.
|
|
1823
|
-
if (
|
|
1824
|
-
abortController.signal.aborted ||
|
|
1825
|
-
toolAbortController.signal.aborted
|
|
1826
|
-
) {
|
|
1827
|
-
break inner;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
turnDepth++;
|
|
1831
|
-
continue inner;
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
// No tool calls (or stop conditions) → inner loop done
|
|
1837
|
-
break inner;
|
|
1838
|
-
} catch (error) {
|
|
1839
|
-
// End LLM span with error
|
|
1840
|
-
endLLMRequestSpan(llmSpan, {
|
|
1841
|
-
model: model || this.getModelConfig().model || "",
|
|
1842
|
-
success: false,
|
|
1843
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1844
|
-
});
|
|
1845
|
-
|
|
1846
|
-
// Log error event
|
|
1847
|
-
logOTelEvent("error", {
|
|
1848
|
-
error_type:
|
|
1849
|
-
error instanceof Error ? error.constructor.name : "Unknown",
|
|
1850
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1851
|
-
}).catch(() => {}); // Non-blocking
|
|
1852
|
-
|
|
1853
|
-
// Finalize any streaming text/reasoning blocks so the UI stops ticking
|
|
1854
|
-
// its in-progress timer (e.g. when the request is aborted mid-thought).
|
|
1855
|
-
this.messageManager.finalizeStreamingBlocks();
|
|
1856
|
-
|
|
1857
|
-
// Finalize any tool blocks stuck in start/streaming/running so the UI
|
|
1858
|
-
// stops showing the yellow "running" spinner (e.g. abort mid-tool-stream).
|
|
1859
|
-
this.messageManager.finalizeAbortedToolBlocks(
|
|
1860
|
-
error instanceof Error ? error.message : undefined,
|
|
1861
|
-
);
|
|
1862
|
-
|
|
1863
|
-
this.messageManager.addErrorBlock(
|
|
1864
|
-
error instanceof Error ? error.message : "Unknown error occurred",
|
|
1865
|
-
);
|
|
1866
|
-
|
|
1867
|
-
// Exit inner loop on error
|
|
1868
|
-
break inner;
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Finally-equivalent (runs once per outer iteration):
|
|
1873
|
-
// Only execute cleanup and hooks for the initial call
|
|
1874
|
-
if (turnOffset === 0) {
|
|
1875
|
-
// OpenTelemetry: end interaction span
|
|
1876
|
-
endInteractionSpan();
|
|
1877
|
-
|
|
1878
|
-
// Save session in each iteration to ensure message persistence
|
|
1879
|
-
await this.messageManager.saveSession();
|
|
1880
|
-
|
|
1881
|
-
// Clear temporary rules
|
|
1882
|
-
this.permissionManager?.clearTemporaryRules();
|
|
1883
|
-
|
|
1884
|
-
// Clear abort controllers
|
|
1885
|
-
this.abortController = null;
|
|
1886
|
-
this.toolAbortController = null;
|
|
1887
|
-
|
|
1888
|
-
// Execute Stop/SubagentStop hooks only if the operation was not aborted
|
|
1889
|
-
const isCurrentlyAborted =
|
|
1890
|
-
abortController.signal.aborted || toolAbortController.signal.aborted;
|
|
1891
|
-
|
|
1892
|
-
if (!isCurrentlyAborted) {
|
|
1893
|
-
// Record committed snapshots to message history for the final turn
|
|
1894
|
-
if (this.reversionManager) {
|
|
1895
|
-
const snapshots =
|
|
1896
|
-
this.reversionManager.getAndClearCommittedSnapshots();
|
|
1897
|
-
if (snapshots.length > 0) {
|
|
1898
|
-
this.messageManager.addFileHistoryBlock(snapshots);
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// Execute Stop hooks
|
|
1903
|
-
const shouldContinue = await this.executeStopHooks();
|
|
1904
|
-
|
|
1905
|
-
// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
|
|
1906
|
-
// restart the AI conversation cycle
|
|
1907
|
-
if (shouldContinue) {
|
|
1908
|
-
logger?.info(
|
|
1909
|
-
`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
|
|
1910
|
-
);
|
|
1911
|
-
|
|
1912
|
-
// Restart the conversation to let AI fix the issues
|
|
1913
|
-
shouldRestart = true;
|
|
1914
|
-
turnOffset = 0;
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
// Inject pending notifications from background tasks (after Stop hooks,
|
|
1919
|
-
// aligned with Claude Code which fires Stop hooks unconditionally)
|
|
1920
|
-
const messageQueue = this.container.has("MessageQueue")
|
|
1921
|
-
? this.container.get<MessageQueue>("MessageQueue")
|
|
1922
|
-
: undefined;
|
|
1923
|
-
if (messageQueue && messageQueue.hasNotifications()) {
|
|
1924
|
-
const notifications = messageQueue.drainNotifications();
|
|
1925
|
-
for (const notification of notifications) {
|
|
1926
|
-
const block = parseTaskNotificationXml(notification);
|
|
1927
|
-
if (block) {
|
|
1928
|
-
this.messageManager.addNotificationMessage({
|
|
1929
|
-
taskId: block.taskId,
|
|
1930
|
-
taskType: block.taskType,
|
|
1931
|
-
status: block.status,
|
|
1932
|
-
summary: block.summary,
|
|
1933
|
-
outputFile: block.outputFile,
|
|
1934
|
-
});
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
// Re-assert loading state before restarting: if this turn was
|
|
1938
|
-
// aborted, abortAIMessage already reset loading to false, and the
|
|
1939
|
-
// restart below continues the conversation — the UI must show
|
|
1940
|
-
// streaming again (cursor, stop button, ESC handling).
|
|
1941
|
-
this.setIsLoading(true);
|
|
1942
|
-
// Adopt the current (possibly abort-bumped) generation so this
|
|
1943
|
-
// continued turn's end-of-turn cleanup is not skipped as superseded.
|
|
1944
|
-
myGeneration = this.turnGeneration;
|
|
1945
|
-
// Restart outer loop to process the notifications
|
|
1946
|
-
shouldRestart = true;
|
|
1947
|
-
turnOffset = 0;
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
if (!shouldRestart) {
|
|
1952
|
-
// Release loading state only after ALL cleanup is done (session save,
|
|
1953
|
-
// Stop hooks, notification injection). Generation check ensures a
|
|
1954
|
-
// superseded (aborted or newer) turn doesn't clobber the current
|
|
1955
|
-
// loading state.
|
|
1956
|
-
if (myGeneration === this.turnGeneration) {
|
|
1957
|
-
this.setIsLoading(false);
|
|
1958
|
-
}
|
|
1959
|
-
break outer;
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
/**
|
|
1965
|
-
* Execute Stop or SubagentStop hooks when AI response cycle completes
|
|
1966
|
-
* Uses "SubagentStop" hook name when triggered by a subagent, otherwise uses "Stop"
|
|
1967
|
-
* @returns Promise<boolean> - true if should continue conversation, false if should stop
|
|
1968
|
-
*/
|
|
1969
|
-
private async executeStopHooks(): Promise<boolean> {
|
|
1970
|
-
if (!this.hookManager) return false;
|
|
1971
|
-
|
|
1972
|
-
try {
|
|
1973
|
-
// Use "SubagentStop" hook name when triggered by a subagent, otherwise use "Stop"
|
|
1974
|
-
const hookName = this.subagentType ? "SubagentStop" : "Stop";
|
|
1975
|
-
|
|
1976
|
-
// For main-agent Stop events, snapshot running background tasks and
|
|
1977
|
-
// session cron jobs so the hook can decide whether to block stopping.
|
|
1978
|
-
// SubagentStop does not include these fields (per spec FR-065).
|
|
1979
|
-
const backgroundTasks: BackgroundTaskInfo[] | undefined =
|
|
1980
|
-
hookName === "Stop"
|
|
1981
|
-
? (this.backgroundTaskManager?.getAllTasks() ?? [])
|
|
1982
|
-
.filter((t) => t.status === "running")
|
|
1983
|
-
.map((t) => mapBackgroundTask(t, this.subagentManager))
|
|
1984
|
-
: undefined;
|
|
1985
|
-
|
|
1986
|
-
const sessionCrons: SessionCronInfo[] | undefined =
|
|
1987
|
-
hookName === "Stop"
|
|
1988
|
-
? (this.cronManager?.listJobs() ?? []).map((j) => ({
|
|
1989
|
-
id: j.id,
|
|
1990
|
-
schedule: j.cron,
|
|
1991
|
-
recurring: j.recurring,
|
|
1992
|
-
prompt: j.prompt,
|
|
1993
|
-
}))
|
|
1994
|
-
: undefined;
|
|
1995
|
-
|
|
1996
|
-
// Extract text content from the last assistant message so hooks can
|
|
1997
|
-
// inspect the final response without reading the transcript file.
|
|
1998
|
-
const allMessages = this.messageManager.getMessages();
|
|
1999
|
-
const lastAssistant = [...allMessages]
|
|
2000
|
-
.reverse()
|
|
2001
|
-
.find((m) => m.role === "assistant");
|
|
2002
|
-
const lastAssistantText = lastAssistant
|
|
2003
|
-
? lastAssistant.blocks
|
|
2004
|
-
.filter((b) => b.type === "text")
|
|
2005
|
-
.map((b) => b.content)
|
|
2006
|
-
.join("\n")
|
|
2007
|
-
.trim() || undefined
|
|
2008
|
-
: undefined;
|
|
2009
|
-
|
|
2010
|
-
const context: ExtendedHookExecutionContext = {
|
|
2011
|
-
event: hookName,
|
|
2012
|
-
projectDir: this.getWorkdir(),
|
|
2013
|
-
timestamp: new Date(),
|
|
2014
|
-
sessionId: this.messageManager.getSessionId(),
|
|
2015
|
-
transcriptPath: this.messageManager.getTranscriptPath(),
|
|
2016
|
-
cwd: this.getWorkdir(),
|
|
2017
|
-
subagentType: this.subagentType, // Include subagent type in hook context
|
|
2018
|
-
backgroundTasks, // Stop-only: running background tasks snapshot
|
|
2019
|
-
sessionCrons, // Stop-only: session cron jobs snapshot
|
|
2020
|
-
lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
|
|
2021
|
-
// Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
|
|
2022
|
-
env: Object.fromEntries(
|
|
2023
|
-
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2024
|
-
) as Record<string, string>, // Include environment variables
|
|
2025
|
-
};
|
|
2026
|
-
|
|
2027
|
-
const results = await this.hookManager.executeHooks(hookName, context);
|
|
2028
|
-
|
|
2029
|
-
// Process hook results to handle exit codes and appropriate responses
|
|
2030
|
-
let shouldContinue = false;
|
|
2031
|
-
if (results.length > 0) {
|
|
2032
|
-
const processResult = this.hookManager.processHookResults(
|
|
2033
|
-
hookName,
|
|
2034
|
-
results,
|
|
2035
|
-
this.messageManager,
|
|
2036
|
-
);
|
|
2037
|
-
|
|
2038
|
-
// If hook processing indicates we should block (exit code 2), continue conversation
|
|
2039
|
-
if (processResult.shouldBlock) {
|
|
2040
|
-
logger?.info(
|
|
2041
|
-
`${hookName} hook blocked stopping with error:`,
|
|
2042
|
-
processResult.errorMessage,
|
|
2043
|
-
);
|
|
2044
|
-
shouldContinue = true;
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
// Log hook execution results for debugging
|
|
2049
|
-
if (results.length > 0) {
|
|
2050
|
-
logger?.debug(
|
|
2051
|
-
`Executed ${results.length} ${hookName} hook(s):`,
|
|
2052
|
-
results.map((r) => ({
|
|
2053
|
-
success: r.success,
|
|
2054
|
-
duration: r.duration,
|
|
2055
|
-
exitCode: r.exitCode,
|
|
2056
|
-
timedOut: r.timedOut,
|
|
2057
|
-
stderr: r.stderr,
|
|
2058
|
-
})),
|
|
2059
|
-
);
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// Trigger auto-memory extraction if enabled and this is the main agent
|
|
2063
|
-
if (!this.subagentType) {
|
|
2064
|
-
const autoMemoryService =
|
|
2065
|
-
this.container.get<
|
|
2066
|
-
import("../services/autoMemoryService.js").AutoMemoryService
|
|
2067
|
-
>("AutoMemoryService");
|
|
2068
|
-
if (autoMemoryService) {
|
|
2069
|
-
// Trigger extraction, but don't block the return.
|
|
2070
|
-
// onTurnEnd itself returns quickly after forking.
|
|
2071
|
-
autoMemoryService.onTurnEnd(this.getWorkdir()).catch((err) => {
|
|
2072
|
-
logger?.error("Auto-memory extraction trigger failed:", err);
|
|
2073
|
-
});
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
return shouldContinue;
|
|
2078
|
-
} catch (error) {
|
|
2079
|
-
// Hook execution errors should not interrupt the main workflow
|
|
2080
|
-
logger?.error(
|
|
2081
|
-
`${this.subagentType ? "SubagentStop" : "Stop"} hook execution failed:`,
|
|
2082
|
-
error,
|
|
2083
|
-
);
|
|
2084
|
-
return false;
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
/**
|
|
2089
|
-
* Execute a single tool call: arg parsing, block emission, PreToolUse hooks,
|
|
2090
|
-
* tool execution, PostToolUse hooks, and error handling.
|
|
2091
|
-
*/
|
|
2092
|
-
private async executeToolCall(
|
|
2093
|
-
functionToolCall: ChatCompletionMessageFunctionToolCall,
|
|
2094
|
-
abortController: AbortController,
|
|
2095
|
-
toolAbortController: AbortController,
|
|
2096
|
-
finishReason?: string | null,
|
|
2097
|
-
): Promise<void> {
|
|
2098
|
-
const toolId = functionToolCall.id || "";
|
|
2099
|
-
|
|
2100
|
-
// Check if already interrupted, skip tool execution if so
|
|
2101
|
-
if (abortController.signal.aborted || toolAbortController.signal.aborted) {
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
const toolName = functionToolCall.function?.name || "";
|
|
2106
|
-
// Safely parse tool parameters, handle tools without parameters
|
|
2107
|
-
let toolArgs: Record<string, unknown> = {};
|
|
2108
|
-
let jsonRecovered = false;
|
|
2109
|
-
const argsString = functionToolCall.function?.arguments?.trim();
|
|
2110
|
-
|
|
2111
|
-
if (!argsString || argsString === "") {
|
|
2112
|
-
// Tool without parameters, use empty object
|
|
2113
|
-
toolArgs = {};
|
|
2114
|
-
} else {
|
|
2115
|
-
let recoveredArgs = argsString;
|
|
2116
|
-
try {
|
|
2117
|
-
toolArgs = JSON.parse(argsString);
|
|
2118
|
-
} catch {
|
|
2119
|
-
// Attempt to recover truncated JSON (e.g., missing closing braces)
|
|
2120
|
-
recoveredArgs = recoverTruncatedJson(argsString);
|
|
2121
|
-
try {
|
|
2122
|
-
toolArgs = JSON.parse(recoveredArgs);
|
|
2123
|
-
jsonRecovered = true;
|
|
2124
|
-
logger.warn(`Recovered truncated JSON for tool "${toolName}"`);
|
|
2125
|
-
} catch (parseError) {
|
|
2126
|
-
let errorMessage = `Failed to parse tool arguments`;
|
|
2127
|
-
if (finishReason === "length") {
|
|
2128
|
-
errorMessage += " (output truncated, please reduce your output)";
|
|
2129
|
-
}
|
|
2130
|
-
logger?.error(errorMessage, parseError);
|
|
2131
|
-
this.messageManager.updateToolBlock({
|
|
2132
|
-
id: toolId,
|
|
2133
|
-
parameters: argsString,
|
|
2134
|
-
result: errorMessage,
|
|
2135
|
-
success: false,
|
|
2136
|
-
error: errorMessage,
|
|
2137
|
-
stage: "end",
|
|
2138
|
-
name: toolName,
|
|
2139
|
-
compactParams: "",
|
|
2140
|
-
timestamp: Date.now(),
|
|
2141
|
-
});
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
const compactParams = this.generateCompactParams(toolName, toolArgs);
|
|
2148
|
-
|
|
2149
|
-
// Emit start stage for non-streaming tool calls
|
|
2150
|
-
if (!this.stream) {
|
|
2151
|
-
this.messageManager.updateToolBlock({
|
|
2152
|
-
id: toolId,
|
|
2153
|
-
stage: "start",
|
|
2154
|
-
name: toolName,
|
|
2155
|
-
compactParams,
|
|
2156
|
-
parameters: argsString,
|
|
2157
|
-
});
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
// Emit running stage (tool execution about to start)
|
|
2161
|
-
this.messageManager.updateToolBlock({
|
|
2162
|
-
id: toolId,
|
|
2163
|
-
stage: "running",
|
|
2164
|
-
name: toolName,
|
|
2165
|
-
compactParams,
|
|
2166
|
-
parameters: argsString,
|
|
2167
|
-
parametersChunk: "",
|
|
2168
|
-
});
|
|
2169
|
-
|
|
2170
|
-
try {
|
|
2171
|
-
// Execute PreToolUse hooks before tool execution
|
|
2172
|
-
const shouldExecuteTool = await this.executePreToolUseHooks(
|
|
2173
|
-
toolName,
|
|
2174
|
-
toolArgs,
|
|
2175
|
-
toolId,
|
|
2176
|
-
);
|
|
2177
|
-
|
|
2178
|
-
// If PreToolUse hooks blocked execution, skip tool execution
|
|
2179
|
-
if (!shouldExecuteTool) {
|
|
2180
|
-
logger?.info(`Tool ${toolName} execution blocked by PreToolUse hooks`);
|
|
2181
|
-
return;
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
// Create tool execution context
|
|
2185
|
-
const context: ToolContext = {
|
|
2186
|
-
abortSignal: toolAbortController.signal,
|
|
2187
|
-
backgroundTaskManager: this.backgroundTaskManager,
|
|
2188
|
-
workdir: this.getWorkdir(),
|
|
2189
|
-
originalWorkdir: this.originalWorkdir,
|
|
2190
|
-
messageId: this.messageManager.getMessages().slice(-1)[0]?.id,
|
|
2191
|
-
sessionId: this.messageManager.getSessionId(),
|
|
2192
|
-
toolCallId: toolId,
|
|
2193
|
-
taskManager: this.taskManager,
|
|
2194
|
-
readFileState: this.readFileState,
|
|
2195
|
-
onShortResultUpdate: (shortResult: string) => {
|
|
2196
|
-
this.messageManager.updateToolBlock({
|
|
2197
|
-
id: toolId,
|
|
2198
|
-
shortResult,
|
|
2199
|
-
stage: "running",
|
|
2200
|
-
compactParams,
|
|
2201
|
-
name: toolName,
|
|
2202
|
-
});
|
|
2203
|
-
},
|
|
2204
|
-
onResultUpdate: (result: string) => {
|
|
2205
|
-
this.messageManager.updateToolBlock({
|
|
2206
|
-
id: toolId,
|
|
2207
|
-
result,
|
|
2208
|
-
stage: "running",
|
|
2209
|
-
compactParams,
|
|
2210
|
-
name: toolName,
|
|
2211
|
-
});
|
|
2212
|
-
},
|
|
2213
|
-
onCwdChange: async (newCwd: string) => {
|
|
2214
|
-
const oldCwd = this.getWorkdir();
|
|
2215
|
-
this.container.register("Workdir", newCwd);
|
|
2216
|
-
this._onCwdChange?.(newCwd);
|
|
2217
|
-
if (this.hookManager) {
|
|
2218
|
-
const sessionId = this.messageManager.getSessionId();
|
|
2219
|
-
const transcriptPath = this.messageManager.getTranscriptPath();
|
|
2220
|
-
const env = Object.fromEntries(
|
|
2221
|
-
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2222
|
-
) as Record<string, string>;
|
|
2223
|
-
await this.hookManager.executeCwdChangedHooks(
|
|
2224
|
-
oldCwd,
|
|
2225
|
-
newCwd,
|
|
2226
|
-
sessionId,
|
|
2227
|
-
transcriptPath,
|
|
2228
|
-
env,
|
|
2229
|
-
);
|
|
2230
|
-
}
|
|
2231
|
-
},
|
|
2232
|
-
};
|
|
2233
|
-
|
|
2234
|
-
// Execute tool
|
|
2235
|
-
const toolResult = await this.toolManager.execute(
|
|
2236
|
-
functionToolCall.function?.name || "",
|
|
2237
|
-
toolArgs,
|
|
2238
|
-
context,
|
|
2239
|
-
);
|
|
2240
|
-
|
|
2241
|
-
// Build result content, adding truncation warning if JSON was recovered
|
|
2242
|
-
let toolResultContent =
|
|
2243
|
-
toolResult.content ||
|
|
2244
|
-
(toolResult.error ? `Error: ${toolResult.error}` : "");
|
|
2245
|
-
if (jsonRecovered) {
|
|
2246
|
-
toolResultContent +=
|
|
2247
|
-
"\n\nTool arguments were truncated (likely exceeded max output tokens). Please reduce your output or split into multiple tool calls.";
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
// Update message state - tool execution completed
|
|
2251
|
-
this.messageManager.updateToolBlock({
|
|
2252
|
-
id: toolId,
|
|
2253
|
-
parameters: argsString,
|
|
2254
|
-
result: toolResultContent,
|
|
2255
|
-
success: toolResult.success,
|
|
2256
|
-
error: toolResult.error,
|
|
2257
|
-
stage: "end",
|
|
2258
|
-
name: toolName,
|
|
2259
|
-
compactParams,
|
|
2260
|
-
shortResult: toolResult.shortResult,
|
|
2261
|
-
isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
|
|
2262
|
-
startLineNumber: toolResult.startLineNumber,
|
|
2263
|
-
images: toolResult.images,
|
|
2264
|
-
timestamp: Date.now(),
|
|
2265
|
-
});
|
|
2266
|
-
|
|
2267
|
-
// Execute PostToolUse hooks after successful tool completion
|
|
2268
|
-
await this.executePostToolUseHooks(
|
|
2269
|
-
toolId,
|
|
2270
|
-
toolName,
|
|
2271
|
-
toolArgs,
|
|
2272
|
-
toolResult,
|
|
2273
|
-
);
|
|
2274
|
-
} catch (toolError) {
|
|
2275
|
-
const errorMessage =
|
|
2276
|
-
toolError instanceof Error ? toolError.message : String(toolError);
|
|
2277
|
-
|
|
2278
|
-
this.messageManager.updateToolBlock({
|
|
2279
|
-
id: toolId,
|
|
2280
|
-
parameters: JSON.stringify(toolArgs, null, 2),
|
|
2281
|
-
result: `Tool execution failed: ${errorMessage}`,
|
|
2282
|
-
success: false,
|
|
2283
|
-
error: errorMessage,
|
|
2284
|
-
stage: "end",
|
|
2285
|
-
name: toolName,
|
|
2286
|
-
compactParams,
|
|
2287
|
-
isManuallyBackgrounded: false,
|
|
2288
|
-
timestamp: Date.now(),
|
|
2289
|
-
});
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
/**
|
|
2294
|
-
* Execute PreToolUse hooks before tool execution
|
|
2295
|
-
* Returns true if hooks allow tool execution, false if blocked
|
|
2296
|
-
*/
|
|
2297
|
-
private async executePreToolUseHooks(
|
|
2298
|
-
toolName: string,
|
|
2299
|
-
toolInput?: Record<string, unknown>,
|
|
2300
|
-
toolId?: string,
|
|
2301
|
-
): Promise<boolean> {
|
|
2302
|
-
if (!this.hookManager) return true;
|
|
2303
|
-
|
|
2304
|
-
try {
|
|
2305
|
-
const context: ExtendedHookExecutionContext = {
|
|
2306
|
-
event: "PreToolUse",
|
|
2307
|
-
projectDir: this.getWorkdir(),
|
|
2308
|
-
timestamp: new Date(),
|
|
2309
|
-
toolName,
|
|
2310
|
-
sessionId: this.messageManager.getSessionId(),
|
|
2311
|
-
transcriptPath: this.messageManager.getTranscriptPath(),
|
|
2312
|
-
cwd: this.getWorkdir(),
|
|
2313
|
-
toolInput,
|
|
2314
|
-
subagentType: this.subagentType, // Include subagent type in hook context
|
|
2315
|
-
env: Object.fromEntries(
|
|
2316
|
-
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2317
|
-
) as Record<string, string>, // Include environment variables
|
|
2318
|
-
};
|
|
2319
|
-
|
|
2320
|
-
const results = await this.hookManager.executeHooks(
|
|
2321
|
-
"PreToolUse",
|
|
2322
|
-
context,
|
|
2323
|
-
);
|
|
2324
|
-
|
|
2325
|
-
// Process hook results to handle exit codes and determine if tool should be blocked
|
|
2326
|
-
let shouldContinue = true;
|
|
2327
|
-
if (results.length > 0) {
|
|
2328
|
-
const processResult = this.hookManager.processHookResults(
|
|
2329
|
-
"PreToolUse",
|
|
2330
|
-
results,
|
|
2331
|
-
this.messageManager,
|
|
2332
|
-
toolId, // Pass toolId for proper PreToolUse blocking error handling
|
|
2333
|
-
JSON.stringify(toolInput || {}, null, 2), // Pass serialized tool parameters
|
|
2334
|
-
);
|
|
2335
|
-
shouldContinue = !processResult.shouldBlock;
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// Log tool_decision event
|
|
2339
|
-
logOTelEvent("tool_decision", {
|
|
2340
|
-
tool_name: toolName,
|
|
2341
|
-
decision: shouldContinue ? "approved" : "blocked",
|
|
2342
|
-
source: "hook",
|
|
2343
|
-
}).catch(() => {}); // Non-blocking
|
|
2344
|
-
|
|
2345
|
-
// Log hook execution results for debugging
|
|
2346
|
-
if (results.length > 0) {
|
|
2347
|
-
logger?.debug(
|
|
2348
|
-
`Executed ${results.length} PreToolUse hook(s) for ${toolName}:`,
|
|
2349
|
-
results.map((r) => ({
|
|
2350
|
-
success: r.success,
|
|
2351
|
-
duration: r.duration,
|
|
2352
|
-
exitCode: r.exitCode,
|
|
2353
|
-
timedOut: r.timedOut,
|
|
2354
|
-
stderr: r.stderr,
|
|
2355
|
-
})),
|
|
2356
|
-
);
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
return shouldContinue;
|
|
2360
|
-
} catch (error) {
|
|
2361
|
-
// Hook execution errors should not interrupt the main workflow
|
|
2362
|
-
logger?.error("PreToolUse hook execution failed:", error);
|
|
2363
|
-
return true; // Allow tool execution on hook errors
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
|
|
2367
|
-
/**
|
|
2368
|
-
* Execute PostToolUse hooks after tool completion
|
|
2369
|
-
*/
|
|
2370
|
-
private async executePostToolUseHooks(
|
|
2371
|
-
toolId: string,
|
|
2372
|
-
toolName: string,
|
|
2373
|
-
toolInput?: Record<string, unknown>,
|
|
2374
|
-
toolResponse?: ToolResult,
|
|
2375
|
-
): Promise<void> {
|
|
2376
|
-
if (!this.hookManager) return;
|
|
2377
|
-
|
|
2378
|
-
try {
|
|
2379
|
-
const context: ExtendedHookExecutionContext = {
|
|
2380
|
-
event: "PostToolUse",
|
|
2381
|
-
projectDir: this.getWorkdir(),
|
|
2382
|
-
timestamp: new Date(),
|
|
2383
|
-
toolName,
|
|
2384
|
-
sessionId: this.messageManager.getSessionId(),
|
|
2385
|
-
transcriptPath: this.messageManager.getTranscriptPath(),
|
|
2386
|
-
cwd: this.getWorkdir(),
|
|
2387
|
-
toolInput,
|
|
2388
|
-
toolResponse,
|
|
2389
|
-
subagentType: this.subagentType, // Include subagent type in hook context
|
|
2390
|
-
planFilePath: this.permissionManager?.getPlanFilePath(),
|
|
2391
|
-
env: Object.fromEntries(
|
|
2392
|
-
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2393
|
-
) as Record<string, string>, // Include environment variables
|
|
2394
|
-
};
|
|
2395
|
-
|
|
2396
|
-
const results = await this.hookManager.executeHooks(
|
|
2397
|
-
"PostToolUse",
|
|
2398
|
-
context,
|
|
2399
|
-
);
|
|
2400
|
-
|
|
2401
|
-
// Process hook results to handle exit codes and update tool results
|
|
2402
|
-
if (results.length > 0) {
|
|
2403
|
-
this.hookManager.processHookResults(
|
|
2404
|
-
"PostToolUse",
|
|
2405
|
-
results,
|
|
2406
|
-
this.messageManager,
|
|
2407
|
-
toolId,
|
|
2408
|
-
);
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
// Log hook execution results for debugging
|
|
2412
|
-
if (results.length > 0) {
|
|
2413
|
-
logger?.debug(
|
|
2414
|
-
`Executed ${results.length} PostToolUse hook(s) for ${toolName}:`,
|
|
2415
|
-
results.map((r) => ({
|
|
2416
|
-
success: r.success,
|
|
2417
|
-
duration: r.duration,
|
|
2418
|
-
exitCode: r.exitCode,
|
|
2419
|
-
timedOut: r.timedOut,
|
|
2420
|
-
stderr: r.stderr,
|
|
2421
|
-
})),
|
|
2422
|
-
);
|
|
2423
|
-
}
|
|
2424
|
-
} catch (error) {
|
|
2425
|
-
// Hook execution errors should not interrupt the main workflow
|
|
2426
|
-
logger?.error("PostToolUse hook execution failed:", error);
|
|
2427
|
-
}
|
|
2428
|
-
}
|
|
2429
|
-
}
|