Source code for oumi.core.synthesis.conversation_synthesizer

# Copyright 2025 - Oumi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import dataclasses
import json
import random
from typing import Any

from oumi.builders.inference_engines import build_inference_engine
from oumi.core.configs.environment_config import EnvironmentConfig
from oumi.core.configs.inference_config import InferenceConfig
from oumi.core.configs.inference_engine_type import InferenceEngineType
from oumi.core.configs.params.guided_decoding_params import GuidedDecodingParams
from oumi.core.configs.params.synthesis_params import (
    GeneralSynthesisParams,
    MultiTurnAttribute,
)
from oumi.core.configs.params.tool_params import (
    ToolArgumentError,
    ToolLookupError,
    ToolParams,
)
from oumi.core.synthesis.attribute_formatter import AttributeFormatter
from oumi.core.synthesis.tool_router import ToolRouter
from oumi.core.types.conversation import (
    PLANNER_JSON_SCHEMA,
    Conversation,
    Message,
    Role,
)
from oumi.core.types.tool_call import ToolCall, ToolDefinition, ToolResult
from oumi.environments import GroundingFact
from oumi.environments.base_environment import BaseEnvironment
from oumi.environments.simulated_environment import SimulatedEnvironment
from oumi.environments.utils import describe_grounding_default
from oumi.inference.native_tool_calling import (
    NATIVE_TOOL_CALLING_ENGINES,
    supports_native_tool_calling,
)
from oumi.utils.logging import logger
from oumi.utils.str_utils import extract_json

_STRAGGLER_NUDGE = (
    "Stop calling tools. Based on the information gathered so far, "
    "provide a final natural-language answer to the user."
)


[docs] @dataclasses.dataclass class PlannerPrompt: """A planner-prompt conversation and the augmented sample it was built from.""" augmented_sample: dict conversation: Conversation
[docs] @dataclasses.dataclass class OpeningTurnPrompt: """An augmented sample plus its opening-turn generation prompt.""" augmented_sample: dict conversation: Conversation
[docs] @dataclasses.dataclass class SeedConversation: """A seed conversation plus the ``generation_state`` a turn driver needs. The user persona, which drives user message synthesis, is at ``conversation.metadata["user_persona"]``. """ conversation: Conversation generation_state: dict
[docs] class ConversationSynthesizer: """Synthesizes a conversation. Args: params: The parameters for the conversation synthesizer. inference_config: The configuration for the inference engine. """ def __init__( self, params: GeneralSynthesisParams, inference_config: InferenceConfig, environment_config: EnvironmentConfig | None = None, ): """Initialize the synthesizer.""" self._params = params self._environment_config = environment_config self._formatter = AttributeFormatter(params) self._inference_engine = build_inference_engine( engine_type=inference_config.engine or InferenceEngineType.NATIVE, model_params=inference_config.model, remote_params=inference_config.remote_params, ) self._inference_config = inference_config self._default_turn_order = [Role.USER, Role.ASSISTANT] self._total_input_tokens: int = 0 self._total_output_tokens: int = 0 self._total_cached_tokens: int = 0 if ( self._environment_config is not None and self._environment_config.all_tools and not supports_native_tool_calling(inference_config.engine) ): supported = sorted(e.value for e in NATIVE_TOOL_CALLING_ENGINES) raise ValueError( f"Tool synthesis requires an engine with native tool-calling " f"support. Configured engine '{inference_config.engine}' does " f"not support it. Use one of: {supported}." ) self._router: ToolRouter | None = None if self._environment_config is not None: self._router = ToolRouter.from_environment_config( self._environment_config, on_env_built=self._wire_inference, ) self._sample_routers: list[ToolRouter | None] = [] def _prepare_sample_routers(self, n_samples: int) -> None: """Replace ``self._sample_routers`` with one router clone per sample. Each sample's tool dispatch and grounding read hit an env with state independent of every other sample's. Callers must pair this with ``_close_sample_routers`` to release the per-sample envs. """ self._sample_routers = ( [self._router.for_sample() for _ in range(n_samples)] if self._router is not None else [None] * n_samples ) def _close_sample_routers(self, *, suppress_errors: bool) -> None: """Close each per-sample router, guarding so one failure can't leak the rest. Clears the list first, then closes each router; re-raises the first close error unless ``suppress_errors`` (set when a body exception is already propagating and must not be masked). """ routers, self._sample_routers = self._sample_routers, [] first_error: BaseException | None = None for router in routers: if router is None: continue try: router.close() except BaseException as error: if first_error is None: first_error = error if first_error is not None and not suppress_errors: raise first_error def _wire_inference(self, env: BaseEnvironment) -> None: """Inject the synthesizer's engine + base config into simulated envs.""" if isinstance(env, SimulatedEnvironment): env.attach_inference(self._inference_engine, self._inference_config) def _resolve_available_tools( self, multiturn_attribute: MultiTurnAttribute ) -> list[ToolParams]: """Resolve tools for a multiturn attribute from selected environments.""" if self._environment_config is None: return [] return self._environment_config.resolve_tools( environment_ids=multiturn_attribute.available_environments or None, tool_ids=multiturn_attribute.available_tools or None, ) @staticmethod def _tool_error(tool_call: ToolCall, msg: str) -> Message: return Message( role=Role.TOOL, tool_call_id=tool_call.id, content=json.dumps({"error": msg}), ) @staticmethod def _final_assistant_text(msg: Message | None) -> str: if msg is None or not isinstance(msg.content, str): return "" return msg.content @staticmethod def _tool_message(tool_call: ToolCall, result: ToolResult) -> Message: content = ( result.output if isinstance(result.output, str) else json.dumps(result.output) ) return Message( role=Role.TOOL, tool_call_id=tool_call.id, content=content, ) def _dispatch_tool_calls( self, tool_calls: list[ToolCall], sample_idx: int ) -> list[Message]: """Dispatch a batch of tool calls; returns one TOOL message per call. Validates each call via the router, then groups surviving calls by env and routes each group in one batched ``env.step()``. If the batched route raises, falls back to per-call routing so individual errors stay attributed. ``sample_idx`` selects the per-sample router clone built at ``synthesize()`` entry; routing through it keeps state mutations scoped to one sample's env instances. """ router = self._sample_routers[sample_idx] assert router is not None, "tool calls require an environment_config" results: list[Message | None] = [None] * len(tool_calls) groups: dict[int, list[tuple[int, ToolCall, dict[str, Any]]]] = {} for idx, tc in enumerate(tool_calls): try: arguments = router.parse_and_validate_arguments( tc.function.name, tc.function.arguments ) except (ToolArgumentError, ToolLookupError) as exc: results[idx] = self._tool_error(tc, str(exc)) continue env = router.tool_to_env[tc.function.name] groups.setdefault(id(env), []).append((idx, tc, arguments)) for group in groups.values(): calls = [(tc.function.name, args) for _, tc, args in group] try: outputs = router.route_batch(calls) except Exception: # On batch failure, re-route each call individually so per-call # errors stay attributed. SimulatedEnvironment's in-batch cache # shields earlier successes from re-inference, but calls past # the failing index re-infer. Acceptable for attribution today; # Phase 2's corrective-retry should replace this fallback. for idx, tc, args in group: try: [single] = router.route_batch([(tc.function.name, args)]) except Exception as exc: results[idx] = self._tool_error( tc, f"Tool '{tc.function.name}' raised: {exc}" ) continue results[idx] = self._tool_message(tc, single) continue for (idx, tc, _), out in zip(group, outputs): results[idx] = self._tool_message(tc, out) assert all(r is not None for r in results), "every call must produce a message" return results # type: ignore[return-value] def _validate_roles(self, multiturn_attribute: MultiTurnAttribute) -> None: """Validate that required roles have corresponding personas. Args: multiturn_attribute: The multi-turn attribute to validate. Raises: ValueError: If a required role is missing from role_instruction_messages. """ available_roles = set(multiturn_attribute.role_instruction_messages.keys()) for role in self._default_turn_order: if role not in available_roles: raise ValueError( f"Role '{role.value}' is missing from " f"role_instruction_messages. Available roles: " f"{[r.value for r in available_roles]}" )
[docs] def synthesize( self, samples: list[dict], multiturn_attributes: MultiTurnAttribute, ) -> list[dict[str, dict | str] | None]: """Synthesize a multi-turn conversation. Order will be identical to the order of the samples. Args: samples: The samples to synthesize values for. multiturn_attributes: The multi-turn attribute defining conversation rules. Returns: A list aligned to the input samples. Each entry is either: - a dictionary containing the conversation and plan, or - None when the synthesized conversation is filtered out. """ if not samples: return [] self._validate_roles(multiturn_attributes) logger.info( f"Synthesizing {len(samples)} conversations for " f"attribute '{multiturn_attributes.id}'" ) available_tools = self._resolve_available_tools(multiturn_attributes) if available_tools: logger.debug( "Resolved tools for '%s': %s", multiturn_attributes.id, [tool.id for tool in available_tools], ) self._prepare_sample_routers(len(samples)) try: self._warn_on_grounding_placeholder(multiturn_attributes) self._attach_grounding_facts(samples, multiturn_attributes) samples = self._plan_samples(samples, multiturn_attributes) conversations = self._synthesize_all_samples(samples, multiturn_attributes) except BaseException: self._close_sample_routers(suppress_errors=True) raise else: self._close_sample_routers(suppress_errors=False) records: list[dict[str, dict | str] | None] = [] plan_key = f"{multiturn_attributes.id}_plan" filtered_count = 0 for sample, conversation in zip(samples, conversations): if self._has_empty_messages(conversation): filtered_count += 1 records.append(None) continue record: dict[str, dict | str] = { multiturn_attributes.id: conversation.to_dict(), plan_key: sample["conversation_plan"], } records.append(record) if filtered_count > 0: logger.warning( f"Filtered out {filtered_count} conversation(s) with empty messages " f"out of {len(conversations)} total" ) return records
[docs] def build_planner_prompts( self, samples: list[dict], multiturn_attribute: MultiTurnAttribute, ) -> list[PlannerPrompt]: """Attach grounding and build planner prompts, without inference. Self-contained inference-free entrypoint for callers that drive the planner as a separate stage: prepares routers, attaches grounding, and renders one prompt per sample. ``target_turns`` is drawn randomly, so persist it if plans are inferred out-of-process. Args: samples: The samples to plan conversations for. multiturn_attribute: The multi-turn attribute defining conversation rules. Returns: One :class:`PlannerPrompt` per input sample, in order. """ self._validate_roles(multiturn_attribute) self._prepare_sample_routers(len(samples)) try: self._warn_on_grounding_placeholder(multiturn_attribute) self._attach_grounding_facts(samples, multiturn_attribute) prompts = self._render_planner_prompts(samples, multiturn_attribute) except BaseException: self._close_sample_routers(suppress_errors=True) raise else: self._close_sample_routers(suppress_errors=False) return prompts
def _render_planner_prompts( self, samples: list[dict], multiturn_attribute: MultiTurnAttribute, ) -> list[PlannerPrompt]: """Render planner prompts, assuming grounding attachment has already run.""" turn_order = self._default_turn_order prompts: list[PlannerPrompt] = [] for sample in samples: target_turns = self._select_target_turns(multiturn_attribute, turn_order) augmented_sample = { **sample, "target_turns": target_turns, "conversation_plan": "", "parsed_turn_plans": [""] * target_turns, } logger.debug(f"Planning conversation with {target_turns} turns") prompts.append( PlannerPrompt( augmented_sample=augmented_sample, conversation=self._create_planner_prompt( multiturn_attribute, augmented_sample ), ) ) return prompts
[docs] def build_opening_turn_prompts( self, samples: list[dict], plans: list[str], multiturn_attribute: MultiTurnAttribute, ) -> list[OpeningTurnPrompt]: """Parse plans and build the opening (turn 1, USER) generation prompts. Inference-free stage after :meth:`build_planner_prompts`: parses each plan into per-turn instructions and renders the opening-turn prompt. Args: samples: Augmented samples from ``build_planner_prompts`` (each carrying ``target_turns``). plans: Raw planner output strings, aligned 1:1 with ``samples``. multiturn_attribute: The multi-turn attribute defining conversation rules. Returns: One :class:`OpeningTurnPrompt` per sample, in order. """ self._validate_roles(multiturn_attribute) opening_role = self._default_turn_order[0] prompts: list[OpeningTurnPrompt] = [] for sample, plan in zip(samples, plans): target_turns = sample["target_turns"] parsed = self._parse_plan(plan, target_turns) or [""] * target_turns augmented = { **sample, "conversation_plan": plan, "parsed_turn_plans": parsed, } prompts.append( OpeningTurnPrompt( augmented_sample=augmented, conversation=self._build_turn_prompt( augmented, multiturn_attribute, opening_role, current_turn=1, history=[], ), ) ) return prompts
[docs] def build_seed_conversations( self, samples: list[dict], opening_turns: list[str], multiturn_attribute: MultiTurnAttribute, ) -> list[SeedConversation]: """Build seed conversations for out-of-process multi-turn generation. Inference-free stage after :meth:`build_opening_turn_prompts`: pairs each opening user utterance with its seed conversation and generation state. Args: samples: Augmented samples (carrying ``target_turns`` and ``parsed_turn_plans``) from ``build_opening_turn_prompts``. opening_turns: The opening user utterances, aligned 1:1 with ``samples``. multiturn_attribute: The multi-turn attribute defining conversation rules. Returns: One :class:`SeedConversation` per sample, in order. """ self._validate_roles(multiturn_attribute) assistant_persona = multiturn_attribute.role_instruction_messages[ Role.ASSISTANT ] user_persona = multiturn_attribute.role_instruction_messages[Role.USER] seeds: list[SeedConversation] = [] for sample, opening in zip(samples, opening_turns): # Personas may reference {current_turn}; the seed is turn 1, matching # build_opening_turn_prompts. Rendered once and reused for every turn. sample_with_turn = {**sample, "current_turn": 1} seed = Conversation( messages=[ self._format_persona( sample_with_turn, assistant_persona, Role.ASSISTANT ), Message(role=Role.USER, content=opening), ], metadata={ "user_persona": self._formatter.format( sample_with_turn, user_persona, missing_values_allowed=False ) }, ) output_message = self._format_output_system_message( sample, multiturn_attribute.output_system_prompt ) output_system_prompt = ( output_message.content if output_message is not None else None ) generation_state = { "target_turns": sample["target_turns"], "turn_plans": sample.get("parsed_turn_plans", []), "output_system_prompt": output_system_prompt, } seeds.append( SeedConversation(conversation=seed, generation_state=generation_state) ) return seeds
def _plan_samples( self, samples: list[dict], multiturn_attributes: MultiTurnAttribute, max_retries: int = 2, ) -> list[dict]: """Plan the conversation samples with retry logic for failed parses. Args: samples: The conversation samples to plan. multiturn_attributes: The multi-turn attribute defining conversation rules. max_retries: Maximum number of retry attempts for failed plan parsing. Returns: A list of sample dicts augmented with runtime fields (target_turns, conversation_plan, parsed_turn_plans). """ planner_prompts = self._render_planner_prompts(samples, multiturn_attributes) augmented_samples = [prompt.augmented_sample for prompt in planner_prompts] planner_conversations = [prompt.conversation for prompt in planner_prompts] indices_to_process = list(range(len(augmented_samples))) for attempt in range(max_retries + 1): if not indices_to_process: break plans = self._generate_plan( [planner_conversations[i] for i in indices_to_process] ) failed_indices: list[int] = [] for idx, plan in zip(indices_to_process, plans): augmented_sample = augmented_samples[idx] target_turns = augmented_sample["target_turns"] parsed = self._parse_plan(plan, target_turns) if parsed is not None: augmented_sample["conversation_plan"] = plan augmented_sample["parsed_turn_plans"] = parsed else: failed_indices.append(idx) if attempt < max_retries: logger.warning( f"Plan parsing failed for sample {idx}, " f"retrying ({attempt + 1}/{max_retries})" ) indices_to_process = failed_indices if indices_to_process: logger.warning( f"Failed to parse plans for {len(indices_to_process)} samples " f"after {max_retries + 1} attempts, proceeding without plan" ) return augmented_samples def _parse_plan(self, plan: str, target_turns: int) -> list[str] | None: """Parse a guided-decoded planner output into per-turn instructions. Expects the ``{"turns": [{"turn": 1, "instruction": "..."}, ...]}`` shape enforced by ``PLANNER_JSON_SCHEMA``. Anything else returns ``None`` so ``_plan_samples``'s retry loop can re-prompt. Args: plan: The full plan text from the planner. target_turns: Expected number of turns. Returns: List of instruction strings (one per turn), or None if parsing failed. """ if not plan: return None wrapped = extract_json(plan, expected_type=dict) if not isinstance(wrapped, dict) or not isinstance(wrapped.get("turns"), list): return None turns = wrapped["turns"] result = [""] * target_turns for turn in turns: if not isinstance(turn, dict): continue turn_num = turn.get("turn") instruction = turn.get("instruction", "") if isinstance(turn_num, str): try: turn_num = int(turn_num) except ValueError: continue if isinstance(turn_num, int) and 1 <= turn_num <= target_turns: result[turn_num - 1] = str(instruction).strip() return result @property def total_input_tokens(self) -> int: """Total input/prompt tokens accumulated across all synthesize() calls.""" return self._total_input_tokens @property def total_output_tokens(self) -> int: """Total output/completion tokens accumulated across all synthesize() calls.""" return self._total_output_tokens @property def total_cached_tokens(self) -> int: """Total cached tokens accumulated across all synthesize() calls.""" return self._total_cached_tokens def _accumulate_token_usage(self, inference_results: list[Conversation]) -> None: """Accumulate token usage from inference response metadata.""" for result in inference_results: usage = result.metadata.get("usage", {}) self._total_input_tokens += usage.get("prompt_tokens", 0) self._total_output_tokens += usage.get("completion_tokens", 0) self._total_cached_tokens += usage.get("cached_tokens", 0) def _extract_response( self, inference_conversations: list[Conversation], ) -> list[str]: """Get the inference results from the inference conversations. If the inference result is not a string or the conversation is empty, an empty string will be returned. Strips whitespace to avoid API errors with trailing whitespace. """ results = [] for inference_result in inference_conversations: if not inference_result.messages: results.append("") continue content = inference_result.messages[-1].content if isinstance(content, str): results.append(content.strip()) else: results.append("") return results def _has_empty_messages(self, conversation: Conversation) -> bool: """Check if any non-system message has empty content. Skips system messages (synthesizer-generated) and assistant messages with ``tool_calls`` (where ``content`` is legitimately empty). """ for message in conversation.messages: if message.role == Role.SYSTEM: continue if message.role == Role.ASSISTANT and message.tool_calls: continue if not isinstance(message.content, str) or not message.content.strip(): return True return False def _format_persona(self, sample: dict, persona: str, role: Role) -> Message: """Format the persona for the sample. Args: sample: The sample dict containing all attributes. persona: The persona string to format. role: The role for this persona. Returns: A Message with the formatted persona as a SYSTEM message. """ formatted_content = self._formatter.format( sample, persona, missing_values_allowed=False, ) return Message( role=Role.SYSTEM, content=formatted_content, ) def _build_role_context( self, sample: dict, multiturn_attribute: MultiTurnAttribute ) -> str: """Build formatted role context for the planner. Formats the persona strings for each role. The returned string has curly braces escaped ({{ and }}) so it can be safely embedded in another template without causing format errors. """ parts = [] for role, persona in multiturn_attribute.role_instruction_messages.items(): formatted = self._formatter.format( sample, persona, missing_values_allowed=False ) parts.append(f"[{role.value.upper()}]\n{formatted}") result = "\n\n".join(parts) return result.replace("{", "{{").replace("}", "}}") def _build_turn_order_str(self, turn_order: list[Role], target_turns: int) -> str: """Build a string showing which role speaks at each turn. Args: turn_order: The role sequence that repeats. target_turns: Total number of turns. Returns: A string like "Turn 1: USER, Turn 2: ASSISTANT, Turn 3: USER, ..." """ parts = [] for i in range(target_turns): role = turn_order[i % len(turn_order)] parts.append(f"Turn {i + 1}: {role.value.upper()}") return ", ".join(parts) def _create_planner_prompt( self, multiturn_attribute: MultiTurnAttribute, sample: dict ) -> Conversation: """Create the planner prompt template with role context and turn order. Returns a Conversation with a one-shot example for consistent formatting. Pairs with :meth:`_planner_inference_config` to drive guided JSON decoding against ``PLANNER_JSON_SCHEMA``. """ role_context = self._build_role_context(sample, multiturn_attribute) turn_order = self._default_turn_order target_turns = sample["target_turns"] turn_order_str = self._build_turn_order_str(turn_order, target_turns) system_prompt = ( "You are a conversation planner. Create conversation outlines " "that flow logically from start to finish.\n\n" "IMPORTANT: Output your plan as a raw JSON object with a `turns` " "array. Do not use markdown or code fences. " "Each element of `turns` must have: turn (number) and instruction " "(string).\n" "Your instructions MUST be specific to the role context provided. " "Each turn's instruction should reflect what that specific role " "would do at that point in the conversation." ) example_request = ( "Plan a 4-turn conversation.\n" "Turn order: Turn 1: USER, Turn 2: ASSISTANT, Turn 3: USER, " "Turn 4: ASSISTANT\n\n" "Role context:\n" "[USER]\n" "You are a customer who has an issue with a recent order.\n\n" "[ASSISTANT]\n" "You are a helpful support agent who resolves customer issues.\n\n" "Additional instructions: Focus on resolving the order issue " "efficiently while maintaining a polite and helpful tone." ) example_response = ( '{"turns": [\n' ' {"turn": 1, "instruction": "Greet support and explain the ' 'issue with the order"},\n' ' {"turn": 2, "instruction": "Acknowledge the issue and ask ' 'for order details"},\n' ' {"turn": 3, "instruction": "Provide order number and describe ' 'the problem further"},\n' ' {"turn": 4, "instruction": "Confirm the issue and offer a ' 'resolution"}\n' "]}" ) base_prompt = ( f"Plan a {target_turns}-turn conversation.\n" f"Turn order: {turn_order_str}\n\n" "Guidelines:\n" "- Each turn should build on the previous turn.\n" f"- Pace the conversation naturally for {target_turns} turns.\n" "- Focus on what happens, not exact wording.\n" "- Instructions MUST be specific to the roles and context provided below.\n" ) if role_context: base_prompt += f"\nRole context:\n{role_context}\n" grounding_facts = sample.get("grounding_facts") or [] if grounding_facts: block = describe_grounding_default(grounding_facts) base_prompt += ( "\nGround this plan in these specific entities:\n" f"{block}\n" "Grounding rules (role-aware):\n" "- USER turn instructions MAY inline concrete identifiers " "from the list above (e.g. 'order ORD-4421 is late', " "'book B007'). The user persona cannot see this list, so " "identifiers the user should mention must be written into " "their turn instruction.\n" "- Treat each entity's non-identifier fields (e.g. status, " "due_date, return_date) as preconditions. If a field's value " "contradicts the conversation intent -- for example trying " "to borrow a book whose status is 'borrowed' or 'overdue', " "or trying to return one that is 'available' -- plan a " "recovery flow that handles the conflict (offer an " "alternative entity from the list, explain the conflict, ask " "a clarifying question) instead of a happy-path that the " "tool will reject.\n" "- ASSISTANT turn instructions MUST NOT pre-resolve or " "pre-state any tool output — no identifiers, statuses, " "borrower names, due dates, or other facts the assistant " "would normally look up. Reference entities by what the " "user said (e.g. the title) and describe which TOOL the " "assistant should call to resolve or verify. Example — " "write 'call lookup_book_status with the book_id from the " "catalog', not 'tell the user book B007 is checked out'.\n" "- The planner's job for assistant turns is to probe the " "right tool usage, not to do the tool's work.\n" ) if multiturn_attribute.conversation_planner: formatted_planner = self._formatter.format( sample, multiturn_attribute.conversation_planner, missing_values_allowed=False, ) base_prompt += f"\nAdditional instructions: {formatted_planner}\n" base_prompt += "\nOutput ONLY the JSON object. No markdown. No other text." return Conversation( messages=[ Message(role=Role.SYSTEM, content=system_prompt), Message(role=Role.USER, content=example_request), Message(role=Role.ASSISTANT, content=example_response), Message(role=Role.USER, content=base_prompt), ], ) def _generate_plan(self, planners: list[Conversation]) -> list[str]: """Generate plans for how the conversations should proceed. Args: planners: The planner conversation templates (already formatted). Returns: A list of plan strings, one per sample. """ inference_results = self._inference_engine.infer( planners, inference_config=self._planner_inference_config(), ) self._accumulate_token_usage(inference_results) return self._extract_response(inference_results) def _planner_inference_config(self) -> InferenceConfig: """Create an inference config for planner calls with JSON guided decoding. Returns a copy of ``self._inference_config`` whose ``generation`` block carries ``GuidedDecodingParams(json=PLANNER_JSON_SCHEMA)``. The base config is left untouched so per-turn (non-planner) inference is not constrained. """ return dataclasses.replace( self._inference_config, generation=dataclasses.replace( self._inference_config.generation, guided_decoding=GuidedDecodingParams(json=PLANNER_JSON_SCHEMA), ), ) def _build_turn_prompt( self, sample: dict, multiturn_attribute: MultiTurnAttribute, role: Role, current_turn: int, history: list[Message], ) -> Conversation: """Build one turn's generation prompt: persona + history + instruction. Shared by the in-process turn loop and out-of-process opening-turn generation so both render turns identically. """ target_turns = sample["target_turns"] parsed_turn_plans = sample.get("parsed_turn_plans", []) turn_idx = current_turn - 1 turn_instruction = "" if 0 <= turn_idx < len(parsed_turn_plans): turn_instruction = parsed_turn_plans[turn_idx] sample_with_turn = {**sample, "current_turn": current_turn} persona = multiturn_attribute.role_instruction_messages[role] messages: list[Message] = [ self._format_persona(sample_with_turn, persona, role) ] messages.extend(history) turn_info = ( f"You are generating turn {current_turn} of {target_turns} " f"as the {role.value.upper()}.\n\n" ) if turn_instruction: turn_info += f"For this turn: {turn_instruction}\n\n" turn_info += "Generate ONLY your response for this turn. Stay in character." messages.append(Message(role=Role.USER, content=turn_info)) return Conversation(messages=messages) def _synthesize_all_samples( self, samples: list[dict], multiturn_attribute: MultiTurnAttribute, ) -> list[Conversation]: """Synthesize multi-turn conversations for all samples with batched inference. Args: samples: List of sample dicts with runtime fields (target_turns, conversation_plan). multiturn_attribute: The multi-turn attribute defining conversation rules. Returns: List of Conversation objects, one per sample. """ if not samples: return [] histories: list[list[Message]] = [[] for _ in samples] max_turns = max(sample["target_turns"] for sample in samples) available_tools = self._resolve_available_tools(multiturn_attribute) assistant_tools: list[ToolDefinition] | None = ( [t.to_tool_definition() for t in available_tools] if available_tools else None ) for turn_idx in range(max_turns): current_turn = turn_idx + 1 prompts: list[Conversation] = [] sample_indices: list[int] = [] roles_for_turn: list[Role] = [] for i, sample in enumerate(samples): if turn_idx >= sample["target_turns"]: continue turn_order = self._default_turn_order role = turn_order[turn_idx % len(turn_order)] roles_for_turn.append(role) prompts.append( self._build_turn_prompt( sample, multiturn_attribute, role, current_turn, histories[i] ) ) sample_indices.append(i) if not prompts: break # roles_for_turn is uniform: role is picked per turn_idx, not per sample. uniform_role = roles_for_turn[0] if uniform_role == Role.ASSISTANT: self._run_assistant_agentic_loop( prompts=prompts, sample_indices=sample_indices, histories=histories, max_consecutive_tool_turns=multiturn_attribute.max_consecutive_tool_turns, assistant_tools=assistant_tools, ) continue inference_results = self._inference_engine.infer( prompts, inference_config=self._inference_config, ) self._accumulate_token_usage(inference_results) generated_texts = self._extract_response(inference_results) if len(generated_texts) != len(prompts): raise RuntimeError( f"Inference engine returned {len(generated_texts)} results " f"but {len(prompts)} prompts were submitted. " f"This may indicate an inference engine error." ) for idx, generated_text, role in zip( sample_indices, generated_texts, roles_for_turn ): histories[idx].append(Message(role=role, content=generated_text)) conversations: list[Conversation] = [] for sample, history in zip(samples, histories): output_messages: list[Message] = [] output_message = self._format_output_system_message( sample, multiturn_attribute.output_system_prompt ) if output_message: output_messages.append(output_message) output_messages.extend(history) conversations.append( Conversation(messages=output_messages, tools=assistant_tools) ) return conversations def _run_assistant_tool_round( self, active: list[int], base_msgs: dict[int, list[Message]], staging: dict[int, list[Message]], round_count: dict[int, int], done: dict[int, bool], assistant_tools: list[ToolDefinition] | None, ) -> None: """One round of the assistant→tool agentic loop for the active subset. For each active sample: run one assistant inference, then either - dispatch the emitted ``tool_calls`` (and bump ``round_count``), or - commit the emitted text as the final assistant message and mark ``done``. """ active_prompts = [ Conversation( messages=base_msgs[idx] + staging[idx], tools=assistant_tools, ) for idx in active ] results = self._inference_engine.infer( active_prompts, inference_config=self._inference_config, ) self._accumulate_token_usage(results) if len(results) != len(active): raise RuntimeError( f"Inference engine returned {len(results)} results for " f"{len(active)} prompts in the assistant tool-call loop." ) for idx, result in zip(active, results): assistant_msg = result.messages[-1] if result.messages else None if assistant_msg is not None and assistant_msg.tool_calls: staging[idx].append( Message( role=Role.ASSISTANT, content=assistant_msg.content, tool_calls=assistant_msg.tool_calls, ) ) staging[idx].extend( self._dispatch_tool_calls(assistant_msg.tool_calls, idx) ) round_count[idx] += 1 else: staging[idx].append( Message( role=Role.ASSISTANT, content=self._final_assistant_text(assistant_msg), ) ) done[idx] = True def _finalize_stragglers( self, stragglers: list[int], nudged_prompts: list[Conversation], staging: dict[int, list[Message]], ) -> None: """Force a final assistant answer for stragglers (samples at the round cap). Each straggler gets one nudged inference (see ``_STRAGGLER_NUDGE``); whatever the model emits is committed as the final assistant message. If the model defies the nudge and emits more ``tool_calls``, they are preserved on the message so it isn't dropped by ``_has_empty_messages`` when ``content`` is None. """ results = self._inference_engine.infer( nudged_prompts, inference_config=self._inference_config, ) self._accumulate_token_usage(results) if len(results) != len(stragglers): raise RuntimeError( f"Inference engine returned {len(results)} results for " f"{len(stragglers)} straggler prompts." ) for idx, result in zip(stragglers, results): assistant_msg = result.messages[-1] if result.messages else None if assistant_msg is not None and assistant_msg.tool_calls: staging[idx].append( Message( role=Role.ASSISTANT, content=assistant_msg.content, tool_calls=assistant_msg.tool_calls, ) ) else: staging[idx].append( Message( role=Role.ASSISTANT, content=self._final_assistant_text(assistant_msg), ) ) def _run_assistant_agentic_loop( self, prompts: list[Conversation], sample_indices: list[int], histories: list[list[Message]], max_consecutive_tool_turns: int, assistant_tools: list[ToolDefinition] | None, ) -> None: """Drive the agentic loop that produces one assistant turn. Each iteration calls ``_run_assistant_tool_round`` on the active subset (samples that aren't done and haven't hit ``max_consecutive_tool_turns``). Samples that exit by hitting the cap are then handled by ``_finalize_stragglers`` to force a final answer. Mutates ``histories`` in place by extending each per-sample list with the assistant + tool messages produced during this loop. """ base_msgs: dict[int, list[Message]] = { idx: prompt.messages for idx, prompt in zip(sample_indices, prompts) } staging: dict[int, list[Message]] = {idx: [] for idx in sample_indices} round_count: dict[int, int] = {idx: 0 for idx in sample_indices} done: dict[int, bool] = {idx: False for idx in sample_indices} while True: active = [ idx for idx in sample_indices if not done[idx] and round_count[idx] < max_consecutive_tool_turns ] if not active: break self._run_assistant_tool_round( active=active, base_msgs=base_msgs, staging=staging, round_count=round_count, done=done, assistant_tools=assistant_tools, ) stragglers = [idx for idx in sample_indices if not done[idx]] if stragglers: nudge = Message(role=Role.USER, content=_STRAGGLER_NUDGE) nudged_prompts = [ Conversation(messages=base_msgs[idx] + staging[idx] + [nudge]) for idx in stragglers ] self._finalize_stragglers(stragglers, nudged_prompts, staging) for idx in sample_indices: histories[idx].extend(staging[idx]) def _select_target_turns( self, multiturn_attribute: MultiTurnAttribute, turn_order: list[Role] ) -> int: # min_turns/max_turns count rounds; return a message count (one round # == one pass through turn_order) so the loop and planner stay message-based. target_rounds = random.randint( multiturn_attribute.min_turns, multiturn_attribute.max_turns ) return target_rounds * len(turn_order) def _format_output_system_message( self, sample: dict, system_message: str | None, ) -> Message | None: if system_message is None: return None formatted_content = self._formatter.format( sample, system_message, ) return Message(role=Role.SYSTEM, content=formatted_content.strip()) def _make_grounding_rng(self, seed: int | None, sample_index: int) -> random.Random: """Build the per-sample RNG for grounding. Seeded mode makes facts deterministic from ``(seed + sample_index)``; unseeded uses OS entropy. """ if seed is None: return random.Random() return random.Random(seed + sample_index) def _warn_on_grounding_placeholder( self, multiturn_attribute: MultiTurnAttribute ) -> None: """Warn if ``{grounding_facts}`` appears in user/assistant personas. Grounding facts are planner-only — placing the placeholder in a user or assistant persona template defeats its purpose and may leak env state to roles that should not see it. """ for role, persona in multiturn_attribute.role_instruction_messages.items(): if not isinstance(persona, str): continue if "{grounding_facts}" in persona and role in ( Role.USER, Role.ASSISTANT, ): logger.warning( "MultiTurnAttribute '%s' references {grounding_facts} in " "the %s persona template. grounding is planner-only; " "placing {grounding_facts} in user/assistant templates " "defeats its purpose and may leak env state to roles " "that should not see it.", multiturn_attribute.id, role.value, ) def _attach_grounding_facts( self, samples: list[dict], multiturn_attribute: MultiTurnAttribute, ) -> None: """Attach per-sample grounding facts drawn from grounded envs in scope. Writes ``sample["grounding_facts"]`` as a flat list concatenated across all envs in scope that declare a ``GroundingConfig``. No-op when ``environment_config`` is absent or no env in scope declares grounding. Emits one ``logger.warning`` per env when truncation occurs (sample_size > pool_size). Each sample reads its grounding pool from the same per-sample router that will later receive its tool calls. """ if self._environment_config is None: return scoped_env_ids = ( set(multiturn_attribute.available_environments) if multiturn_attribute.available_environments else {env.id for env in self._environment_config.environments} ) grounding_env_params = [ env_params for env_params in self._environment_config.environments if env_params.id in scoped_env_ids and env_params.grounding is not None ] if not grounding_env_params: return warned_envs: set[str] = set() tool_scope = ( set(multiturn_attribute.available_tools) if multiturn_attribute.available_tools else None ) for sample_index, sample in enumerate(samples): router = self._sample_routers[sample_index] assert router is not None, "grounding requires an environment_config" facts: list[GroundingFact] = [] for env_params in grounding_env_params: grounding = env_params.grounding assert grounding is not None env_runtime = router.env_by_id[env_params.id] rng = self._make_grounding_rng(grounding.seed, sample_index) sampled = env_runtime.sample_grounding( n=grounding.sample_size, rng=rng, tool_ids=tool_scope, ) if ( len(sampled) < grounding.sample_size and env_params.id not in warned_envs ): logger.warning( "Grounding sample_size=%d exceeds pool size for " "environment '%s'; truncating to %d facts.", grounding.sample_size, env_params.id, len(sampled), ) warned_envs.add(env_params.id) facts.extend(sampled) sample["grounding_facts"] = facts