Nodes¶
Abstract base classes¶
Abstract node classes for LangGraph processing nodes
See devdocs/system/streams.md (NodeStreamData/_get_inspect) and
devdocs/system/prompt-conventions.md (node prompt fields); ADRs 0019,
0040 and 0045.
File: klea_utils/nodes/abstract.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.abstract.AbstractLLMNode(logger: logging.Logger, label: str, llm_models: dict[str, Any], output_schema: type[TOutput] | None = None)[source]¶
Bases:
AbstractLangGraphNode[TState,dict[str,Any],LLMNodeContext],GenericAbstract base class for LangGraph nodes that use LLMs.
Generic over
TState(the graph state) andTOutput(the structured output schema; useBaseModelfor nodes without one).Subclasses must set
model_roleto a key present in thellm_modelsdict (e.g."chat","plan","guard").Implements a template execution flow: 1. Pre-execution check (optional skip) 2. Build prompt (system + human) 3. Invoke LLM 4. Process output (structured or raw) 5. Update state
- final async execute(state: TState) dict[str, Any][source]¶
Template method defining standard execution flow.
All intermediate values live on the per-run
ctx(not on the shared node instance), so concurrent runs cannot clobber each other. The streaming hooks read them from the samectx.
- max_validation_retries: ClassVar[int] = 0¶
How many times
executere-invokes on a validation failure.A node overrides
_validate_result()to reject structurally invalid output; when it returns an error,executere-invokes the model with the error exposed as thevalidation_feedbackprompt variable, up to this many retries. Default0disables the loop.
- model_defaults: ClassVar[dict[str, Any]] = {}¶
Node-level model configuration defaults.
These are frozen — user context overrides cannot change them. Set this as a class attribute on each subclass to pin model params (temperature, model, num_predict, etc.) that should never be overridden at runtime.
Subclasses that need dynamic initialisation may also set
self.model_defaultsin__init__.
- class klea_utils.nodes.abstract.AbstractLangGraphNode(logger: Logger, label: str)[source]¶
-
Abstract base class for all LangGraph nodes.
Generic over
TState(the graph state the node operates on) andTReturn(state-updating nodes returndict[str, Any]; router nodes returnstr).Provides a consistent interface: all nodes have a logger and an execute(state) method.
- abstractmethod async execute(state: TState) TReturn[source]¶
Execute this node and return the result.
- Parameters:
state – Current graph state
- Returns:
State updates (dict) or routing label (str)
- stream_tokens: ClassVar[bool] = False¶
Whether this node’s LLM token deltas are forwarded as
tokenstream events. Off by default: structured-output nodes emit JSON fragments, which are useless to a live-streaming UI. A node that produces user-facing free text sets this True, and the orchestrator adds its label toBaseLangGraph.token_stream_nodes.
- write_custom_stream(event: dict) None[source]¶
Emit a custom event to the LangGraph v3 stream.
Writes to the
customchannel viaget_stream_writer(). Requires aStreamTransformerwithrequired_stream_modes = ("custom",)registered so the channel is enabled (done byBaseLangGraph.run_graph_astream_events()).Call this at the top of
execute()to emit progress, or anywhere to emit debug or intermediate data for UI consumers.- Parameters:
event – Dict to emit as a custom protocol event
- class klea_utils.nodes.abstract.AbstractRouterNode(logger: Logger, label: str)[source]¶
Bases:
AbstractLangGraphNode[TState,str,NodeContext],GenericAbstract class for LangGraph router nodes.
Router nodes inspect the state and return a string label that determines which edge to follow next. Used with
add_conditional_edges().
- class klea_utils.nodes.abstract.NodeStreamData(*, heading: str = '', summary: str = '', details: dict[str, ~typing.Any]=<factory>, display: str = '', key: str = '', preformatted: bool = False)[source]¶
Bases:
BaseModelData payload for node streaming events.
This is the contract between nodes and the frontend.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class klea_utils.nodes.abstract.NodeStreamEvent(*, type: Literal['progress', 'inspect', 'state', 'usage', 'notice'], node: str, data: NodeStreamData)[source]¶
Bases:
BaseModelFull streaming event emitted by nodes.
This is the contract between the graph infrastructure and the frontend.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
Concrete base class¶
Base node classes for LangGraph processing nodes
See devdocs/system/structured-output-fallback.md and
devdocs/system/prompt-conventions.md; ADRs 0017 and 0044.
File: klea_utils/nodes/base.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.base.BaseLLMNode(logger: logging.Logger, label: str, llm_models: dict[str, Any], output_schema: type[TOutput] | None, memory: bool = False)[source]¶
Bases:
AbstractLLMNode,GenericBase class for LangGraph nodes that load prompts from files.
Extends AbstractLLMNode with: - File-based prompt loading via load_prompt() - Optional memory support (appends memory content to system prompt) - Auto-derived prompt registry location from subclass file path
Prompt files are expected to be named
{prefix}_system.mdand{prefix}_user.md.Subclasses can override
prompt_prefixorprompt_registry_locationvia the setter if the defaults (lowercase class name / siblingprompts/) are not appropriate.- include_model_identity: ClassVar[bool] = False¶
Report the agent and effective model identity in the system prompt. Opt in on nodes that compose a user-facing reply, so the model can answer “which agent/model are you running” with the values that actually produced the reply. The constant agent line is prepended (cacheable prefix); the per-run model line is appended after the stable prompt sections. Off by default: most nodes never need it and it costs prompt tokens.
- property output_schema: type[TOutput] | None¶
Return Pydantic schema for structured output if required
- klea_utils.nodes.base.DEFAULT_RECENT_MEMORY_CHARS = 10000¶
Character budget for the verbatim recent-conversation window injected into memory-enabled node prompts. Older history is represented by
context_summaryinstead.
- klea_utils.nodes.base.EMPTY_RESULT_FALLBACK = 'Something went wrong. Please retry.'¶
User-facing fallback when an LLM produced no usable answer and the node has no more specific fallback of its own.
- klea_utils.nodes.base.KLEA_AGENT_NAME = 'Klea'¶
Product name reported to the model by user-facing nodes.
- klea_utils.nodes.base.MAX_CONTEXT_OVERFLOW_RETRIES = 3¶
Max times to retry an invoke that overflowed the context window, each time shrinking the reserved output window to free headroom.
- klea_utils.nodes.base.MAX_EMPTY_OUTPUT_RETRIES = 2¶
Max times to retry an invoke that returned an empty response. Some providers (notably HuggingFace) intermittently return a successful but blank message; the retry is a plain re-invoke with no window change.
- klea_utils.nodes.base.MAX_OUTPUT_TOKENS_CEILING = 65536¶
Output-window ceiling for truncation retries. Reasoning-capable models can spend a large, unpredictable number of tokens thinking before producing a small final answer, so the grow ladder clamps at a generous ceiling (mirrors opencode’s
OUTPUT_TOKEN_MAX). This is the fallback when the model’s context is unknown; for a custom endpoint that advertises a largermax_model_len,BaseLLMNode._jump_output_target()raises the ceiling to the endpoint’s remaining context. It is still clamped to the model’s catalog output limit / total budget byresolve_output_token_limit.
- klea_utils.nodes.base.MAX_TRANSIENT_RETRIES = 2¶
Max times to retry a transiently failed invoke (a timeout, e.g. a stalled stream that never produced a chunk). Rate limits, auth and model-not-found failures are deliberately not retried here: the provider SDK already retries
429/5xxand honoursRetry-After, so retrying again would multiply the attempts and can amplify a rate limit.
- klea_utils.nodes.base.MAX_TRUNCATION_RETRIES = 15¶
Max times to retry an invoke whose output was truncated (
finish_reason == "length"), each time growing the reserved output window. The budget covers the full climb from the smallest node window to the largest advertised context (262144) so heavy-thinking models are not cut short, while still bounding pathological repeated truncations.
- klea_utils.nodes.base.MIN_OUTPUT_TOKENS = 64¶
Floor for the reserved output window when shrinking it on overflow.
- klea_utils.nodes.base.TRANSIENT_RETRY_BACKOFF_S = 1.0¶
Base backoff (seconds) between transient retries; doubles each attempt.
- klea_utils.nodes.base.TRUNCATION_LINEAR_CAP = 16384¶
Ceiling for the linear phase; above it the window grows by
TRUNCATION_PHASE2_STEP.
- klea_utils.nodes.base.TRUNCATION_LINEAR_STEP = 2048¶
the window grows by
TRUNCATION_LINEAR_STEPwhile belowTRUNCATION_LINEAR_CAP. Covers the common case – a model cut off a few hundred to a couple of thousand tokens short of finishing – with small, predictable reservations.- Type:
Linear-phase step for truncation retries
- klea_utils.nodes.base.TRUNCATION_PHASE2_STEP = 32768¶
once the linear phase is exhausted the window grows by this fixed step up to the output ceiling. Fixed-size steps (not exponential) keep reservations predictable for inference engines that allocate resources against
max_tokens(e.g. vLLM KV-cache), avoiding the spikes of a doubling ladder.- Type:
Large-step phase for truncation retries
Per-run node context¶
Per-run node context.
A node’s intermediate values (prompt, LLM instance, raw output, result,
token usage, …) are local to a single execute invocation. They live
in a NodeContext built and passed by execute, instead of on
the shared node instance - so two concurrent runs cannot clobber each
other’s state.
The context deliberately does not carry the graph state: that is
already an explicit argument to the hooks that need it, and keeping it out
avoids duplicating (and diverging from) the checkpointed state.
File: klea_utils/nodes/context.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.context.LLMNodeContext(output_schema: type[BaseModel] | None = None, system_prompt: Any = None, human_prompt: str | None = None, template: ChatPromptTemplate | None = None, variables: dict[str, ~typing.Any]=<factory>, prompt: PromptValue | None = None, llm: Runnable | None = None, config: RunnableConfig | None = None, output: Any = None, result: TOutput | None = None, validation_feedback: str = '', state_updates: dict[str, ~typing.Any] | None=None, final_state: dict[str, ~typing.Any] | None=None, token_usage: TokenUsage | None = None)[source]¶
Bases:
NodeContext,Generic[TOutput]Per-run scratch for an LLM node.
Mirrors the values the LLM template computes and the streaming/inspection hooks read: the resolved schema, the prompt pipeline, the LLM invocation and its processed output.
- output_schema: type[BaseModel] | None = None¶
Output schema resolved for this run (static or dynamic).
- state_updates: dict[str, Any] | None = None¶
State updates produced by
_update_stateand the final merged dict.
- token_usage: TokenUsage | None = None¶
Token usage for this run.
- class klea_utils.nodes.context.NodeContext[source]¶
Bases:
objectBase per-run node scratch space (no graph state).
Subclasses add the fields a node family computes during execution. The object is created by
executefor a single run and passed to that run’s hooks; it is never stored on the shared node instance.
- class klea_utils.nodes.context.ToolCallerContext(tool_results: list[fastmcp.client.client.CallToolResult] = <factory>, display_flags: list[bool] = <factory>, allowed_session_dirs: list[str] = <factory>, allowed_session_files: list[str] = <factory>)[source]¶
Bases:
NodeContextPer-run scratch for the tool-caller node.
- allowed_session_dirs: list[str]¶
Directories the user approved for the session while resolving a permission interrupt (
allow for session); persisted tostate.allowed_dirsby the node (ADR-0007 update 2026-10-08).
Guard / safety nodes¶
Guard node for safety checking
See devdocs/adr/0010-guard-node.md.
File: klea_utils/nodes/guard.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.guard.GuardNode(logger: Logger, label: str, llm_models: dict[str, Any], memory: bool = False)[source]¶
Bases:
BaseLLMNode[BaseModel,BaseModel]- model_defaults: ClassVar[dict[str, Any]] = {'max_output_tokens': 2048, 'temperature': 0.3}¶
Safety guard node that checks if user queries are safe to process.
Evaluates whether a query contains potentially harmful content and returns a routing decision (“safe” or “unsafe”).
Note: to be used with llama-guard, which always returns safe/unsafe.
To skip, do not set a model.
Guard router node for routing based on guard decision
File: klea_utils/nodes/guard_router.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.guard_router.GuardRouterNode(logger: Logger, label: str)[source]¶
Bases:
AbstractRouterNodeRouter node that routes based on guard decision.
Reads the guard_decision from state and returns routing label: - “safe” -> continue to next node - “unsafe” -> decline to respond node
Answer / response nodes¶
Provide a fixed answer.
File: rag_pkg/klea_rag/nodes/fixed_answer.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.fixed_answer.FixedAnswer(logger: Logger, label: str, state_attr: str, message: str)[source]¶
Bases:
AbstractLangGraphNode[BaseModel,dict[str,Any],NodeContext]Provide a fixed answer
Answer general question node
File: klea_utils/nodes/answer_general.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.answer_general.AnswerGeneral(logger: Logger, label: str, llm_models: dict[str, Any], memory: bool = False, num_history_chars: int = 10000, fallback_config: FallbackConfig | None = None)[source]¶
Bases:
BaseLLMNode[BaseModel,BaseModel]- include_model_identity: ClassVar[bool] = True¶
Report the model that produced the reply, so “which model are you running” is answerable.
- model_defaults: ClassVar[dict[str, Any]] = {'temperature': 0.3}¶
Answer general (non-domain) questions using the LLM’s training data.
Provides a conversational, user-friendly response. Optionally appends conversation history for context and a fallback warning when configured.
Tool nodes¶
Shared MCP tools picker node.
See devdocs/system/mcp-permissions.md and devdocs/system/streams.md;
ADRs 0020, 0034 and 0044.
File: klea_utils/nodes/tools_picker.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- klea_utils.nodes.tools_picker.MAX_BATCH_CALLS = 16¶
Maximum tool calls bound in one picker invocation (ADR-0041 batch cap).
- klea_utils.nodes.tools_picker.MAX_BATCH_STEPS = 8¶
Maximum plan steps bound in one picker invocation (ADR-0041 batch cap).
- klea_utils.nodes.tools_picker.MAX_BIND_GROUP = 4¶
Maximum steps bound in one binder invocation when steps share a tool set.
- class klea_utils.nodes.tools_picker.ToolsPicker(logger: Logger, label: str, llm_models: dict[str, Any], tools_info: dict[str, dict[str, ToolInfo]] | None = None, model_role: str = 'chat', prompt_prefix: str = 'ToolsPicker', prompt_registry_location: str | Path | None = None, on_unusable: Callable[[Any, str], dict[str, Any]] | None = None, bind_planned_steps: bool = False)[source]¶
Bases:
BaseLLMNode[BaseModel,ToolCallsSchema]Node that selects MCP tools for the current step or query.
Shared by Klea Agent and Klea RAG. The two applications differ only in the prompt files, the model role, and which context fields exist in the state, so all of that is configuration:
prompt_registry_location points at the application’s
prompts/directory. RAG usesToolsPicker_*; the agent’sToolCallBinderusesToolCallBinder_*.model_role selects the
llm_modelsrole ("tool_picker"for the agent binder,"chat"for RAG).tools_info is the per-domain
BaseLangGraph.tools_info; when the state carriesquery_domainsthe descriptions are filtered to those domains (RAG), otherwise all tools are offered (agent).
_get_prompt_variablesreturns a superset of variables; each prompt file uses only the ones it declares (ChatPromptTemplateignores the rest), so one class serves both prompts.With
bind_planned_steps=True(the agent’sToolCallBinder) the node binds the Planner’s chosen tools instead of choosing tools: it processes one contiguous group of plan steps that share the same suggested-tool set, exposes only those tools, and accumulates calls inbinding_callsuntil the batch is fully bound. RAG keeps the default picker behavior.- feedback_key: ClassVar[str] = 'picker_feedback'¶
Prompt variable and heading for the conditional feedback section.
- include_model_identity: ClassVar[bool] = True¶
Report the agent and effective model identity in the system prompt. Opt in on nodes that compose a user-facing reply, so the model can answer “which agent/model are you running” with the values that actually produced the reply. The constant agent line is prepended (cacheable prefix); the per-run model line is appended after the stable prompt sections. Off by default: most nodes never need it and it costs prompt tokens.
- max_validation_retries: ClassVar[int] = 2¶
Binder mode retries an off-contract selection before failing closed. Picker mode does not validate selections, so this is inert for RAG.
- model_defaults: ClassVar[dict[str, Any]] = {'temperature': 0.01}¶
Node-level model configuration defaults.
These are frozen — user context overrides cannot change them. Set this as a class attribute on each subclass to pin model params (temperature, model, num_predict, etc.) that should never be overridden at runtime.
Subclasses that need dynamic initialisation may also set
self.model_defaultsin__init__.
Shared MCP tools caller node.
See devdocs/system/streams.md (chat-renderable tool events) and
devdocs/system/mcp-permissions.md; ADRs 0020, 0034 and 0040.
File: klea_utils/nodes/tools_caller.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.tools_caller.ToolsCallerNode(logger: Logger, label: str, mcp_client: Any | None, tool_infos: dict[str, ToolInfo] | None = None, post_dispatch: Callable[[Any, list[fastmcp.client.client.CallToolResult], list[bool]], dict[str, Any]] | None = None, permission_policy: Literal['deny', 'ask'] = 'deny', mcp_session_pool: Any | None = None)[source]¶
Bases:
AbstractLangGraphNode[BaseModel,dict[str,Any],ToolCallerContext]Node that gates and dispatches the selected MCP tool calls.
Shared by Klea Agent and Klea RAG. Reads
state.tool_calls(a list ofToolCallSchema), gates each call client-side throughklea_utils.mcp.dispatch.dispatch_tool_calls()(permission layer), emits info/debug stream events, and writesstate.tool_results.Applications that need extra post-dispatch state updates (e.g. the agent’s per-plan-step status) pass a post_dispatch callback that receives the state, the results, and a per-result “displayed” flag, and returns additional state updates.
- async execute(state: BaseModel) dict[str, Any][source]¶
Gate and dispatch the tool calls in
state.tool_calls.Skipped entirely – no dispatch, no streaming, no state update – when there is nothing to dispatch (no tool calls, or no MCP client). An empty round is routed around upstream (the agent’s picker router sends it to replan; RAG resets
tool_resultsat graph entry), so a dispatched round always overwritestool_resultsand no stale batch survives.- Parameters:
state – Current graph state (must carry
tool_calls).- Returns:
{"tool_results": [...]}plus any callback extras.
Memory¶
Summarise conversation history node
See devdocs/adr/0018-message-memory.md.
File: klea_utils/nodes/summarise_memory.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.summarise_memory.SummariseMemoryNode(logger: Logger, label: str, llm_models: dict[str, Any], summarisation_threshold_chars: int = 10000, num_history_chars: int = 10000, memory: bool = False, announce: str = '')[source]¶
Bases:
BaseLLMNode[BaseModel,BaseModel]- model_defaults: ClassVar[dict[str, Any]] = {'temperature': 0.3}¶
Node that summarises conversation history into a context summary.
Uses _pre_exec() to skip execution if there isn’t enough old conversation to summarise. The most recent messages (within
num_history_chars) form the verbatim window that the prompt assembly injects as real messages, so this node summarises user-visible history up to that window. Internal run history (messages_internal) is not injected verbatim into prompts; it is summarised from its own cursor to the end. Does NOT append the summary to messages – it is metadata, not a turn.Expects state to have the following fields:
messages: user-visible conversation messages
messages_internal: internal plan/evaluation/reasoning run history
summarised_till / summarised_internal_till: per-stream cursors
context_summary: previous memory/context summary
Command¶
Command node for the session-command framework (ADR-0047).
Commands are user-invoked tool calls; this node is the user-facing
counterpart of the tools picker/caller. It parses a leading / command
from state.query, dispatches it to an app-provided graph handler, emits an
inspect event for provenance, and ends the run with the handler’s message.
A handler may set a route to hand off to another node first (the app’s
conditional edge on command_route maps it; "" ends the run). A
command that is not persists: message is kept out of the LLM’s message
history (the graph-state change still persists in the checkpoint).
See devdocs/system/session-commands.md; ADR-0047.
File: klea_utils/nodes/command.py
Copyright 2026 Ankur Sinha Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
- class klea_utils.nodes.command.CommandNode(logger: Logger, label: str, *, registry: CommandRegistry, handlers: Mapping[str, Callable[[BaseModel, ParsedCommand], GraphCommandResult | Awaitable[GraphCommandResult]]])[source]¶
Bases:
AbstractLangGraphNode[BaseModel,dict[str,Any],NodeContext]Execute a graph (server-side) command, optionally handing off, then end.
Constructed with the app’s
CommandRegistry(metadata) and its graph handler map. A command with no handler, or one markedimplemented=False, replies that it is not implemented yet. A handler may be synchronous orasync(the node awaits an awaitable result), so a workflow command can await an LLM/tool call, and may set arouteto hand off to another node before the run ends.- async execute(state: BaseModel) dict[str, Any][source]¶
Dispatch the command in
state.queryand end the run.An executable command runs (its
inspectevent records the effect). An unknown, client-side, not-yet-implemented, or malformed command is rejected with a direct user message; the inspect event is reserved for a command’s actual effect, not for rejections.