@code-yeongyu/senpi-agent-core 2026.10.30-1 → 2026.10.30-2
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 +26 -7
- package/dist/agent.d.ts +3 -0
- package/dist/agent.js +2 -0
- package/dist/stream-keepalive.d.ts +28 -0
- package/dist/stream-keepalive.js +42 -0
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
package/dist/agent-loop.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createTerminalFailureAssistantMessage, isStreamIdleTimeoutError, normal
|
|
|
7
7
|
import { createResponseTimingRecorder } from "./response-timing.js";
|
|
8
8
|
import { internalProgressSink } from "./internal-events.js";
|
|
9
9
|
import { getDefaultStreamFn, withEmptyAssistantRecovery } from "./stream-fn.js";
|
|
10
|
+
import { createTransportLiveness, StreamKeepAliveTimeoutError } from "./stream-keepalive.js";
|
|
10
11
|
import { prepareAgentToolCallArguments } from "./tool-arguments.js";
|
|
11
12
|
import { resolveCallTool, withToolNameCorrection } from "./tool-name-alias.js";
|
|
12
13
|
/**
|
|
@@ -465,6 +466,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
|
|
|
465
466
|
// Dedicated controller for the provider request so the loop can tear the
|
|
466
467
|
// request down itself (idle timeout), not only when the caller aborts.
|
|
467
468
|
const requestAbortController = new AbortController();
|
|
469
|
+
const liveness = createTransportLiveness(normalizeTimeoutMs(config.streamKeepAliveTimeoutMs));
|
|
468
470
|
let detachCallerAbort;
|
|
469
471
|
if (signal !== undefined) {
|
|
470
472
|
if (signal.aborted) {
|
|
@@ -490,6 +492,10 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
|
|
|
490
492
|
streamKind: "main",
|
|
491
493
|
apiKey: resolvedApiKey,
|
|
492
494
|
signal: requestAbortController.signal,
|
|
495
|
+
onTransportActivity: () => {
|
|
496
|
+
liveness.noteActivity();
|
|
497
|
+
config.onTransportActivity?.();
|
|
498
|
+
},
|
|
493
499
|
// Cursor exec bridging (ignored by every other provider): handlers
|
|
494
500
|
// execute mid-stream; their paired results buffer here.
|
|
495
501
|
...(config.cursorExecHandlers
|
|
@@ -515,7 +521,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
|
|
|
515
521
|
const sink = config[internalProgressSink];
|
|
516
522
|
const records = sink ? assistantRecords(response) : response;
|
|
517
523
|
const iterator = records[Symbol.asyncIterator]();
|
|
518
|
-
const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs));
|
|
524
|
+
const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs), liveness);
|
|
519
525
|
try {
|
|
520
526
|
while (true) {
|
|
521
527
|
const next = await eventReader.next();
|
|
@@ -660,7 +666,10 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
|
|
|
660
666
|
// A step that rethrows the caller's own reason (`throwIfAborted()`, a fetch rejected with it) shows that reason
|
|
661
667
|
// through the same shaping and cap (#3510).
|
|
662
668
|
const callerAbortedPastBound = signal?.aborted === true &&
|
|
663
|
-
(error instanceof StreamStartTimeoutError ||
|
|
669
|
+
(error instanceof StreamStartTimeoutError ||
|
|
670
|
+
error instanceof StreamKeepAliveTimeoutError ||
|
|
671
|
+
isStreamIdleTimeoutError(error) ||
|
|
672
|
+
error === signal.reason);
|
|
664
673
|
const finalMessage = createTerminalFailureAssistantMessage(config.model, signal?.aborted ? "aborted" : "error", callerAbortedPastBound ? abortError(signal.reason) : error, partialMessage);
|
|
665
674
|
stampTiming(finalMessage);
|
|
666
675
|
if (addedPartial) {
|
|
@@ -763,7 +772,7 @@ function normalizeTimeoutMs(timeoutMs) {
|
|
|
763
772
|
function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream,
|
|
764
773
|
// The bound the error names, when the timer only gets what is left of it (the steps before the stream used
|
|
765
774
|
// the rest); the error must name the configured bound, which users raise.
|
|
766
|
-
configuredStartTimeoutMs) {
|
|
775
|
+
configuredStartTimeoutMs, liveness) {
|
|
767
776
|
const idleTimeoutMs = normalizeTimeoutMs(timeoutMs);
|
|
768
777
|
const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
|
|
769
778
|
const reportedStartTimeoutMs = configuredStartTimeoutMs ?? startTimeoutMs;
|
|
@@ -798,20 +807,24 @@ configuredStartTimeoutMs) {
|
|
|
798
807
|
? (ms) => new StreamStartTimeoutError(reportedStartTimeoutMs ?? ms)
|
|
799
808
|
: (ms) => new StreamIdleTimeoutError(idleTimeoutMs ?? ms);
|
|
800
809
|
const readStartedAt = Date.now();
|
|
801
|
-
|
|
810
|
+
// Keep-alives are judged against the whole configured bound, not the share of it left for this read.
|
|
811
|
+
const windowMs = useStartBound ? (reportedStartTimeoutMs ?? startTimeoutMs) : idleTimeoutMs;
|
|
812
|
+
const result = await readNextAssistantEvent(iterator, readTimeoutMs === undefined ? undefined : Math.max(1, readTimeoutMs - silentMs), makeTimeoutError, abortPromise, onIdleTimeout, stream, signal, liveness && windowMs !== undefined ? { liveness, windowMs } : undefined);
|
|
802
813
|
if (!result.done && isAssistantProgress(result.value))
|
|
803
814
|
silentMs += Date.now() - readStartedAt;
|
|
804
815
|
else {
|
|
805
816
|
silentMs = 0;
|
|
806
|
-
if (!result.done)
|
|
817
|
+
if (!result.done) {
|
|
807
818
|
sawFirstEvent = true;
|
|
819
|
+
liveness?.contentArrived();
|
|
820
|
+
}
|
|
808
821
|
}
|
|
809
822
|
return result;
|
|
810
823
|
},
|
|
811
824
|
dispose: () => removeAbortListener?.(),
|
|
812
825
|
};
|
|
813
826
|
}
|
|
814
|
-
async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal) {
|
|
827
|
+
async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal, keepAlive) {
|
|
815
828
|
if (idleTimeoutMs === undefined && abortPromise === undefined) {
|
|
816
829
|
return iterator.next();
|
|
817
830
|
}
|
|
@@ -837,7 +850,13 @@ async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError,
|
|
|
837
850
|
timeout = setTimeout(onIdleDeadline, idleTimeoutMs);
|
|
838
851
|
return;
|
|
839
852
|
}
|
|
840
|
-
|
|
853
|
+
// A provider that accepted the request and is still sending keep-alive bytes is working, not dead.
|
|
854
|
+
const verdict = keepAlive?.liveness.onSilenceDeadline(keepAlive.windowMs);
|
|
855
|
+
if (verdict?.kind === "extend") {
|
|
856
|
+
timeout = setTimeout(onIdleDeadline, verdict.delayMs);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const error = verdict?.kind === "keep-alive-exhausted" ? verdict.error : makeTimeoutError(idleTimeoutMs);
|
|
841
860
|
closeAssistantIterator(iterator);
|
|
842
861
|
settle(() => reject(error));
|
|
843
862
|
// Abort after settling so the failure surfaces as an idle timeout,
|
package/dist/agent.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface AgentOptions {
|
|
|
34
34
|
transport?: Transport;
|
|
35
35
|
timeoutMs?: number;
|
|
36
36
|
streamStartTimeoutMs?: number;
|
|
37
|
+
streamKeepAliveTimeoutMs?: number;
|
|
37
38
|
maxRetryDelayMs?: number;
|
|
38
39
|
toolExecution?: ToolExecutionMode;
|
|
39
40
|
removedToolHints?: Record<string, string>;
|
|
@@ -106,6 +107,8 @@ export declare class Agent {
|
|
|
106
107
|
timeoutMs?: number;
|
|
107
108
|
/** Optional bound on the wait for the first provider stream event. */
|
|
108
109
|
streamStartTimeoutMs?: number;
|
|
110
|
+
/** Optional bound on how long keep-alives alone may hold a provider request without content. */
|
|
111
|
+
streamKeepAliveTimeoutMs?: number;
|
|
109
112
|
/** Optional cap for provider-requested retry delays. */
|
|
110
113
|
maxRetryDelayMs?: number;
|
|
111
114
|
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
|
package/dist/agent.js
CHANGED
|
@@ -163,6 +163,7 @@ export class Agent {
|
|
|
163
163
|
this.transport = runtimeOptions.transport ?? "auto";
|
|
164
164
|
this.timeoutMs = runtimeOptions.timeoutMs;
|
|
165
165
|
this.streamStartTimeoutMs = runtimeOptions.streamStartTimeoutMs;
|
|
166
|
+
this.streamKeepAliveTimeoutMs = runtimeOptions.streamKeepAliveTimeoutMs;
|
|
166
167
|
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
|
|
167
168
|
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
|
|
168
169
|
this.removedToolHints = runtimeOptions.removedToolHints ?? {};
|
|
@@ -473,6 +474,7 @@ export class Agent {
|
|
|
473
474
|
thinkingBudgets: this.thinkingBudgets,
|
|
474
475
|
timeoutMs: this.timeoutMs,
|
|
475
476
|
streamStartTimeoutMs: this.streamStartTimeoutMs,
|
|
477
|
+
streamKeepAliveTimeoutMs: this.streamKeepAliveTimeoutMs,
|
|
476
478
|
initialRequestTimeoutMs: options.initialRequestTimeoutMs,
|
|
477
479
|
initialRequestStreamStartTimeoutMs: options.initialRequestStreamStartTimeoutMs,
|
|
478
480
|
maxRetryDelayMs: this.maxRetryDelayMs,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport liveness for one provider request (senpi#4608).
|
|
3
|
+
*
|
|
4
|
+
* The loop's silence bounds count public assistant events. A provider that accepted the request and keeps its
|
|
5
|
+
* connection alive - SSE `ping` events, `:` comment keep-alives a relay sends while the upstream model is still
|
|
6
|
+
* thinking - emits none of those, so a slow first token used to be killed as if the connection were dead. Providers
|
|
7
|
+
* report received bytes through `onTransportActivity`; a silence bound that fires within one bound of the last
|
|
8
|
+
* byte is extended instead of ending the request, and keep-alives alone are bounded by their own budget.
|
|
9
|
+
*/
|
|
10
|
+
export declare class StreamKeepAliveTimeoutError extends Error {
|
|
11
|
+
constructor(timeoutMs: number);
|
|
12
|
+
}
|
|
13
|
+
export type SilenceDeadlineVerdict = {
|
|
14
|
+
kind: "expire";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "extend";
|
|
17
|
+
delayMs: number;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "keep-alive-exhausted";
|
|
20
|
+
error: StreamKeepAliveTimeoutError;
|
|
21
|
+
};
|
|
22
|
+
export interface TransportLiveness {
|
|
23
|
+
readonly noteActivity: () => void;
|
|
24
|
+
contentArrived(): void;
|
|
25
|
+
onSilenceDeadline(windowMs: number): SilenceDeadlineVerdict;
|
|
26
|
+
}
|
|
27
|
+
export declare function createTransportLiveness(keepAliveTimeoutMs: number | undefined): TransportLiveness;
|
|
28
|
+
//# sourceMappingURL=stream-keepalive.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport liveness for one provider request (senpi#4608).
|
|
3
|
+
*
|
|
4
|
+
* The loop's silence bounds count public assistant events. A provider that accepted the request and keeps its
|
|
5
|
+
* connection alive - SSE `ping` events, `:` comment keep-alives a relay sends while the upstream model is still
|
|
6
|
+
* thinking - emits none of those, so a slow first token used to be killed as if the connection were dead. Providers
|
|
7
|
+
* report received bytes through `onTransportActivity`; a silence bound that fires within one bound of the last
|
|
8
|
+
* byte is extended instead of ending the request, and keep-alives alone are bounded by their own budget.
|
|
9
|
+
*/
|
|
10
|
+
// The wording keeps "timed out" so the retryable-error classifier treats it like the other stream bounds.
|
|
11
|
+
export class StreamKeepAliveTimeoutError extends Error {
|
|
12
|
+
constructor(timeoutMs) {
|
|
13
|
+
super(`Provider stream sent only keep-alives and timed out after ${timeoutMs}ms (raise streamKeepAliveTimeoutMs: retry.provider.keepAliveTimeoutMs in senpi settings; 0 disables)`);
|
|
14
|
+
this.name = "StreamKeepAliveTimeoutError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function createTransportLiveness(keepAliveTimeoutMs) {
|
|
18
|
+
let lastActivityAt;
|
|
19
|
+
let contentSilenceSince = Date.now();
|
|
20
|
+
return {
|
|
21
|
+
noteActivity: () => {
|
|
22
|
+
lastActivityAt = Date.now();
|
|
23
|
+
},
|
|
24
|
+
contentArrived: () => {
|
|
25
|
+
contentSilenceSince = Date.now();
|
|
26
|
+
},
|
|
27
|
+
onSilenceDeadline: (windowMs) => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (lastActivityAt === undefined || now - lastActivityAt >= windowMs)
|
|
30
|
+
return { kind: "expire" };
|
|
31
|
+
const delayMs = windowMs - (now - lastActivityAt);
|
|
32
|
+
if (keepAliveTimeoutMs === undefined)
|
|
33
|
+
return { kind: "extend", delayMs };
|
|
34
|
+
const keepAliveLeftMs = keepAliveTimeoutMs - (now - contentSilenceSince);
|
|
35
|
+
if (keepAliveLeftMs <= 0) {
|
|
36
|
+
return { kind: "keep-alive-exhausted", error: new StreamKeepAliveTimeoutError(keepAliveTimeoutMs) };
|
|
37
|
+
}
|
|
38
|
+
return { kind: "extend", delayMs: Math.max(1, Math.min(delayMs, keepAliveLeftMs)) };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=stream-keepalive.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -185,6 +185,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
185
185
|
* Unset or non-positive values disable the start bound.
|
|
186
186
|
*/
|
|
187
187
|
streamStartTimeoutMs?: number;
|
|
188
|
+
/**
|
|
189
|
+
* Bytes the provider reports through `onTransportActivity` (SSE pings, keep-alive comments) keep the start and
|
|
190
|
+
* idle bounds from firing while they keep arriving within one bound. This caps how long keep-alives alone may
|
|
191
|
+
* hold a request without any assistant event. Unset or non-positive values leave keep-alives unbounded.
|
|
192
|
+
*/
|
|
193
|
+
streamKeepAliveTimeoutMs?: number;
|
|
188
194
|
/** Provider/SDK timeout override for only the first request in this loop invocation. */
|
|
189
195
|
initialRequestTimeoutMs?: number;
|
|
190
196
|
/** Stream-start timeout override for only the first request in this loop invocation. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-agent-core",
|
|
3
|
-
"version": "2026.10.30-
|
|
3
|
+
"version": "2026.10.30-2",
|
|
4
4
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@earendil-works/chord": "0.99.1",
|
|
73
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.30-
|
|
74
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.30-
|
|
73
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.30-2",
|
|
74
|
+
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.30-2",
|
|
75
75
|
"diff": "9.0.0",
|
|
76
76
|
"ignore": "7.0.9",
|
|
77
77
|
"typebox": "1.3.34",
|