Source code for kavalai.run_context

"""
Copyright 2026 OÜ KAVAL AI (registry code 17393877)

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 json
import re
from typing import Optional, Any, Dict
from uuid import UUID

from loguru import logger
from pydantic import BaseModel, ConfigDict

from kavalai.resolvers import resolve_path
from kavalai.utils import to_plain
from kavalai.workflow.models import ArgumentInfo

PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*(templates|context|history)\.(.+?)\s*\}\}")
"""One ``{{ templates.NAME }}``, ``{{ context.PATH }}`` or ``{{ history.PATH }}``
placeholder; the only three prefixes a template may reference."""


[docs] class RunContext(BaseModel): """Runtime data for a single interaction. ``token_stats`` is the run's ``TokenAccumulator``. Every LLM client built during the run reports into it, and parallel branches share the parent's, so the totals cover the whole run. ``seq_counter`` is the run's task-sequence counter, shared with parallel branches in the same way so that every task row of the run draws from one sequence and the interleaving of concurrent branches is recorded rather than lost. ``current_seq`` is the number handed to the node this context is currently executing, and is per-branch. ``task_logger`` overrides the engine's ``TaskLogger`` for this run only. One engine serves many concurrent runs, so a caller that wants *this* run's trajectory on its own — the evaluation runner, a notebook debugging one call — cannot swap the engine's logger. ``template_overrides`` holds the template values passed to this run only (``run_stream(templates=...)``); they are already merged into ``templates`` and are kept separately so the run record can say which values came from the caller rather than from the document. ``started_at`` is the monotonic-clock reading taken when the run started, shared with parallel branches, from which the run's ``duration_seconds`` is measured when it completes or fails. ``user_message`` is the run's input as the chat history records it (the input's ``user_message`` field, else the input itself as text). An agent node states it in its step message: the history sent with each step ends on the previous turn, so without it the model answers that turn. """ model_config = ConfigDict(arbitrary_types_allowed=True) agent_id: Optional[UUID] = None session_id: Optional[UUID] = None run_id: Optional[UUID] = None data: dict = {} templates: Dict[str, str] = {} template_overrides: Dict[str, str] = {} agent_service: Optional[Any] = None token_stats: Optional[Any] = None seq_counter: Optional[Any] = None started_at: Optional[float] = None current_seq: Optional[int] = None task_logger: Optional[Any] = None user_message: Optional[str] = None
[docs] def next_seq(self) -> Optional[int]: """Take the next number from the run's task sequence. Returns ``None`` when the run has no counter, which is the case for a bare ``RunContext()`` built outside the engine. """ if self.seq_counter is None: return None return next(self.seq_counter)
[docs] def resolve_context_value(self, path: str): """Resolve a dotted path like 'input.user_message' from context data.""" return resolve_path(self.data, path)
[docs] async def resolve_history_value(self, path: str): """Resolve a value from session history.""" if not self.agent_service or not self.session_id: logger.error( f"Cannot load from history for {path}: agent_service or session_id not set" ) return None return await self.agent_service.get_history_value(self.session_id, str(path))
[docs] async def resolve_template_value(self, name: str): """Resolve a template value by name.""" return self.templates.get(name)
[docs] async def resolve_placeholder(self, prefix: str, path: str): """The value behind one ``{{ prefix.path }}`` placeholder. ``prefix`` is ``templates``, ``context`` or ``history``. A reference that resolves to nothing raises ``ValueError``: a prompt or filter silently missing a value is worse than a failed run. """ if prefix == "templates": val = await self.resolve_template_value(path) elif prefix == "context": val = self.resolve_context_value(path) else: val = await self.resolve_history_value(path) if val is None: raise ValueError(f"Could not resolve {prefix}.{path}") return val
[docs] async def render_value(self, template: str): """Render a template that may stand for a single typed value. A template that is exactly one placeholder resolves to the referenced value itself — a number stays a number, a boolean a boolean — so a value an earlier node extracted can be compared without passing through text. Anything else is rendered with :meth:`render_prompt`. """ whole = PLACEHOLDER_PATTERN.fullmatch(template.strip()) if whole is None: return await self.render_prompt(template) return await self.resolve_placeholder(whole.group(1), whole.group(2).strip())
[docs] async def render_prompt(self, prompt: str) -> str: """ Render a prompt string by replacing {{ templates.NAME }}, {{ context.PATH }}, and {{ history.PATH }} with their resolved values. """ async def replace_match(match): path = match.group(2).strip() val = await self.resolve_placeholder(match.group(1), path) if isinstance(val, (dict, list, BaseModel)): try: return json.dumps(to_plain(val), ensure_ascii=False) except Exception as e: logger.opt(exception=True).warning( f"Error serializing template value {path}: {e}" ) return str(val) return str(val) # re.sub cannot take an async replacement, so splice by hand. last_pos = 0 pieces = [] for match in PLACEHOLDER_PATTERN.finditer(prompt): pieces.append(prompt[last_pos : match.start()]) pieces.append(await replace_match(match)) last_pos = match.end() pieces.append(prompt[last_pos:]) return "".join(pieces)
[docs] async def resolve_input_info(self, info: ArgumentInfo): """Resolve an :class:`ArgumentInfo` to its actual value.""" if info.type == "literal": return info.value # ``value`` is the path; ``name`` is the fallback. path = info.value or info.name if info.type == "history": return await self.resolve_history_value(str(path)) if path: return self.resolve_context_value(str(path)) return None
[docs] async def prepare_tool_inputs(self, task: Any) -> dict: """Resolve a task/node's ``inputs`` mapping into plain values.""" inputs = {} for name, info in task.inputs.items(): if info.value is None and info.name is None: info = info.model_copy(update={"value": name}) value = await self.resolve_input_info(info) if isinstance(value, BaseModel): value = value.model_dump() inputs[name] = value return inputs