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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ taisce>=0.1.0
2
+ agent-framework-core>=1.18.0
@@ -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"