@fastagent-sh/fastagent 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/kit/preview-kit.d.ts +6 -1
  3. package/dist/channels/kit/preview-kit.js +21 -4
  4. package/dist/channels/slack/preview.js +2 -1
  5. package/dist/cli/commands/info.js +7 -7
  6. package/dist/cli/commands/login.js +2 -2
  7. package/dist/cli/commands/models.js +3 -3
  8. package/dist/cli/invoke-stream.js +14 -0
  9. package/dist/cli/shared.d.ts +2 -1
  10. package/dist/cli/shared.js +6 -7
  11. package/dist/deploy/preflight.js +19 -1
  12. package/dist/dev-supervisor.js +2 -2
  13. package/dist/engines/pi/agent-models.d.ts +14 -1
  14. package/dist/engines/pi/agent-models.js +35 -8
  15. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  16. package/dist/engines/pi/agent-session-factory.js +137 -159
  17. package/dist/engines/pi/auth.js +6 -94
  18. package/dist/engines/pi/create.d.ts +16 -9
  19. package/dist/engines/pi/create.js +71 -38
  20. package/dist/engines/pi/invoke-session.js +82 -16
  21. package/dist/engines/pi/locked-file.d.ts +9 -0
  22. package/dist/engines/pi/locked-file.js +102 -0
  23. package/dist/engines/pi/login.d.ts +7 -1
  24. package/dist/engines/pi/login.js +22 -6
  25. package/dist/engines/pi/machine.d.ts +23 -0
  26. package/dist/engines/pi/machine.js +24 -2
  27. package/dist/engines/pi/models.d.ts +47 -42
  28. package/dist/engines/pi/models.js +154 -67
  29. package/dist/engines/pi/open.d.ts +29 -12
  30. package/dist/engines/pi/open.js +52 -21
  31. package/dist/engines/pi/openai-account-models.d.ts +35 -0
  32. package/dist/engines/pi/openai-account-models.js +116 -0
  33. package/dist/engines/pi/report.d.ts +3 -0
  34. package/dist/engines/pi/report.js +11 -0
  35. package/dist/engines/pi/session-builder.js +6 -22
  36. package/dist/engines/pi/session-control.d.ts +4 -4
  37. package/dist/engines/pi/session-control.js +72 -40
  38. package/dist/engines/pi/session-settings.d.ts +9 -0
  39. package/dist/engines/pi/session-settings.js +17 -0
  40. package/dist/engines/pi/session-store.d.ts +2 -2
  41. package/dist/engines/pi/tool-context.d.ts +3 -5
  42. package/dist/engines/pi/tool-context.js +4 -16
  43. package/dist/engines/pi/tool.d.ts +32 -21
  44. package/dist/engines/pi/tool.js +21 -25
  45. package/dist/engines/pi/turn-kit.d.ts +18 -9
  46. package/dist/engines/pi/turn-kit.js +104 -27
  47. package/dist/pi.d.ts +1 -1
  48. package/dist/pi.js +1 -1
  49. package/dist/session.d.ts +60 -8
  50. package/dist/session.js +5 -1
  51. package/package.json +5 -5
  52. package/dist/engines/pi/search-tools.d.ts +0 -4
  53. package/dist/engines/pi/search-tools.js +0 -109
@@ -1,3 +1,6 @@
1
+ /** The turn mechanism's ENGINE-agnostic half: the parts that describe a turn rather than pi. */
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { contentText } from "@earendil-works/pi-ai";
1
4
  import { ABORTED_CODE } from "../../agent.js";
2
5
  import { log } from "../../log.js";
3
6
  export function inProcessLease() {
@@ -71,19 +74,38 @@ function messageSignal(message) {
71
74
  return {};
72
75
  }
73
76
  /**
74
- * Terminal mapping, decided by the resolved message's stopReason: pi's `prompt()` RESOLVES a message with stopReason
77
+ * How an answer ended, read off the stopReason pi records on the message. The ONE reading: `message_finished`,
78
+ * the `assistant` entry `entries()` publishes, and the invoke terminal all derive from it, so the live outcome and
79
+ * the recorded one cannot disagree.
80
+ */
81
+ export function answerOutcome(message) {
82
+ const error = message.errorMessage ? { error: { message: message.errorMessage } } : {};
83
+ switch (message.stopReason) {
84
+ case "error":
85
+ return { status: "failed", ...error };
86
+ case "aborted":
87
+ return { status: "aborted", ...error };
88
+ case "length":
89
+ return { status: "truncated" };
90
+ default:
91
+ return undefined;
92
+ }
93
+ }
94
+ /**
95
+ * Terminal mapping, decided by the resolved message's outcome: pi's `prompt()` RESOLVES a message with stopReason
75
96
  * "error"/"aborted" rather than throwing, so relying on catch alone would miss that whole failure class and violate
76
- * SPEC MUST 1.
97
+ * SPEC MUST 1. A truncated final answer still completes the run.
77
98
  */
78
99
  export function toTerminal(message) {
79
- if (message.stopReason === "aborted") {
100
+ const outcome = answerOutcome(message);
101
+ if (outcome?.status === "aborted") {
80
102
  // A deliberate stop (a control-plane or consumer abort), not an error — see {@link ABORTED_CODE} for the consumer
81
103
  // contract (design §6).
82
- const details = message.errorMessage ?? "run aborted";
104
+ const details = outcome.error?.message ?? "run aborted";
83
105
  return { type: "failed", details, retryable: false, code: ABORTED_CODE };
84
106
  }
85
- if (message.stopReason === "error") {
86
- const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
107
+ if (outcome?.status === "failed") {
108
+ const details = outcome.error?.message ?? "model stopped: error";
87
109
  return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
88
110
  }
89
111
  return { type: "completed" };
@@ -119,27 +141,82 @@ export async function toPiPromptOptions(prompt, delivery) {
119
141
  }));
120
142
  return { images };
121
143
  }
122
- /** The SPEC projection of the rich stream. */
123
- export function projectAgentEvent(se) {
124
- switch (se.type) {
125
- case "message_delta": {
126
- const d = se.data;
127
- return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
128
- }
129
- case "tool_started": {
130
- const d = se.data;
131
- return { type: "tool_started", id: d.id, name: d.name, args: d.args };
132
- }
133
- case "tool_finished": {
134
- const d = se.data;
135
- return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
136
- }
137
- case "retry_scheduled": {
138
- // `operation` (compaction | branch_summary) stays session-plane vocabulary.
139
- const d = se.data;
140
- return { type: "retrying", attempt: d.attempt, maxAttempts: d.maxAttempts, delayMs: d.delayMs, reason: d.error };
141
- }
142
- default:
144
+ /**
145
+ * The SPEC projection of ONE run's rich stream. Stateful only for `tool_progress`, the one event that is a status
146
+ * rather than a fact: an outer call's status line is sent when it changes, and a call that call makes, at any depth,
147
+ * is its status. Nested calls are otherwise the observation plane's alone.
148
+ */
149
+ export function agentEventProjection() {
150
+ /** Each nested call's outer call, the one the stream announced with `tool_started`. */
151
+ const outerOf = new Map();
152
+ /** The status line each outer call last reported. */
153
+ const shown = new Map();
154
+ const progress = (id, text) => {
155
+ if (text === undefined || shown.get(id) === text)
143
156
  return null;
157
+ shown.set(id, text);
158
+ return { type: "tool_progress", id, text };
159
+ };
160
+ return (se) => {
161
+ switch (se.type) {
162
+ case "message_delta": {
163
+ const d = se.data;
164
+ return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
165
+ }
166
+ case "tool_started": {
167
+ const d = se.data;
168
+ if (!d.parentToolCallId)
169
+ return { type: "tool_started", id: d.id, name: d.name, args: d.args };
170
+ const outer = outerOf.get(d.parentToolCallId) ?? d.parentToolCallId;
171
+ outerOf.set(d.id, outer);
172
+ return progress(outer, callLine(d.name, d.args));
173
+ }
174
+ case "tool_progress": {
175
+ const d = se.data;
176
+ // A nested call's own output is not its caller's status; the nested call starting is (above).
177
+ if (d.parentToolCallId || !Array.isArray(d.partialResult.content))
178
+ return null;
179
+ return progress(d.id, lastLine(contentText(d.partialResult.content, "\n")));
180
+ }
181
+ case "tool_finished": {
182
+ const d = se.data;
183
+ if (d.parentToolCallId)
184
+ return null;
185
+ return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
186
+ }
187
+ case "retry_scheduled": {
188
+ // `operation` (compaction | branch_summary) stays session-plane vocabulary.
189
+ const d = se.data;
190
+ return {
191
+ type: "retrying",
192
+ attempt: d.attempt,
193
+ maxAttempts: d.maxAttempts,
194
+ delayMs: d.delayMs,
195
+ reason: d.error,
196
+ };
197
+ }
198
+ default:
199
+ return null;
200
+ }
201
+ };
202
+ }
203
+ /**
204
+ * The line a terminal shows last: control sequences removed, and a bare `\r`, a progress bar redrawing in place, ends a
205
+ * line like `\n` does. Undefined when there is no visible text yet (a shell that has printed nothing).
206
+ */
207
+ function lastLine(text) {
208
+ const lines = stripVTControlCharacters(text).split(/\r\n|\r|\n/);
209
+ for (let i = lines.length - 1; i >= 0; i--) {
210
+ const line = lines[i]?.trim();
211
+ if (line)
212
+ return line;
144
213
  }
214
+ return undefined;
215
+ }
216
+ /** A nested call as its caller's status: the tool and its first plain argument, on one line (`weather London`). */
217
+ function callLine(name, args) {
218
+ const first = args !== null && typeof args === "object" && !Array.isArray(args)
219
+ ? Object.values(args).find((value) => typeof value === "string" || typeof value === "number")
220
+ : undefined;
221
+ return first === undefined ? name : `${name} ${String(first).replace(/\s+/g, " ").trim()}`.trim();
145
222
  }
package/dist/pi.d.ts CHANGED
@@ -5,7 +5,7 @@ export { z } from "zod";
5
5
  export type { AgentTool, ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
6
6
  /** A conversation record, as the tool runtime and the control plane hold it. */
7
7
  export type { SessionManager, SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
8
- export { availableModelsFromDir, createPiAgentFromDir, type CreatePiAgentFromDirOptions, refreshModelCatalog, } from "./engines/pi/open.ts";
8
+ export { availableModelsFromDir, createPiAgentFromDir, type CreatePiAgentFromDirOptions, refreshMachineModelCatalog, refreshModelCatalog, } from "./engines/pi/open.ts";
9
9
  export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
10
10
  export { defineConfig, listModels, resolveModel, type FastagentConfig, } from "./engines/pi/config.ts";
11
11
  export type { SessionObserver } from "./engines/pi/turn-kit.ts";
package/dist/pi.js CHANGED
@@ -2,7 +2,7 @@
2
2
  export { createPiAgent, createPiAgentFromDefinition, } from "./engines/pi/create.js";
3
3
  export { defineTool, } from "./engines/pi/tool.js";
4
4
  export { z } from "zod";
5
- export { availableModelsFromDir, createPiAgentFromDir, refreshModelCatalog, } from "./engines/pi/open.js";
5
+ export { availableModelsFromDir, createPiAgentFromDir, refreshMachineModelCatalog, refreshModelCatalog, } from "./engines/pi/open.js";
6
6
  export { defineConfig, listModels, resolveModel, } from "./engines/pi/config.js";
7
7
  export { inProcessLease } from "./engines/pi/turn-kit.js";
8
8
  export { createPiSessionControl, } from "./engines/pi/session-control.js";
package/dist/session.d.ts CHANGED
@@ -54,14 +54,22 @@ export interface Session {
54
54
  */
55
55
  image(ref: string): Promise<ImageRef | undefined>;
56
56
  events(): SessionEventStream;
57
- /** Set durable session properties. */
57
+ /**
58
+ * Set durable session properties, applied by the session's next turn. An id with no record (nothing has written it
59
+ * yet) takes them too: the write creates its record, and the session's first turn runs on them. Refused there:
60
+ * `leafEntryId` (no entries to point at) and an id no client could address ({@link isAddressableSession}).
61
+ */
58
62
  update(patch: SessionUpdate): Promise<SessionResult>;
59
63
  /**
60
64
  * Join the active run: delivered after the current turn's tool calls, before the next model call. `prompt.text`
61
- * must not be empty (`invalid_command`): a queued prompt is reported by its text ({@link PendingPrompts}).
65
+ * must not be empty (`invalid_command`): a queued prompt is reported by its text ({@link PendingPrompts}). An
66
+ * accepted result carries its {@link PromptDisposition}.
62
67
  */
63
68
  steer(prompt: Prompt): Promise<SessionResult>;
64
- /** Queue for the active run, FIFO, delivered when it is otherwise idle. Same non-empty `text` rule as `steer`. */
69
+ /**
70
+ * Queue for the active run, FIFO, delivered when it is otherwise idle. Same non-empty `text` rule and
71
+ * {@link PromptDisposition} as `steer`.
72
+ */
65
73
  followUp(prompt: Prompt): Promise<SessionResult>;
66
74
  /** Stop the active run — its queues, its retry delay, its cancellable tool work. */
67
75
  abort(): Promise<SessionResult>;
@@ -121,11 +129,22 @@ export interface SessionCapabilities {
121
129
  delete: boolean;
122
130
  /** Which {@link SessionUpdate} fields this deployment accepts. */
123
131
  updatable: SessionUpdateField[];
124
- /** The specs `update({ model })` accepts — present iff `model` is updatable. */
125
- allowedModels?: string[];
132
+ /** The models `update({ model })` accepts, by `spec` — present iff `model` is updatable. */
133
+ allowedModels?: ModelDescriptor[];
126
134
  toolProgress: boolean;
127
135
  usage: boolean;
128
136
  }
137
+ /**
138
+ * A model as a picker shows it, before any session runs on it. `thinkingLevels` is what `update({ thinkingLevel })`
139
+ * accepts for a session on this model; `name` and `contextWindow` are absent when the model does not declare them.
140
+ */
141
+ export interface ModelDescriptor {
142
+ /** What `update({ model })` takes. */
143
+ spec: string;
144
+ name?: string;
145
+ thinkingLevels: string[];
146
+ contextWindow?: number;
147
+ }
129
148
  /** One name a client can offer the user. */
130
149
  export interface AgentCommand {
131
150
  name: string;
@@ -144,7 +163,11 @@ export declare const UNSUPPORTED_CAPABILITY_CODE = "unsupported_capability";
144
163
  export declare const NO_ACTIVE_RUN_CODE = "no_active_run";
145
164
  /** Stable `SessionResult.error.code` for a PAYLOAD that is invalid for this runtime. */
146
165
  export declare const INVALID_COMMAND_CODE = "invalid_command";
147
- /** Stable `SessionResult.error.code` for a write against a session that does not exist. */
166
+ /**
167
+ * Stable `SessionResult.error.code` for a write that needs a record (fork's source, compact, delete) against an id
168
+ * that has none, because nothing has written it yet. `update()` creates the record instead, and answers this code only
169
+ * when a concurrent `delete()` removes the record between its read and its write.
170
+ */
148
171
  export declare const NO_SUCH_SESSION_CODE = "no_such_session";
149
172
  /** Stable `SessionResult.error.code` for a write rejected BEFORE acceptance with nothing durable landed. */
150
173
  export declare const BOUNDARY_COMMAND_FAILED_CODE = "boundary_command_failed";
@@ -180,6 +203,7 @@ export declare const PARTIAL_UPDATE_CODE = "partial_update";
180
203
  export type SessionResult = {
181
204
  ok: true;
182
205
  runId?: string;
206
+ disposition?: PromptDisposition;
183
207
  } | {
184
208
  ok: false;
185
209
  error: {
@@ -188,6 +212,13 @@ export type SessionResult = {
188
212
  retryable: boolean;
189
213
  };
190
214
  };
215
+ /**
216
+ * What became of an accepted `steer`/`followUp` prompt — present on exactly those results. `queued`: it waits in
217
+ * {@link PendingPrompts} until the run takes it in. `handled`: the engine consumed it before the queue (in pi, a
218
+ * definition extension's `input` handler), so it never enters the conversation, and no `queue_changed` or
219
+ * `user_message` reports it.
220
+ */
221
+ export type PromptDisposition = "queued" | "handled";
191
222
  /** One session in {@link SessionControl.sessions} — a conversation-list row, not a session's contents. */
192
223
  export interface SessionSummary {
193
224
  session: string;
@@ -205,7 +236,10 @@ export interface SessionState {
205
236
  /** `compacting` refers to MANUAL compaction (`compact`) at a session boundary. */
206
237
  status: "idle" | "running" | "compacting";
207
238
  activeRunId?: string;
208
- /** What this session will RUN with, not what was recorded. */
239
+ /**
240
+ * What this session will RUN with, not what was recorded. An id with no record reports what its first turn would run
241
+ * on: the defaults, the level clamped to the model.
242
+ */
209
243
  model?: string;
210
244
  thinkingLevel?: string;
211
245
  /** What `update({ thinkingLevel })` accepts for THIS session — re-read after a model change. */
@@ -319,13 +353,29 @@ export type MessageDeltaEvent = SessionEvent<"message_delta", {
319
353
  }> & {
320
354
  runId: string;
321
355
  };
322
- export type MessageFinishedEvent = SessionEvent<"message_finished", Record<never, never>> & {
356
+ /**
357
+ * How an answer ended, when it did not end normally: absent on an answer that did. One value, carried live by its
358
+ * `message_finished` and durably by its `assistant` entry, so a reopened conversation reads what a watcher saw.
359
+ * `truncated` is an answer cut off at the output limit — a property of the answer, not of its run, which may still
360
+ * complete. An answer whose entry a `context_edit { omitted }` targets (`failed` or `truncated`) is an attempt the
361
+ * engine abandoned; a retry usually, but not always, follows it.
362
+ */
363
+ export type AnswerOutcome = {
364
+ status: "failed" | "aborted" | "truncated";
365
+ error?: {
366
+ message: string;
367
+ };
368
+ };
369
+ export type MessageFinishedEvent = SessionEvent<"message_finished", {
370
+ outcome?: AnswerOutcome;
371
+ }> & {
323
372
  runId: string;
324
373
  };
325
374
  export type ToolStartedEvent = SessionEvent<"tool_started", {
326
375
  id: string;
327
376
  name: string;
328
377
  args: Json;
378
+ parentToolCallId?: string;
329
379
  }> & {
330
380
  runId: string;
331
381
  };
@@ -334,6 +384,7 @@ export type ToolProgressEvent = SessionEvent<"tool_progress", {
334
384
  id: string;
335
385
  name: string;
336
386
  partialResult: Json;
387
+ parentToolCallId?: string;
337
388
  }> & {
338
389
  runId: string;
339
390
  };
@@ -341,6 +392,7 @@ export type ToolFinishedEvent = SessionEvent<"tool_finished", {
341
392
  id: string;
342
393
  isError: boolean;
343
394
  content: Json;
395
+ parentToolCallId?: string;
344
396
  }> & {
345
397
  runId: string;
346
398
  };
package/dist/session.js CHANGED
@@ -28,7 +28,11 @@ export const UNSUPPORTED_CAPABILITY_CODE = "unsupported_capability";
28
28
  export const NO_ACTIVE_RUN_CODE = "no_active_run";
29
29
  /** Stable `SessionResult.error.code` for a PAYLOAD that is invalid for this runtime. */
30
30
  export const INVALID_COMMAND_CODE = "invalid_command";
31
- /** Stable `SessionResult.error.code` for a write against a session that does not exist. */
31
+ /**
32
+ * Stable `SessionResult.error.code` for a write that needs a record (fork's source, compact, delete) against an id
33
+ * that has none, because nothing has written it yet. `update()` creates the record instead, and answers this code only
34
+ * when a concurrent `delete()` removes the record between its read and its write.
35
+ */
32
36
  export const NO_SUCH_SESSION_CODE = "no_such_session";
33
37
  /** Stable `SessionResult.error.code` for a write rejected BEFORE acceptance with nothing durable landed. */
34
38
  export const BOUNDARY_COMMAND_FAILED_CODE = "boundary_command_failed";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on Telegram, Slack, Feishu, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",
@@ -101,16 +101,16 @@
101
101
  },
102
102
  "dependencies": {
103
103
  "@clack/prompts": "^1.6.0",
104
- "@earendil-works/pi-agent-core": "^0.87.1",
105
- "@earendil-works/pi-ai": "^0.87.1",
106
- "@earendil-works/pi-coding-agent": "^0.87.1",
104
+ "@earendil-works/pi-agent-core": "^0.99.2",
105
+ "@earendil-works/pi-ai": "^0.99.2",
106
+ "@earendil-works/pi-coding-agent": "^0.99.2",
107
107
  "@hono/node-server": "^2.1.1",
108
108
  "@larksuiteoapi/node-sdk": "^1.71.1",
109
109
  "@smithy/signature-v4": "^5.7.4",
110
110
  "chokidar": "^5.0.0",
111
111
  "commander": "^15.0.0",
112
112
  "croner": "^10.0.1",
113
- "effect": "4.0.0-rc.117",
113
+ "effect": "4.0.0-rc.118",
114
114
  "giget": "^3.3.0",
115
115
  "ignore": "^7.0.5",
116
116
  "proper-lockfile": "^4.1.2",
@@ -1,4 +0,0 @@
1
- import { type MountedTool } from "./tool.ts";
2
- /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
3
- export declare function withSearchTool(tools: MountedTool[]): MountedTool[];
4
- export declare function makeSearchToolsTool(): MountedTool;
@@ -1,109 +0,0 @@
1
- /** The built-in `search_tools` loader — the discovery surface for deferred tools (defineTool `deferred: true`). */
2
- import { z } from "zod";
3
- import { log } from "../../log.js";
4
- import { defineTool, isDeferredTool, stripDeferredMarker } from "./tool.js";
5
- /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
6
- export function withSearchTool(tools) {
7
- if (!tools.some(isDeferredTool))
8
- return tools;
9
- const authored = tools.find((t) => t.name === "search_tools");
10
- if (!authored)
11
- return [...tools, makeSearchToolsTool()];
12
- let fixed = authored;
13
- if (isDeferredTool(fixed)) {
14
- // A deferred LOADER is a contradiction — it is the only entry point to the deferred tools, so nothing could ever
15
- // activate it (or, through it, them).
16
- log.warn("[fastagent] search_tools is marked deferred — ignoring the marker: the loader must stay active, or no deferred tool could ever be activated");
17
- fixed = stripDeferredMarker(fixed);
18
- }
19
- return fixed === authored ? tools : tools.map((t) => (t === authored ? fixed : t));
20
- }
21
- /** Activation cap per search: activation is additive, session-persisted, and has NO deactivate path. */
22
- const MAX_ACTIVATIONS_PER_SEARCH = 5;
23
- /** Miss-path listing cap — same rationale as the activation cap. */
24
- const MAX_MISS_LISTING = 10;
25
- export function makeSearchToolsTool() {
26
- return defineTool({
27
- name: "search_tools",
28
- description:
29
- // First line short on purpose: the base prompt's tools list truncates at the first newline, and the discovery
30
- // guidance below would otherwise flood it (and duplicate its deferred note).
31
- "Discover and activate additional tools.\n" +
32
- "Part of this agent's toolset is inactive until needed: search by keywords (e.g. what you are " +
33
- "trying to do), and matching inactive tools are activated and become callable from that point on " +
34
- "(if too many match, you get the candidates back — narrow the query, or query an exact tool " +
35
- "name). ALWAYS search here before concluding a capability is missing.",
36
- input: z.object({
37
- query: z.string().min(1).describe("keywords describing the capability you need (e.g. 'weather forecast')"),
38
- }),
39
- async execute(input, ctx) {
40
- if (!ctx.tools)
41
- return "tool activation is unavailable outside a conversation turn.";
42
- // Search the WHOLE registered catalog: the loader is the only discovery surface, and in a long conversation the
43
- // model does not remember what it activated.
44
- // ponytail: naive keyword match (any query token as a case-insensitive substring of name+description) under a
45
- // hard per-search activation cap — the two ceilings are relevance and irreversibility; swap in
46
- // scoring/embeddings if catalogs outgrow this.
47
- const tokens = input.query
48
- .toLowerCase()
49
- .split(/[^a-z0-9]+/)
50
- .filter(Boolean);
51
- // AND semantics: EVERY token must hit — "adding a word narrows" must actually hold, or the over-cap "narrow the
52
- // query" instruction sends the model in circles (OR would widen with each word, and a shared prefix like
53
- // "fetch" could make a whole tool family permanently over-cap).
54
- const matchesQuery = (t) => {
55
- const haystack = `${t.name} ${t.description}`.toLowerCase();
56
- return tokens.every((token) => haystack.includes(token));
57
- };
58
- const describe = (t) => `${t.name} — ${t.description.split("\n")[0]}`;
59
- const active = new Set(ctx.tools.active());
60
- const registered = ctx.tools.registered();
61
- // A query with no searchable tokens (all punctuation/symbols) must not match.
62
- if (tokens.length === 0)
63
- return `"${input.query}" contains no searchable keywords — describe the capability you need.`;
64
- // Exact-name shortcut: the guaranteed escape hatch from the cap — a query that IS an INACTIVE registered tool's
65
- // name addresses that one tool, no keyword scoring in the way.
66
- const exact = registered.find((t) => t.name.toLowerCase() === input.query.trim().toLowerCase());
67
- const activeMatches = registered.filter((t) => active.has(t.name) && (t === exact || matchesQuery(t)));
68
- const inactiveMatches = exact && !active.has(exact.name) ? [exact] : registered.filter((t) => !active.has(t.name) && matchesQuery(t));
69
- // Same listing cap as every other branch — a wide token can match most of the ACTIVE set too (in chat that
70
- // includes pi's default tools), and no answer may pour a catalog into the context.
71
- const listedActive = activeMatches.slice(0, MAX_MISS_LISTING);
72
- const moreActive = activeMatches.length - listedActive.length;
73
- const activeNote = activeMatches.length > 0
74
- ? `Already active (call directly): ${listedActive.map(describe).join("; ")}${moreActive > 0 ? ` … and ${moreActive} more` : ""}.`
75
- : "";
76
- if (inactiveMatches.length === 0) {
77
- if (activeNote)
78
- return activeNote;
79
- const inactive = registered.filter((t) => !active.has(t.name));
80
- if (inactive.length === 0)
81
- return "All tools are already active — nothing to discover.";
82
- // Cap the miss listing like the activation cap — both guard the same semantic (don't pour the catalog back
83
- // into the context the deferral exists to protect).
84
- const listed = inactive.slice(0, MAX_MISS_LISTING);
85
- const more = inactive.length - listed.length;
86
- return `No tools matched "${input.query}". Inactive tools: ${listed.map(describe).join("; ")}${more > 0 ? ` … and ${more} more — search with different keywords.` : ""}`;
87
- }
88
- if (inactiveMatches.length > MAX_ACTIVATIONS_PER_SEARCH) {
89
- // Same listing cap as the miss path — an over-cap answer must not pour the catalog into the context either.
90
- const listed = inactiveMatches.slice(0, MAX_MISS_LISTING);
91
- const more = inactiveMatches.length - listed.length;
92
- return `${inactiveMatches.length} inactive tools matched "${input.query}" — too many to activate at once (activation is permanent for this conversation). Narrow the query (or query an exact name). Matches: ${listed
93
- .map((t) => t.name)
94
- .join(", ")}${more > 0 ? ` … and ${more} more` : ""}.${activeNote ? ` ${activeNote}` : ""}`;
95
- }
96
- const activated = ctx.tools.activate(inactiveMatches.map((t) => t.name));
97
- // Report what actually happened, not what was attempted: a parallel sibling call may have activated the same
98
- // matches first, leaving nothing new here.
99
- if (activated.length === 0) {
100
- return `Matched ${inactiveMatches.map((t) => t.name).join(", ")} — already active (possibly activated by a concurrent call). Call them directly.${activeNote ? ` ${activeNote}` : ""}`;
101
- }
102
- const raced = inactiveMatches.filter((t) => !activated.includes(t.name));
103
- return `Activated: ${inactiveMatches
104
- .filter((t) => activated.includes(t.name))
105
- .map(describe)
106
- .join("; ")}.${raced.length > 0 ? ` Already active: ${raced.map((t) => t.name).join(", ")}.` : ""}${activeNote ? ` ${activeNote}` : ""} These tools are callable now.`;
107
- },
108
- });
109
- }