@code-yeongyu/senpi-agent-core 2026.10.25-4 → 2026.10.26-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.
@@ -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.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) {
@@ -458,6 +459,7 @@ export class Agent {
458
459
  const giveBack = (queue, messages) => this.giveBack(queue, messages, queue === "steering" ? steeringQueueGeneration : followUpQueueGeneration);
459
460
  const reasoning = this._state.reasoningBaseline ?? this._state.thinkingLevel;
460
461
  return {
462
+ ...internalLoopSink(this),
461
463
  model: this._state.model,
462
464
  reasoning: reasoning === "off" ? undefined : reasoning,
463
465
  thinkingSelection: this._state.thinkingSelection,
@@ -653,6 +655,9 @@ export class Agent {
653
655
  if (!signal) {
654
656
  throw new Error("Agent listener invoked outside active run");
655
657
  }
658
+ const internal = dispatchInternalAgent(this, { event });
659
+ if (internal)
660
+ await internal;
656
661
  for (const listener of this.listeners) {
657
662
  await listener(event, signal);
658
663
  }
@@ -0,0 +1,29 @@
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
+ export type OrderedAgentRecord = InternalEvent & {
14
+ readonly sequence: number;
15
+ };
16
+ type Listener = (record: OrderedAgentRecord) => Promise<void> | void;
17
+ /**
18
+ * S02a owner seam: deliberately not exported by the package barrel or subscribed by a host.
19
+ * A listener holds a capture subscription, so provider streams that first push after it build capture records.
20
+ */
21
+ export declare function subscribeInternalAgent(owner: object, listener: Listener): () => void;
22
+ /** A run started without an internal listener gets no sink, and its loop reads the public stream exactly as before. */
23
+ export declare function internalLoopSink(owner: object): {
24
+ [internalProgressSink]?: ProgressSink;
25
+ };
26
+ /** Returns nothing, so it costs the public pipeline no microtask, while the owner has no internal listener. */
27
+ export declare function dispatchInternalAgent(owner: object, event: InternalEvent): Promise<void> | undefined;
28
+ export {};
29
+ //# sourceMappingURL=internal-events.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { subscribeAssistantCapture } from "@earendil-works/pi-ai";
2
+ export const internalProgressSink = Symbol("internalProgressSink");
3
+ const channels = new WeakMap();
4
+ /**
5
+ * S02a owner seam: deliberately not exported by the package barrel or subscribed by a host.
6
+ * A listener holds a capture subscription, so provider streams that first push after it build capture records.
7
+ */
8
+ export function subscribeInternalAgent(owner, listener) {
9
+ let channel = channels.get(owner);
10
+ if (!channel) {
11
+ channel = { sequence: 0, listeners: new Set() };
12
+ channels.set(owner, channel);
13
+ }
14
+ channel.listeners.add(listener);
15
+ const release = subscribeAssistantCapture();
16
+ return () => {
17
+ channel.listeners.delete(listener);
18
+ release();
19
+ };
20
+ }
21
+ /** A run started without an internal listener gets no sink, and its loop reads the public stream exactly as before. */
22
+ export function internalLoopSink(owner) {
23
+ if (!channels.get(owner)?.listeners.size)
24
+ return {};
25
+ return { [internalProgressSink]: (progress) => dispatchInternalAgent(owner, { progress }) };
26
+ }
27
+ /** Returns nothing, so it costs the public pipeline no microtask, while the owner has no internal listener. */
28
+ export function dispatchInternalAgent(owner, event) {
29
+ const channel = channels.get(owner);
30
+ if (!channel?.listeners.size)
31
+ return undefined;
32
+ const record = Object.freeze({ ...event, sequence: ++channel.sequence });
33
+ return (async () => {
34
+ for (const listener of channel.listeners)
35
+ await listener(record);
36
+ })();
37
+ }
38
+ //# 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-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",
@@ -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-1",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.26-1",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",