@agent-native/core 0.84.40 → 0.84.42

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.
@@ -560,6 +560,7 @@ function maxRetriesForError(err) {
560
560
  }
561
561
  const TOOL_INPUT_ACTIVITY_INTERVAL_MS = 1500;
562
562
  const ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS = 90_000;
563
+ const ACTION_PREPARATION_ZERO_BYTE_RESTART_LIMIT = 2;
563
564
  const MAX_TEXT_ATTACHMENT_CHARS = 60_000;
564
565
  const MAX_SELECTION_CONTEXT_CHARS = 8_000;
565
566
  const MAX_RESOURCE_INVENTORY_ITEMS = 40;
@@ -1977,6 +1978,7 @@ export async function runAgentLoop(opts) {
1977
1978
  const toolInputBytes = new Map();
1978
1979
  let lastToolInputActivityAt = 0;
1979
1980
  const activeToolInputs = new Map();
1981
+ let zeroByteToolInputRestart;
1980
1982
  let endedForActionPreparationNoProgress = false;
1981
1983
  const sendToolInputActivity = (toolName, toolInputId, progressBytes, force = false) => {
1982
1984
  const now = Date.now();
@@ -2031,6 +2033,37 @@ export async function runAgentLoop(opts) {
2031
2033
  bytes,
2032
2034
  });
2033
2035
  };
2036
+ const resetZeroByteToolInputRestart = (toolName) => {
2037
+ if (!zeroByteToolInputRestart)
2038
+ return;
2039
+ if (!toolName || zeroByteToolInputRestart.toolName === toolName) {
2040
+ zeroByteToolInputRestart = undefined;
2041
+ }
2042
+ };
2043
+ const noteZeroByteToolInputStart = (toolName) => {
2044
+ if (!toolName)
2045
+ return false;
2046
+ const now = Date.now();
2047
+ if (zeroByteToolInputRestart?.toolName === toolName) {
2048
+ zeroByteToolInputRestart = {
2049
+ ...zeroByteToolInputRestart,
2050
+ lastStartedAt: now,
2051
+ count: zeroByteToolInputRestart.count + 1,
2052
+ };
2053
+ }
2054
+ else {
2055
+ zeroByteToolInputRestart = {
2056
+ toolName,
2057
+ firstStartedAt: now,
2058
+ lastStartedAt: now,
2059
+ count: 1,
2060
+ };
2061
+ }
2062
+ return (zeroByteToolInputRestart.count >
2063
+ ACTION_PREPARATION_ZERO_BYTE_RESTART_LIMIT &&
2064
+ now - zeroByteToolInputRestart.firstStartedAt >=
2065
+ ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS);
2066
+ };
2034
2067
  const clearActiveToolInputs = () => {
2035
2068
  activeToolInputs.clear();
2036
2069
  };
@@ -2060,6 +2093,7 @@ export async function runAgentLoop(opts) {
2060
2093
  }
2061
2094
  }
2062
2095
  if (event.type === "text-delta") {
2096
+ resetZeroByteToolInputRestart();
2063
2097
  streamedAssistantText += event.text;
2064
2098
  send({ type: "text", text: event.text });
2065
2099
  }
@@ -2078,6 +2112,14 @@ export async function runAgentLoop(opts) {
2078
2112
  trackActiveToolInput(key, event.name, 0);
2079
2113
  }
2080
2114
  sendToolInputActivity(event.name, key, undefined, true);
2115
+ if (noteZeroByteToolInputStart(event.name)) {
2116
+ send({
2117
+ type: "auto_continue",
2118
+ reason: "no_progress",
2119
+ });
2120
+ endedForActionPreparationNoProgress = true;
2121
+ break;
2122
+ }
2081
2123
  }
2082
2124
  else if (event.type === "tool-input-delta") {
2083
2125
  const key = event.id ?? event.name;
@@ -2092,6 +2134,7 @@ export async function runAgentLoop(opts) {
2092
2134
  toolInputBytes.set(key, progressBytes);
2093
2135
  if (progressBytes > previous) {
2094
2136
  trackActiveToolInput(key, toolName, progressBytes);
2137
+ resetZeroByteToolInputRestart(toolName);
2095
2138
  }
2096
2139
  }
2097
2140
  sendToolInputActivity(toolName, key, progressBytes);
@@ -2102,9 +2145,11 @@ export async function runAgentLoop(opts) {
2102
2145
  else if (event.type === "tool-call") {
2103
2146
  // The authoritative tool-call blocks arrive in assistant-content.
2104
2147
  resetActiveToolInput(event.name, event.id);
2148
+ resetZeroByteToolInputRestart(event.name);
2105
2149
  }
2106
2150
  else if (event.type === "tool-call-error") {
2107
2151
  resetActiveToolInput(event.name, event.id);
2152
+ resetZeroByteToolInputRestart(event.name);
2108
2153
  toolCallErrors.set(event.id, {
2109
2154
  name: event.name,
2110
2155
  input: event.input,
@@ -3135,38 +3180,110 @@ function isPreparingActionActivityEvent(event) {
3135
3180
  return label.startsWith("preparing ") && label.includes(" action");
3136
3181
  }
3137
3182
  export function lastUnfinishedPreparingActionToolFromEvents(events) {
3138
- let active = null;
3139
- for (const event of events) {
3183
+ const active = new Map();
3184
+ const idlessToolStarts = new Map();
3185
+ const removeOldestMatchingActivePreparation = (tool, shouldRemove = () => true) => {
3186
+ let oldest;
3187
+ for (const [key, value] of active) {
3188
+ if (value.tool !== tool || !shouldRemove(value))
3189
+ continue;
3190
+ if (!oldest || value.order < oldest.order) {
3191
+ oldest = {
3192
+ key,
3193
+ order: value.order,
3194
+ };
3195
+ }
3196
+ }
3197
+ if (oldest)
3198
+ active.delete(oldest.key);
3199
+ return Boolean(oldest);
3200
+ };
3201
+ const removeMatchingActivePreparation = (event) => {
3202
+ const id = event.id?.trim();
3203
+ const tool = event.tool?.trim();
3204
+ if (!tool)
3205
+ return;
3206
+ if (id) {
3207
+ if (!active.delete(`id:${id}`)) {
3208
+ removeOldestMatchingActivePreparation(tool, (value) => !value.id);
3209
+ }
3210
+ return;
3211
+ }
3212
+ if (event.type === "tool_start") {
3213
+ if (removeOldestMatchingActivePreparation(tool)) {
3214
+ idlessToolStarts.set(tool, (idlessToolStarts.get(tool) ?? 0) + 1);
3215
+ }
3216
+ return;
3217
+ }
3218
+ const startedCount = idlessToolStarts.get(tool) ?? 0;
3219
+ if (startedCount > 0) {
3220
+ if (startedCount === 1) {
3221
+ idlessToolStarts.delete(tool);
3222
+ }
3223
+ else {
3224
+ idlessToolStarts.set(tool, startedCount - 1);
3225
+ }
3226
+ return;
3227
+ }
3228
+ removeOldestMatchingActivePreparation(tool);
3229
+ };
3230
+ events.forEach((event, order) => {
3140
3231
  if (isPreparingActionActivityEvent(event)) {
3141
3232
  const tool = event.tool?.trim();
3142
3233
  if (tool) {
3143
- active = {
3234
+ const id = event.id?.trim();
3235
+ const key = id ? `id:${id}` : `tool:${tool}:${order}`;
3236
+ active.set(key, {
3144
3237
  tool,
3145
- ...(event.id?.trim() ? { id: event.id.trim() } : {}),
3146
- };
3238
+ order,
3239
+ ...(id ? { id } : {}),
3240
+ });
3147
3241
  }
3148
- continue;
3242
+ return;
3149
3243
  }
3150
3244
  if (event.type === "tool_start" || event.type === "tool_done") {
3151
- const tool = event.tool?.trim();
3152
- const id = event.id?.trim();
3153
- if (active &&
3154
- ((id && active.id === id) || (!id && tool && active.tool === tool))) {
3155
- active = null;
3156
- }
3157
- continue;
3245
+ removeMatchingActivePreparation(event);
3246
+ return;
3158
3247
  }
3159
3248
  if (event.type === "error" && isRecoverableContinuationError(event)) {
3160
- continue;
3249
+ return;
3161
3250
  }
3162
3251
  if (event.type === "clear" ||
3163
3252
  event.type === "done" ||
3164
3253
  event.type === "error" ||
3165
3254
  event.type === "missing_api_key") {
3166
- active = null;
3255
+ active.clear();
3256
+ idlessToolStarts.clear();
3257
+ }
3258
+ });
3259
+ let latest;
3260
+ for (const value of active.values()) {
3261
+ if (!latest || value.order > latest.order) {
3262
+ latest = value;
3167
3263
  }
3168
3264
  }
3169
- return active?.tool;
3265
+ return latest?.tool;
3266
+ }
3267
+ function endsAfterCompletedToolWithoutAssistantFinal(run) {
3268
+ let completedToolAfterLastAssistantText = false;
3269
+ for (const { event } of run.events) {
3270
+ if (event.type === "text" && event.text.trim().length > 0) {
3271
+ completedToolAfterLastAssistantText = false;
3272
+ continue;
3273
+ }
3274
+ if (event.type === "tool_done" && event.isError !== true) {
3275
+ completedToolAfterLastAssistantText = true;
3276
+ continue;
3277
+ }
3278
+ if (event.type === "clear" ||
3279
+ event.type === "error" ||
3280
+ event.type === "missing_api_key" ||
3281
+ event.type === "auto_continue" ||
3282
+ event.type === "loop_limit") {
3283
+ completedToolAfterLastAssistantText = false;
3284
+ }
3285
+ }
3286
+ return completedToolAfterLastAssistantText;
3170
3287
  }
3171
3288
  function lastUnfinishedPreparingActionTool(run) {
3172
3289
  return lastUnfinishedPreparingActionToolFromEvents(run.events.map(({ event }) => event));
@@ -3182,8 +3299,15 @@ export function backgroundContinuationReasonForRun(run) {
3182
3299
  if (last?.type === "error" && isRecoverableContinuationError(last)) {
3183
3300
  return continuationReasonForResumableError(new EngineError(last.error, { errorCode: last.errorCode }));
3184
3301
  }
3302
+ if (endsAfterCompletedToolWithoutAssistantFinal(run)) {
3303
+ return "stream_ended";
3304
+ }
3185
3305
  return "run_timeout";
3186
3306
  }
3307
+ function endsAtContinuationBoundary(run) {
3308
+ return (endsAtInternalContinuationBoundary(run) ||
3309
+ endsAfterCompletedToolWithoutAssistantFinal(run));
3310
+ }
3187
3311
  /**
3188
3312
  * Hard cap on server-driven background→background continuation chunks for a
3189
3313
  * single logical turn. A `backgroundFunction` run gets a ~13-min soft timeout,
@@ -3195,14 +3319,13 @@ export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
3195
3319
  /**
3196
3320
  * Whether the background worker should self-fire the next server-driven
3197
3321
  * continuation chunk. True only when this is a background worker run that ended
3198
- * at a recoverable soft-timeout boundary (not aborted/stopped) and the chain is
3199
- * still under its budget. Extracted so the decision is unit testable without
3200
- * booting the whole handler.
3322
+ * at a recoverable unfinished boundary (not aborted/stopped) and the chain is
3323
+ * still under its budget. Aborted / user-stopped runs do NOT chain.
3201
3324
  */
3202
3325
  export function shouldChainBackgroundContinuation(opts) {
3203
3326
  return (opts.isBackgroundWorker &&
3204
3327
  opts.run.status !== "aborted" &&
3205
- endsAtInternalContinuationBoundary(opts.run) &&
3328
+ endsAtContinuationBoundary(opts.run) &&
3206
3329
  opts.continuationCount < MAX_BACKGROUND_RUN_CONTINUATIONS);
3207
3330
  }
3208
3331
  export async function claimBackgroundWorkerRunEarly(opts) {
@@ -4122,10 +4245,15 @@ export function createProductionAgentHandler(options) {
4122
4245
  turnId: effectiveTurnId,
4123
4246
  }
4124
4247
  : null;
4248
+ const willChainBackgroundContinuation = (run) => shouldChainBackgroundContinuation({
4249
+ isBackgroundWorker,
4250
+ run,
4251
+ continuationCount: backgroundContinuationCount,
4252
+ });
4125
4253
  const completeTrackedProgressRun = async (run, completionError) => {
4126
4254
  if (!trackedProgressRunId || !trackedProgressOwner)
4127
4255
  return;
4128
- if (!completionError && endsAtInternalContinuationBoundary(run)) {
4256
+ if (!completionError && willChainBackgroundContinuation(run)) {
4129
4257
  return;
4130
4258
  }
4131
4259
  const terminalStatus = run.status === "aborted"
@@ -4210,7 +4338,7 @@ export function createProductionAgentHandler(options) {
4210
4338
  // below, they did not "throw").
4211
4339
  if (isBackgroundWorker &&
4212
4340
  run.status === "errored" &&
4213
- !endsAtInternalContinuationBoundary(run)) {
4341
+ !willChainBackgroundContinuation(run)) {
4214
4342
  const errEvent = [...run.events]
4215
4343
  .reverse()
4216
4344
  .find((e) => e.event.type === "error")?.event;
@@ -4231,28 +4359,24 @@ export function createProductionAgentHandler(options) {
4231
4359
  // agent-teams `fireInternalDispatch({ body: { mode: "continue" }})`
4232
4360
  // chain. Bounded by MAX_BACKGROUND_RUN_CONTINUATIONS. Aborted /
4233
4361
  // user-stopped runs do NOT chain.
4234
- if (shouldChainBackgroundContinuation({
4235
- // Self-chain server-side for EVERY durable worker, not only the
4236
- // ones inside a `-background` function. Server-driven
4237
- // continuation is the whole point of durable background: the run
4238
- // must survive the client disconnecting (closed tab), so it
4239
- // cannot depend on the browser re-POSTing `auto_continue`. A
4240
- // worker on the regular ~60s function — a Netlify routing miss,
4241
- // or a non-Netlify host (Vercel/Cloudflare/Render/Fly) that
4242
- // never emits a `-background` function checkpoints at the 40s
4243
- // soft-timeout and self-dispatches the next 40s chunk; a worker
4244
- // in a real `-background` function chains ~13-min chunks. Only
4245
- // the per-chunk BUDGET differs by function type (gated by
4246
- // `runsInBackgroundFunction` at the startRun call below); the
4247
- // continuation itself must stay server-driven on both. (The
4248
- // self-chain is only reachable when the initial dispatch already
4249
- // succeeded a dispatch fast-fail degrades to the inline
4250
- // foreground fallback, which is not a worker and rides the
4251
- // connected client's auto_continue instead.)
4252
- isBackgroundWorker,
4253
- run,
4254
- continuationCount: backgroundContinuationCount,
4255
- })) {
4362
+ // Self-chain server-side for EVERY durable worker, not only the
4363
+ // ones inside a `-background` function. Server-driven
4364
+ // continuation is the whole point of durable background: the run
4365
+ // must survive the client disconnecting (closed tab), so it
4366
+ // cannot depend on the browser re-POSTing `auto_continue`. A
4367
+ // worker on the regular ~60s function — a Netlify routing miss,
4368
+ // or a non-Netlify host (Vercel/Cloudflare/Render/Fly) that
4369
+ // never emits a `-background` function checkpoints at the 40s
4370
+ // soft-timeout and self-dispatches the next 40s chunk; a worker
4371
+ // in a real `-background` function chains ~13-min chunks. Only
4372
+ // the per-chunk BUDGET differs by function type (gated by
4373
+ // `runsInBackgroundFunction` at the startRun call below); the
4374
+ // continuation itself must stay server-driven on both. (The
4375
+ // self-chain is only reachable when the initial dispatch already
4376
+ // succeeded a dispatch fast-fail degrades to the inline
4377
+ // foreground fallback, which is not a worker and rides the
4378
+ // connected client's auto_continue instead.)
4379
+ if (willChainBackgroundContinuation(run)) {
4256
4380
  // Mint the next chunk's runId here and sign the dispatch token
4257
4381
  // over it, so the `_process-run` route's HMAC check and the
4258
4382
  // worker's run identity agree. Fresh runId (not this chunk's) so