@m6d/cortex-server 2.0.1 → 2.1.0
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/README.md +10 -0
- package/contracts/interactive.ts +53 -0
- package/contracts/runtime.ts +22 -0
- package/contracts/wire.ts +32 -0
- package/dist/contracts/interactive.d.ts +39 -0
- package/dist/contracts/runtime.d.ts +80 -0
- package/dist/contracts/wire.d.ts +30 -0
- package/dist/src/lib/adapters/database/index.d.ts +7 -0
- package/dist/src/lib/adapters/database/message-content.d.ts +2 -0
- package/dist/src/lib/adapters/database/mssql/index.d.ts +1 -0
- package/dist/src/lib/adapters/database/mssql/messages.d.ts +1 -0
- package/dist/src/lib/adapters/database/postgres/index.d.ts +1 -0
- package/dist/src/lib/adapters/database/postgres/messages.d.ts +1 -0
- package/dist/src/lib/ai/cc-runtime.d.ts +2 -1
- package/dist/src/lib/ai/interactive.d.ts +62 -0
- package/dist/src/lib/ai/turn-tools.d.ts +14 -0
- package/dist/src/lib/cc/client.d.ts +24 -0
- package/dist/src/lib/cc/registry.d.ts +14 -1
- package/dist/src/lib/cc/types.d.ts +1 -1
- package/package.json +5 -2
- package/src/lib/adapters/database/index.ts +12 -0
- package/src/lib/adapters/database/mssql/messages.ts +28 -1
- package/src/lib/adapters/database/postgres/messages.ts +22 -1
- package/src/lib/ai/cc-runtime.ts +16 -1
- package/src/lib/ai/index.ts +34 -5
- package/src/lib/ai/interactive.ts +364 -0
- package/src/lib/ai/tools/search-tools.tool.ts +6 -2
- package/src/lib/ai/turn-tools.ts +23 -0
- package/src/lib/cc/client.ts +5 -1
- package/src/lib/cc/format.ts +5 -2
- package/src/lib/cc/registry.ts +16 -2
- package/src/lib/cc/types.ts +1 -0
- package/src/lib/routes/chat.ts +23 -0
|
@@ -42,6 +42,18 @@ export type DatabaseAdapter = {
|
|
|
42
42
|
messages: ChatMessage[],
|
|
43
43
|
options?: { replaceAttachments?: boolean },
|
|
44
44
|
): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Atomically merges one anchored interactive reference into the
|
|
47
|
+
* message's metadata as a single-statement JSON merge — safe under
|
|
48
|
+
* concurrent writers across replicas, unlike a read-modify-write of
|
|
49
|
+
* the whole message.
|
|
50
|
+
*/
|
|
51
|
+
mergeInteractiveReference(
|
|
52
|
+
threadId: string,
|
|
53
|
+
messageId: string,
|
|
54
|
+
toolCallId: string,
|
|
55
|
+
reference: string,
|
|
56
|
+
): Promise<void>;
|
|
45
57
|
};
|
|
46
58
|
llmRequests: {
|
|
47
59
|
insert(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// fallow-ignore-file duplicate-export -- mssql twin of the postgres repository, same factory name by design
|
|
2
|
-
import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
|
|
2
|
+
import { and, desc, eq, getColumns, inArray, sql, type InferInsertModel } from "drizzle-orm";
|
|
3
3
|
import { threads, messages } from "@/db/schema.mssql";
|
|
4
4
|
import type { ChatMessage } from "@/types";
|
|
5
5
|
import type { DatabaseAdapter } from "@/adapters/database/index";
|
|
@@ -88,5 +88,32 @@ export function createMessagesRepository(db: MssqlDb) {
|
|
|
88
88
|
.execute();
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
|
+
|
|
92
|
+
async mergeInteractiveReference(threadId, messageId, toolCallId, reference) {
|
|
93
|
+
// JSON path keys are quoted strings; the id lands inside one.
|
|
94
|
+
const escapedKey = toolCallId.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
95
|
+
const anchorPath = `lax $.metadata.interactiveReferences."${escapedKey}"`;
|
|
96
|
+
await db
|
|
97
|
+
.update(messages)
|
|
98
|
+
.set({
|
|
99
|
+
// All JSON_QUERY reads see the row's pre-update value, so
|
|
100
|
+
// the statement is one atomic merge.
|
|
101
|
+
content: sql`JSON_MODIFY(
|
|
102
|
+
JSON_MODIFY(
|
|
103
|
+
JSON_MODIFY(
|
|
104
|
+
${messages.content},
|
|
105
|
+
'lax $.metadata',
|
|
106
|
+
JSON_QUERY(COALESCE(JSON_QUERY(${messages.content}, '$.metadata'), '{}'))
|
|
107
|
+
),
|
|
108
|
+
'lax $.metadata.interactiveReferences',
|
|
109
|
+
JSON_QUERY(COALESCE(JSON_QUERY(${messages.content}, '$.metadata.interactiveReferences'), '{}'))
|
|
110
|
+
),
|
|
111
|
+
${anchorPath},
|
|
112
|
+
${reference}
|
|
113
|
+
)`,
|
|
114
|
+
})
|
|
115
|
+
.where(and(eq(messages.id, messageId), eq(messages.threadId, threadId)))
|
|
116
|
+
.execute();
|
|
117
|
+
},
|
|
91
118
|
} satisfies DatabaseAdapter["messages"];
|
|
92
119
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication -- mirrors ../mssql/messages.ts by design;
|
|
2
2
|
// Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
|
|
3
|
-
import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
|
|
3
|
+
import { and, desc, eq, getColumns, inArray, sql, type InferInsertModel } from "drizzle-orm";
|
|
4
4
|
import { threads, messages } from "@/db/schema.pg";
|
|
5
5
|
import type { ChatMessage } from "@/types";
|
|
6
6
|
import type { DatabaseAdapter } from "@/adapters/database/index";
|
|
@@ -88,5 +88,26 @@ export function createMessagesRepository(db: PostgresDb) {
|
|
|
88
88
|
.execute();
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
|
+
|
|
92
|
+
async mergeInteractiveReference(threadId, messageId, toolCallId, reference) {
|
|
93
|
+
await db
|
|
94
|
+
.update(messages)
|
|
95
|
+
.set({
|
|
96
|
+
content: sql`jsonb_set(
|
|
97
|
+
jsonb_set(
|
|
98
|
+
${messages.content},
|
|
99
|
+
'{metadata}',
|
|
100
|
+
coalesce(${messages.content}->'metadata', '{}'::jsonb),
|
|
101
|
+
true
|
|
102
|
+
),
|
|
103
|
+
'{metadata,interactiveReferences}',
|
|
104
|
+
coalesce(${messages.content}->'metadata'->'interactiveReferences', '{}'::jsonb)
|
|
105
|
+
|| jsonb_build_object(${toolCallId}::text, ${reference}::text),
|
|
106
|
+
true
|
|
107
|
+
)`,
|
|
108
|
+
})
|
|
109
|
+
.where(and(eq(messages.id, messageId), eq(messages.threadId, threadId)))
|
|
110
|
+
.execute();
|
|
111
|
+
},
|
|
91
112
|
} satisfies DatabaseAdapter["messages"];
|
|
92
113
|
}
|
package/src/lib/ai/cc-runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ResolvedCortexAgentConfig } from "@/config";
|
|
2
2
|
import type { Thread } from "@/types";
|
|
3
3
|
import type { ControlCenterClient } from "@/cc/client";
|
|
4
|
-
import type { CcRuntime, CcToolRegistry } from "@/cc/registry";
|
|
4
|
+
import type { CcInteractiveTool, CcRuntime, CcToolRegistry } from "@/cc/registry";
|
|
5
5
|
import { registerCcTools } from "@/cc/registry";
|
|
6
6
|
import type { ResolveResponse, RuntimeAgentConfig } from "@/cc/types";
|
|
7
7
|
|
|
@@ -35,6 +35,20 @@ export function createCcRuntime(options: CcRuntimeOptions) {
|
|
|
35
35
|
const registry: CcToolRegistry = new Map();
|
|
36
36
|
if (ccResolved) registerCcTools(registry, ccResolved.tools);
|
|
37
37
|
|
|
38
|
+
// Interactive tools resolved at turn start become client-executed LLM
|
|
39
|
+
// tools. Mid-turn search finds are excluded on purpose: the model's tool
|
|
40
|
+
// set is fixed once the turn starts, so declaring them is impossible.
|
|
41
|
+
const interactiveTools = new Map<string, CcInteractiveTool>();
|
|
42
|
+
for (const tool of ccResolved?.tools ?? []) {
|
|
43
|
+
if (!tool.interaction) continue;
|
|
44
|
+
interactiveTools.set(tool.name, {
|
|
45
|
+
toolId: tool.toolId,
|
|
46
|
+
signature: tool.signature,
|
|
47
|
+
interaction: tool.interaction,
|
|
48
|
+
inputSchema: tool.inputSchema,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
let currentStep = -1;
|
|
39
53
|
let callIndex = 0;
|
|
40
54
|
|
|
@@ -43,6 +57,7 @@ export function createCcRuntime(options: CcRuntimeOptions) {
|
|
|
43
57
|
agentId: config.agentId,
|
|
44
58
|
config: ccConfig,
|
|
45
59
|
registry,
|
|
60
|
+
interactiveTools,
|
|
46
61
|
threadId: thread.id,
|
|
47
62
|
turnKey: options.turnKey,
|
|
48
63
|
userId,
|
package/src/lib/ai/index.ts
CHANGED
|
@@ -15,7 +15,6 @@ import type { ResolvedCortexAgentConfig, ToolSet } from "@/config";
|
|
|
15
15
|
import type { ChatMessage, MessageMetadata, Thread } from "@/types";
|
|
16
16
|
import { createModel } from "./helpers";
|
|
17
17
|
import { buildSystemPrompt, resolveSession } from "./prompt";
|
|
18
|
-
import { ControlCenterClient } from "@/cc/client";
|
|
19
18
|
import { getControlCenterConfig } from "@/cc/config-cache";
|
|
20
19
|
import { buildPromptVariables } from "@/cc/format";
|
|
21
20
|
import { getRecentCcTools } from "@/cc/registry";
|
|
@@ -30,7 +29,13 @@ import {
|
|
|
30
29
|
buildTurnTools,
|
|
31
30
|
createToolInstrumentation,
|
|
32
31
|
hasDefaultAttachmentInterceptor,
|
|
32
|
+
interactiveToolDeclarations,
|
|
33
33
|
} from "./turn-tools";
|
|
34
|
+
import {
|
|
35
|
+
createCcClient,
|
|
36
|
+
interactiveToolBindings,
|
|
37
|
+
settleInteractiveToolResults,
|
|
38
|
+
} from "./interactive";
|
|
34
39
|
import { createInspector } from "./inspector";
|
|
35
40
|
import { commitBeforeTerminal } from "./commit-gate";
|
|
36
41
|
import { finishTurn } from "./finish-turn";
|
|
@@ -63,6 +68,19 @@ export async function startTurn(
|
|
|
63
68
|
const incoming = params.messages
|
|
64
69
|
.map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
|
|
65
70
|
.filter((message) => message.role === "user" || answersToolCalls(message));
|
|
71
|
+
|
|
72
|
+
// Interactive tool answers are untrusted client input: re-establish
|
|
73
|
+
// them via the tool's verify call and apply result masking before
|
|
74
|
+
// anything — transcript or model — reads them.
|
|
75
|
+
const ccClient = createCcClient(config);
|
|
76
|
+
await settleInteractiveToolResults({
|
|
77
|
+
messages: incoming,
|
|
78
|
+
thread,
|
|
79
|
+
userId,
|
|
80
|
+
token,
|
|
81
|
+
config,
|
|
82
|
+
ccClient,
|
|
83
|
+
});
|
|
66
84
|
await config.db.messages.upsert(thread.id, incoming);
|
|
67
85
|
|
|
68
86
|
const lastUserMessage = incoming.findLast((message) => message.role === "user");
|
|
@@ -111,7 +129,7 @@ export async function startTurn(
|
|
|
111
129
|
const turnKey = lastUserMessage?.id ?? "0";
|
|
112
130
|
|
|
113
131
|
const inspector = createInspector();
|
|
114
|
-
const { model, embed, neo4j
|
|
132
|
+
const { model, embed, neo4j } = createTurnProviders(config, inspector.fetch);
|
|
115
133
|
|
|
116
134
|
// Run independent operations in parallel
|
|
117
135
|
const [contextResult, resolved, session, ccConfig, ccResolved] = await Promise.all([
|
|
@@ -173,6 +191,10 @@ export async function startTurn(
|
|
|
173
191
|
threadAttachments,
|
|
174
192
|
};
|
|
175
193
|
const tools = buildTurnTools(turnToolsOptions);
|
|
194
|
+
const consumerToolNames = new Set([
|
|
195
|
+
...tools.map((tool) => tool.name),
|
|
196
|
+
...params.tools.map((tool) => tool.name),
|
|
197
|
+
]);
|
|
176
198
|
const instrumentation = createToolInstrumentation({
|
|
177
199
|
...turnToolsOptions,
|
|
178
200
|
onToolStart: (toolName, toolCallId) =>
|
|
@@ -212,8 +234,13 @@ export async function startTurn(
|
|
|
212
234
|
const stream = chat({
|
|
213
235
|
adapter: model,
|
|
214
236
|
// Client tool declarations ride in on every request, so the server
|
|
215
|
-
// never re-declares them.
|
|
216
|
-
|
|
237
|
+
// never re-declares them. Interactive CC tools join them: also
|
|
238
|
+
// executor-less, answered by the widget. Consumer-owned names win
|
|
239
|
+
// the collision, and only names that won are treated as interactive.
|
|
240
|
+
tools: mergeAgentTools(tools, [
|
|
241
|
+
...params.tools,
|
|
242
|
+
...interactiveToolDeclarations(cc, consumerToolNames),
|
|
243
|
+
]),
|
|
217
244
|
messages: convertMessagesToModelMessages(
|
|
218
245
|
fitToContextWindow(contextMessages, systemPrompt, tools, config),
|
|
219
246
|
),
|
|
@@ -265,6 +292,9 @@ export async function startTurn(
|
|
|
265
292
|
modelId: inspector.modelId,
|
|
266
293
|
isAborted: run.abortController.signal.aborted,
|
|
267
294
|
tokenUsage: inspector.tokenUsage,
|
|
295
|
+
// Stamped so a parked interactive call can be initiated and
|
|
296
|
+
// verified after any reload or server restart.
|
|
297
|
+
interactiveTools: interactiveToolBindings(cc, consumerToolNames),
|
|
268
298
|
} satisfies MessageMetadata,
|
|
269
299
|
};
|
|
270
300
|
}
|
|
@@ -338,7 +368,6 @@ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof gl
|
|
|
338
368
|
model: createModel(config.model, { fetch }),
|
|
339
369
|
embed,
|
|
340
370
|
neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
|
|
341
|
-
ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
|
|
342
371
|
};
|
|
343
372
|
}
|
|
344
373
|
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
import type { InteractiveInitiateResult, MessageMetadata } from "@cortex/contracts/wire";
|
|
3
|
+
import { INTERACTIVE_STATUSES } from "@cortex/contracts/interactive";
|
|
4
|
+
import type { ResolvedCortexAgentConfig } from "@/config";
|
|
5
|
+
import type { ChatMessage, Thread } from "@/types";
|
|
6
|
+
import { ControlCenterClient } from "@/cc/client";
|
|
7
|
+
import type { CcRuntime } from "@/cc/registry";
|
|
8
|
+
import { recordRecentCcTool } from "@/cc/registry";
|
|
9
|
+
|
|
10
|
+
type InteractiveBinding = NonNullable<MessageMetadata["interactiveTools"]>[string];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The wire-shape bindings stamped on the turn's final assistant message, so a
|
|
14
|
+
* parked interactive call can be initiated and verified after any restart.
|
|
15
|
+
* Only names that actually won declaration merging are stamped — a call to a
|
|
16
|
+
* colliding consumer-owned tool must never be treated as interactive. Undefined
|
|
17
|
+
* when nothing qualifies, keeping metadata lean.
|
|
18
|
+
*/
|
|
19
|
+
export function interactiveToolBindings(
|
|
20
|
+
cc: CcRuntime | undefined,
|
|
21
|
+
takenNames: ReadonlySet<string>,
|
|
22
|
+
) {
|
|
23
|
+
if (!cc) return undefined;
|
|
24
|
+
const entries = [...cc.interactiveTools]
|
|
25
|
+
.filter(([name]) => !takenNames.has(name))
|
|
26
|
+
.map(
|
|
27
|
+
([name, tool]) =>
|
|
28
|
+
[
|
|
29
|
+
name,
|
|
30
|
+
{ toolId: tool.toolId, ...tool.interaction } satisfies InteractiveBinding,
|
|
31
|
+
] as const,
|
|
32
|
+
);
|
|
33
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Null when the agent has no Control Center — every caller treats that as "skip". */
|
|
37
|
+
export function createCcClient(config: ResolvedCortexAgentConfig) {
|
|
38
|
+
return config.controlCenter ? new ControlCenterClient(config.controlCenter) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How long initiate waits out the turn-commit race before giving up. */
|
|
42
|
+
const PENDING_CALL_RETRIES = 6;
|
|
43
|
+
const PENDING_CALL_RETRY_MS = 700;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The widget initiates the moment its stream goes idle, which can beat the
|
|
47
|
+
* turn's commit to the database — so a missing call is retried briefly before
|
|
48
|
+
* it becomes a 404. An *answered* call 404s immediately: it was settled
|
|
49
|
+
* elsewhere (another tab) and the widget should fall back to its pill.
|
|
50
|
+
*/
|
|
51
|
+
async function findPendingInteractiveCall(
|
|
52
|
+
config: ResolvedCortexAgentConfig,
|
|
53
|
+
userId: string,
|
|
54
|
+
threadId: string,
|
|
55
|
+
toolCallId: string,
|
|
56
|
+
) {
|
|
57
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
58
|
+
const stored = await config.db.messages.list(userId, threadId);
|
|
59
|
+
const messages = stored.map((row) => row.content);
|
|
60
|
+
const answered = messages.some((candidate) =>
|
|
61
|
+
candidate.parts.some(
|
|
62
|
+
(part) => part.type === "tool-result" && part.toolCallId === toolCallId,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
if (answered) return null;
|
|
66
|
+
|
|
67
|
+
for (const message of messages) {
|
|
68
|
+
const call = message.parts.find(
|
|
69
|
+
(part) => part.type === "tool-call" && part.id === toolCallId,
|
|
70
|
+
);
|
|
71
|
+
if (call?.type === "tool-call") return { message, call };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (attempt >= PENDING_CALL_RETRIES) return null;
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, PENDING_CALL_RETRY_MS));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `POST /chat/:chatId/tools/:toolCallId/initiate` — runs the interactive
|
|
81
|
+
* tool's initiate call (the tool's own endpoint) and hands the widget its
|
|
82
|
+
* embed payload. The model never sees this payload. The idempotency key is
|
|
83
|
+
* pinned to the tool call, so a reload mid-flow reuses the created session
|
|
84
|
+
* instead of opening a second one.
|
|
85
|
+
*/
|
|
86
|
+
export async function initiateInteractiveTool(options: {
|
|
87
|
+
config: ResolvedCortexAgentConfig;
|
|
88
|
+
thread: Thread;
|
|
89
|
+
userId: string;
|
|
90
|
+
token: string;
|
|
91
|
+
toolCallId: string;
|
|
92
|
+
}) {
|
|
93
|
+
const { config, thread, userId, token, toolCallId } = options;
|
|
94
|
+
const pending = await findPendingInteractiveCall(config, userId, thread.id, toolCallId);
|
|
95
|
+
if (!pending) {
|
|
96
|
+
throw new HTTPException(404, { message: "No pending tool call with this id" });
|
|
97
|
+
}
|
|
98
|
+
const { message, call } = pending;
|
|
99
|
+
|
|
100
|
+
const binding = message.metadata?.interactiveTools?.[call.name];
|
|
101
|
+
const ccClient = createCcClient(config);
|
|
102
|
+
if (!binding || !ccClient) {
|
|
103
|
+
return { interactive: false } satisfies InteractiveInitiateResult;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const result = await ccClient.execute(
|
|
107
|
+
config.agentId,
|
|
108
|
+
binding.toolId,
|
|
109
|
+
{
|
|
110
|
+
input: asRecord(call.input) ?? asRecord(parseJson(call.arguments)) ?? {},
|
|
111
|
+
context: { threadId: thread.id, userId },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
readOnly: false,
|
|
115
|
+
endUserToken: token,
|
|
116
|
+
threadId: thread.id,
|
|
117
|
+
turnKey: toolCallId,
|
|
118
|
+
stepIndex: 0,
|
|
119
|
+
callIndex: 0,
|
|
120
|
+
idempotencyKey: `${thread.id}:${toolCallId}:initiate`,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
if (!result) {
|
|
124
|
+
throw new HTTPException(502, { message: "The tool is temporarily unavailable" });
|
|
125
|
+
}
|
|
126
|
+
if ("error" in result) {
|
|
127
|
+
throw new HTTPException(502, { message: `initiate failed: ${result.error.kind}` });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const embedUrl = asRecord(result.output)?.embedUrl;
|
|
131
|
+
if (typeof embedUrl !== "string" || !isOnOrigin(embedUrl, binding.embedOrigin)) {
|
|
132
|
+
throw new HTTPException(502, {
|
|
133
|
+
message: "initiate did not return an embedUrl on the configured origin",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Anchor the trusted reference to this call: a verified completion may
|
|
138
|
+
// only settle against the session this initiate created, never against
|
|
139
|
+
// whichever reference the browser chooses to report.
|
|
140
|
+
if (binding.hasVerify) {
|
|
141
|
+
const reference = asRecord(result.output)?.reference;
|
|
142
|
+
if (typeof reference !== "string" || !reference) {
|
|
143
|
+
throw new HTTPException(502, {
|
|
144
|
+
message: "initiate did not return the reference its verify endpoint needs",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
await config.db.messages.mergeInteractiveReference(
|
|
148
|
+
thread.id,
|
|
149
|
+
message.id,
|
|
150
|
+
toolCallId,
|
|
151
|
+
reference,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
interactive: true,
|
|
157
|
+
embedUrl,
|
|
158
|
+
surface: binding.surface,
|
|
159
|
+
embedOrigin: binding.embedOrigin,
|
|
160
|
+
} satisfies InteractiveInitiateResult;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The trust boundary for interactive results: whatever the client submitted
|
|
165
|
+
* for a bound tool call is replaced wholesale — cancelled/failed pass through
|
|
166
|
+
* normalized, completed is re-established by the tool's verify call when it
|
|
167
|
+
* has one, and result delivery masking is applied before the model reads it.
|
|
168
|
+
* Runs before the answering turn is persisted, so both the stored transcript
|
|
169
|
+
* and the model context carry only settled results.
|
|
170
|
+
*/
|
|
171
|
+
export async function settleInteractiveToolResults(options: {
|
|
172
|
+
messages: ChatMessage[];
|
|
173
|
+
thread: Thread;
|
|
174
|
+
userId: string;
|
|
175
|
+
token: string;
|
|
176
|
+
config: ResolvedCortexAgentConfig;
|
|
177
|
+
ccClient: ControlCenterClient | null;
|
|
178
|
+
}) {
|
|
179
|
+
// Deliberately no early-out on a missing CC client: normalization must run
|
|
180
|
+
// for every bound result regardless — only the verify call needs CC, and
|
|
181
|
+
// without it a claimed completion fails rather than passing through.
|
|
182
|
+
const { messages, thread, userId, token, config, ccClient } = options;
|
|
183
|
+
const answering = messages.filter(
|
|
184
|
+
(message) =>
|
|
185
|
+
message.role === "assistant" &&
|
|
186
|
+
message.parts.some((part) => part.type === "tool-result"),
|
|
187
|
+
);
|
|
188
|
+
if (answering.length === 0) return;
|
|
189
|
+
|
|
190
|
+
const stored = await config.db.messages.list(userId, thread.id);
|
|
191
|
+
const storedMessages = stored.map((row) => row.content);
|
|
192
|
+
const pendingCalls = collectPendingCalls(storedMessages);
|
|
193
|
+
if (pendingCalls.size === 0) return;
|
|
194
|
+
|
|
195
|
+
const context = {
|
|
196
|
+
ccClient,
|
|
197
|
+
agentId: config.agentId,
|
|
198
|
+
threadId: thread.id,
|
|
199
|
+
userId,
|
|
200
|
+
token,
|
|
201
|
+
references: collectReferences(storedMessages),
|
|
202
|
+
};
|
|
203
|
+
for (const message of answering) {
|
|
204
|
+
await settleMessage(message, pendingCalls, context);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function settleMessage(
|
|
209
|
+
message: ChatMessage,
|
|
210
|
+
pendingCalls: Map<string, PendingInteractiveCall>,
|
|
211
|
+
context: SettleContext,
|
|
212
|
+
) {
|
|
213
|
+
for (const part of message.parts) {
|
|
214
|
+
if (part.type !== "tool-result") continue;
|
|
215
|
+
const pending = pendingCalls.get(part.toolCallId);
|
|
216
|
+
if (!pending) continue;
|
|
217
|
+
|
|
218
|
+
recordRecentCcTool(context.threadId, pending.name);
|
|
219
|
+
const settled = await settleOne(pending.binding, part.content, part.toolCallId, context);
|
|
220
|
+
part.content = JSON.stringify(settled);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type PendingInteractiveCall = { name: string; binding: InteractiveBinding };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The authoritative view of what may settle: stored, unanswered tool calls
|
|
228
|
+
* whose own message carries a binding for their name, keyed by call id. The
|
|
229
|
+
* incoming message's tool-call parts are client-authored and never consulted —
|
|
230
|
+
* a fabricated call/result pair matches nothing here.
|
|
231
|
+
*/
|
|
232
|
+
function collectPendingCalls(messages: ChatMessage[]) {
|
|
233
|
+
const answered = answeredCallIds(messages);
|
|
234
|
+
const pending = new Map<string, PendingInteractiveCall>();
|
|
235
|
+
for (const message of messages) {
|
|
236
|
+
const bindings = message.metadata?.interactiveTools;
|
|
237
|
+
if (!bindings) continue;
|
|
238
|
+
for (const part of message.parts) {
|
|
239
|
+
if (part.type !== "tool-call" || answered.has(part.id)) continue;
|
|
240
|
+
const binding = bindings[part.name];
|
|
241
|
+
if (binding) pending.set(part.id, { name: part.name, binding });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return pending;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function answeredCallIds(messages: ChatMessage[]) {
|
|
248
|
+
const answered = new Set<string>();
|
|
249
|
+
for (const message of messages) {
|
|
250
|
+
for (const part of message.parts) {
|
|
251
|
+
if (part.type === "tool-result") answered.add(part.toolCallId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return answered;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The references initiate anchored, keyed by tool call id. */
|
|
258
|
+
function collectReferences(messages: ChatMessage[]) {
|
|
259
|
+
const references = new Map<string, string>();
|
|
260
|
+
for (const message of messages) {
|
|
261
|
+
for (const [toolCallId, reference] of Object.entries(
|
|
262
|
+
message.metadata?.interactiveReferences ?? {},
|
|
263
|
+
)) {
|
|
264
|
+
references.set(toolCallId, reference);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return references;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type SettleContext = {
|
|
271
|
+
ccClient: ControlCenterClient | null;
|
|
272
|
+
agentId: string;
|
|
273
|
+
threadId: string;
|
|
274
|
+
userId: string;
|
|
275
|
+
token: string;
|
|
276
|
+
references: Map<string, string>;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
async function settleOne(
|
|
280
|
+
binding: InteractiveBinding,
|
|
281
|
+
content: string | unknown[],
|
|
282
|
+
toolCallId: string,
|
|
283
|
+
context: SettleContext,
|
|
284
|
+
) {
|
|
285
|
+
const reported = parseReported(content);
|
|
286
|
+
if (reported.status === "cancelled") return { status: "cancelled" };
|
|
287
|
+
if (reported.status !== "completed") return { status: "failed" };
|
|
288
|
+
|
|
289
|
+
if (!binding.hasVerify) {
|
|
290
|
+
// A completion the server cannot re-establish is just the browser's
|
|
291
|
+
// word. Current Control Centers always configure verification, so this
|
|
292
|
+
// only fires against a foreign runtime — refuse rather than trust.
|
|
293
|
+
return { status: "failed", detail: "verification required" };
|
|
294
|
+
}
|
|
295
|
+
const anchored = context.references.get(toolCallId);
|
|
296
|
+
if (!anchored || reported.reference !== anchored) {
|
|
297
|
+
return { status: "failed", detail: "completion does not match the initiated session" };
|
|
298
|
+
}
|
|
299
|
+
if (!context.ccClient) {
|
|
300
|
+
return { status: "failed", detail: "verification unavailable" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const result = await context.ccClient.execute(
|
|
304
|
+
context.agentId,
|
|
305
|
+
binding.toolId,
|
|
306
|
+
{
|
|
307
|
+
input: { reference: anchored },
|
|
308
|
+
phase: "verify",
|
|
309
|
+
context: { threadId: context.threadId, userId: context.userId },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
readOnly: true,
|
|
313
|
+
endUserToken: context.token,
|
|
314
|
+
threadId: context.threadId,
|
|
315
|
+
turnKey: "verify",
|
|
316
|
+
stepIndex: 0,
|
|
317
|
+
callIndex: 0,
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
if (!result) return { status: "failed", detail: "verification unavailable" };
|
|
321
|
+
if ("error" in result) return { status: "failed", detail: result.error.kind };
|
|
322
|
+
return deliver(binding, reported.reference, result.output);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function deliver(binding: InteractiveBinding, reference: string | undefined, output: unknown) {
|
|
326
|
+
return binding.resultDelivery === "endpoint"
|
|
327
|
+
? { status: "completed", reference }
|
|
328
|
+
: { status: "completed", reference, result: output };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseReported(content: string | unknown[]) {
|
|
332
|
+
const record = asRecord(typeof content === "string" ? parseJson(content) : content);
|
|
333
|
+
const status = INTERACTIVE_STATUSES.find((known) => known === record?.status);
|
|
334
|
+
return {
|
|
335
|
+
status,
|
|
336
|
+
reference: typeof record?.reference === "string" ? record.reference : undefined,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseJson(value: string | undefined) {
|
|
341
|
+
if (value === undefined) return undefined;
|
|
342
|
+
try {
|
|
343
|
+
return JSON.parse(value) as unknown;
|
|
344
|
+
} catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function asRecord(value: unknown) {
|
|
350
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
351
|
+
? (value as Record<string, unknown>)
|
|
352
|
+
: undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isOnOrigin(url: string, origin: string) {
|
|
356
|
+
try {
|
|
357
|
+
const parsed = new URL(url);
|
|
358
|
+
// http(s) only: javascript:/data: URLs normalize to origin "null",
|
|
359
|
+
// which a bare equality check could otherwise be tricked into passing.
|
|
360
|
+
return ["http:", "https:"].includes(parsed.protocol) && parsed.origin === origin;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -20,10 +20,14 @@ export function createSearchToolsTool(cc: CcRuntime) {
|
|
|
20
20
|
if (!result) return "Tool search is temporarily unavailable. Try again later.";
|
|
21
21
|
|
|
22
22
|
registerCcTools(cc.registry, result.tools);
|
|
23
|
-
|
|
23
|
+
// Interactive tools cannot join a turn already underway (the model's
|
|
24
|
+
// tool set is fixed at turn start), so surfacing them here would only
|
|
25
|
+
// advertise dead ends.
|
|
26
|
+
const callable = result.tools.filter((tool) => !tool.interaction);
|
|
27
|
+
if (callable.length === 0) return "No matching tools found.";
|
|
24
28
|
|
|
25
29
|
const shapes =
|
|
26
30
|
result.sharedShapes.length > 0 ? `${formatSharedShapes(result.sharedShapes)}\n\n` : "";
|
|
27
|
-
return shapes + formatToolSignatures(
|
|
31
|
+
return shapes + formatToolSignatures(callable);
|
|
28
32
|
});
|
|
29
33
|
}
|
package/src/lib/ai/turn-tools.ts
CHANGED
|
@@ -110,6 +110,29 @@ export function createToolInstrumentation(
|
|
|
110
110
|
} satisfies ChatMiddleware;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Interactive CC tools are declared like the request's client tools: no
|
|
115
|
+
* executor, so the call streams to the widget as a tool-call part and the run
|
|
116
|
+
* parks until the widget answers. The declaration carries the tool's published
|
|
117
|
+
* input schema — function-calling models ignore schemas described only in
|
|
118
|
+
* prose — with a permissive object as the fallback for older Control Centers.
|
|
119
|
+
* Names the consumer already claimed are skipped: those calls belong to the
|
|
120
|
+
* consumer's tool, and must neither be declared nor stamped as interactive.
|
|
121
|
+
*/
|
|
122
|
+
export function interactiveToolDeclarations(
|
|
123
|
+
cc: CcRuntime | undefined,
|
|
124
|
+
takenNames: ReadonlySet<string>,
|
|
125
|
+
) {
|
|
126
|
+
if (!cc) return [];
|
|
127
|
+
return [...cc.interactiveTools]
|
|
128
|
+
.filter(([name]) => !takenNames.has(name))
|
|
129
|
+
.map(([name, tool]) => ({
|
|
130
|
+
name,
|
|
131
|
+
description: tool.signature,
|
|
132
|
+
parameters: tool.inputSchema ?? { type: "object", additionalProperties: true },
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
function createCcTools(cc: CcRuntime | undefined) {
|
|
114
137
|
if (!cc) return [];
|
|
115
138
|
|
package/src/lib/cc/client.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type ExecuteOptions = {
|
|
|
21
21
|
turnKey: string;
|
|
22
22
|
stepIndex: number;
|
|
23
23
|
callIndex: number;
|
|
24
|
+
/** Overrides the composed key — used where stability must outlive the turn
|
|
25
|
+
* counters, e.g. one interactive initiate per tool call across reloads. */
|
|
26
|
+
idempotencyKey?: string;
|
|
24
27
|
timeoutMs?: number;
|
|
25
28
|
abortSignal?: AbortSignal;
|
|
26
29
|
};
|
|
@@ -138,7 +141,8 @@ export class ControlCenterClient {
|
|
|
138
141
|
if (!options.readOnly) {
|
|
139
142
|
headers.set(
|
|
140
143
|
"Idempotency-Key",
|
|
141
|
-
|
|
144
|
+
options.idempotencyKey ??
|
|
145
|
+
`${options.threadId}:${options.turnKey}:${options.stepIndex}:${options.callIndex}`,
|
|
142
146
|
);
|
|
143
147
|
}
|
|
144
148
|
|
package/src/lib/cc/format.ts
CHANGED
|
@@ -74,10 +74,13 @@ export function buildCcSection(cc: CcPromptInput) {
|
|
|
74
74
|
if (cc.resolved.sharedShapes.length > 0) {
|
|
75
75
|
parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// Interactive tools are declared to the model as real tools, so they
|
|
78
|
+
// stay out of the sandbox-only Dynamic Tools section.
|
|
79
|
+
const dynamicTools = cc.resolved.tools.filter((tool) => !tool.interaction);
|
|
80
|
+
if (dynamicTools.length > 0) {
|
|
78
81
|
parts.push(
|
|
79
82
|
"## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
|
|
80
|
-
formatToolSignatures(
|
|
83
|
+
formatToolSignatures(dynamicTools),
|
|
81
84
|
);
|
|
82
85
|
}
|
|
83
86
|
if (cc.resolved.services.length > 0) {
|