agent-chronicle 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-chronicle
3
+ Version: 0.1.0
4
+ Summary: Record-and-replay for agent decision graphs: reproduce a prod agent failure as a committed regression test — and re-run your fix without live LLM calls.
5
+ Project-URL: Homepage, https://github.com/theagentplane/chronicle
6
+ Project-URL: Repository, https://github.com/theagentplane/chronicle
7
+ Project-URL: Issues, https://github.com/theagentplane/chronicle/issues
8
+ Project-URL: Changelog, https://github.com/theagentplane/chronicle/blob/main/CHANGELOG.md
9
+ Author: Tisha Chawla, Susheem Koul
10
+ License-Expression: MIT
11
+ License-File: LICENSE.txt
12
+ Keywords: agents,llm,observability,openinference,opentelemetry,record-replay,regression,testing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: click>=8
23
+ Requires-Dist: pydantic>=2
24
+ Provides-Extra: dev
25
+ Requires-Dist: arize-phoenix>=4; extra == 'dev'
26
+ Requires-Dist: build>=1.2; extra == 'dev'
27
+ Requires-Dist: langchain-core>=0.3; extra == 'dev'
28
+ Requires-Dist: langgraph>=0.2; extra == 'dev'
29
+ Requires-Dist: openai>=1; extra == 'dev'
30
+ Requires-Dist: openinference-instrumentation-langchain>=0.1; extra == 'dev'
31
+ Requires-Dist: openinference-instrumentation>=0.1; extra == 'dev'
32
+ Requires-Dist: openinference-semantic-conventions>=0.1; extra == 'dev'
33
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'dev'
34
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20; extra == 'dev'
35
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'dev'
36
+ Requires-Dist: pytest>=8; extra == 'dev'
37
+ Requires-Dist: ruff>=0.6; extra == 'dev'
38
+ Provides-Extra: judge
39
+ Requires-Dist: openai>=1; extra == 'judge'
40
+ Provides-Extra: langgraph
41
+ Requires-Dist: langchain-core>=0.3; extra == 'langgraph'
42
+ Requires-Dist: langgraph>=0.2; extra == 'langgraph'
43
+ Requires-Dist: openinference-instrumentation-langchain>=0.1; extra == 'langgraph'
44
+ Provides-Extra: phoenix
45
+ Requires-Dist: arize-phoenix>=4; extra == 'phoenix'
46
+ Requires-Dist: openinference-instrumentation>=0.1; extra == 'phoenix'
47
+ Requires-Dist: openinference-semantic-conventions>=0.1; extra == 'phoenix'
48
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'phoenix'
49
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20; extra == 'phoenix'
50
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'phoenix'
51
+ Description-Content-Type: text/markdown
52
+
53
+ # Chronicle
54
+
55
+ [![CI](https://github.com/theagentplane/chronicle/actions/workflows/ci.yml/badge.svg)](https://github.com/theagentplane/chronicle/actions/workflows/ci.yml)
56
+ [![PyPI](https://img.shields.io/pypi/v/agent-chronicle.svg)](https://pypi.org/project/agent-chronicle/)
57
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
58
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://github.com/theagentplane/chronicle)
59
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
60
+
61
+ **Record-and-replay for agent decision graphs.** Chronicle records what your agent
62
+ did at each decision boundary (its LLM calls, tool calls, and routing choices) so
63
+ you can reproduce a production failure as a committed regression test and re-run
64
+ your fix without live LLM calls. The target is one specific, real problem:
65
+ control-flow and tool-safety regressions in multi-agent systems, caught
66
+ deterministically from recorded incidents.
67
+
68
+ ## Demo
69
+
70
+ [![Watch the Chronicle demo](https://img.youtube.com/vi/Lc8zRh9muoY/maxresdefault.jpg)](https://www.youtube.com/watch?v=Lc8zRh9muoY)
71
+
72
+ ## Architecture
73
+
74
+ Chronicle is two systems that share one artifact, the **Envelope**: a recorder
75
+ that captures boundary crossings during a live run, and a test bench that
76
+ replays them without touching the model.
77
+
78
+ ```mermaid
79
+ flowchart LR
80
+ subgraph REC["Record (LIVE run)"]
81
+ A["Your agent<br/>llm · tool · router calls"] -->|"@boundary"| B["Envelope Recorder"]
82
+ B --> C["Append-only<br/>envelope store (.jsonl)"]
83
+ C --> D["Execution graph<br/>(side graph, no topology change)"]
84
+ end
85
+
86
+ D -->|"export / extract"| E["fixtures/ committed to git<br/>envelopes/ · traces/"]
87
+
88
+ subgraph REP["Replay & Verify (no live LLM)"]
89
+ E --> F["ReplayPlan<br/>stub upstream · run one boundary live"]
90
+ F --> G["Layer 1: structural replay<br/>control flow &amp; tool safety"]
91
+ E --> H["Layer 2: LLM-as-judge<br/>grounding · safety · refusal"]
92
+ end
93
+
94
+ G --> I["pytest / CI<br/>regression tests"]
95
+ H --> I
96
+ D -.->|"on_crossing hook"| J["Cost / governance<br/>(external, e.g. TokenOps)"]
97
+ ```
98
+
99
+ **The Envelope** is an immutable, append-only record of one boundary crossing:
100
+
101
+ | Field | Contents |
102
+ |---|---|
103
+ | Contextual metadata | Model version, sampling parameters, runtime build ID |
104
+ | Input state | Assembled prompt, graph state, retrieved context chunks |
105
+ | Action / result | Structured tool calls and model completion |
106
+ | Graph linkage | `parent_envelope_id`, `sequence`, `invocation_index` for retries |
107
+
108
+ Optional OpenInference and Arize Phoenix integrations feed framework-agnostic
109
+ tracing into this same envelope format.
110
+
111
+ ## Install
112
+
113
+ ```bash
114
+ # From source (development):
115
+ pip install -e ".[dev]"
116
+
117
+ # From PyPI:
118
+ pip install agent-chronicle
119
+ ```
120
+
121
+ ## Quick start
122
+
123
+ The primary API is one decorator. Annotate a decision boundary once; it records
124
+ in live mode and stubs from a fixture in replay mode.
125
+
126
+ ```python
127
+ from chronicle import boundary, reset_session, ReplayPlan
128
+ from chronicle.envelope.store import EnvelopeStore
129
+
130
+ @boundary("agent", kind="llm")
131
+ def agent_plan(state: dict) -> dict:
132
+ ...
133
+
134
+ @boundary("delete_file", kind="tool")
135
+ def delete_file(path: str, environment: str) -> dict:
136
+ ...
137
+
138
+ # 1. Record a run (live mode is the default)
139
+ session = reset_session()
140
+ session.store = EnvelopeStore(".chronicle/runs/incident.jsonl")
141
+ session.begin_trace("trace-001")
142
+ run_agent(...)
143
+
144
+ # 2. Freeze it as a committed fixture
145
+ session.export_trace("fixtures/traces/incident-001/")
146
+ ```
147
+
148
+ ### Cut-point replay
149
+
150
+ Test a fix in one boundary while the rest of the incident stays frozen. Upstream
151
+ boundaries are stubbed from the fixture, your changed boundary runs live, and you
152
+ assert on its captured result.
153
+
154
+ ```python
155
+ session = reset_session()
156
+ session.load_trace("fixtures/traces/deletion-incident-001/")
157
+ session.enable_replay(
158
+ ReplayPlan()
159
+ .stub("agent", 1) # upstream: frozen from fixture
160
+ .live("delete_file", 1) # cut-point: run new code
161
+ .live("agent", 2) # downstream: observe the effect
162
+ )
163
+ run_agent(...)
164
+
165
+ assert session.captured_result("delete_file", 1)["blocked"] is True
166
+ ```
167
+
168
+ One decorator, two behaviors: in **live** mode your function runs and its
169
+ input/output are recorded into an Envelope; in **replay + stub** mode it does not
170
+ run and Chronicle returns the recorded output. A cut-point is the one boundary you
171
+ flip back to live to test new code against real upstream inputs.
172
+
173
+ ## Verification layers
174
+
175
+ | Layer | Goal | Mechanism |
176
+ |---|---|---|
177
+ | Layer 1: replay | Validate control flow and tool safety | Structural assertions over recorded fixtures; never calls the LLM |
178
+ | Layer 2: evaluation | Validate generation quality | LLM-as-a-judge on meaning (grounding, safety, refusal), not bitwise equality |
179
+ | Cut-point replay | Test a change in one boundary | Stub upstream from fixtures, run the target boundary live |
180
+
181
+ ### Layer 1 (single-envelope injector)
182
+
183
+ ```python
184
+ from chronicle.replay import ReplayInjector
185
+ from chronicle import Envelope
186
+
187
+ envelope = Envelope.from_file("fixtures/envelopes/incident-2026-06-17-001.json")
188
+ injector = ReplayInjector(envelope)
189
+
190
+ def agent(state, inj):
191
+ inj.stub_llm()
192
+ inj.stub_tool("search_docs", {"query": "reset API key"})
193
+ return {"finish_reason": "tool_calls"}
194
+
195
+ _, _, assertions = injector.replay(agent)
196
+ assert all(a.passed for a in assertions)
197
+ ```
198
+
199
+ ### Layer 2 (LLM-as-judge)
200
+
201
+ ```python
202
+ from chronicle.judge import JudgeRunner, OpenAIJudgeClient
203
+
204
+ runner = JudgeRunner(OpenAIJudgeClient(model="gpt-4o-mini"))
205
+ result = runner.evaluate(envelope)
206
+ assert result.overall_passed
207
+ ```
208
+
209
+ ## Demos
210
+
211
+ Each demo records an incident from an ungated tool, then a cut-point test verifies
212
+ the gated fix. All use the same `agent@1 -> tool@1 -> agent@2` shape.
213
+
214
+ | Scenario | Command | Incident |
215
+ |---|---|---|
216
+ | Refund equals order ID | `refund` | $9.8M refund on a $47 order |
217
+ | Invoice currency mismatch | `invoice` | EUR 2M invoice sent as USD |
218
+ | Trade notional vs shares | `trade` | ~$190k sell instead of ~$1k |
219
+ | Deletion agent | (see below) | Ungated `delete_file` wipes prod |
220
+
221
+ ```bash
222
+ # Financial incidents: record, then cut-point test
223
+ python examples/financial_incidents/run.py refund record
224
+ python examples/financial_incidents/run.py refund test
225
+ python examples/financial_incidents/run.py all test # all three in sequence
226
+ pytest tests/test_financial_incidents.py -v
227
+
228
+ # Deletion agent: record, visualize, cut-point test
229
+ python examples/deletion_agent/record_incident.py
230
+ python examples/deletion_agent/show_trace.py --ui # interactive timeline + graph
231
+ python examples/deletion_agent/run_cutpoint_demo.py
232
+ pytest tests/test_deletion_cutpoint.py -v
233
+ ```
234
+
235
+ The gated fix in each tool refuses when an amount exceeds a flat cap
236
+ (`MAX_REFUND_CENTS`, `MAX_INVOICE_CENTS`, `MAX_ORDER_NOTIONAL_CENTS`). Source
237
+ lives under `examples/financial_incidents/` and `examples/deletion_agent/`.
238
+
239
+ ## CLI
240
+
241
+ ```bash
242
+ chronicle record # bootstrap tracing + instrumentation
243
+ chronicle extract --trace-id ID # export envelopes to fixtures/
244
+ chronicle replay FIXTURE.json # Layer 1 deterministic replay
245
+ chronicle verify FIXTURE.json --layer2 --mock-judge # Layer 1 + Layer 2
246
+ chronicle show-graph fixtures/traces/TRACE --ui # interactive trace visualization
247
+ chronicle show-graph TRACE --html out.html # static HTML export
248
+ chronicle schema # print Envelope JSON Schema
249
+ chronicle list-fixtures # list committed envelope fixtures
250
+ ```
251
+
252
+ ## LangGraph integration (optional)
253
+
254
+ For LangGraph-specific node wrapping as an alternative to `@boundary`:
255
+
256
+ ```python
257
+ from chronicle.envelope.capture import EnvelopeRecorder
258
+ from chronicle.envelope.store import EnvelopeStore
259
+ from chronicle.instrumentation import instrument_graph_nodes
260
+
261
+ recorder = EnvelopeRecorder(
262
+ store=EnvelopeStore(".chronicle/runs/envelopes.jsonl"),
263
+ model_version="gpt-4o-2024-08-06",
264
+ build_id="deploy-abc123",
265
+ )
266
+ wrapped_nodes = instrument_graph_nodes(recorder, {"agent": agent_node})
267
+ ```
268
+
269
+ See `examples/langgraph_demo/agent.py`.
270
+
271
+ ## Cost and governance observers (`on_crossing`)
272
+
273
+ Chronicle does not embed cost management. External systems (for example TokenOps)
274
+ attach an observer that fires after each live crossing:
275
+
276
+ ```python
277
+ session = reset_session()
278
+ session.on_crossing = my_observer # (boundary_id, kind, input_state, result) -> None
279
+ ```
280
+
281
+ It runs after a live envelope record and a live cut-point capture, and does not
282
+ run on stub replay. See `tests/test_cost_management_e2e.py` for an end-to-end
283
+ ledger and budget pattern.
284
+
285
+ ## Environment variables
286
+
287
+ | Variable | Purpose |
288
+ |---|---|
289
+ | `CHRONICLE_BUILD_ID` | Pin runtime build ID in envelope metadata |
290
+ | `CHRONICLE_STORE` | Default envelope store path |
291
+ | `PHOENIX_COLLECTOR_ENDPOINT` | Phoenix OTLP endpoint (default `http://localhost:4317`) |
292
+
293
+ ## Project structure
294
+
295
+ Only `chronicle/` is the installable library. Demos and interactive benches stay
296
+ under `examples/`; committed regression traces live in `fixtures/`.
297
+
298
+ ```
299
+ chronicle/ # installable package
300
+ ├── boundary.py # @boundary decorator (record + replay + cut-point)
301
+ ├── session.py # runtime session, on_crossing hook, stub/live modes
302
+ ├── execution_graph.py # side graph builder (load/save/render)
303
+ ├── visualizer.py # HTML trace UI (library + CLI)
304
+ ├── envelope/ # schema, capture, append-only store
305
+ ├── replay/ # ReplayPlan, ReplayInjector, structural assertions
306
+ ├── judge/ # Layer 2 rubric + LLM-as-judge runner
307
+ ├── instrumentation/ # OpenInference + LangGraph hooks
308
+ └── cli.py
309
+ fixtures/ # committed regression data (envelopes/ · traces/)
310
+ examples/ # demos and test benches (not imported by the package)
311
+ scripts/ # demo and test runners
312
+ tests/ # unit + e2e
313
+ ```
314
+
315
+ ## Contributing
316
+
317
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup,
318
+ the DCO sign-off, and the record-and-replay reviewer checklist, and please read
319
+ our [Code of Conduct](CODE_OF_CONDUCT.md).
320
+
321
+ ## Security
322
+
323
+ Chronicle captures prompts, agent state, and retrieved context, so a recording can
324
+ contain secrets. Turn on redaction before recording production traffic, so nothing
325
+ sensitive reaches a committed fixture:
326
+
327
+ ```python
328
+ import chronicle
329
+
330
+ session = chronicle.reset_session()
331
+ session.redactors = chronicle.default_redactors() # mask API keys, tokens, JWTs
332
+ ```
333
+
334
+ Redaction runs at record time and keeps the structure your tests assert on (message
335
+ roles, tool names, argument keys) while masking the values. Add your own
336
+ `(str) -> str` redactors for PII. Read the data-handling guidance in
337
+ [SECURITY.md](SECURITY.md) and report vulnerabilities privately per that policy.
338
+
339
+ ## Contact
340
+
341
+ - Susheem Koul: susheemkoul97@gmail.com
342
+ - Tisha Chawla: tishachawla5@gmail.com
343
+
344
+ ## License
345
+
346
+ [MIT](LICENSE.txt) (c) 2026 Susheem Koul and Tisha Chawla.
@@ -0,0 +1,26 @@
1
+ chronicle/__init__.py,sha256=vERK4wTnjh4sM9b1-4x_aOXMEY_fJJ8r5H-_SZwLniE,981
2
+ chronicle/boundary.py,sha256=YIcu7IsQ7pZhGSFZj18Vv1xIfbGNI7wEh5956FRnjFc,5131
3
+ chronicle/cli.py,sha256=lo4xrl67b2h5OxksBVYtmWajs3pJbIDwLPZkRLuhiqg,6430
4
+ chronicle/execution_graph.py,sha256=vJbpbrfTjsAI6n2KAo162Zcs_CXFGeimgULc3Ym3gK8,6100
5
+ chronicle/redaction.py,sha256=rT1D2sBxun-T2NhYO0YimN4uxpWt9xYucakq3GZVN0U,3438
6
+ chronicle/session.py,sha256=wDPt2dj0Eb6olUEufiKMO6QhSuhPpmlXifaPkfeDVDk,12431
7
+ chronicle/visualizer.py,sha256=83QPhf4u9h1R6TPbbpa8VosjwiHmvyOID41HwyqEMzU,16599
8
+ chronicle/envelope/__init__.py,sha256=3Yt95SxicIO6pKCFBMsJMlY452MysHwCoY4K3GC-DKg,215
9
+ chronicle/envelope/capture.py,sha256=Ya7K_Jk_mpTdpy2kWK-NC8dTi0ZkSlhc6uHMRBiTVbk,6075
10
+ chronicle/envelope/schema.py,sha256=k0r9oTSBKEVPiU54d91Jz-frWmuVMbpBq73YfZNxL44,4277
11
+ chronicle/envelope/store.py,sha256=PUHxXVSQf-UO_TqP-EeHr6Ps12OrY_D_8_rgH4wX0YU,2376
12
+ chronicle/instrumentation/__init__.py,sha256=-ijkgLsI5fubZGeoUnrqJHEiaHbq3X3tJNykm0mhz08,475
13
+ chronicle/instrumentation/langgraph.py,sha256=Q-9NPJON0cuRgyq-GjxdHEQjMNHcceYJM_HOlSLO_xM,3639
14
+ chronicle/instrumentation/openinference.py,sha256=J810HIexp5JBNHNmlvDkf13FBTWvG_V9kupqiZ3x1NY,2630
15
+ chronicle/judge/__init__.py,sha256=qjTVTh1RnGo8cuIyO0txREX7-lzN20cV5a5ESwAntLA,364
16
+ chronicle/judge/rubric.py,sha256=QurkM3jicwpmKIZvT87fQb7DL1RqWR6vIU8WToguHFQ,1828
17
+ chronicle/judge/runner.py,sha256=dS1RyNHNE--DgOaUQHGdt0MkVcOjEahwFuPb2-e7JZA,4729
18
+ chronicle/replay/__init__.py,sha256=ThuUe0cTqhDS3NDyZUvxndphx1X9EAlrwf3IMck7eUU,561
19
+ chronicle/replay/assertions.py,sha256=c31qSAjXHN-xWLJ3m6aOEeaIFkoW6ga1tdF9FGWcSWY,5022
20
+ chronicle/replay/injector.py,sha256=HjtzlA718F08Sn8FIMn44XxWZESfsfeJOhM63ZTqAmo,3965
21
+ chronicle/replay/plan.py,sha256=Xw5MZ2on0pAFyQN8GRDTYALVH9CRLo6apASRevpYATc,1725
22
+ agent_chronicle-0.1.0.dist-info/METADATA,sha256=m6TH8CHgOEh3YC5WcIpQTUHzvkFJDvm7H7IoVcvuWR0,13775
23
+ agent_chronicle-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
+ agent_chronicle-0.1.0.dist-info/entry_points.txt,sha256=rFrJnbHVocPBF0X07VJCr4yLJ2O_QrRmElS5MdLvpJU,49
25
+ agent_chronicle-0.1.0.dist-info/licenses/LICENSE.txt,sha256=pflS2CCLsAIr6pIhUOZSJudBKOC5c0R-B4s5OcO3Lb4,1086
26
+ agent_chronicle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chronicle = chronicle.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tisha Chawla and Susheem Koul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
chronicle/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Chronicle: Agent Data Recorder and Verification Test Bench."""
2
+
3
+ from chronicle.boundary import boundary
4
+ from chronicle.envelope.schema import (
5
+ ActionResult,
6
+ ContextMetadata,
7
+ Envelope,
8
+ InputState,
9
+ RagChunk,
10
+ SamplingParams,
11
+ ToolCall,
12
+ ToolSchema,
13
+ )
14
+ from chronicle.execution_graph import ExecutionGraph
15
+ from chronicle.redaction import apply_redactors, default_redactors, redact_secrets
16
+ from chronicle.replay.plan import BoundaryMode, ReplayPlan
17
+ from chronicle.session import ChronicleSession, SessionMode, get_session, reset_session
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "ActionResult",
23
+ "apply_redactors",
24
+ "boundary",
25
+ "BoundaryMode",
26
+ "ChronicleSession",
27
+ "ContextMetadata",
28
+ "default_redactors",
29
+ "Envelope",
30
+ "ExecutionGraph",
31
+ "get_session",
32
+ "InputState",
33
+ "RagChunk",
34
+ "redact_secrets",
35
+ "ReplayPlan",
36
+ "reset_session",
37
+ "SamplingParams",
38
+ "SessionMode",
39
+ "ToolCall",
40
+ "ToolSchema",
41
+ ]
chronicle/boundary.py ADDED
@@ -0,0 +1,143 @@
1
+ """Unified boundary decorator for record and replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable, Mapping
7
+ from typing import Any, TypeVar
8
+
9
+ from chronicle.envelope.schema import InputState
10
+ from chronicle.session import (
11
+ SessionMode,
12
+ get_session,
13
+ model_version_from,
14
+ result_to_action_result,
15
+ sampling_params_from,
16
+ )
17
+
18
+ F = TypeVar("F", bound=Callable[..., Any])
19
+
20
+
21
+ def boundary(
22
+ boundary_id: str,
23
+ *,
24
+ kind: str = "custom",
25
+ extract_input: Callable[..., InputState] | None = None,
26
+ extract_result: Callable[[Any], Any] | None = None,
27
+ extract_metadata: Callable[[Any], Mapping[str, Any]] | None = None,
28
+ ) -> Callable[[F], F]:
29
+ """
30
+ Annotate a decision boundary for Chronicle record and replay.
31
+
32
+ LIVE mode: execute function, record envelope
33
+ REPLAY + STUB: return fixture without executing
34
+ REPLAY + LIVE: execute function (cut-point), capture input/result for assertions
35
+
36
+ extract_metadata lets a boundary surface the real model version and sampling
37
+ parameters it used (returned as a mapping) so the envelope pins what actually
38
+ ran. For ``kind="llm"`` these are also auto-detected from conventional keys on
39
+ the returned dict (``model``/``model_version``, ``temperature``, ``top_p``,
40
+ ``max_tokens``, ``seed``); the hook, when given, takes precedence.
41
+ """
42
+
43
+ def decorator(fn: F) -> F:
44
+ @functools.wraps(fn)
45
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
46
+ session = get_session()
47
+
48
+ if session.mode == SessionMode.LIVE:
49
+ return _record_call(
50
+ session, fn, boundary_id, kind, args, kwargs,
51
+ extract_input, extract_result, extract_metadata,
52
+ )
53
+
54
+ invocation_index = session._replay_cursor.get(boundary_id, 0) + 1
55
+ if session.replay_plan.should_stub(boundary_id, invocation_index):
56
+ return session.stub_result(boundary_id, kind)
57
+
58
+ return _live_cutpoint_call(
59
+ session, fn, boundary_id, kind, args, kwargs,
60
+ extract_input, extract_result, invocation_index,
61
+ )
62
+
63
+ return wrapper # type: ignore[return-value]
64
+
65
+ return decorator
66
+
67
+
68
+ def _default_input_state(args: tuple, kwargs: dict) -> InputState:
69
+ graph_state: dict[str, Any] = {}
70
+ if args and isinstance(args[0], dict):
71
+ graph_state = dict(args[0])
72
+ elif kwargs:
73
+ graph_state = dict(kwargs)
74
+ else:
75
+ graph_state = {"args": list(args), "kwargs": kwargs}
76
+
77
+ messages = graph_state.get("messages", [])
78
+ if not messages and "user_message" in graph_state:
79
+ messages = [{"role": "user", "content": graph_state["user_message"]}]
80
+
81
+ return InputState(
82
+ messages=messages,
83
+ system_prompt=graph_state.get("system_prompt"),
84
+ graph_state=graph_state,
85
+ )
86
+
87
+
88
+ def _record_call(session, fn, boundary_id, kind, args, kwargs, extract_input, extract_result, extract_metadata):
89
+ input_state = (
90
+ extract_input(*args, **kwargs)
91
+ if extract_input
92
+ else _default_input_state(args, kwargs)
93
+ )
94
+ result = fn(*args, **kwargs)
95
+ if extract_result:
96
+ result = extract_result(result)
97
+ action_result = result_to_action_result(result, kind)
98
+ model_version, sampling_params = _call_metadata(result, kind, extract_metadata)
99
+ session.record_envelope(
100
+ boundary_id, kind, input_state, action_result,
101
+ model_version=model_version, sampling_params=sampling_params,
102
+ )
103
+ if session.on_crossing is not None:
104
+ session.on_crossing(boundary_id, kind, input_state, result)
105
+ return result
106
+
107
+
108
+ def _call_metadata(result, kind, extract_metadata):
109
+ """Capture the real model version and sampling params for this crossing.
110
+
111
+ Model metadata only applies to ``llm`` boundaries, so tool and router
112
+ results are not scraped for a stray ``model`` key. An explicit
113
+ extract_metadata hook always wins and works for any kind. Returns
114
+ ``(None, None)`` when nothing is available, letting record_envelope fall
115
+ back to the session default.
116
+ """
117
+ if extract_metadata is not None:
118
+ source = extract_metadata(result)
119
+ return model_version_from(source), sampling_params_from(source)
120
+ if kind == "llm":
121
+ return model_version_from(result), sampling_params_from(result)
122
+ return None, None
123
+
124
+
125
+ def _live_cutpoint_call(
126
+ session, fn, boundary_id, kind, args, kwargs,
127
+ extract_input, extract_result, invocation_index,
128
+ ):
129
+ input_state = (
130
+ extract_input(*args, **kwargs)
131
+ if extract_input
132
+ else _default_input_state(args, kwargs)
133
+ )
134
+ session.capture_live_input(boundary_id, invocation_index, input_state)
135
+ result = fn(*args, **kwargs)
136
+ if extract_result:
137
+ result = extract_result(result)
138
+ session.capture_live_result(boundary_id, invocation_index, result)
139
+ session.next_invocation(boundary_id)
140
+ session._replay_cursor[boundary_id] = invocation_index
141
+ if session.on_crossing is not None:
142
+ session.on_crossing(boundary_id, kind, input_state, result)
143
+ return result