kiln_ai.adapters.model_adapters.base_adapter
1from __future__ import annotations 2 3import json 4import uuid 5from abc import ABCMeta, abstractmethod 6from dataclasses import dataclass 7from typing import TYPE_CHECKING, AsyncIterator, Dict, Tuple 8 9from litellm.types.utils import ModelResponseStream 10 11from kiln_ai.adapters.chat.chat_formatter import ( 12 ChatFormatter, 13 MultiturnFormatter, 14 chat_strategy_for_run, 15 get_chat_formatter, 16) 17from kiln_ai.adapters.errors import ( 18 KilnRunError, 19 StructuredOutputParseError, 20 format_error_message, 21) 22from kiln_ai.adapters.ml_model_list import ( 23 KilnModelProvider, 24 StructuredOutputMode, 25 default_structured_output_mode_for_model_provider, 26) 27from kiln_ai.adapters.model_adapters.adapter_stream import AdapterStreamResult 28from kiln_ai.adapters.model_adapters.stream_events import ( 29 AiSdkStreamConverter, 30 AiSdkStreamEvent, 31 FinishEvent, 32 FinishMessageMetadata, 33 FinishStepEvent, 34 StartEvent, 35 StartStepEvent, 36 ToolCallEvent, 37) 38from kiln_ai.adapters.parsers.json_parser import parse_json_string 39from kiln_ai.adapters.parsers.parser_registry import model_parser_from_id 40from kiln_ai.adapters.parsers.request_formatters import request_formatter_from_id 41from kiln_ai.adapters.prompt_builders import BasePromptBuilder, prompt_builder_from_id 42from kiln_ai.adapters.provider_tools import kiln_model_provider_from 43from kiln_ai.adapters.run_output import RunOutput 44from kiln_ai.datamodel import ( 45 DataSource, 46 DataSourceType, 47 MessageUsage, 48 Task, 49 TaskOutput, 50 TaskRun, 51 Usage, 52) 53from kiln_ai.datamodel.datamodel_enums import InputType 54from kiln_ai.datamodel.json_schema import validate_schema_with_value_error 55from kiln_ai.datamodel.run_config import ( 56 KilnAgentRunConfigProperties, 57 as_kiln_agent_run_config, 58) 59from kiln_ai.datamodel.skill import Skill 60from kiln_ai.datamodel.task import RunConfigProperties 61from kiln_ai.datamodel.task_output import TASK_OUTPUT_SCHEMA_ERROR_PREFIX 62from kiln_ai.datamodel.tool_id import SKILL_TOOL_ID_PREFIX, skill_id_from_tool_id 63from kiln_ai.tools import KilnToolInterface 64from kiln_ai.tools.mcp_session_manager import mcp_session_scope 65from kiln_ai.tools.skill_tool import SkillTool 66from kiln_ai.tools.tool_registry import tool_from_id 67from kiln_ai.utils.config import Config 68from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error 69from kiln_ai.utils.jinja_engine import render_input_transform 70from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam 71 72if TYPE_CHECKING: 73 from kiln_ai.adapters.model_adapters.adapter_stream import AdapterStream 74 75SkillsDict = Dict[str, Skill] 76 77 78@dataclass 79class AdapterConfig: 80 """ 81 An adapter config is config options that do NOT impact the output of the model. 82 83 For example: if it's saved, of if we request additional data like logprobs. 84 """ 85 86 allow_saving: bool = True 87 top_logprobs: int | None = None 88 default_tags: list[str] | None = None 89 90 """ 91 The ID of the TaskRunConfig that originated this run, if any. Stored on the 92 resulting TaskRun so the run can be traced back to its originating saved config 93 (in addition to the inline run_config snapshot). None for ad-hoc/inline runs 94 that were not initiated from a saved TaskRunConfig. 95 """ 96 task_run_config_id: str | None = None 97 98 """ 99 A custom prompt builder can be injected to override the system prompt building process. 100 If not provided, the prompt builder will be created from the run_config.prompt_id which 101 may load additional files from disk. 102 """ 103 prompt_builder: BasePromptBuilder | None = None 104 105 """ 106 Pre-loaded skills keyed by skill ID. When the run config references skills, 107 they are looked up from this dict instead of reading from the filesystem. 108 Use load_skills_for_task() to build this dict. 109 """ 110 skills: SkillsDict | None = None 111 112 """ 113 When True, the adapter will stop and return control to the caller when a tool call 114 is invoked, instead of processing tool calls internally. Default is False (process 115 tool calls internally). 116 """ 117 return_on_tool_call: bool = False 118 119 """ 120 Extra tools provided directly by the caller, in addition to tools resolved from the 121 task's tool registry. These are sent to the model together with registry tools, and 122 their names must not collide with registry tool names. 123 124 If ``return_on_tool_call`` is False (the default), the adapter executes these tools 125 itself just like registry tools. If True, the adapter returns as soon as the model 126 requests a tool call and the caller is responsible for running the tool and passing 127 results back via ``prior_trace``. 128 """ 129 unmanaged_tools: list[KilnToolInterface] | None = None 130 131 """ 132 When True, automatically inject prompt caching hints into completion 133 requests. This is a cost optimization and does not affect model output. 134 """ 135 automatic_prompt_caching: bool = False 136 137 138class BaseAdapter(metaclass=ABCMeta): 139 """Base class for AI model adapters that handle task execution. 140 141 This abstract class provides the foundation for implementing model-specific adapters 142 that can process tasks with structured or unstructured inputs/outputs. It handles 143 input/output validation, prompt building, and run tracking. 144 145 Prompt building is handled internally by the adapter, which uses a prompt builder 146 based on the run config. To override the prompt building behavior, pass a custom prompt 147 builder to the adapter config. 148 """ 149 150 def __init__( 151 self, 152 task: Task, 153 run_config: RunConfigProperties, 154 config: AdapterConfig | None = None, 155 ): 156 self.task = task 157 self.run_config: RunConfigProperties = run_config 158 self.base_adapter_config = config or AdapterConfig() 159 160 if isinstance(run_config, KilnAgentRunConfigProperties): 161 self.update_run_config_unknown_structured_output_mode() 162 self.prompt_builder = ( 163 self.base_adapter_config.prompt_builder 164 or prompt_builder_from_id(run_config.prompt_id, task) 165 ) 166 else: 167 self.prompt_builder = None 168 self._model_provider: KilnModelProvider | None = None 169 self._resolved_skills: list[Skill] | None = None 170 171 self.output_schema = task.output_json_schema 172 self.input_schema = task.input_json_schema 173 174 def model_provider(self) -> KilnModelProvider: 175 """ 176 Lazy load the model provider for this adapter. 177 """ 178 if self._model_provider is not None: 179 return self._model_provider 180 run_config = as_kiln_agent_run_config(self.run_config) 181 if not run_config.model_name or not run_config.model_provider_name: 182 raise ValueError("model_name and model_provider_name must be provided") 183 self._model_provider = kiln_model_provider_from( 184 run_config.model_name, run_config.model_provider_name 185 ) 186 if not self._model_provider: 187 raise ValueError( 188 f"model_provider_name {run_config.model_provider_name} not found for model {run_config.model_name}" 189 ) 190 return self._model_provider 191 192 @staticmethod 193 def _normalize_prior_trace( 194 prior_trace: list[ChatCompletionMessageParam] | None, 195 ) -> list[ChatCompletionMessageParam] | None: 196 if not prior_trace: 197 return None 198 return prior_trace 199 200 def _reject_multiturn_with_structured_input( 201 self, 202 prior_trace: list[ChatCompletionMessageParam] | None, 203 ) -> None: 204 if prior_trace is not None and self.input_schema is not None: 205 raise ValueError( 206 "Cannot run multiturn execution with a task that has a structured input schema. " 207 "Use an unstructured task, or call without prior_trace." 208 ) 209 210 async def invoke( 211 self, 212 input: InputType, 213 input_source: DataSource | None = None, 214 prior_trace: list[ChatCompletionMessageParam] | None = None, 215 parent_task_run: TaskRun | None = None, 216 ) -> TaskRun: 217 task_run, _ = await self.invoke_returning_run_output( 218 input, input_source, prior_trace, parent_task_run 219 ) 220 return task_run 221 222 async def _run_returning_run_output( 223 self, 224 input: InputType, 225 input_source: DataSource | None = None, 226 prior_trace: list[ChatCompletionMessageParam] | None = None, 227 parent_task_run: TaskRun | None = None, 228 ) -> Tuple[TaskRun, RunOutput]: 229 # Pre-run validation: these checks run before any model call, so 230 # there is no trace to preserve. They stay outside the 231 # exception-wrapping block and surface as plain exceptions. 232 prior_trace = self._normalize_prior_trace(prior_trace) 233 self._reject_multiturn_with_structured_input(prior_trace) 234 235 if self.input_schema is not None: 236 validate_schema_with_value_error( 237 input, 238 self.input_schema, 239 "This task requires a specific input schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 240 require_object=False, 241 ) 242 243 # Apply input transform if configured. The original `input` is preserved 244 # for TaskRun.input persistence; `model_input` is what the model sees. 245 model_input = self._apply_input_transform(input) 246 247 # Format model input for model call (we save the original input in the 248 # task without formatting). This runs in the adapter but before any 249 # trace is built, so it also stays outside the wrapped region. 250 formatted_input = model_input 251 formatter_id = self.model_provider().formatter 252 if formatter_id is not None: 253 formatter = request_formatter_from_id(formatter_id) 254 formatted_input = formatter.format_input(model_input) 255 256 # Allocate the trace-so-far list here so the reference survives any 257 # exception thrown from inside `_run` (or the post-processing that 258 # follows). `_run` must mutate this list in place (extend/append, 259 # or `list[:] = ...`) and never rebind the local name. 260 trace_ref: list[ChatCompletionMessageParam] = [] 261 try: 262 run_output, usage = await self._run( 263 formatted_input, trace_ref, prior_trace=prior_trace 264 ) 265 266 if not run_output.is_toolcall_pending: 267 # Normal completion: parse and validate output 268 provider = self.model_provider() 269 parser = model_parser_from_id(provider.parser) 270 parsed_output = parser.parse_output(original_output=run_output) 271 272 # validate output 273 if self.output_schema is not None: 274 # Parse json to dict if we have structured output 275 if isinstance(parsed_output.output, str): 276 try: 277 parsed_output.output = parse_json_string( 278 parsed_output.output 279 ) 280 except ValueError as e: 281 # Re-type (message unchanged) so retries treat bad 282 # JSON like a schema mismatch: a one-off model slip. 283 raise StructuredOutputParseError(str(e)) from e 284 285 if not isinstance(parsed_output.output, dict): 286 # Valid JSON of the wrong shape (often the object wrapped 287 # in a list) is the same one-off model slip as bad JSON, 288 # so it carries the retryable type too. 289 raise StructuredOutputParseError( 290 f"structured response is not a dict: {parsed_output.output}" 291 ) 292 validate_schema_with_value_error( 293 parsed_output.output, 294 self.output_schema, 295 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 296 ) 297 else: 298 if not isinstance(parsed_output.output, str): 299 raise RuntimeError( 300 f"response is not a string for non-structured task: {parsed_output.output}" 301 ) 302 303 trace_has_toolcalls = parsed_output.trace is not None and any( 304 message.get("role", None) == "tool" 305 for message in parsed_output.trace 306 ) 307 308 # Validate reasoning content is present if required. 309 # Models often skip reasoning on the final turn when tools are involved, so we don't require it then. 310 if ( 311 provider.reasoning_capable 312 and ( 313 not parsed_output.intermediate_outputs 314 or "reasoning" not in parsed_output.intermediate_outputs 315 ) 316 and not ( 317 provider.reasoning_optional_for_structured_output 318 and self.has_structured_output() 319 ) 320 and not trace_has_toolcalls 321 ): 322 raise RuntimeError( 323 "Reasoning is required for this model, but no reasoning was returned." 324 ) 325 326 run_output = parsed_output 327 328 run = self.generate_run( 329 input, 330 input_source, 331 run_output, 332 usage, 333 run_output.trace, 334 parent_task_run, 335 ) 336 337 # Save the run if configured to do so, and we have a path to save to 338 if ( 339 self.base_adapter_config.allow_saving 340 and Config.shared().autosave_runs 341 and self.task.path is not None 342 ): 343 run.save_to_file() 344 else: 345 # Clear the ID to indicate it's not persisted 346 run.id = None 347 348 return run, run_output 349 except KilnRunError: 350 # Already wrapped — pass through so we don't double-wrap. 351 raise 352 except Exception as e: 353 # Trace conversion can itself throw (e.g., a malformed partial 354 # assistant message was appended to `messages` just before the 355 # real failure). Never let that swallow the original exception — 356 # fall back to no trace so the user still sees the real error. 357 partial_trace: list[ChatCompletionMessageParam] | None = None 358 if trace_ref: 359 try: 360 partial_trace = self._messages_to_trace(trace_ref) 361 except Exception: 362 partial_trace = None 363 raise KilnRunError( 364 message=format_error_message(e), 365 partial_trace=partial_trace, 366 original=e, 367 ) from e 368 369 async def invoke_returning_run_output( 370 self, 371 input: InputType, 372 input_source: DataSource | None = None, 373 prior_trace: list[ChatCompletionMessageParam] | None = None, 374 parent_task_run: TaskRun | None = None, 375 ) -> Tuple[TaskRun, RunOutput]: 376 async with mcp_session_scope(): 377 return await self._run_returning_run_output( 378 input, input_source, prior_trace, parent_task_run 379 ) 380 381 def invoke_openai_stream( 382 self, 383 input: InputType, 384 input_source: DataSource | None = None, 385 prior_trace: list[ChatCompletionMessageParam] | None = None, 386 parent_task_run: TaskRun | None = None, 387 ) -> OpenAIStreamResult: 388 """Stream raw OpenAI-protocol chunks for the task execution. 389 390 Returns an async-iterable that yields ``ModelResponseStream`` chunks 391 as they arrive from the model. After the iterator is exhausted the 392 run has been validated and saved (when configured). The resulting 393 ``TaskRun`` is available via the ``.task_run`` property. 394 395 Tool-call rounds happen internally and are not surfaced; use 396 ``invoke_ai_sdk_stream`` if you need tool-call events. 397 """ 398 return OpenAIStreamResult( 399 self, input, input_source, prior_trace, parent_task_run 400 ) 401 402 def invoke_ai_sdk_stream( 403 self, 404 input: InputType, 405 input_source: DataSource | None = None, 406 prior_trace: list[ChatCompletionMessageParam] | None = None, 407 parent_task_run: TaskRun | None = None, 408 ) -> AiSdkStreamResult: 409 """Stream AI SDK protocol events for the task execution. 410 411 Returns an async-iterable that yields ``AiSdkStreamEvent`` instances 412 covering text, reasoning, tool-call lifecycle, step boundaries, and 413 control events. After the iterator is exhausted the resulting 414 ``TaskRun`` is available via the ``.task_run`` property. 415 """ 416 return AiSdkStreamResult( 417 self, input, input_source, prior_trace, parent_task_run 418 ) 419 420 def _prepare_stream( 421 self, 422 input: InputType, 423 prior_trace: list[ChatCompletionMessageParam] | None, 424 ) -> AdapterStream: 425 prior_trace = self._normalize_prior_trace(prior_trace) 426 self._reject_multiturn_with_structured_input(prior_trace) 427 428 if self.input_schema is not None: 429 validate_schema_with_value_error( 430 input, 431 self.input_schema, 432 "This task requires a specific input schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 433 require_object=False, 434 ) 435 436 model_input = self._apply_input_transform(input) 437 438 formatted_input = model_input 439 formatter_id = self.model_provider().formatter 440 if formatter_id is not None: 441 formatter = request_formatter_from_id(formatter_id) 442 formatted_input = formatter.format_input(model_input) 443 444 return self._create_run_stream(formatted_input, prior_trace) 445 446 def _finalize_stream( 447 self, 448 adapter_stream: AdapterStream, 449 input: InputType, 450 input_source: DataSource | None, 451 parent_task_run: TaskRun | None = None, 452 ) -> TaskRun: 453 """Streaming invocations are only concerned with passing through events as they come in. 454 At the end of the stream, we still need to validate the output, create a run and everything 455 else that a non-streaming invocation would do. 456 """ 457 458 result: AdapterStreamResult = adapter_stream.result 459 run_output = result.run_output 460 usage = result.usage 461 462 if not run_output.is_toolcall_pending: 463 # Normal completion: parse and validate output 464 provider = self.model_provider() 465 parser = model_parser_from_id(provider.parser) 466 parsed_output = parser.parse_output(original_output=run_output) 467 468 if self.output_schema is not None: 469 if isinstance(parsed_output.output, str): 470 try: 471 parsed_output.output = parse_json_string(parsed_output.output) 472 except ValueError as e: 473 # Re-type (message unchanged) so retries treat bad JSON 474 # like a schema mismatch: a one-off model slip. 475 raise StructuredOutputParseError(str(e)) from e 476 if not isinstance(parsed_output.output, dict): 477 # Valid JSON of the wrong shape (often the object wrapped in 478 # a list) is the same one-off model slip as bad JSON, so it 479 # carries the retryable type too. 480 raise StructuredOutputParseError( 481 f"structured response is not a dict: {parsed_output.output}" 482 ) 483 validate_schema_with_value_error( 484 parsed_output.output, 485 self.output_schema, 486 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 487 ) 488 else: 489 if not isinstance(parsed_output.output, str): 490 raise RuntimeError( 491 f"response is not a string for non-structured task: {parsed_output.output}" 492 ) 493 494 trace_has_toolcalls = parsed_output.trace is not None and any( 495 message.get("role", None) == "tool" for message in parsed_output.trace 496 ) 497 if ( 498 provider.reasoning_capable 499 and ( 500 not parsed_output.intermediate_outputs 501 or "reasoning" not in parsed_output.intermediate_outputs 502 ) 503 and not ( 504 provider.reasoning_optional_for_structured_output 505 and self.has_structured_output() 506 ) 507 and not trace_has_toolcalls 508 ): 509 raise RuntimeError( 510 "Reasoning is required for this model, but no reasoning was returned." 511 ) 512 513 run_output = parsed_output 514 515 run = self.generate_run( 516 input, input_source, run_output, usage, run_output.trace, parent_task_run 517 ) 518 519 if ( 520 self.base_adapter_config.allow_saving 521 and Config.shared().autosave_runs 522 and self.task.path is not None 523 ): 524 run.save_to_file() 525 else: 526 run.id = None 527 528 return run 529 530 def _apply_input_transform(self, input: InputType) -> InputType: 531 """If the run config has an input_transform, render it and return the 532 resulting string. Otherwise return input unchanged. 533 534 MCP run configs (no input_transform field) are a no-op. 535 """ 536 if not isinstance(self.run_config, KilnAgentRunConfigProperties): 537 return input 538 transform = self.run_config.input_transform 539 if transform is None: 540 return input 541 try: 542 return render_input_transform(transform, input) 543 except Exception as e: 544 raise ValueError(f"Input transform failed: {e}") from e 545 546 def has_structured_output(self) -> bool: 547 return self.output_schema is not None 548 549 @abstractmethod 550 def adapter_name(self) -> str: 551 pass 552 553 @abstractmethod 554 async def _run( 555 self, 556 input: InputType, 557 trace_ref: list[ChatCompletionMessageParam], 558 prior_trace: list[ChatCompletionMessageParam] | None = None, 559 ) -> Tuple[RunOutput, Usage | None]: 560 """Run the model. Implementations MUST mutate `trace_ref` in place 561 (extend/append, or `trace_ref[:] = ...`) — never rebind it — so the 562 caller keeps a live reference to the partial trace if an exception 563 escapes. 564 """ 565 pass 566 567 def _messages_to_trace( 568 self, 569 messages: list[ChatCompletionMessageParam], 570 ) -> list[ChatCompletionMessageParam]: 571 """Convert the adapter's internal `messages` list to an API-safe trace. 572 573 Default implementation returns the list as-is. Adapters that store 574 internal message objects (e.g. LiteLLM's `Message`) should override 575 this to normalize to `ChatCompletionMessageParam` shapes. 576 """ 577 return messages 578 579 def _create_run_stream( 580 self, 581 input: InputType, 582 prior_trace: list[ChatCompletionMessageParam] | None = None, 583 ) -> AdapterStream: 584 """Create a stream for the adapter. Implementations must override this method to support streaming.""" 585 raise NotImplementedError("Streaming is not supported for this adapter type") 586 587 def build_prompt(self) -> str: 588 if self.prompt_builder is None: 589 raise ValueError("Prompt builder is not available for MCP run config") 590 # The prompt builder needs to know if we want to inject formatting instructions 591 structured_output_mode = as_kiln_agent_run_config( 592 self.run_config 593 ).structured_output_mode 594 add_json_instructions = self.has_structured_output() and ( 595 structured_output_mode == StructuredOutputMode.json_instructions 596 or structured_output_mode 597 == StructuredOutputMode.json_instruction_and_object 598 ) 599 600 return self.prompt_builder.build_prompt( 601 include_json_instructions=add_json_instructions, 602 skills=self._resolve_skills(), 603 ) 604 605 def _resolve_skills(self) -> list[Skill]: 606 """Resolve skills from the injected skills dict. 607 608 Uses the pre-loaded skills dict from AdapterConfig. Caches the result 609 so that build_prompt and available_tools don't repeat 610 the lookup. Raises ValueError if the run config references a skill 611 that is not in the injected dict. 612 """ 613 if self._resolved_skills is not None: 614 return self._resolved_skills 615 616 if self.run_config.type != "kiln_agent": 617 self._resolved_skills = [] 618 return self._resolved_skills 619 620 tool_config = as_kiln_agent_run_config(self.run_config).tools_config 621 if tool_config is None or tool_config.tools is None: 622 self._resolved_skills = [] 623 return self._resolved_skills 624 625 skill_tool_ids = [ 626 tid for tid in tool_config.tools if tid.startswith(SKILL_TOOL_ID_PREFIX) 627 ] 628 if not skill_tool_ids: 629 self._resolved_skills = [] 630 return self._resolved_skills 631 632 injected = self.base_adapter_config.skills 633 if injected is None: 634 raise ValueError( 635 "Run config references skills but no skills dict was provided via " 636 "AdapterConfig(skills=...). Use load_skills_for_task() to pre-load " 637 "skills and pass them to the adapter." 638 ) 639 640 skills: list[Skill] = [] 641 seen: set[str] = set() 642 for tool_id in skill_tool_ids: 643 sid = skill_id_from_tool_id(tool_id) 644 if sid not in injected: 645 raise ValueError( 646 f"Skill {sid} referenced in run config but not found in the " 647 "injected skills dict." 648 ) 649 if sid in seen: 650 continue 651 seen.add(sid) 652 skills.append(injected[sid]) 653 654 self._resolved_skills = skills 655 return self._resolved_skills 656 657 def build_chat_formatter( 658 self, 659 input: InputType, 660 prior_trace: list[ChatCompletionMessageParam] | None = None, 661 ) -> ChatFormatter: 662 prior_trace = self._normalize_prior_trace(prior_trace) 663 self._reject_multiturn_with_structured_input(prior_trace) 664 if prior_trace is not None: 665 return MultiturnFormatter(prior_trace, input) 666 if self.prompt_builder is None: 667 raise ValueError("Prompt builder is not available for MCP run config") 668 # Determine the chat strategy to use based on the prompt the user selected, the model's capabilities, and if the model was finetuned with a specific chat strategy. 669 670 cot_prompt = self.prompt_builder.chain_of_thought_prompt() 671 system_message = self.build_prompt() 672 673 # The model only enters the decision once a COT prompt is in play, so keep the 674 # provider lookup lazy: a plain prompt is single turn whatever the model is, and 675 # resolving a provider can fail. 676 provider = self.model_provider() if cot_prompt else None 677 strategy = chat_strategy_for_run( 678 cot_prompt=cot_prompt, 679 tuned_chat_strategy=provider.tuned_chat_strategy if provider else None, 680 reasoning_capable=provider.reasoning_capable if provider else False, 681 ) 682 return get_chat_formatter( 683 strategy=strategy, 684 system_message=system_message, 685 user_input=input, 686 thinking_instructions=cot_prompt, 687 ) 688 689 # create a run and task output 690 def generate_run( 691 self, 692 input: InputType, 693 input_source: DataSource | None, 694 run_output: RunOutput, 695 usage: Usage | None = None, 696 trace: list[ChatCompletionMessageParam] | None = None, 697 parent_task_run: TaskRun | None = None, 698 ) -> TaskRun: 699 output_str = ( 700 json.dumps(run_output.output, ensure_ascii=False) 701 if isinstance(run_output.output, dict) 702 else run_output.output 703 ) 704 705 output_source_type = ( 706 DataSourceType.tool_call 707 if self.run_config.type == "mcp" 708 else DataSourceType.synthetic 709 ) 710 711 new_output = TaskOutput( 712 output=output_str, 713 source=DataSource( 714 type=output_source_type, 715 properties=self._properties_for_task_output(), 716 run_config_id=self.base_adapter_config.task_run_config_id, 717 run_config=self.run_config, 718 ), 719 ) 720 721 # Convert input and output to JSON strings if they aren't strings 722 input_str = ( 723 input if isinstance(input, str) else json.dumps(input, ensure_ascii=False) 724 ) 725 726 if input_source is None: 727 input_source = DataSource( 728 type=DataSourceType.human, 729 properties={"created_by": Config.shared().user_id}, 730 ) 731 732 parent_task_run_id: str | None = None 733 if parent_task_run is not None: 734 if parent_task_run.id is None: 735 raise ValueError( 736 "parent_task_run must be persisted before using as parent: save the parent " 737 "TaskRun (e.g. save_to_file()) so it has a stable id." 738 ) 739 parent_task_run_id = parent_task_run.id 740 741 return TaskRun( 742 parent=self.task, 743 parent_task_run_id=parent_task_run_id, 744 input=input_str, 745 input_source=input_source, 746 output=new_output, 747 intermediate_outputs=run_output.intermediate_outputs, 748 tags=self.base_adapter_config.default_tags or [], 749 usage=usage, 750 trace=trace, 751 cumulative_usage=MessageUsage.from_trace(trace), 752 ) 753 754 def _properties_for_task_output(self) -> Dict[str, str | int | float]: 755 match self.run_config.type: 756 case "mcp": 757 return {} 758 case "kiln_agent": 759 if not isinstance(self.run_config, KilnAgentRunConfigProperties): 760 raise ValueError("Kiln agent run config is required") 761 run_config = self.run_config 762 763 props: Dict[str, str | int | float] = {} 764 props["adapter_name"] = self.adapter_name() 765 # Legacy properties where we save the run_config details into custom properties. 766 # These are now also be saved in the run_config field. 767 props["model_name"] = run_config.model_name 768 props["model_provider"] = run_config.model_provider_name 769 props["prompt_id"] = run_config.prompt_id 770 props["structured_output_mode"] = run_config.structured_output_mode 771 props["temperature"] = run_config.temperature 772 props["top_p"] = run_config.top_p 773 774 return props 775 case _: 776 raise_exhaustive_enum_error(self.run_config.type) 777 778 def update_run_config_unknown_structured_output_mode(self) -> None: 779 if self.run_config.type != "kiln_agent": 780 return 781 run_config = as_kiln_agent_run_config(self.run_config) 782 structured_output_mode = run_config.structured_output_mode 783 784 # Old datamodels didn't save the structured output mode. Some clients (tests, end users) might not set it. 785 # Look up our recommended mode from ml_model_list if we have one 786 if structured_output_mode == StructuredOutputMode.unknown: 787 new_run_config = run_config.model_copy(deep=True) 788 structured_output_mode = default_structured_output_mode_for_model_provider( 789 run_config.model_name, 790 run_config.model_provider_name, 791 ) 792 new_run_config.structured_output_mode = structured_output_mode 793 self.run_config = new_run_config 794 795 async def available_tools(self) -> list[KilnToolInterface]: 796 if self.run_config.type != "kiln_agent": 797 return [] 798 tool_config = as_kiln_agent_run_config(self.run_config).tools_config 799 if tool_config is None or tool_config.tools is None: 800 return [] 801 802 return await assemble_unique_agent_tools( 803 self.task, tool_config.tools, self._resolve_skills() 804 ) 805 806 807async def assemble_unique_agent_tools( 808 task: Task, tool_ids: list[str], skills: list[Skill] 809) -> list[KilnToolInterface]: 810 """Resolve a run config's tool IDs into tool instances, rejecting name collisions. 811 812 Names may repeat across a project, but within one run config every tool the 813 model sees must have a unique function name (and every attached skill a 814 unique skill name — skills are loaded by name through the single "skill" 815 tool). Shared by creation-time validation of run configs, which catches 816 collisions at selection time, and by the runtime adapter, which stays the 817 backstop for configs whose tools were renamed after creation (e.g. via 818 edit_kiln_task_tool) or that were created outside the API. 819 """ 820 non_skill_tool_ids = [ 821 tid for tid in tool_ids if not tid.startswith(SKILL_TOOL_ID_PREFIX) 822 ] 823 824 tools: list[KilnToolInterface] = [ 825 tool_from_id(tool_id, task) for tool_id in non_skill_tool_ids 826 ] 827 828 if skills: 829 seen_names: set[str] = set() 830 for skill in skills: 831 if skill.name in seen_names: 832 raise ValueError( 833 f"Duplicate skill name '{skill.name}'. Each skill must have a unique name." 834 ) 835 seen_names.add(skill.name) 836 tools.append(SkillTool(f"{SKILL_TOOL_ID_PREFIX}_combined", skills)) 837 838 tool_names = [await tool.name() for tool in tools] 839 if len(tool_names) != len(set(tool_names)): 840 duplicates = sorted({n for n in tool_names if tool_names.count(n) > 1}) 841 message = ( 842 f"Multiple selected tools share the same function name: {', '.join(duplicates)}. " 843 "Each tool must have a unique name. Either de-select the duplicate tools, or " 844 "modify their names to describe their unique purpose. The model will struggle " 845 "if tools do not have descriptive names and tool execution will be undefined." 846 ) 847 if skills and "skill" in duplicates: 848 message += ( 849 " The name 'skill' is reserved for the tool that loads skills, " 850 "so no other tool may use it while skills are selected." 851 ) 852 raise ValueError(message) 853 854 return tools 855 856 857class OpenAIStreamResult: 858 """Async-iterable wrapper around the OpenAI streaming flow. 859 860 Yields ``ModelResponseStream`` chunks. After iteration the resulting 861 ``TaskRun`` is available via the ``.task_run`` property. 862 863 When return_on_tool_call=True and the model requests tool calls, the stream 864 will stop and ``task_run.is_toolcall_pending`` will be True. 865 """ 866 867 def __init__( 868 self, 869 adapter: BaseAdapter, 870 input: InputType, 871 input_source: DataSource | None, 872 prior_trace: list[ChatCompletionMessageParam] | None, 873 parent_task_run: TaskRun | None = None, 874 ) -> None: 875 self._adapter = adapter 876 self._input = input 877 self._input_source = input_source 878 self._prior_trace = prior_trace 879 self._parent_task_run = parent_task_run 880 self._task_run: TaskRun | None = None 881 882 @property 883 def task_run(self) -> TaskRun: 884 if self._task_run is None: 885 raise RuntimeError( 886 "Stream has not been fully consumed yet. " 887 "Iterate over the stream before accessing .task_run" 888 ) 889 return self._task_run 890 891 async def __aiter__(self) -> AsyncIterator[ModelResponseStream]: 892 self._task_run = None 893 async with mcp_session_scope(): 894 adapter_stream = self._adapter._prepare_stream( 895 self._input, self._prior_trace 896 ) 897 898 async for event in adapter_stream: 899 if isinstance(event, ModelResponseStream): 900 yield event 901 902 self._task_run = self._adapter._finalize_stream( 903 adapter_stream, self._input, self._input_source, self._parent_task_run 904 ) 905 906 907class AiSdkStreamResult: 908 """Async-iterable wrapper around the AI SDK streaming flow. 909 910 Yields ``AiSdkStreamEvent`` instances. After iteration the resulting 911 ``TaskRun`` is available via the ``.task_run`` property. 912 913 When return_on_tool_call=True and the model requests tool calls, the FINISH 914 event will have finishReason: "tool-calls" and ``task_run.is_toolcall_pending`` 915 will be True. 916 """ 917 918 def __init__( 919 self, 920 adapter: BaseAdapter, 921 input: InputType, 922 input_source: DataSource | None, 923 prior_trace: list[ChatCompletionMessageParam] | None, 924 parent_task_run: TaskRun | None = None, 925 ) -> None: 926 self._adapter = adapter 927 self._input = input 928 self._input_source = input_source 929 self._prior_trace = prior_trace 930 self._parent_task_run = parent_task_run 931 self._task_run: TaskRun | None = None 932 933 @property 934 def task_run(self) -> TaskRun: 935 if self._task_run is None: 936 raise RuntimeError( 937 "Stream has not been fully consumed yet. " 938 "Iterate over the stream before accessing .task_run" 939 ) 940 return self._task_run 941 942 async def __aiter__(self) -> AsyncIterator[AiSdkStreamEvent]: 943 self._task_run = None 944 async with mcp_session_scope(): 945 adapter_stream = self._adapter._prepare_stream( 946 self._input, self._prior_trace 947 ) 948 949 message_id = f"msg-{uuid.uuid4().hex}" 950 converter = AiSdkStreamConverter() 951 952 yield StartEvent(messageId=message_id) 953 yield StartStepEvent() 954 955 last_event_was_tool_call = False 956 async for event in adapter_stream: 957 if isinstance(event, ModelResponseStream): 958 if last_event_was_tool_call: 959 converter.reset_for_next_step() 960 last_event_was_tool_call = False 961 for ai_event in converter.convert_chunk(event): 962 yield ai_event 963 elif isinstance(event, ToolCallEvent): 964 last_event_was_tool_call = True 965 for ai_event in converter.convert_tool_event(event): 966 yield ai_event 967 968 for ai_event in converter.close_open_blocks(): 969 yield ai_event 970 971 yield FinishStepEvent() 972 973 self._task_run = self._adapter._finalize_stream( 974 adapter_stream, self._input, self._input_source, self._parent_task_run 975 ) 976 977 if self._task_run.is_toolcall_pending: 978 yield FinishEvent( 979 messageMetadata=FinishMessageMetadata(finishReason="tool-calls"), 980 ) 981 else: 982 for ai_event in converter.finalize(): 983 yield ai_event
79@dataclass 80class AdapterConfig: 81 """ 82 An adapter config is config options that do NOT impact the output of the model. 83 84 For example: if it's saved, of if we request additional data like logprobs. 85 """ 86 87 allow_saving: bool = True 88 top_logprobs: int | None = None 89 default_tags: list[str] | None = None 90 91 """ 92 The ID of the TaskRunConfig that originated this run, if any. Stored on the 93 resulting TaskRun so the run can be traced back to its originating saved config 94 (in addition to the inline run_config snapshot). None for ad-hoc/inline runs 95 that were not initiated from a saved TaskRunConfig. 96 """ 97 task_run_config_id: str | None = None 98 99 """ 100 A custom prompt builder can be injected to override the system prompt building process. 101 If not provided, the prompt builder will be created from the run_config.prompt_id which 102 may load additional files from disk. 103 """ 104 prompt_builder: BasePromptBuilder | None = None 105 106 """ 107 Pre-loaded skills keyed by skill ID. When the run config references skills, 108 they are looked up from this dict instead of reading from the filesystem. 109 Use load_skills_for_task() to build this dict. 110 """ 111 skills: SkillsDict | None = None 112 113 """ 114 When True, the adapter will stop and return control to the caller when a tool call 115 is invoked, instead of processing tool calls internally. Default is False (process 116 tool calls internally). 117 """ 118 return_on_tool_call: bool = False 119 120 """ 121 Extra tools provided directly by the caller, in addition to tools resolved from the 122 task's tool registry. These are sent to the model together with registry tools, and 123 their names must not collide with registry tool names. 124 125 If ``return_on_tool_call`` is False (the default), the adapter executes these tools 126 itself just like registry tools. If True, the adapter returns as soon as the model 127 requests a tool call and the caller is responsible for running the tool and passing 128 results back via ``prior_trace``. 129 """ 130 unmanaged_tools: list[KilnToolInterface] | None = None 131 132 """ 133 When True, automatically inject prompt caching hints into completion 134 requests. This is a cost optimization and does not affect model output. 135 """ 136 automatic_prompt_caching: bool = False
An adapter config is config options that do NOT impact the output of the model.
For example: if it's saved, of if we request additional data like logprobs.
A custom prompt builder can be injected to override the system prompt building process. If not provided, the prompt builder will be created from the run_config.prompt_id which may load additional files from disk.
Pre-loaded skills keyed by skill ID. When the run config references skills, they are looked up from this dict instead of reading from the filesystem. Use load_skills_for_task() to build this dict.
When True, the adapter will stop and return control to the caller when a tool call is invoked, instead of processing tool calls internally. Default is False (process tool calls internally).
Extra tools provided directly by the caller, in addition to tools resolved from the task's tool registry. These are sent to the model together with registry tools, and their names must not collide with registry tool names.
If return_on_tool_call is False (the default), the adapter executes these tools
itself just like registry tools. If True, the adapter returns as soon as the model
requests a tool call and the caller is responsible for running the tool and passing
results back via prior_trace.
139class BaseAdapter(metaclass=ABCMeta): 140 """Base class for AI model adapters that handle task execution. 141 142 This abstract class provides the foundation for implementing model-specific adapters 143 that can process tasks with structured or unstructured inputs/outputs. It handles 144 input/output validation, prompt building, and run tracking. 145 146 Prompt building is handled internally by the adapter, which uses a prompt builder 147 based on the run config. To override the prompt building behavior, pass a custom prompt 148 builder to the adapter config. 149 """ 150 151 def __init__( 152 self, 153 task: Task, 154 run_config: RunConfigProperties, 155 config: AdapterConfig | None = None, 156 ): 157 self.task = task 158 self.run_config: RunConfigProperties = run_config 159 self.base_adapter_config = config or AdapterConfig() 160 161 if isinstance(run_config, KilnAgentRunConfigProperties): 162 self.update_run_config_unknown_structured_output_mode() 163 self.prompt_builder = ( 164 self.base_adapter_config.prompt_builder 165 or prompt_builder_from_id(run_config.prompt_id, task) 166 ) 167 else: 168 self.prompt_builder = None 169 self._model_provider: KilnModelProvider | None = None 170 self._resolved_skills: list[Skill] | None = None 171 172 self.output_schema = task.output_json_schema 173 self.input_schema = task.input_json_schema 174 175 def model_provider(self) -> KilnModelProvider: 176 """ 177 Lazy load the model provider for this adapter. 178 """ 179 if self._model_provider is not None: 180 return self._model_provider 181 run_config = as_kiln_agent_run_config(self.run_config) 182 if not run_config.model_name or not run_config.model_provider_name: 183 raise ValueError("model_name and model_provider_name must be provided") 184 self._model_provider = kiln_model_provider_from( 185 run_config.model_name, run_config.model_provider_name 186 ) 187 if not self._model_provider: 188 raise ValueError( 189 f"model_provider_name {run_config.model_provider_name} not found for model {run_config.model_name}" 190 ) 191 return self._model_provider 192 193 @staticmethod 194 def _normalize_prior_trace( 195 prior_trace: list[ChatCompletionMessageParam] | None, 196 ) -> list[ChatCompletionMessageParam] | None: 197 if not prior_trace: 198 return None 199 return prior_trace 200 201 def _reject_multiturn_with_structured_input( 202 self, 203 prior_trace: list[ChatCompletionMessageParam] | None, 204 ) -> None: 205 if prior_trace is not None and self.input_schema is not None: 206 raise ValueError( 207 "Cannot run multiturn execution with a task that has a structured input schema. " 208 "Use an unstructured task, or call without prior_trace." 209 ) 210 211 async def invoke( 212 self, 213 input: InputType, 214 input_source: DataSource | None = None, 215 prior_trace: list[ChatCompletionMessageParam] | None = None, 216 parent_task_run: TaskRun | None = None, 217 ) -> TaskRun: 218 task_run, _ = await self.invoke_returning_run_output( 219 input, input_source, prior_trace, parent_task_run 220 ) 221 return task_run 222 223 async def _run_returning_run_output( 224 self, 225 input: InputType, 226 input_source: DataSource | None = None, 227 prior_trace: list[ChatCompletionMessageParam] | None = None, 228 parent_task_run: TaskRun | None = None, 229 ) -> Tuple[TaskRun, RunOutput]: 230 # Pre-run validation: these checks run before any model call, so 231 # there is no trace to preserve. They stay outside the 232 # exception-wrapping block and surface as plain exceptions. 233 prior_trace = self._normalize_prior_trace(prior_trace) 234 self._reject_multiturn_with_structured_input(prior_trace) 235 236 if self.input_schema is not None: 237 validate_schema_with_value_error( 238 input, 239 self.input_schema, 240 "This task requires a specific input schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 241 require_object=False, 242 ) 243 244 # Apply input transform if configured. The original `input` is preserved 245 # for TaskRun.input persistence; `model_input` is what the model sees. 246 model_input = self._apply_input_transform(input) 247 248 # Format model input for model call (we save the original input in the 249 # task without formatting). This runs in the adapter but before any 250 # trace is built, so it also stays outside the wrapped region. 251 formatted_input = model_input 252 formatter_id = self.model_provider().formatter 253 if formatter_id is not None: 254 formatter = request_formatter_from_id(formatter_id) 255 formatted_input = formatter.format_input(model_input) 256 257 # Allocate the trace-so-far list here so the reference survives any 258 # exception thrown from inside `_run` (or the post-processing that 259 # follows). `_run` must mutate this list in place (extend/append, 260 # or `list[:] = ...`) and never rebind the local name. 261 trace_ref: list[ChatCompletionMessageParam] = [] 262 try: 263 run_output, usage = await self._run( 264 formatted_input, trace_ref, prior_trace=prior_trace 265 ) 266 267 if not run_output.is_toolcall_pending: 268 # Normal completion: parse and validate output 269 provider = self.model_provider() 270 parser = model_parser_from_id(provider.parser) 271 parsed_output = parser.parse_output(original_output=run_output) 272 273 # validate output 274 if self.output_schema is not None: 275 # Parse json to dict if we have structured output 276 if isinstance(parsed_output.output, str): 277 try: 278 parsed_output.output = parse_json_string( 279 parsed_output.output 280 ) 281 except ValueError as e: 282 # Re-type (message unchanged) so retries treat bad 283 # JSON like a schema mismatch: a one-off model slip. 284 raise StructuredOutputParseError(str(e)) from e 285 286 if not isinstance(parsed_output.output, dict): 287 # Valid JSON of the wrong shape (often the object wrapped 288 # in a list) is the same one-off model slip as bad JSON, 289 # so it carries the retryable type too. 290 raise StructuredOutputParseError( 291 f"structured response is not a dict: {parsed_output.output}" 292 ) 293 validate_schema_with_value_error( 294 parsed_output.output, 295 self.output_schema, 296 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 297 ) 298 else: 299 if not isinstance(parsed_output.output, str): 300 raise RuntimeError( 301 f"response is not a string for non-structured task: {parsed_output.output}" 302 ) 303 304 trace_has_toolcalls = parsed_output.trace is not None and any( 305 message.get("role", None) == "tool" 306 for message in parsed_output.trace 307 ) 308 309 # Validate reasoning content is present if required. 310 # Models often skip reasoning on the final turn when tools are involved, so we don't require it then. 311 if ( 312 provider.reasoning_capable 313 and ( 314 not parsed_output.intermediate_outputs 315 or "reasoning" not in parsed_output.intermediate_outputs 316 ) 317 and not ( 318 provider.reasoning_optional_for_structured_output 319 and self.has_structured_output() 320 ) 321 and not trace_has_toolcalls 322 ): 323 raise RuntimeError( 324 "Reasoning is required for this model, but no reasoning was returned." 325 ) 326 327 run_output = parsed_output 328 329 run = self.generate_run( 330 input, 331 input_source, 332 run_output, 333 usage, 334 run_output.trace, 335 parent_task_run, 336 ) 337 338 # Save the run if configured to do so, and we have a path to save to 339 if ( 340 self.base_adapter_config.allow_saving 341 and Config.shared().autosave_runs 342 and self.task.path is not None 343 ): 344 run.save_to_file() 345 else: 346 # Clear the ID to indicate it's not persisted 347 run.id = None 348 349 return run, run_output 350 except KilnRunError: 351 # Already wrapped — pass through so we don't double-wrap. 352 raise 353 except Exception as e: 354 # Trace conversion can itself throw (e.g., a malformed partial 355 # assistant message was appended to `messages` just before the 356 # real failure). Never let that swallow the original exception — 357 # fall back to no trace so the user still sees the real error. 358 partial_trace: list[ChatCompletionMessageParam] | None = None 359 if trace_ref: 360 try: 361 partial_trace = self._messages_to_trace(trace_ref) 362 except Exception: 363 partial_trace = None 364 raise KilnRunError( 365 message=format_error_message(e), 366 partial_trace=partial_trace, 367 original=e, 368 ) from e 369 370 async def invoke_returning_run_output( 371 self, 372 input: InputType, 373 input_source: DataSource | None = None, 374 prior_trace: list[ChatCompletionMessageParam] | None = None, 375 parent_task_run: TaskRun | None = None, 376 ) -> Tuple[TaskRun, RunOutput]: 377 async with mcp_session_scope(): 378 return await self._run_returning_run_output( 379 input, input_source, prior_trace, parent_task_run 380 ) 381 382 def invoke_openai_stream( 383 self, 384 input: InputType, 385 input_source: DataSource | None = None, 386 prior_trace: list[ChatCompletionMessageParam] | None = None, 387 parent_task_run: TaskRun | None = None, 388 ) -> OpenAIStreamResult: 389 """Stream raw OpenAI-protocol chunks for the task execution. 390 391 Returns an async-iterable that yields ``ModelResponseStream`` chunks 392 as they arrive from the model. After the iterator is exhausted the 393 run has been validated and saved (when configured). The resulting 394 ``TaskRun`` is available via the ``.task_run`` property. 395 396 Tool-call rounds happen internally and are not surfaced; use 397 ``invoke_ai_sdk_stream`` if you need tool-call events. 398 """ 399 return OpenAIStreamResult( 400 self, input, input_source, prior_trace, parent_task_run 401 ) 402 403 def invoke_ai_sdk_stream( 404 self, 405 input: InputType, 406 input_source: DataSource | None = None, 407 prior_trace: list[ChatCompletionMessageParam] | None = None, 408 parent_task_run: TaskRun | None = None, 409 ) -> AiSdkStreamResult: 410 """Stream AI SDK protocol events for the task execution. 411 412 Returns an async-iterable that yields ``AiSdkStreamEvent`` instances 413 covering text, reasoning, tool-call lifecycle, step boundaries, and 414 control events. After the iterator is exhausted the resulting 415 ``TaskRun`` is available via the ``.task_run`` property. 416 """ 417 return AiSdkStreamResult( 418 self, input, input_source, prior_trace, parent_task_run 419 ) 420 421 def _prepare_stream( 422 self, 423 input: InputType, 424 prior_trace: list[ChatCompletionMessageParam] | None, 425 ) -> AdapterStream: 426 prior_trace = self._normalize_prior_trace(prior_trace) 427 self._reject_multiturn_with_structured_input(prior_trace) 428 429 if self.input_schema is not None: 430 validate_schema_with_value_error( 431 input, 432 self.input_schema, 433 "This task requires a specific input schema. While the model produced JSON, that JSON didn't meet the schema. Search 'Troubleshooting Structured Data Issues' in our docs for more information.", 434 require_object=False, 435 ) 436 437 model_input = self._apply_input_transform(input) 438 439 formatted_input = model_input 440 formatter_id = self.model_provider().formatter 441 if formatter_id is not None: 442 formatter = request_formatter_from_id(formatter_id) 443 formatted_input = formatter.format_input(model_input) 444 445 return self._create_run_stream(formatted_input, prior_trace) 446 447 def _finalize_stream( 448 self, 449 adapter_stream: AdapterStream, 450 input: InputType, 451 input_source: DataSource | None, 452 parent_task_run: TaskRun | None = None, 453 ) -> TaskRun: 454 """Streaming invocations are only concerned with passing through events as they come in. 455 At the end of the stream, we still need to validate the output, create a run and everything 456 else that a non-streaming invocation would do. 457 """ 458 459 result: AdapterStreamResult = adapter_stream.result 460 run_output = result.run_output 461 usage = result.usage 462 463 if not run_output.is_toolcall_pending: 464 # Normal completion: parse and validate output 465 provider = self.model_provider() 466 parser = model_parser_from_id(provider.parser) 467 parsed_output = parser.parse_output(original_output=run_output) 468 469 if self.output_schema is not None: 470 if isinstance(parsed_output.output, str): 471 try: 472 parsed_output.output = parse_json_string(parsed_output.output) 473 except ValueError as e: 474 # Re-type (message unchanged) so retries treat bad JSON 475 # like a schema mismatch: a one-off model slip. 476 raise StructuredOutputParseError(str(e)) from e 477 if not isinstance(parsed_output.output, dict): 478 # Valid JSON of the wrong shape (often the object wrapped in 479 # a list) is the same one-off model slip as bad JSON, so it 480 # carries the retryable type too. 481 raise StructuredOutputParseError( 482 f"structured response is not a dict: {parsed_output.output}" 483 ) 484 validate_schema_with_value_error( 485 parsed_output.output, 486 self.output_schema, 487 TASK_OUTPUT_SCHEMA_ERROR_PREFIX, 488 ) 489 else: 490 if not isinstance(parsed_output.output, str): 491 raise RuntimeError( 492 f"response is not a string for non-structured task: {parsed_output.output}" 493 ) 494 495 trace_has_toolcalls = parsed_output.trace is not None and any( 496 message.get("role", None) == "tool" for message in parsed_output.trace 497 ) 498 if ( 499 provider.reasoning_capable 500 and ( 501 not parsed_output.intermediate_outputs 502 or "reasoning" not in parsed_output.intermediate_outputs 503 ) 504 and not ( 505 provider.reasoning_optional_for_structured_output 506 and self.has_structured_output() 507 ) 508 and not trace_has_toolcalls 509 ): 510 raise RuntimeError( 511 "Reasoning is required for this model, but no reasoning was returned." 512 ) 513 514 run_output = parsed_output 515 516 run = self.generate_run( 517 input, input_source, run_output, usage, run_output.trace, parent_task_run 518 ) 519 520 if ( 521 self.base_adapter_config.allow_saving 522 and Config.shared().autosave_runs 523 and self.task.path is not None 524 ): 525 run.save_to_file() 526 else: 527 run.id = None 528 529 return run 530 531 def _apply_input_transform(self, input: InputType) -> InputType: 532 """If the run config has an input_transform, render it and return the 533 resulting string. Otherwise return input unchanged. 534 535 MCP run configs (no input_transform field) are a no-op. 536 """ 537 if not isinstance(self.run_config, KilnAgentRunConfigProperties): 538 return input 539 transform = self.run_config.input_transform 540 if transform is None: 541 return input 542 try: 543 return render_input_transform(transform, input) 544 except Exception as e: 545 raise ValueError(f"Input transform failed: {e}") from e 546 547 def has_structured_output(self) -> bool: 548 return self.output_schema is not None 549 550 @abstractmethod 551 def adapter_name(self) -> str: 552 pass 553 554 @abstractmethod 555 async def _run( 556 self, 557 input: InputType, 558 trace_ref: list[ChatCompletionMessageParam], 559 prior_trace: list[ChatCompletionMessageParam] | None = None, 560 ) -> Tuple[RunOutput, Usage | None]: 561 """Run the model. Implementations MUST mutate `trace_ref` in place 562 (extend/append, or `trace_ref[:] = ...`) — never rebind it — so the 563 caller keeps a live reference to the partial trace if an exception 564 escapes. 565 """ 566 pass 567 568 def _messages_to_trace( 569 self, 570 messages: list[ChatCompletionMessageParam], 571 ) -> list[ChatCompletionMessageParam]: 572 """Convert the adapter's internal `messages` list to an API-safe trace. 573 574 Default implementation returns the list as-is. Adapters that store 575 internal message objects (e.g. LiteLLM's `Message`) should override 576 this to normalize to `ChatCompletionMessageParam` shapes. 577 """ 578 return messages 579 580 def _create_run_stream( 581 self, 582 input: InputType, 583 prior_trace: list[ChatCompletionMessageParam] | None = None, 584 ) -> AdapterStream: 585 """Create a stream for the adapter. Implementations must override this method to support streaming.""" 586 raise NotImplementedError("Streaming is not supported for this adapter type") 587 588 def build_prompt(self) -> str: 589 if self.prompt_builder is None: 590 raise ValueError("Prompt builder is not available for MCP run config") 591 # The prompt builder needs to know if we want to inject formatting instructions 592 structured_output_mode = as_kiln_agent_run_config( 593 self.run_config 594 ).structured_output_mode 595 add_json_instructions = self.has_structured_output() and ( 596 structured_output_mode == StructuredOutputMode.json_instructions 597 or structured_output_mode 598 == StructuredOutputMode.json_instruction_and_object 599 ) 600 601 return self.prompt_builder.build_prompt( 602 include_json_instructions=add_json_instructions, 603 skills=self._resolve_skills(), 604 ) 605 606 def _resolve_skills(self) -> list[Skill]: 607 """Resolve skills from the injected skills dict. 608 609 Uses the pre-loaded skills dict from AdapterConfig. Caches the result 610 so that build_prompt and available_tools don't repeat 611 the lookup. Raises ValueError if the run config references a skill 612 that is not in the injected dict. 613 """ 614 if self._resolved_skills is not None: 615 return self._resolved_skills 616 617 if self.run_config.type != "kiln_agent": 618 self._resolved_skills = [] 619 return self._resolved_skills 620 621 tool_config = as_kiln_agent_run_config(self.run_config).tools_config 622 if tool_config is None or tool_config.tools is None: 623 self._resolved_skills = [] 624 return self._resolved_skills 625 626 skill_tool_ids = [ 627 tid for tid in tool_config.tools if tid.startswith(SKILL_TOOL_ID_PREFIX) 628 ] 629 if not skill_tool_ids: 630 self._resolved_skills = [] 631 return self._resolved_skills 632 633 injected = self.base_adapter_config.skills 634 if injected is None: 635 raise ValueError( 636 "Run config references skills but no skills dict was provided via " 637 "AdapterConfig(skills=...). Use load_skills_for_task() to pre-load " 638 "skills and pass them to the adapter." 639 ) 640 641 skills: list[Skill] = [] 642 seen: set[str] = set() 643 for tool_id in skill_tool_ids: 644 sid = skill_id_from_tool_id(tool_id) 645 if sid not in injected: 646 raise ValueError( 647 f"Skill {sid} referenced in run config but not found in the " 648 "injected skills dict." 649 ) 650 if sid in seen: 651 continue 652 seen.add(sid) 653 skills.append(injected[sid]) 654 655 self._resolved_skills = skills 656 return self._resolved_skills 657 658 def build_chat_formatter( 659 self, 660 input: InputType, 661 prior_trace: list[ChatCompletionMessageParam] | None = None, 662 ) -> ChatFormatter: 663 prior_trace = self._normalize_prior_trace(prior_trace) 664 self._reject_multiturn_with_structured_input(prior_trace) 665 if prior_trace is not None: 666 return MultiturnFormatter(prior_trace, input) 667 if self.prompt_builder is None: 668 raise ValueError("Prompt builder is not available for MCP run config") 669 # Determine the chat strategy to use based on the prompt the user selected, the model's capabilities, and if the model was finetuned with a specific chat strategy. 670 671 cot_prompt = self.prompt_builder.chain_of_thought_prompt() 672 system_message = self.build_prompt() 673 674 # The model only enters the decision once a COT prompt is in play, so keep the 675 # provider lookup lazy: a plain prompt is single turn whatever the model is, and 676 # resolving a provider can fail. 677 provider = self.model_provider() if cot_prompt else None 678 strategy = chat_strategy_for_run( 679 cot_prompt=cot_prompt, 680 tuned_chat_strategy=provider.tuned_chat_strategy if provider else None, 681 reasoning_capable=provider.reasoning_capable if provider else False, 682 ) 683 return get_chat_formatter( 684 strategy=strategy, 685 system_message=system_message, 686 user_input=input, 687 thinking_instructions=cot_prompt, 688 ) 689 690 # create a run and task output 691 def generate_run( 692 self, 693 input: InputType, 694 input_source: DataSource | None, 695 run_output: RunOutput, 696 usage: Usage | None = None, 697 trace: list[ChatCompletionMessageParam] | None = None, 698 parent_task_run: TaskRun | None = None, 699 ) -> TaskRun: 700 output_str = ( 701 json.dumps(run_output.output, ensure_ascii=False) 702 if isinstance(run_output.output, dict) 703 else run_output.output 704 ) 705 706 output_source_type = ( 707 DataSourceType.tool_call 708 if self.run_config.type == "mcp" 709 else DataSourceType.synthetic 710 ) 711 712 new_output = TaskOutput( 713 output=output_str, 714 source=DataSource( 715 type=output_source_type, 716 properties=self._properties_for_task_output(), 717 run_config_id=self.base_adapter_config.task_run_config_id, 718 run_config=self.run_config, 719 ), 720 ) 721 722 # Convert input and output to JSON strings if they aren't strings 723 input_str = ( 724 input if isinstance(input, str) else json.dumps(input, ensure_ascii=False) 725 ) 726 727 if input_source is None: 728 input_source = DataSource( 729 type=DataSourceType.human, 730 properties={"created_by": Config.shared().user_id}, 731 ) 732 733 parent_task_run_id: str | None = None 734 if parent_task_run is not None: 735 if parent_task_run.id is None: 736 raise ValueError( 737 "parent_task_run must be persisted before using as parent: save the parent " 738 "TaskRun (e.g. save_to_file()) so it has a stable id." 739 ) 740 parent_task_run_id = parent_task_run.id 741 742 return TaskRun( 743 parent=self.task, 744 parent_task_run_id=parent_task_run_id, 745 input=input_str, 746 input_source=input_source, 747 output=new_output, 748 intermediate_outputs=run_output.intermediate_outputs, 749 tags=self.base_adapter_config.default_tags or [], 750 usage=usage, 751 trace=trace, 752 cumulative_usage=MessageUsage.from_trace(trace), 753 ) 754 755 def _properties_for_task_output(self) -> Dict[str, str | int | float]: 756 match self.run_config.type: 757 case "mcp": 758 return {} 759 case "kiln_agent": 760 if not isinstance(self.run_config, KilnAgentRunConfigProperties): 761 raise ValueError("Kiln agent run config is required") 762 run_config = self.run_config 763 764 props: Dict[str, str | int | float] = {} 765 props["adapter_name"] = self.adapter_name() 766 # Legacy properties where we save the run_config details into custom properties. 767 # These are now also be saved in the run_config field. 768 props["model_name"] = run_config.model_name 769 props["model_provider"] = run_config.model_provider_name 770 props["prompt_id"] = run_config.prompt_id 771 props["structured_output_mode"] = run_config.structured_output_mode 772 props["temperature"] = run_config.temperature 773 props["top_p"] = run_config.top_p 774 775 return props 776 case _: 777 raise_exhaustive_enum_error(self.run_config.type) 778 779 def update_run_config_unknown_structured_output_mode(self) -> None: 780 if self.run_config.type != "kiln_agent": 781 return 782 run_config = as_kiln_agent_run_config(self.run_config) 783 structured_output_mode = run_config.structured_output_mode 784 785 # Old datamodels didn't save the structured output mode. Some clients (tests, end users) might not set it. 786 # Look up our recommended mode from ml_model_list if we have one 787 if structured_output_mode == StructuredOutputMode.unknown: 788 new_run_config = run_config.model_copy(deep=True) 789 structured_output_mode = default_structured_output_mode_for_model_provider( 790 run_config.model_name, 791 run_config.model_provider_name, 792 ) 793 new_run_config.structured_output_mode = structured_output_mode 794 self.run_config = new_run_config 795 796 async def available_tools(self) -> list[KilnToolInterface]: 797 if self.run_config.type != "kiln_agent": 798 return [] 799 tool_config = as_kiln_agent_run_config(self.run_config).tools_config 800 if tool_config is None or tool_config.tools is None: 801 return [] 802 803 return await assemble_unique_agent_tools( 804 self.task, tool_config.tools, self._resolve_skills() 805 )
Base class for AI model adapters that handle task execution.
This abstract class provides the foundation for implementing model-specific adapters that can process tasks with structured or unstructured inputs/outputs. It handles input/output validation, prompt building, and run tracking.
Prompt building is handled internally by the adapter, which uses a prompt builder based on the run config. To override the prompt building behavior, pass a custom prompt builder to the adapter config.
175 def model_provider(self) -> KilnModelProvider: 176 """ 177 Lazy load the model provider for this adapter. 178 """ 179 if self._model_provider is not None: 180 return self._model_provider 181 run_config = as_kiln_agent_run_config(self.run_config) 182 if not run_config.model_name or not run_config.model_provider_name: 183 raise ValueError("model_name and model_provider_name must be provided") 184 self._model_provider = kiln_model_provider_from( 185 run_config.model_name, run_config.model_provider_name 186 ) 187 if not self._model_provider: 188 raise ValueError( 189 f"model_provider_name {run_config.model_provider_name} not found for model {run_config.model_name}" 190 ) 191 return self._model_provider
Lazy load the model provider for this adapter.
211 async def invoke( 212 self, 213 input: InputType, 214 input_source: DataSource | None = None, 215 prior_trace: list[ChatCompletionMessageParam] | None = None, 216 parent_task_run: TaskRun | None = None, 217 ) -> TaskRun: 218 task_run, _ = await self.invoke_returning_run_output( 219 input, input_source, prior_trace, parent_task_run 220 ) 221 return task_run
370 async def invoke_returning_run_output( 371 self, 372 input: InputType, 373 input_source: DataSource | None = None, 374 prior_trace: list[ChatCompletionMessageParam] | None = None, 375 parent_task_run: TaskRun | None = None, 376 ) -> Tuple[TaskRun, RunOutput]: 377 async with mcp_session_scope(): 378 return await self._run_returning_run_output( 379 input, input_source, prior_trace, parent_task_run 380 )
382 def invoke_openai_stream( 383 self, 384 input: InputType, 385 input_source: DataSource | None = None, 386 prior_trace: list[ChatCompletionMessageParam] | None = None, 387 parent_task_run: TaskRun | None = None, 388 ) -> OpenAIStreamResult: 389 """Stream raw OpenAI-protocol chunks for the task execution. 390 391 Returns an async-iterable that yields ``ModelResponseStream`` chunks 392 as they arrive from the model. After the iterator is exhausted the 393 run has been validated and saved (when configured). The resulting 394 ``TaskRun`` is available via the ``.task_run`` property. 395 396 Tool-call rounds happen internally and are not surfaced; use 397 ``invoke_ai_sdk_stream`` if you need tool-call events. 398 """ 399 return OpenAIStreamResult( 400 self, input, input_source, prior_trace, parent_task_run 401 )
Stream raw OpenAI-protocol chunks for the task execution.
Returns an async-iterable that yields ModelResponseStream chunks
as they arrive from the model. After the iterator is exhausted the
run has been validated and saved (when configured). The resulting
TaskRun is available via the .task_run property.
Tool-call rounds happen internally and are not surfaced; use
invoke_ai_sdk_stream if you need tool-call events.
403 def invoke_ai_sdk_stream( 404 self, 405 input: InputType, 406 input_source: DataSource | None = None, 407 prior_trace: list[ChatCompletionMessageParam] | None = None, 408 parent_task_run: TaskRun | None = None, 409 ) -> AiSdkStreamResult: 410 """Stream AI SDK protocol events for the task execution. 411 412 Returns an async-iterable that yields ``AiSdkStreamEvent`` instances 413 covering text, reasoning, tool-call lifecycle, step boundaries, and 414 control events. After the iterator is exhausted the resulting 415 ``TaskRun`` is available via the ``.task_run`` property. 416 """ 417 return AiSdkStreamResult( 418 self, input, input_source, prior_trace, parent_task_run 419 )
Stream AI SDK protocol events for the task execution.
Returns an async-iterable that yields AiSdkStreamEvent instances
covering text, reasoning, tool-call lifecycle, step boundaries, and
control events. After the iterator is exhausted the resulting
TaskRun is available via the .task_run property.
588 def build_prompt(self) -> str: 589 if self.prompt_builder is None: 590 raise ValueError("Prompt builder is not available for MCP run config") 591 # The prompt builder needs to know if we want to inject formatting instructions 592 structured_output_mode = as_kiln_agent_run_config( 593 self.run_config 594 ).structured_output_mode 595 add_json_instructions = self.has_structured_output() and ( 596 structured_output_mode == StructuredOutputMode.json_instructions 597 or structured_output_mode 598 == StructuredOutputMode.json_instruction_and_object 599 ) 600 601 return self.prompt_builder.build_prompt( 602 include_json_instructions=add_json_instructions, 603 skills=self._resolve_skills(), 604 )
658 def build_chat_formatter( 659 self, 660 input: InputType, 661 prior_trace: list[ChatCompletionMessageParam] | None = None, 662 ) -> ChatFormatter: 663 prior_trace = self._normalize_prior_trace(prior_trace) 664 self._reject_multiturn_with_structured_input(prior_trace) 665 if prior_trace is not None: 666 return MultiturnFormatter(prior_trace, input) 667 if self.prompt_builder is None: 668 raise ValueError("Prompt builder is not available for MCP run config") 669 # Determine the chat strategy to use based on the prompt the user selected, the model's capabilities, and if the model was finetuned with a specific chat strategy. 670 671 cot_prompt = self.prompt_builder.chain_of_thought_prompt() 672 system_message = self.build_prompt() 673 674 # The model only enters the decision once a COT prompt is in play, so keep the 675 # provider lookup lazy: a plain prompt is single turn whatever the model is, and 676 # resolving a provider can fail. 677 provider = self.model_provider() if cot_prompt else None 678 strategy = chat_strategy_for_run( 679 cot_prompt=cot_prompt, 680 tuned_chat_strategy=provider.tuned_chat_strategy if provider else None, 681 reasoning_capable=provider.reasoning_capable if provider else False, 682 ) 683 return get_chat_formatter( 684 strategy=strategy, 685 system_message=system_message, 686 user_input=input, 687 thinking_instructions=cot_prompt, 688 )
691 def generate_run( 692 self, 693 input: InputType, 694 input_source: DataSource | None, 695 run_output: RunOutput, 696 usage: Usage | None = None, 697 trace: list[ChatCompletionMessageParam] | None = None, 698 parent_task_run: TaskRun | None = None, 699 ) -> TaskRun: 700 output_str = ( 701 json.dumps(run_output.output, ensure_ascii=False) 702 if isinstance(run_output.output, dict) 703 else run_output.output 704 ) 705 706 output_source_type = ( 707 DataSourceType.tool_call 708 if self.run_config.type == "mcp" 709 else DataSourceType.synthetic 710 ) 711 712 new_output = TaskOutput( 713 output=output_str, 714 source=DataSource( 715 type=output_source_type, 716 properties=self._properties_for_task_output(), 717 run_config_id=self.base_adapter_config.task_run_config_id, 718 run_config=self.run_config, 719 ), 720 ) 721 722 # Convert input and output to JSON strings if they aren't strings 723 input_str = ( 724 input if isinstance(input, str) else json.dumps(input, ensure_ascii=False) 725 ) 726 727 if input_source is None: 728 input_source = DataSource( 729 type=DataSourceType.human, 730 properties={"created_by": Config.shared().user_id}, 731 ) 732 733 parent_task_run_id: str | None = None 734 if parent_task_run is not None: 735 if parent_task_run.id is None: 736 raise ValueError( 737 "parent_task_run must be persisted before using as parent: save the parent " 738 "TaskRun (e.g. save_to_file()) so it has a stable id." 739 ) 740 parent_task_run_id = parent_task_run.id 741 742 return TaskRun( 743 parent=self.task, 744 parent_task_run_id=parent_task_run_id, 745 input=input_str, 746 input_source=input_source, 747 output=new_output, 748 intermediate_outputs=run_output.intermediate_outputs, 749 tags=self.base_adapter_config.default_tags or [], 750 usage=usage, 751 trace=trace, 752 cumulative_usage=MessageUsage.from_trace(trace), 753 )
779 def update_run_config_unknown_structured_output_mode(self) -> None: 780 if self.run_config.type != "kiln_agent": 781 return 782 run_config = as_kiln_agent_run_config(self.run_config) 783 structured_output_mode = run_config.structured_output_mode 784 785 # Old datamodels didn't save the structured output mode. Some clients (tests, end users) might not set it. 786 # Look up our recommended mode from ml_model_list if we have one 787 if structured_output_mode == StructuredOutputMode.unknown: 788 new_run_config = run_config.model_copy(deep=True) 789 structured_output_mode = default_structured_output_mode_for_model_provider( 790 run_config.model_name, 791 run_config.model_provider_name, 792 ) 793 new_run_config.structured_output_mode = structured_output_mode 794 self.run_config = new_run_config
796 async def available_tools(self) -> list[KilnToolInterface]: 797 if self.run_config.type != "kiln_agent": 798 return [] 799 tool_config = as_kiln_agent_run_config(self.run_config).tools_config 800 if tool_config is None or tool_config.tools is None: 801 return [] 802 803 return await assemble_unique_agent_tools( 804 self.task, tool_config.tools, self._resolve_skills() 805 )
808async def assemble_unique_agent_tools( 809 task: Task, tool_ids: list[str], skills: list[Skill] 810) -> list[KilnToolInterface]: 811 """Resolve a run config's tool IDs into tool instances, rejecting name collisions. 812 813 Names may repeat across a project, but within one run config every tool the 814 model sees must have a unique function name (and every attached skill a 815 unique skill name — skills are loaded by name through the single "skill" 816 tool). Shared by creation-time validation of run configs, which catches 817 collisions at selection time, and by the runtime adapter, which stays the 818 backstop for configs whose tools were renamed after creation (e.g. via 819 edit_kiln_task_tool) or that were created outside the API. 820 """ 821 non_skill_tool_ids = [ 822 tid for tid in tool_ids if not tid.startswith(SKILL_TOOL_ID_PREFIX) 823 ] 824 825 tools: list[KilnToolInterface] = [ 826 tool_from_id(tool_id, task) for tool_id in non_skill_tool_ids 827 ] 828 829 if skills: 830 seen_names: set[str] = set() 831 for skill in skills: 832 if skill.name in seen_names: 833 raise ValueError( 834 f"Duplicate skill name '{skill.name}'. Each skill must have a unique name." 835 ) 836 seen_names.add(skill.name) 837 tools.append(SkillTool(f"{SKILL_TOOL_ID_PREFIX}_combined", skills)) 838 839 tool_names = [await tool.name() for tool in tools] 840 if len(tool_names) != len(set(tool_names)): 841 duplicates = sorted({n for n in tool_names if tool_names.count(n) > 1}) 842 message = ( 843 f"Multiple selected tools share the same function name: {', '.join(duplicates)}. " 844 "Each tool must have a unique name. Either de-select the duplicate tools, or " 845 "modify their names to describe their unique purpose. The model will struggle " 846 "if tools do not have descriptive names and tool execution will be undefined." 847 ) 848 if skills and "skill" in duplicates: 849 message += ( 850 " The name 'skill' is reserved for the tool that loads skills, " 851 "so no other tool may use it while skills are selected." 852 ) 853 raise ValueError(message) 854 855 return tools
Resolve a run config's tool IDs into tool instances, rejecting name collisions.
Names may repeat across a project, but within one run config every tool the model sees must have a unique function name (and every attached skill a unique skill name — skills are loaded by name through the single "skill" tool). Shared by creation-time validation of run configs, which catches collisions at selection time, and by the runtime adapter, which stays the backstop for configs whose tools were renamed after creation (e.g. via edit_kiln_task_tool) or that were created outside the API.
858class OpenAIStreamResult: 859 """Async-iterable wrapper around the OpenAI streaming flow. 860 861 Yields ``ModelResponseStream`` chunks. After iteration the resulting 862 ``TaskRun`` is available via the ``.task_run`` property. 863 864 When return_on_tool_call=True and the model requests tool calls, the stream 865 will stop and ``task_run.is_toolcall_pending`` will be True. 866 """ 867 868 def __init__( 869 self, 870 adapter: BaseAdapter, 871 input: InputType, 872 input_source: DataSource | None, 873 prior_trace: list[ChatCompletionMessageParam] | None, 874 parent_task_run: TaskRun | None = None, 875 ) -> None: 876 self._adapter = adapter 877 self._input = input 878 self._input_source = input_source 879 self._prior_trace = prior_trace 880 self._parent_task_run = parent_task_run 881 self._task_run: TaskRun | None = None 882 883 @property 884 def task_run(self) -> TaskRun: 885 if self._task_run is None: 886 raise RuntimeError( 887 "Stream has not been fully consumed yet. " 888 "Iterate over the stream before accessing .task_run" 889 ) 890 return self._task_run 891 892 async def __aiter__(self) -> AsyncIterator[ModelResponseStream]: 893 self._task_run = None 894 async with mcp_session_scope(): 895 adapter_stream = self._adapter._prepare_stream( 896 self._input, self._prior_trace 897 ) 898 899 async for event in adapter_stream: 900 if isinstance(event, ModelResponseStream): 901 yield event 902 903 self._task_run = self._adapter._finalize_stream( 904 adapter_stream, self._input, self._input_source, self._parent_task_run 905 )
Async-iterable wrapper around the OpenAI streaming flow.
Yields ModelResponseStream chunks. After iteration the resulting
TaskRun is available via the .task_run property.
When return_on_tool_call=True and the model requests tool calls, the stream
will stop and task_run.is_toolcall_pending will be True.
868 def __init__( 869 self, 870 adapter: BaseAdapter, 871 input: InputType, 872 input_source: DataSource | None, 873 prior_trace: list[ChatCompletionMessageParam] | None, 874 parent_task_run: TaskRun | None = None, 875 ) -> None: 876 self._adapter = adapter 877 self._input = input 878 self._input_source = input_source 879 self._prior_trace = prior_trace 880 self._parent_task_run = parent_task_run 881 self._task_run: TaskRun | None = None
908class AiSdkStreamResult: 909 """Async-iterable wrapper around the AI SDK streaming flow. 910 911 Yields ``AiSdkStreamEvent`` instances. After iteration the resulting 912 ``TaskRun`` is available via the ``.task_run`` property. 913 914 When return_on_tool_call=True and the model requests tool calls, the FINISH 915 event will have finishReason: "tool-calls" and ``task_run.is_toolcall_pending`` 916 will be True. 917 """ 918 919 def __init__( 920 self, 921 adapter: BaseAdapter, 922 input: InputType, 923 input_source: DataSource | None, 924 prior_trace: list[ChatCompletionMessageParam] | None, 925 parent_task_run: TaskRun | None = None, 926 ) -> None: 927 self._adapter = adapter 928 self._input = input 929 self._input_source = input_source 930 self._prior_trace = prior_trace 931 self._parent_task_run = parent_task_run 932 self._task_run: TaskRun | None = None 933 934 @property 935 def task_run(self) -> TaskRun: 936 if self._task_run is None: 937 raise RuntimeError( 938 "Stream has not been fully consumed yet. " 939 "Iterate over the stream before accessing .task_run" 940 ) 941 return self._task_run 942 943 async def __aiter__(self) -> AsyncIterator[AiSdkStreamEvent]: 944 self._task_run = None 945 async with mcp_session_scope(): 946 adapter_stream = self._adapter._prepare_stream( 947 self._input, self._prior_trace 948 ) 949 950 message_id = f"msg-{uuid.uuid4().hex}" 951 converter = AiSdkStreamConverter() 952 953 yield StartEvent(messageId=message_id) 954 yield StartStepEvent() 955 956 last_event_was_tool_call = False 957 async for event in adapter_stream: 958 if isinstance(event, ModelResponseStream): 959 if last_event_was_tool_call: 960 converter.reset_for_next_step() 961 last_event_was_tool_call = False 962 for ai_event in converter.convert_chunk(event): 963 yield ai_event 964 elif isinstance(event, ToolCallEvent): 965 last_event_was_tool_call = True 966 for ai_event in converter.convert_tool_event(event): 967 yield ai_event 968 969 for ai_event in converter.close_open_blocks(): 970 yield ai_event 971 972 yield FinishStepEvent() 973 974 self._task_run = self._adapter._finalize_stream( 975 adapter_stream, self._input, self._input_source, self._parent_task_run 976 ) 977 978 if self._task_run.is_toolcall_pending: 979 yield FinishEvent( 980 messageMetadata=FinishMessageMetadata(finishReason="tool-calls"), 981 ) 982 else: 983 for ai_event in converter.finalize(): 984 yield ai_event
Async-iterable wrapper around the AI SDK streaming flow.
Yields AiSdkStreamEvent instances. After iteration the resulting
TaskRun is available via the .task_run property.
When return_on_tool_call=True and the model requests tool calls, the FINISH
event will have finishReason: "tool-calls" and task_run.is_toolcall_pending
will be True.
919 def __init__( 920 self, 921 adapter: BaseAdapter, 922 input: InputType, 923 input_source: DataSource | None, 924 prior_trace: list[ChatCompletionMessageParam] | None, 925 parent_task_run: TaskRun | None = None, 926 ) -> None: 927 self._adapter = adapter 928 self._input = input 929 self._input_source = input_source 930 self._prior_trace = prior_trace 931 self._parent_task_run = parent_task_run 932 self._task_run: TaskRun | None = None