@agent-native/core 0.153.4 → 0.153.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/corpus/templates/analytics/actions/account-deep-dive.ts +1 -0
  2. package/corpus/templates/analytics/actions/bigquery-query.ts +1 -0
  3. package/corpus/templates/analytics/actions/bigquery.ts +1 -0
  4. package/corpus/templates/analytics/actions/content-calendar-schema.ts +1 -0
  5. package/corpus/templates/analytics/actions/content-calendar.ts +1 -0
  6. package/corpus/templates/analytics/actions/gcloud.ts +1 -0
  7. package/corpus/templates/analytics/actions/get-data-program.ts +4 -0
  8. package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
  9. package/corpus/templates/analytics/actions/get-monitor-stats.ts +1 -0
  10. package/corpus/templates/analytics/actions/get-monitor.ts +1 -0
  11. package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
  12. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
  13. package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +1 -0
  14. package/corpus/templates/analytics/actions/gong-calls.ts +1 -0
  15. package/corpus/templates/analytics/actions/gong-native-insights.ts +1 -0
  16. package/corpus/templates/analytics/actions/grafana.ts +1 -0
  17. package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
  18. package/corpus/templates/analytics/actions/hubspot-metrics.ts +1 -0
  19. package/corpus/templates/analytics/actions/hubspot-pipelines.ts +1 -0
  20. package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
  21. package/corpus/templates/analytics/actions/jira-search.ts +1 -0
  22. package/corpus/templates/analytics/actions/jira.ts +1 -0
  23. package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
  24. package/corpus/templates/analytics/actions/list-monitors.ts +1 -0
  25. package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
  26. package/corpus/templates/analytics/actions/match-error-issues.ts +1 -0
  27. package/corpus/templates/analytics/actions/prometheus.ts +1 -0
  28. package/corpus/templates/analytics/actions/pylon-issues.ts +1 -0
  29. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -0
  30. package/corpus/templates/analytics/actions/query-dashboard-panel.ts +1 -0
  31. package/corpus/templates/analytics/actions/query-inbound-forms.ts +1 -0
  32. package/corpus/templates/analytics/actions/run-monitor-check.ts +1 -0
  33. package/corpus/templates/analytics/actions/sentry.ts +1 -0
  34. package/corpus/templates/analytics/actions/seo-blog-pages.ts +1 -0
  35. package/corpus/templates/analytics/actions/seo-page-keywords.ts +1 -0
  36. package/corpus/templates/analytics/actions/seo-top-keywords.ts +1 -0
  37. package/corpus/templates/analytics/actions/slack-messages.ts +1 -0
  38. package/corpus/templates/analytics/actions/stripe.ts +1 -0
  39. package/corpus/templates/analytics/actions/test-custom-api-connection.ts +1 -0
  40. package/corpus/templates/analytics/server/lib/real-data-actions.ts +62 -47
  41. package/corpus/templates/analytics/server/plugins/agent-chat.ts +27 -34
  42. package/dist/action.d.ts +13 -0
  43. package/dist/action.js +3 -0
  44. package/dist/agent/engine/tool-call-journal-seed.d.ts +27 -10
  45. package/dist/agent/engine/tool-call-journal-seed.js +63 -32
  46. package/dist/agent/production-agent.d.ts +40 -1
  47. package/dist/agent/production-agent.js +201 -62
  48. package/dist/agent/run-loop-with-resume.d.ts +1 -1
  49. package/dist/agent/run-loop-with-resume.js +49 -35
  50. package/dist/agent/run-manager.js +17 -11
  51. package/dist/agent/run-store.d.ts +16 -0
  52. package/dist/agent/run-store.js +51 -9
  53. package/dist/agent/thread-data-builder.d.ts +1 -0
  54. package/dist/agent/thread-data-builder.js +15 -2
  55. package/dist/client/RouteTransitionIndicator.js +1 -1
  56. package/dist/client/agent-chat-adapter.js +242 -282
  57. package/dist/client/sse-event-processor.d.ts +7 -0
  58. package/dist/client/sse-event-processor.js +28 -1
  59. package/dist/collab/routes.d.ts +1 -1
  60. package/dist/db/client.js +2 -2
  61. package/dist/notifications/routes.d.ts +1 -1
  62. package/dist/progress/routes.d.ts +1 -1
  63. package/dist/provider-api/actions/github-repo-files.js +1 -0
  64. package/dist/provider-api/actions/provider-api.js +1 -0
  65. package/dist/provider-api/actions/staged-datasets.js +1 -0
  66. package/dist/provider-api/corpus-jobs.js +1 -0
  67. package/dist/server/action-discovery.js +2 -0
  68. package/dist/server/agent-chat-plugin.js +35 -3
  69. package/dist/server/realtime-token.d.ts +1 -1
  70. package/package.json +1 -1
@@ -2439,6 +2439,13 @@ async function waitForInterruptedToolLedgerEntry(opts) {
2439
2439
  * Dropping the echoed input costs nothing: the fault is already named by the
2440
2440
  * sentence before it, and two failures that differ ONLY in the arguments they
2441
2441
  * echo are the same failure.
2442
+ *
2443
+ * Digits stay. Collapsing them merges failures that are genuinely different:
2444
+ * "Record 41 not found" and "Record 42 not found" are two missing records, so a
2445
+ * seven-item sweep where every item legitimately 404s would trip the
2446
+ * across-arguments breaker at item six. A guard message that varies its own
2447
+ * running tally is fixed where that message is written, not by blinding every
2448
+ * breaker to numbers.
2442
2449
  */
2443
2450
  function normalizeToolErrorForBreaker(error) {
2444
2451
  return (error
@@ -2455,6 +2462,61 @@ function rateLimitRecoveryHint(message) {
2455
2462
  }
2456
2463
  return "\n\nProvider rate-limit guidance: stop retrying this provider in this turn. Report the rate limit as a coverage gap, include any evidence already gathered, and ask the user to retry after the provider quota resets if full coverage is required.";
2457
2464
  }
2465
+ /**
2466
+ * Tool errors the model has no way to clear: the missing thing lives outside
2467
+ * the turn (a credential, a role grant, a connected account, a runtime, the
2468
+ * user's own approval). Every retry costs a full round-trip carrying the whole
2469
+ * transcript and lands on the identical error, so these stop on the FIRST
2470
+ * occurrence rather than after `MAX_SAME_ERROR_ACROSS_ARGUMENTS` of them.
2471
+ *
2472
+ * The distinction is the one `stopForBigQueryNotConfigured` draws in
2473
+ * templates/analytics: a *configuration* gap is permanent for this turn, a
2474
+ * *query* failure is not. Anything the model can act on — a stale read to
2475
+ * rebase, a bad argument, a full quota it can free, a wrong dataset name, a
2476
+ * statement timeout — must stay out of this list and keep its retries.
2477
+ * Precision over recall: a wrong match kills a turn that would have succeeded,
2478
+ * which is worse than one more retry.
2479
+ */
2480
+ export function permanentPreconditionRemedy(message) {
2481
+ const trimmed = message.replace(/\s+/g, " ").trim();
2482
+ for (const pattern of PERMANENT_PRECONDITION_PATTERNS) {
2483
+ if (pattern.test(trimmed))
2484
+ return trimmed;
2485
+ }
2486
+ return null;
2487
+ }
2488
+ const PERMANENT_PRECONDITION_PATTERNS = [
2489
+ // "Requires editor role on deck ZJshjrXhjx (have viewer)". The trailing space
2490
+ // is load-bearing: it keeps "must have required property" out.
2491
+ /\brequires? (?:an? |the )?[\w-]+ role\b/i,
2492
+ // "Gemini API key not configured. Save GEMINI_API_KEY in settings." Only the
2493
+ // not-configured/not-set wordings — "not found" is how providers report a
2494
+ // missing dataset or record, which the model can and should correct.
2495
+ /\b(?:api[ -]?keys?|access tokens?|credentials?|secrets?)\b[^.]{0,60}\bnot (?:configured|set|connected|available)\b/i,
2496
+ /\bsave [A-Z][A-Z0-9_]{3,} in (?:the )?settings\b/i,
2497
+ // "Connect Builder.io before indexing a design system from Figma or code."
2498
+ // Case-sensitive, sentence-initial, and followed by a capitalized service
2499
+ // name, because that is the only shape that separates the imperative remedy
2500
+ // from the retryable network failure: "failed to connect to the warehouse
2501
+ // before the deadline" and "Connect timed out, retry first" both satisfy
2502
+ // every looser reading of the same words.
2503
+ /(?:^|[.:!?]\s+)Connect [A-Z][\w.-]*[^;]{0,40}?\b(?:before|first|in settings)\b/,
2504
+ // "Plan mode blocked `update-extension`. Switch to Act mode …" — cleared by
2505
+ // the user approving the plan, never by the model trying again.
2506
+ /\bplan mode blocked\b/i,
2507
+ // The templates' most-copied throw, and always `if (!getRequestUserEmail())`:
2508
+ // one synchronous AsyncLocalStorage read of a value fixed when the request
2509
+ // context was established. It cannot appear and disappear between tool calls
2510
+ // of the same chunk, so retrying inside this turn lands on it again.
2511
+ /\bno authenticated user\b/i,
2512
+ // "SSRF blocked: refusing to fetch private/internal address (…)"
2513
+ /\bssrf blocked\b/i,
2514
+ // Deliberately absent: "only available in …". Retention windows ("only
2515
+ // available from the last 90 days") share its wording and are fixed by
2516
+ // narrowing the range, so it stopped turns that were one argument away from
2517
+ // succeeding. The count-based breaker still ends a genuine runtime gate
2518
+ // after six.
2519
+ ];
2458
2520
  const SOURCE_SWEEP_TOOL_NAME = /\b(?:api|calls?|deals?|docs?|events?|issues?|messages?|metrics?|provider|query|records?|request|search|source|tickets?|transcripts?)\b/i;
2459
2521
  const SOURCE_SWEEP_PROVIDER_TOKEN = /\b(?:amplitude|apollo|bigquery|commonroom|data-source|ga4|github|gong|grafana|hubspot|jira|mixpanel|notion|posthog|postgres|postgresql|pylon|sentry|slack|stripe)\b/i;
2460
2522
  const SOURCE_SWEEP_EXCLUDED_TOOLS = new Set([
@@ -2560,14 +2622,20 @@ function restrictAgentTeamsAfterSourceSweep(tools) {
2560
2622
  };
2561
2623
  });
2562
2624
  }
2625
+ /**
2626
+ * Deliberately carries no running call count. This decline is recorded as a
2627
+ * tool error, and the breakers key on that text: a message that reports its own
2628
+ * tally ("already made 13 call(s)") mints a fresh key on every decline, so the
2629
+ * decline that exists to stop a spiral becomes the one thing nothing can count.
2630
+ * The fixed threshold below says the same thing without varying.
2631
+ */
2563
2632
  export function repeatedSourceSweepGuardMessage(opts) {
2564
2633
  const threshold = opts.threshold ?? SOURCE_SWEEP_TOOL_CALL_THRESHOLD;
2565
2634
  const target = opts.scope === "aggregate"
2566
2635
  ? "read-only source/search tools"
2567
2636
  : "the same read-only source/search tool";
2568
- return (`Skipped ${opts.toolName}: this turn already made ${opts.priorCalls} ` +
2569
- `call(s) to ${target}, which exceeds the ` +
2570
- `${threshold}-call convergence budget. Stop calling ${opts.toolName} ` +
2637
+ return (`Skipped ${opts.toolName}: this turn already exhausted its ` +
2638
+ `${threshold}-call convergence budget for ${target}. Stop calling ${opts.toolName} ` +
2571
2639
  `one item at a time and change strategy before answering. If a broader ` +
2572
2640
  `read-only bulk/source mechanism is available, use it now: provider API ` +
2573
2641
  `catalog/docs/request tools with pagination or staging, code execution ` +
@@ -2597,7 +2665,6 @@ export function shouldGuardRepeatedSourceSweep(opts) {
2597
2665
  priorCalls: priorSourceSweepCalls,
2598
2666
  message: repeatedSourceSweepGuardMessage({
2599
2667
  toolName: opts.toolName,
2600
- priorCalls: priorSourceSweepCalls,
2601
2668
  threshold,
2602
2669
  scope: "aggregate",
2603
2670
  }),
@@ -2610,7 +2677,6 @@ export function shouldGuardRepeatedSourceSweep(opts) {
2610
2677
  priorCalls,
2611
2678
  message: repeatedSourceSweepGuardMessage({
2612
2679
  toolName: opts.toolName,
2613
- priorCalls,
2614
2680
  threshold,
2615
2681
  scope: "tool",
2616
2682
  }),
@@ -3095,29 +3161,50 @@ export async function runAgentLoop(opts) {
3095
3161
  // calls/results are folded into `toolCallHistory` / `toolResultHistory` so
3096
3162
  // final response guards see evidence from earlier chunks of this turn.
3097
3163
  const consumedJournalKeys = new Set();
3098
- const { toolCallJournal, priorToolCalls: journaledPriorToolCalls, priorToolResults: journaledPriorToolResults, } = await loadPriorTurnToolCallJournal(opts.threadId, opts.turnId);
3164
+ const journalRead = await loadPriorTurnToolCallJournal(opts.threadId, opts.turnId);
3165
+ const toolCallJournal = journalRead.status === "read" ? journalRead.toolCallJournal : null;
3166
+ const journaledPriorToolCalls = journalRead.status === "read" ? journalRead.priorToolCalls : [];
3167
+ const journaledPriorToolResults = journalRead.status === "read" ? journalRead.priorToolResults : [];
3099
3168
  toolCallHistory.push(...journaledPriorToolCalls);
3100
3169
  toolResultHistory.push(...journaledPriorToolResults);
3170
+ // Every per-turn ceiling below is seeded from that read, and the write-replay
3171
+ // hard block is enforced from it. On a continuation we cannot tell an
3172
+ // already-completed side effect from a fresh one without it, so a failed read
3173
+ // stops the turn instead of quietly resuming with a blank ledger.
3174
+ const unreadableJournalStop = journalRead.status === "unreadable" && isInternalContinuationTurn(messages)
3175
+ ? {
3176
+ message: "I stopped because I could not read this turn's run ledger, so I could not tell which steps had already finished. " +
3177
+ "Retrying would risk repeating a step that already completed. Please send the request again.",
3178
+ errorCode: "tool_call_journal_unreadable",
3179
+ details: journalRead.error,
3180
+ }
3181
+ : null;
3101
3182
  const readOnlyToolResultCache = seedReadOnlyToolResultsFromHistory(messages, actions, seedReadOnlyToolResultsFromJournal(toolCallJournal, actions));
3102
3183
  const duplicateReadOnlyToolCalls = seedDuplicateReadOnlyToolCallsFromHistory(messages, actions);
3103
3184
  const writeToolInterruptions = seedWriteToolInterruptionsFromHistory(messages, actions);
3185
+ // All three repetition ledgers are SEEDED from earlier chunks of this turn,
3186
+ // like `toolCallHistory` and `toolResultHistory` above. A fresh Map makes the
3187
+ // limit per-CHUNK rather than per-turn, and a turn may chain up to
3188
+ // MAX_RUN_LOOP_CONTINUATIONS (6) or MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS
3189
+ // (20) of them — so the real ceiling becomes 20x the constant, and a spiral
3190
+ // resumes with a clean slate at every chunk boundary.
3104
3191
  const repeatedToolErrors = new Map();
3105
- // Keyed WITHOUT the arguments — see MAX_SAME_ERROR_ACROSS_ARGUMENTS.
3106
- //
3107
- // SEEDED from earlier chunks of this turn, like `toolCallHistory` and
3108
- // `toolResultHistory` above. A fresh Map here would make the limit per-CHUNK
3109
- // rather than per-turn, and a turn may chain up to MAX_RUN_LOOP_CONTINUATIONS
3110
- // (6) or MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS (20) of them — so the real
3111
- // ceiling would be 36 or 120 identical failures, not 6, and a spiral would
3112
- // resume with a clean slate at every chunk boundary.
3192
+ /** Keyed WITHOUT the arguments — see MAX_SAME_ERROR_ACROSS_ARGUMENTS. */
3113
3193
  const repeatedToolErrorsAnyArgs = new Map();
3114
- for (const prior of toolResultHistory) {
3194
+ const repeatedToolCalls = new Map();
3195
+ for (const prior of journaledPriorToolCalls) {
3196
+ const key = toolCallCacheKey(prior.name, prior.input);
3197
+ repeatedToolCalls.set(key, (repeatedToolCalls.get(key) ?? 0) + 1);
3198
+ }
3199
+ for (const prior of journaledPriorToolResults) {
3115
3200
  if (!prior.isError)
3116
3201
  continue;
3117
- const key = `${prior.name}:${normalizeToolErrorForBreaker(prior.content)}`;
3118
- repeatedToolErrorsAnyArgs.set(key, (repeatedToolErrorsAnyArgs.get(key) ?? 0) + 1);
3202
+ const normalized = normalizeToolErrorForBreaker(prior.content);
3203
+ const anyArgsKey = `${prior.name}:${normalized}`;
3204
+ repeatedToolErrorsAnyArgs.set(anyArgsKey, (repeatedToolErrorsAnyArgs.get(anyArgsKey) ?? 0) + 1);
3205
+ const errorKey = `${toolCallCacheKey(prior.name, prior.input)}:${normalized}`;
3206
+ repeatedToolErrors.set(errorKey, (repeatedToolErrors.get(errorKey) ?? 0) + 1);
3119
3207
  }
3120
- const repeatedToolCalls = new Map();
3121
3208
  let finalGuardRetries = 0;
3122
3209
  let emptyFinalResponseRetries = 0;
3123
3210
  let iterations = 0;
@@ -3125,6 +3212,31 @@ export async function runAgentLoop(opts) {
3125
3212
  // trailing `done` would overwrite the boundary the continuation logic reads.
3126
3213
  let endedAtLoopLimit = false;
3127
3214
  let terminalActionStop = null;
3215
+ /**
3216
+ * A stop is either a hand-off to the user (the two `input_required` codes) or
3217
+ * a failed run. Failed runs MUST leave a terminal `error` event behind: the
3218
+ * run row's status, `error_code`, `listErroredRuns`, the outcome counters and
3219
+ * the client's retry affordance all key off that event, and a stop that only
3220
+ * streams text is recorded as `status='completed', terminal_reason='done'` —
3221
+ * a guard trip counted as a success.
3222
+ */
3223
+ const sendTerminalActionStop = (stop) => {
3224
+ if (stop.errorCode === "needs-approval" ||
3225
+ stop.errorCode === "awaiting-user-input") {
3226
+ send({ type: "text", text: stop.message });
3227
+ return;
3228
+ }
3229
+ // Sent ONLY as an error: both the live client and the thread rebuild render
3230
+ // an error event's message as assistant text themselves, so also sending it
3231
+ // as `text` prints the same sentence twice.
3232
+ send({
3233
+ type: "error",
3234
+ error: stop.message,
3235
+ errorCode: stop.errorCode ?? "tool_failed",
3236
+ ...(stop.details ? { details: stop.details } : {}),
3237
+ recoverable: false,
3238
+ });
3239
+ };
3128
3240
  // Overridden (raised tokens, lowered effort) only after an empty-final-
3129
3241
  // response retry below — kept separate from `opts.maxOutputTokens`/
3130
3242
  // `opts.reasoningEffort` so the very first attempt is unaffected and later
@@ -3151,6 +3263,11 @@ export async function runAgentLoop(opts) {
3151
3263
  while (true) {
3152
3264
  if (signal.aborted)
3153
3265
  break;
3266
+ if (unreadableJournalStop) {
3267
+ terminalActionStop = unreadableJournalStop;
3268
+ sendTerminalActionStop(unreadableJournalStop);
3269
+ break;
3270
+ }
3154
3271
  const turnInputTokens = priorTurnInputTokens + usage.inputTokens;
3155
3272
  if (turnInputTokens > maxRunInputTokens) {
3156
3273
  // Terminal for the whole TURN, not a chunk boundary: emitting
@@ -3882,9 +3999,6 @@ export async function runAgentLoop(opts) {
3882
3999
  // call run. The model cannot populate this — it comes from the request.
3883
4000
  const approvedToolCallKeys = new Set(opts.approvedToolCalls ?? []);
3884
4001
  const runToolCall = async (toolCall) => {
3885
- // Counted before any journal/cache short-circuit: serving a repeat from
3886
- // cache still means the model is asking the same question again.
3887
- noteRepeatedToolCall(toolCall.name, toolCall.input);
3888
4002
  const actionEntry = actions[toolCall.name];
3889
4003
  const placeholderNormalization = actionEntry
3890
4004
  ? normalizeOptionalToolPlaceholders(actionEntry.tool.parameters, toolCall.input)
@@ -3898,6 +4012,11 @@ export async function runAgentLoop(opts) {
3898
4012
  if (jsonStringCoercion.changed) {
3899
4013
  toolCall = { ...toolCall, input: jsonStringCoercion.input };
3900
4014
  }
4015
+ // Count after the same normalization that is persisted in the journal:
4016
+ // a model can send a JSON-encoded object/array, while the action and
4017
+ // ledger both see the coerced value. Serving a repeat from cache still
4018
+ // counts as asking the same question again.
4019
+ noteRepeatedToolCall(toolCall.name, toolCall.input);
3901
4020
  const toolInputNormalized = placeholderNormalization.changed || jsonStringCoercion.changed;
3902
4021
  const wireToolInput = JSON.stringify(toolCall.input ?? {});
3903
4022
  const normalizedToolInput = normalizeToolCallInputForHistory(toolCall.input);
@@ -3931,6 +4050,21 @@ export async function runAgentLoop(opts) {
3931
4050
  };
3932
4051
  const finalizeToolErrorResult = (rawResult) => {
3933
4052
  const sanitizedResult = sanitizeToolErrorText(rawResult);
4053
+ // Counting is the wrong instrument for a precondition the turn cannot
4054
+ // satisfy: six identical round-trips through a missing API key cost the
4055
+ // user minutes and end where the first one did. Classified first so the
4056
+ // remedy reaches them on attempt one.
4057
+ const permanentRemedy = permanentPreconditionRemedy(sanitizedResult);
4058
+ if (permanentRemedy) {
4059
+ requestedActionStop ??= {
4060
+ message: `I stopped because ${toolCall.name} needs a setup step outside this turn — a credential, a role, a connected account, or an approval — before it can run. ` +
4061
+ "Retrying would not have changed it, and anything completed before this is saved.",
4062
+ errorCode: "permanent_precondition",
4063
+ details: sanitizedResult,
4064
+ };
4065
+ return (`Stopped: ${toolCall.name} cannot run until a setup step outside this turn is fixed. ` +
4066
+ `Do not retry it with different arguments. ${sanitizedResult}`);
4067
+ }
3934
4068
  const errorKey = `${toolCallCacheKey(toolCall.name, toolCall.input)}:${normalizeToolErrorForBreaker(sanitizedResult)}`;
3935
4069
  const count = (repeatedToolErrors.get(errorKey) ?? 0) + 1;
3936
4070
  repeatedToolErrors.set(errorKey, count);
@@ -3946,9 +4080,9 @@ export async function runAgentLoop(opts) {
3946
4080
  `with different arguments. Last error: ${sanitizedResult}`;
3947
4081
  requestedActionStop ??= {
3948
4082
  message: `I stopped because the ${toolCall.name} action rejected ${anyArgsCount} different attempts the same way, ` +
3949
- "so changing the arguments again would not have worked. Anything completed before this is saved. " +
3950
- `Error: ${sanitizedResult}`,
4083
+ "so changing the arguments again would not have worked. Anything completed before this is saved.",
3951
4084
  errorCode: "repeated_tool_error_across_arguments",
4085
+ details: sanitizedResult,
3952
4086
  };
3953
4087
  return result;
3954
4088
  }
@@ -3961,15 +4095,23 @@ export async function runAgentLoop(opts) {
3961
4095
  // can do; never instruct them to "fix the arguments".
3962
4096
  requestedActionStop ??= {
3963
4097
  message: `I stopped because the ${toolCall.name} action failed ${count} times in a row the same way, ` +
3964
- "so retrying it again would not have worked. Anything completed before this is saved. " +
3965
- `Error: ${sanitizedResult}`,
4098
+ "so retrying it again would not have worked. Anything completed before this is saved.",
3966
4099
  errorCode: "repeated_identical_tool_error",
4100
+ details: sanitizedResult,
3967
4101
  };
3968
4102
  return result;
3969
4103
  };
3970
- if (sourceSweepGuard) {
3971
- sourceSweepDelegationGuardActive = true;
3972
- const result = sourceSweepGuard.message;
4104
+ /**
4105
+ * A guard refused to run the tool. Recorded as an ERROR, not as a plain
4106
+ * result: a decline costs a full model round-trip carrying the whole
4107
+ * transcript, and `noteRepeatedToolCall` keys on name+args, so a model
4108
+ * iterating ids mints a fresh key every time and declines forever until
4109
+ * `maxIterations`. Routing through `finalizeToolErrorResult` gives the
4110
+ * existing breakers something to count — no new counter — while the text
4111
+ * the model reads is still the guard's own guidance.
4112
+ */
4113
+ const declineToolCall = (guidance) => {
4114
+ const result = finalizeToolErrorResult(guidance);
3973
4115
  send({
3974
4116
  type: "tool_start",
3975
4117
  id: toolCall.id,
@@ -3982,9 +4124,10 @@ export async function runAgentLoop(opts) {
3982
4124
  tool: toolCall.name,
3983
4125
  input: toolCall.input,
3984
4126
  result,
4127
+ isError: true,
3985
4128
  completedSideEffect: false,
3986
4129
  });
3987
- recordToolResult(result, false);
4130
+ recordToolResult(result, true);
3988
4131
  return {
3989
4132
  type: "tool-result",
3990
4133
  toolCallId: toolCall.id,
@@ -3992,31 +4135,13 @@ export async function runAgentLoop(opts) {
3992
4135
  toolInput: wireToolInput,
3993
4136
  content: result,
3994
4137
  };
4138
+ };
4139
+ if (sourceSweepGuard) {
4140
+ sourceSweepDelegationGuardActive = true;
4141
+ return declineToolCall(sourceSweepGuard.message);
3995
4142
  }
3996
4143
  if (sourceSweepDelegationGuard) {
3997
- const result = sourceSweepDelegationGuard;
3998
- send({
3999
- type: "tool_start",
4000
- id: toolCall.id,
4001
- tool: toolCall.name,
4002
- input: toolCall.input,
4003
- });
4004
- send({
4005
- type: "tool_done",
4006
- id: toolCall.id,
4007
- tool: toolCall.name,
4008
- input: toolCall.input,
4009
- result,
4010
- completedSideEffect: false,
4011
- });
4012
- recordToolResult(result, false);
4013
- return {
4014
- type: "tool-result",
4015
- toolCallId: toolCall.id,
4016
- toolName: toolCall.name,
4017
- toolInput: wireToolInput,
4018
- content: result,
4019
- };
4144
+ return declineToolCall(sourceSweepDelegationGuard);
4020
4145
  }
4021
4146
  if (!actionEntry) {
4022
4147
  const result = finalizeToolErrorResult(`Error: Unknown tool "${toolCall.name}"`);
@@ -4817,7 +4942,7 @@ export async function runAgentLoop(opts) {
4817
4942
  // TypeScript can't track ??= through async closures; cast to known type.
4818
4943
  const stop = requestedActionStop;
4819
4944
  terminalActionStop = stop;
4820
- send({ type: "text", text: stop.message });
4945
+ sendTerminalActionStop(stop);
4821
4946
  break;
4822
4947
  }
4823
4948
  }
@@ -4893,6 +5018,10 @@ export async function runAgentLoop(opts) {
4893
5018
  }
4894
5019
  }
4895
5020
  }
5021
+ // Assignments happen inside async tool-loop closures, which TypeScript cannot
5022
+ // use for narrowing at this point. Snapshot the runtime value before the
5023
+ // outcome branches so a stop is still represented as a possible value.
5024
+ const finalTerminalActionStop = terminalActionStop;
4896
5025
  if (signal.aborted) {
4897
5026
  reportOutcome({ state: "canceled", message: "Agent run was aborted." });
4898
5027
  }
@@ -4904,26 +5033,26 @@ export async function runAgentLoop(opts) {
4904
5033
  message: `Agent stopped after ${maxIterations} iterations.`,
4905
5034
  });
4906
5035
  }
4907
- else if (terminalActionStop?.errorCode === "needs-approval") {
5036
+ else if (finalTerminalActionStop?.errorCode === "needs-approval") {
4908
5037
  reportOutcome({
4909
5038
  state: "input_required",
4910
5039
  code: "needs_approval",
4911
- message: terminalActionStop.message,
5040
+ message: finalTerminalActionStop.message,
4912
5041
  });
4913
5042
  }
4914
- else if (terminalActionStop?.errorCode === "awaiting-user-input") {
5043
+ else if (finalTerminalActionStop?.errorCode === "awaiting-user-input") {
4915
5044
  reportOutcome({
4916
5045
  state: "input_required",
4917
5046
  code: "awaiting_user_input",
4918
- message: terminalActionStop.message,
5047
+ message: finalTerminalActionStop.message,
4919
5048
  });
4920
5049
  }
4921
- else if (terminalActionStop) {
5050
+ else if (finalTerminalActionStop) {
4922
5051
  reportOutcome({
4923
5052
  state: "failed",
4924
- code: terminalActionStop.errorCode ?? "tool_failed",
5053
+ code: finalTerminalActionStop.errorCode ?? "tool_failed",
4925
5054
  retryable: false,
4926
- message: terminalActionStop.message,
5055
+ message: finalTerminalActionStop.message,
4927
5056
  });
4928
5057
  }
4929
5058
  else {
@@ -4944,6 +5073,13 @@ function isRecoverableContinuationError(event) {
4944
5073
  const message = event.error.toLowerCase();
4945
5074
  if (code === "builder_gateway_error")
4946
5075
  return false;
5076
+ // An explicit flag outranks the message sniff below, which exists for events
5077
+ // that carry no flag at all. Every guard stop sets `recoverable: false`, and
5078
+ // sniffing its prose is how a stop that merely NAMES a connection or a
5079
+ // timeout gets continued into the spiral it was raised to end. The client
5080
+ // applies the same precedence in `isAutoRecoverableError`.
5081
+ if (event.recoverable === false)
5082
+ return false;
4947
5083
  return (event.recoverable === true ||
4948
5084
  code === "builder_gateway_timeout" ||
4949
5085
  code === "builder_gateway_network_error" ||
@@ -5383,8 +5519,11 @@ async function emitRunText(run, text) {
5383
5519
  * per-turn journal the resume note uses (no new ledger read shape).
5384
5520
  */
5385
5521
  async function describeTurnProgress(threadId, turnId) {
5386
- const { toolCallJournal } = await loadPriorTurnToolCallJournal(threadId, turnId);
5387
- const completed = toolCallJournal?.completed ?? [];
5522
+ const journalRead = await loadPriorTurnToolCallJournal(threadId, turnId);
5523
+ if (journalRead.status === "unreadable") {
5524
+ return "I could not read the run ledger, so I cannot say which steps completed.";
5525
+ }
5526
+ const completed = journalRead.toolCallJournal?.completed ?? [];
5388
5527
  if (completed.length === 0)
5389
5528
  return "No steps had completed yet.";
5390
5529
  const names = [...new Set(completed.map((entry) => entry.tool))];
@@ -27,7 +27,7 @@ import type { ResolveRunSoftTimeoutOptions } from "./run-manager.js";
27
27
  * Rebuild the same safe continuation context for a logical turn that resumes
28
28
  * in a fresh hosted invocation.
29
29
  */
30
- export declare function appendDurableContinuationContext(messages: EngineMessage[], reason: AgentLoopContinuationReason, threadId: string): Promise<void>;
30
+ export declare function appendDurableContinuationContext(messages: EngineMessage[], reason: AgentLoopContinuationReason, threadId: string, turnId?: string): Promise<void>;
31
31
  /**
32
32
  * Cap on continuation iterations inside a single
33
33
  * `runAgentLoopDirectWithSoftTimeout` invocation. The host's hard function
@@ -24,23 +24,35 @@ import { runAgentLoop, appendAgentLoopContinuation, isResumableEngineError, isTr
24
24
  import { resolveRunSoftTimeoutMs } from "./run-manager.js";
25
25
  import { getCurrentTurnEventsForThread } from "./run-store.js";
26
26
  import { classifyToolCallJournal, buildResumeJournalNote, } from "./tool-call-journal.js";
27
- async function readCurrentTurnEventsForResume(threadId, localEvents = []) {
27
+ /**
28
+ * `persisted: false` means the durable half of the turn could not be read, so
29
+ * `events` holds only what this invocation saw. Callers that decide whether to
30
+ * DISCARD user-visible output must not read that as "nothing else happened".
31
+ */
32
+ async function readCurrentTurnEventsForResume(threadId, turnId, localEvents = []) {
28
33
  let persistedEvents = [];
34
+ let persisted = true;
29
35
  try {
36
+ // Without the turnId this can return the PREVIOUS turn's events — see
37
+ // `getCurrentTurnEventsForThread`, whose run row is written without await.
30
38
  persistedEvents = threadId
31
- ? await getCurrentTurnEventsForThread(threadId)
39
+ ? await getCurrentTurnEventsForThread(threadId, turnId)
32
40
  : [];
33
41
  }
34
- catch {
35
- persistedEvents = [];
42
+ catch (err) {
43
+ persisted = false;
44
+ console.warn("[run-loop] current-turn ledger read failed:", err instanceof Error ? err.message : String(err));
36
45
  }
37
46
  if (localEvents.length === 0)
38
- return persistedEvents;
47
+ return { events: persistedEvents, persisted };
39
48
  const seen = new Set(persistedEvents.map((event) => JSON.stringify(event)));
40
- return [
41
- ...persistedEvents,
42
- ...localEvents.filter((event) => !seen.has(JSON.stringify(event))),
43
- ];
49
+ return {
50
+ events: [
51
+ ...persistedEvents,
52
+ ...localEvents.filter((event) => !seen.has(JSON.stringify(event))),
53
+ ],
54
+ persisted,
55
+ };
44
56
  }
45
57
  function actionPreparationContinuationOptions(events) {
46
58
  const actionPreparationTool = lastUnfinishedPreparingActionToolFromEvents(events);
@@ -86,8 +98,8 @@ function appendToolCallJournalNote(messages, events) {
86
98
  // up — the model still continues, just without the structured journal.
87
99
  }
88
100
  }
89
- async function appendContinuationAndJournal(messages, reason, threadId, localEvents = []) {
90
- const events = await readCurrentTurnEventsForResume(threadId, localEvents);
101
+ async function appendContinuationAndJournal(messages, reason, threadId, turnId, localEvents = []) {
102
+ const { events } = await readCurrentTurnEventsForResume(threadId, turnId, localEvents);
91
103
  appendAgentLoopContinuation(messages, reason, actionPreparationContinuationOptions(events));
92
104
  appendToolCallJournalNote(messages, events);
93
105
  }
@@ -95,21 +107,23 @@ async function appendContinuationAndJournal(messages, reason, threadId, localEve
95
107
  * Rebuild the same safe continuation context for a logical turn that resumes
96
108
  * in a fresh hosted invocation.
97
109
  */
98
- export async function appendDurableContinuationContext(messages, reason, threadId) {
99
- await appendContinuationAndJournal(messages, reason, threadId);
110
+ export async function appendDurableContinuationContext(messages, reason, threadId, turnId) {
111
+ await appendContinuationAndJournal(messages, reason, threadId, turnId);
100
112
  }
101
- async function hasCompletedSideEffectToolCallInCurrentTurn(threadId, localEvents = []) {
102
- try {
103
- const events = await readCurrentTurnEventsForResume(threadId, localEvents);
104
- if (events.length === 0)
105
- return false;
106
- return events.some((event) => event.type === "tool_done" &&
107
- event.completedSideEffect === true &&
108
- event.isError !== true);
109
- }
110
- catch {
111
- return false;
112
- }
113
+ /**
114
+ * `"unknown"` is not `"none"`: the only caller uses this to decide whether to
115
+ * emit `clear`, and `clear` WIPES user-visible output. A transient ledger error
116
+ * must not delete the tool cards that are the user's only proof a side effect
117
+ * landed.
118
+ */
119
+ async function completedSideEffectInCurrentTurn(threadId, turnId, localEvents = []) {
120
+ const { events, persisted } = await readCurrentTurnEventsForResume(threadId, turnId, localEvents);
121
+ const found = events.some((event) => event.type === "tool_done" &&
122
+ event.completedSideEffect === true &&
123
+ event.isError !== true);
124
+ if (found)
125
+ return "some";
126
+ return persisted ? "none" : "unknown";
113
127
  }
114
128
  function internalContinuationReasonForAttempt(events) {
115
129
  const last = events.at(-1);
@@ -362,15 +376,15 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
362
376
  if (internalContinuationReason && !upstreamSignal.aborted) {
363
377
  lastAttemptWasUnfinishedContinuation = true;
364
378
  const continuationEvents = [...localTurnEvents];
365
- if (!(await hasCompletedSideEffectToolCallInCurrentTurn(opts.threadId, continuationEvents))) {
379
+ if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, continuationEvents)) === "none") {
366
380
  opts.send({ type: "clear" });
367
381
  }
368
- await appendContinuationAndJournal(opts.messages, internalContinuationReason, opts.threadId, continuationEvents);
382
+ await appendContinuationAndJournal(opts.messages, internalContinuationReason, opts.threadId, opts.turnId, continuationEvents);
369
383
  continue;
370
384
  }
371
385
  if (softTimedOut && !upstreamSignal.aborted) {
372
386
  lastAttemptWasUnfinishedContinuation = true;
373
- await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, localTurnEvents);
387
+ await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, opts.turnId, localTurnEvents);
374
388
  continue;
375
389
  }
376
390
  reportFinalOutcome(upstreamSignal.aborted
@@ -383,10 +397,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
383
397
  // Clear partial text the client received before the abort so the
384
398
  // resumed model doesn't re-emit it and produce duplicated output.
385
399
  lastAttemptWasUnfinishedContinuation = true;
386
- if (!(await hasCompletedSideEffectToolCallInCurrentTurn(opts.threadId, localTurnEvents))) {
400
+ if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
387
401
  opts.send({ type: "clear" });
388
402
  }
389
- await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, localTurnEvents);
403
+ await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, opts.turnId, localTurnEvents);
390
404
  continue;
391
405
  }
392
406
  const transientRateLimit = isTransientProviderRateLimitError(err);
@@ -402,10 +416,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
402
416
  rateLimitRetryFitsBudget) {
403
417
  lastAttemptWasUnfinishedContinuation = true;
404
418
  backgroundRateLimitContinuations++;
405
- if (!(await hasCompletedSideEffectToolCallInCurrentTurn(opts.threadId, localTurnEvents))) {
419
+ if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
406
420
  opts.send({ type: "clear" });
407
421
  }
408
- await appendContinuationAndJournal(opts.messages, "rate_limited", opts.threadId, localTurnEvents);
422
+ await appendContinuationAndJournal(opts.messages, "rate_limited", opts.threadId, opts.turnId, localTurnEvents);
409
423
  await waitForBackgroundRateLimitCooldown(upstreamSignal);
410
424
  continue;
411
425
  }
@@ -424,10 +438,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
424
438
  // the in-memory messages array, so the next attempt re-emits it).
425
439
  if (!upstreamSignal.aborted && isResumableEngineError(err)) {
426
440
  lastAttemptWasUnfinishedContinuation = true;
427
- if (!(await hasCompletedSideEffectToolCallInCurrentTurn(opts.threadId, localTurnEvents))) {
441
+ if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
428
442
  opts.send({ type: "clear" });
429
443
  }
430
- await appendContinuationAndJournal(opts.messages, continuationReasonForResumableError(err), opts.threadId, localTurnEvents);
444
+ await appendContinuationAndJournal(opts.messages, continuationReasonForResumableError(err), opts.threadId, opts.turnId, localTurnEvents);
431
445
  continue;
432
446
  }
433
447
  if (upstreamSignal.aborted) {
@@ -467,7 +481,7 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
467
481
  // the terminal message stands alone instead of trailing a half sentence.
468
482
  // Preserve completed tool cards: they are the user's only durable proof
469
483
  // that a side effect landed before the final assistant note timed out.
470
- if (!(await hasCompletedSideEffectToolCallInCurrentTurn(opts.threadId, localTurnEvents))) {
484
+ if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
471
485
  opts.send({ type: "clear" });
472
486
  }
473
487
  opts.send({