@ikarmic/forge-core 5.2.0 → 5.4.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/dist/agent/agent.js +324 -122
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +19 -2
- package/dist/agent/prompts.js +44 -2
- package/dist/agent/retry.d.ts +50 -0
- package/dist/agent/retry.js +192 -0
- package/dist/agent/shell-command.d.ts +23 -0
- package/dist/agent/shell-command.js +93 -0
- package/dist/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -0
- package/dist/catalog/models-dev-snapshot.d.ts +1 -0
- package/dist/catalog/models-dev-snapshot.js +3 -0
- package/dist/catalog/models-dev.d.ts +45 -0
- package/dist/catalog/models-dev.js +155 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +94 -28
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +14 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/memory/session-digest.js +2 -1
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/pricing.js +12 -0
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/edit-file.d.ts +8 -0
- package/dist/tools/edit-file.js +39 -16
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +44 -9
- package/dist/tools/search-files.js +12 -53
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +86 -0
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +4 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +132 -1
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
package/dist/agent/agent.js
CHANGED
|
@@ -2,13 +2,19 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { release } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { streamText } from 'ai';
|
|
5
|
+
import { formatFile, formatMessage } from '../format/auto-format.js';
|
|
6
|
+
import { BackgroundProcesses } from '../tools/background-process.js';
|
|
7
|
+
import { networkDenial } from '../tools/network-policy.js';
|
|
8
|
+
import { runnerShell } from '../tools/shell.js';
|
|
9
|
+
import { cancelWakeupTool, scheduleWakeupTool } from '../wakeup/wakeup-tools.js';
|
|
5
10
|
import { lintFile, lintMessage } from '../lint/auto-lint.js';
|
|
6
11
|
import { buildMemoryBlock } from '../memory/memory-store.js';
|
|
7
12
|
import { filesTouchedBy } from '../tools/write-tools.js';
|
|
8
13
|
import { buildExtraInstructionsBlock } from '../steering/extra-instructions.js';
|
|
9
14
|
import { buildProjectInstructionsBlock } from '../steering/project-instructions.js';
|
|
10
15
|
import { buildRepoMap, repoMapBlock } from '../repomap/repo-map.js';
|
|
11
|
-
import {
|
|
16
|
+
import { adaptHistoryForModel, normalizeForModel } from './history-adapt.js';
|
|
17
|
+
import { buildLlmSummarizer, compactMessages, effectiveLimitTokens, estimateMessageTokens, getModelWindowTokens, shouldCompactForBudget } from '../context/compaction.js';
|
|
12
18
|
import { createStepMasker } from '../context/mask.js';
|
|
13
19
|
import { lookupPricing, computeCostUsd } from '../pricing.js';
|
|
14
20
|
import { BrowserSession } from '../browser/session.js';
|
|
@@ -19,6 +25,7 @@ import { createCodebaseSearchTool } from '../tools/codebase-search.js';
|
|
|
19
25
|
import { createGitHubTools, githubFromEnv } from '../github/github-tools.js';
|
|
20
26
|
import { createWebSearchTool, webSearchFromEnv } from '../tools/web-search.js';
|
|
21
27
|
import { embedderFromEnv } from '../semantic/embedder.js';
|
|
28
|
+
import { capToolResult } from '../tools/result-cap.js';
|
|
22
29
|
import { DEFAULT_VERIFY_RETRIES, failureMessage, runVerification } from './verify.js';
|
|
23
30
|
import { LoopGuard, resultKeyOf } from './loop-guard.js';
|
|
24
31
|
import { isListedCommand } from '../tools/command-allow.js';
|
|
@@ -27,17 +34,29 @@ import { createLanguageModel } from '../providers.js';
|
|
|
27
34
|
import { BUILTIN_TOOLS } from '../tools/index.js';
|
|
28
35
|
import { ToolResultCache, cachedExecute } from '../tools/result-cache.js';
|
|
29
36
|
import { ThinkSplitter, stripThinkTags } from './think-tags.js';
|
|
37
|
+
import { repairToolCall } from './tool-repair.js';
|
|
38
|
+
import { maxOutputTokensFor } from '../catalog/models-dev.js';
|
|
30
39
|
import { redact } from '../tools/redact.js';
|
|
31
|
-
import { composeSkillsPrompt, selectSkills } from '../skills/activator.js';
|
|
40
|
+
import { buildSkillIndex, composeSkillsPrompt, selectSkills } from '../skills/activator.js';
|
|
41
|
+
import { createSkillTool } from '../skills/skill-tool.js';
|
|
32
42
|
import { MAX_TOOL_INJECTIONS, selectAtStart, selectOnToolTouch, toWorkspaceRelative, wrapSteering } from '../steering/select.js';
|
|
33
|
-
import { applyCacheHints } from './cache-hints.js';
|
|
43
|
+
import { applyCacheHints, cacheProviderOptions } from './cache-hints.js';
|
|
34
44
|
import { raceAbort } from './race-abort.js';
|
|
35
|
-
import {
|
|
45
|
+
import { DEFAULT_PROVIDER_RETRIES, DEFAULT_RETRY_BASE_MS, defaultNetworkProbe, errorSummary, isContextOverflowError, isDisconnected, retryDecision, waitForNetwork, waitOrAbort, } from './retry.js';
|
|
46
|
+
import { attachmentParts, formatBytes } from '../attachments.js';
|
|
36
47
|
import { renderAnswersBlock } from '../journey/answers-block.js';
|
|
37
|
-
import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, environmentBlock } from './prompts.js';
|
|
48
|
+
import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, LAST_STEP_PROMPT, environmentBlock, modelGuidanceBlock, todoNudgePrompt } from './prompts.js';
|
|
38
49
|
import { detectStackFacts, stackBlock } from './stack-facts.js';
|
|
39
50
|
/** Read-only tool names whose output flows through the redactor. */
|
|
40
51
|
const DEFAULT_MAX_CONTINUATIONS = 3;
|
|
52
|
+
/** Nudges per run when a turn ends with open todos; never two without a tool call between them. */
|
|
53
|
+
const MAX_TODO_NUDGES = 3;
|
|
54
|
+
/** Times an empty or reasoning-only reply is asked for again before it is accepted. */
|
|
55
|
+
const MAX_EMPTY_REPLAYS = 2;
|
|
56
|
+
/** Consecutive tool calls with invalid arguments before the run stops (Kilo: REPEATED_TOOL_FAILURE_LIMIT). */
|
|
57
|
+
const MAX_INVALID_CALLS = 3;
|
|
58
|
+
/** Most recent content kept word for word by compaction, at the top end (RB-56). */
|
|
59
|
+
const KEEP_RECENT_TOKENS = 20_000;
|
|
41
60
|
export const DEFAULT_MAX_DURATION_MS = 10 * 60 * 1000;
|
|
42
61
|
const REDACTABLE_TOOLS = new Set(['read_file', 'search_files', 'list_files']);
|
|
43
62
|
/** Tools whose successful `path` argument can trigger mid-run fileMatch steering. */
|
|
@@ -74,6 +93,12 @@ export class Agent {
|
|
|
74
93
|
const allTools = (config.disableBuiltins
|
|
75
94
|
? [...(config.extraTools ?? [])]
|
|
76
95
|
: [...BUILTIN_TOOLS, ...(config.extraTools ?? [])]);
|
|
96
|
+
// Wakeups need a chat that can deliver them, so the tools exist only when a surface supplied a scheduler.
|
|
97
|
+
// The `skill` tool goes with the skills index in the prompt: both exist only when a registry is supplied and not disabled.
|
|
98
|
+
if (config.skills && !config.skills.disabled)
|
|
99
|
+
allTools.push(createSkillTool(config.skills.registry));
|
|
100
|
+
if (config.wakeups)
|
|
101
|
+
allTools.push(scheduleWakeupTool, cancelWakeupTool);
|
|
77
102
|
if (config.browser) {
|
|
78
103
|
this.browser = new BrowserSession(config.browser === true ? {} : config.browser);
|
|
79
104
|
allTools.push(...createBrowserTools(this.browser));
|
|
@@ -89,6 +114,8 @@ export class Agent {
|
|
|
89
114
|
...(config.semanticSearch.extensions ? { extensions: config.semanticSearch.extensions } : {}),
|
|
90
115
|
...(config.semanticSearch.minScore !== undefined ? { minScore: config.semanticSearch.minScore } : {}),
|
|
91
116
|
...(config.semanticSearch.defaultLimit ? { defaultLimit: config.semanticSearch.defaultLimit } : {}),
|
|
117
|
+
...(config.semanticSearch.consent ? { consent: config.semanticSearch.consent } : {}),
|
|
118
|
+
...(config.semanticSearch.onStatus ? { onStatus: config.semanticSearch.onStatus } : {}),
|
|
92
119
|
}));
|
|
93
120
|
}
|
|
94
121
|
const memoryFiltered = config.disableMemory
|
|
@@ -116,7 +143,7 @@ export class Agent {
|
|
|
116
143
|
const cfg = this.checkpoints?.captureBeforeTools;
|
|
117
144
|
if (!cfg) {
|
|
118
145
|
// Default: file-mutating tools + run_command (which can mutate via shell).
|
|
119
|
-
return new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command']);
|
|
146
|
+
return new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command', 'bg_start']);
|
|
120
147
|
}
|
|
121
148
|
return new Set(cfg);
|
|
122
149
|
}
|
|
@@ -190,9 +217,15 @@ export class Agent {
|
|
|
190
217
|
/** Docs injected mid-run (not skipped ones), so compaction can tell which it removed. */
|
|
191
218
|
const steeringInMessages = new Set();
|
|
192
219
|
let steeringInjections = 0;
|
|
220
|
+
// The system message is rebuilt on every turn from what is true now (RB-57): files, steering, the mode's prompt
|
|
221
|
+
// and the date can change between turns. Built in a stable order, it is byte-identical when nothing changed.
|
|
222
|
+
const systemSteering = options.history
|
|
223
|
+
? selectAtStart(steeringDocs, { task: options.task, cwd, includeAlways: true }, this.config.steering?.maxBytes).system
|
|
224
|
+
: startSteering.system;
|
|
225
|
+
const system = { role: 'system', content: await buildSystemContent(this.config, options.task, this.skills, systemSteering) };
|
|
193
226
|
const messages = options.history
|
|
194
|
-
? [...options.history, { role: 'user', content: userContent }]
|
|
195
|
-
:
|
|
227
|
+
? [system, ...adaptHistoryForModel(options.history, this.config.model).filter((m, i) => !(i === 0 && m.role === 'system')), { role: 'user', content: userContent }]
|
|
228
|
+
: [system, { role: 'user', content: userContent }];
|
|
196
229
|
if (options.attachments && options.attachments.length > 0) {
|
|
197
230
|
// Attached files ride in this turn's user message as native parts (see attachments.ts).
|
|
198
231
|
messages[messages.length - 1] = {
|
|
@@ -200,6 +233,18 @@ export class Agent {
|
|
|
200
233
|
content: [{ type: 'text', text: userContent }, ...attachmentParts(options.attachments)],
|
|
201
234
|
};
|
|
202
235
|
}
|
|
236
|
+
// This turn's messages on their own (RB-58): compaction replaces `messages`, never these, so a surface can show
|
|
237
|
+
// the whole conversation. Everything the run adds goes through append().
|
|
238
|
+
const turnMessages = [messages[messages.length - 1]];
|
|
239
|
+
const append = (m) => {
|
|
240
|
+
messages.push(m);
|
|
241
|
+
turnMessages.push(m);
|
|
242
|
+
};
|
|
243
|
+
const unappend = () => {
|
|
244
|
+
const last = messages.pop();
|
|
245
|
+
if (last !== undefined && turnMessages[turnMessages.length - 1] === last)
|
|
246
|
+
turnMessages.pop();
|
|
247
|
+
};
|
|
203
248
|
// Plan mode: hard-cap iterations to 1 and add a system prompt suffix
|
|
204
249
|
// telling the model to emit a plan as text and stop. Spec mode: allow
|
|
205
250
|
// up to `specMaxIterations` turns (default 15) so the model can
|
|
@@ -219,6 +264,7 @@ export class Agent {
|
|
|
219
264
|
? createStepMasker({
|
|
220
265
|
keepRecentTurns: (typeof maskConfig === 'object' ? maskConfig.keepRecentTurns : undefined) ?? 6,
|
|
221
266
|
minChars: (typeof maskConfig === 'object' ? maskConfig.minChars : undefined) ?? 600,
|
|
267
|
+
...(typeof maskConfig === 'object' && maskConfig.protectTokens !== undefined ? { protectTokens: maskConfig.protectTokens } : {}),
|
|
222
268
|
softBudgetTokens: 0.5 * effectiveLimitTokens(this.config.model, this.config.compactionThreshold ?? 0.7, this.config.contextBudgetTokens),
|
|
223
269
|
})
|
|
224
270
|
: undefined;
|
|
@@ -234,6 +280,8 @@ export class Agent {
|
|
|
234
280
|
let reportedStreamError;
|
|
235
281
|
const modelFactory = this.config.modelFactory ?? createLanguageModel;
|
|
236
282
|
let currentModelConfig = this.config.model;
|
|
283
|
+
/** The prompt size at which to compact for a given model: its window times the threshold, or the budget. */
|
|
284
|
+
const limitOf = (m) => effectiveLimitTokens(m, this.config.compactionThreshold ?? 0.7, this.config.contextBudgetTokens);
|
|
237
285
|
let model = modelFactory(currentModelConfig);
|
|
238
286
|
let stepPricing = this.pricing;
|
|
239
287
|
let lastToolNames = [];
|
|
@@ -259,6 +307,26 @@ export class Agent {
|
|
|
259
307
|
let partialStepText = '';
|
|
260
308
|
let continuations = 0;
|
|
261
309
|
const maxContinuations = this.config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS;
|
|
310
|
+
// Open-todo nudges: the one sent last (so a run that ends before answering it drops it) and whether
|
|
311
|
+
// the step just answered was a nudge (a second text-only reply in a row is the model's answer).
|
|
312
|
+
// Only a list this run wrote counts: the chat carries the last turn's list over, and an unrelated
|
|
313
|
+
// question answered while it is open must not be pushed back onto the old task.
|
|
314
|
+
let todoNudges = 0;
|
|
315
|
+
let todoNudgeMessage;
|
|
316
|
+
let answeringNudge = false;
|
|
317
|
+
let todosWrittenThisRun = false;
|
|
318
|
+
// Provider failures and empty replies retried for the step now running; both reset when a step succeeds.
|
|
319
|
+
const maxProviderRetries = this.config.providerRetry?.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
320
|
+
const retryBaseMs = this.config.providerRetry?.baseDelayMs ?? DEFAULT_RETRY_BASE_MS;
|
|
321
|
+
let providerFailures = 0;
|
|
322
|
+
let emptyReplays = 0;
|
|
323
|
+
// Time spent waiting for the network in this run, across every offline spell.
|
|
324
|
+
const offlinePollMs = this.config.providerRetry?.offlinePollMs ?? 3000;
|
|
325
|
+
const maxOfflineMs = this.config.providerRetry?.maxOfflineMs ?? 15 * 60_000;
|
|
326
|
+
let offlineWaitedMs = 0;
|
|
327
|
+
let offlineWaits = 0;
|
|
328
|
+
// Consecutive tool calls whose arguments failed the schema, across steps; any valid call resets it.
|
|
329
|
+
let invalidStreak = { tool: '', count: 0 };
|
|
262
330
|
const ctx = {
|
|
263
331
|
cwd: this.config.cwd ?? process.cwd(),
|
|
264
332
|
auto: this.config.auto ?? false,
|
|
@@ -269,20 +337,69 @@ export class Agent {
|
|
|
269
337
|
...(this.config.permissionRules ? { permissionRules: this.config.permissionRules } : {}),
|
|
270
338
|
...(this.config.toolSandbox ? { toolSandbox: this.config.toolSandbox } : {}),
|
|
271
339
|
shownFolderInstructions: new Set(),
|
|
340
|
+
processes: new BackgroundProcesses(),
|
|
341
|
+
...(this.config.shell ? { shell: this.config.shell } : {}),
|
|
342
|
+
// Earlier grants of this chat (G16), then whatever repo_clone and a yes to a question add during the run.
|
|
343
|
+
readRoots: [...(this.config.readRoots ?? [])],
|
|
344
|
+
...(this.config.askReadOutside ? { askReadOutside: this.config.askReadOutside } : {}),
|
|
345
|
+
...(this.config.suggestPlan ? { suggestPlan: true } : {}),
|
|
346
|
+
...(this.config.planDeclined ? { planDeclined: true } : {}),
|
|
347
|
+
...(this.config.wakeups ? { wakeups: this.config.wakeups } : {}),
|
|
272
348
|
...(this.config.maxToolOutputChars ? { maxOutputChars: this.config.maxToolOutputChars } : {}),
|
|
349
|
+
...(typeof this.config.verify?.lsp === 'object' ? { lspConfig: this.config.verify.lsp } : {}),
|
|
273
350
|
...(this.config.askUser ? { askUser: this.config.askUser } : {}),
|
|
274
351
|
...(this.config.askBudget ? { askBudget: this.config.askBudget } : {}),
|
|
275
352
|
todos: {
|
|
276
353
|
get: () => this.todoList,
|
|
277
354
|
set: (todos) => {
|
|
278
355
|
this.todoList = todos;
|
|
356
|
+
todosWrittenThisRun = true;
|
|
279
357
|
emit({ type: 'todo_updated', todos });
|
|
280
358
|
},
|
|
281
359
|
},
|
|
282
360
|
...(this.config.commandRunner ? { commandRunner: this.config.commandRunner } : {}),
|
|
361
|
+
...(this.config.commandSandbox ? { commandSandbox: this.config.commandSandbox } : {}),
|
|
283
362
|
...(abortSignal ? { abortSignal } : {}),
|
|
284
363
|
...(this.config.memoryMaxBodyChars !== undefined ? { memoryMaxBodyChars: this.config.memoryMaxBodyChars } : {}),
|
|
285
364
|
};
|
|
365
|
+
// Summarize older turns in place; returns how many messages were dropped (0: nothing could be compacted).
|
|
366
|
+
const compactNow = async (tokens) => {
|
|
367
|
+
if (this.hooks) {
|
|
368
|
+
await this.hooks.dispatch({ type: 'PreCompact', messagesCount: messages.length, tokens, cwd });
|
|
369
|
+
}
|
|
370
|
+
const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
|
|
371
|
+
const limit = limitOf(currentModelConfig);
|
|
372
|
+
const result = await compactMessages(messages, summarizer, {
|
|
373
|
+
keepRecent: this.config.compactionKeepRecent,
|
|
374
|
+
keepRecentTokens: Math.min(KEEP_RECENT_TOKENS, Math.floor(limit / 4)),
|
|
375
|
+
});
|
|
376
|
+
if (result.droppedCount === 0)
|
|
377
|
+
return 0;
|
|
378
|
+
// The last reported size describes the prompt before compaction; until the next call reports, the estimate rules.
|
|
379
|
+
lastTurnInputTokens = 0;
|
|
380
|
+
messages.length = 0;
|
|
381
|
+
for (const m of result.messages)
|
|
382
|
+
messages.push(m);
|
|
383
|
+
masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
|
|
384
|
+
// A mid-run steering doc summarized away may be injected again on the next touch.
|
|
385
|
+
for (const name of [...steeringInMessages]) {
|
|
386
|
+
const marker = `<steering name="${name}">`;
|
|
387
|
+
if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
|
|
388
|
+
continue;
|
|
389
|
+
steeringInMessages.delete(name);
|
|
390
|
+
steeringInjected.delete(name);
|
|
391
|
+
steeringInjections--;
|
|
392
|
+
}
|
|
393
|
+
emit({
|
|
394
|
+
type: 'compaction',
|
|
395
|
+
droppedCount: result.droppedCount,
|
|
396
|
+
savedTokens: result.savedTokens,
|
|
397
|
+
summary: result.summary,
|
|
398
|
+
});
|
|
399
|
+
return result.droppedCount;
|
|
400
|
+
};
|
|
401
|
+
// The provider refused the prompt as too long: compact once and ask again (Kilo does the same).
|
|
402
|
+
let overflowCompactions = 0;
|
|
286
403
|
// Emit initial cache stats so the UI knows the cache is wired up.
|
|
287
404
|
emit({ type: 'cache_stats', ...this.cache.getStats() });
|
|
288
405
|
try {
|
|
@@ -327,6 +444,7 @@ export class Agent {
|
|
|
327
444
|
// Track per-turn token deltas so we can compute per-turn cost.
|
|
328
445
|
const turnStart = { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens };
|
|
329
446
|
let reasoningText = '';
|
|
447
|
+
let stepFailure;
|
|
330
448
|
partialStepText = '';
|
|
331
449
|
// Inline <think> blocks (MiniMax over OpenAI-compatible, some open models) become reasoning events.
|
|
332
450
|
const thinkSplitter = new ThinkSplitter();
|
|
@@ -348,27 +466,54 @@ export class Agent {
|
|
|
348
466
|
}
|
|
349
467
|
wasInThink = thinkSplitter.reasoning;
|
|
350
468
|
};
|
|
469
|
+
if (!planMode && !specMode && this.config.enableCompaction) {
|
|
470
|
+
const before = Math.max(lastTurnInputTokens, estimateMessageTokens(messages));
|
|
471
|
+
if (before >= limitOf(currentModelConfig))
|
|
472
|
+
await compactNow(before);
|
|
473
|
+
}
|
|
351
474
|
let promptMessages = messages;
|
|
352
475
|
if (masker) {
|
|
353
|
-
const view = masker.apply(messages, Math.max(lastTurnInputTokens,
|
|
354
|
-
|
|
476
|
+
const view = masker.apply(messages, Math.max(lastTurnInputTokens, estimateMessageTokens(messages)));
|
|
477
|
+
// Stubs are stored in the prompt history (RB-60), so the next turn sends the same prefix and the cache hits.
|
|
478
|
+
// The full results stay in turnMessages, which the transcript keeps (RB-58).
|
|
479
|
+
for (let i = 0; i < messages.length; i++)
|
|
480
|
+
if (view.messages[i] !== messages[i])
|
|
481
|
+
messages[i] = view.messages[i];
|
|
482
|
+
promptMessages = messages;
|
|
355
483
|
if (view.stepped)
|
|
356
484
|
emit({ type: 'context_masked', maskedCount: view.maskedCount, savedTokens: Math.ceil(view.savedChars / 4) });
|
|
357
485
|
}
|
|
486
|
+
// The last allowed step: no tools, and a note asking for a summary instead of stopping mid-task.
|
|
487
|
+
const lastStep = !planMode && !specMode && effectiveMaxIter > 1 && iterations >= effectiveMaxIter;
|
|
488
|
+
if (lastStep)
|
|
489
|
+
promptMessages = [...promptMessages, { role: 'user', content: LAST_STEP_PROMPT }];
|
|
358
490
|
const result = streamText({
|
|
359
491
|
model,
|
|
360
|
-
|
|
492
|
+
// Per-provider clean-up on every call (RB-61), then cache breakpoints on the cleaned messages.
|
|
493
|
+
messages: cachePrompts
|
|
494
|
+
? applyCacheHints(normalizeForModel(promptMessages, currentModelConfig), currentModelConfig)
|
|
495
|
+
: normalizeForModel(promptMessages, currentModelConfig),
|
|
496
|
+
...(cachePrompts && cacheProviderOptions(currentModelConfig, this.config.sessionId)
|
|
497
|
+
? { providerOptions: cacheProviderOptions(currentModelConfig, this.config.sessionId) }
|
|
498
|
+
: {}),
|
|
361
499
|
// v7 rejects a system-role message inside `messages` by default
|
|
362
500
|
// ("Invalid prompt: System messages are not allowed in the prompt
|
|
363
501
|
// or messages fields. Use the instructions option instead.").
|
|
364
|
-
// Forge's system prompt lives as messages[0] (
|
|
502
|
+
// Forge's system prompt lives as messages[0] (buildSystemContent)
|
|
365
503
|
//: this is the SDK's own documented escape hatch to keep that
|
|
366
504
|
// architecture rather than restructuring session persistence and
|
|
367
505
|
// compaction (both of which also treat messages[0] as the system
|
|
368
506
|
// message) around a separate `system` option.
|
|
369
507
|
allowSystemInMessages: true,
|
|
370
508
|
tools: toAiSdkTools(this.tools),
|
|
509
|
+
repairToolCall,
|
|
510
|
+
// Ask for the model's own reply length (G8): some hosts default to a few thousand tokens, which cuts a long
|
|
511
|
+
// write_file call in half. A half-written tool call cannot be continued, unlike text.
|
|
512
|
+
...(maxOutputTokensFor(currentModelConfig) ? { maxOutputTokens: maxOutputTokensFor(currentModelConfig) } : {}),
|
|
513
|
+
...(lastStep ? { toolChoice: 'none' } : {}),
|
|
371
514
|
abortSignal,
|
|
515
|
+
// Retries are decided below (retryDecision), for failures before and during the stream alike.
|
|
516
|
+
maxRetries: 0,
|
|
372
517
|
...(this.config.model.reasoningEffort ? { reasoning: this.config.model.reasoningEffort } : {}),
|
|
373
518
|
...(this.config.model.temperature !== undefined ? { temperature: this.config.model.temperature } : {}),
|
|
374
519
|
onChunk: ({ chunk }) => {
|
|
@@ -388,8 +533,9 @@ export class Agent {
|
|
|
388
533
|
// Pressing Stop aborts the request; that is not a failure to report.
|
|
389
534
|
if (abortSignal?.aborted)
|
|
390
535
|
return;
|
|
536
|
+
// Reported only if it is not retried (below); the SDK then throws a generic "No output generated".
|
|
537
|
+
stepFailure = error;
|
|
391
538
|
reportedStreamError = errorMessageOf(error);
|
|
392
|
-
emit({ type: 'error', error: reportedStreamError });
|
|
393
539
|
},
|
|
394
540
|
onFinish: ({ text: rawText, usage }) => {
|
|
395
541
|
emitSegments(thinkSplitter.flush());
|
|
@@ -439,14 +585,90 @@ export class Agent {
|
|
|
439
585
|
}
|
|
440
586
|
},
|
|
441
587
|
});
|
|
442
|
-
|
|
443
|
-
|
|
588
|
+
let stepText;
|
|
589
|
+
let toolCalls;
|
|
590
|
+
let response;
|
|
591
|
+
try {
|
|
592
|
+
await result.consumeStream();
|
|
593
|
+
stepText = stripThinkTags(await result.text);
|
|
594
|
+
toolCalls = await result.toolCalls;
|
|
595
|
+
response = await result.response;
|
|
596
|
+
if (stepFailure !== undefined)
|
|
597
|
+
throw stepFailure;
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (abortSignal?.aborted)
|
|
601
|
+
throw err;
|
|
602
|
+
const failure = stepFailure ?? err;
|
|
603
|
+
stepFailure = undefined;
|
|
604
|
+
if (isContextOverflowError(failure) && this.config.enableCompaction && !planMode && !specMode && overflowCompactions < 2) {
|
|
605
|
+
overflowCompactions++;
|
|
606
|
+
if ((await compactNow(Math.max(lastTurnInputTokens, estimateMessageTokens(messages)))) > 0) {
|
|
607
|
+
partialStepText = '';
|
|
608
|
+
iterations--;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// No network at all: wait for it to come back instead of spending retries (Kilo's offline guard).
|
|
613
|
+
// Only when a probe confirms it: a mistyped provider host fails DNS too while the network is fine.
|
|
614
|
+
const probe = this.config.providerRetry?.networkProbe ?? defaultNetworkProbe;
|
|
615
|
+
if (isDisconnected(failure) && offlineWaitedMs < maxOfflineMs && !(await probe().catch(() => false))) {
|
|
616
|
+
offlineWaits++;
|
|
617
|
+
emit({ type: 'retry', kind: 'offline', attempt: offlineWaits, maxRetries: 0, delayMs: offlinePollMs, reason: errorSummary(failure) });
|
|
618
|
+
const waited = await waitForNetwork(probe, {
|
|
619
|
+
pollMs: offlinePollMs,
|
|
620
|
+
budgetMs: maxOfflineMs - offlineWaitedMs,
|
|
621
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
622
|
+
});
|
|
623
|
+
offlineWaitedMs += waited.waitedMs;
|
|
624
|
+
if (waited.outcome === 'aborted') {
|
|
625
|
+
reason = 'aborted';
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
if (waited.outcome === 'online') {
|
|
629
|
+
partialStepText = '';
|
|
630
|
+
iterations--;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
throw failure;
|
|
634
|
+
}
|
|
635
|
+
const decision = retryDecision(failure, providerFailures + 1, retryBaseMs);
|
|
636
|
+
if (!decision.retry || providerFailures >= maxProviderRetries)
|
|
637
|
+
throw failure;
|
|
638
|
+
providerFailures++;
|
|
639
|
+
// The step is thrown away whole: nothing it streamed is kept, and it does not count as a step.
|
|
640
|
+
partialStepText = '';
|
|
641
|
+
emit({ type: 'retry', kind: 'provider_error', attempt: providerFailures, maxRetries: maxProviderRetries, delayMs: decision.delayMs, reason: decision.message });
|
|
642
|
+
iterations--;
|
|
643
|
+
if ((await waitOrAbort(decision.delayMs, abortSignal)) === 'aborted') {
|
|
644
|
+
reason = 'aborted';
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// A reply with neither text nor a tool call (empty, or reasoning only) is an unfinished answer, not the end.
|
|
650
|
+
if ((!toolCalls || toolCalls.length === 0) &&
|
|
651
|
+
!stepText.trim() &&
|
|
652
|
+
!continuedText &&
|
|
653
|
+
(await result.finishReason) !== 'length' &&
|
|
654
|
+
emptyReplays < MAX_EMPTY_REPLAYS) {
|
|
655
|
+
emptyReplays++;
|
|
656
|
+
const delayMs = retryBaseMs * emptyReplays;
|
|
657
|
+
emit({ type: 'retry', kind: 'empty_reply', attempt: emptyReplays, maxRetries: MAX_EMPTY_REPLAYS, delayMs, reason: 'The model sent an empty reply.' });
|
|
658
|
+
iterations--;
|
|
659
|
+
if ((await waitOrAbort(delayMs, abortSignal)) === 'aborted') {
|
|
660
|
+
reason = 'aborted';
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
providerFailures = 0;
|
|
666
|
+
emptyReplays = 0;
|
|
667
|
+
finalText = continuedText + stepText;
|
|
444
668
|
continuedText = '';
|
|
445
|
-
const toolCalls = await result.toolCalls;
|
|
446
669
|
lastToolNames = (toolCalls ?? []).map((c) => c.toolName);
|
|
447
|
-
const response = await result.response;
|
|
448
670
|
for (const m of response.messages) {
|
|
449
|
-
|
|
671
|
+
append(m);
|
|
450
672
|
}
|
|
451
673
|
// Infinite output: a reply cut off by the output-token limit (and not
|
|
452
674
|
// calling tools) is continued with a follow-up user message rather
|
|
@@ -458,7 +680,7 @@ export class Agent {
|
|
|
458
680
|
continuations++;
|
|
459
681
|
continuedText = finalText;
|
|
460
682
|
emit({ type: 'continuation', count: continuations });
|
|
461
|
-
|
|
683
|
+
append({ role: 'user', content: CONTINUE_PROMPT });
|
|
462
684
|
continue;
|
|
463
685
|
}
|
|
464
686
|
if (!toolCalls || toolCalls.length === 0) {
|
|
@@ -479,6 +701,22 @@ export class Agent {
|
|
|
479
701
|
reason = 'spec';
|
|
480
702
|
break;
|
|
481
703
|
}
|
|
704
|
+
// A reply that ends the turn while the model's own todo list has open items ("Now I'll implement X…")
|
|
705
|
+
// is usually a stall, not an answer. Nudge once, if the model can still update the list and has a step left.
|
|
706
|
+
const openTodos = this.todoList.filter((t) => t.status !== 'completed');
|
|
707
|
+
if (openTodos.length > 0 &&
|
|
708
|
+
todosWrittenThisRun &&
|
|
709
|
+
!answeringNudge &&
|
|
710
|
+
todoNudges < MAX_TODO_NUDGES &&
|
|
711
|
+
iterations < effectiveMaxIter &&
|
|
712
|
+
this.tools.some((t) => t.name === 'todo_write')) {
|
|
713
|
+
todoNudges++;
|
|
714
|
+
answeringNudge = true;
|
|
715
|
+
todoNudgeMessage = { role: 'user', content: todoNudgePrompt(openTodos) };
|
|
716
|
+
append(todoNudgeMessage);
|
|
717
|
+
emit({ type: 'todo_nudge', open: openTodos.length });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
482
720
|
if (this.config.verify && (this.config.verify.commands.length > 0 || this.config.verify.lsp) && editedSinceVerify) {
|
|
483
721
|
if (verifyApproved === undefined) {
|
|
484
722
|
// Like a tool approval, the question must not outlive Stop: race it against the abort signal.
|
|
@@ -518,13 +756,14 @@ export class Agent {
|
|
|
518
756
|
verification = { passed: outcome.passed, attempts: verifyAttempts };
|
|
519
757
|
if (!outcome.passed && verifyFixes < (this.config.verify.maxRetries ?? DEFAULT_VERIFY_RETRIES)) {
|
|
520
758
|
verifyFixes++;
|
|
521
|
-
|
|
759
|
+
append({ role: 'user', content: failureMessage(outcome.failures) });
|
|
522
760
|
continue;
|
|
523
761
|
}
|
|
524
762
|
}
|
|
525
763
|
reason = 'completed';
|
|
526
764
|
break;
|
|
527
765
|
}
|
|
766
|
+
answeringNudge = false;
|
|
528
767
|
// Resolve which tools the model called (only those without `execute`
|
|
529
768
|
// reach this code path; tools with `execute` are handled by the SDK).
|
|
530
769
|
const toolResultMessages = [];
|
|
@@ -540,12 +779,14 @@ export class Agent {
|
|
|
540
779
|
}
|
|
541
780
|
const argsParsed = def.parameters.safeParse(call.input);
|
|
542
781
|
if (!argsParsed.success) {
|
|
782
|
+
invalidStreak = { tool: call.toolName, count: invalidStreak.count + 1 };
|
|
543
783
|
const message = `Invalid arguments for ${call.toolName}: ${argsParsed.error.message}`;
|
|
544
784
|
trackLoop(call.toolName, call.input, message);
|
|
545
785
|
currentToolResults.push(toolResultError(call.toolCallId, call.toolName, message));
|
|
546
786
|
emit({ type: 'tool_result', name: call.toolName, id: call.toolCallId, result: message, isError: true });
|
|
547
787
|
continue;
|
|
548
788
|
}
|
|
789
|
+
invalidStreak = { tool: '', count: 0 };
|
|
549
790
|
const approval = await this.requestApproval(def, call.toolCallId, argsParsed.data, abortSignal, ctx);
|
|
550
791
|
this.audit?.record({ event: 'tool_call', tool: call.toolName, args: approval.args ?? argsParsed.data, approved: approval.approved, auto: this.config.auto === true }, auditCtx);
|
|
551
792
|
if (!approval.approved) {
|
|
@@ -569,7 +810,7 @@ export class Agent {
|
|
|
569
810
|
}
|
|
570
811
|
toolArgs = edited.data;
|
|
571
812
|
}
|
|
572
|
-
emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs });
|
|
813
|
+
emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs, ...(approval.source ? { approval: approval.source } : {}) });
|
|
573
814
|
if (this.hooks) {
|
|
574
815
|
await this.hooks.dispatch({
|
|
575
816
|
type: 'PreToolUse',
|
|
@@ -599,12 +840,17 @@ export class Agent {
|
|
|
599
840
|
}
|
|
600
841
|
const toolStartedAt = Date.now();
|
|
601
842
|
try {
|
|
602
|
-
|
|
843
|
+
// With the sandbox's network off, Forge's own network tools are held to its allowed hosts, from one place (F13).
|
|
844
|
+
const denied = networkDenial(def.name, toolArgs, this.config.commandSandbox);
|
|
845
|
+
if (denied)
|
|
846
|
+
throw new Error(`[sandbox] ${denied}`);
|
|
847
|
+
// One size limit for every tool, not only run_command (G6).
|
|
848
|
+
const rawResult = capToolResult(await def.execute(toolArgs, {
|
|
603
849
|
...ctx,
|
|
604
850
|
policy: this.config.toolSandbox?.policies?.[def.name],
|
|
605
851
|
onProgress: (msg) => emit({ type: 'text_delta', text: `\n[${def.name}] ${msg}\n` }),
|
|
606
852
|
onChildProgress: (event) => emit({ type: 'tool_result', name: def.name, id: call.toolCallId, result: `[child] ${event.event.type}`, isError: false }),
|
|
607
|
-
});
|
|
853
|
+
}), this.config.maxToolOutputChars);
|
|
608
854
|
// Apply redaction to read-style tool output. The model gets the
|
|
609
855
|
// scrubbed version; the user (via the on-event stream) sees the
|
|
610
856
|
// count so they know something was hidden.
|
|
@@ -625,6 +871,15 @@ export class Agent {
|
|
|
625
871
|
if (FILE_WRITE_TOOLS.has(call.toolName))
|
|
626
872
|
for (const p of touchedFiles)
|
|
627
873
|
editedFiles.add(p);
|
|
874
|
+
if (this.config.autoFormat && FILE_WRITE_TOOLS.has(call.toolName)) {
|
|
875
|
+
for (const editedPath of touchedFiles) {
|
|
876
|
+
const note = formatMessage(editedPath, await formatFile(editedPath, this.config.autoFormat, { ...ctx, policy: undefined }));
|
|
877
|
+
if (note)
|
|
878
|
+
finalResult = typeof finalResult === 'string' ? `${finalResult}
|
|
879
|
+
|
|
880
|
+
${note}` : { result: finalResult, autoFormat: note };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
628
883
|
if (this.config.autoLint && FILE_WRITE_TOOLS.has(call.toolName)) {
|
|
629
884
|
for (const editedPath of touchedFiles) {
|
|
630
885
|
const report = await lintFile(editedPath, this.config.autoLint, { ...ctx, policy: undefined }).catch(() => null);
|
|
@@ -694,12 +949,18 @@ export class Agent {
|
|
|
694
949
|
toolResultMessages.push({ role: 'tool', content: currentToolResults });
|
|
695
950
|
}
|
|
696
951
|
for (const m of toolResultMessages)
|
|
697
|
-
|
|
952
|
+
append(m);
|
|
698
953
|
if (loopTripped) {
|
|
699
954
|
emit({ type: 'loop_detected', tool: loopTripped.tool, count: loopTripped.count });
|
|
700
955
|
reason = 'loop_detected';
|
|
701
956
|
break;
|
|
702
957
|
}
|
|
958
|
+
if (invalidStreak.count >= MAX_INVALID_CALLS) {
|
|
959
|
+
errorMessage = `Stopped: the model sent invalid arguments for ${invalidStreak.tool} ${invalidStreak.count} times in a row.`;
|
|
960
|
+
emit({ type: 'error', error: errorMessage });
|
|
961
|
+
reason = 'error';
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
703
964
|
// Mid-run fileMatch steering: one extra user message per round, each doc once per run.
|
|
704
965
|
if (steeringDocs.length > 0 && touchedPaths.length > 0) {
|
|
705
966
|
const injected = [];
|
|
@@ -722,7 +983,7 @@ export class Agent {
|
|
|
722
983
|
}
|
|
723
984
|
}
|
|
724
985
|
if (injected.length > 0) {
|
|
725
|
-
|
|
986
|
+
append({ role: 'user', content: injected.map(wrapSteering).join('\n\n') });
|
|
726
987
|
}
|
|
727
988
|
}
|
|
728
989
|
// Emit updated cache stats after the tool round so the UI sees hits.
|
|
@@ -737,41 +998,9 @@ export class Agent {
|
|
|
737
998
|
// Two independent estimates of "how big is the next prompt":
|
|
738
999
|
// take the larger rather than summing them (they're not additive
|
|
739
1000
|
// components of the same total, they're two guesses at it).
|
|
740
|
-
const totalForNextTurn = Math.max(lastTurnInputTokens,
|
|
741
|
-
if (shouldCompactForBudget(totalForNextTurn,
|
|
742
|
-
|
|
743
|
-
await this.hooks.dispatch({
|
|
744
|
-
type: 'PreCompact',
|
|
745
|
-
messagesCount: messages.length,
|
|
746
|
-
tokens: totalForNextTurn,
|
|
747
|
-
cwd,
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
|
|
751
|
-
const result = await compactMessages(messages, summarizer, {
|
|
752
|
-
keepRecent: this.config.compactionKeepRecent,
|
|
753
|
-
});
|
|
754
|
-
if (result.droppedCount > 0) {
|
|
755
|
-
messages.length = 0;
|
|
756
|
-
for (const m of result.messages)
|
|
757
|
-
messages.push(m);
|
|
758
|
-
masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
|
|
759
|
-
// A mid-run steering doc summarized away may be injected again on the next touch.
|
|
760
|
-
for (const name of [...steeringInMessages]) {
|
|
761
|
-
const marker = `<steering name="${name}">`;
|
|
762
|
-
if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
|
|
763
|
-
continue;
|
|
764
|
-
steeringInMessages.delete(name);
|
|
765
|
-
steeringInjected.delete(name);
|
|
766
|
-
steeringInjections--;
|
|
767
|
-
}
|
|
768
|
-
emit({
|
|
769
|
-
type: 'compaction',
|
|
770
|
-
droppedCount: result.droppedCount,
|
|
771
|
-
savedTokens: result.savedTokens,
|
|
772
|
-
summary: result.summary,
|
|
773
|
-
});
|
|
774
|
-
}
|
|
1001
|
+
const totalForNextTurn = Math.max(lastTurnInputTokens, estimateMessageTokens(messages));
|
|
1002
|
+
if (shouldCompactForBudget(totalForNextTurn, currentModelConfig, threshold, this.config.contextBudgetTokens)) {
|
|
1003
|
+
await compactNow(totalForNextTurn);
|
|
775
1004
|
}
|
|
776
1005
|
}
|
|
777
1006
|
}
|
|
@@ -809,29 +1038,31 @@ export class Agent {
|
|
|
809
1038
|
else {
|
|
810
1039
|
reason = 'error';
|
|
811
1040
|
const message = errorMessageOf(err);
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
}
|
|
816
|
-
else {
|
|
817
|
-
errorMessage = message;
|
|
818
|
-
emit({ type: 'error', error: errorMessage });
|
|
819
|
-
}
|
|
1041
|
+
// The SDK follows a failed call with this generic error; the first one is the useful one.
|
|
1042
|
+
errorMessage = reportedStreamError && /^No output generated/i.test(message) ? reportedStreamError : message;
|
|
1043
|
+
emit({ type: 'error', error: errorMessage });
|
|
820
1044
|
}
|
|
821
1045
|
}
|
|
822
1046
|
// A run that ends between pushing the continue message and sending it (stop, timeout)
|
|
823
1047
|
// must not hand that unanswered message back as history.
|
|
824
1048
|
const lastMessage = messages[messages.length - 1];
|
|
825
1049
|
if (continuedText !== '' && lastMessage?.role === 'user' && lastMessage.content === CONTINUE_PROMPT)
|
|
826
|
-
|
|
1050
|
+
unappend();
|
|
1051
|
+
else if (todoNudgeMessage && lastMessage === todoNudgeMessage)
|
|
1052
|
+
unappend();
|
|
827
1053
|
// Stopped mid-answer: keep the part the user already saw, marked as cut short, so "continue" can pick it up
|
|
828
1054
|
// instead of starting over. Only when the model is the next speaker (after the user or a tool result).
|
|
829
1055
|
if (reason === 'aborted' && partialStepText.trim()) {
|
|
830
1056
|
const last = messages[messages.length - 1];
|
|
831
1057
|
if (last && (last.role === 'user' || last.role === 'tool')) {
|
|
832
|
-
|
|
1058
|
+
append({ role: 'assistant', content: `${partialStepText.trim()}\n\n[Stopped by the user before finishing.]` });
|
|
833
1059
|
}
|
|
834
1060
|
}
|
|
1061
|
+
// Background processes belong to the run: stop what is still going and say so in the answer.
|
|
1062
|
+
const leftRunning = ctx.processes?.stopAll() ?? [];
|
|
1063
|
+
if (leftRunning.length > 0) {
|
|
1064
|
+
finalText += `${finalText ? '\n\n' : ''}[Stopped ${leftRunning.length} background process${leftRunning.length === 1 ? '' : 'es'} when this run ended: ${leftRunning.map((p) => p.command).join(', ')}]`;
|
|
1065
|
+
}
|
|
835
1066
|
// The browser is per-run: never leave a Chrome process behind.
|
|
836
1067
|
await this.browser?.close().catch(() => undefined);
|
|
837
1068
|
emit({ type: 'done', reason });
|
|
@@ -855,6 +1086,7 @@ export class Agent {
|
|
|
855
1086
|
// Snapshot of the final conversation: the REPL passes this back as
|
|
856
1087
|
// the `history` argument on the next turn so context persists.
|
|
857
1088
|
messages: messages.slice(),
|
|
1089
|
+
turnMessages: turnMessages.slice(),
|
|
858
1090
|
...(verification ? { verification } : {}),
|
|
859
1091
|
...(errorMessage ? { error: errorMessage } : {}),
|
|
860
1092
|
};
|
|
@@ -981,23 +1213,23 @@ export class Agent {
|
|
|
981
1213
|
if (rule?.action === 'deny')
|
|
982
1214
|
return { approved: false, note: rule.reason };
|
|
983
1215
|
if (rule?.action === 'allow')
|
|
984
|
-
return { approved: true };
|
|
1216
|
+
return { approved: true, source: 'rule' };
|
|
985
1217
|
const forceAsk = rule?.action === 'ask';
|
|
986
1218
|
// Read-only tools the user chose not to be asked about (never a destructive one).
|
|
987
1219
|
if (!forceAsk && !def.destructive && this.config.autoApproveTools?.includes(def.name)) {
|
|
988
|
-
return { approved: true };
|
|
1220
|
+
return { approved: true, source: 'list' };
|
|
989
1221
|
}
|
|
990
1222
|
// Memory writes the user chose not to be asked about. memory_forget is deleted data: always asked.
|
|
991
1223
|
if (!forceAsk && def.name === 'memory_write' && this.config.memoryAutoApproveWrites) {
|
|
992
|
-
return { approved: true };
|
|
1224
|
+
return { approved: true, source: 'memory' };
|
|
993
1225
|
}
|
|
994
1226
|
// A plain command the user already said "always allow" for: exact match only (see command-allow.ts).
|
|
995
1227
|
if (!forceAsk && def.name === 'run_command' && this.config.autoApproveCommands?.length && isListedCommand(args, this.config.autoApproveCommands)) {
|
|
996
|
-
return { approved: true };
|
|
1228
|
+
return { approved: true, source: 'always' };
|
|
997
1229
|
}
|
|
998
1230
|
const auto = this.config.auto ?? false;
|
|
999
1231
|
if (!forceAsk && auto && !(def.destructive && !this.config.allowDestructive)) {
|
|
1000
|
-
return { approved: true };
|
|
1232
|
+
return { approved: true, source: 'autonomous' };
|
|
1001
1233
|
}
|
|
1002
1234
|
let preview;
|
|
1003
1235
|
if (def.previewApproval && ctx) {
|
|
@@ -1025,7 +1257,8 @@ export class Agent {
|
|
|
1025
1257
|
const decision = await raceAbort(answered, abortSignal, 'aborted');
|
|
1026
1258
|
if (decision === 'aborted')
|
|
1027
1259
|
return { approved: false };
|
|
1028
|
-
|
|
1260
|
+
const answer = typeof decision === 'boolean' ? { approved: decision } : decision;
|
|
1261
|
+
return answer.approved ? { ...answer, source: 'person' } : answer;
|
|
1029
1262
|
}
|
|
1030
1263
|
return { approved: false };
|
|
1031
1264
|
}
|
|
@@ -1103,7 +1336,9 @@ function extractMediaAttachment(result) {
|
|
|
1103
1336
|
const bytes = typeof r.bytes === 'number' ? r.bytes : 0;
|
|
1104
1337
|
const path = typeof r.path === 'string' ? r.path : '<unknown>';
|
|
1105
1338
|
const what = mime.startsWith('image/') ? 'Image' : 'Document';
|
|
1106
|
-
|
|
1339
|
+
// A tool that sends words with its media (an MCP result) keeps them next to the attachment line.
|
|
1340
|
+
const words = typeof r.text === 'string' && r.text.trim() ? `\n${r.text}` : '';
|
|
1341
|
+
const summary = `${what} attached: ${mime} (${formatBytes(bytes)}) at ${path}${words}`;
|
|
1107
1342
|
const comma = dataUrl.indexOf(',');
|
|
1108
1343
|
const filename = typeof r.path === 'string' ? r.path.split(/[\\/]/).pop() : undefined;
|
|
1109
1344
|
return { dataUrl, base64: dataUrl.slice(comma + 1), mime, ...(filename ? { filename } : {}), summary };
|
|
@@ -1142,15 +1377,14 @@ export function applySandbox(tools, sandbox) {
|
|
|
1142
1377
|
return result;
|
|
1143
1378
|
}
|
|
1144
1379
|
/**
|
|
1145
|
-
* Build the
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
* prompt so the agent picks up project conventions on turn one.
|
|
1380
|
+
* Build the system message for a run, on every turn (RB-57). If memory is enabled and a `.forge/MEMORY.md` exists
|
|
1381
|
+
* somewhere up the cwd tree, a `<project-memory>...</project-memory>` block is appended so the agent picks up project
|
|
1382
|
+
* conventions.
|
|
1149
1383
|
*
|
|
1150
1384
|
* This is async because memory loading hits the disk. When memory is
|
|
1151
1385
|
* disabled or no file exists, this is a fast synchronous-feeling path.
|
|
1152
1386
|
*/
|
|
1153
|
-
async function
|
|
1387
|
+
async function buildSystemContent(config, task, skills, steeringSystem = []) {
|
|
1154
1388
|
let basePrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
1155
1389
|
if (config.planMode) {
|
|
1156
1390
|
basePrompt = basePrompt + (config.planModePrompt ?? DEFAULT_PLAN_MODE_SUFFIX);
|
|
@@ -1176,7 +1410,8 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1176
1410
|
try {
|
|
1177
1411
|
const all = skills.registry.list();
|
|
1178
1412
|
const selected = selectSkills(all, task, skills.maxSkills ?? 3);
|
|
1179
|
-
|
|
1413
|
+
// The index of every skill (G12) comes first and is the same every turn; the picked bodies follow.
|
|
1414
|
+
skillsBlock = [buildSkillIndex(all), composeSkillsPrompt(selected)].filter(Boolean).join('\n\n');
|
|
1180
1415
|
}
|
|
1181
1416
|
catch {
|
|
1182
1417
|
// Skill selection failure shouldn't kill a run: just skip.
|
|
@@ -1209,12 +1444,7 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1209
1444
|
// A map failure shouldn't kill a run: just skip.
|
|
1210
1445
|
}
|
|
1211
1446
|
}
|
|
1212
|
-
// Interview answers from a guided journey.
|
|
1213
|
-
// history is passed (see the `options.history` branch in `run`), so every
|
|
1214
|
-
// block here is assembled once, on a session's first turn, and then rides
|
|
1215
|
-
// inside `history[0]`. That is why a journey's interview must finish before
|
|
1216
|
-
// the first run: an answer that arrives on turn 3 can never enter the system
|
|
1217
|
-
// message, and a middle turn would be summarised away by compaction.
|
|
1447
|
+
// Interview answers from a guided journey. Rebuilt with the rest of the system message on every turn (RB-57).
|
|
1218
1448
|
const answersBlock = renderAnswersBlock(config.journeyAnswers ?? []);
|
|
1219
1449
|
// The versions the workspace's files state, so code is written for them and not for what training saw most.
|
|
1220
1450
|
let stackFactsBlock = '';
|
|
@@ -1235,42 +1465,14 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
|
|
|
1235
1465
|
isGitRepo: existsSync(path.join(cwd, '.git')),
|
|
1236
1466
|
today: new Date().toISOString().slice(0, 10),
|
|
1237
1467
|
model: config.model?.model,
|
|
1468
|
+
// A custom runner (the VS Code terminal) says which shell it is; Forge's own runner picks one itself.
|
|
1469
|
+
...(config.runnerShell ? { shell: config.runnerShell } : config.commandRunner ? {} : { shell: runnerShell(config.shell).kind }),
|
|
1238
1470
|
});
|
|
1239
1471
|
// Compose the final system prompt: base + (memory, skills) blocks in a
|
|
1240
1472
|
// stable order so cache hits survive across runs that share state. The
|
|
1241
1473
|
// environment block goes last: it is the part that varies per session.
|
|
1242
1474
|
const steeringBlock = steeringSystem.map(wrapSteering).join('\n\n');
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
return
|
|
1246
|
-
{ role: 'system', content: systemContent },
|
|
1247
|
-
{ role: 'user', content: userContent },
|
|
1248
|
-
];
|
|
1249
|
-
}
|
|
1250
|
-
/**
|
|
1251
|
-
* Rough estimate of the next-turn prompt size from the message list.
|
|
1252
|
-
* Used to decide whether to compact before the next model call. We use
|
|
1253
|
-
* `Math.ceil(text.length / 4)` (≈ OpenAI's tokenizer behavior): good
|
|
1254
|
-
* enough for "are we close to the window?" decisions.
|
|
1255
|
-
*/
|
|
1256
|
-
function estimateNextPromptTokens(messages) {
|
|
1257
|
-
let total = 0;
|
|
1258
|
-
for (const m of messages) {
|
|
1259
|
-
if (typeof m.content === 'string') {
|
|
1260
|
-
total += Math.ceil(m.content.length / 4);
|
|
1261
|
-
}
|
|
1262
|
-
else if (Array.isArray(m.content)) {
|
|
1263
|
-
for (const part of m.content) {
|
|
1264
|
-
if (part && typeof part === 'object' && 'text' in part) {
|
|
1265
|
-
const text = part.text;
|
|
1266
|
-
if (typeof text === 'string')
|
|
1267
|
-
total += Math.ceil(text.length / 4);
|
|
1268
|
-
}
|
|
1269
|
-
else {
|
|
1270
|
-
total += mediaPartTokens(part);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
return total;
|
|
1475
|
+
const guidanceBlock = config.model ? modelGuidanceBlock(config.model) : '';
|
|
1476
|
+
const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, stackFactsBlock, guidanceBlock, envBlock].filter((b) => b.length > 0);
|
|
1477
|
+
return blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
|
|
1276
1478
|
}
|