agenticexperience 0.1.0a14__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.
- agenticexperience-0.1.0a14/.gitignore +9 -0
- agenticexperience-0.1.0a14/PKG-INFO +334 -0
- agenticexperience-0.1.0a14/README.md +312 -0
- agenticexperience-0.1.0a14/pyproject.toml +77 -0
- agenticexperience-0.1.0a14/samples/quickstart.expected.txt +24 -0
- agenticexperience-0.1.0a14/samples/quickstart.py +131 -0
- agenticexperience-0.1.0a14/src/agentexperience/__init__.py +108 -0
- agenticexperience-0.1.0a14/src/agentexperience/_text.py +270 -0
- agenticexperience-0.1.0a14/src/agentexperience/block.py +702 -0
- agenticexperience-0.1.0a14/src/agentexperience/capture.py +200 -0
- agenticexperience-0.1.0a14/src/agentexperience/classifiers.py +273 -0
- agenticexperience-0.1.0a14/src/agentexperience/encoding.py +205 -0
- agenticexperience-0.1.0a14/src/agentexperience/error_class.py +129 -0
- agenticexperience-0.1.0a14/src/agentexperience/finalize.py +137 -0
- agenticexperience-0.1.0a14/src/agentexperience/identity.py +158 -0
- agenticexperience-0.1.0a14/src/agentexperience/ids.py +52 -0
- agenticexperience-0.1.0a14/src/agentexperience/inmemory.py +275 -0
- agenticexperience-0.1.0a14/src/agentexperience/limits.py +40 -0
- agenticexperience-0.1.0a14/src/agentexperience/maf.py +1116 -0
- agenticexperience-0.1.0a14/src/agentexperience/postgres.py +363 -0
- agenticexperience-0.1.0a14/src/agentexperience/py.typed +0 -0
- agenticexperience-0.1.0a14/src/agentexperience/records.py +548 -0
- agenticexperience-0.1.0a14/src/agentexperience/reflector.py +324 -0
- agenticexperience-0.1.0a14/src/agentexperience/sanitization.py +161 -0
- agenticexperience-0.1.0a14/src/agentexperience/search.py +225 -0
- agenticexperience-0.1.0a14/src/agentexperience/sql/declare-bounds.sql +12 -0
- agenticexperience-0.1.0a14/src/agentexperience/sql/record-injection.sql +25 -0
- agenticexperience-0.1.0a14/src/agentexperience/sql/search-experience-text.sql +29 -0
- agenticexperience-0.1.0a14/src/agentexperience/task_text.py +190 -0
- agenticexperience-0.1.0a14/src/agentexperience/verification.py +130 -0
- agenticexperience-0.1.0a14/src/agentexperience/verifiers.py +350 -0
- agenticexperience-0.1.0a14/tests/_contract.py +194 -0
- agenticexperience-0.1.0a14/tests/_maf.py +86 -0
- agenticexperience-0.1.0a14/tests/_postgres.py +203 -0
- agenticexperience-0.1.0a14/tests/_sync.py +18 -0
- agenticexperience-0.1.0a14/tests/conftest.py +31 -0
- agenticexperience-0.1.0a14/tests/cross-language/python-agent-records.json +246 -0
- agenticexperience-0.1.0a14/tests/cross-language/python-agent-rows.json +97 -0
- agenticexperience-0.1.0a14/tests/test_classifiers.py +205 -0
- agenticexperience-0.1.0a14/tests/test_contract.py +382 -0
- agenticexperience-0.1.0a14/tests/test_cross_language.py +368 -0
- agenticexperience-0.1.0a14/tests/test_identity_and_store.py +146 -0
- agenticexperience-0.1.0a14/tests/test_maf.py +1100 -0
- agenticexperience-0.1.0a14/tests/test_parity.py +443 -0
- agenticexperience-0.1.0a14/tests/test_postgres.py +409 -0
- agenticexperience-0.1.0a14/tests/test_quickstart.py +39 -0
- agenticexperience-0.1.0a14/tests/test_units.py +132 -0
- agenticexperience-0.1.0a14/tests/test_verifiers.py +265 -0
- agenticexperience-0.1.0a14/uv.lock +1795 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agenticexperience
|
|
3
|
+
Version: 0.1.0a14
|
|
4
|
+
Summary: Verified agent experience for Python: a pure-Python core that passes the AgentExperience.NET language-neutral contract 1.5.
|
|
5
|
+
Project-URL: Repository, https://github.com/fabbrik/AgentExperience.NET
|
|
6
|
+
Author: AgentExperience contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: maf
|
|
18
|
+
Requires-Dist: agent-framework-core>=1.0.0; extra == 'maf'
|
|
19
|
+
Provides-Extra: postgres
|
|
20
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# AgentExperience for Python
|
|
24
|
+
|
|
25
|
+
A pure-Python core of AgentExperience that implements the [language-neutral contract](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/README.md), so a
|
|
26
|
+
lesson learned by a Python agent is stored in the format the .NET library reads, and the other way round.
|
|
27
|
+
|
|
28
|
+
**Status: alpha, contract 1.5, with a Microsoft Agent Framework integration (the `maf` extra).** It passes every
|
|
29
|
+
fixture of contract 1.5. It is published to PyPI as `agenticexperience` (the import name stays `agentexperience`), with every
|
|
30
|
+
release of this repository and at the same version as the .NET packages: .NET `0.1.0-preview.N` is
|
|
31
|
+
`agenticexperience` `0.1.0aN` (a PEP 440 alpha; while every release is an alpha, pip and uv install the newest one).
|
|
32
|
+
Like the .NET packages, it claims no production readiness, and its API may change between alphas.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install "agenticexperience[maf]" # with Microsoft Agent Framework
|
|
36
|
+
pip install "agenticexperience[postgres]" # with the PostgreSQL store
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start with Microsoft Agent Framework
|
|
40
|
+
|
|
41
|
+
Install with the `maf` extra (`pip install "agenticexperience[maf]"`, which brings `agent-framework-core` 1.0.0 or
|
|
42
|
+
later, or `pip install ".[maf]"` from this directory); bring `client` (any MAF chat client) and `run_tests` (your test
|
|
43
|
+
run, returning text such as `exit=0`). Development and tests only: see [In production](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/README.md#in-production). After the two imports, five lines: one call that
|
|
44
|
+
wires the agent (three lines), and two runs.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agent_framework import Agent
|
|
48
|
+
from agentexperience.maf import InMemoryExperienceStore, development_identity, exit_code, tool_succeeded, use_agent_experience
|
|
49
|
+
|
|
50
|
+
agent = use_agent_experience(Agent(client=client, name="triage-ticket", tools=[run_tests]),
|
|
51
|
+
store=InMemoryExperienceStore(), identity=development_identity(), # one local identity: development and tests only
|
|
52
|
+
verify=tool_succeeded("run_tests"), classify_tool_result=exit_code("run_tests")) # your check, never the model
|
|
53
|
+
await agent.run("Ticket #4812: a refund is stuck on a lock. Triage it.")
|
|
54
|
+
await agent.run("Ticket #4977: a refund is stuck on a lock again. Triage it.") # gets the lesson
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The second run's model is handed the Historical Reference block, as one user-role message, which names both of the
|
|
58
|
+
first run's test runs:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
Lesson: Verified after 1 attempt. Worked: attempt 0. Checks: [run_tests].
|
|
62
|
+
Tried:
|
|
63
|
+
- attempt 0: run_tests [failed: exit 3], run_tests [returned] → completed
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
[`samples/quickstart.py`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/samples/quickstart.py) is based on this snippet and runs it with no key and no network:
|
|
67
|
+
`uv run python samples/quickstart.py`. It differs in what a runnable, repeatable demo needs: the model is a scripted
|
|
68
|
+
stand-in (`ScriptedModel`, which calls `run_tests` until a call returns `exit=0`), `run_tests` is a small class whose
|
|
69
|
+
first call of each run returns `exit=3`, the identity names its application (`development_identity(application=
|
|
70
|
+
"quickstart")`), the recorded environment is fixed (`environment=...`) so the output is the same on every machine, and it
|
|
71
|
+
prints each run and the block. Its output is checked in as
|
|
72
|
+
[`samples/quickstart.expected.txt`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/samples/quickstart.expected.txt), and a test compares them.
|
|
73
|
+
|
|
74
|
+
### What the one call wires
|
|
75
|
+
|
|
76
|
+
- **Capture**: agent-run middleware and function middleware. Each non-streaming invocation is one run of one attempt:
|
|
77
|
+
every tool call with its arguments (sanitized when stored: only allowlisted values are kept, secrets redacted), its
|
|
78
|
+
result, and its error. A tool that raises is recorded by the exception's type name only, never its message. A tool
|
|
79
|
+
that reports failure in its return value is recorded as failed when `classify_tool_result` names a class for it;
|
|
80
|
+
a class that is not a single error-class token is recorded as `ToolReportedError`. When the invocation ends, `verify`
|
|
81
|
+
decides from your own facts; a verification is finalized through the core (the deterministic lesson, the derived
|
|
82
|
+
IDs) and stored. Without `verify` nothing is stored. A streaming invocation is injected but not captured.
|
|
83
|
+
- **Injection**: a context provider. It derives the task text from the invocation's messages (the contract's rule),
|
|
84
|
+
searches the store in the identity's scope, ranks with the contract's weights, renders the block with
|
|
85
|
+
`block_options` (the contract's options and limits), and adds it as one user-role message stamped
|
|
86
|
+
`additional_properties["AgentExperience.HistoricalReference"] = True` (`message_role="system"` sends it as a system
|
|
87
|
+
message). An agent that already holds an `ExperienceContextProvider` gets no second one; `inject=False` captures
|
|
88
|
+
without injecting.
|
|
89
|
+
- **Injection tracking** (contract 1.4). Once a block is handed over, a store with `record_injection` (the PostgreSQL
|
|
90
|
+
and in-memory stores) is told which records it delivered, so the .NET lessons view shows when each lesson was last
|
|
91
|
+
injected, by a Python agent too. It runs in a background task on the event loop, bounded by
|
|
92
|
+
`injection_record_timeout` (5 seconds, .NET's), and never delays or fails the invocation; `on_injection_recorded`
|
|
93
|
+
reports how it ended (`Recorded`, `NotTracked`, `NotRecorded` or `OutcomeUnknown`, as .NET's `record_injection`
|
|
94
|
+
operation). `await provider.wait_for_injection_records()` awaits the ones still running, for a host shutting down.
|
|
95
|
+
- **The task text, and where history sits.** Capture records the task text of the invocation's own input, as .NET's
|
|
96
|
+
agent-level capture does. Injection derives it from the messages the provider sees: the new input, plus the
|
|
97
|
+
replayed history of a history provider that runs *before* it. The provider is appended to the agent's
|
|
98
|
+
`context_providers`, and the `InMemoryHistoryProvider` MAF adds by itself for a session is appended after it, so by
|
|
99
|
+
default injection sees only the new input, like .NET's agent-level wiring; add a history provider to the agent
|
|
100
|
+
before calling `use_agent_experience` and a short follow-up ("and retry") is searched joined after the previous
|
|
101
|
+
request, like .NET's `ChatClientAgent`-level provider. The stored task summary is then the follow-up alone. Either
|
|
102
|
+
way each request carries exactly one block: a replayed message stamped as the block never counts as a request.
|
|
103
|
+
- **The run's end, as in .NET.** An invocation that raises ends its run `Failed` (the error is the exception's type
|
|
104
|
+
name) and one that is cancelled ends it `Cancelled`; `verify` still decides whether that run is stored, and the
|
|
105
|
+
exception or cancellation then reaches the caller unchanged. A middleware raising `MiddlewareTermination` ends the
|
|
106
|
+
run `Completed` with the result it set. Finalization is bounded by `finalization_timeout` (5 seconds, .NET's
|
|
107
|
+
`FinalizationTimeout`), on its own clock: past it the work is abandoned and reported, so a cancellation is never held
|
|
108
|
+
up longer than that.
|
|
109
|
+
- **Nothing raises into the agent.** A store, identity, classifier or verifier failure means no block or no stored
|
|
110
|
+
record, reported through `on_capture_failure` or `on_injection_failure` (the reason never quotes record content);
|
|
111
|
+
a failed injection record is reported through `on_injection_recorded`.
|
|
112
|
+
Only the invocation's own cancellation and the tool's own exceptions propagate, unchanged.
|
|
113
|
+
|
|
114
|
+
**Verifiers** (`agentexperience.verifiers`, re-exported by `agentexperience.maf`) have the semantics of .NET's
|
|
115
|
+
`ExperienceVerifiers`: `tool_succeeded(tool)` (the last call to the tool decides), `check(id, fn)` (your own check:
|
|
116
|
+
`True`, `False`, or `None` for "do not store"), `approval(id, fn)` (a person's decision, by an opaque approver ID),
|
|
117
|
+
`all_of(...)` (every check; stops at the first `None`) and `any_of(...)` (the first that passes). **Classifiers**
|
|
118
|
+
(`agentexperience.classifiers`) have the semantics of `ExperienceToolResultClassifiers`: `exit_code(*tools)` and
|
|
119
|
+
`http_status(*tools)` read a JSON object's top-level fields, else the last match in the text.
|
|
120
|
+
|
|
121
|
+
### In production
|
|
122
|
+
|
|
123
|
+
The identity must come from your own authentication, never from model output: pass a function of an
|
|
124
|
+
`IdentityContext` (the agent and the session) that returns an `ExperienceIdentity(principal, scope)`, or `None` to
|
|
125
|
+
leave an invocation neither captured nor injected. `development_identity()` runs everything as one fixed caller in
|
|
126
|
+
one fixed scope, so it refuses outside Development, Test or Testing: the Python-native `AGENTEXPERIENCE_ENVIRONMENT`
|
|
127
|
+
variable is read first, then, by the .NET library's rule, `DOTNET_ENVIRONMENT`, else `ASPNETCORE_ENVIRONMENT`; with
|
|
128
|
+
none set it is allowed (pass `environment_name=` when your host has its own name, and it is kept with the identity).
|
|
129
|
+
A resolver that returns a development identity is checked on every invocation: outside development the invocation is
|
|
130
|
+
reported (`ResolveIdentity`) and runs with no memory. The store is `PostgresExperienceStore` (the `postgres` extra, below),
|
|
131
|
+
which shares lessons with .NET agents on the same database.
|
|
132
|
+
|
|
133
|
+
## What it does
|
|
134
|
+
|
|
135
|
+
- **Capture and finalize.** `RunCapture` sanitizes a run as it is recorded: secret-named fields are redacted, argument
|
|
136
|
+
values are kept only when allowlisted, and the default limits apply. `finalize` aggregates the host's verification,
|
|
137
|
+
writes the deterministic lesson for a verified run, and returns the stored record (`Validated`, or `Quarantined`
|
|
138
|
+
without a lesson) and its initial lifecycle event. The IDs are derived, so a retry derives the same ones.
|
|
139
|
+
- **The injected block.** `render_block` turns ranked records into the Historical Reference block, byte for byte as
|
|
140
|
+
the .NET adapter writes it, in the compact or verbose layout, within the record and byte limits, with record text
|
|
141
|
+
neutralized.
|
|
142
|
+
- **The task text.** `derive_task_text` derives the text retrieval searches with from an invocation's messages.
|
|
143
|
+
- **The error class.** `error_class` reduces a captured error to its short class.
|
|
144
|
+
- **PostgreSQL** (the `postgres` extra, psycopg 3). `PostgresExperienceStore` writes a finalized record and its
|
|
145
|
+
initial event as the application role, reads records back, runs the contract's text search, and records injections
|
|
146
|
+
(`record_injection`, contract 1.4). It ships the contract's SQL files and runs them as written. `rank` applies the
|
|
147
|
+
contract's safety checks, truncation and ranking to the rows the search returns. The store needs a schema the .NET
|
|
148
|
+
migrator applied, with row-level security on; it never creates or alters a schema object. The application role's
|
|
149
|
+
privileges are the .NET privileges call's, or the contract's two files, in this order:
|
|
150
|
+
`sql/application-role-privileges.sql`, then `sql/application-role-privileges-1.4.sql` (without the second, every
|
|
151
|
+
injection is still made and its record is reported `NotRecorded`).
|
|
152
|
+
|
|
153
|
+
It does nothing outside contract 1.x: no grants, crypto-shredding, provenance signing or vectors, and no lifecycle
|
|
154
|
+
after finalization (reuse evidence, reinforcement, contest, revocation). It writes no logs at all, so no record
|
|
155
|
+
content is ever logged.
|
|
156
|
+
|
|
157
|
+
The pieces that need no run or database:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from agentexperience import Message, MessagePart, derive_task_text, error_class
|
|
161
|
+
|
|
162
|
+
messages = [
|
|
163
|
+
Message("user", [MessagePart("text", "Deploy service X to prod")]),
|
|
164
|
+
Message("user", [MessagePart("text", "and retry")]),
|
|
165
|
+
]
|
|
166
|
+
print(derive_task_text(messages)) # Deploy service X to prod — and retry
|
|
167
|
+
print(error_class("System.TimeoutException: The operation has timed out.")) # TimeoutException
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### End to end
|
|
171
|
+
|
|
172
|
+
Capture a run, finalize it, store it, search for it, rank the rows and render the block. Steps 1 and 2 need nothing
|
|
173
|
+
else; step 3 needs the `postgres` extra (`pip install "agenticexperience[postgres]"`) and a database
|
|
174
|
+
the .NET migrator set up.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import os
|
|
178
|
+
import uuid
|
|
179
|
+
|
|
180
|
+
from agentexperience import (
|
|
181
|
+
AuthorizationBounds,
|
|
182
|
+
BlockOptions,
|
|
183
|
+
ClosedVerificationRound,
|
|
184
|
+
Duration,
|
|
185
|
+
EnvironmentFingerprint,
|
|
186
|
+
Evidence,
|
|
187
|
+
Instant,
|
|
188
|
+
Provenance,
|
|
189
|
+
RawAttempt,
|
|
190
|
+
RawToolCall,
|
|
191
|
+
RequiredCheck,
|
|
192
|
+
RunCapture,
|
|
193
|
+
Sanitization,
|
|
194
|
+
Scope,
|
|
195
|
+
SearchParameters,
|
|
196
|
+
finalize,
|
|
197
|
+
rank,
|
|
198
|
+
render_block,
|
|
199
|
+
)
|
|
200
|
+
from agentexperience.postgres import PostgresExperienceStore
|
|
201
|
+
|
|
202
|
+
scope = Scope("acme", "support-app", "refunds", agent_id="refund-agent")
|
|
203
|
+
start = Instant.parse("2026-03-02T09:00:00+00:00")
|
|
204
|
+
|
|
205
|
+
# 1. Capture a run as it happens. Only the allowlisted argument "strategy" keeps its value.
|
|
206
|
+
capture = RunCapture.start(
|
|
207
|
+
run_id=uuid.uuid4(),
|
|
208
|
+
task_id="refund-stuck-pending",
|
|
209
|
+
task_description="Refund ticket 42 is stuck in pending",
|
|
210
|
+
scope=scope,
|
|
211
|
+
environment=EnvironmentFingerprint("host-1", "python-3.13", "linux", "1.0.0", {"region": "eu-west"}),
|
|
212
|
+
provenance=Provenance("my-agent", "1", start, None),
|
|
213
|
+
started_at=start,
|
|
214
|
+
sanitization=Sanitization.allowing(["strategy"]),
|
|
215
|
+
)
|
|
216
|
+
second = Duration.from_seconds(1)
|
|
217
|
+
capture.append_attempt(
|
|
218
|
+
RawAttempt(
|
|
219
|
+
uuid.uuid4(),
|
|
220
|
+
start,
|
|
221
|
+
second,
|
|
222
|
+
[
|
|
223
|
+
RawToolCall(
|
|
224
|
+
uuid.uuid4(),
|
|
225
|
+
"run_refund_check",
|
|
226
|
+
{"strategy": "retry"},
|
|
227
|
+
start,
|
|
228
|
+
second,
|
|
229
|
+
error="InvalidOperationException: the refund lock is held",
|
|
230
|
+
)
|
|
231
|
+
],
|
|
232
|
+
error="InvalidOperationException: the refund lock is held",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
capture.append_attempt(
|
|
236
|
+
RawAttempt(
|
|
237
|
+
uuid.uuid4(),
|
|
238
|
+
start.add(second),
|
|
239
|
+
second,
|
|
240
|
+
[RawToolCall(uuid.uuid4(), "run_refund_check", {"strategy": "wait-for-lock"}, start, second, result="issued")],
|
|
241
|
+
result="Refund issued.",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
run = capture.complete("Completed", start.add(Duration.from_seconds(20)))
|
|
245
|
+
|
|
246
|
+
# 2. Finalize against the host's verification: one passing check makes it a Validated lesson.
|
|
247
|
+
round_ = ClosedVerificationRound(uuid.uuid4(), "rev-1")
|
|
248
|
+
finished = start.add(Duration.from_seconds(30))
|
|
249
|
+
finalized = finalize(
|
|
250
|
+
run,
|
|
251
|
+
closed_round=round_,
|
|
252
|
+
required_checks=[RequiredCheck("refund-issued", "integration-test")],
|
|
253
|
+
evidence=[
|
|
254
|
+
Evidence(
|
|
255
|
+
uuid.uuid4(), round_.round_id, "rev-1", "refund-issued", "integration-test", "Pass", "ci", None, finished
|
|
256
|
+
)
|
|
257
|
+
],
|
|
258
|
+
current_artifact_revision="rev-1",
|
|
259
|
+
finalized_at=finished,
|
|
260
|
+
)
|
|
261
|
+
print(finalized.record.status, "-", finalized.record.reflection.lesson)
|
|
262
|
+
|
|
263
|
+
# 3. Store it, search it and render the block (needs the postgres extra and a schema the .NET migrator applied).
|
|
264
|
+
store = PostgresExperienceStore(os.environ["AGENTEXPERIENCE_CONNINFO"]) # libpq string, as the application role
|
|
265
|
+
bounds = AuthorizationBounds.of(scope)
|
|
266
|
+
store.insert_finalized(bounds, finalized, actor="host-principal")
|
|
267
|
+
rows = store.search(bounds, SearchParameters(scope, "refund ticket stuck in pending"))
|
|
268
|
+
ranked = rank(rows, scope, now=finished)
|
|
269
|
+
print(render_block(ranked.ranked, BlockOptions(rendering="Compact")).text)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Running the tests
|
|
273
|
+
|
|
274
|
+
[uv](https://docs.astral.sh/uv/) manages the environment. From this directory:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
uv sync
|
|
278
|
+
uv run ruff check && uv run ruff format --check
|
|
279
|
+
uv run mypy --strict src tests
|
|
280
|
+
uv run pytest
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`uv run pytest` runs the unit tests and the conformance runner (`tests/test_contract.py`), which loads every fixture
|
|
284
|
+
family from [`../contract/fixtures`](https://github.com/fabbrik/AgentExperience.NET/tree/main/contract/fixtures) and compares this package's output exactly: blocks and task
|
|
285
|
+
text byte for byte, JSON by value. It validates every record against the contract's JSON Schema, checks every
|
|
286
|
+
contract file against `contract/lock.json`, and fails with a clear message if the lock names a contract version this
|
|
287
|
+
package does not support.
|
|
288
|
+
|
|
289
|
+
The development environment includes `agent-framework-core`, so `uv run pytest` also runs the Agent Framework tests:
|
|
290
|
+
`tests/test_maf.py` drives a real `agent_framework.Agent` with a scripted chat client (capture, classification,
|
|
291
|
+
verification, storage, injection, failures, sessions, streaming); `tests/test_verifiers.py` and
|
|
292
|
+
`tests/test_classifiers.py` port the cases of the .NET verifier and classifier tests; and `tests/test_quickstart.py`
|
|
293
|
+
runs the sample (`AGENTEXPERIENCE_QUICKSTART_GOLDEN_UPDATE=1` rewrites its expected output on purpose).
|
|
294
|
+
|
|
295
|
+
**The cross-language proof** (`tests/test_cross_language.py`):
|
|
296
|
+
|
|
297
|
+
- *Python writes, .NET reads.* A Python agent on a fixed clock and fixed identifiers stores two runs, and the records
|
|
298
|
+
must be exactly [`tests/cross-language/python-agent-records.json`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/tests/cross-language/python-agent-records.json)
|
|
299
|
+
(checked in memory, and written to and read back from PostgreSQL in the PostgreSQL leg). In the PostgreSQL leg the
|
|
300
|
+
rows the Python store wrote, every column it writes in PostgreSQL's own text form, must be exactly
|
|
301
|
+
[`tests/cross-language/python-agent-rows.json`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/tests/cross-language/python-agent-rows.json).
|
|
302
|
+
The .NET contract tests' `PythonAgentRecordsPostgresRoundTripTests` insert those raw rows into a schema the .NET
|
|
303
|
+
migrator applied, each value cast from its text by PostgreSQL, read every record back through the .NET PostgreSQL
|
|
304
|
+
store (it must be `python-agent-records.json`), check each initial event is the one .NET finalization derives,
|
|
305
|
+
compare every column with what the .NET store writes for the same record, read the injection the second run
|
|
306
|
+
recorded through the .NET lessons view and compare its row with what the .NET store records for it, and find the
|
|
307
|
+
Python agent's lessons through the .NET retrieval. So a column the Python store wrote in its own encoding fails there, even when the
|
|
308
|
+
Python reader undoes it. `AGENTEXPERIENCE_CROSS_LANGUAGE_UPDATE=1` rewrites both files on purpose (the rows in the
|
|
309
|
+
PostgreSQL leg).
|
|
310
|
+
- *.NET writes, Python reads.* The records the .NET library finalized for the contract's finalize fixtures (generated
|
|
311
|
+
from .NET and checked against it on every build) are written into PostgreSQL, and a Python agent on their scope is
|
|
312
|
+
injected with the one its task matches. No .NET process runs at test time: the fixtures are the .NET-written
|
|
313
|
+
records.
|
|
314
|
+
|
|
315
|
+
The PostgreSQL tests (`tests/test_postgres.py` and the cross-language tests, marker `postgres`) write each finalize fixture through the store and
|
|
316
|
+
read it back row for row, run every search fixture, then rank, and run every record-injection fixture. Each test database is migrated with the .NET
|
|
317
|
+
migrator's own scripts from
|
|
318
|
+
[`src/AgentExperience.Storage.Postgres/Migrations`](https://github.com/fabbrik/AgentExperience.NET/tree/main/src/AgentExperience.Storage.Postgres/Migrations), in order;
|
|
319
|
+
the application role's privileges are the contract's
|
|
320
|
+
[`sql/application-role-privileges.sql`](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/sql/application-role-privileges.sql) then
|
|
321
|
+
[`sql/application-role-privileges-1.4.sql`](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/sql/application-role-privileges-1.4.sql), which a .NET test keeps
|
|
322
|
+
identical to the statements the .NET migrator runs. The server is a Testcontainers `postgres` container (`AGENTEXPERIENCE_POSTGRES_MAJOR`, 16 by default), or
|
|
323
|
+
the server libpq's `PGHOST`, `PGPORT`, `PGUSER` and `PGPASSWORD` name (a superuser). With neither, they are skipped.
|
|
324
|
+
On a Mac with Rancher Desktop or Colima, Testcontainers may need:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock TESTCONTAINERS_RYUK_DISABLED=true uv run pytest -m postgres
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
CI runs the `python` job on CPython 3.10 to 3.14 (lint, `mypy --strict`, the tests, the Agent Framework tests again
|
|
331
|
+
on agent-framework-core 1.0.0, the `maf` extra's floor, and an import of the built wheel from a clean environment,
|
|
332
|
+
without and then with the `maf` extra), and the PostgreSQL tests on PostgreSQL 16 and 18. To run a PostgreSQL leg as CI does, set
|
|
333
|
+
`AGENTEXPERIENCE_POSTGRES_MAJOR` (16 or 18) and `AGENTEXPERIENCE_REQUIRE_POSTGRES=1`, which fails rather than skips
|
|
334
|
+
without a server, and run `uv run pytest -m postgres`.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# AgentExperience for Python
|
|
2
|
+
|
|
3
|
+
A pure-Python core of AgentExperience that implements the [language-neutral contract](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/README.md), so a
|
|
4
|
+
lesson learned by a Python agent is stored in the format the .NET library reads, and the other way round.
|
|
5
|
+
|
|
6
|
+
**Status: alpha, contract 1.5, with a Microsoft Agent Framework integration (the `maf` extra).** It passes every
|
|
7
|
+
fixture of contract 1.5. It is published to PyPI as `agenticexperience` (the import name stays `agentexperience`), with every
|
|
8
|
+
release of this repository and at the same version as the .NET packages: .NET `0.1.0-preview.N` is
|
|
9
|
+
`agenticexperience` `0.1.0aN` (a PEP 440 alpha; while every release is an alpha, pip and uv install the newest one).
|
|
10
|
+
Like the .NET packages, it claims no production readiness, and its API may change between alphas.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install "agenticexperience[maf]" # with Microsoft Agent Framework
|
|
14
|
+
pip install "agenticexperience[postgres]" # with the PostgreSQL store
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start with Microsoft Agent Framework
|
|
18
|
+
|
|
19
|
+
Install with the `maf` extra (`pip install "agenticexperience[maf]"`, which brings `agent-framework-core` 1.0.0 or
|
|
20
|
+
later, or `pip install ".[maf]"` from this directory); bring `client` (any MAF chat client) and `run_tests` (your test
|
|
21
|
+
run, returning text such as `exit=0`). Development and tests only: see [In production](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/README.md#in-production). After the two imports, five lines: one call that
|
|
22
|
+
wires the agent (three lines), and two runs.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from agent_framework import Agent
|
|
26
|
+
from agentexperience.maf import InMemoryExperienceStore, development_identity, exit_code, tool_succeeded, use_agent_experience
|
|
27
|
+
|
|
28
|
+
agent = use_agent_experience(Agent(client=client, name="triage-ticket", tools=[run_tests]),
|
|
29
|
+
store=InMemoryExperienceStore(), identity=development_identity(), # one local identity: development and tests only
|
|
30
|
+
verify=tool_succeeded("run_tests"), classify_tool_result=exit_code("run_tests")) # your check, never the model
|
|
31
|
+
await agent.run("Ticket #4812: a refund is stuck on a lock. Triage it.")
|
|
32
|
+
await agent.run("Ticket #4977: a refund is stuck on a lock again. Triage it.") # gets the lesson
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The second run's model is handed the Historical Reference block, as one user-role message, which names both of the
|
|
36
|
+
first run's test runs:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
Lesson: Verified after 1 attempt. Worked: attempt 0. Checks: [run_tests].
|
|
40
|
+
Tried:
|
|
41
|
+
- attempt 0: run_tests [failed: exit 3], run_tests [returned] → completed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
[`samples/quickstart.py`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/samples/quickstart.py) is based on this snippet and runs it with no key and no network:
|
|
45
|
+
`uv run python samples/quickstart.py`. It differs in what a runnable, repeatable demo needs: the model is a scripted
|
|
46
|
+
stand-in (`ScriptedModel`, which calls `run_tests` until a call returns `exit=0`), `run_tests` is a small class whose
|
|
47
|
+
first call of each run returns `exit=3`, the identity names its application (`development_identity(application=
|
|
48
|
+
"quickstart")`), the recorded environment is fixed (`environment=...`) so the output is the same on every machine, and it
|
|
49
|
+
prints each run and the block. Its output is checked in as
|
|
50
|
+
[`samples/quickstart.expected.txt`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/samples/quickstart.expected.txt), and a test compares them.
|
|
51
|
+
|
|
52
|
+
### What the one call wires
|
|
53
|
+
|
|
54
|
+
- **Capture**: agent-run middleware and function middleware. Each non-streaming invocation is one run of one attempt:
|
|
55
|
+
every tool call with its arguments (sanitized when stored: only allowlisted values are kept, secrets redacted), its
|
|
56
|
+
result, and its error. A tool that raises is recorded by the exception's type name only, never its message. A tool
|
|
57
|
+
that reports failure in its return value is recorded as failed when `classify_tool_result` names a class for it;
|
|
58
|
+
a class that is not a single error-class token is recorded as `ToolReportedError`. When the invocation ends, `verify`
|
|
59
|
+
decides from your own facts; a verification is finalized through the core (the deterministic lesson, the derived
|
|
60
|
+
IDs) and stored. Without `verify` nothing is stored. A streaming invocation is injected but not captured.
|
|
61
|
+
- **Injection**: a context provider. It derives the task text from the invocation's messages (the contract's rule),
|
|
62
|
+
searches the store in the identity's scope, ranks with the contract's weights, renders the block with
|
|
63
|
+
`block_options` (the contract's options and limits), and adds it as one user-role message stamped
|
|
64
|
+
`additional_properties["AgentExperience.HistoricalReference"] = True` (`message_role="system"` sends it as a system
|
|
65
|
+
message). An agent that already holds an `ExperienceContextProvider` gets no second one; `inject=False` captures
|
|
66
|
+
without injecting.
|
|
67
|
+
- **Injection tracking** (contract 1.4). Once a block is handed over, a store with `record_injection` (the PostgreSQL
|
|
68
|
+
and in-memory stores) is told which records it delivered, so the .NET lessons view shows when each lesson was last
|
|
69
|
+
injected, by a Python agent too. It runs in a background task on the event loop, bounded by
|
|
70
|
+
`injection_record_timeout` (5 seconds, .NET's), and never delays or fails the invocation; `on_injection_recorded`
|
|
71
|
+
reports how it ended (`Recorded`, `NotTracked`, `NotRecorded` or `OutcomeUnknown`, as .NET's `record_injection`
|
|
72
|
+
operation). `await provider.wait_for_injection_records()` awaits the ones still running, for a host shutting down.
|
|
73
|
+
- **The task text, and where history sits.** Capture records the task text of the invocation's own input, as .NET's
|
|
74
|
+
agent-level capture does. Injection derives it from the messages the provider sees: the new input, plus the
|
|
75
|
+
replayed history of a history provider that runs *before* it. The provider is appended to the agent's
|
|
76
|
+
`context_providers`, and the `InMemoryHistoryProvider` MAF adds by itself for a session is appended after it, so by
|
|
77
|
+
default injection sees only the new input, like .NET's agent-level wiring; add a history provider to the agent
|
|
78
|
+
before calling `use_agent_experience` and a short follow-up ("and retry") is searched joined after the previous
|
|
79
|
+
request, like .NET's `ChatClientAgent`-level provider. The stored task summary is then the follow-up alone. Either
|
|
80
|
+
way each request carries exactly one block: a replayed message stamped as the block never counts as a request.
|
|
81
|
+
- **The run's end, as in .NET.** An invocation that raises ends its run `Failed` (the error is the exception's type
|
|
82
|
+
name) and one that is cancelled ends it `Cancelled`; `verify` still decides whether that run is stored, and the
|
|
83
|
+
exception or cancellation then reaches the caller unchanged. A middleware raising `MiddlewareTermination` ends the
|
|
84
|
+
run `Completed` with the result it set. Finalization is bounded by `finalization_timeout` (5 seconds, .NET's
|
|
85
|
+
`FinalizationTimeout`), on its own clock: past it the work is abandoned and reported, so a cancellation is never held
|
|
86
|
+
up longer than that.
|
|
87
|
+
- **Nothing raises into the agent.** A store, identity, classifier or verifier failure means no block or no stored
|
|
88
|
+
record, reported through `on_capture_failure` or `on_injection_failure` (the reason never quotes record content);
|
|
89
|
+
a failed injection record is reported through `on_injection_recorded`.
|
|
90
|
+
Only the invocation's own cancellation and the tool's own exceptions propagate, unchanged.
|
|
91
|
+
|
|
92
|
+
**Verifiers** (`agentexperience.verifiers`, re-exported by `agentexperience.maf`) have the semantics of .NET's
|
|
93
|
+
`ExperienceVerifiers`: `tool_succeeded(tool)` (the last call to the tool decides), `check(id, fn)` (your own check:
|
|
94
|
+
`True`, `False`, or `None` for "do not store"), `approval(id, fn)` (a person's decision, by an opaque approver ID),
|
|
95
|
+
`all_of(...)` (every check; stops at the first `None`) and `any_of(...)` (the first that passes). **Classifiers**
|
|
96
|
+
(`agentexperience.classifiers`) have the semantics of `ExperienceToolResultClassifiers`: `exit_code(*tools)` and
|
|
97
|
+
`http_status(*tools)` read a JSON object's top-level fields, else the last match in the text.
|
|
98
|
+
|
|
99
|
+
### In production
|
|
100
|
+
|
|
101
|
+
The identity must come from your own authentication, never from model output: pass a function of an
|
|
102
|
+
`IdentityContext` (the agent and the session) that returns an `ExperienceIdentity(principal, scope)`, or `None` to
|
|
103
|
+
leave an invocation neither captured nor injected. `development_identity()` runs everything as one fixed caller in
|
|
104
|
+
one fixed scope, so it refuses outside Development, Test or Testing: the Python-native `AGENTEXPERIENCE_ENVIRONMENT`
|
|
105
|
+
variable is read first, then, by the .NET library's rule, `DOTNET_ENVIRONMENT`, else `ASPNETCORE_ENVIRONMENT`; with
|
|
106
|
+
none set it is allowed (pass `environment_name=` when your host has its own name, and it is kept with the identity).
|
|
107
|
+
A resolver that returns a development identity is checked on every invocation: outside development the invocation is
|
|
108
|
+
reported (`ResolveIdentity`) and runs with no memory. The store is `PostgresExperienceStore` (the `postgres` extra, below),
|
|
109
|
+
which shares lessons with .NET agents on the same database.
|
|
110
|
+
|
|
111
|
+
## What it does
|
|
112
|
+
|
|
113
|
+
- **Capture and finalize.** `RunCapture` sanitizes a run as it is recorded: secret-named fields are redacted, argument
|
|
114
|
+
values are kept only when allowlisted, and the default limits apply. `finalize` aggregates the host's verification,
|
|
115
|
+
writes the deterministic lesson for a verified run, and returns the stored record (`Validated`, or `Quarantined`
|
|
116
|
+
without a lesson) and its initial lifecycle event. The IDs are derived, so a retry derives the same ones.
|
|
117
|
+
- **The injected block.** `render_block` turns ranked records into the Historical Reference block, byte for byte as
|
|
118
|
+
the .NET adapter writes it, in the compact or verbose layout, within the record and byte limits, with record text
|
|
119
|
+
neutralized.
|
|
120
|
+
- **The task text.** `derive_task_text` derives the text retrieval searches with from an invocation's messages.
|
|
121
|
+
- **The error class.** `error_class` reduces a captured error to its short class.
|
|
122
|
+
- **PostgreSQL** (the `postgres` extra, psycopg 3). `PostgresExperienceStore` writes a finalized record and its
|
|
123
|
+
initial event as the application role, reads records back, runs the contract's text search, and records injections
|
|
124
|
+
(`record_injection`, contract 1.4). It ships the contract's SQL files and runs them as written. `rank` applies the
|
|
125
|
+
contract's safety checks, truncation and ranking to the rows the search returns. The store needs a schema the .NET
|
|
126
|
+
migrator applied, with row-level security on; it never creates or alters a schema object. The application role's
|
|
127
|
+
privileges are the .NET privileges call's, or the contract's two files, in this order:
|
|
128
|
+
`sql/application-role-privileges.sql`, then `sql/application-role-privileges-1.4.sql` (without the second, every
|
|
129
|
+
injection is still made and its record is reported `NotRecorded`).
|
|
130
|
+
|
|
131
|
+
It does nothing outside contract 1.x: no grants, crypto-shredding, provenance signing or vectors, and no lifecycle
|
|
132
|
+
after finalization (reuse evidence, reinforcement, contest, revocation). It writes no logs at all, so no record
|
|
133
|
+
content is ever logged.
|
|
134
|
+
|
|
135
|
+
The pieces that need no run or database:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from agentexperience import Message, MessagePart, derive_task_text, error_class
|
|
139
|
+
|
|
140
|
+
messages = [
|
|
141
|
+
Message("user", [MessagePart("text", "Deploy service X to prod")]),
|
|
142
|
+
Message("user", [MessagePart("text", "and retry")]),
|
|
143
|
+
]
|
|
144
|
+
print(derive_task_text(messages)) # Deploy service X to prod — and retry
|
|
145
|
+
print(error_class("System.TimeoutException: The operation has timed out.")) # TimeoutException
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### End to end
|
|
149
|
+
|
|
150
|
+
Capture a run, finalize it, store it, search for it, rank the rows and render the block. Steps 1 and 2 need nothing
|
|
151
|
+
else; step 3 needs the `postgres` extra (`pip install "agenticexperience[postgres]"`) and a database
|
|
152
|
+
the .NET migrator set up.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
import os
|
|
156
|
+
import uuid
|
|
157
|
+
|
|
158
|
+
from agentexperience import (
|
|
159
|
+
AuthorizationBounds,
|
|
160
|
+
BlockOptions,
|
|
161
|
+
ClosedVerificationRound,
|
|
162
|
+
Duration,
|
|
163
|
+
EnvironmentFingerprint,
|
|
164
|
+
Evidence,
|
|
165
|
+
Instant,
|
|
166
|
+
Provenance,
|
|
167
|
+
RawAttempt,
|
|
168
|
+
RawToolCall,
|
|
169
|
+
RequiredCheck,
|
|
170
|
+
RunCapture,
|
|
171
|
+
Sanitization,
|
|
172
|
+
Scope,
|
|
173
|
+
SearchParameters,
|
|
174
|
+
finalize,
|
|
175
|
+
rank,
|
|
176
|
+
render_block,
|
|
177
|
+
)
|
|
178
|
+
from agentexperience.postgres import PostgresExperienceStore
|
|
179
|
+
|
|
180
|
+
scope = Scope("acme", "support-app", "refunds", agent_id="refund-agent")
|
|
181
|
+
start = Instant.parse("2026-03-02T09:00:00+00:00")
|
|
182
|
+
|
|
183
|
+
# 1. Capture a run as it happens. Only the allowlisted argument "strategy" keeps its value.
|
|
184
|
+
capture = RunCapture.start(
|
|
185
|
+
run_id=uuid.uuid4(),
|
|
186
|
+
task_id="refund-stuck-pending",
|
|
187
|
+
task_description="Refund ticket 42 is stuck in pending",
|
|
188
|
+
scope=scope,
|
|
189
|
+
environment=EnvironmentFingerprint("host-1", "python-3.13", "linux", "1.0.0", {"region": "eu-west"}),
|
|
190
|
+
provenance=Provenance("my-agent", "1", start, None),
|
|
191
|
+
started_at=start,
|
|
192
|
+
sanitization=Sanitization.allowing(["strategy"]),
|
|
193
|
+
)
|
|
194
|
+
second = Duration.from_seconds(1)
|
|
195
|
+
capture.append_attempt(
|
|
196
|
+
RawAttempt(
|
|
197
|
+
uuid.uuid4(),
|
|
198
|
+
start,
|
|
199
|
+
second,
|
|
200
|
+
[
|
|
201
|
+
RawToolCall(
|
|
202
|
+
uuid.uuid4(),
|
|
203
|
+
"run_refund_check",
|
|
204
|
+
{"strategy": "retry"},
|
|
205
|
+
start,
|
|
206
|
+
second,
|
|
207
|
+
error="InvalidOperationException: the refund lock is held",
|
|
208
|
+
)
|
|
209
|
+
],
|
|
210
|
+
error="InvalidOperationException: the refund lock is held",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
capture.append_attempt(
|
|
214
|
+
RawAttempt(
|
|
215
|
+
uuid.uuid4(),
|
|
216
|
+
start.add(second),
|
|
217
|
+
second,
|
|
218
|
+
[RawToolCall(uuid.uuid4(), "run_refund_check", {"strategy": "wait-for-lock"}, start, second, result="issued")],
|
|
219
|
+
result="Refund issued.",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
run = capture.complete("Completed", start.add(Duration.from_seconds(20)))
|
|
223
|
+
|
|
224
|
+
# 2. Finalize against the host's verification: one passing check makes it a Validated lesson.
|
|
225
|
+
round_ = ClosedVerificationRound(uuid.uuid4(), "rev-1")
|
|
226
|
+
finished = start.add(Duration.from_seconds(30))
|
|
227
|
+
finalized = finalize(
|
|
228
|
+
run,
|
|
229
|
+
closed_round=round_,
|
|
230
|
+
required_checks=[RequiredCheck("refund-issued", "integration-test")],
|
|
231
|
+
evidence=[
|
|
232
|
+
Evidence(
|
|
233
|
+
uuid.uuid4(), round_.round_id, "rev-1", "refund-issued", "integration-test", "Pass", "ci", None, finished
|
|
234
|
+
)
|
|
235
|
+
],
|
|
236
|
+
current_artifact_revision="rev-1",
|
|
237
|
+
finalized_at=finished,
|
|
238
|
+
)
|
|
239
|
+
print(finalized.record.status, "-", finalized.record.reflection.lesson)
|
|
240
|
+
|
|
241
|
+
# 3. Store it, search it and render the block (needs the postgres extra and a schema the .NET migrator applied).
|
|
242
|
+
store = PostgresExperienceStore(os.environ["AGENTEXPERIENCE_CONNINFO"]) # libpq string, as the application role
|
|
243
|
+
bounds = AuthorizationBounds.of(scope)
|
|
244
|
+
store.insert_finalized(bounds, finalized, actor="host-principal")
|
|
245
|
+
rows = store.search(bounds, SearchParameters(scope, "refund ticket stuck in pending"))
|
|
246
|
+
ranked = rank(rows, scope, now=finished)
|
|
247
|
+
print(render_block(ranked.ranked, BlockOptions(rendering="Compact")).text)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Running the tests
|
|
251
|
+
|
|
252
|
+
[uv](https://docs.astral.sh/uv/) manages the environment. From this directory:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
uv sync
|
|
256
|
+
uv run ruff check && uv run ruff format --check
|
|
257
|
+
uv run mypy --strict src tests
|
|
258
|
+
uv run pytest
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`uv run pytest` runs the unit tests and the conformance runner (`tests/test_contract.py`), which loads every fixture
|
|
262
|
+
family from [`../contract/fixtures`](https://github.com/fabbrik/AgentExperience.NET/tree/main/contract/fixtures) and compares this package's output exactly: blocks and task
|
|
263
|
+
text byte for byte, JSON by value. It validates every record against the contract's JSON Schema, checks every
|
|
264
|
+
contract file against `contract/lock.json`, and fails with a clear message if the lock names a contract version this
|
|
265
|
+
package does not support.
|
|
266
|
+
|
|
267
|
+
The development environment includes `agent-framework-core`, so `uv run pytest` also runs the Agent Framework tests:
|
|
268
|
+
`tests/test_maf.py` drives a real `agent_framework.Agent` with a scripted chat client (capture, classification,
|
|
269
|
+
verification, storage, injection, failures, sessions, streaming); `tests/test_verifiers.py` and
|
|
270
|
+
`tests/test_classifiers.py` port the cases of the .NET verifier and classifier tests; and `tests/test_quickstart.py`
|
|
271
|
+
runs the sample (`AGENTEXPERIENCE_QUICKSTART_GOLDEN_UPDATE=1` rewrites its expected output on purpose).
|
|
272
|
+
|
|
273
|
+
**The cross-language proof** (`tests/test_cross_language.py`):
|
|
274
|
+
|
|
275
|
+
- *Python writes, .NET reads.* A Python agent on a fixed clock and fixed identifiers stores two runs, and the records
|
|
276
|
+
must be exactly [`tests/cross-language/python-agent-records.json`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/tests/cross-language/python-agent-records.json)
|
|
277
|
+
(checked in memory, and written to and read back from PostgreSQL in the PostgreSQL leg). In the PostgreSQL leg the
|
|
278
|
+
rows the Python store wrote, every column it writes in PostgreSQL's own text form, must be exactly
|
|
279
|
+
[`tests/cross-language/python-agent-rows.json`](https://github.com/fabbrik/AgentExperience.NET/blob/main/python/tests/cross-language/python-agent-rows.json).
|
|
280
|
+
The .NET contract tests' `PythonAgentRecordsPostgresRoundTripTests` insert those raw rows into a schema the .NET
|
|
281
|
+
migrator applied, each value cast from its text by PostgreSQL, read every record back through the .NET PostgreSQL
|
|
282
|
+
store (it must be `python-agent-records.json`), check each initial event is the one .NET finalization derives,
|
|
283
|
+
compare every column with what the .NET store writes for the same record, read the injection the second run
|
|
284
|
+
recorded through the .NET lessons view and compare its row with what the .NET store records for it, and find the
|
|
285
|
+
Python agent's lessons through the .NET retrieval. So a column the Python store wrote in its own encoding fails there, even when the
|
|
286
|
+
Python reader undoes it. `AGENTEXPERIENCE_CROSS_LANGUAGE_UPDATE=1` rewrites both files on purpose (the rows in the
|
|
287
|
+
PostgreSQL leg).
|
|
288
|
+
- *.NET writes, Python reads.* The records the .NET library finalized for the contract's finalize fixtures (generated
|
|
289
|
+
from .NET and checked against it on every build) are written into PostgreSQL, and a Python agent on their scope is
|
|
290
|
+
injected with the one its task matches. No .NET process runs at test time: the fixtures are the .NET-written
|
|
291
|
+
records.
|
|
292
|
+
|
|
293
|
+
The PostgreSQL tests (`tests/test_postgres.py` and the cross-language tests, marker `postgres`) write each finalize fixture through the store and
|
|
294
|
+
read it back row for row, run every search fixture, then rank, and run every record-injection fixture. Each test database is migrated with the .NET
|
|
295
|
+
migrator's own scripts from
|
|
296
|
+
[`src/AgentExperience.Storage.Postgres/Migrations`](https://github.com/fabbrik/AgentExperience.NET/tree/main/src/AgentExperience.Storage.Postgres/Migrations), in order;
|
|
297
|
+
the application role's privileges are the contract's
|
|
298
|
+
[`sql/application-role-privileges.sql`](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/sql/application-role-privileges.sql) then
|
|
299
|
+
[`sql/application-role-privileges-1.4.sql`](https://github.com/fabbrik/AgentExperience.NET/blob/main/contract/sql/application-role-privileges-1.4.sql), which a .NET test keeps
|
|
300
|
+
identical to the statements the .NET migrator runs. The server is a Testcontainers `postgres` container (`AGENTEXPERIENCE_POSTGRES_MAJOR`, 16 by default), or
|
|
301
|
+
the server libpq's `PGHOST`, `PGPORT`, `PGUSER` and `PGPASSWORD` name (a superuser). With neither, they are skipped.
|
|
302
|
+
On a Mac with Rancher Desktop or Colima, Testcontainers may need:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock TESTCONTAINERS_RYUK_DISABLED=true uv run pytest -m postgres
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
CI runs the `python` job on CPython 3.10 to 3.14 (lint, `mypy --strict`, the tests, the Agent Framework tests again
|
|
309
|
+
on agent-framework-core 1.0.0, the `maf` extra's floor, and an import of the built wheel from a clean environment,
|
|
310
|
+
without and then with the `maf` extra), and the PostgreSQL tests on PostgreSQL 16 and 18. To run a PostgreSQL leg as CI does, set
|
|
311
|
+
`AGENTEXPERIENCE_POSTGRES_MAJOR` (16 or 18) and `AGENTEXPERIENCE_REQUIRE_POSTGRES=1`, which fails rather than skips
|
|
312
|
+
without a server, and run `uv run pytest -m postgres`.
|