@fastagent-sh/fastagent 0.17.1 → 0.19.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.
Files changed (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -1,618 +0,0 @@
1
- import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
2
- import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
3
- import { abortFirstIterator } from "../../collect.js";
4
- import { log } from "../../log.js";
5
- import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
6
- import { additiveActivation, turnContext } from "./tool-context.js";
7
- export function inProcessLease() {
8
- const busy = new Set();
9
- return {
10
- tryAcquire(session) {
11
- if (busy.has(session))
12
- return null;
13
- busy.add(session);
14
- let released = false;
15
- return () => {
16
- if (released)
17
- return;
18
- released = true;
19
- busy.delete(session);
20
- };
21
- },
22
- };
23
- }
24
- // ── §2 translate: the single pi↔SPEC translation point ───────────────────────
25
- //
26
- // `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
27
- // guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
28
- // is actually available differs by path, and the two are NOT symmetric:
29
- // - thrown error (errorToTerminal): an HTTP `.status`/`.statusCode` AND a network `.code` (incl.
30
- // `.cause.code`) — this is where a numeric status genuinely drives the decision.
31
- // - failed message (toTerminal): ONLY `diagnostics[].error.code`. pi's `DiagnosticErrorInfo` carries
32
- // a `code` (a network code, or a status delivered as a code), with no separate HTTP-status field —
33
- // so a message whose provider `code` is a string label (e.g. "rate_limit_exceeded") is not
34
- // decisive here and falls to prose.
35
- // The prose fallback is bounded, not a cop-out: pi-ai already ran its own status-code-based client
36
- // retries (harness.ts PROVIDER_MAX_RETRIES) before surfacing, so an error that reaches this point has
37
- // already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
38
- // Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
39
- // entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
40
- /** Clearly-transient network error codes (Node/undici), decisive on their own. */
41
- const RETRYABLE_CODES = new Set([
42
- "ECONNRESET",
43
- "ETIMEDOUT",
44
- "ENETUNREACH",
45
- "ENETDOWN",
46
- "EAI_AGAIN",
47
- "EPIPE",
48
- "UND_ERR_CONNECT_TIMEOUT",
49
- "UND_ERR_SOCKET",
50
- ]);
51
- /** 429 (rate limit) and 5xx (server) are worth retrying; other statuses are decisive NON-retryable. */
52
- const statusIsRetryable = (status) => status === 429 || (status >= 500 && status < 600);
53
- /** Last-resort prose match, used only when no structured status/code is available. */
54
- const RETRYABLE_MESSAGE = /\b(429|5\d\d|timeout|timed out|rate.?limit|overloaded|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up)\b/i;
55
- /** A structured status/code decision, or `null` when the signal is absent/undecisive → fall to prose. */
56
- function retryableFromSignal(signal) {
57
- if (typeof signal.status === "number")
58
- return statusIsRetryable(signal.status);
59
- const { code } = signal;
60
- if (typeof code === "number")
61
- return statusIsRetryable(code);
62
- if (typeof code === "string") {
63
- if (RETRYABLE_CODES.has(code))
64
- return true;
65
- if (/^\d{3}$/.test(code))
66
- return statusIsRetryable(Number(code)); // a status carried as a string
67
- }
68
- return null; // no code, or an unknown one — not decisive on its own
69
- }
70
- /** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
71
- export function classifyRetryable(details, signal) {
72
- return retryableFromSignal(signal) ?? RETRYABLE_MESSAGE.test(details);
73
- }
74
- /** Pull a structured status/code off a thrown error (HTTP status or a network code, incl. its cause). */
75
- function errorSignal(error) {
76
- if (!error || typeof error !== "object")
77
- return {};
78
- const e = error;
79
- const status = typeof e.status === "number" ? e.status : typeof e.statusCode === "number" ? e.statusCode : undefined;
80
- const causeCode = e.cause && typeof e.cause === "object" ? e.cause.code : undefined;
81
- return { status, code: e.code ?? causeCode };
82
- }
83
- /**
84
- * Pull the structured error `code` pi records on a failed message's diagnostics. `diagnostics`
85
- * accumulates across attempts (`appendAssistantMessageDiagnostic`), so the terminal cause is the LAST
86
- * code-bearing entry — `findLast`, not `find`: an earlier attempt's transient 503 must not classify a
87
- * terminal 400/auth failure as retryable. (Reverse scan rather than `findLast` — the tsconfig lib is
88
- * ES2022.)
89
- */
90
- function messageSignal(message) {
91
- const diagnostics = message.diagnostics ?? [];
92
- for (let i = diagnostics.length - 1; i >= 0; i--) {
93
- const code = diagnostics[i]?.error?.code;
94
- if (code !== undefined)
95
- return { code };
96
- }
97
- return {};
98
- }
99
- /**
100
- * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
101
- * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
102
- * translation plus one projection, never two parallel translations). pi events with no session
103
- * vocabulary yet (turn_start, agent_start, …) are dropped.
104
- */
105
- export function toSessionEvent(pe, runId) {
106
- const at = Date.now();
107
- switch (pe.type) {
108
- case "queue_update":
109
- return {
110
- type: "queue_changed",
111
- timestamp: at,
112
- runId,
113
- data: { steering: pe.steer.length, followUp: pe.followUp.length },
114
- };
115
- case "message_start":
116
- // Assistant streaming only — a user/toolResult message is not a live message boundary.
117
- if (pe.message.role !== "assistant")
118
- return null;
119
- return { type: "message_started", timestamp: at, runId, data: {} };
120
- case "message_update": {
121
- const ev = pe.assistantMessageEvent;
122
- if (ev.type === "text_delta") {
123
- return { type: "message_delta", timestamp: at, runId, data: { channel: "text", delta: ev.delta } };
124
- }
125
- if (ev.type === "thinking_delta") {
126
- return { type: "message_delta", timestamp: at, runId, data: { channel: "thinking", delta: ev.delta } };
127
- }
128
- return null;
129
- }
130
- case "message_end":
131
- if (pe.message.role !== "assistant")
132
- return null;
133
- return { type: "message_finished", timestamp: at, runId, data: {} };
134
- case "tool_execution_start":
135
- return {
136
- type: "tool_started",
137
- timestamp: at,
138
- runId,
139
- data: { id: pe.toolCallId, name: pe.toolName, args: pe.args },
140
- };
141
- case "tool_execution_update":
142
- return {
143
- type: "tool_progress",
144
- timestamp: at,
145
- runId,
146
- data: { id: pe.toolCallId, name: pe.toolName, partialResult: pe.partialResult },
147
- };
148
- case "tool_execution_end":
149
- return {
150
- type: "tool_finished",
151
- timestamp: at,
152
- runId,
153
- data: { id: pe.toolCallId, isError: pe.isError, content: pe.result },
154
- };
155
- case "retry_scheduled": {
156
- // Summarization retry backoff (auto-compaction / branch summary, pi ≥0.81.1) — without it,
157
- // up to ~14s of backoff at the turn's tail reads as a hang. `retry_attempt_start`/
158
- // `retry_finished` stay dropped: they carry no outcome, and the next event is the closure.
159
- // Typed against the vocabulary (as is the second construction site, session-control's
160
- // manual-compact callback) so a payload change breaks both at compile time.
161
- const event = {
162
- type: "retry_scheduled",
163
- timestamp: at,
164
- runId,
165
- data: {
166
- operation: pe.operation,
167
- attempt: pe.attempt,
168
- maxAttempts: pe.maxAttempts,
169
- delayMs: pe.delayMs,
170
- error: pe.errorMessage,
171
- },
172
- };
173
- return event;
174
- }
175
- default:
176
- return null;
177
- }
178
- }
179
- /** The SPEC projection of the rich stream. Events with no `AgentEvent` counterpart (progress,
180
- * message boundaries, run boundaries) project to null — the invoke terminal is produced from the
181
- * resolved message ({@link toTerminal}), not from `run_settled`. */
182
- export function projectAgentEvent(se) {
183
- switch (se.type) {
184
- case "message_delta": {
185
- const d = se.data;
186
- return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
187
- }
188
- case "tool_started": {
189
- const d = se.data;
190
- return { type: "tool_started", id: d.id, name: d.name, args: d.args };
191
- }
192
- case "tool_finished": {
193
- const d = se.data;
194
- return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
195
- }
196
- case "retry_scheduled": {
197
- // `operation` (compaction | branch_summary) stays session-plane vocabulary — a turn renderer
198
- // only needs "transient failure, retrying"; the engine detail lives in the control plane.
199
- const d = se.data;
200
- return { type: "retrying", attempt: d.attempt, maxAttempts: d.maxAttempts, delayMs: d.delayMs, reason: d.error };
201
- }
202
- default:
203
- return null;
204
- }
205
- }
206
- /**
207
- * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
208
- * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
209
- * entire failure class (violating SPEC MUST 1).
210
- */
211
- export function toTerminal(message) {
212
- if (message.stopReason === "aborted") {
213
- // A deliberate stop (control-plane abort / harness abort), not an error — see {@link ABORTED_CODE}
214
- // for the consumer contract (design §6).
215
- const details = message.errorMessage ?? "run aborted";
216
- return { type: "failed", details, retryable: false, code: ABORTED_CODE };
217
- }
218
- if (message.stopReason === "error") {
219
- const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
220
- return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
221
- }
222
- return { type: "completed" };
223
- }
224
- export function errorToTerminal(error) {
225
- const details = error instanceof Error ? error.message : String(error);
226
- return { type: "failed", details, retryable: classifyRetryable(details, errorSignal(error)) };
227
- }
228
- /** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
229
- function toolSessionManager(sessionId, harness) {
230
- const session = harnessSession(harness);
231
- if (!session)
232
- return undefined;
233
- return {
234
- getSessionId: () => sessionId,
235
- async getHeader() {
236
- const metadata = await session.getMetadata();
237
- return { id: sessionId, timestamp: metadata.createdAt };
238
- },
239
- getBranch: () => session.getBranch(),
240
- };
241
- }
242
- /**
243
- * The turn's {@link ToolActivation} over the live harness. `activate` is additive and filters to the
244
- * registered names first — pi's `setActiveTools` THROWS on unknown names, and a loader must get a
245
- * usable "nothing new" answer, not an exception. pi persists the change in the session, so the
246
- * per-invoke restore (harness.ts) carries it into later turns.
247
- */
248
- function toolActivation(harness) {
249
- // Serialize activations per turn: "who activated first" must be decided HERE, not by whether pi's
250
- // setActiveTools happens to mutate before its first await — parallel tool calls in one batch race
251
- // their activate() calls, and the addedToolNames load points must not double-stamp.
252
- let chain = Promise.resolve([]);
253
- return {
254
- active: () => harness.getActiveTools().map((t) => t.name),
255
- registered: () => harness.getTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
256
- activate(names) {
257
- const run = async () => {
258
- const current = harness.getActiveTools().map((t) => t.name);
259
- const added = additiveActivation(harness.getTools().map((t) => t.name), current, names);
260
- if (added.length > 0) {
261
- await harness.setActiveTools([...current, ...added]);
262
- // Persist the DELTA in a dedicated entry — what the per-invoke resolve (harness.ts) reads.
263
- // pi's own active_tools_change record is a full snapshot and is deliberately ignored there.
264
- // Absent session (a harness built outside piHarnessFactory): in-turn activation still works,
265
- // it just isn't durable — the factory owns persistence.
266
- await harnessSession(harness)?.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
267
- }
268
- return added;
269
- };
270
- const result = chain.then(run, run); // run after the predecessor settles, success or failure
271
- chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
272
- return result;
273
- },
274
- };
275
- }
276
- /**
277
- * After a successful turn, compact the session if its context has grown past pi's threshold — a long
278
- * shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
279
- * (`harness.compact()` writes a summary entry into the session, so the next reopen is compacted); the
280
- * bare harness does NOT auto-trigger it, so fastagent checks `shouldCompact` here and fires it. The
281
- * context size is the provider's own count from the turn's assistant message (`usage`).
282
- */
283
- async function maybeCompact(harness, message) {
284
- const contextWindow = harness.getModel().contextWindow;
285
- if (!contextWindow)
286
- return;
287
- if (shouldCompact(calculateContextTokens(message.usage), contextWindow, DEFAULT_COMPACTION_SETTINGS)) {
288
- await harness.compact();
289
- }
290
- }
291
- /**
292
- * Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
293
- * Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
294
- * loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
295
- * bytes — the provider then applies its own limit.
296
- */
297
- async function toPiPromptOptions(prompt) {
298
- if (!prompt.images || prompt.images.length === 0)
299
- return undefined;
300
- const { resizeImage } = await import("@earendil-works/pi-coding-agent");
301
- const images = await Promise.all(prompt.images.map(async (img) => {
302
- const resized = await resizeImage(Buffer.from(img.data, "base64"), img.mimeType, {
303
- maxWidth: 1568,
304
- maxHeight: 1568,
305
- maxBytes: 5 * 1024 * 1024,
306
- }).catch(() => null);
307
- return resized
308
- ? { type: "image", data: resized.data, mimeType: resized.mimeType }
309
- : { type: "image", data: img.data, mimeType: img.mimeType };
310
- }));
311
- return { images };
312
- }
313
- // ── §3 EventQueue: push→pull plumbing for pi's two-port shape ────────────────
314
- //
315
- // Single-consumer async queue; single-threaded JS means no await interleaves between push and
316
- // drain, so no locking. Engines that are natively async-iterable would not need it.
317
- class EventQueue {
318
- buffer = [];
319
- wake;
320
- push(item) {
321
- this.buffer.push(item);
322
- const wake = this.wake;
323
- this.wake = undefined;
324
- wake?.();
325
- }
326
- /**
327
- * Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
328
- * produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
329
- * `run` itself) to avoid unhandled rejections.
330
- */
331
- async *drainUntil(done) {
332
- let settled = false;
333
- const onSettle = () => {
334
- settled = true;
335
- const wake = this.wake;
336
- this.wake = undefined;
337
- wake?.();
338
- };
339
- const finished = done.then(onSettle, onSettle);
340
- while (true) {
341
- while (this.buffer.length > 0) {
342
- yield this.buffer.shift();
343
- }
344
- if (settled)
345
- break;
346
- await new Promise((resolve) => {
347
- this.wake = resolve;
348
- });
349
- }
350
- await finished;
351
- }
352
- }
353
- /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
354
- export function createPiAgentFromHarness(options) {
355
- const { harnessFactory, lease = inProcessLease(), observer } = options;
356
- function invoke(scope, prompt) {
357
- // The cancellation DOOR (SPEC MUST 3), via the shared abort-first protocol (see
358
- // abortFirstIterator): cancel aborts the engine work, which settles the run and releases a
359
- // generator suspended on a quiet stream (a tool mid-execution). The local for-await pattern
360
- // never hit the underlying deadlock (it breaks at a yield boundary); pull-driven consumers
361
- // (the SSE handler's eager reads) do.
362
- // The cancel intent is LATCHED: a consumer may cancel while the generator is still inside
363
- // the harness build (the door not yet armed) — abortFirstIterator knocks exactly once, and a
364
- // knock before prompt() starts would be a no-op on an idle harness (the LATER run would
365
- // ignore it). So turn consults the latch right after arming and, when the consumer already
366
- // walked away, never starts the model call at all.
367
- let externalCancel;
368
- let cancelled = false;
369
- const gen = turn(scope, prompt, (cancel) => {
370
- externalCancel = cancel;
371
- }, () => cancelled);
372
- const iterator = abortFirstIterator(gen, () => {
373
- cancelled = true;
374
- externalCancel?.();
375
- });
376
- return {
377
- [Symbol.asyncIterator]() {
378
- return iterator;
379
- },
380
- };
381
- }
382
- async function* turn(scope, prompt, onCancelReady,
383
- /** The consumer's cancel latch (see invoke's wrapper) — checked once at arming. */
384
- wasCancelled) {
385
- const release = lease.tryAcquire(scope.session);
386
- if (!release) {
387
- // Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
388
- yield {
389
- type: "failed",
390
- details: "session busy: a turn is already in flight for this session",
391
- retryable: true,
392
- code: SESSION_BUSY_CODE,
393
- };
394
- return;
395
- }
396
- // The run exists from here: one run_started, exactly one run_settled. Terminal points only
397
- // RECORD the outcome; the settlement event is emitted in the outer finally, right before
398
- // release() — so the observation plane's "running" window equals the lease window (state()
399
- // must never say idle while a new invoke would still be rejected session_busy), and the
400
- // post-terminal auto-compaction is naturally inside the run. A run with no recorded outcome
401
- // was cancelled by the caller (SPEC: cancellation has no terminal event) → aborted.
402
- const runId = crypto.randomUUID();
403
- let outcome;
404
- const observe = (event, run) => {
405
- if (!event || !observer)
406
- return;
407
- try {
408
- observer(scope.session, event, run);
409
- }
410
- catch (error) {
411
- // The observation plane must never break the data plane; a broken hub is its own problem.
412
- log.warn(`[fastagent] session observer threw (event ${event.type}): ${String(error)}`);
413
- }
414
- };
415
- // run_started must be observed before the (awaited) harness build so no early event outruns
416
- // registration — so the controls AWAIT the build instead of erroring on the assembling window:
417
- // a dispatch that races the build simply queues on the freshly built harness. A setup failure
418
- // rejects the gate (and the run settles failed); the guard keeps an undispatched rejection from
419
- // becoming an unhandled-rejection crash.
420
- let harnessReady;
421
- let harnessFailed;
422
- const harnessGate = new Promise((resolve, reject) => {
423
- harnessReady = resolve;
424
- harnessFailed = reject;
425
- });
426
- harnessGate.catch(() => { }); // observed via controls only when a dispatch actually happens
427
- // Aborted classification has two attribution sources, either suffices: pi's own
428
- // stopReason:"aborted" (toTerminal), and control-plane INTENT — needed because providers do
429
- // not uniformly attribute an aborted stream (verified empirically: the faux path surfaces a
430
- // plain error). Intent = "an abort() succeeded, OR one was still in flight when the terminal
431
- // arrived" (the harness error often lands before abort() resolves). A rejected abort that
432
- // RETURNED before the terminal counts as nothing — no rollback dance, no interleaving hazard.
433
- // GUARANTEE BOUNDARY: an abort still in flight that ultimately rejects can classify a
434
- // concurrent real error as aborted — narrow, and non-lossy: the settlement carries
435
- // `error.message` either way.
436
- let abortsInFlight = 0;
437
- let abortSucceeded = false;
438
- // Stale-controls guard: after settlement pi's steer()/followUp()/abort() would still resolve
439
- // (they queue / no-op on the to-be-discarded harness) — a silent acceptance of a command that
440
- // can never take effect. The flag flips at THREE points, earliest wins: (1) the moment the
441
- // run's terminal is determined (the main window — before the consumer-paced `yield terminal`
442
- // and auto-compaction), (2) the setup-failure path, (3) the outer finally as the backstop for
443
- // caller cancellation. A post-settle call throws and the dispatcher maps it to
444
- // `run_command_failed`.
445
- let runSettled = false;
446
- const settledError = () => new Error("run already settled; the command cannot take effect");
447
- // The settled check and the harness call MUST share one synchronous block (no await between):
448
- // pi enqueues/aborts synchronously at method entry, so check-then-call in the same tick truly
449
- // closes the race — a check behind its own await boundary would only shrink it.
450
- const controls = {
451
- async steer(p) {
452
- const opts = await toPiPromptOptions(p);
453
- const harness = await harnessGate;
454
- if (runSettled)
455
- throw settledError();
456
- await harness.steer(p.text, opts);
457
- },
458
- async followUp(p) {
459
- const opts = await toPiPromptOptions(p);
460
- const harness = await harnessGate;
461
- if (runSettled)
462
- throw settledError();
463
- await harness.followUp(p.text, opts);
464
- },
465
- async abort() {
466
- const harness = await harnessGate;
467
- if (runSettled)
468
- throw settledError();
469
- abortsInFlight++;
470
- try {
471
- await harness.abort();
472
- abortSucceeded = true;
473
- }
474
- finally {
475
- abortsInFlight--;
476
- }
477
- },
478
- };
479
- observe({ type: "run_started", timestamp: Date.now(), runId, data: {} }, controls);
480
- try {
481
- let harness;
482
- try {
483
- harness = await harnessFactory(scope.session);
484
- }
485
- catch (error) {
486
- // Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
487
- harnessFailed(error); // a pending dispatch learns the run cannot take commands
488
- const terminal = errorToTerminal(error);
489
- outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
490
- runSettled = true; // commands can no longer take effect — reject stale controls from here on
491
- yield terminal;
492
- return; // → outer finally emits the settlement
493
- }
494
- harnessReady(harness);
495
- // Arm the cancellation door (see invoke's wrapper): aborting the harness settles the run,
496
- // releasing any await the generator is parked on so a queued return() can reach the finally.
497
- onCancelReady(() => {
498
- void harness.abort().catch(() => { });
499
- });
500
- // The consumer cancelled DURING the build (latched — the door above came too late to be
501
- // knocked): never start the model call; settle as aborted and let the queued return()
502
- // finish the generator. Same synchronous tick as the arming — a cancel from here on
503
- // reaches the armed door instead.
504
- if (wasCancelled()) {
505
- outcome = { status: "aborted" };
506
- runSettled = true;
507
- try {
508
- await harness.abort(); // teardown — fresh-harness discipline
509
- }
510
- catch (error) {
511
- log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
512
- }
513
- return; // → outer finally emits the settlement
514
- }
515
- const queue = new EventQueue();
516
- const unsub = harness.subscribe((pe) => {
517
- // Summarization retries also warn to server logs: the session `retry_scheduled` event only
518
- // reaches attached observers, and an operator tailing logs must see the backoff too.
519
- if (pe.type === "retry_scheduled") {
520
- log.warn(`[fastagent] ${pe.operation} retry ${pe.attempt}/${pe.maxAttempts} in ${pe.delayMs}ms (session ${scope.session}): ${pe.errorMessage}`);
521
- }
522
- const rich = toSessionEvent(pe, runId);
523
- if (!rich)
524
- return;
525
- observe(rich);
526
- const event = projectAgentEvent(rich);
527
- if (event)
528
- queue.push(event);
529
- });
530
- let completed; // the assistant message of a cleanly completed turn
531
- try {
532
- // Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
533
- const opts = await toPiPromptOptions(prompt);
534
- const run = turnContext.run({
535
- cwd: options.cwd ?? process.cwd(),
536
- sessionManager: toolSessionManager(scope.session, harness),
537
- tools: toolActivation(harness),
538
- }, () => harness.prompt(prompt.text, opts));
539
- yield* queue.drainUntil(run);
540
- let terminal;
541
- try {
542
- const message = await run;
543
- terminal = toTerminal(message);
544
- if (terminal.type === "completed")
545
- completed = message;
546
- }
547
- catch (error) {
548
- terminal = errorToTerminal(error);
549
- }
550
- if ((abortSucceeded || abortsInFlight > 0) && terminal.type === "failed") {
551
- terminal = { type: "failed", details: terminal.details, retryable: false, code: ABORTED_CODE };
552
- }
553
- if (terminal.type === "completed")
554
- outcome = { status: "completed" };
555
- else if (terminal.type === "failed") {
556
- outcome =
557
- terminal.code === ABORTED_CODE
558
- ? // Carry the detail: an independent real error that raced an accepted abort must stay
559
- // diagnosable in the settlement (audit consumers read run_settled, not the invoke
560
- // stream) — aborted classifies the run, the message preserves what actually stopped it.
561
- { status: "aborted", error: { message: terminal.details, retryable: false } }
562
- : {
563
- status: "failed",
564
- error: { code: terminal.code, message: terminal.details, retryable: terminal.retryable },
565
- };
566
- }
567
- // Commands become ineffective the moment the run resolved — NOT at the outer finally, which
568
- // sits behind `yield terminal` (a consumer-paced suspension) and auto-compaction. Flipping
569
- // here closes the silent-drop window for steer/follow_up dispatched in that gap; the
570
- // outer-finally flip remains as the backstop for caller cancellation.
571
- runSettled = true;
572
- yield terminal;
573
- }
574
- finally {
575
- // After a successful turn, keep the session under the model's context window (a long shared group
576
- // or 1:1 conversation would otherwise overflow). Runs HERE — before teardown (it uses the harness)
577
- // and BEFORE the lease release below, and is awaited via the generator's return(), so the next
578
- // turn for this session waits and never reopens mid-compaction. That await rides the consumer's
579
- // iteration: a STREAMING consumer (e.g. telegram) already sent the reply on the terminal before
580
- // returning, so compaction — rare, only over threshold — does not delay it; a `collect`-style
581
- // consumer returns the reply FROM the loop, so it waits for the (occasional) compaction. Non-fatal:
582
- // a failed compaction leaves the (still-valid) session for the next turn to retry.
583
- if (completed) {
584
- try {
585
- await maybeCompact(harness, completed);
586
- }
587
- catch (error) {
588
- log.warn(`[fastagent] auto-compaction failed during cleanup: ${String(error)}`);
589
- }
590
- }
591
- // Cleanup MUST NOT throw after the terminal was yielded — that would make an already-closed
592
- // event stream throw on iteration (violating SPEC MUST 2 / MUST 3). Contain it, but surface it
593
- // (a cleanup failure is abnormal).
594
- try {
595
- unsub();
596
- }
597
- catch (error) {
598
- log.warn(`[fastagent] harness unsubscribe failed during cleanup: ${String(error)}`);
599
- }
600
- try {
601
- await harness.abort();
602
- }
603
- catch (error) {
604
- log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
605
- }
606
- }
607
- }
608
- finally {
609
- // Exactly-one settlement, after ALL run work (incl. auto-compaction) and immediately before
610
- // the lease releases — see the outcome note above. The stale-controls flag flips FIRST so a
611
- // dispatch racing this settlement is rejected instead of silently accepted.
612
- runSettled = true;
613
- observe({ type: "run_settled", timestamp: Date.now(), runId, data: outcome ?? { status: "aborted" } });
614
- release(); // after cleanup, so the next invoke for this session can enter
615
- }
616
- }
617
- return { invoke };
618
- }
@@ -1,4 +0,0 @@
1
- import type { ReadImageProcessor } from "@earendil-works/pi-agent-core";
2
- /** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
3
- * as tool output, so a reworded one is a different prompt, not a different implementation detail. */
4
- export declare const readImageProcessor: ReadImageProcessor;