@fastagent-sh/fastagent 0.18.0 → 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 (159) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -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.js +1 -1
  22. package/dist/channels/feishu/feishu.d.ts +1 -1
  23. package/dist/channels/feishu/feishu.js +7 -7
  24. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  25. package/dist/channels/feishu/invoke-turn.js +3 -4
  26. package/dist/channels/feishu/preview.d.ts +1 -1
  27. package/dist/channels/feishu/preview.js +7 -3
  28. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  29. package/dist/channels/github/github.d.ts +1 -1
  30. package/dist/channels/http.d.ts +11 -7
  31. package/dist/channels/http.js +1 -109
  32. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  33. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  34. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  35. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  36. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  37. package/dist/channels/{state.js → kit/state.js} +4 -7
  38. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  39. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  40. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  41. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  42. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  43. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  44. package/dist/channels/lark/lark.d.ts +1 -1
  45. package/dist/channels/serve.d.ts +73 -0
  46. package/dist/channels/serve.js +243 -0
  47. package/dist/channels/slack/bot-auth.js +3 -14
  48. package/dist/channels/slack/context-buffer.d.ts +2 -2
  49. package/dist/channels/slack/context-buffer.js +2 -2
  50. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  51. package/dist/channels/slack/invoke-turn.js +8 -5
  52. package/dist/channels/slack/parse.js +2 -2
  53. package/dist/channels/slack/preview.d.ts +1 -1
  54. package/dist/channels/slack/preview.js +11 -3
  55. package/dist/channels/slack/slack-api.js +1 -1
  56. package/dist/channels/slack/slack.d.ts +1 -1
  57. package/dist/channels/slack/slack.js +8 -8
  58. package/dist/channels/slack/welcomed.js +1 -1
  59. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  60. package/dist/channels/telegram/context-buffer.js +2 -2
  61. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  62. package/dist/channels/telegram/invoke-turn.js +1 -1
  63. package/dist/channels/telegram/parse.js +2 -2
  64. package/dist/channels/telegram/preview.d.ts +1 -1
  65. package/dist/channels/telegram/preview.js +6 -2
  66. package/dist/channels/telegram/telegram.d.ts +1 -1
  67. package/dist/channels/telegram/telegram.js +3 -3
  68. package/dist/channels/telegram/turn-store.d.ts +2 -2
  69. package/dist/channels/telegram/turn-store.js +2 -2
  70. package/dist/cli/commands/add.js +1 -1
  71. package/dist/cli/commands/dev.js +25 -13
  72. package/dist/cli/commands/fire.js +1 -1
  73. package/dist/cli/commands/info.js +7 -4
  74. package/dist/cli/commands/schedule.js +1 -1
  75. package/dist/cli/commands/start.js +63 -94
  76. package/dist/cli/commands/tool.js +5 -7
  77. package/dist/cli/program.js +1 -1
  78. package/dist/cli/serve.d.ts +18 -57
  79. package/dist/cli/serve.js +57 -243
  80. package/dist/cli/shared.d.ts +1 -1
  81. package/dist/cli/shared.js +2 -2
  82. package/dist/core.d.ts +3 -5
  83. package/dist/core.js +7 -5
  84. package/dist/deploy/preflight.js +1 -1
  85. package/dist/dev-supervisor.js +9 -3
  86. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  87. package/dist/engines/pi/agent-session-factory.js +314 -0
  88. package/dist/engines/pi/config.d.ts +4 -4
  89. package/dist/engines/pi/config.js +1 -1
  90. package/dist/engines/pi/create.d.ts +56 -43
  91. package/dist/engines/pi/create.js +141 -71
  92. package/dist/engines/pi/definition.d.ts +41 -7
  93. package/dist/engines/pi/definition.js +115 -11
  94. package/dist/engines/pi/invoke-session.d.ts +22 -19
  95. package/dist/engines/pi/invoke-session.js +286 -100
  96. package/dist/engines/pi/login.js +7 -4
  97. package/dist/engines/pi/models.d.ts +14 -0
  98. package/dist/engines/pi/models.js +9 -1
  99. package/dist/engines/pi/open.d.ts +9 -6
  100. package/dist/engines/pi/open.js +20 -10
  101. package/dist/engines/pi/report.d.ts +0 -4
  102. package/dist/engines/pi/report.js +0 -7
  103. package/dist/engines/pi/service.d.ts +23 -0
  104. package/dist/engines/pi/service.js +19 -0
  105. package/dist/engines/pi/session-builder.js +84 -88
  106. package/dist/engines/pi/session-control.d.ts +10 -9
  107. package/dist/engines/pi/session-control.js +114 -77
  108. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  109. package/dist/engines/pi/session-inheritance.js +242 -0
  110. package/dist/engines/pi/session-settings.d.ts +14 -2
  111. package/dist/engines/pi/session-settings.js +18 -0
  112. package/dist/engines/pi/session-store.d.ts +70 -0
  113. package/dist/engines/pi/session-store.js +313 -0
  114. package/dist/engines/pi/tool-context.d.ts +18 -8
  115. package/dist/engines/pi/tool-context.js +23 -1
  116. package/dist/engines/pi/tool.d.ts +5 -5
  117. package/dist/engines/pi/tool.js +1 -1
  118. package/dist/engines/pi/turn-kit.d.ts +32 -6
  119. package/dist/engines/pi/turn-kit.js +28 -1
  120. package/dist/engines/pi/wake-tool.js +1 -1
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/log.d.ts +3 -0
  124. package/dist/log.js +5 -1
  125. package/dist/node.d.ts +16 -0
  126. package/dist/node.js +20 -0
  127. package/dist/paths.d.ts +7 -0
  128. package/dist/paths.js +10 -0
  129. package/dist/pi.d.ts +14 -8
  130. package/dist/pi.js +6 -5
  131. package/dist/scaffold/templates/persona.md +2 -2
  132. package/dist/schedule/state.js +4 -9
  133. package/dist/schedule/wakeups.d.ts +1 -1
  134. package/dist/schedule/wakeups.js +1 -1
  135. package/dist/service.d.ts +158 -0
  136. package/dist/service.js +354 -0
  137. package/dist/session-remote.d.ts +1 -1
  138. package/dist/session.d.ts +4 -2
  139. package/dist/session.js +1 -1
  140. package/package.json +14 -5
  141. package/dist/engines/pi/harness.d.ts +0 -115
  142. package/dist/engines/pi/harness.js +0 -154
  143. package/dist/engines/pi/invoke.d.ts +0 -60
  144. package/dist/engines/pi/invoke.js +0 -459
  145. package/dist/engines/pi/read-image.d.ts +0 -4
  146. package/dist/engines/pi/read-image.js +0 -62
  147. package/dist/engines/pi/sessions.d.ts +0 -60
  148. package/dist/engines/pi/sessions.js +0 -464
  149. package/dist/host/node.js +0 -52
  150. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  151. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  152. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  153. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  154. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  155. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  156. /package/dist/channels/{text.js → kit/text.js} +0 -0
  157. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  158. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  159. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -1,459 +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 { cancellableStream } from "../../collect.js";
4
- import { log } from "../../log.js";
5
- import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
6
- import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
7
- import { additiveActivation, turnContext } from "./tool-context.js";
8
- // ── Event translation: the single pi↔SPEC translation point ─────────────────
9
- //
10
- // `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
11
- // guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
12
- // is actually available differs by path, and the two are NOT symmetric:
13
- // - thrown error (errorToTerminal): an HTTP `.status`/`.statusCode` AND a network `.code` (incl.
14
- // `.cause.code`) — this is where a numeric status genuinely drives the decision.
15
- // - failed message (toTerminal): ONLY `diagnostics[].error.code`. pi's `DiagnosticErrorInfo` carries
16
- // a `code` (a network code, or a status delivered as a code), with no separate HTTP-status field —
17
- // so a message whose provider `code` is a string label (e.g. "rate_limit_exceeded") is not
18
- // decisive here and falls to prose.
19
- // The prose fallback is bounded, not a cop-out: pi-ai already ran its own status-code-based client
20
- // retries (harness.ts PROVIDER_MAX_RETRIES) before surfacing, so an error that reaches this point has
21
- // already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
22
- // Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
23
- // entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
24
- /**
25
- * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
26
- * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
27
- * translation plus one projection, never two parallel translations). pi events with no session
28
- * vocabulary yet (turn_start, agent_start, …) are dropped.
29
- */
30
- export function toSessionEvent(pe, runId) {
31
- const at = Date.now();
32
- switch (pe.type) {
33
- case "queue_update":
34
- return {
35
- type: "queue_changed",
36
- timestamp: at,
37
- runId,
38
- data: { steering: pe.steer.length, followUp: pe.followUp.length },
39
- };
40
- case "message_start":
41
- // Assistant streaming only — a user/toolResult message is not a live message boundary.
42
- if (pe.message.role !== "assistant")
43
- return null;
44
- return { type: "message_started", timestamp: at, runId, data: {} };
45
- case "message_update": {
46
- const ev = pe.assistantMessageEvent;
47
- if (ev.type === "text_delta") {
48
- return { type: "message_delta", timestamp: at, runId, data: { channel: "text", delta: ev.delta } };
49
- }
50
- if (ev.type === "thinking_delta") {
51
- return { type: "message_delta", timestamp: at, runId, data: { channel: "thinking", delta: ev.delta } };
52
- }
53
- return null;
54
- }
55
- case "message_end":
56
- if (pe.message.role !== "assistant")
57
- return null;
58
- return { type: "message_finished", timestamp: at, runId, data: {} };
59
- case "tool_execution_start":
60
- return {
61
- type: "tool_started",
62
- timestamp: at,
63
- runId,
64
- data: { id: pe.toolCallId, name: pe.toolName, args: pe.args },
65
- };
66
- case "tool_execution_update":
67
- return {
68
- type: "tool_progress",
69
- timestamp: at,
70
- runId,
71
- data: { id: pe.toolCallId, name: pe.toolName, partialResult: pe.partialResult },
72
- };
73
- case "tool_execution_end":
74
- return {
75
- type: "tool_finished",
76
- timestamp: at,
77
- runId,
78
- data: { id: pe.toolCallId, isError: pe.isError, content: pe.result },
79
- };
80
- case "retry_scheduled": {
81
- // Summarization retry backoff (auto-compaction / branch summary, pi ≥0.81.1) — without it,
82
- // up to ~14s of backoff at the turn's tail reads as a hang. `retry_attempt_start`/
83
- // `retry_finished` stay dropped: they carry no outcome, and the next event is the closure.
84
- // Typed against the vocabulary (as is the second construction site, session-control's
85
- // manual-compact callback) so a payload change breaks both at compile time.
86
- const event = {
87
- type: "retry_scheduled",
88
- timestamp: at,
89
- runId,
90
- data: {
91
- operation: pe.operation,
92
- attempt: pe.attempt,
93
- maxAttempts: pe.maxAttempts,
94
- delayMs: pe.delayMs,
95
- error: pe.errorMessage,
96
- },
97
- };
98
- return event;
99
- }
100
- default:
101
- return null;
102
- }
103
- }
104
- /** The SPEC projection of the rich stream. Events with no `AgentEvent` counterpart (progress,
105
- * message boundaries, run boundaries) project to null — the invoke terminal is produced from the
106
- * resolved message ({@link toTerminal}), not from `run_settled`. */
107
- export function projectAgentEvent(se) {
108
- switch (se.type) {
109
- case "message_delta": {
110
- const d = se.data;
111
- return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
112
- }
113
- case "tool_started": {
114
- const d = se.data;
115
- return { type: "tool_started", id: d.id, name: d.name, args: d.args };
116
- }
117
- case "tool_finished": {
118
- const d = se.data;
119
- return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
120
- }
121
- case "retry_scheduled": {
122
- // `operation` (compaction | branch_summary) stays session-plane vocabulary — a turn renderer
123
- // only needs "transient failure, retrying"; the engine detail lives in the control plane.
124
- const d = se.data;
125
- return { type: "retrying", attempt: d.attempt, maxAttempts: d.maxAttempts, delayMs: d.delayMs, reason: d.error };
126
- }
127
- default:
128
- return null;
129
- }
130
- }
131
- /** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
132
- function toolSessionManager(sessionId, harness) {
133
- const session = harnessSession(harness);
134
- if (!session)
135
- return undefined;
136
- return {
137
- getSessionId: () => sessionId,
138
- async getHeader() {
139
- const metadata = await session.getMetadata();
140
- return { id: sessionId, timestamp: metadata.createdAt };
141
- },
142
- getBranch: () => session.getBranch(),
143
- };
144
- }
145
- /**
146
- * The turn's {@link ToolActivation} over the live harness. `activate` is additive and filters to the
147
- * registered names first — pi's `setActiveTools` THROWS on unknown names, and a loader must get a
148
- * usable "nothing new" answer, not an exception. pi persists the change in the session, so the
149
- * per-invoke restore (harness.ts) carries it into later turns.
150
- */
151
- function toolActivation(harness) {
152
- // Serialize activations per turn: "who activated first" must be decided HERE, not by whether pi's
153
- // setActiveTools happens to mutate before its first await — parallel tool calls in one batch race
154
- // their activate() calls, and the addedToolNames load points must not double-stamp.
155
- let chain = Promise.resolve([]);
156
- return {
157
- active: () => harness.getActiveTools().map((t) => t.name),
158
- registered: () => harness.getTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
159
- activate(names) {
160
- const run = async () => {
161
- const current = harness.getActiveTools().map((t) => t.name);
162
- const added = additiveActivation(harness.getTools().map((t) => t.name), current, names);
163
- if (added.length > 0) {
164
- await harness.setActiveTools([...current, ...added]);
165
- // Persist the DELTA in a dedicated entry — what the per-invoke resolve (harness.ts) reads.
166
- // pi's own active_tools_change record is a full snapshot and is deliberately ignored there.
167
- // Absent session (a harness built outside piHarnessFactory): in-turn activation still works,
168
- // it just isn't durable — the factory owns persistence.
169
- await harnessSession(harness)?.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
170
- }
171
- return added;
172
- };
173
- const result = chain.then(run, run); // run after the predecessor settles, success or failure
174
- chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
175
- return result;
176
- },
177
- };
178
- }
179
- /**
180
- * After a successful turn, compact the session if its context has grown past pi's threshold — a long
181
- * shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
182
- * (`harness.compact()` writes a summary entry into the session, so the next reopen is compacted); the
183
- * bare harness does NOT auto-trigger it, so fastagent checks `shouldCompact` here and fires it. The
184
- * context size is the provider's own count from the turn's assistant message (`usage`).
185
- */
186
- async function maybeCompact(harness, message) {
187
- const contextWindow = harness.getModel().contextWindow;
188
- if (!contextWindow)
189
- return;
190
- if (shouldCompact(calculateContextTokens(message.usage), contextWindow, DEFAULT_COMPACTION_SETTINGS)) {
191
- await harness.compact();
192
- }
193
- }
194
- /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
195
- export function createPiAgentFromHarness(options) {
196
- const { harnessFactory, lease = inProcessLease(), observer } = options;
197
- // The cancellation protocol (SPEC MUST 3) lives in cancellableStream. Both halves matter here:
198
- // the DOOR settles a generator suspended on a quiet stream (a tool mid-execution) — the local
199
- // for-await pattern never hit that deadlock, but pull-driven consumers (the SSE handler's eager
200
- // reads) do — and the LATCH covers the window where the harness is still being built, where a
201
- // knock would land on an idle harness and the LATER run would ignore it.
202
- const invoke = (scope, prompt) => cancellableStream((hooks) => turn(scope, prompt, hooks));
203
- async function* turn(scope, prompt, { onCancelReady, wasCancelled }) {
204
- const release = lease.tryAcquire(scope.session);
205
- if (!release) {
206
- // Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
207
- yield {
208
- type: "failed",
209
- details: "session busy: a turn is already in flight for this session",
210
- retryable: true,
211
- code: SESSION_BUSY_CODE,
212
- };
213
- return;
214
- }
215
- // The run exists from here: one run_started, exactly one run_settled. Terminal points only
216
- // RECORD the outcome; the settlement event is emitted in the outer finally, right before
217
- // release() — so the observation plane's "running" window equals the lease window (state()
218
- // must never say idle while a new invoke would still be rejected session_busy), and the
219
- // post-terminal auto-compaction is naturally inside the run. A run with no recorded outcome
220
- // was cancelled by the caller (SPEC: cancellation has no terminal event) → aborted.
221
- const runId = crypto.randomUUID();
222
- let outcome;
223
- const observe = (event, run) => {
224
- if (!event || !observer)
225
- return;
226
- try {
227
- observer(scope.session, event, run);
228
- }
229
- catch (error) {
230
- // The observation plane must never break the data plane; a broken hub is its own problem.
231
- log.warn(`[fastagent] session observer threw (event ${event.type}): ${String(error)}`);
232
- }
233
- };
234
- // run_started must be observed before the (awaited) harness build so no early event outruns
235
- // registration — so the controls AWAIT the build instead of erroring on the assembling window:
236
- // a dispatch that races the build simply queues on the freshly built harness. A setup failure
237
- // rejects the gate (and the run settles failed); the guard keeps an undispatched rejection from
238
- // becoming an unhandled-rejection crash.
239
- let harnessReady;
240
- let harnessFailed;
241
- const harnessGate = new Promise((resolve, reject) => {
242
- harnessReady = resolve;
243
- harnessFailed = reject;
244
- });
245
- harnessGate.catch(() => { }); // observed via controls only when a dispatch actually happens
246
- // Aborted classification has two attribution sources, either suffices: pi's own
247
- // stopReason:"aborted" (toTerminal), and control-plane INTENT — needed because providers do
248
- // not uniformly attribute an aborted stream (verified empirically: the faux path surfaces a
249
- // plain error). Intent = "an abort() succeeded, OR one was still in flight when the terminal
250
- // arrived" (the harness error often lands before abort() resolves). A rejected abort that
251
- // RETURNED before the terminal counts as nothing — no rollback dance, no interleaving hazard.
252
- // GUARANTEE BOUNDARY: an abort still in flight that ultimately rejects can classify a
253
- // concurrent real error as aborted — narrow, and non-lossy: the settlement carries
254
- // `error.message` either way.
255
- let abortsInFlight = 0;
256
- let abortSucceeded = false;
257
- // Stale-controls guard: after settlement pi's steer()/followUp()/abort() would still resolve
258
- // (they queue / no-op on the to-be-discarded harness) — a silent acceptance of a command that
259
- // can never take effect. The flag flips at THREE points, earliest wins: (1) the moment the
260
- // run's terminal is determined (the main window — before the consumer-paced `yield terminal`
261
- // and auto-compaction), (2) the setup-failure path, (3) the outer finally as the backstop for
262
- // caller cancellation. A post-settle call throws and the dispatcher maps it to
263
- // `run_command_failed`.
264
- let runSettled = false;
265
- const settledError = () => new Error("run already settled; the command cannot take effect");
266
- // The settled check and the harness call MUST share one synchronous block (no await between):
267
- // pi enqueues/aborts synchronously at method entry, so check-then-call in the same tick truly
268
- // closes the race — a check behind its own await boundary would only shrink it.
269
- const controls = {
270
- async steer(p) {
271
- const opts = await toPiPromptOptions(p);
272
- const harness = await harnessGate;
273
- if (runSettled)
274
- throw settledError();
275
- await harness.steer(p.text, opts);
276
- },
277
- async followUp(p) {
278
- const opts = await toPiPromptOptions(p);
279
- const harness = await harnessGate;
280
- if (runSettled)
281
- throw settledError();
282
- await harness.followUp(p.text, opts);
283
- },
284
- async abort() {
285
- const harness = await harnessGate;
286
- if (runSettled)
287
- throw settledError();
288
- abortsInFlight++;
289
- try {
290
- await harness.abort();
291
- abortSucceeded = true;
292
- }
293
- finally {
294
- abortsInFlight--;
295
- }
296
- },
297
- };
298
- observe({ type: "run_started", timestamp: Date.now(), runId, data: {} }, controls);
299
- try {
300
- let harness;
301
- try {
302
- // Scope's lineage extension flows to the store's CREATE path only — an existing session
303
- // opens exactly as before, whatever the scope names (inheritance is one-time by construction).
304
- harness = await harnessFactory(scope.session, scope.parentSession === undefined
305
- ? undefined
306
- : {
307
- parentSession: scope.parentSession,
308
- ...(scope.branchHints !== undefined ? { branchHints: scope.branchHints } : {}),
309
- });
310
- }
311
- catch (error) {
312
- // Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
313
- harnessFailed(error); // a pending dispatch learns the run cannot take commands
314
- const terminal = errorToTerminal(error);
315
- outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
316
- runSettled = true; // commands can no longer take effect — reject stale controls from here on
317
- yield terminal;
318
- return; // → outer finally emits the settlement
319
- }
320
- harnessReady(harness);
321
- // Arm the cancellation door (see invoke's wrapper): aborting the harness settles the run,
322
- // releasing any await the generator is parked on so a queued return() can reach the finally.
323
- onCancelReady(() => {
324
- void harness.abort().catch(() => { });
325
- });
326
- const queue = new EventQueue();
327
- const unsub = harness.subscribe((pe) => {
328
- // Summarization retries also warn to server logs: the session `retry_scheduled` event only
329
- // reaches attached observers, and an operator tailing logs must see the backoff too.
330
- if (pe.type === "retry_scheduled") {
331
- log.warn(`[fastagent] ${pe.operation} retry ${pe.attempt}/${pe.maxAttempts} in ${pe.delayMs}ms (session ${scope.session}): ${pe.errorMessage}`);
332
- }
333
- const rich = toSessionEvent(pe, runId);
334
- if (!rich)
335
- return;
336
- observe(rich);
337
- const event = projectAgentEvent(rich);
338
- if (event)
339
- queue.push(event);
340
- });
341
- let completed; // the assistant message of a cleanly completed turn
342
- try {
343
- // Preparing the prompt lazy-loads the image pipeline and re-encodes every attachment, so it
344
- // both takes time and can throw BEFORE any engine work exists to fail — and a throw here
345
- // would escape the generator and break iteration for the caller, which MUST 2 forbids. It
346
- // settles the run the same way a setup failure does.
347
- let opts;
348
- try {
349
- opts = await toPiPromptOptions(prompt);
350
- }
351
- catch (error) {
352
- const terminal = errorToTerminal(error);
353
- outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
354
- runSettled = true;
355
- yield terminal;
356
- return; // → outer finally emits the settlement
357
- }
358
- // The consumer walked away while the harness was built or the prompt prepared (latched — the
359
- // door armed above only stops a RUNNING harness, so a knock in that window is a no-op the
360
- // LATER run would ignore): never start the model call. Read AFTER the last await before it,
361
- // so both windows are covered; settle as aborted and let the queued return() finish the
362
- // generator.
363
- if (wasCancelled()) {
364
- outcome = { status: "aborted" };
365
- runSettled = true;
366
- try {
367
- await harness.abort(); // teardown — fresh-harness discipline
368
- }
369
- catch (error) {
370
- log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
371
- }
372
- return; // → outer finally emits the settlement
373
- }
374
- // Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
375
- const run = turnContext.run({
376
- cwd: options.cwd ?? process.cwd(),
377
- sessionManager: toolSessionManager(scope.session, harness),
378
- tools: toolActivation(harness),
379
- }, () => harness.prompt(prompt.text, opts));
380
- yield* queue.drainUntil(run);
381
- let terminal;
382
- try {
383
- const message = await run;
384
- terminal = toTerminal(message);
385
- if (terminal.type === "completed")
386
- completed = message;
387
- }
388
- catch (error) {
389
- terminal = errorToTerminal(error);
390
- }
391
- if ((abortSucceeded || abortsInFlight > 0) && terminal.type === "failed") {
392
- terminal = { type: "failed", details: terminal.details, retryable: false, code: ABORTED_CODE };
393
- }
394
- if (terminal.type === "completed")
395
- outcome = { status: "completed" };
396
- else if (terminal.type === "failed") {
397
- outcome =
398
- terminal.code === ABORTED_CODE
399
- ? // Carry the detail: an independent real error that raced an accepted abort must stay
400
- // diagnosable in the settlement (audit consumers read run_settled, not the invoke
401
- // stream) — aborted classifies the run, the message preserves what actually stopped it.
402
- { status: "aborted", error: { message: terminal.details, retryable: false } }
403
- : {
404
- status: "failed",
405
- error: { code: terminal.code, message: terminal.details, retryable: terminal.retryable },
406
- };
407
- }
408
- // Commands become ineffective the moment the run resolved — NOT at the outer finally, which
409
- // sits behind `yield terminal` (a consumer-paced suspension) and auto-compaction. Flipping
410
- // here closes the silent-drop window for steer/follow_up dispatched in that gap; the
411
- // outer-finally flip remains as the backstop for caller cancellation.
412
- runSettled = true;
413
- yield terminal;
414
- }
415
- finally {
416
- // After a successful turn, keep the session under the model's context window (a long shared group
417
- // or 1:1 conversation would otherwise overflow). Runs HERE — before teardown (it uses the harness)
418
- // and BEFORE the lease release below, and is awaited via the generator's return(), so the next
419
- // turn for this session waits and never reopens mid-compaction. That await rides the consumer's
420
- // iteration: a STREAMING consumer (e.g. telegram) already sent the reply on the terminal before
421
- // returning, so compaction — rare, only over threshold — does not delay it; a `collect`-style
422
- // consumer returns the reply FROM the loop, so it waits for the (occasional) compaction. Non-fatal:
423
- // a failed compaction leaves the (still-valid) session for the next turn to retry.
424
- if (completed) {
425
- try {
426
- await maybeCompact(harness, completed);
427
- }
428
- catch (error) {
429
- log.warn(`[fastagent] auto-compaction failed during cleanup: ${String(error)}`);
430
- }
431
- }
432
- // Cleanup MUST NOT throw after the terminal was yielded — that would make an already-closed
433
- // event stream throw on iteration (violating SPEC MUST 2 / MUST 3). Contain it, but surface it
434
- // (a cleanup failure is abnormal).
435
- try {
436
- unsub();
437
- }
438
- catch (error) {
439
- log.warn(`[fastagent] harness unsubscribe failed during cleanup: ${String(error)}`);
440
- }
441
- try {
442
- await harness.abort();
443
- }
444
- catch (error) {
445
- log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
446
- }
447
- }
448
- }
449
- finally {
450
- // Exactly-one settlement, after ALL run work (incl. auto-compaction) and immediately before
451
- // the lease releases — see the outcome note above. The stale-controls flag flips FIRST so a
452
- // dispatch racing this settlement is rejected instead of silently accepted.
453
- runSettled = true;
454
- observe({ type: "run_settled", timestamp: Date.now(), runId, data: outcome ?? { status: "aborted" } });
455
- release(); // after cleanup, so the next invoke for this session can enter
456
- }
457
- }
458
- return { invoke };
459
- }
@@ -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;
@@ -1,62 +0,0 @@
1
- /**
2
- * The image pipeline pi's `read` tool needs: normalize an unsupported format to PNG, resize below the
3
- * inline limit, and hand back the hints that tell the model what it is looking at.
4
- *
5
- * pi-agent-core's `createReadTool` takes this as an INJECTED processor and does nothing without one —
6
- * unlike pi-coding-agent's, which wires its private `processImage` internally. That function is not
7
- * exported (nor reachable: the package's `exports` map has no deep paths), so this rebuilds it from the
8
- * two halves that ARE public, `convertToPng` and `resizeImage`/`formatDimensionNote`.
9
- *
10
- * It is upstream logic restated, which is a real cost — without it `read` on a screenshot sends the raw
11
- * bytes (measured: 7.48 MB of base64 where pi-coding-agent sends 3.48 MB, and no dimension note for the
12
- * model's coordinate math), and a bmp is dropped entirely while the tool's own description still
13
- * advertises it. test/tools-parity.test.ts compares this against pi-coding-agent's real `read` on both
14
- * paths, so upstream changing the pipeline surfaces as a failing test rather than as drift.
15
- */
16
- import { convertToPng, formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
17
- /** Formats a provider takes inline as-is; everything else has to become a PNG first. */
18
- const INLINE_MIME = {
19
- "image/png": "image/png",
20
- "image/jpeg": "image/jpeg",
21
- "image/jpg": "image/jpeg",
22
- "image/gif": "image/gif",
23
- "image/webp": "image/webp",
24
- };
25
- /** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
26
- * as tool output, so a reworded one is a different prompt, not a different implementation detail. */
27
- export const readImageProcessor = async (bytes, mimeType, options) => {
28
- const base = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
29
- const inline = INLINE_MIME[base];
30
- let normalized;
31
- if (inline) {
32
- normalized = { bytes, mimeType: inline };
33
- }
34
- else {
35
- const png = await convertToPng(Buffer.from(bytes).toString("base64"), base);
36
- if (!png)
37
- return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
38
- normalized = { bytes: Buffer.from(png.data, "base64"), mimeType: png.mimeType, convertedFrom: base };
39
- }
40
- const hints = [];
41
- const converted = (to) => normalized.convertedFrom && normalized.convertedFrom !== to
42
- ? `[Image converted from ${normalized.convertedFrom} to ${to}.]`
43
- : undefined;
44
- if (!options.autoResizeImages) {
45
- const hint = converted(normalized.mimeType);
46
- if (hint)
47
- hints.push(hint);
48
- return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
49
- }
50
- const resized = await resizeImage(normalized.bytes, normalized.mimeType);
51
- if (!resized)
52
- return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
53
- const hint = converted(resized.mimeType);
54
- if (hint)
55
- hints.push(hint);
56
- // The scale factor the model needs to map coordinates back onto the original — dropping it is what
57
- // makes a resized screenshot unusable for anything positional.
58
- const note = formatDimensionNote(resized);
59
- if (note)
60
- hints.push(note);
61
- return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
62
- };
@@ -1,60 +0,0 @@
1
- import type { Session, SessionTreeEntry } from "@earendil-works/pi-agent-core";
2
- /**
3
- * Where a NEW session starts from, when it names a parent (participant-model.md §5: "a thread starts
4
- * from what the room knew"). Read only on the create path — an EXISTING session ignores it entirely,
5
- * which is what makes inheritance one-time by construction: no marker to persist, no decision to
6
- * retry per turn; the session existing IS the record that the decision was taken.
7
- */
8
- export interface SessionInheritance {
9
- /** The session to fork from. Missing or unreadable → the new session starts empty, with a warn —
10
- * context is not the ask, and losing it must not cost the turn. */
11
- parentSession: string;
12
- /** Opaque markers that MAY locate the branch point on the parent's active path (searched in
13
- * message content, first hit wins, most recent occurrence). No match → the parent's present. */
14
- branchHints?: string[];
15
- }
16
- /** What fastagent needs from a session backend: open-or-create by opaque id. */
17
- export interface PiSessionStore {
18
- openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<Session>;
19
- }
20
- /**
21
- * OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
22
- * `undefined`, never creates one — sessions are the data plane's monopoly. Two consumers:
23
- * - the OBSERVATION plane (`state()`/`entries()`), strictly read-only (design §16 invariant 4);
24
- * - the control plane's BOUNDARY writers (`set_model`/`set_thinking` append override records to the
25
- * returned handle; `navigate` moves its leaf) after an existence check, under the run lease.
26
- * `openIfExists` skips the open-time crash reconciliation (that appends repair entries — a write
27
- * the observation plane must not perform). The boundary writers are safe WITHOUT it for two
28
- * different reasons: an override record is not a message, so it cannot create or pair with a
29
- * dangling tool_use; a `navigate` writes no message either, but it CAN expose one — parking the
30
- * leaf on an assistant entry whose tool results are now off-path is the dangling-pair state
31
- * {@link reconcileInterruptedToolCalls} exists for. That is repaired at the next `openOrCreate`,
32
- * which repairs AT THE LEAF — exactly where a move puts it.
33
- *
34
- * Writing MESSAGE-class records through this handle would bypass that repair: use `openOrCreate`
35
- * for anything that enters the transcript.
36
- */
37
- export interface PiSessionReader {
38
- openIfExists(sessionId: string): Promise<Session | undefined>;
39
- }
40
- /**
41
- * The entries on the session's ACTIVE path, root→leaf — what every last-wins read must walk.
42
- * `getEntries()` is the whole TREE: once `navigate` can move the leaf, the journal still carries
43
- * the abandoned branch, and reading it flat would run the session on a setting it moved away from.
44
- * Deliberately NOT `Session.getBranch()`: that walk is bounded by the last compaction's retained
45
- * window, which is the right bound for MODEL CONTEXT and the wrong one for settings — an override
46
- * recorded before a compaction is a preference, and it still governs the session after one.
47
- */
48
- export declare function activePathEntries(session: Session): Promise<SessionTreeEntry[]>;
49
- /** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
50
- export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader;
51
- /**
52
- * Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
53
- * `cwd` is recorded in session metadata; defaults to process.cwd().
54
- */
55
- export declare function jsonlSessionStore(options: {
56
- dir: string;
57
- cwd?: string;
58
- /** Inheritance guard override (tests): parent journals above this are not forked. Default 32 MiB. */
59
- forkMaxBytes?: number;
60
- }): PiSessionStore & PiSessionReader;