@aexhq/agentloop-pi 1.6.0 → 2.0.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 CHANGED
@@ -2,28 +2,31 @@
2
2
 
3
3
  A pi-style agent loop for Brain: a semantic port of the pi coding agent's loop,
4
4
  pinned against [earendil-works/pi](https://github.com/earendil-works/pi) tag
5
- `v0.84.4` (`@earendil-works/pi-agent-core@0.84.4`). The loop drives each turn
6
- through Brain's services from inside its WebAssembly component and reproduces
5
+ `v0.84.4` (`@earendil-works/pi-agent-core@0.84.4`). The package ships a precompiled
6
+ WebAssembly Component that drives each turn through Brain's Agentloop host imports and reproduces
7
7
  pi's per-turn contract:
8
8
 
9
- - tool calls are issued as one **parallel batch**, and results return in
10
- assistant source order;
11
- - a `length`-stopped response that carries tool calls **fails the whole batch
12
- without executing it** and re-asks the model;
9
+ - tool calls are issued as one **parallel batch**, and results return in assistant source order;
10
+ - a `length`-stopped response that carries tool calls **fails the whole batch without executing
11
+ it** and re-asks the model;
13
12
  - **automatic compaction**: when the estimated context exceeds
14
- `contextWindow - reserveTokens` (default 16384), history older than
15
- ~`keepRecentTokens` (default 20000) is summarized into pi's structured
16
- context checkpoint (`## Goal` … `## Critical Context`) that replaces it.
13
+ `contextWindow - reserveTokens` (default 16384), history older than ~`keepRecentTokens`
14
+ (default 20000) is summarized into pi's structured context checkpoint.
17
15
 
18
- pi's steering and follow-up queues and per-tool `executionMode` are host-app
19
- seams with no Brain equivalent and are not ported.
16
+ pi's steering and follow-up queues and per-tool `executionMode` are host-app seams with no Brain
17
+ equivalent and are not ported.
20
18
 
21
19
  ```ts
20
+ import { brainWasm } from "@aexhq/brain";
22
21
  import { pi } from "@aexhq/agentloop-pi";
23
22
 
23
+ const loopRuntime = brainWasm();
24
24
  const session = await brain.sessions.create({
25
- agentloop: pi({ contextWindow: 200_000 }),
25
+ agentloop: pi({ env: loopRuntime, contextWindow: 200_000 }),
26
26
  model,
27
27
  tools: [read({ env: workspace })],
28
28
  });
29
29
  ```
30
+
31
+ The component is built by this package's publisher. Brain consumes the resulting Component and
32
+ does not compile its JavaScript source.
package/dist/index.mjs CHANGED
@@ -1,5 +1,18 @@
1
- import { installExtensionIdentity } from "@aexhq/brain";
2
- import * as definitions from "./source.mjs";
3
-
4
- export const pi = definitions.pi;
5
- installExtensionIdentity(pi, "pi", new URL("./pi.brain.json", import.meta.url));
1
+ // src/index.mjs
2
+ import { agentloop, component } from "@aexhq/brain";
3
+ import { z } from "zod";
4
+ var options = z.object({
5
+ contextWindow: z.number().int().positive().default(2e5),
6
+ // pi defaults (compaction.ts): compact when context exceeds
7
+ // contextWindow - reserveTokens, keep ~keepRecentTokens of recent messages.
8
+ reserveTokens: z.number().int().positive().default(16384),
9
+ keepRecentTokens: z.number().int().positive().default(2e4),
10
+ compaction: z.boolean().default(true)
11
+ }).strict();
12
+ var pi = agentloop({
13
+ options,
14
+ implementation: component(new URL("./loop.component.wasm", import.meta.url))
15
+ });
16
+ export {
17
+ pi
18
+ };
Binary file
package/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { Agentloop } from "@aexhq/brain";
1
+ import type { AgentloopBinding, Environment } from "@aexhq/brain";
2
2
 
3
3
  export interface PiOptions {
4
+ readonly env: Environment;
4
5
  /** Model context window in tokens the compaction budget is measured against. Default 200000. */
5
6
  readonly contextWindow?: number;
6
7
  /** Compact when the estimated context exceeds contextWindow - reserveTokens. pi default 16384. */
@@ -11,4 +12,4 @@ export interface PiOptions {
11
12
  readonly compaction?: boolean;
12
13
  }
13
14
 
14
- export declare const pi: (options?: PiOptions) => Agentloop;
15
+ export declare const pi: (options: PiOptions) => AgentloopBinding;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexhq/agentloop-pi",
3
- "version": "1.6.0",
3
+ "version": "2.0.0",
4
4
  "description": "Pi-style parallel-Tool Brain extension",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "publishConfig": {
17
17
  "access": "public",
18
- "provenance": true,
18
+ "provenance": false,
19
19
  "tag": "next"
20
20
  },
21
21
  "exports": {
@@ -29,12 +29,12 @@
29
29
  "README.md"
30
30
  ],
31
31
  "scripts": {
32
- "build": "brain build src/index.mjs --out dist",
32
+ "build": "node ../../tools/build-agentloop.mjs src/component.mjs src/index.mjs dist",
33
33
  "test": "npm run build && node --test",
34
34
  "prepack": "npm run build"
35
35
  },
36
36
  "dependencies": {
37
- "@aexhq/brain": "0.15.1",
37
+ "@aexhq/brain": "0.16.0",
38
38
  "zod": "4.4.3"
39
39
  },
40
40
  "devDependencies": {}
@@ -0,0 +1,47 @@
1
+ import * as host from "brain:agentloop/host@0.1.0";
2
+
3
+ import { runPi } from "./logic.mjs";
4
+
5
+ function hosted(call) {
6
+ try {
7
+ return call();
8
+ } catch (error) {
9
+ const payload = error !== null && typeof error === "object" && "payload" in error ? error.payload : undefined;
10
+ const failure = new Error(payload?.message ?? String(error?.message ?? error));
11
+ failure.code = payload?.code ?? "host_error";
12
+ failure.retryable = payload?.retryable ?? false;
13
+ throw failure;
14
+ }
15
+ }
16
+
17
+ export async function turn(input) {
18
+ try {
19
+ const output = await runPi({
20
+ input: JSON.parse(input.inputJson),
21
+ transcript: JSON.parse(input.transcriptJson),
22
+ slots: JSON.parse(input.slotsJson),
23
+ events: JSON.parse(input.eventsJson),
24
+ configuration: JSON.parse(input.configurationJson),
25
+ system: input.system,
26
+ tools: JSON.parse(input.toolsJson),
27
+ }, {
28
+ model: (request) => JSON.parse(hosted(() => host.model(JSON.stringify(request)))),
29
+ dispatch: (calls) => JSON.parse(hosted(() => host.dispatch(JSON.stringify(calls)))),
30
+ emit: (kind, data) => hosted(() => host.emit(kind, JSON.stringify(data ?? null))),
31
+ telemetry: (record) => host.telemetry(JSON.stringify(record ?? null)),
32
+ });
33
+ return {
34
+ transcriptJson: JSON.stringify(output.transcript),
35
+ slotsJson: JSON.stringify(output.slots),
36
+ resultJson: output.result === undefined ? undefined : JSON.stringify(output.result),
37
+ };
38
+ } catch (error) {
39
+ const failure = new Error(String(error?.message ?? error) || "Agentloop turn failed");
40
+ failure.payload = {
41
+ code: typeof error?.code === "string" && error.code.length > 0 ? error.code : "agentloop_failed",
42
+ message: failure.message,
43
+ retryable: Boolean(error?.retryable),
44
+ };
45
+ throw failure;
46
+ }
47
+ }
package/src/index.mjs CHANGED
@@ -1,189 +1,15 @@
1
- import { agentloop } from "@aexhq/brain";
1
+ import { agentloop, component } from "@aexhq/brain";
2
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 loop drives one whole turn through Brain's services: it calls the model,
9
- // hands tool calls to Brain, and edits the transcript Brain persists. What it
10
- // reproduces of pi:
11
- // - tool calls are issued as one parallel batch, results return as one
12
- // user message of tool_result blocks in assistant source order
13
- // - a length-stop that carries tool calls fails the whole batch without
14
- // executing it, and the loop re-asks the model
15
- // - automatic compaction: when the estimated context exceeds
16
- // contextWindow - reserveTokens, everything older than ~keepRecentTokens
17
- // is summarized into a structured context checkpoint that replaces it
18
- // (cut points never split a tool_result away from its tool_use)
19
- // Host-app seams of pi (steering and follow-up queues, per-tool
20
- // executionMode) have no Brain equivalent and are not ported.
21
-
22
- const optionsSchema = z.object({
3
+ const options = z.object({
23
4
  contextWindow: z.number().int().positive().default(200_000),
24
5
  // pi defaults (compaction.ts): compact when context exceeds
25
6
  // contextWindow - reserveTokens, keep ~keepRecentTokens of recent messages.
26
7
  reserveTokens: z.number().int().positive().default(16_384),
27
8
  keepRecentTokens: z.number().int().positive().default(20_000),
28
9
  compaction: z.boolean().default(true),
29
- }).default({});
30
-
31
- // The checkpoint is the transcript's first message once a compaction has
32
- // happened; the slot remembers its text so the next compaction can update it.
33
- const checkpointSchema = z.object({ summary: z.union([z.string(), z.null()]) });
34
-
35
- const CHECKPOINT_PREFIX = "Context checkpoint from earlier in this conversation:\n\n";
36
-
37
- 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.
38
-
39
- Use this EXACT format:
40
-
41
- ## Goal
42
-
43
- ## Constraints & Preferences
44
-
45
- ## Progress
46
-
47
- ### Done
48
-
49
- ### In Progress
50
-
51
- ### Blocked
52
-
53
- ## Key Decisions
54
-
55
- ## Next Steps
56
-
57
- ## Critical Context
58
-
59
- Keep each section concise. Preserve exact file paths, function names, and error messages.`;
60
-
61
- const UPDATE_RULES = `Update the existing structured summary with new information.
62
-
63
- RULES:
64
- - PRESERVE all existing information from the previous summary
65
- - ADD new progress, decisions, and context from the new messages
66
- - Move items from In Progress to Done as they complete
67
-
68
- `;
69
-
70
- const TRUNCATED_CALL_MESSAGE =
71
- "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.";
72
-
73
- const text = (message) =>
74
- message.content
75
- .filter((block) => block.type === "text")
76
- .map((block) => block.text)
77
- .join("");
78
-
79
- const blockText = (block) => {
80
- if (block.type === "text") return block.text;
81
- if (block.type === "tool_use") return `[tool_use ${block.name}] ${JSON.stringify(block.input)}`;
82
- const output = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
83
- return `[tool_result${block.is_error ? " (error)" : ""}] ${output.length > 2000 ? `${output.slice(0, 2000)}…` : output}`;
84
- };
85
-
86
- const serializeConversation = (messages) =>
87
- messages.map((message) => `${message.role}:\n${message.content.map(blockText).join("\n")}`).join("\n\n");
88
-
89
- export const pi = agentloop({ options: optionsSchema }, (author) => {
90
- const options = author.options;
91
- const checkpoint = author.slot("checkpoint", checkpointSchema, () => ({ summary: null }));
92
-
93
- // The conversation proper: the transcript minus the checkpoint message.
94
- const body = (transcript) => (checkpoint.summary === null ? transcript : transcript.slice(1));
95
-
96
- const shouldCompact = (transcript) =>
97
- options.compaction && author.context.estimateTokens(transcript) > options.contextWindow - options.reserveTokens;
98
-
99
- // Earliest index the recent-token budget allows, then forward to the next
100
- // boundary that does not split a tool_result away from its tool_use.
101
- const cutPoint = (messages) => {
102
- let kept = 0;
103
- let cut = 0;
104
- for (let index = messages.length - 1; index >= 0; index -= 1) {
105
- kept += author.context.estimateTokens([messages[index]]);
106
- if (kept > options.keepRecentTokens) {
107
- cut = index + 1;
108
- break;
109
- }
110
- }
111
- while (
112
- cut < messages.length &&
113
- messages[cut].role === "user" &&
114
- messages[cut].content.some((block) => block.type === "tool_result")
115
- ) {
116
- cut += 1;
117
- }
118
- return cut;
119
- };
120
-
121
- const compact = async (turn) => {
122
- const messages = body(turn.transcript);
123
- const cut = cutPoint(messages);
124
- if (cut === 0) return;
125
- const previous = checkpoint.summary === null ? "" : `Previous summary:\n\n${checkpoint.summary}\n\n`;
126
- const prompt = checkpoint.summary === null ? SUMMARIZATION_PROMPT : `${UPDATE_RULES}${SUMMARIZATION_PROMPT}`;
127
- const { message } = await turn.model({
128
- messages: [
129
- {
130
- role: "user",
131
- content: [{ type: "text", text: `${previous}${serializeConversation(messages.slice(0, cut))}\n\n${prompt}` }],
132
- },
133
- ],
134
- });
135
- checkpoint.summary = text(message);
136
- turn.transcript.splice(
137
- 0,
138
- turn.transcript.length,
139
- { role: "user", content: [{ type: "text", text: `${CHECKPOINT_PREFIX}${checkpoint.summary}` }] },
140
- ...messages.slice(cut),
141
- );
142
- };
10
+ }).strict();
143
11
 
144
- author.turn(async (turn) => {
145
- turn.transcript.push({ role: "user", content: [{ type: "text", text: turn.input.message }] });
146
- for (;;) {
147
- if (shouldCompact(turn.transcript)) await compact(turn);
148
- const { message, stop_reason } = await turn.model({ messages: turn.transcript });
149
- turn.transcript.push(message);
150
- const calls = message.content
151
- .filter((block) => block.type === "tool_use")
152
- .map((block) => ({ callId: block.id, name: block.name, input: block.input }));
153
- if (calls.length === 0) {
154
- await turn.reply(text(message));
155
- return turn.done();
156
- }
157
- if (stop_reason === "max_tokens") {
158
- // pi fails the whole batch without executing it when the response was
159
- // cut off: the arguments cannot be trusted.
160
- turn.transcript.push({
161
- role: "user",
162
- content: calls.map((call) => ({
163
- type: "tool_result",
164
- tool_use_id: call.callId,
165
- content: TRUNCATED_CALL_MESSAGE,
166
- is_error: true,
167
- })),
168
- });
169
- continue;
170
- }
171
- // One dispatch: Brain runs the batch in parallel and reports back once.
172
- const results = await turn.dispatch(calls);
173
- const byCall = new Map(results.map((result) => [result.callId, result]));
174
- turn.transcript.push({
175
- role: "user",
176
- // Assistant source order, the order pi appends result messages in.
177
- content: calls.map(({ callId }) => {
178
- const result = byCall.get(callId);
179
- return {
180
- type: "tool_result",
181
- tool_use_id: callId,
182
- content: result === undefined ? "Tool produced no result." : result.output,
183
- is_error: result === undefined ? true : result.isError,
184
- };
185
- }),
186
- });
187
- }
188
- });
12
+ export const pi = agentloop({
13
+ options,
14
+ implementation: component(new URL("./loop.component.wasm", import.meta.url)),
189
15
  });
package/src/logic.mjs ADDED
@@ -0,0 +1,130 @@
1
+ const CHECKPOINT_PREFIX = "Context checkpoint from earlier in this conversation:\n\n";
2
+
3
+ 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.
4
+
5
+ Use this EXACT format:
6
+
7
+ ## Goal
8
+
9
+ ## Constraints & Preferences
10
+
11
+ ## Progress
12
+
13
+ ### Done
14
+
15
+ ### In Progress
16
+
17
+ ### Blocked
18
+
19
+ ## Key Decisions
20
+
21
+ ## Next Steps
22
+
23
+ ## Critical Context
24
+
25
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`;
26
+
27
+ const UPDATE_RULES = `Update the existing structured summary with new information.
28
+
29
+ RULES:
30
+ - PRESERVE all existing information from the previous summary
31
+ - ADD new progress, decisions, and context from the new messages
32
+ - Move items from In Progress to Done as they complete
33
+
34
+ `;
35
+
36
+ const TRUNCATED_CALL_MESSAGE =
37
+ "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.";
38
+
39
+ const estimateTokens = (messages) => Math.ceil(JSON.stringify(messages).length / 4);
40
+ const text = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
41
+
42
+ const blockText = (block) => {
43
+ if (block.type === "text") return block.text;
44
+ if (block.type === "tool_use") return `[tool_use ${block.name}] ${JSON.stringify(block.input)}`;
45
+ const output = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
46
+ return `[tool_result${block.is_error ? " (error)" : ""}] ${output.length > 2000 ? `${output.slice(0, 2000)}…` : output}`;
47
+ };
48
+
49
+ const serializeConversation = (messages) =>
50
+ messages.map((message) => `${message.role}:\n${message.content.map(blockText).join("\n")}`).join("\n\n");
51
+
52
+ export async function runPi(input, context) {
53
+ const options = {
54
+ contextWindow: 200_000,
55
+ reserveTokens: 16_384,
56
+ keepRecentTokens: 20_000,
57
+ compaction: true,
58
+ ...input.configuration,
59
+ };
60
+ const transcript = cloneJson(input.transcript);
61
+ const saved = input.slots.checkpoint;
62
+ const checkpoint = saved === undefined ? { summary: null } : cloneJson(saved);
63
+ const body = () => checkpoint.summary === null ? transcript : transcript.slice(1);
64
+ const shouldCompact = () =>
65
+ options.compaction && estimateTokens(transcript) > options.contextWindow - options.reserveTokens;
66
+ const cutPoint = (messages) => {
67
+ let kept = 0;
68
+ let cut = 0;
69
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
70
+ kept += estimateTokens([messages[index]]);
71
+ if (kept > options.keepRecentTokens) {
72
+ cut = index + 1;
73
+ break;
74
+ }
75
+ }
76
+ while (cut < messages.length && messages[cut].role === "user" && messages[cut].content.some((block) => block.type === "tool_result")) cut += 1;
77
+ return cut;
78
+ };
79
+ const compact = async () => {
80
+ const messages = body();
81
+ const cut = cutPoint(messages);
82
+ if (cut === 0) return;
83
+ const previous = checkpoint.summary === null ? "" : `Previous summary:\n\n${checkpoint.summary}\n\n`;
84
+ const prompt = checkpoint.summary === null ? SUMMARIZATION_PROMPT : `${UPDATE_RULES}${SUMMARIZATION_PROMPT}`;
85
+ const { message } = await context.model({
86
+ messages: [{ role: "user", content: [{ type: "text", text: `${previous}${serializeConversation(messages.slice(0, cut))}\n\n${prompt}` }] }],
87
+ });
88
+ checkpoint.summary = text(message);
89
+ transcript.splice(0, transcript.length,
90
+ { role: "user", content: [{ type: "text", text: `${CHECKPOINT_PREFIX}${checkpoint.summary}` }] },
91
+ ...messages.slice(cut));
92
+ };
93
+
94
+ transcript.push({ role: "user", content: [{ type: "text", text: input.input.message }] });
95
+ for (;;) {
96
+ if (shouldCompact()) await compact();
97
+ const { message, stop_reason } = await context.model({ messages: transcript });
98
+ transcript.push(message);
99
+ const calls = message.content
100
+ .filter((block) => block.type === "tool_use")
101
+ .map((block) => ({ call_id: block.id, name: block.name, input: block.input }));
102
+ if (calls.length === 0) {
103
+ await context.emit("output_emitted", { type: "assistant_message", message: text(message) });
104
+ return { transcript, slots: { checkpoint } };
105
+ }
106
+ if (stop_reason === "max_tokens") {
107
+ transcript.push({
108
+ role: "user",
109
+ content: calls.map((call) => ({ type: "tool_result", tool_use_id: call.call_id, content: TRUNCATED_CALL_MESSAGE, is_error: true })),
110
+ });
111
+ continue;
112
+ }
113
+ const results = await context.dispatch(calls);
114
+ const byCall = new Map(results.map((result) => [result.call_id, result]));
115
+ transcript.push({
116
+ role: "user",
117
+ content: calls.map(({ call_id }) => {
118
+ const result = byCall.get(call_id);
119
+ return {
120
+ type: "tool_result",
121
+ tool_use_id: call_id,
122
+ content: result === undefined ? "Tool produced no result." : result.output,
123
+ is_error: result === undefined ? true : result.is_error,
124
+ };
125
+ }),
126
+ });
127
+ }
128
+ }
129
+
130
+ const cloneJson = (value) => JSON.parse(JSON.stringify(value));