@vellumai/assistant 0.10.7-dev.202607101106.c87f4e3 → 0.10.7-dev.202607101343.e727d4c

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.202607101343.e727d4c",
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
+ };