@prestyj/agent 5.9.0 → 5.11.0

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.
package/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  isAbortError: () => isAbortError,
27
27
  isBillingError: () => isBillingError,
28
28
  isContextOverflow: () => isContextOverflow,
29
+ isLocalBackendUrl: () => isLocalBackendUrl,
29
30
  isUsageLimitError: () => isUsageLimitError,
30
31
  setStreamDiagnostic: () => setStreamDiagnostic
31
32
  });
@@ -37,7 +38,32 @@ var import_ai2 = require("@prestyj/ai");
37
38
  // src/agent-loop.ts
38
39
  var import_zod = require("zod");
39
40
  var import_ai = require("@prestyj/ai");
41
+
42
+ // src/local-backend.ts
43
+ function isLocalBackendUrl(baseUrl) {
44
+ if (!baseUrl) return false;
45
+ let host;
46
+ try {
47
+ host = new URL(baseUrl).hostname.toLowerCase();
48
+ } catch {
49
+ return false;
50
+ }
51
+ if (host === "[::1]" || host === "::1") return true;
52
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
53
+ if (host === "0.0.0.0") return true;
54
+ if (host.endsWith(".local")) return true;
55
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
56
+ if (ipv4) {
57
+ const octets = ipv4.slice(1).map(Number);
58
+ if (octets.some((n) => n > 255)) return false;
59
+ return octets[0] === 127;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ // src/agent-loop.ts
40
65
  var DEFAULT_MAX_TURNS = 300;
66
+ var DEFAULT_TOOL_TIMEOUT_MS = 3e5;
41
67
  var _diagFn = null;
42
68
  function setStreamDiagnostic(fn) {
43
69
  _diagFn = fn;
@@ -240,6 +266,9 @@ function abortablePromise(promise, signal) {
240
266
  promise.then(resolveOnce, rejectOnce);
241
267
  });
242
268
  }
269
+ function turnBudgetContinuationPrompt() {
270
+ return "[You reached the turn limit for this segment but the work is not finished, so you have been granted more turns. Before continuing, state in one or two sentences what is already done and what remains, then keep going from there \u2014 do not restart work that is already complete.]";
271
+ }
243
272
  function abortableSleep(ms, signal) {
244
273
  if (signal?.aborted) return Promise.reject(createAbortError());
245
274
  return new Promise((resolve, reject) => {
@@ -261,6 +290,9 @@ function closeIterator(iterator) {
261
290
  }
262
291
  async function* agentLoop(messages, options) {
263
292
  const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS;
293
+ let effectiveMaxTurns = maxTurns;
294
+ const maxTurnExtensions = Math.max(0, options.maxTurnExtensions ?? 2);
295
+ let turnExtensions = 0;
264
296
  const maxContinuations = options.maxContinuations ?? 5;
265
297
  let toolMap = new Map((options.tools ?? []).map((t) => [t.name, t]));
266
298
  const totalUsage = { inputTokens: 0, outputTokens: 0 };
@@ -303,16 +335,17 @@ async function* agentLoop(messages, options) {
303
335
  const STREAM_THINKING_IDLE_TIMEOUT_MS = 3e5;
304
336
  const STREAM_THINKING_HARD_TIMEOUT_MS = 6e5;
305
337
  const NON_STREAMING_HARD_TIMEOUT_MS = 3e5;
306
- const isSakana = options.provider === "sakana";
307
- const firstEventTimeoutMs = isSakana ? STREAM_THINKING_IDLE_TIMEOUT_MS : STREAM_FIRST_EVENT_TIMEOUT_MS;
308
- const initialHardTimeoutMs = isSakana ? STREAM_THINKING_HARD_TIMEOUT_MS : STREAM_HARD_TIMEOUT_MS;
338
+ const usesSilentReasoningBudget = options.provider === "sakana" || options.provider === "openai" && options.thinking != null;
339
+ const localBackend = isLocalBackendUrl(options.baseUrl);
340
+ const firstEventTimeoutMs = localBackend ? Number.POSITIVE_INFINITY : usesSilentReasoningBudget ? STREAM_THINKING_IDLE_TIMEOUT_MS : STREAM_FIRST_EVENT_TIMEOUT_MS;
341
+ const initialHardTimeoutMs = localBackend || usesSilentReasoningBudget ? STREAM_THINKING_HARD_TIMEOUT_MS : STREAM_HARD_TIMEOUT_MS;
309
342
  const MAX_TOOLCALL_DELTA_CHARS = 1e6;
310
- const MAX_TOOLCALL_DELTA_EVENTS = 2e4;
343
+ const MAX_TOOLCALL_NO_PROGRESS_EVENTS = 2e4;
311
344
  let logicalTurnStartedAt = 0;
312
345
  let firstProviderEventAt;
313
346
  let providerDurationMs = 0;
314
347
  try {
315
- while (turn < maxTurns) {
348
+ while (turn < effectiveMaxTurns) {
316
349
  options.signal?.throwIfAborted();
317
350
  turn++;
318
351
  if (logicalTurnStartedAt === 0) logicalTurnStartedAt = Date.now();
@@ -333,7 +366,11 @@ async function* agentLoop(messages, options) {
333
366
  messages: messages.length,
334
367
  chars: msgChars,
335
368
  provider: options.provider,
336
- model: options.model
369
+ model: options.model,
370
+ thinking: options.thinking ?? "off",
371
+ firstEventTimeoutMs,
372
+ initialHardTimeoutMs,
373
+ localBackend
337
374
  });
338
375
  }
339
376
  if (firstTurn && options.getSteeringMessages) {
@@ -379,6 +416,7 @@ async function* agentLoop(messages, options) {
379
416
  let lastEventType = "";
380
417
  let toolcallDeltaChars = 0;
381
418
  let toolcallDeltaCount = 0;
419
+ let toolcallNoProgressCount = 0;
382
420
  let runawayDetected = null;
383
421
  let attemptText = "";
384
422
  let lastYieldEndTime = Date.now();
@@ -391,6 +429,7 @@ async function* agentLoop(messages, options) {
391
429
  if (useNonStreamingFallback) return;
392
430
  if (idleTimer) clearTimeout(idleTimer);
393
431
  const timeoutMs = hasReceivedEvent ? STREAM_IDLE_TIMEOUT_MS : hasReceivedThinking ? STREAM_THINKING_IDLE_TIMEOUT_MS : firstEventTimeoutMs;
432
+ if (!Number.isFinite(timeoutMs)) return;
394
433
  idleTimer = setTimeout(() => {
395
434
  diag("idle_timeout_fired", {
396
435
  events: streamEventCount,
@@ -532,11 +571,13 @@ async function* agentLoop(messages, options) {
532
571
  const chunkChars = event.argsJson?.length ?? 0;
533
572
  toolcallDeltaChars += chunkChars;
534
573
  toolcallDeltaCount++;
535
- if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallDeltaCount > MAX_TOOLCALL_DELTA_EVENTS)) {
574
+ toolcallNoProgressCount = chunkChars > 0 ? 0 : toolcallNoProgressCount + 1;
575
+ if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallNoProgressCount > MAX_TOOLCALL_NO_PROGRESS_EVENTS)) {
536
576
  runawayDetected = {
537
577
  kind: toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS ? "chars" : "events",
538
578
  chars: toolcallDeltaChars,
539
- events: toolcallDeltaCount
579
+ events: toolcallDeltaCount,
580
+ noProgressEvents: toolcallNoProgressCount
540
581
  };
541
582
  diag("runaway_toolcall_detected", {
542
583
  ...runawayDetected,
@@ -718,11 +759,15 @@ async function* agentLoop(messages, options) {
718
759
  turn--;
719
760
  continue;
720
761
  }
721
- const detail = runawayDetected.kind === "chars" ? `${(runawayDetected.chars / 1024).toFixed(0)} KB of tool-call arguments` : `${runawayDetected.events} tool-call delta events`;
762
+ const detail = runawayDetected.kind === "chars" ? `${(runawayDetected.chars / 1024).toFixed(0)} KB of tool-call arguments` : `${runawayDetected.noProgressEvents} consecutive tool-call delta events without argument progress`;
722
763
  yield {
723
764
  type: "error",
724
- error: new Error(
725
- `The model repeatedly failed to close a tool call after ${MAX_RUNAWAY_TOOLCALL_RETRIES} automatic retries (${detail}). Switch models and retry; your conversation is preserved.`
765
+ error: new import_ai.EZCoderAIError(
766
+ `The model repeatedly failed to close a tool call after ${MAX_RUNAWAY_TOOLCALL_RETRIES} automatic retries (${detail}). Your conversation is preserved.`,
767
+ {
768
+ source: "provider",
769
+ hint: "Retry once. If it keeps happening, switch models and continue the preserved conversation."
770
+ }
726
771
  )
727
772
  };
728
773
  break;
@@ -749,7 +794,15 @@ async function* agentLoop(messages, options) {
749
794
  role: "assistant",
750
795
  content: [{ type: "text", text: attemptText }]
751
796
  });
752
- messages.push({ role: "user", content: PARTIAL_CONTINUATION_PROMPT });
797
+ messages.push({
798
+ role: "user",
799
+ content: PARTIAL_CONTINUATION_PROMPT,
800
+ provenance: {
801
+ source: "runtime",
802
+ kind: "continuation",
803
+ visibility: "hidden"
804
+ }
805
+ });
753
806
  preservedChars = attemptText.length;
754
807
  }
755
808
  diag("retry", {
@@ -775,15 +828,29 @@ async function* agentLoop(messages, options) {
775
828
  continue;
776
829
  }
777
830
  if (transportFailure) {
831
+ const cause = malformed ? "malformed_stream" : socketDrop ? "socket_drop" : "stream_stall";
778
832
  diag("stall_exhausted", {
779
833
  stallRetries: MAX_STALL_RETRIES,
780
834
  provider: options.provider,
781
- model: options.model
835
+ model: options.model,
836
+ cause,
837
+ nonStreaming: useNonStreamingFallback,
838
+ events: streamEventCount,
839
+ eventTypes: eventTypeCounts,
840
+ lastEventType,
841
+ sinceLastEventMs: Date.now() - lastEventTime,
842
+ attemptDurationMs: Date.now() - streamCallStart,
843
+ maxConsumerLagMs
782
844
  });
783
845
  yield {
784
846
  type: "error",
785
- error: new Error(
786
- `The API provider's stream stalled ${MAX_STALL_RETRIES} times \u2014 the provider may be experiencing capacity issues. Your conversation is preserved. Send another message to retry.`
847
+ error: new import_ai.EZCoderAIError(
848
+ `The connection to the API provider stopped responding after ${MAX_STALL_RETRIES} automatic retries. Your conversation is preserved.`,
849
+ {
850
+ source: "network",
851
+ hint: "Retry once. If it keeps happening on this device, disable any VPN or proxy and allow EZ Coder through firewall or antivirus web protection.",
852
+ cause: err
853
+ }
787
854
  )
788
855
  };
789
856
  break;
@@ -854,6 +921,9 @@ async function* agentLoop(messages, options) {
854
921
  useNonStreamingFallback = false;
855
922
  totalUsage.inputTokens += response.usage.inputTokens;
856
923
  totalUsage.outputTokens += response.usage.outputTokens;
924
+ if (response.usage.reasoningTokens) {
925
+ totalUsage.reasoningTokens = (totalUsage.reasoningTokens ?? 0) + response.usage.reasoningTokens;
926
+ }
857
927
  if (response.usage.cacheRead) {
858
928
  totalUsage.cacheRead = (totalUsage.cacheRead ?? 0) + response.usage.cacheRead;
859
929
  }
@@ -905,7 +975,15 @@ async function* agentLoop(messages, options) {
905
975
  model: options.model
906
976
  });
907
977
  yield { type: "truncated", reason: "max_tokens", continued: true };
908
- messages.push({ role: "user", content: MAX_TOKENS_CONTINUATION_PROMPT });
978
+ messages.push({
979
+ role: "user",
980
+ content: MAX_TOKENS_CONTINUATION_PROMPT,
981
+ provenance: {
982
+ source: "runtime",
983
+ kind: "continuation",
984
+ visibility: "hidden"
985
+ }
986
+ });
909
987
  continue;
910
988
  }
911
989
  yield { type: "truncated", reason: "max_tokens", continued: false };
@@ -981,6 +1059,7 @@ async function* agentLoop(messages, options) {
981
1059
  );
982
1060
  const executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
983
1061
  messages.push({ role: "tool", content: executionResult.toolResults });
1062
+ yield { type: "checkpoint", turn };
984
1063
  const toolsAborted = executionResult.aborted;
985
1064
  if (fatalToolArgumentError) {
986
1065
  if (fatalToolArgumentRecoverable && !toolArgumentAutoContinueUsed) {
@@ -1012,8 +1091,51 @@ async function* agentLoop(messages, options) {
1012
1091
  }
1013
1092
  }
1014
1093
  }
1015
- if (turn >= maxTurns) {
1016
- hitMaxTurns = true;
1094
+ if (turn >= effectiveMaxTurns) {
1095
+ let extended = false;
1096
+ if (options.onTurnBudgetExhausted && turnExtensions < maxTurnExtensions) {
1097
+ const extension = turnExtensions + 1;
1098
+ let granted = false;
1099
+ try {
1100
+ granted = await options.onTurnBudgetExhausted({
1101
+ turn,
1102
+ maxTurns: effectiveMaxTurns,
1103
+ extension
1104
+ });
1105
+ } catch {
1106
+ granted = false;
1107
+ }
1108
+ if (granted) {
1109
+ turnExtensions = extension;
1110
+ effectiveMaxTurns += maxTurns;
1111
+ extended = true;
1112
+ diag("turn_budget_extended", {
1113
+ turn,
1114
+ grantedTurns: effectiveMaxTurns,
1115
+ extension,
1116
+ provider: options.provider,
1117
+ model: options.model
1118
+ });
1119
+ yield {
1120
+ type: "turn_budget_extended",
1121
+ turn,
1122
+ grantedTurns: effectiveMaxTurns,
1123
+ extension
1124
+ };
1125
+ messages.push({
1126
+ role: "user",
1127
+ content: turnBudgetContinuationPrompt(),
1128
+ provenance: {
1129
+ source: "runtime",
1130
+ kind: "continuation",
1131
+ visibility: "hidden"
1132
+ }
1133
+ });
1134
+ }
1135
+ }
1136
+ if (!extended) {
1137
+ hitMaxTurns = true;
1138
+ }
1017
1139
  }
1018
1140
  }
1019
1141
  } finally {
@@ -1029,14 +1151,15 @@ async function* agentLoop(messages, options) {
1029
1151
  if (hitMaxTurns) {
1030
1152
  diag("max_turns_reached", {
1031
1153
  turn,
1032
- maxTurns,
1154
+ maxTurns: effectiveMaxTurns,
1155
+ extensions: turnExtensions,
1033
1156
  provider: options.provider,
1034
1157
  model: options.model
1035
1158
  });
1036
1159
  yield {
1037
1160
  type: "max_turns",
1038
1161
  totalTurns: turn,
1039
- maxTurns
1162
+ maxTurns: effectiveMaxTurns
1040
1163
  };
1041
1164
  }
1042
1165
  yield {
@@ -1072,7 +1195,7 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1072
1195
  try {
1073
1196
  const parsed = tool.parameters.parse(toolCall.args);
1074
1197
  const callerSignal = options.signal;
1075
- const toolTimeout = AbortSignal.timeout(3e5);
1198
+ const toolTimeout = AbortSignal.timeout(tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
1076
1199
  const ctx = {
1077
1200
  signal: callerSignal ? AbortSignal.any([callerSignal, toolTimeout]) : toolTimeout,
1078
1201
  toolCallId: toolCall.id,
@@ -1285,9 +1408,9 @@ function capToolResults(toolResults, maxToolResultChars) {
1285
1408
  const originalChars = toolResult.content.length;
1286
1409
  const headChars = Math.floor(max * 0.7);
1287
1410
  const tailChars = max - headChars;
1288
- const head = toolResult.content.slice(0, headChars);
1289
- const tail = toolResult.content.slice(-tailChars);
1290
- const omitted = originalChars - headChars - tailChars;
1411
+ const head = (0, import_ai.sliceHead)(toolResult.content, headChars);
1412
+ const tail = (0, import_ai.sliceTail)(toolResult.content, tailChars);
1413
+ const omitted = originalChars - head.length - tail.length;
1291
1414
  toolResult.content = head + `
1292
1415
 
1293
1416
  [... ${omitted} characters omitted ...]
@@ -1322,11 +1445,11 @@ function capTurnToolResults(toolResults, maxTurnToolResultChars) {
1322
1445
  const headChars = Math.floor(fairShare * 0.7);
1323
1446
  const tailChars = fairShare - headChars;
1324
1447
  const omitted = originalChars - fairShare;
1325
- toolResult.content = toolResult.content.slice(0, headChars) + `
1448
+ toolResult.content = (0, import_ai.sliceHead)(toolResult.content, headChars) + `
1326
1449
 
1327
1450
  [... ${omitted} characters trimmed: this turn's combined tool results exceeded the per-turn budget. Re-run this call alone with narrower filters or offset/limit if you need the omitted content ...]
1328
1451
 
1329
- ` + (tailChars > 0 ? toolResult.content.slice(-tailChars) : "");
1452
+ ` + (0, import_ai.sliceTail)(toolResult.content, tailChars);
1330
1453
  toolResult.capped = {
1331
1454
  originalChars: toolResult.capped?.originalChars ?? originalChars,
1332
1455
  keptChars: toolResult.content.length,
@@ -1345,12 +1468,14 @@ function truncateToolResultText(text, maxChars) {
1345
1468
  if (text.length <= maxChars) return text;
1346
1469
  const tailChars = Math.min(Math.floor(maxChars * 0.3), 2e4);
1347
1470
  const headChars = Math.max(maxChars - tailChars, 0);
1348
- const omitted = text.length - headChars - tailChars;
1349
- return `${text.slice(0, headChars)}
1471
+ const head = (0, import_ai.sliceHead)(text, headChars);
1472
+ const tail = (0, import_ai.sliceTail)(text, tailChars);
1473
+ const omitted = text.length - head.length - tail.length;
1474
+ return `${head}
1350
1475
 
1351
1476
  [... ${omitted} characters omitted after context overflow ...]
1352
1477
 
1353
- ${text.slice(-tailChars)}`;
1478
+ ${tail}`;
1354
1479
  }
1355
1480
  function truncateOversizedToolResults(messages, maxChars) {
1356
1481
  if (maxChars <= 0) return false;
@@ -1603,6 +1728,7 @@ var Agent = class {
1603
1728
  isAbortError,
1604
1729
  isBillingError,
1605
1730
  isContextOverflow,
1731
+ isLocalBackendUrl,
1606
1732
  isUsageLimitError,
1607
1733
  setStreamDiagnostic
1608
1734
  });