@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.
- package/dist/agent-loop.js +29 -7
- package/dist/agent.d.ts +3 -0
- package/dist/agent.js +6 -0
- package/dist/harness/skills.js +5 -2
- package/dist/stream-keepalive.d.ts +28 -0
- package/dist/stream-keepalive.js +42 -0
- package/dist/types.d.ts +18 -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
|
/**
|
|
@@ -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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/harness/skills.js
CHANGED
|
@@ -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.
|
|
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.
|
|
74
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.
|
|
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",
|