taisce-langgraph 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taisce_langgraph-0.1.0/PKG-INFO +20 -0
- taisce_langgraph-0.1.0/README.md +8 -0
- taisce_langgraph-0.1.0/pyproject.toml +15 -0
- taisce_langgraph-0.1.0/setup.cfg +4 -0
- taisce_langgraph-0.1.0/taisce_langgraph/__init__.py +6 -0
- taisce_langgraph-0.1.0/taisce_langgraph/conformance.py +139 -0
- taisce_langgraph-0.1.0/taisce_langgraph/memory.py +166 -0
- taisce_langgraph-0.1.0/taisce_langgraph.egg-info/PKG-INFO +20 -0
- taisce_langgraph-0.1.0/taisce_langgraph.egg-info/SOURCES.txt +12 -0
- taisce_langgraph-0.1.0/taisce_langgraph.egg-info/dependency_links.txt +1 -0
- taisce_langgraph-0.1.0/taisce_langgraph.egg-info/requires.txt +4 -0
- taisce_langgraph-0.1.0/taisce_langgraph.egg-info/top_level.txt +1 -0
- taisce_langgraph-0.1.0/tests/test_compaction_middleware.py +51 -0
- taisce_langgraph-0.1.0/tests/test_memory.py +30 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangGraph adapter for Taisce: agent middleware that injects governed memory as one untrusted human message and records the turn afterwards.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: taisce>=0.1.0
|
|
9
|
+
Requires-Dist: langchain>=1.0
|
|
10
|
+
Requires-Dist: langgraph>=1.0
|
|
11
|
+
Requires-Dist: langchain-core>=1.0
|
|
12
|
+
|
|
13
|
+
# taisce-langgraph
|
|
14
|
+
|
|
15
|
+
LangGraph has no context-provider interface; its seam is agent middleware. `TaisceMemory` is an
|
|
16
|
+
`AgentMiddleware` for `create_agent`: before each model call it injects memory as one human message
|
|
17
|
+
marked untrusted, and after the agent run it records the person's message and the assistant's final
|
|
18
|
+
reply. Tool calls, tool results and the injected message are never stored. Recall failure is not
|
|
19
|
+
fatal; a failed store raises. Held to the conformance suite through
|
|
20
|
+
`python -m taisce_langgraph.conformance`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# taisce-langgraph
|
|
2
|
+
|
|
3
|
+
LangGraph has no context-provider interface; its seam is agent middleware. `TaisceMemory` is an
|
|
4
|
+
`AgentMiddleware` for `create_agent`: before each model call it injects memory as one human message
|
|
5
|
+
marked untrusted, and after the agent run it records the person's message and the assistant's final
|
|
6
|
+
reply. Tool calls, tool results and the injected message are never stored. Recall failure is not
|
|
7
|
+
fatal; a failed store raises. Held to the conformance suite through
|
|
8
|
+
`python -m taisce_langgraph.conformance`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "taisce-langgraph"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "LangGraph adapter for Taisce: agent middleware that injects governed memory as one untrusted human message and records the turn afterwards."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = ["taisce>=0.1.0", "langchain>=1.0", "langgraph>=1.0", "langchain-core>=1.0"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
include = ["taisce_langgraph*"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The conformance driver: the middleware wrapped in the subprocess protocol the suite speaks.
|
|
4
|
+
|
|
5
|
+
The agent is LangGraph's own ``create_agent`` with the middleware attached and one tool; the model
|
|
6
|
+
is a scripted chat model that records what it was handed and answers the turn's reply, calling the
|
|
7
|
+
tool first when the turn has tool calls, or fails when the turn says so.
|
|
8
|
+
|
|
9
|
+
python -m taisce_langgraph.conformance
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, List, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from langchain.agents import create_agent
|
|
20
|
+
from langchain_core.language_models import BaseChatModel
|
|
21
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
|
22
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
23
|
+
from langchain_core.tools import tool
|
|
24
|
+
from pydantic import PrivateAttr
|
|
25
|
+
|
|
26
|
+
from taisce import Client
|
|
27
|
+
from taisce_langgraph import UNTRUSTED_KEY, TaisceMemory
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@tool
|
|
31
|
+
def act(call: str) -> str:
|
|
32
|
+
"""Performs the call the turn scripted and answers its result."""
|
|
33
|
+
return _RESULTS.get(call, "")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_RESULTS: dict = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ScriptedModel(BaseChatModel):
|
|
40
|
+
"""Records what it was handed on the first call, then follows the turn's script.
|
|
41
|
+
|
|
42
|
+
The report is a private attribute rather than a field: a field holding a dict is validated
|
|
43
|
+
into a copy, and a copy is where the record of what the model saw would silently go."""
|
|
44
|
+
|
|
45
|
+
turn: dict
|
|
46
|
+
_report: dict = PrivateAttr(default_factory=dict)
|
|
47
|
+
_calls: int = PrivateAttr(default=0)
|
|
48
|
+
|
|
49
|
+
def attach(self, report: dict) -> "ScriptedModel":
|
|
50
|
+
self._report = report
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _llm_type(self) -> str:
|
|
55
|
+
return "scripted"
|
|
56
|
+
|
|
57
|
+
def _generate(self, messages: List[BaseMessage], stop: Optional[List[str]] = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
|
|
58
|
+
if self._calls == 0:
|
|
59
|
+
self._report["model_messages"] = [
|
|
60
|
+
{"role": _role(m), "content": _text(m), "untrusted": bool((m.additional_kwargs or {}).get(UNTRUSTED_KEY))}
|
|
61
|
+
for m in messages
|
|
62
|
+
]
|
|
63
|
+
self._calls += 1
|
|
64
|
+
if self.turn.get("model_failure"):
|
|
65
|
+
raise RuntimeError("the model failed")
|
|
66
|
+
tool_calls = self.turn.get("tool_calls") or []
|
|
67
|
+
if self._calls <= len(tool_calls):
|
|
68
|
+
call = tool_calls[self._calls - 1]
|
|
69
|
+
message = AIMessage(content="", tool_calls=[{"name": "act", "args": {"call": call["call"]}, "id": f"call-{self._calls}"}])
|
|
70
|
+
else:
|
|
71
|
+
message = AIMessage(content=self.turn.get("assistant") or "")
|
|
72
|
+
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
73
|
+
|
|
74
|
+
def bind_tools(self, tools: Any, **kwargs: Any) -> "ScriptedModel":
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _role(m: BaseMessage) -> str:
|
|
79
|
+
return {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}.get(m.type, m.type)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _text(m: BaseMessage) -> str:
|
|
83
|
+
return m.content if isinstance(m.content, str) else "".join(p.get("text", "") if isinstance(p, dict) else str(p) for p in m.content)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def run_turn(instruction: dict) -> dict:
|
|
87
|
+
report: dict = {"model_messages": [], "fatal": False, "observed": False}
|
|
88
|
+
turn = instruction["turn"]
|
|
89
|
+
_RESULTS.clear()
|
|
90
|
+
for call in turn.get("tool_calls") or []:
|
|
91
|
+
_RESULTS[call["call"]] = call["result"]
|
|
92
|
+
|
|
93
|
+
async def saw_request(request: httpx.Request) -> None:
|
|
94
|
+
if request.method == "POST" and request.url.path.endswith("/observations"):
|
|
95
|
+
report["observed"] = True
|
|
96
|
+
|
|
97
|
+
http = httpx.AsyncClient(timeout=10.0, event_hooks={"request": [saw_request]})
|
|
98
|
+
client = Client(instruction["api"], instruction["token"], http=http)
|
|
99
|
+
|
|
100
|
+
def on_error(stage: str, exc: Exception) -> None:
|
|
101
|
+
# Stderr is the driver's own: a swallowed recall failure is still worth a line for a person.
|
|
102
|
+
sys.stderr.write(f"{instruction['case']}: {stage} failed: {exc!r}\n")
|
|
103
|
+
if stage == "observe":
|
|
104
|
+
report["store_error"] = str(exc)
|
|
105
|
+
|
|
106
|
+
# When the turn arms compaction, any history at all trips it.
|
|
107
|
+
memory = TaisceMemory(client, data_subject_id=instruction.get("data_subject_id"), run_id=instruction["case"], on_error=on_error,
|
|
108
|
+
compact_after_messages=1 if turn.get("compact") else None)
|
|
109
|
+
model = ScriptedModel(turn=turn).attach(report)
|
|
110
|
+
agent = create_agent(model, tools=[act], middleware=[memory])
|
|
111
|
+
# The history is the state the application's graph already holds; here it enters with the
|
|
112
|
+
# turn's input, which is where LangGraph puts an existing conversation.
|
|
113
|
+
messages: List[BaseMessage] = [AIMessage(content=m["content"]) if m["role"] == "assistant" else HumanMessage(content=m["content"])
|
|
114
|
+
for m in (turn.get("history") or []) + (turn.get("synthetic") or [])]
|
|
115
|
+
messages.append(HumanMessage(content=turn["user"]))
|
|
116
|
+
try:
|
|
117
|
+
await agent.ainvoke({"messages": messages})
|
|
118
|
+
except Exception: # noqa: BLE001 - the run's error reaches the application; which it was is in the report
|
|
119
|
+
report["fatal"] = "store_error" not in report
|
|
120
|
+
finally:
|
|
121
|
+
await http.aclose()
|
|
122
|
+
return report
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def main() -> int:
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
while True:
|
|
128
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
129
|
+
if not line:
|
|
130
|
+
return 0
|
|
131
|
+
if not line.strip():
|
|
132
|
+
continue
|
|
133
|
+
report = await run_turn(json.loads(line))
|
|
134
|
+
sys.stdout.write(json.dumps(report) + "\n")
|
|
135
|
+
sys.stdout.flush()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
sys.exit(asyncio.run(main()))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The memory middleware: recall before the model call, observe after the agent run.
|
|
4
|
+
|
|
5
|
+
LangGraph's agent has no context-provider interface; what it has is middleware around the model
|
|
6
|
+
call and around the agent run, and that is the seam this maps onto, with nothing of its own.
|
|
7
|
+
|
|
8
|
+
**The role rule.** Memory is injected into the model request as one ``HumanMessage`` marked
|
|
9
|
+
untrusted in its ``additional_kwargs``, never as a ``SystemMessage``: the same bytes as a system
|
|
10
|
+
message are instructions the model obeys, and every memory is something somebody said once. It is
|
|
11
|
+
injected into the request only, not into the graph's state, so it is never persisted as history
|
|
12
|
+
and never stored as though a person said it.
|
|
13
|
+
|
|
14
|
+
**Failure policy is asymmetric.** A recall that cannot reach the deployment injects nothing and
|
|
15
|
+
raises nothing; the agent runs without memory. A failed store raises, because a lost turn is
|
|
16
|
+
invisible until a subject access request asks for it.
|
|
17
|
+
|
|
18
|
+
**Store only on success, and only what people said.** ``aafter_agent`` runs when the agent run
|
|
19
|
+
completed; a model that raised never reaches it. What is stored is the last human message and the
|
|
20
|
+
assistant messages after it that carry text and no tool calls; tool messages, tool-calling
|
|
21
|
+
messages and anything before the human message, which the framework or an earlier turn wrote, are
|
|
22
|
+
not this turn's memory.
|
|
23
|
+
|
|
24
|
+
**Compaction is replacement, in the state, the way the framework's own summarisation middleware
|
|
25
|
+
works.** When ``compact_after_messages`` is set and the state holds more non-system messages than
|
|
26
|
+
that, ``abefore_model`` asks the deployment for the subject's context and rewrites the state with
|
|
27
|
+
the framework's remove-all message: the system messages, one untrusted ``HumanMessage`` carrying
|
|
28
|
+
the context unchanged, and the person's current message. Nothing is summarised here; a context
|
|
29
|
+
that cannot be fetched leaves the state as it was and reports.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any, Callable, List, Optional, Sequence
|
|
35
|
+
|
|
36
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
37
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, RemoveMessage, SystemMessage
|
|
38
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
39
|
+
|
|
40
|
+
from taisce import Client, bundle_is_empty
|
|
41
|
+
from taisce.memory import is_memory_text, render_context_message, render_memory_message, turn_key
|
|
42
|
+
|
|
43
|
+
#: The key under which the injected message is marked untrusted in its additional kwargs.
|
|
44
|
+
UNTRUSTED_KEY = "taisce.untrusted"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TaisceMemory(AgentMiddleware):
|
|
48
|
+
"""Injects governed memory before each model call and records the turn after the run."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client: Client,
|
|
53
|
+
*,
|
|
54
|
+
data_subject_id: Optional[str] = None,
|
|
55
|
+
run_id: Optional[str] = None,
|
|
56
|
+
max_characters: Optional[int] = None,
|
|
57
|
+
source_roles: Optional[Sequence[str]] = None,
|
|
58
|
+
on_error: Optional[Callable[[str, Exception], None]] = None,
|
|
59
|
+
compact_after_messages: Optional[int] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
if compact_after_messages is not None:
|
|
63
|
+
if compact_after_messages <= 0:
|
|
64
|
+
raise ValueError("compact_after_messages must be positive: it is the history length that triggers compaction")
|
|
65
|
+
if not data_subject_id or not data_subject_id.strip():
|
|
66
|
+
raise ValueError("a data subject is required to compact: a context is one subject's history")
|
|
67
|
+
self._client = client
|
|
68
|
+
self._data_subject_id = data_subject_id
|
|
69
|
+
self._run_id = run_id
|
|
70
|
+
self._max_characters = max_characters
|
|
71
|
+
self._source_roles = list(source_roles) if source_roles else None
|
|
72
|
+
self._on_error = on_error
|
|
73
|
+
self._compact_after = compact_after_messages
|
|
74
|
+
|
|
75
|
+
async def abefore_model(self, state: Any, runtime: Any) -> Optional[dict]:
|
|
76
|
+
if self._compact_after is None:
|
|
77
|
+
return None
|
|
78
|
+
messages = list(state.get("messages") or [])
|
|
79
|
+
if sum(1 for m in messages if not isinstance(m, SystemMessage)) <= self._compact_after:
|
|
80
|
+
return None
|
|
81
|
+
at = _last_human_index(messages)
|
|
82
|
+
if at >= len(messages):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
assembled = await self._client.context(data_subject_id=self._data_subject_id, max_characters=self._max_characters)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 - swallowed by design, see the module docstring
|
|
87
|
+
self._report("context", exc)
|
|
88
|
+
return None
|
|
89
|
+
memory = HumanMessage(content=render_context_message(assembled), name="taisce", additional_kwargs={UNTRUSTED_KEY: True})
|
|
90
|
+
kept = [m for m in messages[:at] if isinstance(m, SystemMessage)]
|
|
91
|
+
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *kept, memory, *messages[at:]]}
|
|
92
|
+
|
|
93
|
+
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
|
|
94
|
+
question = _last_human_text(request.messages)
|
|
95
|
+
if not question:
|
|
96
|
+
return await handler(request)
|
|
97
|
+
try:
|
|
98
|
+
watermark = await self._client.freshness()
|
|
99
|
+
bundle = await self._client.recall(
|
|
100
|
+
question=question, data_subject_id=self._data_subject_id,
|
|
101
|
+
max_characters=self._max_characters, source_roles=self._source_roles)
|
|
102
|
+
except Exception as exc: # noqa: BLE001 - swallowed by design, see the module docstring
|
|
103
|
+
self._report("recall", exc)
|
|
104
|
+
return await handler(request)
|
|
105
|
+
if bundle_is_empty(bundle):
|
|
106
|
+
return await handler(request)
|
|
107
|
+
rendered = render_memory_message(
|
|
108
|
+
{"stored": watermark.stored, "formed": watermark.formed, "parked": watermark.parked}, bundle)
|
|
109
|
+
memory = HumanMessage(content=rendered, name="taisce", additional_kwargs={UNTRUSTED_KEY: True})
|
|
110
|
+
# Before the person's last message, so the model reads memory and then the question.
|
|
111
|
+
messages = list(request.messages)
|
|
112
|
+
at = _last_human_index(messages)
|
|
113
|
+
messages.insert(at, memory)
|
|
114
|
+
return await handler(request.override(messages=messages))
|
|
115
|
+
|
|
116
|
+
async def aafter_agent(self, state: Any, runtime: Any) -> Optional[dict]:
|
|
117
|
+
messages = turn_messages(state.get("messages") or [])
|
|
118
|
+
if not messages:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
await self._client.observe(
|
|
122
|
+
idempotency_key=turn_key(self._data_subject_id, self._run_id, messages),
|
|
123
|
+
messages=messages, data_subject_id=self._data_subject_id,
|
|
124
|
+
occurred_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
125
|
+
except Exception as exc: # noqa: BLE001 - reported, then raised
|
|
126
|
+
self._report("observe", exc)
|
|
127
|
+
raise
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def _report(self, stage: str, exc: Exception) -> None:
|
|
131
|
+
if self._on_error is not None:
|
|
132
|
+
self._on_error(stage, exc)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def turn_messages(messages: Sequence[BaseMessage]) -> List[dict]:
|
|
136
|
+
"""The turn as the deployment stores it: the last human message and the assistant's own words
|
|
137
|
+
after it. A tool message is the tool talking; an assistant message with tool calls is the
|
|
138
|
+
model talking to a tool; neither is something a person said."""
|
|
139
|
+
at = _last_human_index(list(messages))
|
|
140
|
+
if at >= len(messages):
|
|
141
|
+
return []
|
|
142
|
+
out = [{"role": "user", "content": _text(messages[at])}]
|
|
143
|
+
for m in messages[at + 1:]:
|
|
144
|
+
if isinstance(m, AIMessage) and not m.tool_calls and _text(m).strip():
|
|
145
|
+
out.append({"role": "assistant", "content": _text(m)})
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _text(message: BaseMessage) -> str:
|
|
150
|
+
content = message.content
|
|
151
|
+
if isinstance(content, str):
|
|
152
|
+
return content
|
|
153
|
+
return "".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in content)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _last_human_index(messages: List[BaseMessage]) -> int:
|
|
157
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
158
|
+
m = messages[i]
|
|
159
|
+
if isinstance(m, HumanMessage) and not is_memory_text(_text(m)) and _text(m).strip():
|
|
160
|
+
return i
|
|
161
|
+
return len(messages)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _last_human_text(messages: Sequence[BaseMessage]) -> str:
|
|
165
|
+
at = _last_human_index(list(messages))
|
|
166
|
+
return _text(messages[at]).strip() if at < len(messages) else ""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangGraph adapter for Taisce: agent middleware that injects governed memory as one untrusted human message and records the turn afterwards.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: taisce>=0.1.0
|
|
9
|
+
Requires-Dist: langchain>=1.0
|
|
10
|
+
Requires-Dist: langgraph>=1.0
|
|
11
|
+
Requires-Dist: langchain-core>=1.0
|
|
12
|
+
|
|
13
|
+
# taisce-langgraph
|
|
14
|
+
|
|
15
|
+
LangGraph has no context-provider interface; its seam is agent middleware. `TaisceMemory` is an
|
|
16
|
+
`AgentMiddleware` for `create_agent`: before each model call it injects memory as one human message
|
|
17
|
+
marked untrusted, and after the agent run it records the person's message and the assistant's final
|
|
18
|
+
reply. Tool calls, tool results and the injected message are never stored. Recall failure is not
|
|
19
|
+
fatal; a failed store raises. Held to the conformance suite through
|
|
20
|
+
`python -m taisce_langgraph.conformance`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
taisce_langgraph/__init__.py
|
|
4
|
+
taisce_langgraph/conformance.py
|
|
5
|
+
taisce_langgraph/memory.py
|
|
6
|
+
taisce_langgraph.egg-info/PKG-INFO
|
|
7
|
+
taisce_langgraph.egg-info/SOURCES.txt
|
|
8
|
+
taisce_langgraph.egg-info/dependency_links.txt
|
|
9
|
+
taisce_langgraph.egg-info/requires.txt
|
|
10
|
+
taisce_langgraph.egg-info/top_level.txt
|
|
11
|
+
tests/test_compaction_middleware.py
|
|
12
|
+
tests/test_memory.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
taisce_langgraph
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What holds without a deployment: the state rewrite a compaction returns. The rest is held by the
|
|
4
|
+
conformance suite."""
|
|
5
|
+
import pytest
|
|
6
|
+
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
|
|
7
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
8
|
+
|
|
9
|
+
from taisce import Client, MEMORY_MESSAGE_PREFIX
|
|
10
|
+
from taisce_langgraph import TaisceMemory, UNTRUSTED_KEY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _ContextClient:
|
|
14
|
+
def __init__(self, answer=None, error=None):
|
|
15
|
+
self.answer, self.error, self.calls = answer, error, 0
|
|
16
|
+
|
|
17
|
+
async def context(self, *, data_subject_id, max_characters=None):
|
|
18
|
+
self.calls += 1
|
|
19
|
+
if self.error:
|
|
20
|
+
raise self.error
|
|
21
|
+
return self.answer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_compaction_needs_a_subject_and_a_positive_trigger():
|
|
25
|
+
client = Client("http://127.0.0.1:1", "tsk")
|
|
26
|
+
with pytest.raises(ValueError):
|
|
27
|
+
TaisceMemory(client, compact_after_messages=8)
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
TaisceMemory(client, data_subject_id="s", compact_after_messages=0)
|
|
30
|
+
assert TaisceMemory(client, data_subject_id="s", compact_after_messages=8) is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_the_state_is_rewritten_as_system_messages_the_context_and_the_persons_message():
|
|
35
|
+
history = [SystemMessage(content="Be brief."), HumanMessage(content="old one"), AIMessage(content="old reply"), HumanMessage(content="now?")]
|
|
36
|
+
client = _ContextClient(answer={"watermark": {"stored": 3}, "segments": [{"summary": "It began."}], "turns": [], "characters": 9, "truncated": False})
|
|
37
|
+
update = await TaisceMemory(client, data_subject_id="s", compact_after_messages=1).abefore_model({"messages": history}, None)
|
|
38
|
+
messages = update["messages"]
|
|
39
|
+
assert isinstance(messages[0], RemoveMessage) and messages[0].id == REMOVE_ALL_MESSAGES
|
|
40
|
+
assert messages[1].content == "Be brief."
|
|
41
|
+
assert isinstance(messages[2], HumanMessage) and messages[2].content.startswith(MEMORY_MESSAGE_PREFIX) and messages[2].additional_kwargs[UNTRUSTED_KEY] is True
|
|
42
|
+
assert messages[3].content == "now?" and len(messages) == 4
|
|
43
|
+
# Under the trigger, or with no person's message, nothing is rewritten; a failing deployment leaves the state as it was.
|
|
44
|
+
idle = _ContextClient()
|
|
45
|
+
assert await TaisceMemory(idle, data_subject_id="s", compact_after_messages=8).abefore_model({"messages": history}, None) is None
|
|
46
|
+
assert await TaisceMemory(idle, data_subject_id="s", compact_after_messages=1).abefore_model({"messages": [AIMessage(content="a"), AIMessage(content="b")]}, None) is None
|
|
47
|
+
assert idle.calls == 0
|
|
48
|
+
seen = []
|
|
49
|
+
broken = _ContextClient(error=RuntimeError("down"))
|
|
50
|
+
assert await TaisceMemory(broken, data_subject_id="s", compact_after_messages=1, on_error=lambda stage, exc: seen.append(stage)).abefore_model({"messages": history}, None) is None
|
|
51
|
+
assert seen == ["context"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What holds without a deployment: which messages of a run become the turn. Everything the
|
|
4
|
+
middleware does against a deployment is held by the conformance suite through
|
|
5
|
+
``python -m taisce_langgraph.conformance``."""
|
|
6
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
7
|
+
|
|
8
|
+
from taisce.memory import render_memory_message
|
|
9
|
+
from taisce_langgraph import turn_messages
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_the_turn_is_the_last_human_message_and_the_assistants_own_words_after_it():
|
|
13
|
+
memory = render_memory_message({"stored": 1}, {"facts": [{"fact_id": "f1"}]})
|
|
14
|
+
messages = [
|
|
15
|
+
AIMessage(content="Summary of earlier conversation."),
|
|
16
|
+
HumanMessage(content="Book the room for Tuesday."),
|
|
17
|
+
HumanMessage(content=memory),
|
|
18
|
+
AIMessage(content="", tool_calls=[{"name": "act", "args": {"call": "book"}, "id": "c1"}]),
|
|
19
|
+
ToolMessage(content="booked", tool_call_id="c1"),
|
|
20
|
+
AIMessage(content="Room A is booked for Tuesday."),
|
|
21
|
+
]
|
|
22
|
+
assert turn_messages(messages) == [
|
|
23
|
+
{"role": "user", "content": "Book the room for Tuesday."},
|
|
24
|
+
{"role": "assistant", "content": "Room A is booked for Tuesday."},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_a_run_with_no_human_message_is_no_turn():
|
|
29
|
+
assert turn_messages([AIMessage(content="hello")]) == []
|
|
30
|
+
assert turn_messages([]) == []
|