@fastagent-sh/fastagent 0.25.0 → 0.26.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 (63) hide show
  1. package/README.md +3 -2
  2. package/dist/agent-api.d.ts +10 -2
  3. package/dist/agent-api.js +10 -2
  4. package/dist/authoring.d.ts +12 -8
  5. package/dist/authoring.js +14 -7
  6. package/dist/cli/add-feishu.js +1 -1
  7. package/dist/cli/add-slack.js +1 -1
  8. package/dist/cli/box-login.d.ts +3 -2
  9. package/dist/cli/commands/add.js +2 -1
  10. package/dist/cli/commands/deploy.js +1 -1
  11. package/dist/cli/commands/env.js +5 -4
  12. package/dist/cli/commands/init.js +3 -3
  13. package/dist/cli/commands/invoke.js +2 -1
  14. package/dist/cli/commands/login.js +6 -4
  15. package/dist/cli/login-relay.d.ts +4 -3
  16. package/dist/cli/login-relay.js +1 -1
  17. package/dist/cli/shared.js +5 -3
  18. package/dist/content/git.d.ts +1 -1
  19. package/dist/content/git.js +7 -4
  20. package/dist/content/resolve.d.ts +1 -1
  21. package/dist/content/resolve.js +2 -2
  22. package/dist/deploy/preflight.js +1 -1
  23. package/dist/dev-supervisor.js +1 -1
  24. package/dist/env.d.ts +0 -13
  25. package/dist/env.js +1 -44
  26. package/dist/environment/mise.d.ts +8 -7
  27. package/dist/environment/mise.js +24 -20
  28. package/dist/git.d.ts +2 -2
  29. package/dist/git.js +5 -5
  30. package/dist/harnesses/models/environment.d.ts +7 -2
  31. package/dist/harnesses/models/environment.js +16 -10
  32. package/dist/harnesses/models/host.d.ts +30 -0
  33. package/dist/harnesses/models/host.js +97 -0
  34. package/dist/harnesses/models/login.d.ts +38 -73
  35. package/dist/harnesses/models/login.js +56 -56
  36. package/dist/harnesses/models/models.d.ts +4 -3
  37. package/dist/harnesses/models/models.js +51 -12
  38. package/dist/harnesses/pi-durable/agent.js +4 -0
  39. package/dist/harnesses/pi-durable/ids.js +2 -0
  40. package/dist/harnesses/pi-durable/open.d.ts +7 -0
  41. package/dist/harnesses/pi-durable/open.js +4 -3
  42. package/dist/harnesses/pi-durable/parked.d.ts +18 -0
  43. package/dist/harnesses/pi-durable/parked.js +27 -0
  44. package/dist/harnesses/pi-durable/release.d.ts +3 -2
  45. package/dist/harnesses/pi-durable/serve.d.ts +11 -1
  46. package/dist/harnesses/pi-durable/serve.js +42 -12
  47. package/dist/harnesses/pi-durable/storage.js +4 -2
  48. package/dist/harnesses/pi-durable/wake.d.ts +8 -1
  49. package/dist/harnesses/pi-durable/wake.js +10 -1
  50. package/dist/http/contract.d.ts +3 -3
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.js +3 -0
  53. package/dist/scaffold/add-channel.js +1 -1
  54. package/dist/schedule/cron.d.ts +4 -2
  55. package/dist/schedule/cron.js +4 -2
  56. package/dist/schedule/eventbridge-cron.d.ts +7 -3
  57. package/dist/schedule/eventbridge-cron.js +9 -8
  58. package/dist/sign-in-api.d.ts +147 -0
  59. package/dist/sign-in-api.js +9 -0
  60. package/dist/tunnel.js +2 -1
  61. package/dist/value-file.d.ts +19 -0
  62. package/dist/value-file.js +61 -5
  63. package/package.json +16 -16
@@ -1,20 +1,19 @@
1
1
  /**
2
- * Signing in to a MODEL PROVIDER, for any front end: {@link login} runs one provider's flow over pi-ai's own
2
+ * Signing in to a MODEL PROVIDER, for any front end: {@link loginOver} runs one provider's flow over pi-ai's own
3
3
  * `AuthInteraction` and persists the credential through the same {@link fastagentCredentialStore} the runtime reads
4
- * (one writer, one lock/corruption semantics); {@link loginOptions} is the list a picker offers. `fastagent login` is
5
- * one front end ({@link loginFlow}: terminal menus, then {@link login}); `login --deployment` drives the same flow on a
6
- * deployed box over a relay (cli/login-relay.ts).
4
+ * (one writer, one lock/corruption semantics); {@link loginOptionsOver} is the list a picker offers. An embedding host
5
+ * reaches both through host.ts (`signIn`, `signInOptions`); `fastagent login` is a front end of its own
6
+ * ({@link loginFlow}: terminal menus, then {@link loginOver}); `login --deployment` drives the same flow on a deployed
7
+ * box over a relay (cli/login-relay.ts).
7
8
  */
8
9
  import { InMemoryCredentialStore, } from "@earendil-works/pi-ai";
9
10
  import { randomUUID } from "node:crypto";
10
11
  import { mkdir, readFile } from "node:fs/promises";
11
12
  import { dirname, join } from "node:path";
12
13
  import { writeFileAtomic } from "../../atomic-write.js";
14
+ import { SignInCancelled } from "../../sign-in-api.js";
13
15
  import { assertOneCredentialSource, fastagentCredentialStore } from "./credentials.js";
14
16
  import { UNNAMED_AUTH_SOURCE, interactiveAuth, loginProviders, modelsOver, probeApiKey } from "./models.js";
15
- /** The user backed out of a prompt/menu, or the flow was aborted — a decision, not a failure. */
16
- export class LoginCancelled extends Error {
17
- }
18
17
  /** A sign-in that cannot start as asked: an unknown provider, or a choice a caller without menus did not make. */
19
18
  export class LoginRefused extends Error {
20
19
  /** The sign-ins it could be instead, for a caller to choose from. */
@@ -26,17 +25,13 @@ export class LoginRefused extends Error {
26
25
  }
27
26
  const ACCOUNT_LABEL = "Sign in with an account";
28
27
  const API_KEY_LABEL = "Sign in with an API key";
29
- /**
30
- * Every sign-in of pi's built-in providers, sorted by name as pi's `/login` lists them, with what authenticates each
31
- * one here: a credential `credentials` (a credentials file, or the caller's own store) holds, else this process's
32
- * environment, which a stored credential outranks.
33
- */
34
- export function loginOptions(credentials) {
35
- return loginOptionsOver(credentials);
36
- }
37
28
  /** A credentials file path names fastagent's store over it; a store is used as given. */
38
29
  const storeOf = (credentials) => typeof credentials === "string" ? fastagentCredentialStore(credentials) : credentials;
39
- /** {@link loginOptions} over pi's built-ins plus `providers` (a same id replaces one). Not public: a test seam. */
30
+ /**
31
+ * Every sign-in of pi's built-in providers plus `providers` (a same id replaces one), sorted by name as pi's `/login`
32
+ * lists them, with what authenticates each one here: a credential `credentials` (a credentials file, or a store) holds,
33
+ * else this process's environment, which a stored credential outranks.
34
+ */
40
35
  export async function loginOptionsOver(credentials, providers) {
41
36
  // ONE read of the store: `list` is metadata only, and reading per provider would repeat a corrupt file's warning for
42
37
  // every provider.
@@ -49,21 +44,17 @@ export async function loginOptionsOver(credentials, providers) {
49
44
  if (methods.length === 0)
50
45
  continue;
51
46
  const stored = held.get(provider.id);
52
- let status;
47
+ let ambient;
53
48
  let problem;
54
- if (stored !== undefined) {
55
- status = { type: stored, source: stored === "oauth" ? "OAuth" : "stored credential" };
49
+ try {
50
+ const found = await environment.checkAuth(provider.id);
51
+ if (found !== undefined)
52
+ ambient = { type: found.type, source: found.source ?? UNNAMED_AUTH_SOURCE };
56
53
  }
57
- else {
58
- try {
59
- const found = await environment.checkAuth(provider.id);
60
- if (found !== undefined)
61
- status = { type: found.type, source: found.source ?? UNNAMED_AUTH_SOURCE };
62
- }
63
- catch (error) {
64
- problem = error.message;
65
- }
54
+ catch (error) {
55
+ problem = error.message;
66
56
  }
57
+ const status = stored === undefined ? ambient : { type: stored, source: stored === "oauth" ? "OAuth" : "stored credential" };
67
58
  for (const method of methods) {
68
59
  offered.push({
69
60
  provider: provider.id,
@@ -73,6 +64,7 @@ export async function loginOptionsOver(credentials, providers) {
73
64
  subscription: method === "oauth" && provider.auth.oauth?.isSubscription === true,
74
65
  external: interactiveAuth(provider, method) === undefined,
75
66
  ...(status ? { status } : {}),
67
+ ...(ambient ? { environment: ambient.source } : {}),
76
68
  ...(problem ? { problem } : {}),
77
69
  });
78
70
  }
@@ -87,11 +79,10 @@ function credentialKind(type, subscription) {
87
79
  }
88
80
  /** An option's status as pi's picker writes it after the provider's name. */
89
81
  export function loginStatusText(option) {
90
- if (option.problem !== undefined)
91
- return `auth check failed: ${option.problem}`;
92
82
  const { status } = option;
83
+ // A stored credential outranks the environment, so a failed check of the environment does not decide it.
93
84
  if (status === undefined)
94
- return "not configured";
85
+ return option.problem === undefined ? "not configured" : `auth check failed: ${option.problem}`;
95
86
  if (status.type !== option.method)
96
87
  return `${credentialKind(status.type, option.subscription)} configured`;
97
88
  if (status.source === "OAuth" || status.source === "stored credential")
@@ -106,8 +97,8 @@ function anySignal(...signals) {
106
97
  return present.length === 0 ? undefined : present.length === 1 ? present[0] : AbortSignal.any(present);
107
98
  }
108
99
  /**
109
- * Sign in to one of pi's built-in providers and persist the credential to `authPath`, or to the caller's
110
- * `credentialStore`.
100
+ * Sign in to one of pi's built-in providers (plus the definition's) and persist the credential to `authPath`, or to
101
+ * the caller's `credentialStore`.
111
102
  *
112
103
  * The store is checked FIRST (a no-op write runs the file store's refuse-corrupt and writability checks, or whatever
113
104
  * the caller's store does on a write), so a flow never runs toward a credential that could not be saved. An entered API key is verified with one minimal request to pi's
@@ -116,14 +107,11 @@ function anySignal(...signals) {
116
107
  * the provider refuse the key, HTTP 401); a key the provider rejects is never stored, and the provider's key flow runs
117
108
  * again (the provider and method were not the mistake). Progress and outcome go out through `interaction.notify`.
118
109
  *
119
- * Aborting `interaction.signal` at any point, the verification included, rejects with {@link LoginCancelled} and
120
- * writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
121
- * beating a `manual_code` prompt), and when the flow settles with a prompt still pending.
110
+ * Aborting `interaction.signal` at any point, the verification included, rejects with {@link SignInCancelled} and
111
+ * writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a
112
+ * signal that aborts on it, on the prompt's own signal (a callback server beating a `manual_code` prompt), and when the
113
+ * flow settles with a prompt still pending.
122
114
  */
123
- export function login(request) {
124
- return loginOver(request);
125
- }
126
- /** {@link login}, plus {@link LoginInternals}. */
127
115
  export async function loginOver(request, internals = {}) {
128
116
  const { providers, verifyWith } = internals;
129
117
  const { method, interaction } = request;
@@ -158,7 +146,7 @@ export async function loginOver(request, internals = {}) {
158
146
  await trial.modify(provider.id, async () => credential);
159
147
  const verdict = await verifyApiKey(models, provider.id, verifyWith, notify, signal);
160
148
  if (signal.aborted)
161
- throw new LoginCancelled("cancelled");
149
+ throw new SignInCancelled("cancelled");
162
150
  if (verdict === "rejected")
163
151
  continue;
164
152
  verified = verdict;
@@ -202,20 +190,32 @@ async function readOrCreateDeviceId(path) {
202
190
  }
203
191
  async function runFlow(flow, interaction, signal) {
204
192
  if (signal.aborted)
205
- throw new LoginCancelled("cancelled");
193
+ throw new SignInCancelled("cancelled");
206
194
  const done = new AbortController();
195
+ let backedOut;
207
196
  let credential;
208
197
  try {
209
198
  credential = await flow({
210
199
  signal,
211
- prompt: (prompt) => interaction.prompt({ ...prompt, signal: anySignal(prompt.signal, signal, done.signal) }),
200
+ prompt: (prompt) => {
201
+ const asked = anySignal(prompt.signal, signal, done.signal);
202
+ return interaction.prompt({ ...prompt, signal: asked }).catch((error) => {
203
+ // Remembered here: a provider may wrap the rejection it is handed in an error of its own. Only a question
204
+ // still asked: one the flow withdrew (its callback came first) rejects too, and nobody backed out of it.
205
+ if (error instanceof SignInCancelled && !asked?.aborted)
206
+ backedOut = error;
207
+ throw error;
208
+ });
209
+ },
212
210
  notify: (event) => interaction.notify(event),
213
211
  });
214
212
  }
215
213
  catch (error) {
216
214
  // Whatever the provider made of the abort (its own error, a rejected prompt), it was the caller's decision.
217
215
  if (signal.aborted)
218
- throw new LoginCancelled("cancelled");
216
+ throw new SignInCancelled("cancelled");
217
+ if (backedOut !== undefined)
218
+ throw backedOut;
219
219
  throw error;
220
220
  }
221
221
  finally {
@@ -223,7 +223,7 @@ async function runFlow(flow, interaction, signal) {
223
223
  }
224
224
  // A provider that ignored the abort and finished anyway: the caller still cancelled.
225
225
  if (signal.aborted)
226
- throw new LoginCancelled("cancelled");
226
+ throw new SignInCancelled("cancelled");
227
227
  return credential;
228
228
  }
229
229
  /**
@@ -239,11 +239,11 @@ export const PI_DEFAULT_MODELS = {
239
239
  azure: "gpt-5.4",
240
240
  "openai-codex": "gpt-6.1-sol",
241
241
  radius: "balanced",
242
- nvidia: "nvidia/nemotron-3-ultra-550b-a55b",
242
+ nvidia: "nvidia/nemotron-3.5-super-vl-120b-a12b",
243
243
  deepseek: "deepseek-v4-pro",
244
244
  google: "gemini-3.1-pro-preview",
245
245
  "google-vertex": "gemini-3.1-pro-preview",
246
- "github-copilot": "gpt-5.4",
246
+ "github-copilot": "gpt-6.1-sol",
247
247
  openrouter: "moonshotai/kimi-k2.6",
248
248
  "vercel-ai-gateway": "zai/glm-5.1",
249
249
  xai: "grok-4.7",
@@ -269,10 +269,10 @@ export const PI_DEFAULT_MODELS = {
269
269
  "qwen-token-plan": "qwen3.7-max",
270
270
  "qwen-token-plan-cn": "qwen3.7-max",
271
271
  "qwen-token-plan-individual": "qwen3.8-max",
272
- xiaomi: "mimo-v2.5-pro",
273
- "xiaomi-token-plan-cn": "mimo-v2.5-pro",
274
- "xiaomi-token-plan-ams": "mimo-v2.5-pro",
275
- "xiaomi-token-plan-sgp": "mimo-v2.5-pro",
272
+ xiaomi: "mimo-v2.6-pro",
273
+ "xiaomi-token-plan-cn": "mimo-v2.6-pro",
274
+ "xiaomi-token-plan-ams": "mimo-v2.6-pro",
275
+ "xiaomi-token-plan-sgp": "mimo-v2.6-pro",
276
276
  };
277
277
  /** One minimal request with the key under test. */
278
278
  async function verifyApiKey(models, providerId, verifyWith, notify, signal) {
@@ -314,7 +314,7 @@ async function verifyApiKey(models, providerId, verifyWith, notify, signal) {
314
314
  }
315
315
  return probe.state;
316
316
  }
317
- /** {@link LoginIO} as pi-ai's `AuthInteraction`: what {@link login} drives for the terminal. */
317
+ /** {@link LoginIO} as pi-ai's `AuthInteraction`: what {@link loginOver} drives for the terminal. */
318
318
  function terminalInteraction(io, signal) {
319
319
  return {
320
320
  ...(signal ? { signal } : {}),
@@ -332,7 +332,7 @@ function terminalInteraction(io, signal) {
332
332
  ...(p.signal ? { signal: p.signal } : {}),
333
333
  });
334
334
  if (v === undefined)
335
- throw new LoginCancelled("cancelled");
335
+ throw new SignInCancelled("cancelled");
336
336
  return v;
337
337
  },
338
338
  notify: (e) => {
@@ -354,7 +354,7 @@ function terminalInteraction(io, signal) {
354
354
  }
355
355
  /**
356
356
  * `fastagent login` in a terminal: ask only what was not given, as pi's `/login` asks it (how to sign in, then the
357
- * provider; a provider named by its id or its name, then how, when it offers both), then {@link login}.
357
+ * provider; a provider named by its id or its name, then how, when it offers both), then {@link loginOver}.
358
358
  */
359
359
  export async function loginFlow(io, options) {
360
360
  const offered = await loginOptionsOver(options.statusFrom ?? options.authPath, options.providers);
@@ -383,7 +383,7 @@ export async function chooseLogin(offered, given, menus) {
383
383
  const value = await menus.select(message, options);
384
384
  const option = from.find((o) => by(o) === value);
385
385
  if (option === undefined)
386
- throw new LoginCancelled("nothing selected");
386
+ throw new SignInCancelled("nothing selected");
387
387
  return option;
388
388
  };
389
389
  if (given.provider !== undefined) {
@@ -420,7 +420,7 @@ export async function chooseLogin(offered, given, menus) {
420
420
  if (way === "radius" && radius)
421
421
  return radius;
422
422
  if (way !== "oauth" && way !== "api_key")
423
- throw new LoginCancelled("no authentication method selected");
423
+ throw new SignInCancelled("no authentication method selected");
424
424
  method = way;
425
425
  }
426
426
  const candidates = offered.filter((o) => o.method === method);
@@ -11,10 +11,11 @@ export declare function providerOf(spec: string): string;
11
11
  /** Every chat model `models` offers, as `"provider/modelId"`, sorted. */
12
12
  export declare function listModels(models: Models): string[];
13
13
  /**
14
- * The built-ins plus `providers` (a same id replaces a built-in), over any credential store. With `catalogs`, the lists
15
- * its refresh brings up to date; without, the ones this pi-ai was built with.
14
+ * The built-ins plus `providers` (a same id replaces a built-in), over any credential store, in `environment`: what
15
+ * the agent's environment adds to this process's, read before it by a provider's auth and by each request. With
16
+ * `catalogs`, the lists its refresh brings up to date; without, the ones this pi-ai was built with.
16
17
  */
17
- export declare function modelsOver(credentials: CredentialStore, providers?: readonly Provider[], catalogs?: Catalogs): Models;
18
+ export declare function modelsOver(credentials: CredentialStore, providers?: readonly Provider[], catalogs?: Catalogs, environment?: Readonly<Record<string, string>>): Models;
18
19
  /**
19
20
  * Whether `env` alone satisfies a provider, by pi's own check, and with what when the provider says (its API-key
20
21
  * variable, or an ambient source such as an AWS profile); undefined when it does not. A provider in code may
@@ -21,19 +21,62 @@ export function listModels(models) {
21
21
  return specs.sort();
22
22
  }
23
23
  /**
24
- * The built-ins plus `providers` (a same id replaces a built-in), over any credential store. With `catalogs`, the lists
25
- * its refresh brings up to date; without, the ones this pi-ai was built with.
24
+ * pi's own reading of an environment (a blank variable is unset) through `read` instead of this process's: an agent's
25
+ * environment is its own when the process runs several. Files a source points at are this machine's unless
26
+ * `fileExists` says otherwise.
26
27
  */
27
- export function modelsOver(credentials, providers = [], catalogs) {
28
+ function authContextIn(read, fileExists = defaultProviderAuthContext().fileExists) {
29
+ return {
30
+ env: async (name) => {
31
+ const value = read(name);
32
+ return value?.trim() ? value : undefined;
33
+ },
34
+ fileExists,
35
+ };
36
+ }
37
+ /**
38
+ * `provider` with `environment` given to each request, as its auth reads it: pi resolves a provider's key from the
39
+ * auth context, but a request reads its region, endpoint or ambient key (Bedrock, Azure, Vertex) from the request's
40
+ * own `env`, which it otherwise takes from this process.
41
+ */
42
+ function inEnvironment(provider, environment) {
43
+ const withEnv = (options) => ({ ...options, env: { ...environment, ...options?.env } });
44
+ const { fetchDeferred, cancelDeferred, generateImages, classify } = provider;
45
+ return {
46
+ ...provider,
47
+ stream: (model, context, options) => provider.stream(model, context, withEnv(options)),
48
+ streamSimple: (model, context, options) => provider.streamSimple(model, context, withEnv(options)),
49
+ ...(fetchDeferred && {
50
+ fetchDeferred: (model, handle, options) => fetchDeferred.call(provider, model, handle, withEnv(options)),
51
+ }),
52
+ ...(cancelDeferred && {
53
+ cancelDeferred: (model, handle, options) => cancelDeferred.call(provider, model, handle, withEnv(options)),
54
+ }),
55
+ ...(generateImages && {
56
+ generateImages: (model, context, options) => generateImages.call(provider, model, context, withEnv(options)),
57
+ }),
58
+ ...(classify && {
59
+ classify: (model, context, options) => classify.call(provider, model, context, withEnv(options)),
60
+ }),
61
+ };
62
+ }
63
+ /**
64
+ * The built-ins plus `providers` (a same id replaces a built-in), over any credential store, in `environment`: what
65
+ * the agent's environment adds to this process's, read before it by a provider's auth and by each request. With
66
+ * `catalogs`, the lists its refresh brings up to date; without, the ones this pi-ai was built with.
67
+ */
68
+ export function modelsOver(credentials, providers = [], catalogs, environment = {}) {
28
69
  const models = createModels({
29
70
  credentials,
30
- authContext: defaultProviderAuthContext(),
71
+ // As a request reads it (pi's getProviderEnvValue): the request's env first, then this process's.
72
+ authContext: authContextIn((name) => environment[name] || process.env[name]),
31
73
  ...(catalogs ? { modelsStore: catalogs.fetched } : {}),
32
74
  });
33
- for (const builtIn of builtinProviders())
34
- models.setProvider(catalogs ? catalogs.over(builtIn) : builtIn);
75
+ for (const builtIn of builtinProviders()) {
76
+ models.setProvider(inEnvironment(catalogs ? catalogs.over(builtIn) : builtIn, environment));
77
+ }
35
78
  for (const provider of providers)
36
- models.setProvider(provider);
79
+ models.setProvider(inEnvironment(provider, environment));
37
80
  return models;
38
81
  }
39
82
  /**
@@ -51,11 +94,7 @@ export function modelsOver(credentials, providers = [], catalogs) {
51
94
  export async function environmentAuth(providerId, env, fileExists = defaultProviderAuthContext().fileExists, providers = []) {
52
95
  const models = builtinModels({
53
96
  credentials: new InMemoryCredentialStore(),
54
- authContext: {
55
- // pi's own reading of a variable (a blank one is unset), over the given environment.
56
- env: async (name) => (env[name]?.trim() ? env[name] : undefined),
57
- fileExists,
58
- },
97
+ authContext: authContextIn((name) => env[name], fileExists),
59
98
  });
60
99
  for (const provider of providers)
61
100
  models.setProvider(provider);
@@ -22,6 +22,7 @@ import { admitSettings } from "./defaults.js";
22
22
  import { USER_ENTRY, imageAt, isPublished, parseImageRef } from "./entries.js";
23
23
  import { sessionEvents } from "./events.js";
24
24
  import { storageIds } from "./ids.js";
25
+ import { giveUpOnParked } from "./parked.js";
25
26
  import { isInput, queuedInputs, requestIdFor, usageOfState } from "./messages.js";
26
27
  import { durableReads } from "./reads.js";
27
28
  import { releaseWaitingQueues } from "./release.js";
@@ -42,6 +43,7 @@ export async function openDurableAgent(options) {
42
43
  try {
43
44
  const ids = await storageIds(harness, context);
44
45
  const sessions = await Effect.runPromise(Scope.provide(trackSessions(harness, options.storage, context), lifetime));
46
+ await giveUpOnParked(harness, context);
45
47
  // Before `resume()`, which runs an earlier process's work. An abort enables the scheduler too, but commits first, so
46
48
  // the aborted work does not run (measured).
47
49
  const unsettled = (await harness.inspect(context)).submissions.filter(isInput);
@@ -177,6 +179,8 @@ function durableAgent(harness, ids, sessions, lifetime, options) {
177
179
  provider: model.provider,
178
180
  modelId: model.id,
179
181
  ...(model.name ? { name: model.name } : {}),
182
+ // pi reads a window a model does not declare (a virtual model's, before its first answer) as 0.
183
+ ...(model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
180
184
  })),
181
185
  ...(options.model === undefined
182
186
  ? {}
@@ -11,6 +11,8 @@ const StorageDoc = defineDoc({
11
11
  version: 1,
12
12
  scope: "session",
13
13
  initial: () => ({ incarnation: "" }),
14
+ // Written once, when the storage is first opened: no deltas pile up to bound.
15
+ checkpointWhen: () => false,
14
16
  });
15
17
  export async function storageIds(harness, context) {
16
18
  const incarnation = await harness.commit(async (tx) => {
@@ -82,7 +82,14 @@ export interface AssembleAgentOptions {
82
82
  settings?: HarnessSettings;
83
83
  /** Where the instance's state is: given by a serve, whose scheduler fires the wake-ups `wake` keeps there. */
84
84
  stateRoot?: string;
85
+ /** Whether that scheduler runs now: an app's `openAgent` runs one only while it serves. Default always. */
86
+ wakeTicking?: () => boolean;
85
87
  unfinished?: Unfinished;
88
+ /**
89
+ * What the agent's environment adds to this process's, for the commands it runs: its value file's variables and its
90
+ * packages' PATH.
91
+ */
92
+ environment?: Readonly<Record<string, string>>;
86
93
  }
87
94
  /** Open the definition as an Agent over `models`: the coding tools, then its own. */
88
95
  export declare function assembleAgent(definition: AgentDefinition, options: AssembleAgentOptions): Promise<DurableAgent>;
@@ -309,8 +309,9 @@ export async function assembleAgent(definition, options) {
309
309
  }));
310
310
  for (const extension of code.extensions)
311
311
  registry.install(extension);
312
- if (options.stateRoot !== undefined && !ownWake)
313
- registry.install(wakeExtension(options.stateRoot));
312
+ if (options.stateRoot !== undefined && !ownWake) {
313
+ registry.install(wakeExtension(options.stateRoot, options.wakeTicking === undefined ? {} : { ticking: options.wakeTicking }));
314
+ }
314
315
  const thinkingLevel = options.thinkingLevel ?? config.thinkingLevel;
315
316
  return openDurableAgent({
316
317
  storage: options.storage,
@@ -323,7 +324,7 @@ export async function assembleAgent(definition, options) {
323
324
  ...(options.settings === undefined ? {} : { settings: options.settings }),
324
325
  ...(options.unfinished === undefined ? {} : { unfinished: options.unfinished }),
325
326
  // What the agent runs is not `dev`'s own serving, even when `dev` is what runs the agent.
326
- env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? dir, shellEnv: { [DEV_SERVE_ENV]: undefined } }),
327
+ env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? dir, shellEnv: { ...options.environment, [DEV_SERVE_ENV]: undefined } }),
327
328
  commands: () => readCommands(dir),
328
329
  });
329
330
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What becomes of a task pi-durable's crash-loop guard parked (`settings.restartLimit`: cut off by a crash that many
3
+ * times in a row, with no progress between). Left parked, the message it was answering stays `running` for ever with
4
+ * nothing to say why (SPEC.md §5.4 ends a message that cannot be answered `failed`). Parked is a state the task keeps,
5
+ * which `inspect()` shows as blocked for `restart_limit`: it is read from there, and not from the one `task_parked`
6
+ * report an open makes, so a process that dies half way, or a step that fails, is finished by the next open.
7
+ */
8
+ import type { Context } from "@earendil-works/chord/context";
9
+ import { type Harness } from "@earendil-works/pi-durable";
10
+ /**
11
+ * Settle the messages each parked task was answering `unanswered` for {@link RESTART_LIMIT_REASON}, and abort the task,
12
+ * which frees its session. The settlement comes first: an abort alone settles them `aborted`, which reads as a
13
+ * cancellation nobody asked for, and the first settlement of a submission stands (measured). Both steps can be run
14
+ * again over their own result: the run stays until the abort's handler ends it, so a task left half way is found
15
+ * parked, and finished, by the next open. A parked task that is not its conversation's run (a tool's, a compaction's)
16
+ * is only aborted: the run goes on without it.
17
+ */
18
+ export declare function giveUpOnParked(harness: Harness, context: Context): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { LiveDoc } from "@earendil-works/pi-durable";
2
+ import { log } from "../../log.js";
3
+ const RESTART_LIMIT_REASON = "restart_limit";
4
+ const DETAIL = "The process was cut off repeatedly while answering this message, so it was given up on";
5
+ /**
6
+ * Settle the messages each parked task was answering `unanswered` for {@link RESTART_LIMIT_REASON}, and abort the task,
7
+ * which frees its session. The settlement comes first: an abort alone settles them `aborted`, which reads as a
8
+ * cancellation nobody asked for, and the first settlement of a submission stands (measured). Both steps can be run
9
+ * again over their own result: the run stays until the abort's handler ends it, so a task left half way is found
10
+ * parked, and finished, by the next open. A parked task that is not its conversation's run (a tool's, a compaction's)
11
+ * is only aborted: the run goes on without it.
12
+ */
13
+ export async function giveUpOnParked(harness, context) {
14
+ const parked = (await harness.inspect(context)).tasks.filter(({ state }) => state.kind === "blocked" && state.reason === RESTART_LIMIT_REASON);
15
+ for (const { record } of parked) {
16
+ await harness.commit(async (tx) => {
17
+ const run = (await tx.doc(LiveDoc, record.conversationId))?.run;
18
+ if (run?.taskId !== record.id)
19
+ return;
20
+ for (const id of run.inputs) {
21
+ tx.settleSubmission(id, { status: "unanswered", reason: RESTART_LIMIT_REASON, detail: DETAIL });
22
+ }
23
+ }, context);
24
+ await harness.abortTask(record.id, context);
25
+ log.warn(`[fastagent] gave up on ${record.kind} task ${record.id} of conversation ${record.conversationId}: it was parked`);
26
+ }
27
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Temporary: pi-durable 1.3.0 leaves a conversation's queued messages waiting after a run that did not answer (a model
2
+ * Temporary: pi-durable leaves a conversation's queued messages waiting after a run that did not answer (a model
3
3
  * failure, an abort, a message sent while an aborted run was still ending) until another submission places them, while
4
4
  * the Agent API takes them whatever the message before them ended with (SPEC.md §5.3). A write of FastAgent's own
5
- * kind places them. Remove once pi-durable ships `afterFailedRun` and it covers aborts too.
5
+ * kind places them. pi-durable 1.4.0's `settings.afterFailedRun` covers a model failure (`model_error`, `no_model`)
6
+ * and not an abort: remove this once it does.
6
7
  */
7
8
  import type { Context } from "@earendil-works/chord/context";
8
9
  import { type Harness } from "@earendil-works/pi-durable";
@@ -1,7 +1,8 @@
1
- import type { CredentialStore, ModelThinkingLevel } from "@earendil-works/pi-ai";
1
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
2
2
  import { type Harness } from "@earendil-works/pi-durable";
3
3
  import type { CredentialSourceOptions } from "../models/credentials.ts";
4
4
  import { type ModelEnvironment } from "../models/environment.ts";
5
+ import type { CredentialStore } from "../../sign-in-api.ts";
5
6
  import { type ResolvedContent } from "../../content/resolve.ts";
6
7
  import type { Agent } from "../../agent-api.ts";
7
8
  import { type Service } from "../../service.ts";
@@ -22,6 +23,10 @@ export interface OpenServingOptions extends CredentialSourceOptions {
22
23
  storage?: ServingStorage;
23
24
  /** What becomes of the messages an earlier process left unfinished in the storage. Default `"resume"`. */
24
25
  unfinished?: Unfinished;
26
+ /** The instance's state root, over `FASTAGENT_STATE_DIR` and the directory's own. */
27
+ stateRoot?: string;
28
+ /** Whether a clock fires the instance's wake-ups now (`assembleAgent`'s `wakeTicking`). Default always. */
29
+ wakeTicking?: () => boolean;
25
30
  }
26
31
  type ServingStorage = {
27
32
  instance: StorageLocking;
@@ -70,6 +75,11 @@ export interface OpenAgentOptions {
70
75
  model?: string;
71
76
  /** Where provider credentials are kept, instead of the agent's own `.secrets/auth.json` over the machine's. */
72
77
  credentials?: CredentialStore;
78
+ /**
79
+ * Where this instance keeps its sessions, its channels' and schedules' state and its model lists, instead of
80
+ * `FASTAGENT_STATE_DIR` or the agent's `.state/`: a host's own instance of an agent beside `fastagent dev`'s.
81
+ */
82
+ stateDir?: string;
73
83
  }
74
84
  /**
75
85
  * Open the agent directory `dir` the way `dev` and `start` do before they serve: its definition, its code built with
@@ -8,11 +8,13 @@ import { join } from "node:path";
8
8
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
9
9
  import { MemoryStorage } from "@earendil-works/pi-durable";
10
10
  import { modelEnvironment } from "../models/environment.js";
11
+ import { piCredentials } from "../models/host.js";
11
12
  import { loadContent } from "../../content/file.js";
12
13
  import { absentNotice, cloneContent, contentAbsentHere, resolveContent, } from "../../content/resolve.js";
13
- import { enterEnvironment } from "../../environment/mise.js";
14
+ import { agentValues } from "../../value-file.js";
15
+ import { prepareEnvironment } from "../../environment/mise.js";
14
16
  import { log } from "../../log.js";
15
- import { resolveStateRoot } from "../../paths.js";
17
+ import { resolveOverridePath, resolveStateRoot } from "../../paths.js";
16
18
  import { serveAgent } from "../../service.js";
17
19
  import { buildDefinition, assembleAgent, prepareDefinition, } from "./open.js";
18
20
  import { openLockedStorage } from "./storage.js";
@@ -24,12 +26,12 @@ const INSTANCE_HOLDERS = "another `fastagent dev`, `start` or `schedules` comman
24
26
  * up to date in place where git can do so without touching the agent's work, first; then resolved again as what is now
25
27
  * on disk.
26
28
  */
27
- async function contentForThisRun(dir) {
29
+ async function contentForThisRun(dir, env) {
28
30
  const declaration = loadContent(dir);
29
31
  for (const entry of resolveContent(dir, declaration)) {
30
32
  if (entry.kind !== "github" || !entry.clone)
31
33
  continue;
32
- const done = await cloneContent(entry);
34
+ const done = await cloneContent(entry, env);
33
35
  if (done.outcome === "kept") {
34
36
  log.warn(`[fastagent] content "${entry.name}": the clone in ${entry.location} is kept as it is, ${done.reason}`);
35
37
  }
@@ -67,27 +69,33 @@ function openStorage(stateRoot, storage) {
67
69
  export async function openServing(directory, options = {}) {
68
70
  const definition = await prepareDefinition(directory);
69
71
  const { dir } = definition;
70
- const code = buildDefinition(definition);
72
+ // The agent's environment is ONE value every reader below is given, and this process's is never written: a process
73
+ // may run several agents, each with its own values and packages.
74
+ const values = agentValues(dir);
75
+ const code = buildDefinition(definition, { ...process.env, ...values });
71
76
  // The commands the agent runs, as `context.json`'s environment declares them, before anything can run one.
72
- await enterEnvironment(dir);
73
- const content = await contentForThisRun(dir);
77
+ const environment = { ...values, ...(await prepareEnvironment(dir, { ...process.env, ...values })) };
78
+ const env = { ...process.env, ...environment };
79
+ const content = await contentForThisRun(dir, env);
80
+ const stateRoot = options.stateRoot ?? resolveStateRoot(dir, env);
74
81
  const models = await modelEnvironment(dir, {
75
82
  ...(options.authPath === undefined ? {} : { authPath: options.authPath }),
76
83
  ...(options.credentialStore === undefined ? {} : { credentialStore: options.credentialStore }),
77
- }, code.providers);
78
- const stateRoot = resolveStateRoot(dir);
84
+ }, code.providers, { stateRoot, environment });
79
85
  const chosen = options.storage ?? { instance: "posix" };
80
86
  const storage = await openStorage(stateRoot, chosen);
81
87
  const agent = await assembleAgent(definition, {
82
88
  storage,
83
89
  code,
84
90
  content,
91
+ environment,
85
92
  models: models.models,
86
93
  ...(models.catalogProblem === undefined ? {} : { catalogProblem: models.catalogProblem }),
87
94
  ...(typeof chosen === "object" && "instance" in chosen ? { stateRoot } : {}),
88
95
  ...(options.model === undefined ? {} : { model: options.model }),
89
96
  ...(options.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }),
90
97
  ...(options.unfinished === undefined ? {} : { unfinished: options.unfinished }),
98
+ ...(options.wakeTicking === undefined ? {} : { wakeTicking: options.wakeTicking }),
91
99
  }).catch(async (error) => {
92
100
  // An Agent that did not open does not own the storage, and its lock would refuse this process's next attempt.
93
101
  await storage.close(BACKGROUND_CONTEXT);
@@ -105,15 +113,26 @@ export async function openServing(directory, options = {}) {
105
113
  schedules: code.schedules,
106
114
  };
107
115
  }
116
+ /** `stateDir` read as `FASTAGENT_STATE_DIR` is: `~` is the home directory, a relative one resolves against the cwd. */
117
+ function stateDirOf(stateDir) {
118
+ const resolved = resolveOverridePath(stateDir);
119
+ if (resolved === undefined)
120
+ throw new Error("stateDir needs the directory the instance's state is kept in");
121
+ return resolved;
122
+ }
108
123
  /**
109
124
  * Open the agent directory `dir` the way `dev` and `start` do before they serve: its definition, its code built with
110
125
  * the secrets it declares, its environment, its content and the instance's storage, which one process holds at a time.
111
126
  * Nothing reaches the agent until `serve`.
112
127
  */
113
128
  export async function openAgent(dir, options = {}) {
129
+ // An app may never serve the agent, and then no clock fires a wake-up: `wake` is refused until it does.
130
+ let ticking = false;
114
131
  const opened = await openServing(dir, {
132
+ wakeTicking: () => ticking,
115
133
  ...(options.model === undefined ? {} : { model: options.model }),
116
- ...(options.credentials === undefined ? {} : { credentialStore: options.credentials }),
134
+ ...(options.credentials === undefined ? {} : { credentialStore: piCredentials(options.credentials) }),
135
+ ...(options.stateDir === undefined ? {} : { stateRoot: stateDirOf(options.stateDir) }),
117
136
  });
118
137
  const { agent } = opened;
119
138
  let served;
@@ -130,10 +149,21 @@ export async function openAgent(dir, options = {}) {
130
149
  return Promise.reject(new Error("this agent is closed"));
131
150
  if (served !== undefined)
132
151
  return Promise.reject(new Error("this agent is already served"));
133
- const serving = serveAgent(opened, serveOptions);
152
+ const serving = serveAgent(opened, serveOptions).then((service) => ({
153
+ ...service,
154
+ close: () => {
155
+ // Its clock stops with it, whichever closes it: a wake-up kept after this is one nothing fires.
156
+ ticking = false;
157
+ return service.close();
158
+ },
159
+ }));
134
160
  served = serving;
161
+ serving.then(() => {
162
+ if (served === serving && closing === undefined)
163
+ ticking = true;
164
+ },
135
165
  // A serve that failed to start rolled itself back, so the agent may be served again.
136
- serving.catch(() => {
166
+ () => {
137
167
  if (served === serving)
138
168
  served = undefined;
139
169
  });