@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
@@ -33,13 +33,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
33
33
  * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
34
34
  * is ever re-run.
35
35
  */
36
- export async function* streamTurnWithBusyRetry(agent, session, prompt, options) {
36
+ export async function* streamTurnWithBusyRetry(agent,
37
+ /** The full scope, not a session string — channels that set extension fields (lineage) pass them
38
+ * through here; channels that don't pass `{ session }` and nothing changes. */
39
+ scope, prompt, options) {
37
40
  const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
41
+ const session = scope.session;
38
42
  const deadline = Date.now() + busyRetry.maxWaitMs;
39
43
  for (;;) {
40
44
  let retryBusy = false;
41
45
  let first = true;
42
- for await (const e of agent.invoke({ session }, prompt)) {
46
+ for await (const e of agent.invoke(scope, prompt)) {
43
47
  if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
44
48
  retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
45
49
  break;
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
56
56
 
57
57
  export default defineTool({
58
58
  description:
59
- "Send a message to a Lark chat: plain `text`, or `markdown` (rendered as a card headings, " +
60
- "bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
61
- "scheduled or self-scheduled (wake) turn — or to reach a chat OTHER than the one you are " +
62
- "answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
63
- "answer (it would post the message twice). chatId comes from the [lark: chat ] context line in a " +
64
- "chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
59
+ "Send a message to a Lark chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
60
+ "channel is carrying a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
61
+ "nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
62
+ "channel streams and delivers your reply itself, so do NOT call this to answer the current " +
63
+ "chat: it would post the message twice, outside the conversation thread. `chatId` (oc_) names " +
64
+ "the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
65
+ "or memory); the [lark: chat …] context line only identifies the chat you are answering — the " +
66
+ "one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
67
+ "`markdown` (rendered as a card: headings, bold, code blocks, links).",
65
68
  input: z.object({
66
69
  chatId: z.string().describe("target chat id (oc_…)"),
67
70
  text: z.string().optional().describe("plain text message to send"),
@@ -59,5 +59,5 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
59
59
  return;
60
60
  }
61
61
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
62
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
62
+ yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: transport.label, onCompleted, busyRetry });
63
63
  }
@@ -7,7 +7,7 @@ import { text } from "../respond.js";
7
7
  import { createSeenRing } from "../seen.js";
8
8
  import { createThreadParticipants } from "../thread-participants.js";
9
9
  import { createTaskTracker } from "../tasks.js";
10
- import { ensureStateHome, removeRetiredStateFile } from "../state.js";
10
+ import { ensureStateHome } from "../state.js";
11
11
  import { dispatchStop, isStopText } from "../stop-command.js";
12
12
  import { codePointPrefix } from "../text.js";
13
13
  import { createTurnQueue } from "../turn-queue.js";
@@ -141,10 +141,6 @@ export function slackChannel(options) {
141
141
  * under a custom route is the `routed.session === undefined` condition on the write, not the
142
142
  * absence of a route: a route that supplies its own session records nothing here. */
143
143
  const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
144
- // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
145
- // after the release following the participant model ships — by then no live deployment can still
146
- // be carrying the file. test/migration-deadline.test.ts fails when due.
147
- removeRetiredStateFile(stateHome, "owned-threads.json", label);
148
144
  const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
149
145
  const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
150
146
  const store = createTurnStore(join(stateHome, "turns.json"), {
@@ -4,13 +4,3 @@ export declare function ensureStateHome(dir: string): void;
4
4
  * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
5
5
  export declare function loadStateFile(path: string): unknown;
6
6
  export declare function saveStateFile(path: string, value: unknown): void;
7
- /**
8
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
9
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
10
- * whose loss changes behaviour needs a migration, not a delete.
11
- *
12
- * Shared because a retired file is usually retired in every channel at once: one best-effort
13
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
14
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
15
- */
16
- export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
@@ -12,8 +12,8 @@
12
12
  * is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
13
13
  * with silently-empty state would hide real data behind a config mistake.
14
14
  */
15
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
- import { dirname, join } from "node:path";
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
17
  import { log } from "../log.js";
18
18
  /** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
19
19
  export function ensureStateHome(dir) {
@@ -48,20 +48,3 @@ export function saveStateFile(path, value) {
48
48
  writeFileSync(tmp, JSON.stringify(value));
49
49
  renameSync(tmp, path);
50
50
  }
51
- /**
52
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
53
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
54
- * whose loss changes behaviour needs a migration, not a delete.
55
- *
56
- * Shared because a retired file is usually retired in every channel at once: one best-effort
57
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
58
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
59
- */
60
- export function removeRetiredStateFile(stateHome, name, label) {
61
- try {
62
- rmSync(join(stateHome, name), { force: true });
63
- }
64
- catch (error) {
65
- log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
66
- }
67
- }
@@ -76,5 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
76
76
  return;
77
77
  }
78
78
  const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
79
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
79
+ yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: "[telegram]", onCompleted, busyRetry });
80
80
  }
@@ -6,6 +6,13 @@ export interface ThreadParticipants {
6
6
  * get wrong twice, and "a second human restores the mention requirement" must have one place to change.
7
7
  */
8
8
  admitsBareMessage(key: string): boolean;
9
+ /**
10
+ * Has the agent answered into this thread before — the "first answered turn" fact
11
+ * (participant-model.md §8), unlike {@link ThreadParticipants.admitsBareMessage} which also weighs
12
+ * the second-human rule. An evicted record answers false (this store is a cache — see the header),
13
+ * so gate a repeatable read on it, never a durable claim.
14
+ */
15
+ agentSpokeIn(key: string): boolean;
9
16
  /**
10
17
  * Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
11
18
  *
@@ -68,6 +68,9 @@ export function createThreadParticipants(path, label) {
68
68
  const heard = records.get(key);
69
69
  return heard?.agentSpoke === true && heard.humans.length <= 1;
70
70
  },
71
+ agentSpokeIn(key) {
72
+ return records.get(key)?.agentSpoke === true;
73
+ },
71
74
  merge(key, heard) {
72
75
  const previous = records.get(key);
73
76
  const humans = new Set(previous?.humans ?? []);
@@ -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));
@@ -18,6 +18,7 @@ import { logAgentLoop } from "../../observe.js";
18
18
  import { installProxyFetch } from "../../proxy.js";
19
19
  import { exists } from "../../paths.js";
20
20
  import { bindAddress } from "../../bind.js";
21
+ import { parseRouteKey } from "../../host/node.js";
21
22
  import { failStartup, placementOrExit } from "../fail.js";
22
23
  import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
23
24
  import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
@@ -48,7 +49,7 @@ export async function runStart(dirArg, opts) {
48
49
  reportLine("workspace", workspace);
49
50
  reportWorkspaceHint(workspaceHint({ agentDir, workspace }));
50
51
  reportLine("model", `${modelSpec}${config.thinkingLevel ? ` (thinking: ${config.thinkingLevel})` : ""}`);
51
- await reportAuth(modelSpec, authPath);
52
+ await reportAuth(agentDir, modelSpec, authPath);
52
53
  reportLine("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
53
54
  if (definition.persona)
54
55
  reportLine("persona", "persona.md");
@@ -89,13 +90,21 @@ export async function runStart(dirArg, opts) {
89
90
  const agentcore = process.env.FASTAGENT_AGENTCORE === "1";
90
91
  // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
91
92
  const traced = logAgentLoop(agent);
92
- const routed = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: !agentcore }).catch(failStartup);
93
+ // On AgentCore the channels are constructed LAZILY (mountAgentcore's lazyChannels, resolved on the
94
+ // first envelope after the state-snapshot restore): construction loads channel state and replays
95
+ // turn intent, and at boot the state mount is PRE-RESTORE — empty after every version update — so
96
+ // an eager build would cache that emptiness (thread participation, delivery dedup, pending turns)
97
+ // and then clobber the restored files with it. Everywhere else the state root is durable at boot,
98
+ // so channels mount eagerly and a broken channel fails startup.
99
+ const routed = agentcore
100
+ ? undefined
101
+ : await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: true }).catch(failStartup);
93
102
  // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
94
103
  // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
95
104
  const configured = config.http?.host;
96
105
  const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
97
106
  assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
98
- const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
107
+ const withControl = mountSessionControl(routed?.routes ?? {}, sessionControl, stateRoot, {
99
108
  tunnel: opts.tunnel ?? false,
100
109
  agent: traced,
101
110
  host,
@@ -126,17 +135,42 @@ export async function runStart(dirArg, opts) {
126
135
  });
127
136
  let routes = withControl.routes;
128
137
  if (agentcore) {
138
+ // The lazy channel surface the adapter resolves post-restore. Control routes ride along so a
139
+ // forwarder-relayed /control/* request dispatches the same as on a direct host. Long-connection
140
+ // channels cannot serve here — scale-to-zero severs a resident connection and nothing
141
+ // re-establishes it — so their presence is a configuration error, surfaced per envelope and by
142
+ // the deploy driver's health probe (there is no boot to fail on this host).
143
+ const lazyChannels = async () => {
144
+ const surface = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: false });
145
+ if (surface.longConnections.length > 0) {
146
+ throw new Error(`long-connection channel(s) ${surface.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
147
+ `AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
148
+ }
149
+ // mountSessionControl's PATH-level collision rule, re-asserted here: with no channels at boot
150
+ // its own check ran against an empty base, and a spread merge would silently let control win —
151
+ // but a channel on /control/* is the same configuration error it is on every other host.
152
+ const controlPaths = new Set(Object.keys(withControl.routes).map((key) => parseRouteKey(key).path));
153
+ const collisions = Object.keys(surface.routes).filter((key) => controlPaths.has(parseRouteKey(key).path));
154
+ if (collisions.length > 0) {
155
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control ` +
156
+ `plane — rename the channel route or disable sessionControl in fastagent.config`);
157
+ }
158
+ return { ...surface.routes, ...withControl.routes };
159
+ };
129
160
  try {
130
- routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady });
161
+ routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady, lazyChannels });
131
162
  }
132
163
  catch (e) {
133
164
  failStartup(e);
134
165
  }
135
166
  log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
136
167
  }
137
- serve({ ...routed, routes }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
168
+ serve({
169
+ ...(routed ?? { longConnections: [], routeChannels: [], builtinInvoke: false, markReady() { } }),
170
+ routes,
171
+ }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
138
172
  withControl.announce(p);
139
- maybeTunnel(agentDir, routed.routeChannels, p, opts.tunnel ?? false, stateRoot);
173
+ maybeTunnel(agentDir, routed?.routeChannels ?? [], p, opts.tunnel ?? false, stateRoot);
140
174
  });
141
175
  // No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
142
176
  // in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
@@ -49,6 +49,10 @@ export declare function mountAgentcore(routes: Routes, options: {
49
49
  stateRoot: string;
50
50
  schedules: LoadedSchedule[];
51
51
  onStateReady?: () => void;
52
+ /** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
53
+ * AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
54
+ * `routes` is the dispatch target — for wirings whose state root is already authoritative. */
55
+ lazyChannels?: () => Promise<Routes>;
52
56
  }): Routes;
53
57
  /**
54
58
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
package/dist/cli/serve.js CHANGED
@@ -151,9 +151,9 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
151
151
  * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
152
152
  */
153
153
  export function mountAgentcore(routes, options) {
154
- const { agent, stateRoot, schedules, onStateReady } = options;
154
+ const { agent, stateRoot, schedules, onStateReady, lazyChannels } = options;
155
155
  const mounted = agentcoreRoutes({
156
- routes,
156
+ routes: lazyChannels ?? routes,
157
157
  agent,
158
158
  stateRoot,
159
159
  isBusy: () => activeWork() > 0,
@@ -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 {