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.
Files changed (49) hide show
  1. agenticexperience-0.1.0a14/.gitignore +9 -0
  2. agenticexperience-0.1.0a14/PKG-INFO +334 -0
  3. agenticexperience-0.1.0a14/README.md +312 -0
  4. agenticexperience-0.1.0a14/pyproject.toml +77 -0
  5. agenticexperience-0.1.0a14/samples/quickstart.expected.txt +24 -0
  6. agenticexperience-0.1.0a14/samples/quickstart.py +131 -0
  7. agenticexperience-0.1.0a14/src/agentexperience/__init__.py +108 -0
  8. agenticexperience-0.1.0a14/src/agentexperience/_text.py +270 -0
  9. agenticexperience-0.1.0a14/src/agentexperience/block.py +702 -0
  10. agenticexperience-0.1.0a14/src/agentexperience/capture.py +200 -0
  11. agenticexperience-0.1.0a14/src/agentexperience/classifiers.py +273 -0
  12. agenticexperience-0.1.0a14/src/agentexperience/encoding.py +205 -0
  13. agenticexperience-0.1.0a14/src/agentexperience/error_class.py +129 -0
  14. agenticexperience-0.1.0a14/src/agentexperience/finalize.py +137 -0
  15. agenticexperience-0.1.0a14/src/agentexperience/identity.py +158 -0
  16. agenticexperience-0.1.0a14/src/agentexperience/ids.py +52 -0
  17. agenticexperience-0.1.0a14/src/agentexperience/inmemory.py +275 -0
  18. agenticexperience-0.1.0a14/src/agentexperience/limits.py +40 -0
  19. agenticexperience-0.1.0a14/src/agentexperience/maf.py +1116 -0
  20. agenticexperience-0.1.0a14/src/agentexperience/postgres.py +363 -0
  21. agenticexperience-0.1.0a14/src/agentexperience/py.typed +0 -0
  22. agenticexperience-0.1.0a14/src/agentexperience/records.py +548 -0
  23. agenticexperience-0.1.0a14/src/agentexperience/reflector.py +324 -0
  24. agenticexperience-0.1.0a14/src/agentexperience/sanitization.py +161 -0
  25. agenticexperience-0.1.0a14/src/agentexperience/search.py +225 -0
  26. agenticexperience-0.1.0a14/src/agentexperience/sql/declare-bounds.sql +12 -0
  27. agenticexperience-0.1.0a14/src/agentexperience/sql/record-injection.sql +25 -0
  28. agenticexperience-0.1.0a14/src/agentexperience/sql/search-experience-text.sql +29 -0
  29. agenticexperience-0.1.0a14/src/agentexperience/task_text.py +190 -0
  30. agenticexperience-0.1.0a14/src/agentexperience/verification.py +130 -0
  31. agenticexperience-0.1.0a14/src/agentexperience/verifiers.py +350 -0
  32. agenticexperience-0.1.0a14/tests/_contract.py +194 -0
  33. agenticexperience-0.1.0a14/tests/_maf.py +86 -0
  34. agenticexperience-0.1.0a14/tests/_postgres.py +203 -0
  35. agenticexperience-0.1.0a14/tests/_sync.py +18 -0
  36. agenticexperience-0.1.0a14/tests/conftest.py +31 -0
  37. agenticexperience-0.1.0a14/tests/cross-language/python-agent-records.json +246 -0
  38. agenticexperience-0.1.0a14/tests/cross-language/python-agent-rows.json +97 -0
  39. agenticexperience-0.1.0a14/tests/test_classifiers.py +205 -0
  40. agenticexperience-0.1.0a14/tests/test_contract.py +382 -0
  41. agenticexperience-0.1.0a14/tests/test_cross_language.py +368 -0
  42. agenticexperience-0.1.0a14/tests/test_identity_and_store.py +146 -0
  43. agenticexperience-0.1.0a14/tests/test_maf.py +1100 -0
  44. agenticexperience-0.1.0a14/tests/test_parity.py +443 -0
  45. agenticexperience-0.1.0a14/tests/test_postgres.py +409 -0
  46. agenticexperience-0.1.0a14/tests/test_quickstart.py +39 -0
  47. agenticexperience-0.1.0a14/tests/test_units.py +132 -0
  48. agenticexperience-0.1.0a14/tests/test_verifiers.py +265 -0
  49. agenticexperience-0.1.0a14/uv.lock +1795 -0
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -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`.