@vellumai/assistant 0.8.10-dev.202606101122.0de2aff → 0.8.10-dev.202606101436.d73da44
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/docs/workspace-tools.md +196 -0
- package/openapi.yaml +13 -3
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +9 -81
- package/src/__tests__/agent-loop.test.ts +220 -77
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +219 -427
- package/src/__tests__/conversation-agent-loop.test.ts +73 -504
- package/src/__tests__/conversation-provider-retry-repair.test.ts +11 -336
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
- package/src/__tests__/empty-response-hook.test.ts +54 -30
- package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
- package/src/__tests__/registry.test.ts +240 -1
- package/src/__tests__/tool-error-hook.test.ts +4 -3
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/tools-get-route.test.ts +76 -0
- package/src/__tests__/workspace-tool-loader.test.ts +319 -0
- package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
- package/src/agent/loop.ts +236 -159
- package/src/cli/commands/tools.ts +17 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +3 -25
- package/src/daemon/conversation-agent-loop.ts +83 -319
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +11 -0
- package/src/daemon/conversation.ts +25 -0
- package/src/daemon/providers-setup.ts +3 -0
- package/src/daemon/server.ts +3 -0
- package/src/daemon/workspace-tools-watcher.ts +324 -0
- package/src/plugin-api/types.ts +43 -19
- package/src/plugins/defaults/compaction/compact.ts +27 -1
- package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
- package/src/plugins/defaults/compaction/window-manager.ts +108 -0
- package/src/plugins/defaults/empty-response/hooks/stop.ts +37 -30
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +4 -4
- package/src/plugins/pipeline.ts +8 -5
- package/src/runtime/routes/settings-routes.ts +99 -21
- package/src/tools/registry.ts +387 -5
- package/src/tools/types.ts +2 -2
- package/src/tools/workspace-tools/loader.ts +673 -0
- package/src/util/platform.ts +16 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Workspace Tool Overrides
|
|
2
|
+
|
|
3
|
+
Workspace tool overrides let the operator replace a core assistant tool (or
|
|
4
|
+
add a brand-new one) by dropping a single file under their
|
|
5
|
+
`<workspaceDir>/tools/` directory. The override survives assistant restarts,
|
|
6
|
+
takes effect during the same startup phase as core tool initialization,
|
|
7
|
+
and is recoverable: removing the file restores the original core behavior.
|
|
8
|
+
When the `workspace-tools-watcher` feature flag is enabled, overrides are
|
|
9
|
+
also hot-reloaded by a filesystem watcher (no restart required after the
|
|
10
|
+
initial boot).
|
|
11
|
+
|
|
12
|
+
This page explains the file convention, lifecycle position, and the
|
|
13
|
+
"single canonical source per name" invariant the design is built around.
|
|
14
|
+
|
|
15
|
+
## File convention
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
<workspaceDir>/
|
|
19
|
+
tools/
|
|
20
|
+
skill_load.ts ← name="skill_load" via .ts default export
|
|
21
|
+
my_tool.js ← name="my_tool" via compiled .js
|
|
22
|
+
data_lookup.json ← name="data_lookup" via JSON spec
|
|
23
|
+
host_bash.removed ← name="host_bash" — strip from registry
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
|
|
28
|
+
- The **filename stem** is the registered tool name verbatim. There is no
|
|
29
|
+
derivation, no basename transformation, no directory layer.
|
|
30
|
+
- The loader looks for `.js` first, then `.ts`, then `.json`. The
|
|
31
|
+
`.js`-preferred ordering matches compiled-binary semantics: a release
|
|
32
|
+
build that ships compiled artifacts next to source runs the compiled
|
|
33
|
+
file.
|
|
34
|
+
- `.json` files load as declarative specs — they describe schema and
|
|
35
|
+
metadata but cannot supply an `execute` function. Calling a
|
|
36
|
+
JSON-sourced tool returns an `isError` result; they exist for stubs
|
|
37
|
+
and override placeholders.
|
|
38
|
+
- A `<name>.removed` sentinel file (any contents, typically empty)
|
|
39
|
+
strips the same-named core tool from the registry without
|
|
40
|
+
substituting a replacement. Removing the sentinel file restores the
|
|
41
|
+
core tool on the next reconcile.
|
|
42
|
+
- Filename stems must satisfy `isProviderSafeToolName`
|
|
43
|
+
(`/^[a-zA-Z0-9_-]{1,64}$/`). A stem with spaces or punctuation is
|
|
44
|
+
logged at error and skipped; the loader never silently rewrites the
|
|
45
|
+
name with a hash suffix that would be unfindable.
|
|
46
|
+
- Stems starting with `.` are skipped (so `.gitignore`, `.DS_Store`
|
|
47
|
+
cannot accidentally register a tool).
|
|
48
|
+
- If `<name>.ts` and `<name>.js` (or any other shadow combination)
|
|
49
|
+
coexist for the same stem, the winning extension is used and the
|
|
50
|
+
shadowed siblings are logged at warn.
|
|
51
|
+
|
|
52
|
+
## Tool file shape
|
|
53
|
+
|
|
54
|
+
The default export is a generic `ToolDefinition` — the same shape every
|
|
55
|
+
tool surface in the assistant uses. All four normally-required fields
|
|
56
|
+
fall back to documented defaults if omitted, so a misconfigured tool
|
|
57
|
+
still loads cleanly and surfaces its problem at call time rather than
|
|
58
|
+
blocking assistant boot.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// /workspace/tools/skill_load.ts
|
|
62
|
+
import type {
|
|
63
|
+
ToolContext,
|
|
64
|
+
ToolDefinition,
|
|
65
|
+
ToolExecutionResult,
|
|
66
|
+
} from "@vellumai/plugin-api";
|
|
67
|
+
|
|
68
|
+
const tool: ToolDefinition = {
|
|
69
|
+
description: "Skill loader (custom workspace implementation)",
|
|
70
|
+
defaultRiskLevel: "low",
|
|
71
|
+
input_schema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: { skill_id: { type: "string" } },
|
|
74
|
+
required: ["skill_id"],
|
|
75
|
+
},
|
|
76
|
+
async execute(
|
|
77
|
+
input: { skill_id: string },
|
|
78
|
+
ctx: ToolContext,
|
|
79
|
+
): Promise<ToolExecutionResult> {
|
|
80
|
+
return { content: `loaded ${input.skill_id}`, isError: false };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default tool;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Defaults applied when fields are omitted:
|
|
88
|
+
|
|
89
|
+
| Field | Default |
|
|
90
|
+
| ------------------ | ------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `description` | `""` |
|
|
92
|
+
| `defaultRiskLevel` | `"high"` |
|
|
93
|
+
| `executionTarget` | `"sandbox"` |
|
|
94
|
+
| `input_schema` | `{ type: "object", properties: {}, additionalProperties: false }` |
|
|
95
|
+
| `execute` | Returns `{ content: "workspace tool <name> has no execute implementation", isError: true }` |
|
|
96
|
+
|
|
97
|
+
## Override semantics
|
|
98
|
+
|
|
99
|
+
Workspace tools are the highest-priority origin in the tool registry:
|
|
100
|
+
|
|
101
|
+
- **Same name as a core tool** → the original core tool is moved into an
|
|
102
|
+
internal stash (`getCoreToolOverride(name)`) and the workspace tool takes
|
|
103
|
+
its place. Removing the workspace file causes the watcher to restore
|
|
104
|
+
the original — workspace tools are not destructive to the core baseline.
|
|
105
|
+
- **Brand-new name** → registers as a net-new entry. No stash.
|
|
106
|
+
- **`<name>.removed` sentinel for a core tool** → the core tool is
|
|
107
|
+
stripped (stashed in the same map as override-style stashing) and no
|
|
108
|
+
replacement is registered. Deleting the sentinel restores the core
|
|
109
|
+
tool. Stripping a name that doesn't exist is a logged no-op.
|
|
110
|
+
- **Collision with a skill / plugin / MCP tool of the same name** → the
|
|
111
|
+
workspace registration throws. Workspace tools must register before
|
|
112
|
+
other extension categories; seeing one of these owners during a
|
|
113
|
+
workspace registration means the assistant startup order regressed.
|
|
114
|
+
|
|
115
|
+
Subsequent (post-workspace-load) registrations from plugins, skills, or
|
|
116
|
+
MCP servers that try to claim a name a workspace tool already owns (or
|
|
117
|
+
has stripped via `.removed`) are **warn-and-skipped**. Plugins, skills,
|
|
118
|
+
and MCP servers cannot bypass this by registering before workspace
|
|
119
|
+
tools load — the lifecycle pins workspace tools to register first.
|
|
120
|
+
|
|
121
|
+
Skill / plugin / MCP collisions with a workspace tool name across the
|
|
122
|
+
distinct namespacing those surfaces enforce are impossible — each runs
|
|
123
|
+
in its own owner-scoped registry path, and the workspace registration's
|
|
124
|
+
stamping is authoritative regardless of pre-existing ownership fields
|
|
125
|
+
on the incoming tool.
|
|
126
|
+
|
|
127
|
+
## Lifecycle position
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
initializeTools() # core tools register
|
|
131
|
+
→ loadWorkspaceTools() # initial workspace tool scan
|
|
132
|
+
→ MCP tool registration
|
|
133
|
+
→ loadUserPlugins()
|
|
134
|
+
→ bootstrapPlugins()
|
|
135
|
+
|
|
136
|
+
# after providers-setup completes:
|
|
137
|
+
DaemonServer.start()
|
|
138
|
+
→ WorkspaceToolsWatcher.getInstance().start() # hot register/unregister via fs.watch
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Workspace tools register _after_ core tools and _before_ every other
|
|
142
|
+
extension surface during the initial scan so that every subsequent
|
|
143
|
+
registration sees the workspace tool as already-owned. The initial scan
|
|
144
|
+
(`loadWorkspaceTools()`) always runs, so workspace tools load from disk
|
|
145
|
+
at every boot regardless of the flag.
|
|
146
|
+
|
|
147
|
+
The filesystem watcher is gated on the `workspace-tools-watcher` feature
|
|
148
|
+
flag (default off). When enabled, it runs for the lifetime of the
|
|
149
|
+
assistant, picking up add/change/delete events on `<workspaceDir>/tools/`
|
|
150
|
+
and reconciling the registry without requiring a restart. When disabled,
|
|
151
|
+
no watch loop is mounted and live edits to `<workspaceDir>/tools/` take
|
|
152
|
+
effect only on the next daemon restart. The flag is read at startup, so
|
|
153
|
+
toggling it takes effect on restart rather than mid-process.
|
|
154
|
+
|
|
155
|
+
The watcher debounces per filename stem and reconciles by re-deriving
|
|
156
|
+
the world from disk ("given what's on disk right now for `<stem>.*`,
|
|
157
|
+
what registry state should the assistant be in?") rather than routing
|
|
158
|
+
on `fs.watch`'s unreliable add/change/rename event types. This is the
|
|
159
|
+
same eventual-consistency pattern the plugin source watcher uses.
|
|
160
|
+
|
|
161
|
+
## Per-tool isolation
|
|
162
|
+
|
|
163
|
+
A single broken workspace tool must never block assistant boot. The
|
|
164
|
+
loader applies the same per-tool isolation contract as the user-plugin
|
|
165
|
+
loader:
|
|
166
|
+
|
|
167
|
+
- A `.ts` / `.js` whose module body throws during import is logged at
|
|
168
|
+
error and skipped — the loader proceeds to the next file.
|
|
169
|
+
- A `.ts` / `.js` whose default export is missing or not an object is
|
|
170
|
+
logged at error and skipped.
|
|
171
|
+
- A `.json` that doesn't parse, or doesn't decode to an object, is
|
|
172
|
+
logged at error and skipped.
|
|
173
|
+
- A `.ts` / `.js` whose module evaluation hangs is bounded by a
|
|
174
|
+
10-second timeout. The abandoned import continues running in the
|
|
175
|
+
background but the loader has already moved on. Bun's `import()`
|
|
176
|
+
cannot be cancelled, so the registry guards against late-arriving
|
|
177
|
+
registrations by the time-bounded path.
|
|
178
|
+
|
|
179
|
+
The registry's batch validation runs ahead of any mutation, so a single
|
|
180
|
+
late-discovered conflict (e.g. duplicate name within the batch) aborts
|
|
181
|
+
the whole call without partially populating the registry.
|
|
182
|
+
|
|
183
|
+
## Unregistering
|
|
184
|
+
|
|
185
|
+
Deleting `<workspaceDir>/tools/<name>.{ts,js,json}` triggers the file
|
|
186
|
+
watcher's reconcile, which calls `unregisterWorkspaceTool(name)` and
|
|
187
|
+
restores the stashed core tool when one exists, or simply deletes the
|
|
188
|
+
entry when the workspace tool was net-new. No assistant restart is
|
|
189
|
+
required.
|
|
190
|
+
|
|
191
|
+
To strip a core tool without substituting a replacement, drop an empty
|
|
192
|
+
`<workspaceDir>/tools/<name>.removed` file. Removing that sentinel
|
|
193
|
+
restores the core tool on the next reconcile. The two states (override
|
|
194
|
+
vs. strip) are mutually exclusive — placing both `<name>.ts` and
|
|
195
|
+
`<name>.removed` for the same stem causes the watcher to tear down
|
|
196
|
+
both states until the conflict is resolved on disk.
|
package/openapi.yaml
CHANGED
|
@@ -24691,9 +24691,12 @@ paths:
|
|
|
24691
24691
|
operationId: tools_get
|
|
24692
24692
|
summary: List registered tools with metadata and schemas
|
|
24693
24693
|
description:
|
|
24694
|
-
"Return
|
|
24695
|
-
category, and contributing source: core,
|
|
24696
|
-
skill
|
|
24694
|
+
"Return registered tools. Without `conversationId`, returns every tool in the global registry; `tools`
|
|
24695
|
+
carries per-tool metadata (description, author-asserted risk level, category, and contributing source: core,
|
|
24696
|
+
skill, plugin, or MCP server) and `names`/`schemas` additionally cover skill tools whose manifests are present
|
|
24697
|
+
but not yet loaded, for the permission-simulator catalog. With `conversationId`, scopes the result to the tools
|
|
24698
|
+
available to that conversation as of its most recent turn (including skill/MCP tools registered over its
|
|
24699
|
+
lifecycle); 404 if no such conversation is active."
|
|
24697
24700
|
tags:
|
|
24698
24701
|
- tools
|
|
24699
24702
|
responses:
|
|
@@ -24747,6 +24750,13 @@ paths:
|
|
|
24747
24750
|
- schemas
|
|
24748
24751
|
- tools
|
|
24749
24752
|
additionalProperties: false
|
|
24753
|
+
parameters:
|
|
24754
|
+
- name: conversationId
|
|
24755
|
+
in: query
|
|
24756
|
+
required: false
|
|
24757
|
+
schema:
|
|
24758
|
+
type: string
|
|
24759
|
+
description: When set, scope the tool inventory to this conversation's most recent turn instead of the global registry.
|
|
24750
24760
|
/v1/tools/simulate-permission:
|
|
24751
24761
|
post:
|
|
24752
24762
|
operationId: tools_simulatepermission_post
|
package/package.json
CHANGED
|
@@ -365,7 +365,7 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
365
365
|
});
|
|
366
366
|
|
|
367
367
|
const onCheckpoint = (_info: CheckpointInfo): CheckpointDecision =>
|
|
368
|
-
"
|
|
368
|
+
"handoff";
|
|
369
369
|
|
|
370
370
|
const events: AgentEvent[] = [];
|
|
371
371
|
await loop.run({
|
|
@@ -380,42 +380,9 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
380
380
|
expect(countExitEvents(events)).toBe(0);
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
-
test("
|
|
384
|
-
// GIVEN a
|
|
385
|
-
//
|
|
386
|
-
const { provider } = createMockProvider([
|
|
387
|
-
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
388
|
-
textResponse("never reached"),
|
|
389
|
-
]);
|
|
390
|
-
const toolExecutor = async () => ({ content: "ok", isError: false });
|
|
391
|
-
const loop = new AgentLoop({
|
|
392
|
-
provider: provider,
|
|
393
|
-
systemPrompt: "system",
|
|
394
|
-
conversationId: "test-conversation",
|
|
395
|
-
tools: dummyTools,
|
|
396
|
-
toolExecutor: toolExecutor,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// AND an effective context window so small that any real token estimate
|
|
400
|
-
// of the running history exceeds the mid-loop threshold.
|
|
401
|
-
// WHEN the loop checkpoints after the tool results land
|
|
402
|
-
const result = await loop.run({
|
|
403
|
-
messages: [userMessage],
|
|
404
|
-
onEvent: () => {},
|
|
405
|
-
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
406
|
-
resolveContextWindow: () => ({
|
|
407
|
-
maxInputTokens: 10,
|
|
408
|
-
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
409
|
-
}),
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// THEN it yields for budget before issuing the next provider call.
|
|
413
|
-
expect(result.exitReason).toBe("budget");
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
test("does not yield for budget when overflow recovery is disabled", async () => {
|
|
417
|
-
// GIVEN the same tiny context window but overflow recovery disabled — the
|
|
418
|
-
// agent-wake configuration, which must never yield for budget.
|
|
383
|
+
test("runs to a clean exit when overflow recovery is disabled", async () => {
|
|
384
|
+
// GIVEN a tiny context window but overflow recovery disabled — the
|
|
385
|
+
// agent-wake configuration, which must never compact mid-loop.
|
|
419
386
|
const { provider } = createMockProvider([
|
|
420
387
|
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
421
388
|
textResponse("done"),
|
|
@@ -440,8 +407,8 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
440
407
|
}),
|
|
441
408
|
});
|
|
442
409
|
|
|
443
|
-
// THEN it
|
|
444
|
-
expect(result.exitReason).
|
|
410
|
+
// THEN it reaches a clean exit without pausing the loop.
|
|
411
|
+
expect(result.exitReason).toBeNull();
|
|
445
412
|
});
|
|
446
413
|
|
|
447
414
|
test("compacts in place and continues when the budget gate trips with a compaction hook", async () => {
|
|
@@ -485,52 +452,13 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
485
452
|
});
|
|
486
453
|
|
|
487
454
|
// THEN the loop runs the compaction ceremony in place and continues to a
|
|
488
|
-
// clean exit
|
|
489
|
-
//
|
|
455
|
+
// clean exit. The durable commit is signalled via a `compaction_completed`
|
|
456
|
+
// event rather than an injected hook.
|
|
490
457
|
expect(events.some((event) => event.type === "compaction_completed")).toBe(
|
|
491
458
|
true,
|
|
492
459
|
);
|
|
493
460
|
expect(reinjected).toBe(true);
|
|
494
|
-
expect(result.exitReason).
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
test("yields 'budget' when inline compaction reports exhausted", async () => {
|
|
498
|
-
const { provider } = createMockProvider([
|
|
499
|
-
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
500
|
-
textResponse("never reached"),
|
|
501
|
-
]);
|
|
502
|
-
const toolExecutor = async () => ({ content: "ok", isError: false });
|
|
503
|
-
const loop = new AgentLoop({
|
|
504
|
-
provider: provider,
|
|
505
|
-
systemPrompt: "system",
|
|
506
|
-
conversationId: "test-conversation",
|
|
507
|
-
tools: dummyTools,
|
|
508
|
-
toolExecutor: toolExecutor,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
postCompactImpl = async () => {
|
|
512
|
-
throw new Error(
|
|
513
|
-
"post-compaction re-injection must not run when exhausted",
|
|
514
|
-
);
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
// WHEN compaction exhausts its retry budget
|
|
518
|
-
const result = await loop.run({
|
|
519
|
-
messages: [userMessage],
|
|
520
|
-
onEvent: () => {},
|
|
521
|
-
resolveContextWindow: () => ({
|
|
522
|
-
maxInputTokens: 10,
|
|
523
|
-
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
524
|
-
}),
|
|
525
|
-
compactInPlace: true,
|
|
526
|
-
...fakeCompaction("test-conversation", {
|
|
527
|
-
compacted: false,
|
|
528
|
-
exhausted: true,
|
|
529
|
-
}),
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
// THEN the loop yields for budget so the orchestrator can escalate.
|
|
533
|
-
expect(result.exitReason).toBe("budget");
|
|
461
|
+
expect(result.exitReason).toBeNull();
|
|
534
462
|
});
|
|
535
463
|
|
|
536
464
|
test("emits 'error' when provider throws an unhandled error", async () => {
|