@fastagent-sh/fastagent 0.23.0 → 0.24.1

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 (53) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/kit/preview-kit.d.ts +6 -1
  3. package/dist/channels/kit/preview-kit.js +21 -4
  4. package/dist/channels/slack/preview.js +2 -1
  5. package/dist/cli/commands/info.js +7 -7
  6. package/dist/cli/commands/login.js +2 -2
  7. package/dist/cli/commands/models.js +3 -3
  8. package/dist/cli/invoke-stream.js +14 -0
  9. package/dist/cli/shared.d.ts +2 -1
  10. package/dist/cli/shared.js +6 -7
  11. package/dist/deploy/preflight.js +19 -1
  12. package/dist/dev-supervisor.js +2 -2
  13. package/dist/engines/pi/agent-models.d.ts +14 -1
  14. package/dist/engines/pi/agent-models.js +35 -8
  15. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  16. package/dist/engines/pi/agent-session-factory.js +137 -159
  17. package/dist/engines/pi/auth.js +6 -94
  18. package/dist/engines/pi/create.d.ts +16 -9
  19. package/dist/engines/pi/create.js +71 -38
  20. package/dist/engines/pi/invoke-session.js +82 -16
  21. package/dist/engines/pi/locked-file.d.ts +9 -0
  22. package/dist/engines/pi/locked-file.js +102 -0
  23. package/dist/engines/pi/login.d.ts +7 -1
  24. package/dist/engines/pi/login.js +22 -6
  25. package/dist/engines/pi/machine.d.ts +23 -0
  26. package/dist/engines/pi/machine.js +24 -2
  27. package/dist/engines/pi/models.d.ts +47 -42
  28. package/dist/engines/pi/models.js +154 -67
  29. package/dist/engines/pi/open.d.ts +29 -12
  30. package/dist/engines/pi/open.js +52 -21
  31. package/dist/engines/pi/openai-account-models.d.ts +35 -0
  32. package/dist/engines/pi/openai-account-models.js +116 -0
  33. package/dist/engines/pi/report.d.ts +3 -0
  34. package/dist/engines/pi/report.js +11 -0
  35. package/dist/engines/pi/session-builder.js +6 -22
  36. package/dist/engines/pi/session-control.d.ts +4 -4
  37. package/dist/engines/pi/session-control.js +72 -40
  38. package/dist/engines/pi/session-settings.d.ts +9 -0
  39. package/dist/engines/pi/session-settings.js +17 -0
  40. package/dist/engines/pi/session-store.d.ts +2 -2
  41. package/dist/engines/pi/tool-context.d.ts +3 -5
  42. package/dist/engines/pi/tool-context.js +4 -16
  43. package/dist/engines/pi/tool.d.ts +32 -21
  44. package/dist/engines/pi/tool.js +21 -25
  45. package/dist/engines/pi/turn-kit.d.ts +18 -9
  46. package/dist/engines/pi/turn-kit.js +104 -27
  47. package/dist/pi.d.ts +1 -1
  48. package/dist/pi.js +1 -1
  49. package/dist/session.d.ts +60 -8
  50. package/dist/session.js +5 -1
  51. package/package.json +5 -5
  52. package/dist/engines/pi/search-tools.d.ts +0 -4
  53. package/dist/engines/pi/search-tools.js +0 -109
package/dist/agent.d.ts CHANGED
@@ -47,6 +47,16 @@ export type AgentEvent = {
47
47
  isError: boolean;
48
48
  content: Json;
49
49
  }
50
+ /**
51
+ * Advisory, non-terminal (engines MAY emit it): the running tool `id`'s current status, one line, replacing the last.
52
+ * It may be a line of the tool's output so far (trust it like `tool_ended.content`), never the whole output;
53
+ * untruncated, so a consumer that shows it clips it.
54
+ */
55
+ | {
56
+ type: "tool_progress";
57
+ id: string;
58
+ text: string;
59
+ }
50
60
  /**
51
61
  * Advisory, non-terminal (engines MAY emit it): a transient internal failure scheduled a retry with backoff — the
52
62
  * turn is still alive.
@@ -23,6 +23,8 @@ export declare const THINKING_PLACEHOLDER = "\uD83D\uDCAD Thinking\u2026";
23
23
  interface ToolLine {
24
24
  label: string;
25
25
  status: "running" | "ok" | "error";
26
+ /** The call's latest `tool_progress` text, shown while it runs. */
27
+ progress?: string;
26
28
  }
27
29
  /** The channel-neutral view STATE of one in-flight turn. */
28
30
  export interface TurnView {
@@ -38,7 +40,10 @@ export interface TurnView {
38
40
  }
39
41
  export declare function createTurnView(): TurnView;
40
42
  export declare function applyTurnEvent(view: TurnView, e: AgentEvent, now?: number): boolean;
41
- /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
43
+ /**
44
+ * The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
45
+ * progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
46
+ */
42
47
  export declare function toolLines(view: TurnView): string;
43
48
  /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
44
49
  export declare function thinkingLine(view: TurnView, maxTail: number): string;
@@ -36,6 +36,13 @@ export function applyTurnEvent(view, e, now = Date.now()) {
36
36
  view.toolById.set(e.id, line);
37
37
  return true;
38
38
  }
39
+ case "tool_progress": {
40
+ const line = view.toolById.get(e.id);
41
+ if (line?.status !== "running")
42
+ return closedRetry;
43
+ line.progress = e.text;
44
+ return true;
45
+ }
39
46
  case "tool_ended": {
40
47
  const line = view.toolById.get(e.id);
41
48
  if (line)
@@ -50,9 +57,17 @@ export function applyTurnEvent(view, e, now = Date.now()) {
50
57
  }
51
58
  }
52
59
  const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
53
- /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
60
+ /**
61
+ * The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
62
+ * progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
63
+ */
54
64
  export function toolLines(view) {
55
- return view.tools.map((t) => `🔧 ${t.label} ${TOOL_MARK[t.status]}`).join("\n");
65
+ return view.tools
66
+ .map((t) => {
67
+ const progress = t.status === "running" && t.progress ? ` › ${clip(t.progress, TOOL_PROGRESS_MAX)}` : "";
68
+ return `🔧 ${t.label} ${TOOL_MARK[t.status]}${progress}`;
69
+ })
70
+ .join("\n");
56
71
  }
57
72
  /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
58
73
  export function thinkingLine(view, maxTail) {
@@ -77,15 +92,17 @@ export function composeTurnBody(parts) {
77
92
  }
78
93
  /** Max length (code points) of a tool's arg preview. */
79
94
  const TOOL_ARG_MAX = 48;
95
+ /** Max length (code points) of a running tool's progress line: one status, not its output. */
96
+ const TOOL_PROGRESS_MAX = 64;
80
97
  /** Max length (code points) of a humanized tool label. */
81
98
  const TOOL_NAME_MAX = 80;
82
99
  /**
83
100
  * One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg stays on one line, and
84
101
  * never tear a surrogate pair mid-emoji.
85
102
  */
86
- function clip(s) {
103
+ function clip(s, max = TOOL_ARG_MAX) {
87
104
  const one = s.replace(/\s+/g, " ").trim();
88
- return truncateCodePointPrefix(one, TOOL_ARG_MAX);
105
+ return truncateCodePointPrefix(one, max);
89
106
  }
90
107
  /**
91
108
  * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md` rather than just
@@ -119,7 +119,8 @@ function streamClassicSlackReply(events, api, target, formatError, initialPrevie
119
119
  let lastSent = "";
120
120
  const view = () => composeTurnBody([
121
121
  THINKING_PLACEHOLDER,
122
- toolLines(turn),
122
+ // Tool names, arguments and progress lines are untrusted text, like the answer.
123
+ sanitizeSlackMarkdown(toolLines(turn)),
123
124
  turn.retrying ? RETRY_NOTICE : "",
124
125
  sanitizeSlackMarkdown(revealedAnswer(turn, CLASSIC_UPDATE_INTERVAL_MS, now())),
125
126
  ]);
@@ -8,7 +8,7 @@ import { agentModels } from "../../engines/pi/agent-models.js";
8
8
  import { resolveSessionsDir, resolveStateRoot } from "../../paths.js";
9
9
  import { CODING_TOOL_NAMES, resolveAgentTools } from "../../engines/pi/create.js";
10
10
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
11
- import { reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
11
+ import { describeIndirectTools, reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
12
12
  import { allSecrets, describeSecrets, missingSecrets } from "../../declared-secrets.js";
13
13
  import { log } from "../../log.js";
14
14
  import { reportModuleLoadFailures } from "../../loader.js";
@@ -32,7 +32,7 @@ export async function runInfo(dirArg, opts) {
32
32
  const tools = await resolveAgentTools(config, agentDir, workspace)
33
33
  .then((r) => ({
34
34
  names: r.toolNames,
35
- deferred: r.deferredToolNames,
35
+ indirect: r.indirectTools,
36
36
  collisions: r.toolCollisions,
37
37
  failures: r.toolFailures,
38
38
  secrets: allSecrets(r.toolSecrets),
@@ -40,7 +40,7 @@ export async function runInfo(dirArg, opts) {
40
40
  }))
41
41
  .catch((e) => ({
42
42
  names: [],
43
- deferred: [],
43
+ indirect: [],
44
44
  collisions: [],
45
45
  failures: [],
46
46
  secrets: [],
@@ -75,7 +75,7 @@ export async function runInfo(dirArg, opts) {
75
75
  const sessionsDir = resolveSessionsDir(agentDir);
76
76
  // Both layers: "what is this agent's state" is the question `info` answers, and a credential it runs on can live in
77
77
  // a file the agent dir does not contain.
78
- const models = agentModels(agentDir);
78
+ const models = agentModels(agentDir, {}, { cwd: workspace });
79
79
  const { auth } = models;
80
80
  // RESOLVE the spec, do not just echo it: a spec is only real once its provider/model exist in the agent's own
81
81
  // surface (built-ins + its models.json), which is exactly what a custom endpoint changes.
@@ -107,7 +107,7 @@ export async function runInfo(dirArg, opts) {
107
107
  persona: definition.persona !== undefined,
108
108
  skills: skills.map((skill) => ({ name: skill.name, description: skill.description })),
109
109
  tools: tools.names,
110
- deferredTools: tools.deferred,
110
+ indirectTools: tools.indirect,
111
111
  toolError: tools.error ?? null,
112
112
  channels,
113
113
  routines,
@@ -145,8 +145,8 @@ export async function runInfo(dirArg, opts) {
145
145
  line("persona", definition.persona ? "persona.md" : "(none)");
146
146
  line("skills", skills.map((skill) => skill.name).join(", ") || "(none)");
147
147
  line("tools", tools.error ? "(could not load — see warning below)" : tools.names.join(", ") || "(none)");
148
- if (tools.deferred.length > 0)
149
- line("deferred", `${tools.deferred.join(", ")} (activated via search_tools)`);
148
+ if (tools.indirect.length > 0)
149
+ line("indirect", describeIndirectTools(tools.indirect));
150
150
  line("channels", channels.join(", ") || "(none)");
151
151
  line("routines", routines.map((r) => `${r.name} (${r.cron === null ? "on demand" : `next ${r.next ?? "never"}`})`).join(", ") ||
152
152
  "(none)");
@@ -9,7 +9,7 @@ import { enterAgentEnv } from "../../env.js";
9
9
  import { GLOBAL_AUTH_PATH, resolveAuthPath } from "../../engines/pi/auth.js";
10
10
  import { agentModels } from "../../engines/pi/agent-models.js";
11
11
  import { DEPLOY_HOSTS } from "../../deploy/hosts.js";
12
- import { findAgentDir, globalHome, placementDeadEnd } from "../../paths.js";
12
+ import { findAgentDir, globalHome, placementDeadEnd, resolvePlacement } from "../../paths.js";
13
13
  import { LoginCancelled, loginFlow } from "../../engines/pi/login.js";
14
14
  import { environmentAuthSource } from "../../engines/pi/models.js";
15
15
  import { loginOnBox } from "../box-login.js";
@@ -148,7 +148,7 @@ async function stdioLogin(io, provider, opts) {
148
148
  enterAgentEnv(agentDir);
149
149
  // The model environment the serving runtime reads (createPiAgentFromDir), so "logged in" means what the server will
150
150
  // use.
151
- const models = agentModels(agentDir);
151
+ const models = agentModels(agentDir, {}, { cwd: resolvePlacement(agentDir).workspace });
152
152
  const { auth } = models;
153
153
  if (opts.ifMissing && provider) {
154
154
  // The server's own answer (`authStatus`), the one its startup report prints. Known ceiling: nothing is asked of
@@ -7,8 +7,8 @@ import { join } from "node:path";
7
7
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
8
8
  import { formatModelsCommand } from "../models-view.js";
9
9
  import { listModels } from "../../engines/pi/config.js";
10
- import { createPiModelRuntime, globalCatalogPath, machineModelRuntime, refreshGlobalModelCatalog, } from "../../engines/pi/models.js";
11
- import { refreshModelCatalog } from "../../engines/pi/open.js";
10
+ import { createPiModelRuntime, globalCatalogPath, machineModelRuntime } from "../../engines/pi/models.js";
11
+ import { refreshMachineModelCatalog, refreshModelCatalog } from "../../engines/pi/open.js";
12
12
  import { AGENT_MODEL_CATALOG_FILE, findAgentDir, globalHome, placementDeadEnd } from "../../paths.js";
13
13
  import { enterAgentEnv } from "../../env.js";
14
14
  import { failStartup, placementOrExit } from "../fail.js";
@@ -29,7 +29,7 @@ export async function runModels(search, opts = {}) {
29
29
  console.error(`[fastagent] refreshed ${join(agentDir, AGENT_MODEL_CATALOG_FILE)} — commit it: it ships with a deploy`);
30
30
  }
31
31
  else {
32
- await refreshGlobalModelCatalog().catch(failStartup);
32
+ await refreshMachineModelCatalog().catch(failStartup);
33
33
  console.error(`[fastagent] refreshed ${globalCatalogPath()} — every agent on this machine reads it`);
34
34
  }
35
35
  }
@@ -10,6 +10,9 @@ export async function runInvokeStream(events, out, err) {
10
10
  toolName.set(event.id, event.name);
11
11
  err(`[tool] ${event.name}`);
12
12
  break;
13
+ case "tool_progress":
14
+ err(`[tool] ${toolName.get(event.id) ?? event.id} › ${clipStatus(event.text)}`);
15
+ break;
13
16
  case "tool_ended":
14
17
  if (event.isError)
15
18
  err(`[tool] ${toolName.get(event.id) ?? event.id} failed`);
@@ -28,3 +31,14 @@ export async function runInvokeStream(events, out, err) {
28
31
  }
29
32
  return exitCode;
30
33
  }
34
+ /** How much of a status line a terminal gets, in code points. */
35
+ const STATUS_MAX = 120;
36
+ /**
37
+ * A `tool_progress` line clipped to {@link STATUS_MAX} code points. The event is untruncated by contract, and a shell
38
+ * printing one long line (`curl -s` of a JSON body) re-sends that growing line every 100 ms.
39
+ */
40
+ function clipStatus(text) {
41
+ // Only the first STATUS_MAX + 1 code points decide the result, and they fit in twice as many UTF-16 units.
42
+ const head = [...text.slice(0, (STATUS_MAX + 1) * 2)];
43
+ return head.length > STATUS_MAX ? `${head.slice(0, STATUS_MAX).join("")}…` : head.join("");
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { type LoginIO } from "../engines/pi/login.ts";
2
2
  import { type AgentModels } from "../engines/pi/agent-models.ts";
3
+ import { type IndirectTool } from "../engines/pi/create.ts";
3
4
  import type { LoadedDefinition } from "../engines/pi/definition.ts";
4
5
  import type { ToolCollision } from "../engines/pi/tool.ts";
5
6
  import { type ResolvedPlacement } from "../paths.ts";
@@ -19,7 +20,7 @@ export interface ReportableAssembly {
19
20
  };
20
21
  definition: LoadedDefinition;
21
22
  toolNames: string[];
22
- deferredToolNames: string[];
23
+ indirectTools: IndirectTool[];
23
24
  toolCollisions: ToolCollision[];
24
25
  }
25
26
  /** What `dev` and `start` say about the directory they just opened, in the order they say it. */
@@ -13,7 +13,7 @@ import { providerAuthStatuses } from "../engines/pi/models.js";
13
13
  import { agentModels } from "../engines/pi/agent-models.js";
14
14
  import { formatAuthReport } from "./auth-view.js";
15
15
  import { CODING_TOOL_NAMES } from "../engines/pi/create.js";
16
- import { reportFindingsIfChanged, reportToolCollisions } from "../engines/pi/report.js";
16
+ import { describeIndirectTools, reportFindingsIfChanged, reportToolCollisions } from "../engines/pi/report.js";
17
17
  import { isDeployedWorkspace } from "../paths.js";
18
18
  import { log } from "../log.js";
19
19
  import { enterAgentEnv } from "../env.js";
@@ -24,7 +24,7 @@ import { failStartup, failUsage, placementOrExit } from "./fail.js";
24
24
  export async function enterAgentCommand(dirArg, opts) {
25
25
  const placement = placementOrExit(resolve(dirArg));
26
26
  enterAgentEnv(placement.agentDir);
27
- await resolveFirstRunModel(placement.agentDir, opts);
27
+ await resolveFirstRunModel(placement, opts);
28
28
  return placement;
29
29
  }
30
30
  /** The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). */
@@ -48,9 +48,8 @@ export async function reportAssembly(a, extras = {}) {
48
48
  reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
49
49
  if (a.toolNames.length > 0)
50
50
  reportLine("tools", a.toolNames.join(", "));
51
- if (a.deferredToolNames.length > 0) {
52
- reportLine("deferred", `${a.deferredToolNames.join(", ")} (activated via search_tools)`);
53
- }
51
+ if (a.indirectTools.length > 0)
52
+ reportLine("indirect", describeIndirectTools(a.indirectTools));
54
53
  reportToolCollisions(a.toolCollisions);
55
54
  for (const [label, value] of extras.afterTools ?? [])
56
55
  reportLine(label, value);
@@ -107,7 +106,7 @@ export async function reportAuth(models, modelSpec) {
107
106
  /**
108
107
  * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE funnel, no dead ends.
109
108
  */
110
- async function resolveFirstRunModel(agentDir, options = {}) {
109
+ async function resolveFirstRunModel({ agentDir, workspace }, options = {}) {
111
110
  const { config, path: configPath } = await loadConfig(agentDir).catch(failStartup);
112
111
  if (resolveModelSpec(options.model, config))
113
112
  return; // already set (flag > FASTAGENT_MODEL > config)
@@ -115,7 +114,7 @@ async function resolveFirstRunModel(agentDir, options = {}) {
115
114
  return; // --no-input: never prompt (clig) — the opener raises the clear error
116
115
  if (!isInteractive())
117
116
  return; // CI/deploy: the opener throws the actionable missing-model error
118
- const environment = agentModels(agentDir);
117
+ const environment = agentModels(agentDir, {}, { cwd: workspace });
119
118
  // The picker lists the AGENT's surface: built-ins plus whatever its models.json declares, so a self-hosted endpoint
120
119
  // is pickable on first run instead of being invisible until hand-set.
121
120
  const models = await environment.runtime().catch(failStartup);
@@ -13,6 +13,7 @@ import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, exists } from "../paths.js
13
13
  import { inspectChannels } from "../channels/discover.js";
14
14
  import { loadRoutines } from "../schedule/discover.js";
15
15
  import { resolveAgentTools } from "../engines/pi/create.js";
16
+ import { agentModels } from "../engines/pi/agent-models.js";
16
17
  import { allSecrets } from "../declared-secrets.js";
17
18
  import { createPiModelRuntime, definitionKeyOf, environmentAuthSource, literalKeyProviders, isBuiltinProvider, machineModels, globalCatalogPath, inGlobalCatalog, interactiveLoginKind, loginProviders, } from "../engines/pi/models.js";
18
19
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
@@ -160,7 +161,15 @@ async function gatherFacts(input, report) {
160
161
  if (modelSpec)
161
162
  await checkGlobalCatalogModel(modelSpec, deployed, report);
162
163
  const authPath = resolveAuthPath(agentDir);
163
- const route = modelSpec ? await credentialRoute(agentDir, modelSpec, values) : {};
164
+ // A model only the definition's extensions declare (a virtual model, or a provider one registers) is authenticated
165
+ // by that code on the box, per request: a virtual model's credential is whichever physical model it routes to.
166
+ // Asked of the catalog the box builds, extensions included, so deploy judges the model the box will run.
167
+ const fromExtension = modelSpec ? await extensionDeclared(agentDir, workspace, modelSpec, deployed) : false;
168
+ if (fromExtension && modelSpec) {
169
+ report.note(`${modelSpec} is declared by the definition's extensions, which resolve its credentials on the box — deploy ` +
170
+ `cannot check them. Set the keys the models it uses need in ${valueFile}`);
171
+ }
172
+ const route = modelSpec && !fromExtension ? await credentialRoute(agentDir, modelSpec, values) : {};
164
173
  const modelAuth = route.envVar;
165
174
  const boxLogin = route.boxLogin;
166
175
  if (boxLogin !== undefined) {
@@ -297,6 +306,15 @@ async function credentialRoute(agentDir, spec, values) {
297
306
  return isEnvKey(fromValues) ? { envVar: fromValues } : {};
298
307
  return { boxLogin: provider };
299
308
  }
309
+ /** Whether `spec` exists only once the definition's extensions have registered their models. */
310
+ async function extensionDeclared(agentDir, workspace, spec, deployed) {
311
+ const provider = providerOf(spec);
312
+ const id = spec.slice(provider.length + 1);
313
+ if (deployed.getModel(provider, id))
314
+ return false;
315
+ const catalog = await agentModels(agentDir, { credentialStore: new InMemoryCredentialStore() }, { machineLayer: false, cwd: workspace }).runtime();
316
+ return catalog.getModel(provider, id) !== undefined;
317
+ }
300
318
  /** Does `provider` offer an interactive login, i.e. can `fastagent login --deployment` authenticate it on the box? */
301
319
  function hasLogin(providerId) {
302
320
  const provider = loginProviders().find((p) => p.id === providerId);
@@ -29,8 +29,8 @@ export function devWatchIgnored(root, envFile) {
29
29
  return false;
30
30
  if (rel === "package.json")
31
31
  return false;
32
- // models.json is read ONCE per worker (the model hub is built during assembly), so an edit needs a restart like
33
- // any other code input.
32
+ // models.json is read ONCE per worker (agentModels' snapshot, which every session's runtime is built from), so an
33
+ // edit needs a restart like any other code input.
34
34
  if (rel === AGENT_MODELS_FILE)
35
35
  return false;
36
36
  // So is the model catalog: a `models --refresh` during `dev` restarts the worker onto the new models.
@@ -5,6 +5,7 @@
5
5
  * login, the model list — so none of them can read other credential layers or another registry than the runtime
6
6
  * does. Each used to compose these parts itself, and two of those copies drifted from the rule (#636, #660).
7
7
  */
8
+ import type { ExecutionEnv } from "@earendil-works/pi-agent-core";
8
9
  import type { Credential, CredentialStore, Models, Provider } from "@earendil-works/pi-ai";
9
10
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
10
11
  import { type AuthLayers, type CredentialSourceOptions, type FastagentAuthOptions } from "./auth.ts";
@@ -30,17 +31,29 @@ export interface AgentModels {
30
31
  credentials: CredentialStore;
31
32
  /**
32
33
  * The registry: pi's built-ins, plus (with a directory) the agent's `models.json` and model catalog over the
33
- * machine's, plus `providers`. Built on first use and shared after, so a malformed file fails the first reader.
34
+ * machine's, plus `providers` and extension model declarations. An unbound catalog, built on first use and shared
35
+ * after; session bindings use createRuntime and their own extension instances.
34
36
  */
35
37
  runtime(): Promise<ModelRuntime>;
38
+ /** A session-local registry, before its extensions are loaded. Credentials remain shared. */
39
+ createRuntime(): Promise<ModelRuntime>;
36
40
  /**
37
41
  * What authenticates `provider` here, resolved exactly as a turn resolves it: the ONE answer the startup report and a
38
42
  * deployed box's `login --if-missing` both give. `modelId` picks the model to probe with; the provider's first one
39
43
  * otherwise (auth is provider-scoped).
40
44
  */
41
45
  authStatus(provider: string, modelId?: string): Promise<AuthStatus>;
46
+ /**
47
+ * The definition's extension entry points, discovered once: what this catalog registers models from and what every
48
+ * session of the assembly built over it loads. Empty without a directory.
49
+ */
50
+ extensionPaths(): Promise<readonly string[]>;
42
51
  }
43
52
  export interface AgentModelsOptions {
53
+ /** Workspace for extension model registration; defaults to agentDir for standalone definitions. */
54
+ cwd?: string;
55
+ /** Where `extensions/` is listed from; Node's filesystem by default. */
56
+ env?: ExecutionEnv;
44
57
  /** Extra providers registered on top of the built-ins (a same id replaces one). */
45
58
  providers?: readonly Provider[];
46
59
  /**
@@ -1,4 +1,6 @@
1
1
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import { definitionServices } from "./agent-session-factory.js";
3
+ import { loadExtensionPaths } from "./definition.js";
2
4
  import { GLOBAL_AUTH_PATH, assertOneCredentialSource, fastagentCredentialStore, resolveAuthLayers, } from "./auth.js";
3
5
  import { createPiModelRuntime, environmentAuthSource, modelRuntimeFiles, piModelsOver, probeAuthSource, } from "./models.js";
4
6
  export function agentModels(agentDir, source = {}, options = {}) {
@@ -9,17 +11,40 @@ export function agentModels(agentDir, source = {}, options = {}) {
9
11
  : agentDir
10
12
  ? resolveAuthLayers(agentDir, source.authPath)
11
13
  : { path: source.authPath ?? GLOBAL_AUTH_PATH };
14
+ // The model files, read once for every runtime this agent builds: a session's and the control plane's.
15
+ let modelFiles;
16
+ const readModelFiles = () => (modelFiles ??= modelRuntimeFiles({
17
+ ...(agentDir ? { agentDir } : {}),
18
+ ...(machineLayer !== undefined ? { machineLayer } : {}),
19
+ }));
12
20
  const store = files
13
- ? agentCredentialStore(files, { agentDir, providers, machineLayer, warn: source.warn })
21
+ ? agentCredentialStore(files, readModelFiles, { providers, warn: source.warn })
14
22
  : undefined;
15
23
  const credentials = source.credentialStore ?? store;
24
+ const createRuntime = async () => createPiModelRuntime({
25
+ credentials,
26
+ files: await readModelFiles(),
27
+ ...(agentDir ? { agentDir } : {}),
28
+ ...(providers ? { providers } : {}),
29
+ ...(machineLayer !== undefined ? { machineLayer } : {}),
30
+ });
31
+ const cwd = options.cwd ?? agentDir;
32
+ let discovered;
33
+ const extensionPaths = () => (discovered ??= agentDir
34
+ ? loadExtensionPaths(agentDir, { ...(cwd ? { cwd } : {}), ...(options.env ? { env: options.env } : {}) })
35
+ : Promise.resolve([]));
16
36
  let registry;
17
37
  const runtime = () => {
18
- registry ??= createPiModelRuntime({
19
- credentials,
20
- ...(agentDir ? { agentDir } : {}),
21
- ...(providers ? { providers } : {}),
22
- ...(machineLayer !== undefined ? { machineLayer } : {}),
38
+ registry ??= createRuntime().then(async (models) => {
39
+ if (agentDir) {
40
+ await definitionServices({
41
+ cwd: cwd ?? agentDir,
42
+ modelRuntime: models,
43
+ definition: { skills: [] },
44
+ extensionPaths: await extensionPaths(),
45
+ });
46
+ }
47
+ return models;
23
48
  });
24
49
  return registry;
25
50
  };
@@ -27,6 +52,8 @@ export function agentModels(agentDir, source = {}, options = {}) {
27
52
  ...(files ? { auth: files } : {}),
28
53
  credentials,
29
54
  runtime,
55
+ createRuntime,
56
+ extensionPaths,
30
57
  async authStatus(provider, modelId) {
31
58
  const models = await runtime();
32
59
  const id = modelId ?? models.getProvider(provider)?.getModels()[0]?.id;
@@ -66,7 +93,7 @@ export function createPiModels(options = {}) {
66
93
  * there. A deployment never has the global file, so a global login that outranked, say, an `ANTHROPIC_API_KEY` in
67
94
  * `.secrets/.env` would run one credential here and a different one deployed.
68
95
  */
69
- function agentCredentialStore(auth, options) {
96
+ function agentCredentialStore(auth, modelFiles, options) {
70
97
  const { warn, providers = [] } = options;
71
98
  const fallback = auth.fallback;
72
99
  if (fallback === undefined)
@@ -76,7 +103,7 @@ function agentCredentialStore(auth, options) {
76
103
  project ??= (async () => {
77
104
  const runtime = await ModelRuntime.create({
78
105
  credentials: fastagentCredentialStore(auth.path, { warn }),
79
- ...(await modelRuntimeFiles(options)).create,
106
+ ...(await (await modelFiles()).create()),
80
107
  refreshOnCreate: false,
81
108
  });
82
109
  for (const provider of providers)
@@ -3,7 +3,7 @@ import type { Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import { type AgentSession, type AgentSessionServices, type CompactionResult, type CreateAgentSessionServicesOptions, type ModelRuntime, type ResolvedCommand, SessionManager, createAgentSessionFromServices } from "@earendil-works/pi-coding-agent";
4
4
  import type { PiAgentSessionFactory } from "./invoke-session.ts";
5
5
  import type { PiSessionRecordStore } from "./session-store.ts";
6
- import { type MountedTool } from "./tool.ts";
6
+ import type { MountedTool } from "./tool.ts";
7
7
  import { type AnyModel } from "./models.ts";
8
8
  interface PiSessionDefinition {
9
9
  systemPrompt?: string;
@@ -12,11 +12,12 @@ interface PiSessionDefinition {
12
12
  export interface PiAgentSessionFactoryOptions {
13
13
  /** Where conversations live. */
14
14
  sessions: PiSessionRecordStore;
15
- /** The model to run and the hub that authenticates it, resolved on FIRST USE and kept. */
15
+ /** A fresh model runtime per binding: extension routers and provider registrations belong to this session. */
16
16
  engine: () => Promise<{
17
17
  modelRuntime: ModelRuntime;
18
- model: AnyModel;
19
18
  }>;
19
+ /** Resolved after extensions register their models. */
20
+ modelSpec: string;
20
21
  thinkingLevel?: ThinkingLevel;
21
22
  tools?: MountedTool[];
22
23
  /** Read once per binding; prompt and skills come from the same definition read. */
@@ -42,19 +43,11 @@ export interface BindPiSessionOptions {
42
43
  excludedToolNames?: readonly string[];
43
44
  /** The CALLER's session id — what a tool asking which conversation it is in hears. */
44
45
  sessionId?: string;
45
- /** Record each discovered activation on the session, so the next bind restores it. */
46
- recordActivations: boolean;
47
46
  }
48
47
  /**
49
- * Bind ONE pi session to a record: the definition's tools as pi definitions over one turn context, pi's own tool
50
- * copies kept off, and deferral applied.
48
+ * Bind ONE pi session to a record: authored tools replace same-name built-ins; Pi owns exposure and discovery.
51
49
  */
52
50
  export declare function bindPiSession(options: BindPiSessionOptions): ReturnType<typeof createAgentSessionFromServices>;
53
- /**
54
- * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and carries on with the
55
- * rest.
56
- */
57
- export declare function reportExtensionErrors(services: AgentSessionServices): void;
58
51
  /** What pi is allowed to discover, minus the parts each assembly fills in itself. */
59
52
  type DefinitionLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
60
53
  /** The resource posture a fastagent definition asks pi for — ONE definition of it, for both assemblies. */
@@ -81,9 +74,16 @@ export declare function startCompaction(session: AgentSession, instructions: str
81
74
  admission: Promise<CompactAdmission>;
82
75
  done: Promise<CompactionResult>;
83
76
  };
77
+ /** A fresh loader and Pi's native model registration, over a session-local runtime. */
78
+ export declare function definitionServices(options: {
79
+ cwd: string;
80
+ modelRuntime: ModelRuntime;
81
+ definition: PiSessionDefinition;
82
+ extensionPaths: readonly string[];
83
+ }): Promise<AgentSessionServices>;
84
84
  /**
85
85
  * The `/name` commands a served session dispatches, named the way pi resolves them (a name two extensions share gets
86
- * a `:N` suffix). Loaded through the same {@link servingServices} a turn binds, so the menu and the dispatch cannot
86
+ * a `:N` suffix). Loaded through the same {@link definitionServices} a turn binds, so the menu and the dispatch cannot
87
87
  * disagree. Loading runs the extensions' factories; no session opens, so `session_start` does not fire, and the
88
88
  * instances are never bound (any action they call throws).
89
89
  */