@fastagent-sh/fastagent 0.17.1 → 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 (54) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  3. package/dist/channels/feishu/feishu.js +39 -9
  4. package/dist/channels/feishu/invoke-turn.d.ts +8 -2
  5. package/dist/channels/feishu/invoke-turn.js +150 -31
  6. package/dist/channels/feishu/parse.js +6 -0
  7. package/dist/channels/http.js +15 -2
  8. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  9. package/dist/channels/invoke-turn-kit.js +6 -2
  10. package/dist/channels/slack/invoke-turn.js +1 -1
  11. package/dist/channels/slack/slack.js +1 -5
  12. package/dist/channels/state.d.ts +0 -10
  13. package/dist/channels/state.js +2 -19
  14. package/dist/channels/telegram/invoke-turn.js +1 -1
  15. package/dist/channels/thread-participants.d.ts +7 -0
  16. package/dist/channels/thread-participants.js +3 -0
  17. package/dist/cli/commands/deploy.js +13 -5
  18. package/dist/cli/commands/dev.js +1 -1
  19. package/dist/cli/commands/fire.js +1 -1
  20. package/dist/cli/commands/info.js +21 -1
  21. package/dist/cli/commands/invoke.js +1 -1
  22. package/dist/cli/commands/start.js +1 -1
  23. package/dist/cli/shared.d.ts +4 -2
  24. package/dist/cli/shared.js +12 -5
  25. package/dist/collect.d.ts +30 -4
  26. package/dist/collect.js +39 -6
  27. package/dist/deploy/preflight.d.ts +8 -2
  28. package/dist/deploy/preflight.js +21 -3
  29. package/dist/deploy/secrets.d.ts +3 -0
  30. package/dist/deploy/secrets.js +6 -0
  31. package/dist/dev-supervisor.js +8 -2
  32. package/dist/engines/pi/create.d.ts +2 -1
  33. package/dist/engines/pi/create.js +12 -7
  34. package/dist/engines/pi/harness.d.ts +6 -3
  35. package/dist/engines/pi/harness.js +4 -3
  36. package/dist/engines/pi/invoke-session.d.ts +32 -0
  37. package/dist/engines/pi/invoke-session.js +171 -0
  38. package/dist/engines/pi/invoke.d.ts +6 -27
  39. package/dist/engines/pi/invoke.js +49 -208
  40. package/dist/engines/pi/models.d.ts +45 -11
  41. package/dist/engines/pi/models.js +55 -8
  42. package/dist/engines/pi/session-builder.js +4 -2
  43. package/dist/engines/pi/session-control.d.ts +2 -1
  44. package/dist/engines/pi/sessions.d.ts +17 -1
  45. package/dist/engines/pi/sessions.js +292 -10
  46. package/dist/engines/pi/turn-kit.d.ts +56 -0
  47. package/dist/engines/pi/turn-kit.js +161 -0
  48. package/dist/paths.d.ts +6 -0
  49. package/dist/paths.js +6 -0
  50. package/dist/pi.d.ts +3 -2
  51. package/dist/pi.js +1 -1
  52. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  53. package/dist/session-remote.js +10 -2
  54. package/package.json +1 -1
@@ -76,7 +76,7 @@ export async function runDeploy(host, dirArg, opts) {
76
76
  failStartup(new Error(`deploy stopped: ${pre.gate}`));
77
77
  for (const m of pre.messages)
78
78
  console.error(`[fastagent] ${m.level}: ${m.text}`);
79
- const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets, } = pre;
79
+ const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets, } = pre;
80
80
  const hasDeclaredChannels = routeChannels.length + longConnectionChannels.length > 0;
81
81
  // Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
82
82
  // with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
@@ -126,6 +126,7 @@ export async function runDeploy(host, dirArg, opts) {
126
126
  port,
127
127
  requireTunnel: requestedTunnel,
128
128
  modelAuth,
129
+ modelKeyInDefinition,
129
130
  authPath,
130
131
  channels,
131
132
  longConnectionChannels,
@@ -176,6 +177,7 @@ export async function runDeploy(host, dirArg, opts) {
176
177
  workspace,
177
178
  name: serviceName,
178
179
  modelAuth,
180
+ modelKeyInDefinition,
179
181
  authPath,
180
182
  channels,
181
183
  longConnectionChannels,
@@ -278,6 +280,7 @@ export async function runDeploy(host, dirArg, opts) {
278
280
  agentPrefix: container.agentPrefix,
279
281
  name: acName,
280
282
  modelAuth,
283
+ modelKeyInDefinition,
281
284
  authPath,
282
285
  channels,
283
286
  extraSecrets,
@@ -371,6 +374,7 @@ export async function runDeploy(host, dirArg, opts) {
371
374
  agentPrefix: container.agentPrefix,
372
375
  appName,
373
376
  modelAuth,
377
+ modelKeyInDefinition,
374
378
  authPath,
375
379
  channels,
376
380
  longConnectionChannels,
@@ -468,9 +472,10 @@ function deployEnvironment(agentDir, channels) {
468
472
  * when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
469
473
  */
470
474
  async function runDeployDocker(params) {
471
- const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, } = params;
475
+ const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, } = params;
472
476
  const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
473
477
  modelAuth,
478
+ modelKeyInDefinition,
474
479
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
475
480
  channels,
476
481
  longConnectionChannels,
@@ -511,7 +516,7 @@ async function runDeployDocker(params) {
511
516
  * behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
512
517
  */
513
518
  async function runDeployFly(params) {
514
- const { agentDir, workspace, agentPrefix, appName, modelAuth, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
519
+ const { agentDir, workspace, agentPrefix, appName, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
515
520
  const fly = spawnRunner("fly", workspace);
516
521
  // Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
517
522
  if ((await fly(["version"], { capture: true })).code === 127) {
@@ -520,6 +525,7 @@ async function runDeployFly(params) {
520
525
  const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
521
526
  const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
522
527
  modelAuth,
528
+ modelKeyInDefinition,
523
529
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
524
530
  channels,
525
531
  longConnectionChannels,
@@ -553,9 +559,10 @@ async function runDeployFly(params) {
553
559
  * file (secret values off argv) is created here — 0600, removed after the run either way.
554
560
  */
555
561
  async function runDeployAgentcore(params) {
556
- const { agentDir, workspace, agentPrefix, name, modelAuth, authPath, channels, extraSecrets, selfSchedule } = params;
562
+ const { agentDir, workspace, agentPrefix, name, modelAuth, modelKeyInDefinition, authPath, channels, extraSecrets, selfSchedule, } = params;
557
563
  const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
558
564
  modelAuth,
565
+ modelKeyInDefinition,
559
566
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
560
567
  channels,
561
568
  extraSecrets,
@@ -627,7 +634,7 @@ async function runDeployAgentcore(params) {
627
634
  * webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
628
635
  */
629
636
  async function runDeployRailway(params) {
630
- const { agentDir, workspace, name, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
637
+ const { agentDir, workspace, name, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
631
638
  const railway = spawnRunner("railway", workspace);
632
639
  // Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
633
640
  if ((await railway(["--version"], { capture: true })).code === 127) {
@@ -635,6 +642,7 @@ async function runDeployRailway(params) {
635
642
  }
636
643
  const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
637
644
  modelAuth,
645
+ modelKeyInDefinition,
638
646
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
639
647
  channels,
640
648
  longConnectionChannels,
@@ -56,7 +56,7 @@ async function serveOnce(dir, opts) {
56
56
  reportWorkspaceHint(workspaceHint(a));
57
57
  reportLine("config", a.configPath ?? "(none)");
58
58
  reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
59
- await reportAuth(a.modelSpec, a.authPath);
59
+ await reportAuth(a.agentDir, a.modelSpec, a.authPath);
60
60
  reportAgentsSkillsTools(a);
61
61
  // Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev, gated
62
62
  // out in start (level info), keeping end-user content out of production logs. Wired in both postures.
@@ -37,7 +37,7 @@ export async function runFire(name, dirArg, opts) {
37
37
  authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
38
38
  }).catch(failStartup);
39
39
  console.error(`[fastagent] fire: ${name} (${modelSpec})`);
40
- await reportAuth(modelSpec, authPath);
40
+ await reportAuth(placement.agentDir, modelSpec, authPath);
41
41
  const exitCode = await runInvokeStream(agent.invoke({ session: scheduleSession(name) }, { text: schedule.prompt }), (text) => process.stdout.write(text), (line) => console.error(line));
42
42
  process.stdout.write("\n");
43
43
  process.exit(exitCode);
@@ -2,7 +2,8 @@
2
2
  import { resolve } from "node:path";
3
3
  import { loadDotEnv } from "../../env.js";
4
4
  import { discoverChannelFiles } from "../../engines/pi/channel.js";
5
- import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModelSpec, resolveSessionsDirOverride, } from "../../engines/pi/config.js";
5
+ import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModel, resolveModelSpec, resolveSessionsDirOverride, } from "../../engines/pi/config.js";
6
+ import { createPiModelRuntime } from "../../engines/pi/models.js";
6
7
  import { resolveStateRoot, workspaceHint } from "../../paths.js";
7
8
  import { resolveAgentTools } from "../../engines/pi/create.js";
8
9
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
@@ -55,12 +56,27 @@ export async function runInfo(dirArg, opts) {
55
56
  const stateRoot = resolveStateRoot(agentDir);
56
57
  const sessionsDir = resolveSessionsDirOverride(opts.sessionsDir) ?? defaultSessionsDir(stateRoot);
57
58
  const authPath = resolveAuthPath(agentDir, opts.authPath); // flag > FASTAGENT_AUTH_PATH > default — the one owner
59
+ // RESOLVE the spec, do not just echo it: a spec is only real once its provider/model exist in the
60
+ // agent's own surface (built-ins + its models.json), which is exactly what a custom endpoint changes.
61
+ // Reporting a healthy-looking spec that `dev`/`start` then reject is the failure this pre-empts.
62
+ // Reported as DATA rather than thrown — a broken agent is what `info` is for — and read-only: the
63
+ // runtime reads models.json without creating anything (its catalog cache is written on refresh, and
64
+ // there is none here).
65
+ const modelError = modelSpec
66
+ ? await createPiModelRuntime({ agentDir, authPath })
67
+ .then((models) => {
68
+ resolveModel(models, modelSpec);
69
+ return undefined;
70
+ })
71
+ .catch((error) => error.message)
72
+ : undefined;
58
73
  if (opts.json) {
59
74
  console.log(JSON.stringify({
60
75
  agentDir,
61
76
  workspace,
62
77
  configPath: configPath ?? null,
63
78
  model: modelSpec ?? null,
79
+ modelError: modelError ?? null,
64
80
  thinkingLevel: config.thinkingLevel ?? null,
65
81
  context: definition.contextFiles.map((f) => f.path),
66
82
  persona: definition.persona !== undefined,
@@ -85,6 +101,8 @@ export async function runInfo(dirArg, opts) {
85
101
  // One padded label writer: hand-spaced labels drifted out of alignment the moment a longer one
86
102
  // (agent/workspace/selfSchedule) joined the report.
87
103
  const line = (label, value) => console.log(`${`${label}:`.padEnd(13)} ${value}`);
104
+ /** A continuation under the previous line, aligned to the same column (no label, so no bare colon). */
105
+ const cont = (value) => console.log(`${"".padEnd(13)} ${value}`);
88
106
  line("agent", agentDir);
89
107
  line("workspace", workspace);
90
108
  const hint = workspaceHint({ agentDir, workspace });
@@ -92,6 +110,8 @@ export async function runInfo(dirArg, opts) {
92
110
  line("hint", hint);
93
111
  line("config", configPath ?? "(none)");
94
112
  line("model", modelSpec ?? "(not set — pass --model, set FASTAGENT_MODEL, or config.model)");
113
+ if (modelError)
114
+ cont(`⚠ does not resolve: ${modelError}`);
95
115
  if (config.thinkingLevel)
96
116
  line("thinking", config.thinkingLevel);
97
117
  line("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
@@ -20,7 +20,7 @@ export async function runInvoke(message, dirArg, opts) {
20
20
  // BOTH directories, like dev/start: from the workspace, `placement.workspace` alone equals the dir you
21
21
  // typed, so it cannot tell you which agent actually ran.
22
22
  console.error(`[fastagent] invoke: ${placement.agentDir} (workspace ${placement.workspace}, ${modelSpec})`);
23
- await reportAuth(modelSpec, authPath);
23
+ await reportAuth(placement.agentDir, modelSpec, authPath);
24
24
  // Fresh session per invoke (one-shot, no resume). runInvokeStream maps events→IO: reply→stdout,
25
25
  // tool/failure→stderr, exit 1 iff the turn failed (so CI can gate on it).
26
26
  const exitCode = await runInvokeStream(agent.invoke({ session: randomUUID() }, { text: message }), (text) => process.stdout.write(text), (line) => console.error(line));
@@ -49,7 +49,7 @@ export async function runStart(dirArg, opts) {
49
49
  reportLine("workspace", workspace);
50
50
  reportWorkspaceHint(workspaceHint({ agentDir, workspace }));
51
51
  reportLine("model", `${modelSpec}${config.thinkingLevel ? ` (thinking: ${config.thinkingLevel})` : ""}`);
52
- await reportAuth(modelSpec, authPath);
52
+ await reportAuth(agentDir, modelSpec, authPath);
53
53
  reportLine("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
54
54
  if (definition.persona)
55
55
  reportLine("persona", "persona.md");
@@ -26,8 +26,10 @@ export declare function parsePort(value: string | undefined, source: string, fro
26
26
  * a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
27
27
  */
28
28
  export declare function parseBind(value: string | undefined): string | undefined;
29
- /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
30
- export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
29
+ /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
30
+ * Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
31
+ * reported like any built-in rather than as an unknown provider. */
32
+ export declare function reportAuth(agentDir: string, modelSpec: string, authPath: string): Promise<void>;
31
33
  /**
32
34
  * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
33
35
  * funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
@@ -10,7 +10,7 @@ import { buildModelPickerOptions } from "./models-view.js";
10
10
  import { fastagentCredentialStore } from "../engines/pi/auth.js";
11
11
  import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resolveModel, resolveModelSpec, rewriteConfigModel, } from "../engines/pi/config.js";
12
12
  import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
13
- import { createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses } from "../engines/pi/models.js";
13
+ import { createPiModelRuntime, createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses, } from "../engines/pi/models.js";
14
14
  import { formatAuthReport } from "./auth-view.js";
15
15
  import { log } from "../log.js";
16
16
  import { openExternalUrl } from "../open-url.js";
@@ -70,10 +70,13 @@ export function parseBind(value) {
70
70
  failUsage(`invalid --bind "${value}": must be an IP address or "localhost"`);
71
71
  return bindAddress(trimmed); // a name never travels past this point — see bind.ts
72
72
  }
73
- /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
74
- export async function reportAuth(modelSpec, authPath) {
73
+ /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
74
+ * Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
75
+ * reported like any built-in rather than as an unknown provider. */
76
+ export async function reportAuth(agentDir, modelSpec, authPath) {
75
77
  const provider = providerOf(modelSpec);
76
- const source = await probeAuthSource(createPiModels({ authPath }), modelSpec);
78
+ const models = await createPiModelRuntime({ agentDir, authPath }).catch(failStartup);
79
+ const source = await probeAuthSource(models, modelSpec);
77
80
  // Only when nothing satisfies auth do we read the store (refresh-FREE) to tell "nothing stored" from
78
81
  // "stored but unusable" — see formatAuthReport for why. store.read warns on a corrupt file itself.
79
82
  const stored = source === undefined
@@ -107,7 +110,9 @@ export async function resolveFirstRunModel(agentDir, options = {}) {
107
110
  if (!isInteractive())
108
111
  return; // CI/deploy: the opener throws the actionable missing-model error
109
112
  const authPath = resolveAuthPath(agentDir, options.authPath);
110
- const models = createPiModels({ authPath });
113
+ // The picker lists the AGENT's surface: built-ins plus whatever its models.json declares, so a
114
+ // self-hosted endpoint is pickable on first run instead of being invisible until hand-set.
115
+ const models = await createPiModelRuntime({ agentDir, authPath }).catch(failStartup);
111
116
  const chosen = await pickWithCredentials(models, authPath);
112
117
  if (chosen === undefined)
113
118
  return; // cancelled (or auth probe failed): the caller raises its clear missing-model error
@@ -208,6 +213,8 @@ seams = {}) {
208
213
  * more than keeping a doubtful one.
209
214
  */
210
215
  async function verifyApiKeyLogin(provider, authPath, spec) {
216
+ // Built-ins only: `login` itself offers built-in providers (login.ts), and a models.json endpoint
217
+ // authenticates from its own `apiKey` (env/command), so there is no stored credential to verify here.
211
218
  const models = createPiModels({ authPath });
212
219
  const model = spec ? resolveModel(models, spec) : models.getProvider(provider)?.getModels()[0];
213
220
  if (!model) {
package/dist/collect.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Caller-side stream helpers: `collect` (buffered consumption, SPEC §7) reduces an AgentEvent
3
- * stream to a final value, encoding the terminal discipline (failed → throw, missing terminal →
4
- * error) streaming consumers for-await themselves. `abortFirstIterator` is the shared
5
- * cancellation protocol for generator-backed streams.
2
+ * Stream helpers around the SPEC's two stream disciplines.
3
+ *
4
+ * Caller side: `collect` (buffered consumption, SPEC §7) reduces an AgentEvent stream to a final
5
+ * value, encoding the terminal discipline (failed → throw, missing terminal → error) — streaming
6
+ * consumers for-await themselves.
7
+ *
8
+ * Agent side: the cancellation protocol (SPEC MUST 3), in two halves that only work together —
9
+ * `abortFirstIterator` delivers the consumer's knock, `cancellableStream` is what an engine wraps
10
+ * its turn in to receive it.
6
11
  */
7
12
  import type { AgentEvent, Json } from "./agent.ts";
8
13
  /**
@@ -13,8 +18,29 @@ import type { AgentEvent, Json } from "./agent.ts";
13
18
  * settles the suspension), then delegates to `gen.return`, swallowing its rejection (the
14
19
  * generator's own catch/finally already surfaced the outcome). `throw()` tears down identically
15
20
  * and rethrows the caller's error deterministically instead of poking a completed generator.
21
+ *
22
+ * Cancellation also SILENCES the stream, and that belongs here rather than in each producer: a
23
+ * generator parked in an await can still reach a `yield` on its way out (an error path that
24
+ * yields a terminal, say), and that yield satisfies the pending `next()` — handing a terminal
25
+ * event to a consumer that already walked away, which SPEC MUST 3 forbids. Deciding it once, at
26
+ * the protocol boundary, is what keeps every producer from having to re-ask "is anyone still
27
+ * listening?" before each yield.
16
28
  */
17
29
  export declare function abortFirstIterator<T>(gen: AsyncGenerator<T>, cancel: () => void): AsyncIterator<T>;
30
+ /** What a turn generator gets so a consumer walking away can stop it. */
31
+ export interface CancelHooks {
32
+ /** Publish the door: how to abort the engine work, once there is engine work to abort. */
33
+ onCancelReady: (cancel: () => void) => void;
34
+ /** The latch, for the window where the door is armed but the engine is still idle — knocking then
35
+ * does nothing, so a turn must read this before committing to work no one is waiting for. */
36
+ wasCancelled: () => boolean;
37
+ }
38
+ /**
39
+ * Wrap a turn generator in the cancellation protocol: cancelling the returned stream latches the
40
+ * intent AND knocks on whatever door the generator published, in that order (the latch must be set
41
+ * before the knock, or a turn checking it mid-flight could miss the cancel it just received).
42
+ */
43
+ export declare function cancellableStream<T>(start: (hooks: CancelHooks) => AsyncGenerator<T>): AsyncIterable<T>;
18
44
  /** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
19
45
  * a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
20
46
  export declare class AgentFailure extends Error {
package/dist/collect.js CHANGED
@@ -6,22 +6,55 @@
6
6
  * settles the suspension), then delegates to `gen.return`, swallowing its rejection (the
7
7
  * generator's own catch/finally already surfaced the outcome). `throw()` tears down identically
8
8
  * and rethrows the caller's error deterministically instead of poking a completed generator.
9
+ *
10
+ * Cancellation also SILENCES the stream, and that belongs here rather than in each producer: a
11
+ * generator parked in an await can still reach a `yield` on its way out (an error path that
12
+ * yields a terminal, say), and that yield satisfies the pending `next()` — handing a terminal
13
+ * event to a consumer that already walked away, which SPEC MUST 3 forbids. Deciding it once, at
14
+ * the protocol boundary, is what keeps every producer from having to re-ask "is anyone still
15
+ * listening?" before each yield.
9
16
  */
10
17
  export function abortFirstIterator(gen, cancel) {
18
+ let cancelled = false;
19
+ const teardown = async () => {
20
+ cancelled = true;
21
+ cancel();
22
+ await gen.return(undefined).catch(() => { });
23
+ };
11
24
  return {
12
- next: () => gen.next(),
13
- async return(value) {
14
- cancel();
15
- await gen.return(value).catch(() => { });
25
+ async next() {
26
+ const result = await gen.next();
27
+ return cancelled ? { done: true, value: undefined } : result;
28
+ },
29
+ async return() {
30
+ await teardown();
16
31
  return { done: true, value: undefined };
17
32
  },
18
33
  async throw(error) {
19
- cancel();
20
- await gen.return(undefined).catch(() => { });
34
+ await teardown();
21
35
  throw error;
22
36
  },
23
37
  };
24
38
  }
39
+ /**
40
+ * Wrap a turn generator in the cancellation protocol: cancelling the returned stream latches the
41
+ * intent AND knocks on whatever door the generator published, in that order (the latch must be set
42
+ * before the knock, or a turn checking it mid-flight could miss the cancel it just received).
43
+ */
44
+ export function cancellableStream(start) {
45
+ let door;
46
+ let cancelled = false;
47
+ const iterator = abortFirstIterator(start({
48
+ onCancelReady: (cancel) => {
49
+ door = cancel;
50
+ },
51
+ wasCancelled: () => cancelled,
52
+ }), () => {
53
+ cancelled = true;
54
+ door?.();
55
+ });
56
+ return { [Symbol.asyncIterator]: () => iterator };
57
+ }
25
58
  /** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
26
59
  * a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
27
60
  export class AgentFailure extends Error {
@@ -19,9 +19,15 @@ interface DeployFacts {
19
19
  * has no external wake-up, so the deployment must keep one machine running: the fly plan forces
20
20
  * `min_machines_running=1`, the railway runbook forbids App Sleeping. */
21
21
  hasTimeTriggers: boolean;
22
- /** What satisfies model auth locally ({@link probeAuthSource}) — an env-var name, an OAuth/stored label,
23
- * or undefined. Drives the runbook's secret guidance and `--run`'s credential carry. */
22
+ /** What satisfies model auth locally — an env-var name, an OAuth/stored label, or undefined. Drives the
23
+ * runbook's secret guidance and `--run`'s credential carry. For a models.json endpoint keyed from the
24
+ * environment this is the VARIABLE NAME (see {@link modelCredentialCarry}), not the display label, so
25
+ * the value carries like any provider key. */
24
26
  modelAuth: string | undefined;
27
+ /** The definition itself carries the model key (a models.json literal `apiKey`, or a `!command` run on
28
+ * the host), so there is nothing for `--run` to carry AND nothing to gate: `fastagent login` cannot
29
+ * serve a custom provider, so gating on it would strand a correctly configured agent. */
30
+ modelKeyInDefinition: boolean;
25
31
  /** The project-level auth file `--run` reads to carry the credential (probed with the same path). */
26
32
  authPath: string;
27
33
  /** Container facts shared by the plan and the generated Dockerfile — ONE source, so they can't drift. */
@@ -17,12 +17,13 @@ import { resolveAuthPath } from "../engines/pi/config.js";
17
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
18
18
  import { inspectChannels } from "../engines/pi/channel.js";
19
19
  import { discoverScheduleFiles } from "../schedule/discover.js";
20
- import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
20
+ import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
21
21
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
22
22
  import { exists } from "../paths.js";
23
23
  import { detectRuntime, readPackageJson } from "../runtime.js";
24
24
  import { fastagentVersion } from "../version.js";
25
25
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
26
+ import { isEnvKey } from "./secrets.js";
26
27
  /**
27
28
  * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
28
29
  * matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
@@ -126,9 +127,25 @@ export async function preflightDeploy(input) {
126
127
  });
127
128
  }
128
129
  // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
129
- // miss a `fastagent login` credential and falsely report "none configured".
130
+ // miss a `fastagent login` credential and falsely report "none configured". Through the AGENT's model
131
+ // surface too (its models.json travels into the image), so a custom endpoint is not read as an unknown
132
+ // provider — this probe feeds the gate that decides whether `--run` may proceed.
130
133
  const authPath = resolveAuthPath(agentDir, authPathFlag);
131
- const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
134
+ const models = await createPiModelRuntime({ agentDir, authPath });
135
+ let modelAuth = modelSpec ? await probeAuthSource(models, modelSpec) : undefined;
136
+ let modelKeyInDefinition = false;
137
+ // probeAuthSource answers "is it authenticated here", which is not the deploy question ("how does the
138
+ // credential REACH the host"). It reports every models.json endpoint as "configured API key" — not an
139
+ // env-var name — so without this the gate below sees no credential and stops the deploy with two
140
+ // remedies that are both wrong for such an agent: `fastagent login` cannot serve a custom provider,
141
+ // and the key is already in the environment.
142
+ if (modelSpec && !isEnvKey(modelAuth)) {
143
+ const carry = modelCredentialCarry(models, modelSpec);
144
+ if (carry.envVar)
145
+ modelAuth = carry.envVar;
146
+ else
147
+ modelKeyInDefinition = carry.inDefinition;
148
+ }
132
149
  // Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
133
150
  // its package.json/runtime/lockfile drive the image's install step — never the workspace's (the bake
134
151
  // ships the whole tree, but the workspace's own manifest belongs to its own deploy).
@@ -410,6 +427,7 @@ export async function preflightDeploy(input) {
410
427
  longConnectionChannels,
411
428
  hasTimeTriggers,
412
429
  modelAuth,
430
+ modelKeyInDefinition,
413
431
  authPath,
414
432
  container,
415
433
  port,
@@ -38,6 +38,9 @@ export declare function deploymentSecrets(modelAuth: string | undefined, channel
38
38
  */
39
39
  export declare function assembleSecrets(input: {
40
40
  modelAuth: string | undefined;
41
+ /** The definition carries the model key itself (a models.json literal `apiKey` / `!command`): there is
42
+ * no value to carry and no gate to raise — see {@link modelCredentialCarry}. */
43
+ modelKeyInDefinition?: boolean;
41
44
  authFile: Buffer | undefined;
42
45
  channels: ChannelKind[];
43
46
  longConnectionChannels?: string[];
@@ -65,6 +65,12 @@ export function assembleSecrets(input) {
65
65
  else if (input.authFile) {
66
66
  secrets.FASTAGENT_AUTH_SEED = input.authFile.toString("base64");
67
67
  }
68
+ else if (input.modelKeyInDefinition) {
69
+ // The definition authenticates itself (models.json literal key, or a command run on the host), so it
70
+ // travels in the image with everything else. Gating here would be the worst kind of wrong: both
71
+ // remedies we print are impossible for such an agent — `fastagent login` has no flow for a custom
72
+ // provider, and there is no provider env key to set.
73
+ }
68
74
  else {
69
75
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
70
76
  }
@@ -13,7 +13,7 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
- import { AGENT_CONFIG_NAMES, resolveStateRoot } from "./paths.js";
16
+ import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
17
  import { isUnderDir } from "./engines/pi/definition.js";
18
18
  import { dotEnvPath } from "./env.js";
19
19
  import { log } from "./log.js";
@@ -21,7 +21,7 @@ import { installProxyFetch } from "./proxy.js";
21
21
  import { openExternalUrl } from "./open-url.js";
22
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
23
23
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
- const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, .secrets/.env";
24
+ const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, models.json, .secrets/.env";
25
25
  /**
26
26
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
27
  * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
@@ -49,6 +49,12 @@ export function devWatchIgnored(root, envFile) {
49
49
  return false;
50
50
  if (rel === "package.json")
51
51
  return false;
52
+ // models.json is read ONCE per worker (the model hub is built during assembly), so an edit needs a
53
+ // restart like any other code input. It is also the one input whose breakage KILLS the worker — a
54
+ // malformed file fails assembly — so leaving it unwatched would strand the author: the fix that
55
+ // repairs it would not be the edit that restarts.
56
+ if (rel === AGENT_MODELS_FILE)
57
+ return false;
52
58
  const segments = rel.split(sep);
53
59
  if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
54
60
  return false;
@@ -7,7 +7,8 @@ import { type AnyModel, piHarnessFactory } from "./harness.ts";
7
7
  import { type PiSessionStore } from "./sessions.ts";
8
8
  import type { ModuleLoadFailure } from "../../loader.ts";
9
9
  import { type ToolCollision, type MountedTool } from "./tool.ts";
10
- import { type Lease, type SessionObserver } from "./invoke.ts";
10
+ import { type SessionObserver } from "./invoke.ts";
11
+ import { type Lease } from "./turn-kit.ts";
11
12
  /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
12
13
  * turn as the harness tool context — hence no argument here. */
13
14
  export declare function piDefaultTools(): MountedTool[];
@@ -18,12 +18,13 @@ import { defaultAuthPath, resolveModel } from "./config.js";
18
18
  import { resolveSecretsDir } from "../../paths.js";
19
19
  import { loadAgentDefinition } from "./definition.js";
20
20
  import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
21
- import { createPiModels } from "./models.js";
21
+ import { createPiModelRuntime, createPiModels } from "./models.js";
22
22
  import { reportFindingsIfChanged } from "./report.js";
23
23
  import { inMemorySessionStore } from "./sessions.js";
24
24
  import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
25
25
  import { withSearchTool } from "./search-tools.js";
26
- import { createPiAgentFromHarness, inProcessLease } from "./invoke.js";
26
+ import { createPiAgentFromHarness } from "./invoke.js";
27
+ import { inProcessLease } from "./turn-kit.js";
27
28
  // ── §1 tools ─────────────────────────────────────────────────────────────────
28
29
  //
29
30
  // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
@@ -164,7 +165,7 @@ export function assembleSystemPrompt(options) {
164
165
  * directory) and route through here.
165
166
  */
166
167
  function buildPiAgent(opts) {
167
- const models = createPiModels({ providers: opts.providers, authPath: opts.authPath });
168
+ const models = opts.models ?? createPiModels({ providers: opts.providers, authPath: opts.authPath });
168
169
  const env = opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() });
169
170
  // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
170
171
  // the SAME lease instance the agent runs under — boundary mutations must contend on it.
@@ -241,13 +242,17 @@ export async function createPiAgentFromDefinition(dir, options) {
241
242
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
242
243
  // it; a caller's own search_tools wins).
243
244
  const tools = withSearchTool(options.tools ?? piDefaultTools());
245
+ // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
246
+ // passes an explicit authPath, so this only affects direct L2 callers).
247
+ const authPath = options.authPath ?? defaultAuthPath(resolveSecretsDir(dir));
244
248
  const agent = buildPiAgent({
245
249
  model: options.model,
246
250
  thinkingLevel: options.thinkingLevel,
247
- providers: options.providers,
248
- // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
249
- // passes an explicit authPath, so this only affects direct L2 callers).
250
- authPath: options.authPath ?? defaultAuthPath(resolveSecretsDir(dir)),
251
+ // THE directory rung's model surface: built-ins + the agent's own models.json (custom endpoints,
252
+ // which are definition data and travel with the artifact) + any injected Provider instance. This
253
+ // is what makes `dev`/`start`/`invoke` and an embedded L2 caller resolve the same specs.
254
+ models: await createPiModelRuntime({ agentDir: dir, authPath, providers: options.providers }),
255
+ authPath,
251
256
  // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills
252
257
  // edits (the author's, or the agent's own self-modification) take effect on the next turn with
253
258
  // no process restart — restarts are reserved for code (tools/channels/config, module cache).
@@ -9,7 +9,7 @@
9
9
  import { AgentHarness } from "@earendil-works/pi-agent-core";
10
10
  import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
11
11
  import type { Model, Models } from "@earendil-works/pi-ai";
12
- import { type PiSessionStore } from "./sessions.ts";
12
+ import { type PiSessionStore, type SessionInheritance } from "./sessions.ts";
13
13
  import { type MountedTool } from "./tool.ts";
14
14
  import { type OverrideEntryLike } from "./session-settings.ts";
15
15
  /**
@@ -38,7 +38,9 @@ export type AnyModel = Model<any>;
38
38
  * pi's env-backed default tools read (pi 0.83). Custom tools are context-FREE and stay assignable — a
39
39
  * four-parameter `execute` satisfies the five-parameter one, so `defineTool` is untouched by this. */
40
40
  type PiHarness = AgentHarness<ExecutionToolContext>;
41
- export type PiHarnessFactory = (session: string) => PiHarness | Promise<PiHarness>;
41
+ export type PiHarnessFactory = (session: string,
42
+ /** Where a NEW session starts from (sessions.ts) — an existing session ignores it. */
43
+ inherit?: SessionInheritance) => PiHarness | Promise<PiHarness>;
42
44
  export interface PiHarnessFactoryOptions {
43
45
  /** Session persistence. Continuity = same backing store + same session id. */
44
46
  sessions: PiSessionStore;
@@ -107,6 +109,7 @@ export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], mo
107
109
  thinkingLevel: ThinkingLevel;
108
110
  };
109
111
  export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: MountedTool[], sessionId: string): string[] | undefined;
110
- /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
112
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing →
113
+ * create, seeded from `inherit` when the scope names a parent. */
111
114
  export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
112
115
  export {};
@@ -114,10 +114,11 @@ export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
114
114
  }
115
115
  return [...new Set([...initial, ...known])];
116
116
  }
117
- /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
117
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing →
118
+ * create, seeded from `inherit` when the scope names a parent. */
118
119
  export function piHarnessFactory(options) {
119
- return async (sessionId) => {
120
- const session = await options.sessions.openOrCreate(sessionId);
120
+ return async (sessionId, inherit) => {
121
+ const session = await options.sessions.openOrCreate(sessionId, inherit);
121
122
  // One extra entry walk per invoke to collect the activation deltas — negligible against the model
122
123
  // call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
123
124
  // the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose