@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.
@@ -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 || isStreamIdleTimeoutError(error) || error === signal.reason);
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
- const result = await readNextAssistantEvent(iterator, readTimeoutMs === undefined ? undefined : Math.max(1, readTimeoutMs - silentMs), makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
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
- const error = makeTimeoutError(idleTimeoutMs);
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-1",
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-1",
74
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.30-1",
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",