@fastagent-sh/fastagent 0.17.0 → 0.18.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 (68) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/agentcore.d.ts +16 -2
  3. package/dist/channels/agentcore.js +66 -4
  4. package/dist/channels/feishu/card.d.ts +20 -9
  5. package/dist/channels/feishu/card.js +27 -13
  6. package/dist/channels/feishu/feishu-api.d.ts +13 -2
  7. package/dist/channels/feishu/feishu.js +121 -17
  8. package/dist/channels/feishu/invoke-turn.d.ts +12 -2
  9. package/dist/channels/feishu/invoke-turn.js +159 -14
  10. package/dist/channels/feishu/normalize.js +97 -32
  11. package/dist/channels/feishu/parse.js +6 -0
  12. package/dist/channels/feishu/preview.d.ts +4 -3
  13. package/dist/channels/feishu/preview.js +77 -23
  14. package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
  15. package/dist/channels/http.js +15 -2
  16. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  17. package/dist/channels/invoke-turn-kit.js +6 -2
  18. package/dist/channels/lark/scaffold/lark-send.ts +9 -6
  19. package/dist/channels/slack/invoke-turn.js +1 -1
  20. package/dist/channels/slack/slack.js +1 -5
  21. package/dist/channels/state.d.ts +0 -10
  22. package/dist/channels/state.js +2 -19
  23. package/dist/channels/telegram/invoke-turn.js +1 -1
  24. package/dist/channels/thread-participants.d.ts +7 -0
  25. package/dist/channels/thread-participants.js +3 -0
  26. package/dist/cli/commands/deploy.js +13 -5
  27. package/dist/cli/commands/dev.js +1 -1
  28. package/dist/cli/commands/fire.js +1 -1
  29. package/dist/cli/commands/info.js +21 -1
  30. package/dist/cli/commands/invoke.js +1 -1
  31. package/dist/cli/commands/start.js +40 -6
  32. package/dist/cli/serve.d.ts +4 -0
  33. package/dist/cli/serve.js +2 -2
  34. package/dist/cli/shared.d.ts +4 -2
  35. package/dist/cli/shared.js +12 -5
  36. package/dist/collect.d.ts +30 -4
  37. package/dist/collect.js +39 -6
  38. package/dist/deploy/agentcore/plan.js +17 -0
  39. package/dist/deploy/agentcore/run.d.ts +7 -1
  40. package/dist/deploy/agentcore/run.js +93 -8
  41. package/dist/deploy/preflight.d.ts +8 -2
  42. package/dist/deploy/preflight.js +55 -10
  43. package/dist/deploy/secrets.d.ts +3 -0
  44. package/dist/deploy/secrets.js +6 -0
  45. package/dist/dev-supervisor.js +8 -2
  46. package/dist/engines/pi/create.d.ts +2 -1
  47. package/dist/engines/pi/create.js +12 -7
  48. package/dist/engines/pi/harness.d.ts +6 -3
  49. package/dist/engines/pi/harness.js +4 -3
  50. package/dist/engines/pi/invoke-session.d.ts +32 -0
  51. package/dist/engines/pi/invoke-session.js +171 -0
  52. package/dist/engines/pi/invoke.d.ts +6 -27
  53. package/dist/engines/pi/invoke.js +49 -208
  54. package/dist/engines/pi/models.d.ts +45 -11
  55. package/dist/engines/pi/models.js +55 -8
  56. package/dist/engines/pi/session-builder.js +4 -2
  57. package/dist/engines/pi/session-control.d.ts +2 -1
  58. package/dist/engines/pi/sessions.d.ts +17 -1
  59. package/dist/engines/pi/sessions.js +292 -10
  60. package/dist/engines/pi/turn-kit.d.ts +56 -0
  61. package/dist/engines/pi/turn-kit.js +161 -0
  62. package/dist/paths.d.ts +6 -0
  63. package/dist/paths.js +6 -0
  64. package/dist/pi.d.ts +3 -2
  65. package/dist/pi.js +1 -1
  66. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  67. package/dist/session-remote.js +10 -2
  68. package/package.json +1 -1
@@ -0,0 +1,171 @@
1
+ import { SESSION_BUSY_CODE } from "../../agent.js";
2
+ import { cancellableStream } from "../../collect.js";
3
+ import { log } from "../../log.js";
4
+ import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
5
+ /**
6
+ * SPEC events from pi's session stream. `auto_retry_start` has no harness counterpart: an
7
+ * `AgentSession` retries a failed assistant request itself, which the SPEC already has a word for.
8
+ */
9
+ function toAgentEvent(event) {
10
+ switch (event.type) {
11
+ case "message_update": {
12
+ // An empty delta is not output: it moves no consumer's state, and treating it as output would
13
+ // spend the silent window that auto-retry is allowed to use (see runOnSession).
14
+ const ev = event.assistantMessageEvent;
15
+ if (ev.type === "text_delta")
16
+ return ev.delta === "" ? null : { type: "text", delta: ev.delta };
17
+ if (ev.type === "thinking_delta")
18
+ return ev.delta === "" ? null : { type: "thinking", delta: ev.delta };
19
+ return null;
20
+ }
21
+ case "tool_execution_start":
22
+ return { type: "tool_started", id: event.toolCallId, name: event.toolName, args: event.args };
23
+ case "tool_execution_end":
24
+ return { type: "tool_ended", id: event.toolCallId, isError: event.isError, content: event.result };
25
+ case "auto_retry_start":
26
+ return {
27
+ type: "retrying",
28
+ attempt: event.attempt,
29
+ maxAttempts: event.maxAttempts,
30
+ delayMs: event.delayMs,
31
+ reason: event.errorMessage,
32
+ };
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * The turn's outcome. `prompt()` resolves void and never throws for an engine-side failure (measured:
39
+ * a provider error and an abort both resolve normally), so the terminal comes from the assistant
40
+ * message the run ended on — the same read {@link toTerminal} performs on the harness path.
41
+ *
42
+ * That message is taken from the EVENT STREAM, not from `session.state.messages`. Session state is
43
+ * mutable mid-turn: compaction replaces the array, and overflow recovery splices the last assistant
44
+ * message out of it outright (`state.messages = messages.slice(0, -1)`). Any index into it is a
45
+ * turn boundary that the engine is free to invalidate, while a `message_end` payload is a fact that
46
+ * already happened. Auto-compaction runs its own model call outside the agent's event stream, so it
47
+ * cannot masquerade as the turn's answer here.
48
+ */
49
+ const ENGINE_PRODUCED_NOTHING = {
50
+ // Unreachable on a settled run: pi ends every outcome, error and abort included, with an assistant
51
+ // message. Reaching it means the engine broke its own contract — name the engine, not the turn.
52
+ type: "failed",
53
+ details: "the engine settled the run without ending an assistant message",
54
+ retryable: false,
55
+ };
56
+ export function createPiAgentFromSession(options) {
57
+ const { sessionFactory, lease = inProcessLease() } = options;
58
+ /** Own the session's lifetime: one writer, built here, disposed here whatever the turn did. */
59
+ async function* turn(scope, prompt, hooks) {
60
+ const release = lease.tryAcquire(scope.session);
61
+ if (!release) {
62
+ yield {
63
+ type: "failed",
64
+ details: "session busy: a turn is already in flight for this session",
65
+ retryable: true,
66
+ code: SESSION_BUSY_CODE,
67
+ };
68
+ return;
69
+ }
70
+ try {
71
+ let session;
72
+ try {
73
+ session = await sessionFactory(scope.session);
74
+ }
75
+ catch (error) {
76
+ yield errorToTerminal(error); // setup failures are events, never throws (MUST 2)
77
+ return;
78
+ }
79
+ try {
80
+ yield* runOnSession(session, prompt, hooks);
81
+ }
82
+ finally {
83
+ try {
84
+ session.dispose();
85
+ }
86
+ catch (error) {
87
+ log.warn(`[fastagent] session dispose failed during cleanup: ${String(error)}`);
88
+ }
89
+ }
90
+ }
91
+ finally {
92
+ release();
93
+ }
94
+ }
95
+ return { invoke: (scope, prompt) => cancellableStream((hooks) => turn(scope, prompt, hooks)) };
96
+ }
97
+ /** One turn on a session someone else owns: subscribe, prompt, stream, terminal. */
98
+ async function* runOnSession(session, prompt, { onCancelReady, wasCancelled }) {
99
+ const abort = () => session.abort().catch(() => { });
100
+ onCancelReady(() => void abort());
101
+ const queue = new EventQueue();
102
+ let finalAssistant;
103
+ /** Whether any of THIS attempt's answer has been streamed — the only output a retry would duplicate. */
104
+ let streamedAnswer = false;
105
+ /** Set when a retry is refused because the answer already streamed — carries the error that ends it. */
106
+ let retriedAfterAnswer;
107
+ const unsub = session.subscribe((event) => {
108
+ if (retriedAfterAnswer !== undefined)
109
+ return; // the turn is decided; the retry's output is not ours
110
+ if (event.type === "message_end" && event.message.role === "assistant") {
111
+ finalAssistant = event.message;
112
+ }
113
+ // pi retries a failed assistant request by DISCARDING that attempt's assistant message and asking
114
+ // again. Everything the turn achieved before it survives - executed tools keep their persisted
115
+ // results, and the retry resumes from them - so the only thing a retry can duplicate is answer
116
+ // text this L0 already streamed, which SPEC deltas cannot retract. Refuse it exactly there:
117
+ // refusing on tool events instead would push the retry out to the CALLER, who can only re-run the
118
+ // whole prompt and execute the tool a second time.
119
+ if (event.type === "auto_retry_start" && streamedAnswer) {
120
+ retriedAfterAnswer = event.errorMessage;
121
+ // Not synchronously: pi emits this event BEFORE creating the controller that makes its backoff
122
+ // abortable, so an abort from inside the listener would find nothing to cancel and the turn
123
+ // would still pay the full delay and burn a provider call on an answer we must discard.
124
+ queueMicrotask(() => void abort());
125
+ return;
126
+ }
127
+ const projected = toAgentEvent(event);
128
+ if (!projected)
129
+ return;
130
+ if (projected.type === "text" || projected.type === "thinking")
131
+ streamedAnswer = true;
132
+ queue.push(projected);
133
+ });
134
+ try {
135
+ // Resolving prompt options lazy-loads the image pipeline and re-encodes every attachment, so it
136
+ // both takes time and can throw before any engine work exists to fail. Hence the two guards, in
137
+ // this order and no earlier: its failure is a turn failure (MUST 2), and the latch has to be read
138
+ // after the LAST await before the call — the door armed above only stops a RUNNING session, so a
139
+ // consumer who walked away during the build or the resize would knock on an idle one and have
140
+ // the turn start anyway.
141
+ let options;
142
+ try {
143
+ options = await toPiPromptOptions(prompt);
144
+ }
145
+ catch (error) {
146
+ yield errorToTerminal(error);
147
+ return;
148
+ }
149
+ if (wasCancelled()) {
150
+ await abort();
151
+ return;
152
+ }
153
+ const run = session.prompt(prompt.text, options);
154
+ yield* queue.drainUntil(run);
155
+ try {
156
+ await run;
157
+ if (retriedAfterAnswer !== undefined) {
158
+ yield { type: "failed", details: retriedAfterAnswer, retryable: true };
159
+ }
160
+ else {
161
+ yield finalAssistant ? toTerminal(finalAssistant) : ENGINE_PRODUCED_NOTHING;
162
+ }
163
+ }
164
+ catch (error) {
165
+ yield errorToTerminal(error);
166
+ }
167
+ }
168
+ finally {
169
+ unsub();
170
+ }
171
+ }
@@ -1,32 +1,20 @@
1
1
  /**
2
- * The turn mechanism (request-time): fan pi AgentHarness's two ports (subscribe event side-channel
3
- * + prompt final value) into SPEC's single event stream, under a single-writer-per-session lease.
2
+ * The harness L0: fan pi AgentHarness's two ports (subscribe event side-channel + prompt final
3
+ * value) into SPEC's single event stream, under a single-writer-per-session lease.
4
4
  *
5
- * §1 Lease — single-writer concurrency floor (injectable port + in-process default)
6
- * §2 translate — the single pi↔SPEC translation point (both directions)
7
- * §3 EventQueue — push→pull plumbing for pi's two-port shape
8
- * §4 createPiAgentFromHarness — composes §1–§3 into Agent.invoke
5
+ * The half that does not care which pi class runs the turn — lease, terminals, event queue, prompt
6
+ * prep is turn-kit.ts. What lives here is what only the harness has: its event vocabulary, translated ONCE
7
+ * into the rich `SessionEvent` layer, and the observation plane that layer feeds.
9
8
  *
10
9
  * Concurrency: at most one in-flight turn per session; a second invoke fails fast with
11
10
  * `failed{retryable}` ("session busy"), leaving dedupe/queueing/steering to the channel. Each
12
11
  * invoke builds a fresh harness bound to the session and discards it (stateless multi-session).
13
12
  */
14
13
  import type { AgentHarnessEvent } from "@earendil-works/pi-agent-core";
15
- import type { AssistantMessage } from "@earendil-works/pi-ai";
16
14
  import { type Agent, type AgentEvent, type Prompt } from "../../agent.ts";
17
15
  import type { SessionEvent } from "../../session.ts";
18
16
  import { type PiHarnessFactory } from "./harness.ts";
19
- export type Release = () => void;
20
- export interface Lease {
21
- /** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
22
- tryAcquire(session: string): Release | null;
23
- }
24
- export declare function inProcessLease(): Lease;
25
- /** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
26
- export declare function classifyRetryable(details: string, signal: {
27
- status?: number;
28
- code?: unknown;
29
- }): boolean;
17
+ import { type Lease } from "./turn-kit.ts";
30
18
  /**
31
19
  * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
32
20
  * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
@@ -58,15 +46,6 @@ export interface RunControls {
58
46
  * modulation handles — it is the trusted hub seam, not a public fan-out point. Do not wire
59
47
  * untrusted taps here; give third parties the read-only `events()` stream instead. */
60
48
  export type SessionObserver = (session: string, event: SessionEvent, run?: RunControls) => void;
61
- /**
62
- * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
63
- * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
64
- * entire failure class (violating SPEC MUST 1).
65
- */
66
- export declare function toTerminal(message: AssistantMessage): AgentEvent;
67
- export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
68
- type: "failed";
69
- }>;
70
49
  export interface CreatePiAgentFromHarnessOptions {
71
50
  harnessFactory: PiHarnessFactory;
72
51
  /** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
@@ -1,27 +1,11 @@
1
1
  import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
2
2
  import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
3
- import { abortFirstIterator } from "../../collect.js";
3
+ import { cancellableStream } from "../../collect.js";
4
4
  import { log } from "../../log.js";
5
5
  import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
6
+ import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
6
7
  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 ───────────────────────
8
+ // ── Event translation: the single pi↔SPEC translation point ─────────────────
25
9
  //
26
10
  // `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
27
11
  // guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
@@ -37,65 +21,6 @@ export function inProcessLease() {
37
21
  // already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
38
22
  // Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
39
23
  // 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
24
  /**
100
25
  * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
101
26
  * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
@@ -203,28 +128,6 @@ export function projectAgentEvent(se) {
203
128
  return null;
204
129
  }
205
130
  }
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
131
  /** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
229
132
  function toolSessionManager(sessionId, harness) {
230
133
  const session = harnessSession(harness);
@@ -288,100 +191,16 @@ async function maybeCompact(harness, message) {
288
191
  await harness.compact();
289
192
  }
290
193
  }
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
194
  /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
354
195
  export function createPiAgentFromHarness(options) {
355
196
  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) {
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 }) {
385
204
  const release = lease.tryAcquire(scope.session);
386
205
  if (!release) {
387
206
  // Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
@@ -480,7 +299,14 @@ export function createPiAgentFromHarness(options) {
480
299
  try {
481
300
  let harness;
482
301
  try {
483
- harness = await harnessFactory(scope.session);
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
+ });
484
310
  }
485
311
  catch (error) {
486
312
  // Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
@@ -497,21 +323,6 @@ export function createPiAgentFromHarness(options) {
497
323
  onCancelReady(() => {
498
324
  void harness.abort().catch(() => { });
499
325
  });
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
326
  const queue = new EventQueue();
516
327
  const unsub = harness.subscribe((pe) => {
517
328
  // Summarization retries also warn to server logs: the session `retry_scheduled` event only
@@ -529,8 +340,38 @@ export function createPiAgentFromHarness(options) {
529
340
  });
530
341
  let completed; // the assistant message of a cleanly completed turn
531
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
+ }
532
374
  // Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
533
- const opts = await toPiPromptOptions(prompt);
534
375
  const run = turnContext.run({
535
376
  cwd: options.cwd ?? process.cwd(),
536
377
  sessionManager: toolSessionManager(scope.session, harness),
@@ -1,13 +1,16 @@
1
- /**
2
- * The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId
3
- * lookup) AND auth (per-request credential resolution). fastagent builds one per opener and threads
4
- * it into the harness alongside the selected `model`; the two must come from the same collection so
5
- * the model's provider auth is in scope.
6
- */
7
1
  import { type Api, type Model, type Models, type Provider } from "@earendil-works/pi-ai";
8
2
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
9
3
  import { type FastagentAuthOptions } from "./auth.ts";
10
4
  import { type InteractiveLoginKind } from "./login.ts";
5
+ /** The DEFINITION-LOCAL custom-endpoint file, in pi's own models.json schema (see pi's docs/models.md):
6
+ * declare a self-hosted / gateway endpoint as `{ providers: { <id>: { baseUrl, api, apiKey, models } } }`
7
+ * and select it with a `<id>/<modelId>` model spec. Keys belong in the environment — `apiKey` supports
8
+ * `"$ENV_VAR"` interpolation and `"!command"` — with the NAME listed in `deploy.secrets` so the value
9
+ * travels to the host. Living in the agent dir is the whole point: it is part of the definition, so it
10
+ * is baked into the deployed image. pi's MACHINE-GLOBAL `~/.pi/agent/models.json` stays unread — that
11
+ * one is builder-machine state, and reading it would make an agent work locally and lose its model on
12
+ * deploy. The NAME itself lives in the neutral paths.ts — `dev`'s watcher needs the same one, and a
13
+ * second spelling would let the restart scope drift from what the worker loads. */
11
14
  export interface CreatePiModelsOptions extends FastagentAuthOptions {
12
15
  /** Credentials file path. Defaults to the global `~/.fastagent/.secrets/auth.json`; the directory opener passes
13
16
  * the project-level `<root>/.secrets/auth.json`. */
@@ -26,15 +29,46 @@ export declare function createPiModels(options?: CreatePiModelsOptions): Models;
26
29
  /**
27
30
  * The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
28
31
  * providers + fastagent's credential store at `authPath`) in the type pi's session services require
29
- * (`createAgentSessionServices({ modelRuntime })`). Builtins only (`modelsPath: null` — pi's
30
- * machine-global models.json is definition-foreign) and no availability network, so the model
31
- * surface equals serving's. No `providers` option: `ModelRuntime` registers providers by config
32
- * record, not `Provider` instance — accepting the option and dropping it would be a silent no-op;
33
- * add the mapping when a consumer actually needs it.
32
+ * (`createAgentSessionServices({ modelRuntime })`). Built-ins PLUS the agent's own
33
+ * {@link AGENT_MODELS_FILE} when `agentDir` is given (a dir-less caller gets built-ins only), and no
34
+ * availability network, so the model surface equals serving's.
35
+ *
36
+ * `ModelRuntime` also takes `Provider` INSTANCES via `registerNativeProvider` (pi 0.83); the
37
+ * declarative file is what this rung wires because it is data that travels with the definition.
34
38
  */
35
39
  export declare function createPiModelRuntime(options?: FastagentAuthOptions & {
36
40
  authPath?: string;
41
+ /** The agent dir, whose {@link AGENT_MODELS_FILE} declares custom endpoints. Omit for built-ins only. */
42
+ agentDir?: string;
43
+ /** Where the dynamic model-catalog cache goes; defaults to the agent's resolved state root. */
44
+ stateRoot?: string;
45
+ /** Extra providers for the ids the built-ins do not cover — the CODE-shaped sibling of models.json,
46
+ * for what a file cannot express (minting a token per request, a test fake).
47
+ *
48
+ * On an id COLLISION the file wins, not this: upstream installs a native provider as the BASE and
49
+ * composes the models.json entry over it. Right way round — where a deployed agent's traffic goes
50
+ * is a property of the definition, not of the program that embedded it — but it does mean a same-id
51
+ * file entry silently replaces the endpoint injected here. Use a distinct id to keep both. */
52
+ providers?: Provider[];
37
53
  }): Promise<ModelRuntime>;
54
+ /**
55
+ * How a model's credential will REACH a deployed agent — the question `deploy` asks, which
56
+ * {@link probeAuthSource} cannot answer: it flattens every models.json endpoint to the display label
57
+ * "configured API key", so a self-hosted endpoint looks credential-less to the deploy gate even when
58
+ * its key is sitting in an env var.
59
+ *
60
+ * - `envVar`: an environment variable backs it, BY NAME — the shape `deploy` already understands, so
61
+ * the value carries as a host secret with no extra declaration from the author.
62
+ * - `inDefinition`: the definition itself carries it (a literal `apiKey`, or a `!command` run on the
63
+ * host). Nothing for `deploy` to carry — and nothing to gate on either, which is the point: the
64
+ * `fastagent login` remedy is meaningless for a provider login cannot serve.
65
+ *
66
+ * Neither set = a stored credential or nothing at all; the existing auth.json / gate paths decide.
67
+ */
68
+ export declare function modelCredentialCarry(runtime: ModelRuntime, spec: string): {
69
+ envVar?: string;
70
+ inDefinition: boolean;
71
+ };
38
72
  /** Per-provider auth status for the first-run model picker: usable now (with the source label), not
39
73
  * configured, or configured-but-broken (expired token, refresh failure, corrupt store — kept as DATA
40
74
  * so the picker can show it instead of silently dropping the provider). Non-ready states carry the