diff --git a/agent/node_pool.py b/agent/node_pool.py new file mode 100644 index 0000000..ef2e0cd --- /dev/null +++ b/agent/node_pool.py @@ -0,0 +1,50 @@ +"""NodePool: shared stateless node instances across all sessions. + +Stateless nodes (InputNode, PANode, ExpertNode, etc.) hold no per-session +state — only config (model, system prompt). They can safely serve multiple +concurrent sessions. Session-specific HUD routing uses contextvars. + +Stateful nodes (SensorNode, MemorizerNode, UINode) hold conversational +state and must be created per-session. +""" + +import logging + +from .engine import load_graph, instantiate_nodes + +log = logging.getLogger("runtime") + +# Roles that hold per-session state — always created fresh per Runtime +STATEFUL_ROLES = frozenset({"sensor", "memorizer", "ui"}) + + +async def _noop_hud(data: dict): + """Placeholder HUD — shared nodes use contextvars for session routing.""" + pass + + +class NodePool: + """Shared node instances for stateless LLM nodes. + + Usage: + pool = NodePool("v4-eras") + # Shared nodes (one instance, all sessions): + input_node = pool.shared["input"] + # Stateful nodes must be created per-session (not in pool) + """ + + def __init__(self, graph_name: str = "v4-eras"): + self.graph = load_graph(graph_name) + self.graph_name = graph_name + + # Instantiate all nodes with noop HUD (shared nodes use contextvars) + all_nodes = instantiate_nodes(self.graph, send_hud=_noop_hud) + + # Split: shared (stateless) vs excluded (stateful) + self.shared = { + role: node for role, node in all_nodes.items() + if role not in STATEFUL_ROLES + } + + log.info(f"[pool] created for graph '{graph_name}': " + f"{len(self.shared)} shared, {len(STATEFUL_ROLES)} stateful") diff --git a/agent/nodes/base.py b/agent/nodes/base.py index c416330..396cb43 100644 --- a/agent/nodes/base.py +++ b/agent/nodes/base.py @@ -1,11 +1,16 @@ """Base Node class with context management.""" +import contextvars import logging from ..llm import estimate_tokens, fit_context log = logging.getLogger("runtime") +# Per-task HUD callback — set by FrameEngine/Runtime before calling shared nodes. +# Isolates HUD events between concurrent sessions (asyncio.Task-scoped). +_current_hud = contextvars.ContextVar('send_hud', default=None) + class Node: name: str = "node" @@ -18,10 +23,11 @@ class Node: self.context_fill_pct = 0 async def hud(self, event: str, **data): - # Always include model on context events so frontend knows what model each node uses + # Use task-scoped HUD if set (shared node pool), else instance callback + hud_fn = _current_hud.get() or self.send_hud if event == "context" and self.model: data["model"] = self.model - await self.send_hud({"node": self.name, "event": event, **data}) + await hud_fn({"node": self.name, "event": event, **data}) def trim_context(self, messages: list[dict]) -> list[dict]: """Fit messages within this node's token budget."""