@vellumai/assistant 0.12.2-staging.6 → 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/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 +6 -2
- package/package.json +2 -2
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- 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__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- 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/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 +57 -1
- 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/messaging/provider-message-metadata.ts +3 -3
- 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-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-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/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/schedule/run-script.ts +2 -2
- 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/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +2 -1
- 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
|
@@ -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.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
type InboundTrustReadResult =
|
|
4
|
+
| {
|
|
5
|
+
ok: true;
|
|
6
|
+
verdict: {
|
|
7
|
+
trustClass: string;
|
|
8
|
+
canonicalSenderId: string | null;
|
|
9
|
+
contactId?: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
resolutionFailed?: boolean;
|
|
12
|
+
};
|
|
13
|
+
admissionPolicy: string | null;
|
|
14
|
+
}
|
|
15
|
+
| { ok: false };
|
|
16
|
+
|
|
17
|
+
let nextRead: InboundTrustReadResult = { ok: false };
|
|
18
|
+
|
|
19
|
+
mock.module("../calls/inbound-trust-reader.js", () => ({
|
|
20
|
+
readInboundTrust: async () => nextRead,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
PluginTurnNotAdmittedError,
|
|
25
|
+
resolvePluginChannelTurnTrust,
|
|
26
|
+
} from "./plugin-channel-turn-trust.js";
|
|
27
|
+
|
|
28
|
+
const CHANNEL = {
|
|
29
|
+
sourceChannel: "plugin" as const,
|
|
30
|
+
externalChatId: "imessage:+12025550142",
|
|
31
|
+
externalUserId: "imessage:+12025550142",
|
|
32
|
+
displayName: "Ada",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
nextRead = { ok: false };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolvePluginChannelTurnTrust", () => {
|
|
40
|
+
test("returns the gateway verdict as the turn trust context", async () => {
|
|
41
|
+
nextRead = {
|
|
42
|
+
ok: true,
|
|
43
|
+
verdict: {
|
|
44
|
+
trustClass: "trusted_contact",
|
|
45
|
+
canonicalSenderId: "imessage:+12025550142",
|
|
46
|
+
contactId: "c-ada",
|
|
47
|
+
status: "active",
|
|
48
|
+
},
|
|
49
|
+
admissionPolicy: "trusted_contacts",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const trust = await resolvePluginChannelTurnTrust(CHANNEL);
|
|
53
|
+
expect(trust.trustClass).toBe("trusted_contact");
|
|
54
|
+
expect(trust.sourceChannel).toBe("plugin");
|
|
55
|
+
expect(trust.requesterChatId).toBe(CHANNEL.externalChatId);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("fails closed when the gateway could not vouch for the sender", async () => {
|
|
59
|
+
nextRead = {
|
|
60
|
+
ok: true,
|
|
61
|
+
verdict: {
|
|
62
|
+
trustClass: "unknown",
|
|
63
|
+
canonicalSenderId: null,
|
|
64
|
+
resolutionFailed: true,
|
|
65
|
+
},
|
|
66
|
+
admissionPolicy: "strangers",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
|
|
70
|
+
name: "PluginTurnNotAdmittedError",
|
|
71
|
+
reason: "trust_resolution_failed",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("fails closed when the gateway trust read fails", async () => {
|
|
76
|
+
nextRead = { ok: false };
|
|
77
|
+
await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
|
|
78
|
+
name: "PluginTurnNotAdmittedError",
|
|
79
|
+
reason: "trust_resolution_failed",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rejects a sender below the channel admission floor", async () => {
|
|
84
|
+
nextRead = {
|
|
85
|
+
ok: true,
|
|
86
|
+
verdict: { trustClass: "unknown", canonicalSenderId: null },
|
|
87
|
+
admissionPolicy: "guardian_only",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toEqual(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
name: "PluginTurnNotAdmittedError",
|
|
93
|
+
reason: "admission_policy_guardian_only",
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects a blocked member regardless of floor", async () => {
|
|
99
|
+
nextRead = {
|
|
100
|
+
ok: true,
|
|
101
|
+
verdict: {
|
|
102
|
+
trustClass: "trusted_contact",
|
|
103
|
+
canonicalSenderId: "imessage:+12025550142",
|
|
104
|
+
contactId: "c-ada",
|
|
105
|
+
status: "blocked",
|
|
106
|
+
},
|
|
107
|
+
admissionPolicy: "strangers",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await expect(resolvePluginChannelTurnTrust(CHANNEL)).rejects.toMatchObject({
|
|
111
|
+
name: "PluginTurnNotAdmittedError",
|
|
112
|
+
reason: "member_blocked",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("admits when the gateway reports no admission policy", async () => {
|
|
117
|
+
nextRead = {
|
|
118
|
+
ok: true,
|
|
119
|
+
verdict: { trustClass: "unknown", canonicalSenderId: null },
|
|
120
|
+
admissionPolicy: null,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const trust = await resolvePluginChannelTurnTrust(CHANNEL);
|
|
124
|
+
expect(trust.trustClass).toBe("unknown");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("PluginTurnNotAdmittedError names the deny reason", () => {
|
|
128
|
+
const err = new PluginTurnNotAdmittedError("admission_policy_no_one");
|
|
129
|
+
expect(err).toBeInstanceOf(Error);
|
|
130
|
+
expect(err.reason).toBe("admission_policy_no_one");
|
|
131
|
+
expect(err.message).toContain("admission_policy_no_one");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway admission and trust for plugin turns addressed by a channel chat.
|
|
3
|
+
*
|
|
4
|
+
* A channel address means an inbound sender, not an internal plugin job.
|
|
5
|
+
* Trust comes from the gateway verdict (`resolve_inbound_trust`); the turn
|
|
6
|
+
* runs only when that sender clears the channel admission floor.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { meetsAdmissionFloor } from "@vellumai/gateway-client";
|
|
10
|
+
|
|
11
|
+
import { readInboundTrust } from "../calls/inbound-trust-reader.js";
|
|
12
|
+
import type { ChannelId } from "../channels/types.js";
|
|
13
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
14
|
+
import { trustContextFromVerdict } from "../runtime/trust-verdict-consumer.js";
|
|
15
|
+
|
|
16
|
+
interface PluginChannelTurnAddress {
|
|
17
|
+
sourceChannel: ChannelId;
|
|
18
|
+
externalChatId: string;
|
|
19
|
+
externalUserId?: string | null;
|
|
20
|
+
displayName?: string | null;
|
|
21
|
+
username?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PluginTurnNotAdmittedError extends Error {
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
|
|
27
|
+
constructor(reason: string) {
|
|
28
|
+
super(`This inbound plugin turn was not admitted (${reason}).`);
|
|
29
|
+
this.name = "PluginTurnNotAdmittedError";
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function resolvePluginChannelTurnTrust(
|
|
35
|
+
channel: PluginChannelTurnAddress,
|
|
36
|
+
): Promise<TrustContext> {
|
|
37
|
+
const actorExternalId = channel.externalUserId?.trim() || undefined;
|
|
38
|
+
const read = await readInboundTrust({
|
|
39
|
+
channelType: channel.sourceChannel,
|
|
40
|
+
actorExternalId,
|
|
41
|
+
});
|
|
42
|
+
if (!read.ok) {
|
|
43
|
+
throw new PluginTurnNotAdmittedError("trust_resolution_failed");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (read.verdict.resolutionFailed) {
|
|
47
|
+
throw new PluginTurnNotAdmittedError("trust_resolution_failed");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const memberStatus = read.verdict.status;
|
|
51
|
+
if (memberStatus === "blocked" || memberStatus === "revoked") {
|
|
52
|
+
throw new PluginTurnNotAdmittedError(`member_${memberStatus}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trustClass = read.verdict.trustClass;
|
|
56
|
+
if (
|
|
57
|
+
read.admissionPolicy != null &&
|
|
58
|
+
!meetsAdmissionFloor(read.admissionPolicy, trustClass)
|
|
59
|
+
) {
|
|
60
|
+
throw new PluginTurnNotAdmittedError(
|
|
61
|
+
`admission_policy_${read.admissionPolicy}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return trustContextFromVerdict(read.verdict, {
|
|
66
|
+
sourceChannel: channel.sourceChannel,
|
|
67
|
+
conversationExternalId: channel.externalChatId,
|
|
68
|
+
actorDisplayName: channel.displayName ?? undefined,
|
|
69
|
+
actorUsername: channel.username ?? undefined,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -68,6 +68,13 @@ Everything else under the plugin root is **spine**:
|
|
|
68
68
|
the writer plus the one matcher every reader uses. It lives at the root
|
|
69
69
|
precisely so `substrate/`, `graph/`, and `graph-topology/` can all reach it
|
|
70
70
|
without a tier importing spine. Do not add a second matcher anywhere),
|
|
71
|
+
`buffer-file` (the two writers of `memory/buffer.md`: the append every
|
|
72
|
+
`remember()`-shaped path uses and the consume the consolidation job runs
|
|
73
|
+
after a pass. Do not write the buffer from anywhere else, and never
|
|
74
|
+
rewrite it from a read that is not inside `consumeBufferEntries`'s
|
|
75
|
+
synchronous critical section), `memory-run-evidence` (readers of what a
|
|
76
|
+
background memory run durably produced from its persisted messages; every
|
|
77
|
+
job that gates a state transition on a verified write uses them),
|
|
71
78
|
`segmenter`, `message-media`, `worker`, `worker-control`,
|
|
72
79
|
`memory-recall-log-store`, `activation-session-store` (the onboarding
|
|
73
80
|
activation rail — **not** a memory tier despite the name),
|
|
@@ -407,8 +414,13 @@ per distinct matched section, at most `memory.v3.finderSectionsPerPage` in
|
|
|
407
414
|
surfacing order (needle, dense, reply, span) plus its entity and rare-term
|
|
408
415
|
lines, and selecting a line selects that section; the selection log keeps
|
|
409
416
|
one row per slug, so the pool row is where the per-section verdicts live. A
|
|
410
|
-
turn whose selector never judged a pool
|
|
411
|
-
|
|
417
|
+
turn whose selector never judged a pool persists it with `selector_ran = 0`:
|
|
418
|
+
an empty pool when the injection gate hard-skipped it or nothing was pooled,
|
|
419
|
+
and the pool as the selector was given it, stable-prefix cards chosen and
|
|
420
|
+
finder lines not, when the selector's provider failed and the orchestrator
|
|
421
|
+
kept that prefix unjudged. That turn writes no `memory_v3_selections` rows:
|
|
422
|
+
the hot set's frecency and the learned-edge graph read that table as
|
|
423
|
+
judgments, and an unjudged page is not one. A
|
|
412
424
|
turn that logged no selections is still reachable by its stamped
|
|
413
425
|
`message_id`, so the inspector shows negative verdicts too. The pool row and
|
|
414
426
|
the turn's `memory_v3_selections` rows are
|