Implement conversation state management #10922

Merged
HAL9000 merged 6 commits from issue-1-conversation-state into master 2026-06-18 14:53:56 +00:00
10 changed files with 932 additions and 144 deletions
@@ -0,0 +1,82 @@
Feature: Conversation state management
The ConversationStateManager separates persistent conversation history from
ephemeral execution state. Conversation history survives across runs while
execution state (current_node, execution_count, error) resets each time.
Scenario: Conversation history persists across execution resets
Given a ConversationStateManager with no initial state
When I append messages "hello" and "world" to the conversation history
And I reset the execution state
Then the conversation history should still contain 2 messages
And the execution count should be 0 after reset
Scenario: reset_execution_state clears only execution fields
Given a ConversationStateManager with no initial state
When I update the state with current_node "agent_a" and execution_count 5
And I append a message "keep me" to the conversation history
And I reset the execution state
Then the conversation history should contain the message "keep me"
And the current_node should be None after reset
Scenario: get_full_history returns all persistent messages
Given a ConversationStateManager with no initial state
When I append messages "first" and "second" and "third" to the conversation history
Then get_full_history should return 3 messages
Scenario: history property returns persistent conversation messages
Given a ConversationStateManager with no initial state
When I append a message "persistent" to the conversation history
Then the history property should contain the message "persistent"
Scenario: update_state with messages key appends to conversation history
Given a ConversationStateManager with no initial state
When I call update_state with messages containing "new message"
Then the conversation history should contain the message "new message"
Scenario: GraphState.to_graph_state returns conversation_history and execution_state keys
Given a GraphState with messages and metadata
When I call to_graph_state on the GraphState
Then the result should contain a conversation_history key
And the result should contain an execution_state key with current_node
Scenario: ExecutionState model holds execution-level fields
Given an ExecutionState with current_node "node_x" and execution_count 3
Then the ExecutionState current_node should be "node_x"
And the ExecutionState execution_count should be 3
Scenario: StateManager backward-compat alias works as ConversationStateManager
Given a StateManager created via the backward-compat alias
When I append a message "compat" to the conversation history via StateManager
Then the StateManager history property should contain the message "compat"
Scenario: append_messages evicts oldest entries when MAX_HISTORY_SIZE is exceeded
Given a ConversationStateManager with no initial state
When I append 55 messages to the conversation history
Then the conversation history should contain at most 50 messages
And the most recent messages should be retained
Scenario: Full reset clears both conversation history and execution state
Given a ConversationStateManager with no initial state
When I append a message "before reset" to the conversation history
And I update the state with current_node "some_node" and execution_count 5
And I perform a full reset
Then the conversation history should be empty after full reset
And the execution count should be 0 after full reset
Scenario: Bridge default update mode is APPEND
Given a RxPyLangGraphBridge instance
Then the default update mode should be APPEND
Scenario: Session full_history property returns all messages
Given a Session with two messages appended
Then the full_history property should return 2 messages
Scenario: Session get_messages with no limit returns full thread
Given a Session with three messages appended
When I call get_messages with no limit
Then all 3 messages should be returned
Scenario: Session append_message preserves full history
Given a Session with one existing message
When I append a second message to the session
Then the session should have 2 messages in total
@@ -0,0 +1,407 @@
"""BDD step definitions for conversation state management feature."""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from unittest.mock import MagicMock
from cleveragents.domain.models.core.session import MessageRole, Session
from cleveragents.langgraph.bridge import RxPyLangGraphBridge
from cleveragents.langgraph.state import (
ConversationStateManager,
ExecutionState,
GraphState,
StateManager,
StateUpdateMode,
)
from cleveragents.reactive.stream_router import ReactiveStreamRouter
def _ensure(context: Context) -> None:
context.results = getattr(context, "results", {}) or {}
context.managers = getattr(context, "managers", {}) or {}
# ---------------------------------------------------------------------------
# ConversationStateManager scenarios
# ---------------------------------------------------------------------------
@given("a ConversationStateManager with no initial state")
def step_create_conversation_state_manager(context: Context) -> None:
_ensure(context)
context.managers["csm"] = ConversationStateManager()
@when('I append messages "hello" and "world" to the conversation history')
def step_append_hello_world(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.append_messages([{"role": "user", "content": "hello"}])
mgr.append_messages([{"role": "user", "content": "world"}])
@when("I reset the execution state")
def step_reset_execution_state(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.reset_execution_state()
@then("the conversation history should still contain 2 messages")
def step_assert_history_has_2_messages(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
assert len(mgr.get_full_history()) == 2, (
f"Expected 2 messages, got {len(mgr.get_full_history())}"
)
@then("the execution count should be 0 after reset")
def step_assert_execution_count_zero(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
state = mgr.get_state()
assert state.execution_count == 0, (
f"Expected execution_count=0, got {state.execution_count}"
)
@when('I update the state with current_node "agent_a" and execution_count 5')
def step_update_state_agent_a(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.update_state(
{"current_node": "agent_a"},
mode=StateUpdateMode.REPLACE,
)
# Perform 5 updates to increment execution_count
for _ in range(5):
mgr.update_state({}, mode=StateUpdateMode.REPLACE)
@when('I append a message "keep me" to the conversation history')
def step_append_keep_me(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.append_messages([{"role": "user", "content": "keep me"}])
@then('the conversation history should contain the message "keep me"')
def step_assert_history_contains_keep_me(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
contents = [m.get("content") for m in history]
assert "keep me" in contents, f"'keep me' not found in history: {contents}"
@then("the current_node should be None after reset")
def step_assert_current_node_none(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
state = mgr.get_state()
assert state.current_node is None, (
f"Expected current_node=None, got {state.current_node!r}"
)
@when('I append messages "first" and "second" and "third" to the conversation history')
def step_append_three_messages(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
for content in ("first", "second", "third"):
mgr.append_messages([{"role": "user", "content": content}])
@then("get_full_history should return 3 messages")
def step_assert_full_history_3(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
assert len(history) == 3, f"Expected 3 messages, got {len(history)}"
@when('I append a message "persistent" to the conversation history')
def step_append_persistent(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.append_messages([{"role": "user", "content": "persistent"}])
@then('the history property should contain the message "persistent"')
def step_assert_history_property_persistent(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.history
contents = [m.get("content") for m in history]
assert "persistent" in contents, (
f"'persistent' not found in history property: {contents}"
)
@when('I call update_state with messages containing "new message"')
def step_update_state_with_new_message(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.update_state(
{"messages": [{"role": "user", "content": "new message"}]},
mode=StateUpdateMode.APPEND,
)
@then('the conversation history should contain the message "new message"')
def step_assert_history_contains_new_message(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
contents = [m.get("content") for m in history]
assert "new message" in contents, f"'new message' not found in history: {contents}"
# ---------------------------------------------------------------------------
# GraphState.to_graph_state scenario
# ---------------------------------------------------------------------------
@given("a GraphState with messages and metadata")
def step_create_graph_state_with_data(context: Context) -> None:
_ensure(context)
context.results["graph_state"] = GraphState(
messages=[{"role": "user", "content": "hi"}],
metadata={"key": "value"},
current_node="test_node",
execution_count=2,
)
@when("I call to_graph_state on the GraphState")
def step_call_to_graph_state(context: Context) -> None:
gs: GraphState = context.results["graph_state"]
context.results["graph_state_dict"] = gs.to_graph_state()
@then("the result should contain a conversation_history key")
def step_assert_conversation_history_key(context: Context) -> None:
result: dict = context.results["graph_state_dict"]
assert "conversation_history" in result, (
f"'conversation_history' key missing from: {list(result.keys())}"
)
assert isinstance(result["conversation_history"], list)
assert len(result["conversation_history"]) == 1
@then("the result should contain an execution_state key with current_node")
def step_assert_execution_state_key(context: Context) -> None:
result: dict = context.results["graph_state_dict"]
assert "execution_state" in result, (
f"'execution_state' key missing from: {list(result.keys())}"
)
exec_state = result["execution_state"]
assert exec_state["current_node"] == "test_node"
assert exec_state["execution_count"] == 2
# ---------------------------------------------------------------------------
# ExecutionState scenario
# ---------------------------------------------------------------------------
@given('an ExecutionState with current_node "node_x" and execution_count 3')
def step_create_execution_state(context: Context) -> None:
_ensure(context)
context.results["exec_state"] = ExecutionState(
current_node="node_x",
execution_count=3,
)
@then('the ExecutionState current_node should be "node_x"')
def step_assert_exec_state_current_node(context: Context) -> None:
es: ExecutionState = context.results["exec_state"]
assert es.current_node == "node_x", f"Expected 'node_x', got {es.current_node!r}"
@then("the ExecutionState execution_count should be 3")
def step_assert_exec_state_execution_count(context: Context) -> None:
es: ExecutionState = context.results["exec_state"]
assert es.execution_count == 3, f"Expected 3, got {es.execution_count}"
# ---------------------------------------------------------------------------
# StateManager backward-compat alias scenario
# ---------------------------------------------------------------------------
@given("a StateManager created via the backward-compat alias")
def step_create_state_manager_alias(context: Context) -> None:
_ensure(context)
context.managers["sm"] = StateManager()
@when('I append a message "compat" to the conversation history via StateManager')
def step_append_compat_via_state_manager(context: Context) -> None:
mgr: StateManager = context.managers["sm"]
mgr.append_messages([{"role": "user", "content": "compat"}])
@then('the StateManager history property should contain the message "compat"')
def step_assert_state_manager_history_compat(context: Context) -> None:
mgr: StateManager = context.managers["sm"]
history = mgr.history
contents = [m.get("content") for m in history]
assert "compat" in contents, (
f"'compat' not found in StateManager history: {contents}"
)
# ---------------------------------------------------------------------------
# MAX_HISTORY_SIZE eviction scenario
# ---------------------------------------------------------------------------
@when("I append 55 messages to the conversation history")
def step_append_55_messages(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
for i in range(55):
mgr.append_messages([{"role": "user", "content": f"msg_{i}"}])
@then("the conversation history should contain at most 50 messages")
def step_assert_history_at_most_50(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
assert len(history) <= 50, f"Expected at most 50 messages, got {len(history)}"
@then("the most recent messages should be retained")
def step_assert_most_recent_retained(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
# The last message appended was msg_54
assert history[-1]["content"] == "msg_54", (
f"Expected last message to be 'msg_54', got {history[-1]['content']!r}"
)
# ---------------------------------------------------------------------------
# Full reset scenario
# ---------------------------------------------------------------------------
@when('I append a message "before reset" to the conversation history')
def step_append_before_reset(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.append_messages([{"role": "user", "content": "before reset"}])
@when('I update the state with current_node "some_node" and execution_count 5')
def step_update_state_some_node(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.update_state({"current_node": "some_node"}, mode=StateUpdateMode.REPLACE)
for _ in range(5):
mgr.update_state({}, mode=StateUpdateMode.REPLACE)
@when("I perform a full reset")
def step_perform_full_reset(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
mgr.reset()
@then("the conversation history should be empty after full reset")
def step_assert_history_empty_after_full_reset(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
history = mgr.get_full_history()
assert len(history) == 0, f"Expected empty history, got {len(history)} messages"
@then("the execution count should be 0 after full reset")
def step_assert_execution_count_zero_after_full_reset(context: Context) -> None:
mgr: ConversationStateManager = context.managers["csm"]
state = mgr.get_state()
assert state.execution_count == 0, (
f"Expected execution_count=0, got {state.execution_count}"
)
# ---------------------------------------------------------------------------
# Bridge default update mode scenario
# ---------------------------------------------------------------------------
@given("a RxPyLangGraphBridge instance")
def step_create_bridge(context: Context) -> None:
_ensure(context)
scheduler = MagicMock()
router = ReactiveStreamRouter(scheduler=scheduler)
context.results["bridge"] = RxPyLangGraphBridge(stream_router=router)
@then("the default update mode should be APPEND")
def step_assert_bridge_default_mode_append(context: Context) -> None:
bridge: RxPyLangGraphBridge = context.results["bridge"]
assert bridge._DEFAULT_UPDATE_MODE == StateUpdateMode.APPEND, (
f"Expected APPEND, got {bridge._DEFAULT_UPDATE_MODE}"
)
# ---------------------------------------------------------------------------
# Session scenarios
# ---------------------------------------------------------------------------
def _make_session() -> Session:
from ulid import ULID
return Session(session_id=str(ULID()))
@given("a Session with two messages appended")
def step_session_with_two_messages(context: Context) -> None:
_ensure(context)
session = _make_session()
session.append_message(MessageRole.USER, "first message")
session.append_message(MessageRole.ASSISTANT, "first response")
context.results["session"] = session
@then("the full_history property should return 2 messages")
def step_assert_full_history_2(context: Context) -> None:
session: Session = context.results["session"]
full_history = session.full_history
assert len(full_history) == 2, (
f"Expected 2 messages in full_history, got {len(full_history)}"
)
@given("a Session with three messages appended")
def step_session_with_three_messages(context: Context) -> None:
_ensure(context)
session = _make_session()
session.append_message(MessageRole.USER, "msg one")
session.append_message(MessageRole.ASSISTANT, "msg two")
session.append_message(MessageRole.USER, "msg three")
context.results["session"] = session
@when("I call get_messages with no limit")
def step_call_get_messages_no_limit(context: Context) -> None:
session: Session = context.results["session"]
context.results["messages"] = session.get_messages(limit=None)
@then("all 3 messages should be returned")
def step_assert_all_3_messages(context: Context) -> None:
messages = context.results["messages"]
assert len(messages) == 3, f"Expected 3 messages, got {len(messages)}"
@given("a Session with one existing message")
def step_session_with_one_message(context: Context) -> None:
_ensure(context)
session = _make_session()
session.append_message(MessageRole.USER, "first")
context.results["session"] = session
@when("I append a second message to the session")
def step_append_second_message(context: Context) -> None:
session: Session = context.results["session"]
session.append_message(MessageRole.ASSISTANT, "second")
@then("the session should have 2 messages in total")
def step_assert_session_has_2_messages(context: Context) -> None:
session: Session = context.results["session"]
assert session.message_count == 2, (
f"Expected 2 messages, got {session.message_count}"
)
@@ -116,13 +116,13 @@ def step_perform_4_updates_time_travel(context: Context):
@then("only the two most recent history snapshots should remain")
def step_verify_two_history_snapshots(context: Context):
manager = context.state_managers["history_trim"]
assert len(manager.history) == 2
assert len(manager.snapshots) == 2
@then("the earliest snapshots should be discarded")
def step_verify_earliest_discarded(context: Context):
manager = context.state_managers["history_trim"]
ids = [snap.node_id for snap in manager.history]
ids = [snap.node_id for snap in manager.snapshots]
assert ids == ["node_2", "node_3"]
@@ -188,12 +188,12 @@ def step_manager_with_two_checkpoints(context: Context):
manager = StateManager(checkpoint_dir=checkpoint_dir)
# Write first checkpoint
manager.state.metadata = {"first": True}
manager.update_state({"metadata": {"first": True}}, mode=StateUpdateMode.REPLACE)
manager.update_count = 1
manager._save_checkpoint()
# Write second checkpoint with newer mtime
manager.state.metadata = {"second": True}
manager.update_state({"metadata": {"second": True}}, mode=StateUpdateMode.REPLACE)
manager.update_count = 2
manager._save_checkpoint()
@@ -246,7 +246,7 @@ def step_clear_and_reset(context: Context, metadata_json: str):
@then("the history should be empty after reset")
def step_verify_history_empty(context: Context):
manager: StateManager = context.results["reset_manager"]
assert len(manager.history) == 0
assert len(manager.snapshots) == 0
@then("the execution count should be zero after reset")
@@ -47,6 +47,10 @@ class PersistentSessionService(SessionService):
``SessionMessageRepository`` for persistence. All operations delegate
to the repositories which flush but do not commit; callers must manage
transactions via the UnitOfWork pattern.
Message appending uses APPEND-mode semantics: new messages are
appended to the existing session history without discarding prior
content. Session exports include the full conversation history.
"""
def __init__(
@@ -157,7 +161,10 @@ class PersistentSessionService(SessionService):
content: str,
metadata: dict[str, Any] | None = None,
) -> SessionMessage:
"""Append a message to a session.
"""Append a message to a session (APPEND mode).
New messages are **appended** to the existing conversation
history; no prior messages are discarded or replaced.
Args:
session_id: The ULID of the target session.
@@ -180,7 +187,7 @@ class PersistentSessionService(SessionService):
result = self._sanitizer.sanitize_user_input(content)
content = result.sanitized
# Determine next sequence number
# Determine next sequence number (APPEND: always after the last)
count = self._message_repo.count_for_session(session_id)
message = SessionMessage(
@@ -220,6 +227,9 @@ class PersistentSessionService(SessionService):
def export_session(self, session_id: str) -> dict[str, Any]:
"""Export a session as a JSON-serializable dict.
Includes the **full conversation history** (all messages in the
session thread) as part of the export.
Args:
session_id: The ULID of the session to export.
@@ -233,7 +243,7 @@ class PersistentSessionService(SessionService):
if session is None:
raise SessionNotFoundError(f"Session '{session_id}' not found")
# Load all messages
# Load all messages to include the full history in export
messages = self._message_repo.get_for_session(session_id)
session.messages = messages
+20 -3
View File
@@ -293,6 +293,15 @@ class Session(BaseModel):
"""Return True if the session has no messages."""
return len(self.messages) == 0
@property
def full_history(self) -> list[SessionMessage]:
"""Return the complete conversation history (full thread).
Messages are always stored in sequence-order so this property
provides read-only access to the entire persistent thread.
"""
return list(self.messages)
# -- Methods ------------------------------------------------------------
def append_message(
@@ -307,6 +316,10 @@ class Session(BaseModel):
Auto-generates a ULID for the message, sets the sequence number
based on existing messages, and updates the session timestamp.
**Full message history is preserved**: every call to
``append_message`` adds to the existing list; no prior messages
are discarded or replaced.
Args:
role: The message role.
content: The message content.
@@ -335,6 +348,10 @@ class Session(BaseModel):
) -> list[SessionMessage]:
"""Return messages with optional pagination.
When *limit* is ``None`` the method returns the **full thread**
starting from *offset*. This makes the entire conversation
queryable in a single call.
Args:
limit: Maximum number of messages to return (None = all).
offset: Number of messages to skip from the beginning.
@@ -470,7 +487,7 @@ class Session(BaseModel):
This is a **lossy** export intended for sharing and documentation.
It does not contain enough information to fully restore a session via
``import_session`` use :meth:`as_export_dict` for that.
``import_session`` -- use :meth:`as_export_dict` for that.
The output format is::
@@ -485,7 +502,7 @@ class Session(BaseModel):
## Messages
### [<sequence>] <ROLE> <timestamp>
### [<sequence>] <ROLE> - <timestamp>
<content>
@@ -508,7 +525,7 @@ class Session(BaseModel):
lines.append("")
for msg in self.messages:
ts = msg.timestamp.strftime("%Y-%m-%d %H:%M:%S")
lines.append(f"### [{msg.sequence}] {msg.role.value.upper()} {ts}")
lines.append(f"### [{msg.sequence}] {msg.role.value.upper()} - {ts}")
lines.append("")
lines.append(msg.content)
lines.append("")
+17 -3
View File
@@ -14,7 +14,7 @@ from rx import operators as ops # type: ignore[attr-defined]
from cleveragents.langgraph.graph import GraphConfig, LangGraph
from cleveragents.langgraph.nodes import Edge, NodeConfig, NodeType
from cleveragents.langgraph.state import GraphState
from cleveragents.langgraph.state import GraphState, StateUpdateMode
from cleveragents.reactive.stream_router import (
ReactiveStreamRouter,
StreamConfig,
@@ -24,7 +24,13 @@ from cleveragents.reactive.stream_router import (
class RxPyLangGraphBridge:
"""Allows LangGraph nodes as RxPy operators and streams triggering graphs."""
"""Allows LangGraph nodes as RxPy operators and streams triggering graphs.
Uses ``StateUpdateMode.APPEND`` as the default update mode so that
conversation history is preserved across operator invocations.
"""
_DEFAULT_UPDATE_MODE = StateUpdateMode.APPEND
def __init__(self, stream_router: ReactiveStreamRouter):
self.stream_router = stream_router
@@ -237,16 +243,24 @@ class RxPyLangGraphBridge:
return ops.flat_map(lambda msg: rx.from_future(create_future_task(msg)))
def _create_state_updater(self, params: dict[str, Any]) -> Any:
"""Create a state-update operator.
Uses ``StateUpdateMode.APPEND`` by default so that incoming
messages are appended to the existing conversation history rather
than replacing it. An explicit ``mode`` key in *params* can
override this default.
"""
graph_name = params.get("graph")
if not graph_name or graph_name not in self.graphs:
raise ValueError(f"Invalid graph name: {graph_name}")
graph = self.graphs[graph_name]
mode = StateUpdateMode(params.get("mode", self._DEFAULT_UPDATE_MODE.value))
def update_state(msg: StreamMessage) -> StreamMessage:
updates = (
msg.content if isinstance(msg.content, dict) else {"data": msg.content}
)
graph.state_manager.update_state(updates)
graph.state_manager.update_state(updates, mode=mode)
return msg.copy_with(
metadata={**msg.metadata, "state_updated": True, "graph": graph_name}
)
+32 -6
View File
@@ -19,7 +19,11 @@ from rx.subject import Subject # type: ignore[attr-defined]
from cleveragents.agents.base import Agent
from cleveragents.langgraph.nodes import Edge, Node, NodeConfig, NodeType
from cleveragents.langgraph.state import GraphState, StateManager
from cleveragents.langgraph.state import (
GraphState,
StateManager,
StateUpdateMode,
)
from cleveragents.reactive.stream_router import (
ReactiveStreamRouter,
StreamConfig,
@@ -83,7 +87,7 @@ class LangGraph: # pylint: disable=too-many-instance-attributes
)
self._node_executors: dict[str, Callable[[StreamMessage], StreamMessage]] = {}
# Default: min(32, cpu_count+4) suitable for I/O-bound node executors
# Default: min(32, cpu_count+4) -- suitable for I/O-bound node executors
self._executor_pool: concurrent.futures.ThreadPoolExecutor = (
concurrent.futures.ThreadPoolExecutor()
)
@@ -101,6 +105,14 @@ class LangGraph: # pylint: disable=too-many-instance-attributes
self._executor_pool.shutdown(wait=False)
async def execute(self, input_data: GraphState | dict[str, Any]) -> GraphState:
"""Execute the graph for one run.
At the start of each run the execution state is reset so that
``current_node``, ``execution_count`` and ``error`` start fresh,
while the full conversation history (persistent across runs) is
made available to every node via the graph state and the node
config.
"""
if not self.is_running:
raise RuntimeError(
f"Cannot execute graph {self.name!r}: graph is not running"
@@ -110,15 +122,29 @@ class LangGraph: # pylint: disable=too-many-instance-attributes
if isinstance(input_data, GraphState)
else GraphState.from_dict(input_data)
)
# Use replace_state() so the is_closed guard is enforced and state stream
# subscribers are notified through the proper StateManager API, preventing
# silent state corruption after StateManager.close() has been called.
self.state_manager.replace_state(state)
# --- reset execution state, preserve conversation history ---
self.state_manager.reset_execution_state()
# --- inject user message using APPEND mode ---
if isinstance(input_data, dict) and "messages" in input_data:
self.state_manager.update_state(
{"messages": input_data["messages"]},
mode=StateUpdateMode.APPEND,
)
else:
self.state_manager.update_state(
{"messages": list(state.messages)},
mode=StateUpdateMode.APPEND,
)
# Start stream signalling.
start_stream = f"__{self.name}_node_start__"
if start_stream in self.stream_router.streams:
self.stream_router.send_message(start_stream, state.model_copy(deep=True))
else:
raise ValueError("Start stream not initialized")
return self.state_manager.get_state()
def get_execution_history(self) -> list[str]:
+57 -6
View File
@@ -59,6 +59,11 @@ class Edge(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
def _is_tool_agent(agent: Any) -> bool:
"""Return True if *agent* is an instance of :class:`ToolAgent`."""
return isinstance(agent, ToolAgent)
class Node: # pylint: disable=too-many-instance-attributes
MAX_HISTORY_MESSAGES = 20
MAX_HISTORY_CHARS = 12000
@@ -76,8 +81,18 @@ class Node: # pylint: disable=too-many-instance-attributes
def _prepare_conversation_history(
self, messages: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], bool]:
"""Prepare the conversation history that will be passed to an agent.
If ``config.metadata["full_history"]`` is ``True`` the entire message
list is returned without truncation -- this is the path used by nodes
that need full thread context.
"""
if not messages:
return [], False
if self.config.metadata.get("full_history", False):
return list(messages), False
raw_max_messages = self.config.metadata.get(
"max_history_messages", self.MAX_HISTORY_MESSAGES
)
@@ -150,11 +165,17 @@ class Node: # pylint: disable=too-many-instance-attributes
self.last_execution_time = loop.time() - start_time
async def _execute_agent(self, state: GraphState) -> dict[str, Any]:
"""Execute an agent node, passing full conversation history to the context.
When the node config has ``full_history: true`` the untruncated
message list is placed into :attr:`NodeConfig.metadata` under the
key ``conversation_history`` so that the executor can forward it
to the agent via the context dict.
"""
if not self.config.agent:
raise ValueError(f"Agent node {self.name} has no agent specified")
agent = self.agents.get(self.config.agent)
if not agent:
# Fallback: synthesize a response when agent instance is unavailable
return {
"messages": [
{
@@ -167,8 +188,9 @@ class Node: # pylint: disable=too-many-instance-attributes
"current_node": self.name,
}
# Extract actionable input.
if state.messages:
if isinstance(agent, ToolAgent):
if _is_tool_agent(agent):
current_msg = state.metadata.get("current_message", "")
agent_input = (
str(current_msg)
@@ -191,9 +213,37 @@ class Node: # pylint: disable=too-many-instance-attributes
else:
agent_input = ""
trimmed_history, history_truncated = self._prepare_conversation_history(
state.messages
# Prepare conversation history for the agent.
# When full_history is enabled the entire thread is used.
full_history_enabled = self.config.metadata.get("full_history", False)
if full_history_enabled:
trimmed_history = list(state.messages)
history_truncated = False
else:
trimmed_history, history_truncated = self._prepare_conversation_history(
state.messages
)
# Build a node config that explicitly contains conversation history
# so the executor layer can pass it to the agent process_message call.
config_with_history = NodeConfig(
name=self.config.name,
type=self.config.type,
agent=self.config.agent,
function=self.config.function,
tools=list(self.config.tools),
retry_policy=self.config.retry_policy,
timeout=self.config.timeout,
parallel=self.config.parallel,
condition=self.config.condition,
subgraph=self.config.subgraph,
metadata={
**self.config.metadata,
"conversation_history": trimmed_history,
"full_thread": full_history_enabled,
},
)
graph_state_dict = state.to_dict()
graph_state_dict["messages"] = trimmed_history
@@ -201,6 +251,7 @@ class Node: # pylint: disable=too-many-instance-attributes
"graph_state": graph_state_dict,
"conversation_history": trimmed_history,
"full_context": True,
"node_config": config_with_history.model_dump(),
}
if history_truncated:
context["_history_truncated"] = True
@@ -238,8 +289,8 @@ class Node: # pylint: disable=too-many-instance-attributes
"graph_state",
"conversation_history",
"full_context",
"_history_truncated",
"_history_original_length",
"node_config",
"full_thread",
}
snapshot_keys = set(context_snapshot.keys()) if context_snapshot else set()
current_keys = set(context.keys())
+299 -118
View File
@@ -41,7 +41,7 @@ class GraphState(BaseModel):
model_config = ConfigDict(validate_assignment=True)
def update(
self, updates: dict[str, Any], mode: StateUpdateMode = StateUpdateMode.MERGE
self, updates: dict[str, Any], mode: StateUpdateMode = StateUpdateMode.APPEND
) -> None:
if mode == StateUpdateMode.REPLACE:
for key, value in updates.items():
@@ -80,110 +80,360 @@ class GraphState(BaseModel):
"error": self.error,
}
def to_graph_state(self) -> dict[str, Any]:
"""Return both conversation_history and execution_state keys.
This method is the canonical interface for serialising the state
into a graph-compatible dictionary, producing keys
``conversation_history`` and ``execution_state`` as required by
the LangGraph wiring layer.
"""
return {
"conversation_history": list(self.messages),
"execution_state": {
"current_node": self.current_node,
"execution_count": self.execution_count,
"error": self.error,
"metadata": dict(self.metadata),
},
}
@classmethod
def from_dict(cls: type[T], data: dict[str, Any]) -> T:
return cls(**data)
class StateManager: # pylint: disable=too-many-instance-attributes
class ExecutionState(BaseModel):
"""Execution-level state that resets between runs.
This intentionally does NOT include messagesthat belongs to the
conversation manager and must persist across runs.
"""
current_node: str | None = None
execution_count: int = 0
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ConversationStateManager: # pylint: disable=too-many-instance-attributes
"""High-level state manager separating conversation history from execution state.
Conversation history (messages) *persists* across executions. Execution-level
state (``current_node``, ``execution_count``, ``error``) is reset at the start
of each run via :meth:`reset_execution_state`.
Default update mode is ``StateUpdateMode.APPEND``.
"""
MAX_HISTORY_SIZE: int = 50
def __init__(
self,
initial_state: GraphState | None = None,
checkpoint_dir: Path | None = None,
enable_time_travel: bool = False,
):
) -> None:
self.logger = logging.getLogger(__name__)
self._lock = threading.RLock()
self.state = initial_state or GraphState()
self.checkpoint_dir = checkpoint_dir
self.enable_time_travel = enable_time_travel
self.state_stream = BehaviorSubject(self.state)
self.history: list[StateSnapshot] = []
self._mode: StateUpdateMode = StateUpdateMode.APPEND
# Persistent conversation messages.
self._conversation_history: list[dict[str, Any]] = []
if initial_state is not None and initial_state.messages:
self._conversation_history = list(initial_state.messages)
# Execution bookkeeping only.
self._execution_state = ExecutionState()
self.state_stream = BehaviorSubject(
initial_state or GraphState(messages=list(self._conversation_history))
)
self._snapshots: list[StateSnapshot] = []
self.max_history_size = 100
self.checkpoint_interval = 10
self.update_count = 0
self.is_closed = False
self.checkpoint_dir = checkpoint_dir
self.enable_time_travel = enable_time_travel
if self.checkpoint_dir:
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
def get_state(self) -> GraphState:
# -- Public API ----------------------------------------------------------
@property
def state(self) -> GraphState:
"""Return the current full graph state (live reference for backward compat).
.. note::
This property returns a *live* ``GraphState`` built from the
internal conversation history and execution state. Mutations
to the returned object's ``metadata`` dict are reflected in
``_execution_state.metadata`` for backward compatibility with
code that does ``manager.state.metadata[key] = value``.
"""
with self._lock:
return self.state.model_copy(deep=True)
return self._full_state()
def get_state(self) -> GraphState:
"""Return a deep-copy of the full graph state."""
with self._lock:
return self._full_state()
@property
def history(self) -> list[dict[str, Any]]:
"""Return persistent conversation messages (the full history).
This property provides read-only access to the running conversation
transcript stored within this manager.
"""
with self._lock:
return list(self._conversation_history)
@history.setter
def history(self, value: list[dict[str, Any]]) -> None:
"""Replace the persistent conversation history."""
with self._lock:
self._conversation_history = list(value)
def get_full_history(self) -> list[dict[str, Any]]:
"""Return the full persistent conversation history (deep copy)."""
with self._lock:
return list(self._conversation_history)
def reset_execution_state(self) -> None:
"""Reset only execution-level state, NOT conversation history.
Clears ``current_node``, ``execution_count``, and ``error`` while
leaving the stored message history intact.
"""
with self._lock:
self._execution_state = ExecutionState()
self.update_count = 0
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
self.logger.debug("Execution state reset; conversation history preserved")
def append_messages(self, messages: list[dict[str, Any]]) -> None:
"""Append new messages to the persistent conversation history.
If the accumulated history exceeds :attr:`MAX_HISTORY_SIZE` the
oldest entries are evicted, keeping the most recent messages.
"""
if not messages:
return
with self._lock:
self._conversation_history.extend(messages)
if len(self._conversation_history) > self.MAX_HISTORY_SIZE:
self._conversation_history = self._conversation_history[
-self.MAX_HISTORY_SIZE :
]
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
def update_state(
self,
updates: dict[str, Any],
mode: StateUpdateMode = StateUpdateMode.MERGE,
mode: StateUpdateMode | None = None,
node_id: str | None = None,
) -> GraphState:
"""Apply *updates* to the internal state and notify subscribers.
.. note::
Emission order is **not** guaranteed to match mutation order
under concurrent access. The internal state is always
consistent (mutations are serialized by ``_lock``), but
``state_stream.on_next()`` is called outside the lock, so
Thread B's emission may be observed before Thread A's if the
OS schedules B first after both release the lock.
Uses ``APPEND`` mode by default. Message lists are *never*
clobberedappending or merging is always used so that
conversation history is preserved across invocations.
"""
effective_mode = mode if mode is not None else self._mode
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
if self.enable_time_travel:
snapshot = StateSnapshot(
state=self.state.to_dict(),
state=self._full_state().to_dict(),
timestamp=datetime.now(),
node_id=node_id,
)
self.history.append(snapshot)
if len(self.history) > self.max_history_size:
self.history = self.history[-self.max_history_size :]
self.state.update(updates, mode)
self.state.execution_count += 1
self._snapshots.append(snapshot)
if len(self._snapshots) > self.max_history_size:
self._snapshots = self._snapshots[-self.max_history_size :]
self._execution_state.execution_count += 1
self.update_count += 1
for key, value in updates.items():
if key == "messages":
if isinstance(value, list):
self._conversation_history.extend(value)
if len(self._conversation_history) > self.MAX_HISTORY_SIZE:
self._conversation_history = self._conversation_history[
-self.MAX_HISTORY_SIZE :
]
elif key == "metadata":
if isinstance(value, dict):
self._execution_state.metadata.update(value)
else:
exec_field = key if key in ExecutionState.model_fields else None
if exec_field is not None:
if effective_mode == StateUpdateMode.REPLACE:
setattr(self._execution_state, exec_field, value)
elif effective_mode == StateUpdateMode.MERGE:
cur = getattr(self._execution_state, exec_field)
if isinstance(cur, dict) and isinstance(value, dict):
cur.update(value)
else:
setattr(self._execution_state, exec_field, value)
else: # APPEND
setattr(self._execution_state, exec_field, value)
checkpoint_data_to_save: dict[str, Any] | None = None
if (
self.checkpoint_dir
and self.update_count % self.checkpoint_interval == 0
):
checkpoint_data_to_save = {
"state": self.state.to_dict(),
"state": self._full_state().to_dict(),
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
"update_count": self.update_count,
}
# Capture a deep copy while still under the lock so subscribers
# receive an immutable point-in-time snapshot.
state_copy = self.state.model_copy(deep=True)
state_copy = self._full_state()
# Perform file I/O outside the lock to avoid blocking other threads.
if checkpoint_data_to_save is not None:
self._save_checkpoint(checkpoint_data_to_save)
# Emit outside the lock to avoid re-entrant deadlock and priority
# inversion when subscribers call back into StateManager.
self.state_stream.on_next(state_copy)
return state_copy
def _save_checkpoint(self, checkpoint_data: dict[str, Any] | None = None) -> None:
"""Persist a checkpoint to disk.
def get_state_observable(self) -> Observable:
return self.state_stream
When *checkpoint_data* is ``None`` the method acquires ``_lock``
internally to capture a consistent snapshot. The lock is
reentrant (``threading.RLock``), so calling this method while
``_lock`` is already held by the same thread is safe.
@property
def snapshots(self) -> list[StateSnapshot]:
"""Return the list of time-travel state snapshots (read-only copy)."""
with self._lock:
return list(self._snapshots)
def clear_history(self) -> None:
"""Clear the time-travel snapshot history."""
with self._lock:
self._snapshots.clear()
def load_checkpoint(self, checkpoint_file: Path) -> None:
"""Restore state from a checkpoint file on disk."""
checkpoint_data = json.loads(checkpoint_file.read_text(encoding="utf-8"))
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
loaded = GraphState.from_dict(checkpoint_data["state"])
self._conversation_history = list(loaded.messages)
self._execution_state = ExecutionState(
current_node=loaded.current_node,
execution_count=loaded.execution_count,
error=loaded.error,
metadata=dict(loaded.metadata),
)
self.update_count = checkpoint_data.get("update_count", 0)
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
self.logger.info("Loaded checkpoint: %s", checkpoint_file)
def get_latest_checkpoint(self) -> Path | None:
"""Return the path to the most recently written checkpoint, or None."""
if not self.checkpoint_dir:
return None
checkpoints = list(self.checkpoint_dir.glob("checkpoint_*.json"))
if not checkpoints:
return None
return max(checkpoints, key=lambda p: p.stat().st_mtime)
def time_travel(self, steps_back: int = 1) -> GraphState | None:
"""Restore state to a previous snapshot and return it.
Returns ``None`` when time travel is disabled or no snapshots exist.
"""
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
if not self.enable_time_travel or not self._snapshots:
return None
if steps_back >= len(self._snapshots):
steps_back = len(self._snapshots) - 1
snapshot = self._snapshots[-(steps_back + 1)]
restored = GraphState.from_dict(snapshot.state)
self._conversation_history = list(restored.messages)
self._execution_state = ExecutionState(
current_node=restored.current_node,
execution_count=restored.execution_count,
error=restored.error,
metadata=dict(restored.metadata),
)
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
return state_copy
def replace_state(self, new_state: GraphState) -> None:
"""Atomically replace the internal state and notify subscribers."""
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self._conversation_history = list(new_state.messages)
self._execution_state = ExecutionState(
current_node=new_state.current_node,
execution_count=new_state.execution_count,
error=new_state.error,
metadata=dict(new_state.metadata),
)
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
def reset(self, initial_state: GraphState | None = None) -> None:
"""Full reset: both conversation history AND execution state."""
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self._conversation_history = []
if initial_state is not None:
if initial_state.messages:
self._conversation_history = list(initial_state.messages)
self._execution_state = ExecutionState(
current_node=initial_state.current_node,
execution_count=initial_state.execution_count,
error=initial_state.error,
metadata=dict(initial_state.metadata),
)
else:
self._execution_state = ExecutionState()
self.update_count = 0
self._snapshots.clear()
state_copy = self._full_state()
self.state_stream.on_next(state_copy)
def close(self) -> None:
"""Mark this manager as closed and complete the state stream."""
if self.is_closed:
return
self.is_closed = True
self.state_stream.on_completed()
self.logger.info("StateManager closed")
# -- Private helpers -----------------------------------------------------
def _full_state(self) -> GraphState:
"""Build a GraphState from persistent + execution state."""
return GraphState(
messages=list(self._conversation_history),
metadata=dict(self._execution_state.metadata),
current_node=self._execution_state.current_node,
execution_count=self._execution_state.execution_count,
error=self._execution_state.error,
)
def _save_checkpoint(self, checkpoint_data: dict[str, Any] | None = None) -> None:
"""Persist a checkpoint to disk."""
if not self.checkpoint_dir:
return
if checkpoint_data is None:
# External callers (e.g. bridge) may invoke without pre-built
# data. Acquire the lock to capture a consistent snapshot.
with self._lock:
checkpoint_data = {
"state": self.state.to_dict(),
"state": self._full_state().to_dict(),
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
"update_count": self.update_count,
}
@@ -200,84 +450,15 @@ class StateManager: # pylint: disable=too-many-instance-attributes
)
self.logger.debug("Saved checkpoint: %s", checkpoint_file)
def load_checkpoint(self, checkpoint_file: Path) -> None:
# Read file I/O outside the lock to avoid blocking other threads.
checkpoint_data = json.loads(checkpoint_file.read_text(encoding="utf-8"))
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self.state = GraphState.from_dict(checkpoint_data["state"])
self.update_count = checkpoint_data.get("update_count", 0)
state_copy = self.state.model_copy(deep=True)
self.state_stream.on_next(state_copy)
self.logger.info("Loaded checkpoint: %s", checkpoint_file)
def get_latest_checkpoint(self) -> Path | None:
if not self.checkpoint_dir:
return None
checkpoints = list(self.checkpoint_dir.glob("checkpoint_*.json"))
if not checkpoints:
return None
return max(checkpoints, key=lambda p: p.stat().st_mtime)
# -- Backward-compatible alias -------------------------------------------------
def time_travel(self, steps_back: int = 1) -> GraphState | None:
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
if not self.enable_time_travel or not self.history:
return None
if steps_back >= len(self.history):
steps_back = len(self.history) - 1
snapshot = self.history[-(steps_back + 1)]
self.state = GraphState.from_dict(snapshot.state)
state_copy = self.state.model_copy(deep=True)
self.state_stream.on_next(state_copy)
return state_copy
def replace_state(self, new_state: GraphState) -> None:
"""Atomically replace the internal state and notify subscribers.
class StateManager(ConversationStateManager):
"""Backward-compatible alias for ``ConversationStateManager``.
Raises :class:`RuntimeError` if the manager has been closed.
Provides the same interface as the original ``StateManager`` class while
inheriting all new conversation-state separation logic.
"""
.. note::
See :meth:`update_state` for a note on emission ordering
under concurrent access.
"""
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self.state = new_state.model_copy(deep=True)
state_copy = new_state.model_copy(deep=True)
self.state_stream.on_next(state_copy)
def get_state_observable(self) -> Observable:
return self.state_stream
def clear_history(self) -> None:
with self._lock:
self.history.clear()
def reset(self, initial_state: GraphState | None = None) -> None:
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self.state = (
initial_state.model_copy(deep=True) if initial_state else GraphState()
)
self.update_count = 0
self.history.clear()
state_copy = self.state.model_copy(deep=True)
self.state_stream.on_next(state_copy)
def close(self) -> None:
"""Mark this manager as closed and complete the state stream.
After calling ``close()``, no further state updates should be
performed. Any underlying checkpoint resources are released.
"""
if self.is_closed:
return
self.is_closed = True
self.state_stream.on_completed()
self.logger.info("StateManager closed")
pass