@m6d/cortex-server 2.1.0 → 2.3.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 +23 -9
- package/contracts/README.md +22 -8
- package/contracts/src/client-tools/index.ts +56 -0
- package/contracts/src/graph/index.ts +30 -0
- package/contracts/{runtime.ts → src/runtime/index.ts} +7 -12
- package/contracts/{wire.ts → src/wire/index.ts} +25 -13
- package/dist/contracts/{graph.d.ts → src/graph/index.d.ts} +8 -8
- package/dist/contracts/{runtime.d.ts → src/runtime/index.d.ts} +9 -43
- package/dist/contracts/{wire.d.ts → src/wire/index.d.ts} +23 -13
- package/dist/src/lib/adapters/database/index.d.ts +0 -7
- package/dist/src/lib/adapters/database/message-content.d.ts +1 -2
- package/dist/src/lib/adapters/database/mssql/index.d.ts +0 -1
- package/dist/src/lib/adapters/database/mssql/messages.d.ts +0 -1
- package/dist/src/lib/adapters/database/postgres/index.d.ts +0 -1
- package/dist/src/lib/adapters/database/postgres/messages.d.ts +0 -1
- package/dist/src/lib/ai/cc-runtime.d.ts +2 -3
- package/dist/src/lib/ai/client-tools.d.ts +84 -0
- package/dist/src/lib/ai/tools/query-graph.tool.d.ts +1 -1
- package/dist/src/lib/ai/turn-tools.d.ts +8 -6
- package/dist/src/lib/cc/client.d.ts +4 -11
- package/dist/src/lib/cc/config-cache.d.ts +0 -1
- package/dist/src/lib/cc/registry.d.ts +6 -6
- package/dist/src/lib/cc/types.d.ts +1 -1
- package/dist/src/lib/config.d.ts +9 -4
- package/dist/src/lib/graph/index.d.ts +1 -1
- package/dist/src/lib/graph/resolver.d.ts +1 -1
- package/dist/src/lib/index.d.ts +2 -1
- package/dist/src/lib/types.d.ts +2 -2
- package/dist/src/lib/ws/connections.d.ts +1 -1
- package/package.json +2 -2
- package/src/lib/adapters/database/index.ts +0 -12
- package/src/lib/adapters/database/mssql/messages.ts +1 -28
- package/src/lib/adapters/database/postgres/messages.ts +1 -22
- package/src/lib/ai/cc-runtime.ts +7 -7
- package/src/lib/ai/client-tools.ts +288 -0
- package/src/lib/ai/index.ts +60 -47
- package/src/lib/ai/tools/search-tools.tool.ts +2 -2
- package/src/lib/ai/turn-tools.ts +7 -8
- package/src/lib/cc/client.ts +1 -1
- package/src/lib/cc/format.ts +2 -6
- package/src/lib/cc/registry.ts +7 -7
- package/src/lib/cc/types.ts +1 -1
- package/src/lib/config.ts +8 -3
- package/src/lib/index.ts +4 -0
- package/src/lib/routes/chat.ts +5 -5
- package/tsconfig.json +1 -1
- package/contracts/graph.ts +0 -36
- package/contracts/interactive.ts +0 -53
- package/dist/contracts/interactive.d.ts +0 -39
- package/dist/src/lib/ai/interactive.d.ts +0 -62
- package/src/lib/ai/interactive.ts +0 -364
- /package/contracts/{graph → src/graph/clients}/embed.ts +0 -0
- /package/contracts/{graph → src/graph/clients}/neo4j.ts +0 -0
- /package/contracts/{graph → src/graph}/helpers.ts +0 -0
- /package/contracts/{graph → src/graph}/schema.ts +0 -0
- /package/contracts/{graph → src/graph}/types.ts +0 -0
- /package/contracts/{rich-text.ts → src/rich-text/index.ts} +0 -0
- /package/dist/contracts/{graph → src/graph/clients}/embed.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph/clients}/neo4j.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/helpers.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/schema.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/types.d.ts +0 -0
- /package/dist/contracts/{rich-text.d.ts → src/rich-text/index.d.ts} +0 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
import { CLIENT_TOOL_ANSWERS_KEY } from "@cortex/contracts/wire";
|
|
3
|
+
import type {
|
|
4
|
+
ClientToolAnswer,
|
|
5
|
+
ClientToolBinding,
|
|
6
|
+
ClientToolInitiateResult,
|
|
7
|
+
} from "@cortex/contracts/wire";
|
|
8
|
+
import type { ResolvedCortexAgentConfig } from "@/config";
|
|
9
|
+
import type { ChatMessage, Thread } from "@/types";
|
|
10
|
+
import { ControlCenterClient } from "@/cc/client";
|
|
11
|
+
import type { CcRuntime } from "@/cc/registry";
|
|
12
|
+
import { recordRecentCcTool } from "@/cc/registry";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The wire-shape bindings stamped on the turn's final assistant message, so a
|
|
16
|
+
* parked client tool call can be initiated after any restart. Only names that
|
|
17
|
+
* actually won declaration merging are stamped — a call to a colliding
|
|
18
|
+
* consumer-owned tool must never be treated as a client tool. Undefined when
|
|
19
|
+
* nothing qualifies, keeping metadata lean.
|
|
20
|
+
*/
|
|
21
|
+
export function clientToolBindings(cc: CcRuntime | undefined, takenNames: ReadonlySet<string>) {
|
|
22
|
+
if (!cc) return undefined;
|
|
23
|
+
const entries = [...cc.clientTools]
|
|
24
|
+
.filter(([name]) => !takenNames.has(name))
|
|
25
|
+
.map(
|
|
26
|
+
([name, tool]) =>
|
|
27
|
+
[name, { toolId: tool.toolId, ...tool.embed } satisfies ClientToolBinding] as const,
|
|
28
|
+
);
|
|
29
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Null when the agent has no Control Center — every caller treats that as "skip". */
|
|
33
|
+
export function createCcClient(config: ResolvedCortexAgentConfig) {
|
|
34
|
+
return config.controlCenter ? new ControlCenterClient(config.controlCenter) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `clientToolAnswers` a continuation request carried under
|
|
39
|
+
* `forwardedProps`, validated structurally — the widget is not the only
|
|
40
|
+
* possible author of a request body. All-or-nothing: `[]` means the request
|
|
41
|
+
* carried no answers at all, while `null` means it carried a payload with any
|
|
42
|
+
* malformed entry — the caller must not act on such a request, neither by
|
|
43
|
+
* settling its valid remainder nor by running a turn for it.
|
|
44
|
+
*/
|
|
45
|
+
export function clientToolAnswersFrom(forwardedProps: Record<string, unknown>) {
|
|
46
|
+
const raw = forwardedProps[CLIENT_TOOL_ANSWERS_KEY];
|
|
47
|
+
if (raw === undefined) return [];
|
|
48
|
+
if (!Array.isArray(raw)) return null;
|
|
49
|
+
|
|
50
|
+
const answers = raw.flatMap((item) => {
|
|
51
|
+
const record = asRecord(item);
|
|
52
|
+
if (!record || typeof record.toolCallId !== "string" || !("output" in record)) return [];
|
|
53
|
+
const state = record.state;
|
|
54
|
+
if (state !== "complete" && state !== "error") return [];
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
toolCallId: record.toolCallId,
|
|
58
|
+
output: record.output,
|
|
59
|
+
state,
|
|
60
|
+
} satisfies ClientToolAnswer,
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
return answers.length === raw.length ? answers : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Applies client tool answers to the stored messages carrying their calls,
|
|
68
|
+
* mirroring the SDK's own settle shape (call part gains output + state, a
|
|
69
|
+
* tool-result part is appended). Already-settled calls are skipped, so a
|
|
70
|
+
* replayed continuation is a no-op. Returns only the messages that changed.
|
|
71
|
+
*/
|
|
72
|
+
export function answerStoredToolCalls(stored: ChatMessage[], answers: ClientToolAnswer[]) {
|
|
73
|
+
const byCallId = new Map(answers.map((answer) => [answer.toolCallId, answer]));
|
|
74
|
+
|
|
75
|
+
return stored.flatMap((message) => {
|
|
76
|
+
const pending = message.parts.filter(
|
|
77
|
+
(part) =>
|
|
78
|
+
part.type === "tool-call" &&
|
|
79
|
+
byCallId.has(part.id) &&
|
|
80
|
+
part.state !== "complete" &&
|
|
81
|
+
part.state !== "error",
|
|
82
|
+
);
|
|
83
|
+
if (pending.length === 0) return [];
|
|
84
|
+
|
|
85
|
+
let parts = message.parts;
|
|
86
|
+
for (const part of pending) {
|
|
87
|
+
if (part.type !== "tool-call") continue;
|
|
88
|
+
const answer = byCallId.get(part.id)!;
|
|
89
|
+
const content =
|
|
90
|
+
typeof answer.output === "string" ? answer.output : JSON.stringify(answer.output);
|
|
91
|
+
parts = parts
|
|
92
|
+
.map((candidate) =>
|
|
93
|
+
candidate === part
|
|
94
|
+
? { ...candidate, output: answer.output, state: answer.state }
|
|
95
|
+
: candidate,
|
|
96
|
+
)
|
|
97
|
+
.concat([
|
|
98
|
+
{ type: "tool-result", toolCallId: part.id, content, state: answer.state },
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
return [{ ...message, parts }];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether an assistant reply already follows the message carrying the
|
|
107
|
+
* answered calls — the mark of a continuation that completed. A settled call
|
|
108
|
+
* with nothing after it means the continuation died before replying, so a
|
|
109
|
+
* retried delivery must run rather than be discarded as a replay. Ids that
|
|
110
|
+
* match no stored call count as replied: there is nothing to run for them.
|
|
111
|
+
*/
|
|
112
|
+
function repliedAfterAnswers(stored: ChatMessage[], answers: ClientToolAnswer[]) {
|
|
113
|
+
const ids = new Set(answers.map((answer) => answer.toolCallId));
|
|
114
|
+
const index = stored.findLastIndex((message) =>
|
|
115
|
+
message.parts.some((part) => part.type === "tool-call" && ids.has(part.id)),
|
|
116
|
+
);
|
|
117
|
+
if (index === -1) return true;
|
|
118
|
+
return stored.slice(index + 1).some((message) => message.role === "assistant");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The server-authoritative half of a client tool continuation: the widget
|
|
123
|
+
* sends `messages: []` plus the answers, and the transcript is updated in
|
|
124
|
+
* place — the answered assistant message never round-trips, so the id the
|
|
125
|
+
* SDK's park-boundary snapshot failed to preserve no longer matters.
|
|
126
|
+
* `settled` is how many messages the answers changed; with `replied` it lets
|
|
127
|
+
* the caller tell a true replay (nothing settled, reply already stored) from
|
|
128
|
+
* a retry after a continuation that died before replying.
|
|
129
|
+
*/
|
|
130
|
+
export async function applyClientToolAnswers(
|
|
131
|
+
config: ResolvedCortexAgentConfig,
|
|
132
|
+
userId: string,
|
|
133
|
+
threadId: string,
|
|
134
|
+
answers: ClientToolAnswer[],
|
|
135
|
+
) {
|
|
136
|
+
const stored = (await config.db.messages.list(userId, threadId)).map((row) => row.content);
|
|
137
|
+
|
|
138
|
+
// An id matching no stored call — or repeated within the payload — voids
|
|
139
|
+
// the whole request: nothing settles, not even a valid remainder. A
|
|
140
|
+
// retry's ids always exist (settled by the attempt that died) and never
|
|
141
|
+
// repeat, so only a broken or forged request looks like this.
|
|
142
|
+
const storedCallIds = new Set(
|
|
143
|
+
stored.flatMap((message) =>
|
|
144
|
+
message.parts.flatMap((part) => (part.type === "tool-call" ? [part.id] : [])),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const ids = answers.map((answer) => answer.toolCallId);
|
|
148
|
+
if (new Set(ids).size !== ids.length || !ids.every((id) => storedCallIds.has(id))) {
|
|
149
|
+
return { settled: 0, replied: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const updated = answerStoredToolCalls(stored, answers);
|
|
153
|
+
if (updated.length) await config.db.messages.upsert(threadId, updated);
|
|
154
|
+
return { settled: updated.length, replied: repliedAfterAnswers(stored, answers) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** How long initiate waits out the turn-commit race before giving up. */
|
|
158
|
+
const PENDING_CALL_RETRIES = 6;
|
|
159
|
+
const PENDING_CALL_RETRY_MS = 700;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The widget initiates the moment its stream goes idle, which can beat the
|
|
163
|
+
* turn's commit to the database — so a missing call is retried briefly before
|
|
164
|
+
* it becomes a 404. An *answered* call 404s immediately: it was settled
|
|
165
|
+
* elsewhere (another tab) and the widget should fall back to its pill.
|
|
166
|
+
*/
|
|
167
|
+
async function findPendingClientToolCall(
|
|
168
|
+
config: ResolvedCortexAgentConfig,
|
|
169
|
+
userId: string,
|
|
170
|
+
threadId: string,
|
|
171
|
+
toolCallId: string,
|
|
172
|
+
) {
|
|
173
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
174
|
+
const stored = await config.db.messages.list(userId, threadId);
|
|
175
|
+
const messages = stored.map((row) => row.content);
|
|
176
|
+
const answered = messages.some((candidate) =>
|
|
177
|
+
candidate.parts.some(
|
|
178
|
+
(part) => part.type === "tool-result" && part.toolCallId === toolCallId,
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
if (answered) return null;
|
|
182
|
+
|
|
183
|
+
for (const message of messages) {
|
|
184
|
+
const call = message.parts.find(
|
|
185
|
+
(part) => part.type === "tool-call" && part.id === toolCallId,
|
|
186
|
+
);
|
|
187
|
+
if (call?.type === "tool-call") return { message, call };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (attempt >= PENDING_CALL_RETRIES) return null;
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, PENDING_CALL_RETRY_MS));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `POST /chat/:chatId/tools/:toolCallId/initiate` — runs the interactive
|
|
197
|
+
* tool's initiate call (the tool's own endpoint) and hands the widget its
|
|
198
|
+
* embed payload. The model never sees this payload. The idempotency key is
|
|
199
|
+
* pinned to the tool call, so a reload mid-flow reuses the created session
|
|
200
|
+
* instead of opening a second one. The result the page later posts back is
|
|
201
|
+
* relayed to the agent as-is — verifying it is the integrating backend's job.
|
|
202
|
+
*/
|
|
203
|
+
export async function initiateClientTool(options: {
|
|
204
|
+
config: ResolvedCortexAgentConfig;
|
|
205
|
+
thread: Thread;
|
|
206
|
+
userId: string;
|
|
207
|
+
token: string;
|
|
208
|
+
toolCallId: string;
|
|
209
|
+
}) {
|
|
210
|
+
const { config, thread, userId, token, toolCallId } = options;
|
|
211
|
+
const pending = await findPendingClientToolCall(config, userId, thread.id, toolCallId);
|
|
212
|
+
if (!pending) {
|
|
213
|
+
throw new HTTPException(404, { message: "No pending tool call with this id" });
|
|
214
|
+
}
|
|
215
|
+
const { message, call } = pending;
|
|
216
|
+
|
|
217
|
+
const binding = message.metadata?.clientTools?.[call.name];
|
|
218
|
+
const ccClient = createCcClient(config);
|
|
219
|
+
if (!binding || !ccClient) {
|
|
220
|
+
return { embed: false } satisfies ClientToolInitiateResult;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const result = await ccClient.execute(
|
|
224
|
+
config.agentId,
|
|
225
|
+
binding.toolId,
|
|
226
|
+
{
|
|
227
|
+
input: asRecord(call.input) ?? asRecord(parseJson(call.arguments)) ?? {},
|
|
228
|
+
context: { threadId: thread.id, userId },
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
readOnly: false,
|
|
232
|
+
endUserToken: token,
|
|
233
|
+
threadId: thread.id,
|
|
234
|
+
turnKey: toolCallId,
|
|
235
|
+
stepIndex: 0,
|
|
236
|
+
callIndex: 0,
|
|
237
|
+
idempotencyKey: `${thread.id}:${toolCallId}:initiate`,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
if (!result) {
|
|
241
|
+
throw new HTTPException(502, { message: "The tool is temporarily unavailable" });
|
|
242
|
+
}
|
|
243
|
+
if ("error" in result) {
|
|
244
|
+
throw new HTTPException(502, { message: `initiate failed: ${result.error.kind}` });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const embedUrl = asRecord(result.output)?.embedUrl;
|
|
248
|
+
if (typeof embedUrl !== "string" || !isOnOrigin(embedUrl, binding.embedOrigin)) {
|
|
249
|
+
throw new HTTPException(502, {
|
|
250
|
+
message: "initiate did not return an embedUrl on the configured origin",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
recordRecentCcTool(thread.id, call.name);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
embed: true,
|
|
258
|
+
embedUrl,
|
|
259
|
+
surface: binding.surface,
|
|
260
|
+
embedOrigin: binding.embedOrigin,
|
|
261
|
+
} satisfies ClientToolInitiateResult;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseJson(value: string | undefined) {
|
|
265
|
+
if (value === undefined) return undefined;
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(value) as unknown;
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function asRecord(value: unknown) {
|
|
274
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
275
|
+
? (value as Record<string, unknown>)
|
|
276
|
+
: undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isOnOrigin(url: string, origin: string) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = new URL(url);
|
|
282
|
+
// http(s) only: javascript:/data: URLs normalize to origin "null",
|
|
283
|
+
// which a bare equality check could otherwise be tricked into passing.
|
|
284
|
+
return ["http:", "https:"].includes(parsed.protocol) && parsed.origin === origin;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
package/src/lib/ai/index.ts
CHANGED
|
@@ -29,13 +29,14 @@ import {
|
|
|
29
29
|
buildTurnTools,
|
|
30
30
|
createToolInstrumentation,
|
|
31
31
|
hasDefaultAttachmentInterceptor,
|
|
32
|
-
|
|
32
|
+
clientToolDeclarations,
|
|
33
33
|
} from "./turn-tools";
|
|
34
34
|
import {
|
|
35
|
+
applyClientToolAnswers,
|
|
36
|
+
clientToolAnswersFrom,
|
|
35
37
|
createCcClient,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} from "./interactive";
|
|
38
|
+
clientToolBindings,
|
|
39
|
+
} from "./client-tools";
|
|
39
40
|
import { createInspector } from "./inspector";
|
|
40
41
|
import { commitBeforeTerminal } from "./commit-gate";
|
|
41
42
|
import { finishTurn } from "./finish-turn";
|
|
@@ -57,33 +58,12 @@ export async function startTurn(
|
|
|
57
58
|
requestContext: Record<string, unknown>,
|
|
58
59
|
config: ResolvedCortexAgentConfig,
|
|
59
60
|
) {
|
|
61
|
+
if (await absorbContinuationAnswers(params, config, userId, thread.id)) return null;
|
|
62
|
+
|
|
60
63
|
const run = await startRun(thread.id);
|
|
61
64
|
try {
|
|
62
|
-
|
|
63
|
-
// once a client-side tool has answered — without that answer stored, the
|
|
64
|
-
// next turn replays an assistant message whose tool calls nothing ever
|
|
65
|
-
// resolved, and the provider rejects the thread from there on. Everything
|
|
66
|
-
// else the server generated stays the server's; the repository keeps the
|
|
67
|
-
// usage and model id only it ever knew.
|
|
68
|
-
const incoming = params.messages
|
|
69
|
-
.map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
|
|
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.
|
|
65
|
+
const lastUserMessage = await absorbIncoming(params, config, userId, thread.id);
|
|
75
66
|
const ccClient = createCcClient(config);
|
|
76
|
-
await settleInteractiveToolResults({
|
|
77
|
-
messages: incoming,
|
|
78
|
-
thread,
|
|
79
|
-
userId,
|
|
80
|
-
token,
|
|
81
|
-
config,
|
|
82
|
-
ccClient,
|
|
83
|
-
});
|
|
84
|
-
await config.db.messages.upsert(thread.id, incoming);
|
|
85
|
-
|
|
86
|
-
const lastUserMessage = incoming.findLast((message) => message.role === "user");
|
|
87
67
|
const prompt = textOf(lastUserMessage);
|
|
88
68
|
if (thread.title === null && prompt) {
|
|
89
69
|
void generateTitle(thread.id, prompt, userId, config);
|
|
@@ -234,12 +214,12 @@ export async function startTurn(
|
|
|
234
214
|
const stream = chat({
|
|
235
215
|
adapter: model,
|
|
236
216
|
// Client tool declarations ride in on every request, so the server
|
|
237
|
-
// never re-declares them.
|
|
217
|
+
// never re-declares them. CC client tools join them: also
|
|
238
218
|
// executor-less, answered by the widget. Consumer-owned names win
|
|
239
|
-
// the collision, and only names that won are treated as
|
|
219
|
+
// the collision, and only names that won are treated as a client tool.
|
|
240
220
|
tools: mergeAgentTools(tools, [
|
|
241
221
|
...params.tools,
|
|
242
|
-
...
|
|
222
|
+
...clientToolDeclarations(cc, consumerToolNames),
|
|
243
223
|
]),
|
|
244
224
|
messages: convertMessagesToModelMessages(
|
|
245
225
|
fitToContextWindow(contextMessages, systemPrompt, tools, config),
|
|
@@ -292,9 +272,9 @@ export async function startTurn(
|
|
|
292
272
|
modelId: inspector.modelId,
|
|
293
273
|
isAborted: run.abortController.signal.aborted,
|
|
294
274
|
tokenUsage: inspector.tokenUsage,
|
|
295
|
-
// Stamped so a parked
|
|
296
|
-
//
|
|
297
|
-
|
|
275
|
+
// Stamped so a parked client tool call can be initiated
|
|
276
|
+
// after any reload or server restart.
|
|
277
|
+
clientTools: clientToolBindings(cc, consumerToolNames),
|
|
298
278
|
} satisfies MessageMetadata,
|
|
299
279
|
};
|
|
300
280
|
}
|
|
@@ -328,20 +308,53 @@ function toChatMessage(message: UIMessage) {
|
|
|
328
308
|
}
|
|
329
309
|
|
|
330
310
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
311
|
+
* Applies a parked client tool's answers to the stored messages carrying the
|
|
312
|
+
* calls — before the thread is claimed, because applying is idempotent and
|
|
313
|
+
* needs no run. True means the request was a stale replay: it settled
|
|
314
|
+
* nothing, the reply it would produce is already stored, and it carries no
|
|
315
|
+
* user message — so there is no turn to run, and claiming would only abort
|
|
316
|
+
* the legitimate run it duplicates. A retry whose earlier continuation died
|
|
317
|
+
* before replying settles nothing too, but has no stored reply, so it runs.
|
|
318
|
+
* A malformed payload voids the whole request the same way: nothing settles
|
|
319
|
+
* and no turn runs, so a broken client can neither partially settle a turn's
|
|
320
|
+
* calls nor mint an empty-message model turn.
|
|
338
321
|
*/
|
|
339
|
-
function
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
322
|
+
async function absorbContinuationAnswers(
|
|
323
|
+
params: TurnParams,
|
|
324
|
+
config: ResolvedCortexAgentConfig,
|
|
325
|
+
userId: string,
|
|
326
|
+
threadId: string,
|
|
327
|
+
) {
|
|
328
|
+
const answers = clientToolAnswersFrom(params.forwardedProps);
|
|
329
|
+
if (answers === null) return true;
|
|
330
|
+
if (!answers.length) return false;
|
|
331
|
+
|
|
332
|
+
const { settled, replied } = await applyClientToolAnswers(config, userId, threadId, answers);
|
|
333
|
+
return settled === 0 && replied && !params.messages.some((message) => message.role === "user");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* What a request contributes to the stored transcript: the user messages the
|
|
338
|
+
* client wrote — nothing else, because the server owns history (a parked
|
|
339
|
+
* client tool's answers go through `absorbContinuationAnswers`; the answered
|
|
340
|
+
* assistant turn itself never crosses the wire, because the SDK's
|
|
341
|
+
* park-boundary snapshot does not preserve its message id and round-tripping
|
|
342
|
+
* it duplicated the stored message). Returns the newest user message, the
|
|
343
|
+
* turn's prompt, when the request carried one.
|
|
344
|
+
*/
|
|
345
|
+
async function absorbIncoming(
|
|
346
|
+
params: TurnParams,
|
|
347
|
+
config: ResolvedCortexAgentConfig,
|
|
348
|
+
userId: string,
|
|
349
|
+
threadId: string,
|
|
350
|
+
) {
|
|
351
|
+
const incoming = params.messages
|
|
352
|
+
.map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
|
|
353
|
+
.filter((message) => message.role === "user");
|
|
354
|
+
|
|
355
|
+
if (incoming.length) await config.db.messages.upsert(threadId, incoming);
|
|
356
|
+
|
|
357
|
+
return incoming.findLast((message) => message.role === "user");
|
|
345
358
|
}
|
|
346
359
|
|
|
347
360
|
function textOf(message: ChatMessage | undefined) {
|
|
@@ -20,10 +20,10 @@ 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
|
+
// Client tools cannot join a turn already underway (the model's
|
|
24
24
|
// tool set is fixed at turn start), so surfacing them here would only
|
|
25
25
|
// advertise dead ends.
|
|
26
|
-
const callable = result.tools.filter((tool) => !tool.
|
|
26
|
+
const callable = result.tools.filter((tool) => !tool.embed);
|
|
27
27
|
if (callable.length === 0) return "No matching tools found.";
|
|
28
28
|
|
|
29
29
|
const shapes =
|
package/src/lib/ai/turn-tools.ts
CHANGED
|
@@ -29,7 +29,9 @@ type TurnToolsOptions = {
|
|
|
29
29
|
* configured, then the agent's own tools.
|
|
30
30
|
*
|
|
31
31
|
* User tools are appended last and deliberately win on name collision — an agent
|
|
32
|
-
* must be able to replace a built-in it doesn't want.
|
|
32
|
+
* must be able to replace a built-in it doesn't want. A user tool without an
|
|
33
|
+
* `execute` function is a static client tool: it passes through to the model
|
|
34
|
+
* declaration untouched, and the runtime streams its calls to the widget.
|
|
33
35
|
*/
|
|
34
36
|
export function buildTurnTools(options: TurnToolsOptions) {
|
|
35
37
|
const { config, cc, neo4j, thread, userId, token, session, requestContext, threadAttachments } =
|
|
@@ -111,20 +113,17 @@ export function createToolInstrumentation(
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
*
|
|
116
|
+
* CC client tools are declared like the request's client tools: no
|
|
115
117
|
* executor, so the call streams to the widget as a tool-call part and the run
|
|
116
118
|
* parks until the widget answers. The declaration carries the tool's published
|
|
117
119
|
* input schema — function-calling models ignore schemas described only in
|
|
118
120
|
* prose — with a permissive object as the fallback for older Control Centers.
|
|
119
121
|
* Names the consumer already claimed are skipped: those calls belong to the
|
|
120
|
-
* consumer's tool, and must neither be declared nor stamped as
|
|
122
|
+
* consumer's tool, and must neither be declared nor stamped as client tools.
|
|
121
123
|
*/
|
|
122
|
-
export function
|
|
123
|
-
cc: CcRuntime | undefined,
|
|
124
|
-
takenNames: ReadonlySet<string>,
|
|
125
|
-
) {
|
|
124
|
+
export function clientToolDeclarations(cc: CcRuntime | undefined, takenNames: ReadonlySet<string>) {
|
|
126
125
|
if (!cc) return [];
|
|
127
|
-
return [...cc.
|
|
126
|
+
return [...cc.clientTools]
|
|
128
127
|
.filter(([name]) => !takenNames.has(name))
|
|
129
128
|
.map(([name, tool]) => ({
|
|
130
129
|
name,
|
package/src/lib/cc/client.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type ExecuteOptions = {
|
|
|
22
22
|
stepIndex: number;
|
|
23
23
|
callIndex: number;
|
|
24
24
|
/** Overrides the composed key — used where stability must outlive the turn
|
|
25
|
-
* counters, e.g. one
|
|
25
|
+
* counters, e.g. one initiate per tool call across reloads. */
|
|
26
26
|
idempotencyKey?: string;
|
|
27
27
|
timeoutMs?: number;
|
|
28
28
|
abortSignal?: AbortSignal;
|
package/src/lib/cc/format.ts
CHANGED
|
@@ -66,17 +66,13 @@ export function buildCcSection(cc: CcPromptInput) {
|
|
|
66
66
|
const prompt = substituteVariables(cc.config.systemPrompt, cc.variables).trim();
|
|
67
67
|
if (prompt) parts.push(prompt);
|
|
68
68
|
|
|
69
|
-
if (cc.config.catalogBlurb) {
|
|
70
|
-
parts.push(`## Capabilities\n${cc.config.catalogBlurb}`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
69
|
if (cc.resolved) {
|
|
74
70
|
if (cc.resolved.sharedShapes.length > 0) {
|
|
75
71
|
parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
|
|
76
72
|
}
|
|
77
|
-
//
|
|
73
|
+
// Client tools are declared to the model as real tools, so they
|
|
78
74
|
// stay out of the sandbox-only Dynamic Tools section.
|
|
79
|
-
const dynamicTools = cc.resolved.tools.filter((tool) => !tool.
|
|
75
|
+
const dynamicTools = cc.resolved.tools.filter((tool) => !tool.embed);
|
|
80
76
|
if (dynamicTools.length > 0) {
|
|
81
77
|
parts.push(
|
|
82
78
|
"## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
|
package/src/lib/cc/registry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ControlCenterClient } from "./client";
|
|
2
|
-
import type { RuntimeAgentConfig,
|
|
2
|
+
import type { RuntimeAgentConfig, ToolEmbed } from "./types";
|
|
3
3
|
|
|
4
4
|
export type CcToolBinding = {
|
|
5
5
|
toolId: string;
|
|
@@ -7,14 +7,14 @@ export type CcToolBinding = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* A client tool resolved for this turn. Declared to the model as a real
|
|
11
11
|
* client-executed tool — never a sandbox binding — so its call streams to the
|
|
12
12
|
* widget as a tool-call part and parks the run until the flow settles.
|
|
13
13
|
*/
|
|
14
|
-
export type
|
|
14
|
+
export type CcClientTool = {
|
|
15
15
|
toolId: string;
|
|
16
16
|
signature: string;
|
|
17
|
-
|
|
17
|
+
embed: ToolEmbed;
|
|
18
18
|
inputSchema?: Record<string, unknown>;
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -26,10 +26,10 @@ export type CcToolRegistry = Map<string, CcToolBinding>;
|
|
|
26
26
|
|
|
27
27
|
export function registerCcTools(
|
|
28
28
|
registry: CcToolRegistry,
|
|
29
|
-
tools: { name: string; toolId: string; readOnly: boolean;
|
|
29
|
+
tools: { name: string; toolId: string; readOnly: boolean; embed?: ToolEmbed }[],
|
|
30
30
|
) {
|
|
31
31
|
for (const tool of tools) {
|
|
32
|
-
if (tool.
|
|
32
|
+
if (tool.embed) continue;
|
|
33
33
|
registry.set(tool.name, { toolId: tool.toolId, readOnly: tool.readOnly });
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -63,7 +63,7 @@ export type CcRuntime = {
|
|
|
63
63
|
agentId: string;
|
|
64
64
|
config: RuntimeAgentConfig;
|
|
65
65
|
registry: CcToolRegistry;
|
|
66
|
-
|
|
66
|
+
clientTools: Map<string, CcClientTool>;
|
|
67
67
|
threadId: string;
|
|
68
68
|
turnKey: string;
|
|
69
69
|
userId: string;
|
package/src/lib/cc/types.ts
CHANGED
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnyServerTool } from "@tanstack/ai";
|
|
1
|
+
import type { AnyClientTool, AnyServerTool } from "@tanstack/ai";
|
|
2
2
|
import type { DatabaseAdapter } from "./adapters/database/index";
|
|
3
3
|
import type { StorageAdapter } from "./adapters/storage/index";
|
|
4
4
|
import type { DomainDef } from "@cortex/contracts/graph";
|
|
@@ -6,8 +6,13 @@ import type { RequestInterceptorOptions } from "./ai/interceptors/request-interc
|
|
|
6
6
|
import type { ContextConfig } from "./ai/context/types";
|
|
7
7
|
import type { ChatMessage, Thread } from "./types";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* An agent's tools, as `chat()` takes them. A tool with an `execute` function
|
|
11
|
+
* runs on the server; one without is a static client tool — declared to the
|
|
12
|
+
* model, streamed to the widget as a tool-call part, and answered by the host
|
|
13
|
+
* app (`hooks.onToolCall` or a `toolComponents` entry with `setOutput`).
|
|
14
|
+
*/
|
|
15
|
+
export type ToolSet = ReadonlyArray<AnyServerTool | AnyClientTool>;
|
|
11
16
|
|
|
12
17
|
type ModelConfig = {
|
|
13
18
|
baseURL: string;
|
package/src/lib/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type {
|
|
|
33
33
|
} from "./ai/interceptors/request-interceptor";
|
|
34
34
|
export { createRequestInterceptor } from "./ai/interceptors/request-interceptor";
|
|
35
35
|
|
|
36
|
+
// Re-exported so consumers define tools (server or static client) without a
|
|
37
|
+
// direct @tanstack/ai import.
|
|
38
|
+
export { toolDefinition } from "@tanstack/ai";
|
|
39
|
+
|
|
36
40
|
// Tools (consumers may register custom tools or use built-in ones)
|
|
37
41
|
export { createQueryGraphTool } from "./ai/tools/query-graph.tool";
|
|
38
42
|
export { createExecuteCodeTool } from "./ai/tools/execute-code.tool";
|
package/src/lib/routes/chat.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
6
6
|
import type { CortexAppEnv } from "@/types";
|
|
7
7
|
import { requireAuth } from "@/auth/middleware";
|
|
8
8
|
import { startTurn } from "@/ai/index";
|
|
9
|
-
import {
|
|
9
|
+
import { initiateClientTool } from "@/ai/client-tools";
|
|
10
10
|
import { abortRun, getRunId, joinLog, offsetBelongsToRun } from "@/ai/active-runs";
|
|
11
11
|
import { notify } from "@/ws/connections";
|
|
12
12
|
import { requireOwnedThread } from "./owned-thread";
|
|
@@ -93,9 +93,9 @@ export function createChatRoutes() {
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Runs
|
|
97
|
-
* returns the widget's embed payload. `
|
|
98
|
-
* widget the call is not an
|
|
96
|
+
* Runs a client tool's initiate call for a pending tool call and
|
|
97
|
+
* returns the widget's embed payload. `embed: false` tells the
|
|
98
|
+
* widget the call is not an CC client tool — fall back to its
|
|
99
99
|
* default rendering. The payload never reaches the model.
|
|
100
100
|
*/
|
|
101
101
|
app.post("/chat/:chatId/tools/:toolCallId/initiate", requireAuth, async function (c) {
|
|
@@ -104,7 +104,7 @@ export function createChatRoutes() {
|
|
|
104
104
|
const thread = await requireOwnedThread(c, c.req.param("chatId"));
|
|
105
105
|
|
|
106
106
|
return c.json(
|
|
107
|
-
await
|
|
107
|
+
await initiateClientTool({
|
|
108
108
|
config,
|
|
109
109
|
thread,
|
|
110
110
|
userId,
|
package/tsconfig.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// `vendor` copies them into ./contracts for build and pack. The first
|
|
24
24
|
// candidate exists only then (and in the published tarball) — inside
|
|
25
25
|
// the repo resolution falls through to the workspace source.
|
|
26
|
-
"@cortex/contracts/*": ["./contracts/*", "../../internal/contracts/*"],
|
|
26
|
+
"@cortex/contracts/*": ["./contracts/src/*", "../../internal/contracts/src/*"],
|
|
27
27
|
// Internal-only. tsc does not rewrite aliases on emit, so `build` runs
|
|
28
28
|
// tsc-alias to turn these back into relative paths in dist/**/*.d.ts —
|
|
29
29
|
// a consumer's tsc knows nothing about our `@/`. The published raw src
|