@fastagent-sh/fastagent 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) 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 +50 -50
  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 +2 -0
  39. package/dist/harnesses/pi-durable/open.d.ts +7 -0
  40. package/dist/harnesses/pi-durable/open.js +4 -3
  41. package/dist/harnesses/pi-durable/serve.d.ts +11 -1
  42. package/dist/harnesses/pi-durable/serve.js +42 -12
  43. package/dist/harnesses/pi-durable/wake.d.ts +8 -1
  44. package/dist/harnesses/pi-durable/wake.js +10 -1
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.js +3 -0
  47. package/dist/scaffold/add-channel.js +1 -1
  48. package/dist/sign-in-api.d.ts +147 -0
  49. package/dist/sign-in-api.js +9 -0
  50. package/dist/tunnel.js +2 -1
  51. package/dist/value-file.d.ts +19 -0
  52. package/dist/value-file.js +61 -5
  53. package/package.json +1 -1
@@ -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
  /**
@@ -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);
@@ -177,6 +177,8 @@ function durableAgent(harness, ids, sessions, lifetime, options) {
177
177
  provider: model.provider,
178
178
  modelId: model.id,
179
179
  ...(model.name ? { name: model.name } : {}),
180
+ // pi reads a window a model does not declare (a virtual model's, before its first answer) as 0.
181
+ ...(model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
180
182
  })),
181
183
  ...(options.model === undefined
182
184
  ? {}
@@ -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
  }
@@ -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
  });
@@ -1,4 +1,11 @@
1
1
  import { type Extension } from "@earendil-works/pi-durable";
2
2
  export declare const WAKE_EXTENSION = "fastagent.wake";
3
3
  export declare const WAKE_TOOLS: string[];
4
- export declare function wakeExtension(stateRoot: string, now?: () => Date): Extension;
4
+ /**
5
+ * `ticking` says whether a clock fires the wake-ups kept in `stateRoot` now; a wake-up nothing would fire is refused.
6
+ * Without it, one always does: `dev`, `start` and AgentCore serve whatever they open.
7
+ */
8
+ export declare function wakeExtension(stateRoot: string, options?: {
9
+ now?: () => Date;
10
+ ticking?: () => boolean;
11
+ }): Extension;
@@ -12,7 +12,14 @@ export const WAKE_TOOLS = ["wake", "unwake"];
12
12
  const said = (text) => ({ output: [{ type: "text", text }] });
13
13
  const refused = (text) => ({ output: [{ type: "text", text }], isError: true });
14
14
  const NOT_A_SESSION = "wake-ups belong to a session, and this conversation is not one (a subagent's): nothing would wake it.";
15
- export function wakeExtension(stateRoot, now = () => new Date()) {
15
+ const NO_CLOCK = "nothing fires wake-ups for this agent now: the app that opened it does not serve it, so no clock runs. Do the " +
16
+ "work now, or say it cannot be scheduled.";
17
+ /**
18
+ * `ticking` says whether a clock fires the wake-ups kept in `stateRoot` now; a wake-up nothing would fire is refused.
19
+ * Without it, one always does: `dev`, `start` and AgentCore serve whatever they open.
20
+ */
21
+ export function wakeExtension(stateRoot, options = {}) {
22
+ const now = options.now ?? (() => new Date());
16
23
  return defineExtension({
17
24
  name: WAKE_EXTENSION,
18
25
  tools: [
@@ -37,6 +44,8 @@ export function wakeExtension(stateRoot, now = () => new Date()) {
37
44
  }),
38
45
  }),
39
46
  execute: async (args, api, context) => {
47
+ if (options.ticking?.() === false)
48
+ return refused(NO_CLOCK);
40
49
  if ((args.in === undefined) === (args.cron === undefined)) {
41
50
  return refused("pass exactly one of `in` (one-shot) or `cron` (recurring).");
42
51
  }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * The package's one entry (SPEC.md §8), besides each first-party channel's: the Agent API, opening an agent
3
- * directory and serving it, and what an author writes.
3
+ * directory and serving it, what a host embedding agents needs beside them (creating one, model credentials and
4
+ * sign-in), and what an author writes.
4
5
  */
5
6
  export type { Agent, AgentErrorData, AgentInfo, CompactOptions, ContentPart, CreateSessionOptions, EntriesOptions, Entry, ErrorCode, Json, ListOptions, Message, MessageKey, MessageStatus, MessageStreamEvent, ModelRef, Page, SendRequest, Session, SessionChanges, SessionEvent, SessionSettings, SessionStatus, ThinkingLevel, Tokens, Usage, WaitOptions, } from "./agent-api.ts";
6
7
  export { AgentError, LIMITS, hasEnded } from "./agent-api.ts";
7
8
  export { openAgent, type LocalAgent, type OpenAgentOptions, type ServeAgentOptions, } from "./harnesses/pi-durable/serve.ts";
8
9
  export type { Service } from "./service.ts";
9
- /** What `openAgent`'s `credentials` takes: where provider credentials are kept, pi-ai's. */
10
- export type { CredentialStore } from "@earendil-works/pi-ai";
10
+ export { createAgent, type CreatedAgent } from "./authoring.ts";
11
+ export { SignInCancelled, type Credential, type CredentialStore, type ProviderAuth, type SignInEvent, type SignInInteraction, type SignInMethod, type SignInOption, type SignInPrompt, type SignInRequest, type SignInResult, } from "./sign-in-api.ts";
12
+ export { credentialFile, providerAuth, signIn, signInOptions } from "./harnesses/models/host.ts";
11
13
  export { defineAgent, defineSchedule, needs, type AgentConfig, type Needs, type SecretSpec, type SecretValues, } from "./harnesses/pi-durable/define.ts";
12
14
  export type { ScheduleConfig } from "./schedule/schedule.ts";
13
15
  export { defineChannel, type ChannelContext, type ChannelHandler, type ChannelModule, type ForkSource, type LongConnection, type LongConnectionChannelModule, type Routes, } from "./channel.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export { AgentError, LIMITS, hasEnded } from "./agent-api.js";
2
2
  export { openAgent, } from "./harnesses/pi-durable/serve.js";
3
+ export { createAgent } from "./authoring.js";
4
+ export { SignInCancelled, } from "./sign-in-api.js";
5
+ export { credentialFile, providerAuth, signIn, signInOptions } from "./harnesses/models/host.js";
3
6
  export { defineAgent, defineSchedule, needs, } from "./harnesses/pi-durable/define.js";
4
7
  export { defineChannel, } from "./channel.js";
5
8
  // What a channel reads a request with, answers with, and compares its platform's secret with (constant time).
@@ -8,7 +8,7 @@ import { detectRuntime } from "../runtime.js";
8
8
  import { assertInsideAgentDir, exists } from "../paths.js";
9
9
  import { channelBundleFiles, channelTemplate } from "./templates.js";
10
10
  import { writeValues } from "../value-file.js";
11
- import { envExamplePath } from "../env.js";
11
+ import { envExamplePath } from "../value-file.js";
12
12
  import { FEISHU_AGENT_SCOPES } from "../channels/feishu/setup-mode.js";
13
13
  import { slackBotScopes } from "../channels/slack/manifest.js";
14
14
  const AGENT_SCOPE_LIST = FEISHU_AGENT_SCOPES.map((entry) => entry.request).join(", ");