@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.
- package/dist/agent-loop.js +100 -8
- 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();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
66
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.
|
|
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",
|