@code-yeongyu/senpi-agent-core 2026.10.30-1 → 2026.10.31-1

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
  /**
@@ -430,6 +431,9 @@ export async function buildProviderContext(context, config, signal) {
430
431
  const { tools, activeToolNames } = providerTools(context, config.model);
431
432
  return normalizeContext({
432
433
  systemPrompt: context.systemPrompt,
434
+ ...(context.systemPromptStablePrefix === undefined
435
+ ? {}
436
+ : { systemPromptStablePrefix: context.systemPromptStablePrefix }),
433
437
  messages: await config.convertToLlm(messages),
434
438
  ...(tools === undefined ? {} : { tools: tools.map(toProviderToolDeclaration) }),
435
439
  ...(activeToolNames === undefined ? {} : { activeToolNames }),
@@ -465,6 +469,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
465
469
  // Dedicated controller for the provider request so the loop can tear the
466
470
  // request down itself (idle timeout), not only when the caller aborts.
467
471
  const requestAbortController = new AbortController();
472
+ const liveness = createTransportLiveness(normalizeTimeoutMs(config.streamKeepAliveTimeoutMs));
468
473
  let detachCallerAbort;
469
474
  if (signal !== undefined) {
470
475
  if (signal.aborted) {
@@ -490,6 +495,10 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
490
495
  streamKind: "main",
491
496
  apiKey: resolvedApiKey,
492
497
  signal: requestAbortController.signal,
498
+ onTransportActivity: () => {
499
+ liveness.noteActivity();
500
+ config.onTransportActivity?.();
501
+ },
493
502
  // Cursor exec bridging (ignored by every other provider): handlers
494
503
  // execute mid-stream; their paired results buffer here.
495
504
  ...(config.cursorExecHandlers
@@ -515,7 +524,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
515
524
  const sink = config[internalProgressSink];
516
525
  const records = sink ? assistantRecords(response) : response;
517
526
  const iterator = records[Symbol.asyncIterator]();
518
- const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs));
527
+ const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs), liveness);
519
528
  try {
520
529
  while (true) {
521
530
  const next = await eventReader.next();
@@ -660,7 +669,10 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
660
669
  // A step that rethrows the caller's own reason (`throwIfAborted()`, a fetch rejected with it) shows that reason
661
670
  // through the same shaping and cap (#3510).
662
671
  const callerAbortedPastBound = signal?.aborted === true &&
663
- (error instanceof StreamStartTimeoutError || isStreamIdleTimeoutError(error) || error === signal.reason);
672
+ (error instanceof StreamStartTimeoutError ||
673
+ error instanceof StreamKeepAliveTimeoutError ||
674
+ isStreamIdleTimeoutError(error) ||
675
+ error === signal.reason);
664
676
  const finalMessage = createTerminalFailureAssistantMessage(config.model, signal?.aborted ? "aborted" : "error", callerAbortedPastBound ? abortError(signal.reason) : error, partialMessage);
665
677
  stampTiming(finalMessage);
666
678
  if (addedPartial) {
@@ -763,7 +775,7 @@ function normalizeTimeoutMs(timeoutMs) {
763
775
  function createAssistantEventReader(iterator, timeoutMs, signal, onIdleTimeout, streamStartTimeoutMs, stream,
764
776
  // The bound the error names, when the timer only gets what is left of it (the steps before the stream used
765
777
  // the rest); the error must name the configured bound, which users raise.
766
- configuredStartTimeoutMs) {
778
+ configuredStartTimeoutMs, liveness) {
767
779
  const idleTimeoutMs = normalizeTimeoutMs(timeoutMs);
768
780
  const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
769
781
  const reportedStartTimeoutMs = configuredStartTimeoutMs ?? startTimeoutMs;
@@ -798,20 +810,24 @@ configuredStartTimeoutMs) {
798
810
  ? (ms) => new StreamStartTimeoutError(reportedStartTimeoutMs ?? ms)
799
811
  : (ms) => new StreamIdleTimeoutError(idleTimeoutMs ?? ms);
800
812
  const readStartedAt = Date.now();
801
- const result = await readNextAssistantEvent(iterator, readTimeoutMs === undefined ? undefined : Math.max(1, readTimeoutMs - silentMs), makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
813
+ // Keep-alives are judged against the whole configured bound, not the share of it left for this read.
814
+ const windowMs = useStartBound ? (reportedStartTimeoutMs ?? startTimeoutMs) : idleTimeoutMs;
815
+ 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
816
  if (!result.done && isAssistantProgress(result.value))
803
817
  silentMs += Date.now() - readStartedAt;
804
818
  else {
805
819
  silentMs = 0;
806
- if (!result.done)
820
+ if (!result.done) {
807
821
  sawFirstEvent = true;
822
+ liveness?.contentArrived();
823
+ }
808
824
  }
809
825
  return result;
810
826
  },
811
827
  dispose: () => removeAbortListener?.(),
812
828
  };
813
829
  }
814
- async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal) {
830
+ async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal, keepAlive) {
815
831
  if (idleTimeoutMs === undefined && abortPromise === undefined) {
816
832
  return iterator.next();
817
833
  }
@@ -837,7 +853,13 @@ async function readNextAssistantEvent(iterator, idleTimeoutMs, makeTimeoutError,
837
853
  timeout = setTimeout(onIdleDeadline, idleTimeoutMs);
838
854
  return;
839
855
  }
840
- const error = makeTimeoutError(idleTimeoutMs);
856
+ // A provider that accepted the request and is still sending keep-alive bytes is working, not dead.
857
+ const verdict = keepAlive?.liveness.onSilenceDeadline(keepAlive.windowMs);
858
+ if (verdict?.kind === "extend") {
859
+ timeout = setTimeout(onIdleDeadline, verdict.delayMs);
860
+ return;
861
+ }
862
+ const error = verdict?.kind === "keep-alive-exhausted" ? verdict.error : makeTimeoutError(idleTimeoutMs);
841
863
  closeAssistantIterator(iterator);
842
864
  settle(() => reject(error));
843
865
  // 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
@@ -35,6 +35,7 @@ function createMutableAgentState(initialState) {
35
35
  let messages = initialState?.messages?.slice() ?? [];
36
36
  return {
37
37
  systemPrompt: initialState?.systemPrompt ?? "",
38
+ systemPromptStablePrefix: initialState?.systemPromptStablePrefix,
38
39
  model: initialState?.model ?? DEFAULT_MODEL,
39
40
  thinkingLevel: initialState?.thinkingLevel ?? "off",
40
41
  reasoningBaseline: initialState?.reasoningBaseline,
@@ -163,6 +164,7 @@ export class Agent {
163
164
  this.transport = runtimeOptions.transport ?? "auto";
164
165
  this.timeoutMs = runtimeOptions.timeoutMs;
165
166
  this.streamStartTimeoutMs = runtimeOptions.streamStartTimeoutMs;
167
+ this.streamKeepAliveTimeoutMs = runtimeOptions.streamKeepAliveTimeoutMs;
166
168
  this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
167
169
  this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
168
170
  this.removedToolHints = runtimeOptions.removedToolHints ?? {};
@@ -447,6 +449,9 @@ export class Agent {
447
449
  createContextSnapshot() {
448
450
  return {
449
451
  systemPrompt: this._state.systemPrompt,
452
+ ...(this._state.systemPromptStablePrefix === undefined
453
+ ? {}
454
+ : { systemPromptStablePrefix: this._state.systemPromptStablePrefix }),
450
455
  messages: this._state.messages.slice(),
451
456
  tools: this._state.tools.slice(),
452
457
  ...(this._state.declaredTools ? { declaredTools: this._state.declaredTools.slice() } : {}),
@@ -473,6 +478,7 @@ export class Agent {
473
478
  thinkingBudgets: this.thinkingBudgets,
474
479
  timeoutMs: this.timeoutMs,
475
480
  streamStartTimeoutMs: this.streamStartTimeoutMs,
481
+ streamKeepAliveTimeoutMs: this.streamKeepAliveTimeoutMs,
476
482
  initialRequestTimeoutMs: options.initialRequestTimeoutMs,
477
483
  initialRequestStreamStartTimeoutMs: options.initialRequestStreamStartTimeoutMs,
478
484
  maxRetryDelayMs: this.maxRetryDelayMs,
@@ -111,12 +111,15 @@ async function loadSkillsFromDirInternal(env, dir, includeRootFiles, ignoreMatch
111
111
  const kind = await resolveKind(env, entry, diagnostics, context);
112
112
  if (!kind)
113
113
  continue;
114
+ // A linked entry belongs to another tree: the ignore files above it neither hide it nor reach into it,
115
+ // while a linked directory's own ignore files still apply inside it (senpi#5060, senpi#5069).
116
+ const isLink = entry.kind === "symlink";
114
117
  const relPath = relativeEnvPath(rootDir, fullPath);
115
118
  const ignorePath = kind === "directory" ? `${relPath}/` : relPath;
116
- if (ignoreMatcher.ignores(ignorePath))
119
+ if (!isLink && ignoreMatcher.ignores(ignorePath))
117
120
  continue;
118
121
  if (kind === "directory") {
119
- const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir, context);
122
+ const result = await loadSkillsFromDirInternal(env, fullPath, false, isLink ? ignore() : ignoreMatcher, rootDir, context);
120
123
  skills.push(...result.skills);
121
124
  diagnostics.push(...result.diagnostics);
122
125
  continue;
@@ -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. */
@@ -392,6 +398,11 @@ export interface AgentState {
392
398
  * `content` or `sections` still apply on top of it.
393
399
  */
394
400
  systemPrompt: string;
401
+ /**
402
+ * Leading part of `systemPrompt` that is the same across sessions; see
403
+ * {@link AgentContext.systemPromptStablePrefix} (senpi#4447).
404
+ */
405
+ systemPromptStablePrefix?: string;
395
406
  /** Active model used for future turns. */
396
407
  model: Model<any>;
397
408
  /** Requested reasoning level for future turns. */
@@ -514,6 +525,13 @@ export interface AgentContext {
514
525
  * `normalizeContext()`. Upstream-built contexts carry the prompt in transcript system messages.
515
526
  */
516
527
  systemPrompt?: string;
528
+ /**
529
+ * Leading part of `systemPrompt` that is the same across sessions, followed by content that is not
530
+ * (memory, MCP instructions). Providers with block-level prompt caching put the system breakpoint at
531
+ * its end so another session can read it from cache. Ignored unless `systemPrompt` starts with it
532
+ * (senpi#4447).
533
+ */
534
+ systemPromptStablePrefix?: string;
517
535
  /** Transcript visible to the model. */
518
536
  messages: AgentMessage[];
519
537
  /** Tools available for execution in this run. */
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.31-1",
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.31-1",
74
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.31-1",
75
75
  "diff": "9.0.0",
76
76
  "ignore": "7.0.9",
77
77
  "typebox": "1.3.34",