@apolloyh/apollo-agent 0.1.0
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/.apollo/config.example.json +37 -0
- package/.env.example +17 -0
- package/README.md +62 -0
- package/dist/brand.d.ts +17 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +23 -0
- package/dist/cli-args.d.ts +13 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +50 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +190 -0
- package/dist/constants/spinner-verbs.d.ts +7 -0
- package/dist/constants/spinner-verbs.d.ts.map +1 -0
- package/dist/constants/spinner-verbs.js +21 -0
- package/dist/coordinator/workflow.d.ts +20 -0
- package/dist/coordinator/workflow.d.ts.map +1 -0
- package/dist/coordinator/workflow.js +120 -0
- package/dist/goal/goal-mode.d.ts +26 -0
- package/dist/goal/goal-mode.d.ts.map +1 -0
- package/dist/goal/goal-mode.js +141 -0
- package/dist/hooks/runner.d.ts +43 -0
- package/dist/hooks/runner.d.ts.map +1 -0
- package/dist/hooks/runner.js +94 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1716 -0
- package/dist/llm/anthropic.d.ts +46 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +278 -0
- package/dist/mcp/manager.d.ts +14 -0
- package/dist/mcp/manager.d.ts.map +1 -0
- package/dist/mcp/manager.js +93 -0
- package/dist/memory/memdir.d.ts +19 -0
- package/dist/memory/memdir.d.ts.map +1 -0
- package/dist/memory/memdir.js +96 -0
- package/dist/plan/plan-mode.d.ts +14 -0
- package/dist/plan/plan-mode.d.ts.map +1 -0
- package/dist/plan/plan-mode.js +90 -0
- package/dist/project-instructions.d.ts +8 -0
- package/dist/project-instructions.d.ts.map +1 -0
- package/dist/project-instructions.js +33 -0
- package/dist/runtime/compaction.d.ts +5 -0
- package/dist/runtime/compaction.d.ts.map +1 -0
- package/dist/runtime/compaction.js +37 -0
- package/dist/runtime/context.d.ts +25 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/context.js +75 -0
- package/dist/runtime/permissions.d.ts +11 -0
- package/dist/runtime/permissions.d.ts.map +1 -0
- package/dist/runtime/permissions.js +119 -0
- package/dist/runtime/prompt-builder.d.ts +27 -0
- package/dist/runtime/prompt-builder.d.ts.map +1 -0
- package/dist/runtime/prompt-builder.js +112 -0
- package/dist/runtime/query-engine.d.ts +136 -0
- package/dist/runtime/query-engine.d.ts.map +1 -0
- package/dist/runtime/query-engine.js +1182 -0
- package/dist/runtime/tool-policy.d.ts +13 -0
- package/dist/runtime/tool-policy.d.ts.map +1 -0
- package/dist/runtime/tool-policy.js +106 -0
- package/dist/sdk.d.ts +33 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +41 -0
- package/dist/session/resume-ui.d.ts +17 -0
- package/dist/session/resume-ui.d.ts.map +1 -0
- package/dist/session/resume-ui.js +64 -0
- package/dist/session/store.d.ts +66 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/store.js +120 -0
- package/dist/skillify/skillify.d.ts +12 -0
- package/dist/skillify/skillify.d.ts.map +1 -0
- package/dist/skillify/skillify.js +52 -0
- package/dist/skills/skills.d.ts +26 -0
- package/dist/skills/skills.d.ts.map +1 -0
- package/dist/skills/skills.js +195 -0
- package/dist/status-ui.d.ts +37 -0
- package/dist/status-ui.d.ts.map +1 -0
- package/dist/status-ui.js +130 -0
- package/dist/thought-fold.d.ts +48 -0
- package/dist/thought-fold.d.ts.map +1 -0
- package/dist/thought-fold.js +156 -0
- package/dist/tools/builtin.d.ts +27 -0
- package/dist/tools/builtin.d.ts.map +1 -0
- package/dist/tools/builtin.js +898 -0
- package/dist/tools/registry.d.ts +17 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +86 -0
- package/dist/trace.d.ts +68 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +600 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +78 -0
- package/docs/sdk.md +259 -0
- package/package.json +53 -0
- package/src/skills/tech-research-skill/SKILL.md +200 -0
- package/src/skills/tech-research-skill/agents/openai.yaml +4 -0
- package/src/skills/tech-research-skill/repo-analyze +435 -0
- package/src/skills/tech-research-skill/repo-fetch +310 -0
- package/src/skills/tech-research-skill/report-generate +254 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AnthropicApiError, AnthropicClient, MalformedStreamError, } from "../llm/anthropic.js";
|
|
3
|
+
import { ContextManager, isContextSummary } from "./context.js";
|
|
4
|
+
import { buildCompactionTranscript, COMPACTION_SYSTEM_PROMPT, normalizeCompactionSummary, } from "./compaction.js";
|
|
5
|
+
import { createCliApprovalProvider } from "./permissions.js";
|
|
6
|
+
import { SkillManager } from "../skills/skills.js";
|
|
7
|
+
import { McpManager } from "../mcp/manager.js";
|
|
8
|
+
import { createBuiltinRegistry } from "../tools/builtin.js";
|
|
9
|
+
import { MAX_TOOL_RESULT_CHARS } from "../tools/registry.js";
|
|
10
|
+
import { messageText, stringify, truncate, truncateMiddle } from "../utils.js";
|
|
11
|
+
import { PromptBuilder } from "./prompt-builder.js";
|
|
12
|
+
import { loadProjectInstructions } from "../project-instructions.js";
|
|
13
|
+
import { HookRunner } from "../hooks/runner.js";
|
|
14
|
+
import { memoryContextBlock } from "../memory/memdir.js";
|
|
15
|
+
import { planModeSystemExtra, readPlan, } from "../plan/plan-mode.js";
|
|
16
|
+
import { applyWorkflowMarkers, createWorkflow, workflowStatusBar, workflowSystemExtra, } from "../coordinator/workflow.js";
|
|
17
|
+
import { createSession, loadLatestSession, loadSession, listSessions, saveSession, } from "../session/store.js";
|
|
18
|
+
import { skillifySession } from "../skillify/skillify.js";
|
|
19
|
+
import { applyGoalMarkers, createGoal, goalStatusBar, goalSystemExtra, nextGoalContinuePrompt, shouldAutoContinue, } from "../goal/goal-mode.js";
|
|
20
|
+
const TASK_TOOL_NAMES = new Set(["Task"]);
|
|
21
|
+
const MAX_API_RETRIES = 3;
|
|
22
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
23
|
+
const MAX_OUTPUT_RECOVERIES = 2;
|
|
24
|
+
export class QueryEngine {
|
|
25
|
+
options;
|
|
26
|
+
client;
|
|
27
|
+
contextManager;
|
|
28
|
+
skillManager;
|
|
29
|
+
mcpManager;
|
|
30
|
+
hooks;
|
|
31
|
+
messages = [];
|
|
32
|
+
archivedMessages = [];
|
|
33
|
+
projectInstructionsCache;
|
|
34
|
+
stableSystemPrompt;
|
|
35
|
+
stableToolDefinitions;
|
|
36
|
+
mode = "normal";
|
|
37
|
+
plan = null;
|
|
38
|
+
workflow = null;
|
|
39
|
+
goal = null;
|
|
40
|
+
session = null;
|
|
41
|
+
askUser;
|
|
42
|
+
usage = emptyUsageCounters();
|
|
43
|
+
usageByCategory = {
|
|
44
|
+
main: emptyUsageCounters(),
|
|
45
|
+
task: emptyUsageCounters(),
|
|
46
|
+
compaction: emptyUsageCounters(),
|
|
47
|
+
};
|
|
48
|
+
latestUsageByCategory = {
|
|
49
|
+
main: null,
|
|
50
|
+
task: null,
|
|
51
|
+
compaction: null,
|
|
52
|
+
};
|
|
53
|
+
loadedSkillPaths = new Set();
|
|
54
|
+
seenMemoryBlocks = new Set();
|
|
55
|
+
lastPlanContext;
|
|
56
|
+
lastWorkflowState;
|
|
57
|
+
lastGoalState;
|
|
58
|
+
skillCatalogSnapshot;
|
|
59
|
+
lastCacheRequest = new Map();
|
|
60
|
+
cacheDiagnostics = {
|
|
61
|
+
prefixFingerprint: null,
|
|
62
|
+
configFingerprint: null,
|
|
63
|
+
systemFingerprint: null,
|
|
64
|
+
toolsFingerprint: null,
|
|
65
|
+
missesAfterHit: 0,
|
|
66
|
+
lastMissReason: null,
|
|
67
|
+
};
|
|
68
|
+
activeTurnAbort;
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.options = options;
|
|
71
|
+
this.client = new AnthropicClient(options.llmConfig);
|
|
72
|
+
this.contextManager = new ContextManager(options.agentConfig.context, options.emit);
|
|
73
|
+
this.skillManager = new SkillManager(options.agentConfig);
|
|
74
|
+
this.mcpManager = new McpManager(options.agentConfig);
|
|
75
|
+
this.hooks = new HookRunner(options.agentConfig.workspaceRoot);
|
|
76
|
+
this.askUser = options.askUser;
|
|
77
|
+
}
|
|
78
|
+
getMode() {
|
|
79
|
+
return this.mode;
|
|
80
|
+
}
|
|
81
|
+
getWorkflow() {
|
|
82
|
+
return this.workflow;
|
|
83
|
+
}
|
|
84
|
+
getGoal() {
|
|
85
|
+
return this.goal;
|
|
86
|
+
}
|
|
87
|
+
getPlan() {
|
|
88
|
+
return this.plan;
|
|
89
|
+
}
|
|
90
|
+
getSessionId() {
|
|
91
|
+
return this.session?.meta.id ?? null;
|
|
92
|
+
}
|
|
93
|
+
cancelCurrentTurn() {
|
|
94
|
+
if (!this.activeTurnAbort || this.activeTurnAbort.signal.aborted)
|
|
95
|
+
return false;
|
|
96
|
+
this.activeTurnAbort.abort();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
getMessages() {
|
|
100
|
+
return this.messages;
|
|
101
|
+
}
|
|
102
|
+
setMessages(messages) {
|
|
103
|
+
this.messages = messages;
|
|
104
|
+
this.archivedMessages = [];
|
|
105
|
+
}
|
|
106
|
+
conversationSize() {
|
|
107
|
+
return this.messages.length;
|
|
108
|
+
}
|
|
109
|
+
getUsageStats() {
|
|
110
|
+
return {
|
|
111
|
+
...usageStats(this.usage),
|
|
112
|
+
byCategory: {
|
|
113
|
+
main: usageStats(this.usageByCategory.main),
|
|
114
|
+
task: usageStats(this.usageByCategory.task),
|
|
115
|
+
compaction: usageStats(this.usageByCategory.compaction),
|
|
116
|
+
},
|
|
117
|
+
latestByCategory: {
|
|
118
|
+
main: this.latestUsageByCategory.main ? usageStats(this.latestUsageByCategory.main) : null,
|
|
119
|
+
task: this.latestUsageByCategory.task ? usageStats(this.latestUsageByCategory.task) : null,
|
|
120
|
+
compaction: this.latestUsageByCategory.compaction ? usageStats(this.latestUsageByCategory.compaction) : null,
|
|
121
|
+
},
|
|
122
|
+
cacheDiagnostics: { ...this.cacheDiagnostics },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
getCacheMode() {
|
|
126
|
+
try {
|
|
127
|
+
if (new URL(this.options.llmConfig.baseUrl).hostname.endsWith("bigmodel.cn")) {
|
|
128
|
+
return "GLM implicit (provider-managed)";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* display fallback below */
|
|
133
|
+
}
|
|
134
|
+
return `Anthropic-compatible (${this.options.llmConfig.cacheTtl ?? "5m"} requested)`;
|
|
135
|
+
}
|
|
136
|
+
async close() {
|
|
137
|
+
await this.mcpManager.closeAll();
|
|
138
|
+
}
|
|
139
|
+
async ensureSession(title) {
|
|
140
|
+
if (this.session)
|
|
141
|
+
return this.session;
|
|
142
|
+
this.session = await createSession(this.options.agentConfig.workspaceRoot, title, this.mode);
|
|
143
|
+
return this.session;
|
|
144
|
+
}
|
|
145
|
+
async saveCurrentSession() {
|
|
146
|
+
if (!this.session)
|
|
147
|
+
await this.ensureSession();
|
|
148
|
+
if (!this.session)
|
|
149
|
+
return null;
|
|
150
|
+
this.session.messages = this.messages;
|
|
151
|
+
this.session.archivedMessages = this.archivedMessages;
|
|
152
|
+
this.session.usage = {
|
|
153
|
+
total: { ...this.usage },
|
|
154
|
+
byCategory: {
|
|
155
|
+
main: { ...this.usageByCategory.main },
|
|
156
|
+
task: { ...this.usageByCategory.task },
|
|
157
|
+
compaction: { ...this.usageByCategory.compaction },
|
|
158
|
+
},
|
|
159
|
+
latestByCategory: {
|
|
160
|
+
...(this.latestUsageByCategory.main ? { main: { ...this.latestUsageByCategory.main } } : {}),
|
|
161
|
+
...(this.latestUsageByCategory.task ? { task: { ...this.latestUsageByCategory.task } } : {}),
|
|
162
|
+
...(this.latestUsageByCategory.compaction ? { compaction: { ...this.latestUsageByCategory.compaction } } : {}),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
this.session.meta.mode = this.mode;
|
|
166
|
+
this.session.plan = this.plan
|
|
167
|
+
? { path: this.plan.path, content: this.plan.content, approved: this.plan.approved }
|
|
168
|
+
: null;
|
|
169
|
+
this.session.workflow = this.workflow
|
|
170
|
+
? { goal: this.workflow.goal, phases: this.workflow.phases }
|
|
171
|
+
: null;
|
|
172
|
+
this.session.goal = this.goal
|
|
173
|
+
? {
|
|
174
|
+
goal: this.goal.goal,
|
|
175
|
+
status: this.goal.status,
|
|
176
|
+
criteria: this.goal.criteria,
|
|
177
|
+
progress: this.goal.progress,
|
|
178
|
+
iteration: this.goal.iteration,
|
|
179
|
+
maxIterations: this.goal.maxIterations,
|
|
180
|
+
startedAt: this.goal.startedAt,
|
|
181
|
+
completedAt: this.goal.completedAt,
|
|
182
|
+
lastBlockedReason: this.goal.lastBlockedReason,
|
|
183
|
+
}
|
|
184
|
+
: null;
|
|
185
|
+
return saveSession(this.options.agentConfig.workspaceRoot, this.session);
|
|
186
|
+
}
|
|
187
|
+
async resumeSession(id) {
|
|
188
|
+
const snap = id
|
|
189
|
+
? await loadSession(this.options.agentConfig.workspaceRoot, id)
|
|
190
|
+
: await loadLatestSession(this.options.agentConfig.workspaceRoot);
|
|
191
|
+
if (!snap)
|
|
192
|
+
return null;
|
|
193
|
+
this.session = snap;
|
|
194
|
+
this.messages = snap.messages ?? [];
|
|
195
|
+
this.archivedMessages = snap.archivedMessages ?? [];
|
|
196
|
+
this.usage = restoreUsageCounters(snap.usage?.total);
|
|
197
|
+
this.usageByCategory = {
|
|
198
|
+
main: restoreUsageCounters(snap.usage?.byCategory?.main),
|
|
199
|
+
task: restoreUsageCounters(snap.usage?.byCategory?.task),
|
|
200
|
+
compaction: restoreUsageCounters(snap.usage?.byCategory?.compaction),
|
|
201
|
+
};
|
|
202
|
+
this.latestUsageByCategory = {
|
|
203
|
+
main: restoreLatestUsage(snap.usage?.latestByCategory?.main),
|
|
204
|
+
task: restoreLatestUsage(snap.usage?.latestByCategory?.task),
|
|
205
|
+
compaction: restoreLatestUsage(snap.usage?.latestByCategory?.compaction),
|
|
206
|
+
};
|
|
207
|
+
this.lastCacheRequest.clear();
|
|
208
|
+
this.cacheDiagnostics = {
|
|
209
|
+
prefixFingerprint: null,
|
|
210
|
+
configFingerprint: null,
|
|
211
|
+
systemFingerprint: null,
|
|
212
|
+
toolsFingerprint: null,
|
|
213
|
+
missesAfterHit: 0,
|
|
214
|
+
lastMissReason: null,
|
|
215
|
+
};
|
|
216
|
+
this.mode = snap.meta.mode ?? "normal";
|
|
217
|
+
this.contextManager.clear();
|
|
218
|
+
this.resetRuntimeContextTracking();
|
|
219
|
+
this.skillCatalogSnapshot = undefined;
|
|
220
|
+
this.plan = null;
|
|
221
|
+
this.workflow = null;
|
|
222
|
+
this.goal = null;
|
|
223
|
+
if (snap.plan) {
|
|
224
|
+
this.plan = {
|
|
225
|
+
active: this.mode === "plan" || !snap.plan.approved,
|
|
226
|
+
path: snap.plan.path,
|
|
227
|
+
content: snap.plan.content,
|
|
228
|
+
approved: snap.plan.approved,
|
|
229
|
+
createdAt: snap.meta.createdAt,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (snap.workflow) {
|
|
233
|
+
this.workflow = {
|
|
234
|
+
goal: snap.workflow.goal,
|
|
235
|
+
phases: snap.workflow.phases,
|
|
236
|
+
startedAt: snap.meta.createdAt,
|
|
237
|
+
maxRetries: 2,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (snap.goal) {
|
|
241
|
+
this.goal = {
|
|
242
|
+
goal: snap.goal.goal,
|
|
243
|
+
status: snap.goal.status,
|
|
244
|
+
criteria: snap.goal.criteria ?? [],
|
|
245
|
+
progress: snap.goal.progress ?? [],
|
|
246
|
+
iteration: snap.goal.iteration ?? 0,
|
|
247
|
+
maxIterations: snap.goal.maxIterations ?? 12,
|
|
248
|
+
startedAt: snap.goal.startedAt,
|
|
249
|
+
completedAt: snap.goal.completedAt,
|
|
250
|
+
lastBlockedReason: snap.goal.lastBlockedReason,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
this.options.emit({
|
|
254
|
+
type: "plan_mode",
|
|
255
|
+
active: this.mode === "plan",
|
|
256
|
+
approved: this.plan?.approved,
|
|
257
|
+
path: this.plan?.path,
|
|
258
|
+
});
|
|
259
|
+
if (this.workflow) {
|
|
260
|
+
this.options.emit({
|
|
261
|
+
type: "workflow_status",
|
|
262
|
+
goal: this.workflow.goal,
|
|
263
|
+
phases: this.workflow.phases.map((p) => ({
|
|
264
|
+
name: p.name,
|
|
265
|
+
label: p.label,
|
|
266
|
+
status: p.status,
|
|
267
|
+
summary: p.summary,
|
|
268
|
+
})),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return snap;
|
|
272
|
+
}
|
|
273
|
+
async listSessions() {
|
|
274
|
+
return listSessions(this.options.agentConfig.workspaceRoot);
|
|
275
|
+
}
|
|
276
|
+
async enterPlanMode() {
|
|
277
|
+
this.lastPlanContext = undefined;
|
|
278
|
+
this.mode = "plan";
|
|
279
|
+
this.plan = (await readPlan(this.options.agentConfig.workspaceRoot)) ?? {
|
|
280
|
+
active: true,
|
|
281
|
+
path: `${this.options.agentConfig.workspaceRoot}/.apollo/plan.md`,
|
|
282
|
+
content: "",
|
|
283
|
+
approved: false,
|
|
284
|
+
createdAt: new Date().toISOString(),
|
|
285
|
+
};
|
|
286
|
+
this.plan.active = true;
|
|
287
|
+
this.plan.approved = false;
|
|
288
|
+
this.options.emit({ type: "plan_mode", active: true, path: this.plan.path });
|
|
289
|
+
await this.saveCurrentSession();
|
|
290
|
+
return this.plan;
|
|
291
|
+
}
|
|
292
|
+
async approvePlan() {
|
|
293
|
+
const existing = await readPlan(this.options.agentConfig.workspaceRoot);
|
|
294
|
+
if (!existing?.content?.trim()) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
this.plan = { ...existing, active: false, approved: true };
|
|
298
|
+
this.lastPlanContext = undefined;
|
|
299
|
+
this.mode = "normal";
|
|
300
|
+
this.options.emit({
|
|
301
|
+
type: "plan_mode",
|
|
302
|
+
active: false,
|
|
303
|
+
approved: true,
|
|
304
|
+
path: this.plan.path,
|
|
305
|
+
});
|
|
306
|
+
await this.saveCurrentSession();
|
|
307
|
+
return this.plan;
|
|
308
|
+
}
|
|
309
|
+
async rejectPlan() {
|
|
310
|
+
this.plan = null;
|
|
311
|
+
this.lastPlanContext = undefined;
|
|
312
|
+
this.mode = "normal";
|
|
313
|
+
this.options.emit({ type: "plan_mode", active: false, approved: false });
|
|
314
|
+
await this.saveCurrentSession();
|
|
315
|
+
}
|
|
316
|
+
startWorkflow(goal) {
|
|
317
|
+
this.lastWorkflowState = undefined;
|
|
318
|
+
this.mode = "workflow";
|
|
319
|
+
this.workflow = createWorkflow(goal);
|
|
320
|
+
// kick first phase
|
|
321
|
+
this.workflow.phases[0].status = "running";
|
|
322
|
+
this.emitWorkflow();
|
|
323
|
+
void this.saveCurrentSession().catch(() => null);
|
|
324
|
+
return this.workflow;
|
|
325
|
+
}
|
|
326
|
+
stopWorkflow() {
|
|
327
|
+
this.workflow = null;
|
|
328
|
+
if (this.mode === "workflow")
|
|
329
|
+
this.mode = "normal";
|
|
330
|
+
void this.saveCurrentSession().catch(() => null);
|
|
331
|
+
}
|
|
332
|
+
startGoal(goal, maxIterations = 12, criteria = []) {
|
|
333
|
+
this.lastGoalState = undefined;
|
|
334
|
+
this.mode = "goal";
|
|
335
|
+
this.goal = createGoal(goal, maxIterations, criteria);
|
|
336
|
+
this.goal.iteration = 0;
|
|
337
|
+
this.emitGoal();
|
|
338
|
+
void this.saveCurrentSession().catch(() => null);
|
|
339
|
+
return this.goal;
|
|
340
|
+
}
|
|
341
|
+
stopGoal(status = "paused") {
|
|
342
|
+
if (this.goal) {
|
|
343
|
+
this.goal = { ...this.goal, status, completedAt: new Date().toISOString() };
|
|
344
|
+
}
|
|
345
|
+
if (this.mode === "goal")
|
|
346
|
+
this.mode = "normal";
|
|
347
|
+
this.emitGoal();
|
|
348
|
+
void this.saveCurrentSession().catch(() => null);
|
|
349
|
+
}
|
|
350
|
+
renderGoalBar(color) {
|
|
351
|
+
if (!this.goal)
|
|
352
|
+
return "(no active goal)\n";
|
|
353
|
+
return goalStatusBar(this.goal, color);
|
|
354
|
+
}
|
|
355
|
+
shouldContinueGoal() {
|
|
356
|
+
return Boolean(this.goal && shouldAutoContinue(this.goal));
|
|
357
|
+
}
|
|
358
|
+
continueGoalPrompt() {
|
|
359
|
+
if (!this.goal)
|
|
360
|
+
return "Continue.";
|
|
361
|
+
return nextGoalContinuePrompt(this.goal);
|
|
362
|
+
}
|
|
363
|
+
emitGoal() {
|
|
364
|
+
if (!this.goal)
|
|
365
|
+
return;
|
|
366
|
+
this.options.emit({
|
|
367
|
+
type: "goal_status",
|
|
368
|
+
goal: this.goal.goal,
|
|
369
|
+
status: this.goal.status,
|
|
370
|
+
iteration: this.goal.iteration,
|
|
371
|
+
maxIterations: this.goal.maxIterations,
|
|
372
|
+
progress: this.goal.progress.slice(-5),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
renderWorkflowBar(color) {
|
|
376
|
+
if (!this.workflow)
|
|
377
|
+
return "(no active workflow)\n";
|
|
378
|
+
return workflowStatusBar(this.workflow, color);
|
|
379
|
+
}
|
|
380
|
+
async skillify(name, description) {
|
|
381
|
+
const skillsDir = this.options.agentConfig.skills.directories[0] ?? "./skills";
|
|
382
|
+
return skillifySession({
|
|
383
|
+
workspaceRoot: this.options.agentConfig.workspaceRoot,
|
|
384
|
+
skillName: name,
|
|
385
|
+
description,
|
|
386
|
+
messages: this.messages,
|
|
387
|
+
skillsDir,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
listSkills() {
|
|
391
|
+
return this.skillManager.search("");
|
|
392
|
+
}
|
|
393
|
+
installSkill(sourcePath, name) {
|
|
394
|
+
return this.skillManager.installLocal(sourcePath, name);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Replace older model context with a semantic summary while retaining the
|
|
398
|
+
* removed raw messages in the persisted session archive.
|
|
399
|
+
*/
|
|
400
|
+
async compactConversation(force = false) {
|
|
401
|
+
const plan = this.contextManager.prepare(this.messages, force);
|
|
402
|
+
if (!plan)
|
|
403
|
+
return "not_needed";
|
|
404
|
+
this.options.emit({ type: "context_compaction_start", beforeChars: plan.beforeChars });
|
|
405
|
+
try {
|
|
406
|
+
const transcript = buildCompactionTranscript(plan.older);
|
|
407
|
+
const { response } = await this.createMessageWithRecovery(() => ({
|
|
408
|
+
system: COMPACTION_SYSTEM_PROMPT,
|
|
409
|
+
messages: [
|
|
410
|
+
{
|
|
411
|
+
role: "user",
|
|
412
|
+
content: `Summarize this conversation transcript:\n\n${transcript}`,
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
tools: [],
|
|
416
|
+
stream: false,
|
|
417
|
+
}), {
|
|
418
|
+
streamEnabled: false,
|
|
419
|
+
hasPartialStream: () => false,
|
|
420
|
+
allowContextCompaction: false,
|
|
421
|
+
});
|
|
422
|
+
this.recordUsage(response.usage, "compaction", {
|
|
423
|
+
prefixFingerprint: fingerprint({ system: COMPACTION_SYSTEM_PROMPT, tools: [] }),
|
|
424
|
+
configFingerprint: this.configFingerprint(),
|
|
425
|
+
systemFingerprint: fingerprint(COMPACTION_SYSTEM_PROMPT),
|
|
426
|
+
toolsFingerprint: fingerprint([]),
|
|
427
|
+
});
|
|
428
|
+
const summary = normalizeCompactionSummary(messageText(response.content));
|
|
429
|
+
const compacted = this.contextManager.apply(plan, summary);
|
|
430
|
+
// A compact model context is necessarily semantic rather than byte-for-byte
|
|
431
|
+
// lossless. The raw messages remain recoverable from the session JSON.
|
|
432
|
+
this.archivedMessages.push(...plan.older.filter((message) => !isContextSummary(message)));
|
|
433
|
+
this.messages = compacted;
|
|
434
|
+
this.resetRuntimeContextTracking();
|
|
435
|
+
await this.saveCurrentSession();
|
|
436
|
+
return "compacted";
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
const reason = error instanceof Error ? error.message : stringify(error);
|
|
440
|
+
this.options.emit({ type: "context_compaction_failed", reason });
|
|
441
|
+
return "failed";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async submitMessage(input) {
|
|
445
|
+
const stateBeforeTurn = {
|
|
446
|
+
mode: this.mode,
|
|
447
|
+
plan: structuredClone(this.plan),
|
|
448
|
+
workflow: structuredClone(this.workflow),
|
|
449
|
+
goal: structuredClone(this.goal),
|
|
450
|
+
loadedSkillPaths: new Set(this.loadedSkillPaths),
|
|
451
|
+
seenMemoryBlocks: new Set(this.seenMemoryBlocks),
|
|
452
|
+
lastPlanContext: this.lastPlanContext,
|
|
453
|
+
lastWorkflowState: this.lastWorkflowState,
|
|
454
|
+
lastGoalState: this.lastGoalState,
|
|
455
|
+
skillCatalogSnapshot: this.skillCatalogSnapshot ? new Map(this.skillCatalogSnapshot) : undefined,
|
|
456
|
+
};
|
|
457
|
+
await this.ensureSession(input.slice(0, 60));
|
|
458
|
+
if (this.mode === "goal" && this.goal && this.goal.status === "active") {
|
|
459
|
+
// iteration already 1 on start; subsequent continues bump
|
|
460
|
+
if (this.goal.progress.length > 0 || this.goal.iteration > 0) {
|
|
461
|
+
/* keep */
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const assumeYes = typeof this.options.assumeYes === "function" ? this.options.assumeYes() : this.options.assumeYes;
|
|
465
|
+
const approval = this.options.approvalProvider ??
|
|
466
|
+
createCliApprovalProvider(this.options.agentConfig, this.options.emit, assumeYes ?? false);
|
|
467
|
+
const isSubagent = Boolean(this.options.subagent);
|
|
468
|
+
const registry = createBuiltinRegistry({
|
|
469
|
+
config: this.options.agentConfig,
|
|
470
|
+
skillManager: this.skillManager,
|
|
471
|
+
mcpManager: this.mcpManager,
|
|
472
|
+
enableTask: !isSubagent,
|
|
473
|
+
allowedTools: this.options.subagent?.tools,
|
|
474
|
+
runTask: isSubagent ? undefined : (agent, prompt, emit) => this.runTask(agent, prompt, emit),
|
|
475
|
+
});
|
|
476
|
+
registry.setHooks(this.hooks);
|
|
477
|
+
registry.setPlanMode(this.mode === "plan");
|
|
478
|
+
const [availableSkills, matchedSkills, memBlock] = await Promise.all([
|
|
479
|
+
this.skillManager.metadata(),
|
|
480
|
+
this.matchedSkillsForPrompt(input),
|
|
481
|
+
isSubagent ? Promise.resolve("") : memoryContextBlock(this.options.agentConfig.workspaceRoot, input),
|
|
482
|
+
]);
|
|
483
|
+
const modeExtra = this.runtimeModeContext();
|
|
484
|
+
const memoryExtra = this.takeMemoryContext(memBlock);
|
|
485
|
+
const { prompt, toolDefinitions } = await this.getStableRequestPrefix(registry.definitions());
|
|
486
|
+
const catalogExtra = this.takeSkillCatalogContext(availableSkills);
|
|
487
|
+
const runtimeContext = buildRuntimeContext(matchedSkills, [catalogExtra, modeExtra, memoryExtra].filter(Boolean).join("\n\n"));
|
|
488
|
+
const requestFingerprints = this.requestFingerprints(prompt, toolDefinitions);
|
|
489
|
+
if (this.activeTurnAbort)
|
|
490
|
+
throw new Error("A turn is already running.");
|
|
491
|
+
const turnAbort = new AbortController();
|
|
492
|
+
this.activeTurnAbort = turnAbort;
|
|
493
|
+
const toolContext = {
|
|
494
|
+
workspaceRoot: this.options.agentConfig.workspaceRoot,
|
|
495
|
+
emit: this.options.emit,
|
|
496
|
+
requestApproval: approval,
|
|
497
|
+
signal: turnAbort.signal,
|
|
498
|
+
askUser: this.askUser,
|
|
499
|
+
planMode: this.mode === "plan",
|
|
500
|
+
};
|
|
501
|
+
const userMessage = {
|
|
502
|
+
role: "user",
|
|
503
|
+
content: runtimeContext ? `${runtimeContext}\n\n# User Request\n${input}` : input,
|
|
504
|
+
};
|
|
505
|
+
this.messages.push(userMessage);
|
|
506
|
+
let finalText = "";
|
|
507
|
+
let turnHasToolResults = false;
|
|
508
|
+
let turnFinished = false;
|
|
509
|
+
const recoveredOutputParts = [];
|
|
510
|
+
let outputRecoveryCount = 0;
|
|
511
|
+
const highRiskToolCounts = new Map();
|
|
512
|
+
const maxHighRiskCallsPerTool = assumeYes ? Number.POSITIVE_INFINITY : 3;
|
|
513
|
+
try {
|
|
514
|
+
for (let turn = 1; turn <= this.options.agentConfig.maxTurns; turn += 1) {
|
|
515
|
+
await this.compactConversation();
|
|
516
|
+
const stream = typeof this.options.stream === "function" ? this.options.stream() : this.options.stream;
|
|
517
|
+
const streamEnabled = stream ?? true;
|
|
518
|
+
this.options.emit({ type: "llm_request", turn, toolCount: toolDefinitions.length });
|
|
519
|
+
let lastToolDeltaAt = 0;
|
|
520
|
+
let lastToolDeltaBytes = 0;
|
|
521
|
+
let lastToolDeltaDetail;
|
|
522
|
+
let streamedText = "";
|
|
523
|
+
const onTextDelta = isSubagent
|
|
524
|
+
? undefined
|
|
525
|
+
: (text) => {
|
|
526
|
+
streamedText += text;
|
|
527
|
+
this.options.emit({ type: "assistant_delta", text });
|
|
528
|
+
};
|
|
529
|
+
const buildRequest = (useStream) => ({
|
|
530
|
+
system: prompt,
|
|
531
|
+
messages: this.messages,
|
|
532
|
+
tools: toolDefinitions,
|
|
533
|
+
stream: useStream,
|
|
534
|
+
onTextDelta: useStream ? onTextDelta : undefined,
|
|
535
|
+
onToolInputDelta: useStream && !isSubagent
|
|
536
|
+
? (event) => {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
if (event.detail === lastToolDeltaDetail &&
|
|
539
|
+
now - lastToolDeltaAt < 1200 &&
|
|
540
|
+
event.bytes - lastToolDeltaBytes < 4000)
|
|
541
|
+
return;
|
|
542
|
+
lastToolDeltaAt = now;
|
|
543
|
+
lastToolDeltaBytes = event.bytes;
|
|
544
|
+
lastToolDeltaDetail = event.detail;
|
|
545
|
+
this.options.emit({ type: "llm_tool_delta", ...event });
|
|
546
|
+
}
|
|
547
|
+
: undefined,
|
|
548
|
+
});
|
|
549
|
+
const recovered = await this.createMessageWithRecovery(buildRequest, {
|
|
550
|
+
streamEnabled,
|
|
551
|
+
hasPartialStream: () => Boolean(streamedText) || lastToolDeltaBytes > 0,
|
|
552
|
+
allowContextCompaction: !isSubagent,
|
|
553
|
+
});
|
|
554
|
+
const response = recovered.response;
|
|
555
|
+
if (!isSubagent && streamEnabled && !recovered.streamed) {
|
|
556
|
+
const recoveredText = messageText(response.content);
|
|
557
|
+
if (recoveredText && recoveredText !== streamedText) {
|
|
558
|
+
const missingText = recoveredText.startsWith(streamedText)
|
|
559
|
+
? recoveredText.slice(streamedText.length)
|
|
560
|
+
: `${streamedText ? "\n" : ""}${recoveredText}`;
|
|
561
|
+
if (missingText)
|
|
562
|
+
this.options.emit({ type: "assistant_delta", text: missingText });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
this.recordUsage(response.usage, "main", requestFingerprints);
|
|
566
|
+
this.messages.push({ role: "assistant", content: response.content });
|
|
567
|
+
finalText = messageText(response.content);
|
|
568
|
+
this.options.emit({
|
|
569
|
+
type: "llm_response",
|
|
570
|
+
turn,
|
|
571
|
+
stopReason: response.stop_reason,
|
|
572
|
+
text: truncate(finalText, 1000),
|
|
573
|
+
});
|
|
574
|
+
// Workflow phase markers
|
|
575
|
+
if (this.workflow && finalText) {
|
|
576
|
+
this.workflow = applyWorkflowMarkers(this.workflow, finalText);
|
|
577
|
+
this.emitWorkflow();
|
|
578
|
+
}
|
|
579
|
+
// Goal markers
|
|
580
|
+
if (this.goal && finalText && this.mode === "goal") {
|
|
581
|
+
this.goal = applyGoalMarkers(this.goal, finalText);
|
|
582
|
+
if (this.goal.status === "active") {
|
|
583
|
+
this.goal = {
|
|
584
|
+
...this.goal,
|
|
585
|
+
iteration: Math.min(this.goal.iteration + 1, this.goal.maxIterations),
|
|
586
|
+
};
|
|
587
|
+
if (this.goal.iteration >= this.goal.maxIterations) {
|
|
588
|
+
this.goal = {
|
|
589
|
+
...this.goal,
|
|
590
|
+
status: "failed",
|
|
591
|
+
lastBlockedReason: "max iterations reached",
|
|
592
|
+
completedAt: new Date().toISOString(),
|
|
593
|
+
};
|
|
594
|
+
this.mode = "normal";
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
this.emitGoal();
|
|
598
|
+
if (this.goal.status === "completed" || this.goal.status === "failed") {
|
|
599
|
+
this.mode = "normal";
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Keep plan content in sync if model wrote it
|
|
603
|
+
if (this.mode === "plan") {
|
|
604
|
+
const p = await readPlan(this.options.agentConfig.workspaceRoot);
|
|
605
|
+
if (p)
|
|
606
|
+
this.plan = { ...p, approved: false, active: true };
|
|
607
|
+
}
|
|
608
|
+
const toolUses = response.content.filter((block) => block.type === "tool_use");
|
|
609
|
+
if (toolUses.length === 0 &&
|
|
610
|
+
isOutputLimitStop(response.stop_reason) &&
|
|
611
|
+
outputRecoveryCount < MAX_OUTPUT_RECOVERIES) {
|
|
612
|
+
if (finalText)
|
|
613
|
+
recoveredOutputParts.push(finalText);
|
|
614
|
+
outputRecoveryCount += 1;
|
|
615
|
+
this.messages.push({
|
|
616
|
+
role: "user",
|
|
617
|
+
content: "Output token limit reached. Continue directly from where you stopped. Do not apologize or recap. Finish the remaining work in smaller pieces and use tools as needed.",
|
|
618
|
+
});
|
|
619
|
+
this.options.emit({
|
|
620
|
+
type: "llm_retry",
|
|
621
|
+
attempt: outputRecoveryCount,
|
|
622
|
+
mode: "continue",
|
|
623
|
+
reason: `model stopped with ${response.stop_reason}`,
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (toolUses.length === 0) {
|
|
628
|
+
turnFinished = true;
|
|
629
|
+
await this.saveCurrentSession();
|
|
630
|
+
return `${recoveredOutputParts.join("")}${finalText}`;
|
|
631
|
+
}
|
|
632
|
+
const executeToolUse = async (toolUse) => {
|
|
633
|
+
try {
|
|
634
|
+
const risk = registry.riskOf(toolUse.name);
|
|
635
|
+
if (risk === "high") {
|
|
636
|
+
const count = highRiskToolCounts.get(toolUse.name) ?? 0;
|
|
637
|
+
if (count >= maxHighRiskCallsPerTool) {
|
|
638
|
+
const content = `High-risk tool ${toolUse.name} was already requested ${count} times in this user turn. Do not call it again for this turn. Summarize what you know, switch to a safer dedicated tool if available, or ask the user for explicit next steps.`;
|
|
639
|
+
this.options.emit({ type: "tool_result", tool: toolUse.name, input: toolUse.input, isError: true, content });
|
|
640
|
+
return {
|
|
641
|
+
type: "tool_result",
|
|
642
|
+
tool_use_id: toolUse.id,
|
|
643
|
+
content,
|
|
644
|
+
is_error: true,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
highRiskToolCounts.set(toolUse.name, count + 1);
|
|
648
|
+
}
|
|
649
|
+
const result = await registry.execute(toolUse.name, toolUse.input, toolContext);
|
|
650
|
+
this.options.emit({
|
|
651
|
+
type: "tool_result",
|
|
652
|
+
tool: toolUse.name,
|
|
653
|
+
input: toolUse.input,
|
|
654
|
+
isError: result.isError,
|
|
655
|
+
content: truncate(result.content, 1000),
|
|
656
|
+
fileChange: result.fileChange,
|
|
657
|
+
});
|
|
658
|
+
// refresh plan after write
|
|
659
|
+
if (toolUse.name === "write_file" && this.mode === "plan") {
|
|
660
|
+
const p = await readPlan(this.options.agentConfig.workspaceRoot);
|
|
661
|
+
if (p)
|
|
662
|
+
this.plan = { ...p, approved: false, active: true };
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
type: "tool_result",
|
|
666
|
+
tool_use_id: toolUse.id,
|
|
667
|
+
content: result.content,
|
|
668
|
+
is_error: result.isError,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
const content = truncateMiddle(error instanceof Error ? error.message : stringify(error), MAX_TOOL_RESULT_CHARS);
|
|
673
|
+
this.options.emit({ type: "tool_result", tool: toolUse.name, input: toolUse.input, isError: true, content });
|
|
674
|
+
return {
|
|
675
|
+
type: "tool_result",
|
|
676
|
+
tool_use_id: toolUse.id,
|
|
677
|
+
content,
|
|
678
|
+
is_error: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
const toolResults = new Array(toolUses.length);
|
|
683
|
+
const taskJobs = [];
|
|
684
|
+
for (const [index, toolUse] of toolUses.entries()) {
|
|
685
|
+
if (TASK_TOOL_NAMES.has(toolUse.name)) {
|
|
686
|
+
taskJobs.push(executeToolUse(toolUse).then((result) => {
|
|
687
|
+
toolResults[index] = result;
|
|
688
|
+
}));
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
toolResults[index] = await executeToolUse(toolUse);
|
|
692
|
+
}
|
|
693
|
+
await Promise.all(taskJobs);
|
|
694
|
+
this.messages.push({ role: "user", content: toolResults });
|
|
695
|
+
turnHasToolResults = true;
|
|
696
|
+
}
|
|
697
|
+
const stopped = `Stopped: max turns reached before a final answer was completed.${finalText ? ` Last assistant text: ${finalText}` : ""}`;
|
|
698
|
+
if (this.messages.at(-1)?.role === "user")
|
|
699
|
+
this.messages.push({ role: "assistant", content: stopped });
|
|
700
|
+
turnFinished = true;
|
|
701
|
+
await this.saveCurrentSession();
|
|
702
|
+
return stopped;
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
if (!turnFinished) {
|
|
706
|
+
const turnStart = this.messages.indexOf(userMessage);
|
|
707
|
+
if (turnStart >= 0) {
|
|
708
|
+
if (turnHasToolResults) {
|
|
709
|
+
if (this.messages.at(-1)?.role === "user") {
|
|
710
|
+
this.messages.push({
|
|
711
|
+
role: "assistant",
|
|
712
|
+
content: "The previous turn was interrupted after tool execution. Inspect the current state before retrying and do not repeat side effects blindly.",
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
this.messages.splice(turnStart);
|
|
718
|
+
this.mode = stateBeforeTurn.mode;
|
|
719
|
+
this.plan = stateBeforeTurn.plan;
|
|
720
|
+
this.workflow = stateBeforeTurn.workflow;
|
|
721
|
+
this.goal = stateBeforeTurn.goal;
|
|
722
|
+
this.loadedSkillPaths.clear();
|
|
723
|
+
stateBeforeTurn.loadedSkillPaths.forEach((path) => this.loadedSkillPaths.add(path));
|
|
724
|
+
this.seenMemoryBlocks.clear();
|
|
725
|
+
stateBeforeTurn.seenMemoryBlocks.forEach((block) => this.seenMemoryBlocks.add(block));
|
|
726
|
+
this.lastPlanContext = stateBeforeTurn.lastPlanContext;
|
|
727
|
+
this.lastWorkflowState = stateBeforeTurn.lastWorkflowState;
|
|
728
|
+
this.lastGoalState = stateBeforeTurn.lastGoalState;
|
|
729
|
+
this.skillCatalogSnapshot = stateBeforeTurn.skillCatalogSnapshot;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
if (this.activeTurnAbort === turnAbort)
|
|
737
|
+
this.activeTurnAbort = undefined;
|
|
738
|
+
await this.saveCurrentSession().catch(() => null);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
clearConversation() {
|
|
742
|
+
this.messages = [];
|
|
743
|
+
this.archivedMessages = [];
|
|
744
|
+
this.contextManager.clear();
|
|
745
|
+
this.resetRuntimeContextTracking();
|
|
746
|
+
this.skillCatalogSnapshot = undefined;
|
|
747
|
+
void this.saveCurrentSession().catch(() => null);
|
|
748
|
+
}
|
|
749
|
+
emitWorkflow() {
|
|
750
|
+
if (!this.workflow)
|
|
751
|
+
return;
|
|
752
|
+
this.options.emit({
|
|
753
|
+
type: "workflow_status",
|
|
754
|
+
goal: this.workflow.goal,
|
|
755
|
+
phases: this.workflow.phases.map((p) => ({
|
|
756
|
+
name: p.name,
|
|
757
|
+
label: p.label,
|
|
758
|
+
status: p.status,
|
|
759
|
+
summary: p.summary,
|
|
760
|
+
})),
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
async getProjectInstructions() {
|
|
764
|
+
if (this.options.subagent)
|
|
765
|
+
return null;
|
|
766
|
+
if (this.projectInstructionsCache !== undefined)
|
|
767
|
+
return this.projectInstructionsCache;
|
|
768
|
+
this.projectInstructionsCache = await loadProjectInstructions(this.options.agentConfig.workspaceRoot);
|
|
769
|
+
return this.projectInstructionsCache;
|
|
770
|
+
}
|
|
771
|
+
async getStableRequestPrefix(currentToolDefinitions) {
|
|
772
|
+
this.stableToolDefinitions ??= currentToolDefinitions;
|
|
773
|
+
this.stableSystemPrompt ??= new PromptBuilder({
|
|
774
|
+
agentConfig: this.options.agentConfig,
|
|
775
|
+
tools: this.stableToolDefinitions,
|
|
776
|
+
subagent: this.options.subagent,
|
|
777
|
+
projectInstructions: await this.getProjectInstructions(),
|
|
778
|
+
}).build();
|
|
779
|
+
return {
|
|
780
|
+
prompt: this.stableSystemPrompt,
|
|
781
|
+
toolDefinitions: this.stableToolDefinitions,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
async createMessageWithRecovery(buildRequest, options) {
|
|
785
|
+
let useStream = options.streamEnabled;
|
|
786
|
+
let malformedStreamRecovered = false;
|
|
787
|
+
let contextCompactionAttempted = false;
|
|
788
|
+
let transientRetries = 0;
|
|
789
|
+
for (;;) {
|
|
790
|
+
try {
|
|
791
|
+
const request = buildRequest(useStream);
|
|
792
|
+
request.signal ??= this.activeTurnAbort?.signal;
|
|
793
|
+
return {
|
|
794
|
+
response: await this.client.createMessage(request),
|
|
795
|
+
streamed: useStream,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
if (error instanceof MalformedStreamError && useStream && !malformedStreamRecovered) {
|
|
800
|
+
malformedStreamRecovered = true;
|
|
801
|
+
useStream = false;
|
|
802
|
+
this.options.emit({
|
|
803
|
+
type: "llm_retry",
|
|
804
|
+
attempt: 1,
|
|
805
|
+
mode: "non-stream",
|
|
806
|
+
reason: error.message,
|
|
807
|
+
});
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (options.allowContextCompaction &&
|
|
811
|
+
!contextCompactionAttempted &&
|
|
812
|
+
isContextOverflowError(error)) {
|
|
813
|
+
contextCompactionAttempted = true;
|
|
814
|
+
const compacted = await this.compactConversation(true);
|
|
815
|
+
if (compacted === "compacted") {
|
|
816
|
+
this.options.emit({
|
|
817
|
+
type: "llm_retry",
|
|
818
|
+
attempt: 1,
|
|
819
|
+
mode: "after-compaction",
|
|
820
|
+
reason: "provider rejected the prompt as too long",
|
|
821
|
+
});
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (!isTransientLlmError(error) || transientRetries >= MAX_API_RETRIES)
|
|
826
|
+
throw error;
|
|
827
|
+
transientRetries += 1;
|
|
828
|
+
if (useStream && options.hasPartialStream())
|
|
829
|
+
useStream = false;
|
|
830
|
+
const delayMs = retryDelayMs(error, transientRetries);
|
|
831
|
+
this.options.emit({
|
|
832
|
+
type: "llm_retry",
|
|
833
|
+
attempt: transientRetries,
|
|
834
|
+
mode: useStream || !options.streamEnabled ? "same-request" : "non-stream",
|
|
835
|
+
reason: retryReason(error),
|
|
836
|
+
delayMs,
|
|
837
|
+
});
|
|
838
|
+
if (delayMs > 0)
|
|
839
|
+
await sleep(delayMs);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async matchedSkillsForPrompt(input) {
|
|
844
|
+
if (this.options.subagent)
|
|
845
|
+
return [];
|
|
846
|
+
const matches = (await this.skillManager.search(input)).slice(0, 2);
|
|
847
|
+
const loaded = [];
|
|
848
|
+
for (const match of matches) {
|
|
849
|
+
if (this.loadedSkillPaths.has(match.path))
|
|
850
|
+
continue;
|
|
851
|
+
try {
|
|
852
|
+
loaded.push({
|
|
853
|
+
content: await this.skillManager.read(match.path),
|
|
854
|
+
name: match.name,
|
|
855
|
+
path: match.path,
|
|
856
|
+
});
|
|
857
|
+
this.loadedSkillPaths.add(match.path);
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
/* ignore */
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return loaded;
|
|
864
|
+
}
|
|
865
|
+
async runTask(agent, prompt, emit) {
|
|
866
|
+
const depth = this.options.depth ?? 0;
|
|
867
|
+
if (depth >= 1) {
|
|
868
|
+
return "Task nesting is not allowed. Complete the work directly with your tools.";
|
|
869
|
+
}
|
|
870
|
+
const runtime = new QueryEngine({
|
|
871
|
+
...this.options,
|
|
872
|
+
emit,
|
|
873
|
+
subagent: agent,
|
|
874
|
+
depth: depth + 1,
|
|
875
|
+
stream: false,
|
|
876
|
+
});
|
|
877
|
+
try {
|
|
878
|
+
return await runtime.submitMessage(prompt);
|
|
879
|
+
}
|
|
880
|
+
finally {
|
|
881
|
+
this.mergeUsage(runtime.getUsageStats(), "task");
|
|
882
|
+
await runtime.close();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
recordUsage(usage, category, fingerprints) {
|
|
886
|
+
addUsage(this.usage, usage);
|
|
887
|
+
addUsage(this.usageByCategory[category], usage);
|
|
888
|
+
this.latestUsageByCategory[category] = requestUsageCounters(usage);
|
|
889
|
+
if (category === "main") {
|
|
890
|
+
this.recordCacheDiagnostics(usage, category, fingerprints);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
mergeUsage(usage, category) {
|
|
894
|
+
mergeUsageCounters(this.usage, usage);
|
|
895
|
+
mergeUsageCounters(this.usageByCategory[category], usage);
|
|
896
|
+
const latest = usage.latestByCategory.main ?? usage.latestByCategory.compaction;
|
|
897
|
+
if (latest)
|
|
898
|
+
this.latestUsageByCategory[category] = rawUsageCounters(latest);
|
|
899
|
+
}
|
|
900
|
+
recordCacheDiagnostics(usage, category, current) {
|
|
901
|
+
this.cacheDiagnostics.prefixFingerprint = current.prefixFingerprint;
|
|
902
|
+
this.cacheDiagnostics.configFingerprint = current.configFingerprint;
|
|
903
|
+
this.cacheDiagnostics.systemFingerprint = current.systemFingerprint;
|
|
904
|
+
this.cacheDiagnostics.toolsFingerprint = current.toolsFingerprint;
|
|
905
|
+
if (!usage)
|
|
906
|
+
return;
|
|
907
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
908
|
+
const previous = this.lastCacheRequest.get(category);
|
|
909
|
+
if (previous?.cacheRead && cacheRead === 0) {
|
|
910
|
+
const systemChanged = previous.systemFingerprint !== current.systemFingerprint;
|
|
911
|
+
const toolsChanged = previous.toolsFingerprint !== current.toolsFingerprint;
|
|
912
|
+
const configChanged = previous.configFingerprint !== current.configFingerprint;
|
|
913
|
+
const changed = [
|
|
914
|
+
configChanged ? "config" : "",
|
|
915
|
+
systemChanged ? "system" : "",
|
|
916
|
+
toolsChanged ? "tools" : "",
|
|
917
|
+
].filter(Boolean);
|
|
918
|
+
this.cacheDiagnostics.missesAfterHit += 1;
|
|
919
|
+
this.cacheDiagnostics.lastMissReason = changed.length > 0
|
|
920
|
+
? `${changed.join(" and ")} changed`
|
|
921
|
+
: "system/tools stable; config stable; message prefix changed, cache expired, or provider missed";
|
|
922
|
+
}
|
|
923
|
+
this.lastCacheRequest.set(category, {
|
|
924
|
+
cacheRead: cacheRead > 0,
|
|
925
|
+
...current,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
configFingerprint() {
|
|
929
|
+
return fingerprint({
|
|
930
|
+
baseUrl: this.options.llmConfig.baseUrl.replace(/\/$/, ""),
|
|
931
|
+
model: this.options.llmConfig.model,
|
|
932
|
+
maxTokens: this.options.llmConfig.maxTokens,
|
|
933
|
+
cacheControl: this.options.llmConfig.cacheTtl === "1h"
|
|
934
|
+
? { type: "ephemeral", ttl: "1h" }
|
|
935
|
+
: { type: "ephemeral" },
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
requestFingerprints(prompt, tools) {
|
|
939
|
+
const configFingerprint = this.configFingerprint();
|
|
940
|
+
const systemFingerprint = fingerprint(prompt);
|
|
941
|
+
const toolsFingerprint = fingerprint(tools);
|
|
942
|
+
return {
|
|
943
|
+
configFingerprint,
|
|
944
|
+
systemFingerprint,
|
|
945
|
+
toolsFingerprint,
|
|
946
|
+
prefixFingerprint: fingerprint({ configFingerprint, systemFingerprint, toolsFingerprint }),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
takeSkillCatalogContext(skills) {
|
|
950
|
+
if (this.options.subagent)
|
|
951
|
+
return "";
|
|
952
|
+
const next = new Map(skills.map((skill) => [skill.path, skill]));
|
|
953
|
+
if (!this.skillCatalogSnapshot) {
|
|
954
|
+
this.skillCatalogSnapshot = next;
|
|
955
|
+
if (skills.length === 0)
|
|
956
|
+
return "";
|
|
957
|
+
return `# Available Skills Catalog\nThis catalog is session context, not part of the cache-stable system prompt. Use these descriptions to decide when a skill applies.\n\n${skills.map(renderSkillMetadata).join("\n")}`;
|
|
958
|
+
}
|
|
959
|
+
const changes = [];
|
|
960
|
+
for (const [skillPath, skill] of next) {
|
|
961
|
+
const previous = this.skillCatalogSnapshot.get(skillPath);
|
|
962
|
+
if (!previous)
|
|
963
|
+
changes.push(`- Added ${renderSkillMetadata(skill).slice(2)}`);
|
|
964
|
+
else if (JSON.stringify(previous) !== JSON.stringify(skill)) {
|
|
965
|
+
changes.push(`- Updated ${renderSkillMetadata(skill).slice(2)}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
for (const [skillPath, skill] of this.skillCatalogSnapshot) {
|
|
969
|
+
if (!next.has(skillPath))
|
|
970
|
+
changes.push(`- Removed ${skill.name}: ${skillPath}`);
|
|
971
|
+
}
|
|
972
|
+
this.skillCatalogSnapshot = next;
|
|
973
|
+
return changes.length > 0
|
|
974
|
+
? `# Available Skills Catalog Update\nThis update supersedes the changed entries in the previous catalog.\n\n${changes.join("\n")}`
|
|
975
|
+
: "";
|
|
976
|
+
}
|
|
977
|
+
runtimeModeContext() {
|
|
978
|
+
const blocks = [];
|
|
979
|
+
const planContext = this.mode === "plan"
|
|
980
|
+
? planModeSystemExtra(this.plan, "plan")
|
|
981
|
+
: this.plan?.approved
|
|
982
|
+
? planModeSystemExtra(this.plan, "normal")
|
|
983
|
+
: "";
|
|
984
|
+
if (planContext && planContext !== this.lastPlanContext) {
|
|
985
|
+
blocks.push(planContext);
|
|
986
|
+
this.lastPlanContext = planContext;
|
|
987
|
+
}
|
|
988
|
+
if (this.mode === "workflow" && this.workflow) {
|
|
989
|
+
const state = JSON.stringify(this.workflow);
|
|
990
|
+
if (state !== this.lastWorkflowState) {
|
|
991
|
+
blocks.push(this.lastWorkflowState === undefined ? workflowSystemExtra(this.workflow) : workflowRuntimeUpdate(this.workflow));
|
|
992
|
+
this.lastWorkflowState = state;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (this.mode === "goal" && this.goal) {
|
|
996
|
+
const state = JSON.stringify(this.goal);
|
|
997
|
+
if (state !== this.lastGoalState) {
|
|
998
|
+
blocks.push(this.lastGoalState === undefined ? goalSystemExtra(this.goal) : goalRuntimeUpdate(this.goal));
|
|
999
|
+
this.lastGoalState = state;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return blocks.join("\n\n");
|
|
1003
|
+
}
|
|
1004
|
+
takeMemoryContext(block) {
|
|
1005
|
+
if (!block || this.seenMemoryBlocks.has(block))
|
|
1006
|
+
return "";
|
|
1007
|
+
this.seenMemoryBlocks.add(block);
|
|
1008
|
+
return block;
|
|
1009
|
+
}
|
|
1010
|
+
resetRuntimeContextTracking() {
|
|
1011
|
+
this.loadedSkillPaths.clear();
|
|
1012
|
+
this.seenMemoryBlocks.clear();
|
|
1013
|
+
this.lastPlanContext = undefined;
|
|
1014
|
+
this.lastWorkflowState = undefined;
|
|
1015
|
+
this.lastGoalState = undefined;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function emptyUsageCounters() {
|
|
1019
|
+
return {
|
|
1020
|
+
requests: 0,
|
|
1021
|
+
requestsWithCacheRead: 0,
|
|
1022
|
+
input_tokens: 0,
|
|
1023
|
+
output_tokens: 0,
|
|
1024
|
+
cache_creation_input_tokens: 0,
|
|
1025
|
+
cache_read_input_tokens: 0,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
function addUsage(target, usage) {
|
|
1029
|
+
target.requests += 1;
|
|
1030
|
+
if (!usage)
|
|
1031
|
+
return;
|
|
1032
|
+
target.input_tokens += usage.input_tokens ?? 0;
|
|
1033
|
+
target.output_tokens += usage.output_tokens ?? 0;
|
|
1034
|
+
target.cache_creation_input_tokens += usage.cache_creation_input_tokens ?? 0;
|
|
1035
|
+
target.cache_read_input_tokens += usage.cache_read_input_tokens ?? 0;
|
|
1036
|
+
if ((usage.cache_read_input_tokens ?? 0) > 0)
|
|
1037
|
+
target.requestsWithCacheRead += 1;
|
|
1038
|
+
}
|
|
1039
|
+
function mergeUsageCounters(target, usage) {
|
|
1040
|
+
target.requests += usage.requests;
|
|
1041
|
+
target.requestsWithCacheRead += usage.requestsWithCacheRead;
|
|
1042
|
+
target.input_tokens += usage.input_tokens;
|
|
1043
|
+
target.output_tokens += usage.output_tokens;
|
|
1044
|
+
target.cache_creation_input_tokens += usage.cache_creation_input_tokens;
|
|
1045
|
+
target.cache_read_input_tokens += usage.cache_read_input_tokens;
|
|
1046
|
+
}
|
|
1047
|
+
function usageStats(usage) {
|
|
1048
|
+
const totalInput = usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
|
|
1049
|
+
return {
|
|
1050
|
+
...usage,
|
|
1051
|
+
cacheHitRate: totalInput > 0 ? usage.cache_read_input_tokens / totalInput : null,
|
|
1052
|
+
requestHitRate: usage.requests > 0 ? usage.requestsWithCacheRead / usage.requests : null,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function requestUsageCounters(usage) {
|
|
1056
|
+
const counters = emptyUsageCounters();
|
|
1057
|
+
addUsage(counters, usage);
|
|
1058
|
+
return counters;
|
|
1059
|
+
}
|
|
1060
|
+
function rawUsageCounters(usage) {
|
|
1061
|
+
return {
|
|
1062
|
+
requests: usage.requests,
|
|
1063
|
+
requestsWithCacheRead: usage.requestsWithCacheRead,
|
|
1064
|
+
input_tokens: usage.input_tokens,
|
|
1065
|
+
output_tokens: usage.output_tokens,
|
|
1066
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
|
1067
|
+
cache_read_input_tokens: usage.cache_read_input_tokens,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function restoreLatestUsage(usage) {
|
|
1071
|
+
return usage ? restoreUsageCounters(usage) : null;
|
|
1072
|
+
}
|
|
1073
|
+
function restoreUsageCounters(usage) {
|
|
1074
|
+
const restored = emptyUsageCounters();
|
|
1075
|
+
if (!usage)
|
|
1076
|
+
return restored;
|
|
1077
|
+
for (const key of Object.keys(restored)) {
|
|
1078
|
+
const value = usage[key];
|
|
1079
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0)
|
|
1080
|
+
restored[key] = value;
|
|
1081
|
+
}
|
|
1082
|
+
return restored;
|
|
1083
|
+
}
|
|
1084
|
+
function fingerprint(value) {
|
|
1085
|
+
const content = typeof value === "string" ? value : JSON.stringify(value);
|
|
1086
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 12);
|
|
1087
|
+
}
|
|
1088
|
+
function isTransientLlmError(error) {
|
|
1089
|
+
if (error instanceof AnthropicApiError) {
|
|
1090
|
+
const shouldRetry = error.responseHeaders.get("x-should-retry");
|
|
1091
|
+
if (shouldRetry === "false")
|
|
1092
|
+
return false;
|
|
1093
|
+
if (shouldRetry === "true")
|
|
1094
|
+
return true;
|
|
1095
|
+
if (error.responseBody.includes("overloaded_error"))
|
|
1096
|
+
return true;
|
|
1097
|
+
return [408, 409, 425, 429, 500, 502, 503, 504, 529].includes(error.status);
|
|
1098
|
+
}
|
|
1099
|
+
if (!(error instanceof Error))
|
|
1100
|
+
return false;
|
|
1101
|
+
if (error.name === "TimeoutError")
|
|
1102
|
+
return true;
|
|
1103
|
+
if (error instanceof TypeError)
|
|
1104
|
+
return true;
|
|
1105
|
+
const code = error.code ??
|
|
1106
|
+
error.cause?.code;
|
|
1107
|
+
return ["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ENETUNREACH", "EAI_AGAIN"].includes(code ?? "");
|
|
1108
|
+
}
|
|
1109
|
+
function isContextOverflowError(error) {
|
|
1110
|
+
if (!(error instanceof AnthropicApiError))
|
|
1111
|
+
return false;
|
|
1112
|
+
if (error.status === 413)
|
|
1113
|
+
return true;
|
|
1114
|
+
if (error.status !== 400)
|
|
1115
|
+
return false;
|
|
1116
|
+
return /prompt.{0,20}too long|context (?:length|window|limit)|maximum context|too many tokens|input.{0,30}tokens.{0,30}exceed/i
|
|
1117
|
+
.test(error.responseBody);
|
|
1118
|
+
}
|
|
1119
|
+
function retryDelayMs(error, attempt) {
|
|
1120
|
+
if (error instanceof AnthropicApiError) {
|
|
1121
|
+
const retryAfter = error.responseHeaders.get("retry-after");
|
|
1122
|
+
if (retryAfter) {
|
|
1123
|
+
const seconds = Number(retryAfter);
|
|
1124
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1125
|
+
return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS);
|
|
1126
|
+
}
|
|
1127
|
+
const dateDelay = Date.parse(retryAfter) - Date.now();
|
|
1128
|
+
if (Number.isFinite(dateDelay) && dateDelay > 0) {
|
|
1129
|
+
return Math.min(dateDelay, MAX_RETRY_DELAY_MS);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const base = Math.min(500 * (2 ** (attempt - 1)), 8000);
|
|
1134
|
+
return Math.round(base + Math.random() * base * 0.25);
|
|
1135
|
+
}
|
|
1136
|
+
function retryReason(error) {
|
|
1137
|
+
if (error instanceof AnthropicApiError)
|
|
1138
|
+
return `provider API ${error.status}`;
|
|
1139
|
+
if (error instanceof Error && error.name === "TimeoutError")
|
|
1140
|
+
return "provider request timed out";
|
|
1141
|
+
return error instanceof Error ? error.message : String(error);
|
|
1142
|
+
}
|
|
1143
|
+
function isOutputLimitStop(reason) {
|
|
1144
|
+
return reason === "max_tokens" || reason === "max_output_tokens";
|
|
1145
|
+
}
|
|
1146
|
+
function sleep(delayMs) {
|
|
1147
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1148
|
+
}
|
|
1149
|
+
function renderSkillMetadata(skill) {
|
|
1150
|
+
return `- ${skill.name}: ${skill.description || "No description."} Path: ${skill.path}`;
|
|
1151
|
+
}
|
|
1152
|
+
function workflowRuntimeUpdate(workflow) {
|
|
1153
|
+
const phases = workflow.phases
|
|
1154
|
+
.map((phase) => `- ${phase.name}: ${phase.status}${phase.summary ? ` — ${phase.summary}` : ""}`)
|
|
1155
|
+
.join("\n");
|
|
1156
|
+
return `# Coordinator Workflow Update
|
|
1157
|
+
This state supersedes the previous workflow state.
|
|
1158
|
+
|
|
1159
|
+
Goal: ${workflow.goal}
|
|
1160
|
+
|
|
1161
|
+
${phases}`;
|
|
1162
|
+
}
|
|
1163
|
+
function goalRuntimeUpdate(goal) {
|
|
1164
|
+
const latestProgress = goal.progress.at(-1) ?? "(none)";
|
|
1165
|
+
return `# Goal Mode Update
|
|
1166
|
+
This state supersedes the previous goal state.
|
|
1167
|
+
|
|
1168
|
+
- Status: ${goal.status}
|
|
1169
|
+
- Iteration: ${goal.iteration} / ${goal.maxIterations}
|
|
1170
|
+
- Latest progress: ${latestProgress}${goal.lastBlockedReason ? `\n- Blocked: ${goal.lastBlockedReason}` : ""}`;
|
|
1171
|
+
}
|
|
1172
|
+
function buildRuntimeContext(matchedSkills, modeExtra) {
|
|
1173
|
+
const skillBlocks = matchedSkills.map((skill) => `## ${skill.name}\nPath: ${skill.path}\n\n${skill.content}`);
|
|
1174
|
+
return [
|
|
1175
|
+
skillBlocks.length
|
|
1176
|
+
? `# Loaded Skill Instructions\nThese trusted local skill instructions matched this turn. Follow them when applicable.\n\n${skillBlocks.join("\n\n")}`
|
|
1177
|
+
: "",
|
|
1178
|
+
modeExtra.trim(),
|
|
1179
|
+
]
|
|
1180
|
+
.filter(Boolean)
|
|
1181
|
+
.join("\n\n");
|
|
1182
|
+
}
|