@vellumai/assistant 0.10.7-dev.202607101106.c87f4e3 → 0.10.7-dev.202607101252.74ea83d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.7-dev.202607101106.c87f4e3",
3
+ "version": "0.10.7-dev.202607101252.74ea83d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,16 +1,41 @@
1
1
  /**
2
- * Verifies the agent loop's exclusive-tool dispatch: when a tool the loop is
3
- * told is exclusive appears in a multi-call turn, only that tool runs and the
2
+ * Verifies the agent loop's exclusive-tool dispatch: when a tool the registry
3
+ * marks exclusive appears in a multi-call turn, only that tool runs and the
4
4
  * siblings are deferred un-run with a benign result — so the model incorporates
5
5
  * the exclusive tool's output before acting on anything else. Drives the REAL
6
6
  * loop, mocking only the provider boundary.
7
7
  */
8
- import { describe, expect, test } from "bun:test";
8
+ import { beforeAll, describe, expect, test } from "bun:test";
9
9
 
10
10
  import { createMockProvider } from "../__tests__/helpers/mock-provider.js";
11
+ import { RiskLevel } from "../permissions/types.js";
11
12
  import type { ContentBlock, ProviderResponse } from "../providers/types.js";
13
+ import { registerTool } from "../tools/registry.js";
14
+ import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
12
15
  import { AgentLoop } from "./loop.js";
13
16
 
17
+ // The loop reads exclusivity straight from the registry (`getTool(name)
18
+ // ?.exclusive`), so seed a registered tool the loop can look up. Other tool
19
+ // names in these turns are absent from the registry, so they read as
20
+ // non-exclusive — exactly the mixed state the deferral logic branches on.
21
+ beforeAll(() => {
22
+ registerTool({
23
+ name: "exclusive_tool",
24
+ description: "Exclusive test tool",
25
+ category: "test",
26
+ defaultRiskLevel: RiskLevel.Low,
27
+ executionTarget: "sandbox",
28
+ exclusive: true,
29
+ input_schema: { type: "object", properties: {}, required: [] },
30
+ async execute(
31
+ _input: Record<string, unknown>,
32
+ _context: ToolContext,
33
+ ): Promise<ToolExecutionResult> {
34
+ return { content: "ok", isError: false };
35
+ },
36
+ });
37
+ });
38
+
14
39
  const endTurn = (text: string): ProviderResponse => ({
15
40
  content: [{ type: "text", text }],
16
41
  model: "mock-model",
@@ -82,7 +107,6 @@ describe("AgentLoop — exclusive tool deferral", () => {
82
107
  executed.push(name);
83
108
  return { content: `ran ${name}`, isError: false };
84
109
  },
85
- isExclusiveTool: (name) => name === "exclusive_tool",
86
110
  });
87
111
 
88
112
  const { history } = await loop.run({
@@ -137,7 +161,6 @@ describe("AgentLoop — exclusive tool deferral", () => {
137
161
  executed.push(name);
138
162
  return { content: `ran ${name}`, isError: false };
139
163
  },
140
- isExclusiveTool: (name) => name === "exclusive_tool",
141
164
  });
142
165
 
143
166
  const { history } = await loop.run({
package/src/agent/loop.ts CHANGED
@@ -43,6 +43,7 @@ import type {
43
43
  ToolResultContent,
44
44
  } from "../providers/types.js";
45
45
  import { isContextOverflowError } from "../providers/types.js";
46
+ import { getTool } from "../tools/registry.js";
46
47
  import type { SensitiveOutputBinding } from "../tools/sensitive-output-placeholders.js";
47
48
  import {
48
49
  applyStreamingSubstitution,
@@ -708,14 +709,6 @@ export interface AgentLoopConstructorOptions {
708
709
  tools?: ToolDefinition[];
709
710
  toolExecutor?: LoopToolExecutor;
710
711
  resolveTools?: (history: Message[]) => ToolDefinition[];
711
- /**
712
- * Decide whether a tool runs exclusively in its turn (see
713
- * {@link ToolDefinition.exclusive}). When it returns true for a tool present
714
- * in a multi-call turn, the loop runs only that tool and defers the siblings
715
- * un-run. Injected by the conversation wiring, which can read the tool
716
- * registry; lightweight loops that omit it never defer.
717
- */
718
- isExclusiveTool?: (toolName: string) => boolean;
719
712
  /**
720
713
  * Conversation this loop drives. Scopes the loop-held compaction circuit
721
714
  * breaker and is the source of truth the loop's pipeline contexts and
@@ -741,7 +734,6 @@ export class AgentLoop {
741
734
  private tools: ToolDefinition[];
742
735
  private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
743
736
  private toolExecutor: LoopToolExecutor | null;
744
- private isExclusiveTool: ((toolName: string) => boolean) | null;
745
737
 
746
738
  /**
747
739
  * Conversation this loop drives. Source of truth for the `conversationId`
@@ -771,7 +763,6 @@ export class AgentLoop {
771
763
  tools,
772
764
  toolExecutor,
773
765
  resolveTools,
774
- isExclusiveTool,
775
766
  conversationId,
776
767
  resolveConversationDir,
777
768
  } = options;
@@ -781,7 +772,6 @@ export class AgentLoop {
781
772
  this.tools = tools ?? [];
782
773
  this.resolveTools = resolveTools ?? null;
783
774
  this.toolExecutor = toolExecutor ?? null;
784
- this.isExclusiveTool = isExclusiveTool ?? null;
785
775
  this.conversationId = conversationId;
786
776
  this.resolveConversationDir = resolveConversationDir ?? null;
787
777
  this.compactionCircuit = new CompactionCircuit(this.conversationId);
@@ -2069,9 +2059,9 @@ export class AgentLoop {
2069
2059
  // the siblings with a benign, un-run result so the model re-issues them
2070
2060
  // next turn if still needed. Every tool_use still gets a matching
2071
2061
  // tool_result, so history stays well-formed.
2072
- const exclusiveBlock = this.isExclusiveTool
2073
- ? toolUseBlocks.find((block) => this.isExclusiveTool!(block.name))
2074
- : undefined;
2062
+ const exclusiveBlock = toolUseBlocks.find(
2063
+ (block) => getTool(block.name)?.exclusive === true,
2064
+ );
2075
2065
  const deferSiblings =
2076
2066
  exclusiveBlock !== undefined && toolUseBlocks.length > 1;
2077
2067
  if (deferSiblings) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Declarative help for the `assistant attachment` command.
3
+ *
4
+ * Plain data (no action handlers, imports only the help contract type) so the
5
+ * memory capability indexer can read it without pulling in the daemon/IPC action
6
+ * graph. The handlers live in `attachment.ts`, which applies this via
7
+ * `applyCommandHelp` and attaches them.
8
+ */
9
+
10
+ import type { CliCommandHelp } from "../lib/cli-command-help.js";
11
+
12
+ export const attachmentHelp: CliCommandHelp = {
13
+ name: "attachment",
14
+ description: "Manage file attachments for conversations",
15
+ helpText: `
16
+ Attachments come in two flavours:
17
+
18
+ File-backed Large files stored by path reference (no memory copy).
19
+ The file must remain on disk for the lifetime of the
20
+ attachment.
21
+ Inline Small payloads encoded directly (handled internally).
22
+
23
+ Use 'register' to record a file-backed attachment and 'lookup' to
24
+ retrieve its stored path by the original source location.
25
+
26
+ Examples:
27
+ $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4
28
+ $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4 --filename recording.mp4
29
+ $ assistant attachment lookup --source /tmp/clip.mp4 --conversation conv_abc123`,
30
+ subcommands: [
31
+ {
32
+ name: "register",
33
+ description: "Register a file-backed attachment with the assistant",
34
+ options: [
35
+ {
36
+ flags: "--path <file>",
37
+ description: "Absolute path to the file (required)",
38
+ required: true,
39
+ },
40
+ {
41
+ flags: "--mime <type>",
42
+ description: "MIME type of the file (required)",
43
+ required: true,
44
+ },
45
+ {
46
+ flags: "--filename <name>",
47
+ description: "Display filename (defaults to basename of path)",
48
+ },
49
+ {
50
+ flags: "--json",
51
+ description: "Output result as machine-readable JSON.",
52
+ },
53
+ ],
54
+ helpText: `
55
+ Registers a file on disk as a file-backed attachment in the assistant's
56
+ attachment store. The file must exist at the given path and must remain
57
+ on disk for the lifetime of the attachment — the assistant stores a
58
+ path reference, not a copy.
59
+
60
+ Returns the attachment ID and metadata on success.
61
+
62
+ Examples:
63
+ $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4
64
+ $ assistant attachment register --path /tmp/screen.png --mime image/png --filename screenshot.png
65
+ $ assistant attachment register --path /tmp/audio.wav --mime audio/wav --json`,
66
+ },
67
+ {
68
+ name: "lookup",
69
+ description: "Look up a stored attachment by its original source path",
70
+ options: [
71
+ {
72
+ flags: "--source <path>",
73
+ description: "Original source path of the file (required)",
74
+ required: true,
75
+ },
76
+ {
77
+ flags: "--conversation <id>",
78
+ description:
79
+ "Conversation ID to search within (required) — run 'assistant conversations list' to find it",
80
+ required: true,
81
+ },
82
+ {
83
+ flags: "--json",
84
+ description: "Output result as machine-readable JSON.",
85
+ },
86
+ ],
87
+ helpText: `
88
+ Searches for an attachment that was previously registered with the
89
+ given source path, scoped to a specific conversation. Returns the
90
+ stored file path on success.
91
+
92
+ Attachments are linked to messages within conversations. Use
93
+ 'assistant conversations list' to find the conversation ID.
94
+
95
+ Examples:
96
+ $ assistant attachment lookup --source /tmp/clip.mp4 --conversation conv_abc123
97
+ $ assistant attachment lookup --source /path/to/recording.mp4 --conversation conv_xyz --json`,
98
+ },
99
+ ],
100
+ };
@@ -3,189 +3,124 @@
3
3
  *
4
4
  * Subcommands: register, lookup — thin wrappers over the daemon's
5
5
  * attachment routes (`attachment_register`, `attachment_lookup`).
6
+ *
7
+ * The command's help structure lives in `attachment.help.ts` (import-safe for
8
+ * the memory capability indexer); this module applies it and attaches the
9
+ * action handlers.
6
10
  */
7
11
 
8
12
  import type { Command } from "commander";
9
13
 
10
14
  import { cliIpcCall } from "../../ipc/cli-client.js";
15
+ import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
11
16
  import { registerCommand } from "../lib/register-command.js";
12
17
  import { log } from "../logger.js";
13
18
  import { shouldOutputJson, writeOutput } from "../output.js";
19
+ import { attachmentHelp } from "./attachment.help.js";
14
20
 
15
21
  // ── Registration ──────────────────────────────────────────────────────
16
22
 
17
23
  export function registerAttachmentCommand(program: Command): void {
18
24
  registerCommand(program, {
19
- name: "attachment",
25
+ name: attachmentHelp.name,
20
26
  transport: "ipc",
21
- description: "Manage file attachments for conversations",
27
+ description: attachmentHelp.description,
22
28
  build: (attachment) => {
29
+ applyCommandHelp(attachment, attachmentHelp);
30
+
31
+ // ── register ───────────────────────────────────────────────────
32
+ subcommand(attachment, "register").action(
33
+ async (
34
+ opts: {
35
+ path: string;
36
+ mime: string;
37
+ filename?: string;
38
+ json?: boolean;
39
+ },
40
+ cmd: Command,
41
+ ) => {
42
+ const jsonOutput = opts.json || shouldOutputJson(cmd);
43
+
44
+ const result = await cliIpcCall<{
45
+ id: string;
46
+ originalFilename: string;
47
+ mimeType: string;
48
+ sizeBytes: number;
49
+ kind: string;
50
+ filePath: string;
51
+ createdAt: number;
52
+ }>("attachment_register", {
53
+ body: {
54
+ path: opts.path,
55
+ mimeType: opts.mime,
56
+ filename: opts.filename,
57
+ },
58
+ });
23
59
 
24
- attachment.addHelpText(
25
- "after",
26
- `
27
- Attachments come in two flavours:
28
-
29
- File-backed Large files stored by path reference (no memory copy).
30
- The file must remain on disk for the lifetime of the
31
- attachment.
32
- Inline Small payloads encoded directly (handled internally).
33
-
34
- Use 'register' to record a file-backed attachment and 'lookup' to
35
- retrieve its stored path by the original source location.
36
-
37
- Examples:
38
- $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4
39
- $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4 --filename recording.mp4
40
- $ assistant attachment lookup --source /tmp/clip.mp4 --conversation conv_abc123`,
41
- );
42
-
43
- // ── register ─────────────────────────────────────────────────────
44
-
45
- attachment
46
- .command("register")
47
- .description("Register a file-backed attachment with the assistant")
48
- .requiredOption("--path <file>", "Absolute path to the file (required)")
49
- .requiredOption("--mime <type>", "MIME type of the file (required)")
50
- .option(
51
- "--filename <name>",
52
- "Display filename (defaults to basename of path)",
53
- )
54
- .option("--json", "Output result as machine-readable JSON.")
55
- .addHelpText(
56
- "after",
57
- `
58
- Registers a file on disk as a file-backed attachment in the assistant's
59
- attachment store. The file must exist at the given path and must remain
60
- on disk for the lifetime of the attachment — the assistant stores a
61
- path reference, not a copy.
62
-
63
- Returns the attachment ID and metadata on success.
64
-
65
- Examples:
66
- $ assistant attachment register --path /tmp/clip.mp4 --mime video/mp4
67
- $ assistant attachment register --path /tmp/screen.png --mime image/png --filename screenshot.png
68
- $ assistant attachment register --path /tmp/audio.wav --mime audio/wav --json`,
69
- )
70
- .action(
71
- async (
72
- opts: {
73
- path: string;
74
- mime: string;
75
- filename?: string;
76
- json?: boolean;
77
- },
78
- cmd: Command,
79
- ) => {
80
- const jsonOutput = opts.json || shouldOutputJson(cmd);
60
+ if (!result.ok) {
61
+ if (jsonOutput) {
62
+ writeOutput(cmd, { ok: false, error: result.error });
63
+ } else {
64
+ log.error(result.error ?? "Unknown error");
65
+ }
66
+ process.exitCode = 1;
67
+ return;
68
+ }
81
69
 
82
- const result = await cliIpcCall<{
83
- id: string;
84
- originalFilename: string;
85
- mimeType: string;
86
- sizeBytes: number;
87
- kind: string;
88
- filePath: string;
89
- createdAt: number;
90
- }>("attachment_register", {
91
- body: {
92
- path: opts.path,
93
- mimeType: opts.mime,
94
- filename: opts.filename,
95
- },
96
- });
70
+ const record = result.result!;
97
71
 
98
- if (!result.ok) {
99
72
  if (jsonOutput) {
100
- writeOutput(cmd, { ok: false, error: result.error });
73
+ writeOutput(cmd, { ok: true, ...record });
101
74
  } else {
102
- log.error(result.error ?? "Unknown error");
75
+ process.stdout.write(`${record.id}\n`);
76
+ log.info(`Attachment registered: ${record.id}`);
77
+ log.info(` Filename: ${record.originalFilename}`);
78
+ log.info(` MIME: ${record.mimeType}`);
79
+ log.info(` Size: ${record.sizeBytes} bytes`);
80
+ log.info(` Kind: ${record.kind}`);
81
+ log.info(` Path: ${record.filePath}`);
103
82
  }
104
- process.exitCode = 1;
105
- return;
106
- }
107
-
108
- const record = result.result!;
109
-
110
- if (jsonOutput) {
111
- writeOutput(cmd, { ok: true, ...record });
112
- } else {
113
- process.stdout.write(`${record.id}\n`);
114
- log.info(`Attachment registered: ${record.id}`);
115
- log.info(` Filename: ${record.originalFilename}`);
116
- log.info(` MIME: ${record.mimeType}`);
117
- log.info(` Size: ${record.sizeBytes} bytes`);
118
- log.info(` Kind: ${record.kind}`);
119
- log.info(` Path: ${record.filePath}`);
120
- }
121
- },
122
- );
123
-
124
- // ── lookup ───────────────────────────────────────────────────────
125
-
126
- attachment
127
- .command("lookup")
128
- .description("Look up a stored attachment by its original source path")
129
- .requiredOption(
130
- "--source <path>",
131
- "Original source path of the file (required)",
132
- )
133
- .requiredOption(
134
- "--conversation <id>",
135
- "Conversation ID to search within (required) — run 'assistant conversations list' to find it",
136
- )
137
- .option("--json", "Output result as machine-readable JSON.")
138
- .addHelpText(
139
- "after",
140
- `
141
- Searches for an attachment that was previously registered with the
142
- given source path, scoped to a specific conversation. Returns the
143
- stored file path on success.
144
-
145
- Attachments are linked to messages within conversations. Use
146
- 'assistant conversations list' to find the conversation ID.
147
-
148
- Examples:
149
- $ assistant attachment lookup --source /tmp/clip.mp4 --conversation conv_abc123
150
- $ assistant attachment lookup --source /path/to/recording.mp4 --conversation conv_xyz --json`,
151
- )
152
- .action(
153
- async (
154
- opts: { source: string; conversation: string; json?: boolean },
155
- cmd: Command,
156
- ) => {
157
- const jsonOutput = opts.json || shouldOutputJson(cmd);
158
-
159
- const result = await cliIpcCall<{ filePath: string }>(
160
- "attachment_lookup",
161
- {
162
- body: {
163
- sourcePath: opts.source,
164
- conversationId: opts.conversation,
83
+ },
84
+ );
85
+
86
+ // ── lookup ─────────────────────────────────────────────────────
87
+ subcommand(attachment, "lookup").action(
88
+ async (
89
+ opts: { source: string; conversation: string; json?: boolean },
90
+ cmd: Command,
91
+ ) => {
92
+ const jsonOutput = opts.json || shouldOutputJson(cmd);
93
+
94
+ const result = await cliIpcCall<{ filePath: string }>(
95
+ "attachment_lookup",
96
+ {
97
+ body: {
98
+ sourcePath: opts.source,
99
+ conversationId: opts.conversation,
100
+ },
165
101
  },
166
- },
167
- );
102
+ );
103
+
104
+ if (!result.ok) {
105
+ if (jsonOutput) {
106
+ writeOutput(cmd, { ok: false, error: result.error });
107
+ } else {
108
+ log.error(result.error ?? "Unknown error");
109
+ }
110
+ process.exitCode = 1;
111
+ return;
112
+ }
168
113
 
169
- if (!result.ok) {
170
114
  if (jsonOutput) {
171
- writeOutput(cmd, { ok: false, error: result.error });
115
+ writeOutput(cmd, {
116
+ ok: true,
117
+ filePath: result.result!.filePath,
118
+ });
172
119
  } else {
173
- log.error(result.error ?? "Unknown error");
120
+ process.stdout.write(result.result!.filePath + "\n");
174
121
  }
175
- process.exitCode = 1;
176
- return;
177
- }
178
-
179
- if (jsonOutput) {
180
- writeOutput(cmd, {
181
- ok: true,
182
- filePath: result.result!.filePath,
183
- });
184
- } else {
185
- process.stdout.write(result.result!.filePath + "\n");
186
- }
187
- },
188
- );
122
+ },
123
+ );
189
124
  },
190
125
  });
191
126
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Declarative help for the `assistant audit` command.
3
+ *
4
+ * Plain data (no action handlers, imports only the help contract type) so the
5
+ * memory capability indexer can read it without pulling in the daemon/IPC action
6
+ * graph. The handler lives in `audit.ts`, which applies this via
7
+ * `applyCommandHelp` and attaches it.
8
+ */
9
+
10
+ import type { CliCommandHelp } from "../lib/cli-command-help.js";
11
+
12
+ export const auditHelp: CliCommandHelp = {
13
+ name: "audit",
14
+ description: "Show recent tool invocations",
15
+ options: [
16
+ {
17
+ flags: "-l, --limit <n>",
18
+ description: "Number of entries to show",
19
+ defaultValue: "20",
20
+ },
21
+ { flags: "--json", description: "Output raw JSON" },
22
+ ],
23
+ helpText: `
24
+ Reads from the tool invocation audit log via the daemon. Each row
25
+ represents one tool call the assistant made, including what was invoked,
26
+ how the approval system classified it, and how long it took.
27
+
28
+ Table columns:
29
+ Timestamp When the tool was invoked (UTC, YYYY-MM-DD HH:MM:SS)
30
+ Tool Tool name (e.g. bash, read_file, write_file, browser)
31
+ Input Truncated summary of the tool input (command, path, etc.)
32
+ Decision Approval decision: allow, deny, or ask
33
+ Risk Risk classification: none, low, medium, high
34
+ Duration Wall-clock execution time (e.g. 120ms, 1.3s)
35
+
36
+ Examples:
37
+ $ assistant audit
38
+ $ assistant audit --limit 50
39
+ $ assistant audit --json`,
40
+ };
@@ -1,8 +1,10 @@
1
1
  import type { Command } from "commander";
2
2
 
3
3
  import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
4
+ import { applyCommandHelp } from "../lib/cli-command-help.js";
4
5
  import { registerCommand } from "../lib/register-command.js";
5
6
  import { log } from "../logger.js";
7
+ import { auditHelp } from "./audit.help.js";
6
8
 
7
9
  interface ToolInvocationRow {
8
10
  toolName: string;
@@ -15,100 +17,78 @@ interface ToolInvocationRow {
15
17
 
16
18
  export function registerAuditCommand(program: Command): void {
17
19
  registerCommand(program, {
18
- name: "audit",
20
+ name: auditHelp.name,
19
21
  transport: "ipc",
20
- description: "Show recent tool invocations",
22
+ description: auditHelp.description,
21
23
  build: (audit) => {
22
- audit
23
- .option("-l, --limit <n>", "Number of entries to show", "20")
24
- .option("--json", "Output raw JSON")
25
- .addHelpText(
26
- "after",
27
- `
28
- Reads from the tool invocation audit log via the daemon. Each row
29
- represents one tool call the assistant made, including what was invoked,
30
- how the approval system classified it, and how long it took.
31
-
32
- Table columns:
33
- Timestamp When the tool was invoked (UTC, YYYY-MM-DD HH:MM:SS)
34
- Tool Tool name (e.g. bash, read_file, write_file, browser)
35
- Input Truncated summary of the tool input (command, path, etc.)
36
- Decision Approval decision: allow, deny, or ask
37
- Risk Risk classification: none, low, medium, high
38
- Duration Wall-clock execution time (e.g. 120ms, 1.3s)
24
+ applyCommandHelp(audit, auditHelp);
25
+ audit.action(async (opts: { limit: string; json?: boolean }) => {
26
+ const limit = parseInt(opts.limit, 10) || 20;
27
+ const response = await cliIpcCall<{
28
+ invocations: ToolInvocationRow[];
29
+ }>("audit_list", {
30
+ queryParams: { limit: String(limit) },
31
+ });
32
+ if (!response.ok) {
33
+ return exitFromIpcResult(response);
34
+ }
35
+ const rows = response.result!.invocations;
39
36
 
40
- Examples:
41
- $ assistant audit
42
- $ assistant audit --limit 50
43
- $ assistant audit --json`,
44
- )
45
- .action(async (opts: { limit: string; json?: boolean }) => {
46
- const limit = parseInt(opts.limit, 10) || 20;
47
- const response = await cliIpcCall<{
48
- invocations: ToolInvocationRow[];
49
- }>("audit_list", {
50
- queryParams: { limit: String(limit) },
51
- });
52
- if (!response.ok) {
53
- return exitFromIpcResult(response);
54
- }
55
- const rows = response.result!.invocations;
37
+ if (opts.json) {
38
+ console.log(JSON.stringify(rows, null, 2));
39
+ return;
40
+ }
56
41
 
57
- if (opts.json) {
58
- console.log(JSON.stringify(rows, null, 2));
59
- return;
42
+ if (rows.length === 0) {
43
+ log.info("No tool invocations recorded");
44
+ return;
45
+ }
46
+ const tsW = 20;
47
+ const toolW = 14;
48
+ const inputW = 30;
49
+ const decW = 8;
50
+ const riskW = 8;
51
+ const durW = 8;
52
+ log.info(
53
+ "Timestamp".padEnd(tsW) +
54
+ "Tool".padEnd(toolW) +
55
+ "Input".padEnd(inputW) +
56
+ "Decision".padEnd(decW) +
57
+ "Risk".padEnd(riskW) +
58
+ "Duration",
59
+ );
60
+ log.info("-".repeat(tsW + toolW + inputW + decW + riskW + durW));
61
+ for (const r of rows) {
62
+ const ts = new Date(r.createdAt)
63
+ .toISOString()
64
+ .slice(0, 19)
65
+ .replace("T", " ");
66
+ let inputSummary = "";
67
+ try {
68
+ const parsed = JSON.parse(r.input);
69
+ if (parsed.command) inputSummary = parsed.command;
70
+ else if (parsed.path) inputSummary = parsed.path;
71
+ else inputSummary = r.input;
72
+ } catch {
73
+ inputSummary = r.input;
60
74
  }
61
-
62
- if (rows.length === 0) {
63
- log.info("No tool invocations recorded");
64
- return;
75
+ if (inputSummary.length > inputW - 2) {
76
+ inputSummary = inputSummary.slice(0, inputW - 4) + "..";
65
77
  }
66
- const tsW = 20;
67
- const toolW = 14;
68
- const inputW = 30;
69
- const decW = 8;
70
- const riskW = 8;
71
- const durW = 8;
78
+ const dur =
79
+ r.durationMs < 1000
80
+ ? `${r.durationMs}ms`
81
+ : `${(r.durationMs / 1000).toFixed(1)}s`;
72
82
  log.info(
73
- "Timestamp".padEnd(tsW) +
74
- "Tool".padEnd(toolW) +
75
- "Input".padEnd(inputW) +
76
- "Decision".padEnd(decW) +
77
- "Risk".padEnd(riskW) +
78
- "Duration",
83
+ ts.padEnd(tsW) +
84
+ r.toolName.padEnd(toolW) +
85
+ inputSummary.padEnd(inputW) +
86
+ r.decision.padEnd(decW) +
87
+ r.riskLevel.padEnd(riskW) +
88
+ dur,
79
89
  );
80
- log.info("-".repeat(tsW + toolW + inputW + decW + riskW + durW));
81
- for (const r of rows) {
82
- const ts = new Date(r.createdAt)
83
- .toISOString()
84
- .slice(0, 19)
85
- .replace("T", " ");
86
- let inputSummary = "";
87
- try {
88
- const parsed = JSON.parse(r.input);
89
- if (parsed.command) inputSummary = parsed.command;
90
- else if (parsed.path) inputSummary = parsed.path;
91
- else inputSummary = r.input;
92
- } catch {
93
- inputSummary = r.input;
94
- }
95
- if (inputSummary.length > inputW - 2) {
96
- inputSummary = inputSummary.slice(0, inputW - 4) + "..";
97
- }
98
- const dur =
99
- r.durationMs < 1000
100
- ? `${r.durationMs}ms`
101
- : `${(r.durationMs / 1000).toFixed(1)}s`;
102
- log.info(
103
- ts.padEnd(tsW) +
104
- r.toolName.padEnd(toolW) +
105
- inputSummary.padEnd(inputW) +
106
- r.decision.padEnd(decW) +
107
- r.riskLevel.padEnd(riskW) +
108
- dur,
109
- );
110
- }
111
- });
90
+ }
91
+ });
112
92
  },
113
93
  });
114
94
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Declarative help for the `assistant auth` command.
3
+ *
4
+ * Plain data (no action handlers, imports only the help contract type) so the
5
+ * memory capability indexer can read it without pulling in the daemon/IPC action
6
+ * graph. The handlers live in `auth.ts`, which applies this via `applyCommandHelp`
7
+ * and attaches them.
8
+ */
9
+
10
+ import type { CliCommandHelp } from "../lib/cli-command-help.js";
11
+
12
+ export const authHelp: CliCommandHelp = {
13
+ name: "auth",
14
+ description: "Manage platform authentication and identity",
15
+ options: [
16
+ { flags: "--json", description: "Machine-readable compact JSON output" },
17
+ ],
18
+ helpText: `
19
+ The auth namespace manages the assistant's authentication state with the
20
+ Vellum platform. It provides commands to inspect identity and connection
21
+ status, helping diagnose configuration issues.
22
+
23
+ Examples:
24
+ $ assistant auth info
25
+ $ assistant auth info --json`,
26
+ subcommands: [
27
+ {
28
+ name: "info",
29
+ description: "Show platform identity and authentication status",
30
+ helpText: `
31
+ Fields:
32
+ platformUrl The Vellum platform base URL this assistant connects to
33
+ assistantId This assistant's platform UUID
34
+ organizationId The organization this assistant belongs to (from PLATFORM_ORGANIZATION_ID)
35
+ userId The user who owns this assistant (from PLATFORM_USER_ID)
36
+ authenticated Whether all prerequisites for platform authentication are met
37
+ (platform URL and assistant API key both present)
38
+
39
+ When not authenticated, a message field provides guidance on next steps.
40
+
41
+ Examples:
42
+ $ assistant auth info
43
+ $ assistant auth info --json`,
44
+ },
45
+ ],
46
+ };
@@ -1,9 +1,11 @@
1
1
  import type { Command } from "commander";
2
2
 
3
3
  import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
4
+ import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
4
5
  import { registerCommand } from "../lib/register-command.js";
5
6
  import { log } from "../logger.js";
6
7
  import { shouldOutputJson, writeOutput } from "../output.js";
8
+ import { authHelp } from "./auth.help.js";
7
9
 
8
10
  interface AuthInfoResponse {
9
11
  platformUrl: string | null;
@@ -16,51 +18,15 @@ interface AuthInfoResponse {
16
18
 
17
19
  export function registerAuthCommand(program: Command): void {
18
20
  registerCommand(program, {
19
- name: "auth",
21
+ name: authHelp.name,
20
22
  transport: "ipc",
21
- description: "Manage platform authentication and identity",
23
+ description: authHelp.description,
22
24
  build: (auth) => {
23
- auth.option("--json", "Machine-readable compact JSON output");
25
+ applyCommandHelp(auth, authHelp);
24
26
 
25
- auth.addHelpText(
26
- "after",
27
- `
28
- The auth namespace manages the assistant's authentication state with the
29
- Vellum platform. It provides commands to inspect identity and connection
30
- status, helping diagnose configuration issues.
31
-
32
- Examples:
33
- $ assistant auth info
34
- $ assistant auth info --json`,
35
- );
36
-
37
- // -----------------------------------------------------------------------
38
- // info
39
- // -----------------------------------------------------------------------
40
-
41
- auth
42
- .command("info")
43
- .description("Show platform identity and authentication status")
44
- .addHelpText(
45
- "after",
46
- `
47
- Fields:
48
- platformUrl The Vellum platform base URL this assistant connects to
49
- assistantId This assistant's platform UUID
50
- organizationId The organization this assistant belongs to (from PLATFORM_ORGANIZATION_ID)
51
- userId The user who owns this assistant (from PLATFORM_USER_ID)
52
- authenticated Whether all prerequisites for platform authentication are met
53
- (platform URL and assistant API key both present)
54
-
55
- When not authenticated, a message field provides guidance on next steps.
56
-
57
- Examples:
58
- $ assistant auth info
59
- $ assistant auth info --json`,
60
- )
61
- .action(async (_opts: Record<string, unknown>, cmd: Command) => {
62
- const response =
63
- await cliIpcCall<AuthInfoResponse>("auth_info");
27
+ subcommand(auth, "info").action(
28
+ async (_opts: Record<string, unknown>, cmd: Command) => {
29
+ const response = await cliIpcCall<AuthInfoResponse>("auth_info");
64
30
 
65
31
  if (!response.ok) {
66
32
  return exitFromIpcResult(response);
@@ -89,7 +55,8 @@ Examples:
89
55
  log.info(result.message);
90
56
  }
91
57
  }
92
- });
58
+ },
59
+ );
93
60
  },
94
61
  });
95
62
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Aggregated declarative help for the top-level `assistant` CLI commands that
3
+ * have adopted the static-help split (`<command>.help.ts`).
4
+ *
5
+ * Plugins — notably the memory capability indexer — import this to read command
6
+ * help without importing `cli/program.ts`, which pulls every command's action
7
+ * handler (and its daemon/IPC deps) into the import graph. Entries are pure data
8
+ * (see {@link ./lib/cli-command-help.CliCommandHelp}); consumers iterate them.
9
+ *
10
+ * Extend this as commands adopt the split. Once every command is listed here,
11
+ * the memory indexer no longer needs `buildCliProgramTree()`.
12
+ */
13
+
14
+ import { attachmentHelp } from "./commands/attachment.help.js";
15
+ import { auditHelp } from "./commands/audit.help.js";
16
+ import { authHelp } from "./commands/auth.help.js";
17
+ import type { CliCommandHelp } from "./lib/cli-command-help.js";
18
+
19
+ export const CLI_COMMAND_HELP: readonly CliCommandHelp[] = [
20
+ attachmentHelp,
21
+ auditHelp,
22
+ authHelp,
23
+ ];
@@ -0,0 +1,80 @@
1
+ import type { Command } from "commander";
2
+
3
+ /**
4
+ * Fully declarative description of a top-level `assistant` CLI command and its
5
+ * subcommands. Plain data by design — no action handlers — so plugins (e.g. the
6
+ * memory capability indexer) can import a command's help and iterate over it
7
+ * without dragging in the CLI's daemon/IPC action graph. Command modules apply
8
+ * the same data via {@link applyCommandHelp}, then attach their handlers.
9
+ */
10
+ export interface CliCommandHelp {
11
+ name: string;
12
+ description: string;
13
+ /** Options declared directly on the top-level command. */
14
+ options?: CliOptionHelp[];
15
+ /** Extra help appended after the option list (`addHelpText("after", …)`). */
16
+ helpText?: string;
17
+ subcommands?: CliSubcommandHelp[];
18
+ }
19
+
20
+ export interface CliSubcommandHelp {
21
+ name: string;
22
+ description: string;
23
+ options?: CliOptionHelp[];
24
+ /** Extra help appended after the option list (`addHelpText("after", …)`). */
25
+ helpText?: string;
26
+ }
27
+
28
+ export interface CliOptionHelp {
29
+ /** Commander flag spec, e.g. `"--path <file>"` or `"-l, --limit <n>"`. */
30
+ flags: string;
31
+ description: string;
32
+ /** When true, applied via `requiredOption` (missing → error) rather than `option`. */
33
+ required?: boolean;
34
+ /** Default value passed to `option(flags, description, defaultValue)`. */
35
+ defaultValue?: string;
36
+ }
37
+
38
+ function applyOptions(command: Command, options?: CliOptionHelp[]): void {
39
+ for (const option of options ?? []) {
40
+ if (option.required) {
41
+ command.requiredOption(option.flags, option.description);
42
+ } else if (option.defaultValue !== undefined) {
43
+ command.option(option.flags, option.description, option.defaultValue);
44
+ } else {
45
+ command.option(option.flags, option.description);
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Configure a Commander command from its declarative {@link CliCommandHelp}:
52
+ * top-level options, appended help text, and subcommands (with their options).
53
+ * Does not set the top-level name/description — `registerCommand` owns those —
54
+ * and does not attach action handlers; the command module attaches those to the
55
+ * command or its subcommands.
56
+ */
57
+ export function applyCommandHelp(command: Command, help: CliCommandHelp): void {
58
+ applyOptions(command, help.options);
59
+ if (help.helpText) {
60
+ command.addHelpText("after", help.helpText);
61
+ }
62
+ for (const sub of help.subcommands ?? []) {
63
+ const child = command.command(sub.name).description(sub.description);
64
+ applyOptions(child, sub.options);
65
+ if (sub.helpText) {
66
+ child.addHelpText("after", sub.helpText);
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Return a subcommand by name, throwing if absent. */
72
+ export function subcommand(parent: Command, name: string): Command {
73
+ const found = parent.commands.find((c) => c.name() === name);
74
+ if (!found) {
75
+ throw new Error(
76
+ `Subcommand "${name}" not found on "${parent.name()}" — is it declared in the command's .help module?`,
77
+ );
78
+ }
79
+ return found;
80
+ }
@@ -74,7 +74,7 @@ import {
74
74
  type SystemPromptPersonaOverride,
75
75
  } from "../prompts/system-prompt.js";
76
76
  import type { ContentBlock, Message } from "../providers/types.js";
77
- import type { Provider } from "../providers/types.js";
77
+ import type { Provider, ToolDefinition } from "../providers/types.js";
78
78
  import { type TrustClass } from "../runtime/actor-trust-resolver.js";
79
79
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
80
80
  import type { AuthContext } from "../runtime/auth/types.js";
@@ -768,9 +768,6 @@ export class Conversation {
768
768
  tools: toolDefs.length > 0 ? toolDefs : undefined,
769
769
  toolExecutor: toolDefs.length > 0 ? toolExecutor : undefined,
770
770
  resolveTools,
771
- // A tool the registry marks exclusive (e.g. `advisor`) runs alone in its
772
- // turn; the loop defers any sibling calls until the next turn.
773
- isExclusiveTool: (name) => getTool(name)?.exclusive === true,
774
771
  resolveConversationDir: () => {
775
772
  const conv = getConversation(this.conversationId);
776
773
  if (!conv) {
@@ -2379,6 +2376,26 @@ export class Conversation {
2379
2376
  return new Set(this.lastResolvedToolNames ?? this.coreToolNames);
2380
2377
  }
2381
2378
 
2379
+ /**
2380
+ * The {@link getRegisteredToolNames} inventory resolved to full definitions
2381
+ * (name, description, input schema), sorted by name. Resolution reads the
2382
+ * live registry so skill/MCP tools — whose definitions are not in the base
2383
+ * turn snapshot — are included. Consumers (e.g. turn-trace telemetry) read
2384
+ * this instead of reaching into the tool registry themselves.
2385
+ */
2386
+ getRegisteredToolDefinitions(): ToolDefinition[] {
2387
+ return Array.from(this.getRegisteredToolNames())
2388
+ .sort((a, b) => a.localeCompare(b))
2389
+ .map((name) => {
2390
+ const tool = getTool(name);
2391
+ return {
2392
+ name,
2393
+ description: tool?.description ?? "",
2394
+ input_schema: tool?.input_schema ?? {},
2395
+ };
2396
+ });
2397
+ }
2398
+
2382
2399
  // ── History ──────────────────────────────────────────────────────
2383
2400
 
2384
2401
  getMessages(): Message[] {
@@ -180,6 +180,11 @@ export {
180
180
  getAssistantName,
181
181
  resolveUserName,
182
182
  } from "../daemon/identity-helpers.js";
183
+ // Declarative help for the top-level `assistant` CLI commands that have adopted
184
+ // the static-help split. Plugins (e.g. the memory capability indexer) read this
185
+ // to embed CLI command capabilities without importing the CLI action graph.
186
+ // Pure data — iterate the fields directly.
187
+ export { CLI_COMMAND_HELP } from "../cli/index.help.js";
183
188
  // Embeddings — self-contained operations on the host's shared embedding /
184
189
  // vector-store subsystem. Host-resolved: each reads the live workspace config
185
190
  // internally, so plugins hold no config. Async because the facade loads the
@@ -109,6 +109,12 @@ mock.module("../../../../../cli/program.js", () => ({
109
109
  },
110
110
  }));
111
111
 
112
+ // Keep the suite driven entirely by the mocked program tree above: no commands
113
+ // are sourced from the declarative `cli/index.help.ts` aggregate.
114
+ mock.module("../../../../../cli/index.help.js", () => ({
115
+ CLI_COMMAND_HELP: [],
116
+ }));
117
+
112
118
  mock.module(
113
119
  "../../../../../persistence/embeddings/embedding-backend.js",
114
120
  () => ({
@@ -10,6 +10,12 @@
10
10
  * size without trouble, and trimming would drop the very examples and flag
11
11
  * descriptions that make commands semantically findable.
12
12
  */
13
+
14
+ import type { CLI_COMMAND_HELP } from "@vellumai/plugin-api";
15
+
16
+ /** Element type of the plugin-api's declarative CLI help constant. */
17
+ type CliCommandHelp = (typeof CLI_COMMAND_HELP)[number];
18
+
13
19
  export function buildCliCommandContent(
14
20
  name: string,
15
21
  description: string,
@@ -17,3 +23,49 @@ export function buildCliCommandContent(
17
23
  ): string {
18
24
  return `The "assistant ${name}" CLI command is available. ${description}.\n\nFull help:\n${helpText}`;
19
25
  }
26
+
27
+ /**
28
+ * Render capability content from a command's declarative {@link CliCommandHelp}
29
+ * (flags, subcommands, help prose) for commands that have adopted the static-help
30
+ * split. Produces the same prose lead-in as {@link buildCliCommandContent} with a
31
+ * data-derived help body, so commands read declaratively are indexed the same way
32
+ * as those still read from the Commander tree.
33
+ */
34
+ export function buildCliCommandHelpContent(help: CliCommandHelp): string {
35
+ const sections: string[] = [];
36
+ const topOptions = renderOptionLines(help.options);
37
+ if (topOptions) {
38
+ sections.push(topOptions);
39
+ }
40
+ if (help.helpText) {
41
+ sections.push(help.helpText.trim());
42
+ }
43
+ for (const sub of help.subcommands ?? []) {
44
+ const lines = [`${help.name} ${sub.name} — ${sub.description}`];
45
+ const options = renderOptionLines(sub.options);
46
+ if (options) {
47
+ lines.push(options);
48
+ }
49
+ if (sub.helpText) {
50
+ lines.push(sub.helpText.trim());
51
+ }
52
+ sections.push(lines.join("\n"));
53
+ }
54
+ return buildCliCommandContent(
55
+ help.name,
56
+ help.description,
57
+ sections.join("\n\n"),
58
+ );
59
+ }
60
+
61
+ function renderOptionLines(options: CliCommandHelp["options"]): string | null {
62
+ if (!options?.length) {
63
+ return null;
64
+ }
65
+ return options
66
+ .map(
67
+ (option) =>
68
+ ` ${option.flags}${option.required ? " (required)" : ""} ${option.description}`,
69
+ )
70
+ .join("\n");
71
+ }
@@ -20,12 +20,17 @@
20
20
  // - No MCP-style augmentation — Commander's description is the canonical
21
21
  // summary.
22
22
 
23
+ import { CLI_COMMAND_HELP } from "@vellumai/plugin-api";
24
+
23
25
  import { getConfig } from "../../../../config/loader.js";
24
26
  import { generateSparseEmbedding } from "../../../../persistence/embeddings/embedding-backend.js";
25
27
  import { applyCorrectionIfCalibrated } from "../anisotropy.js";
26
28
  import { embedWithBackend } from "../embeddings.js";
27
29
  import { getLogger } from "../logging.js";
28
- import { buildCliCommandContent } from "./cli-command-content.js";
30
+ import {
31
+ buildCliCommandContent,
32
+ buildCliCommandHelpContent,
33
+ } from "./cli-command-content.js";
29
34
  import { invalidatePageIndex } from "./page-index.js";
30
35
  import {
31
36
  backfillKindOnPointsWithPrefix,
@@ -135,21 +140,37 @@ function resolveSeedWaiters(): void {
135
140
  async function runSeedV2CliCommandEntries(generation: number): Promise<void> {
136
141
  try {
137
142
  const config = getConfig();
138
- // Dynamic import so callers that only need `getCliCommandCapability` or
139
- // `listCliCommandEntries` (e.g. the render path inside `injection.ts` and
140
- // the `page-index.ts` dependency loader) never drag the full CLI command
141
- // graph into their import tree. The CLI tree pulls in many provider and
142
- // workspace modules whose presence has been a recurring source of test-
143
- // mock cascades and circular-import surprises.
143
+ const seeds: CliCommandEntry[] = [];
144
+ const declarativeNames = new Set<string>();
145
+
146
+ // Commands that have adopted the static-help split are read from their
147
+ // declarative help (exposed via `@vellumai/plugin-api`) — pure data, no CLI
148
+ // action graph. Content is rendered from that data here in the plugin.
149
+ for (const help of CLI_COMMAND_HELP) {
150
+ declarativeNames.add(help.name);
151
+ seeds.push({
152
+ id: help.name,
153
+ description: help.description,
154
+ content: buildCliCommandHelpContent(help),
155
+ });
156
+ }
157
+
158
+ // Remaining commands still come from the Commander tree. Dynamic import so
159
+ // callers that only need `getCliCommandCapability` or `listCliCommandEntries`
160
+ // (e.g. the render path inside `injection.ts` and the `page-index.ts`
161
+ // dependency loader) never drag the full CLI command graph into their import
162
+ // tree. The CLI tree pulls in many provider and workspace modules whose
163
+ // presence has been a recurring source of test-mock cascades and
164
+ // circular-import surprises.
144
165
  const { buildCliProgramTree } = await import("../../../../cli/program.js");
145
166
  const program = buildCliProgramTree();
146
167
 
147
- const seeds: CliCommandEntry[] = [];
148
168
  for (const cmd of program.commands) {
149
169
  const name = cmd.name();
150
170
  // Skip the `help` builtin Commander adds automatically — it carries no
151
- // capability information of its own and is uniform across commands.
152
- if (name === "help") continue;
171
+ // capability information of its own — and commands already sourced
172
+ // declaratively above.
173
+ if (name === "help" || declarativeNames.has(name)) continue;
153
174
  const description = cmd.description();
154
175
  const content = buildCliCommandContent(
155
176
  name,
@@ -26,28 +26,17 @@ let mockLiveConversation:
26
26
  | {
27
27
  isProcessing: () => boolean;
28
28
  getCurrentSystemPrompt?: () => string;
29
- getRegisteredToolNames?: () => Set<string>;
29
+ getRegisteredToolDefinitions?: () => Array<{
30
+ name: string;
31
+ description: string;
32
+ input_schema: object;
33
+ }>;
30
34
  }
31
35
  | undefined;
32
36
  mock.module("../daemon/conversation-registry.js", () => ({
33
37
  findConversation: () => mockLiveConversation,
34
38
  }));
35
39
 
36
- // Stub the tool registry so trace assembly can resolve descriptions without
37
- // loading the real tool graph. Returns `undefined` by default; tests override
38
- // for specific tool names.
39
- let mockToolDescriptions: Record<string, string> = {};
40
- mock.module("../tools/registry.js", () => ({
41
- getTool: (name: string) =>
42
- mockToolDescriptions[name]
43
- ? { description: mockToolDescriptions[name], input_schema: {} }
44
- : undefined,
45
- resolveTool: (name: string) =>
46
- mockToolDescriptions[name]
47
- ? { description: mockToolDescriptions[name], input_schema: {} }
48
- : undefined,
49
- }));
50
-
51
40
  import { createConversation } from "../persistence/conversation-crud.js";
52
41
  import { getDb } from "../persistence/db-connection.js";
53
42
  import { initializeDb } from "../persistence/db-init.js";
@@ -72,7 +61,6 @@ function purge(): void {
72
61
  beforeEach(() => {
73
62
  purge();
74
63
  mockLiveConversation = undefined;
75
- mockToolDescriptions = {};
76
64
  });
77
65
 
78
66
  interface MessageSeed {
@@ -238,16 +226,16 @@ describe("assembleTurnTrace", () => {
238
226
  createdAt: 1100,
239
227
  });
240
228
 
229
+ // The conversation resolves + sorts its registered tools; trace assembly
230
+ // just maps them into the trace, so feed it already-resolved definitions.
241
231
  mockLiveConversation = {
242
232
  isProcessing: () => false,
243
233
  getCurrentSystemPrompt: () => "You are a helpful assistant.",
244
- getRegisteredToolNames: () =>
245
- new Set(["web_search", "file_read", "notify_parent"]),
246
- };
247
- mockToolDescriptions = {
248
- web_search: "Search the web",
249
- file_read: "Read a file",
250
- // notify_parent intentionally not in the map — description should be ""
234
+ getRegisteredToolDefinitions: () => [
235
+ { name: "file_read", description: "Read a file", input_schema: {} },
236
+ { name: "notify_parent", description: "", input_schema: {} },
237
+ { name: "web_search", description: "Search the web", input_schema: {} },
238
+ ],
251
239
  };
252
240
 
253
241
  const trace = assembleTurnTrace(boundary(conv.id, "m-user-1", 1000));
@@ -3,7 +3,6 @@ import { and, asc, eq, gt, lt, lte, or, sql } from "drizzle-orm";
3
3
  import { findConversation } from "../daemon/conversation-registry.js";
4
4
  import { getDb } from "../persistence/db-connection.js";
5
5
  import { messages, toolInvocations } from "../persistence/schema/index.js";
6
- import { getTool } from "../tools/registry.js";
7
6
  import { getLogger } from "../util/logger.js";
8
7
  import type {
9
8
  TurnTrace,
@@ -315,16 +314,11 @@ export function assembleTurnTrace(boundary: TurnTraceBoundary): TurnTrace {
315
314
  const systemPrompt = conversation?.getCurrentSystemPrompt() ?? null;
316
315
 
317
316
  const toolDefinitions: TurnTraceToolDefinition[] = conversation
318
- ? Array.from(conversation.getRegisteredToolNames())
319
- .sort((a, b) => a.localeCompare(b))
320
- .map((name) => {
321
- const tool = getTool(name);
322
- return {
323
- name,
324
- description: tool?.description ?? "",
325
- input_schema: (tool?.input_schema ?? {}) as Record<string, unknown>,
326
- };
327
- })
317
+ ? conversation.getRegisteredToolDefinitions().map((tool) => ({
318
+ name: tool.name,
319
+ description: tool.description,
320
+ input_schema: tool.input_schema as Record<string, unknown>,
321
+ }))
328
322
  : [];
329
323
 
330
324
  return {