@code-yeongyu/senpi-agent-core 2026.10.16 → 2026.10.18

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 (2) hide show
  1. package/dist/agent-loop.js +100 -8
  2. package/package.json +3 -3
@@ -452,10 +452,14 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
452
452
  }
453
453
  }
454
454
  try {
455
- const llmContext = await buildProviderContext(context, config, signal);
455
+ // The start bound (or, without one, the idle bound) runs from here, not from the first stream read: the
456
+ // context transform, the key lookup and the stream function can each hang, and a hung step must end the
457
+ // turn with a provider failure like a provider that never answers (senpi#3471).
458
+ const preStream = createPreStreamBound(normalizeTimeoutMs(config.streamStartTimeoutMs), normalizeTimeoutMs(streamIdleTimeoutMs), requestAbortController);
459
+ const llmContext = await preStream.run(buildProviderContext(context, config, requestAbortController.signal));
456
460
  // Resolve API key (important for expiring tokens)
457
- const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
458
- const response = await streamFunction(config.model, llmContext, {
461
+ const resolvedApiKey = (config.getApiKey ? await preStream.run(config.getApiKey(config.model.provider)) : undefined) || config.apiKey;
462
+ const response = await preStream.run(streamFunction(config.model, llmContext, {
459
463
  ...config,
460
464
  streamKind: "main",
461
465
  apiKey: resolvedApiKey,
@@ -472,11 +476,17 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
472
476
  },
473
477
  }
474
478
  : {}),
479
+ }),
480
+ // A stream that arrives after the bound ended the turn is closed here, not left to the provider.
481
+ // A custom stream function may resolve to something that is not a stream; there is nothing to close then.
482
+ (late) => {
483
+ if (typeof late?.[Symbol.asyncIterator] === "function")
484
+ closeAssistantIterator(late[Symbol.asyncIterator]());
475
485
  });
476
486
  // Record the requested level, whichever stream function answered.
477
487
  const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });
478
488
  const iterator = response[Symbol.asyncIterator]();
479
- const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), config.streamStartTimeoutMs, response);
489
+ const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs));
480
490
  try {
481
491
  while (true) {
482
492
  const next = await eventReader.next();
@@ -599,7 +609,13 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
599
609
  await emit({ type: "message_end", message: finalMessage });
600
610
  return { message: finalMessage, providerToolResults };
601
611
  }
602
- const finalMessage = createTerminalFailureAssistantMessage(config.model, signal?.aborted ? "aborted" : "error", error, partialMessage);
612
+ // A caller abort is the reason a turn ended even when the bound fired first on a step that never finished:
613
+ // the user gets the abort's own message, not a timeout and advice to raise it (#3481).
614
+ // A step that rethrows the caller's own reason (`throwIfAborted()`, a fetch rejected with it) shows that reason
615
+ // through the same shaping and cap (#3510).
616
+ const callerAbortedPastBound = signal?.aborted === true &&
617
+ (error instanceof StreamStartTimeoutError || isStreamIdleTimeoutError(error) || error === signal.reason);
618
+ const finalMessage = createTerminalFailureAssistantMessage(config.model, signal?.aborted ? "aborted" : "error", callerAbortedPastBound ? abortError(signal.reason) : error, partialMessage);
603
619
  propagateThinkingTiming(finalMessage);
604
620
  if (addedPartial) {
605
621
  context.messages[context.messages.length - 1] = finalMessage;
@@ -617,18 +633,94 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
617
633
  }
618
634
  }
619
635
  const ABORTED = Symbol("aborted");
636
+ const MAX_ABORT_REASON_CHARS = 500;
620
637
  function abortError(reason) {
621
- return reason instanceof Error ? reason : new Error("Request was aborted");
638
+ if (reason instanceof Error)
639
+ return reason;
640
+ // A caller's own words are its abort reason: a plain string, or an object carrying a `message` (#3510).
641
+ const text = abortReasonText(reason);
642
+ if (text === undefined)
643
+ return new Error("Request was aborted");
644
+ const characters = Array.from(text);
645
+ return new Error(characters.length > MAX_ABORT_REASON_CHARS ? `${characters.slice(0, MAX_ABORT_REASON_CHARS).join("")}…` : text);
646
+ }
647
+ function abortReasonText(reason) {
648
+ const text = typeof reason === "string"
649
+ ? reason
650
+ : typeof reason === "object" && reason !== null && "message" in reason && typeof reason.message === "string"
651
+ ? reason.message
652
+ : undefined;
653
+ return text !== undefined && text.trim() !== "" ? text : undefined;
622
654
  }
623
655
  function closeAssistantIterator(iterator) {
624
656
  void Promise.resolve(iterator.return?.()).catch(() => undefined);
625
657
  }
658
+ /**
659
+ * The bound on everything before a provider stream's first event. With a start bound the budget is shared:
660
+ * whatever the steps before the stream used, the first read only gets the rest. Without one the idle bound
661
+ * covers each step, as it covers each wait between events.
662
+ */
663
+ function createPreStreamBound(startTimeoutMs, idleTimeoutMs, request) {
664
+ const startedAt = Date.now();
665
+ const remaining = () => startTimeoutMs === undefined ? idleTimeoutMs : Math.max(0, startTimeoutMs - (Date.now() - startedAt));
666
+ const timeoutError = () => startTimeoutMs === undefined
667
+ ? new StreamIdleTimeoutError(idleTimeoutMs ?? 0)
668
+ : new StreamStartTimeoutError(startTimeoutMs);
669
+ return {
670
+ run(work, discardLate) {
671
+ const budget = remaining();
672
+ // Only the bound ends a step here. A caller abort is not raced: the step sees it through the request
673
+ // signal and ends the way it did before this bound existed (the turn then reads aborted), so an abort
674
+ // and a timeout never compete for the same step.
675
+ if (budget === undefined)
676
+ return Promise.resolve(work);
677
+ return new Promise((resolve, reject) => {
678
+ let timer;
679
+ let settled = false;
680
+ const finish = (complete) => {
681
+ if (settled)
682
+ return;
683
+ settled = true;
684
+ if (timer !== undefined)
685
+ clearTimeout(timer);
686
+ complete();
687
+ };
688
+ // Handle the work's outcome first, so work that settles after the turn ended neither goes unhandled
689
+ // nor leaks: a late value is handed to discardLate, a late rejection is dropped.
690
+ Promise.resolve(work).then((value) => {
691
+ if (settled) {
692
+ // The turn already ended; the value is only cleaned up. A cleanup that throws must not
693
+ // become an unhandled rejection of a request nobody waits for any more.
694
+ try {
695
+ discardLate?.(value);
696
+ }
697
+ catch { }
698
+ return;
699
+ }
700
+ finish(() => resolve(value));
701
+ }, (error) => finish(() => reject(error)));
702
+ timer = setTimeout(() => {
703
+ const error = timeoutError();
704
+ finish(() => reject(error));
705
+ // Abort after rejecting so the failure reads as the timeout, and the hung step is told to stop.
706
+ request.abort(error);
707
+ }, budget);
708
+ });
709
+ },
710
+ // A start bound already spent before the stream answered still ends the first read at once.
711
+ remainingStartMs: () => (startTimeoutMs === undefined ? undefined : Math.max(1, remaining() ?? 1)),
712
+ };
713
+ }
626
714
  function normalizeTimeoutMs(timeoutMs) {
627
715
  return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
628
716
  }
629
- function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream) {
717
+ function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream,
718
+ // The bound the error names, when the timer only gets what is left of it (the steps before the stream used
719
+ // the rest); the error must name the configured bound, which users raise.
720
+ configuredStartTimeoutMs) {
630
721
  const idleTimeoutMs = normalizeTimeoutMs(timeoutMs);
631
722
  const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
723
+ const reportedStartTimeoutMs = configuredStartTimeoutMs ?? startTimeoutMs;
632
724
  let sawFirstEvent = false;
633
725
  let removeAbortListener;
634
726
  let abortPromise;
@@ -655,7 +747,7 @@ function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout,
655
747
  const useStartBound = !sawFirstEvent && startTimeoutMs !== undefined;
656
748
  const readTimeoutMs = useStartBound ? startTimeoutMs : idleTimeoutMs;
657
749
  const makeTimeoutError = useStartBound
658
- ? (ms) => new StreamStartTimeoutError(ms)
750
+ ? (ms) => new StreamStartTimeoutError(reportedStartTimeoutMs ?? ms)
659
751
  : (ms) => new StreamIdleTimeoutError(ms);
660
752
  const result = await readNextAssistantEvent(iterator, readTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
661
753
  if (!result.done)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.16",
3
+ "version": "2026.10.18",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,8 +62,8 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@earendil-works/chord": "0.99.1",
65
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.16",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.16",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.18",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.18",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",