@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.
- package/dist/agent-loop.js +76 -6
- package/package.json +3 -3
package/dist/agent-loop.js
CHANGED
|
@@ -452,10 +452,14 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
|
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
454
|
try {
|
|
455
|
-
|
|
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),
|
|
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-
|
|
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-
|
|
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.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",
|