@agent-native/core 0.161.4 → 0.161.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.
@@ -9,6 +9,7 @@ import { z } from "zod";
9
9
  import { interpolate } from "../app/pages/adhoc/sql-dashboard/interpolate";
10
10
  import { dryRunQuery } from "../server/lib/bigquery";
11
11
  import { queueDashboardCollabSync } from "../server/lib/dashboard-collab-sync";
12
+ import { serializeProgramDescriptorInput } from "../server/lib/dashboard-panel-query";
12
13
  import { validateFirstPartyDashboardTimeScope } from "../server/lib/dashboard-time-scope";
13
14
  import {
14
15
  upsertDashboard,
@@ -362,6 +363,13 @@ export function validateDashboardConfig(
362
363
  if (!isSection && !isExtension && !validSources.has(p.source as string)) {
363
364
  return `panel[${i}].source must be 'bigquery', 'ga4', 'amplitude', 'first-party', 'demo', 'prometheus', or 'program' (got '${p.source}'). source selects the backend — put the PromQL/SQL/table name or program descriptor in sql, not here.`;
364
365
  }
366
+ if (p.source === "program") {
367
+ try {
368
+ serializeProgramDescriptorInput(p.sql);
369
+ } catch (e: any) {
370
+ return `panel[${i}] "${p.title || p.id}" program descriptor is invalid: ${e?.message ?? e}`;
371
+ }
372
+ }
365
373
  if (isExtension) {
366
374
  const cfg = p.config as Record<string, unknown> | undefined;
367
375
  const extensionId =
@@ -601,6 +601,9 @@ function renderFormPage(
601
601
  if (v) data[f.id] = parseInt(v);
602
602
  } else if (f.type === "scale") {
603
603
  data[f.id] = parseInt(el.querySelector(".slider").value);
604
+ } else if (f.type === "radio") {
605
+ var checked = el.querySelector('input[type="radio"]:checked');
606
+ if (checked && checked.value) data[f.id] = checked.value;
604
607
  } else {
605
608
  var input = el.querySelector("input, textarea, select");
606
609
  if (input && input.value) data[f.id] = input.value;
@@ -965,11 +965,13 @@ function MultiSelectOutline({
965
965
  }
966
966
 
967
967
  /** Translucent rectangle drawn while marquee-dragging */
968
+ type MarqueeSelectionRect = { x: number; y: number; w: number; h: number };
969
+
968
970
  function MarqueeRect({
969
971
  rect,
970
972
  viewportRect,
971
973
  }: {
972
- rect: { x: number; y: number; w: number; h: number };
974
+ rect: MarqueeSelectionRect;
973
975
  viewportRect: DOMRect | null;
974
976
  }) {
975
977
  return (
@@ -1204,12 +1206,7 @@ export default function SlideEditor({
1204
1206
  /** Anchor rect for the floating chip (the slide canvas) */
1205
1207
  const [chipAnchorRect, setChipAnchorRect] = useState<DOMRect | null>(null);
1206
1208
  /** Active marquee rectangle (viewport coords). null = not dragging. */
1207
- const [marquee, setMarquee] = useState<{
1208
- x: number;
1209
- y: number;
1210
- w: number;
1211
- h: number;
1212
- } | null>(null);
1209
+ const [marquee, setMarquee] = useState<MarqueeSelectionRect | null>(null);
1213
1210
  const [activeAlignmentGuides, setActiveAlignmentGuides] = useState<{
1214
1211
  guides: SlideAlignmentGuide[];
1215
1212
  viewport: AlignmentGuideViewport;
@@ -1502,6 +1499,8 @@ export default function SlideEditor({
1502
1499
  }, [overflowInfo, slide.id, dims.width, dims.height]);
1503
1500
  /** Marquee origin (viewport coords). Set on pointerdown. */
1504
1501
  const marqueeOriginRef = useRef<{ x: number; y: number } | null>(null);
1502
+ /** Latest marquee geometry, readable by the stable window pointer handlers. */
1503
+ const marqueeRef = useRef<MarqueeSelectionRect | null>(null);
1505
1504
  /** Set right before placing a text box so the click event that follows the
1506
1505
  * placing pointerdown doesn't fall through to click-to-select/deselect
1507
1506
  * logic and steal focus back off the freshly created box. */
@@ -4432,7 +4431,12 @@ export default function SlideEditor({
4432
4431
  marqueeOriginRef.current = { x: e.clientX, y: e.clientY };
4433
4432
  marqueeAdditiveRef.current = e.shiftKey || e.metaKey || e.ctrlKey;
4434
4433
  marqueePrevSelectionRef.current = new Set(multiSelection);
4435
- setMarquee({ x: e.clientX, y: e.clientY, w: 0, h: 0 });
4434
+ const initialMarquee = { x: e.clientX, y: e.clientY, w: 0, h: 0 };
4435
+ marqueeRef.current = initialMarquee;
4436
+ setMarquee(initialMarquee);
4437
+ if (e.pointerId >= 0) {
4438
+ e.currentTarget.setPointerCapture(e.pointerId);
4439
+ }
4436
4440
 
4437
4441
  // Clear single-select feedback when starting a marquee on whitespace
4438
4442
  // (non-additive). Additive marquee preserves the existing selection.
@@ -4462,25 +4466,28 @@ export default function SlideEditor({
4462
4466
  ],
4463
4467
  );
4464
4468
 
4465
- // Window-level pointermove / pointerup so the drag still tracks if the
4466
- // pointer leaves the slide.
4469
+ // Keep these listeners stable while React re-renders the marquee overlay.
4470
+ // Re-attaching them whenever marquee state changes can lose a fast
4471
+ // pointermove/pointerup between the effect cleanup and re-install.
4467
4472
  useEffect(() => {
4468
- if (!marquee) return;
4469
4473
  const onMove = (e: PointerEvent) => {
4470
4474
  const origin = marqueeOriginRef.current;
4471
- if (!origin) return;
4475
+ if (!origin || !marqueeRef.current) return;
4472
4476
  const x = Math.min(origin.x, e.clientX);
4473
4477
  const y = Math.min(origin.y, e.clientY);
4474
4478
  const w = Math.abs(e.clientX - origin.x);
4475
4479
  const h = Math.abs(e.clientY - origin.y);
4476
- setMarquee({ x, y, w, h });
4480
+ const nextMarquee = { x, y, w, h };
4481
+ marqueeRef.current = nextMarquee;
4482
+ setMarquee(nextMarquee);
4477
4483
  };
4478
- const onUp = () => {
4484
+ const finish = (cancelled: boolean) => {
4479
4485
  const origin = marqueeOriginRef.current;
4480
- const current = marquee;
4486
+ const current = marqueeRef.current;
4481
4487
  marqueeOriginRef.current = null;
4488
+ marqueeRef.current = null;
4482
4489
  setMarquee(null);
4483
- if (!origin || !current) return;
4490
+ if (cancelled || !origin || !current) return;
4484
4491
 
4485
4492
  const slideContent = getSlideContent();
4486
4493
  if (!slideContent) return;
@@ -4518,13 +4525,18 @@ export default function SlideEditor({
4518
4525
 
4519
4526
  applyMultiSelection(hits);
4520
4527
  };
4528
+
4529
+ const onUp = () => finish(false);
4530
+ const onCancel = () => finish(true);
4521
4531
  window.addEventListener("pointermove", onMove);
4522
4532
  window.addEventListener("pointerup", onUp);
4533
+ window.addEventListener("pointercancel", onCancel);
4523
4534
  return () => {
4524
4535
  window.removeEventListener("pointermove", onMove);
4525
4536
  window.removeEventListener("pointerup", onUp);
4537
+ window.removeEventListener("pointercancel", onCancel);
4526
4538
  };
4527
- }, [marquee, getSlideContent, applyMultiSelection]);
4539
+ }, [getSlideContent, applyMultiSelection]);
4528
4540
 
4529
4541
  /** Send the current selection to the agent chat composer */
4530
4542
  const sendSelectionToAgent = useCallback(() => {
@@ -935,6 +935,52 @@ export declare function runAgentLoopWithMainChatInternalContinuations(opts: Para
935
935
  * background invocations forever, mirroring `MAX_AGENT_TEAM_CONTINUATIONS`.
936
936
  */
937
937
  export declare const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
938
+ /**
939
+ * Consecutive chunks allowed to end on the SAME terminal error code having
940
+ * produced nothing before the chain stops.
941
+ *
942
+ * Two, because two independent recovery layers multiply here and neither can
943
+ * see the other: the engine already retried this identical request 3x with
944
+ * backoff before the error was ever emitted, and a recoverable error is also a
945
+ * continuation boundary, so every chunk that fails costs 4 gateway attempts
946
+ * and dispatches a fresh one. A production turn spent 27 background runs and
947
+ * 15 minutes on one message this way. The first repeat is the retry this path
948
+ * exists for; a second identical failure that moved nothing is evidence the
949
+ * retrying itself is what is broken, not the request.
950
+ */
951
+ export declare const MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS = 2;
952
+ /** Consecutive-identical-failure state for one chunk of a background chain. */
953
+ export interface BackgroundNoProgressRepeat {
954
+ /** This chunk's terminal error code, when it ended having produced nothing. */
955
+ errorCode?: string;
956
+ /** Chunks in a row that ended on that code with no forward progress. */
957
+ count: number;
958
+ /** True once the streak reaches `MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS`. */
959
+ tripped: boolean;
960
+ }
961
+ /**
962
+ * Advance the no-progress streak across a chunk boundary. A chunk that emitted
963
+ * any text or tool activity resets it even when it still ended in an error —
964
+ * that turn IS moving, and cutting it off is what would weaken recovery for
965
+ * truncated streams. So does a different error code, and so does a boundary
966
+ * with no error at all (a soft-timeout `auto_continue` is the run-manager's
967
+ * no-progress backstop to bound, not this one).
968
+ */
969
+ export declare function resolveBackgroundNoProgressRepeat(opts: {
970
+ run: ActiveRun;
971
+ priorErrorCode?: string;
972
+ priorCount?: number;
973
+ }): BackgroundNoProgressRepeat;
974
+ /**
975
+ * The single honest failure the breaker leaves behind. Keeps the underlying
976
+ * code and the gateway's own message (which carries its `ERROR ID:` reference)
977
+ * so the failure stays diagnosable, and marks it non-recoverable so neither
978
+ * this chain nor the client's continuation path re-enters it.
979
+ */
980
+ export declare function backgroundNoProgressTerminalEvent(run: ActiveRun, repeat: BackgroundNoProgressRepeat): Extract<AgentChatEvent, {
981
+ type: "error";
982
+ }> | null;
983
+ export declare function installBackgroundNoProgressTerminalEvent(run: ActiveRun, repeat: BackgroundNoProgressRepeat): boolean;
938
984
  /**
939
985
  * Whether this run should self-fire the next server-driven continuation chunk
940
986
  * instead of depending on the client to re-POST `auto_continue`. True for
@@ -949,7 +995,9 @@ export declare const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
949
995
  * the durable background worker (`dispatchedToBackground` false) — a run
950
996
  * already headed to the durable background path chains via the
951
997
  * `isBackgroundWorker` branch above, never both.
952
- * Aborted / user-stopped runs do NOT chain either way.
998
+ * Aborted / user-stopped runs do NOT chain either way, and neither does a run
999
+ * whose no-progress streak has tripped
1000
+ * (`MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS`).
953
1001
  */
954
1002
  export declare function shouldChainBackgroundContinuation(opts: {
955
1003
  isBackgroundWorker: boolean;
@@ -970,6 +1018,10 @@ export declare function shouldChainBackgroundContinuation(opts: {
970
1018
  * owned by the background circuit-breaker, not this path.
971
1019
  */
972
1020
  dispatchedToBackground?: boolean;
1021
+ /** Streak state carried on the continuation marker — see
1022
+ * `resolveBackgroundNoProgressRepeat`. Absent on the first chunk. */
1023
+ priorNoProgressErrorCode?: string;
1024
+ priorNoProgressCount?: number;
973
1025
  }): boolean;
974
1026
  /**
975
1027
  * Minimum remaining budget (ms) a synchronous self-chain continuation chunk
@@ -1275,6 +1327,9 @@ export declare function chainServerDrivenContinuation(opts: {
1275
1327
  * is derived from it (marker stripped, `internalContinuation` set). */
1276
1328
  requestBody: Record<string, unknown>;
1277
1329
  backgroundContinuationCount: number;
1330
+ /** This chunk's no-progress streak, carried to the successor so the breaker
1331
+ * can see a repeat across the invocation boundary. */
1332
+ noProgressRepeat?: BackgroundNoProgressRepeat;
1278
1333
  /**
1279
1334
  * Input tokens this logical turn has consumed across every chunk so far,
1280
1335
  * carried on the successor's body so the per-turn token ceiling is a real
@@ -5422,6 +5422,87 @@ function endsAtContinuationBoundary(run) {
5422
5422
  * background invocations forever, mirroring `MAX_AGENT_TEAM_CONTINUATIONS`.
5423
5423
  */
5424
5424
  export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
5425
+ /**
5426
+ * Consecutive chunks allowed to end on the SAME terminal error code having
5427
+ * produced nothing before the chain stops.
5428
+ *
5429
+ * Two, because two independent recovery layers multiply here and neither can
5430
+ * see the other: the engine already retried this identical request 3x with
5431
+ * backoff before the error was ever emitted, and a recoverable error is also a
5432
+ * continuation boundary, so every chunk that fails costs 4 gateway attempts
5433
+ * and dispatches a fresh one. A production turn spent 27 background runs and
5434
+ * 15 minutes on one message this way. The first repeat is the retry this path
5435
+ * exists for; a second identical failure that moved nothing is evidence the
5436
+ * retrying itself is what is broken, not the request.
5437
+ */
5438
+ export const MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS = 2;
5439
+ /**
5440
+ * Forward progress inside ONE chunk, read from the events it actually emitted:
5441
+ * assistant text or tool activity. Same evidence the agent-teams no-progress
5442
+ * budget counts (`agent-teams.ts`), and the same events
5443
+ * `endsAfterCompletedToolWithoutAssistantFinal` reads to tell an unfinished
5444
+ * turn from a finished one.
5445
+ */
5446
+ function chunkMadeForwardProgress(run) {
5447
+ return run.events.some(({ event }) => (event.type === "text" && event.text.trim().length > 0) ||
5448
+ event.type === "tool_start" ||
5449
+ event.type === "tool_done");
5450
+ }
5451
+ /**
5452
+ * Advance the no-progress streak across a chunk boundary. A chunk that emitted
5453
+ * any text or tool activity resets it even when it still ended in an error —
5454
+ * that turn IS moving, and cutting it off is what would weaken recovery for
5455
+ * truncated streams. So does a different error code, and so does a boundary
5456
+ * with no error at all (a soft-timeout `auto_continue` is the run-manager's
5457
+ * no-progress backstop to bound, not this one).
5458
+ */
5459
+ export function resolveBackgroundNoProgressRepeat(opts) {
5460
+ const last = opts.run.events.at(-1)?.event;
5461
+ const errorCode = last?.type === "error" ? (last.errorCode ?? "").trim() : "";
5462
+ if (!errorCode || chunkMadeForwardProgress(opts.run)) {
5463
+ return { count: 0, tripped: false };
5464
+ }
5465
+ const prior = opts.priorErrorCode === errorCode &&
5466
+ typeof opts.priorCount === "number" &&
5467
+ Number.isFinite(opts.priorCount)
5468
+ ? Math.max(0, Math.floor(opts.priorCount))
5469
+ : 0;
5470
+ const count = prior + 1;
5471
+ return {
5472
+ errorCode,
5473
+ count,
5474
+ tripped: count >= MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS,
5475
+ };
5476
+ }
5477
+ /**
5478
+ * The single honest failure the breaker leaves behind. Keeps the underlying
5479
+ * code and the gateway's own message (which carries its `ERROR ID:` reference)
5480
+ * so the failure stays diagnosable, and marks it non-recoverable so neither
5481
+ * this chain nor the client's continuation path re-enters it.
5482
+ */
5483
+ export function backgroundNoProgressTerminalEvent(run, repeat) {
5484
+ const last = run.events.at(-1)?.event;
5485
+ if (last?.type !== "error")
5486
+ return null;
5487
+ return {
5488
+ ...last,
5489
+ error: `${last.error}\n\nThis failed ${repeat.count} times in a row without ` +
5490
+ `making any progress, so I stopped instead of retrying again.`,
5491
+ recoverable: false,
5492
+ };
5493
+ }
5494
+ export function installBackgroundNoProgressTerminalEvent(run, repeat) {
5495
+ const terminalEvent = backgroundNoProgressTerminalEvent(run, repeat);
5496
+ const lastRunEvent = run.events.at(-1);
5497
+ if (!terminalEvent || lastRunEvent?.event.type !== "error")
5498
+ return false;
5499
+ run.events = [
5500
+ ...run.events.slice(0, -1),
5501
+ { ...lastRunEvent, event: terminalEvent },
5502
+ ];
5503
+ run.continuationTerminalEvent = terminalEvent;
5504
+ return true;
5505
+ }
5425
5506
  /**
5426
5507
  * Whether this run should self-fire the next server-driven continuation chunk
5427
5508
  * instead of depending on the client to re-POST `auto_continue`. True for
@@ -5436,7 +5517,9 @@ export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
5436
5517
  * the durable background worker (`dispatchedToBackground` false) — a run
5437
5518
  * already headed to the durable background path chains via the
5438
5519
  * `isBackgroundWorker` branch above, never both.
5439
- * Aborted / user-stopped runs do NOT chain either way.
5520
+ * Aborted / user-stopped runs do NOT chain either way, and neither does a run
5521
+ * whose no-progress streak has tripped
5522
+ * (`MAX_CONSECUTIVE_NO_PROGRESS_CONTINUATIONS`).
5440
5523
  */
5441
5524
  export function shouldChainBackgroundContinuation(opts) {
5442
5525
  const eligible = opts.isBackgroundWorker ||
@@ -5445,7 +5528,12 @@ export function shouldChainBackgroundContinuation(opts) {
5445
5528
  return (eligible &&
5446
5529
  opts.run.status !== "aborted" &&
5447
5530
  endsAtContinuationBoundary(opts.run) &&
5448
- opts.continuationCount < MAX_BACKGROUND_RUN_CONTINUATIONS);
5531
+ opts.continuationCount < MAX_BACKGROUND_RUN_CONTINUATIONS &&
5532
+ !resolveBackgroundNoProgressRepeat({
5533
+ run: opts.run,
5534
+ priorErrorCode: opts.priorNoProgressErrorCode,
5535
+ priorCount: opts.priorNoProgressCount,
5536
+ }).tripped);
5449
5537
  }
5450
5538
  /**
5451
5539
  * Minimum remaining budget (ms) a synchronous self-chain continuation chunk
@@ -5885,6 +5973,12 @@ export async function chainServerDrivenContinuation(opts) {
5885
5973
  continuationCount: opts.backgroundContinuationCount + 1,
5886
5974
  continuationReason,
5887
5975
  ...(actionPreparationTool ? { actionPreparationTool } : {}),
5976
+ ...(opts.noProgressRepeat?.errorCode
5977
+ ? {
5978
+ noProgressErrorCode: opts.noProgressRepeat.errorCode,
5979
+ noProgressCount: opts.noProgressRepeat.count,
5980
+ }
5981
+ : {}),
5888
5982
  backgroundFunctionRuntimeExpected: continuationExpectsNetlifyBackgroundFunction,
5889
5983
  };
5890
5984
  // Strip this chunk's own marker before persisting/forwarding — the next
@@ -6237,6 +6331,15 @@ export function createProductionAgentHandler(options) {
6237
6331
  Number.isFinite(backgroundRunMarker.continuationCount)
6238
6332
  ? Math.max(0, Math.floor(backgroundRunMarker.continuationCount))
6239
6333
  : 0;
6334
+ // No-progress streak so far, carried on the marker: this invocation has no
6335
+ // other memory of what the previous chunk failed with.
6336
+ const priorNoProgressErrorCode = typeof backgroundRunMarker?.noProgressErrorCode === "string"
6337
+ ? backgroundRunMarker.noProgressErrorCode
6338
+ : undefined;
6339
+ const priorNoProgressCount = typeof backgroundRunMarker?.noProgressCount === "number" &&
6340
+ Number.isFinite(backgroundRunMarker.noProgressCount)
6341
+ ? Math.max(0, Math.floor(backgroundRunMarker.noProgressCount))
6342
+ : 0;
6240
6343
  let backgroundRunClaimedEarly = false;
6241
6344
  if (isBackgroundWorker && bgRunId) {
6242
6345
  const earlyClaim = await claimBackgroundWorkerRunEarly({
@@ -7312,12 +7415,19 @@ export function createProductionAgentHandler(options) {
7312
7415
  typeof threadId === "string" &&
7313
7416
  threadId.trim().length > 0 &&
7314
7417
  isAgentChatForegroundSelfChainEnabled();
7418
+ const noProgressRepeatForRun = (run) => resolveBackgroundNoProgressRepeat({
7419
+ run,
7420
+ priorErrorCode: priorNoProgressErrorCode,
7421
+ priorCount: priorNoProgressCount,
7422
+ });
7315
7423
  const willChainBackgroundContinuation = (run) => shouldChainBackgroundContinuation({
7316
7424
  isBackgroundWorker,
7317
7425
  run,
7318
7426
  continuationCount: backgroundContinuationCount,
7319
7427
  foregroundSelfChainEligible,
7320
7428
  dispatchedToBackground: dispatchToBackground,
7429
+ priorNoProgressErrorCode,
7430
+ priorNoProgressCount,
7321
7431
  });
7322
7432
  const completeTrackedProgressRun = async (run, completionError) => {
7323
7433
  if (!trackedProgressRunId || !trackedProgressOwner)
@@ -7404,6 +7514,14 @@ export function createProductionAgentHandler(options) {
7404
7514
  ? `${errEvent.errorCode ?? ""} ${errEvent.error ?? ""}`.trim()
7405
7515
  : "run ended in errored state").catch(() => { });
7406
7516
  }
7517
+ const noProgressRepeat = noProgressRepeatForRun(run);
7518
+ if (noProgressRepeat.tripped) {
7519
+ // Install the replacement before the thread writer runs. The
7520
+ // writer builds durable thread_data from events, so changing
7521
+ // only continuationTerminalEvent afterwards leaves the original
7522
+ // recoverable error persisted and eligible for another retry.
7523
+ installBackgroundNoProgressTerminalEvent(run, noProgressRepeat);
7524
+ }
7407
7525
  // Persist the (partial) assistant turn to thread_data FIRST — the
7408
7526
  // server-driven continuation below rebuilds from it, so it must be
7409
7527
  // committed before we re-fire.
@@ -7434,7 +7552,14 @@ export function createProductionAgentHandler(options) {
7434
7552
  // succeeded — a dispatch fast-fail degrades to the inline
7435
7553
  // foreground fallback, which is not a worker and rides the
7436
7554
  // connected client's auto_continue instead.)
7437
- if (willChainBackgroundContinuation(run)) {
7555
+ if (noProgressRepeat.tripped) {
7556
+ if (run.continuationTerminalEvent?.type === "error") {
7557
+ console.error(`[agent-chat] stopping background chain: ${noProgressRepeat.errorCode} ` +
7558
+ `failed ${noProgressRepeat.count}x with no progress`, run.runId);
7559
+ await recordRunDiagnostic(run.runId, RUN_DIAG_STAGE.workerThrew, `chain_stopped_no_progress code=${noProgressRepeat.errorCode} count=${noProgressRepeat.count}`).catch(() => { });
7560
+ }
7561
+ }
7562
+ else if (willChainBackgroundContinuation(run)) {
7438
7563
  // Full handoff discipline lives in
7439
7564
  // `chainServerDrivenContinuation` (exported + unit-tested):
7440
7565
  // per-turn SQL run budget, successor row PRE-INSERTED before
@@ -7447,6 +7572,7 @@ export function createProductionAgentHandler(options) {
7447
7572
  effectiveTurnId,
7448
7573
  requestBody: body,
7449
7574
  backgroundContinuationCount,
7575
+ noProgressRepeat,
7450
7576
  turnInputTokens,
7451
7577
  // Re-evaluate the durable gate rather than keying off
7452
7578
  // isBackgroundWorker: a successor chunk of a FOREGROUND
@@ -11,13 +11,18 @@ export interface ActiveRun {
11
11
  abort: AbortController;
12
12
  abortReason?: string;
13
13
  /**
14
- * Terminal event to emit when a server-driven continuation has been handed
15
- * off successfully. The continuation runs outside this process, so the
16
- * normal loop-level auto_continue event is not sent through this run's
17
- * `send` callback.
14
+ * Terminal event the completion callback installs in place of the one the
15
+ * loop stashed: `auto_continue` when a server-driven continuation has been
16
+ * handed off successfully (that continuation runs outside this process, so
17
+ * the loop-level auto_continue never goes through this run's `send`), or
18
+ * `error` when the callback decided the turn must stop here instead — the
19
+ * stashed error is recoverable by construction, so leaving it in place
20
+ * re-enters the very chain the callback just refused to continue.
18
21
  */
19
22
  continuationTerminalEvent?: Extract<AgentChatEvent, {
20
23
  type: "auto_continue";
24
+ } | {
25
+ type: "error";
21
26
  }>;
22
27
  startedAt: number;
23
28
  }
@@ -1282,6 +1282,13 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
1282
1282
  }
1283
1283
  : run;
1284
1284
  await onComplete(completionRun);
1285
+ // `completionRun` is a shallow COPY whenever the loop stashed a
1286
+ // terminal event, so a callback that installs its own terminal
1287
+ // event writes it to the copy and `resolveTerminalEventForCompletion`
1288
+ // below never sees it — the run then emits the pre-callback event
1289
+ // the callback was overriding.
1290
+ run.continuationTerminalEvent ??=
1291
+ completionRun.continuationTerminalEvent;
1285
1292
  }
1286
1293
  catch (err) {
1287
1294
  completionError = err;
@@ -9,6 +9,13 @@ function isInternalContinuationError(event) {
9
9
  const msg = event.error.toLowerCase();
10
10
  if (code === "builder_gateway_error")
11
11
  return false;
12
+ // An explicit `recoverable: false` outranks the code and message inference
13
+ // below, matching `isRecoverableContinuationError`. The background
14
+ // no-progress breaker stops a turn while PRESERVING the underlying transient
15
+ // code, so reading the code instead of the flag drops the one error the user
16
+ // was supposed to see out of the persisted turn.
17
+ if (event.recoverable === false)
18
+ return false;
12
19
  return (event.recoverable === true ||
13
20
  code === "builder_gateway_timeout" ||
14
21
  // Carries what `msg.includes("stream ended")` below used to: a
@@ -175,6 +175,16 @@ export interface AgentChatRequest {
175
175
  * boundary and refuses to chain past `MAX_BACKGROUND_RUN_CONTINUATIONS`.
176
176
  */
177
177
  continuationCount?: number;
178
+ /**
179
+ * Terminal error code the previous chunk failed with, plus how many chunks
180
+ * in a row have now ended on that same code having emitted no assistant
181
+ * text and no tool activity. Carried on the marker because each chunk is a
182
+ * separate invocation with no memory of the last one — without it the
183
+ * no-progress circuit breaker in `shouldChainBackgroundContinuation`
184
+ * cannot see a repeat at all.
185
+ */
186
+ noProgressErrorCode?: string;
187
+ noProgressCount?: number;
178
188
  /**
179
189
  * True when the dispatcher expects the self-POST to land in a real
180
190
  * Netlify `-background` function rather than the ~60s synchronous function.
@@ -163,6 +163,9 @@ class RemoteCodeAgentConnector {
163
163
  ok: false,
164
164
  error: err instanceof Error ? err.message : String(err),
165
165
  }));
166
+ if (result.ok === false) {
167
+ this.output.write(`Remote command ${command.id} failed: ${typeof result.error === "string" ? result.error : "Unknown connector error."}\n`);
168
+ }
166
169
  await this.postCommandResult(command, result);
167
170
  }
168
171
  }
@@ -334,7 +337,9 @@ class RemoteCodeAgentConnector {
334
337
  commandId: command.id,
335
338
  deviceId: this.config.deviceId,
336
339
  relayUrl: this.relayUrl,
337
- ...(portalWorkspace ? { remoteRunId: run.id } : {}),
340
+ ...(portalWorkspace && requestedRunId
341
+ ? { remoteRunId: requestedRunId }
342
+ : {}),
338
343
  },
339
344
  ...(portalWorkspace
340
345
  ? {
@@ -789,7 +789,11 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
789
789
  catch { }
790
790
  }, [subAgentNames, SUB_AGENT_NAMES_KEY]);
791
791
  // Open tabs — persisted to localStorage so they survive refresh.
792
- const OPEN_TABS_KEY = `agent-chat-open-tabs${keyPrefix}`;
792
+ // Per-scope, for the same reason the active thread is: the tab list must
793
+ // follow the resource in view, so one resource's tabs never stay mounted
794
+ // (and rebroadcasting their run state) while another resource is open.
795
+ const scopeKeyPart = scope ? `:scope:${scope.type}:${scope.id}` : "";
796
+ const OPEN_TABS_KEY = `agent-chat-open-tabs${keyPrefix}${scopeKeyPart}`;
793
797
  const [openTabIds, setOpenTabIds] = useState(() => {
794
798
  if (!restoreActiveThread && activeThreadId) {
795
799
  for (const id of [activeThreadId])
@@ -814,7 +818,35 @@ export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderO
814
818
  const openTabIdsRef = useRef(openTabIds);
815
819
  openTabIdsRef.current = openTabIds;
816
820
  const initializedRef = useRef(false);
821
+ // Rehydrate open tabs when the scope flips. Read the new key before the
822
+ // persistence effect can write the current (now-wrong) tab list under it.
817
823
  const openTabsKeyRef = useRef(OPEN_TABS_KEY);
824
+ useEffect(() => {
825
+ if (openTabsKeyRef.current === OPEN_TABS_KEY)
826
+ return;
827
+ openTabsKeyRef.current = OPEN_TABS_KEY;
828
+ initializedRef.current = false;
829
+ if (!restoreActiveThread) {
830
+ setOpenTabIds(activeThreadId ? [activeThreadId] : []);
831
+ return;
832
+ }
833
+ try {
834
+ const saved = localStorage.getItem(OPEN_TABS_KEY);
835
+ if (saved) {
836
+ const parsed = JSON.parse(saved);
837
+ if (Array.isArray(parsed)) {
838
+ for (const id of parsed)
839
+ mountedTabsRef.current.add(id);
840
+ setOpenTabIds(parsed);
841
+ return;
842
+ }
843
+ }
844
+ }
845
+ catch {
846
+ // coercion-ok: malformed persisted tab data is an absent tab list.
847
+ }
848
+ setOpenTabIds([]);
849
+ }, [OPEN_TABS_KEY, activeThreadId, restoreActiveThread]);
818
850
  useBrowserLayoutEffect(() => {
819
851
  const nextScope = scope;
820
852
  if (!nextScope)
@@ -473,6 +473,17 @@ async function readChunkWithProgressTimeout(reader, lastMeaningfulEventAt, noPro
473
473
  function isAutoRecoverableError(ev, errMsg) {
474
474
  const code = String(ev.errorCode ?? "").toLowerCase();
475
475
  const msg = errMsg.toLowerCase();
476
+ // An explicit `recoverable: false` outranks EVERY inference below — the code
477
+ // list as well as the message sniff — matching the server's own precedence in
478
+ // `isRecoverableContinuationError`. The repeat guards stop a turn with a
479
+ // message that names the looping tool, so a stop on
480
+ // `list-workspace-connections` matched the "connection" sniff and
481
+ // auto-continued the very loop it was emitted to break; the background
482
+ // no-progress breaker stops one while PRESERVING the underlying transient
483
+ // code (so the failure stays diagnosable), so reading the code instead of the
484
+ // flag re-POSTs the exact chain the server just refused to continue.
485
+ if (ev.recoverable === false)
486
+ return false;
476
487
  if (code === "context_length_exceeded" ||
477
488
  code === "input_too_long" ||
478
489
  code.startsWith("credits-limit") ||
@@ -548,13 +559,6 @@ function isAutoRecoverableError(ev, errMsg) {
548
559
  }
549
560
  if (ev.recoverable === true)
550
561
  return true;
551
- // An explicit flag outranks the message sniff below, which exists only for
552
- // events that carry no flag at all. The repeat guards stop a turn with
553
- // `recoverable: false` and a message that names the looping tool, so a stop
554
- // on `list-workspace-connections` matched the "connection" sniff and
555
- // auto-continued the very loop it was emitted to break.
556
- if (ev.recoverable === false)
557
- return false;
558
562
  if (msg.includes("daily gateway request cap"))
559
563
  return false;
560
564
  // The engine's structural verdict, checked after every terminal code above so
@@ -467,9 +467,9 @@ export function useChatThreads(apiUrl = agentNativePath("/_agent-native/agent-ch
467
467
  // it; the server hasn't seen it yet because there's no POST anymore,
468
468
  // the row gets written when the user sends a message.
469
469
  // - savedId is set but not on the current page → look it up directly. A
470
- // found thread stays active. An unavailable lookup keeps the saved id so
471
- // the detail surface can preserve cached state and offer recovery instead
472
- // of replacing a shared/reopened conversation with a blank local tab.
470
+ // found thread stays active. An unavailable lookup keeps the saved id for
471
+ // list-only readers and explicit routes, while a normal home surface drops
472
+ // a dead local pointer instead of opening with a restore error.
473
473
  // - No savedId → synthesize a fresh local id (no POST; server creates the
474
474
  // row on first message). The server may contain chats from another
475
475
  // branch, preview, or project that shares the same user/database, so
@@ -511,12 +511,12 @@ export function useChatThreads(apiUrl = agentNativePath("/_agent-native/agent-ch
511
511
  const restoredIsUnavailable = restoredThread === null && lookupRestored && !restoredOnPage;
512
512
  const restoredBelongsElsewhere = Boolean(restoredThread &&
513
513
  !threadCanStayVisibleInScope(restoredThread.scope ?? null, scopeRef.current));
514
- // Keep the saved id when the direct lookup says 404. AssistantChat owns
515
- // the detail restore and can show a retryable error while preserving any
516
- // cached transcript or composer draft. Replacing the id here silently
517
- // turns a shared/reopened conversation into a new blank chat after the
518
- // slower lookup finishes.
519
- const restoredNeedsReplacement = restoredBelongsElsewhere;
514
+ // A missing saved id is stale local UI state on a normal home surface,
515
+ // not a reason to show an error above a fresh composer. Preserve it for
516
+ // explicit routes and list-only readers, where the caller still owns
517
+ // recovery for a deliberately selected thread.
518
+ const restoredNeedsReplacement = restoredBelongsElsewhere ||
519
+ (restoredIsUnavailable && autoCreate && !routeControlsActiveThread);
520
520
  if (restoredNeedsReplacement)
521
521
  setActiveThreadId(null);
522
522
  const savedId = restoredNeedsReplacement ? null : restoredId;
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- error: string;
17
16
  ok?: undefined;
17
+ error: string;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
17
17
  id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
+ error?: undefined;
20
21
  configured?: undefined;
21
22
  connectPath?: undefined;
22
23
  url: string;
23
24
  id: string;
24
25
  provider: string;
25
- error?: undefined;
26
26
  }>;
27
27
  export default _default;
@@ -42,22 +42,22 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
42
42
  avgEvalScore: number;
43
43
  } | {
44
44
  error?: undefined;
45
+ ok?: undefined;
45
46
  summary: import("./types.js").TraceSummary;
46
47
  spans: import("./types.js").TraceSpan[];
47
48
  id?: undefined;
48
- ok?: undefined;
49
49
  } | {
50
50
  error?: undefined;
51
+ ok?: undefined;
51
52
  summary?: undefined;
52
53
  spans?: undefined;
53
54
  id: string;
54
- ok?: undefined;
55
55
  } | {
56
+ ok?: undefined;
56
57
  summary?: undefined;
57
58
  spans?: undefined;
58
59
  id?: undefined;
59
60
  error: any;
60
- ok?: undefined;
61
61
  } | {
62
62
  error?: undefined;
63
63
  summary?: undefined;
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- ok: boolean;
19
18
  error?: undefined;
19
+ ok: boolean;
20
20
  }>>;
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
48
48
  }>;
49
49
  /** DELETE /_agent-native/resources/:id — delete a resource */
50
50
  export declare function handleDeleteResource(event: any): Promise<{
51
- error: string;
52
51
  ok?: undefined;
52
+ error: string;
53
53
  } | {
54
54
  error?: undefined;
55
55
  ok: boolean;
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
34
34
  /** POST /_agent-native/secrets/:key — write a secret. */
35
35
  export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
36
36
  error: string;
37
- status?: undefined;
38
37
  ok?: undefined;
38
+ status?: undefined;
39
39
  } | {
40
+ error?: undefined;
40
41
  ok: boolean;
41
42
  status: string;
42
- error?: undefined;
43
43
  } | {
44
+ ok?: undefined;
44
45
  error: string;
45
46
  removed?: undefined;
46
- ok?: undefined;
47
47
  } | {
48
+ error?: undefined;
48
49
  ok: boolean;
49
50
  removed: boolean;
50
- error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
54
54
  * or the current stored value without changing anything.
55
55
  */
56
56
  export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
+ ok?: undefined;
57
58
  error: string;
58
59
  note?: undefined;
59
- ok?: undefined;
60
60
  } | {
61
+ error?: undefined;
61
62
  ok: boolean;
62
63
  note?: undefined;
63
- error?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  ok: boolean;
66
67
  note: string;
67
- error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
+ error?: undefined;
98
99
  ok: boolean;
99
100
  key: string;
100
- error?: undefined;
101
101
  } | {
102
+ error?: undefined;
102
103
  ok: boolean;
103
104
  removed: boolean;
104
- error?: undefined;
105
105
  }>>;
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
+ error?: undefined;
29
30
  token: string;
30
31
  expiresAt: string;
31
32
  ttlSeconds: number;
32
- error?: undefined;
33
33
  }>>;
@@ -20,6 +20,6 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- text: string;
24
23
  error?: undefined;
24
+ text: string;
25
25
  }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.161.4",
3
+ "version": "0.161.6",
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": {
@@ -427,8 +427,8 @@
427
427
  "y-protocols": "^1.0.7",
428
428
  "yjs": "^13.6.31",
429
429
  "zod": "^4.3.6",
430
- "@agent-native/recap-cli": "0.5.4",
431
- "@agent-native/toolkit": "^0.16.4"
430
+ "@agent-native/toolkit": "^0.16.4",
431
+ "@agent-native/recap-cli": "0.5.4"
432
432
  },
433
433
  "devDependencies": {
434
434
  "@ai-sdk/anthropic": "^3.0.71",