@fastagent-sh/fastagent 0.22.1 → 0.24.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.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/agentcore-protocol.d.ts +2 -1
- package/dist/channels/agentcore-service.js +10 -18
- package/dist/channels/agentcore.js +6 -3
- package/dist/channels/control.js +23 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/config-api.d.ts +2 -2
- package/dist/channels/slack/config-api.js +2 -2
- package/dist/channels/slack/onboard.js +2 -2
- package/dist/channels/slack/preview.js +2 -1
- package/dist/channels/slack/register-webhook.js +2 -2
- package/dist/channels/slack/slack-api.js +4 -4
- package/dist/cli/add-feishu.js +4 -2
- package/dist/cli/auth-view.d.ts +15 -6
- package/dist/cli/auth-view.js +24 -8
- package/dist/cli/box-login.d.ts +33 -0
- package/dist/cli/box-login.js +162 -0
- package/dist/cli/commands/deploy/agentcore.js +48 -11
- package/dist/cli/commands/deploy/docker.js +24 -13
- package/dist/cli/commands/deploy/fly.js +30 -7
- package/dist/cli/commands/deploy/railway.js +21 -7
- package/dist/cli/commands/deploy/shared.d.ts +18 -17
- package/dist/cli/commands/deploy/shared.js +20 -21
- package/dist/cli/commands/destroy.js +2 -2
- package/dist/cli/commands/info.js +15 -12
- package/dist/cli/commands/invoke.js +2 -2
- package/dist/cli/commands/login.d.ts +7 -1
- package/dist/cli/commands/login.js +123 -10
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/models.d.ts +4 -1
- package/dist/cli/commands/models.js +37 -4
- package/dist/cli/commands/routine-run.js +2 -2
- package/dist/cli/commands/start.d.ts +0 -5
- package/dist/cli/commands/start.js +3 -13
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/kernel.d.ts +2 -0
- package/dist/cli/kernel.js +1 -1
- package/dist/cli/login-relay.d.ts +38 -0
- package/dist/cli/login-relay.js +121 -0
- package/dist/cli/program.js +46 -8
- package/dist/cli/shared.d.ts +6 -5
- package/dist/cli/shared.js +29 -36
- package/dist/deploy/agentcore/plan.d.ts +10 -11
- package/dist/deploy/agentcore/plan.js +8 -14
- package/dist/deploy/agentcore/run.d.ts +8 -1
- package/dist/deploy/agentcore/run.js +20 -14
- package/dist/deploy/agentcore/shell.d.ts +25 -0
- package/dist/deploy/agentcore/shell.js +202 -0
- package/dist/deploy/box-shell.d.ts +43 -0
- package/dist/deploy/box-shell.js +28 -0
- package/dist/deploy/build-context.js +16 -7
- package/dist/deploy/channel-ingress.d.ts +20 -1
- package/dist/deploy/channel-ingress.js +34 -7
- package/dist/deploy/container.d.ts +14 -0
- package/dist/deploy/container.js +45 -1
- package/dist/deploy/docker/plan.d.ts +7 -3
- package/dist/deploy/docker/plan.js +9 -14
- package/dist/deploy/docker/run.d.ts +14 -7
- package/dist/deploy/docker/run.js +58 -35
- package/dist/deploy/fly/plan.d.ts +6 -3
- package/dist/deploy/fly/plan.js +6 -8
- package/dist/deploy/fly/run.d.ts +8 -2
- package/dist/deploy/fly/run.js +15 -25
- package/dist/deploy/preflight.d.ts +9 -7
- package/dist/deploy/preflight.js +92 -20
- package/dist/deploy/railway/plan.d.ts +6 -3
- package/dist/deploy/railway/plan.js +7 -9
- package/dist/deploy/railway/run.d.ts +10 -6
- package/dist/deploy/railway/run.js +29 -13
- package/dist/deploy/runner.d.ts +16 -0
- package/dist/deploy/runner.js +31 -0
- package/dist/deploy/secrets.d.ts +4 -13
- package/dist/deploy/secrets.js +8 -27
- package/dist/dev-supervisor.js +7 -4
- package/dist/engines/pi/agent-models.d.ts +83 -0
- package/dist/engines/pi/agent-models.js +120 -0
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +132 -159
- package/dist/engines/pi/auth.d.ts +56 -3
- package/dist/engines/pi/auth.js +68 -109
- package/dist/engines/pi/config.d.ts +0 -25
- package/dist/engines/pi/config.js +3 -37
- package/dist/engines/pi/create.d.ts +34 -21
- package/dist/engines/pi/create.js +90 -57
- package/dist/engines/pi/entry-images.d.ts +14 -0
- package/dist/engines/pi/entry-images.js +23 -0
- package/dist/engines/pi/invoke-session.js +88 -20
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +27 -14
- package/dist/engines/pi/login.js +41 -18
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +120 -36
- package/dist/engines/pi/models.js +316 -66
- package/dist/engines/pi/open.d.ts +65 -20
- package/dist/engines/pi/open.js +88 -34
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/service.d.ts +2 -2
- package/dist/engines/pi/service.js +1 -0
- package/dist/engines/pi/session-builder.d.ts +3 -2
- package/dist/engines/pi/session-builder.js +30 -76
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +86 -43
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +115 -32
- package/dist/open-url.d.ts +15 -1
- package/dist/open-url.js +35 -3
- package/dist/paths.d.ts +4 -2
- package/dist/paths.js +6 -2
- package/dist/pi.d.ts +5 -3
- package/dist/pi.js +3 -2
- package/dist/service.d.ts +5 -8
- package/dist/session-remote.js +21 -0
- package/dist/session.d.ts +88 -15
- package/dist/session.js +5 -1
- package/package.json +6 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- package/dist/engines/pi/search-tools.js +0 -109
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Building pi's model registry: the built-ins, an agent's `models.json` and model catalog layered over the machine's,
|
|
3
|
+
* the catalog refresh, and the reads of those files a report or a deploy check needs. Which credential store an agent
|
|
4
|
+
* runs it over is agent-models.ts's; registry and auth are one collection, so the model's provider auth is in scope.
|
|
5
5
|
*/
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
9
|
import { join } from "node:path";
|
|
11
|
-
import { InMemoryCredentialStore, defaultProviderAuthContext, } from "@earendil-works/pi-ai";
|
|
12
|
-
import { builtinModels, builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
10
|
+
import { InMemoryCredentialStore, InMemoryModelsStore, defaultProviderAuthContext, } from "@earendil-works/pi-ai";
|
|
11
|
+
import { builtinModels, builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
|
|
13
12
|
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import {
|
|
15
|
-
import { providerOf } from "./config.js";
|
|
16
|
-
import { AGENT_MODELS_FILE, GLOBAL_HOME_DIR, resolveOverridePath, resolveStateRoot } from "../../paths.js";
|
|
13
|
+
import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, globalHome, resolveOverridePath } from "../../paths.js";
|
|
17
14
|
import { writeFileAtomic } from "../../atomic-write.js";
|
|
18
|
-
|
|
19
|
-
export function createPiModels(options = {}) {
|
|
20
|
-
return piModelsOver(fastagentCredentialStore(options.authPath, { warn: options.warn }), options.providers);
|
|
21
|
-
}
|
|
15
|
+
import { withLockedFile } from "./locked-file.js";
|
|
22
16
|
/** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
|
|
23
17
|
export function piModelsOver(credentials, providers = []) {
|
|
24
18
|
const models = builtinModels({ credentials, authContext: defaultProviderAuthContext() });
|
|
@@ -33,8 +27,8 @@ export const DEFAULT_THINKING_LEVEL = "medium";
|
|
|
33
27
|
* (a local Ollama, a company gateway), which every agent here inherits the way it inherits the machine's skills.
|
|
34
28
|
* `FASTAGENT_MODELS_PATH` moves it. It never ships: a deployed agent has only its definition's file.
|
|
35
29
|
*/
|
|
36
|
-
|
|
37
|
-
return resolveOverridePath(env.FASTAGENT_MODELS_PATH) ?? join(
|
|
30
|
+
function machineModelsPath(env = process.env) {
|
|
31
|
+
return resolveOverridePath(env.FASTAGENT_MODELS_PATH) ?? join(globalHome(), AGENT_MODELS_FILE);
|
|
38
32
|
}
|
|
39
33
|
/** A custom-endpoint file's providers by id, or undefined when there is no file. Strict JSON: see {@link modelLayers}. */
|
|
40
34
|
async function readProviders(path) {
|
|
@@ -76,30 +70,45 @@ async function modelLayers(agentDir) {
|
|
|
76
70
|
return { machinePath, machine, own: (await readProviders(join(agentDir, AGENT_MODELS_FILE))) ?? {} };
|
|
77
71
|
}
|
|
78
72
|
/**
|
|
79
|
-
* The models.json pi loads for an agent: its own file, layered over the machine's. The agent's
|
|
80
|
-
* outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
|
|
73
|
+
* The models.json pi loads for an agent: its own file, layered over the machine's when `machine` is on. The agent's
|
|
74
|
+
* file wins a provider id outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
|
|
81
75
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
76
|
+
* Always a SNAPSHOT named by its content, in fastagent's own home: pi reloads `modelsPath` on every refresh, and every
|
|
77
|
+
* session builds its own runtime, so a live file would let turns run on an edit the control plane never read. Not in
|
|
78
|
+
* the agent (so `info` writes nothing there), and not in a shared temp dir, which the OS clears while a long-running
|
|
79
|
+
* process still re-reads it. Content addressing is what lets every process share the directory: a snapshot is never
|
|
80
|
+
* rewritten, so no process can change what another reads. A running process keeps the content it started with; an
|
|
81
|
+
* edit to either file takes effect on the next start.
|
|
87
82
|
*
|
|
88
83
|
* ponytail: snapshots are never pruned (one per distinct content, a few KB each) because a running process may still
|
|
89
84
|
* read an old one; delete the directory while no fastagent process runs if it ever matters.
|
|
90
85
|
*/
|
|
91
|
-
async function modelsFileFor(agentDir) {
|
|
86
|
+
async function modelsFileFor(agentDir, machine) {
|
|
92
87
|
const definition = join(agentDir, AGENT_MODELS_FILE);
|
|
93
|
-
const layers = await modelLayers(agentDir);
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
const layers = machine ? await modelLayers(agentDir) : undefined;
|
|
89
|
+
if (layers) {
|
|
90
|
+
const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
|
|
91
|
+
return { path: snapshotFile(content), merged: { machine: layers.machinePath, definition } };
|
|
92
|
+
}
|
|
93
|
+
let own;
|
|
94
|
+
try {
|
|
95
|
+
own = await readFile(definition, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return { path: null };
|
|
100
|
+
throw new Error(`could not read ${definition}: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
// Verbatim, so pi parses it exactly as it would the file itself.
|
|
103
|
+
return { path: snapshotFile(own), snapshotOf: definition };
|
|
104
|
+
}
|
|
105
|
+
function snapshotFile(content) {
|
|
97
106
|
const hash = createHash("sha256").update(content).digest("hex").slice(0, 32);
|
|
98
|
-
const path = join(
|
|
107
|
+
const path = join(globalHome(), ".cache", "models", `${hash}.json`);
|
|
99
108
|
// 0600: it may carry literal keys. Several processes may create the same snapshot at once.
|
|
100
109
|
if (!existsSync(path))
|
|
101
110
|
writeFileAtomic(path, content, 0o600, true);
|
|
102
|
-
return
|
|
111
|
+
return path;
|
|
103
112
|
}
|
|
104
113
|
/**
|
|
105
114
|
* Where an agent's custom endpoints come from, for a report: the machine file, the providers the agent inherits from
|
|
@@ -120,49 +129,265 @@ export async function machineModels(agentDir) {
|
|
|
120
129
|
export function isBuiltinProvider(providerId) {
|
|
121
130
|
return builtinProviders().some((provider) => provider.id === providerId);
|
|
122
131
|
}
|
|
123
|
-
/**
|
|
124
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The MACHINE's model catalog: the models pi.dev lists that are newer than the catalog bundled with pi, fetched by
|
|
134
|
+
* `fastagent models --refresh -g`. Every agent here reads it under its own; like `~/.fastagent/models.json`, it
|
|
135
|
+
* never ships.
|
|
136
|
+
*/
|
|
137
|
+
export function globalCatalogPath() {
|
|
138
|
+
return join(globalHome(), AGENT_MODEL_CATALOG_FILE);
|
|
139
|
+
}
|
|
140
|
+
/** A catalog file as pi writes it (entries by provider), or `{}` when there is none. Read without a lock: its one
|
|
141
|
+
* writer ({@link catalogFileStore}) replaces the file whole, so a read sees the old catalog or the new one. */
|
|
142
|
+
async function readCatalog(path) {
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return {};
|
|
145
|
+
return parseCatalog(await readFile(path, "utf8"), path);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The one reading of a catalog file's content, for reads and writes alike. Empty is an empty catalog, as pi reads it:
|
|
149
|
+
* the writer creates the file exclusively an instant before its first content, and pi's own store, which wrote these
|
|
150
|
+
* files before, left one behind whenever a refresh it began wrote no entry. That is safe only because nothing writes
|
|
151
|
+
* these files in place any more: a rewrite in place is read mostly as empty while it runs. Anything else that does not
|
|
152
|
+
* parse is a corrupt file, and a WRITE refuses it too: serializing over it would drop every other provider's entries.
|
|
153
|
+
*/
|
|
154
|
+
function parseCatalog(content, path) {
|
|
155
|
+
if (content === "")
|
|
156
|
+
return {};
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(content);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw new Error(`model catalog ${path} is not valid JSON (${error.message}): refresh or delete it`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The catalog file a refresh writes, as pi's store. It replaces pi's own file store, which rewrites the file in place
|
|
166
|
+
* under its lock: a reader that takes no lock (every catalog read here, and anything else reading the file) could open
|
|
167
|
+
* it cut short mid-write and blame a healthy file. Same lock as pi's, so concurrent refreshes still queue; reading
|
|
168
|
+
* creates nothing, so a refresh that is refused writes no file.
|
|
169
|
+
*/
|
|
170
|
+
export function catalogFileStore(path) {
|
|
171
|
+
const change = (update, signal) => withLockedFile(path, async (current) => {
|
|
172
|
+
const catalog = parseCatalog(current ?? "", path);
|
|
173
|
+
update(catalog);
|
|
174
|
+
return { result: undefined, next: JSON.stringify(catalog, null, 2) };
|
|
175
|
+
}, signal ? { signal } : {});
|
|
176
|
+
return {
|
|
177
|
+
async read(providerId, options) {
|
|
178
|
+
options?.signal?.throwIfAborted();
|
|
179
|
+
return (await readCatalog(path))[providerId];
|
|
180
|
+
},
|
|
181
|
+
async write(providerId, entry, options) {
|
|
182
|
+
options?.signal?.throwIfAborted();
|
|
183
|
+
await change((catalog) => {
|
|
184
|
+
catalog[providerId] = structuredClone(entry);
|
|
185
|
+
}, options?.signal);
|
|
186
|
+
},
|
|
187
|
+
async delete(providerId, options) {
|
|
188
|
+
options?.signal?.throwIfAborted();
|
|
189
|
+
await change((catalog) => {
|
|
190
|
+
delete catalog[providerId];
|
|
191
|
+
}, options?.signal);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The catalog files layered into one read-only store, later files winning a (type, model id) pair. An entry pi would ignore (no
|
|
197
|
+
* newer than the catalog bundled with it) is dropped before the merge, so a stale layer cannot ride a newer one's
|
|
198
|
+
* date past pi's rule.
|
|
199
|
+
*
|
|
200
|
+
* Read here rather than handed to pi as a file: pi takes one store, and its file store creates the file (and a lock)
|
|
201
|
+
* on read, which would drop an empty catalog into every agent dir that was never refreshed.
|
|
202
|
+
*/
|
|
203
|
+
async function layeredCatalog(paths) {
|
|
204
|
+
return catalogStore(await readLayeredCatalog(paths));
|
|
205
|
+
}
|
|
206
|
+
/** A fresh store over a catalog read once: pi may write into the store it is given, so runtimes never share one. */
|
|
207
|
+
async function catalogStore(entries) {
|
|
208
|
+
const store = new InMemoryModelsStore();
|
|
209
|
+
for (const [provider, entry] of entries)
|
|
210
|
+
await store.write(provider, structuredClone(entry));
|
|
211
|
+
return store;
|
|
212
|
+
}
|
|
213
|
+
async function readLayeredCatalog(paths) {
|
|
214
|
+
const bundledAt = getBuiltinModelDataGeneratedAt();
|
|
215
|
+
const merged = new Map();
|
|
216
|
+
for (const path of paths) {
|
|
217
|
+
for (const [provider, entry] of Object.entries(await readCatalog(path))) {
|
|
218
|
+
if (bundledAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= bundledAt))
|
|
219
|
+
continue;
|
|
220
|
+
const held = merged.get(provider);
|
|
221
|
+
const key = (model) => `${model.type ?? "chat"}\u0000${model.id}`;
|
|
222
|
+
const ids = new Set(entry.models.map(key));
|
|
223
|
+
merged.set(provider, held
|
|
224
|
+
? {
|
|
225
|
+
models: [...held.models.filter((model) => !ids.has(key(model))), ...entry.models],
|
|
226
|
+
lastModified: Math.max(held.lastModified ?? 0, entry.lastModified ?? 0),
|
|
227
|
+
}
|
|
228
|
+
: entry);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return merged;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
|
|
235
|
+
* ({@link layeredCatalog}): an entry pi would ignore supplies nothing. The one layer a deployed agent does not have.
|
|
236
|
+
*/
|
|
237
|
+
export async function inGlobalCatalog(provider, id) {
|
|
238
|
+
const entry = await (await layeredCatalog([globalCatalogPath()])).read(provider);
|
|
239
|
+
return entry?.models.some((model) => (model.type ?? "chat") === "chat" && model.id === id) ?? false;
|
|
240
|
+
}
|
|
241
|
+
/** The models.json a runtime for these options loads, and which model catalog it reads. */
|
|
242
|
+
export async function modelRuntimeFiles(options) {
|
|
125
243
|
const { agentDir } = options;
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
244
|
+
const machine = options.machineLayer !== false;
|
|
245
|
+
const models = agentDir ? await modelsFileFor(agentDir, machine) : undefined;
|
|
246
|
+
const catalogs = !agentDir
|
|
247
|
+
? []
|
|
248
|
+
: [...(machine ? [globalCatalogPath()] : []), join(agentDir, AGENT_MODEL_CATALOG_FILE)];
|
|
249
|
+
const catalog = !options.catalogFile && agentDir ? await readLayeredCatalog(catalogs) : undefined;
|
|
250
|
+
return {
|
|
251
|
+
...(models ? { models } : {}),
|
|
252
|
+
create: async () => ({
|
|
253
|
+
modelsPath: models?.path ?? null,
|
|
254
|
+
// Always a store of our own whenever modelsPath is set: pi's default is a file at
|
|
255
|
+
// `<dirname(modelsPath)>/models-store.json`. Without a directory, pi keeps an empty one in memory.
|
|
256
|
+
...(options.catalogFile
|
|
257
|
+
? { modelsStore: catalogFileStore(options.catalogFile) }
|
|
258
|
+
: catalog
|
|
259
|
+
? { modelsStore: await catalogStore(catalog) }
|
|
260
|
+
: {}),
|
|
261
|
+
// Never fetched while a runtime is built: serving stays offline and reproducible. A refresh is asked for.
|
|
262
|
+
allowModelNetwork: false,
|
|
263
|
+
...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* This machine's registry, without an agent: pi's built-ins, the machine's models.json and its model catalog. What
|
|
269
|
+
* `fastagent models` lists outside an agent; {@link catalogFile} is the refresh's (it writes the file).
|
|
270
|
+
*/
|
|
271
|
+
export async function machineModelRuntime(options = {}) {
|
|
131
272
|
const runtime = await ModelRuntime.create({
|
|
132
|
-
credentials: options.credentials ??
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
...(agentDir
|
|
138
|
-
? { modelsStorePath: join(options.stateRoot ?? resolveStateRoot(agentDir), "models-store.json") }
|
|
139
|
-
: {}),
|
|
273
|
+
credentials: options.credentials ?? new InMemoryCredentialStore(),
|
|
274
|
+
modelsPath: machineModelsPath(),
|
|
275
|
+
modelsStore: options.catalogFile
|
|
276
|
+
? catalogFileStore(options.catalogFile)
|
|
277
|
+
: await layeredCatalog([globalCatalogPath()]),
|
|
140
278
|
allowModelNetwork: false,
|
|
279
|
+
...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
|
|
141
280
|
});
|
|
281
|
+
const error = runtime.getError();
|
|
282
|
+
if (error)
|
|
283
|
+
throw new Error(error);
|
|
284
|
+
return runtime;
|
|
285
|
+
}
|
|
286
|
+
/** How long a catalog refresh may take, as `pi update --models` allows. */
|
|
287
|
+
const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
|
|
288
|
+
/**
|
|
289
|
+
* Fetch the model catalog of every provider the runtime can authenticate into `catalogFile` ({@link
|
|
290
|
+
* catalogFileStore}): the refresh `pi update --models` runs. `build` makes the runtime over that file, and runs only
|
|
291
|
+
* once the refresh is known to go ahead. A refusal writes nothing: the store creates the file only when pi writes an
|
|
292
|
+
* entry. pi asks pi.dev only for a provider with a usable credential, and may refresh an expired OAuth token of the
|
|
293
|
+
* runtime's store to get one. Rejects, naming each provider that failed, when any part fails, when it outlasts 15
|
|
294
|
+
* seconds, when `PI_OFFLINE` is set, when the catalog file is corrupt, and when no provider has a usable credential (the
|
|
295
|
+
* refresh would ask for nothing).
|
|
296
|
+
*/
|
|
297
|
+
export async function refreshCatalog(catalogFile, build, options = {}) {
|
|
298
|
+
// pi skips its own background refresh under PI_OFFLINE, but an explicit `allowNetwork: true` overrides that.
|
|
299
|
+
if (process.env.PI_OFFLINE !== undefined)
|
|
300
|
+
throw new Error("PI_OFFLINE is set, so the model catalog is not refreshed");
|
|
301
|
+
// A corrupt file fails here, once, by its own message: past this point every provider's refresh reads the store and
|
|
302
|
+
// would repeat it, worded as that provider's failure.
|
|
303
|
+
await readCatalog(catalogFile);
|
|
304
|
+
const runtime = await build(catalogFile);
|
|
305
|
+
// pi skips a provider it cannot authenticate without recording anything, so with no usable credential at all the
|
|
306
|
+
// refresh would "succeed" having asked for nothing.
|
|
307
|
+
const refreshable = runtime.getProviders().filter((provider) => provider.refreshModels !== undefined);
|
|
308
|
+
const usable = await Promise.all(refreshable.map(async (provider) => (await runtime.checkAuth(provider.id)) !== undefined));
|
|
309
|
+
if (!usable.includes(true)) {
|
|
310
|
+
throw new Error("no provider has a usable credential here, so there is no model catalog to fetch — log in " +
|
|
311
|
+
"(`fastagent login`) or set a provider's API key first");
|
|
312
|
+
}
|
|
313
|
+
const timeout = AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS);
|
|
314
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
315
|
+
const result = await runtime.refresh({ allowNetwork: true, force: true, signal });
|
|
316
|
+
if (result.aborted) {
|
|
317
|
+
throw new Error(options.signal?.aborted
|
|
318
|
+
? "model catalog refresh cancelled"
|
|
319
|
+
: `model catalog refresh did not finish within ${CATALOG_REFRESH_TIMEOUT_MS / 1000}s`);
|
|
320
|
+
}
|
|
321
|
+
if (result.errors.size > 0) {
|
|
322
|
+
const details = [...result.errors].map(([provider, error]) => `${provider}: ${error.message}`).join("; ");
|
|
323
|
+
throw new Error(`could not refresh the model catalog: ${details}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* What satisfies a provider from `env` alone (its API-key variable, or an ambient source such as an AWS profile), by
|
|
328
|
+
* pi's own check, or undefined. Passed explicitly rather than read from `process.env`: only the environment a SHELL
|
|
329
|
+
* hands down is shared by every agent on the machine, and a process that has loaded an agent's `.secrets/.env` holds
|
|
330
|
+
* that agent's variables too. What the shell shares outranks the global credentials file (agent-models.ts),
|
|
331
|
+
* so a global login for such a provider is not used.
|
|
332
|
+
*
|
|
333
|
+
* `fileExists` answers for the files a source points at (an AWS profile, Google ADC): this machine's by default, which
|
|
334
|
+
* is right wherever the answer is about THIS machine. A deployment passes one that finds nothing, because no file of
|
|
335
|
+
* the builder's travels with it.
|
|
336
|
+
*/
|
|
337
|
+
export async function environmentAuthSource(providerId, env, fileExists = defaultProviderAuthContext().fileExists) {
|
|
338
|
+
const models = builtinModels({
|
|
339
|
+
credentials: new InMemoryCredentialStore(),
|
|
340
|
+
authContext: {
|
|
341
|
+
// pi's own reading of a variable (a blank one is unset), over the given environment.
|
|
342
|
+
env: async (name) => (env[name]?.trim() ? env[name] : undefined),
|
|
343
|
+
fileExists,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
return (await models.checkAuth(providerId))?.source;
|
|
347
|
+
}
|
|
348
|
+
/** Pi registration starts background refreshes; wait for all of them before publishing this fresh runtime. */
|
|
349
|
+
export async function withModelRegistration(runtime, register) {
|
|
350
|
+
const refresh = runtime.refresh;
|
|
351
|
+
const refreshes = [];
|
|
352
|
+
runtime.refresh = (...args) => {
|
|
353
|
+
const pending = refresh.apply(runtime, args);
|
|
354
|
+
refreshes.push(pending);
|
|
355
|
+
return pending;
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
const result = await register();
|
|
359
|
+
await Promise.all(refreshes);
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
runtime.refresh = refresh;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* A registry over the given credential store. What an agent runs on is built through `agentModels`
|
|
368
|
+
* (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
|
|
369
|
+
* ships), a trial key under test (login), or the catalog file a refresh writes.
|
|
370
|
+
*/
|
|
371
|
+
export async function createPiModelRuntime(options) {
|
|
372
|
+
const { models, create } = options.files ?? (await modelRuntimeFiles(options));
|
|
373
|
+
const runtime = await ModelRuntime.create({ credentials: options.credentials, ...(await create()) });
|
|
142
374
|
// A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the reason in
|
|
143
375
|
// getError().
|
|
144
376
|
const error = runtime.getError();
|
|
145
377
|
if (error) {
|
|
146
378
|
const origin = models?.merged
|
|
147
379
|
? `\n\nThat file merges ${models.merged.machine} with ${models.merged.definition} (the agent's own wins a provider id).`
|
|
148
|
-
:
|
|
380
|
+
: models?.snapshotOf
|
|
381
|
+
? `\n\nThat file is ${models.snapshotOf} as read at startup.`
|
|
382
|
+
: "";
|
|
149
383
|
throw new Error(`${error}${origin}`);
|
|
150
384
|
}
|
|
151
|
-
|
|
152
|
-
|
|
385
|
+
await withModelRegistration(runtime, async () => {
|
|
386
|
+
for (const provider of options.providers ?? [])
|
|
387
|
+
runtime.registerNativeProvider(provider);
|
|
388
|
+
});
|
|
153
389
|
return runtime;
|
|
154
390
|
}
|
|
155
|
-
/** How a model's credential will REACH a deployed agent. */
|
|
156
|
-
export function modelCredentialCarry(runtime, spec) {
|
|
157
|
-
const status = runtime.getProviderAuthStatus(providerOf(spec));
|
|
158
|
-
if (!status.configured)
|
|
159
|
-
return { inDefinition: false };
|
|
160
|
-
// An env-var name is only useful downstream if it IS one.
|
|
161
|
-
if (status.source === "environment" && status.label && /^[A-Z][A-Z0-9_]*$/.test(status.label)) {
|
|
162
|
-
return { envVar: status.label, inDefinition: false };
|
|
163
|
-
}
|
|
164
|
-
return { inDefinition: status.source !== "stored" };
|
|
165
|
-
}
|
|
166
391
|
/**
|
|
167
392
|
* EVERY provider whose `models.json` entry writes its key as a LITERAL rather than a `"$NAME"` reference or a
|
|
168
393
|
* `"!cmd"`. The file ships inside the image, so a literal there is a credential in a readable layer.
|
|
@@ -178,6 +403,27 @@ export function modelCredentialCarry(runtime, spec) {
|
|
|
178
403
|
* gates what IT causes; what the author wrote into their own committed file, it reports.
|
|
179
404
|
*/
|
|
180
405
|
export async function literalKeyProviders(agentDir) {
|
|
406
|
+
return Object.entries(await definitionApiKeys(agentDir))
|
|
407
|
+
.filter(([, apiKey]) => isLiteralKey(apiKey))
|
|
408
|
+
.map(([id]) => id);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* How the agent's OWN models.json supplies `providerId`'s key, read from the file for the reason
|
|
412
|
+
* {@link literalKeyProviders} gives: `reference` is the variable a `"$NAME"` reads (the value file must hold it),
|
|
413
|
+
* `inFile` a key that travels with the definition itself (a literal, or a `"!command"` run on the box), and undefined
|
|
414
|
+
* no apiKey there at all.
|
|
415
|
+
*/
|
|
416
|
+
export async function definitionKeyOf(agentDir, providerId) {
|
|
417
|
+
const apiKey = (await definitionApiKeys(agentDir))[providerId];
|
|
418
|
+
if (typeof apiKey !== "string" || apiKey === "")
|
|
419
|
+
return undefined;
|
|
420
|
+
if (apiKey.startsWith("!") || isLiteralKey(apiKey))
|
|
421
|
+
return { inFile: true };
|
|
422
|
+
// Not a literal, so isLiteralKey's own pattern matched: there is a name to read.
|
|
423
|
+
return { reference: apiKey.replaceAll("$$", "").match(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/)?.[1] };
|
|
424
|
+
}
|
|
425
|
+
/** Each provider's `apiKey` as the agent's own models.json writes it. */
|
|
426
|
+
async function definitionApiKeys(agentDir) {
|
|
181
427
|
const file = join(agentDir, AGENT_MODELS_FILE);
|
|
182
428
|
let raw;
|
|
183
429
|
try {
|
|
@@ -186,15 +432,13 @@ export async function literalKeyProviders(agentDir) {
|
|
|
186
432
|
catch (error) {
|
|
187
433
|
// No custom endpoints is the normal case; anything else (unreadable, a directory) is the caller's problem.
|
|
188
434
|
if (error.code === "ENOENT")
|
|
189
|
-
return
|
|
435
|
+
return {};
|
|
190
436
|
throw error;
|
|
191
437
|
}
|
|
192
438
|
// Malformed JSON already threw out of createPiModelRuntime before this runs, so a parse failure here would be a
|
|
193
439
|
// genuine surprise and must not be swallowed.
|
|
194
440
|
const providers = JSON.parse(raw).providers ?? {};
|
|
195
|
-
return Object.entries(providers)
|
|
196
|
-
.filter(([, provider]) => isLiteralKey(provider?.apiKey))
|
|
197
|
-
.map(([id]) => id);
|
|
441
|
+
return Object.fromEntries(Object.entries(providers).map(([id, provider]) => [id, provider?.apiKey]));
|
|
198
442
|
}
|
|
199
443
|
/**
|
|
200
444
|
* `!cmd` runs on the box; `$NAME` / `${NAME}` reads the environment; anything else is the key itself.
|
|
@@ -216,7 +460,7 @@ export function interactiveLoginKind(p) {
|
|
|
216
460
|
export function interactiveAuth(provider, method) {
|
|
217
461
|
return method === "oauth" ? provider.auth.oauth : provider.auth.apiKey?.login ? provider.auth.apiKey : undefined;
|
|
218
462
|
}
|
|
219
|
-
/** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link
|
|
463
|
+
/** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link piModelsOver} does. */
|
|
220
464
|
export function loginProviders(extra) {
|
|
221
465
|
return piModelsOver(new InMemoryCredentialStore(), extra).getProviders();
|
|
222
466
|
}
|
|
@@ -238,7 +482,11 @@ export async function providerAuthStatuses(models) {
|
|
|
238
482
|
}
|
|
239
483
|
return statuses;
|
|
240
484
|
}
|
|
241
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Which source currently satisfies auth for `spec`, or undefined when none does (or the spec is not in `models`).
|
|
487
|
+
* Rejects when resolving fails — a corrupt entry, a refresh the provider refused — which is a different answer from
|
|
488
|
+
* "nothing configured" and must reach whoever reports it.
|
|
489
|
+
*/
|
|
242
490
|
export async function probeAuthSource(models, spec) {
|
|
243
491
|
const slash = spec.indexOf("/");
|
|
244
492
|
if (slash < 1)
|
|
@@ -246,8 +494,10 @@ export async function probeAuthSource(models, spec) {
|
|
|
246
494
|
const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
|
|
247
495
|
if (!model)
|
|
248
496
|
return undefined;
|
|
249
|
-
|
|
250
|
-
|
|
497
|
+
// A virtual selection authenticates the physical model after routing, not its catalog entry.
|
|
498
|
+
if (model.api === "pi-virtual")
|
|
499
|
+
return "virtual";
|
|
500
|
+
return (await models.getAuth(model))?.source;
|
|
251
501
|
}
|
|
252
502
|
/** Quick-fail probe for a just-stored API key. */
|
|
253
503
|
export async function probeApiKey(models, model, signal) {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { Agent } from "../../agent.ts";
|
|
2
|
-
import { type
|
|
3
|
-
import type { AgentCommand, SessionControl } from "../../session.ts";
|
|
2
|
+
import { type FastagentConfig } from "./config.ts";
|
|
3
|
+
import type { AgentCommand, ModelDescriptor, SessionControl } from "../../session.ts";
|
|
4
|
+
import { type IndirectTool, type PiAssembly } from "./create.ts";
|
|
4
5
|
import type { SessionObserver } from "./turn-kit.ts";
|
|
5
6
|
import { type LoadedDefinition } from "./definition.ts";
|
|
6
7
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type {
|
|
8
|
+
import type { CredentialStore } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { CredentialSourceOptions, FastagentAuthOptions } from "./auth.ts";
|
|
10
|
+
import { type AgentModels } from "./agent-models.ts";
|
|
8
11
|
import { type PiSessionRecordStore } from "./session-store.ts";
|
|
9
12
|
import type { ToolCollision, MountedTool } from "./tool.ts";
|
|
10
13
|
import type { DeclaredSecret } from "../../declared-secrets.ts";
|
|
@@ -36,6 +39,8 @@ export interface CreatePiAgentFromDirOptions {
|
|
|
36
39
|
sessionsDir?: string;
|
|
37
40
|
/** Credentials file override. */
|
|
38
41
|
authPath?: string;
|
|
42
|
+
/** The caller's own credential store, in place of any file ({@link CredentialSourceOptions}). */
|
|
43
|
+
credentialStore?: CredentialStore;
|
|
39
44
|
/**
|
|
40
45
|
* This is a long-running SERVE (`dev`/`start`), where the scheduler poller runs — so a self-scheduled wake-up is
|
|
41
46
|
* actually honored.
|
|
@@ -65,32 +70,72 @@ export interface AgentAssembly {
|
|
|
65
70
|
workspace: string;
|
|
66
71
|
/** Absolute state root (FASTAGENT_STATE_DIR > <agentDir>/.state). */
|
|
67
72
|
stateRoot: string;
|
|
68
|
-
/** The
|
|
69
|
-
|
|
70
|
-
/** The full mounted tool surface (all coding tools + config.tools + discovered tools
|
|
73
|
+
/** The credential store and model registry every turn and every report reads ({@link agentModels}). */
|
|
74
|
+
models: AgentModels;
|
|
75
|
+
/** The full mounted tool surface (all coding tools + config.tools + discovered tools/). */
|
|
71
76
|
tools: MountedTool[];
|
|
72
77
|
toolNames: string[];
|
|
73
|
-
|
|
78
|
+
indirectTools: IndirectTool[];
|
|
74
79
|
toolCollisions: ToolCollision[];
|
|
75
80
|
/** Env vars the mounted tools declared, by tool name — already asserted present by this function. */
|
|
76
81
|
toolSecrets: Map<string, DeclaredSecret[]>;
|
|
77
82
|
}
|
|
78
83
|
export declare function resolveAgentAssembly(dir: string, options?: {
|
|
79
84
|
model?: string;
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
} & CredentialSourceOptions): Promise<AgentAssembly>;
|
|
86
|
+
/**
|
|
87
|
+
* L2 over a resolved front half: the ONE mapping from what the directory resolved to what the assembly runs on, for
|
|
88
|
+
* both session shapes (the served agent and `chat`'s resident one). Two copies of it drifted once (#566, chat ran at
|
|
89
|
+
* the wrong reasoning effort). `models` is the front's own, so the assembly runs on what the report describes.
|
|
90
|
+
*/
|
|
91
|
+
export declare function assembleFront(front: AgentAssembly, extra?: {
|
|
92
|
+
tools?: MountedTool[];
|
|
93
|
+
sessions?: PiSessionRecordStore;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
assembly: PiAssembly;
|
|
96
|
+
definition: LoadedDefinition;
|
|
97
|
+
}>;
|
|
82
98
|
/**
|
|
83
|
-
* The
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
99
|
+
* The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
|
|
100
|
+
* levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
|
|
101
|
+
* `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
|
|
102
|
+
* directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
|
|
103
|
+
* authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
|
|
87
104
|
*
|
|
88
105
|
* `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
|
|
89
106
|
* configured", so a client that must not show that as an empty list passes a sink that throws.
|
|
90
107
|
*/
|
|
91
|
-
export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions &
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<ModelDescriptor[]>;
|
|
109
|
+
/**
|
|
110
|
+
* Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
|
|
111
|
+
* `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
|
|
112
|
+
* {@link availableModelsFromDir} and runs. Only providers those credentials authenticate are fetched (pi asks pi.dev
|
|
113
|
+
* for no other). Nothing refreshes it on its own. The file is part of the definition: commit it, and it ships with a
|
|
114
|
+
* deploy, so the deployed agent knows the same models without a network call.
|
|
115
|
+
*
|
|
116
|
+
* Rejects, naming each provider that failed, when the refresh fails, outlasts 15 seconds or `PI_OFFLINE` is set, and
|
|
117
|
+
* when the credentials authenticate no provider at all.
|
|
118
|
+
*/
|
|
119
|
+
export declare function refreshModelCatalog(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions & {
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}): Promise<void>;
|
|
122
|
+
/** {@link refreshModelCatalog} against another catalog server. Not public: a test seam. */
|
|
123
|
+
export declare function refreshModelCatalogOver(dir: string, options: FastagentAuthOptions & CredentialSourceOptions & {
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}, catalogBaseUrl?: string): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
|
|
128
|
+
* so one refresh serves them all and no file is written into any agent. Credentials resolve as for
|
|
129
|
+
* {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
|
|
130
|
+
* the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
|
|
131
|
+
*/
|
|
132
|
+
export declare function refreshMachineModelCatalog(options?: FastagentAuthOptions & CredentialSourceOptions & {
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}): Promise<void>;
|
|
135
|
+
/** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
|
|
136
|
+
export declare function refreshMachineModelCatalogOver(options: FastagentAuthOptions & CredentialSourceOptions & {
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
}, catalogBaseUrl?: string): Promise<void>;
|
|
94
139
|
/**
|
|
95
140
|
* "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
|
|
96
141
|
* itself), load the config, resolve model and tools, then L2.
|
|
@@ -110,8 +155,8 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
|
|
|
110
155
|
stateRoot: string;
|
|
111
156
|
/** Absolute session store directory in use (for the startup report). */
|
|
112
157
|
sessionsDir: string;
|
|
113
|
-
/** The
|
|
114
|
-
|
|
158
|
+
/** The credential store and registry the agent runs on — what the startup report describes. */
|
|
159
|
+
models: AgentModels;
|
|
115
160
|
sessions: PiSessionRecordStore;
|
|
116
161
|
/** The observation plane over this agent's sessions; present on every serve (a channel's stop command reaches the
|
|
117
162
|
* live run through it). Its boundary is wired only when {@link publishControl}. */
|
|
@@ -122,7 +167,7 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
|
|
|
122
167
|
http?: HttpSurface;
|
|
123
168
|
/** Non-default, active-by-default tool names in effect: config.tools + discovered tools/. */
|
|
124
169
|
toolNames: string[];
|
|
125
|
-
/**
|
|
126
|
-
|
|
170
|
+
/** Mounted authored tools the model is not given up front, with how each is reached. */
|
|
171
|
+
indirectTools: IndirectTool[];
|
|
127
172
|
toolCollisions: ToolCollision[];
|
|
128
173
|
}>;
|