@code-yeongyu/senpi-agent-core 2026.10.25-4 → 2026.10.26-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.
@@ -2,9 +2,10 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { createInitialSystemMessage, EventStream, getCurrentTools, getToolStateChanges, isCursorExecResolved, normalizeContext, supportsAllowedToolChoice, validateToolArguments, } from "@earendil-works/pi-ai";
5
+ import { assistantRecords, carryAssistantCapture, getAssistantCapture, createInitialSystemMessage, EventStream, getCurrentTools, getToolStateChanges, isCursorExecResolved, isAssistantProgress, normalizeContext, supportsAllowedToolChoice, validateToolArguments, } from "@earendil-works/pi-ai";
6
6
  import { createTerminalFailureAssistantMessage, isStreamIdleTimeoutError, normalizeTerminalAssistantMessage, settleStopReason, shouldFinalizeIdleAsStop, shouldTerminateAssistantTurn, } from "./assistant-terminal-state.js";
7
7
  import { createResponseTimingRecorder } from "./response-timing.js";
8
+ import { internalProgressSink } from "./internal-events.js";
8
9
  import { getDefaultStreamFn, withEmptyAssistantRecovery } from "./stream-fn.js";
9
10
  import { prepareAgentToolCallArguments } from "./tool-arguments.js";
10
11
  import { resolveCallTool, withToolNameCorrection } from "./tool-name-alias.js";
@@ -510,7 +511,10 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
510
511
  });
511
512
  // Record the requested level, whichever stream function answered.
512
513
  const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });
513
- const iterator = response[Symbol.asyncIterator]();
514
+ // Without an internal listener the loop reads the public stream exactly as before: no records, no carriers.
515
+ const sink = config[internalProgressSink];
516
+ const records = sink ? assistantRecords(response) : response;
517
+ const iterator = records[Symbol.asyncIterator]();
514
518
  const eventReader = createAssistantEventReader(iterator, streamIdleTimeoutMs, requestAbortController.signal, (error) => requestAbortController.abort(error), preStream.remainingStartMs(), response, normalizeTimeoutMs(config.streamStartTimeoutMs));
515
519
  try {
516
520
  while (true) {
@@ -518,16 +522,20 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
518
522
  if (next.done)
519
523
  break;
520
524
  const event = next.value;
525
+ if (isAssistantProgress(event)) {
526
+ await sink?.(event);
527
+ continue;
528
+ }
521
529
  switch (event.type) {
522
530
  case "start":
523
531
  responseTiming.started();
524
532
  partialMessage = event.partial;
525
533
  context.messages.push(partialMessage);
526
534
  addedPartial = true;
527
- await emit({
535
+ await emit(carryAssistantCapture(event, {
528
536
  type: "message_start",
529
537
  message: { ...partialMessage },
530
- });
538
+ }));
531
539
  break;
532
540
  case "text_start":
533
541
  case "text_delta":
@@ -541,6 +549,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
541
549
  if (partialMessage) {
542
550
  partialMessage = event.partial;
543
551
  responseTiming.observe(event);
552
+ let timingProgress;
544
553
  if (event.type === "thinking_start" ||
545
554
  event.type === "thinking_delta" ||
546
555
  event.type === "thinking_end") {
@@ -556,14 +565,23 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
556
565
  block.startedAt = timing.startedAt;
557
566
  if (timing.endedAt !== undefined)
558
567
  block.endedAt = timing.endedAt;
568
+ // Stamped after provider capture: it amends this event's seed or end block, so it follows it.
569
+ if (getAssistantCapture(event) && event.type !== "thinking_delta")
570
+ timingProgress = Object.freeze({
571
+ type: "message_block_metadata",
572
+ contentIndex: event.contentIndex,
573
+ ...timing,
574
+ });
559
575
  }
560
576
  }
561
577
  context.messages[context.messages.length - 1] = partialMessage;
562
- await emit({
578
+ await emit(carryAssistantCapture(event, {
563
579
  type: "message_update",
564
580
  assistantMessageEvent: event,
565
581
  message: { ...partialMessage },
566
- });
582
+ }));
583
+ if (timingProgress)
584
+ await sink?.(timingProgress);
567
585
  }
568
586
  break;
569
587
  case "done":
@@ -577,12 +595,12 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
577
595
  context.messages.push(finalMessage);
578
596
  }
579
597
  if (!addedPartial) {
580
- await emit({
598
+ await emit(carryAssistantCapture(event, {
581
599
  type: "message_start",
582
600
  message: { ...finalMessage },
583
- });
601
+ }, finalMessage));
584
602
  }
585
- await emit({ type: "message_end", message: finalMessage });
603
+ await emit(carryAssistantCapture(event, { type: "message_end", message: finalMessage }, finalMessage));
586
604
  return { message: finalMessage, providerToolResults };
587
605
  }
588
606
  }
@@ -749,6 +767,8 @@ configuredStartTimeoutMs) {
749
767
  const startTimeoutMs = normalizeTimeoutMs(streamStartTimeoutMs);
750
768
  const reportedStartTimeoutMs = configuredStartTimeoutMs ?? startTimeoutMs;
751
769
  let sawFirstEvent = false;
770
+ // Provider silence since the last public event: internal progress records re-arm neither bound.
771
+ let silentMs = 0;
752
772
  let removeAbortListener;
753
773
  let abortPromise;
754
774
  if (signal !== undefined) {
@@ -775,10 +795,16 @@ configuredStartTimeoutMs) {
775
795
  const readTimeoutMs = useStartBound ? startTimeoutMs : idleTimeoutMs;
776
796
  const makeTimeoutError = useStartBound
777
797
  ? (ms) => new StreamStartTimeoutError(reportedStartTimeoutMs ?? ms)
778
- : (ms) => new StreamIdleTimeoutError(ms);
779
- const result = await readNextAssistantEvent(iterator, readTimeoutMs, makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
780
- if (!result.done)
781
- sawFirstEvent = true;
798
+ : (ms) => new StreamIdleTimeoutError(idleTimeoutMs ?? ms);
799
+ const readStartedAt = Date.now();
800
+ const result = await readNextAssistantEvent(iterator, readTimeoutMs === undefined ? undefined : Math.max(1, readTimeoutMs - silentMs), makeTimeoutError, abortPromise, onIdleTimeout, stream, signal);
801
+ if (!result.done && isAssistantProgress(result.value))
802
+ silentMs += Date.now() - readStartedAt;
803
+ else {
804
+ silentMs = 0;
805
+ if (!result.done)
806
+ sawFirstEvent = true;
807
+ }
782
808
  return result;
783
809
  },
784
810
  dispose: () => removeAbortListener?.(),
package/dist/agent.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type ImageContent, type Message, type SimpleStreamOptions, type ThinkingBudgets, type TranscriptContext, type Transport } from "@earendil-works/pi-ai";
2
+ import { type InternalAgentChannel } from "./internal-events.ts";
2
3
  import type { AfterToolCallContext, AfterToolCallResult, AgentContext, AgentEvent, AgentLoopConfig, AgentLoopTurnUpdate, AgentMessage, AgentState, BeforeToolCallContext, BeforeToolCallResult, FinishTurn, PrepareNextTurnContext, PrepareRequest, QueueMode, StreamFn, ToolExecutionMode } from "./types.ts";
3
4
  export type { QueueMode } from "./types.ts";
4
5
  /**
@@ -8,6 +9,8 @@ export type { QueueMode } from "./types.ts";
8
9
  export type AgentInitialState = Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage" | "providerDiagnostic">>;
9
10
  /** Options for constructing an {@link Agent}. */
10
11
  export interface AgentOptions {
12
+ /** @internal Construction-time channel for senpi's session layer only. */
13
+ internalChannel?: InternalAgentChannel;
11
14
  initialState?: AgentInitialState;
12
15
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
13
16
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
@@ -74,6 +77,7 @@ export interface AgentContinuationOptions {
74
77
  * and exposes queueing APIs for steering and follow-up messages.
75
78
  */
76
79
  export declare class Agent {
80
+ #private;
77
81
  private _state;
78
82
  private readonly listeners;
79
83
  private readonly steeringQueue;
package/dist/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readProviderDiagnostic, sanitizeProviderDiagnostic, } from "@earendil-works/pi-ai";
2
2
  import { buildProviderContext as buildProviderContextFromAgentContext, runAgentLoop, runAgentLoopContinue, } from "./agent-loop.js";
3
3
  import { ProviderRetryWatchdogAbortError } from "./assistant-terminal-state.js";
4
+ import { dispatchInternalAgent, internalLoopSink, } from "./internal-events.js";
4
5
  import { isLocalFailure, localFailurePort } from "./local-failure.js";
5
6
  import { getDefaultStreamFn } from "./stream-fn.js";
6
7
  function defaultConvertToLlm(messages) {
@@ -131,6 +132,7 @@ class PendingMessageQueue {
131
132
  * and exposes queueing APIs for steering and follow-up messages.
132
133
  */
133
134
  export class Agent {
135
+ #internalChannel;
134
136
  async buildProviderContext(context, signal) {
135
137
  return buildProviderContextFromAgentContext(context, { convertToLlm: this.convertToLlm, transformContext: this.transformContext, model: this._state.model }, signal);
136
138
  }
@@ -138,6 +140,7 @@ export class Agent {
138
140
  this.listeners = new Set();
139
141
  // Older compiled consumers may omit options or streamFn even though the current API requires them.
140
142
  const runtimeOptions = options ?? {};
143
+ this.#internalChannel = runtimeOptions.internalChannel;
141
144
  this._state = createMutableAgentState(runtimeOptions.initialState);
142
145
  this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm;
143
146
  this.transformContext = runtimeOptions.transformContext;
@@ -458,6 +461,7 @@ export class Agent {
458
461
  const giveBack = (queue, messages) => this.giveBack(queue, messages, queue === "steering" ? steeringQueueGeneration : followUpQueueGeneration);
459
462
  const reasoning = this._state.reasoningBaseline ?? this._state.thinkingLevel;
460
463
  return {
464
+ ...internalLoopSink(this.#internalChannel),
461
465
  model: this._state.model,
462
466
  reasoning: reasoning === "off" ? undefined : reasoning,
463
467
  thinkingSelection: this._state.thinkingSelection,
@@ -653,6 +657,9 @@ export class Agent {
653
657
  if (!signal) {
654
658
  throw new Error("Agent listener invoked outside active run");
655
659
  }
660
+ const internal = dispatchInternalAgent(this.#internalChannel, { event });
661
+ if (internal)
662
+ await internal;
656
663
  for (const listener of this.listeners) {
657
664
  await listener(event, signal);
658
665
  }
@@ -1,4 +1,4 @@
1
- import { createAssistantMessageEventStream, EMPTY_RESPONSE_ERROR, EMPTY_TOOL_USE_ERROR, FORWARDED_EMPTY_RESPONSE_ERROR, FORWARDED_EMPTY_TOOL_USE_ERROR, getToolCallFormat, hasKimiTextToolCallRecovery, hasVisibleAssistantContent, hasVisibleText, shouldRecoverTextToolCalls, } from "@earendil-works/pi-ai";
1
+ import { assistantEvents, carryAssistantCapture, createAssistantMessageEventStream, EMPTY_RESPONSE_ERROR, EMPTY_TOOL_USE_ERROR, FORWARDED_EMPTY_RESPONSE_ERROR, FORWARDED_EMPTY_TOOL_USE_ERROR, getToolCallFormat, hasKimiTextToolCallRecovery, hasVisibleAssistantContent, hasVisibleText, pushAssistantProgress, shouldRecoverTextToolCalls, } from "@earendil-works/pi-ai";
2
2
  function isEmptyStop(message) {
3
3
  return message.stopReason === "stop" && !hasVisibleAssistantContent(message);
4
4
  }
@@ -56,24 +56,34 @@ function createForwardedEmptyFailure(message, toolUse) {
56
56
  }
57
57
  function createRetryingStream(firstStream, createStream, policy) {
58
58
  const outerStream = createAssistantMessageEventStream();
59
+ const forward = (entry) => {
60
+ if ("event" in entry)
61
+ outerStream.push(entry.event);
62
+ else
63
+ pushAssistantProgress(outerStream, entry.progress);
64
+ };
65
+ const replaced = (source, error) => carryAssistantCapture(source, { type: "error", reason: "error", error }, error);
59
66
  void (async () => {
60
67
  try {
61
68
  let stream = firstStream;
62
69
  let retrying = false;
63
70
  for (;;) {
71
+ // Controls are held beside the events they follow, so a discarded attempt discards both.
64
72
  const buffered = [];
65
73
  let forwarding = false;
66
74
  let retry = false;
67
- for await (const event of stream) {
75
+ const events = assistantEvents(stream, (progress) => {
76
+ if (forwarding)
77
+ pushAssistantProgress(outerStream, progress);
78
+ else
79
+ buffered.push({ progress });
80
+ });
81
+ for await (const event of events) {
68
82
  if (event.type === "done") {
69
83
  const emptyToolUse = isEmptyToolUse(event.message);
70
84
  if (isEmptyStop(event.message) || emptyToolUse) {
71
85
  if (forwarding) {
72
- outerStream.push({
73
- type: "error",
74
- reason: "error",
75
- error: createForwardedEmptyFailure(event.message, emptyToolUse),
76
- });
86
+ outerStream.push(replaced(event, createForwardedEmptyFailure(event.message, emptyToolUse)));
77
87
  outerStream.end();
78
88
  return;
79
89
  }
@@ -81,20 +91,17 @@ function createRetryingStream(firstStream, createStream, policy) {
81
91
  retry = true;
82
92
  break;
83
93
  }
84
- const error = createEmptyResponseFailure(event.message, emptyToolUse);
85
- outerStream.push({ type: "error", reason: "error", error });
94
+ outerStream.push(replaced(event, createEmptyResponseFailure(event.message, emptyToolUse)));
86
95
  outerStream.end();
87
96
  return;
88
97
  }
89
- const terminal = retrying
90
- ? {
91
- ...event,
92
- message: appendRetryDiagnostic(event.message, recoveryDiagnosticType(isEmptyToolUse(event.message))),
93
- }
94
- : event;
98
+ const message = retrying
99
+ ? appendRetryDiagnostic(event.message, recoveryDiagnosticType(isEmptyToolUse(event.message)))
100
+ : event.message;
101
+ const terminal = retrying ? carryAssistantCapture(event, { ...event, message }, message) : event;
95
102
  if (!forwarding) {
96
103
  for (const pending of buffered)
97
- outerStream.push(pending);
104
+ forward(pending);
98
105
  }
99
106
  outerStream.push(terminal);
100
107
  outerStream.end();
@@ -103,7 +110,7 @@ function createRetryingStream(firstStream, createStream, policy) {
103
110
  if (event.type === "error") {
104
111
  if (!forwarding) {
105
112
  for (const pending of buffered)
106
- outerStream.push(pending);
113
+ forward(pending);
107
114
  }
108
115
  outerStream.push(event);
109
116
  outerStream.end();
@@ -113,10 +120,10 @@ function createRetryingStream(firstStream, createStream, policy) {
113
120
  outerStream.push(event);
114
121
  continue;
115
122
  }
116
- buffered.push(event);
123
+ buffered.push({ event });
117
124
  if (isMeaningfulContentEvent(event, policy)) {
118
125
  for (const pending of buffered)
119
- outerStream.push(pending);
126
+ forward(pending);
120
127
  forwarding = true;
121
128
  }
122
129
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { AttributeValue, ExactTelemetryAttributes, InferEventAttributes, In
3
3
  export { createTypedSpanStarter, defineTelemetrySchema, InMemoryTelemetryContext, NOOP_TELEMETRY_CONTEXT, } from "@earendil-works/pi-telemetry";
4
4
  export * from "./agent.ts";
5
5
  export * from "./agent-loop.ts";
6
+ export { createInternalAgentChannel, type InternalAgentChannel, type OrderedAgentRecord } from "./internal-events.ts";
6
7
  export { EMPTY_TOOL_USE_DEMOTION_DIAGNOSTIC, ProviderRetryWatchdogAbortError } from "./assistant-terminal-state.ts";
7
8
  export * from "./harness/agent-harness.ts";
8
9
  export { type BranchPreparation, type BranchSummaryDetails, type BranchSummaryResult, type CollectEntriesResult, collectEntriesForBranchSummary, type FileOperations, type GenerateBranchSummaryOptions, generateBranchSummary, prepareBranchEntries, } from "./harness/compaction/branch-summarization.ts";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { uuidv7 } from "@earendil-works/pi-ai";
2
2
  export { createTypedSpanStarter, defineTelemetrySchema, InMemoryTelemetryContext, NOOP_TELEMETRY_CONTEXT, } from "@earendil-works/pi-telemetry";
3
3
  export * from "./agent.js";
4
4
  export * from "./agent-loop.js";
5
+ export { createInternalAgentChannel } from "./internal-events.js";
5
6
  export { EMPTY_TOOL_USE_DEMOTION_DIAGNOSTIC, ProviderRetryWatchdogAbortError } from "./assistant-terminal-state.js";
6
7
  export * from "./harness/agent-harness.js";
7
8
  export { collectEntriesForBranchSummary, generateBranchSummary, prepareBranchEntries, } from "./harness/compaction/branch-summarization.js";
@@ -0,0 +1,34 @@
1
+ import { type AssistantProgress } from "@earendil-works/pi-ai";
2
+ import type { AgentEvent, AgentLoopConfig } from "./types.ts";
3
+ export declare const internalProgressSink: unique symbol;
4
+ type ProgressSink = (progress: AssistantProgress) => Promise<void> | undefined;
5
+ export type InternalLoopConfig = AgentLoopConfig & {
6
+ [internalProgressSink]?: ProgressSink;
7
+ };
8
+ type InternalEvent = {
9
+ event: AgentEvent;
10
+ } | {
11
+ progress: AssistantProgress;
12
+ };
13
+ /** @internal For senpi's session layer only. */
14
+ export type OrderedAgentRecord = InternalEvent & {
15
+ readonly sequence: number;
16
+ };
17
+ type Listener = (record: OrderedAgentRecord) => Promise<void> | void;
18
+ /** @internal Construction-time capability for senpi's session layer only. */
19
+ export interface InternalAgentChannel {
20
+ subscribe(listener: Listener): () => void;
21
+ }
22
+ /**
23
+ * @internal For senpi's session layer only. Pass at Agent construction; an existing Agent cannot be attached.
24
+ * Capture is held from the first listener until the last release, never by an idle channel.
25
+ */
26
+ export declare function createInternalAgentChannel(): InternalAgentChannel;
27
+ /** A run started without an internal listener gets no sink, and its loop reads the public stream exactly as before. */
28
+ export declare function internalLoopSink(channel: InternalAgentChannel | undefined): {
29
+ [internalProgressSink]?: ProgressSink;
30
+ };
31
+ /** Returns nothing, so it costs the public pipeline no microtask, while the owner has no internal listener. */
32
+ export declare function dispatchInternalAgent(channel: InternalAgentChannel | undefined, event: InternalEvent): Promise<void> | undefined;
33
+ export {};
34
+ //# sourceMappingURL=internal-events.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { subscribeAssistantCapture } from "@earendil-works/pi-ai";
2
+ export const internalProgressSink = Symbol("internalProgressSink");
3
+ const channels = new WeakMap();
4
+ /**
5
+ * @internal For senpi's session layer only. Pass at Agent construction; an existing Agent cannot be attached.
6
+ * Capture is held from the first listener until the last release, never by an idle channel.
7
+ */
8
+ export function createInternalAgentChannel() {
9
+ const state = { sequence: 0, listeners: new Set() };
10
+ let releaseCapture;
11
+ const channel = Object.freeze({
12
+ subscribe(listener) {
13
+ state.listeners.add(listener);
14
+ releaseCapture ??= subscribeAssistantCapture();
15
+ return () => {
16
+ state.listeners.delete(listener);
17
+ if (state.listeners.size === 0) {
18
+ releaseCapture?.();
19
+ releaseCapture = undefined;
20
+ }
21
+ };
22
+ },
23
+ });
24
+ channels.set(channel, state);
25
+ return channel;
26
+ }
27
+ /** A run started without an internal listener gets no sink, and its loop reads the public stream exactly as before. */
28
+ export function internalLoopSink(channel) {
29
+ if (!channel || !channels.get(channel)?.listeners.size)
30
+ return {};
31
+ return { [internalProgressSink]: (progress) => dispatchInternalAgent(channel, { progress }) };
32
+ }
33
+ /** Returns nothing, so it costs the public pipeline no microtask, while the owner has no internal listener. */
34
+ export function dispatchInternalAgent(channel, event) {
35
+ const state = channel && channels.get(channel);
36
+ if (!state?.listeners.size)
37
+ return undefined;
38
+ const record = Object.freeze({ ...event, sequence: ++state.sequence });
39
+ return (async () => {
40
+ for (const listener of state.listeners)
41
+ await listener(record);
42
+ })();
43
+ }
44
+ //# sourceMappingURL=internal-events.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.25-4",
3
+ "version": "2026.10.26-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",
@@ -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.25-4",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.25-4",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.26-2",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.26-2",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",