@aexhq/agentloop-pi 0.0.0 → 1.2.0
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 +30 -0
- package/dist/index.mjs +5 -0
- package/dist/pi.brain.json +1 -0
- package/dist/source.mjs +14696 -0
- package/index.d.ts +14 -0
- package/package.json +34 -12
- package/src/compat.mjs +33 -0
- package/src/index.mjs +219 -0
- package/src/polyfill.mjs +14 -0
- package/index.js +0 -1
package/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Agentloop } from "@aexhq/brain";
|
|
2
|
+
|
|
3
|
+
export interface PiOptions {
|
|
4
|
+
/** Model context window in tokens the compaction budget is measured against. Default 200000. */
|
|
5
|
+
readonly contextWindow?: number;
|
|
6
|
+
/** Compact when the estimated context exceeds contextWindow - reserveTokens. pi default 16384. */
|
|
7
|
+
readonly reserveTokens?: number;
|
|
8
|
+
/** Approximate tokens of recent conversation kept verbatim through a compaction. pi default 20000. */
|
|
9
|
+
readonly keepRecentTokens?: number;
|
|
10
|
+
/** Disable automatic compaction entirely. Default true (enabled). */
|
|
11
|
+
readonly compaction?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export declare const pi: (options?: PiOptions) => Agentloop;
|
package/package.json
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aexhq/agentloop-pi",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Pi-style parallel-Tool Brain extension",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aexhq/extensions.git",
|
|
9
|
+
"directory": "packages/loop-pi"
|
|
10
|
+
},
|
|
6
11
|
"type": "module",
|
|
7
|
-
"
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"provenance": true,
|
|
19
|
+
"tag": "next"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": { "types": "./index.d.ts", "import": "./dist/index.mjs" },
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
8
25
|
"files": [
|
|
9
|
-
"
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"index.d.ts",
|
|
29
|
+
"README.md"
|
|
10
30
|
],
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "brain build src/index.mjs --out dist",
|
|
33
|
+
"test": "npm run build && node --test",
|
|
34
|
+
"prepack": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@aexhq/brain": "0.11.0",
|
|
38
|
+
"zod": "4.4.3"
|
|
14
39
|
},
|
|
15
|
-
"
|
|
16
|
-
"stagedVersion": "1.1.0",
|
|
17
|
-
"source": "720d4ff2ae98f7df85fcd54a53a36ccbd6f03230"
|
|
18
|
-
}
|
|
40
|
+
"devDependencies": {}
|
|
19
41
|
}
|
package/src/compat.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// StarlingMonkey compatibility rewrites for pi's dependency tree, applied while bundling.
|
|
2
|
+
// COMPAT (recorded in the H0 report): the pinned guest engine rejects Unicode property
|
|
3
|
+
// escapes in regex literals at parse time. typebox (a pi dependency) emits `\p{ID_Start}` in
|
|
4
|
+
// its identifier guard and builds IDN validators from `\p{…}` classes; the guard rewrites to
|
|
5
|
+
// an ASCII-equivalent class and the unused IDN modules stub out.
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
const UNICODE_ID_REGEX_PATTERN = /\/\^\[\\p\{ID_Start\}[^/]*\*\$\/u/g;
|
|
9
|
+
const ASCII_ID_REGEX = String.raw`/^[A-Za-z_$][A-Za-z0-9_$]*$/`;
|
|
10
|
+
|
|
11
|
+
/** esbuild plugin for `buildLoopBundle({ plugins: [compatRewrite] })`. */
|
|
12
|
+
export const compatRewrite = {
|
|
13
|
+
name: "starlingmonkey-compat",
|
|
14
|
+
setup(b) {
|
|
15
|
+
b.onLoad({ filter: /typebox[\\/].*\.mjs$/ }, async (args) => {
|
|
16
|
+
if (/[\\/]format[\\/]idn_email\.mjs$/.test(args.path)) {
|
|
17
|
+
return { contents: "export function IsIdnEmail(){return false;}", loader: "js" };
|
|
18
|
+
}
|
|
19
|
+
if (/[\\/]format[\\/]_idna\.mjs$/.test(args.path)) {
|
|
20
|
+
return {
|
|
21
|
+
contents:
|
|
22
|
+
"export function IsIdnLabel(){return false;}\nexport function IsLabel(){return false;}",
|
|
23
|
+
loader: "js",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
let contents = await readFile(args.path, "utf8");
|
|
27
|
+
if (UNICODE_ID_REGEX_PATTERN.test(contents)) {
|
|
28
|
+
contents = contents.replace(UNICODE_ID_REGEX_PATTERN, ASCII_ID_REGEX);
|
|
29
|
+
}
|
|
30
|
+
return { contents, loader: "js" };
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { agentloop } from "@aexhq/brain";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// Semantic port of the pi coding agent's loop, pinned against
|
|
5
|
+
// earendil-works/pi tag v0.84.4 (@earendil-works/pi-agent-core@0.84.4,
|
|
6
|
+
// packages/agent/src/agent-loop.ts + packages/coding-agent/src/core/compaction/compaction.ts).
|
|
7
|
+
//
|
|
8
|
+
// The published loop is an async driver: one invocation awaits the LLM stream
|
|
9
|
+
// and every tool execution internally, so it cannot run inside Brain's
|
|
10
|
+
// deterministic synchronous sandbox. This port reproduces its per-turn
|
|
11
|
+
// contract instead:
|
|
12
|
+
// - tool calls are issued as one parallel batch, results return as one
|
|
13
|
+
// user message of tool_result blocks in assistant source order
|
|
14
|
+
// - a length-stop that carries tool calls fails the whole batch without
|
|
15
|
+
// executing it, and the loop re-asks the model
|
|
16
|
+
// - automatic compaction: when the estimated context exceeds
|
|
17
|
+
// contextWindow - reserveTokens, everything older than ~keepRecentTokens
|
|
18
|
+
// is summarized into a structured context checkpoint that replaces it
|
|
19
|
+
// (cut points never split a tool_result away from its tool_use)
|
|
20
|
+
// Host-app seams of pi (steering and follow-up queues, per-tool
|
|
21
|
+
// executionMode) have no Brain equivalent and are not ported.
|
|
22
|
+
|
|
23
|
+
const optionsSchema = z.object({
|
|
24
|
+
contextWindow: z.number().int().positive().default(200_000),
|
|
25
|
+
// pi defaults (compaction.ts): compact when context exceeds
|
|
26
|
+
// contextWindow - reserveTokens, keep ~keepRecentTokens of recent messages.
|
|
27
|
+
reserveTokens: z.number().int().positive().default(16_384),
|
|
28
|
+
keepRecentTokens: z.number().int().positive().default(20_000),
|
|
29
|
+
compaction: z.boolean().default(true),
|
|
30
|
+
}).default({});
|
|
31
|
+
|
|
32
|
+
const stateSchema = z.object({
|
|
33
|
+
messages: z.array(z.unknown()),
|
|
34
|
+
summary: z.union([z.string(), z.null()]),
|
|
35
|
+
compacting: z.boolean(),
|
|
36
|
+
keepFrom: z.number().int().nonnegative(),
|
|
37
|
+
pending: z.array(z.object({ callId: z.string(), name: z.string() })),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
|
41
|
+
|
|
42
|
+
Use this EXACT format:
|
|
43
|
+
|
|
44
|
+
## Goal
|
|
45
|
+
|
|
46
|
+
## Constraints & Preferences
|
|
47
|
+
|
|
48
|
+
## Progress
|
|
49
|
+
|
|
50
|
+
### Done
|
|
51
|
+
|
|
52
|
+
### In Progress
|
|
53
|
+
|
|
54
|
+
### Blocked
|
|
55
|
+
|
|
56
|
+
## Key Decisions
|
|
57
|
+
|
|
58
|
+
## Next Steps
|
|
59
|
+
|
|
60
|
+
## Critical Context
|
|
61
|
+
|
|
62
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
|
63
|
+
|
|
64
|
+
const UPDATE_RULES = `Update the existing structured summary with new information.
|
|
65
|
+
|
|
66
|
+
RULES:
|
|
67
|
+
- PRESERVE all existing information from the previous summary
|
|
68
|
+
- ADD new progress, decisions, and context from the new messages
|
|
69
|
+
- Move items from In Progress to Done as they complete
|
|
70
|
+
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const TRUNCATED_CALL_MESSAGE =
|
|
74
|
+
"Tool call was not executed: the model response was cut off by the output token limit, so the arguments may be truncated. Re-issue the tool call.";
|
|
75
|
+
|
|
76
|
+
const blockText = (block) => {
|
|
77
|
+
if (block.type === "text") return block.text;
|
|
78
|
+
if (block.type === "tool_use") return `[tool_use ${block.name}] ${JSON.stringify(block.input)}`;
|
|
79
|
+
const output = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
|
|
80
|
+
return `[tool_result${block.is_error ? " (error)" : ""}] ${output.length > 2000 ? `${output.slice(0, 2000)}…` : output}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const serializeConversation = (messages) =>
|
|
84
|
+
messages.map((message) => `${message.role}:\n${message.content.map(blockText).join("\n")}`).join("\n\n");
|
|
85
|
+
|
|
86
|
+
export const pi = agentloop({ options: optionsSchema }, (author) => {
|
|
87
|
+
const options = author.options;
|
|
88
|
+
const state = author.state(stateSchema, () => ({
|
|
89
|
+
messages: [],
|
|
90
|
+
summary: null,
|
|
91
|
+
compacting: false,
|
|
92
|
+
keepFrom: 0,
|
|
93
|
+
pending: [],
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const context = () =>
|
|
97
|
+
state.summary === null
|
|
98
|
+
? state.messages
|
|
99
|
+
: [
|
|
100
|
+
{
|
|
101
|
+
role: "user",
|
|
102
|
+
content: [{ type: "text", text: `Context checkpoint from earlier in this conversation:\n\n${state.summary}` }],
|
|
103
|
+
},
|
|
104
|
+
...state.messages,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const shouldCompact = () =>
|
|
108
|
+
options.compaction && author.context.estimateTokens(context()) > options.contextWindow - options.reserveTokens;
|
|
109
|
+
|
|
110
|
+
// Earliest index the recent-token budget allows, then forward to the next
|
|
111
|
+
// boundary that does not split a tool_result away from its tool_use.
|
|
112
|
+
const cutPoint = () => {
|
|
113
|
+
let kept = 0;
|
|
114
|
+
let cut = 0;
|
|
115
|
+
for (let index = state.messages.length - 1; index >= 0; index -= 1) {
|
|
116
|
+
kept += author.context.estimateTokens([state.messages[index]]);
|
|
117
|
+
if (kept > options.keepRecentTokens) {
|
|
118
|
+
cut = index + 1;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
while (
|
|
123
|
+
cut < state.messages.length &&
|
|
124
|
+
state.messages[cut].role === "user" &&
|
|
125
|
+
state.messages[cut].content.some((block) => block.type === "tool_result")
|
|
126
|
+
) {
|
|
127
|
+
cut += 1;
|
|
128
|
+
}
|
|
129
|
+
return cut;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const compact = (turn) => {
|
|
133
|
+
state.keepFrom = cutPoint();
|
|
134
|
+
if (state.keepFrom === 0) return turn.model({ messages: context() });
|
|
135
|
+
state.compacting = true;
|
|
136
|
+
const previous = state.summary === null ? "" : `Previous summary:\n\n${state.summary}\n\n`;
|
|
137
|
+
const prompt = state.summary === null ? SUMMARIZATION_PROMPT : `${UPDATE_RULES}${SUMMARIZATION_PROMPT}`;
|
|
138
|
+
return turn.model({
|
|
139
|
+
messages: [
|
|
140
|
+
{
|
|
141
|
+
role: "user",
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: `${previous}${serializeConversation(state.messages.slice(0, state.keepFrom))}\n\n${prompt}`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const advance = (turn) => (shouldCompact() ? compact(turn) : turn.model({ messages: context() }));
|
|
154
|
+
|
|
155
|
+
author.on.message(({ input }, turn) => {
|
|
156
|
+
state.messages.push({ role: "user", content: [{ type: "text", text: input.message }] });
|
|
157
|
+
return advance(turn);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
author.on.model((completed, turn) => {
|
|
161
|
+
const { message, stop_reason } = completed.response;
|
|
162
|
+
if (state.compacting) {
|
|
163
|
+
state.summary = message.content
|
|
164
|
+
.filter((block) => block.type === "text")
|
|
165
|
+
.map((block) => block.text)
|
|
166
|
+
.join("");
|
|
167
|
+
state.messages = state.messages.slice(state.keepFrom);
|
|
168
|
+
state.compacting = false;
|
|
169
|
+
state.keepFrom = 0;
|
|
170
|
+
return turn.model({ messages: context() });
|
|
171
|
+
}
|
|
172
|
+
const calls = message.content
|
|
173
|
+
.filter((block) => block.type === "tool_use")
|
|
174
|
+
.map((block) => ({ callId: block.id, name: block.name, input: block.input }));
|
|
175
|
+
state.messages.push(message);
|
|
176
|
+
if (calls.length > 0 && stop_reason === "max_tokens") {
|
|
177
|
+
// pi fails the whole batch without executing it when the response was
|
|
178
|
+
// cut off: the arguments cannot be trusted.
|
|
179
|
+
state.messages.push({
|
|
180
|
+
role: "user",
|
|
181
|
+
content: calls.map((call) => ({
|
|
182
|
+
type: "tool_result",
|
|
183
|
+
tool_use_id: call.callId,
|
|
184
|
+
content: TRUNCATED_CALL_MESSAGE,
|
|
185
|
+
is_error: true,
|
|
186
|
+
})),
|
|
187
|
+
});
|
|
188
|
+
return advance(turn);
|
|
189
|
+
}
|
|
190
|
+
if (calls.length > 0) {
|
|
191
|
+
state.pending = calls.map(({ callId, name }) => ({ callId, name }));
|
|
192
|
+
return turn.tools(calls);
|
|
193
|
+
}
|
|
194
|
+
const text = message.content
|
|
195
|
+
.filter((block) => block.type === "text")
|
|
196
|
+
.map((block) => block.text)
|
|
197
|
+
.join("");
|
|
198
|
+
return turn.reply(text);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
author.on.tools((completed, turn) => {
|
|
202
|
+
const byCall = new Map(completed.results.map((result) => [result.call_id, result]));
|
|
203
|
+
state.messages.push({
|
|
204
|
+
role: "user",
|
|
205
|
+
// Assistant source order, the order pi appends result messages in.
|
|
206
|
+
content: state.pending.map(({ callId }) => {
|
|
207
|
+
const result = byCall.get(callId);
|
|
208
|
+
return {
|
|
209
|
+
type: "tool_result",
|
|
210
|
+
tool_use_id: callId,
|
|
211
|
+
content: result === undefined ? "Tool produced no result." : result.output,
|
|
212
|
+
is_error: result === undefined ? true : result.is_error,
|
|
213
|
+
};
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
state.pending = [];
|
|
217
|
+
return advance(turn);
|
|
218
|
+
});
|
|
219
|
+
});
|
package/src/polyfill.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Runs before the workload bundle's top-level code (ESM evaluation order).
|
|
2
|
+
if (typeof globalThis.TextEncoder === "undefined") {
|
|
3
|
+
globalThis.TextEncoder = class TextEncoder {
|
|
4
|
+
encode(s) {
|
|
5
|
+
const bytes = unescape(encodeURIComponent(String(s)));
|
|
6
|
+
const out = new Uint8Array(bytes.length);
|
|
7
|
+
for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
if (typeof globalThis.structuredClone === "undefined") {
|
|
13
|
+
globalThis.structuredClone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
|
|
14
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
throw new Error("@aexhq/agentloop-pi has no stable release; install @aexhq/agentloop-pi@next or an exact version");
|