@code-yeongyu/senpi-agent-core 2026.10.16-2 → 2026.10.16-4

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 +76 -6
  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();
@@ -623,12 +633,72 @@ function abortError(reason) {
623
633
  function closeAssistantIterator(iterator) {
624
634
  void Promise.resolve(iterator.return?.()).catch(() => undefined);
625
635
  }
636
+ /**
637
+ * The bound on everything before a provider stream's first event. With a start bound the budget is shared:
638
+ * whatever the steps before the stream used, the first read only gets the rest. Without one the idle bound
639
+ * covers each step, as it covers each wait between events.
640
+ */
641
+ function createPreStreamBound(startTimeoutMs, idleTimeoutMs, request) {
642
+ const startedAt = Date.now();
643
+ const remaining = () => startTimeoutMs === undefined ? idleTimeoutMs : Math.max(0, startTimeoutMs - (Date.now() - startedAt));
644
+ const timeoutError = () => startTimeoutMs === undefined
645
+ ? new StreamIdleTimeoutError(idleTimeoutMs ?? 0)
646
+ : new StreamStartTimeoutError(startTimeoutMs);
647
+ return {
648
+ run(work, discardLate) {
649
+ const budget = remaining();
650
+ // Only the bound ends a step here. A caller abort is not raced: the step sees it through the request
651
+ // signal and ends the way it did before this bound existed (the turn then reads aborted), so an abort
652
+ // and a timeout never compete for the same step.
653
+ if (budget === undefined)
654
+ return Promise.resolve(work);
655
+ return new Promise((resolve, reject) => {
656
+ let timer;
657
+ let settled = false;
658
+ const finish = (complete) => {
659
+ if (settled)
660
+ return;
661
+ settled = true;
662
+ if (timer !== undefined)
663
+ clearTimeout(timer);
664
+ complete();
665
+ };
666
+ // Handle the work's outcome first, so work that settles after the turn ended neither goes unhandled
667
+ // nor leaks: a late value is handed to discardLate, a late rejection is dropped.
668
+ Promise.resolve(work).then((value) => {
669
+ if (settled) {
670
+ // The turn already ended; the value is only cleaned up. A cleanup that throws must not
671
+ // become an unhandled rejection of a request nobody waits for any more.
672
+ try {
673
+ discardLate?.(value);
674
+ }
675
+ catch { }
676
+ return;
677
+ }
678
+ finish(() => resolve(value));
679
+ }, (error) => finish(() => reject(error)));
680
+ timer = setTimeout(() => {
681
+ const error = timeoutError();
682
+ finish(() => reject(error));
683
+ // Abort after rejecting so the failure reads as the timeout, and the hung step is told to stop.
684
+ request.abort(error);
685
+ }, budget);
686
+ });
687
+ },
688
+ // A start bound already spent before the stream answered still ends the first read at once.
689
+ remainingStartMs: () => (startTimeoutMs === undefined ? undefined : Math.max(1, remaining() ?? 1)),
690
+ };
691
+ }
626
692
  function normalizeTimeoutMs(timeoutMs) {
627
693
  return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
628
694
  }
629
- function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream) {
695
+ function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream,
696
+ // The bound the error names, when the timer only gets what is left of it (the steps before the stream used
697
+ // the rest); the error must name the configured bound, which users raise.
698
+ configuredStartTimeoutMs) {
630
699
  const idleTimeoutMs = normalizeTimeoutMs(timeoutMs);
631
700
  const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
701
+ const reportedStartTimeoutMs = configuredStartTimeoutMs ?? startTimeoutMs;
632
702
  let sawFirstEvent = false;
633
703
  let removeAbortListener;
634
704
  let abortPromise;
@@ -655,7 +725,7 @@ function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout,
655
725
  const useStartBound = !sawFirstEvent && startTimeoutMs !== undefined;
656
726
  const readTimeoutMs = useStartBound ? startTimeoutMs : idleTimeoutMs;
657
727
  const makeTimeoutError = useStartBound
658
- ? (ms) => new StreamStartTimeoutError(ms)
728
+ ? (ms) => new StreamStartTimeoutError(reportedStartTimeoutMs ?? ms)
659
729
  : (ms) => new StreamIdleTimeoutError(ms);
660
730
  const result = await readNextAssistantEvent(iterator, readTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
661
731
  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-2",
3
+ "version": "2026.10.16-4",
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-2",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.16-2",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.16-4",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.16-4",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",