Workflow API¶
The workflow engine lives in kavalai.workflow. It turns a YAML graph (or a
WorkflowBuilder chain) into an executable state machine, runs
it as a stream of WorkflowStreamEvent events,
and records a serialisable state through an
AgentService and a pluggable task-logger backend.
Engine and builder¶
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.
DEFAULT_RAG_SERVICE is the name a rag_query node resolves to when
neither it nor the workflow names a service. It matches BaseRagService’s
own default collection and source identifiers, so the simple case never has to
name anything.
- class kavalai.workflow.engine.BranchDecision(expr: str, value: Any, taken: str | None, matched: bool)[source]¶
Bases:
objectWhy an
iforswitchnode routed where it did.Returned by
WorkflowEngine._next_node()so the routing itself stays a pure function and the recording happens at its single call site.expris the condition or expression verbatim, as written in the YAML — kept as text so an old recorded run stays interpretable after the graph changes.valueis what it evaluated to: a bool forif, the switched value forswitch. This is the diagnostic — a mis-route is usually an upstream classifier emitting"Refund"where the graph expects"refund".takenis the node routed to, orNonewhen nothing matched.matchedsays, for aswitch, whether an explicit case matched;Falsemeans the value fell through todefault— almost always an upstream bug.
- kavalai.workflow.engine.make_prompt(prompt: str, input_data: dict) str[source]¶
Combine a rendered prompt with resolved input data into a system message.
-
class kavalai.workflow.engine.WorkflowEngine(graph: WorkflowGraph, *, agent_service: AgentService | None =
None, task_logger: TaskLogger | None =None, client_factory: Callable[[...], BaseLlmClient] | None =None, data_models: dict[str, type[BaseModel]] | None =None, rag_services: Any | dict[str, Any] | None =None, max_node_visits: int =1000, default_llm_model: str | None =None, default_llm_parameters: dict | None =None, record_context: bool =True, run_timeout: float | None =None)[source]¶ Bases:
objectExecutes a v2
WorkflowGraphas a DAG / state machine.The engine walks the graph from the start node, following transitions and evaluating branch nodes, until it reaches an end node. Each node’s result is stored in the run context; per-node debug data flows to
task_logger.- Parameters:¶
- graph : WorkflowGraph¶
The parsed workflow definition.
- agent_service : Optional[AgentService]¶
Persistence for agents/sessions/runs/chat history.
Noneruns the workflow without any persistence (no chat memory across turns).- task_logger : Optional[TaskLogger]¶
Backend for per-node debug data and model statistics.
- client_factory : Optional[ClientFactory]¶
Factory
(model, parameters, stats_receiver) -> BaseLlmClientused to build LLM clients. Defaults to the provider factory; inject a fake for offline testing.- rag_services : Optional[BaseRagService | dict[str, BaseRagService]]¶
Services available to
rag_querynodes. A bare service is registered under"default", which is what a node resolves to when neither it nor the workflow names one. Anything not passed here is looked up among the services registered withregister_rag_service(), so a workflow served bypython -m kavalai.server— where nobody constructs the engine — still works.- max_node_visits : int¶
Safety cap on total node executions to guard against infinite loops.
- default_llm_model : Optional[str]¶
Model used when neither a node nor the graph names one, as
provider/model. The engine reads no environment variable for it:python -m kavalai.serverpassesKAVALAI_DEFAULT_LLM_MODELhere.- default_llm_parameters : Optional[dict]¶
Fleet-wide
llm_kwargsapplied to every model call, below the graph’s and the node’s own: node > graph > these > provider defaults.python -m kavalai.serverfills it from theKAVALAI_LLM_*variables.- record_context : bool¶
Whether
runs.contextkeeps every node’s data (the default) or only the run’s input and output. The full context is what makes a run explainable afterwards; it is also a second copy of everything the run saw, which a deployer bound by data-protection rules may not want to keep. Off is an explicit choice, never a silent one.- run_timeout : Optional[float]¶
Seconds after which a run is cancelled and recorded as failed, unless
run_stream(timeout=...)says otherwise.None(the default) sets no limit.python -m kavalai.serverfills it fromKAVALAI_AGENT_RUN_TIMEOUT_SECONDS.
- classmethod from_yaml(yaml_string: str, **kwargs) WorkflowEngine[source]¶
Build an engine from a YAML workflow definition string.
- classmethod from_yaml_path(yaml_path: str, **kwargs) WorkflowEngine[source]¶
Build an engine from a YAML workflow definition file.
- classmethod from_dict(data: dict, **kwargs) WorkflowEngine[source]¶
Build an engine from a parsed workflow definition dict.
- async connect() WorkflowEngine[source]¶
Open the connections the workflow’s tool servers need.
Only MCP servers need this: they are separate processes (or HTTP endpoints) whose tool lists are discovered on connect. Calling it up front means a misconfigured server fails before any tokens are spent, and — more importantly — that an agent node is told about the MCP tools it has, instead of being handed an empty list because nothing has called one yet.
Safe to call more than once; already-connected servers are skipped. The engine still connects lazily on first use if you never call this, so it is an optimisation and a fail-fast check rather than a requirement.
- Returns:¶
The engine, so it can be used as
engine = await WorkflowEngine.from_yaml_path(...).connect().
- async aclose() None[source]¶
Release the tool servers this engine owns.
Call once when the engine is discarded — from a FastAPI lifespan, or at the end of a script. Runs must not do this: the kernel is engine state, shared by every run, so closing it mid-flight would tear down MCP sessions other runs are still using.
-
async run(input_data: dict, *, session_id: str | None =
None, external_id: str | None =None, task_logger: TaskLogger | None =None, templates: dict[str, str] | None =None, timeout: float | None =None) WorkflowState[source]¶ Execute the workflow for
input_dataand return the final state.Drains
run_stream()— the single execution path.- Parameters:¶
- task_logger: TaskLogger | None =
None¶ Records this run only, instead of the engine’s own logger. Pass a
MemoryTaskLoggerto get the run’s trajectory back without a database — this is what the evaluation runner does, and what makes one engine safe to share across concurrent runs that each want their own trace.- templates: dict[str, str] | None =
None¶ Template values for this run only. See
run_stream().- timeout: float | None =
None¶ Seconds before the run is cancelled. See
run_stream().
- task_logger: TaskLogger | None =
-
async run_stream(input_data: dict, *, session_id: str | None =
None, external_id: str | None =None, state: WorkflowState | None =None, task_logger: TaskLogger | None =None, templates: dict[str, str] | None =None, timeout: float | None =None) AsyncGenerator[WorkflowStreamEvent, None][source]¶ Execute the workflow, yielding
WorkflowStreamEventevents.Lifecycle events (
workflow_started,node_started/node_completed,workflow_completed/workflow_failed) frame the run; nodes with streaming enabled contributepartial/complete/restartcontent events in between.Closing the generator early (e.g. the SSE client disconnected) aborts the run; the abort is recorded on the run row, best-effort. On failure a
workflow_failedevent is yielded before theWorkflowExceptionis raised to the caller.- Parameters:¶
- input_data: dict¶
The workflow input.
- session_id: str | None =
None¶ Optional session to continue.
- external_id: str | None =
None¶ Optional caller-supplied session key.
- state: WorkflowState | None =
None¶ Optional
WorkflowStateinstance populated in place, so blocking callers can read the final state after draining the stream.- task_logger: TaskLogger | None =
None¶ Records this run only, instead of the engine’s own logger. See
run().- templates: dict[str, str] | None =
None¶ Values for templates the workflow declares, for this run only. They are rendered in one pass like every template value, so text passed here is never interpreted as template syntax, and they are recorded with the run. This is a Python argument and deliberately not a field of the HTTP request: a request field would let a caller rewrite the author’s instructions.
- timeout: float | None =
None¶ Seconds after which the run is cancelled — parallel branches included — and recorded as failed with a
WorkflowTimeoutError. Defaults to the engine’srun_timeout.
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.
-
class kavalai.workflow.builder.WorkflowBuilder(name: str, *, description: str =
'', version: str ='1.0', llm_model: str | None =None, llm_kwargs: dict[str, Any] | None =None, rag_service: str | None =None, rag_collection: str | None =None)[source]¶ Bases:
objectA small fluent builder for constructing a
WorkflowGraphin code.Every method returns
selfso calls can be chained, andbuild()validates and returns the graph (build_engine()returns a readyWorkflowEngine). For example:graph = ( WorkflowBuilder("Greeter", llm_model="openai/gpt-4o-mini") .data_type("input", {"user_message": str}) .data_type("output", {"agent_response": str}) .start("reply") .llm("reply", prompt="Greet the user.", inputs={"input": "input"}, output="output", next="end") .end() .build() )-
data_type(name: str, fields: dict[str, str | type | dict] | None =
None, *, schema: dict | None =None, ref: str | None =None) WorkflowBuilder[source]¶ Declare a data type.
Pass
fieldsfor an object of named scalars ({"intent": str}),schemafor a full JSON-schema fragment, orrefto alias another type.
- data_model(name: str, model: type[BaseModel]) WorkflowBuilder[source]¶
Declare a data type from a Pydantic model class.
The model is used directly at run time (its fields validate the matching node input/output and drive structured LLM output), so you get full Pydantic expressiveness —
Literalenums, defaults, validators — without writing a JSON-schema fragment. Its JSON schema is also recorded on the graph for storage and inspection.Build the engine with
build_engine()(which forwards the models) or passdata_models=toWorkflowEngineyourself.
-
llm(name: str, *, prompt: str, output: str, next: str, inputs: dict[str, ArgumentInfo | str | dict] | None =
None, use_history: bool =True, history_limit: int =50, history_max_chars: int | None =None, llm_model: str | None =None, llm_kwargs: dict[str, Any] | None =None, stream_output: bool =False, stream_delta: bool =False) WorkflowBuilder[source]¶
-
agent(name: str, *, prompt: str, output: str, next: str, inputs: dict[str, ArgumentInfo | str | dict] | None =
None, allowed_tools: list[str] | None =None, max_steps: int =10, use_history: bool =True, history_limit: int =50, history_max_chars: int | None =None, llm_model: str | None =None, llm_kwargs: dict[str, Any] | None =None, stream_output: bool =False, stream_delta: bool =False, stream_instructions: bool =False, stream_partials: bool =False) WorkflowBuilder[source]¶
-
function(name: str, *, tool: str, output: str, next: str, inputs: dict[str, ArgumentInfo | str | dict] | None =
None, method: str ='get') WorkflowBuilder[source]¶
-
rag_query(name: str, *, query: str, output: str, next: str, service: str | None =
None, collection: str | None =None, top_k: int =5, source_ids: list[str] | None =None, match: dict | None =None, keep_best: bool =False, min_similarity: float | None =None, store: str ='results') WorkflowBuilder[source]¶ Add a read-only retrieval node.
queryis a template, like anllmnode’s prompt.serviceandcollectiondefault to the workflow’srag_service/rag_collection; leave both unset when there is only one index.source_ids=[]matches nothing;Nonedoes not filter.matchkeeps only entries whose metadata has every given key equal, inside the nearest-neighbour search; its string values are templates.min_similaritydrops weaker hits.store="content"keeps just the hit texts, which is what a followingllmnode’s prompt usually wants.
-
parallel(name: str, *, branches: list[str], next: str, max_concurrency: int | None =
None) WorkflowBuilder[source]¶ Fan out to independent branches and rejoin at
next.Each entry in
branchesnames the first node of a branch; every branch is walked concurrently up tonext. Branch subgraphs must be disjoint and must not write the same output variable — both are checked bybuild().
-
switch(name: str, *, expr: str, cases: dict[str, str] | None =
None, default: str | None =None) WorkflowBuilder[source]¶
- python_function(name: str, path: str) WorkflowBuilder[source]¶
Register a Python tool by import path (e.g.
pkg.mod.func).
- rest_server(server: RestServer | dict) WorkflowBuilder[source]¶
- build() WorkflowGraph[source]¶
Validate and return the
WorkflowGraph.
- build_engine(**kwargs)[source]¶
Build the graph and wrap it in a ready-to-run
WorkflowEngine.Any Pydantic models registered via
data_model()are forwarded to the engine. Keyword arguments are passed through (agent_service,task_logger,client_factory,data_models,max_node_visits).
-
data_type(name: str, fields: dict[str, str | type | dict] | None =
Graph models¶
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.
Workflow data models: the shared building blocks (input wiring, server/tool
declarations and the workflow exception) and the v2 workflow graph (nodes and
WorkflowGraph).
- exception kavalai.workflow.models.WorkflowException[source]¶
Bases:
ExceptionBase exception for errors building, validating or running a workflow.
- exception kavalai.workflow.models.WorkflowTimeoutError[source]¶
Bases:
WorkflowExceptionA run exceeded its time limit and was cancelled.
Raised by
run_stream()whentimeout(or the engine’srun_timeout) elapses. The run is recorded as failed with this message, and its parallel branches are cancelled with it.
-
class kavalai.workflow.models.ArgumentInfo(*, type: 'literal' | 'context' | 'history', value: BaseModel | str | int | float | bool | None =
None, name: str | None =None)[source]¶ Bases:
BaseModelDescribes input arguments in workflow YAML files.
The ‘type’ field describes where the input argument should be retrieved from. ‘literal’ - use value as specified ‘context’ - retrieve from agent run context ‘history’ - retrieve from previous agent run contexts.
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
-
class kavalai.workflow.models.RestServer(*, name: str, url: str | None =
None, url_env: str | None =None, username_env: str | None =None, password_env: str | None =None)[source]¶ Bases:
BaseModelDefines a REST server.
We also support HTTP Basic Auth for REST server endpoints, which are defined via environment variables username_env and password_env.
Note that url_env can also be read from the env file.
- check_url_configs() RestServer[source]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
class kavalai.workflow.models.McpServer(*, name: str, command: str | None =
None, command_env: str | None =None, args: list[str] =[], env: dict[str, str] ={}, url: str | None =None, url_env: str | None =None)[source]¶ Bases:
BaseModelDefines an MCP server.
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
- class kavalai.workflow.models.PythonFunction(*, name: str, path: str)[source]¶
Bases:
BaseModelDeclares a Python tool available to a workflow.
- Variables:¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class kavalai.workflow.models.TemplateModel(*, name: str, value: str)[source]¶
Bases:
BaseModelA named, reusable text template referenced within a workflow.
- Variables:¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class kavalai.workflow.models.BaseNode(*, name: str)[source]¶
Bases:
BaseModelCommon fields shared by every node in a workflow graph.
A node is one vertex in the DAG/state-machine.
nameuniquely identifies the node and is the target referenced by transitions (next/then/else/cases/default).-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
-
class kavalai.workflow.models.StartNode(*, name: str, type: 'start' =
'start', next: str)[source]¶ Bases:
BaseNodeInteraction start node.
The caller hands an input to this node; execution begins here and proceeds to
next.-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
-
class kavalai.workflow.models.EndNode(*, name: str, type: 'end' =
'end', output: str ='output')[source]¶ Bases:
BaseNodeInteraction end node.
Reaching an end node terminates the interaction.
outputnames the context variable whose value is returned to the caller.-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
- class kavalai.workflow.models.LLMNode(*, name: str, type: ~typing.Literal['llm'] = 'llm', prompt: str, inputs: dict[str, ~kavalai.workflow.models.ArgumentInfo] = {}, output: str, next: str, use_history: bool = True, history_limit: ~typing.Annotated[int, ~annotated_types.Ge(ge=0)] = 50, history_max_chars: ~typing.Annotated[int | None, ~annotated_types.Ge(ge=1)] = None, llm_model: str | None = None, llm_kwargs: dict[str, ~typing.Any] = <factory>, stream_output: bool = False, stream_delta: bool = False)[source]¶
Bases:
BaseNodeSingle LLM completion node.
Resolves
inputsfrom context, renderspromptand calls the LLM, storing the structured result in theoutputcontext variable, then transitions tonext.Streaming (see
WorkflowStreamEventfor the event contract):stream_outputStream the completion as
partialevents named after this node while it is generated; auxiliary provider streams (e.g. Gemini thoughts) are streamed as<node>_<stream>(<node>_thought).stream_deltaWhen True, each
partialcarries only the newly generated text and the client reassembles the value. When False (default), eachpartialcarries the full accumulated, safe-parsed value so far — render-ready with no client-side assembly, at the cost of re-sending the whole buffer on every chunk (O(n^2) wire traffic over the stream; preferstream_delta: truefor long outputs).
Chat history (read only when
use_historyis true and the engine has anAgentService):history_limitThe most recent messages of the session sent with the prompt, the current user message included.
0sends none.history_max_charsA ceiling on the characters of those messages. Whole messages are dropped from the oldest end until the rest fits; a message is never cut. Characters rather than tokens, so the budget means the same for every provider and needs no tokenizer; it is a cost ceiling, not an exact token count.
- inputs : dict[str, ArgumentInfo]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class kavalai.workflow.models.AgentNode(*, name: str, type: ~typing.Literal['agent'] = 'agent', prompt: str, inputs: dict[str, ~kavalai.workflow.models.ArgumentInfo] = {}, output: str, next: str, allowed_tools: list[str] | None = None, max_steps: int = 10, use_history: bool = True, history_limit: ~typing.Annotated[int, ~annotated_types.Ge(ge=0)] = 50, history_max_chars: ~typing.Annotated[int | None, ~annotated_types.Ge(ge=1)] = None, llm_model: str | None = None, llm_kwargs: dict[str, ~typing.Any] = <factory>, stream_output: bool = False, stream_delta: bool = False, stream_instructions: bool = False, stream_partials: bool = False)[source]¶
Bases:
BaseNodeMulti-step agent node.
Runs the v2
Agentloop (tool calling) up tomax_stepsand stores the final result inoutput.Streaming (see
WorkflowStreamEventfor the event contract):stream_outputStream the step’s
outputfield aspartialevents named after this node while the model writes it. The streamed value is always the full safe-parsed JSON of the output so far (partial objects are not delta-able); the finalcompleteevent is authoritative — a provisional output produced alongside tool calls may be superseded.stream_instructionsStream each step’s
instructionsas<node>_instructionspartials, completed once per step — an “ideating…” status line the UI replaces each step.stream_partialsStream each step’s raw model output as
<node>_step<N>— a debug firehose including tool-call JSON.stream_deltaAs on
LLMNode(including the O(n^2) full-buffer trade-off); applies to the_instructionsand_step<N>streams, not to the structured output stream.
Chat history (read only when
use_historyis true and the engine has anAgentService): the earlier turns of the session are sent with every step, between the system prompt and the step message. The current user message is left out — the node’spromptalready carries it, and the model would read it twice.history_limitThe most recent earlier messages sent.
0sends none.history_max_charsA ceiling on the characters of those messages, applied as on
LLMNode.
allowed_toolsrestricts what this node may call, and means the same here as it does in the Python API:allowed_tools: ["*"] # every registered tool allowed_tools: ["mcp://github.*"] # one server's tools allowed_tools: ["python://lookup_resident"] # one tool allowed_tools: [] # no tools at all # key omitted # every registered tool- inputs : dict[str, ArgumentInfo]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
class kavalai.workflow.models.FunctionNode(*, name: str, type: 'function' =
'function', tool: str, inputs: dict[str, ArgumentInfo] ={}, output: str, next: str, method: str ='get')[source]¶ Bases:
BaseNodeFunction-call node.
Invokes a single tool via the
FunctionKernel(python:///rest:///mcp://URIs) and stores the result inoutput.- inputs : dict[str, ArgumentInfo]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
class kavalai.workflow.models.IfNode(*, name: str, type: 'if' =
'if', condition: str, then: str, else_: str)[source]¶ Bases:
BaseNodeBoolean branch node.
Evaluates the
conditionstring expression (e.g.state.count > 3) against the run context and transitions tothenwhen truthy, otherwise toelse_(authored aselsein YAML).-
model_config : ClassVar[ConfigDict] =
{'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
-
class kavalai.workflow.models.SwitchNode(*, name: str, type: 'switch' =
'switch', expr: str, cases: dict[str, str] ={}, default: str | None =None)[source]¶ Bases:
BaseNodeMulti-way branch node.
Evaluates the
exprstring expression, stringifies the result and looks it up incases; falls back todefaultwhen no case matches.-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
model_config : ClassVar[ConfigDict] =
-
class kavalai.workflow.models.ParallelNode(*, name: str, type: 'parallel' =
'parallel', branches: list[str], next: str, max_concurrency: int | None =None)[source]¶ Bases:
BaseNodeFan-out node: runs several independent branches concurrently.
Each name in
branchesis the entry node of a branch — a subgraph walked exactly like the main graph, up to (and not including) the join node named bynext. All branches start together, and the run continues atnextonce every branch has reached it.Branches each get their own copy of the run context, so a node in one branch cannot see another branch’s outputs while they run; the outputs are merged back into the parent context at the join. Because of that, branches must be independent, which the graph validator enforces at load time:
branch subgraphs must be disjoint from each other,
no two branches may write the same
outputvariable,a branch may not contain an
endnode or re-enter theparallelnode itself.
Loops,
if/switchrouting and nestedparallelnodes inside a branch are all fine.- name: gather type: parallel branches: [fetch_weather, fetch_news] next: summarise max_concurrency: 4- Variables:¶
- branches : list[str]¶
Entry node of each branch. Branches run concurrently.
- next : str¶
The join node; every branch ends by transitioning to it, and the run resumes there once all branches have arrived.
- max_concurrency : int | None¶
Cap on branches running at once (
None= all of them). Useful when the branches hit a rate-limited provider.
- check_branches() ParallelNode[source]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
class kavalai.workflow.models.RagQueryNode(*, name: str, type: 'rag_query' =
'rag_query', query: str, output: str, next: str, service: str | None =None, collection: str | None =None, top_k: int =5, source_ids: list[str] | None =None, match: Annotated[dict[str, str | int | float | bool] | None, MinLen(min_length=1)] =None, keep_best: bool =False, min_similarity: Annotated[float | None, Ge(ge=-1.0), Le(le=1.0)] =None, store: 'results' | 'content' ='results')[source]¶ Bases:
BaseNodeRetrieval node: queries a RAG service and stores the hits.
Read-only by construction. The node calls exactly one method,
BaseRagService.query; nothing that writes to an index is reachable from a workflow document. Retrieval is idempotent, so a cycle revisiting the node is harmless, whereas indexing is a side effect whose blast radius is a corrupted index – that belongs in a script or a tool, where the author is holding the service object.queryis a template rendered exactly like anllmnode’sprompt, so it may reference{{ context.* }},{{ templates.* }}and{{ history.* }}.serviceandcollectionfollow thellm_modelpattern: a workflow-level default that a node may override. The common case is one service and one collection, and that case needs neither field:- name: retrieve type: rag_query query: "{{ context.user_message }}" output: docs next: answerThe service is resolved as node → workflow
rag_service→"default", looked up first among the services passed to the engine and then among those registered withregister_rag_service(). A workflow names a service; it never carries a connection string, because the document is served byGET /workflowand edited in the backoffice.- Variables:¶
- query : str¶
Query text, rendered as a template.
- output : str¶
Context variable the hits are stored under.
- next : str¶
The node to continue at.
- service : str | None¶
Registered service name. Defaults to the workflow’s
rag_service, then"default".- collection : str | None¶
Collection to search. Defaults to the workflow’s
rag_collection, then the backend’s own default.- top_k : int¶
Maximum number of hits.
- source_ids : list[str] | None¶
Restrict the search to these source identifiers. Absent means no restriction; an empty list matches nothing, so a filter computed to be empty never widens into a search of everything.
- match : dict[str, str | int | float | bool] | None¶
Return only entries whose metadata has every key of
matchequal — top-level keys and scalar values, the conditionquery()takes asmatch. Each string value is a template: one that consists of a single{{ context.* }}placeholder takes the referenced value as it is, number or boolean included, so a filter extracted by an earlier node keeps its type; any other string is rendered as text. The filter is a condition of the nearest-neighbour search itself, sotop_kmatching entries come back when the collection holds that many.- keep_best : bool¶
Keep only the best hit per
source_id. Useful when one document was indexed as many chunks.- min_similarity : float | None¶
Drop hits whose
similarityis below this value.similarityis higher-is-better cosine on every backend, so one number means the same thing on each; a useful threshold still depends on the embedding model and on whether a normaliser is used.- store : Literal['results', 'content']¶
"results"(default) stores the fullRagServiceResultlist, so scores and metadata stay available for routing."content"stores just the hit texts joined by blank lines, which is what a followingllmnode’s prompt usually wants.
Whatever
storesays, the node records its hits —id,source_id,similarityandmetadata, without the text — on its task row and in theoutput_dataof itsnode_completedevent, so the passages behind an answer can be cited and audited.-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
-
class kavalai.workflow.models.WorkflowStreamEvent(*, type: 'partial' | 'complete' | 'restart' | 'node_started' | 'node_completed' | 'workflow_started' | 'workflow_completed' | 'workflow_failed', name: str, value: str | None =
None, session_id: str | None =None, run_id: str | None =None, output_data: dict | None =None, token_usage: dict | None =None)[source]¶ Bases:
BaseModelOne event in a streamed workflow run (the SSE payload of
POST /stream_agent, yielded byWorkflowEngine.run_stream).Event types and their
name:workflow_started/workflow_completed/workflow_failed: run lifecycle;nameis the workflow name.workflow_startedcarriessession_id/run_id;workflow_completedcarriesoutput_dataandtoken_usage;workflow_failedcarries the error message invalue.node_started/node_completed: node lifecycle;nameis the node name. Arag_querynode’snode_completedcarries its hits inoutput_data({"hits": [...]}, without the text).partial/complete: streamed content. The node’s own output streams under the node name; auxiliary streams are prefixed with it (<node>_thought,<node>_instructions,<node>_step<N>). Whethervalueis a delta or the full accumulated content depends on the node’sstream_deltasetting.restart: the named stream is starting over (an LLM call was retried after a transient error;valuedescribes the attempt). Clients must discard content accumulated for streams under this name — it will be re-sent. Only a node that streams emits it: a restart exists to tell a client to discard partial output, and a node that streams nothing has none to discard.
The engine’s events are complete, because in-process consumers (tests, notebooks, the evaluation runner) want the full error text and token counts. What reaches an HTTP client is decided at the server boundary; see
kavalai.server.public_events.- type : Literal['partial', 'complete', 'restart', 'node_started', 'node_completed', 'workflow_started', 'workflow_completed', 'workflow_failed']¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class kavalai.workflow.models.WorkflowGraph(*, name: str, description: str = '', version: str = '2.0', llm_model: str | None = None, llm_kwargs: dict[str, ~typing.Any] = <factory>, rag_service: str | None = None, rag_collection: str | None = None, data_types: dict[str, dict], rest_servers: list[~kavalai.workflow.models.RestServer] = [], mcp_servers: list[~kavalai.workflow.models.McpServer] = [], templates: list[~kavalai.workflow.models.TemplateModel] = [], python_functions: list[~kavalai.workflow.models.PythonFunction] = [], nodes: list[~typing.Annotated[~kavalai.workflow.models.StartNode | ~kavalai.workflow.models.EndNode | ~kavalai.workflow.models.LLMNode | ~kavalai.workflow.models.AgentNode | ~kavalai.workflow.models.FunctionNode | ~kavalai.workflow.models.IfNode | ~kavalai.workflow.models.SwitchNode | ~kavalai.workflow.models.ParallelNode | ~kavalai.workflow.models.RagQueryNode, FieldInfo(annotation=NoneType, required=True, discriminator='type')]])[source]¶
Bases:
BaseModelA workflow: a directed graph of nodes forming a state machine.
This is the root of the YAML document a workflow is loaded from, so every field below is also a top-level YAML key.
nameWorkflow / agent name. Also the agent name runs are recorded under.
descriptionHuman-readable description.
versionSchema version (
"2.0").llm_modelDefault LLM model (
provider/model); nodes may override it.llm_kwargsDefault LLM kwargs; nodes may override them.
rag_serviceDefault RAG service name for
rag_querynodes; nodes may override it. Falls back to"default".rag_collectionDefault collection for
rag_querynodes; nodes may override it.data_typesJSON-schema data type definitions, compiled to Pydantic models by
SchemaParser.inputandoutputare the workflow’s own input and output types.rest_servers/mcp_serversTool servers registered on the kernel before the run (see
RestServer,McpServer).python_functionsPython tools to import and register by module path (see
PythonFunction).templatesNamed prompt fragments reusable across nodes (see
TemplateModel).nodesThe graph vertices; exactly one
startnode, and everyendnode returns the sameoutputdata type.
- rest_servers : list[RestServer]¶
- templates : list[TemplateModel]¶
- python_functions : list[PythonFunction]¶
- nodes : list[Annotated[StartNode | EndNode | LLMNode | AgentNode | FunctionNode | IfNode | SwitchNode | ParallelNode | RagQueryNode, FieldInfo(annotation=NoneType, required=True, discriminator='type')]]¶
- model_dump_public() dict[str, Any][source]¶
Serialise the graph with secrets removed.
McpServer.envis a plain mapping merged into the tool server’s process environment, so in practice it is where an API key for a stdio MCP server ends up. Anything that hands a workflow definition to a caller —GET /workflow, a UI rendering the graph — should use this rather thanmodel_dump(), and doing the redaction on the model means no such caller can leak by forgetting.Keys are kept, values replaced: which variables a server needs is useful to see, what they are set to is not.
- model_dump_public_json() str[source]¶
model_dump_public(), JSON-encoded.
- validate_graph() WorkflowGraph[source]¶
- property node_map : dict[str, Annotated[StartNode | EndNode | LLMNode | AgentNode | FunctionNode | IfNode | SwitchNode | ParallelNode | RagQueryNode, FieldInfo(annotation=NoneType, required=True, discriminator='type')]]¶
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
Run state¶
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.
- class kavalai.workflow.state.WorkflowState(*, workflow_name: str, status: ~typing.Literal['pending', 'running', 'completed', 'failed'] = 'pending', current_node: str | None = None, trace: list[str] = <factory>, data: dict = <factory>, input_data: dict = <factory>, output_data: dict | None = None, error: str | None = None, invocation_id: str | None = None, token_usage: dict | None = None, duration_seconds: float | None = None, run_id: str | None = None, session_id: str | None = None, agent_id: str | None = None)[source]¶
Bases:
BaseModelSerializable runtime state of a single workflow interaction.
The state is JSON round-trippable (
to_json/from_json) and is returned byWorkflowEngine.run()so a run can be inspected.workflow_name: name of the workflow being executed. status: lifecycle status of the run. current_node: name of the node about to run / last run. trace: ordered list of executed node names. data: the run context data (
RunContext.data) passed throughto_plain. input_data: the original interaction input. output_data: the value of the end node’s output variable (when finished). error: error message whenstatus == 'failed'. invocation_id: short id shared by every log line of this run (for scanning). token_usage: aggregate model token counts for the run. duration_seconds: wall-clock time of the run once it completed or failed, as recorded on the run row;Nonewhile it is in progress. run_id / session_id / agent_id: persistence identifiers (string UUIDs).- classmethod from_json(data: str) WorkflowState[source]¶
Deserialize a
WorkflowStatefrom a JSON string.
-
model_config : ClassVar[ConfigDict] =
{}¶ Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
Expressions¶
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.
- exception kavalai.workflow.expressions.ExpressionError[source]¶
Bases:
ValueErrorRaised when an expression cannot be parsed or safely evaluated.
Signals an invalid or empty expression, a syntax error, use of an unsupported/disallowed construct (function calls, comprehensions, imports, etc.) or an error encountered while evaluating the expression.
- kavalai.workflow.expressions.evaluate_expression(expr: str, context: dict) Any[source]¶
Safely evaluate a simple string expression against
context.Supports comparisons (
==,!=,<,<=,>,>=,in,not in,is,is not), boolean logic (and,or,not), arithmetic (+,-,*,/,//,%), literals, and list/tuple/dict displays. Names and attribute/subscript chains (state.count,input.user_message,items[0].title) are resolved fromcontextviaresolve_path(); unknown names resolve toNone.Arbitrary code is rejected: function calls, lambdas, comprehensions, imports, attribute writes, etc. all raise
ExpressionError.
- kavalai.workflow.expressions.evaluate_bool(expr: str, context: dict) bool[source]¶
Evaluate
exprand coerce the result to a bool (forifnodes).
- kavalai.workflow.expressions.evaluate_value(expr: str, context: dict) str[source]¶
Evaluate
exprand stringify the result (forswitchcase lookup).
Client factory¶
Build LLM clients from provider/model identifiers.
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.
OUTPUT_CAP_NAMES are the names other libraries and providers give the
output cap; an llm_kwargs key spelled so is pointed at
max_output_tokens.
- kavalai.workflow.clients.build_parameters(llm_kwargs: dict[str, Any] | None) LlmClientParameters[source]¶
Build
LlmClientParametersfrom a node’sllm_kwargs.Every key must be a field of
LlmClientParameters. An unknown key raises rather than being dropped: a misspelledtemperatureor a provider’s own name for the output cap would otherwise leave the call running on defaults the author believes they changed.- Raises:¶
WorkflowException – A key is not a parameter; the message names it, suggests the closest parameter and lists the valid ones.
- kavalai.workflow.clients.unknown_keys_message(unknown: list[str], valid: list[str]) str[source]¶
The error text for
llm_kwargskeys that are not parameters.
-
kavalai.workflow.clients.make_client(model: str, parameters: LlmClientParameters | None =
None, stats_receiver: ModelStatsReceiver | None =None, **kwargs: Any) BaseLlmClient[source]¶ Construct an LLM client from a
provider/modelstring.The provider is the part before the first
/; the remainder is the model name. Built-in providers areopenai,gemini,anthropic,ollamaandbrowser. Thebrowserprovider runs inference client-side via a WebLLM bridge (Pyodide only) and needs no API key — seeBrowserLLMClient.Any provider registered with
register_llm_provider()resolves here too, and an exactprovider/modelregistration takes precedence over the provider-wide one, so a single model can be pinned to its own client class.- Parameters:¶
- model: str¶
The
provider/modelidentifier.- parameters: LlmClientParameters | None =
None¶ Optional per-call sampling and timeout parameters.
- stats_receiver: ModelStatsReceiver | None =
None¶ Where the client reports model-call statistics.
- **kwargs: Any¶
Extra constructor arguments, overriding any bound at registration.
- Raises:¶
ValueError –
modelhas no provider prefix.RegistryError – No provider is registered for the prefix.
Task logging backends¶
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.
DEFAULT_MAX_PAYLOAD_BYTES is an operational limit, not a privacy control: a
single crawl_url result or a long prompt can be megabytes, and writing that
into a row hits statement-size limits, grows the write-behind logger’s memory
under concurrency and makes the backoffice task list unusable. Payloads above
the cap are replaced by a marker carrying their real size and a preview;
everything below it is stored in full. The privacy controls are
record_nodes and record_payloads on TaskLogger.
- kavalai.workflow.tasklog.base.truncate_payload(value: Any, max_bytes: int) Any[source]¶
Return
valueunchanged, or a marker dict when it is too large.
- kavalai.workflow.tasklog.base.truncate_text_payload(text: str | None, max_bytes: int) str | None[source]¶
truncate_payload()for a payload already encoded as JSON text.Model-call payloads travel as strings, so the marker is returned as a JSON string too, and the column keeps a single type.
- kavalai.workflow.tasklog.base.as_model_call_stat(stats: Any) ModelCallStat[source]¶
The Pydantic
ModelCallStatfor a stats record of either kind.LLM clients report the Pydantic model; older embedding code and callers that build rows by hand pass the ORM row, whose payloads may be dicts. Loggers receive one type, with payloads as JSON text.
-
class kavalai.workflow.tasklog.base.TaskLogger(max_payload_bytes: int =
262144, *, record_nodes: bool =True, record_payloads: bool =True)[source]¶ Bases:
ABCCommon interface for storing per-node debugging data and model stats.
Logging is fire-and-forget: the public
log_*methods schedule a background task and return immediately so they never block workflow execution. Callflush()(e.g. at the end of a run or in tests) to await all pending writes.A backend implements two hooks,
write_node()andwrite_model_call(). Several loggers compose withTeeTaskLogger— a metering logger beside the database one, for example — so a subclass never needs to override thelog_*methods themselves.Two options record less, for deployers who must not keep a second copy of what a run saw:
record_nodesWhen false, node executions are not recorded at all. Model calls still are.
record_payloadsWhen false, a node’s
inputs,outputandpromptand a model call’srequest_dataandresponse_dataare not stored. Names, timings, errors and token counts are. A model call’s request is the whole prompt — chat history and retrieved passages included — so this is the option that keeps transcripts out of the telemetry.
max_payload_bytescaps each stored payload, node and model call alike.-
log_node(*, run_id: str | None, session_id: str | None, agent_id: str | None, node_name: str, node_type: str, inputs: dict | None, output: Any, prompt: str | None =
None, duration: float =0.0, errors: list[str] | None =None, seq: int | None =None, parent_task_name: str | None =None, tool_uri: str | None =None) None[source]¶ Record the execution of a single node (fire-and-forget).
- Parameters:¶
-
log_model_call(stats: ModelCallStat, agent_id: str | None =
None, *, session_id: str | None =None, run_id: str | None =None) None[source]¶ Record an LLM / embedding model call (fire-and-forget).
session_idandrun_idattribute the call to the run that made it; the engine supplies both, so cost per run and per session is a query over the recorded rows.
-
abstractmethod async write_node(*, run_id: str | None, session_id: str | None, agent_id: str | None, node_name: str, node_type: str, inputs: dict | None, output: Any, prompt: str | None, duration: float, errors: list[str] | None, seq: int | None =
None, parent_task_name: str | None =None, tool_uri: str | None =None) None[source]¶ Persist a node execution record.
Called in a background task, after the recording options and the payload cap have been applied.
- abstractmethod async write_model_call(stats: ModelCallStat, *, agent_id: str | None, session_id: str | None, run_id: str | None) None[source]¶
Persist a model call statistics record.
Called in a background task, after the recording options and the payload cap have been applied.
statsis always the PydanticModelCallStat.
-
class kavalai.workflow.tasklog.base.StatsBridge(task_logger: TaskLogger, agent_id: str | None =
None, *, session_id: str | None =None, run_id: str | None =None)[source]¶ Bases:
ModelStatsReceiverAdapter forwarding LLM
ModelCallStatevents to aTaskLogger.Pass it as an LLM or embedding client’s stats receiver — or as a RAG service’s
stats_receiver— to log every call it makes againstagent_id(and, when known, a session and a run). The engine itself usesTokenAccumulator, which also forwards to a logger.- receive_model_stats(stats: ModelCallStat) None[source]¶
-
class kavalai.workflow.tasklog.base.TokenAccumulator(task_logger: TaskLogger | None =
None, agent_id: str | None =None, *, session_id: str | None =None, run_id: str | None =None)[source]¶ Bases:
ModelStatsReceiverAggregates token usage across a workflow run and optionally forwards each
ModelCallStatto aTaskLogger.The engine wires one accumulator into every LLM client built during a run, and hands it to the RAG services the run queries, so that when the run ends it can report the total token spend. When a
task_loggeris supplied each individual call is still logged through it, attributed toagent_id,session_idandrun_id, so this fully subsumesStatsBridge.- receive_model_stats(stats: ModelCallStat) None[source]¶
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.
_SCHEMA mirrors the tasks and model_call_stats tables of
kavalai.db using TEXT UUIDs and JSON-encoded payload columns.
_ADDED_COLUMNS lists the columns added since the first release of this
logger; a file written by an older version gains them when it is opened.
-
class kavalai.workflow.tasklog.sqlite.SqliteTaskLogger(path: str =
':memory:', *, max_payload_bytes: int =262144, record_nodes: bool =True, record_payloads: bool =True)[source]¶ Bases:
TaskLoggerTask logger storing node executions and model stats in SQLite.
Defaults to a private
:memory:database. Pass a filepathto keep the debugging data across runs.max_payload_bytes,record_nodesandrecord_payloadsare theTaskLoggeroptions.-
async write_node(*, run_id: str | None, session_id: str | None, agent_id: str | None, node_name: str, node_type: str, inputs: dict | None, output: Any, prompt: str | None, duration: float, errors: list[str] | None, seq: int | None =
None, parent_task_name: str | None =None, tool_uri: str | None =None) None[source]¶ Persist a node execution record.
Called in a background task, after the recording options and the payload cap have been applied.
- async write_model_call(stats: ModelCallStat, *, agent_id: str | None, session_id: str | None, run_id: str | None) None[source]¶
Persist a model call statistics record.
Called in a background task, after the recording options and the payload cap have been applied.
statsis always the PydanticModelCallStat.
-
async get_tasks(run_id: str | None =
None) list[dict][source]¶ Return logged node executions, in table order, as plain dicts.
inputs,outputanderrorscome back decoded rather than as JSON strings.
-
async write_node(*, run_id: str | None, session_id: str | None, agent_id: str | None, node_name: str, node_type: str, inputs: dict | None, output: Any, prompt: str | None, duration: float, errors: list[str] | None, seq: int | None =
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.
-
class kavalai.workflow.tasklog.postgres.PostgresTaskLogger(agent_service: AgentService, *, max_payload_bytes: int =
262144, record_nodes: bool =True, record_payloads: bool =True)[source]¶ Bases:
TaskLoggerDatabase-backed
TaskLoggerdelegating toAgentService.Node executions become
tasksrows (with theirnode_type) and model calls becomemodel_call_statsrows carrying the agent, session and run that made them, which is what the backoffice dashboards read. Despite the name it writes to whatever database the service’s sessionmaker points at, SQLite included.record_nodes,record_payloadsandmax_payload_bytesare theTaskLoggeroptions.- classmethod from_session_maker(session_maker: async_sessionmaker[AsyncSession], **options: Any) PostgresTaskLogger[source]¶
-
async write_node(*, run_id: str | None, session_id: str | None, agent_id: str | None, node_name: str, node_type: str, inputs: dict | None, output: Any, prompt: str | None, duration: float, errors: list[str] | None, seq: int | None =
None, parent_task_name: str | None =None, tool_uri: str | None =None) None[source]¶ Persist a node execution record.
Called in a background task, after the recording options and the payload cap have been applied.
- async write_model_call(stats: ModelCallStat, *, agent_id: str | None, session_id: str | None, run_id: str | None) None[source]¶
Persist a model call statistics record.
Called in a background task, after the recording options and the payload cap have been applied.
statsis always the PydanticModelCallStat.