@fastagent-sh/fastagent 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -15,14 +15,15 @@ import ignore from "ignore";
15
15
  import { classifyBind } from "../bind.js";
16
16
  import { resolveAuthPath } from "../engines/pi/config.js";
17
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
18
- import { inspectChannels } from "../engines/pi/channel.js";
18
+ import { inspectChannels } from "../channels/discover.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,15 +13,21 @@
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";
17
- import { isUnderDir } from "./engines/pi/definition.js";
16
+ import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
+ import { isUnderDir } from "./paths.js";
18
18
  import { dotEnvPath } from "./env.js";
19
19
  import { log } from "./log.js";
20
20
  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
+ /**
25
+ * The agent-dir directories loaded ONCE per worker: a restart is their only re-read. One list, so
26
+ * the watcher and the line printed at startup cannot disagree — a directory watched but unannounced
27
+ * looks broken, and one announced but unwatched silently strands the author mid-edit.
28
+ */
29
+ const CODE_INPUT_DIRS = ["tools", "channels", "schedules", "extensions"];
30
+ const WATCHED_HINT = `${CODE_INPUT_DIRS.map((dir) => `${dir}/`).join(", ")}, package.json, fastagent.config.*, models.json, .secrets/.env`;
25
31
  /**
26
32
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
33
  * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
@@ -49,8 +55,14 @@ export function devWatchIgnored(root, envFile) {
49
55
  return false;
50
56
  if (rel === "package.json")
51
57
  return false;
58
+ // models.json is read ONCE per worker (the model hub is built during assembly), so an edit needs a
59
+ // restart like any other code input. It is also the one input whose breakage KILLS the worker — a
60
+ // malformed file fails assembly — so leaving it unwatched would strand the author: the fix that
61
+ // repairs it would not be the edit that restarts.
62
+ if (rel === AGENT_MODELS_FILE)
63
+ return false;
52
64
  const segments = rel.split(sep);
53
- if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
65
+ if (CODE_INPUT_DIRS.includes(segments[0]))
54
66
  return false;
55
67
  // The `.env` restarts too (credentials are process-bound). Keep it AND its ancestor directories
56
68
  // un-pruned so chokidar can descend to it; every sibling inside them (auth.json, .env.example)
@@ -0,0 +1,104 @@
1
+ import type { ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import { type AgentSessionServices, type CreateAgentSessionServicesOptions, type ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ import type { PiAgentSessionFactory } from "./invoke-session.ts";
5
+ import type { PiSessionRecordStore } from "./session-store.ts";
6
+ import { type MountedTool } from "./tool.ts";
7
+ /** pi's Model with the API-shape generic erased; fastagent only passes models through. */
8
+ type AnyModel = Model<any>;
9
+ export interface PiAgentSessionFactoryOptions {
10
+ /** Where conversations live. Continuity = same store + same session id. */
11
+ sessions: PiSessionRecordStore;
12
+ /**
13
+ * The model to run and the hub that authenticates it, resolved on FIRST USE and kept.
14
+ *
15
+ * A thunk because building a `ModelRuntime` is async while assembling an agent is not: the L1
16
+ * surface hands back an `Agent` synchronously, so the credential read that a runtime performs
17
+ * belongs on the first turn rather than in the caller's constructor. The two travel together
18
+ * because a model must be resolved against the runtime that holds its provider's auth.
19
+ */
20
+ engine: () => Promise<{
21
+ modelRuntime: ModelRuntime;
22
+ model: AnyModel;
23
+ }>;
24
+ thinkingLevel?: ThinkingLevel;
25
+ tools?: MountedTool[];
26
+ /** Final assembled prompt, or a factory re-evaluated per turn. */
27
+ systemPrompt?: string | (() => string);
28
+ skills?: Skill[];
29
+ /** Per-turn source for the prompt+skills PAIR; supersedes the two above. This is what keeps
30
+ * "the directory is the agent, LIVE" true on a shared `services`: the ResourceLoader is built
31
+ * once, but what it serves is re-read here. */
32
+ live?: () => Promise<{
33
+ systemPrompt?: string;
34
+ skills?: Skill[];
35
+ }>;
36
+ /** The agent's working directory — what fastagent-defined tools see as `cwd`. */
37
+ cwd: string;
38
+ /**
39
+ * Where pi looks for ITS settings (retry budget, compaction thresholds, default thinking level).
40
+ *
41
+ * Deliberately NOT pi's machine-global `~/.pi/agent`: a served agent must behave the same on the
42
+ * author's laptop and in a container, and reading the operator's personal pi configuration is the
43
+ * artifact losing to the machine. Point it at a definition-scoped path; a missing directory simply
44
+ * means pi's own defaults, which is the intended baseline.
45
+ */
46
+ agentDir?: string;
47
+ /**
48
+ * The definition's own extension entry points, for ANNOUNCING that serving does not run them.
49
+ *
50
+ * pi's extension machinery is built for one process serving one session: `bindCore()` copies the
51
+ * session's actions into a runtime the pi source itself calls "the shared runtime", extension
52
+ * modules are cached per assembly, and `session_start`/`session_shutdown` are a matched pair.
53
+ * Serving breaks every one of those assumptions — concurrent turns for unrelated conversations —
54
+ * and the failure is silent cross-talk: with two turns in flight, an extension calling
55
+ * `pi.sendMessage()` can deliver into the other conversation.
56
+ *
57
+ * Loading them anyway would be a correctness bug dressed as a feature, so serving does not, and
58
+ * warns when a definition ships some. `chat` runs them fully: one session, one runtime, which is
59
+ * exactly the shape pi is built for.
60
+ *
61
+ * Isolating them per session is mechanically possible — pi's uncached loader path builds a fresh
62
+ * module (jiti with `moduleCache: false`) and takes the runtime as an argument — but that function
63
+ * is not exported and the deep path is blocked by the package's `exports`. Reopening this needs
64
+ * that entry point upstream, not a workaround here.
65
+ */
66
+ extensionPaths?: string[];
67
+ /** Built-ins omitted by an explicit lower-level tool list. */
68
+ excludedToolNames?: readonly string[];
69
+ /** Filesystem/process environment: definition loading, and the turn context for tools that read one. */
70
+ env: ExecutionEnv;
71
+ }
72
+ /**
73
+ * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
74
+ * carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
75
+ * A definition running without the extension it ships is exactly the "quietly missing" failure this
76
+ * exists to remove. CHAT calls it, once per built services — serving does not load extensions at
77
+ * all, and announces that instead (see PiAgentSessionFactoryOptions.extensionPaths).
78
+ */
79
+ export declare function reportExtensionErrors(services: AgentSessionServices): void;
80
+ /** What pi is allowed to discover, minus the parts each assembly fills in itself. */
81
+ type DefinitionLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
82
+ /**
83
+ * The resource posture a fastagent definition asks pi for — ONE definition of it, for both
84
+ * assemblies. Serving (`piAgentSessionFactory`) and chat (`buildAgentSessionRuntime`) build
85
+ * different sessions on top, but what pi is allowed to DISCOVER is not one of the differences:
86
+ * everything comes from the definition, nothing from the machine that happens to be running it.
87
+ *
88
+ * Two copies of this drifted once already: `additionalExtensionPaths` was added to both, and only
89
+ * one of them also passed the resulting tool names through pi's `tools` allowlist — so extensions
90
+ * worked when served and vanished in chat. A difference between the two has to be visible AS a
91
+ * difference, which is what the parameters are for: serving reads a prompt and skills that change
92
+ * per turn and passes NO extension paths (it does not run them — see
93
+ * {@link PiAgentSessionFactoryOptions.extensionPaths}); chat reads a fixed assembly and passes its
94
+ * own. Both are arguments now, rather than two files that happen to disagree.
95
+ */
96
+ export declare function definitionResourceLoaderOptions(source: {
97
+ systemPrompt: () => string | undefined;
98
+ skills: () => Skill[];
99
+ /** Omitted by serving, which does not run them. */
100
+ extensionPaths?: readonly string[];
101
+ }): DefinitionLoaderOptions;
102
+ /** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
103
+ export declare function piAgentSessionFactory(options: PiAgentSessionFactoryOptions): PiAgentSessionFactory;
104
+ export {};
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The AgentSession L0's engine binding: fastagent's assembled agent — model, prompt, skills, tools —
3
+ * bound to one durable record, per invoke.
4
+ *
5
+ * The chat path builds the same pi class from the same assembly (session-builder.ts) but keeps ONE
6
+ * resident session. What is specific here is the posture: many sessions, one turn each, nothing in
7
+ * memory between turns.
8
+ *
9
+ * Shared once, rebuilt per turn:
10
+ * - `services` (ResourceLoader, settings, model runtime) is built lazily and reused — it is the
11
+ * expensive half, and it holds nothing session-specific;
12
+ * - the `AgentSession` and its tool bindings are per turn, because a tool's `execute` closes over the
13
+ * session it runs in and this posture has several in flight at once.
14
+ */
15
+ import { dirname, join } from "node:path";
16
+ import { createAgentSessionFromServices, createAgentSessionServices, } from "@earendil-works/pi-coding-agent";
17
+ import { log } from "../../log.js";
18
+ import { isDeferredTool } from "./tool.js";
19
+ import { activePath, resolveSessionSettings } from "./session-settings.js";
20
+ import { DEFAULT_THINKING_LEVEL } from "./models.js";
21
+ import { additiveActivation, agentSessionManager, turnContext } from "./tool-context.js";
22
+ /**
23
+ * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
24
+ * tools a loader activated in that call.
25
+ *
26
+ * A DEDICATED record, not pi's own `active_tools_change`: that one is a full SNAPSHOT of everything
27
+ * active at the moment, so replaying it would keep a tool active in old sessions after the author
28
+ * flips it to `deferred` — the session never discovered it. A delta carries only what was actually
29
+ * found, and is layered onto whatever the workspace mounts TODAY.
30
+ */
31
+ const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
32
+ /** Every deferred tool this session has ever discovered, oldest first. */
33
+ function recordedActivations(session) {
34
+ const names = [];
35
+ for (const entry of session.sessionManager.getBranch()) {
36
+ const record = entry;
37
+ if (record.type !== "custom" || record.customType !== TOOL_ACTIVATION_ENTRY)
38
+ continue;
39
+ if (Array.isArray(record.data?.names)) {
40
+ for (const name of record.data.names)
41
+ if (typeof name === "string")
42
+ names.push(name);
43
+ }
44
+ }
45
+ return names;
46
+ }
47
+ /** Warned once per session+missing set: a fresh session is built per invoke and channel sessions run
48
+ * for weeks, so an un-deduped warn would repeat every turn and dilute its own signal. */
49
+ const warnedDroppedActivations = new Set();
50
+ /**
51
+ * The turn's {@link ToolActivation} over a live session — the same bridge chat uses, so one
52
+ * built-in `search_tools` serves both.
53
+ *
54
+ * Activations are PERSISTED as deltas, so a tool discovered in one turn stays callable in the next.
55
+ * pi's own chat session does not do this (it has no place to put the record); a served session does,
56
+ * because the alternative is an agent that re-discovers the same capability every single turn.
57
+ */
58
+ function sessionToolActivation(session) {
59
+ // Serialize activations: the read-modify-write below is only race-free while nothing awaits
60
+ // between read and write, and parallel tool calls in one batch would otherwise double-stamp.
61
+ let chain = Promise.resolve([]);
62
+ return {
63
+ active: () => session.getActiveToolNames(),
64
+ registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
65
+ activate(names) {
66
+ const run = async () => {
67
+ const current = session.getActiveToolNames();
68
+ const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
69
+ if (added.length > 0) {
70
+ session.setActiveToolsByName([...current, ...added]);
71
+ session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
72
+ }
73
+ return added;
74
+ };
75
+ const result = chain.then(run, run);
76
+ chain = result.catch(() => []);
77
+ return result;
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * fastagent's tools as pi tool definitions, bound to ONE session.
83
+ *
84
+ * `bound` is filled after the session exists — pi needs the definitions to build the session, and a
85
+ * tool needs the session to reach the turn context. A tool that somehow runs before that binding
86
+ * throws rather than executing outside the turn: a broken lifecycle must not look like a normal
87
+ * out-of-turn call.
88
+ */
89
+ function toolDefinitions(tools, cwd, env, sessionId, bound) {
90
+ return tools.map((tool) => ({
91
+ name: tool.name,
92
+ label: tool.name,
93
+ description: tool.description ?? "",
94
+ parameters: tool.parameters,
95
+ // An activating tool (the built-in loader) declares "sequential" so pi serializes its batch;
96
+ // without it pi's outer active-set diff double-stamps parallel calls.
97
+ executionMode: tool.executionMode,
98
+ execute: (id, params, signal) => {
99
+ const session = bound.session;
100
+ if (!session)
101
+ throw new Error("tool executed before its session was bound (lifecycle invariant broken)");
102
+ return turnContext.run({ cwd, sessionManager: agentSessionManager(session, sessionId), tools: sessionToolActivation(session) },
103
+ // Lower-level MountedTools may consume the fifth-argument env. Directory coding tools are
104
+ // cwd-bound and ignore it; authored tools read FastAgent's turnContext instead.
105
+ () => tool.execute(id, params, signal, undefined, { env }));
106
+ },
107
+ }));
108
+ }
109
+ /**
110
+ * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
111
+ * carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
112
+ * A definition running without the extension it ships is exactly the "quietly missing" failure this
113
+ * exists to remove. CHAT calls it, once per built services — serving does not load extensions at
114
+ * all, and announces that instead (see PiAgentSessionFactoryOptions.extensionPaths).
115
+ */
116
+ export function reportExtensionErrors(services) {
117
+ for (const { path, error } of services.resourceLoader.getExtensions().errors) {
118
+ log.warn(`[fastagent] extension ${path} failed to load: ${error}`);
119
+ }
120
+ }
121
+ /**
122
+ * The resource posture a fastagent definition asks pi for — ONE definition of it, for both
123
+ * assemblies. Serving (`piAgentSessionFactory`) and chat (`buildAgentSessionRuntime`) build
124
+ * different sessions on top, but what pi is allowed to DISCOVER is not one of the differences:
125
+ * everything comes from the definition, nothing from the machine that happens to be running it.
126
+ *
127
+ * Two copies of this drifted once already: `additionalExtensionPaths` was added to both, and only
128
+ * one of them also passed the resulting tool names through pi's `tools` allowlist — so extensions
129
+ * worked when served and vanished in chat. A difference between the two has to be visible AS a
130
+ * difference, which is what the parameters are for: serving reads a prompt and skills that change
131
+ * per turn and passes NO extension paths (it does not run them — see
132
+ * {@link PiAgentSessionFactoryOptions.extensionPaths}); chat reads a fixed assembly and passes its
133
+ * own. Both are arguments now, rather than two files that happen to disagree.
134
+ */
135
+ export function definitionResourceLoaderOptions(source) {
136
+ return {
137
+ // Definition-only, like dev/start: pi's machine-global discovery (the operator's own ~/.pi
138
+ // extensions, slash commands, global AGENTS.md, APPEND_SYSTEM.md) stays out, so the agent that
139
+ // runs is the artifact, not the artifact plus whoever's laptop it is.
140
+ noExtensions: true,
141
+ // ...except the definition's OWN extensions/: pi honours additionalExtensionPaths even under
142
+ // noExtensions, which is exactly the split wanted here — the artifact travels with its
143
+ // extensions, the machine's stay out.
144
+ ...(source.extensionPaths?.length ? { additionalExtensionPaths: [...source.extensionPaths] } : {}),
145
+ noPromptTemplates: true,
146
+ noContextFiles: true,
147
+ // A SPACE, not "", when the assembly has no prompt: pi treats an empty custom prompt as absent
148
+ // and substitutes its own coding-assistant identity, which an L1 agent
149
+ // (`createPiAgent({ model, tools })`) never asked for. pi appends its own working-directory line
150
+ // either way — that is engine behaviour this binding does not fight.
151
+ systemPromptOverride: () => source.systemPrompt() ?? " ",
152
+ appendSystemPromptOverride: () => [],
153
+ skillsOverride: (base) => ({
154
+ skills: toPiSkills(source.skills()),
155
+ diagnostics: base.diagnostics,
156
+ }),
157
+ };
158
+ }
159
+ /** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
160
+ export function piAgentSessionFactory(options) {
161
+ const { sessions, thinkingLevel, cwd, env } = options;
162
+ const extensionPaths = options.extensionPaths ?? [];
163
+ const excludedToolNames = options.excludedToolNames ?? [];
164
+ if (extensionPaths.length > 0) {
165
+ log.warn(`[fastagent] ${extensionPaths.length} extension(s) in the definition are NOT loaded when serving ` +
166
+ "(they run in `fastagent chat`): pi's extension runtime is shared across sessions, and serving " +
167
+ "runs concurrent turns for different conversations. See docs/configuration.md#extensions.");
168
+ }
169
+ const tools = options.tools ?? [];
170
+ const deferred = tools.filter(isDeferredTool).map((t) => t.name);
171
+ // What the shared ResourceLoader serves, refreshed per turn before the session is built.
172
+ let prompt = typeof options.systemPrompt === "function" ? options.systemPrompt() : options.systemPrompt;
173
+ let skills = options.skills ?? [];
174
+ let services;
175
+ let engine;
176
+ const buildServices = async (modelRuntime) => createAgentSessionServices({
177
+ cwd,
178
+ agentDir: options.agentDir ?? join(cwd, ".fastagent", "pi"),
179
+ modelRuntime,
180
+ // No extensionPaths: serving does not run them (see PiAgentSessionFactoryOptions), which is
181
+ // the one resource question the two assemblies answer differently. The accessors read the
182
+ // CURRENT prompt/skills — serving refreshes both per turn, so a snapshot taken here would
183
+ // serve a stale definition after the first edit.
184
+ resourceLoaderOptions: definitionResourceLoaderOptions({
185
+ systemPrompt: () => prompt,
186
+ skills: () => skills,
187
+ }),
188
+ });
189
+ return async (sessionId, inherit) => {
190
+ const fresh = options.live ? await options.live() : undefined;
191
+ const nextPrompt = fresh
192
+ ? fresh.systemPrompt
193
+ : typeof options.systemPrompt === "function"
194
+ ? options.systemPrompt()
195
+ : prompt;
196
+ const nextSkills = fresh ? (fresh.skills ?? []) : skills;
197
+ engine ??= options.engine();
198
+ const { modelRuntime, model } = await engine;
199
+ if (services === undefined) {
200
+ prompt = nextPrompt;
201
+ skills = nextSkills;
202
+ services = buildServices(modelRuntime); // assigned before any await: concurrent turns share it
203
+ }
204
+ else {
205
+ // The ResourceLoader reads the overrides once and caches, so a re-read of the definition only
206
+ // reaches the model after a reload. Reload only when the definition ACTUALLY changed — an
207
+ // author edits persona.md far less often than the agent takes a turn, and a reload costs ~5ms
208
+ // against ~0.6ms to bind a session.
209
+ //
210
+ // "Changed" is measured against the LOADER, not against what this factory last wrote. Serving
211
+ // is concurrent across sessions, and a shared variable makes the check lie: one turn writes
212
+ // its new prompt, awaits before reloading, and the next turn sees that value already present,
213
+ // concludes nothing changed, and skips the reload — so the edit reaches neither the loader nor
214
+ // any error. Asking the loader what it is actually serving cannot go stale that way. The cost
215
+ // of losing the race is one redundant reload, not a swallowed edit.
216
+ //
217
+ // Skill CONTENT is not part of this — pi reads a skill from its file at invocation time, so
218
+ // only the declared set matters.
219
+ //
220
+ // What this deliberately does NOT provide is a per-turn snapshot. The definition is an AGENT
221
+ // property, not a session one: two turns running either side of an edit each get a definition
222
+ // that genuinely existed, and the product promise — an edit is live on the next turn — holds
223
+ // for both. Pinning a snapshot per turn would cost either a loader per turn or a queue in
224
+ // front of every bind, to buy a guarantee nothing asks for.
225
+ const loader = (await services).resourceLoader;
226
+ const definitionChanged = loader.getSystemPrompt() !== nextPrompt || loadedSkillSet(loader.getSkills().skills) !== skillSet(nextSkills);
227
+ if (definitionChanged) {
228
+ prompt = nextPrompt;
229
+ skills = nextSkills;
230
+ await loader.reload();
231
+ }
232
+ }
233
+ const sessionManager = await sessions.openOrCreate(sessionId, inherit);
234
+ // What the session RUNS on: the boundary plane records model/thinking overrides as entries, and
235
+ // pi does not read them back — a binding that ignored them would silently run every turn on the
236
+ // assembly default, and `state()` would report a setting no turn uses.
237
+ // The SAME read the control plane performs, including its integrity check: a record whose chain
238
+ // is broken must not run on assembly defaults while `state()` rejects it — one of the two planes
239
+ // would be lying. A throw here becomes this turn's `failed` event, which is where the fault has
240
+ // a channel to be reported through.
241
+ const settings = resolveSessionSettings(activePath(sessionManager), modelRuntime, {
242
+ model,
243
+ thinkingLevel: thinkingLevel ?? DEFAULT_THINKING_LEVEL,
244
+ });
245
+ const bound = {};
246
+ const { session } = await createAgentSessionFromServices({
247
+ services: await services,
248
+ sessionManager,
249
+ model: settings.model,
250
+ thinkingLevel: settings.thinkingLevel,
251
+ // pi would otherwise mount its built-ins on top of fastagent's copies, offering duplicate names.
252
+ // Lower-level callers with an explicit list also rely on omitted built-ins staying omitted.
253
+ noTools: "builtin",
254
+ ...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
255
+ customTools: toolDefinitions(tools, cwd, env, sessionId, bound),
256
+ });
257
+ bound.session = session;
258
+ // An extension handler that throws is otherwise dropped: pi fans errors out to registered
259
+ // listeners and has none by default, which on a server means a broken extension looks like an
260
+ // extension that simply did nothing.
261
+ //
262
+ // Deferral, then restoration: pi starts every mounted tool active, so narrow by SUBTRACTING the
263
+ // deferred names (robust to pi mounting tools of its own, unlike an exact-set replacement), then
264
+ // add back what THIS session has already discovered.
265
+ if (deferred.length > 0) {
266
+ const active = session.getActiveToolNames();
267
+ const mounted = new Set(session.getAllTools().map((tool) => tool.name));
268
+ const recorded = recordedActivations(session);
269
+ // A recorded name that is no longer mounted is dropped rather than replayed: pi's setter
270
+ // THROWS on an unknown name, so replaying one would brick every future turn of this session.
271
+ const restored = recorded.filter((name) => mounted.has(name));
272
+ const dropped = recorded.filter((name) => !mounted.has(name));
273
+ if (dropped.length > 0) {
274
+ const key = `${sessionId}\u0000${[...new Set(dropped)].sort().join(",")}`;
275
+ const emit = warnedDroppedActivations.has(key) ? log.debug : log.warn;
276
+ warnedDroppedActivations.add(key);
277
+ emit(`[fastagent] session ${sessionId}: dropping recorded activation(s) no longer mounted: ${[...new Set(dropped)].join(", ")}`);
278
+ }
279
+ const next = [...new Set([...active.filter((name) => !deferred.includes(name)), ...restored])];
280
+ if (next.length !== active.length || next.some((name) => !active.includes(name))) {
281
+ session.setActiveToolsByName(next);
282
+ }
283
+ }
284
+ return session;
285
+ };
286
+ }
287
+ /** What a reload has to notice: the declared set, not the files behind it. */
288
+ function skillSet(skills) {
289
+ return skills.map((s) => `${s.name}\u0000${s.filePath}\u0000${s.description}`).join("\u0001");
290
+ }
291
+ /** The same signature, read back off the loader. */
292
+ function loadedSkillSet(skills) {
293
+ return skills.map((s) => `${s.name}\u0000${s.filePath ?? ""}\u0000${s.description}`).join("\u0001");
294
+ }
295
+ /** fastagent's Skill (content inline) as pi's (read from filePath at invocation time). */
296
+ function toPiSkills(skills) {
297
+ return skills.map((skill) => {
298
+ const baseDir = dirname(skill.filePath);
299
+ return {
300
+ name: skill.name,
301
+ description: skill.description,
302
+ filePath: skill.filePath,
303
+ baseDir,
304
+ sourceInfo: {
305
+ path: skill.filePath,
306
+ source: "fastagent",
307
+ scope: "project",
308
+ origin: "top-level",
309
+ baseDir,
310
+ },
311
+ disableModelInvocation: skill.disableModelInvocation ?? false,
312
+ };
313
+ });
314
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { FastagentTool } from "./tool.ts";
3
3
  import type { Models } from "@earendil-works/pi-ai";
4
- import type { AnyModel } from "./harness.ts";
4
+ import type { AnyModel } from "./models.ts";
5
5
  export interface FastagentConfig {
6
6
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
7
7
  model?: string;
@@ -9,8 +9,8 @@ export interface FastagentConfig {
9
9
  * "xhigh" | "max"). Unset = pi's default. Authors tune thinking in the pi TUI while vibing — this
10
10
  * is the serving-side counterpart (fidelity). Levels a model doesn't support are clamped by pi. */
11
11
  thinkingLevel?: ThinkingLevel;
12
- /** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
13
- * plus the optional `deferred` marker (see defineTool). */
12
+ /** Extra custom tools, appended after the pi coding tools — never replaces them. `FastagentTool`
13
+ * = AgentTool plus the optional `deferred` marker (see defineTool). */
14
14
  tools?: FastagentTool[];
15
15
  /** `host` is the bind address: unset (or `0.0.0.0`) binds all interfaces — what containers need;
16
16
  * `127.0.0.1` keeps the serve (including `/control/*`) off the LAN. Precedence: `--bind` > this. */
@@ -60,7 +60,7 @@ export declare function loadConfig(dir: string): Promise<LoadedConfig>;
60
60
  * lookups then miss visibly (an unknown-provider error / a login-required hint), never a mangled id
61
61
  * (`slice(0, indexOf("/"))` silently drops the last char when "/" is absent). */
62
62
  export declare function providerOf(spec: string): string;
63
- /** Resolve "provider/modelId" → a pi Model from `models`, so the harness resolves auth from the same collection. */
63
+ /** Resolve "provider/modelId" → a pi Model from `models`, so auth resolves from the same collection. */
64
64
  export declare function resolveModel(models: Models, spec: string): AnyModel;
65
65
  /** All registered "provider/modelId" specs in `models`, sorted — the list behind `fastagent models`. */
66
66
  export declare function listModels(models: Models): string[];
@@ -151,7 +151,7 @@ export function providerOf(spec) {
151
151
  const slash = spec.indexOf("/");
152
152
  return slash > 0 ? spec.slice(0, slash) : spec;
153
153
  }
154
- /** Resolve "provider/modelId" → a pi Model from `models`, so the harness resolves auth from the same collection. */
154
+ /** Resolve "provider/modelId" → a pi Model from `models`, so auth resolves from the same collection. */
155
155
  export function resolveModel(models, spec) {
156
156
  const slash = spec.indexOf("/");
157
157
  if (slash < 1 || slash === spec.length - 1) {