@ikarmic/forge-core 5.1.1 → 5.3.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 (81) hide show
  1. package/dist/agent/agent.js +208 -57
  2. package/dist/agent/enhance-prompt.js +2 -1
  3. package/dist/agent/goal.d.ts +5 -8
  4. package/dist/agent/prompts.d.ts +15 -0
  5. package/dist/agent/prompts.js +34 -0
  6. package/dist/agent/race-abort.d.ts +8 -0
  7. package/dist/agent/race-abort.js +24 -0
  8. package/dist/agent/retry.d.ts +50 -0
  9. package/dist/agent/retry.js +192 -0
  10. package/dist/agent/shell-command.d.ts +23 -0
  11. package/dist/agent/shell-command.js +93 -0
  12. package/dist/agent/stack-facts.d.ts +23 -0
  13. package/dist/agent/stack-facts.js +150 -0
  14. package/dist/catalog/models-dev-snapshot.d.ts +1 -0
  15. package/dist/catalog/models-dev-snapshot.js +3 -0
  16. package/dist/catalog/models-dev.d.ts +35 -0
  17. package/dist/catalog/models-dev.js +143 -0
  18. package/dist/code-index/parser.js +48 -3
  19. package/dist/context/compaction.js +8 -2
  20. package/dist/git/auto-commit.d.ts +4 -2
  21. package/dist/git/auto-commit.js +16 -5
  22. package/dist/github/github-tools.d.ts +2 -18
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +9 -2
  25. package/dist/journey/answers-block.js +14 -1
  26. package/dist/journey/offer.js +2 -1
  27. package/dist/mcp/json-schema-to-zod.js +12 -1
  28. package/dist/memory/session-digest.js +2 -1
  29. package/dist/orchestrator/index.d.ts +1 -1
  30. package/dist/orchestrator/index.js +1 -1
  31. package/dist/orchestrator/orchestrator.d.ts +45 -6
  32. package/dist/orchestrator/orchestrator.js +62 -12
  33. package/dist/orchestrator/router.d.ts +1 -1
  34. package/dist/orchestrator/router.js +4 -1
  35. package/dist/orchestrator/specialists.d.ts +6 -1
  36. package/dist/orchestrator/specialists.js +165 -68
  37. package/dist/orchestrator/types.d.ts +1 -1
  38. package/dist/policy/policy.d.ts +6 -59
  39. package/dist/pricing.d.ts +2 -0
  40. package/dist/pricing.js +47 -16
  41. package/dist/structured/schema.js +9 -12
  42. package/dist/tools/apply-patch.d.ts +1 -5
  43. package/dist/tools/ask-user.d.ts +2 -10
  44. package/dist/tools/ask-user.js +12 -4
  45. package/dist/tools/browser-tools.d.ts +15 -31
  46. package/dist/tools/codebase-search.d.ts +1 -7
  47. package/dist/tools/command-sandbox.d.ts +24 -0
  48. package/dist/tools/command-sandbox.js +73 -0
  49. package/dist/tools/dependency-audit.d.ts +30 -0
  50. package/dist/tools/dependency-audit.js +197 -0
  51. package/dist/tools/edit-file.d.ts +9 -15
  52. package/dist/tools/edit-file.js +64 -16
  53. package/dist/tools/fetch-url.d.ts +1 -18
  54. package/dist/tools/find-references.d.ts +1 -5
  55. package/dist/tools/find-symbol.d.ts +10 -8
  56. package/dist/tools/git-diff.d.ts +13 -0
  57. package/dist/tools/git-diff.js +85 -0
  58. package/dist/tools/index.d.ts +3 -1
  59. package/dist/tools/index.js +5 -1
  60. package/dist/tools/list-files.d.ts +1 -19
  61. package/dist/tools/list-symbols.d.ts +1 -5
  62. package/dist/tools/memory.d.ts +23 -43
  63. package/dist/tools/notebook.d.ts +11 -17
  64. package/dist/tools/read-file.d.ts +1 -11
  65. package/dist/tools/read-image.d.ts +1 -12
  66. package/dist/tools/read-only.d.ts +1 -1
  67. package/dist/tools/read-only.js +2 -0
  68. package/dist/tools/run-command.d.ts +1 -17
  69. package/dist/tools/run-command.js +12 -2
  70. package/dist/tools/search-files.d.ts +1 -17
  71. package/dist/tools/search-files.js +8 -49
  72. package/dist/tools/search-worker.d.ts +23 -0
  73. package/dist/tools/search-worker.js +77 -0
  74. package/dist/tools/sub-agent-wait.d.ts +2 -8
  75. package/dist/tools/sub-agent.d.ts +2 -70
  76. package/dist/tools/sub-agent.js +1 -0
  77. package/dist/tools/todo.d.ts +8 -20
  78. package/dist/tools/web-search.d.ts +1 -7
  79. package/dist/tools/write-file.d.ts +1 -7
  80. package/dist/types.d.ts +60 -1
  81. package/package.json +3 -3
@@ -31,11 +31,20 @@ import { redact } from '../tools/redact.js';
31
31
  import { composeSkillsPrompt, selectSkills } from '../skills/activator.js';
32
32
  import { MAX_TOOL_INJECTIONS, selectAtStart, selectOnToolTouch, toWorkspaceRelative, wrapSteering } from '../steering/select.js';
33
33
  import { applyCacheHints } from './cache-hints.js';
34
+ import { raceAbort } from './race-abort.js';
35
+ import { DEFAULT_PROVIDER_RETRIES, DEFAULT_RETRY_BASE_MS, defaultNetworkProbe, errorSummary, isContextOverflowError, isDisconnected, retryDecision, waitForNetwork, waitOrAbort, } from './retry.js';
34
36
  import { attachmentParts, formatBytes, mediaPartTokens } from '../attachments.js';
35
37
  import { renderAnswersBlock } from '../journey/answers-block.js';
36
- import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, environmentBlock } from './prompts.js';
38
+ import { CONTINUE_PROMPT, DEFAULT_PLAN_MODE_SUFFIX, DEFAULT_SPEC_MODE_SUFFIX, DEFAULT_SYSTEM_PROMPT, LAST_STEP_PROMPT, environmentBlock, modelGuidanceBlock, todoNudgePrompt } from './prompts.js';
39
+ import { detectStackFacts, stackBlock } from './stack-facts.js';
37
40
  /** Read-only tool names whose output flows through the redactor. */
38
41
  const DEFAULT_MAX_CONTINUATIONS = 3;
42
+ /** Nudges per run when a turn ends with open todos; never two without a tool call between them. */
43
+ const MAX_TODO_NUDGES = 3;
44
+ /** Times an empty or reasoning-only reply is asked for again before it is accepted. */
45
+ const MAX_EMPTY_REPLAYS = 2;
46
+ /** Consecutive tool calls with invalid arguments before the run stops (Kilo: REPEATED_TOOL_FAILURE_LIMIT). */
47
+ const MAX_INVALID_CALLS = 3;
39
48
  export const DEFAULT_MAX_DURATION_MS = 10 * 60 * 1000;
40
49
  const REDACTABLE_TOOLS = new Set(['read_file', 'search_files', 'list_files']);
41
50
  /** Tools whose successful `path` argument can trigger mid-run fileMatch steering. */
@@ -257,6 +266,26 @@ export class Agent {
257
266
  let partialStepText = '';
258
267
  let continuations = 0;
259
268
  const maxContinuations = this.config.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS;
269
+ // Open-todo nudges: the one sent last (so a run that ends before answering it drops it) and whether
270
+ // the step just answered was a nudge (a second text-only reply in a row is the model's answer).
271
+ // Only a list this run wrote counts: the chat carries the last turn's list over, and an unrelated
272
+ // question answered while it is open must not be pushed back onto the old task.
273
+ let todoNudges = 0;
274
+ let todoNudgeMessage;
275
+ let answeringNudge = false;
276
+ let todosWrittenThisRun = false;
277
+ // Provider failures and empty replies retried for the step now running; both reset when a step succeeds.
278
+ const maxProviderRetries = this.config.providerRetry?.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
279
+ const retryBaseMs = this.config.providerRetry?.baseDelayMs ?? DEFAULT_RETRY_BASE_MS;
280
+ let providerFailures = 0;
281
+ let emptyReplays = 0;
282
+ // Time spent waiting for the network in this run, across every offline spell.
283
+ const offlinePollMs = this.config.providerRetry?.offlinePollMs ?? 3000;
284
+ const maxOfflineMs = this.config.providerRetry?.maxOfflineMs ?? 15 * 60_000;
285
+ let offlineWaitedMs = 0;
286
+ let offlineWaits = 0;
287
+ // Consecutive tool calls whose arguments failed the schema, across steps; any valid call resets it.
288
+ let invalidStreak = { tool: '', count: 0 };
260
289
  const ctx = {
261
290
  cwd: this.config.cwd ?? process.cwd(),
262
291
  auto: this.config.auto ?? false,
@@ -274,13 +303,49 @@ export class Agent {
274
303
  get: () => this.todoList,
275
304
  set: (todos) => {
276
305
  this.todoList = todos;
306
+ todosWrittenThisRun = true;
277
307
  emit({ type: 'todo_updated', todos });
278
308
  },
279
309
  },
280
310
  ...(this.config.commandRunner ? { commandRunner: this.config.commandRunner } : {}),
311
+ ...(this.config.commandSandbox ? { commandSandbox: this.config.commandSandbox } : {}),
281
312
  ...(abortSignal ? { abortSignal } : {}),
282
313
  ...(this.config.memoryMaxBodyChars !== undefined ? { memoryMaxBodyChars: this.config.memoryMaxBodyChars } : {}),
283
314
  };
315
+ // Summarize older turns in place; returns how many messages were dropped (0: nothing could be compacted).
316
+ const compactNow = async (tokens) => {
317
+ if (this.hooks) {
318
+ await this.hooks.dispatch({ type: 'PreCompact', messagesCount: messages.length, tokens, cwd });
319
+ }
320
+ const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
321
+ const result = await compactMessages(messages, summarizer, {
322
+ keepRecent: this.config.compactionKeepRecent,
323
+ });
324
+ if (result.droppedCount === 0)
325
+ return 0;
326
+ messages.length = 0;
327
+ for (const m of result.messages)
328
+ messages.push(m);
329
+ masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
330
+ // A mid-run steering doc summarized away may be injected again on the next touch.
331
+ for (const name of [...steeringInMessages]) {
332
+ const marker = `<steering name="${name}">`;
333
+ if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
334
+ continue;
335
+ steeringInMessages.delete(name);
336
+ steeringInjected.delete(name);
337
+ steeringInjections--;
338
+ }
339
+ emit({
340
+ type: 'compaction',
341
+ droppedCount: result.droppedCount,
342
+ savedTokens: result.savedTokens,
343
+ summary: result.summary,
344
+ });
345
+ return result.droppedCount;
346
+ };
347
+ // The provider refused the prompt as too long: compact once and ask again (Kilo does the same).
348
+ let overflowCompactions = 0;
284
349
  // Emit initial cache stats so the UI knows the cache is wired up.
285
350
  emit({ type: 'cache_stats', ...this.cache.getStats() });
286
351
  try {
@@ -325,6 +390,7 @@ export class Agent {
325
390
  // Track per-turn token deltas so we can compute per-turn cost.
326
391
  const turnStart = { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens };
327
392
  let reasoningText = '';
393
+ let stepFailure;
328
394
  partialStepText = '';
329
395
  // Inline <think> blocks (MiniMax over OpenAI-compatible, some open models) become reasoning events.
330
396
  const thinkSplitter = new ThinkSplitter();
@@ -353,6 +419,10 @@ export class Agent {
353
419
  if (view.stepped)
354
420
  emit({ type: 'context_masked', maskedCount: view.maskedCount, savedTokens: Math.ceil(view.savedChars / 4) });
355
421
  }
422
+ // The last allowed step: no tools, and a note asking for a summary instead of stopping mid-task.
423
+ const lastStep = !planMode && !specMode && effectiveMaxIter > 1 && iterations >= effectiveMaxIter;
424
+ if (lastStep)
425
+ promptMessages = [...promptMessages, { role: 'user', content: LAST_STEP_PROMPT }];
356
426
  const result = streamText({
357
427
  model,
358
428
  messages: cachePrompts ? applyCacheHints(promptMessages, currentModelConfig) : promptMessages,
@@ -366,7 +436,10 @@ export class Agent {
366
436
  // message) around a separate `system` option.
367
437
  allowSystemInMessages: true,
368
438
  tools: toAiSdkTools(this.tools),
439
+ ...(lastStep ? { toolChoice: 'none' } : {}),
369
440
  abortSignal,
441
+ // Retries are decided below (retryDecision), for failures before and during the stream alike.
442
+ maxRetries: 0,
370
443
  ...(this.config.model.reasoningEffort ? { reasoning: this.config.model.reasoningEffort } : {}),
371
444
  ...(this.config.model.temperature !== undefined ? { temperature: this.config.model.temperature } : {}),
372
445
  onChunk: ({ chunk }) => {
@@ -386,8 +459,9 @@ export class Agent {
386
459
  // Pressing Stop aborts the request; that is not a failure to report.
387
460
  if (abortSignal?.aborted)
388
461
  return;
462
+ // Reported only if it is not retried (below); the SDK then throws a generic "No output generated".
463
+ stepFailure = error;
389
464
  reportedStreamError = errorMessageOf(error);
390
- emit({ type: 'error', error: reportedStreamError });
391
465
  },
392
466
  onFinish: ({ text: rawText, usage }) => {
393
467
  emitSegments(thinkSplitter.flush());
@@ -437,12 +511,88 @@ export class Agent {
437
511
  }
438
512
  },
439
513
  });
440
- await result.consumeStream();
441
- finalText = continuedText + stripThinkTags(await result.text);
514
+ let stepText;
515
+ let toolCalls;
516
+ let response;
517
+ try {
518
+ await result.consumeStream();
519
+ stepText = stripThinkTags(await result.text);
520
+ toolCalls = await result.toolCalls;
521
+ response = await result.response;
522
+ if (stepFailure !== undefined)
523
+ throw stepFailure;
524
+ }
525
+ catch (err) {
526
+ if (abortSignal?.aborted)
527
+ throw err;
528
+ const failure = stepFailure ?? err;
529
+ stepFailure = undefined;
530
+ if (isContextOverflowError(failure) && this.config.enableCompaction && !planMode && !specMode && overflowCompactions < 2) {
531
+ overflowCompactions++;
532
+ if ((await compactNow(Math.max(lastTurnInputTokens, estimateNextPromptTokens(messages)))) > 0) {
533
+ partialStepText = '';
534
+ iterations--;
535
+ continue;
536
+ }
537
+ }
538
+ // No network at all: wait for it to come back instead of spending retries (Kilo's offline guard).
539
+ // Only when a probe confirms it: a mistyped provider host fails DNS too while the network is fine.
540
+ const probe = this.config.providerRetry?.networkProbe ?? defaultNetworkProbe;
541
+ if (isDisconnected(failure) && offlineWaitedMs < maxOfflineMs && !(await probe().catch(() => false))) {
542
+ offlineWaits++;
543
+ emit({ type: 'retry', kind: 'offline', attempt: offlineWaits, maxRetries: 0, delayMs: offlinePollMs, reason: errorSummary(failure) });
544
+ const waited = await waitForNetwork(probe, {
545
+ pollMs: offlinePollMs,
546
+ budgetMs: maxOfflineMs - offlineWaitedMs,
547
+ ...(abortSignal ? { abortSignal } : {}),
548
+ });
549
+ offlineWaitedMs += waited.waitedMs;
550
+ if (waited.outcome === 'aborted') {
551
+ reason = 'aborted';
552
+ break;
553
+ }
554
+ if (waited.outcome === 'online') {
555
+ partialStepText = '';
556
+ iterations--;
557
+ continue;
558
+ }
559
+ throw failure;
560
+ }
561
+ const decision = retryDecision(failure, providerFailures + 1, retryBaseMs);
562
+ if (!decision.retry || providerFailures >= maxProviderRetries)
563
+ throw failure;
564
+ providerFailures++;
565
+ // The step is thrown away whole: nothing it streamed is kept, and it does not count as a step.
566
+ partialStepText = '';
567
+ emit({ type: 'retry', kind: 'provider_error', attempt: providerFailures, maxRetries: maxProviderRetries, delayMs: decision.delayMs, reason: decision.message });
568
+ iterations--;
569
+ if ((await waitOrAbort(decision.delayMs, abortSignal)) === 'aborted') {
570
+ reason = 'aborted';
571
+ break;
572
+ }
573
+ continue;
574
+ }
575
+ // A reply with neither text nor a tool call (empty, or reasoning only) is an unfinished answer, not the end.
576
+ if ((!toolCalls || toolCalls.length === 0) &&
577
+ !stepText.trim() &&
578
+ !continuedText &&
579
+ (await result.finishReason) !== 'length' &&
580
+ emptyReplays < MAX_EMPTY_REPLAYS) {
581
+ emptyReplays++;
582
+ const delayMs = retryBaseMs * emptyReplays;
583
+ emit({ type: 'retry', kind: 'empty_reply', attempt: emptyReplays, maxRetries: MAX_EMPTY_REPLAYS, delayMs, reason: 'The model sent an empty reply.' });
584
+ iterations--;
585
+ if ((await waitOrAbort(delayMs, abortSignal)) === 'aborted') {
586
+ reason = 'aborted';
587
+ break;
588
+ }
589
+ continue;
590
+ }
591
+ providerFailures = 0;
592
+ emptyReplays = 0;
593
+ finalText = continuedText + stepText;
442
594
  continuedText = '';
443
- const toolCalls = await result.toolCalls;
444
595
  lastToolNames = (toolCalls ?? []).map((c) => c.toolName);
445
- const response = await result.response;
446
596
  for (const m of response.messages) {
447
597
  messages.push(m);
448
598
  }
@@ -477,6 +627,22 @@ export class Agent {
477
627
  reason = 'spec';
478
628
  break;
479
629
  }
630
+ // A reply that ends the turn while the model's own todo list has open items ("Now I'll implement X…")
631
+ // is usually a stall, not an answer. Nudge once, if the model can still update the list and has a step left.
632
+ const openTodos = this.todoList.filter((t) => t.status !== 'completed');
633
+ if (openTodos.length > 0 &&
634
+ todosWrittenThisRun &&
635
+ !answeringNudge &&
636
+ todoNudges < MAX_TODO_NUDGES &&
637
+ iterations < effectiveMaxIter &&
638
+ this.tools.some((t) => t.name === 'todo_write')) {
639
+ todoNudges++;
640
+ answeringNudge = true;
641
+ todoNudgeMessage = { role: 'user', content: todoNudgePrompt(openTodos) };
642
+ messages.push(todoNudgeMessage);
643
+ emit({ type: 'todo_nudge', open: openTodos.length });
644
+ continue;
645
+ }
480
646
  if (this.config.verify && (this.config.verify.commands.length > 0 || this.config.verify.lsp) && editedSinceVerify) {
481
647
  if (verifyApproved === undefined) {
482
648
  // Like a tool approval, the question must not outlive Stop: race it against the abort signal.
@@ -523,6 +689,7 @@ export class Agent {
523
689
  reason = 'completed';
524
690
  break;
525
691
  }
692
+ answeringNudge = false;
526
693
  // Resolve which tools the model called (only those without `execute`
527
694
  // reach this code path; tools with `execute` are handled by the SDK).
528
695
  const toolResultMessages = [];
@@ -538,12 +705,14 @@ export class Agent {
538
705
  }
539
706
  const argsParsed = def.parameters.safeParse(call.input);
540
707
  if (!argsParsed.success) {
708
+ invalidStreak = { tool: call.toolName, count: invalidStreak.count + 1 };
541
709
  const message = `Invalid arguments for ${call.toolName}: ${argsParsed.error.message}`;
542
710
  trackLoop(call.toolName, call.input, message);
543
711
  currentToolResults.push(toolResultError(call.toolCallId, call.toolName, message));
544
712
  emit({ type: 'tool_result', name: call.toolName, id: call.toolCallId, result: message, isError: true });
545
713
  continue;
546
714
  }
715
+ invalidStreak = { tool: '', count: 0 };
547
716
  const approval = await this.requestApproval(def, call.toolCallId, argsParsed.data, abortSignal, ctx);
548
717
  this.audit?.record({ event: 'tool_call', tool: call.toolName, args: approval.args ?? argsParsed.data, approved: approval.approved, auto: this.config.auto === true }, auditCtx);
549
718
  if (!approval.approved) {
@@ -567,7 +736,7 @@ export class Agent {
567
736
  }
568
737
  toolArgs = edited.data;
569
738
  }
570
- emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs });
739
+ emit({ type: 'tool_call', name: call.toolName, id: call.toolCallId, args: toolArgs, ...(approval.source ? { approval: approval.source } : {}) });
571
740
  if (this.hooks) {
572
741
  await this.hooks.dispatch({
573
742
  type: 'PreToolUse',
@@ -698,6 +867,12 @@ export class Agent {
698
867
  reason = 'loop_detected';
699
868
  break;
700
869
  }
870
+ if (invalidStreak.count >= MAX_INVALID_CALLS) {
871
+ errorMessage = `Stopped: the model sent invalid arguments for ${invalidStreak.tool} ${invalidStreak.count} times in a row.`;
872
+ emit({ type: 'error', error: errorMessage });
873
+ reason = 'error';
874
+ break;
875
+ }
701
876
  // Mid-run fileMatch steering: one extra user message per round, each doc once per run.
702
877
  if (steeringDocs.length > 0 && touchedPaths.length > 0) {
703
878
  const injected = [];
@@ -737,39 +912,7 @@ export class Agent {
737
912
  // components of the same total, they're two guesses at it).
738
913
  const totalForNextTurn = Math.max(lastTurnInputTokens, estimateNextPromptTokens(messages));
739
914
  if (shouldCompactForBudget(totalForNextTurn, this.config.model, threshold, this.config.contextBudgetTokens)) {
740
- if (this.hooks) {
741
- await this.hooks.dispatch({
742
- type: 'PreCompact',
743
- messagesCount: messages.length,
744
- tokens: totalForNextTurn,
745
- cwd,
746
- });
747
- }
748
- const summarizer = this.config.compactionSummarizer ?? this.buildDefaultSummarizer();
749
- const result = await compactMessages(messages, summarizer, {
750
- keepRecent: this.config.compactionKeepRecent,
751
- });
752
- if (result.droppedCount > 0) {
753
- messages.length = 0;
754
- for (const m of result.messages)
755
- messages.push(m);
756
- masker?.reset(); // the history was replaced, so the frozen boundary no longer applies
757
- // A mid-run steering doc summarized away may be injected again on the next touch.
758
- for (const name of [...steeringInMessages]) {
759
- const marker = `<steering name="${name}">`;
760
- if (messages.some((m) => typeof m.content === 'string' && m.content.includes(marker)))
761
- continue;
762
- steeringInMessages.delete(name);
763
- steeringInjected.delete(name);
764
- steeringInjections--;
765
- }
766
- emit({
767
- type: 'compaction',
768
- droppedCount: result.droppedCount,
769
- savedTokens: result.savedTokens,
770
- summary: result.summary,
771
- });
772
- }
915
+ await compactNow(totalForNextTurn);
773
916
  }
774
917
  }
775
918
  }
@@ -807,14 +950,9 @@ export class Agent {
807
950
  else {
808
951
  reason = 'error';
809
952
  const message = errorMessageOf(err);
810
- if (reportedStreamError && /^No output generated/i.test(message)) {
811
- // The SDK follows a failed call with this generic error; the first one is the useful one.
812
- errorMessage = reportedStreamError;
813
- }
814
- else {
815
- errorMessage = message;
816
- emit({ type: 'error', error: errorMessage });
817
- }
953
+ // The SDK follows a failed call with this generic error; the first one is the useful one.
954
+ errorMessage = reportedStreamError && /^No output generated/i.test(message) ? reportedStreamError : message;
955
+ emit({ type: 'error', error: errorMessage });
818
956
  }
819
957
  }
820
958
  // A run that ends between pushing the continue message and sending it (stop, timeout)
@@ -822,6 +960,8 @@ export class Agent {
822
960
  const lastMessage = messages[messages.length - 1];
823
961
  if (continuedText !== '' && lastMessage?.role === 'user' && lastMessage.content === CONTINUE_PROMPT)
824
962
  messages.pop();
963
+ else if (todoNudgeMessage && lastMessage === todoNudgeMessage)
964
+ messages.pop();
825
965
  // Stopped mid-answer: keep the part the user already saw, marked as cut short, so "continue" can pick it up
826
966
  // instead of starting over. Only when the model is the next speaker (after the user or a tool result).
827
967
  if (reason === 'aborted' && partialStepText.trim()) {
@@ -979,23 +1119,23 @@ export class Agent {
979
1119
  if (rule?.action === 'deny')
980
1120
  return { approved: false, note: rule.reason };
981
1121
  if (rule?.action === 'allow')
982
- return { approved: true };
1122
+ return { approved: true, source: 'rule' };
983
1123
  const forceAsk = rule?.action === 'ask';
984
1124
  // Read-only tools the user chose not to be asked about (never a destructive one).
985
1125
  if (!forceAsk && !def.destructive && this.config.autoApproveTools?.includes(def.name)) {
986
- return { approved: true };
1126
+ return { approved: true, source: 'list' };
987
1127
  }
988
1128
  // Memory writes the user chose not to be asked about. memory_forget is deleted data: always asked.
989
1129
  if (!forceAsk && def.name === 'memory_write' && this.config.memoryAutoApproveWrites) {
990
- return { approved: true };
1130
+ return { approved: true, source: 'memory' };
991
1131
  }
992
1132
  // A plain command the user already said "always allow" for: exact match only (see command-allow.ts).
993
1133
  if (!forceAsk && def.name === 'run_command' && this.config.autoApproveCommands?.length && isListedCommand(args, this.config.autoApproveCommands)) {
994
- return { approved: true };
1134
+ return { approved: true, source: 'always' };
995
1135
  }
996
1136
  const auto = this.config.auto ?? false;
997
1137
  if (!forceAsk && auto && !(def.destructive && !this.config.allowDestructive)) {
998
- return { approved: true };
1138
+ return { approved: true, source: 'autonomous' };
999
1139
  }
1000
1140
  let preview;
1001
1141
  if (def.previewApproval && ctx) {
@@ -1020,11 +1160,11 @@ export class Agent {
1020
1160
  if (abortSignal?.aborted)
1021
1161
  return { approved: false };
1022
1162
  const answered = Promise.resolve(this.config.approve(request));
1023
- const aborted = new Promise((resolve) => abortSignal?.addEventListener('abort', () => resolve('aborted'), { once: true }));
1024
- const decision = await Promise.race([answered, aborted]);
1163
+ const decision = await raceAbort(answered, abortSignal, 'aborted');
1025
1164
  if (decision === 'aborted')
1026
1165
  return { approved: false };
1027
- return typeof decision === 'boolean' ? { approved: decision } : decision;
1166
+ const answer = typeof decision === 'boolean' ? { approved: decision } : decision;
1167
+ return answer.approved ? { ...answer, source: 'person' } : answer;
1028
1168
  }
1029
1169
  return { approved: false };
1030
1170
  }
@@ -1215,6 +1355,16 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
1215
1355
  // the first run: an answer that arrives on turn 3 can never enter the system
1216
1356
  // message, and a middle turn would be summarised away by compaction.
1217
1357
  const answersBlock = renderAnswersBlock(config.journeyAnswers ?? []);
1358
+ // The versions the workspace's files state, so code is written for them and not for what training saw most.
1359
+ let stackFactsBlock = '';
1360
+ if (!config.disableStackFacts) {
1361
+ try {
1362
+ stackFactsBlock = stackBlock(await detectStackFacts(cwd));
1363
+ }
1364
+ catch {
1365
+ // Skipped, never fatal.
1366
+ }
1367
+ }
1218
1368
  // Facts the model would otherwise have to guess: the platform decides
1219
1369
  // whether `cat` or `type` works, and it cannot see the date at all.
1220
1370
  const envBlock = environmentBlock({
@@ -1229,7 +1379,8 @@ async function buildInitialMessages(config, task, skills, steeringSystem = [], u
1229
1379
  // stable order so cache hits survive across runs that share state. The
1230
1380
  // environment block goes last: it is the part that varies per session.
1231
1381
  const steeringBlock = steeringSystem.map(wrapSteering).join('\n\n');
1232
- const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, envBlock].filter((b) => b.length > 0);
1382
+ const guidanceBlock = config.model ? modelGuidanceBlock(config.model) : '';
1383
+ const blocks = [steeringBlock, instructionsBlock, extraInstructionsBlock, config.mcpInstructions ?? '', config.projectCommands ?? '', memoryBlock, skillsBlock, answersBlock, mapBlock, stackFactsBlock, guidanceBlock, envBlock].filter((b) => b.length > 0);
1233
1384
  const systemContent = blocks.length > 0 ? `${basePrompt}\n\n${blocks.join('\n\n')}` : basePrompt;
1234
1385
  return [
1235
1386
  { role: 'system', content: systemContent },
@@ -1,5 +1,6 @@
1
1
  import { generateText } from 'ai';
2
2
  import { createLanguageModel } from '../providers.js';
3
+ import { withModelRetry } from './retry.js';
3
4
  import { stripThinkTags } from './think-tags.js';
4
5
  const ENHANCE_INSTRUCTIONS = 'Rewrite the request below so a coding agent can act on it: say what to change and where, name the expected result, and keep every file, symbol and constraint the user mentioned. ' +
5
6
  'Do not invent requirements or add steps the user did not ask for. Reply with the rewritten request only, no quotes, no code fences, no preface.';
@@ -15,7 +16,7 @@ export async function enhancePrompt(input, model, modelFactory = createLanguageM
15
16
  if (!model || !input.trim())
16
17
  return input;
17
18
  try {
18
- const result = await generateText({ model: modelFactory(model), prompt: enhanceRequestPrompt(input, instruction) });
19
+ const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: enhanceRequestPrompt(input, instruction), maxRetries: 0 }), { maxRetries: 2 });
19
20
  return clean(result.text) || input;
20
21
  }
21
22
  catch {
@@ -39,15 +39,12 @@ export declare function resumeGoal(g: GoalState): GoalState;
39
39
  /** The message that drives one goal turn. */
40
40
  export declare function goalPrompt(g: GoalState): string;
41
41
  declare const reportParams: z.ZodObject<{
42
- kind: z.ZodEnum<["complete", "blocked"]>;
42
+ kind: z.ZodEnum<{
43
+ blocked: "blocked";
44
+ complete: "complete";
45
+ }>;
43
46
  reason: z.ZodString;
44
- }, "strip", z.ZodTypeAny, {
45
- kind: "blocked" | "complete";
46
- reason: string;
47
- }, {
48
- kind: "blocked" | "complete";
49
- reason: string;
50
- }>;
47
+ }, z.core.$strip>;
51
48
  export declare function createGoalReportTool(onReport: (r: GoalReport) => void): ToolDefinition<z.infer<typeof reportParams>, {
52
49
  recorded: true;
53
50
  }>;
@@ -29,6 +29,14 @@ export interface EnvironmentFacts {
29
29
  * uses in `run_command`: `cmd.exe` on Windows, `/bin/sh` elsewhere.
30
30
  */
31
31
  export declare function environmentBlock(facts: EnvironmentFacts): string;
32
+ /**
33
+ * Extra instructions for model families that need them (Kilo chooses a whole prompt per family; Forge appends a
34
+ * block so the base prompt stays the same for prompt caching and mode switching, RB-27). Empty for the rest.
35
+ */
36
+ export declare function modelGuidanceBlock(model: {
37
+ provider: string;
38
+ model: string;
39
+ }): string;
32
40
  /**
33
41
  * System-prompt suffix appended when the agent runs in plan mode. The
34
42
  * model is told to emit a numbered plan as plain text and stop without
@@ -44,5 +52,12 @@ export declare const DEFAULT_PLAN_MODE_SUFFIX = "\n\nPLAN MODE \u2014 IMPORTANT:
44
52
  export declare const DEFAULT_SPEC_MODE_SUFFIX = "\n\nSPEC MODE \u2014 IMPORTANT:\n- You may use read-only tools (read_file, search_files, list_files, find_symbol, list_symbols, find_references) to explore the codebase as needed. Do NOT call write_file, edit_file, run_command, or any other tool.\n- When you have enough context, stop calling tools and produce your final answer as exactly three markdown sections, in this order, each starting with its own heading line:\n\n## requirements.md\n<user stories with EARS-syntax acceptance criteria, e.g. \"WHEN <trigger> THE SYSTEM SHALL <response>\". Number each requirement (1, 2, 3, ...) so tasks.md can reference them.>\n\n## design.md\n<technical design grounded in what you actually found in the codebase: data flow, schemas, interfaces, API endpoints, existing patterns to follow.>\n\n## tasks.md\n<sequenced implementation tasks as a markdown checklist. Write each task as \"- [ ] 1. Title\", numbering them 1, 2, 3, ...; indent subtasks as \" - [ ] 1.1 Title\". Directly under each task add a line like \"_Requirements: 2.1, 3.2_\" referencing the requirement numbers it satisfies. Put every task and every subtask on its own line; never write a second checkbox on the same line as another.>\n\n- Do not proceed to implement anything. The user will review these three documents and decide whether to approve, edit, or cancel.\n";
45
53
  /** User message sent after a reply was cut off by the output-token limit. */
46
54
  export declare const CONTINUE_PROMPT = "Continue exactly where you stopped. Do not repeat anything you already wrote.";
55
+ /** Added to the last step a run is allowed (tools are off for it); never kept in the conversation. */
56
+ export declare const LAST_STEP_PROMPT: string;
57
+ /** User message sent when a reply ends the turn while the model's own todo list still has open items. */
58
+ export declare function todoNudgePrompt(open: ReadonlyArray<{
59
+ content: string;
60
+ status: string;
61
+ }>): string;
47
62
  /** Spec-mode prompt override for architect phase 1 (see agent/architect.ts). */
48
63
  export declare const ARCHITECT_PROMPT = "\n\nYou are the ARCHITECT. Explore the codebase with your read-only tools, then\nreply with a precise change plan for a separate editor model to apply. Do not\nwrite code outside the plan. Your final message must contain only:\n\n1. Files to change \u2014 for each file, the exact edits (what to find, what to\n replace it with, or the new code to add and where).\n2. New files \u2014 path and full contents.\n3. Verification \u2014 the commands to run to check the change.\n\nBe exact: the editor applies your plan literally and cannot ask questions.\n";
@@ -69,6 +69,26 @@ export function environmentBlock(facts) {
69
69
  ];
70
70
  return `<environment>\n${lines.join('\n')}\n</environment>`;
71
71
  }
72
+ /** Model ids of families that tend to end a turn early or announce a tool call without making it. */
73
+ const PERSISTENCE_FAMILIES = /minimax|kimi|moonshot|deepseek|qwen|qwq|glm|llama|mistral|mixtral|codestral|devstral|gpt-oss|grok|nemotron|phi-|gemma|ling-|trinity/i;
74
+ /** Providers that serve local open-weight models, whatever their name. */
75
+ const LOCAL_PROVIDERS = new Set(['ollama', 'lmstudio']);
76
+ /**
77
+ * Extra instructions for model families that need them (Kilo chooses a whole prompt per family; Forge appends a
78
+ * block so the base prompt stays the same for prompt caching and mode switching, RB-27). Empty for the rest.
79
+ */
80
+ export function modelGuidanceBlock(model) {
81
+ if (!LOCAL_PROVIDERS.has(model.provider) && !PERSISTENCE_FAMILIES.test(model.model))
82
+ return '';
83
+ return [
84
+ '<model-guidance>',
85
+ '- Keep working until the task is fully done before you end your turn; the user expects you to finish it, not to describe what you would do next.',
86
+ '- When you say you are about to call a tool, call it in the same reply. Never end a reply with "Now I will…" and no tool call.',
87
+ '- For a task with several steps, keep the list in `todo_write`: mark one item in_progress, and mark each item completed as soon as it is done.',
88
+ '- End your turn only when every item is done, when you need the user to decide something, or when the user asked you to stop.',
89
+ '</model-guidance>',
90
+ ].join('\n');
91
+ }
72
92
  /**
73
93
  * System-prompt suffix appended when the agent runs in plan mode. The
74
94
  * model is told to emit a numbered plan as plain text and stop without
@@ -108,6 +128,20 @@ SPEC MODE — IMPORTANT:
108
128
  `;
109
129
  /** User message sent after a reply was cut off by the output-token limit. */
110
130
  export const CONTINUE_PROMPT = 'Continue exactly where you stopped. Do not repeat anything you already wrote.';
131
+ /** Added to the last step a run is allowed (tools are off for it); never kept in the conversation. */
132
+ export const LAST_STEP_PROMPT = '[Automatic note by Forge, not a message from the user.]\n' +
133
+ 'This is the last step this run is allowed, and tools are turned off for it. Reply with text only, to the user: ' +
134
+ 'say briefly that the step limit was reached, what is done, and what is left to do, so they can press Continue.';
135
+ /** User message sent when a reply ends the turn while the model's own todo list still has open items. */
136
+ export function todoNudgePrompt(open) {
137
+ const items = open.map((t) => `- [${t.status}] ${t.content}`).join('\n');
138
+ return ('[Automatic check by Forge, not a message from the user.]\n' +
139
+ `Your todo list still has open items:\n${items}\n\n` +
140
+ "The user's own instructions come first. If the user asked you to stop or wait, or you need their answer, " +
141
+ 'do not continue: leave the open items as they are and end with one short sentence to the user, addressed as "you" (do not mention this check). ' +
142
+ 'If an item no longer applies, update the list with `todo_write`. ' +
143
+ 'Otherwise you stopped before finishing: carry on with the next item now, using your tools.');
144
+ }
111
145
  /** Spec-mode prompt override for architect phase 1 (see agent/architect.ts). */
112
146
  export const ARCHITECT_PROMPT = `
113
147
 
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Race `work` against `signal`, resolving to `onAbort` if the signal fires first.
3
+ *
4
+ * The listener is removed once the race settles. A run's signal lives for the
5
+ * whole run, so a listener added per question or per approval and left behind
6
+ * keeps every one of those closures alive until the run ends.
7
+ */
8
+ export declare function raceAbort<T, A>(work: Promise<T>, signal: AbortSignal | undefined, onAbort: A): Promise<T | A>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Race `work` against `signal`, resolving to `onAbort` if the signal fires first.
3
+ *
4
+ * The listener is removed once the race settles. A run's signal lives for the
5
+ * whole run, so a listener added per question or per approval and left behind
6
+ * keeps every one of those closures alive until the run ends.
7
+ */
8
+ export async function raceAbort(work, signal, onAbort) {
9
+ if (!signal)
10
+ return work;
11
+ if (signal.aborted)
12
+ return onAbort;
13
+ let listener;
14
+ const aborted = new Promise((resolve) => {
15
+ listener = () => resolve(onAbort);
16
+ signal.addEventListener('abort', listener, { once: true });
17
+ });
18
+ try {
19
+ return await Promise.race([work, aborted]);
20
+ }
21
+ finally {
22
+ signal.removeEventListener('abort', listener);
23
+ }
24
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * When a failed model call is worth repeating, and how long to wait first. Patterns follow Kilo's
3
+ * session/retry.ts: status codes, the provider's own retry-after headers, and the wordings providers and
4
+ * Node use for overloads and dropped connections.
5
+ */
6
+ export declare const DEFAULT_PROVIDER_RETRIES = 5;
7
+ export declare const DEFAULT_RETRY_BASE_MS = 2000;
8
+ export type RetryDecision = {
9
+ retry: true;
10
+ delayMs: number;
11
+ message: string;
12
+ } | {
13
+ retry: false;
14
+ };
15
+ /** The first line of the error a person should see. */
16
+ export declare function errorSummary(err: unknown): string;
17
+ export declare function isContextOverflowError(err: unknown): boolean;
18
+ /**
19
+ * Whether to retry after failed attempt number `attempt` (1-based) and how long to wait.
20
+ * `random` in [0, 1) adds up to 25% jitter to the back-off.
21
+ */
22
+ export declare function retryDecision(err: unknown, attempt: number, baseDelayMs?: number, random?: number): RetryDecision;
23
+ /** True when the failure looks like no network (DNS or route gone), not a busy or resetting server. */
24
+ export declare function isDisconnected(err: unknown): boolean;
25
+ /** Opens a TCP connection to a few well-known hosts by address (no DNS needed); true when any of them answers. */
26
+ export declare function defaultNetworkProbe(): Promise<boolean>;
27
+ /**
28
+ * Poll `probe` every `pollMs` until it reports a network, the signal fires, or `budgetMs` runs out.
29
+ * Returns how long it waited and why it stopped.
30
+ */
31
+ export declare function waitForNetwork(probe: () => Promise<boolean>, opts: {
32
+ pollMs: number;
33
+ budgetMs: number;
34
+ abortSignal?: AbortSignal;
35
+ }): Promise<{
36
+ outcome: 'online' | 'aborted' | 'gave_up';
37
+ waitedMs: number;
38
+ }>;
39
+ /**
40
+ * Run a one-shot model call (generateText with `maxRetries: 0`) under the agent loop's retry policy.
41
+ * Throws the failure itself when it is not worth retrying, when the retries are used up, or on abort.
42
+ */
43
+ export declare function withModelRetry<T>(call: () => Promise<T>, opts?: {
44
+ maxRetries?: number;
45
+ baseDelayMs?: number;
46
+ abortSignal?: AbortSignal;
47
+ onRetry?: (attempt: number, delayMs: number, reason: string) => void;
48
+ }): Promise<T>;
49
+ /** Resolves after `ms`, or early (to 'aborted') when the signal fires; leaves no listener behind (RB-36). */
50
+ export declare function waitOrAbort(ms: number, signal: AbortSignal | undefined): Promise<'waited' | 'aborted'>;