@vellumai/assistant 0.8.10-dev.202606101324.2fc90b3 → 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/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +196 -2
- package/src/__tests__/empty-response-hook.test.ts +54 -30
- 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__/workspace-tool-loader.test.ts +319 -0
- package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
- package/src/agent/loop.ts +49 -14
- package/src/config/feature-flag-registry.json +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- 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 +36 -19
- package/src/plugins/defaults/empty-response/hooks/stop.ts +37 -30
- package/src/plugins/pipeline.ts +8 -5
- 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/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
CheckpointInfo,
|
|
7
7
|
} from "../agent/loop.js";
|
|
8
8
|
import { AgentLoop } from "../agent/loop.js";
|
|
9
|
+
import { REFUSAL_FALLBACK_TEXT } from "../plugins/defaults/empty-response/hooks/stop.js";
|
|
9
10
|
import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
|
|
10
11
|
import type {
|
|
11
12
|
ContentBlock,
|
|
@@ -2164,12 +2165,23 @@ describe("AgentLoop", () => {
|
|
|
2164
2165
|
);
|
|
2165
2166
|
expect(messageCompletes).toHaveLength(2);
|
|
2166
2167
|
|
|
2167
|
-
//
|
|
2168
|
+
// An organic empty `end_turn` (not a refusal) is left as-is — the fallback
|
|
2169
|
+
// is refusal-specific, so the exhausted empty turn stays empty.
|
|
2168
2170
|
const lastAssistant = [...history]
|
|
2169
2171
|
.reverse()
|
|
2170
2172
|
.find((m) => m.role === "assistant");
|
|
2171
2173
|
expect(lastAssistant).toBeDefined();
|
|
2172
2174
|
expect(lastAssistant!.content).toEqual([]);
|
|
2175
|
+
|
|
2176
|
+
// AND no fallback text is streamed for a non-refusal empty turn.
|
|
2177
|
+
const textDeltas = events.filter((e) => e.type === "text_delta");
|
|
2178
|
+
expect(
|
|
2179
|
+
textDeltas.some(
|
|
2180
|
+
(e) =>
|
|
2181
|
+
(e as { type: "text_delta"; text: string }).text ===
|
|
2182
|
+
REFUSAL_FALLBACK_TEXT,
|
|
2183
|
+
),
|
|
2184
|
+
).toBe(false);
|
|
2173
2185
|
});
|
|
2174
2186
|
|
|
2175
2187
|
test("does not retry empty response on first turn (no prior tool use)", async () => {
|
|
@@ -2196,7 +2208,189 @@ describe("AgentLoop", () => {
|
|
|
2196
2208
|
|
|
2197
2209
|
// Should NOT retry — this is the first turn with no tool use history
|
|
2198
2210
|
expect(calls).toHaveLength(1);
|
|
2199
|
-
|
|
2211
|
+
// user + assistant; an organic empty `end_turn` is left as-is (the fallback
|
|
2212
|
+
// is refusal-specific), so the empty turn stays empty.
|
|
2213
|
+
expect(history).toHaveLength(2);
|
|
2214
|
+
expect(history[1].content).toEqual([]);
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
// Refusal stop boundary — the provider zeroed the response with
|
|
2218
|
+
// `stopReason: "refusal"`. The default `stop` hook rewrites the empty turn
|
|
2219
|
+
// into a user-facing fallback (no retry — a safety-classifier refusal
|
|
2220
|
+
// re-fires on a re-query), and the loop persists + streams it rather than the
|
|
2221
|
+
// empty assistant bubble the user would otherwise see.
|
|
2222
|
+
test("rewrites a refusal into a user-facing fallback without retrying", async () => {
|
|
2223
|
+
// GIVEN a provider that refuses (empty content, stopReason "refusal").
|
|
2224
|
+
const refusalResponse: ProviderResponse = {
|
|
2225
|
+
content: [],
|
|
2226
|
+
model: "mock-model",
|
|
2227
|
+
usage: { inputTokens: 10, outputTokens: 0 },
|
|
2228
|
+
stopReason: "refusal",
|
|
2229
|
+
};
|
|
2230
|
+
const { provider, calls } = createMockProvider([
|
|
2231
|
+
refusalResponse,
|
|
2232
|
+
refusalResponse,
|
|
2233
|
+
]);
|
|
2234
|
+
const loop = new AgentLoop({
|
|
2235
|
+
provider: provider,
|
|
2236
|
+
systemPrompt: "system",
|
|
2237
|
+
conversationId: "test-conversation",
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
// WHEN the loop runs.
|
|
2241
|
+
const events: AgentEvent[] = [];
|
|
2242
|
+
const { history } = await loop.run({
|
|
2243
|
+
messages: [userMessage],
|
|
2244
|
+
onEvent: collectEvents(events),
|
|
2245
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// THEN the loop calls the provider once: the refusal is rewritten in place
|
|
2249
|
+
// with no nudge-driven retry.
|
|
2250
|
+
expect(calls).toHaveLength(1);
|
|
2251
|
+
|
|
2252
|
+
// AND the user-visible assistant turn is the fallback, not an empty bubble.
|
|
2253
|
+
const lastAssistant = [...history]
|
|
2254
|
+
.reverse()
|
|
2255
|
+
.find((m) => m.role === "assistant");
|
|
2256
|
+
expect(lastAssistant!.content).toEqual([
|
|
2257
|
+
{ type: "text", text: REFUSAL_FALLBACK_TEXT },
|
|
2258
|
+
]);
|
|
2259
|
+
|
|
2260
|
+
// AND the fallback is streamed so the client renders it.
|
|
2261
|
+
const textDeltas = events.filter((e) => e.type === "text_delta");
|
|
2262
|
+
expect(
|
|
2263
|
+
textDeltas.some(
|
|
2264
|
+
(e) =>
|
|
2265
|
+
(e as { type: "text_delta"; text: string }).text ===
|
|
2266
|
+
REFUSAL_FALLBACK_TEXT,
|
|
2267
|
+
),
|
|
2268
|
+
).toBe(true);
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
// The fallback must not pile a spurious apology beneath a real answer: when
|
|
2272
|
+
// an earlier turn this run already produced visible text (text alongside a
|
|
2273
|
+
// tool call), a refusal on the trailing turn after the tool result must not
|
|
2274
|
+
// rewrite the turn even though a refusal would normally trigger it.
|
|
2275
|
+
test("does not substitute a fallback when the run already produced visible text", async () => {
|
|
2276
|
+
// GIVEN a first turn with text + a tool call, then a refusal after the
|
|
2277
|
+
// tool result.
|
|
2278
|
+
const textPlusToolUse: ProviderResponse = {
|
|
2279
|
+
content: [
|
|
2280
|
+
{ type: "text", text: "Here is your answer." },
|
|
2281
|
+
{
|
|
2282
|
+
type: "tool_use",
|
|
2283
|
+
id: "t1",
|
|
2284
|
+
name: "read_file",
|
|
2285
|
+
input: { path: "/a" },
|
|
2286
|
+
},
|
|
2287
|
+
],
|
|
2288
|
+
model: "mock-model",
|
|
2289
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
2290
|
+
stopReason: "tool_use",
|
|
2291
|
+
};
|
|
2292
|
+
const refusalResponse: ProviderResponse = {
|
|
2293
|
+
content: [],
|
|
2294
|
+
model: "mock-model",
|
|
2295
|
+
usage: { inputTokens: 10, outputTokens: 0 },
|
|
2296
|
+
stopReason: "refusal",
|
|
2297
|
+
};
|
|
2298
|
+
const { provider } = createMockProvider([textPlusToolUse, refusalResponse]);
|
|
2299
|
+
const loop = new AgentLoop({
|
|
2300
|
+
provider: provider,
|
|
2301
|
+
systemPrompt: "system",
|
|
2302
|
+
conversationId: "test-conversation",
|
|
2303
|
+
tools: dummyTools,
|
|
2304
|
+
toolExecutor: async () => ({ content: "data", isError: false }),
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
// WHEN the loop runs.
|
|
2308
|
+
const events: AgentEvent[] = [];
|
|
2309
|
+
const { history } = await loop.run({
|
|
2310
|
+
messages: [userMessage],
|
|
2311
|
+
onEvent: collectEvents(events),
|
|
2312
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
// THEN no fallback text is emitted or persisted — the real answer stands
|
|
2316
|
+
// alone and the trailing empty turn stays empty.
|
|
2317
|
+
const fallbackEmitted = events.some(
|
|
2318
|
+
(e) =>
|
|
2319
|
+
e.type === "text_delta" &&
|
|
2320
|
+
(e as { type: "text_delta"; text: string }).text ===
|
|
2321
|
+
REFUSAL_FALLBACK_TEXT,
|
|
2322
|
+
);
|
|
2323
|
+
expect(fallbackEmitted).toBe(false);
|
|
2324
|
+
const fallbackInHistory = history.some(
|
|
2325
|
+
(m) =>
|
|
2326
|
+
m.role === "assistant" &&
|
|
2327
|
+
m.content.some(
|
|
2328
|
+
(b) =>
|
|
2329
|
+
b.type === "text" &&
|
|
2330
|
+
"text" in b &&
|
|
2331
|
+
(b as { text: string }).text === REFUSAL_FALLBACK_TEXT,
|
|
2332
|
+
),
|
|
2333
|
+
);
|
|
2334
|
+
expect(fallbackInHistory).toBe(false);
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
// A native web-search turn lands at the stop boundary with no `tool_use` and
|
|
2338
|
+
// no visible text, but with `stopReason: "end_turn"` (not a refusal) and
|
|
2339
|
+
// `server_tool_use`/`web_search_tool_result` blocks that render the search
|
|
2340
|
+
// card. Because the fallback is refusal-specific, it must not fire here and
|
|
2341
|
+
// the server-tool blocks must persist untouched.
|
|
2342
|
+
test("does not substitute a fallback for a server-tool turn with no text", async () => {
|
|
2343
|
+
// GIVEN a turn carrying only native web-search blocks (no text, no
|
|
2344
|
+
// client-side tool_use).
|
|
2345
|
+
const serverToolBlocks: ContentBlock[] = [
|
|
2346
|
+
{
|
|
2347
|
+
type: "server_tool_use",
|
|
2348
|
+
id: "srv1",
|
|
2349
|
+
name: "web_search",
|
|
2350
|
+
input: { query: "weather" },
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
type: "web_search_tool_result",
|
|
2354
|
+
tool_use_id: "srv1",
|
|
2355
|
+
content: [{ title: "result" }],
|
|
2356
|
+
},
|
|
2357
|
+
];
|
|
2358
|
+
const webSearchResponse: ProviderResponse = {
|
|
2359
|
+
content: serverToolBlocks,
|
|
2360
|
+
model: "mock-model",
|
|
2361
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
2362
|
+
stopReason: "end_turn",
|
|
2363
|
+
};
|
|
2364
|
+
const { provider, calls } = createMockProvider([webSearchResponse]);
|
|
2365
|
+
const loop = new AgentLoop({
|
|
2366
|
+
provider: provider,
|
|
2367
|
+
systemPrompt: "system",
|
|
2368
|
+
conversationId: "test-conversation",
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
// WHEN the loop runs.
|
|
2372
|
+
const events: AgentEvent[] = [];
|
|
2373
|
+
const { history } = await loop.run({
|
|
2374
|
+
messages: [userMessage],
|
|
2375
|
+
onEvent: collectEvents(events),
|
|
2376
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
// THEN the provider is called once (no nudge for a non-refusal turn) and
|
|
2380
|
+
// the server-tool blocks are persisted untouched, with no fallback added.
|
|
2381
|
+
expect(calls).toHaveLength(1);
|
|
2382
|
+
const lastAssistant = [...history]
|
|
2383
|
+
.reverse()
|
|
2384
|
+
.find((m) => m.role === "assistant");
|
|
2385
|
+
expect(lastAssistant!.content).toEqual(serverToolBlocks);
|
|
2386
|
+
|
|
2387
|
+
const fallbackEmitted = events.some(
|
|
2388
|
+
(e) =>
|
|
2389
|
+
e.type === "text_delta" &&
|
|
2390
|
+
(e as { type: "text_delta"; text: string }).text ===
|
|
2391
|
+
REFUSAL_FALLBACK_TEXT,
|
|
2392
|
+
);
|
|
2393
|
+
expect(fallbackEmitted).toBe(false);
|
|
2200
2394
|
});
|
|
2201
2395
|
|
|
2202
2396
|
// PR 6: callSite threading from AgentLoop.run() into provider config.
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Tests for the default `empty-response` plugin's `stop` hook.
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
|
-
* - The default hook's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* - The default hook's decision for the canonical cases: empty turn after a
|
|
6
|
+
* prior tool-use turn → continue (with the canonical nudge text); visible
|
|
7
|
+
* text → stop; prior turn already delivered visible text → stop; first model
|
|
8
|
+
* call with no prior turn → stop; provider refusal → stop with the turn
|
|
9
|
+
* rewritten to the user-facing fallback; refusal-but-recovered → stop;
|
|
10
|
+
* refusal after a visible reply this run → stop with no rewrite.
|
|
10
11
|
* - The hook scopes its prior-turn signals to the current response cycle (the
|
|
11
12
|
* messages after the last genuine user prompt), so visible text from the
|
|
12
13
|
* inbound conversation does not suppress the nudge.
|
|
@@ -28,7 +29,7 @@ import { HOOKS } from "../plugin-api/constants.js";
|
|
|
28
29
|
import type { PluginLogger, StopContext } from "../plugin-api/types.js";
|
|
29
30
|
import {
|
|
30
31
|
NUDGE_TEXT,
|
|
31
|
-
|
|
32
|
+
REFUSAL_FALLBACK_TEXT,
|
|
32
33
|
} from "../plugins/defaults/empty-response/hooks/stop.js";
|
|
33
34
|
import stop from "../plugins/defaults/empty-response/hooks/stop.js";
|
|
34
35
|
import { defaultEmptyResponsePlugin } from "../plugins/defaults/index.js";
|
|
@@ -172,7 +173,7 @@ describe("empty-response stop hook — default decisions", () => {
|
|
|
172
173
|
|
|
173
174
|
// ─── Refusal stop ──────────────────────────────────────────────────────────
|
|
174
175
|
|
|
175
|
-
test("refusal on the first call with no content →
|
|
176
|
+
test("refusal on the first call with no content → stop with rewritten fallback", async () => {
|
|
176
177
|
// GIVEN the canonical failure mode: the provider's safety classifier zeros
|
|
177
178
|
// the response on the very first model call, returning `stopReason:
|
|
178
179
|
// "refusal"` and no visible text.
|
|
@@ -185,15 +186,16 @@ describe("empty-response stop hook — default decisions", () => {
|
|
|
185
186
|
// WHEN the hook runs.
|
|
186
187
|
await stop(ctx);
|
|
187
188
|
|
|
188
|
-
// THEN it
|
|
189
|
-
|
|
190
|
-
expect(ctx.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
// THEN it lets the turn end and rewrites the turn content to the
|
|
190
|
+
// user-facing fallback, without nudging the model.
|
|
191
|
+
expect(ctx.decision).toBe("stop");
|
|
192
|
+
expect(ctx.responseContent).toEqual([
|
|
193
|
+
{ type: "text", text: REFUSAL_FALLBACK_TEXT },
|
|
194
|
+
]);
|
|
195
|
+
expect(ctx.messages).toEqual([]);
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
test("refusal with a thinking-only block still
|
|
198
|
+
test("refusal with a thinking-only block is still rewritten", async () => {
|
|
197
199
|
// GIVEN a refusal whose only content is a thinking block — the user sees
|
|
198
200
|
// nothing.
|
|
199
201
|
const ctx = makeCtx({
|
|
@@ -207,15 +209,14 @@ describe("empty-response stop hook — default decisions", () => {
|
|
|
207
209
|
// WHEN the hook runs.
|
|
208
210
|
await stop(ctx);
|
|
209
211
|
|
|
210
|
-
// THEN it
|
|
211
|
-
expect(ctx.decision).toBe("
|
|
212
|
-
expect(ctx.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
});
|
|
212
|
+
// THEN it stops and rewrites the turn content to the fallback.
|
|
213
|
+
expect(ctx.decision).toBe("stop");
|
|
214
|
+
expect(ctx.responseContent).toEqual([
|
|
215
|
+
{ type: "text", text: REFUSAL_FALLBACK_TEXT },
|
|
216
|
+
]);
|
|
216
217
|
});
|
|
217
218
|
|
|
218
|
-
test("refusal but visible text present → stop (model recovered)", async () => {
|
|
219
|
+
test("refusal but visible text present → stop, no rewrite (model recovered)", async () => {
|
|
219
220
|
// GIVEN a refusal that still delivered some visible text before refusing —
|
|
220
221
|
// the user has something to see.
|
|
221
222
|
const ctx = makeCtx({
|
|
@@ -226,13 +227,35 @@ describe("empty-response stop hook — default decisions", () => {
|
|
|
226
227
|
// WHEN the hook runs.
|
|
227
228
|
await stop(ctx);
|
|
228
229
|
|
|
229
|
-
// THEN the decision stays at stop.
|
|
230
|
+
// THEN the decision stays at stop and the turn content is left untouched.
|
|
230
231
|
expect(ctx.decision).toBe("stop");
|
|
232
|
+
expect(ctx.responseContent).toEqual([
|
|
233
|
+
{ type: "text", text: "partial answer" },
|
|
234
|
+
]);
|
|
231
235
|
});
|
|
232
236
|
|
|
233
|
-
test("refusal
|
|
237
|
+
test("refusal after a visible reply this run → stop, no rewrite", async () => {
|
|
238
|
+
// GIVEN an earlier turn this run already delivered a real answer (text
|
|
239
|
+
// alongside a tool call), then the trailing turn refuses after the tool
|
|
240
|
+
// result. Rewriting here would stack an apology beneath the real answer.
|
|
241
|
+
const ctx = makeCtx({
|
|
242
|
+
messages: [userPrompt("do X"), priorVisibleTextTurn, priorToolUseTurn],
|
|
243
|
+
responseContent: [],
|
|
244
|
+
stopReason: "refusal",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// WHEN the hook runs.
|
|
248
|
+
await stop(ctx);
|
|
249
|
+
|
|
250
|
+
// THEN the refusal is left as-is — no fallback is stacked beneath the
|
|
251
|
+
// earlier reply.
|
|
252
|
+
expect(ctx.decision).toBe("stop");
|
|
253
|
+
expect(ctx.responseContent).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("refusal beats the post-tool-empty nudge", async () => {
|
|
234
257
|
// GIVEN conditions that would trip both the refusal branch and the
|
|
235
|
-
// post-tool-empty branch.
|
|
258
|
+
// post-tool-empty branch (a prior tool-use turn with no visible text).
|
|
236
259
|
const ctx = makeCtx({
|
|
237
260
|
messages: [priorToolUseTurn],
|
|
238
261
|
responseContent: [],
|
|
@@ -242,12 +265,13 @@ describe("empty-response stop hook — default decisions", () => {
|
|
|
242
265
|
// WHEN the hook runs.
|
|
243
266
|
await stop(ctx);
|
|
244
267
|
|
|
245
|
-
// THEN refusal wins
|
|
246
|
-
|
|
247
|
-
expect(ctx.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
268
|
+
// THEN refusal wins: the turn content is rewritten to the fallback and the
|
|
269
|
+
// model is not nudged.
|
|
270
|
+
expect(ctx.decision).toBe("stop");
|
|
271
|
+
expect(ctx.responseContent).toEqual([
|
|
272
|
+
{ type: "text", text: REFUSAL_FALLBACK_TEXT },
|
|
273
|
+
]);
|
|
274
|
+
expect(ctx.messages).toEqual([priorToolUseTurn]);
|
|
251
275
|
});
|
|
252
276
|
});
|
|
253
277
|
|