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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ # Copyright 2026 The Taisce Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """LangGraph adapter for Taisce."""
4
+ from .memory import UNTRUSTED_KEY, TaisceMemory, turn_messages
5
+
6
+ __all__ = ["TaisceMemory", "UNTRUSTED_KEY", "turn_messages"]
@@ -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,4 @@
1
+ taisce>=0.1.0
2
+ langchain>=1.0
3
+ langgraph>=1.0
4
+ langchain-core>=1.0
@@ -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([]) == []