@code-yeongyu/senpi-agent-core 2026.10.21-1 → 2026.10.21-3

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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { createInitialSystemMessage, EventStream, getCurrentTools, getToolStateChanges, isCursorExecResolved, normalizeContext, supportsAllowedToolChoice, validateToolArguments, } from "@earendil-works/pi-ai";
6
6
  import { createTerminalFailureAssistantMessage, demoteToolUseWithoutToolCalls, isStreamIdleTimeoutError, normalizeTerminalAssistantMessage, promoteStopWithPendingToolCalls, shouldFinalizeIdleAsStop, shouldTerminateAssistantTurn, } from "./assistant-terminal-state.js";
7
+ import { createResponseTimingRecorder } from "./response-timing.js";
7
8
  import { getDefaultStreamFn, withEmptyAssistantRecovery } from "./stream-fn.js";
8
9
  import { prepareAgentToolCallArguments } from "./tool-arguments.js";
9
10
  import { resolveCallTool, withToolNameCorrection } from "./tool-name-alias.js";
@@ -445,7 +446,9 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
445
446
  // after the assistant message so pairs stay adjacent in the transcript.
446
447
  const providerToolResults = [];
447
448
  const thinkingTiming = new Map();
448
- function propagateThinkingTiming(finalMessage) {
449
+ const responseTiming = createResponseTimingRecorder();
450
+ function stampTiming(finalMessage) {
451
+ responseTiming.stamp(finalMessage);
449
452
  for (const timing of thinkingTiming.values()) {
450
453
  if (timing.endedAt === undefined)
451
454
  timing.endedAt = Date.now();
@@ -480,6 +483,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
480
483
  const llmContext = await preStream.run(buildProviderContext(context, config, requestAbortController.signal));
481
484
  // Resolve API key (important for expiring tokens)
482
485
  const resolvedApiKey = (config.getApiKey ? await preStream.run(config.getApiKey(config.model.provider)) : undefined) || config.apiKey;
486
+ responseTiming.requested();
483
487
  const response = await preStream.run(streamFunction(config.model, llmContext, {
484
488
  ...config,
485
489
  streamKind: "main",
@@ -516,6 +520,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
516
520
  const event = next.value;
517
521
  switch (event.type) {
518
522
  case "start":
523
+ responseTiming.started();
519
524
  partialMessage = event.partial;
520
525
  context.messages.push(partialMessage);
521
526
  addedPartial = true;
@@ -535,6 +540,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
535
540
  case "toolcall_end":
536
541
  if (partialMessage) {
537
542
  partialMessage = event.partial;
543
+ responseTiming.observe(event);
538
544
  if (event.type === "thinking_start" ||
539
545
  event.type === "thinking_delta" ||
540
546
  event.type === "thinking_end") {
@@ -563,7 +569,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
563
569
  case "done":
564
570
  case "error": {
565
571
  const finalMessage = normalizeTerminalAssistantMessage(await result(), event);
566
- propagateThinkingTiming(finalMessage);
572
+ stampTiming(finalMessage);
567
573
  if (addedPartial) {
568
574
  context.messages[context.messages.length - 1] = finalMessage;
569
575
  }
@@ -586,7 +592,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
586
592
  eventReader.dispose();
587
593
  }
588
594
  const finalMessage = await result();
589
- propagateThinkingTiming(finalMessage);
595
+ stampTiming(finalMessage);
590
596
  if (addedPartial) {
591
597
  context.messages[context.messages.length - 1] = finalMessage;
592
598
  }
@@ -619,7 +625,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
619
625
  stopReason: "stop",
620
626
  timestamp: partialMessage?.timestamp ?? Date.now(),
621
627
  };
622
- propagateThinkingTiming(finalMessage);
628
+ stampTiming(finalMessage);
623
629
  if (addedPartial) {
624
630
  context.messages[context.messages.length - 1] = finalMessage;
625
631
  }
@@ -637,7 +643,7 @@ async function streamAssistantResponse(context, config, signal, emit, streamFunc
637
643
  const callerAbortedPastBound = signal?.aborted === true &&
638
644
  (error instanceof StreamStartTimeoutError || isStreamIdleTimeoutError(error) || error === signal.reason);
639
645
  const finalMessage = createTerminalFailureAssistantMessage(config.model, signal?.aborted ? "aborted" : "error", callerAbortedPastBound ? abortError(signal.reason) : error, partialMessage);
640
- propagateThinkingTiming(finalMessage);
646
+ stampTiming(finalMessage);
641
647
  if (addedPartial) {
642
648
  context.messages[context.messages.length - 1] = finalMessage;
643
649
  }
@@ -79,6 +79,9 @@ export const DEFAULT_COMPACTION_SETTINGS = {
79
79
  };
80
80
  /** Calculate total context tokens from provider usage. */
81
81
  export function calculateContextTokens(usage) {
82
+ if (usage.contextUsage !== undefined) {
83
+ return usage.contextUsage === null ? 0 : usage.contextUsage.input + usage.contextUsage.output;
84
+ }
82
85
  return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
83
86
  }
84
87
  function getAssistantUsage(msg) {
@@ -96,7 +96,11 @@ export declare class Harness<Ks extends readonly AnyKind[] = []> {
96
96
  private reconcileOrphans;
97
97
  quiescent(): boolean;
98
98
  hold(): () => void;
99
- /** Cancel and join in-process invocations, clear transient waits, then close without terminalizing tasks. */
99
+ /**
100
+ * Cancel and join in-process invocations, clear transient waits, then close without terminalizing tasks.
101
+ * The views and the session are closed even when joining or the waiting-state commit fails (a storage
102
+ * that refuses writes), so a failed suspend never leaks the session files; the first error is rethrown.
103
+ */
100
104
  suspend(ctx: Context): Promise<void>;
101
105
  registerTaskKind<K extends AnyKind>(kind: K): () => void;
102
106
  namespace<T extends JsonObject>(id: string, defaults: NamespaceDefaults<T>, opts?: {
@@ -266,26 +266,43 @@ export class Harness {
266
266
  throw new Error("cannot hold a non-quiescent harness; suspend it instead");
267
267
  return this.scheduler.hold();
268
268
  }
269
- /** Cancel and join in-process invocations, clear transient waits, then close without terminalizing tasks. */
269
+ /**
270
+ * Cancel and join in-process invocations, clear transient waits, then close without terminalizing tasks.
271
+ * The views and the session are closed even when joining or the waiting-state commit fails (a storage
272
+ * that refuses writes), so a failed suspend never leaks the session files; the first error is rethrown.
273
+ */
270
274
  async suspend(ctx) {
271
275
  if (this.suspended)
272
276
  return;
273
277
  this.suspended = true;
274
- await this.scheduler.joinAll();
275
- const tools = [...this.session.liveTasks.values()].filter((task) => task.kind === "pi.tool");
276
- if (tools.length > 0) {
277
- const docs = [...new Set(tools.map((task) => task.conversationId))].map((conversationId) => ({ doc: "sticky", conversationId }));
278
- await this.session.commit(KERNEL, (tx) => {
279
- for (const task of tools) {
280
- const index = task.input.index;
281
- const slot = tx.sticky(task.conversationId).turn.tools[index];
282
- if (slot?.waitingOn !== undefined)
283
- delete slot.waitingOn;
284
- }
285
- }, ctx, { docs });
278
+ let failure;
279
+ try {
280
+ await this.scheduler.joinAll();
281
+ const tools = [...this.session.liveTasks.values()].filter((task) => task.kind === "pi.tool");
282
+ if (tools.length > 0) {
283
+ const docs = [...new Set(tools.map((task) => task.conversationId))].map((conversationId) => ({ doc: "sticky", conversationId }));
284
+ await this.session.commit(KERNEL, (tx) => {
285
+ for (const task of tools) {
286
+ const index = task.input.index;
287
+ const slot = tx.sticky(task.conversationId).turn.tools[index];
288
+ if (slot?.waitingOn !== undefined)
289
+ delete slot.waitingOn;
290
+ }
291
+ }, ctx, { docs });
292
+ }
293
+ }
294
+ catch (error) {
295
+ failure = { error };
286
296
  }
287
297
  this.views.close();
288
- await this.session.close(ctx);
298
+ try {
299
+ await this.session.close(ctx);
300
+ }
301
+ catch (error) {
302
+ failure ??= { error };
303
+ }
304
+ if (failure !== undefined)
305
+ throw failure.error;
289
306
  }
290
307
  // --- registries --------------------------------------------------------------
291
308
  registerTaskKind(kind) {
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ export * from "./harness/utils/segmented-read-view.ts";
26
26
  export * from "./harness/utils/shell-output.ts";
27
27
  export * from "./harness/utils/truncate.ts";
28
28
  export * from "./proxy.ts";
29
+ export * from "./response-timing.ts";
29
30
  export * from "./search/index.ts";
30
31
  export { setDefaultStreamFn } from "./stream-fn.ts";
31
32
  export { prepareToolArguments } from "./tool-arguments.ts";
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export * from "./harness/utils/segmented-read-view.js";
24
24
  export * from "./harness/utils/shell-output.js";
25
25
  export * from "./harness/utils/truncate.js";
26
26
  export * from "./proxy.js";
27
+ export * from "./response-timing.js";
27
28
  export * from "./search/index.js";
28
29
  export { setDefaultStreamFn } from "./stream-fn.js";
29
30
  export { prepareToolArguments } from "./tool-arguments.js";
@@ -0,0 +1,31 @@
1
+ import type { AssistantMessage, AssistantMessageEvent } from "@earendil-works/pi-ai";
2
+ /** The deltas that count as streamed output for time to first token and the generation window. */
3
+ export declare const STREAMED_DELTA_TYPES: ReadonlySet<string>;
4
+ export type GenerationWindowMessage = {
5
+ readonly usage?: {
6
+ readonly reasoning?: number;
7
+ };
8
+ readonly content?: ReadonlyArray<{
9
+ readonly type?: string;
10
+ }>;
11
+ };
12
+ /**
13
+ * Whether a response generated output before its first streamed delta, so its generation window starts at the
14
+ * response start instead (senpi#3393). Reasoning counted in usage.output may be hidden or only summarised after it
15
+ * finished; a thinking block whose text never streamed (Bedrock, omitted display) reports no reasoning count. Shared
16
+ * by the persisted timing (senpi#3879) and the TPS notice so both measure the same window.
17
+ */
18
+ export declare function reasonedBeforeFirstDelta(message: GenerationWindowMessage, thinkingStreamed: boolean): boolean;
19
+ /**
20
+ * Records one response's timing as the agent loop streams it, then stamps it on the final message (senpi#3879).
21
+ * Wall-clock fields use `Date.now()` so they line up with `timestamp`; the generation window uses the monotonic
22
+ * clock, like the TPS notice, so a clock jump cannot make it negative.
23
+ */
24
+ export declare function createResponseTimingRecorder(): {
25
+ requested(): void;
26
+ started(): void;
27
+ observe(event: AssistantMessageEvent): void;
28
+ /** Stamps the final message. A response whose request was never issued gets no timing. */
29
+ stamp(message: AssistantMessage): void;
30
+ };
31
+ //# sourceMappingURL=response-timing.d.ts.map
@@ -0,0 +1,66 @@
1
+ /** The deltas that count as streamed output for time to first token and the generation window. */
2
+ export const STREAMED_DELTA_TYPES = new Set(["text_delta", "thinking_delta", "toolcall_delta"]);
3
+ /**
4
+ * Whether a response generated output before its first streamed delta, so its generation window starts at the
5
+ * response start instead (senpi#3393). Reasoning counted in usage.output may be hidden or only summarised after it
6
+ * finished; a thinking block whose text never streamed (Bedrock, omitted display) reports no reasoning count. Shared
7
+ * by the persisted timing (senpi#3879) and the TPS notice so both measure the same window.
8
+ */
9
+ export function reasonedBeforeFirstDelta(message, thinkingStreamed) {
10
+ if ((message.usage?.reasoning ?? 0) > 0)
11
+ return true;
12
+ if (thinkingStreamed)
13
+ return false;
14
+ return message.content?.some((block) => block.type === "thinking") ?? false;
15
+ }
16
+ /**
17
+ * Records one response's timing as the agent loop streams it, then stamps it on the final message (senpi#3879).
18
+ * Wall-clock fields use `Date.now()` so they line up with `timestamp`; the generation window uses the monotonic
19
+ * clock, like the TPS notice, so a clock jump cannot make it negative.
20
+ */
21
+ export function createResponseTimingRecorder() {
22
+ let requestedAt;
23
+ let startedMs;
24
+ let firstDeltaMs;
25
+ let lastDeltaMs;
26
+ let firstTokenAt;
27
+ let lastTokenAt;
28
+ let thinkingStreamed = false;
29
+ return {
30
+ requested() {
31
+ requestedAt = Date.now();
32
+ },
33
+ started() {
34
+ startedMs ??= performance.now();
35
+ },
36
+ observe(event) {
37
+ if (!STREAMED_DELTA_TYPES.has(event.type))
38
+ return;
39
+ const now = performance.now();
40
+ const at = Date.now();
41
+ if (event.type === "thinking_delta")
42
+ thinkingStreamed = true;
43
+ firstDeltaMs ??= now;
44
+ firstTokenAt ??= at;
45
+ lastDeltaMs = now;
46
+ lastTokenAt = at;
47
+ },
48
+ /** Stamps the final message. A response whose request was never issued gets no timing. */
49
+ stamp(message) {
50
+ if (requestedAt === undefined)
51
+ return;
52
+ const windowStart = reasonedBeforeFirstDelta(message, thinkingStreamed) ? startedMs : firstDeltaMs;
53
+ const generationMs = windowStart !== undefined && lastDeltaMs !== undefined && lastDeltaMs > windowStart
54
+ ? Math.round(lastDeltaMs - windowStart)
55
+ : undefined;
56
+ message.timing = {
57
+ requestedAt,
58
+ ...(firstTokenAt === undefined ? {} : { firstTokenAt }),
59
+ ...(lastTokenAt === undefined ? {} : { lastTokenAt }),
60
+ endedAt: Math.max(Date.now(), lastTokenAt ?? requestedAt),
61
+ ...(generationMs === undefined ? {} : { generationMs }),
62
+ };
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=response-timing.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.21-1",
3
+ "version": "2026.10.21-3",
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.21-1",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.21-1",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.21-3",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.21-3",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",