Skip to content

Commit a5af518

Browse files
authored
feat(llm-builtin): improve cell link serialization with @link format and any-schema handling (commontoolsinc#2092)
feat(llm-builtin): improve cell link serialization with @link format and any-schema handling - Use `@link` format instead of `/` for cell link serialization to LLM tools - Add `@arrayEntry` decorator for array elements that reference cells - Output schema with the `read` tool - Handle `any` schema types by converting to cell links when appropriate - Add base64 validation to prevent false positive link detection - Add timeout handling for tool calls (30s timeout) - Refactor chatbot.tsx to use patternTool helper for listMentionable - Fix schema.ts to properly copy values before annotation to avoid mutations
1 parent 9c25a4b commit a5af518

File tree

3 files changed

+112
-35
lines changed

3 files changed

+112
-35
lines changed

packages/patterns/chatbot.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
handler,
1010
llmDialog,
1111
NAME,
12+
patternTool,
1213
recipe,
1314
Stream,
1415
UI,
@@ -126,19 +127,16 @@ export const TitleGenerator = recipe<
126127
return title;
127128
});
128129

129-
const listMentionable = handler<
130-
{
131-
/** A cell to store the result text */
132-
result: Cell<string>;
133-
},
134-
{ mentionable: Cell<MentionableCharm>[] }
130+
const listMentionable = recipe<
131+
{ mentionable: Array<MentionableCharm> },
132+
{ result: Array<{ label: string; cell: Cell<unknown> }> }
135133
>(
136-
(args, state) => {
137-
const namesList = state.mentionable.map((charm) => ({
138-
label: charm.get()[NAME],
134+
({ mentionable }) => {
135+
const result = mentionable.map((charm) => ({
136+
label: charm[NAME]!,
139137
cell: charm,
140138
}));
141-
args.result.set(JSON.stringify(namesList));
139+
return { result };
142140
},
143141
);
144142

@@ -166,11 +164,7 @@ export default recipe<ChatInput, ChatOutput>(
166164
const recentCharms = schemaifyWish<MentionableCharm[]>("#recent");
167165

168166
const assistantTools = {
169-
listMentionable: {
170-
description:
171-
"List all mentionable items in the space, read() the result.",
172-
handler: listMentionable({ mentionable }),
173-
},
167+
listMentionable: patternTool(listMentionable, { mentionable }),
174168
listRecent: {
175169
description:
176170
"List all recently viewed charms in the space, read() the result.",

packages/runner/src/builtins/llm-dialog.ts

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
getCellOrThrow,
3030
isCellResultForDereferencing,
3131
} from "../query-result-proxy.ts";
32+
import { ContextualFlowControl } from "../cfc.ts";
3233

3334
// Avoid importing from @commontools/charm to prevent circular deps in tests
3435

@@ -39,6 +40,7 @@ const logger = getLogger("llm-dialog", {
3940

4041
const client = new LLMClient();
4142
const REQUEST_TIMEOUT = 1000 * 60 * 5; // 5 minutes
43+
const TOOL_CALL_TIMEOUT = 1000 * 30 * 1; // 30 seconds
4244

4345
/**
4446
* Remove the injected `result` field from a JSON schema so tools don't
@@ -201,20 +203,42 @@ function createLLMFriendlyLink(link: NormalizedFullLink): string {
201203
*/
202204
function traverseAndSerialize(
203205
value: unknown,
206+
schema: JSONSchema | undefined,
207+
rootSchema: JSONSchema | undefined = schema,
204208
seen: Set<unknown> = new Set(),
205209
): unknown {
206210
if (!isRecord(value)) return value;
207211

212+
// If we encounter an `any` schema, turn value into a cell link
213+
if (
214+
seen.size > 0 && schema !== undefined &&
215+
ContextualFlowControl.isTrueSchema(schema) &&
216+
isCellResultForDereferencing(value)
217+
) {
218+
// Next step will turn this into a link
219+
value = getCellOrThrow(value);
220+
}
221+
222+
// Turn cells into a link, unless they are data: URIs and traverse instead
208223
if (isCell(value)) {
209-
const link = value.getAsNormalizedFullLink();
210-
return { "/": encodeJsonPointer(["", link.id, ...link.path]) };
224+
const link = value.resolveAsCell().getAsNormalizedFullLink();
225+
if (link.id.startsWith("data:")) {
226+
return traverseAndSerialize(value.get(), schema, rootSchema, seen);
227+
} else {
228+
return { "@link": encodeJsonPointer(["", link.id, ...link.path]) };
229+
}
211230
}
212231

213232
// If we've already seen this and it can be mapped to a cell, serialized as
214233
// cell link, otherwise throw (this should never happen in our cases)
215234
if (seen.has(value)) {
216235
if (isCellResultForDereferencing(value)) {
217-
return traverseAndSerialize(getCellOrThrow(value), seen);
236+
return traverseAndSerialize(
237+
getCellOrThrow(value),
238+
schema,
239+
rootSchema,
240+
seen,
241+
);
218242
} else {
219243
throw new Error(
220244
"Cannot serialize a value that has already been seen and cannot be mapped to a cell.",
@@ -223,13 +247,43 @@ function traverseAndSerialize(
223247
}
224248
seen.add(value);
225249

250+
const cfc = new ContextualFlowControl();
251+
226252
if (Array.isArray(value)) {
227-
return value.map((v) => traverseAndSerialize(v, seen));
253+
return value.map((v, index) => {
254+
const linkSchema = schema !== undefined
255+
? cfc.schemaAtPath(schema, [index.toString()], rootSchema)
256+
: undefined;
257+
let result = traverseAndSerialize(v, linkSchema, rootSchema, seen);
258+
// Decorate array entries with links that point to underlying cells, if
259+
// any. Ignores data: URIs, since they're not useful as links for the LLM.
260+
if (isRecord(result) && isCellResultForDereferencing(v)) {
261+
const link = getCellOrThrow(v).resolveAsCell()
262+
.getAsNormalizedFullLink();
263+
if (!link.id.startsWith("data:")) {
264+
result = {
265+
"@arrayEntry": encodeJsonPointer(["", link.id, ...link.path]),
266+
...result,
267+
};
268+
}
269+
}
270+
return result;
271+
});
228272
} else {
229273
return Object.fromEntries(
230-
Object.entries(value).map((
274+
Object.entries(value as Record<string, unknown>).map((
231275
[key, value],
232-
) => [key, traverseAndSerialize(value, seen)]),
276+
) => [
277+
key,
278+
traverseAndSerialize(
279+
value,
280+
schema !== undefined
281+
? cfc.schemaAtPath(schema, [key], rootSchema)
282+
: undefined,
283+
rootSchema,
284+
seen,
285+
),
286+
]),
233287
);
234288
}
235289
}
@@ -254,10 +308,10 @@ function traverseAndCellify(
254308
// - it's a record with a single key "/"
255309
// - the value of the "/" key is a string that matches the URI pattern
256310
if (
257-
isRecord(value) && typeof value["/"] === "string" &&
258-
Object.keys(value).length === 1 && matchLLMFriendlyLink.test(value["/"])
311+
isRecord(value) && typeof value["@link"] === "string" &&
312+
Object.keys(value).length === 1 && matchLLMFriendlyLink.test(value["@link"])
259313
) {
260-
const link = parseLLMFriendlyLink(value["/"], space);
314+
const link = parseLLMFriendlyLink(value["@link"], space);
261315
return runtime.getCellFromLink(link);
262316
}
263317
if (Array.isArray(value)) {
@@ -1363,15 +1417,17 @@ function handleSchema(
13631417
*/
13641418
function handleRead(
13651419
resolved: ResolvedToolCall & { type: "read" },
1366-
): { type: string; value: any } {
1367-
const serialized = traverseAndSerialize(resolved.cellRef.get());
1420+
): { type: string; value: unknown } {
1421+
let cell = resolved.cellRef;
1422+
if (!cell.schema) {
1423+
cell = cell.asSchema(getCellSchema(cell));
1424+
}
13681425

1369-
// Handle undefined values gracefully - return null for undefined/null
1370-
const value = serialized === undefined || serialized === null
1371-
? null
1372-
: JSON.parse(JSON.stringify(serialized));
1426+
const schema = cell.schema;
1427+
const serialized = traverseAndSerialize(cell.get(), schema);
13731428

1374-
return { type: "json", value };
1429+
// Handle undefined by returning null (valid JSON) instead
1430+
return { type: "json", value: serialized ?? null, ...(schema && { schema }) };
13751431
}
13761432

13771433
/**
@@ -1476,8 +1532,22 @@ async function handleRun(
14761532
const cancel = result.sink((r) => {
14771533
r !== undefined && resolve(r);
14781534
});
1479-
await promise;
1480-
cancel();
1535+
1536+
let timeout;
1537+
const timeoutPromise = new Promise((_, reject) => {
1538+
timeout = setTimeout(() => {
1539+
reject(new Error("Tool call timed out"));
1540+
}, TOOL_CALL_TIMEOUT);
1541+
}).then(() => {
1542+
throw new Error("Tool call timed out");
1543+
});
1544+
1545+
try {
1546+
await Promise.race([promise, timeoutPromise]);
1547+
} finally {
1548+
clearTimeout(timeout);
1549+
cancel();
1550+
}
14811551

14821552
// Get the actual entity ID from the result cell
14831553
const resultLink = createLLMFriendlyLink(result.getAsNormalizedFullLink());

packages/runner/src/schema.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -835,8 +835,21 @@ export function validateAndTransform(
835835
}
836836

837837
// Add the current value to seen before returning
838-
seen.push([seenKey, value]);
839-
return annotateWithBackToCellSymbols(value, runtime, link, tx);
838+
if (isRecord(value)) {
839+
const cloned = isObject(value)
840+
? { ...(value as Record<string, unknown>) }
841+
: [...(value as unknown[])];
842+
seen.push([seenKey, cloned]);
843+
return annotateWithBackToCellSymbols(
844+
cloned,
845+
runtime,
846+
link,
847+
tx,
848+
);
849+
} else {
850+
seen.push([seenKey, value]);
851+
return value;
852+
}
840853
}
841854

842855
/**

0 commit comments

Comments
 (0)