@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.
Files changed (129) hide show
  1. package/dist/agent/agent.js +324 -122
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/enhance-prompt.js +2 -1
  5. package/dist/agent/history-adapt.d.ts +23 -0
  6. package/dist/agent/history-adapt.js +114 -0
  7. package/dist/agent/prompts.d.ts +19 -2
  8. package/dist/agent/prompts.js +44 -2
  9. package/dist/agent/retry.d.ts +50 -0
  10. package/dist/agent/retry.js +192 -0
  11. package/dist/agent/shell-command.d.ts +23 -0
  12. package/dist/agent/shell-command.js +93 -0
  13. package/dist/agent/tool-repair.d.ts +8 -0
  14. package/dist/agent/tool-repair.js +16 -0
  15. package/dist/catalog/models-dev-snapshot.d.ts +1 -0
  16. package/dist/catalog/models-dev-snapshot.js +3 -0
  17. package/dist/catalog/models-dev.d.ts +45 -0
  18. package/dist/catalog/models-dev.js +155 -0
  19. package/dist/checkpoints/store.js +3 -1
  20. package/dist/context/compaction.d.ts +21 -6
  21. package/dist/context/compaction.js +94 -28
  22. package/dist/context/mask.d.ts +9 -9
  23. package/dist/context/mask.js +30 -1
  24. package/dist/format/auto-format.d.ts +22 -0
  25. package/dist/format/auto-format.js +39 -0
  26. package/dist/git/auto-commit.d.ts +4 -2
  27. package/dist/git/auto-commit.js +16 -5
  28. package/dist/github/github-tools.d.ts +1 -0
  29. package/dist/github/github-tools.js +9 -9
  30. package/dist/github/pr.d.ts +34 -0
  31. package/dist/github/pr.js +70 -0
  32. package/dist/human-text.d.ts +6 -0
  33. package/dist/human-text.js +37 -0
  34. package/dist/index.d.ts +19 -9
  35. package/dist/index.js +14 -6
  36. package/dist/journey/answers-block.d.ts +2 -4
  37. package/dist/lsp/diagnostics.d.ts +4 -0
  38. package/dist/lsp/diagnostics.js +8 -2
  39. package/dist/lsp/index.d.ts +1 -1
  40. package/dist/lsp/index.js +1 -1
  41. package/dist/mcp/index.d.ts +1 -1
  42. package/dist/mcp/index.js +1 -1
  43. package/dist/mcp/json-schema-to-zod.js +2 -4
  44. package/dist/mcp/mcp-client.d.ts +14 -0
  45. package/dist/mcp/mcp-client.js +102 -18
  46. package/dist/memory/session-digest.js +2 -1
  47. package/dist/orchestrator/specialists-registry.js +14 -1
  48. package/dist/orchestrator/specialists.js +3 -3
  49. package/dist/pricing.js +12 -0
  50. package/dist/semantic/embedder.d.ts +11 -0
  51. package/dist/semantic/embedder.js +16 -0
  52. package/dist/semantic/semantic-index.d.ts +39 -1
  53. package/dist/semantic/semantic-index.js +43 -13
  54. package/dist/semantic/vector-store.d.ts +2 -0
  55. package/dist/semantic/vector-store.js +13 -1
  56. package/dist/sessions/index.d.ts +2 -2
  57. package/dist/sessions/index.js +1 -1
  58. package/dist/sessions/search.js +2 -2
  59. package/dist/sessions/session-export.d.ts +1 -1
  60. package/dist/sessions/session-export.js +41 -33
  61. package/dist/sessions/session-store.d.ts +20 -1
  62. package/dist/sessions/session-store.js +7 -0
  63. package/dist/sessions/watch.js +4 -4
  64. package/dist/skills/activator.d.ts +6 -0
  65. package/dist/skills/activator.js +17 -0
  66. package/dist/skills/skill-tool.d.ts +12 -0
  67. package/dist/skills/skill-tool.js +28 -0
  68. package/dist/tools/background-process.d.ts +63 -0
  69. package/dist/tools/background-process.js +150 -0
  70. package/dist/tools/command-sandbox.d.ts +24 -0
  71. package/dist/tools/command-sandbox.js +73 -0
  72. package/dist/tools/edit-file.d.ts +8 -0
  73. package/dist/tools/edit-file.js +39 -16
  74. package/dist/tools/edit-match.d.ts +16 -0
  75. package/dist/tools/edit-match.js +115 -0
  76. package/dist/tools/fetch-url.d.ts +15 -1
  77. package/dist/tools/fetch-url.js +7 -47
  78. package/dist/tools/get-diagnostics.d.ts +14 -0
  79. package/dist/tools/get-diagnostics.js +44 -0
  80. package/dist/tools/glob-filter.d.ts +7 -0
  81. package/dist/tools/glob-filter.js +12 -0
  82. package/dist/tools/html-to-markdown.d.ts +13 -0
  83. package/dist/tools/html-to-markdown.js +507 -0
  84. package/dist/tools/index.d.ts +5 -1
  85. package/dist/tools/index.js +11 -1
  86. package/dist/tools/list-files.d.ts +1 -0
  87. package/dist/tools/list-files.js +17 -5
  88. package/dist/tools/list-symbols.js +2 -2
  89. package/dist/tools/network-policy.d.ts +5 -0
  90. package/dist/tools/network-policy.js +77 -0
  91. package/dist/tools/office-docs.d.ts +6 -0
  92. package/dist/tools/office-docs.js +393 -0
  93. package/dist/tools/permission-rules.js +55 -5
  94. package/dist/tools/read-file.d.ts +2 -0
  95. package/dist/tools/read-file.js +47 -10
  96. package/dist/tools/read-image.js +2 -2
  97. package/dist/tools/read-only.d.ts +1 -1
  98. package/dist/tools/read-only.js +4 -0
  99. package/dist/tools/repo-clone.d.ts +40 -0
  100. package/dist/tools/repo-clone.js +218 -0
  101. package/dist/tools/result-cap.d.ts +8 -0
  102. package/dist/tools/result-cap.js +44 -0
  103. package/dist/tools/run-command.d.ts +18 -1
  104. package/dist/tools/run-command.js +44 -9
  105. package/dist/tools/search-files.js +12 -53
  106. package/dist/tools/search-worker.d.ts +23 -0
  107. package/dist/tools/search-worker.js +86 -0
  108. package/dist/tools/shared.d.ts +14 -1
  109. package/dist/tools/shared.js +76 -4
  110. package/dist/tools/shell.d.ts +27 -0
  111. package/dist/tools/shell.js +80 -0
  112. package/dist/tools/sub-agent.d.ts +1 -1
  113. package/dist/tools/sub-agent.js +4 -1
  114. package/dist/tools/suggest-plan.d.ts +18 -0
  115. package/dist/tools/suggest-plan.js +28 -0
  116. package/dist/types.d.ts +132 -1
  117. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  118. package/dist/wakeup/wakeup-tools.js +34 -0
  119. package/dist/wakeup/wakeup.d.ts +75 -0
  120. package/dist/wakeup/wakeup.js +151 -0
  121. package/dist/worktree/health.d.ts +51 -0
  122. package/dist/worktree/health.js +186 -0
  123. package/dist/worktree/index.d.ts +4 -0
  124. package/dist/worktree/index.js +2 -0
  125. package/dist/worktree/setup.d.ts +51 -0
  126. package/dist/worktree/setup.js +173 -0
  127. package/dist/worktree/worktree.d.ts +7 -0
  128. package/dist/worktree/worktree.js +9 -4
  129. package/package.json +1 -1
@@ -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 { buildLlmSummarizer, compactMessages, effectiveLimitTokens, getModelWindowTokens, shouldCompactForBudget } from '../context/compaction.js';
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 { attachmentParts, formatBytes, mediaPartTokens } from '../attachments.js';
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
- : await buildInitialMessages(this.config, options.task, this.skills, startSteering.system, userContent);
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, estimateNextPromptTokens(messages)));
354
- promptMessages = view.messages;
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
- messages: cachePrompts ? applyCacheHints(promptMessages, currentModelConfig) : promptMessages,
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] (buildInitialMessages)
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
- await result.consumeStream();
443
- finalText = continuedText + stripThinkTags(await result.text);
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
- messages.push(m);
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
- messages.push({ role: 'user', content: CONTINUE_PROMPT });
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
- messages.push({ role: 'user', content: failureMessage(outcome.failures) });
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
- const rawResult = await def.execute(toolArgs, {
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
- messages.push(m);
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
- messages.push({ role: 'user', content: injected.map(wrapSteering).join('\n\n') });
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, estimateNextPromptTokens(messages));
741
- if (shouldCompactForBudget(totalForNextTurn, this.config.model, threshold, this.config.contextBudgetTokens)) {
742
- if (this.hooks) {
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
- if (reportedStreamError && /^No output generated/i.test(message)) {
813
- // The SDK follows a failed call with this generic error; the first one is the useful one.
814
- errorMessage = reportedStreamError;
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
- messages.pop();
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
- messages.push({ role: 'assistant', content: `${partialStepText.trim()}\n\n[Stopped by the user before finishing.]` });
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
- return typeof decision === 'boolean' ? { approved: decision } : decision;
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
- const summary = `${what} attached: ${mime} (${formatBytes(bytes)}) at ${path}`;
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 initial `[system, user]` messages for a fresh run. If memory
1146
- * is enabled and a `.forge/MEMORY.md` exists somewhere up the cwd tree, we
1147
- * append a `<project-memory>...</project-memory>` block to the system
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 buildInitialMessages(config, task, skills, steeringSystem = [], userContent = task) {
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
- skillsBlock = composeSkillsPrompt(selected);
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. This function runs only when no
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 blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, stackFactsBlock, envBlock].filter((b) => b.length > 0);
1244
- const systemContent = blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
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
  }