@agent-native/core 0.153.2 → 0.153.5

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 (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/AGENTS.md +4 -0
  3. package/corpus/templates/analytics/actions/account-deep-dive.ts +1 -0
  4. package/corpus/templates/analytics/actions/bigquery-query.ts +1 -0
  5. package/corpus/templates/analytics/actions/bigquery.ts +1 -0
  6. package/corpus/templates/analytics/actions/content-calendar-schema.ts +1 -0
  7. package/corpus/templates/analytics/actions/content-calendar.ts +1 -0
  8. package/corpus/templates/analytics/actions/gcloud.ts +1 -0
  9. package/corpus/templates/analytics/actions/get-data-program.ts +1 -0
  10. package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
  11. package/corpus/templates/analytics/actions/get-monitor-stats.ts +1 -0
  12. package/corpus/templates/analytics/actions/get-monitor.ts +1 -0
  13. package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
  14. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
  15. package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +1 -0
  16. package/corpus/templates/analytics/actions/gong-calls.ts +1 -0
  17. package/corpus/templates/analytics/actions/gong-native-insights.ts +1 -0
  18. package/corpus/templates/analytics/actions/grafana.ts +1 -0
  19. package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
  20. package/corpus/templates/analytics/actions/hubspot-metrics.ts +1 -0
  21. package/corpus/templates/analytics/actions/hubspot-pipelines.ts +1 -0
  22. package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
  23. package/corpus/templates/analytics/actions/jira-search.ts +1 -0
  24. package/corpus/templates/analytics/actions/jira.ts +1 -0
  25. package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
  26. package/corpus/templates/analytics/actions/list-monitors.ts +1 -0
  27. package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
  28. package/corpus/templates/analytics/actions/match-error-issues.ts +1 -0
  29. package/corpus/templates/analytics/actions/prometheus.ts +1 -0
  30. package/corpus/templates/analytics/actions/pylon-issues.ts +1 -0
  31. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -0
  32. package/corpus/templates/analytics/actions/query-dashboard-panel.ts +1 -0
  33. package/corpus/templates/analytics/actions/query-inbound-forms.ts +1 -0
  34. package/corpus/templates/analytics/actions/run-monitor-check.ts +1 -0
  35. package/corpus/templates/analytics/actions/sentry.ts +1 -0
  36. package/corpus/templates/analytics/actions/seo-blog-pages.ts +1 -0
  37. package/corpus/templates/analytics/actions/seo-page-keywords.ts +1 -0
  38. package/corpus/templates/analytics/actions/seo-top-keywords.ts +1 -0
  39. package/corpus/templates/analytics/actions/slack-messages.ts +1 -0
  40. package/corpus/templates/analytics/actions/stripe.ts +1 -0
  41. package/corpus/templates/analytics/actions/test-custom-api-connection.ts +1 -0
  42. package/corpus/templates/analytics/server/lib/real-data-actions.ts +38 -47
  43. package/corpus/templates/analytics/server/plugins/agent-chat.ts +41 -34
  44. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +9 -6
  45. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +70 -10
  46. package/corpus/templates/forms/AGENTS.md +6 -0
  47. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -0
  48. package/corpus/templates/mail/AGENTS.md +5 -4
  49. package/corpus/templates/mail/actions/get-automation-settings.ts +17 -6
  50. package/corpus/templates/mail/actions/send-email.ts +17 -9
  51. package/corpus/templates/mail/actions/send-queued-drafts.ts +21 -11
  52. package/corpus/templates/mail/actions/update-automation-settings.ts +20 -6
  53. package/corpus/templates/mail/app/i18n/en-US.ts +5 -0
  54. package/corpus/templates/mail/app/pages/SettingsPage.tsx +68 -7
  55. package/corpus/templates/mail/server/lib/automation-settings.ts +27 -0
  56. package/corpus/templates/mail/server/plugins/agent-chat.ts +2 -0
  57. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +3 -3
  58. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +17 -4
  59. package/corpus/templates/slides/app/context/DeckContext.tsx +3 -2
  60. package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -0
  61. package/corpus/templates/slides/changelog/2026-08-12-arrow-key-navigation-in-the-slide-film-strip-now-scrolls-sli.md +6 -0
  62. package/corpus/templates/slides/changelog/2026-08-12-duplicate-a-slide-from-the-film-strip-with-cmd-ctrl-c-then-c.md +6 -0
  63. package/corpus/templates/slides/changelog/2026-08-12-presenting-a-deck-now-returns-you-to-the-slide-you-were-view.md +6 -0
  64. package/dist/action.d.ts +13 -0
  65. package/dist/action.js +3 -0
  66. package/dist/agent/engine/tool-call-journal-seed.d.ts +27 -10
  67. package/dist/agent/engine/tool-call-journal-seed.js +46 -32
  68. package/dist/agent/production-agent.d.ts +33 -0
  69. package/dist/agent/production-agent.js +172 -57
  70. package/dist/agent/run-loop-with-resume.d.ts +1 -1
  71. package/dist/agent/run-loop-with-resume.js +49 -35
  72. package/dist/client/agent-chat-adapter.js +204 -273
  73. package/dist/client/resources/mcp-integration-catalog.d.ts +7 -0
  74. package/dist/client/resources/mcp-integration-catalog.js +14 -1
  75. package/dist/client/settings/AgentsSection.js +46 -30
  76. package/dist/client/sse-event-processor.d.ts +7 -0
  77. package/dist/client/sse-event-processor.js +21 -1
  78. package/dist/collab/awareness.d.ts +2 -2
  79. package/dist/collab/routes.d.ts +1 -1
  80. package/dist/collab/struct-routes.d.ts +1 -1
  81. package/dist/notifications/routes.d.ts +1 -1
  82. package/dist/observability/routes.d.ts +3 -3
  83. package/dist/provider-api/actions/github-repo-files.js +1 -0
  84. package/dist/provider-api/actions/provider-api.js +1 -0
  85. package/dist/provider-api/actions/staged-datasets.js +1 -0
  86. package/dist/provider-api/corpus-jobs.js +1 -0
  87. package/dist/server/action-discovery.js +2 -0
  88. package/dist/server/agent-chat-plugin.js +1 -0
  89. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  90. package/dist/server/realtime-token.d.ts +1 -1
  91. package/dist/server/transcribe-voice.d.ts +1 -1
  92. package/package.json +3 -3
@@ -2439,6 +2439,11 @@ 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
+ * Numbers go the same way, for the same reason. A guard that reports its own
2444
+ * running tally ("this turn already made 13 call(s)") hands the breaker a new
2445
+ * key on every single decline, so the decline that exists to stop a spiral is
2446
+ * itself uncountable.
2442
2447
  */
2443
2448
  function normalizeToolErrorForBreaker(error) {
2444
2449
  return (error
@@ -2446,6 +2451,7 @@ function normalizeToolErrorForBreaker(error) {
2446
2451
  .replace(/Received:\s*[\s\S]*?\.\s(?=Expected:|The tool was not executed)/g, "")
2447
2452
  // Bare JSON payloads some providers inline instead of a Received: span.
2448
2453
  .replace(/\{[\s\S]{0,2000}?\}/g, "{}")
2454
+ .replace(/\d+/g, "#")
2449
2455
  .replace(/\s+/g, " ")
2450
2456
  .trim());
2451
2457
  }
@@ -2455,6 +2461,50 @@ function rateLimitRecoveryHint(message) {
2455
2461
  }
2456
2462
  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
2463
  }
2464
+ /**
2465
+ * Tool errors the model has no way to clear: the missing thing lives outside
2466
+ * the turn (a credential, a role grant, a connected account, a runtime, the
2467
+ * user's own approval). Every retry costs a full round-trip carrying the whole
2468
+ * transcript and lands on the identical error, so these stop on the FIRST
2469
+ * occurrence rather than after `MAX_SAME_ERROR_ACROSS_ARGUMENTS` of them.
2470
+ *
2471
+ * The distinction is the one `stopForBigQueryNotConfigured` draws in
2472
+ * templates/analytics: a *configuration* gap is permanent for this turn, a
2473
+ * *query* failure is not. Anything the model can act on — a stale read to
2474
+ * rebase, a bad argument, a full quota it can free, a wrong dataset name, a
2475
+ * statement timeout — must stay out of this list and keep its retries.
2476
+ * Precision over recall: a wrong match kills a turn that would have succeeded,
2477
+ * which is worse than one more retry.
2478
+ */
2479
+ export function permanentPreconditionRemedy(message) {
2480
+ const trimmed = message.replace(/\s+/g, " ").trim();
2481
+ for (const pattern of PERMANENT_PRECONDITION_PATTERNS) {
2482
+ if (pattern.test(trimmed))
2483
+ return trimmed;
2484
+ }
2485
+ return null;
2486
+ }
2487
+ const PERMANENT_PRECONDITION_PATTERNS = [
2488
+ // "Requires editor role on deck ZJshjrXhjx (have viewer)". The trailing space
2489
+ // is load-bearing: it keeps "must have required property" out.
2490
+ /\brequires? (?:an? |the )?[\w-]+ role\b/i,
2491
+ // "Gemini API key not configured. Save GEMINI_API_KEY in settings." Only the
2492
+ // not-configured/not-set wordings — "not found" is how providers report a
2493
+ // missing dataset or record, which the model can and should correct.
2494
+ /\b(?:api[ -]?keys?|access tokens?|credentials?|secrets?)\b[^.]{0,60}\bnot (?:configured|set|connected|available)\b/i,
2495
+ /\bsave [A-Z][A-Z0-9_]{3,} in (?:the )?settings\b/i,
2496
+ // "Connect Builder.io before indexing a design system from Figma or code."
2497
+ /\bconnect\b[^;]{1,40}?\b(?:before|first|in settings)\b/i,
2498
+ // "…is only available in local Plan runtime." Excludes "only available after
2499
+ // …", which names a condition that clears on its own.
2500
+ /\bonly available (?:in|on|from|to)\b/i,
2501
+ // "Plan mode blocked `update-extension`. Switch to Act mode …" — cleared by
2502
+ // the user approving the plan, never by the model trying again.
2503
+ /\bplan mode blocked\b/i,
2504
+ /\bno authenticated user\b/i,
2505
+ // "SSRF blocked: refusing to fetch private/internal address (…)"
2506
+ /\bssrf blocked\b/i,
2507
+ ];
2458
2508
  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
2509
  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
2510
  const SOURCE_SWEEP_EXCLUDED_TOOLS = new Set([
@@ -3095,29 +3145,50 @@ export async function runAgentLoop(opts) {
3095
3145
  // calls/results are folded into `toolCallHistory` / `toolResultHistory` so
3096
3146
  // final response guards see evidence from earlier chunks of this turn.
3097
3147
  const consumedJournalKeys = new Set();
3098
- const { toolCallJournal, priorToolCalls: journaledPriorToolCalls, priorToolResults: journaledPriorToolResults, } = await loadPriorTurnToolCallJournal(opts.threadId, opts.turnId);
3148
+ const journalRead = await loadPriorTurnToolCallJournal(opts.threadId, opts.turnId);
3149
+ const toolCallJournal = journalRead.status === "read" ? journalRead.toolCallJournal : null;
3150
+ const journaledPriorToolCalls = journalRead.status === "read" ? journalRead.priorToolCalls : [];
3151
+ const journaledPriorToolResults = journalRead.status === "read" ? journalRead.priorToolResults : [];
3099
3152
  toolCallHistory.push(...journaledPriorToolCalls);
3100
3153
  toolResultHistory.push(...journaledPriorToolResults);
3154
+ // Every per-turn ceiling below is seeded from that read, and the write-replay
3155
+ // hard block is enforced from it. On a continuation we cannot tell an
3156
+ // already-completed side effect from a fresh one without it, so a failed read
3157
+ // stops the turn instead of quietly resuming with a blank ledger.
3158
+ const unreadableJournalStop = journalRead.status === "unreadable" && isInternalContinuationTurn(messages)
3159
+ ? {
3160
+ message: "I stopped because I could not read this turn's run ledger, so I could not tell which steps had already finished. " +
3161
+ "Retrying would risk repeating a step that already completed. Please send the request again.",
3162
+ errorCode: "tool_call_journal_unreadable",
3163
+ details: journalRead.error,
3164
+ }
3165
+ : null;
3101
3166
  const readOnlyToolResultCache = seedReadOnlyToolResultsFromHistory(messages, actions, seedReadOnlyToolResultsFromJournal(toolCallJournal, actions));
3102
3167
  const duplicateReadOnlyToolCalls = seedDuplicateReadOnlyToolCallsFromHistory(messages, actions);
3103
3168
  const writeToolInterruptions = seedWriteToolInterruptionsFromHistory(messages, actions);
3169
+ // All three repetition ledgers are SEEDED from earlier chunks of this turn,
3170
+ // like `toolCallHistory` and `toolResultHistory` above. A fresh Map makes the
3171
+ // limit per-CHUNK rather than per-turn, and a turn may chain up to
3172
+ // MAX_RUN_LOOP_CONTINUATIONS (6) or MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS
3173
+ // (20) of them — so the real ceiling becomes 20x the constant, and a spiral
3174
+ // resumes with a clean slate at every chunk boundary.
3104
3175
  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.
3176
+ /** Keyed WITHOUT the arguments — see MAX_SAME_ERROR_ACROSS_ARGUMENTS. */
3113
3177
  const repeatedToolErrorsAnyArgs = new Map();
3114
- for (const prior of toolResultHistory) {
3178
+ const repeatedToolCalls = new Map();
3179
+ for (const prior of journaledPriorToolCalls) {
3180
+ const key = toolCallCacheKey(prior.name, prior.input);
3181
+ repeatedToolCalls.set(key, (repeatedToolCalls.get(key) ?? 0) + 1);
3182
+ }
3183
+ for (const prior of journaledPriorToolResults) {
3115
3184
  if (!prior.isError)
3116
3185
  continue;
3117
- const key = `${prior.name}:${normalizeToolErrorForBreaker(prior.content)}`;
3118
- repeatedToolErrorsAnyArgs.set(key, (repeatedToolErrorsAnyArgs.get(key) ?? 0) + 1);
3186
+ const normalized = normalizeToolErrorForBreaker(prior.content);
3187
+ const anyArgsKey = `${prior.name}:${normalized}`;
3188
+ repeatedToolErrorsAnyArgs.set(anyArgsKey, (repeatedToolErrorsAnyArgs.get(anyArgsKey) ?? 0) + 1);
3189
+ const errorKey = `${toolCallCacheKey(prior.name, prior.input)}:${normalized}`;
3190
+ repeatedToolErrors.set(errorKey, (repeatedToolErrors.get(errorKey) ?? 0) + 1);
3119
3191
  }
3120
- const repeatedToolCalls = new Map();
3121
3192
  let finalGuardRetries = 0;
3122
3193
  let emptyFinalResponseRetries = 0;
3123
3194
  let iterations = 0;
@@ -3125,6 +3196,31 @@ export async function runAgentLoop(opts) {
3125
3196
  // trailing `done` would overwrite the boundary the continuation logic reads.
3126
3197
  let endedAtLoopLimit = false;
3127
3198
  let terminalActionStop = null;
3199
+ /**
3200
+ * A stop is either a hand-off to the user (the two `input_required` codes) or
3201
+ * a failed run. Failed runs MUST leave a terminal `error` event behind: the
3202
+ * run row's status, `error_code`, `listErroredRuns`, the outcome counters and
3203
+ * the client's retry affordance all key off that event, and a stop that only
3204
+ * streams text is recorded as `status='completed', terminal_reason='done'` —
3205
+ * a guard trip counted as a success.
3206
+ */
3207
+ const sendTerminalActionStop = (stop) => {
3208
+ if (stop.errorCode === "needs-approval" ||
3209
+ stop.errorCode === "awaiting-user-input") {
3210
+ send({ type: "text", text: stop.message });
3211
+ return;
3212
+ }
3213
+ // Sent ONLY as an error: both the live client and the thread rebuild render
3214
+ // an error event's message as assistant text themselves, so also sending it
3215
+ // as `text` prints the same sentence twice.
3216
+ send({
3217
+ type: "error",
3218
+ error: stop.message,
3219
+ errorCode: stop.errorCode ?? "tool_failed",
3220
+ ...(stop.details ? { details: stop.details } : {}),
3221
+ recoverable: false,
3222
+ });
3223
+ };
3128
3224
  // Overridden (raised tokens, lowered effort) only after an empty-final-
3129
3225
  // response retry below — kept separate from `opts.maxOutputTokens`/
3130
3226
  // `opts.reasoningEffort` so the very first attempt is unaffected and later
@@ -3151,6 +3247,11 @@ export async function runAgentLoop(opts) {
3151
3247
  while (true) {
3152
3248
  if (signal.aborted)
3153
3249
  break;
3250
+ if (unreadableJournalStop) {
3251
+ terminalActionStop = unreadableJournalStop;
3252
+ sendTerminalActionStop(unreadableJournalStop);
3253
+ break;
3254
+ }
3154
3255
  const turnInputTokens = priorTurnInputTokens + usage.inputTokens;
3155
3256
  if (turnInputTokens > maxRunInputTokens) {
3156
3257
  // Terminal for the whole TURN, not a chunk boundary: emitting
@@ -3882,9 +3983,6 @@ export async function runAgentLoop(opts) {
3882
3983
  // call run. The model cannot populate this — it comes from the request.
3883
3984
  const approvedToolCallKeys = new Set(opts.approvedToolCalls ?? []);
3884
3985
  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
3986
  const actionEntry = actions[toolCall.name];
3889
3987
  const placeholderNormalization = actionEntry
3890
3988
  ? normalizeOptionalToolPlaceholders(actionEntry.tool.parameters, toolCall.input)
@@ -3898,6 +3996,11 @@ export async function runAgentLoop(opts) {
3898
3996
  if (jsonStringCoercion.changed) {
3899
3997
  toolCall = { ...toolCall, input: jsonStringCoercion.input };
3900
3998
  }
3999
+ // Count after the same normalization that is persisted in the journal:
4000
+ // a model can send a JSON-encoded object/array, while the action and
4001
+ // ledger both see the coerced value. Serving a repeat from cache still
4002
+ // counts as asking the same question again.
4003
+ noteRepeatedToolCall(toolCall.name, toolCall.input);
3901
4004
  const toolInputNormalized = placeholderNormalization.changed || jsonStringCoercion.changed;
3902
4005
  const wireToolInput = JSON.stringify(toolCall.input ?? {});
3903
4006
  const normalizedToolInput = normalizeToolCallInputForHistory(toolCall.input);
@@ -3931,6 +4034,20 @@ export async function runAgentLoop(opts) {
3931
4034
  };
3932
4035
  const finalizeToolErrorResult = (rawResult) => {
3933
4036
  const sanitizedResult = sanitizeToolErrorText(rawResult);
4037
+ // Counting is the wrong instrument for a precondition the turn cannot
4038
+ // satisfy: six identical round-trips through a missing API key cost the
4039
+ // user minutes and end where the first one did. Classified first so the
4040
+ // remedy reaches them on attempt one.
4041
+ const permanentRemedy = permanentPreconditionRemedy(sanitizedResult);
4042
+ if (permanentRemedy) {
4043
+ requestedActionStop ??= {
4044
+ message: `I stopped because ${toolCall.name} cannot run until this is fixed: ${permanentRemedy} Retrying would not have changed it, and anything completed before this is saved.`,
4045
+ errorCode: "permanent_precondition",
4046
+ details: sanitizedResult,
4047
+ };
4048
+ return (`Stopped: ${toolCall.name} cannot run until a setup step outside this turn is fixed. ` +
4049
+ `Do not retry it with different arguments. ${sanitizedResult}`);
4050
+ }
3934
4051
  const errorKey = `${toolCallCacheKey(toolCall.name, toolCall.input)}:${normalizeToolErrorForBreaker(sanitizedResult)}`;
3935
4052
  const count = (repeatedToolErrors.get(errorKey) ?? 0) + 1;
3936
4053
  repeatedToolErrors.set(errorKey, count);
@@ -3946,9 +4063,9 @@ export async function runAgentLoop(opts) {
3946
4063
  `with different arguments. Last error: ${sanitizedResult}`;
3947
4064
  requestedActionStop ??= {
3948
4065
  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}`,
4066
+ "so changing the arguments again would not have worked. Anything completed before this is saved.",
3951
4067
  errorCode: "repeated_tool_error_across_arguments",
4068
+ details: sanitizedResult,
3952
4069
  };
3953
4070
  return result;
3954
4071
  }
@@ -3961,15 +4078,23 @@ export async function runAgentLoop(opts) {
3961
4078
  // can do; never instruct them to "fix the arguments".
3962
4079
  requestedActionStop ??= {
3963
4080
  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}`,
4081
+ "so retrying it again would not have worked. Anything completed before this is saved.",
3966
4082
  errorCode: "repeated_identical_tool_error",
4083
+ details: sanitizedResult,
3967
4084
  };
3968
4085
  return result;
3969
4086
  };
3970
- if (sourceSweepGuard) {
3971
- sourceSweepDelegationGuardActive = true;
3972
- const result = sourceSweepGuard.message;
4087
+ /**
4088
+ * A guard refused to run the tool. Recorded as an ERROR, not as a plain
4089
+ * result: a decline costs a full model round-trip carrying the whole
4090
+ * transcript, and `noteRepeatedToolCall` keys on name+args, so a model
4091
+ * iterating ids mints a fresh key every time and declines forever until
4092
+ * `maxIterations`. Routing through `finalizeToolErrorResult` gives the
4093
+ * existing breakers something to count — no new counter — while the text
4094
+ * the model reads is still the guard's own guidance.
4095
+ */
4096
+ const declineToolCall = (guidance) => {
4097
+ const result = finalizeToolErrorResult(guidance);
3973
4098
  send({
3974
4099
  type: "tool_start",
3975
4100
  id: toolCall.id,
@@ -3982,9 +4107,10 @@ export async function runAgentLoop(opts) {
3982
4107
  tool: toolCall.name,
3983
4108
  input: toolCall.input,
3984
4109
  result,
4110
+ isError: true,
3985
4111
  completedSideEffect: false,
3986
4112
  });
3987
- recordToolResult(result, false);
4113
+ recordToolResult(result, true);
3988
4114
  return {
3989
4115
  type: "tool-result",
3990
4116
  toolCallId: toolCall.id,
@@ -3992,31 +4118,13 @@ export async function runAgentLoop(opts) {
3992
4118
  toolInput: wireToolInput,
3993
4119
  content: result,
3994
4120
  };
4121
+ };
4122
+ if (sourceSweepGuard) {
4123
+ sourceSweepDelegationGuardActive = true;
4124
+ return declineToolCall(sourceSweepGuard.message);
3995
4125
  }
3996
4126
  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
- };
4127
+ return declineToolCall(sourceSweepDelegationGuard);
4020
4128
  }
4021
4129
  if (!actionEntry) {
4022
4130
  const result = finalizeToolErrorResult(`Error: Unknown tool "${toolCall.name}"`);
@@ -4817,7 +4925,7 @@ export async function runAgentLoop(opts) {
4817
4925
  // TypeScript can't track ??= through async closures; cast to known type.
4818
4926
  const stop = requestedActionStop;
4819
4927
  terminalActionStop = stop;
4820
- send({ type: "text", text: stop.message });
4928
+ sendTerminalActionStop(stop);
4821
4929
  break;
4822
4930
  }
4823
4931
  }
@@ -4893,6 +5001,10 @@ export async function runAgentLoop(opts) {
4893
5001
  }
4894
5002
  }
4895
5003
  }
5004
+ // Assignments happen inside async tool-loop closures, which TypeScript cannot
5005
+ // use for narrowing at this point. Snapshot the runtime value before the
5006
+ // outcome branches so a stop is still represented as a possible value.
5007
+ const finalTerminalActionStop = terminalActionStop;
4896
5008
  if (signal.aborted) {
4897
5009
  reportOutcome({ state: "canceled", message: "Agent run was aborted." });
4898
5010
  }
@@ -4904,26 +5016,26 @@ export async function runAgentLoop(opts) {
4904
5016
  message: `Agent stopped after ${maxIterations} iterations.`,
4905
5017
  });
4906
5018
  }
4907
- else if (terminalActionStop?.errorCode === "needs-approval") {
5019
+ else if (finalTerminalActionStop?.errorCode === "needs-approval") {
4908
5020
  reportOutcome({
4909
5021
  state: "input_required",
4910
5022
  code: "needs_approval",
4911
- message: terminalActionStop.message,
5023
+ message: finalTerminalActionStop.message,
4912
5024
  });
4913
5025
  }
4914
- else if (terminalActionStop?.errorCode === "awaiting-user-input") {
5026
+ else if (finalTerminalActionStop?.errorCode === "awaiting-user-input") {
4915
5027
  reportOutcome({
4916
5028
  state: "input_required",
4917
5029
  code: "awaiting_user_input",
4918
- message: terminalActionStop.message,
5030
+ message: finalTerminalActionStop.message,
4919
5031
  });
4920
5032
  }
4921
- else if (terminalActionStop) {
5033
+ else if (finalTerminalActionStop) {
4922
5034
  reportOutcome({
4923
5035
  state: "failed",
4924
- code: terminalActionStop.errorCode ?? "tool_failed",
5036
+ code: finalTerminalActionStop.errorCode ?? "tool_failed",
4925
5037
  retryable: false,
4926
- message: terminalActionStop.message,
5038
+ message: finalTerminalActionStop.message,
4927
5039
  });
4928
5040
  }
4929
5041
  else {
@@ -5383,8 +5495,11 @@ async function emitRunText(run, text) {
5383
5495
  * per-turn journal the resume note uses (no new ledger read shape).
5384
5496
  */
5385
5497
  async function describeTurnProgress(threadId, turnId) {
5386
- const { toolCallJournal } = await loadPriorTurnToolCallJournal(threadId, turnId);
5387
- const completed = toolCallJournal?.completed ?? [];
5498
+ const journalRead = await loadPriorTurnToolCallJournal(threadId, turnId);
5499
+ if (journalRead.status === "unreadable") {
5500
+ return "I could not read the run ledger, so I cannot say which steps completed.";
5501
+ }
5502
+ const completed = journalRead.toolCallJournal?.completed ?? [];
5388
5503
  if (completed.length === 0)
5389
5504
  return "No steps had completed yet.";
5390
5505
  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({