Skip to content

Commit c0ca25d

Browse files
authored
Refactor structured output API improving flexibility, support native structured output for OpenAI and Google. (#443)
1 parent 68d5962 commit c0ca25d

File tree

75 files changed

+4201
-2214
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+4201
-2214
lines changed

agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/entity/AIAgentSubgraph.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import ai.koog.agents.core.tools.ToolDescriptor
1313
import ai.koog.agents.core.tools.annotations.LLMDescription
1414
import ai.koog.prompt.llm.LLModel
1515
import ai.koog.prompt.params.LLMParams
16-
import ai.koog.prompt.structure.json.JsonSchemaGenerator
16+
import ai.koog.prompt.structure.StructureFixingParser
17+
import ai.koog.prompt.structure.StructuredOutput
18+
import ai.koog.prompt.structure.StructuredOutputConfig
1719
import ai.koog.prompt.structure.json.JsonStructuredData
20+
import ai.koog.prompt.structure.json.generator.StandardJsonSchemaGenerator
1821
import io.github.oshai.kotlinlogging.KotlinLogging
1922
import kotlinx.serialization.Serializable
2023
import kotlin.reflect.KType
@@ -99,11 +102,15 @@ public open class AIAgentSubgraph<Input, Output>(
99102
}
100103

101104
val selectedTools = this.requestLLMStructured(
102-
structure = JsonStructuredData.createJsonStructure<SelectedTools>(
103-
schemaFormat = JsonSchemaGenerator.SchemaFormat.JsonSchema,
104-
examples = listOf(SelectedTools(listOf()), SelectedTools(tools.map { it.name }.take(3))),
105-
),
106-
retries = toolSelectionStrategy.maxRetries,
105+
config = StructuredOutputConfig(
106+
default = StructuredOutput.Manual(
107+
JsonStructuredData.createJsonStructure<SelectedTools>(
108+
schemaGenerator = StandardJsonSchemaGenerator,
109+
examples = listOf(SelectedTools(listOf()), SelectedTools(tools.map { it.name }.take(3))),
110+
),
111+
),
112+
fixingParser = toolSelectionStrategy.fixingParser,
113+
)
107114
).getOrThrow()
108115

109116
prompt = initialPrompt
@@ -262,8 +269,9 @@ public sealed interface ToolSelectionStrategy {
262269
* This ensures that unnecessary tools are excluded, optimizing the toolset for the specific use case.
263270
*
264271
* @property subtaskDescription A description of the subtask for which the relevant tools should be selected.
272+
* @property fixingParser Optional [StructureFixingParser] to attempt fixes when malformed structured response with tool list is received.
265273
*/
266-
public data class AutoSelectForTask(val subtaskDescription: String, val maxRetries: Int = 3) : ToolSelectionStrategy
274+
public data class AutoSelectForTask(val subtaskDescription: String, val fixingParser: StructureFixingParser? = null) : ToolSelectionStrategy
267275

268276
/**
269277
* Represents a subset of tools to be utilized within a subgraph or task.

agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/session/AIAgentLLMSession.kt

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import ai.koog.agents.core.tools.ToolDescriptor
66
import ai.koog.agents.core.utils.ActiveProperty
77
import ai.koog.prompt.dsl.ModerationResult
88
import ai.koog.prompt.dsl.Prompt
9-
import ai.koog.prompt.executor.clients.openai.OpenAIModels
109
import ai.koog.prompt.executor.model.LLMChoice
1110
import ai.koog.prompt.executor.model.PromptExecutor
1211
import ai.koog.prompt.llm.LLModel
1312
import ai.koog.prompt.message.Message
1413
import ai.koog.prompt.params.LLMParams
15-
import ai.koog.prompt.structure.StructuredData
14+
import ai.koog.prompt.structure.StructureFixingParser
15+
import ai.koog.prompt.structure.StructuredOutputConfig
1616
import ai.koog.prompt.structure.StructuredResponse
1717
import ai.koog.prompt.structure.executeStructured
18-
import ai.koog.prompt.structure.executeStructuredOneShot
18+
import kotlinx.serialization.KSerializer
19+
import kotlinx.serialization.serializer
1920

2021
/**
2122
* Represents a session for an AI agent that interacts with an LLM (Language Learning Model).
@@ -49,9 +50,7 @@ public sealed class AIAgentLLMSession(
4950
* Typical usage includes providing input to LLM requests, such as:
5051
* - [requestLLMWithoutTools]
5152
* - [requestLLM]
52-
* - [requestLLMMultiple]
53-
* - [requestLLMStructured]
54-
* - [requestLLMStructuredOneShot]
53+
* etc.
5554
*/
5655
public open val prompt: Prompt by ActiveProperty(prompt) { isActive }
5756

@@ -238,32 +237,81 @@ public sealed class AIAgentLLMSession(
238237
}
239238

240239
/**
241-
* Coerce LLM to provide a structured output.
240+
* Sends a request to LLM and gets a structured response.
241+
*
242+
* @param config A configuration defining structures and behavior.
242243
*
243244
* @see [executeStructured]
244245
*/
245246
public open suspend fun <T> requestLLMStructured(
246-
structure: StructuredData<T>,
247-
retries: Int = 1,
248-
fixingModel: LLModel = OpenAIModels.Chat.GPT4o
247+
config: StructuredOutputConfig<T>,
249248
): Result<StructuredResponse<T>> {
250249
validateSession()
250+
251251
val preparedPrompt = preparePrompt(prompt, tools = emptyList())
252-
return executor.executeStructured(preparedPrompt, model, structure, retries, fixingModel)
252+
253+
return executor.executeStructured(
254+
prompt = preparedPrompt,
255+
model = model,
256+
config = config,
257+
)
253258
}
254259

255260
/**
256-
* Expect LLM to reply in a structured format and try to parse it.
257-
* For more robust version with model coercion and correction see [requestLLMStructured]
261+
* Sends a request to LLM and gets a structured response.
262+
*
263+
* This is a simple version of the full `requestLLMStructured`. Unlike the full version, it does not require specifying
264+
* struct definitions and structured output modes manually. It attempts to find the best approach to provide a structured
265+
* output based on the defined [model] capabilities.
258266
*
259-
* @see [executeStructuredOneShot]
267+
* @param serializer Serializer for the requested structure type.
268+
* @param examples Optional list of examples in case manual mode will be used. These examples might help the model to
269+
* understand the format better.
270+
* @param fixingParser Optional parser that handles malformed responses by using an auxiliary LLM to
271+
* intelligently fix parsing errors. When specified, parsing errors trigger additional
272+
* LLM calls with error context to attempt correction of the structure format.
260273
*/
261-
public open suspend fun <T> requestLLMStructuredOneShot(structure: StructuredData<T>): StructuredResponse<T> {
274+
public open suspend fun <T> requestLLMStructured(
275+
serializer: KSerializer<T>,
276+
examples: List<T> = emptyList(),
277+
fixingParser: StructureFixingParser? = null
278+
): Result<StructuredResponse<T>> {
262279
validateSession()
280+
263281
val preparedPrompt = preparePrompt(prompt, tools = emptyList())
264-
return executor.executeStructuredOneShot(preparedPrompt, model, structure)
282+
283+
return executor.executeStructured(
284+
prompt = preparedPrompt,
285+
model = model,
286+
serializer = serializer,
287+
examples = examples,
288+
fixingParser = fixingParser,
289+
)
265290
}
266291

292+
/**
293+
* Sends a request to LLM and gets a structured response.
294+
*
295+
* This is a simple version of the full `requestLLMStructured`. Unlike the full version, it does not require specifying
296+
* struct definitions and structured output modes manually. It attempts to find the best approach to provide a structured
297+
* output based on the defined [model] capabilities.
298+
*
299+
* @param T The structure to request.
300+
* @param examples Optional list of examples in case manual mode will be used. These examples might help the model to
301+
* understand the format better.
302+
* @param fixingParser Optional parser that handles malformed responses by using an auxiliary LLM to
303+
* intelligently fix parsing errors. When specified, parsing errors trigger additional
304+
* LLM calls with error context to attempt correction of the structure format.
305+
*/
306+
public suspend inline fun <reified T> requestLLMStructured(
307+
examples: List<T> = emptyList(),
308+
fixingParser: StructureFixingParser? = null
309+
): Result<StructuredResponse<T>> = requestLLMStructured(
310+
serializer = serializer<T>(),
311+
examples = examples,
312+
fixingParser = fixingParser,
313+
)
314+
267315
/**
268316
* Sends a request to the language model, potentially receiving multiple choices,
269317
* and returns a list of choices from the model.

agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/session/AIAgentLLMWriteSession.kt

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import ai.koog.prompt.executor.model.PromptExecutor
1616
import ai.koog.prompt.llm.LLModel
1717
import ai.koog.prompt.message.Message
1818
import ai.koog.prompt.params.LLMParams
19-
import ai.koog.prompt.structure.StructuredData
19+
import ai.koog.prompt.structure.StructureFixingParser
2020
import ai.koog.prompt.structure.StructuredDataDefinition
21+
import ai.koog.prompt.structure.StructuredOutputConfig
2122
import ai.koog.prompt.structure.StructuredResponse
2223
import kotlinx.coroutines.flow.Flow
2324
import kotlinx.coroutines.flow.flatMapMerge
2425
import kotlinx.coroutines.flow.flow
2526
import kotlinx.datetime.Clock
27+
import kotlinx.serialization.KSerializer
2628
import kotlin.reflect.KClass
2729

2830
/**
@@ -424,23 +426,47 @@ public class AIAgentLLMWriteSession internal constructor(
424426
}
425427

426428
/**
427-
* Requests an LLM (Language Model) to generate a structured output based on the provided structure.
428-
* The response is post-processed to update the prompt with the raw response.
429+
* Sends a request to LLM and gets a structured response.
429430
*
430-
* @param structure The structured data definition specifying the expected structured output format, schema, and parsing logic.
431-
* @param retries The number of retry attempts to allow in case of generation failures.
432-
* @param fixingModel The language model to use for re-parsing or error correction during retries.
433-
* @return A structured response containing both the parsed structure and the raw response text.
431+
* @param config A configuration defining structures and behavior.
432+
*
433+
* @see [executeStructured]
434+
*/
435+
override suspend fun <T> requestLLMStructured(
436+
config: StructuredOutputConfig<T>,
437+
): Result<StructuredResponse<T>> {
438+
return super.requestLLMStructured(config).also {
439+
it.onSuccess { response ->
440+
updatePrompt {
441+
message(response.message)
442+
}
443+
}
444+
}
445+
}
446+
447+
/**
448+
* Sends a request to LLM and gets a structured response.
449+
*
450+
* This is a simple version of the full `requestLLMStructured`. Unlike the full version, it does not require specifying
451+
* struct definitions and structured output modes manually. It attempts to find the best approach to provide a structured
452+
* output based on the defined [model] capabilities.
453+
*
454+
* @param serializer Serializer for the requested structure type.
455+
* @param examples Optional list of examples in case manual mode will be used. These examples might help the model to
456+
* understand the format better.
457+
* @param fixingParser Optional parser that handles malformed responses by using an auxiliary LLM to
458+
* intelligently fix parsing errors. When specified, parsing errors trigger additional
459+
* LLM calls with error context to attempt correction of the structure format.
434460
*/
435461
override suspend fun <T> requestLLMStructured(
436-
structure: StructuredData<T>,
437-
retries: Int,
438-
fixingModel: LLModel
462+
serializer: KSerializer<T>,
463+
examples: List<T>,
464+
fixingParser: StructureFixingParser?
439465
): Result<StructuredResponse<T>> {
440-
return super.requestLLMStructured(structure, retries, fixingModel).also {
466+
return super.requestLLMStructured(serializer, examples, fixingParser).also {
441467
it.onSuccess { response ->
442468
updatePrompt {
443-
assistant(response.raw)
469+
message(response.message)
444470
}
445471
}
446472
}
@@ -465,19 +491,4 @@ public class AIAgentLLMWriteSession internal constructor(
465491

466492
return executor.executeStreaming(prompt, model)
467493
}
468-
469-
/**
470-
* Sends a request to the LLM using the given structured data and expects a structured response in one attempt.
471-
* Updates the prompt with the raw response received from the LLM.
472-
*
473-
* @param structure The structured data defining the schema, examples, and parsing logic for the response.
474-
* @return A structured response containing both the parsed data and the raw response text from the LLM.
475-
*/
476-
override suspend fun <T> requestLLMStructuredOneShot(structure: StructuredData<T>): StructuredResponse<T> {
477-
return super.requestLLMStructuredOneShot(structure).also { response ->
478-
updatePrompt {
479-
assistant(response.raw)
480-
}
481-
}
482-
}
483494
}

agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/dsl/extension/AIAgentNodes.kt

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import ai.koog.prompt.dsl.PromptBuilder
1717
import ai.koog.prompt.dsl.prompt
1818
import ai.koog.prompt.llm.LLModel
1919
import ai.koog.prompt.message.Message
20-
import ai.koog.prompt.structure.StructuredData
20+
import ai.koog.prompt.structure.StructureFixingParser
2121
import ai.koog.prompt.structure.StructuredDataDefinition
22+
import ai.koog.prompt.structure.StructuredOutputConfig
2223
import ai.koog.prompt.structure.StructuredResponse
2324
import kotlinx.coroutines.flow.Flow
2425

@@ -173,30 +174,57 @@ public fun AIAgentSubgraphBuilderBase<*, *>.nodeLLMModerateMessage(
173174
}
174175

175176
/**
176-
* A node that appends a user message to the LLM prompt and requests structured data from the LLM with error correction capabilities.
177+
* A node that appends a user message to the LLM prompt and requests structured data from the LLM with optional error
178+
* correction capabilities.
177179
*
178180
* @param name Optional node name.
179-
* @param structure Definition of expected output format and parsing logic.
180-
* @param retries Number of retry attempts for failed generations.
181-
* @param fixingModel LLM used for error correction.
181+
* @param config A configuration defining structures and behavior.
182182
*/
183183
@AIAgentBuilderDslMarker
184184
public inline fun <reified T> AIAgentSubgraphBuilderBase<*, *>.nodeLLMRequestStructured(
185185
name: String? = null,
186-
structure: StructuredData<T>,
187-
retries: Int,
188-
fixingModel: LLModel
186+
config: StructuredOutputConfig<T>,
189187
): AIAgentNodeDelegate<String, Result<StructuredResponse<T>>> =
190188
node(name) { message ->
191189
llm.writeSession {
192190
updatePrompt {
193191
user(message)
194192
}
195193

196-
requestLLMStructured(
197-
structure,
198-
retries,
199-
fixingModel
194+
requestLLMStructured(config)
195+
}
196+
}
197+
198+
/**
199+
* A node that appends a user message to the LLM prompt and requests structured data from the LLM with optional error
200+
* correction capabilities.
201+
*
202+
* This is a simple version of the full `nodeLLMRequestStructured`. Unlike the full version, it does not require specifying
203+
* struct definitions and structured output modes manually. It attempts to find the best approach to provide a structured
204+
* output based on the defined model capabilities.
205+
*
206+
* @param name Optional node name.
207+
* @param examples Optional list of examples in case manual mode will be used. These examples might help the model to
208+
* understand the format better.
209+
* @param fixingParser Optional parser that handles malformed responses by using an auxiliary LLM to
210+
* intelligently fix parsing errors. When specified, parsing errors trigger additional
211+
* LLM calls with error context to attempt correction of the structure format.
212+
*/
213+
@AIAgentBuilderDslMarker
214+
public inline fun <reified T> AIAgentSubgraphBuilderBase<*, *>.nodeLLMRequestStructured(
215+
name: String? = null,
216+
examples: List<T> = emptyList(),
217+
fixingParser: StructureFixingParser? = null
218+
): AIAgentNodeDelegate<String, Result<StructuredResponse<T>>> =
219+
node(name) { message ->
220+
llm.writeSession {
221+
updatePrompt {
222+
user(message)
223+
}
224+
225+
requestLLMStructured<T>(
226+
examples = examples,
227+
fixingParser = fixingParser
200228
)
201229
}
202230
}

agents/agents-features/agents-features-memory/src/commonMain/kotlin/ai/koog/agents/memory/feature/AgentMemory.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import ai.koog.agents.memory.providers.NoMemory
2828
import ai.koog.prompt.dsl.Prompt
2929
import ai.koog.prompt.llm.LLModel
3030
import ai.koog.prompt.message.Message
31+
import ai.koog.prompt.structure.StructuredOutput
32+
import ai.koog.prompt.structure.StructuredOutputConfig
3133
import ai.koog.prompt.structure.json.JsonStructuredData
3234
import io.github.oshai.kotlinlogging.KotlinLogging
3335
import kotlinx.serialization.Serializable
@@ -554,7 +556,10 @@ internal suspend fun AIAgentLLMWriteSession.retrieveFactsFromHistory(
554556

555557
val facts = when (concept.factType) {
556558
FactType.SINGLE -> {
557-
val response = requestLLMStructured(JsonStructuredData.createJsonStructure<FactStructure>())
559+
val response = requestLLMStructured(
560+
config = StructuredOutputConfig(default = StructuredOutput.Manual(JsonStructuredData.createJsonStructure<FactStructure>()))
561+
)
562+
558563
SingleFact(
559564
concept = concept,
560565
value = response.getOrNull()?.structure?.fact ?: "No facts extracted",
@@ -563,7 +568,9 @@ internal suspend fun AIAgentLLMWriteSession.retrieveFactsFromHistory(
563568
}
564569

565570
FactType.MULTIPLE -> {
566-
val response = requestLLMStructured(JsonStructuredData.createJsonStructure<FactListStructure>())
571+
val response = requestLLMStructured(
572+
config = StructuredOutputConfig(default = StructuredOutput.Manual(JsonStructuredData.createJsonStructure<FactListStructure>()))
573+
)
567574
val factsList = response.getOrNull()?.structure?.facts ?: emptyList()
568575
MultipleFacts(concept = concept, values = factsList.map { it.fact }, timestamp = timestamp)
569576
}

agents/agents-features/agents-features-memory/src/jvmTest/kotlin/ai/koog/agents/memory/AIAgentMemoryTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import ai.koog.prompt.dsl.prompt
2525
import ai.koog.prompt.executor.clients.anthropic.AnthropicModels
2626
import ai.koog.prompt.executor.clients.openai.OpenAIModels
2727
import ai.koog.prompt.executor.model.PromptExecutor
28+
import ai.koog.prompt.llm.LLMProvider
2829
import ai.koog.prompt.llm.LLModel
2930
import ai.koog.prompt.message.Message
3031
import io.mockk.coEvery
@@ -60,6 +61,7 @@ class AIAgentMemoryTest {
6061

6162
private val testModel = mockk<LLModel> {
6263
every { id } returns "test-model"
64+
every { provider } returns mockk<LLMProvider>()
6365
}
6466

6567
private val testClock: Clock = object : Clock {

0 commit comments

Comments
 (0)