@agent-native/core 0.101.3 → 0.101.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 (79) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/action.ts +15 -0
  5. package/corpus/core/src/agent/production-agent.ts +215 -19
  6. package/corpus/core/src/client/RunStuckBanner.tsx +12 -5
  7. package/corpus/core/src/client/sse-event-processor.ts +36 -1
  8. package/corpus/core/src/client/use-run-stuck-detection.ts +162 -25
  9. package/corpus/core/src/coding-tools/run-code.ts +11 -5
  10. package/corpus/core/src/deploy/build.ts +78 -25
  11. package/corpus/core/src/extensions/actions.ts +15 -0
  12. package/corpus/core/src/extensions/url-safety.ts +11 -1
  13. package/corpus/core/src/notifications/channels.ts +61 -30
  14. package/corpus/core/src/server/action-discovery.ts +4 -1
  15. package/corpus/core/src/server/agent-chat/context-tools.ts +4 -0
  16. package/corpus/templates/chat/changelog/2026-07-14-chat-opens-reliably-on-hosted-deployments-instead-of-failing.md +6 -0
  17. package/corpus/templates/clips/app/components/player/media-duration.ts +24 -0
  18. package/corpus/templates/clips/app/components/player/video-player.tsx +9 -18
  19. package/corpus/templates/clips/changelog/2026-07-14-paused-time-no-longer-counts-toward-chrome-extension-recordi.md +6 -0
  20. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -4
  21. package/corpus/templates/clips/chrome-extension/src/recording-duration.ts +32 -0
  22. package/corpus/templates/slides/actions/_uploaded-files.ts +13 -2
  23. package/corpus/templates/slides/actions/import-docx.ts +3 -9
  24. package/corpus/templates/slides/actions/import-file.ts +11 -14
  25. package/corpus/templates/slides/actions/import-pptx.ts +3 -9
  26. package/corpus/templates/slides/changelog/2026-07-14-powerpoint-template-uploads-now-work-in-hosted-slides-deploy.md +6 -0
  27. package/corpus/templates/slides/server/handlers/uploads.ts +100 -50
  28. package/corpus/templates/slides/server/lib/tenant-files.ts +38 -11
  29. package/corpus/templates/slides/server/lib/uploaded-reference-storage.ts +108 -0
  30. package/dist/action.d.ts +12 -0
  31. package/dist/action.d.ts.map +1 -1
  32. package/dist/action.js +2 -0
  33. package/dist/action.js.map +1 -1
  34. package/dist/agent/production-agent.d.ts +24 -0
  35. package/dist/agent/production-agent.d.ts.map +1 -1
  36. package/dist/agent/production-agent.js +175 -18
  37. package/dist/agent/production-agent.js.map +1 -1
  38. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  39. package/dist/client/RunStuckBanner.js +9 -5
  40. package/dist/client/RunStuckBanner.js.map +1 -1
  41. package/dist/client/sse-event-processor.d.ts.map +1 -1
  42. package/dist/client/sse-event-processor.js +33 -1
  43. package/dist/client/sse-event-processor.js.map +1 -1
  44. package/dist/client/use-run-stuck-detection.d.ts +4 -4
  45. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  46. package/dist/client/use-run-stuck-detection.js +104 -19
  47. package/dist/client/use-run-stuck-detection.js.map +1 -1
  48. package/dist/coding-tools/run-code.d.ts +5 -2
  49. package/dist/coding-tools/run-code.d.ts.map +1 -1
  50. package/dist/coding-tools/run-code.js +11 -5
  51. package/dist/coding-tools/run-code.js.map +1 -1
  52. package/dist/collab/awareness.d.ts +2 -2
  53. package/dist/collab/awareness.d.ts.map +1 -1
  54. package/dist/collab/struct-routes.d.ts +1 -1
  55. package/dist/deploy/build.d.ts +15 -14
  56. package/dist/deploy/build.d.ts.map +1 -1
  57. package/dist/deploy/build.js +64 -23
  58. package/dist/deploy/build.js.map +1 -1
  59. package/dist/extensions/actions.d.ts.map +1 -1
  60. package/dist/extensions/actions.js +14 -0
  61. package/dist/extensions/actions.js.map +1 -1
  62. package/dist/extensions/url-safety.d.ts +6 -0
  63. package/dist/extensions/url-safety.d.ts.map +1 -1
  64. package/dist/extensions/url-safety.js +6 -0
  65. package/dist/extensions/url-safety.js.map +1 -1
  66. package/dist/notifications/channels.d.ts.map +1 -1
  67. package/dist/notifications/channels.js +38 -22
  68. package/dist/notifications/channels.js.map +1 -1
  69. package/dist/notifications/routes.d.ts +3 -3
  70. package/dist/observability/routes.d.ts +1 -1
  71. package/dist/progress/routes.d.ts +1 -1
  72. package/dist/resources/handlers.d.ts +3 -3
  73. package/dist/server/action-discovery.d.ts.map +1 -1
  74. package/dist/server/action-discovery.js +4 -1
  75. package/dist/server/action-discovery.js.map +1 -1
  76. package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
  77. package/dist/server/agent-chat/context-tools.js +4 -0
  78. package/dist/server/agent-chat/context-tools.js.map +1 -1
  79. package/package.json +3 -3
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2258
31
- - template files: 5622
31
+ - template files: 5628
@@ -1,5 +1,19 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.101.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 9dd88f4: Opt the dedicated `get-code-execution` and `refresh-screen` volatile reads out of the duplicate read-only tool-call guard via the new `dedupe: false` action option while retaining default duplicate protection for normal `run-code` executions. Also raise `get-extension` and `get-extension-history-version` result caps to 500,000 and 2,000,000 characters respectively so JSON serialization overhead cannot slice mid-content and corrupt source reads for large extensions or their history.
8
+ - 9dd88f4: Prevent repeated read-only tool loops while preserving trimmed results, allow volatile reads to opt out of deduping, and enforce notification webhook allowlists at the scope that supplied each secret.
9
+
10
+ ## 0.101.4
11
+
12
+ ### Patch Changes
13
+
14
+ - 7d72df1: Keep chat responsive while replaying dense agent event bursts, and surface safe cancellation when a live background run has gone quiet.
15
+ - 7d72df1: Bundle one complete Yjs runtime into serverless deploy output so SSR starts without missing named exports.
16
+
3
17
  ## 0.101.3
4
18
 
5
19
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.101.3",
3
+ "version": "0.101.5",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -373,6 +373,14 @@ interface DefineActionWithSchema<
373
373
  * Only set this for mutating actions that are internally concurrency-safe
374
374
  * and order-independent for same-turn execution. */
375
375
  parallelSafe?: boolean;
376
+ /** Set false to exempt a read-only tool from the agent loop's duplicate
377
+ * read-only call guard (per-turn result cache + "Skipped duplicate..."
378
+ * repeat detection). Default true (deduped). Use this for volatile/polling
379
+ * reads where an identical call is expected to return a different result
380
+ * each time — e.g. polling a code-execution status by id, or re-fetching
381
+ * current on-screen state. Has no effect on non-read-only actions, which
382
+ * are never deduped in the first place. */
383
+ dedupe?: boolean;
376
384
  /** Whether this action may be invoked from the tools (Alpine iframe) bridge
377
385
  * via `appAction(name, params)` — see `packages/core/docs/content/actions.mdx`
378
386
  * ("Tools Callability"). **Default-allow opt-out**: undefined / `true` both
@@ -493,6 +501,9 @@ interface DefineActionWithParams<
493
501
  /** If true, the agent may execute this action concurrently with other
494
502
  * read-only or parallel-safe tool calls emitted in the same model turn. */
495
503
  parallelSafe?: boolean;
504
+ /** Set false to exempt a read-only tool from the duplicate read-only call
505
+ * guard. Default true. See the schema overload above. */
506
+ dedupe?: boolean;
496
507
  /** Whether this action may be invoked from the tools (Alpine iframe) bridge
497
508
  * via `appAction(name, params)`. See the schema overload above for details
498
509
  * and the `toolCallable` section in actions.md. */
@@ -557,6 +568,7 @@ export interface ActionDefinition<TInput, TReturn> {
557
568
  readonly readOnly?: boolean;
558
569
  readonly allowInPlanMode?: boolean;
559
570
  readonly parallelSafe?: boolean;
571
+ readonly dedupe?: boolean;
560
572
  readonly toolCallable?: boolean;
561
573
  readonly publicAgent?: PublicAgentActionConfig;
562
574
  readonly link?: ActionLinkBuilder;
@@ -752,6 +764,8 @@ export function defineAction(options: any) {
752
764
  typeof options.parallelSafe === "boolean"
753
765
  ? options.parallelSafe
754
766
  : undefined;
767
+ const dedupe: boolean | undefined =
768
+ typeof options.dedupe === "boolean" ? options.dedupe : undefined;
755
769
  const publicAgent: PublicAgentActionConfig | undefined =
756
770
  options.publicAgent &&
757
771
  typeof options.publicAgent === "object" &&
@@ -806,6 +820,7 @@ export function defineAction(options: any) {
806
820
  ? { allowInPlanMode: options.allowInPlanMode }
807
821
  : {}),
808
822
  ...(typeof parallelSafe === "boolean" ? { parallelSafe } : {}),
823
+ ...(typeof dedupe === "boolean" ? { dedupe } : {}),
809
824
  ...(typeof toolCallable === "boolean" ? { toolCallable } : {}),
810
825
  ...(publicAgent ? { publicAgent } : {}),
811
826
  ...(link ? { link } : {}),
@@ -583,6 +583,11 @@ export interface ActionEntry {
583
583
  * read-only/parallel-safe tool calls. Only use for actions that handle
584
584
  * their own write ordering and idempotency. */
585
585
  parallelSafe?: boolean;
586
+ /** Set false to exempt a read-only tool from the duplicate read-only
587
+ * tool-call guard (per-turn result cache + repeat-kill). Default true. Use
588
+ * for volatile/polling reads that are expected to return a different
589
+ * result on each identical call. See `defineAction`'s `dedupe` option. */
590
+ dedupe?: boolean;
586
591
  /** Whether this action may be invoked from the tools-iframe bridge.
587
592
  * **Default-allow opt-out**: only an explicit `false` returns 403.
588
593
  * - `true` / `undefined` — allow.
@@ -2170,16 +2175,26 @@ function seedReadOnlyToolResultsFromHistory(
2170
2175
  const cache = new Map<string, string>();
2171
2176
  if (!isInternalContinuationTurn(messages)) return cache;
2172
2177
 
2173
- const pendingToolCalls = new Map<string, { name: string; input: unknown }>();
2174
- for (const message of messages) {
2178
+ // Scoped to the current turn only (same slice as
2179
+ // seedWriteToolInterruptionsFromHistory) reads from a prior turn are no
2180
+ // longer relevant context and must not seed skip-as-duplicate behavior.
2181
+ const turnStart = findCurrentTurnStartForContinuation(messages);
2182
+ const turnMessages = messages.slice(turnStart);
2183
+
2184
+ const pendingToolCalls = new Map<
2185
+ string,
2186
+ { name: string; input: unknown; readOnly: boolean; dedupe: boolean }
2187
+ >();
2188
+ for (const message of turnMessages) {
2175
2189
  if (message.role === "assistant") {
2176
2190
  for (const part of message.content) {
2177
2191
  if (part.type !== "tool-call") continue;
2178
2192
  const entry = actions[part.name];
2179
- if (entry?.readOnly !== true) continue;
2180
2193
  pendingToolCalls.set(part.id, {
2181
2194
  name: part.name,
2182
2195
  input: part.input,
2196
+ readOnly: entry?.readOnly === true,
2197
+ dedupe: entry?.dedupe !== false,
2183
2198
  });
2184
2199
  }
2185
2200
  continue;
@@ -2189,6 +2204,17 @@ function seedReadOnlyToolResultsFromHistory(
2189
2204
  if (part.type !== "tool-result") continue;
2190
2205
  const call = pendingToolCalls.get(part.toolCallId);
2191
2206
  if (!call) continue;
2207
+ if (!call.readOnly) {
2208
+ // Mirror the live loop: a successful write invalidates all cached
2209
+ // reads (see the `readOnlyToolResultCache.clear()` call below), so a
2210
+ // read seeded from before an intervening write must not be replayed
2211
+ // as still-fresh.
2212
+ if (part.isError !== true) cache.clear();
2213
+ continue;
2214
+ }
2215
+ // dedupe:false read-only tools (volatile/polling reads) are never
2216
+ // cached — every call must execute fresh, seeded or not.
2217
+ if (!call.dedupe) continue;
2192
2218
  if (!isReusableReadOnlyToolResult(part)) continue;
2193
2219
  cache.set(toolCallCacheKey(call.name, call.input), part.content);
2194
2220
  }
@@ -2197,11 +2223,100 @@ function seedReadOnlyToolResultsFromHistory(
2197
2223
  return cache;
2198
2224
  }
2199
2225
 
2226
+ function visibleDuplicateReadOnlyToolResult(toolName: string): string {
2227
+ return (
2228
+ `Skipped duplicate read-only call to ${toolName}: identical input already ran in this turn. ` +
2229
+ `Use the previous result already in the conversation instead of calling this tool again.`
2230
+ );
2231
+ }
2232
+
2233
+ function resurfacedDuplicateReadOnlyToolResultPrefix(toolName: string): string {
2234
+ return (
2235
+ `Skipped duplicate read-only call to ${toolName}: identical input already ran in this turn. ` +
2236
+ `Its earlier result is no longer in view, so here it is again:\n\n`
2237
+ );
2238
+ }
2239
+
2240
+ function resurfacedDuplicateReadOnlyToolResult(
2241
+ toolName: string,
2242
+ cachedResult: string,
2243
+ ): string {
2244
+ return `${resurfacedDuplicateReadOnlyToolResultPrefix(toolName)}${cachedResult}`;
2245
+ }
2246
+
2247
+ /** Restore visible-repeat strike counts for this continuation's active turn. */
2248
+ function seedDuplicateReadOnlyToolCallsFromHistory(
2249
+ messages: EngineMessage[],
2250
+ actions: Record<string, ActionEntry>,
2251
+ ): Map<string, number> {
2252
+ const repeats = new Map<string, number>();
2253
+ if (!isInternalContinuationTurn(messages)) return repeats;
2254
+
2255
+ const turnStart = findCurrentTurnStartForContinuation(messages);
2256
+ const pendingToolCalls = new Map<
2257
+ string,
2258
+ { name: string; input: unknown; readOnly: boolean; dedupe: boolean }
2259
+ >();
2260
+ const reusableReadKeys = new Set<string>();
2261
+
2262
+ for (const message of messages.slice(turnStart)) {
2263
+ if (message.role === "assistant") {
2264
+ for (const part of message.content) {
2265
+ if (part.type !== "tool-call") continue;
2266
+ const entry = actions[part.name];
2267
+ pendingToolCalls.set(part.id, {
2268
+ name: part.name,
2269
+ input: part.input,
2270
+ readOnly: entry?.readOnly === true,
2271
+ dedupe: entry?.dedupe !== false,
2272
+ });
2273
+ }
2274
+ continue;
2275
+ }
2276
+
2277
+ for (const part of message.content) {
2278
+ if (part.type !== "tool-result") continue;
2279
+ const call = pendingToolCalls.get(part.toolCallId);
2280
+ if (!call) continue;
2281
+ if (!call.readOnly) {
2282
+ if (part.isError !== true) {
2283
+ repeats.clear();
2284
+ reusableReadKeys.clear();
2285
+ }
2286
+ continue;
2287
+ }
2288
+ if (!call.dedupe || part.isError === true) continue;
2289
+
2290
+ const cacheKey = toolCallCacheKey(call.name, call.input);
2291
+ if (part.content === visibleDuplicateReadOnlyToolResult(call.name)) {
2292
+ if (reusableReadKeys.has(cacheKey)) {
2293
+ repeats.set(cacheKey, (repeats.get(cacheKey) ?? 0) + 1);
2294
+ }
2295
+ continue;
2296
+ }
2297
+ if (
2298
+ part.content.startsWith(
2299
+ resurfacedDuplicateReadOnlyToolResultPrefix(call.name),
2300
+ )
2301
+ ) {
2302
+ if (reusableReadKeys.has(cacheKey)) repeats.set(cacheKey, 0);
2303
+ continue;
2304
+ }
2305
+ if (isReusableReadOnlyToolResult(part)) {
2306
+ reusableReadKeys.add(cacheKey);
2307
+ }
2308
+ }
2309
+ }
2310
+
2311
+ return repeats;
2312
+ }
2313
+
2200
2314
  function isReusableReadOnlyToolResult(part: EngineToolResultPart): boolean {
2201
2315
  if (part.isError) return false;
2202
2316
  const lower = part.content.trim().toLowerCase();
2203
2317
  if (!lower) return false;
2204
2318
  return !(
2319
+ lower.startsWith("skipped duplicate read-only call to ") ||
2205
2320
  lower.startsWith("invalid action parameters for ") ||
2206
2321
  lower.startsWith("error running ") ||
2207
2322
  lower.includes("run aborted") ||
@@ -2211,6 +2326,56 @@ function isReusableReadOnlyToolResult(part: EngineToolResultPart): boolean {
2211
2326
  );
2212
2327
  }
2213
2328
 
2329
+ /**
2330
+ * Whether a cached read-only tool result is still something the model can
2331
+ * actually see in `contextMessages` — the trimmed/summarized view the engine
2332
+ * is streamed (NOT the raw, ever-growing `messages` array the cache is keyed
2333
+ * off of). Context-xray `evict` drops tool-result parts entirely, `summarize`
2334
+ * replaces their content with a placeholder, and observational-memory
2335
+ * trimming drops whole older messages once active. When the cached result
2336
+ * has fallen out of that view, re-serving "use the previous result" is not
2337
+ * actionable — the model has nothing to point back to.
2338
+ *
2339
+ * A visible result must belong to the same tool name + normalized input and
2340
+ * contain either the exact cached body or the exact wrapper used when that
2341
+ * body was re-served after trimming. Matching only by a content suffix is too
2342
+ * loose: short results such as "ok" can also end unrelated tool output.
2343
+ */
2344
+ export function isCachedToolResultVisibleInContext(
2345
+ contextMessages: EngineMessage[],
2346
+ toolCall: { name: string; input: unknown },
2347
+ cachedResult: string,
2348
+ ): boolean {
2349
+ if (cachedResult.length === 0) return true;
2350
+ const cacheKey = toolCallCacheKey(toolCall.name, toolCall.input);
2351
+ const matchingToolCallIds = new Set<string>();
2352
+ for (const message of contextMessages) {
2353
+ if (message.role !== "assistant") continue;
2354
+ for (const part of message.content) {
2355
+ if (part.type !== "tool-call") continue;
2356
+ if (toolCallCacheKey(part.name, part.input) === cacheKey) {
2357
+ matchingToolCallIds.add(part.id);
2358
+ }
2359
+ }
2360
+ }
2361
+
2362
+ const resurfacedResult = resurfacedDuplicateReadOnlyToolResult(
2363
+ toolCall.name,
2364
+ cachedResult,
2365
+ );
2366
+ for (const message of contextMessages) {
2367
+ if (message.role !== "user") continue;
2368
+ for (const part of message.content) {
2369
+ if (part.type !== "tool-result") continue;
2370
+ if (!matchingToolCallIds.has(part.toolCallId)) continue;
2371
+ if (part.content === cachedResult || part.content === resurfacedResult) {
2372
+ return true;
2373
+ }
2374
+ }
2375
+ }
2376
+ return false;
2377
+ }
2378
+
2214
2379
  /**
2215
2380
  * Counts how many times each write (non-read-only) tool call was interrupted
2216
2381
  * before returning a result in the continuation history. When a connection
@@ -3106,7 +3271,10 @@ export async function runAgentLoop(opts: {
3106
3271
  messages,
3107
3272
  actions,
3108
3273
  );
3109
- const duplicateReadOnlyToolCalls = new Map<string, number>();
3274
+ const duplicateReadOnlyToolCalls = seedDuplicateReadOnlyToolCallsFromHistory(
3275
+ messages,
3276
+ actions,
3277
+ );
3110
3278
  const writeToolInterruptions = seedWriteToolInterruptionsFromHistory(
3111
3279
  messages,
3112
3280
  actions,
@@ -4339,18 +4507,49 @@ export async function runAgentLoop(opts: {
4339
4507
  };
4340
4508
  }
4341
4509
 
4510
+ // dedupe: false opts a read-only tool out of the guard entirely — the
4511
+ // cacheKey stays null so it never gets skipped-as-duplicate and never
4512
+ // populates the cache (see the success handler below, which also
4513
+ // leaves dedupe:false results uncached and un-cleared).
4342
4514
  const cacheKey =
4343
- actionEntry.readOnly === true
4515
+ actionEntry.readOnly === true && actionEntry.dedupe !== false
4344
4516
  ? toolCallCacheKey(toolCall.name, toolCall.input)
4345
4517
  : null;
4346
4518
  if (cacheKey && readOnlyToolResultCache.has(cacheKey)) {
4347
- const repeats = (duplicateReadOnlyToolCalls.get(cacheKey) ?? 0) + 1;
4348
- duplicateReadOnlyToolCalls.set(cacheKey, repeats);
4349
4519
  const previousResult = readOnlyToolResultCache.get(cacheKey) ?? "";
4350
- const result =
4351
- `Skipped duplicate read-only call to ${toolCall.name}: identical input already ran in this turn. ` +
4352
- `Use the previous result already in the conversation instead of calling this tool again.\n\n` +
4353
- `Previous result:\n${previousResult}`;
4520
+ // `contextMessages` (not `messages`) is what the model actually sees
4521
+ // this iteration context-xray eviction/summarization and
4522
+ // observational-memory trimming can drop the earlier result from
4523
+ // view even though it's still cached here. Only strike-count the
4524
+ // repeat when the model could have looked back and found it itself.
4525
+ const visible = isCachedToolResultVisibleInContext(
4526
+ contextMessages,
4527
+ toolCall,
4528
+ previousResult,
4529
+ );
4530
+ let result: string;
4531
+ if (visible) {
4532
+ const repeats = (duplicateReadOnlyToolCalls.get(cacheKey) ?? 0) + 1;
4533
+ duplicateReadOnlyToolCalls.set(cacheKey, repeats);
4534
+ result = visibleDuplicateReadOnlyToolResult(toolCall.name);
4535
+ if (repeats >= 3) {
4536
+ requestedActionStop ??= {
4537
+ message:
4538
+ "I stopped because the agent kept asking for the same read-only context it already had. Please send the request again if you want me to retry from a fresh turn.",
4539
+ errorCode: "duplicate_read_only_tool",
4540
+ };
4541
+ }
4542
+ } else {
4543
+ // The earlier result was trimmed out of the model's visible
4544
+ // context — this isn't a repetitive loop, the model legitimately
4545
+ // can't see the answer anymore. Re-serve it in full and don't
4546
+ // count a strike.
4547
+ duplicateReadOnlyToolCalls.set(cacheKey, 0);
4548
+ result = resurfacedDuplicateReadOnlyToolResult(
4549
+ toolCall.name,
4550
+ previousResult,
4551
+ );
4552
+ }
4354
4553
  send({
4355
4554
  type: "tool_done",
4356
4555
  id: toolCall.id,
@@ -4360,13 +4559,6 @@ export async function runAgentLoop(opts: {
4360
4559
  completedSideEffect: false,
4361
4560
  });
4362
4561
  recordToolResult(result, false);
4363
- if (repeats >= 3) {
4364
- requestedActionStop ??= {
4365
- message:
4366
- "I stopped because the agent kept asking for the same read-only context it already had. Please send the request again if you want me to retry from a fresh turn.",
4367
- errorCode: "duplicate_read_only_tool",
4368
- };
4369
- }
4370
4562
  return {
4371
4563
  type: "tool-result" as const,
4372
4564
  toolCallId: toolCall.id,
@@ -4638,7 +4830,11 @@ export async function runAgentLoop(opts: {
4638
4830
  if (!isError) {
4639
4831
  if (cacheKey) {
4640
4832
  readOnlyToolResultCache.set(cacheKey, result);
4641
- } else {
4833
+ } else if (actionEntry.readOnly !== true) {
4834
+ // A genuine write invalidates all cached reads. A dedupe:false
4835
+ // read-only tool also has a null cacheKey (see above) but must NOT
4836
+ // clear the cache — it isn't a write and other tools' cached reads
4837
+ // are still valid.
4642
4838
  readOnlyToolResultCache.clear();
4643
4839
  duplicateReadOnlyToolCalls.clear();
4644
4840
  }
@@ -323,10 +323,17 @@ export function RunStuckBanner({
323
323
  };
324
324
 
325
325
  const handleRetry = async () => {
326
- // Defense in depth: the button is hidden while `inFlightWork` is true
327
- // (see render below), but guard the handler itself too so an abort can
328
- // never be triggered against live work through this path.
329
- if (!state.runId || busy.type !== "none" || inFlightWork) return;
326
+ // Defense in depth: Retry is hidden while a background worker is still
327
+ // heartbeating or work is explicitly in flight (see render below). Guard
328
+ // the handler too so this path cannot abort a run the server says is alive.
329
+ if (
330
+ !state.runId ||
331
+ busy.type !== "none" ||
332
+ backgroundWorkerStillAlive ||
333
+ inFlightWork
334
+ ) {
335
+ return;
336
+ }
330
337
  const runId = state.runId;
331
338
  setBusy({ type: "retry", runId });
332
339
  trackEvent("agent_chat_stuck_retry", {
@@ -380,7 +387,7 @@ export function RunStuckBanner({
380
387
  </span>
381
388
  </div>
382
389
  <div className="flex flex-wrap items-center gap-2">
383
- {inFlightWork ? null : (
390
+ {stillWorking ? null : (
384
391
  <button
385
392
  type="button"
386
393
  onClick={handleRetry}
@@ -112,6 +112,20 @@ const INTERRUPTED_TOOL_RESULT =
112
112
  "Interrupted before this tool returned a result.";
113
113
  const INTERRUPTED_ACTIVITY_RESULT = "Stopped before this action started.";
114
114
 
115
+ /**
116
+ * Maximum number of assistant-ui repository updates we deliver in one browser
117
+ * event-loop turn. Durable-run replay can put hundreds of SSE frames into the
118
+ * stream queue before the client attaches; draining all of them through
119
+ * assistant-ui without a macrotask boundary synchronously nests React external
120
+ * store notifications until React throws "Maximum update depth exceeded."
121
+ *
122
+ * A timer scheduled on the first result resets the count when the stream is
123
+ * naturally idle between network chunks. We only await it when results are
124
+ * arriving densely enough to hit this bound, so normal live token streaming
125
+ * keeps its existing latency while replay bursts yield cooperatively.
126
+ */
127
+ const SSE_RENDER_UPDATES_PER_EVENT_LOOP_TURN = 20;
128
+
115
129
  export function settleInterruptedToolCalls(
116
130
  content: ContentPart[],
117
131
  result = INTERRUPTED_TOOL_RESULT,
@@ -1609,6 +1623,24 @@ export async function* readSSEStream(
1609
1623
  completedToolsAfterLastAssistantText: new Set(),
1610
1624
  streamProgressDispatched: false,
1611
1625
  };
1626
+ let renderUpdatesThisTurn = 0;
1627
+ let nextEventLoopTurn: Promise<void> | null = null;
1628
+
1629
+ const paceRenderUpdate = async (): Promise<void> => {
1630
+ renderUpdatesThisTurn += 1;
1631
+ if (!nextEventLoopTurn) {
1632
+ nextEventLoopTurn = new Promise<void>((resolve) => {
1633
+ setTimeout(() => {
1634
+ renderUpdatesThisTurn = 0;
1635
+ nextEventLoopTurn = null;
1636
+ resolve();
1637
+ }, 0);
1638
+ });
1639
+ }
1640
+ if (renderUpdatesThisTurn >= SSE_RENDER_UPDATES_PER_EVENT_LOOP_TURN) {
1641
+ await nextEventLoopTurn;
1642
+ }
1643
+ };
1612
1644
 
1613
1645
  const withStreamMetadata = (r: ChatModelRunResult): ChatModelRunResult => {
1614
1646
  if (!runId && activityTrail.length === 0) return r;
@@ -1726,7 +1758,10 @@ export async function* readSSEStream(
1726
1758
  processEventState,
1727
1759
  );
1728
1760
 
1729
- if (result) yield withStreamMetadata(result);
1761
+ if (result) {
1762
+ await paceRenderUpdate();
1763
+ yield withStreamMetadata(result);
1764
+ }
1730
1765
  if (
1731
1766
  hasStalledPreparingAction(
1732
1767
  preparingActionState,