@code-yeongyu/senpi-agent-core 2026.10.1 → 2026.10.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.
package/README.md CHANGED
@@ -15,6 +15,10 @@ bun add @earendil-works/pi-agent-core
15
15
 
16
16
  The SQLite session backend and the `node:sqlite` adapter live in a separate package, `@earendil-works/pi-storage-sqlite-node`, so the core package does not pull in runtime builtins or native SQLite dependencies by default. The backend accepts a runtime-specific SQLite factory, allowing other session backends to ship as their own packages in the future.
17
17
 
18
+ ### Agent harness
19
+
20
+ This fork keeps the agent harness (`src/harness/**`) in this package, together with its subpath exports (`./node`, `./harness/*`, `./experimental/pico3`) and the SQLite session backend package. Upstream v1.0.0 moved that runtime into `pi-durable`; the fork did not adopt that move, so coding-agent keeps importing the harness from `@earendil-works/pi-agent-core`.
21
+
18
22
  ## Quick Start
19
23
 
20
24
  ```typescript
package/dist/agent.js CHANGED
@@ -56,6 +56,11 @@ function createMutableAgentState(initialState) {
56
56
  providerDiagnostic: undefined,
57
57
  };
58
58
  }
59
+ const LLM_ROLES = new Set(["user", "assistant", "toolResult", "system"]);
60
+ /** A queued message with an app-defined role (monitor, task or background-command notices). */
61
+ function isBackgroundNotice(message) {
62
+ return !LLM_ROLES.has(message.role);
63
+ }
59
64
  class PendingMessageQueue {
60
65
  constructor(mode) {
61
66
  this.messages = [];
@@ -71,11 +76,23 @@ class PendingMessageQueue {
71
76
  getClearGeneration() {
72
77
  return this.clearGeneration;
73
78
  }
79
+ /**
80
+ * `one-at-a-time` gives each queued LLM message (user, assistant, tool result, system) its own
81
+ * drain. Background notices (custom roles, such as monitor or task events) queued back to back
82
+ * are one batch: a burst of events is answered by one turn instead of one turn per event.
83
+ */
74
84
  peek() {
75
85
  if (this.mode === "all")
76
86
  return this.messages.slice();
77
87
  const first = this.messages[0];
78
- return first ? [first] : [];
88
+ if (!first)
89
+ return [];
90
+ if (!isBackgroundNotice(first))
91
+ return [first];
92
+ let end = 1;
93
+ while (end < this.messages.length && isBackgroundNotice(this.messages[end]))
94
+ end++;
95
+ return this.messages.slice(0, end);
79
96
  }
80
97
  drain() {
81
98
  const drained = this.peek();
@@ -421,9 +421,22 @@ export async function generateSummaryWithRequest(currentMessages, options, reque
421
421
  if (response.stopReason === "error") {
422
422
  return err(new CompactionError("summarization_failed", `Summarization failed: ${response.errorMessage || "Unknown error"}`));
423
423
  }
424
+ const unusable = unusableSummary(response, "Summarization");
425
+ if (unusable !== undefined)
426
+ return err(new CompactionError("summarization_failed", unusable));
424
427
  const textContent = contentTextForSummary(response.content);
425
428
  return ok({ text: textContent, usage: response.usage });
426
429
  }
430
+ /** Why a settled response cannot replace history: only a clean stop with text and no tool call is a summary. */
431
+ function unusableSummary(response, label) {
432
+ if (response.stopReason === "length")
433
+ return `${label} hit the token limit; the summary is incomplete`;
434
+ if (response.content.some((block) => block.type === "toolCall"))
435
+ return `${label} attempted to call a tool`;
436
+ if (contentTextForSummary(response.content).trim().length === 0)
437
+ return `${label} produced no text`;
438
+ return undefined;
439
+ }
427
440
  /** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
428
441
  export function prepareCompaction(pathEntries, settings) {
429
442
  if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
@@ -452,7 +465,7 @@ export function prepareCompaction(pathEntries, settings) {
452
465
  compactableEntries = [...virtualRetainedEntries, ...pathEntries.slice(prevCompactionIndex + 1)];
453
466
  }
454
467
  const boundaryEnd = compactableEntries.length;
455
- const tokensBefore = estimateContextTokens(buildContextEntries(pathEntries).flatMap(sessionEntryToContextMessages)).tokens;
468
+ const tokensBefore = estimateCompactionPathTokens(pathEntries);
456
469
  const cutPoint = findCutPoint(compactableEntries, 0, boundaryEnd, settings.keepRecentTokens);
457
470
  const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
458
471
  const messagesToSummarize = [];
@@ -492,6 +505,29 @@ export function prepareCompaction(pathEntries, settings) {
492
505
  settings,
493
506
  });
494
507
  }
508
+ /**
509
+ * Context tokens of a session path. Usage reported before the newest compaction measured the history that
510
+ * compaction replaced, so only usage reported after it anchors the estimate; until a newer response reports
511
+ * usage, the summary and retained tail are estimated from their content.
512
+ */
513
+ function estimateCompactionPathTokens(pathEntries) {
514
+ const entries = buildContextEntries(pathEntries);
515
+ let boundary = 0;
516
+ for (let i = entries.length - 1; i >= 0; i--) {
517
+ if (entries[i].type === "compaction") {
518
+ boundary = i + 1;
519
+ break;
520
+ }
521
+ }
522
+ const since = estimateContextTokens(entries.slice(boundary).flatMap(sessionEntryToContextMessages));
523
+ if (since.lastUsageIndex !== null)
524
+ return since.tokens;
525
+ let tokens = since.tokens;
526
+ for (const message of dropFailedAssistantTurns(entries.slice(0, boundary).flatMap(sessionEntryToContextMessages))) {
527
+ tokens += estimateTokens(message);
528
+ }
529
+ return tokens;
530
+ }
495
531
  const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
496
532
 
497
533
  Summarize the prefix to provide context for the retained suffix:
@@ -567,6 +603,9 @@ async function generateTurnPrefixSummary(messages, model, reserveTokens, thinkin
567
603
  if (response.stopReason === "error") {
568
604
  return err(new CompactionError("summarization_failed", `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`));
569
605
  }
606
+ const unusable = unusableSummary(response, "Turn prefix summarization");
607
+ if (unusable !== undefined)
608
+ return err(new CompactionError("summarization_failed", unusable));
570
609
  return ok({
571
610
  text: contentTextForSummary(response.content),
572
611
  usage: response.usage,
@@ -700,12 +700,15 @@ export class NodeExecutionEnv {
700
700
  settle(err(callbackError));
701
701
  return;
702
702
  }
703
- if (timedOut) {
704
- settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
705
- return;
706
- }
707
- if (signal?.aborted) {
708
- settle(err(new ExecutionError("aborted", "aborted")));
703
+ const interrupted = timedOut
704
+ ? new ExecutionError("timeout", `timeout:${options?.timeout}`)
705
+ : signal?.aborted
706
+ ? new ExecutionError("aborted", "aborted")
707
+ : undefined;
708
+ if (interrupted !== undefined) {
709
+ if (spillPath !== undefined)
710
+ interrupted.spillPath = spillPath;
711
+ settle(err(interrupted));
709
712
  return;
710
713
  }
711
714
  if (spillError) {
@@ -833,6 +833,10 @@ export async function recoverStructuralGeneration(lane, drive, effect) {
833
833
  }), drive.context);
834
834
  return published.kind === "cancel_requested" ? { kind: "continue" } : published.value;
835
835
  }
836
+ /** Automatic compaction needs history before its cut; summarizing nothing only spends a provider request. */
837
+ function summarizesHistory(preparation) {
838
+ return preparation.messagesToSummarize.length > 0 || preparation.turnPrefixMessages.length > 0;
839
+ }
836
840
  /** Prepare threshold compaction only when no newer compaction already guards this trigger. */
837
841
  export async function prepareCompactionThreshold(lane, drive, checkpoint) {
838
842
  const settings = checkpoint.settings.compaction;
@@ -860,7 +864,9 @@ export async function prepareCompactionThreshold(lane, drive, checkpoint) {
860
864
  const prepared = prepareCompaction(path.value, settings);
861
865
  if (!prepared.ok)
862
866
  throw prepared.error;
863
- if (prepared.value === undefined || !shouldCompact(prepared.value.tokensBefore, model.contextWindow, settings)) {
867
+ if (prepared.value === undefined ||
868
+ !summarizesHistory(prepared.value) ||
869
+ !shouldCompact(prepared.value.tokensBefore, model.contextWindow, settings)) {
864
870
  return { kind: "result", value: undefined };
865
871
  }
866
872
  return {
@@ -870,7 +876,7 @@ export async function prepareCompactionThreshold(lane, drive, checkpoint) {
870
876
  }
871
877
  /** Prepare one overflow compaction before the response settlement transaction. */
872
878
  export async function prepareOverflowCompaction(lane, drive, generation) {
873
- if (generation.generationContext.overflowRecoveryUsed)
879
+ if (generation.generationContext.overflowRecoveryUsed || !generation.settings.compaction.enabled)
874
880
  return undefined;
875
881
  const path = await readBoundedEntries(lane, drive, generation);
876
882
  if (path.kind === "cancel_requested")
@@ -878,7 +884,7 @@ export async function prepareOverflowCompaction(lane, drive, generation) {
878
884
  const prepared = prepareCompaction(path.value, generation.settings.compaction);
879
885
  if (!prepared.ok)
880
886
  throw prepared.error;
881
- if (prepared.value === undefined)
887
+ if (prepared.value === undefined || !summarizesHistory(prepared.value))
882
888
  return undefined;
883
889
  return {
884
890
  taskId: lane.session.idGenerator.next(),
@@ -70,7 +70,12 @@ export function createBashTool(options) {
70
70
  }, context);
71
71
  acceptingUpdates = false;
72
72
  let outputText = view?.text ?? "";
73
- const capture = result.ok ? { text: outputText, ...result.value } : view;
73
+ const interruptedSpillPath = result.ok ? undefined : result.error.spillPath;
74
+ const capture = result.ok
75
+ ? { text: outputText, ...result.value }
76
+ : view !== undefined && view.spillPath === undefined && interruptedSpillPath !== undefined
77
+ ? { ...view, spillPath: interruptedSpillPath }
78
+ : view;
74
79
  let details;
75
80
  if (capture?.truncation.truncated) {
76
81
  details = { truncation: capture.truncation, fullOutputPath: capture.spillPath };
@@ -87,6 +92,9 @@ export function createBashTool(options) {
87
92
  outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${capture.spillPath}]`;
88
93
  }
89
94
  }
95
+ else if (interruptedSpillPath !== undefined) {
96
+ outputText += `${outputText ? "\n\n" : ""}[Full output: ${interruptedSpillPath}]`;
97
+ }
90
98
  if (!result.ok) {
91
99
  const status = result.error.code === "timeout"
92
100
  ? `Command timed out after ${timeout} seconds`
@@ -21,10 +21,11 @@ function isSingleEditInput(value) {
21
21
  const edit = value;
22
22
  return typeof edit.oldText === "string" && typeof edit.newText === "string";
23
23
  }
24
+ /** Works on a copy, so the provider's tool call arguments stay unchanged; arrays are left for validation to reject. */
24
25
  function prepareEditArguments(input) {
25
- if (!input || typeof input !== "object")
26
+ if (!input || typeof input !== "object" || Array.isArray(input))
26
27
  return input;
27
- const args = input;
28
+ const args = { ...input };
28
29
  if (typeof args.edits === "string") {
29
30
  try {
30
31
  const parsed = JSON.parse(args.edits);
@@ -10,12 +10,25 @@ function getState(env) {
10
10
  }
11
11
  async function getMutationQueueKey(env, path, context) {
12
12
  const absolutePath = getOrThrow(await env.absolutePath(path, context));
13
+ return canonicalKeyPath(env, absolutePath, context);
14
+ }
15
+ /**
16
+ * The canonical path; for a file that does not exist yet, its canonical parent joined with its name, so a write that
17
+ * creates a file and a later mutation of it share one key even when one of them goes through a symlinked directory.
18
+ */
19
+ async function canonicalKeyPath(env, absolutePath, context) {
13
20
  const canonicalPath = await env.canonicalPath(absolutePath, context);
14
21
  if (canonicalPath.ok)
15
22
  return canonicalPath.value;
16
- if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported")
23
+ if (canonicalPath.error.code === "not_supported")
24
+ return absolutePath;
25
+ if (canonicalPath.error.code !== "not_found")
26
+ throw canonicalPath.error;
27
+ const parent = getOrThrow(await env.joinPath([absolutePath, ".."], context));
28
+ if (parent === absolutePath || !absolutePath.startsWith(parent))
17
29
  return absolutePath;
18
- throw canonicalPath.error;
30
+ const name = absolutePath.slice(parent.length + (/[/\\]$/.test(parent) ? 0 : 1));
31
+ return getOrThrow(await env.joinPath([await canonicalKeyPath(env, parent, context), name], context));
19
32
  }
20
33
  /** Serialize file mutations targeting the same environment and canonical path. */
21
34
  export async function withFileMutationQueue(env, path, fn, context) {
@@ -129,6 +129,8 @@ export declare class ExecutionError extends Error {
129
129
  readonly cause?: unknown;
130
130
  /** Backend-independent error code. */
131
131
  code: ExecutionErrorCode;
132
+ /** Complete output preserved before a timeout or abort interrupted the command, when it spilled. */
133
+ spillPath?: string;
132
134
  constructor(code: ExecutionErrorCode, message: string, cause?: unknown);
133
135
  }
134
136
  /** Stable compaction error codes returned by compaction helpers. */
@@ -149,10 +149,9 @@ export function truncateHead(content, options = {}) {
149
149
  outputLinesArr.push(line);
150
150
  outputBytesCount += lineBytes;
151
151
  }
152
- // If we exited due to line limit
153
- if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
154
- truncatedBy = "lines";
155
- }
152
+ // Without a byte break, only omitted lines prove the line limit was reached; otherwise a trailing newline exceeded bytes.
153
+ if (truncatedBy !== "bytes")
154
+ truncatedBy = outputLinesArr.length < totalLines ? "lines" : "bytes";
156
155
  const outputContent = outputLinesArr.join("\n");
157
156
  const finalOutputBytes = utf8ByteLength(outputContent);
158
157
  return {
package/dist/types.d.ts CHANGED
@@ -31,6 +31,8 @@ export type ToolExecutionMode = "sequential" | "parallel";
31
31
  *
32
32
  * - "all": drain and inject every queued message at that point.
33
33
  * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
34
+ * A run of consecutive app-defined notices (any role other than user, assistant, toolResult or system)
35
+ * is drained together as that oldest entry, so a burst of background events takes one turn.
34
36
  */
35
37
  export type QueueMode = "all" | "one-at-a-time";
36
38
  /** A single tool call content block emitted by an assistant message. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.1",
3
+ "version": "2026.10.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.1",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.1",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.2",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.2",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",