@@ -25,8 +25,10 @@ import {
2525 getOpenAIConfig ,
2626} from "@dafthunk/runtime/utils/ai-gateway" ;
2727import { createCodeModeToolDefinition } from "@dafthunk/runtime/utils/code-mode" ;
28+ import { schemaToJsonSchema } from "@dafthunk/runtime/utils/schema-to-json-schema" ;
2829import type { TokenPricing } from "@dafthunk/runtime/utils/usage" ;
2930import { calculateTokenUsage } from "@dafthunk/runtime/utils/usage" ;
31+ import type { Schema } from "@dafthunk/types" ;
3032import { GoogleGenAI } from "@google/genai" ;
3133import { Agent } from "agents" ;
3234import OpenAI from "openai" ;
@@ -74,6 +76,8 @@ export interface AgentRunRequest {
7476 agentId ?: string ;
7577 /** Max number of previous messages to load from conversation history */
7678 maxHistory ?: number ;
79+ /** Schema to constrain the final output format (structured JSON output) */
80+ schema ?: Record < string , unknown > ;
7781}
7882
7983export interface AgentRunResponse {
@@ -221,6 +225,28 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
221225 body . maxHistory ?? 50
222226 ) ;
223227
228+ // Convert schema if provided
229+ const jsonSchema =
230+ body . schema &&
231+ typeof body . schema === "object" &&
232+ "fields" in body . schema
233+ ? schemaToJsonSchema ( body . schema as unknown as Schema )
234+ : undefined ;
235+
236+ // Build callFinalLLM that applies schema constraint on the final turn
237+ const callFinalLLM = jsonSchema
238+ ? ( messages : AgentMessage [ ] , tools : ToolDefinition [ ] ) =>
239+ this . callLLM (
240+ body . provider ,
241+ body . model ,
242+ body . instructions ,
243+ messages ,
244+ tools ,
245+ geminiBuiltInTools ,
246+ jsonSchema
247+ )
248+ : undefined ;
249+
224250 // Run the agent loop
225251 const result = await runAgentLoop ( {
226252 userMessage,
@@ -235,6 +261,7 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
235261 tools ,
236262 geminiBuiltInTools
237263 ) ,
264+ callFinalLLM,
238265 onStepComplete : async ( state ) => {
239266 this . ctx . storage . sql . exec (
240267 `UPDATE agent_runs SET state = ?, updated_at = datetime('now') WHERE run_id = ?` ,
@@ -388,6 +415,28 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
388415 body . maxHistory ?? 50
389416 ) ;
390417
418+ // Convert schema if provided
419+ const jsonSchema =
420+ body . schema &&
421+ typeof body . schema === "object" &&
422+ "fields" in body . schema
423+ ? schemaToJsonSchema ( body . schema as unknown as Schema )
424+ : undefined ;
425+
426+ // Build callFinalLLM that applies schema constraint on the final turn
427+ const callFinalLLM = jsonSchema
428+ ? ( messages : AgentMessage [ ] , tools : ToolDefinition [ ] ) =>
429+ this . callLLM (
430+ body . provider ,
431+ body . model ,
432+ body . instructions ,
433+ messages ,
434+ tools ,
435+ geminiBuiltInTools ,
436+ jsonSchema
437+ )
438+ : undefined ;
439+
391440 const result = await runAgentLoop ( {
392441 userMessage,
393442 tools : toolDefinitions ,
@@ -401,6 +450,7 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
401450 tools ,
402451 geminiBuiltInTools
403452 ) ,
453+ callFinalLLM,
404454 onStepComplete : async ( state ) => {
405455 this . ctx . storage . sql . exec (
406456 `UPDATE agent_runs SET state = ?, updated_at = datetime('now') WHERE run_id = ?` ,
@@ -639,23 +689,31 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
639689 instructions : string ,
640690 messages : AgentMessage [ ] ,
641691 tools : ToolDefinition [ ] ,
642- builtInTools ?: Record < string , unknown > [ ]
692+ builtInTools ?: Record < string , unknown > [ ] ,
693+ schema ?: Record < string , unknown >
643694 ) : Promise < LLMResponse > {
644695 switch ( provider ) {
645696 case "anthropic" :
646- return this . callAnthropic ( model , instructions , messages , tools ) ;
697+ return this . callAnthropic ( model , instructions , messages , tools , schema ) ;
647698 case "google" :
648699 return this . callGoogle (
649700 model ,
650701 instructions ,
651702 messages ,
652703 tools ,
653- builtInTools
704+ builtInTools ,
705+ schema
654706 ) ;
655707 case "openai" :
656- return this . callOpenAI ( model , instructions , messages , tools ) ;
708+ return this . callOpenAI ( model , instructions , messages , tools , schema ) ;
657709 case "workers-ai" :
658- return this . callWorkersAI ( model , instructions , messages , tools ) ;
710+ return this . callWorkersAI (
711+ model ,
712+ instructions ,
713+ messages ,
714+ tools ,
715+ schema
716+ ) ;
659717 default :
660718 throw new Error ( `Unsupported provider: ${ provider } ` ) ;
661719 }
@@ -667,7 +725,8 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
667725 model : string ,
668726 instructions : string ,
669727 messages : AgentMessage [ ] ,
670- tools : ToolDefinition [ ]
728+ tools : ToolDefinition [ ] ,
729+ schema ?: Record < string , unknown >
671730 ) : Promise < LLMResponse > {
672731 const client = new Anthropic ( {
673732 apiKey : "gateway-managed" ,
@@ -716,11 +775,16 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
716775 input_schema : t . parameters as Anthropic . Tool . InputSchema ,
717776 } ) ) ;
718777
778+ // When schema is provided, append a JSON constraint to the system prompt
779+ const systemPrompt = schema
780+ ? `${ instructions } \n\nYou MUST respond with valid JSON matching this schema:\n${ JSON . stringify ( schema ) } `
781+ : instructions ;
782+
719783 const response = await client . messages . create ( {
720784 model,
721785 max_tokens : 4096 ,
722786 messages : anthropicMessages ,
723- ...( instructions && { system : instructions } ) ,
787+ ...( systemPrompt && { system : systemPrompt } ) ,
724788 ...( anthropicTools . length > 0 && { tools : anthropicTools } ) ,
725789 } ) ;
726790
@@ -755,7 +819,8 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
755819 instructions : string ,
756820 messages : AgentMessage [ ] ,
757821 tools : ToolDefinition [ ] ,
758- builtInTools ?: Record < string , unknown > [ ]
822+ builtInTools ?: Record < string , unknown > [ ] ,
823+ schema ?: Record < string , unknown >
759824 ) : Promise < LLMResponse > {
760825 const ai = new GoogleGenAI ( {
761826 apiKey : "gateway-managed" ,
@@ -815,6 +880,12 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
815880 config . tools = allTools ;
816881 }
817882
883+ // Apply schema constraint for structured JSON output
884+ if ( schema ) {
885+ config . responseMimeType = "application/json" ;
886+ config . responseSchema = schema ;
887+ }
888+
818889 const response = await ai . models . generateContent ( {
819890 model,
820891 contents : contents as any ,
@@ -859,7 +930,8 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
859930 model : string ,
860931 instructions : string ,
861932 messages : AgentMessage [ ] ,
862- tools : ToolDefinition [ ]
933+ tools : ToolDefinition [ ] ,
934+ schema ?: Record < string , unknown >
863935 ) : Promise < LLMResponse > {
864936 const client = new OpenAI ( {
865937 apiKey : "gateway-managed" ,
@@ -911,11 +983,24 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
911983 } ,
912984 } ) ) ;
913985
986+ // Build response_format when a schema is provided
987+ const responseFormat = schema
988+ ? {
989+ type : "json_schema" as const ,
990+ json_schema : {
991+ name : "response" ,
992+ schema,
993+ strict : true ,
994+ } ,
995+ }
996+ : undefined ;
997+
914998 const completion = await client . chat . completions . create ( {
915999 model,
9161000 max_tokens : 4096 ,
9171001 messages : openaiMessages ,
9181002 ...( openaiTools . length > 0 && { tools : openaiTools } ) ,
1003+ ...( responseFormat && { response_format : responseFormat } ) ,
9191004 } ) ;
9201005
9211006 const choice = completion . choices [ 0 ] ;
@@ -948,13 +1033,20 @@ export class AgentRunner extends Agent<Bindings, AgentRunnerState> {
9481033 model : string ,
9491034 _instructions : string ,
9501035 messages : AgentMessage [ ] ,
951- tools : ToolDefinition [ ]
1036+ tools : ToolDefinition [ ] ,
1037+ schema ?: Record < string , unknown >
9521038 ) : Promise < LLMResponse > {
9531039 // Workers AI uses OpenAI-compatible chat format
9541040 const aiMessages : Array < { role : string ; content : string } > = [ ] ;
9551041
956- if ( _instructions ) {
957- aiMessages . push ( { role : "system" , content : _instructions } ) ;
1042+ // When schema is provided, append a JSON constraint to the system prompt
1043+ // (Workers AI models don't reliably support response_format)
1044+ const systemPrompt = schema
1045+ ? `${ _instructions } \n\nYou MUST respond with valid JSON matching this schema:\n${ JSON . stringify ( schema ) } `
1046+ : _instructions ;
1047+
1048+ if ( systemPrompt ) {
1049+ aiMessages . push ( { role : "system" , content : systemPrompt } ) ;
9581050 }
9591051
9601052 for ( const m of messages ) {
0 commit comments