@vellumai/assistant 0.12.2-staging.5 → 0.12.2-staging.7
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 +7 -7
- package/docs/architecture/memory.md +11 -2
- package/docs/desktop-browser-cli.md +4 -2
- package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
- package/openapi.yaml +157 -55
- package/package.json +5 -4
- package/scripts/bundled-plugin-packages.ts +154 -0
- package/scripts/generate-bundled-plugin-packages.ts +29 -0
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/scripts/test.ts +15 -13
- package/src/__tests__/agent-loop.test.ts +124 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
- package/src/__tests__/channel-approval.test.ts +9 -14
- package/src/__tests__/conversation-agent-loop.test.ts +25 -0
- package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
- package/src/__tests__/managed-store.test.ts +121 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/unicode.test.ts +36 -0
- package/src/agent/loop.ts +19 -0
- package/src/api/events/desktop-activity-changed.ts +10 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/approval-primitive.ts +5 -2
- package/src/approvals/scoped-approval-grants.ts +6 -2
- package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
- package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
- package/src/calls/voice-control-protocol.ts +13 -4
- package/src/calls/voice-session-bridge.ts +27 -6
- package/src/cli/commands/__tests__/plugins.test.ts +66 -0
- package/src/cli/commands/plugins.ts +50 -18
- package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
- package/src/cli/lib/bundled-plugin-packages.json +4 -0
- package/src/cli/lib/bundled-plugin-packages.ts +87 -0
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +80 -12
- package/src/cli/lib/install-from-github.ts +133 -76
- package/src/cli/lib/plugin-catalog-cache.ts +22 -3
- package/src/cli/lib/plugin-catalog-local.ts +13 -3
- package/src/cli/lib/plugin-catalog-platform.ts +6 -1
- package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
- package/src/cli/lib/plugin-details.ts +12 -0
- package/src/cli/lib/plugin-marketplace.ts +121 -21
- package/src/cli/lib/plugin-pin-history.ts +5 -2
- package/src/cli/lib/search-plugins.ts +58 -16
- package/src/cli/lib/upgrade-plugin.ts +48 -3
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-tool-setup.ts +61 -23
- package/src/daemon/conversation.ts +17 -0
- package/src/daemon/daemon-control.ts +2 -6
- package/src/daemon/orphan-reaper.ts +4 -3
- package/src/daemon/tool-setup-types.ts +6 -0
- package/src/daemon/wake-conversation-ops.ts +38 -15
- package/src/desktop/desktop-automation-lease.test.ts +143 -0
- package/src/desktop/desktop-automation-lease.ts +39 -3
- package/src/live-voice/__tests__/live-voice-vad.test.ts +624 -3
- package/src/live-voice/__tests__/session-controls.test.ts +18 -0
- package/src/live-voice/live-voice-session.ts +575 -53
- package/src/live-voice/session-controls.ts +7 -3
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/monitoring/plugin-auto-update.ts +6 -0
- package/src/notifications/__tests__/copy-composer.test.ts +70 -0
- package/src/notifications/copy-composer.ts +11 -3
- package/src/persistence/conversation-plugin-facade.ts +13 -0
- package/src/persistence/conversation-tool-surface.ts +86 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugin-api/conversation-turn.ts +31 -7
- package/src/plugin-api/index.ts +9 -1
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
- package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
- package/src/plugins/defaults/memory/AGENTS.md +14 -2
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
- package/src/plugins/defaults/memory/buffer-file.ts +354 -0
- package/src/plugins/defaults/memory/buffer-format.ts +40 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
- package/src/plugins/defaults/memory/context-search/format.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
- package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
- package/src/plugins/defaults/memory/host-utils.ts +0 -10
- package/src/plugins/defaults/memory/injectors.ts +4 -3
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
- package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
- package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
- package/src/plugins/defaults/memory/v3/card.ts +2 -1
- package/src/plugins/defaults/memory/v3/injector.ts +212 -178
- package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
- package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
- package/src/plugins/defaults/memory/v3/sections.ts +2 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +86 -1
- package/src/runtime/agent-wake.ts +20 -4
- package/src/runtime/guardian-action-service.ts +2 -17
- package/src/runtime/guardian-reply-router.ts +1 -8
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
- package/src/runtime/routes/channel-route-shared.ts +1 -9
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +7 -3
- package/src/runtime/routes/guardian-approval-interception.ts +24 -0
- package/src/runtime/routes/inbound-message-handler.ts +2 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
- package/src/runtime/routes/plugins-routes.ts +69 -49
- package/src/schedule/run-script.ts +2 -2
- package/src/skills/managed-store.ts +98 -32
- package/src/tools/host-terminal/host-shell.ts +12 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
- package/src/tools/shared/shell-output.test.ts +10 -0
- package/src/tools/shared/shell-output.ts +14 -2
- package/src/tools/skills/find-similar-skills.test.ts +3 -0
- package/src/tools/skills/resolve-execute-invocation.ts +24 -0
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +18 -14
- package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
- package/src/tools/terminal/safe-env.ts +30 -1
- package/src/tools/terminal/sanitized-bash.ts +15 -2
- package/src/tools/terminal/shell-launch.test.ts +162 -0
- package/src/tools/terminal/shell.test.ts +29 -0
- package/src/tools/terminal/shell.ts +13 -7
- package/src/util/host-process.test.ts +17 -1
- package/src/util/host-process.ts +24 -0
- package/src/util/unicode.ts +29 -0
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
NORMAL_UPDATES_MARKER,
|
|
9
9
|
parseTerminalSessionControl,
|
|
10
10
|
type SessionControlRequest,
|
|
11
|
+
TASK_STOP_MARKER,
|
|
11
12
|
} from "../calls/voice-control-protocol.js";
|
|
12
13
|
import type { VoiceProgressConfig } from "../config/schemas/voice.js";
|
|
13
14
|
import type { LiveVoiceSessionControl } from "./protocol.js";
|
|
@@ -86,6 +87,8 @@ export function lookFollowUpNote(action: LookSessionControl): string {
|
|
|
86
87
|
// to carry it out.
|
|
87
88
|
const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (for example "don't give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
|
|
88
89
|
|
|
90
|
+
const TASK_STOP_LINE = `- To abandon the unfinished task while keeping the call open (for example "stop that" or "never mind about that task"), confirm briefly, then end your reply with ${TASK_STOP_MARKER}. Use this only when they want the task abandoned, not when they interrupt with a question or correction.`;
|
|
91
|
+
|
|
89
92
|
/**
|
|
90
93
|
* The control-prompt block that teaches the session controls: the ones the
|
|
91
94
|
* client declared plus the progress-update cadence. Every leg gets it,
|
|
@@ -98,7 +101,7 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
|
|
|
98
101
|
export function sessionControlTeaching(
|
|
99
102
|
controls: readonly LiveVoiceSessionControl[],
|
|
100
103
|
leg: { frontDoor?: boolean },
|
|
101
|
-
client: { lookFrames?: boolean } = {},
|
|
104
|
+
client: { lookFrames?: boolean; unfinishedTaskPending?: boolean } = {},
|
|
102
105
|
): string {
|
|
103
106
|
const lines =
|
|
104
107
|
client.lookFrames === true
|
|
@@ -109,6 +112,7 @@ export function sessionControlTeaching(
|
|
|
109
112
|
...controls.map((control) => lines[control]),
|
|
110
113
|
...lookGuidance(controls),
|
|
111
114
|
UPDATES_LINE,
|
|
115
|
+
...(client.unfinishedTaskPending === true ? [TASK_STOP_LINE] : []),
|
|
112
116
|
`The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
|
|
113
117
|
].join("\n");
|
|
114
118
|
}
|
|
@@ -137,7 +141,7 @@ function lookGuidance(controls: readonly LiveVoiceSessionControl[]): string[] {
|
|
|
137
141
|
/** A session control the client carries out, sent as a `session_control` frame. */
|
|
138
142
|
export type ClientSessionControlRequest = Exclude<
|
|
139
143
|
SessionControlRequest,
|
|
140
|
-
{ action: "updates" }
|
|
144
|
+
{ action: "updates" } | { action: "task_stop" }
|
|
141
145
|
>;
|
|
142
146
|
|
|
143
147
|
/**
|
|
@@ -154,7 +158,7 @@ export function requestedSessionControl(
|
|
|
154
158
|
if (request === null) {
|
|
155
159
|
return null;
|
|
156
160
|
}
|
|
157
|
-
if (request.action === "updates") {
|
|
161
|
+
if (request.action === "updates" || request.action === "task_stop") {
|
|
158
162
|
return request;
|
|
159
163
|
}
|
|
160
164
|
return controls.includes(request.action) ? request : null;
|
|
@@ -30,9 +30,9 @@ import { CHANNEL_IDS } from "../channels/types.js";
|
|
|
30
30
|
* The schema passes through what it does not name, so a provider carries its
|
|
31
31
|
* own fields (Slack's file markers and timezone labels) on this same object
|
|
32
32
|
* and validates them with its own schema. This is how `SourceMetadataSchema`
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* carries `botMentioned` and the email fields on the wire: one object per
|
|
34
|
+
* row, no per-provider sub-envelope, and no second copy of anything named
|
|
35
|
+
* here.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
const providerReactionMetadataSchema = z.object({
|
|
@@ -155,6 +155,12 @@ function tracksCuratedSource(inspection: PluginInspection): boolean {
|
|
|
155
155
|
if (!remote) {
|
|
156
156
|
return false;
|
|
157
157
|
}
|
|
158
|
+
if (source.kind === "local") {
|
|
159
|
+
return remote.kind === "local" && source.path === remote.path;
|
|
160
|
+
}
|
|
161
|
+
if (remote.kind === "local") {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
158
164
|
return (
|
|
159
165
|
`${source.owner}/${source.repo}`.toLowerCase() ===
|
|
160
166
|
remote.repo.toLowerCase() && (source.path ?? "") === remote.path
|
|
@@ -503,6 +503,76 @@ describe("composeFallbackCopy plugin schedule templates", () => {
|
|
|
503
503
|
});
|
|
504
504
|
});
|
|
505
505
|
|
|
506
|
+
// ── activity.complete rendering ───────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
describe("activity.complete copy", () => {
|
|
509
|
+
function completeSignal(
|
|
510
|
+
contextPayload: Record<string, unknown>,
|
|
511
|
+
): NotificationSignal {
|
|
512
|
+
return makeSignal({
|
|
513
|
+
sourceEventName: "activity.complete",
|
|
514
|
+
contextPayload,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
test("a producer-authored title survives the fallback path", () => {
|
|
519
|
+
// The home feed reads the payload title first; the popup must say the
|
|
520
|
+
// same thing when the classifier was unreachable.
|
|
521
|
+
const copy = composeFallbackCopy(
|
|
522
|
+
completeSignal({
|
|
523
|
+
title: "Skill updated: Weekly Report Export",
|
|
524
|
+
summary: "Added the retry after an expired session.",
|
|
525
|
+
}),
|
|
526
|
+
CHANNELS,
|
|
527
|
+
);
|
|
528
|
+
expect(copy.vellum?.title).toBe("Skill updated: Weekly Report Export");
|
|
529
|
+
expect(copy.vellum?.body).toBe("Added the retry after an expired session.");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("the pass-through's requestedTitle is honored when no title is set", () => {
|
|
533
|
+
const copy = composeFallbackCopy(
|
|
534
|
+
completeSignal({
|
|
535
|
+
requestedTitle: "Nightly export finished",
|
|
536
|
+
summary: "Wrote 12 files to the reports folder.",
|
|
537
|
+
}),
|
|
538
|
+
CHANNELS,
|
|
539
|
+
);
|
|
540
|
+
expect(copy.vellum?.title).toBe("Nightly export finished");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("without an authored title the headline is the summary's first sentence", () => {
|
|
544
|
+
const copy = composeFallbackCopy(
|
|
545
|
+
completeSignal({
|
|
546
|
+
summary:
|
|
547
|
+
"Added the retry after an expired session. Dropped the login step.",
|
|
548
|
+
}),
|
|
549
|
+
CHANNELS,
|
|
550
|
+
);
|
|
551
|
+
expect(copy.vellum?.title).toBe(
|
|
552
|
+
"Added the retry after an expired session.",
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("an authored title the normalizer rejects falls back to the derived one", () => {
|
|
557
|
+
const copy = composeFallbackCopy(
|
|
558
|
+
completeSignal({
|
|
559
|
+
title: "Skill\nupdated",
|
|
560
|
+
summary: "Added the retry after an expired session.",
|
|
561
|
+
}),
|
|
562
|
+
CHANNELS,
|
|
563
|
+
);
|
|
564
|
+
expect(copy.vellum?.title).toBe(
|
|
565
|
+
"Added the retry after an expired session.",
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("neither a title nor a summary still says something happened", () => {
|
|
570
|
+
const copy = composeFallbackCopy(completeSignal({}), CHANNELS);
|
|
571
|
+
expect(copy.vellum?.title).toBe("Activity complete");
|
|
572
|
+
expect(copy.vellum?.body).toBe("An activity has completed");
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
506
576
|
// ── deriveTitle ───────────────────────────────────────────────────────
|
|
507
577
|
|
|
508
578
|
describe("deriveTitle", () => {
|
|
@@ -346,14 +346,22 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
346
346
|
|
|
347
347
|
// Titled by what was done rather than by the kind of event: the summary's
|
|
348
348
|
// first sentence is the outcome ("Finished the fuel-system diagnostic app"),
|
|
349
|
-
// which is what a reader scanning the bell wants to see. A
|
|
350
|
-
//
|
|
349
|
+
// which is what a reader scanning the bell wants to see. A producer that
|
|
350
|
+
// names its subject in the payload keeps that name here, read in the same
|
|
351
|
+
// order the home feed reads it, so the headline is the same whether or not
|
|
352
|
+
// the classifier ran. A summary with neither still gets a title that says
|
|
353
|
+
// something happened.
|
|
351
354
|
"activity.complete": (payload) => {
|
|
352
355
|
const summary = nonEmpty(
|
|
353
356
|
typeof payload.summary === "string" ? payload.summary : undefined,
|
|
354
357
|
);
|
|
358
|
+
const authored = normalizeTitle(
|
|
359
|
+
readPayloadString(payload, "title") ??
|
|
360
|
+
readPayloadString(payload, "requestedTitle") ??
|
|
361
|
+
"",
|
|
362
|
+
);
|
|
355
363
|
return {
|
|
356
|
-
title: summary ? deriveTitle(summary) : "Activity complete",
|
|
364
|
+
title: authored || (summary ? deriveTitle(summary) : "Activity complete"),
|
|
357
365
|
body: summary ?? "An activity has completed",
|
|
358
366
|
};
|
|
359
367
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ToolDefinition } from "../providers/types.js";
|
|
1
2
|
import type {
|
|
2
3
|
AddMessageOptions,
|
|
3
4
|
ConversationRow,
|
|
@@ -52,6 +53,18 @@ export async function getMessages(
|
|
|
52
53
|
return fn(conversationId);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The tool definitions the conversation's most recent live turn sent to the
|
|
58
|
+
* provider, or `null` when none has been recorded.
|
|
59
|
+
*/
|
|
60
|
+
export async function getConversationToolSurface(
|
|
61
|
+
conversationId: string,
|
|
62
|
+
): Promise<ToolDefinition[] | null> {
|
|
63
|
+
const { getConversationToolSurface: fn } =
|
|
64
|
+
await import("./conversation-tool-surface.js");
|
|
65
|
+
return fn(conversationId);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/** Whether the conversation currently has a turn in flight. */
|
|
56
69
|
export async function isConversationProcessing(id: string): Promise<boolean> {
|
|
57
70
|
const { isConversationProcessing: fn } =
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { eq, sql } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
import type { ToolDefinition } from "../providers/types.js";
|
|
6
|
+
import { getDb } from "./db-connection.js";
|
|
7
|
+
import { conversationToolSurfaces } from "./schema/index.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-conversation record of the tool definitions the most recent live turn
|
|
11
|
+
* sent to the provider (`conversation_tool_surfaces`).
|
|
12
|
+
*
|
|
13
|
+
* The provider prompt cache is a byte-exact prefix match over
|
|
14
|
+
* `tools -> system -> messages`, so a background wake that forks a
|
|
15
|
+
* conversation can only reuse the source's cached prefix by sending the SAME
|
|
16
|
+
* tools array. Re-deriving the array on the fork cannot guarantee that: the
|
|
17
|
+
* fork may run in another process (a different tool registry), with no
|
|
18
|
+
* connected clients (different host-tool gates), or under a presence the
|
|
19
|
+
* persisted message stamps do not encode. Recording the resolved array and
|
|
20
|
+
* replaying it verbatim can.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Content hash of the serialized tools array. */
|
|
24
|
+
export function hashConversationToolSurface(
|
|
25
|
+
tools: readonly ToolDefinition[],
|
|
26
|
+
): string {
|
|
27
|
+
return hashToolsJson(JSON.stringify(tools));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hashToolsJson(toolsJson: string): string {
|
|
31
|
+
return createHash("sha256").update(toolsJson).digest("hex").slice(0, 32);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Persist `tools` as the conversation's current wire tool surface and return
|
|
36
|
+
* its hash. `knownHash` is the hash the caller last recorded for this
|
|
37
|
+
* conversation (undefined when it has recorded nothing this process
|
|
38
|
+
* lifetime): a matching hash skips the statement outright. Otherwise the
|
|
39
|
+
* upsert only rewrites a row whose stored hash differs, so a conversation
|
|
40
|
+
* reloaded from disk never rewrites an unchanged surface either.
|
|
41
|
+
*/
|
|
42
|
+
export function recordConversationToolSurface(
|
|
43
|
+
conversationId: string,
|
|
44
|
+
tools: readonly ToolDefinition[],
|
|
45
|
+
knownHash?: string,
|
|
46
|
+
): string {
|
|
47
|
+
const toolsJson = JSON.stringify(tools);
|
|
48
|
+
const toolsHash = hashToolsJson(toolsJson);
|
|
49
|
+
if (knownHash === toolsHash) {
|
|
50
|
+
return toolsHash;
|
|
51
|
+
}
|
|
52
|
+
const updatedAt = Date.now();
|
|
53
|
+
getDb()
|
|
54
|
+
.insert(conversationToolSurfaces)
|
|
55
|
+
.values({ conversationId, toolsJson, toolsHash, updatedAt })
|
|
56
|
+
.onConflictDoUpdate({
|
|
57
|
+
target: conversationToolSurfaces.conversationId,
|
|
58
|
+
set: { toolsJson, toolsHash, updatedAt },
|
|
59
|
+
setWhere: sql`${conversationToolSurfaces.toolsHash} <> excluded.tools_hash`,
|
|
60
|
+
})
|
|
61
|
+
.run();
|
|
62
|
+
return toolsHash;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The tool definitions the conversation's most recent live turn sent, or
|
|
67
|
+
* `null` when no turn has recorded one (or the stored JSON is unreadable).
|
|
68
|
+
*/
|
|
69
|
+
export function getConversationToolSurface(
|
|
70
|
+
conversationId: string,
|
|
71
|
+
): ToolDefinition[] | null {
|
|
72
|
+
const row = getDb()
|
|
73
|
+
.select({ toolsJson: conversationToolSurfaces.toolsJson })
|
|
74
|
+
.from(conversationToolSurfaces)
|
|
75
|
+
.where(eq(conversationToolSurfaces.conversationId, conversationId))
|
|
76
|
+
.get();
|
|
77
|
+
if (!row) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed: unknown = JSON.parse(row.toolsJson);
|
|
82
|
+
return Array.isArray(parsed) ? (parsed as ToolDefinition[]) : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import * as schema from "../schema.js";
|
|
7
|
+
import { migrateCreateConversationToolSurfaces } from "./378-create-conversation-tool-surfaces.js";
|
|
8
|
+
|
|
9
|
+
function createTestDb() {
|
|
10
|
+
const sqlite = new Database(":memory:");
|
|
11
|
+
sqlite.run("PRAGMA foreign_keys = ON");
|
|
12
|
+
// Only the parent table the FK points at.
|
|
13
|
+
sqlite.run(/*sql*/ `CREATE TABLE conversations (id TEXT PRIMARY KEY)`);
|
|
14
|
+
return { sqlite, db: drizzle(sqlite, { schema }) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tableDdl(sqlite: Database): string | undefined {
|
|
18
|
+
const row = sqlite
|
|
19
|
+
.query(
|
|
20
|
+
`SELECT sql FROM sqlite_master WHERE type='table' AND name='conversation_tool_surfaces'`,
|
|
21
|
+
)
|
|
22
|
+
.get() as { sql: string } | null;
|
|
23
|
+
return row?.sql;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function insertSurface(sqlite: Database, conversationId: string): void {
|
|
27
|
+
sqlite
|
|
28
|
+
.query(
|
|
29
|
+
/*sql*/ `INSERT INTO conversation_tool_surfaces (conversation_id, tools_json, tools_hash, updated_at) VALUES (?, ?, ?, ?)`,
|
|
30
|
+
)
|
|
31
|
+
.run(conversationId, "[]", "hash", 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function surfaceCount(sqlite: Database): number {
|
|
35
|
+
const row = sqlite
|
|
36
|
+
.query(`SELECT COUNT(*) AS n FROM conversation_tool_surfaces`)
|
|
37
|
+
.get() as { n: number };
|
|
38
|
+
return row.n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("migration 378: conversation_tool_surfaces", () => {
|
|
42
|
+
test("creates the table keyed by conversation with a cascading FK", () => {
|
|
43
|
+
const { sqlite, db } = createTestDb();
|
|
44
|
+
expect(tableDdl(sqlite)).toBeUndefined();
|
|
45
|
+
|
|
46
|
+
migrateCreateConversationToolSurfaces(db);
|
|
47
|
+
|
|
48
|
+
const ddl = tableDdl(sqlite);
|
|
49
|
+
expect(ddl).toContain("conversation_id TEXT PRIMARY KEY");
|
|
50
|
+
expect(ddl).toContain("REFERENCES conversations(id) ON DELETE CASCADE");
|
|
51
|
+
expect(ddl).toContain("tools_json TEXT NOT NULL");
|
|
52
|
+
expect(ddl).toContain("tools_hash TEXT NOT NULL");
|
|
53
|
+
expect(ddl).toContain("updated_at INTEGER NOT NULL");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("deleting a conversation cascades to its surface row", () => {
|
|
57
|
+
const { sqlite, db } = createTestDb();
|
|
58
|
+
migrateCreateConversationToolSurfaces(db);
|
|
59
|
+
sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
|
|
60
|
+
insertSurface(sqlite, "conv-1");
|
|
61
|
+
expect(surfaceCount(sqlite)).toBe(1);
|
|
62
|
+
|
|
63
|
+
sqlite.query(`DELETE FROM conversations WHERE id = ?`).run("conv-1");
|
|
64
|
+
|
|
65
|
+
expect(surfaceCount(sqlite)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("is idempotent: a second run keeps the table and its rows", () => {
|
|
69
|
+
const { sqlite, db } = createTestDb();
|
|
70
|
+
migrateCreateConversationToolSurfaces(db);
|
|
71
|
+
sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
|
|
72
|
+
insertSurface(sqlite, "conv-1");
|
|
73
|
+
|
|
74
|
+
expect(() => migrateCreateConversationToolSurfaces(db)).not.toThrow();
|
|
75
|
+
|
|
76
|
+
expect(surfaceCount(sqlite)).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
|
|
3
|
+
const TABLE = "conversation_tool_surfaces";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create `conversation_tool_surfaces`: one row per conversation holding the
|
|
7
|
+
* tool definitions its most recent live turn sent to the provider, keyed by
|
|
8
|
+
* conversation with a content hash so an unchanged surface is never rewritten.
|
|
9
|
+
*
|
|
10
|
+
* Its own table rather than a column on `conversations` because the payload
|
|
11
|
+
* is a JSON array of every tool definition (tens of kilobytes) that only fork
|
|
12
|
+
* wakes read, while `conversations` rows are read whole on every hot path.
|
|
13
|
+
* The row cascades with its conversation, so a deleted conversation leaves
|
|
14
|
+
* no surface behind.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent via IF NOT EXISTS.
|
|
17
|
+
*/
|
|
18
|
+
export function migrateCreateConversationToolSurfaces(
|
|
19
|
+
database: DrizzleDb,
|
|
20
|
+
): void {
|
|
21
|
+
database.run(
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
23
|
+
conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
|
24
|
+
tools_json TEXT NOT NULL,
|
|
25
|
+
tools_hash TEXT NOT NULL,
|
|
26
|
+
updated_at INTEGER NOT NULL
|
|
27
|
+
)`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
import { conversations } from "./conversations.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The tool definitions a conversation's most recent live turn sent to the
|
|
7
|
+
* provider, serialized exactly as resolved. Written by the per-turn tool
|
|
8
|
+
* resolver when the array changes; read by fork wakes that replay it so their
|
|
9
|
+
* provider prompt-cache prefix matches the source's live turns. Cascades with
|
|
10
|
+
* its conversation.
|
|
11
|
+
*/
|
|
12
|
+
export const conversationToolSurfaces = sqliteTable(
|
|
13
|
+
"conversation_tool_surfaces",
|
|
14
|
+
{
|
|
15
|
+
conversationId: text("conversation_id")
|
|
16
|
+
.primaryKey()
|
|
17
|
+
.references(() => conversations.id, { onDelete: "cascade" }),
|
|
18
|
+
toolsJson: text("tools_json").notNull(),
|
|
19
|
+
toolsHash: text("tools_hash").notNull(),
|
|
20
|
+
updatedAt: integer("updated_at").notNull(),
|
|
21
|
+
},
|
|
22
|
+
);
|
|
@@ -5,6 +5,7 @@ export * from "./calls.js";
|
|
|
5
5
|
export * from "./contacts.js";
|
|
6
6
|
export * from "./conversation-groups.js";
|
|
7
7
|
export * from "./conversation-starters.js";
|
|
8
|
+
export * from "./conversation-tool-surfaces.js";
|
|
8
9
|
export * from "./conversations.js";
|
|
9
10
|
export * from "./documents.js";
|
|
10
11
|
export * from "./guardian.js";
|
package/src/persistence/steps.ts
CHANGED
|
@@ -485,6 +485,7 @@ import { migrateChannelInboundMessageIdIndex } from "./migrations/374-channel-in
|
|
|
485
485
|
import { migrateCreateChannelOutboundPosts } from "./migrations/375-create-channel-outbound-posts.js";
|
|
486
486
|
import { migrateNotificationDeliveriesCanonicalMessageId } from "./migrations/376-notification-deliveries-canonical-message-id.js";
|
|
487
487
|
import { migrateAddSubagentBudgetStopReason } from "./migrations/377-add-subagent-budget-stop-reason.js";
|
|
488
|
+
import { migrateCreateConversationToolSurfaces } from "./migrations/378-create-conversation-tool-surfaces.js";
|
|
488
489
|
import type { MigrationStep } from "./migrations/run-migrations.js";
|
|
489
490
|
|
|
490
491
|
export const migrationSteps: MigrationStep[] = [
|
|
@@ -1611,4 +1612,5 @@ export const migrationSteps: MigrationStep[] = [
|
|
|
1611
1612
|
// this as a no-op.
|
|
1612
1613
|
dependsOn: ["migrateCreateSubagentsTable"],
|
|
1613
1614
|
},
|
|
1615
|
+
migrateCreateConversationToolSurfaces,
|
|
1614
1616
|
];
|
|
@@ -284,13 +284,20 @@ export async function runConversationTurn(
|
|
|
284
284
|
const { resolveChannelCapabilities } =
|
|
285
285
|
await import("../daemon/conversation-runtime-assembly.js");
|
|
286
286
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// below) without requiring a client to approve prompts.
|
|
287
|
+
// Channel-addressed turns are inbound: the gateway classifies the sender
|
|
288
|
+
// and the turn runs only if that actor clears the channel admission floor.
|
|
289
|
+
// Internal plugin jobs (no channel address) use guardian trust because they
|
|
290
|
+
// are assistant-owned work, not an external sender.
|
|
292
291
|
const { INTERNAL_GUARDIAN_TRUST_CONTEXT } =
|
|
293
292
|
await import("../daemon/trust-context.js");
|
|
293
|
+
const { resolvePluginChannelTurnTrust } =
|
|
294
|
+
await import("./plugin-channel-turn-trust.js");
|
|
295
|
+
const { prepareChannelInboundContent } =
|
|
296
|
+
await import("../runtime/routes/inbound-stages/inbound-content-prep.js");
|
|
297
|
+
|
|
298
|
+
const trustContext = options.channel
|
|
299
|
+
? await resolvePluginChannelTurnTrust(options.channel)
|
|
300
|
+
: INTERNAL_GUARDIAN_TRUST_CONTEXT;
|
|
294
301
|
|
|
295
302
|
// A channel address resolves through the same binding an inbound message
|
|
296
303
|
// uses, so a turn addressed by chat lands in that chat's conversation
|
|
@@ -311,7 +318,7 @@ export async function runConversationTurn(
|
|
|
311
318
|
? !channelConversation.created
|
|
312
319
|
: getConversation(conversationId) != null;
|
|
313
320
|
const conversation = await getOrCreateConversation(conversationId, {
|
|
314
|
-
trustContext
|
|
321
|
+
trustContext,
|
|
315
322
|
...(options.conversationType
|
|
316
323
|
? { conversationType: options.conversationType }
|
|
317
324
|
: {}),
|
|
@@ -336,10 +343,23 @@ export async function runConversationTurn(
|
|
|
336
343
|
|
|
337
344
|
// Convert ContentBlock[] input to the text + attachments shape the
|
|
338
345
|
// conversation's processMessage path expects.
|
|
339
|
-
const { text, attachments } = extractContentAndAttachments(
|
|
346
|
+
const { text: rawText, attachments } = extractContentAndAttachments(
|
|
340
347
|
options.content,
|
|
341
348
|
resolveMediaSourceData,
|
|
342
349
|
);
|
|
350
|
+
const inboundContent = options.channel
|
|
351
|
+
? prepareChannelInboundContent({
|
|
352
|
+
trimmedContent: rawText,
|
|
353
|
+
trustClass: trustContext.trustClass,
|
|
354
|
+
sourceChannel: options.channel.sourceChannel,
|
|
355
|
+
requesterIdentifier:
|
|
356
|
+
options.channel.username ??
|
|
357
|
+
options.channel.externalUserId ??
|
|
358
|
+
undefined,
|
|
359
|
+
})
|
|
360
|
+
: { content: rawText, displayContent: undefined };
|
|
361
|
+
const text = inboundContent.content;
|
|
362
|
+
const displayContent = inboundContent.displayContent;
|
|
343
363
|
|
|
344
364
|
// The channel this turn speaks on. Runtime assembly reads the per-turn
|
|
345
365
|
// context first and falls back to the conversation's `originChannel`, then
|
|
@@ -409,6 +429,8 @@ export async function runConversationTurn(
|
|
|
409
429
|
requestId,
|
|
410
430
|
isInteractive: false,
|
|
411
431
|
metadata,
|
|
432
|
+
trustContext,
|
|
433
|
+
...(displayContent ? { displayContent } : {}),
|
|
412
434
|
});
|
|
413
435
|
if (enqueueResult.rejected) {
|
|
414
436
|
throw new Error(
|
|
@@ -432,6 +454,8 @@ export async function runConversationTurn(
|
|
|
432
454
|
onEvent,
|
|
433
455
|
isInteractive: false,
|
|
434
456
|
metadata,
|
|
457
|
+
trustContext,
|
|
458
|
+
...(displayContent ? { displayContent } : {}),
|
|
435
459
|
...(options.callSite ? { callSite: options.callSite } : {}),
|
|
436
460
|
});
|
|
437
461
|
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -286,6 +286,11 @@ export {
|
|
|
286
286
|
// writes files under the workspace (e.g. its own `plugins/<name>/data/`
|
|
287
287
|
// directory) resolves them against this instead of hardcoding a base path.
|
|
288
288
|
export { getWorkspaceDir } from "../util/platform.js";
|
|
289
|
+
// `String.prototype.slice` that never cuts a UTF-16 surrogate pair in half.
|
|
290
|
+
// Any text a plugin truncates by character budget and hands to a model must
|
|
291
|
+
// go through this: an orphaned half is invalid UTF-16 that strict provider
|
|
292
|
+
// parsers reject.
|
|
293
|
+
export { safeStringSlice } from "../util/unicode.js";
|
|
289
294
|
// Declarative help for the top-level `assistant` CLI commands that have adopted
|
|
290
295
|
// the static-help split. Plugins (e.g. the memory capability indexer) read this
|
|
291
296
|
// to embed CLI command capabilities without importing the CLI action graph.
|
|
@@ -359,7 +364,8 @@ export {
|
|
|
359
364
|
stringifyMessageContent,
|
|
360
365
|
} from "../persistence/message-content.js";
|
|
361
366
|
// Conversation history — reads and writes on the host conversation store
|
|
362
|
-
// (rows, message history, processing state,
|
|
367
|
+
// (rows, message history, processing state, the recorded wire tool surface,
|
|
368
|
+
// disk-view paths) plus the lexical
|
|
363
369
|
// message-search surface. Every operation takes explicit parameters; nothing
|
|
364
370
|
// is resolved from config. Async because the facade loads the DB store graph
|
|
365
371
|
// lazily on first call.
|
|
@@ -371,6 +377,7 @@ export {
|
|
|
371
377
|
getConversation,
|
|
372
378
|
getConversationDirPath,
|
|
373
379
|
getConversationProcessingStartedAt,
|
|
380
|
+
getConversationToolSurface,
|
|
374
381
|
getMessages,
|
|
375
382
|
hasLexicalTokens,
|
|
376
383
|
isConversationProcessing,
|
|
@@ -438,6 +445,7 @@ export type {
|
|
|
438
445
|
RunConversationTurnResult,
|
|
439
446
|
} from "./conversation-turn.js";
|
|
440
447
|
export { runConversationTurn } from "./conversation-turn.js";
|
|
448
|
+
export { PluginTurnNotAdmittedError } from "./plugin-channel-turn-trust.js";
|
|
441
449
|
// Live voice — drive a single client's real-time voice session (STT → agent
|
|
442
450
|
// turn → TTS, with server-VAD turn-taking, pauses, and barge-in) over a
|
|
443
451
|
// transport the plugin owns. The plugin brings only a `send` callback (e.g.
|