@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 +4 -0
- package/dist/agent.js +18 -1
- package/dist/harness/compaction/compaction.js +40 -1
- package/dist/harness/env/nodejs.js +9 -6
- package/dist/harness/runtime/drive/structural.js +9 -3
- package/dist/harness/tools/bash.js +9 -1
- package/dist/harness/tools/edit.js +3 -2
- package/dist/harness/tools/file-mutation-queue.js +15 -2
- package/dist/harness/types.d.ts +2 -0
- package/dist/harness/utils/truncate.js +3 -4
- package/dist/types.d.ts +2 -0
- package/package.json +3 -3
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
|
-
|
|
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 =
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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 ||
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
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) {
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
153
|
-
if (
|
|
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.
|
|
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.
|
|
66
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.
|
|
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",
|