@m6d/cortex-server 2.0.0 → 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/rich-text.ts +207 -0
- package/contracts/runtime.ts +58 -1
- package/contracts/wire.ts +32 -0
- package/dist/contracts/interactive.d.ts +39 -0
- package/dist/contracts/rich-text.d.ts +32 -0
- package/dist/contracts/runtime.d.ts +119 -7
- 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 +5 -4
- package/dist/src/lib/ai/interactive.d.ts +62 -0
- package/dist/src/lib/ai/tools/search-common.d.ts +1 -1
- package/dist/src/lib/ai/turn-tools.d.ts +14 -0
- package/dist/src/lib/cc/client.d.ts +27 -3
- package/dist/src/lib/cc/config-cache.d.ts +2 -2
- 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 +41 -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 +8 -9
- package/src/lib/cc/registry.ts +16 -2
- package/src/lib/cc/types.ts +1 -0
- package/src/lib/routes/chat.ts +23 -0
|
@@ -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) =>
|
|
@@ -190,6 +212,13 @@ export async function startTurn(
|
|
|
190
212
|
config: cc.config,
|
|
191
213
|
resolved: ccResolved,
|
|
192
214
|
variables: buildPromptVariables(cc.config.promptVariables, {
|
|
215
|
+
// Computed per request; consumer-supplied context
|
|
216
|
+
// with the same key wins via the spreads below. No
|
|
217
|
+
// such default for `timezone`: the server's zone is
|
|
218
|
+
// not the session's, so guessing would feed the
|
|
219
|
+
// model wrong user context — only the consumer can
|
|
220
|
+
// supply it.
|
|
221
|
+
utcTime: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
193
222
|
...session,
|
|
194
223
|
...requestContext,
|
|
195
224
|
locale: cc.locale,
|
|
@@ -205,8 +234,13 @@ export async function startTurn(
|
|
|
205
234
|
const stream = chat({
|
|
206
235
|
adapter: model,
|
|
207
236
|
// Client tool declarations ride in on every request, so the server
|
|
208
|
-
// never re-declares them.
|
|
209
|
-
|
|
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
|
+
]),
|
|
210
244
|
messages: convertMessagesToModelMessages(
|
|
211
245
|
fitToContextWindow(contextMessages, systemPrompt, tools, config),
|
|
212
246
|
),
|
|
@@ -258,6 +292,9 @@ export async function startTurn(
|
|
|
258
292
|
modelId: inspector.modelId,
|
|
259
293
|
isAborted: run.abortController.signal.aborted,
|
|
260
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),
|
|
261
298
|
} satisfies MessageMetadata,
|
|
262
299
|
};
|
|
263
300
|
}
|
|
@@ -331,7 +368,6 @@ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof gl
|
|
|
331
368
|
model: createModel(config.model, { fetch }),
|
|
332
369
|
embed,
|
|
333
370
|
neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
|
|
334
|
-
ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
|
|
335
371
|
};
|
|
336
372
|
}
|
|
337
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { substituteVariables } from "@cortex/contracts/rich-text";
|
|
2
|
+
|
|
1
3
|
import type { ResolveResponse, RuntimeAgentConfig } from "./types";
|
|
2
4
|
|
|
3
5
|
type SharedShape = ResolveResponse["sharedShapes"][number];
|
|
@@ -5,12 +7,6 @@ type ToolSignature = ResolveResponse["tools"][number];
|
|
|
5
7
|
type ServiceCard = ResolveResponse["services"][number];
|
|
6
8
|
type KnowledgeChunk = ResolveResponse["knowledge"][number];
|
|
7
9
|
|
|
8
|
-
function substitutePromptVariables(template: string, variables: Record<string, string>) {
|
|
9
|
-
return template.replace(/\{\{(\w+)\}\}/g, function replaceVariable(match, name: string) {
|
|
10
|
-
return variables[name] ?? match;
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
|
|
14
10
|
/** Values for the published prompt's `{{variable}}` placeholders, read from session/request context. */
|
|
15
11
|
export function buildPromptVariables(
|
|
16
12
|
promptVariables: RuntimeAgentConfig["promptVariables"],
|
|
@@ -67,7 +63,7 @@ export type CcPromptInput = {
|
|
|
67
63
|
export function buildCcSection(cc: CcPromptInput) {
|
|
68
64
|
const parts: string[] = [];
|
|
69
65
|
|
|
70
|
-
const prompt =
|
|
66
|
+
const prompt = substituteVariables(cc.config.systemPrompt, cc.variables).trim();
|
|
71
67
|
if (prompt) parts.push(prompt);
|
|
72
68
|
|
|
73
69
|
if (cc.config.catalogBlurb) {
|
|
@@ -78,10 +74,13 @@ export function buildCcSection(cc: CcPromptInput) {
|
|
|
78
74
|
if (cc.resolved.sharedShapes.length > 0) {
|
|
79
75
|
parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
|
|
80
76
|
}
|
|
81
|
-
|
|
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) {
|
|
82
81
|
parts.push(
|
|
83
82
|
"## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
|
|
84
|
-
formatToolSignatures(
|
|
83
|
+
formatToolSignatures(dynamicTools),
|
|
85
84
|
);
|
|
86
85
|
}
|
|
87
86
|
if (cc.resolved.services.length > 0) {
|