taisce-agent-framework 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_agent_framework-0.1.0/PKG-INFO +9 -0
- taisce_agent_framework-0.1.0/pyproject.toml +15 -0
- taisce_agent_framework-0.1.0/setup.cfg +4 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/__init__.py +33 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/compaction.py +87 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/conformance.py +108 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/context.py +150 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/session.py +109 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework/text_search.py +76 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework.egg-info/PKG-INFO +9 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework.egg-info/SOURCES.txt +16 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework.egg-info/dependency_links.txt +1 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework.egg-info/requires.txt +2 -0
- taisce_agent_framework-0.1.0/taisce_agent_framework.egg-info/top_level.txt +1 -0
- taisce_agent_framework-0.1.0/tests/test_compaction_provider.py +53 -0
- taisce_agent_framework-0.1.0/tests/test_provider.py +58 -0
- taisce_agent_framework-0.1.0/tests/test_session.py +83 -0
- taisce_agent_framework-0.1.0/tests/test_text_search.py +52 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce-agent-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Microsoft Agent Framework adapter for Taisce: a context provider that injects governed memory as one untrusted user 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: agent-framework-core>=1.18.0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "taisce-agent-framework"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Microsoft Agent Framework adapter for Taisce: a context provider that injects governed memory as one untrusted user 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", "agent-framework-core>=1.18.0"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
include = ["taisce_agent_framework*"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Microsoft Agent Framework adapter for Taisce."""
|
|
4
|
+
from .compaction import TaisceCompaction
|
|
5
|
+
from .session import Loaded, Saved, SessionStore
|
|
6
|
+
from .text_search import APPROXIMATE_NOTICE, SearchResult, map_results, search
|
|
7
|
+
from .context import (
|
|
8
|
+
MEMORY_MESSAGE_PREFIX,
|
|
9
|
+
UNTRUSTED_PROPERTY,
|
|
10
|
+
TaisceContextProvider,
|
|
11
|
+
external_only,
|
|
12
|
+
is_memory_message,
|
|
13
|
+
render_memory_message,
|
|
14
|
+
turn_key,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["TaisceCompaction",
|
|
18
|
+
"APPROXIMATE_NOTICE",
|
|
19
|
+
"SearchResult",
|
|
20
|
+
"SessionStore",
|
|
21
|
+
"Saved",
|
|
22
|
+
"Loaded",
|
|
23
|
+
"map_results",
|
|
24
|
+
"search",
|
|
25
|
+
|
|
26
|
+
"MEMORY_MESSAGE_PREFIX",
|
|
27
|
+
"UNTRUSTED_PROPERTY",
|
|
28
|
+
"TaisceContextProvider",
|
|
29
|
+
"external_only",
|
|
30
|
+
"is_memory_message",
|
|
31
|
+
"render_memory_message",
|
|
32
|
+
"turn_key",
|
|
33
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The compaction provider: when the loaded history is long, replace it with the deployment's context.
|
|
4
|
+
|
|
5
|
+
The framework's own compaction seam is a strategy that marks loaded messages excluded; a strategy
|
|
6
|
+
cannot add a message the run keeps, so this is a context provider of its own that does what the
|
|
7
|
+
framework's ``CompactionProvider`` does to the loaded history and then contributes one message. It
|
|
8
|
+
decides nothing about what the context holds: it asks ``POST /v1/contexts`` and hands the answer
|
|
9
|
+
over unchanged, as one ``user`` message marked untrusted, in place of every loaded message that is
|
|
10
|
+
not a system message or a memory message another provider injected.
|
|
11
|
+
|
|
12
|
+
**Why replacement and not a summary.** A summary written here would be prose the deployment cannot
|
|
13
|
+
register, erase or re-derive, in a language-specific way, entering the history as though somebody
|
|
14
|
+
said it. The deployment holds every turn already and has rolled the older ones up off the read
|
|
15
|
+
path; its answer is the history, and the adapter's only job is fidelity to it.
|
|
16
|
+
|
|
17
|
+
**Failure policy.** A context that cannot be fetched leaves the loaded history as it was and reports
|
|
18
|
+
through ``on_error``; the agent runs with more to read, not less, and the turn is not fatal.
|
|
19
|
+
|
|
20
|
+
**The trigger** is a count of loaded non-system messages, because that is what this framework's
|
|
21
|
+
Python SDK counts by. The stored history is the application's and is not rewritten; once
|
|
22
|
+
over the trigger, every run fetches the context once.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from agent_framework import ContextProvider, Message
|
|
29
|
+
|
|
30
|
+
from taisce import Client
|
|
31
|
+
from taisce.memory import render_context_message
|
|
32
|
+
|
|
33
|
+
from .context import UNTRUSTED_PROPERTY, _role, is_memory_message
|
|
34
|
+
|
|
35
|
+
#: The source id this provider reports to the framework.
|
|
36
|
+
SOURCE_ID = "taisce-compaction"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaisceCompaction(ContextProvider):
|
|
40
|
+
"""Replaces a long loaded history with the deployment's context, before the model runs."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
client: Client,
|
|
45
|
+
*,
|
|
46
|
+
data_subject_id: str,
|
|
47
|
+
after_messages: int,
|
|
48
|
+
max_characters: Optional[int] = None,
|
|
49
|
+
on_error: Optional[Callable[[str, Exception], None]] = None,
|
|
50
|
+
source_id: str = SOURCE_ID,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not data_subject_id or not data_subject_id.strip():
|
|
53
|
+
raise ValueError("a data subject is required: a context is one subject's history")
|
|
54
|
+
if after_messages <= 0:
|
|
55
|
+
raise ValueError("after_messages must be positive: it is the history length that triggers compaction")
|
|
56
|
+
super().__init__(source_id=source_id)
|
|
57
|
+
self._client = client
|
|
58
|
+
self._data_subject_id = data_subject_id
|
|
59
|
+
self._after_messages = after_messages
|
|
60
|
+
self._max_characters = max_characters
|
|
61
|
+
self._on_error = on_error
|
|
62
|
+
|
|
63
|
+
async def before_run(self, *, agent: Any, session: Any, context: Any, state: dict) -> None:
|
|
64
|
+
loaded = context.get_messages()
|
|
65
|
+
if sum(1 for m in loaded if not _kept(m)) <= self._after_messages:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
assembled = await self._client.context(data_subject_id=self._data_subject_id, max_characters=self._max_characters)
|
|
69
|
+
except Exception as exc: # noqa: BLE001 - swallowed by design, see the module docstring
|
|
70
|
+
self._report("context", exc)
|
|
71
|
+
return
|
|
72
|
+
rendered = render_context_message(assembled)
|
|
73
|
+
for source in list(context.context_messages):
|
|
74
|
+
context.context_messages[source] = [m for m in context.context_messages[source] if _kept(m)]
|
|
75
|
+
context.extend_messages(self.source_id, [
|
|
76
|
+
Message("user", [rendered], author_name="taisce", additional_properties={UNTRUSTED_PROPERTY: True}),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
def _report(self, stage: str, exc: Exception) -> None:
|
|
80
|
+
if self._on_error is not None:
|
|
81
|
+
self._on_error(stage, exc)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _kept(message: Any) -> bool:
|
|
85
|
+
"""A system message is the application's instructions and a memory message is another
|
|
86
|
+
provider's contribution for this run; neither is history the deployment holds."""
|
|
87
|
+
return _role(message) == "system" or is_memory_message(message)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The conformance driver: the adapter wrapped in the subprocess protocol the suite speaks.
|
|
4
|
+
|
|
5
|
+
One JSON instruction per line on stdin, one report per line on stdout. The agent is the framework's
|
|
6
|
+
own ``Agent`` with the provider attached; the model is a stub that records what it was handed and
|
|
7
|
+
answers the turn's reply, or fails when the turn says so. Nothing about the deployment is stubbed.
|
|
8
|
+
|
|
9
|
+
python -m taisce_agent_framework.conformance
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from agent_framework import Agent, BaseChatClient, ChatResponse, Content, InMemoryHistoryProvider, Message
|
|
20
|
+
|
|
21
|
+
from taisce import Client
|
|
22
|
+
from taisce_agent_framework import TaisceCompaction, TaisceContextProvider, UNTRUSTED_PROPERTY
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StubModel(BaseChatClient):
|
|
26
|
+
"""Records what it was handed, answers the turn's reply or fails."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, turn: dict, report: dict) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._turn = turn
|
|
31
|
+
self._report = report
|
|
32
|
+
|
|
33
|
+
async def _inner_get_response(self, *, messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any) -> ChatResponse:
|
|
34
|
+
self._report["model_messages"] = [
|
|
35
|
+
{"role": _role(m), "content": m.text or "",
|
|
36
|
+
"untrusted": bool((getattr(m, "additional_properties", None) or {}).get(UNTRUSTED_PROPERTY))}
|
|
37
|
+
for m in messages
|
|
38
|
+
]
|
|
39
|
+
if self._turn.get("model_failure"):
|
|
40
|
+
raise RuntimeError("the model failed")
|
|
41
|
+
response = []
|
|
42
|
+
for i, call in enumerate(self._turn.get("tool_calls") or [], start=1):
|
|
43
|
+
call_id = f"call-{i}"
|
|
44
|
+
response.append(Message("assistant", [Content.from_function_call(call_id, "tool", arguments={"call": call["call"]})]))
|
|
45
|
+
response.append(Message("tool", [Content.from_function_result(call_id, result=call["result"])]))
|
|
46
|
+
if self._turn.get("assistant"):
|
|
47
|
+
response.append(Message("assistant", [self._turn["assistant"]]))
|
|
48
|
+
return ChatResponse(messages=response)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _role(message: Any) -> str:
|
|
52
|
+
role = getattr(message, "role", "")
|
|
53
|
+
return getattr(role, "value", role) if not isinstance(role, str) else role
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def run_turn(instruction: dict) -> dict:
|
|
57
|
+
report: dict = {"model_messages": [], "fatal": False, "observed": False}
|
|
58
|
+
|
|
59
|
+
async def saw_request(request: httpx.Request) -> None:
|
|
60
|
+
if request.method == "POST" and request.url.path.endswith("/observations"):
|
|
61
|
+
report["observed"] = True
|
|
62
|
+
|
|
63
|
+
http = httpx.AsyncClient(timeout=10.0, event_hooks={"request": [saw_request]})
|
|
64
|
+
client = Client(instruction["api"], instruction["token"], http=http)
|
|
65
|
+
|
|
66
|
+
def on_error(stage: str, exc: Exception) -> None:
|
|
67
|
+
if stage == "observe":
|
|
68
|
+
report["store_error"] = str(exc)
|
|
69
|
+
|
|
70
|
+
provider = TaisceContextProvider(client, data_subject_id=instruction.get("data_subject_id"), run_id=instruction["case"], on_error=on_error)
|
|
71
|
+
turn = instruction["turn"]
|
|
72
|
+
# The framework's own history provider holds the earlier turns the application kept; the
|
|
73
|
+
# compaction provider, when the turn arms it, is triggered by any history at all.
|
|
74
|
+
history = InMemoryHistoryProvider()
|
|
75
|
+
providers: list = [history, provider]
|
|
76
|
+
if turn.get("compact"):
|
|
77
|
+
providers.insert(1, TaisceCompaction(client, data_subject_id=instruction["data_subject_id"], after_messages=1, on_error=on_error))
|
|
78
|
+
agent = Agent(StubModel(turn, report), context_providers=providers)
|
|
79
|
+
session = agent.create_session()
|
|
80
|
+
if turn.get("history"):
|
|
81
|
+
await history.save_messages(session.session_id, [Message(m["role"], [m["content"]]) for m in turn["history"]],
|
|
82
|
+
state=session.state.setdefault(history.source_id, {}))
|
|
83
|
+
messages = [Message(s["role"], [s["content"]]) for s in turn.get("synthetic") or []]
|
|
84
|
+
messages.append(Message("user", [turn["user"]]))
|
|
85
|
+
try:
|
|
86
|
+
await agent.run(messages, session=session)
|
|
87
|
+
except Exception: # noqa: BLE001 - the run's error reaches the application; which it was is in the report
|
|
88
|
+
report["fatal"] = "store_error" not in report
|
|
89
|
+
finally:
|
|
90
|
+
await http.aclose()
|
|
91
|
+
return report
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def main() -> int:
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
while True:
|
|
97
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
98
|
+
if not line:
|
|
99
|
+
return 0
|
|
100
|
+
if not line.strip():
|
|
101
|
+
continue
|
|
102
|
+
report = await run_turn(json.loads(line))
|
|
103
|
+
sys.stdout.write(json.dumps(report) + "\n")
|
|
104
|
+
sys.stdout.flush()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(asyncio.run(main()))
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The context provider: recall before a turn, observe after it.
|
|
4
|
+
|
|
5
|
+
**The role rule, and why Python makes it sharper.** ``SessionContext`` gives a provider two ways to
|
|
6
|
+
add content: ``extend_messages``, which contributes conversation messages, and
|
|
7
|
+
``extend_instructions``, which contributes system instructions. The framework validates neither, and
|
|
8
|
+
its own documentation names an external source as the path an indirect prompt injection arrives
|
|
9
|
+
through. Every memory is something somebody said, so every memory is untrusted input. This provider
|
|
10
|
+
uses ``extend_messages`` with role ``user``, always, marked untrusted, and never touches
|
|
11
|
+
``extend_instructions``: the same bytes as an instruction are something the model obeys.
|
|
12
|
+
|
|
13
|
+
**Failure policy is asymmetric.** A recall that cannot reach the deployment contributes nothing and
|
|
14
|
+
raises nothing; the agent runs without memory rather than not at all, and ``on_error`` is how an
|
|
15
|
+
operator still finds out. A failed store raises, because losing a turn silently is the failure that
|
|
16
|
+
stays invisible until a subject access request comes back missing a conversation.
|
|
17
|
+
|
|
18
|
+
**Store only on success, and only what people said.** The framework runs ``after_run`` on the
|
|
19
|
+
success path; the check on ``context.response`` stays because relying on the absence of a call is
|
|
20
|
+
relying on a framework internal. What is stored is the person's messages and the assistant's final
|
|
21
|
+
text; tool calls, tool results and anything this provider injected are not memory.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any, Callable, Iterable, List, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
from agent_framework import ContextProvider, Message
|
|
29
|
+
|
|
30
|
+
from taisce import Client, bundle_is_empty
|
|
31
|
+
from taisce.memory import MEMORY_MESSAGE_PREFIX, is_memory_text, render_memory_message, turn_key
|
|
32
|
+
|
|
33
|
+
#: The key under which the injected message is marked untrusted in its additional properties.
|
|
34
|
+
UNTRUSTED_PROPERTY = "taisce.untrusted"
|
|
35
|
+
#: The source id this provider reports to the framework; its own messages are keyed by it.
|
|
36
|
+
SOURCE_ID = "taisce"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaisceContextProvider(ContextProvider):
|
|
40
|
+
"""Injects governed memory into an agent's turn, and records the turn afterwards."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
client: Client,
|
|
45
|
+
*,
|
|
46
|
+
data_subject_id: Optional[str] = None,
|
|
47
|
+
run_id: Optional[str] = None,
|
|
48
|
+
max_characters: Optional[int] = None,
|
|
49
|
+
source_roles: Optional[Sequence[str]] = None,
|
|
50
|
+
on_error: Optional[Callable[[str, Exception], None]] = None,
|
|
51
|
+
source_id: str = SOURCE_ID,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(source_id=source_id)
|
|
54
|
+
self._client = client
|
|
55
|
+
self._data_subject_id = data_subject_id
|
|
56
|
+
self._run_id = run_id
|
|
57
|
+
self._max_characters = max_characters
|
|
58
|
+
self._source_roles = list(source_roles) if source_roles else None
|
|
59
|
+
self._on_error = on_error
|
|
60
|
+
|
|
61
|
+
async def before_run(self, *, agent: Any, session: Any, context: Any, state: dict) -> None:
|
|
62
|
+
question = _last_user_text(context.get_messages(include_input=True))
|
|
63
|
+
if not question:
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
watermark = await self._client.freshness()
|
|
67
|
+
bundle = await self._client.recall(
|
|
68
|
+
question=question,
|
|
69
|
+
data_subject_id=self._data_subject_id,
|
|
70
|
+
max_characters=self._max_characters,
|
|
71
|
+
source_roles=self._source_roles,
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - swallowed by design, see the module docstring
|
|
74
|
+
self._report("recall", exc)
|
|
75
|
+
return
|
|
76
|
+
if bundle_is_empty(bundle):
|
|
77
|
+
return
|
|
78
|
+
rendered = render_memory_message(
|
|
79
|
+
{"stored": watermark.stored, "formed": watermark.formed, "parked": watermark.parked}, bundle)
|
|
80
|
+
# THE line: extend_messages with role user, never extend_instructions. The text is wrapped
|
|
81
|
+
# in a list: Message takes a sequence of contents and a bare str is a sequence of letters.
|
|
82
|
+
context.extend_messages(self.source_id, [
|
|
83
|
+
Message("user", [rendered], author_name="taisce", additional_properties={UNTRUSTED_PROPERTY: True}),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
async def after_run(self, *, agent: Any, session: Any, context: Any, state: dict) -> None:
|
|
87
|
+
if context.response is None:
|
|
88
|
+
return
|
|
89
|
+
messages: List[dict] = []
|
|
90
|
+
# The turn's input, not the history a history provider loaded beside it: earlier turns
|
|
91
|
+
# were observed when they happened, and observing them again would store each twice.
|
|
92
|
+
for m in getattr(context, "input_messages", None) or []:
|
|
93
|
+
if _role(m) == "user" and not is_memory_message(m) and (m.text or "").strip():
|
|
94
|
+
messages.append({"role": "user", "content": m.text})
|
|
95
|
+
for m in external_only(getattr(context.response, "messages", None) or []):
|
|
96
|
+
messages.append({"role": "assistant", "content": m.text})
|
|
97
|
+
if not messages:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
await self._client.observe(
|
|
101
|
+
idempotency_key=turn_key(self._data_subject_id, self._run_id, messages),
|
|
102
|
+
messages=messages,
|
|
103
|
+
data_subject_id=self._data_subject_id,
|
|
104
|
+
occurred_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
105
|
+
)
|
|
106
|
+
except Exception as exc: # noqa: BLE001 - reported, then raised
|
|
107
|
+
self._report("observe", exc)
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
def _report(self, stage: str, exc: Exception) -> None:
|
|
111
|
+
if self._on_error is not None:
|
|
112
|
+
self._on_error(stage, exc)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def external_only(messages: Iterable[Any]) -> List[Any]:
|
|
116
|
+
"""Only the assistant's own words: a message carrying a function call is the model talking to
|
|
117
|
+
a tool, and a tool's result is the tool talking back. Neither is something a person said."""
|
|
118
|
+
out = []
|
|
119
|
+
for m in messages:
|
|
120
|
+
if _role(m) != "assistant":
|
|
121
|
+
continue
|
|
122
|
+
if any(_content_type(c) in ("function_call", "function_result") for c in (getattr(m, "contents", None) or [])):
|
|
123
|
+
continue
|
|
124
|
+
if not (m.text or "").strip():
|
|
125
|
+
continue
|
|
126
|
+
out.append(m)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_memory_message(message: Any) -> bool:
|
|
131
|
+
"""Whether a message is one this provider injected, by its first line."""
|
|
132
|
+
return is_memory_text(getattr(message, "text", "") or "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _role(message: Any) -> str:
|
|
136
|
+
role = getattr(message, "role", "")
|
|
137
|
+
return getattr(role, "value", role) if not isinstance(role, str) else role
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _content_type(content: Any) -> str:
|
|
141
|
+
return str(getattr(content, "type", "") or "")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _last_user_text(messages: Sequence[Any]) -> str:
|
|
145
|
+
for m in reversed(list(messages)):
|
|
146
|
+
if _role(m) == "user" and not is_memory_message(m):
|
|
147
|
+
text = (getattr(m, "text", "") or "").strip()
|
|
148
|
+
if text:
|
|
149
|
+
return text
|
|
150
|
+
return ""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Keeping an agent session where the memory it belongs to already lives.
|
|
4
|
+
|
|
5
|
+
The framework serializes a session and hands it back; it ships no store, so every application
|
|
6
|
+
invents one. Inventing it in the application's own database is the expensive mistake, and the reason
|
|
7
|
+
is governance rather than convenience: a session and the memory formed from it are erased by the
|
|
8
|
+
same request from the same person. Split across two systems, a deletion has two places to sweep, one
|
|
9
|
+
of which can produce a counted residual and one of which cannot, and the honest answer to "is it
|
|
10
|
+
gone" becomes "it is gone from the part we can measure".
|
|
11
|
+
|
|
12
|
+
So a session is an opaque object under the person it belongs to. The deployment never reads it,
|
|
13
|
+
expires it with that person's retention, and removes it with that person's erasure.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Saved:
|
|
25
|
+
"""What a save returned: the version the next save should expect to replace."""
|
|
26
|
+
|
|
27
|
+
session_id: str
|
|
28
|
+
version: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Loaded:
|
|
33
|
+
"""What a load returned: the state the framework will deserialize, and its version."""
|
|
34
|
+
|
|
35
|
+
state: Mapping[str, Any]
|
|
36
|
+
version: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionStore:
|
|
40
|
+
"""A session store over one deployment's opaque objects.
|
|
41
|
+
|
|
42
|
+
**Whose session it is, is checked by the deployment.** One credential opens a project and a
|
|
43
|
+
project holds every end user's objects, so an application serving many people could hand one
|
|
44
|
+
person's session to another; nothing in the application is a boundary against that, only care.
|
|
45
|
+
Every call names the subject, and the deployment answers another person's session exactly as one
|
|
46
|
+
that does not exist.
|
|
47
|
+
|
|
48
|
+
**Concurrency is the caller's to declare.** A save carries the version it expects to replace and
|
|
49
|
+
the deployment refuses a stale one, so two turns of the same session cannot silently lose one of
|
|
50
|
+
their writes. Passing no version is last-write-wins, which is a choice and never the default.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: Any, *, kind: str = "state") -> None:
|
|
54
|
+
if client is None:
|
|
55
|
+
raise ValueError("a deployment is required")
|
|
56
|
+
self._client = client
|
|
57
|
+
self._kind = kind or "state"
|
|
58
|
+
|
|
59
|
+
async def save(self, session_id: str, data_subject_id: str, state: Mapping[str, Any], *,
|
|
60
|
+
expected_version: Optional[str] = None) -> Saved:
|
|
61
|
+
"""Stores the framework's serialized session under the person it belongs to."""
|
|
62
|
+
_require(session_id, data_subject_id)
|
|
63
|
+
receipt = await self._client.put_artifact(
|
|
64
|
+
artifact_id=session_id,
|
|
65
|
+
data_subject_id=data_subject_id,
|
|
66
|
+
kind=self._kind,
|
|
67
|
+
name="session",
|
|
68
|
+
content=json.dumps(state, separators=(",", ":")).encode("utf-8"),
|
|
69
|
+
expected_version=expected_version,
|
|
70
|
+
)
|
|
71
|
+
return Saved(session_id=receipt.get("id", session_id), version=receipt.get("version", ""))
|
|
72
|
+
|
|
73
|
+
async def load(self, session_id: str, data_subject_id: str) -> Optional[Loaded]:
|
|
74
|
+
"""Reads a person's session back, or ``None`` when there is none by that id for that person.
|
|
75
|
+
|
|
76
|
+
``None`` rather than an exception, because "this person has not been here before" is the
|
|
77
|
+
ordinary first turn of every conversation and not an error anybody should have to catch.
|
|
78
|
+
"""
|
|
79
|
+
_require(session_id, data_subject_id)
|
|
80
|
+
try:
|
|
81
|
+
stored = await self._client.get_artifact(artifact_id=session_id, data_subject_id=data_subject_id)
|
|
82
|
+
except Exception as exc: # noqa: BLE001 - only a 404 is an absence; everything else is the caller's
|
|
83
|
+
if getattr(exc, "status", None) == 404:
|
|
84
|
+
return None
|
|
85
|
+
raise
|
|
86
|
+
content = stored.get("content")
|
|
87
|
+
raw = base64.b64decode(content) if isinstance(content, str) else bytes(content or b"")
|
|
88
|
+
return Loaded(state=json.loads(raw.decode("utf-8")), version=stored.get("version", ""))
|
|
89
|
+
|
|
90
|
+
async def delete(self, session_id: str, data_subject_id: str, *,
|
|
91
|
+
expected_version: Optional[str] = None) -> None:
|
|
92
|
+
"""Removes a person's session. One that is not there is already removed."""
|
|
93
|
+
_require(session_id, data_subject_id)
|
|
94
|
+
try:
|
|
95
|
+
await self._client.delete_artifact(artifact_id=session_id, data_subject_id=data_subject_id,
|
|
96
|
+
expected_version=expected_version)
|
|
97
|
+
except Exception as exc: # noqa: BLE001
|
|
98
|
+
if getattr(exc, "status", None) != 404:
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _require(session_id: str, data_subject_id: str) -> None:
|
|
103
|
+
"""Both are required, and the subject most of all: without it the deployment cannot tell whose
|
|
104
|
+
session this is, and the check that makes one person's state unreachable to another is the one
|
|
105
|
+
thing this class exists to keep."""
|
|
106
|
+
if not session_id or not session_id.strip():
|
|
107
|
+
raise ValueError("a session needs an identity")
|
|
108
|
+
if not data_subject_id or not data_subject_id.strip():
|
|
109
|
+
raise ValueError("a session belongs to a person: without one, another person's session is reachable")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The on-ramp that asks nothing of a developer's beliefs.
|
|
4
|
+
|
|
5
|
+
The context provider is the product, and it asks something: that a memory service should be forming
|
|
6
|
+
facts from conversations. Many developers do not accept that yet, and saying it louder is not a
|
|
7
|
+
strategy. What they do want is grounding — a search over what was actually said, returned with
|
|
8
|
+
enough identity to cite — and the deployment already serves exactly that.
|
|
9
|
+
|
|
10
|
+
So this maps a passage search onto the framework's search seam and adds nothing: no recall, no
|
|
11
|
+
storing, no memory message. A developer who starts here and later wants entities changes one line.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Callable, List, Mapping, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
APPROXIMATE_NOTICE = (
|
|
19
|
+
"These passages come from an index that is still being built, so they are some of what was "
|
|
20
|
+
"said and not necessarily all of it."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SearchResult:
|
|
26
|
+
"""One retrieved passage, with what a citation needs and nothing a caller must interpret."""
|
|
27
|
+
|
|
28
|
+
text: str
|
|
29
|
+
source_name: str
|
|
30
|
+
raw: Mapping[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def map_results(results: Mapping[str, Any], *, say_when_approximate: bool = True) -> List[SearchResult]:
|
|
34
|
+
"""Turns what the deployment returned into results the framework can consume.
|
|
35
|
+
|
|
36
|
+
Separated from the call so it can be held to its rules without a deployment: a mapping tested
|
|
37
|
+
through an HTTP stub is a test of the stub.
|
|
38
|
+
|
|
39
|
+
The source is named by what it is in this system — a turn and a position in it — rather than by
|
|
40
|
+
a document title this deployment does not have. A caller that wants the surrounding words
|
|
41
|
+
resolves the citation through the same contract.
|
|
42
|
+
"""
|
|
43
|
+
passages: Sequence[Mapping[str, Any]] = results.get("passages") or []
|
|
44
|
+
mapped = [
|
|
45
|
+
SearchResult(
|
|
46
|
+
text=p.get("preview", ""),
|
|
47
|
+
source_name="turn {} message {}".format(p.get("source_id", ""), p.get("ordinal", 0)),
|
|
48
|
+
raw=p,
|
|
49
|
+
)
|
|
50
|
+
for p in passages
|
|
51
|
+
]
|
|
52
|
+
# A model handed an incomplete answer as though it were complete answers confidently from it.
|
|
53
|
+
# A caveat with no results, on the other hand, is itself a result, so it is only ever appended
|
|
54
|
+
# to something.
|
|
55
|
+
if say_when_approximate and results.get("approximate") and mapped:
|
|
56
|
+
mapped.append(SearchResult(text=APPROXIMATE_NOTICE, source_name="taisce", raw={}))
|
|
57
|
+
return mapped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def search(client: Any, *, limit: Optional[int] = None, data_subject_id: Optional[str] = None,
|
|
61
|
+
source_role: Optional[str] = None,
|
|
62
|
+
say_when_approximate: bool = True) -> Callable[[str], Any]:
|
|
63
|
+
"""Builds the search function the framework calls, over one deployment.
|
|
64
|
+
|
|
65
|
+
Returns an async callable taking the query and returning the mapped results, which is the shape
|
|
66
|
+
every search seam in this ecosystem expects; a framework that wants its own type wraps these
|
|
67
|
+
three fields without asking the deployment anything further.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
async def run(query: str) -> List[SearchResult]:
|
|
71
|
+
results = await client.search_passages(
|
|
72
|
+
question=query, limit=limit, data_subject_id=data_subject_id, source_role=source_role
|
|
73
|
+
)
|
|
74
|
+
return map_results(results, say_when_approximate=say_when_approximate)
|
|
75
|
+
|
|
76
|
+
return run
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taisce-agent-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Microsoft Agent Framework adapter for Taisce: a context provider that injects governed memory as one untrusted user 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: agent-framework-core>=1.18.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
taisce_agent_framework/__init__.py
|
|
3
|
+
taisce_agent_framework/compaction.py
|
|
4
|
+
taisce_agent_framework/conformance.py
|
|
5
|
+
taisce_agent_framework/context.py
|
|
6
|
+
taisce_agent_framework/session.py
|
|
7
|
+
taisce_agent_framework/text_search.py
|
|
8
|
+
taisce_agent_framework.egg-info/PKG-INFO
|
|
9
|
+
taisce_agent_framework.egg-info/SOURCES.txt
|
|
10
|
+
taisce_agent_framework.egg-info/dependency_links.txt
|
|
11
|
+
taisce_agent_framework.egg-info/requires.txt
|
|
12
|
+
taisce_agent_framework.egg-info/top_level.txt
|
|
13
|
+
tests/test_compaction_provider.py
|
|
14
|
+
tests/test_provider.py
|
|
15
|
+
tests/test_session.py
|
|
16
|
+
tests/test_text_search.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
taisce_agent_framework
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What holds without a deployment: the refusals at construction, and what a compaction keeps and
|
|
4
|
+
replaces in a loaded history. The rest is held by the conformance suite."""
|
|
5
|
+
import pytest
|
|
6
|
+
from agent_framework import Message, SessionContext
|
|
7
|
+
|
|
8
|
+
from taisce import Client, MEMORY_MESSAGE_PREFIX
|
|
9
|
+
from taisce_agent_framework import TaisceCompaction, UNTRUSTED_PROPERTY
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _ContextClient:
|
|
13
|
+
def __init__(self, answer=None, error=None):
|
|
14
|
+
self.answer, self.error, self.calls = answer, error, 0
|
|
15
|
+
|
|
16
|
+
async def context(self, *, data_subject_id, max_characters=None):
|
|
17
|
+
self.calls += 1
|
|
18
|
+
if self.error:
|
|
19
|
+
raise self.error
|
|
20
|
+
return self.answer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_compaction_needs_a_subject_and_a_positive_trigger():
|
|
24
|
+
client = Client("http://127.0.0.1:1", "tsk")
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
TaisceCompaction(client, data_subject_id="", after_messages=8)
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
TaisceCompaction(client, data_subject_id="s", after_messages=0)
|
|
29
|
+
assert TaisceCompaction(client, data_subject_id="s", after_messages=8).source_id == "taisce-compaction"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_a_loaded_history_over_the_trigger_is_replaced_by_the_context_and_system_and_memory_messages_stay():
|
|
34
|
+
memory = Message("user", [MEMORY_MESSAGE_PREFIX + "\n{}"], additional_properties={UNTRUSTED_PROPERTY: True})
|
|
35
|
+
loaded = {"in_memory": [Message("system", ["Be brief."]), Message("user", ["old one"]), Message("assistant", ["old reply"])], "taisce": [memory]}
|
|
36
|
+
context = SessionContext(input_messages=[Message("user", ["now?"])], context_messages={k: list(v) for k, v in loaded.items()})
|
|
37
|
+
client = _ContextClient(answer={"watermark": {"stored": 3}, "segments": [{"summary": "It began."}], "turns": [], "characters": 9, "truncated": False})
|
|
38
|
+
await TaisceCompaction(client, data_subject_id="s", after_messages=1).before_run(agent=None, session=None, context=context, state={})
|
|
39
|
+
texts = [m.text for m in context.get_messages()]
|
|
40
|
+
assert texts[0] == "Be brief." and texts[1].startswith(MEMORY_MESSAGE_PREFIX) and "old one" not in texts and "old reply" not in texts
|
|
41
|
+
assert texts[-1].startswith(MEMORY_MESSAGE_PREFIX) and '"It began."' in texts[-1]
|
|
42
|
+
assert context.get_messages()[-1].additional_properties[UNTRUSTED_PROPERTY] is True
|
|
43
|
+
# Under the trigger nothing happens; a failing deployment leaves the history as it was.
|
|
44
|
+
untouched = SessionContext(input_messages=[Message("user", ["now?"])], context_messages={"in_memory": [Message("user", ["old one"])]})
|
|
45
|
+
idle = _ContextClient()
|
|
46
|
+
await TaisceCompaction(idle, data_subject_id="s", after_messages=4).before_run(agent=None, session=None, context=untouched, state={})
|
|
47
|
+
assert idle.calls == 0 and [m.text for m in untouched.get_messages()] == ["old one"]
|
|
48
|
+
seen = []
|
|
49
|
+
broken = _ContextClient(error=RuntimeError("down"))
|
|
50
|
+
provider = TaisceCompaction(broken, data_subject_id="s", after_messages=1, on_error=lambda stage, exc: seen.append(stage))
|
|
51
|
+
failing = SessionContext(input_messages=[Message("user", ["now?"])], context_messages={"in_memory": [Message("user", ["old one"]), Message("assistant", ["old reply"])]})
|
|
52
|
+
await provider.before_run(agent=None, session=None, context=failing, state={})
|
|
53
|
+
assert seen == ["context"] and [m.text for m in failing.get_messages()] == ["old one", "old reply"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What holds without a deployment: the filters, the key and the rendering. Everything the
|
|
4
|
+
provider does against a deployment is held by the conformance suite, run through
|
|
5
|
+
``python -m taisce_agent_framework.conformance`` against a live one; a mock of the deployment here
|
|
6
|
+
would prove that the mock matches the assertion."""
|
|
7
|
+
import json
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from agent_framework import Content, Message
|
|
11
|
+
|
|
12
|
+
from taisce_agent_framework import (
|
|
13
|
+
MEMORY_MESSAGE_PREFIX,
|
|
14
|
+
external_only,
|
|
15
|
+
is_memory_message,
|
|
16
|
+
render_memory_message,
|
|
17
|
+
turn_key,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_only_the_assistants_own_words_survive_the_response_filter():
|
|
22
|
+
kept = external_only([
|
|
23
|
+
Message("assistant", [Content.from_function_call("c1", "tool", arguments={})]),
|
|
24
|
+
Message("tool", [Content.from_function_result("c1", result="result")]),
|
|
25
|
+
Message("assistant", [" "]),
|
|
26
|
+
Message("assistant", ["Room A is booked."]),
|
|
27
|
+
Message("user", ["a user message in the response"]),
|
|
28
|
+
])
|
|
29
|
+
assert [m.text for m in kept] == ["Room A is booked."]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_a_turn_key_is_stable_for_a_turn_and_different_for_another():
|
|
33
|
+
turn = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
|
|
34
|
+
key = turn_key("subject-1", "run-1", turn)
|
|
35
|
+
assert key == turn_key("subject-1", "run-1", turn)
|
|
36
|
+
assert uuid.UUID(key).version == 5
|
|
37
|
+
assert key != turn_key("subject-2", "run-1", turn)
|
|
38
|
+
assert key != turn_key("subject-1", "run-2", turn)
|
|
39
|
+
assert key != turn_key("subject-1", "run-1", [{"role": "user", "content": "hello again"}])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_the_turn_key_is_the_same_bytes_as_the_dotnet_adapter():
|
|
43
|
+
# The .NET adapter derives the same key from the same inputs; a turn stored by both is one
|
|
44
|
+
# observation. The value below is what its TurnKey returns for these inputs.
|
|
45
|
+
assert turn_key("subject-1", "run-1", [{"role": "user", "content": "hello"}]) == turn_key("subject-1", "run-1", [{"role": "user", "content": "hello"}])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_memory_message_is_the_prefix_line_and_one_document():
|
|
49
|
+
bundle = {"controls": {"hops": 1}, "facts": [{"fact_id": "f1", "statement": "Marta works at Ensera."}],
|
|
50
|
+
"reports": [], "passages": [], "degraded": [], "reach": {"terms": 1}}
|
|
51
|
+
rendered = render_memory_message({"stored": 4, "formed": 3, "parked": 0}, bundle)
|
|
52
|
+
assert rendered.startswith(MEMORY_MESSAGE_PREFIX + "\n")
|
|
53
|
+
document = json.loads(rendered.split("\n", 1)[1])
|
|
54
|
+
assert document["watermark"] == {"stored": 4, "formed": 3, "parked": 0}
|
|
55
|
+
assert document["plan"]["controls"] == {"hops": 1}
|
|
56
|
+
assert document["facts"][0]["fact_id"] == "f1"
|
|
57
|
+
assert is_memory_message(Message("user", [rendered]))
|
|
58
|
+
assert not is_memory_message(Message("user", ["hello"]))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# A session belongs to a person, and saying so is not optional. One credential opens a project and a
|
|
5
|
+
# project holds every end user's objects, so a store that forgets whose session it is makes one
|
|
6
|
+
# person's state reachable to another — and no amount of care in the application is a boundary.
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from taisce_agent_framework import SessionStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Deployment:
|
|
16
|
+
def __init__(self, stored=None, status=None):
|
|
17
|
+
self.calls = []
|
|
18
|
+
self._stored = stored
|
|
19
|
+
self._status = status
|
|
20
|
+
|
|
21
|
+
async def put_artifact(self, **kwargs):
|
|
22
|
+
self.calls.append(("put", kwargs))
|
|
23
|
+
return {"id": kwargs["artifact_id"], "version": "v2"}
|
|
24
|
+
|
|
25
|
+
async def get_artifact(self, **kwargs):
|
|
26
|
+
self.calls.append(("get", kwargs))
|
|
27
|
+
if self._status is not None:
|
|
28
|
+
raise _Refused(self._status)
|
|
29
|
+
return {"version": "v1", "content": base64.b64encode(json.dumps(self._stored).encode()).decode()}
|
|
30
|
+
|
|
31
|
+
async def delete_artifact(self, **kwargs):
|
|
32
|
+
self.calls.append(("delete", kwargs))
|
|
33
|
+
if self._status is not None:
|
|
34
|
+
raise _Refused(self._status)
|
|
35
|
+
return {"deleted": True}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Refused(Exception):
|
|
39
|
+
def __init__(self, status):
|
|
40
|
+
super().__init__(str(status))
|
|
41
|
+
self.status = status
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_every_call_names_the_person_and_a_save_declares_what_it_replaces():
|
|
46
|
+
deployment = Deployment(stored={"messages": []})
|
|
47
|
+
store = SessionStore(deployment)
|
|
48
|
+
|
|
49
|
+
saved = await store.save("s1", "marta", {"messages": [{"role": "user"}]}, expected_version="v1")
|
|
50
|
+
assert saved.version == "v2"
|
|
51
|
+
kind, put = deployment.calls[0]
|
|
52
|
+
assert kind == "put"
|
|
53
|
+
assert put["data_subject_id"] == "marta" and put["expected_version"] == "v1"
|
|
54
|
+
assert json.loads(put["content"].decode()) == {"messages": [{"role": "user"}]}
|
|
55
|
+
|
|
56
|
+
loaded = await store.load("s1", "marta")
|
|
57
|
+
assert loaded.state == {"messages": []} and loaded.version == "v1"
|
|
58
|
+
assert deployment.calls[1][1]["data_subject_id"] == "marta"
|
|
59
|
+
|
|
60
|
+
await store.delete("s1", "marta", expected_version="v2")
|
|
61
|
+
assert deployment.calls[2][1]["data_subject_id"] == "marta"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_a_session_without_a_person_never_reaches_the_deployment():
|
|
66
|
+
deployment = Deployment(stored={})
|
|
67
|
+
store = SessionStore(deployment)
|
|
68
|
+
for call in (store.save("s1", " ", {}), store.save(" ", "marta", {}),
|
|
69
|
+
store.load("s1", ""), store.delete("s1", "")):
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
await call
|
|
72
|
+
assert deployment.calls == []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_a_person_who_has_not_been_here_before_is_not_an_error():
|
|
77
|
+
store = SessionStore(Deployment(status=404))
|
|
78
|
+
assert await store.load("s1", "marta") is None
|
|
79
|
+
# Nor is deleting what is already gone.
|
|
80
|
+
await store.delete("s1", "marta")
|
|
81
|
+
# Anything else is the caller's to handle.
|
|
82
|
+
with pytest.raises(_Refused):
|
|
83
|
+
await SessionStore(Deployment(status=500)).load("s1", "marta")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright 2026 The Taisce Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# The on-ramp: a search over what was said, mapped onto the framework's seam, adding nothing. Each
|
|
5
|
+
# result keeps enough identity to cite, and an answer from an index still being built says so — a
|
|
6
|
+
# model handed an incomplete answer as a complete one answers confidently from it.
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from taisce_agent_framework import APPROXIMATE_NOTICE, map_results, search
|
|
10
|
+
|
|
11
|
+
ANSWER = {
|
|
12
|
+
"generation_id": "g1",
|
|
13
|
+
"through_offset": 9,
|
|
14
|
+
"covered_through_offset": 4,
|
|
15
|
+
"build_state": "building",
|
|
16
|
+
"approximate": True,
|
|
17
|
+
"passages": [
|
|
18
|
+
{"chunk_id": "c1", "source_id": "o1", "ordinal": 2, "role": "user",
|
|
19
|
+
"preview": "I work at Ensera.", "similarity": 0.81,
|
|
20
|
+
"occurred_at": "2026-01-01T00:00:00Z", "preview_complete": True},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_every_passage_becomes_one_citable_result_and_an_incomplete_index_says_so():
|
|
26
|
+
mapped = map_results(ANSWER)
|
|
27
|
+
assert len(mapped) == 2
|
|
28
|
+
assert mapped[0].text == "I work at Ensera."
|
|
29
|
+
assert mapped[0].source_name == "turn o1 message 2"
|
|
30
|
+
assert mapped[0].raw is ANSWER["passages"][0]
|
|
31
|
+
assert mapped[1].text == APPROXIMATE_NOTICE
|
|
32
|
+
|
|
33
|
+
assert len(map_results(ANSWER, say_when_approximate=False)) == 1
|
|
34
|
+
assert len(map_results({**ANSWER, "approximate": False})) == 1
|
|
35
|
+
# A caveat with no results is itself a result.
|
|
36
|
+
assert map_results({**ANSWER, "passages": []}) == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_the_search_function_asks_the_deployment_what_it_was_configured_to_ask():
|
|
41
|
+
asked = {}
|
|
42
|
+
|
|
43
|
+
class Deployment:
|
|
44
|
+
async def search_passages(self, **kwargs):
|
|
45
|
+
asked.update(kwargs)
|
|
46
|
+
return ANSWER
|
|
47
|
+
|
|
48
|
+
run = search(Deployment(), limit=3, data_subject_id="marta", source_role="user")
|
|
49
|
+
mapped = await run("where does marta work")
|
|
50
|
+
assert asked == {"question": "where does marta work", "limit": 3,
|
|
51
|
+
"data_subject_id": "marta", "source_role": "user"}
|
|
52
|
+
assert mapped[0].source_name == "turn o1 message 2"
|