@@ -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
4041const client = new LLMClient ( ) ;
4142const 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 */
202204function 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 */
13641418function 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 ( ) ) ;
0 commit comments