@vellumai/assistant 0.12.2-dev.202609171820.9ecfa87 → 0.12.2-dev.202609172017.3da279e
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/node_modules/@vellumai/slack-text/src/index.ts +4 -8
- package/package.json +1 -1
- package/src/__tests__/btw-routes.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +29 -0
- package/src/__tests__/credential-execution-client.test.ts +1 -1
- package/src/__tests__/secure-keys.test.ts +43 -1
- package/src/config/__tests__/memory-retrospective-schema.test.ts +14 -1
- package/src/config/bundled-skills/sequences/TOOLS.json +1 -5
- package/src/config/schemas/memory-retrospective.ts +11 -2
- package/src/credential-execution/ces-connect.test.ts +69 -0
- package/src/credential-execution/ces-connect.ts +115 -0
- package/src/credential-execution/ces-runtime.ts +55 -93
- package/src/daemon/conversation-runtime-assembly.ts +7 -18
- package/src/daemon/lifecycle.ts +5 -5
- package/src/persistence/conversation-crud.ts +6 -0
- package/src/persistence/conversation-queries.ts +3 -16
- package/src/runtime/guardian-reply-router.ts +5 -11
- package/src/runtime/routes/btw-routes.ts +9 -4
- package/src/runtime/routes/identity-routes.ts +1 -5
- package/src/security/secure-keys.ts +53 -103
- package/src/tools/__tests__/tool-schema-root-combinator-guard.test.ts +81 -0
- package/src/tools/ask-question/ask-question-tool.ts +6 -4
- package/src/tools/document/document-tool.ts +2 -6
|
@@ -127,7 +127,7 @@ export async function buildSlackUserLabelMap(
|
|
|
127
127
|
ids.map(async (id): Promise<[string, string] | undefined> => {
|
|
128
128
|
try {
|
|
129
129
|
const label = await resolveLabel(id);
|
|
130
|
-
const sanitized =
|
|
130
|
+
const sanitized = sanitizeSlackLabel(label ?? undefined);
|
|
131
131
|
if (!sanitized || sanitized === id) return undefined;
|
|
132
132
|
return [id, sanitized];
|
|
133
133
|
} catch {
|
|
@@ -166,7 +166,7 @@ export async function buildSlackChannelLabelMap(
|
|
|
166
166
|
ids.map(async (id): Promise<[string, string] | undefined> => {
|
|
167
167
|
try {
|
|
168
168
|
const label = await resolveLabel(id);
|
|
169
|
-
const sanitized =
|
|
169
|
+
const sanitized = sanitizeSlackLabel(label ?? undefined);
|
|
170
170
|
if (!sanitized || sanitized === id) return undefined;
|
|
171
171
|
return [id, sanitized];
|
|
172
172
|
} catch {
|
|
@@ -222,7 +222,7 @@ function renderChannelReference(
|
|
|
222
222
|
return `#${embeddedLabel}`;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
const resolvedLabel =
|
|
225
|
+
const resolvedLabel = sanitizeSlackLabel(
|
|
226
226
|
options.channelLabels?.[channelId],
|
|
227
227
|
);
|
|
228
228
|
if (resolvedLabel && resolvedLabel !== channelId) {
|
|
@@ -320,15 +320,11 @@ export function sanitizeSlackLabel(
|
|
|
320
320
|
function sanitizeEmbeddedSlackLabel(
|
|
321
321
|
label: string | undefined,
|
|
322
322
|
): string | undefined {
|
|
323
|
-
return
|
|
323
|
+
return sanitizeSlackLabel(
|
|
324
324
|
label === undefined ? undefined : decodeSlackHtmlEntities(label),
|
|
325
325
|
);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
function sanitizeOptionalLabel(label: string | undefined): string | undefined {
|
|
329
|
-
return sanitizeSlackLabel(label);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
328
|
function isSlackUserId(value: string): boolean {
|
|
333
329
|
return /^[UW][A-Z0-9]+$/.test(value);
|
|
334
330
|
}
|
package/package.json
CHANGED
|
@@ -338,7 +338,7 @@ describe("POST /v1/btw", () => {
|
|
|
338
338
|
expect(options!.config!.modelIntent).toBeUndefined();
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
-
test("greeting requests pass callSite: 'emptyStateGreeting'", async () => {
|
|
341
|
+
test("greeting requests pass callSite: 'emptyStateGreeting' and send no tools", async () => {
|
|
342
342
|
const provider = makeMockProvider();
|
|
343
343
|
const session = makeMockSession(provider);
|
|
344
344
|
mockGetOrCreateConversation.mockImplementationOnce(async () => session);
|
|
@@ -352,6 +352,9 @@ describe("POST /v1/btw", () => {
|
|
|
352
352
|
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
|
|
353
353
|
const [, options] = provider.sendMessage.mock.calls[0];
|
|
354
354
|
expect(options!.config!.callSite).toBe("emptyStateGreeting");
|
|
355
|
+
// The greeting targets no real conversation, so there is no cache prefix
|
|
356
|
+
// for tool definitions to share; they would only cost tokens.
|
|
357
|
+
expect(options!.tools).toEqual([]);
|
|
355
358
|
});
|
|
356
359
|
|
|
357
360
|
test("greeting requests include fresh turn context using the client timezone", async () => {
|
|
@@ -673,6 +673,35 @@ describe("injectChannelCapabilityContext", () => {
|
|
|
673
673
|
const text = (result.content[0] as { type: "text"; text: string }).text;
|
|
674
674
|
expect(text).not.toContain("Do NOT use markdown tables");
|
|
675
675
|
});
|
|
676
|
+
|
|
677
|
+
test("injects email send CLI constraint for email channel", () => {
|
|
678
|
+
const caps: ChannelCapabilities = {
|
|
679
|
+
channel: "email",
|
|
680
|
+
dashboardCapable: false,
|
|
681
|
+
supportsDynamicUi: false,
|
|
682
|
+
supportsVoiceInput: false,
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const result = injectChannelCapabilityContext(baseUserMessage, caps);
|
|
686
|
+
const text = (result.content[0] as { type: "text"; text: string }).text;
|
|
687
|
+
expect(text).toContain("Conversation text is not emailed");
|
|
688
|
+
expect(text).toContain("assistant email send");
|
|
689
|
+
expect(text).toContain("--reply-to");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("does NOT inject email send CLI constraint for non-email channels", () => {
|
|
693
|
+
const caps: ChannelCapabilities = {
|
|
694
|
+
channel: "telegram",
|
|
695
|
+
dashboardCapable: false,
|
|
696
|
+
supportsDynamicUi: false,
|
|
697
|
+
supportsVoiceInput: false,
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const result = injectChannelCapabilityContext(baseUserMessage, caps);
|
|
701
|
+
const text = (result.content[0] as { type: "text"; text: string }).text;
|
|
702
|
+
expect(text).not.toContain("Conversation text is not emailed");
|
|
703
|
+
expect(text).not.toContain("assistant email send");
|
|
704
|
+
});
|
|
676
705
|
});
|
|
677
706
|
|
|
678
707
|
// ---------------------------------------------------------------------------
|
|
@@ -95,7 +95,7 @@ function withBootstrapDir(dir: string): () => void {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
describe("
|
|
98
|
+
describe("CES discovery", () => {
|
|
99
99
|
test("returns unavailable when bootstrap socket does not exist", () => {
|
|
100
100
|
const bootstrapDir = mkdtempSync(join(tmpdir(), "ces-missing-"));
|
|
101
101
|
const restore = withBootstrapDir(bootstrapDir);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { createServer, type Server } from "node:net";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import {
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
test,
|
|
12
13
|
} from "bun:test";
|
|
13
14
|
|
|
15
|
+
import { resolveIpcEndpoint } from "@vellumai/ipc-server-utils";
|
|
16
|
+
|
|
14
17
|
// ---------------------------------------------------------------------------
|
|
15
18
|
// Mock logger (no-op — compatible with other test files' identical mock)
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
@@ -602,4 +605,43 @@ describe("secure-keys", () => {
|
|
|
602
605
|
expect(result.unreachable).toBe(true);
|
|
603
606
|
});
|
|
604
607
|
});
|
|
608
|
+
|
|
609
|
+
// -----------------------------------------------------------------------
|
|
610
|
+
// Session ownership: boot claims reconnect before identity reads
|
|
611
|
+
// -----------------------------------------------------------------------
|
|
612
|
+
describe("CES session owner skips a second open", () => {
|
|
613
|
+
test("a registered reconnect owner does not handshake a live CES socket", async () => {
|
|
614
|
+
const dir = mkdtempSync(join(tmpdir(), "ces-owner-"));
|
|
615
|
+
const socketPath = resolveIpcEndpoint("ces", { workspaceDir: dir }).path;
|
|
616
|
+
const savedDir = process.env.CES_BOOTSTRAP_SOCKET_DIR;
|
|
617
|
+
process.env.CES_BOOTSTRAP_SOCKET_DIR = dir;
|
|
618
|
+
|
|
619
|
+
const connections: Array<import("node:net").Socket> = [];
|
|
620
|
+
const server: Server = createServer((socket) => {
|
|
621
|
+
connections.push(socket);
|
|
622
|
+
socket.on("error", () => {});
|
|
623
|
+
});
|
|
624
|
+
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
setCesReconnect(async () => undefined);
|
|
628
|
+
const start = Date.now();
|
|
629
|
+
await getSecureKeyAsync("openai");
|
|
630
|
+
expect(Date.now() - start).toBeLessThan(1_000);
|
|
631
|
+
expect(getActiveBackendName()).toBe("encrypted-store");
|
|
632
|
+
expect(connections.length).toBe(0);
|
|
633
|
+
} finally {
|
|
634
|
+
for (const sock of connections) {
|
|
635
|
+
sock.destroy();
|
|
636
|
+
}
|
|
637
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
638
|
+
if (savedDir !== undefined) {
|
|
639
|
+
process.env.CES_BOOTSTRAP_SOCKET_DIR = savedDir;
|
|
640
|
+
} else {
|
|
641
|
+
delete process.env.CES_BOOTSTRAP_SOCKET_DIR;
|
|
642
|
+
}
|
|
643
|
+
rmSync(dir, { recursive: true, force: true });
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
});
|
|
605
647
|
});
|
|
@@ -12,10 +12,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { MemoryRetrospectiveConfigSchema } from "../schemas/memory-retrospective.js";
|
|
13
13
|
|
|
14
14
|
describe("memory.retrospective config schema", () => {
|
|
15
|
-
test("an empty block leaves
|
|
15
|
+
test("an empty block leaves skill improvement on and monitoring off", () => {
|
|
16
16
|
const parsed = MemoryRetrospectiveConfigSchema.parse({});
|
|
17
17
|
expect(parsed.enabled).toBe(true);
|
|
18
18
|
expect(parsed.skillImprovement).toBe(true);
|
|
19
|
+
expect(parsed.skillImprovementMonitoring).toBe(false);
|
|
19
20
|
expect(parsed).not.toHaveProperty("forkStrategy");
|
|
20
21
|
});
|
|
21
22
|
|
|
@@ -41,6 +42,18 @@ describe("memory.retrospective config schema", () => {
|
|
|
41
42
|
).toBe(false);
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
test("skillImprovementMonitoring is a boolean-only opt-in", () => {
|
|
46
|
+
const parsed = MemoryRetrospectiveConfigSchema.parse({
|
|
47
|
+
skillImprovementMonitoring: true,
|
|
48
|
+
});
|
|
49
|
+
expect(parsed.skillImprovementMonitoring).toBe(true);
|
|
50
|
+
expect(
|
|
51
|
+
MemoryRetrospectiveConfigSchema.safeParse({
|
|
52
|
+
skillImprovementMonitoring: "true",
|
|
53
|
+
}).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
44
57
|
test("a leftover forkStrategy key is ignored", () => {
|
|
45
58
|
const parsed = MemoryRetrospectiveConfigSchema.parse({
|
|
46
59
|
forkStrategy: "cloning",
|
|
@@ -165,11 +165,7 @@
|
|
|
165
165
|
},
|
|
166
166
|
"description": "Replacement steps (replaces all existing steps)"
|
|
167
167
|
}
|
|
168
|
-
}
|
|
169
|
-
"oneOf": [
|
|
170
|
-
{ "required": ["id"] },
|
|
171
|
-
{ "required": ["enrollment_id", "enrollment_action"] }
|
|
172
|
-
]
|
|
168
|
+
}
|
|
173
169
|
},
|
|
174
170
|
"executor": "tools/sequence-update.ts",
|
|
175
171
|
"execution_target": "host"
|
|
@@ -11,14 +11,23 @@ export const MemoryRetrospectiveConfigSchema = z
|
|
|
11
11
|
|
|
12
12
|
skillImprovement: z
|
|
13
13
|
.boolean({
|
|
14
|
-
error:
|
|
15
|
-
"memory.retrospective.skillImprovement must be a boolean",
|
|
14
|
+
error: "memory.retrospective.skillImprovement must be a boolean",
|
|
16
15
|
})
|
|
17
16
|
.default(true)
|
|
18
17
|
.describe(
|
|
19
18
|
"Whether retrospectives may discover, refine, and create managed skills from observed procedures. When false, retrospectives still capture ordinary memories through `remember`, but cannot load skill management, search for similar skills, or scaffold managed skills.",
|
|
20
19
|
),
|
|
21
20
|
|
|
21
|
+
skillImprovementMonitoring: z
|
|
22
|
+
.boolean({
|
|
23
|
+
error:
|
|
24
|
+
"memory.retrospective.skillImprovementMonitoring must be a boolean",
|
|
25
|
+
})
|
|
26
|
+
.default(false)
|
|
27
|
+
.describe(
|
|
28
|
+
"Reserved opt-in for monitoring retrospective skill-improvement decisions. This setting currently has no effect.",
|
|
29
|
+
),
|
|
30
|
+
|
|
22
31
|
timeThresholdMs: z
|
|
23
32
|
.number({
|
|
24
33
|
error: "memory.retrospective.timeThresholdMs must be a number",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared CES RPC session helper used by assistant boot and
|
|
3
|
+
* child-process credential resolution.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { openCesRpcSession, reconnectCesRpcSession } from "./ces-connect.js";
|
|
8
|
+
import { createCesProcessManager } from "./process-manager.js";
|
|
9
|
+
|
|
10
|
+
describe("openCesRpcSession", () => {
|
|
11
|
+
test("returns undefined when discovery fails without polling", async () => {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
const pm = createCesProcessManager({
|
|
14
|
+
discover: async () => ({
|
|
15
|
+
mode: "unavailable",
|
|
16
|
+
reason: "missing test socket",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const session = await openCesRpcSession({ processManager: pm });
|
|
21
|
+
|
|
22
|
+
expect(session).toBeUndefined();
|
|
23
|
+
expect(pm.isRunning()).toBe(false);
|
|
24
|
+
expect(Date.now() - start).toBeLessThan(1_000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns undefined when the abort signal is already fired", async () => {
|
|
28
|
+
const abort = new AbortController();
|
|
29
|
+
abort.abort();
|
|
30
|
+
const pm = createCesProcessManager({
|
|
31
|
+
discover: async () => {
|
|
32
|
+
throw new Error("discover should not run after abort");
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const session = await openCesRpcSession({
|
|
37
|
+
processManager: pm,
|
|
38
|
+
signal: abort.signal,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(session).toBeUndefined();
|
|
42
|
+
expect(pm.isRunning()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("creates a process manager when none is provided", async () => {
|
|
46
|
+
const session = await openCesRpcSession({
|
|
47
|
+
discover: async () => ({
|
|
48
|
+
mode: "unavailable",
|
|
49
|
+
reason: "missing test socket",
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(session).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("reconnectCesRpcSession stops the manager and returns undefined when discovery fails", async () => {
|
|
57
|
+
const pm = createCesProcessManager({
|
|
58
|
+
discover: async () => ({
|
|
59
|
+
mode: "unavailable",
|
|
60
|
+
reason: "missing test socket",
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const client = await reconnectCesRpcSession(pm);
|
|
65
|
+
|
|
66
|
+
expect(client).toBeUndefined();
|
|
67
|
+
expect(pm.isRunning()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a CES RPC client over the shared bootstrap socket.
|
|
3
|
+
*
|
|
4
|
+
* Assistant boot and child-process credential reads use this helper. Both
|
|
5
|
+
* are CES API clients: discover `ces.sock`, connect, handshake, reconnect.
|
|
6
|
+
*
|
|
7
|
+
* They stay two entry points because boot must load handshake identity
|
|
8
|
+
* without talking to CES, then hand that identity to CES. Children open a
|
|
9
|
+
* session only when this process has not already claimed one (no live
|
|
10
|
+
* client and no reconnect owner).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AssistantConfig } from "../config/schema.js";
|
|
14
|
+
import { getLogger } from "../util/logger.js";
|
|
15
|
+
import {
|
|
16
|
+
type CesClient,
|
|
17
|
+
type CesClientHandshakeOptions,
|
|
18
|
+
createCesClient,
|
|
19
|
+
} from "./client.js";
|
|
20
|
+
import {
|
|
21
|
+
type CesProcessManager,
|
|
22
|
+
type CesProcessManagerConfig,
|
|
23
|
+
CesUnavailableError,
|
|
24
|
+
createCesProcessManager,
|
|
25
|
+
} from "./process-manager.js";
|
|
26
|
+
|
|
27
|
+
const log = getLogger("ces-connect");
|
|
28
|
+
|
|
29
|
+
export interface CesRpcSession {
|
|
30
|
+
client: CesClient;
|
|
31
|
+
processManager: CesProcessManager;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OpenCesRpcSessionOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Reuse an existing process manager (reconnect after `stop()`). When
|
|
37
|
+
* omitted, a new manager is created.
|
|
38
|
+
*/
|
|
39
|
+
processManager?: CesProcessManager;
|
|
40
|
+
handshake?: CesClientHandshakeOptions;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
assistantConfig?: AssistantConfig;
|
|
43
|
+
discover?: CesProcessManagerConfig["discover"];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Discover the CES socket, connect, and complete the RPC handshake.
|
|
48
|
+
*
|
|
49
|
+
* Returns undefined when CES is missing, the handshake is rejected, the
|
|
50
|
+
* abort signal fires, or the transport fails. The process manager is
|
|
51
|
+
* stopped on those paths so a later reconnect can call `start()` again.
|
|
52
|
+
*/
|
|
53
|
+
export async function openCesRpcSession(
|
|
54
|
+
options: OpenCesRpcSessionOptions = {},
|
|
55
|
+
): Promise<CesRpcSession | undefined> {
|
|
56
|
+
const pm =
|
|
57
|
+
options.processManager ??
|
|
58
|
+
createCesProcessManager({
|
|
59
|
+
assistantConfig: options.assistantConfig,
|
|
60
|
+
discover: options.discover,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const fail = async (): Promise<undefined> => {
|
|
64
|
+
await pm.stop().catch(() => {});
|
|
65
|
+
return undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (options.signal?.aborted) {
|
|
69
|
+
return fail();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const transport = await pm.start();
|
|
74
|
+
if (options.signal?.aborted) {
|
|
75
|
+
return fail();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const client = createCesClient(transport);
|
|
79
|
+
const { accepted, reason } = await client.handshake(options.handshake);
|
|
80
|
+
if (options.signal?.aborted) {
|
|
81
|
+
client.close();
|
|
82
|
+
return fail();
|
|
83
|
+
}
|
|
84
|
+
if (!accepted) {
|
|
85
|
+
log.warn({ reason }, "CES handshake rejected");
|
|
86
|
+
client.close();
|
|
87
|
+
return fail();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { client, processManager: pm };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof CesUnavailableError) {
|
|
93
|
+
log.info({ reason: err.message }, "CES is not available");
|
|
94
|
+
} else {
|
|
95
|
+
log.warn(
|
|
96
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
97
|
+
"Failed to open CES RPC session",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return fail();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop the current transport and open a new session on the same process
|
|
106
|
+
* manager. Used by assistant boot and child-process reconnect callbacks.
|
|
107
|
+
*/
|
|
108
|
+
export async function reconnectCesRpcSession(
|
|
109
|
+
processManager: CesProcessManager,
|
|
110
|
+
handshake?: CesClientHandshakeOptions,
|
|
111
|
+
): Promise<CesClient | undefined> {
|
|
112
|
+
await processManager.stop();
|
|
113
|
+
const session = await openCesRpcSession({ processManager, handshake });
|
|
114
|
+
return session?.client;
|
|
115
|
+
}
|
|
@@ -9,10 +9,16 @@ import {
|
|
|
9
9
|
setCesReconnect,
|
|
10
10
|
} from "../security/secure-keys.js";
|
|
11
11
|
import { getLogger } from "../util/logger.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
openCesRpcSession,
|
|
14
|
+
reconnectCesRpcSession,
|
|
15
|
+
} from "./ces-connect.js";
|
|
16
|
+
import {
|
|
17
|
+
type CesClient,
|
|
18
|
+
type CesClientHandshakeOptions,
|
|
19
|
+
} from "./client.js";
|
|
13
20
|
import {
|
|
14
21
|
type CesProcessManager,
|
|
15
|
-
CesUnavailableError,
|
|
16
22
|
createCesProcessManager,
|
|
17
23
|
} from "./process-manager.js";
|
|
18
24
|
import {
|
|
@@ -45,70 +51,69 @@ interface CesStartupResult {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
|
-
*
|
|
49
|
-
* handles to the in-flight initialization
|
|
50
|
-
* for startup to continue.
|
|
54
|
+
* Open the assistant's CES RPC client and perform the handshake. Returns
|
|
55
|
+
* immediately with handles to the in-flight initialization: callers don't
|
|
56
|
+
* need to await this for startup to continue.
|
|
51
57
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
58
|
+
* Claims reconnect ownership before any credential read so boot identity
|
|
59
|
+
* loading cannot open a second, identity-less session through the child
|
|
60
|
+
* entry point. CES serves a multi-connection Unix socket, so child
|
|
61
|
+
* processes in other address spaces still open their own connections.
|
|
54
62
|
*/
|
|
55
63
|
function startCesProcess(config: AssistantConfig): CesStartupResult {
|
|
56
64
|
const pm = createCesProcessManager({ assistantConfig: config });
|
|
57
65
|
const abortController = new AbortController();
|
|
58
66
|
let currentClient: CesClient | undefined;
|
|
67
|
+
let handshake: CesClientHandshakeOptions = {};
|
|
68
|
+
|
|
69
|
+
// Own this process's CES session before resolveManagedProxyContext()
|
|
70
|
+
// reads the API key. That read must not take the child open path.
|
|
71
|
+
setCesReconnect(async () => {
|
|
72
|
+
const client = await reconnectCesRpcSession(pm, handshake);
|
|
73
|
+
if (client) {
|
|
74
|
+
log.info("CES reconnection handshake accepted");
|
|
75
|
+
}
|
|
76
|
+
return client;
|
|
77
|
+
});
|
|
59
78
|
|
|
60
79
|
const handshakePromise = (async (): Promise<CesClient | undefined> => {
|
|
61
80
|
try {
|
|
62
|
-
const transport = await pm.start();
|
|
63
|
-
if (abortController.signal.aborted) {
|
|
64
|
-
throw new Error("CES initialization aborted during shutdown");
|
|
65
|
-
}
|
|
66
|
-
const client = createCesClient(transport);
|
|
67
|
-
currentClient = client;
|
|
68
81
|
// Resolve the assistant API key so CES can use it for platform
|
|
69
82
|
// credential materialisation. In managed mode the key is provisioned
|
|
70
|
-
// after hatch and stored in the credential store
|
|
71
|
-
// the env var, so we pass it via the handshake.
|
|
83
|
+
// after hatch and stored in the credential store. CES can't read
|
|
84
|
+
// the env var, so we pass it via the handshake. Reconnect ownership
|
|
85
|
+
// is already claimed, so this read uses HTTP or the encrypted store,
|
|
86
|
+
// not a second RPC session.
|
|
72
87
|
const proxyCtx = await resolveManagedProxyContext();
|
|
88
|
+
if (abortController.signal.aborted) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
73
91
|
const assistantId = getPlatformAssistantId();
|
|
74
|
-
|
|
92
|
+
handshake = {
|
|
75
93
|
...(proxyCtx.assistantApiKey
|
|
76
94
|
? { assistantApiKey: proxyCtx.assistantApiKey }
|
|
77
95
|
: {}),
|
|
78
96
|
...(assistantId ? { assistantId } : {}),
|
|
97
|
+
};
|
|
98
|
+
const session = await openCesRpcSession({
|
|
99
|
+
processManager: pm,
|
|
100
|
+
signal: abortController.signal,
|
|
101
|
+
handshake,
|
|
79
102
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
throw new Error("CES initialization aborted during shutdown");
|
|
83
|
-
}
|
|
84
|
-
if (accepted) {
|
|
103
|
+
currentClient = session?.client;
|
|
104
|
+
if (session) {
|
|
85
105
|
log.info(
|
|
86
106
|
"CES client initialized and handshake accepted (server-level)",
|
|
87
107
|
);
|
|
88
|
-
return client;
|
|
89
108
|
}
|
|
109
|
+
return session?.client;
|
|
110
|
+
} catch (err) {
|
|
90
111
|
log.warn(
|
|
91
|
-
{
|
|
92
|
-
"
|
|
112
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
113
|
+
"Failed to initialize CES client",
|
|
93
114
|
);
|
|
94
|
-
client.close();
|
|
95
115
|
currentClient = undefined;
|
|
96
|
-
await pm.stop();
|
|
97
|
-
return undefined;
|
|
98
|
-
} catch (err) {
|
|
99
|
-
if (err instanceof CesUnavailableError) {
|
|
100
|
-
log.info(
|
|
101
|
-
{ reason: err.message },
|
|
102
|
-
"CES is not available — CES tools will be unavailable",
|
|
103
|
-
);
|
|
104
|
-
} else {
|
|
105
|
-
log.warn(
|
|
106
|
-
{ error: err instanceof Error ? err.message : String(err) },
|
|
107
|
-
"Failed to initialize CES client — CES tools will be unavailable",
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
116
|
await pm.stop().catch(() => {});
|
|
111
|
-
currentClient = undefined;
|
|
112
117
|
return undefined;
|
|
113
118
|
}
|
|
114
119
|
})();
|
|
@@ -154,11 +159,11 @@ function updateClientRef(client: CesClient | undefined): void {
|
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* direct credential store.
|
|
162
|
+
* Open the assistant's CES RPC client: handshake (blocking up to a 20s
|
|
163
|
+
* timeout so credential reads can route through CES before provider init)
|
|
164
|
+
* and keep the live client reference in sync. Reconnect ownership is
|
|
165
|
+
* claimed before the identity read. Non-fatal: on failure the assistant
|
|
166
|
+
* falls back to the direct credential store.
|
|
162
167
|
*/
|
|
163
168
|
export async function startCes(config: AssistantConfig): Promise<void> {
|
|
164
169
|
const cesResult = startCesProcess(config);
|
|
@@ -171,7 +176,7 @@ export async function startCes(config: AssistantConfig): Promise<void> {
|
|
|
171
176
|
timeoutMs: DEFAULT_CES_STARTUP_TIMEOUT_MS,
|
|
172
177
|
onTimeout: () => {
|
|
173
178
|
log.warn(
|
|
174
|
-
"CES handshake timed out after 20s
|
|
179
|
+
"CES handshake timed out after 20s, falling back to direct credential store",
|
|
175
180
|
);
|
|
176
181
|
},
|
|
177
182
|
});
|
|
@@ -193,56 +198,13 @@ export async function startCes(config: AssistantConfig): Promise<void> {
|
|
|
193
198
|
}
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
201
|
+
// Reconnect ownership is claimed inside startCesProcess before the
|
|
202
|
+
// identity read. Snapshotting the API key there (not here) avoids a
|
|
203
|
+
// second resolveManagedProxyContext() after setCesClient, which would
|
|
204
|
+
// read the key through CES while reconnecting.
|
|
199
205
|
if (cesResult.processManager) {
|
|
200
206
|
const pm = cesResult.processManager;
|
|
201
207
|
|
|
202
|
-
// Snapshot the managed-proxy context and assistant ID at CES startup so the
|
|
203
|
-
// reconnect closure below never calls back into `resolveManagedProxyContext()`.
|
|
204
|
-
// That function reads the assistant API key via `getSecureKeyAsync()`, which
|
|
205
|
-
// — once `setCesClient()` has resolved the backend to CES RPC — routes the
|
|
206
|
-
// read through CES itself. During a reconnect the old transport is dead and
|
|
207
|
-
// a new one is being set up by this very closure, so the nested credential
|
|
208
|
-
// read recursively awaits its own in-flight reconnection and deadlocks until
|
|
209
|
-
// `CREDENTIAL_OP_TIMEOUT_MS` (45s) fires. That 45-second stall delays every
|
|
210
|
-
// CES restart and causes dependent credential reads (e.g. Meet's STT
|
|
211
|
-
// provider resolution) to return `undefined` during the window. API key
|
|
212
|
-
// rotation uses the `updateAssistantApiKey` RPC on the live client, not a
|
|
213
|
-
// reconnect, so caching at startup is safe.
|
|
214
|
-
const startupProxyCtx = await resolveManagedProxyContext();
|
|
215
|
-
const startupAssistantId = getPlatformAssistantId();
|
|
216
|
-
|
|
217
|
-
setCesReconnect(async () => {
|
|
218
|
-
try {
|
|
219
|
-
await pm.stop();
|
|
220
|
-
const transport = await pm.start();
|
|
221
|
-
const newClient = createCesClient(transport);
|
|
222
|
-
const { accepted, reason } = await newClient.handshake({
|
|
223
|
-
...(startupProxyCtx.assistantApiKey
|
|
224
|
-
? { assistantApiKey: startupProxyCtx.assistantApiKey }
|
|
225
|
-
: {}),
|
|
226
|
-
...(startupAssistantId ? { assistantId: startupAssistantId } : {}),
|
|
227
|
-
});
|
|
228
|
-
if (accepted) {
|
|
229
|
-
log.info("CES reconnection handshake accepted");
|
|
230
|
-
return newClient;
|
|
231
|
-
}
|
|
232
|
-
log.warn({ reason }, "CES reconnection handshake rejected");
|
|
233
|
-
newClient.close();
|
|
234
|
-
await pm.stop().catch(() => {});
|
|
235
|
-
return undefined;
|
|
236
|
-
} catch (err) {
|
|
237
|
-
log.warn(
|
|
238
|
-
{ error: err instanceof Error ? err.message : String(err) },
|
|
239
|
-
"CES reconnection attempt failed",
|
|
240
|
-
);
|
|
241
|
-
await pm.stop().catch(() => {});
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
|
|
246
208
|
// Proactive reconnect: when the transport dies (socket close, process
|
|
247
209
|
// exit), start a retry-with-backoff loop immediately instead of waiting
|
|
248
210
|
// for the next credential operation to trigger the lazy reconnect path.
|
|
@@ -903,6 +903,11 @@ export function buildChannelCapabilityBlock(
|
|
|
903
903
|
"- Do NOT use markdown tables — use bullet lists instead. No markdown headers — use **bold** or CAPS for emphasis.",
|
|
904
904
|
);
|
|
905
905
|
}
|
|
906
|
+
if (caps.channel === "email") {
|
|
907
|
+
lines.push(
|
|
908
|
+
"- Conversation text is not emailed. To reply, run `assistant email send` (see `assistant email send --help`). Use `--reply-to` to keep the thread. Skip a reply only when none is needed.",
|
|
909
|
+
);
|
|
910
|
+
}
|
|
906
911
|
}
|
|
907
912
|
|
|
908
913
|
// Inject group chat etiquette only when the chat type indicates a multi-party
|
|
@@ -2035,20 +2040,6 @@ export async function composeInjectorChain(ctx: TurnContext): Promise<string> {
|
|
|
2035
2040
|
*/
|
|
2036
2041
|
const DEFAULT_PLACEMENT: InjectionPlacement = "append-user-tail";
|
|
2037
2042
|
|
|
2038
|
-
/**
|
|
2039
|
-
* Count leading memory-prefix blocks on a user message's `content`.
|
|
2040
|
-
*
|
|
2041
|
-
* Delegates to {@link countMemoryPrefixBlocks} from
|
|
2042
|
-
* `memory/graph/conversation-graph-memory.js` — the canonical state-machine
|
|
2043
|
-
* for locating the memory-prefix boundary. Reusing it here keeps the
|
|
2044
|
-
* PKB-context / PKB-reminder / NOW splice rules aligned on a single source
|
|
2045
|
-
* of truth so their ordering relative to any memory prefix is stable and
|
|
2046
|
-
* testable.
|
|
2047
|
-
*/
|
|
2048
|
-
function countMemoryPrefixBlocksOnContent(content: ContentBlock[]): number {
|
|
2049
|
-
return countMemoryPrefixBlocks(content);
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
2043
|
/**
|
|
2053
2044
|
* Apply one injector block to a `runMessages` array according to its
|
|
2054
2045
|
* declared {@link InjectionPlacement}:
|
|
@@ -2101,9 +2092,7 @@ function applyInjectionBlock(
|
|
|
2101
2092
|
{ ...userTail, content: [...userTail.content, textBlock] },
|
|
2102
2093
|
];
|
|
2103
2094
|
case "after-memory-prefix": {
|
|
2104
|
-
const memoryPrefixCount =
|
|
2105
|
-
userTail.content,
|
|
2106
|
-
);
|
|
2095
|
+
const memoryPrefixCount = countMemoryPrefixBlocks(userTail.content);
|
|
2107
2096
|
return [
|
|
2108
2097
|
...runMessages.slice(0, -1),
|
|
2109
2098
|
{
|
|
@@ -2161,7 +2150,7 @@ function stripTailV2DynamicMemoryPrefix(
|
|
|
2161
2150
|
if (!last || last.role !== "user") {
|
|
2162
2151
|
return messages;
|
|
2163
2152
|
}
|
|
2164
|
-
const prefixCount =
|
|
2153
|
+
const prefixCount = countMemoryPrefixBlocks(last.content);
|
|
2165
2154
|
if (prefixCount === 0) {
|
|
2166
2155
|
return messages;
|
|
2167
2156
|
}
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -673,11 +673,11 @@ export async function runDaemon(): Promise<void> {
|
|
|
673
673
|
// blocked.
|
|
674
674
|
startConsentRefresh();
|
|
675
675
|
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
676
|
+
// Open the assistant's CES RPC client (handshake + reconnect wiring).
|
|
677
|
+
// Blocks up to a 20s timeout so credential reads route through CES before
|
|
678
|
+
// provider init; non-fatal, falls back to the direct credential store on
|
|
679
|
+
// failure. CES serves a multi-connection bootstrap socket, so child
|
|
680
|
+
// processes can open the same `openCesRpcSession` path independently.
|
|
681
681
|
await startCes(config);
|
|
682
682
|
|
|
683
683
|
// Bring up the plugin layer: install the runtime bridge, register the
|
|
@@ -577,6 +577,12 @@ export function isProviderErrorMetadata(
|
|
|
577
577
|
* assistant rows, and turn grouping closes on them, so display merging and
|
|
578
578
|
* the turn resolver agree on boundaries. Takes the raw persisted `metadata`
|
|
579
579
|
* JSON string; malformed JSON and non-assistant roles are never standalone.
|
|
580
|
+
*
|
|
581
|
+
* The web folds adjacent assistant rows again after pagination and reads the
|
|
582
|
+
* same rule off the wire projection in its own `isStandaloneAssistantMessage`
|
|
583
|
+
* (clients/web/src/domains/chat/utils/is-standalone-assistant-message.ts). A
|
|
584
|
+
* kind added here without a matching flag and check there merges on the
|
|
585
|
+
* client anyway.
|
|
580
586
|
*/
|
|
581
587
|
export function isStandaloneAssistantMessage(
|
|
582
588
|
role: string,
|
|
@@ -955,19 +955,6 @@ function likeContainsPattern(query: string): string {
|
|
|
955
955
|
.replace(/_/g, "\\_")}%`;
|
|
956
956
|
}
|
|
957
957
|
|
|
958
|
-
/**
|
|
959
|
-
* Whether the sparse Qdrant `messages_lexical` index — the only source of
|
|
960
|
-
* message-content matches — is a safe read source. Content matching is
|
|
961
|
-
* unavailable (title matches only) until the one-time upgrade backfill has
|
|
962
|
-
* fully drained: a partially populated collection would silently miss older
|
|
963
|
-
* content (an empty result — not a throw). Indexing itself is unconditional
|
|
964
|
-
* host infrastructure, so completion is the only gate; the recall read site
|
|
965
|
-
* applies the same one via the shared {@link isLexicalBackfillComplete}.
|
|
966
|
-
*/
|
|
967
|
-
function isMessageContentSearchAvailable(): boolean {
|
|
968
|
-
return isLexicalBackfillComplete();
|
|
969
|
-
}
|
|
970
|
-
|
|
971
958
|
/**
|
|
972
959
|
* Full-text search across message content.
|
|
973
960
|
*
|
|
@@ -976,9 +963,9 @@ function isMessageContentSearchAvailable(): boolean {
|
|
|
976
963
|
* merged with a `LIKE` match on conversation titles; matching conversations
|
|
977
964
|
* return with their relevant messages, ordered by most recently updated.
|
|
978
965
|
*
|
|
979
|
-
* Content matching is index-only
|
|
966
|
+
* Content matching is index-only: there is no `messages.content` scan
|
|
980
967
|
* fallback and no other content source. Only the title arm can match while
|
|
981
|
-
* the index is not a safe read source ({@link
|
|
968
|
+
* the index is not a safe read source ({@link isLexicalBackfillComplete}),
|
|
982
969
|
* for a query that tokenizes to nothing under the shared tokenizer (non-ASCII
|
|
983
970
|
* or single-char input like "你", "é", "C++"), or when the Qdrant lexical
|
|
984
971
|
* lookup fails (logged). An unindexed or unreachable index yields fewer
|
|
@@ -1017,7 +1004,7 @@ export async function searchConversations(
|
|
|
1017
1004
|
const maxMsgsPerConv = opts?.maxMessagesPerConversation ?? 3;
|
|
1018
1005
|
|
|
1019
1006
|
const hasTokens = hasLexicalTokens(trimmed);
|
|
1020
|
-
const contentSearchAvailable =
|
|
1007
|
+
const contentSearchAvailable = isLexicalBackfillComplete();
|
|
1021
1008
|
|
|
1022
1009
|
// LIKE pattern for title matching (message-content indexes don't cover titles).
|
|
1023
1010
|
const titlePattern = likeContainsPattern(query);
|
|
@@ -388,7 +388,7 @@ export async function routeGuardianReply(
|
|
|
388
388
|
const request = await getGuardianRequestOrNull(answerTap.requestId);
|
|
389
389
|
if (
|
|
390
390
|
request &&
|
|
391
|
-
|
|
391
|
+
resolveGuardianInstructionModeForRequest(request) === "answer" &&
|
|
392
392
|
parseQuestionAnswerActionId(answerTap.token) &&
|
|
393
393
|
!request.callSessionId &&
|
|
394
394
|
hasLiveQuestionInteraction(request.id)
|
|
@@ -581,7 +581,7 @@ export async function routeGuardianReply(
|
|
|
581
581
|
if (messageText.length > 0 && pendingRequests.length === 1) {
|
|
582
582
|
const soleRequest = pendingRequests[0];
|
|
583
583
|
if (
|
|
584
|
-
|
|
584
|
+
resolveGuardianInstructionModeForRequest(soleRequest) === "answer" &&
|
|
585
585
|
!soleRequest.callSessionId &&
|
|
586
586
|
soleRequest.sourceConversationId === conversationId &&
|
|
587
587
|
hasLiveQuestionInteraction(soleRequest.id)
|
|
@@ -1005,12 +1005,6 @@ function inferActionFromText(
|
|
|
1005
1005
|
return "approve_once";
|
|
1006
1006
|
}
|
|
1007
1007
|
|
|
1008
|
-
function resolveRequestInstructionMode(
|
|
1009
|
-
request?: Pick<GuardianRequestWire, "kind" | "toolName"> | null,
|
|
1010
|
-
): "approval" | "answer" {
|
|
1011
|
-
return resolveGuardianInstructionModeForRequest(request);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
1008
|
// ---------------------------------------------------------------------------
|
|
1015
1009
|
// Failure reason reply text
|
|
1016
1010
|
// ---------------------------------------------------------------------------
|
|
@@ -1044,7 +1038,7 @@ function failureReplyText(
|
|
|
1044
1038
|
return "Something went wrong with this request on our end, so I couldn't apply your decision.";
|
|
1045
1039
|
case "invalid_action":
|
|
1046
1040
|
return buildGuardianInvalidActionReply(
|
|
1047
|
-
|
|
1041
|
+
resolveGuardianInstructionModeForRequest(request),
|
|
1048
1042
|
requestCode ?? undefined,
|
|
1049
1043
|
);
|
|
1050
1044
|
default:
|
|
@@ -1063,7 +1057,7 @@ function failureReplyText(
|
|
|
1063
1057
|
*/
|
|
1064
1058
|
function composeCodeOnlyClarification(request: GuardianRequestWire): string {
|
|
1065
1059
|
const code = request.requestCode ?? "unknown";
|
|
1066
|
-
const mode =
|
|
1060
|
+
const mode = resolveGuardianInstructionModeForRequest(request);
|
|
1067
1061
|
return buildGuardianCodeOnlyClarification(mode, {
|
|
1068
1062
|
requestCode: code,
|
|
1069
1063
|
questionText: request.questionText,
|
|
@@ -1087,7 +1081,7 @@ function composeDisambiguationReply(
|
|
|
1087
1081
|
const lines: string[] = [];
|
|
1088
1082
|
const requestsWithMode = pendingRequests.map((request) => ({
|
|
1089
1083
|
request,
|
|
1090
|
-
mode:
|
|
1084
|
+
mode: resolveGuardianInstructionModeForRequest(request),
|
|
1091
1085
|
}));
|
|
1092
1086
|
|
|
1093
1087
|
if (engineReplyText) {
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Route handler for the POST /v1/btw SSE-streaming side-chain endpoint.
|
|
3
3
|
*
|
|
4
4
|
* Runs an ephemeral LLM call that reuses the conversation's provider, tool
|
|
5
|
-
* definitions, and message history for prompt-cache efficiency
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* definitions, and message history for prompt-cache efficiency; the
|
|
6
|
+
* empty-state greeting targets no real conversation and sends no tools. Uses
|
|
7
|
+
* the conversation's system prompt when a conversation-specific override is
|
|
8
|
+
* active; otherwise builds a fresh prompt excluding BOOTSTRAP.md so first-run
|
|
8
9
|
* onboarding instructions don't leak into cosmetic UI calls like identity
|
|
9
10
|
* intro generation. The response is streamed as SSE events (`btw_text_delta`,
|
|
10
11
|
* `btw_complete`, `btw_error`).
|
|
@@ -145,7 +146,11 @@ async function handleBtw({
|
|
|
145
146
|
const result = await runBtwSidechain({
|
|
146
147
|
content: effectiveContent,
|
|
147
148
|
conversation,
|
|
148
|
-
|
|
149
|
+
// The side-chain forces `tool_choice: none`, so tool definitions
|
|
150
|
+
// only earn their tokens as a shared cache prefix with a real
|
|
151
|
+
// conversation. The greeting runs against an ephemeral one with its
|
|
152
|
+
// own system prompt, so it shares nothing and sends no tools.
|
|
153
|
+
tools: isGreeting ? [] : getAllToolDefinitions(),
|
|
149
154
|
signal: abortSignal,
|
|
150
155
|
...(isGreeting ? { callSite: "emptyStateGreeting" as const } : {}),
|
|
151
156
|
onEvent: (event) => {
|
|
@@ -228,7 +228,7 @@ function getIdentity() {
|
|
|
228
228
|
|
|
229
229
|
const version = APP_VERSION;
|
|
230
230
|
|
|
231
|
-
const createdAt =
|
|
231
|
+
const createdAt = resolveHatchedAtReadOnly(identityPath);
|
|
232
232
|
|
|
233
233
|
return {
|
|
234
234
|
name: fields.name ?? "",
|
|
@@ -241,10 +241,6 @@ function getIdentity() {
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
function resolveIdentityCreatedAt(identityPath: string): string | undefined {
|
|
245
|
-
return resolveHatchedAtReadOnly(identityPath);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
244
|
// ---------------------------------------------------------------------------
|
|
249
245
|
// Zod schemas for profiler health metadata
|
|
250
246
|
// ---------------------------------------------------------------------------
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified secure key storage
|
|
2
|
+
* Unified secure key storage: single-backend routing through CredentialBackend
|
|
3
3
|
* adapters.
|
|
4
4
|
*
|
|
5
5
|
* Backend selection (`resolveBackendAsync`) is the single async decision point:
|
|
6
|
-
* 1. CES RPC (primary) -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* resolution they discover the CES bootstrap socket
|
|
13
|
-
* (`CES_BOOTSTRAP_SOCKET_DIR`) and cache
|
|
14
|
-
* the connection.
|
|
15
|
-
* 3. CES HTTP - containerized failover when IPC is unavailable
|
|
6
|
+
* 1. CES RPC (primary) - connect to the CES bootstrap socket and talk RPC.
|
|
7
|
+
* `openCesRpcSession` is the one client constructor. The assistant
|
|
8
|
+
* claims session ownership at boot (`startCes`) and hands CES the
|
|
9
|
+
* assistant API key. Child processes open a session on first credential
|
|
10
|
+
* read only when this process has no client and no reconnect owner.
|
|
11
|
+
* 2. CES HTTP - containerized failover when IPC is unavailable
|
|
16
12
|
* (`IS_CONTAINERIZED` + `CES_CREDENTIAL_URL`). Used if the assistant's
|
|
17
13
|
* bootstrap RPC transport is down, or if a process with HTTP env could
|
|
18
14
|
* not open the socket.
|
|
19
|
-
*
|
|
15
|
+
* 3. Encrypted file store (fallback) - used when CES is unavailable locally.
|
|
20
16
|
*
|
|
21
17
|
* All operations (reads, writes, lists, deletes) go to exactly one backend.
|
|
22
18
|
* There are no cross-store fallbacks or merges. The only transport failover is
|
|
@@ -35,14 +31,11 @@ import type {
|
|
|
35
31
|
|
|
36
32
|
import { getIsContainerized } from "../config/env-registry.js";
|
|
37
33
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from "../credential-execution/
|
|
34
|
+
openCesRpcSession,
|
|
35
|
+
reconnectCesRpcSession,
|
|
36
|
+
} from "../credential-execution/ces-connect.js";
|
|
37
|
+
import { type CesClient } from "../credential-execution/client.js";
|
|
41
38
|
import { discoverCes } from "../credential-execution/executable-discovery.js";
|
|
42
|
-
import {
|
|
43
|
-
CesUnavailableError,
|
|
44
|
-
createCesProcessManager,
|
|
45
|
-
} from "../credential-execution/process-manager.js";
|
|
46
39
|
import { getAnyProviderEnvVar } from "../providers/provider-env-vars.js";
|
|
47
40
|
import { getLogger } from "../util/logger.js";
|
|
48
41
|
import { getProtectedDir } from "../util/platform.js";
|
|
@@ -85,11 +78,11 @@ let _resolvedBackend: CredentialBackend | undefined;
|
|
|
85
78
|
let _resolvePromise: Promise<CredentialBackend> | undefined;
|
|
86
79
|
|
|
87
80
|
/**
|
|
88
|
-
* In-flight
|
|
81
|
+
* In-flight CES RPC session promise for processes that did not call startCes().
|
|
89
82
|
*
|
|
90
83
|
* Workers and CLI subprocesses never call startCes(). When they hit
|
|
91
84
|
* resolveBackendAsync() with no _cesClient and no _cesReconnect
|
|
92
|
-
* (
|
|
85
|
+
* (assistant-boot only), this promise memoizes `openCesRpcSession` so
|
|
93
86
|
* concurrent credential reads in the same process share a single
|
|
94
87
|
* connect+handshake rather than racing.
|
|
95
88
|
*/
|
|
@@ -206,10 +199,9 @@ function getEncryptedStoreBackend(): CredentialBackend {
|
|
|
206
199
|
* Resolve the primary credential backend for this process (async).
|
|
207
200
|
*
|
|
208
201
|
* Priority:
|
|
209
|
-
* 1. CES RPC client
|
|
210
|
-
* 2.
|
|
211
|
-
* 3.
|
|
212
|
-
* 4. Encrypted file store: local fallback when CES is unavailable.
|
|
202
|
+
* 1. CES RPC: live client, or open one via `openCesRpcSession`.
|
|
203
|
+
* 2. Containerized + CES_CREDENTIAL_URL: CES HTTP, only if IPC is down.
|
|
204
|
+
* 3. Encrypted file store: local fallback when CES is unavailable.
|
|
213
205
|
*
|
|
214
206
|
* Once resolved, the backend is cached. If it becomes unavailable (e.g. the
|
|
215
207
|
* CES transport dies), we attempt to reconnect via `_cesReconnect` rather
|
|
@@ -415,19 +407,19 @@ export async function attemptCesReconnection(
|
|
|
415
407
|
}
|
|
416
408
|
|
|
417
409
|
/**
|
|
418
|
-
*
|
|
410
|
+
* Open a CES RPC session from a process that did not call startCes().
|
|
419
411
|
*
|
|
420
412
|
* Workers and CLI subprocesses never call startCes(). This function
|
|
421
|
-
*
|
|
413
|
+
* opens the same `openCesRpcSession` path the assistant uses at boot,
|
|
422
414
|
* memoizing the in-flight promise so concurrent callers share a single
|
|
423
415
|
* connect+handshake. Discovery uses the shared CES bootstrap socket; a
|
|
424
416
|
* missing socket fails immediately so callers can fall through without
|
|
425
417
|
* polling.
|
|
426
418
|
*
|
|
427
419
|
* On success, the client is injected via setCesClient() so subsequent
|
|
428
|
-
* resolveBackendAsync() calls take the
|
|
429
|
-
*
|
|
430
|
-
*
|
|
420
|
+
* resolveBackendAsync() calls take the live CES RPC path. A reconnect
|
|
421
|
+
* callback is also registered so the session can heal if the transport
|
|
422
|
+
* drops mid-process.
|
|
431
423
|
*
|
|
432
424
|
* Returns undefined on any failure (socket not found, handshake rejected,
|
|
433
425
|
* timeout) so the caller falls through to the encrypted file store.
|
|
@@ -438,70 +430,30 @@ async function tryLazyCesConnect(): Promise<CesClient | undefined> {
|
|
|
438
430
|
}
|
|
439
431
|
|
|
440
432
|
_lazyConnectPromise = (async () => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (discovery.mode === "unavailable") {
|
|
444
|
-
log.info(
|
|
445
|
-
{ reason: discovery.reason },
|
|
446
|
-
"CES socket not reachable for lazy connect, falling back to encrypted file store",
|
|
447
|
-
);
|
|
448
|
-
return undefined;
|
|
449
|
-
}
|
|
450
|
-
const pm = createCesProcessManager({});
|
|
451
|
-
const transport = await pm.start();
|
|
452
|
-
const client = createCesClient(transport);
|
|
453
|
-
const { accepted, reason } = await client.handshake();
|
|
454
|
-
if (!accepted) {
|
|
455
|
-
log.warn(
|
|
456
|
-
{ reason },
|
|
457
|
-
"Lazy CES connection handshake rejected — falling back to encrypted file store",
|
|
458
|
-
);
|
|
459
|
-
client.close();
|
|
460
|
-
await pm.stop().catch(() => {});
|
|
461
|
-
return undefined;
|
|
462
|
-
}
|
|
433
|
+
const discovery = discoverCes();
|
|
434
|
+
if (discovery.mode === "unavailable") {
|
|
463
435
|
log.info(
|
|
464
|
-
|
|
436
|
+
{ reason: discovery.reason },
|
|
437
|
+
"CES socket not reachable for lazy connect, falling back to encrypted file store",
|
|
465
438
|
);
|
|
466
|
-
setCesClient(client);
|
|
467
|
-
// Register a reconnect callback so the lazy connection self-heals
|
|
468
|
-
// if the transport drops, mirroring the daemon's proactive reconnect.
|
|
469
|
-
setCesReconnect(async () => {
|
|
470
|
-
try {
|
|
471
|
-
await pm.stop();
|
|
472
|
-
const newTransport = await pm.start();
|
|
473
|
-
const newClient = createCesClient(newTransport);
|
|
474
|
-
const { accepted: ok } = await newClient.handshake();
|
|
475
|
-
if (ok) {
|
|
476
|
-
log.info("Lazy CES reconnection successful");
|
|
477
|
-
return newClient;
|
|
478
|
-
}
|
|
479
|
-
newClient.close();
|
|
480
|
-
await pm.stop().catch(() => {});
|
|
481
|
-
return undefined;
|
|
482
|
-
} catch (err) {
|
|
483
|
-
log.warn(
|
|
484
|
-
{ error: err instanceof Error ? err.message : String(err) },
|
|
485
|
-
"Lazy CES reconnection failed",
|
|
486
|
-
);
|
|
487
|
-
return undefined;
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
return client;
|
|
491
|
-
} catch (err) {
|
|
492
|
-
if (err instanceof CesUnavailableError) {
|
|
493
|
-
log.info(
|
|
494
|
-
{ reason: err.message },
|
|
495
|
-
"CES socket not reachable for lazy connect — falling back to encrypted file store",
|
|
496
|
-
);
|
|
497
|
-
} else {
|
|
498
|
-
log.warn(
|
|
499
|
-
{ error: err instanceof Error ? err.message : String(err) },
|
|
500
|
-
"Lazy CES connection failed — falling back to encrypted file store",
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
439
|
return undefined;
|
|
504
440
|
}
|
|
441
|
+
const session = await openCesRpcSession();
|
|
442
|
+
if (!session) {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
log.info(
|
|
446
|
+
"CES RPC session established; credential operations route through CES RPC",
|
|
447
|
+
);
|
|
448
|
+
setCesClient(session.client);
|
|
449
|
+
setCesReconnect(async () => {
|
|
450
|
+
const client = await reconnectCesRpcSession(session.processManager);
|
|
451
|
+
if (client) {
|
|
452
|
+
log.info("CES RPC reconnection successful");
|
|
453
|
+
}
|
|
454
|
+
return client;
|
|
455
|
+
});
|
|
456
|
+
return session.client;
|
|
505
457
|
})();
|
|
506
458
|
|
|
507
459
|
try {
|
|
@@ -512,7 +464,10 @@ async function tryLazyCesConnect(): Promise<CesClient | undefined> {
|
|
|
512
464
|
}
|
|
513
465
|
|
|
514
466
|
async function doResolveBackend(): Promise<CredentialBackend> {
|
|
515
|
-
// 1. CES RPC. Primary credential backend in every environment.
|
|
467
|
+
// 1. CES RPC. Primary credential backend in every environment. Boot
|
|
468
|
+
// claims reconnect ownership before it reads handshake identity, so
|
|
469
|
+
// that read cannot open a second session. Children open here only
|
|
470
|
+
// when this process has no client and no reconnect owner.
|
|
516
471
|
if (_cesClient) {
|
|
517
472
|
const cesRpc = new CesRpcCredentialBackend(_cesClient);
|
|
518
473
|
if (cesRpc.isAvailable()) {
|
|
@@ -525,24 +480,19 @@ async function doResolveBackend(): Promise<CredentialBackend> {
|
|
|
525
480
|
);
|
|
526
481
|
}
|
|
527
482
|
|
|
528
|
-
// 2. Lazy CES RPC connect. Child processes never call startCes(). When
|
|
529
|
-
// the assistant's setCesReconnect() is NOT registered, attempt a
|
|
530
|
-
// direct connection to the CES bootstrap socket. On success, inject
|
|
531
|
-
// the client via setCesClient()
|
|
532
|
-
// and re-resolve through the CES RPC path. On failure, fall through.
|
|
533
483
|
if (!_cesClient && !_cesReconnect) {
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
const cesRpc = new CesRpcCredentialBackend(
|
|
484
|
+
const client = await tryLazyCesConnect();
|
|
485
|
+
if (client) {
|
|
486
|
+
const cesRpc = new CesRpcCredentialBackend(client);
|
|
537
487
|
if (cesRpc.isAvailable()) {
|
|
538
488
|
_resolvedBackend = cesRpc;
|
|
539
|
-
log.info("Resolved credential backend: ces-rpc
|
|
489
|
+
log.info("Resolved credential backend: ces-rpc");
|
|
540
490
|
return cesRpc;
|
|
541
491
|
}
|
|
542
492
|
}
|
|
543
493
|
}
|
|
544
494
|
|
|
545
|
-
//
|
|
495
|
+
// 2. CES HTTP. Managed failover when IPC is unavailable.
|
|
546
496
|
if (getIsContainerized() && process.env.CES_CREDENTIAL_URL) {
|
|
547
497
|
const ces = createCesCredentialBackend();
|
|
548
498
|
if (ces.isAvailable()) {
|
|
@@ -556,7 +506,7 @@ async function doResolveBackend(): Promise<CredentialBackend> {
|
|
|
556
506
|
);
|
|
557
507
|
}
|
|
558
508
|
|
|
559
|
-
//
|
|
509
|
+
// 3. On a containerized pod the local encrypted store does not exist and CES
|
|
560
510
|
// owns credentials. Never resolve to the encrypted store here; it would
|
|
561
511
|
// report a provisioned credential as absent. Return an unreachable backend
|
|
562
512
|
// (presence indeterminate, which callers retry) WITHOUT caching it, so the
|
|
@@ -568,7 +518,7 @@ async function doResolveBackend(): Promise<CredentialBackend> {
|
|
|
568
518
|
return createUnavailableBackend();
|
|
569
519
|
}
|
|
570
520
|
|
|
571
|
-
//
|
|
521
|
+
// 4. Encrypted file store: the legitimate backend for local / self-hosted
|
|
572
522
|
// mode when CES is unavailable.
|
|
573
523
|
_resolvedBackend = getEncryptedStoreBackend();
|
|
574
524
|
log.info("Resolved credential backend: encrypted-store (local mode)");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import { parseToolManifestFile } from "../../skills/tool-manifest.js";
|
|
6
|
+
import { explicitTools } from "../tool-manifest.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tool input schemas must be plain objects at the root.
|
|
10
|
+
*
|
|
11
|
+
* Anthropic's Messages API rejects a tool whose `input_schema` carries
|
|
12
|
+
* `oneOf`, `anyOf`, or `allOf` at the top level ("input_schema does not
|
|
13
|
+
* support oneOf, allOf, or anyOf at the top level"). The rejection is a 400
|
|
14
|
+
* for the whole request, so one offending definition takes down every call
|
|
15
|
+
* that advertises it. Other hosts accept the same schema, which lets the
|
|
16
|
+
* mistake hide until a request routes to Anthropic directly. Either/or rules
|
|
17
|
+
* between fields belong in the tool description and the tool's own input
|
|
18
|
+
* validation instead.
|
|
19
|
+
*
|
|
20
|
+
* Covers the core manifest and every bundled skill's `TOOLS.json`.
|
|
21
|
+
* Combinators nested under `properties` are accepted by Anthropic and stay
|
|
22
|
+
* out of scope.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ROOT_COMBINATORS = ["oneOf", "anyOf", "allOf"] as const;
|
|
26
|
+
|
|
27
|
+
const BUNDLED_SKILLS_DIR = resolve(
|
|
28
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
29
|
+
"../../config/bundled-skills",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
interface SchemaCase {
|
|
33
|
+
/** Tool name, prefixed with the skill directory for bundled skill tools. */
|
|
34
|
+
label: string;
|
|
35
|
+
schema: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function bundledSkillSchemas(): SchemaCase[] {
|
|
39
|
+
const cases: SchemaCase[] = [];
|
|
40
|
+
for (const relative of new Bun.Glob("*/TOOLS.json").scanSync({
|
|
41
|
+
cwd: BUNDLED_SKILLS_DIR,
|
|
42
|
+
})) {
|
|
43
|
+
const manifest = parseToolManifestFile(join(BUNDLED_SKILLS_DIR, relative));
|
|
44
|
+
for (const tool of manifest.tools) {
|
|
45
|
+
cases.push({
|
|
46
|
+
label: `${dirname(relative)}/${tool.name}`,
|
|
47
|
+
schema: tool.input_schema,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return cases;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function coreSchemas(): SchemaCase[] {
|
|
55
|
+
return explicitTools.map((tool) => {
|
|
56
|
+
if (!tool.name) {
|
|
57
|
+
throw new Error("core manifest entries carry explicit names");
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
label: tool.name,
|
|
61
|
+
schema: tool.input_schema as Record<string, unknown>,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CASES: SchemaCase[] = [...coreSchemas(), ...bundledSkillSchemas()];
|
|
67
|
+
|
|
68
|
+
describe("tool input schema root", () => {
|
|
69
|
+
test("covers the core manifest and the bundled skills", () => {
|
|
70
|
+
expect(CASES.length).toBeGreaterThan(explicitTools.length);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const { label, schema } of CASES) {
|
|
74
|
+
test(`${label} keeps combinators out of the schema root`, () => {
|
|
75
|
+
for (const keyword of ROOT_COMBINATORS) {
|
|
76
|
+
expect(schema).not.toHaveProperty(keyword);
|
|
77
|
+
}
|
|
78
|
+
expect(schema.type).toBe("object");
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -124,6 +124,8 @@ const DESCRIPTION = [
|
|
|
124
124
|
"For logins, use saved credentials first. Securely collect missing credentials",
|
|
125
125
|
"with assistant credentials prompt, then fill the login form yourself.",
|
|
126
126
|
"",
|
|
127
|
+
"Every call passes exactly one of `questions` or `desktopHelp`.",
|
|
128
|
+
"",
|
|
127
129
|
"Use this tool whenever a request is ambiguous and can be resolved",
|
|
128
130
|
"by 2–4 plausible interpretations or discrete choices. Prefer it over",
|
|
129
131
|
"plain-text clarification — structured options are faster to answer and",
|
|
@@ -255,10 +257,10 @@ export const askQuestionTool = {
|
|
|
255
257
|
category: "interaction",
|
|
256
258
|
executionTarget: "sandbox",
|
|
257
259
|
defaultRiskLevel: RiskLevel.Low,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
// Anthropic rejects `oneOf` / `anyOf` / `allOf` at the root of a tool
|
|
261
|
+
// schema, so the either/or rule between `questions` and `desktopHelp` lives
|
|
262
|
+
// in the description and the Zod refine, not in the wire schema.
|
|
263
|
+
input_schema: toToolInputSchema(askQuestionInputSchema),
|
|
262
264
|
|
|
263
265
|
async execute(
|
|
264
266
|
input: Record<string, unknown>,
|
|
@@ -23,10 +23,6 @@ import {
|
|
|
23
23
|
} from "../shared/zod-tool-schema.js";
|
|
24
24
|
import type { ToolContext, ToolExecutionResult } from "../types.js";
|
|
25
25
|
|
|
26
|
-
function isPrivilegedDocumentActor(context: ToolContext): boolean {
|
|
27
|
-
return canActOnPrivilegedDocuments(context);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
26
|
export function documentNotFound(surfaceId: string): ToolExecutionResult {
|
|
31
27
|
return {
|
|
32
28
|
content: JSON.stringify({
|
|
@@ -43,7 +39,7 @@ export function canAccessDocument(
|
|
|
43
39
|
context: ToolContext,
|
|
44
40
|
): boolean {
|
|
45
41
|
return (
|
|
46
|
-
|
|
42
|
+
canActOnPrivilegedDocuments(context) ||
|
|
47
43
|
isDocumentAssociatedWithConversation(surfaceId, context.conversationId)
|
|
48
44
|
);
|
|
49
45
|
}
|
|
@@ -522,7 +518,7 @@ export function executeDocumentList(
|
|
|
522
518
|
const docs = query
|
|
523
519
|
? searchDocumentsByTitle(
|
|
524
520
|
query,
|
|
525
|
-
|
|
521
|
+
canActOnPrivilegedDocuments(context)
|
|
526
522
|
? {}
|
|
527
523
|
: { conversationId: context.conversationId },
|
|
528
524
|
)
|