@vellumai/assistant 0.10.4-dev.202607010028.1a4efcc → 0.10.4-dev.202607010146.a37a2fa
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/Dockerfile +0 -1
- package/eslint-rules/cli-no-daemon-internals.js +4 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-queue.test.ts +14 -0
- package/src/__tests__/persistence-layering-guard.test.ts +6 -12
- package/src/__tests__/plugin-disabled-state.test.ts +12 -0
- package/src/__tests__/skill-tool-factory.test.ts +53 -1
- package/src/cli/commands/__tests__/status.test.ts +42 -2
- package/src/cli/commands/status.ts +12 -1
- package/src/config/__tests__/messages-search-backend.test.ts +77 -0
- package/src/config/assistant-feature-flags.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/persistence/__tests__/conversation-search-lexical.test.ts +103 -0
- package/src/persistence/conversation-search-lexical.ts +48 -0
- package/src/persistence/jobs-worker.ts +5 -8
- package/src/persistence/memory-lifecycle-hooks.test.ts +15 -0
- package/src/persistence/memory-lifecycle-hooks.ts +14 -1
- package/src/plugins/defaults/index.ts +8 -0
- package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-schedule.test.ts +9 -0
- package/src/plugins/defaults/memory/persistence-hooks.ts +8 -0
- package/src/skills/validate-input.ts +47 -0
- package/src/tools/skills/skill-tool-factory.ts +5 -1
- package/src/util/__tests__/process-tree.test.ts +10 -2
- package/src/util/process-tree.ts +10 -2
package/Dockerfile
CHANGED
|
@@ -24,7 +24,6 @@ COPY packages/egress-proxy ./packages/egress-proxy
|
|
|
24
24
|
COPY packages/environments ./packages/environments
|
|
25
25
|
COPY packages/gateway-client ./packages/gateway-client
|
|
26
26
|
COPY packages/ipc-server-utils ./packages/ipc-server-utils
|
|
27
|
-
COPY packages/skill-host-contracts ./packages/skill-host-contracts
|
|
28
27
|
COPY packages/slack-text ./packages/slack-text
|
|
29
28
|
COPY packages/twilio-client ./packages/twilio-client
|
|
30
29
|
|
|
@@ -21,6 +21,10 @@ const ALLOWED_PREFIXES = {
|
|
|
21
21
|
// Status command's daemon-down fallback needs socket path + platform.
|
|
22
22
|
"../../ipc/socket-path",
|
|
23
23
|
"../../util/platform",
|
|
24
|
+
// App version constant (leaf module; reads package.json/env, no daemon
|
|
25
|
+
// deps) — status prints it to surface CLI-vs-runtime version drift.
|
|
26
|
+
"../../version",
|
|
27
|
+
"../../../version",
|
|
24
28
|
// Logger / output at depth-1 and depth-2.
|
|
25
29
|
"../logger",
|
|
26
30
|
"../output",
|
package/package.json
CHANGED
|
@@ -827,6 +827,13 @@ describe("Conversation message queue", () => {
|
|
|
827
827
|
(e) => e.type === "conversation_error",
|
|
828
828
|
);
|
|
829
829
|
expect(conversationErr3).toBeUndefined();
|
|
830
|
+
|
|
831
|
+
// Settle the aborted in-flight run so its abort watchdog clears the
|
|
832
|
+
// real-time timer it armed. A leaked ~5s timer otherwise fires during a
|
|
833
|
+
// later test and drives this stale turn into commitTurnChanges, inflating
|
|
834
|
+
// the shared turnCommitCalls counter that other tests assert against.
|
|
835
|
+
await resolveRun(0);
|
|
836
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
830
837
|
});
|
|
831
838
|
|
|
832
839
|
test("conversation-scoped errors emit both conversation_error and generic error", async () => {
|
|
@@ -2801,6 +2808,13 @@ describe("Regression: cancel semantics and error channel split", () => {
|
|
|
2801
2808
|
);
|
|
2802
2809
|
expect(conversationErr).toBeUndefined();
|
|
2803
2810
|
}
|
|
2811
|
+
|
|
2812
|
+
// Settle the aborted in-flight run so its abort watchdog clears the
|
|
2813
|
+
// real-time timer it armed. A leaked ~5s timer otherwise fires during a
|
|
2814
|
+
// later test and drives this stale turn into commitTurnChanges, inflating
|
|
2815
|
+
// the shared turnCommitCalls counter that other tests assert against.
|
|
2816
|
+
await resolveRun(0);
|
|
2817
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2804
2818
|
});
|
|
2805
2819
|
|
|
2806
2820
|
test("commitTurnChanges never resolving within budget -> turn still completes and drains queue", async () => {
|
|
@@ -37,22 +37,16 @@ const MEMORY_DIR = join(ASSISTANT_SRC, "plugins", "defaults", "memory");
|
|
|
37
37
|
* TECH DEBT — residual `persistence/` → `memory/` feature back-imports.
|
|
38
38
|
*
|
|
39
39
|
* A genuine coupling to the memory *feature* that violates the one-way
|
|
40
|
-
* memory → persistence direction
|
|
41
|
-
* NEW back-import is introduced.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `countBufferLines`). Inverting it means memory deciding what to enqueue on
|
|
46
|
-
* each worker tick — that is roadmap bullet #4 (memory-jobs → schedule-jobs),
|
|
47
|
-
* deferred until the Schedules worker + `/workspace/schedules` API land.
|
|
48
|
-
* Decouple it then; do not add to this list without a decoupling plan.
|
|
40
|
+
* memory → persistence direction would be pinned here so the guard fails the
|
|
41
|
+
* moment a NEW back-import is introduced. It is currently EMPTY: persistence
|
|
42
|
+
* reaches the memory feature only through the `memory-lifecycle-hooks` seam
|
|
43
|
+
* (persistence calls registered handlers; memory never gets imported), never by
|
|
44
|
+
* importing memory internals. Do not add an entry without a decoupling plan.
|
|
49
45
|
*
|
|
50
46
|
* Keyed by the importing persistence file (relative to repo root); the value
|
|
51
47
|
* is the set of allowed `memory/<specifier>` module paths it may import.
|
|
52
48
|
*/
|
|
53
|
-
const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {
|
|
54
|
-
"assistant/src/persistence/jobs-worker.ts": new Set(["v2/consolidation-job"]),
|
|
55
|
-
};
|
|
49
|
+
const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {};
|
|
56
50
|
|
|
57
51
|
/**
|
|
58
52
|
* PERMANENT exception — the migration registry, NOT tech debt.
|
|
@@ -261,6 +261,7 @@ describe("per-surface disabled-state filtering", () => {
|
|
|
261
261
|
|
|
262
262
|
test("persistence hooks no-op while the memory plugin is disabled", async () => {
|
|
263
263
|
let calls = 0;
|
|
264
|
+
const bufferLines = 25;
|
|
264
265
|
const guarded = guardPersistenceHooksByDisabledState("default-memory", {
|
|
265
266
|
onMessagePersisted() {
|
|
266
267
|
calls++;
|
|
@@ -270,6 +271,9 @@ describe("per-surface disabled-state filtering", () => {
|
|
|
270
271
|
return 0;
|
|
271
272
|
},
|
|
272
273
|
onWorkerStartup() {},
|
|
274
|
+
countMemoryBufferLines() {
|
|
275
|
+
return bufferLines;
|
|
276
|
+
},
|
|
273
277
|
});
|
|
274
278
|
const event: MessagePersistedEvent = {
|
|
275
279
|
messageId: "msg-1",
|
|
@@ -282,16 +286,21 @@ describe("per-surface disabled-state filtering", () => {
|
|
|
282
286
|
// Enabled: the wrapped handler runs.
|
|
283
287
|
await guarded.onMessagePersisted(event);
|
|
284
288
|
expect(calls).toBe(1);
|
|
289
|
+
// The gated query returns the real buffer count while enabled.
|
|
290
|
+
expect(guarded.countMemoryBufferLines()).toBe(25);
|
|
285
291
|
|
|
286
292
|
// Disable via sentinel — checked at call time, no restart needed.
|
|
287
293
|
await createSentinel("default-memory");
|
|
288
294
|
await guarded.onMessagePersisted(event);
|
|
289
295
|
expect(calls).toBe(1);
|
|
296
|
+
// Gated: reports an empty buffer while disabled, so consolidation stays inert.
|
|
297
|
+
expect(guarded.countMemoryBufferLines()).toBe(0);
|
|
290
298
|
|
|
291
299
|
// Re-enable.
|
|
292
300
|
await removeSentinel("default-memory");
|
|
293
301
|
await guarded.onMessagePersisted(event);
|
|
294
302
|
expect(calls).toBe(2);
|
|
303
|
+
expect(guarded.countMemoryBufferLines()).toBe(25);
|
|
295
304
|
});
|
|
296
305
|
|
|
297
306
|
test("cleanup persistence hooks run even while the memory plugin is disabled", async () => {
|
|
@@ -307,6 +316,9 @@ describe("per-surface disabled-state filtering", () => {
|
|
|
307
316
|
onWorkerStartup() {
|
|
308
317
|
swept++;
|
|
309
318
|
},
|
|
319
|
+
countMemoryBufferLines() {
|
|
320
|
+
return 0;
|
|
321
|
+
},
|
|
310
322
|
});
|
|
311
323
|
|
|
312
324
|
await createSentinel("default-memory");
|
|
@@ -334,13 +334,65 @@ describe("createSkillTool — required/type/enum validation", () => {
|
|
|
334
334
|
BUNDLED,
|
|
335
335
|
);
|
|
336
336
|
|
|
337
|
-
const result = await tool.execute({ query:
|
|
337
|
+
const result = await tool.execute({ query: { nested: 1 } }, makeContext());
|
|
338
338
|
|
|
339
339
|
expect(result.isError).toBe(true);
|
|
340
340
|
expect(result.content).toContain('Invalid input for tool "test_tool"');
|
|
341
341
|
expect(result.content).toContain("query must be a string");
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
test("coerces finite numbers to strings before validation and passes the coerced value to the executor", async () => {
|
|
345
|
+
const hash = computeSkillVersionHash(tempDir);
|
|
346
|
+
const tool = createSkillTool(
|
|
347
|
+
makeEntry({
|
|
348
|
+
executor: "echo.ts",
|
|
349
|
+
input_schema: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: { phone_number: { type: "string" } },
|
|
352
|
+
required: ["phone_number"],
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
tempDir,
|
|
356
|
+
hash,
|
|
357
|
+
BUNDLED,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const result = await tool.execute(
|
|
361
|
+
{ phone_number: 15550100 },
|
|
362
|
+
makeContext(),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(result.isError).toBe(false);
|
|
366
|
+
const parsed = JSON.parse(result.content);
|
|
367
|
+
expect(parsed.input).toEqual({ phone_number: "15550100" });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("rejects integers outside the safe range instead of coercing a rounded value", async () => {
|
|
371
|
+
const hash = computeSkillVersionHash(tempDir);
|
|
372
|
+
const tool = createSkillTool(
|
|
373
|
+
makeEntry({
|
|
374
|
+
executor: "echo.ts",
|
|
375
|
+
input_schema: {
|
|
376
|
+
type: "object",
|
|
377
|
+
properties: { account_id: { type: "string" } },
|
|
378
|
+
required: ["account_id"],
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
tempDir,
|
|
382
|
+
hash,
|
|
383
|
+
BUNDLED,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const result = await tool.execute(
|
|
387
|
+
{ account_id: 12345678901234567890 },
|
|
388
|
+
makeContext(),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
expect(result.isError).toBe(true);
|
|
392
|
+
expect(result.content).toContain('Invalid input for tool "test_tool"');
|
|
393
|
+
expect(result.content).toContain("account_id must be a string");
|
|
394
|
+
});
|
|
395
|
+
|
|
344
396
|
test("rejects enum violation with `must be one of` message", async () => {
|
|
345
397
|
const hash = computeSkillVersionHash(tempDir);
|
|
346
398
|
const tool = createSkillTool(
|
|
@@ -51,6 +51,12 @@ mock.module("../../../util/platform.js", () => ({
|
|
|
51
51
|
getWorkspaceDirDisplay: () => "~/.vellum/workspace",
|
|
52
52
|
}));
|
|
53
53
|
|
|
54
|
+
// Fixed installed CLI version so drift-vs-match is deterministic.
|
|
55
|
+
const INSTALLED_CLI_VERSION = "9.9.9";
|
|
56
|
+
mock.module("../../../version.js", () => ({
|
|
57
|
+
APP_VERSION: INSTALLED_CLI_VERSION,
|
|
58
|
+
}));
|
|
59
|
+
|
|
54
60
|
mock.module("../../../util/logger.js", () => ({
|
|
55
61
|
getLogger: () => ({
|
|
56
62
|
info: () => {},
|
|
@@ -182,13 +188,13 @@ describe("status command — daemon unreachable (ENOENT/ECONNREFUSED)", () => {
|
|
|
182
188
|
expect(stdout).toContain("Assistant: running");
|
|
183
189
|
});
|
|
184
190
|
|
|
185
|
-
test("
|
|
191
|
+
test("prints the installed CLI version but no runtime health when unreachable", async () => {
|
|
186
192
|
mockResponse = { ok: false, error: DAEMON_UNREACHABLE_ERROR };
|
|
187
193
|
socketExists = false;
|
|
188
194
|
|
|
189
195
|
const { stdout, stderr } = await runStatusCommand();
|
|
190
196
|
|
|
191
|
-
expect(stdout
|
|
197
|
+
expect(stdout).toContain(`CLI Version: ${INSTALLED_CLI_VERSION}`);
|
|
192
198
|
expect(stdout + stderr).not.toContain("Memory");
|
|
193
199
|
});
|
|
194
200
|
});
|
|
@@ -242,8 +248,42 @@ describe("status command — daemon up", () => {
|
|
|
242
248
|
const { stdout, stderr } = await runStatusCommand();
|
|
243
249
|
const combined = stdout + stderr;
|
|
244
250
|
|
|
251
|
+
expect(combined).toContain("Assistant Version");
|
|
245
252
|
expect(combined).toContain("1.2.3");
|
|
246
253
|
expect(combined).toContain("100");
|
|
247
254
|
expect(combined).toContain("500");
|
|
248
255
|
});
|
|
256
|
+
|
|
257
|
+
test("flags the runtime as stale when it differs from the installed CLI", async () => {
|
|
258
|
+
mockResponse = {
|
|
259
|
+
ok: true,
|
|
260
|
+
result: {
|
|
261
|
+
version: "1.2.3",
|
|
262
|
+
memory: { currentMb: 100, maxMb: 500 },
|
|
263
|
+
disk: null,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const { stdout } = await runStatusCommand();
|
|
268
|
+
|
|
269
|
+
expect(stdout).toContain(
|
|
270
|
+
`1.2.3 (stale — restart to run ${INSTALLED_CLI_VERSION})`,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("shows a bare version with no stale note when versions match", async () => {
|
|
275
|
+
mockResponse = {
|
|
276
|
+
ok: true,
|
|
277
|
+
result: {
|
|
278
|
+
version: INSTALLED_CLI_VERSION,
|
|
279
|
+
memory: { currentMb: 100, maxMb: 500 },
|
|
280
|
+
disk: null,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const { stdout } = await runStatusCommand();
|
|
285
|
+
|
|
286
|
+
expect(stdout).toContain(`Assistant Version ${INSTALLED_CLI_VERSION}`);
|
|
287
|
+
expect(stdout).not.toContain("stale");
|
|
288
|
+
});
|
|
249
289
|
});
|
|
@@ -5,6 +5,7 @@ import type { Command } from "commander";
|
|
|
5
5
|
import { cliIpcCall } from "../../ipc/cli-client.js";
|
|
6
6
|
import { getAssistantSocketPath } from "../../ipc/socket-path.js";
|
|
7
7
|
import { getWorkspaceDirDisplay } from "../../util/platform.js";
|
|
8
|
+
import { APP_VERSION } from "../../version.js";
|
|
8
9
|
import { registerCommand } from "../lib/register-command.js";
|
|
9
10
|
|
|
10
11
|
interface HealthResponse {
|
|
@@ -34,6 +35,9 @@ export function registerStatusCommand(program: Command): void {
|
|
|
34
35
|
const socketPath = getAssistantSocketPath();
|
|
35
36
|
const socketExists = existsSync(socketPath);
|
|
36
37
|
const workspace = getWorkspaceDirDisplay();
|
|
38
|
+
// Daemon unreachable, so its runtime version is unknown; show the
|
|
39
|
+
// installed CLI version so there's always one reliable number.
|
|
40
|
+
process.stdout.write(`CLI Version: ${APP_VERSION}\n`);
|
|
37
41
|
process.stdout.write(
|
|
38
42
|
(socketExists ? "Assistant: running" : "Assistant: down") + "\n",
|
|
39
43
|
);
|
|
@@ -52,8 +56,15 @@ export function registerStatusCommand(program: Command): void {
|
|
|
52
56
|
const h = result.result;
|
|
53
57
|
const workspace = getWorkspaceDirDisplay();
|
|
54
58
|
|
|
59
|
+
// h.version is the running runtime; APP_VERSION is the installed CLI.
|
|
60
|
+
// They drift mid-upgrade (CLI bumped, daemon not yet restarted).
|
|
61
|
+
const runtimeVersion =
|
|
62
|
+
h.version === APP_VERSION
|
|
63
|
+
? h.version
|
|
64
|
+
: `${h.version} (stale — restart to run ${APP_VERSION})`;
|
|
65
|
+
|
|
55
66
|
const rows: [string, string][] = [
|
|
56
|
-
["Version",
|
|
67
|
+
["Assistant Version", runtimeVersion],
|
|
57
68
|
["Workspace", workspace],
|
|
58
69
|
["", ""],
|
|
59
70
|
["Memory", `${fmtMb(h.memory.currentMb)} / ${fmtMb(h.memory.maxMb)}`],
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
4
|
+
const stub: Record<string, unknown> = {};
|
|
5
|
+
for (const m of [
|
|
6
|
+
"info",
|
|
7
|
+
"warn",
|
|
8
|
+
"error",
|
|
9
|
+
"debug",
|
|
10
|
+
"trace",
|
|
11
|
+
"fatal",
|
|
12
|
+
"silent",
|
|
13
|
+
"child",
|
|
14
|
+
]) {
|
|
15
|
+
stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
|
|
16
|
+
}
|
|
17
|
+
return stub;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mock.module("../../util/logger.js", () => ({
|
|
21
|
+
getLogger: () => makeLoggerStub(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
mock.restore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
|
|
29
|
+
import { getMessagesSearchBackend } from "../assistant-feature-flags.js";
|
|
30
|
+
import type { AssistantConfig } from "../schema.js";
|
|
31
|
+
|
|
32
|
+
// The resolver reads the flag from the override cache + registry; it does not
|
|
33
|
+
// consult the passed config, so a minimal stub is sufficient.
|
|
34
|
+
const CONFIG = {} as AssistantConfig;
|
|
35
|
+
|
|
36
|
+
describe("getMessagesSearchBackend", () => {
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
setOverridesForTesting({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Each case seeds the override cache so the value flows through the real
|
|
42
|
+
// `getAssistantFeatureFlagValue` plumbing. The flag is boolean, so enabled
|
|
43
|
+
// (`true`) selects qdrant and disabled/unset falls back to fts5. The string
|
|
44
|
+
// cases are defensive: the override plumbing is typed `boolean | string`, so
|
|
45
|
+
// a stray string could still arrive — only the exact `"qdrant"` selects
|
|
46
|
+
// qdrant; every other value falls back to the safe fts5 default.
|
|
47
|
+
|
|
48
|
+
test("defaults to fts5 when the flag is unset", () => {
|
|
49
|
+
setOverridesForTesting({});
|
|
50
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns qdrant when the flag is enabled (boolean true)", () => {
|
|
54
|
+
setOverridesForTesting({ "messages-search-backend": true });
|
|
55
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("qdrant");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns fts5 when the flag is disabled (boolean false)", () => {
|
|
59
|
+
setOverridesForTesting({ "messages-search-backend": false });
|
|
60
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('defensively selects qdrant for a stray string "qdrant" override', () => {
|
|
64
|
+
setOverridesForTesting({ "messages-search-backend": "qdrant" });
|
|
65
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("qdrant");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('defensively falls back to fts5 for a stray string "fts5" override', () => {
|
|
69
|
+
setOverridesForTesting({ "messages-search-backend": "fts5" });
|
|
70
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("defensively falls back to fts5 for an unexpected non-empty string", () => {
|
|
74
|
+
setOverridesForTesting({ "messages-search-backend": "bogus" });
|
|
75
|
+
expect(getMessagesSearchBackend(CONFIG)).toBe("fts5");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -326,3 +326,25 @@ export function isAssistantFeatureFlagEnabled(
|
|
|
326
326
|
): boolean {
|
|
327
327
|
return !!getAssistantFeatureFlagValue(key, config);
|
|
328
328
|
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Backends that resolve lexical message-content search.
|
|
332
|
+
* fts5 — SQLite FTS5 full-text index (default)
|
|
333
|
+
* qdrant — BM25-style sparse Qdrant lexical index (messages_lexical)
|
|
334
|
+
*/
|
|
335
|
+
export type MessagesSearchBackend = "fts5" | "qdrant";
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Resolve the active messages search backend from the boolean
|
|
339
|
+
* `messages-search-backend` flag. Disabled (default) ⇒ `fts5`; enabled
|
|
340
|
+
* (boolean `true`) ⇒ `qdrant`. The raw comparison also treats a stray
|
|
341
|
+
* string override of `"qdrant"` as enabled; every other value (`false`,
|
|
342
|
+
* `"fts5"`, any other string, undefined) falls back to `fts5` so the safe
|
|
343
|
+
* default always wins for an unexpected override.
|
|
344
|
+
*/
|
|
345
|
+
export function getMessagesSearchBackend(
|
|
346
|
+
config: AssistantConfig,
|
|
347
|
+
): MessagesSearchBackend {
|
|
348
|
+
const raw = getAssistantFeatureFlagValue("messages-search-backend", config);
|
|
349
|
+
return raw === "qdrant" || raw === true ? "qdrant" : "fts5";
|
|
350
|
+
}
|
|
@@ -409,6 +409,14 @@
|
|
|
409
409
|
"label": "New-thread suggestions library",
|
|
410
410
|
"description": "Replace the empty-state conversation-starter chips with the scrollable, categorized suggestions library and detail drawer (currently mocked data).",
|
|
411
411
|
"defaultEnabled": false
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"id": "messages-search-backend",
|
|
415
|
+
"scope": "assistant",
|
|
416
|
+
"key": "messages-search-backend",
|
|
417
|
+
"label": "Messages Search Backend",
|
|
418
|
+
"description": "When disabled (default), message content search uses SQLite FTS5. When enabled, it uses the sparse Qdrant lexical index.",
|
|
419
|
+
"defaultEnabled": false
|
|
412
420
|
}
|
|
413
421
|
]
|
|
414
422
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { SparseEmbedding } from "../embeddings/embedding-types.js";
|
|
4
|
+
import type { MessageLexicalSearchResult } from "../embeddings/messages-lexical-index.js";
|
|
5
|
+
|
|
6
|
+
const SPARSE: SparseEmbedding = { indices: [1, 2, 3], values: [0.5, 0.5, 0.5] };
|
|
7
|
+
const EMPTY_SPARSE: SparseEmbedding = { indices: [], values: [] };
|
|
8
|
+
|
|
9
|
+
// Mirror the real encoder: input that tokenizes to nothing (only whitespace
|
|
10
|
+
// or punctuation) yields an empty sparse vector.
|
|
11
|
+
const generateSparseEmbeddingMock = mock(
|
|
12
|
+
(text: string): SparseEmbedding =>
|
|
13
|
+
/[\p{L}\p{N}]/u.test(text) ? SPARSE : EMPTY_SPARSE,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const searchLexicalMock = mock(
|
|
17
|
+
async (
|
|
18
|
+
_sparse: SparseEmbedding,
|
|
19
|
+
_limit: number,
|
|
20
|
+
_opts?: { conversationId?: string },
|
|
21
|
+
): Promise<MessageLexicalSearchResult[]> => [
|
|
22
|
+
{ messageId: "msg-1", score: 0.9 },
|
|
23
|
+
{ messageId: "msg-2", score: 0.4 },
|
|
24
|
+
],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const getMessagesLexicalIndexMock = mock(() => ({
|
|
28
|
+
searchLexical: searchLexicalMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// `embedding-backend` is mocked WITHOUT spreading the real module: importing
|
|
32
|
+
// it transitively pulls provider/security modules (and `packages/service-
|
|
33
|
+
// contracts` → `zod`) into the test for no benefit. The helper only imports
|
|
34
|
+
// `generateSparseEmbedding`, so a targeted replacement is complete for this
|
|
35
|
+
// file's needs.
|
|
36
|
+
mock.module("../embeddings/embedding-backend.js", () => ({
|
|
37
|
+
generateSparseEmbedding: generateSparseEmbeddingMock,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// `messages-lexical-index` IS spread from the real module. Bun's
|
|
41
|
+
// `mock.module` is process-global and is not undone by `mock.restore()`, so a
|
|
42
|
+
// partial replacement would drop the module's other real exports
|
|
43
|
+
// (`messagePointId`, `initMessagesLexicalIndex`, `MessagesLexicalIndex`, …)
|
|
44
|
+
// for any test that runs later in the same process (e.g.
|
|
45
|
+
// `embeddings/__tests__/messages-lexical-index.test.ts`, which imports
|
|
46
|
+
// `messagePointId`). Spreading keeps those intact; its deps
|
|
47
|
+
// (`@qdrant/js-client-rest`, `uuid`) are installed, so importing it is safe.
|
|
48
|
+
const actualLexicalIndex =
|
|
49
|
+
await import("../embeddings/messages-lexical-index.js");
|
|
50
|
+
mock.module("../embeddings/messages-lexical-index.js", () => ({
|
|
51
|
+
...actualLexicalIndex,
|
|
52
|
+
getMessagesLexicalIndex: getMessagesLexicalIndexMock,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const { searchMessageIdsLexical } =
|
|
56
|
+
await import("../conversation-search-lexical.js");
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
mock.restore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("searchMessageIdsLexical", () => {
|
|
63
|
+
test("encodes the query and returns the index results", async () => {
|
|
64
|
+
generateSparseEmbeddingMock.mockClear();
|
|
65
|
+
searchLexicalMock.mockClear();
|
|
66
|
+
|
|
67
|
+
const results = await searchMessageIdsLexical("hello world", 5);
|
|
68
|
+
|
|
69
|
+
expect(generateSparseEmbeddingMock).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(generateSparseEmbeddingMock.mock.calls[0]).toEqual(["hello world"]);
|
|
71
|
+
|
|
72
|
+
// The encoded sparse vector and limit are passed straight through.
|
|
73
|
+
expect(searchLexicalMock).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(searchLexicalMock.mock.calls[0]![0]).toBe(SPARSE);
|
|
75
|
+
expect(searchLexicalMock.mock.calls[0]![1]).toBe(5);
|
|
76
|
+
|
|
77
|
+
expect(results).toEqual([
|
|
78
|
+
{ messageId: "msg-1", score: 0.9 },
|
|
79
|
+
{ messageId: "msg-2", score: 0.4 },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("passes conversationId through to the index", async () => {
|
|
84
|
+
searchLexicalMock.mockClear();
|
|
85
|
+
|
|
86
|
+
await searchMessageIdsLexical("scoped", 3, { conversationId: "conv-xyz" });
|
|
87
|
+
|
|
88
|
+
expect(searchLexicalMock).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(searchLexicalMock.mock.calls[0]![2]).toEqual({
|
|
90
|
+
conversationId: "conv-xyz",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns [] without querying the index for an empty-after-tokenize query", async () => {
|
|
95
|
+
searchLexicalMock.mockClear();
|
|
96
|
+
|
|
97
|
+
const results = await searchMessageIdsLexical(" ... !!! ", 5);
|
|
98
|
+
|
|
99
|
+
expect(results).toEqual([]);
|
|
100
|
+
// The empty sparse vector must never reach Qdrant.
|
|
101
|
+
expect(searchLexicalMock).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared lexical candidate helper for message-content search.
|
|
3
|
+
*
|
|
4
|
+
* Encodes a free-text query with the local TF-IDF sparse encoder and queries
|
|
5
|
+
* the BM25-style sparse Qdrant lexical index (`messages_lexical`) for matching
|
|
6
|
+
* message ids. This is the single entry point the message-search read sites use
|
|
7
|
+
* when the `messages-search-backend` flag selects `qdrant`, mirroring the
|
|
8
|
+
* candidate set SQLite FTS5 would return.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { generateSparseEmbedding } from "./embeddings/embedding-backend.js";
|
|
12
|
+
import {
|
|
13
|
+
getMessagesLexicalIndex,
|
|
14
|
+
type MessageLexicalSearchResult,
|
|
15
|
+
} from "./embeddings/messages-lexical-index.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve message-id candidates for `query` from the Qdrant lexical index,
|
|
19
|
+
* ranked by sparse similarity score (highest first).
|
|
20
|
+
*
|
|
21
|
+
* This is a **pure lexical candidate generator**: it returns the top-`limit`
|
|
22
|
+
* message ids by sparse score with NO visibility, source, or
|
|
23
|
+
* active-conversation filtering (the only scoping it applies is the optional
|
|
24
|
+
* `conversationId` restriction). Those exclusions (active conversation,
|
|
25
|
+
* private conversations, non-message sources) live in SQL at the read sites.
|
|
26
|
+
*
|
|
27
|
+
* Callers that apply such post-retrieval SQL filters MUST over-fetch: pass an
|
|
28
|
+
* inflated `limit` (the `CONVERSATION_SEARCH_PREFETCH_MULTIPLIER` prefetch
|
|
29
|
+
* pattern the recall source uses) and re-limit after filtering in SQL.
|
|
30
|
+
* Applying the caller's real limit here first would let excluded rows consume
|
|
31
|
+
* the candidate slots and drop valid visible matches below the fold.
|
|
32
|
+
*
|
|
33
|
+
* @param query free-text search query
|
|
34
|
+
* @param limit maximum number of candidates to return
|
|
35
|
+
* @param opts.conversationId restrict results to a single conversation
|
|
36
|
+
*/
|
|
37
|
+
export async function searchMessageIdsLexical(
|
|
38
|
+
query: string,
|
|
39
|
+
limit: number,
|
|
40
|
+
opts?: { conversationId?: string },
|
|
41
|
+
): Promise<MessageLexicalSearchResult[]> {
|
|
42
|
+
const sparse = generateSparseEmbedding(query);
|
|
43
|
+
// A query that tokenizes to nothing (whitespace/punctuation-only) yields an
|
|
44
|
+
// empty sparse vector; matching the FTS paths and other sparse callers,
|
|
45
|
+
// return no candidates without querying Qdrant on an empty vector.
|
|
46
|
+
if (sparse.indices.length === 0) return [];
|
|
47
|
+
return getMessagesLexicalIndex().searchLexical(sparse, limit, opts);
|
|
48
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
|
|
3
1
|
import { getConfig } from "../config/loader.js";
|
|
4
2
|
import { isMemoryV3Live } from "../config/memory-v3-gate.js";
|
|
5
3
|
import type { AssistantConfig } from "../config/types.js";
|
|
@@ -8,9 +6,7 @@ import {
|
|
|
8
6
|
diskPressureBackgroundSkipLogFields,
|
|
9
7
|
shouldLogDiskPressureBackgroundSkip,
|
|
10
8
|
} from "../daemon/disk-pressure-background-gate.js";
|
|
11
|
-
import { countBufferLines } from "../plugins/defaults/memory/v2/consolidation-job.js";
|
|
12
9
|
import { getLogger } from "../util/logger.js";
|
|
13
|
-
import { getWorkspaceDir } from "../util/platform.js";
|
|
14
10
|
import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
|
|
15
11
|
import {
|
|
16
12
|
getLastScheduledCleanupEnqueueMs,
|
|
@@ -796,8 +792,10 @@ export function maybeEnqueueGraphMaintenanceJobs(
|
|
|
796
792
|
// The checkpoint advances so the next check fires after the regular
|
|
797
793
|
// interval. Manual "Run now" is unaffected (routes layer, not schedule).
|
|
798
794
|
if (jobType === consolidateEntry.jobType) {
|
|
799
|
-
|
|
800
|
-
|
|
795
|
+
if (
|
|
796
|
+
getMemoryPersistenceHooks().countMemoryBufferLines() <
|
|
797
|
+
MIN_BUFFER_LINES_FOR_CONSOLIDATION
|
|
798
|
+
) {
|
|
801
799
|
log.debug(
|
|
802
800
|
"Scheduled consolidation skipped: buffer under minimum line threshold",
|
|
803
801
|
);
|
|
@@ -830,8 +828,7 @@ export function maybeEnqueueGraphMaintenanceJobs(
|
|
|
830
828
|
maxLines !== null &&
|
|
831
829
|
!hasActiveJobOfType(consolidateEntry.jobType)
|
|
832
830
|
) {
|
|
833
|
-
|
|
834
|
-
if (countBufferLines(bufferPath) >= maxLines) {
|
|
831
|
+
if (getMemoryPersistenceHooks().countMemoryBufferLines() >= maxLines) {
|
|
835
832
|
enqueueMemoryJob(
|
|
836
833
|
consolidateEntry.jobType,
|
|
837
834
|
AUTOMATIC_CONSOLIDATION_JOB_PAYLOAD,
|
|
@@ -24,6 +24,9 @@ const baseHooks: MemoryPersistenceHooks = {
|
|
|
24
24
|
return 0;
|
|
25
25
|
},
|
|
26
26
|
onWorkerStartup() {},
|
|
27
|
+
countMemoryBufferLines() {
|
|
28
|
+
return 0;
|
|
29
|
+
},
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
describe("memory persistence-lifecycle seam", () => {
|
|
@@ -34,6 +37,18 @@ describe("memory persistence-lifecycle seam", () => {
|
|
|
34
37
|
await getMemoryPersistenceHooks().onMessagePersisted(event);
|
|
35
38
|
});
|
|
36
39
|
|
|
40
|
+
test("countMemoryBufferLines defaults to 0 when memory is not present", () => {
|
|
41
|
+
expect(getMemoryPersistenceHooks().countMemoryBufferLines()).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("countMemoryBufferLines returns the registered implementation's count", () => {
|
|
45
|
+
registerMemoryPersistenceHooks({
|
|
46
|
+
...baseHooks,
|
|
47
|
+
countMemoryBufferLines: () => 42,
|
|
48
|
+
});
|
|
49
|
+
expect(getMemoryPersistenceHooks().countMemoryBufferLines()).toBe(42);
|
|
50
|
+
});
|
|
51
|
+
|
|
37
52
|
test("getMemoryPersistenceHooks returns the registered implementation", async () => {
|
|
38
53
|
const seen: MessagePersistedEvent[] = [];
|
|
39
54
|
registerMemoryPersistenceHooks({
|
|
@@ -2,7 +2,8 @@ import type { TrustClass } from "../runtime/actor-trust-resolver.js";
|
|
|
2
2
|
import type { DrizzleDb } from "./db-connection.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Seam for the memory feature to react to persistence-layer lifecycle events
|
|
5
|
+
* Seam for the memory feature to react to persistence-layer lifecycle events
|
|
6
|
+
* and to answer memory-domain queries the persistence layer needs.
|
|
6
7
|
*
|
|
7
8
|
* Persistence is a layer below the memory plugin, so it cannot import memory
|
|
8
9
|
* internals directly. Instead it calls into this registered-handler seam: the
|
|
@@ -89,6 +90,15 @@ export interface MemoryPersistenceHooks {
|
|
|
89
90
|
* wraps it in try/catch. Cleanup — runs even while the plugin is disabled.
|
|
90
91
|
*/
|
|
91
92
|
onWorkerStartup(): void;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Current line count of the memory buffer (`memory/buffer.md`). The
|
|
96
|
+
* maintenance scheduler reads it to gate scheduled consolidation — skip a
|
|
97
|
+
* scheduled run below a floor, force one above a ceiling. Returns 0 when
|
|
98
|
+
* memory is not present, which the scheduler reads as "no buffered work" and
|
|
99
|
+
* therefore no consolidation.
|
|
100
|
+
*/
|
|
101
|
+
countMemoryBufferLines(): number;
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
const NOOP: MemoryPersistenceHooks = {
|
|
@@ -98,6 +108,9 @@ const NOOP: MemoryPersistenceHooks = {
|
|
|
98
108
|
return 0;
|
|
99
109
|
},
|
|
100
110
|
onWorkerStartup() {},
|
|
111
|
+
countMemoryBufferLines() {
|
|
112
|
+
return 0;
|
|
113
|
+
},
|
|
101
114
|
};
|
|
102
115
|
|
|
103
116
|
let current: MemoryPersistenceHooks = NOOP;
|
|
@@ -527,6 +527,14 @@ export function guardPersistenceHooksByDisabledState(
|
|
|
527
527
|
if (isPluginDisabled(pluginName)) return;
|
|
528
528
|
return hooks.onConversationForked(event);
|
|
529
529
|
},
|
|
530
|
+
// Gated like the active side effects above: a disabled plugin reports an
|
|
531
|
+
// empty buffer, so the maintenance scheduler treats it as "no buffered
|
|
532
|
+
// work" and skips consolidation — matching how disabled injectors/hooks go
|
|
533
|
+
// inert.
|
|
534
|
+
countMemoryBufferLines() {
|
|
535
|
+
if (isPluginDisabled(pluginName)) return 0;
|
|
536
|
+
return hooks.countMemoryBufferLines();
|
|
537
|
+
},
|
|
530
538
|
// Cleanup hooks are NOT gated on disabled-state: they must run even while
|
|
531
539
|
// the plugin is disabled, or jobs/conversations created while it was
|
|
532
540
|
// enabled would be orphaned.
|
|
@@ -57,6 +57,7 @@ afterAll(() => {
|
|
|
57
57
|
process.env.VELLUM_WORKSPACE_DIR = previousWorkspaceEnv;
|
|
58
58
|
}
|
|
59
59
|
rmSync(tmpWorkspace, { recursive: true, force: true });
|
|
60
|
+
resetMemoryPersistenceHooksForTests();
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
const { getMemoryDb } =
|
|
@@ -70,6 +71,14 @@ const { getMemoryCheckpoint, setMemoryCheckpoint, deleteMemoryCheckpoint } =
|
|
|
70
71
|
await import("../../../../persistence/checkpoints.js");
|
|
71
72
|
const { maybeEnqueueGraphMaintenanceJobs } =
|
|
72
73
|
await import("../../../../persistence/jobs-worker.js");
|
|
74
|
+
const { registerMemoryPersistenceHooks, resetMemoryPersistenceHooksForTests } =
|
|
75
|
+
await import("../../../../persistence/memory-lifecycle-hooks.js");
|
|
76
|
+
const { memoryPersistenceHooks } = await import("../persistence-hooks.js");
|
|
77
|
+
|
|
78
|
+
// The scheduler reads the memory buffer's line count through the persistence
|
|
79
|
+
// seam; register the real memory implementation so `countMemoryBufferLines`
|
|
80
|
+
// reflects the buffer files these tests write.
|
|
81
|
+
registerMemoryPersistenceHooks(memoryPersistenceHooks);
|
|
73
82
|
|
|
74
83
|
const CONSOLIDATE_CHECKPOINT_KEY = "memory_v2_consolidate_last_run";
|
|
75
84
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { getConfig } from "../../../config/loader.js";
|
|
2
4
|
import type {
|
|
3
5
|
ConversationForkedEvent,
|
|
4
6
|
MemoryPersistenceHooks,
|
|
5
7
|
MessagePersistedEvent,
|
|
6
8
|
} from "../../../persistence/memory-lifecycle-hooks.js";
|
|
9
|
+
import { getWorkspaceDir } from "../../../util/platform.js";
|
|
7
10
|
import { forkGraphMemoryState } from "./graph/graph-memory-state-store.js";
|
|
8
11
|
import { indexMessageNow } from "./indexer.js";
|
|
9
12
|
import { sweepOrphanMemoryRetrospectiveConversations } from "./memory-retrospective-startup-cleanup.js";
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
forkActivationState,
|
|
14
17
|
seedForkActivationState,
|
|
15
18
|
} from "./v2/activation-store.js";
|
|
19
|
+
import { countBufferLines } from "./v2/consolidation-job.js";
|
|
16
20
|
import {
|
|
17
21
|
extractInjectedConceptSlugs,
|
|
18
22
|
readInjectedBlock,
|
|
@@ -116,4 +120,8 @@ export const memoryPersistenceHooks: MemoryPersistenceHooks = {
|
|
|
116
120
|
onWorkerStartup(): void {
|
|
117
121
|
sweepOrphanMemoryRetrospectiveConversations();
|
|
118
122
|
},
|
|
123
|
+
|
|
124
|
+
countMemoryBufferLines(): number {
|
|
125
|
+
return countBufferLines(join(getWorkspaceDir(), "memory", "buffer.md"));
|
|
126
|
+
},
|
|
119
127
|
};
|
|
@@ -102,6 +102,53 @@ export function coerceStringBooleans(
|
|
|
102
102
|
return coerced ?? input;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Coerce finite JSON numbers to strings for properties the schema declares as
|
|
107
|
+
* `type: "string"`.
|
|
108
|
+
*
|
|
109
|
+
* Some providers' models emit numeric-looking string values as unquoted JSON
|
|
110
|
+
* numbers (e.g. a phone number `15550100` instead of `"+15550100"`).
|
|
111
|
+
* Plain `JSON.parse` yields a JS `Number`, which then fails the validator's
|
|
112
|
+
* `typeof === "string"` check with a confusing "must be a string" error that
|
|
113
|
+
* sends the model down a wrong retry path. The value's intent is unambiguous —
|
|
114
|
+
* a string field received a number — so coerce it the same way we coerce
|
|
115
|
+
* string-encoded booleans. The E.164 / format / enum checks downstream still
|
|
116
|
+
* run on the coerced string, so a number missing a `+` prefix still gets a
|
|
117
|
+
* self-correcting format error rather than a type error.
|
|
118
|
+
*
|
|
119
|
+
* Only finite numbers are coerced; `NaN`/`Infinity` (which can't come from
|
|
120
|
+
* JSON.parse anyway) and non-number types are left untouched. Integers outside
|
|
121
|
+
* the safe range are also left untouched: `JSON.parse` has already rounded them
|
|
122
|
+
* (e.g. `12345678901234567890` → `12345678901234567000`), so `String()` would
|
|
123
|
+
* emit a corrupted identifier. Leaving them un-coerced makes the validator
|
|
124
|
+
* return a type error, prompting the model to retry with a quoted string that
|
|
125
|
+
* preserves every digit. Pure: returns a new object when a coercion applies,
|
|
126
|
+
* otherwise returns `input` unchanged. Never mutates `input` or `schema`.
|
|
127
|
+
*/
|
|
128
|
+
export function coerceStringNumbers(
|
|
129
|
+
input: Record<string, unknown>,
|
|
130
|
+
schema: Record<string, unknown> | undefined,
|
|
131
|
+
): Record<string, unknown> {
|
|
132
|
+
if (!schema) return input;
|
|
133
|
+
const properties = schema.properties;
|
|
134
|
+
if (!isPlainObject(properties)) return input;
|
|
135
|
+
|
|
136
|
+
let coerced: Record<string, unknown> | undefined;
|
|
137
|
+
for (const [key, rawSubSchema] of Object.entries(properties)) {
|
|
138
|
+
if (!isPlainObject(rawSubSchema)) continue;
|
|
139
|
+
if (rawSubSchema.type !== "string") continue;
|
|
140
|
+
const value = input[key];
|
|
141
|
+
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
|
142
|
+
// An integer beyond the safe range was already rounded by JSON.parse;
|
|
143
|
+
// coercing it would lock in a corrupted identifier. Skip it so validation
|
|
144
|
+
// fails and the model retries with a lossless quoted string.
|
|
145
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) continue;
|
|
146
|
+
coerced ??= { ...input };
|
|
147
|
+
coerced[key] = String(value);
|
|
148
|
+
}
|
|
149
|
+
return coerced ?? input;
|
|
150
|
+
}
|
|
151
|
+
|
|
105
152
|
/**
|
|
106
153
|
* Validate a tool input object against the (optional) JSON-schema definition
|
|
107
154
|
* declared on the tool entry. Returns `{ ok: true }` if the input is valid (or
|
|
@@ -2,6 +2,7 @@ import type { SkillToolEntry } from "../../config/skills.js";
|
|
|
2
2
|
import { RiskLevel } from "../../permissions/types.js";
|
|
3
3
|
import {
|
|
4
4
|
coerceStringBooleans,
|
|
5
|
+
coerceStringNumbers,
|
|
5
6
|
validateInputAgainstSchema,
|
|
6
7
|
} from "../../skills/validate-input.js";
|
|
7
8
|
import type {
|
|
@@ -46,7 +47,10 @@ export function createSkillTool(
|
|
|
46
47
|
context: ToolContext,
|
|
47
48
|
): Promise<ToolExecutionResult> {
|
|
48
49
|
const schema = entry.input_schema as Record<string, unknown> | undefined;
|
|
49
|
-
const coercedInput =
|
|
50
|
+
const coercedInput = coerceStringNumbers(
|
|
51
|
+
coerceStringBooleans(input, schema),
|
|
52
|
+
schema,
|
|
53
|
+
);
|
|
50
54
|
const validation = validateInputAgainstSchema(
|
|
51
55
|
entry.name,
|
|
52
56
|
coercedInput,
|
|
@@ -39,8 +39,16 @@ describe("deriveName", () => {
|
|
|
39
39
|
expect(deriveName("bun run /worker.ts")).toBe("worker");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test("
|
|
43
|
-
expect(deriveName("bun repl")).toBe("bun");
|
|
42
|
+
test("surfaces the arguments when an interpreter runs no script file", () => {
|
|
43
|
+
expect(deriveName("bun repl")).toBe("bun repl");
|
|
44
|
+
expect(deriveName("bun run dev")).toBe("bun run dev");
|
|
45
|
+
expect(deriveName("bun x prettier --write .")).toBe("bun x prettier .");
|
|
46
|
+
expect(deriveName("node --inspect server")).toBe("node server");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("falls back to the bare interpreter when only flags follow", () => {
|
|
50
|
+
expect(deriveName("bun --version")).toBe("bun");
|
|
51
|
+
expect(deriveName("bun")).toBe("bun");
|
|
44
52
|
});
|
|
45
53
|
|
|
46
54
|
test("handles empty input", () => {
|
package/src/util/process-tree.ts
CHANGED
|
@@ -64,7 +64,10 @@ function scriptName(scriptPath: string): string {
|
|
|
64
64
|
* Derive a readable name from a command line. For interpreter invocations
|
|
65
65
|
* (`bun run /…/jobs/worker.ts`) the script path is far more useful than the
|
|
66
66
|
* interpreter name, so prefer the first script-looking argument and summarize it
|
|
67
|
-
* as `<parent-dir>-<filename>` (e.g. `jobs-worker`).
|
|
67
|
+
* as `<parent-dir>-<filename>` (e.g. `jobs-worker`). When an interpreter is run
|
|
68
|
+
* without a script file (`bun run dev`, `bun x prettier`, `bun repl`) the bare
|
|
69
|
+
* interpreter name says nothing about what is running, so surface the arguments
|
|
70
|
+
* — what was actually run — alongside it (e.g. `bun run dev`). Plain binaries
|
|
68
71
|
* (`/…/vellum-qdrant`) keep their bare executable name.
|
|
69
72
|
*/
|
|
70
73
|
export function deriveName(command: string): string {
|
|
@@ -73,8 +76,13 @@ export function deriveName(command: string): string {
|
|
|
73
76
|
|
|
74
77
|
const argv0 = basename(tokens[0]);
|
|
75
78
|
if (RUNTIMES.has(argv0)) {
|
|
76
|
-
const
|
|
79
|
+
const args = tokens.slice(1);
|
|
80
|
+
const script = args.find((t) => SCRIPT_EXT_RE.test(t));
|
|
77
81
|
if (script) return scriptName(script);
|
|
82
|
+
// No script to summarize: show the non-flag arguments so the entry reads as
|
|
83
|
+
// "what was run" rather than an opaque `bun`. Flags are dropped as noise.
|
|
84
|
+
const meaningful = args.filter((t) => !t.startsWith("-"));
|
|
85
|
+
if (meaningful.length > 0) return `${argv0} ${meaningful.join(" ")}`;
|
|
78
86
|
}
|
|
79
87
|
return argv0;
|
|
80
88
|
}
|