fathom-read 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fathom_read-0.1.0/LICENSE +21 -0
- fathom_read-0.1.0/PKG-INFO +141 -0
- fathom_read-0.1.0/README.md +98 -0
- fathom_read-0.1.0/pyproject.toml +38 -0
- fathom_read-0.1.0/setup.cfg +4 -0
- fathom_read-0.1.0/src/fathom_read/__init__.py +17 -0
- fathom_read-0.1.0/src/fathom_read/adapters/__init__.py +47 -0
- fathom_read-0.1.0/src/fathom_read/adapters/_tools.py +88 -0
- fathom_read-0.1.0/src/fathom_read/adapters/crewai.py +34 -0
- fathom_read-0.1.0/src/fathom_read/adapters/dbos.py +25 -0
- fathom_read-0.1.0/src/fathom_read/adapters/edits.py +51 -0
- fathom_read-0.1.0/src/fathom_read/adapters/events.py +17 -0
- fathom_read-0.1.0/src/fathom_read/adapters/langgraph.py +56 -0
- fathom_read-0.1.0/src/fathom_read/adapters/letta.py +32 -0
- fathom_read-0.1.0/src/fathom_read/adapters/openinference.py +66 -0
- fathom_read-0.1.0/src/fathom_read/cli.py +128 -0
- fathom_read-0.1.0/src/fathom_read/client.py +40 -0
- fathom_read-0.1.0/src/fathom_read/examples/crewai_events.json +31 -0
- fathom_read-0.1.0/src/fathom_read/examples/knowledge_update.json +26 -0
- fathom_read-0.1.0/src/fathom_read/examples/langgraph_history.json +37 -0
- fathom_read-0.1.0/src/fathom_read/examples/order_duplicate.json +31 -0
- fathom_read-0.1.0/src/fathom_read/examples/rename_coherent.json +63 -0
- fathom_read-0.1.0/src/fathom_read/examples/rename_starved.json +63 -0
- fathom_read-0.1.0/src/fathom_read/ops.py +79 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/PKG-INFO +141 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/SOURCES.txt +30 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/dependency_links.txt +1 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/entry_points.txt +2 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/requires.txt +3 -0
- fathom_read-0.1.0/src/fathom_read.egg-info/top_level.txt +1 -0
- fathom_read-0.1.0/tests/test_adapters.py +53 -0
- fathom_read-0.1.0/tests/test_client.py +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter Galligan, Embedded Risk Analytics
|
|
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.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fathom-read
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Catch the step where an AI agent contradicts a decision it already made. Adapters and a CLI for the Fathom committed-state read over LangGraph, CrewAI, Letta, OpenInference, DBOS, and coding-agent traces.
|
|
5
|
+
Author-email: Embedded Risk Analytics <contact@embeddedriskanalytics.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Peter Galligan, Embedded Risk Analytics
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://embeddedriskanalytics.com
|
|
29
|
+
Project-URL: Repository, https://github.com/ERA-Fathom/fathom
|
|
30
|
+
Project-URL: Research, https://embeddedriskanalytics.com/research.html
|
|
31
|
+
Keywords: ai-agents,llm,langgraph,crewai,letta,openinference,agent-memory,context-engineering,agent-evaluation,coherence
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Topic :: Software Development :: Testing
|
|
36
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# fathom-read
|
|
45
|
+
|
|
46
|
+
**Catch the step where an AI agent contradicts a decision it already made.**
|
|
47
|
+
|
|
48
|
+
On a long task, an agent loses track of what it already decided and starts acting against it. It renames `guest_id` to `customer_id` at step 1, then writes new code against `guest_id` at step 6. The change compiles, imports, and passes the tests. It fails at runtime.
|
|
49
|
+
|
|
50
|
+
`fathom-read` turns the traces your framework already records into an action stream and sends it to the Fathom read, which reconstructs the state the agent committed and flags the step that contradicts it. Deterministic. No model access. Nothing runs in your production path.
|
|
51
|
+
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
pip install fathom-read
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Run
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
fathom demo # the bundled rename example, coherent and not
|
|
64
|
+
fathom read trace.json --supersede guest_id=customer_id # your own trace
|
|
65
|
+
fathom read history.json --format langgraph # or name the format
|
|
66
|
+
fathom read trace.json --ops # see the action stream before anything is sent
|
|
67
|
+
fathom formats # the formats it reads
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`fathom read` exits 0 when the committed state is coherent and 2 when it finds a contradiction, so it drops into a test suite or a CI step as it is. Add `--json` for a machine-readable verdict.
|
|
71
|
+
|
|
72
|
+
The package ships with a demo key that is rate-limited per day. For your own key, which lifts the limit and keeps your traces on a private tier, write to [contact@embeddedriskanalytics.com](mailto:contact@embeddedriskanalytics.com?subject=fathom-read%20key) and set `FATHOM_API_KEY`. `--ops` shows exactly what would be sent: the ops the adapter produced, and nothing else.
|
|
73
|
+
|
|
74
|
+
## What it reads
|
|
75
|
+
|
|
76
|
+
| Format | What you export | How |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `langgraph` | The checkpoint lineage | `[{"values": s.values, "step": s.metadata["step"]} for s in graph.get_state_history(config)]` |
|
|
79
|
+
| `openinference` | The spans Arize Phoenix stores | Export the trace's spans as JSON; only TOOL spans matter |
|
|
80
|
+
| `crewai` | The crew's event log | A listener on the event bus, capturing `tool_usage_finished`, `tool_usage_error`, `task_completed` |
|
|
81
|
+
| `letta` | Blocks, passages, and the memory-edit tool calls | `agents.blocks.list`, `agents.passages.list`, the tool calls from `agents.messages.list` |
|
|
82
|
+
| `dbos` | A workflow's step stream | `{"workflow_id": ..., "steps": [{"step_name", "args", "result", "ok"}]}` |
|
|
83
|
+
| `edits` | A coding agent's edit log | `{"initial_files": {...}, "edits": [{"tool": "str_replace_editor", "args": {...}, "ok": true}]}` |
|
|
84
|
+
| `events` | The native op stream | One op per line: `{"op": "set", "kind": "file", "key": "a.py", "value": "...", "ok": true}` |
|
|
85
|
+
|
|
86
|
+
Your tools have their own names. Map them once with `--map tools.json`:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{"save_decision": {"op": "set", "kind": "decision", "key": "topic", "value": "text"},
|
|
90
|
+
"book_seat": {"op": "add", "kind": "flight", "key": "seats", "value": "seat"},
|
|
91
|
+
"confirm_booking": {"op": "commit", "kind": "flight", "key": "booking"}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What it finds
|
|
95
|
+
|
|
96
|
+
| Finding | The agent... |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `stale_reference` | acts on a fact it already removed or renamed away |
|
|
99
|
+
| `superseded_value` | writes or answers with a value it already replaced |
|
|
100
|
+
| `authored_contradiction` | reintroduces a token into a record it had already migrated |
|
|
101
|
+
| `residual` | ends the run with a record still carrying a value it replaced elsewhere |
|
|
102
|
+
| `duplicate_commit` | adds an entity a collection already holds |
|
|
103
|
+
| `post_commit_mutation` | changes a thing after committing it |
|
|
104
|
+
|
|
105
|
+
Every finding cites the earlier step it contradicts, so the readout is a diff between what the agent decided and what it did.
|
|
106
|
+
|
|
107
|
+
## How it reads
|
|
108
|
+
|
|
109
|
+
The read folds the agent's successful actions into a ledger of committed facts and checks every later action against the ledger. Two rules make this a reconstruction rather than a transcript. A failed action is a no-op: an edit the tool rejected leaves nothing behind. And the read consults only the agent's own actions and their results, never an answer key, so it attaches the same way on any framework. The adapters and the CLI in this repository build the action stream; the read itself runs in ERA's service.
|
|
110
|
+
|
|
111
|
+
## Use it from Python
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from fathom_read import Op, read
|
|
115
|
+
|
|
116
|
+
ops = [
|
|
117
|
+
Op("set", "fact", "user.city", value="Denver"),
|
|
118
|
+
Op("set", "fact", "user.city", value="Austin"),
|
|
119
|
+
Op("answer", "fact", "user.city", value="The user lives in Denver."),
|
|
120
|
+
]
|
|
121
|
+
verdict = read(ops) # uses FATHOM_API_KEY, or the demo key
|
|
122
|
+
for f in verdict.findings:
|
|
123
|
+
print(f.kind, f.step, f.detail)
|
|
124
|
+
# superseded_value 2 step 2 answers 'Denver' for fact 'user.city', a value the agent replaced with 'Austin' at step 1.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## What it does not do
|
|
128
|
+
|
|
129
|
+
It does not run your agent, call a model, or need one. It does not say why the agent contradicted itself or which repair would fix it; that is the [design-partner engagement](https://embeddedriskanalytics.com/contact.html). It reads agents whose committed state lives in tool calls, checkpoints, memory writes, or edits; an agent that keeps state only in free-text logs is out of scope.
|
|
130
|
+
|
|
131
|
+
## Research
|
|
132
|
+
|
|
133
|
+
The read comes out of the Fathom program at [Embedded Risk Analytics](https://embeddedriskanalytics.com). Case studies on LangGraph, CrewAI, Letta, OpenHands, Agent-E, ContextPilot, and τ-bench are at [embeddedriskanalytics.com/research](https://embeddedriskanalytics.com/research.html). The theory is in [Records, Reflexive Modeling, and the Conditions for Stable Physical Histories](https://ssrn.com/abstract=6683578) (SSRN, 2026). See [CITATION.cff](CITATION.cff).
|
|
134
|
+
|
|
135
|
+
## Send us a trace
|
|
136
|
+
|
|
137
|
+
If you run long-horizon agents and want a readout on your own traces, send a batch: [embeddedriskanalytics.com/contact](https://embeddedriskanalytics.com/contact.html).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT. Fathom is a trademark of Embedded Risk Analytics.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# fathom-read
|
|
2
|
+
|
|
3
|
+
**Catch the step where an AI agent contradicts a decision it already made.**
|
|
4
|
+
|
|
5
|
+
On a long task, an agent loses track of what it already decided and starts acting against it. It renames `guest_id` to `customer_id` at step 1, then writes new code against `guest_id` at step 6. The change compiles, imports, and passes the tests. It fails at runtime.
|
|
6
|
+
|
|
7
|
+
`fathom-read` turns the traces your framework already records into an action stream and sends it to the Fathom read, which reconstructs the state the agent committed and flags the step that contradicts it. Deterministic. No model access. Nothing runs in your production path.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install fathom-read
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Run
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
fathom demo # the bundled rename example, coherent and not
|
|
21
|
+
fathom read trace.json --supersede guest_id=customer_id # your own trace
|
|
22
|
+
fathom read history.json --format langgraph # or name the format
|
|
23
|
+
fathom read trace.json --ops # see the action stream before anything is sent
|
|
24
|
+
fathom formats # the formats it reads
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`fathom read` exits 0 when the committed state is coherent and 2 when it finds a contradiction, so it drops into a test suite or a CI step as it is. Add `--json` for a machine-readable verdict.
|
|
28
|
+
|
|
29
|
+
The package ships with a demo key that is rate-limited per day. For your own key, which lifts the limit and keeps your traces on a private tier, write to [contact@embeddedriskanalytics.com](mailto:contact@embeddedriskanalytics.com?subject=fathom-read%20key) and set `FATHOM_API_KEY`. `--ops` shows exactly what would be sent: the ops the adapter produced, and nothing else.
|
|
30
|
+
|
|
31
|
+
## What it reads
|
|
32
|
+
|
|
33
|
+
| Format | What you export | How |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `langgraph` | The checkpoint lineage | `[{"values": s.values, "step": s.metadata["step"]} for s in graph.get_state_history(config)]` |
|
|
36
|
+
| `openinference` | The spans Arize Phoenix stores | Export the trace's spans as JSON; only TOOL spans matter |
|
|
37
|
+
| `crewai` | The crew's event log | A listener on the event bus, capturing `tool_usage_finished`, `tool_usage_error`, `task_completed` |
|
|
38
|
+
| `letta` | Blocks, passages, and the memory-edit tool calls | `agents.blocks.list`, `agents.passages.list`, the tool calls from `agents.messages.list` |
|
|
39
|
+
| `dbos` | A workflow's step stream | `{"workflow_id": ..., "steps": [{"step_name", "args", "result", "ok"}]}` |
|
|
40
|
+
| `edits` | A coding agent's edit log | `{"initial_files": {...}, "edits": [{"tool": "str_replace_editor", "args": {...}, "ok": true}]}` |
|
|
41
|
+
| `events` | The native op stream | One op per line: `{"op": "set", "kind": "file", "key": "a.py", "value": "...", "ok": true}` |
|
|
42
|
+
|
|
43
|
+
Your tools have their own names. Map them once with `--map tools.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{"save_decision": {"op": "set", "kind": "decision", "key": "topic", "value": "text"},
|
|
47
|
+
"book_seat": {"op": "add", "kind": "flight", "key": "seats", "value": "seat"},
|
|
48
|
+
"confirm_booking": {"op": "commit", "kind": "flight", "key": "booking"}}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What it finds
|
|
52
|
+
|
|
53
|
+
| Finding | The agent... |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `stale_reference` | acts on a fact it already removed or renamed away |
|
|
56
|
+
| `superseded_value` | writes or answers with a value it already replaced |
|
|
57
|
+
| `authored_contradiction` | reintroduces a token into a record it had already migrated |
|
|
58
|
+
| `residual` | ends the run with a record still carrying a value it replaced elsewhere |
|
|
59
|
+
| `duplicate_commit` | adds an entity a collection already holds |
|
|
60
|
+
| `post_commit_mutation` | changes a thing after committing it |
|
|
61
|
+
|
|
62
|
+
Every finding cites the earlier step it contradicts, so the readout is a diff between what the agent decided and what it did.
|
|
63
|
+
|
|
64
|
+
## How it reads
|
|
65
|
+
|
|
66
|
+
The read folds the agent's successful actions into a ledger of committed facts and checks every later action against the ledger. Two rules make this a reconstruction rather than a transcript. A failed action is a no-op: an edit the tool rejected leaves nothing behind. And the read consults only the agent's own actions and their results, never an answer key, so it attaches the same way on any framework. The adapters and the CLI in this repository build the action stream; the read itself runs in ERA's service.
|
|
67
|
+
|
|
68
|
+
## Use it from Python
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from fathom_read import Op, read
|
|
72
|
+
|
|
73
|
+
ops = [
|
|
74
|
+
Op("set", "fact", "user.city", value="Denver"),
|
|
75
|
+
Op("set", "fact", "user.city", value="Austin"),
|
|
76
|
+
Op("answer", "fact", "user.city", value="The user lives in Denver."),
|
|
77
|
+
]
|
|
78
|
+
verdict = read(ops) # uses FATHOM_API_KEY, or the demo key
|
|
79
|
+
for f in verdict.findings:
|
|
80
|
+
print(f.kind, f.step, f.detail)
|
|
81
|
+
# superseded_value 2 step 2 answers 'Denver' for fact 'user.city', a value the agent replaced with 'Austin' at step 1.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What it does not do
|
|
85
|
+
|
|
86
|
+
It does not run your agent, call a model, or need one. It does not say why the agent contradicted itself or which repair would fix it; that is the [design-partner engagement](https://embeddedriskanalytics.com/contact.html). It reads agents whose committed state lives in tool calls, checkpoints, memory writes, or edits; an agent that keeps state only in free-text logs is out of scope.
|
|
87
|
+
|
|
88
|
+
## Research
|
|
89
|
+
|
|
90
|
+
The read comes out of the Fathom program at [Embedded Risk Analytics](https://embeddedriskanalytics.com). Case studies on LangGraph, CrewAI, Letta, OpenHands, Agent-E, ContextPilot, and τ-bench are at [embeddedriskanalytics.com/research](https://embeddedriskanalytics.com/research.html). The theory is in [Records, Reflexive Modeling, and the Conditions for Stable Physical Histories](https://ssrn.com/abstract=6683578) (SSRN, 2026). See [CITATION.cff](CITATION.cff).
|
|
91
|
+
|
|
92
|
+
## Send us a trace
|
|
93
|
+
|
|
94
|
+
If you run long-horizon agents and want a readout on your own traces, send a batch: [embeddedriskanalytics.com/contact](https://embeddedriskanalytics.com/contact.html).
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT. Fathom is a trademark of Embedded Risk Analytics.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fathom-read"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Catch the step where an AI agent contradicts a decision it already made. Adapters and a CLI for the Fathom committed-state read over LangGraph, CrewAI, Letta, OpenInference, DBOS, and coding-agent traces."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
|
+
authors = [{name = "Embedded Risk Analytics", email = "contact@embeddedriskanalytics.com"}]
|
|
13
|
+
keywords = ["ai-agents", "llm", "langgraph", "crewai", "letta", "openinference", "agent-memory", "context-engineering", "agent-evaluation", "coherence"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Testing",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://embeddedriskanalytics.com"
|
|
28
|
+
Repository = "https://github.com/ERA-Fathom/fathom"
|
|
29
|
+
Research = "https://embeddedriskanalytics.com/research.html"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
fathom = "fathom_read.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
fathom_read = ["examples/*.json"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""fathom-read: catch the step where an AI agent contradicts a decision it already made."""
|
|
2
|
+
from .ops import Op, Finding, Verdict # noqa: F401
|
|
3
|
+
from .client import read, ReadError # noqa: F401
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["Op", "Finding", "Verdict", "read", "ReadError", "read_file", "load_ops"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_ops(path: str, fmt: str = "auto", mapping_path: str = None):
|
|
10
|
+
"""Turn a trace file in any supported format into the op stream."""
|
|
11
|
+
from .cli import load_ops as _load
|
|
12
|
+
return _load(path, fmt, mapping_path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_file(path: str, fmt: str = "auto", supersede=None, mapping_path: str = None, **kw) -> Verdict:
|
|
16
|
+
"""Read a trace file and return the hosted read's verdict."""
|
|
17
|
+
return read(load_ops(path, fmt, mapping_path), supersede=supersede, **kw)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Adapters turn what a framework already records into the op stream the read consumes."""
|
|
2
|
+
from . import events, edits, openinference, langgraph, crewai, letta, dbos # noqa: F401
|
|
3
|
+
|
|
4
|
+
FORMATS = {
|
|
5
|
+
"events": events,
|
|
6
|
+
"edits": edits,
|
|
7
|
+
"openinference": openinference,
|
|
8
|
+
"langgraph": langgraph,
|
|
9
|
+
"crewai": crewai,
|
|
10
|
+
"letta": letta,
|
|
11
|
+
"dbos": dbos,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect(doc) -> str:
|
|
16
|
+
"""Guess the format of a loaded JSON document."""
|
|
17
|
+
if isinstance(doc, dict):
|
|
18
|
+
if "ops" in doc:
|
|
19
|
+
return "events"
|
|
20
|
+
if "spans" in doc or "resourceSpans" in doc:
|
|
21
|
+
return "openinference"
|
|
22
|
+
if "edits" in doc or "initial_files" in doc:
|
|
23
|
+
return "edits"
|
|
24
|
+
if "blocks" in doc or "passages" in doc:
|
|
25
|
+
return "letta"
|
|
26
|
+
if "steps" in doc and "workflow_id" in doc:
|
|
27
|
+
return "dbos"
|
|
28
|
+
if "events" in doc:
|
|
29
|
+
return "crewai"
|
|
30
|
+
if "snapshots" in doc or "history" in doc:
|
|
31
|
+
return "langgraph"
|
|
32
|
+
if isinstance(doc, list) and doc:
|
|
33
|
+
first = doc[0]
|
|
34
|
+
if isinstance(first, dict):
|
|
35
|
+
if "op" in first and "key" in first:
|
|
36
|
+
return "events"
|
|
37
|
+
if "attributes" in first or "span_kind" in first:
|
|
38
|
+
return "openinference"
|
|
39
|
+
if "values" in first and ("next" in first or "config" in first or "metadata" in first or "step" in first):
|
|
40
|
+
return "langgraph"
|
|
41
|
+
if "type" in first and ("tool_name" in first or "task_name" in first or first.get("type", "").startswith(("tool_", "task_"))):
|
|
42
|
+
return "crewai"
|
|
43
|
+
if "step_name" in first:
|
|
44
|
+
return "dbos"
|
|
45
|
+
if "tool" in first and "args" in first:
|
|
46
|
+
return "edits"
|
|
47
|
+
raise ValueError("could not detect the trace format; pass --format")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared mapping from tool calls to ops. Adapters that see (tool name, args, result, ok) use this."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from ..ops import Op
|
|
8
|
+
|
|
9
|
+
# Default mapping: tool name -> how to build an op from its arguments.
|
|
10
|
+
# "key" and "value" name the argument fields; "kind" labels the fact; "op" is the ledger op.
|
|
11
|
+
DEFAULT_MAP: Dict[str, Dict[str, Any]] = {
|
|
12
|
+
# coding agents (OpenHands, CodeAct-style editors); content folding lives in the edits adapter
|
|
13
|
+
"str_replace_editor": {"op": "edit"},
|
|
14
|
+
"edit_file": {"op": "edit"},
|
|
15
|
+
"write_file": {"op": "set", "kind": "file", "key": "path", "value": "content"},
|
|
16
|
+
"create_file": {"op": "set", "kind": "file", "key": "path", "value": "content"},
|
|
17
|
+
"delete_file": {"op": "remove", "kind": "file", "key": "path"},
|
|
18
|
+
# record-keeping crews and workflows
|
|
19
|
+
"write_record": {"op": "set", "kind": "record", "key": "record", "value": "content"},
|
|
20
|
+
"update_record": {"op": "set", "kind": "record", "key": "record", "value": "content"},
|
|
21
|
+
"commit_record": {"op": "set", "kind": "record", "key": "record", "value": "content"},
|
|
22
|
+
"set_value": {"op": "set", "kind": "fact", "key": "key", "value": "value"},
|
|
23
|
+
"update_value": {"op": "set", "kind": "fact", "key": "key", "value": "value"},
|
|
24
|
+
"delete_value": {"op": "remove", "kind": "fact", "key": "key"},
|
|
25
|
+
"rename_key": {"op": "rename", "kind": "fact", "key": "old", "to": "new"},
|
|
26
|
+
# Letta memory tools
|
|
27
|
+
"core_memory_append": {"op": "append", "kind": "block", "key": "label", "value": "content"},
|
|
28
|
+
"core_memory_replace": {"op": "set", "kind": "block", "key": "label", "value": "new_content"},
|
|
29
|
+
"memory_replace": {"op": "set", "kind": "block", "key": "label", "value": "new_str"},
|
|
30
|
+
"memory_insert": {"op": "append", "kind": "block", "key": "label", "value": "new_str"},
|
|
31
|
+
"memory_rethink": {"op": "set", "kind": "block", "key": "label", "value": "new_memory"},
|
|
32
|
+
"archival_memory_insert": {"op": "add", "kind": "passage", "key": "archival", "value": "content"},
|
|
33
|
+
# ordering and transactions (web agents, customer-service agents)
|
|
34
|
+
"add_item": {"op": "add", "kind": "order", "key": "cart", "value": "item"},
|
|
35
|
+
"add_to_cart": {"op": "add", "kind": "order", "key": "cart", "value": "item"},
|
|
36
|
+
"remove_item": {"op": "remove_member", "kind": "order", "key": "cart", "value": "item"},
|
|
37
|
+
"place_order": {"op": "commit", "kind": "order", "key": "order"},
|
|
38
|
+
"checkout": {"op": "commit", "kind": "order", "key": "order"},
|
|
39
|
+
"submit": {"op": "commit", "kind": "order", "key": "order"},
|
|
40
|
+
"book_reservation": {"op": "set", "kind": "reservation", "key": "reservation_id", "value": "details"},
|
|
41
|
+
"cancel_reservation": {"op": "remove", "kind": "reservation", "key": "reservation_id"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _first(args: Dict[str, Any], *names: str) -> Optional[str]:
|
|
46
|
+
for n in names:
|
|
47
|
+
if n in args and args[n] is not None:
|
|
48
|
+
v = args[n]
|
|
49
|
+
return v if isinstance(v, str) else json.dumps(v, sort_keys=True)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_map(path: Optional[str]) -> Dict[str, Dict[str, Any]]:
|
|
54
|
+
m = dict(DEFAULT_MAP)
|
|
55
|
+
if path:
|
|
56
|
+
with open(path) as f:
|
|
57
|
+
m.update(json.load(f))
|
|
58
|
+
return m
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def op_from_tool(name: str, args: Any, ok: bool, step: int, mapping: Dict[str, Dict[str, Any]],
|
|
62
|
+
source: str = "") -> Optional[Op]:
|
|
63
|
+
"""Build an op from one tool call, or None when the tool does not write committed state."""
|
|
64
|
+
name = (name or "").strip().lower().replace(" ", "_")
|
|
65
|
+
spec = mapping.get(name)
|
|
66
|
+
if spec is None:
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(args, dict):
|
|
69
|
+
try:
|
|
70
|
+
args = json.loads(args) if args else {}
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
args = {}
|
|
73
|
+
kind = spec.get("kind", "fact")
|
|
74
|
+
op = spec["op"]
|
|
75
|
+
key = _first(args, spec.get("key", "key"), "key", "record", "path", "label", "name", "id") or spec.get("key", name)
|
|
76
|
+
value = _first(args, spec.get("value", "value"), "value", "content", "new_str", "new_content", "text")
|
|
77
|
+
if op == "edit":
|
|
78
|
+
return None # the edits adapter folds these with file contents
|
|
79
|
+
if op == "append":
|
|
80
|
+
return Op("set", kind, key, value=value, ok=ok, step=step, source=source or name)
|
|
81
|
+
if op == "remove_member":
|
|
82
|
+
return Op("remove", kind, f"{key}:{value}", ok=ok, step=step, source=source or name)
|
|
83
|
+
if op == "rename":
|
|
84
|
+
to = _first(args, spec.get("to", "new"), "new", "to")
|
|
85
|
+
return Op("rename", kind, key, to=to, ok=ok, step=step, source=source or name)
|
|
86
|
+
if op == "commit":
|
|
87
|
+
return Op("commit", kind, key, ok=ok, step=step, source=source or name)
|
|
88
|
+
return Op(op, kind, key, value=value, ok=ok, step=step, source=source or name)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrewAI event logs: the events the crew's event bus already emits.
|
|
3
|
+
|
|
4
|
+
Input: a list of events (or {"events": [...]}) with "type" in tool_usage_finished,
|
|
5
|
+
tool_usage_error, task_completed; tool events carry tool_name and tool_args. A write tool's
|
|
6
|
+
arguments name the record and the content committed to it; a task_completed event commits
|
|
7
|
+
the task's output under the task name. Capture them with a listener on crewai's event bus.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, List
|
|
12
|
+
|
|
13
|
+
from ..ops import Op
|
|
14
|
+
from ._tools import op_from_tool, load_map
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load(doc: Any, mapping_path: str = None, **_) -> List[Op]:
|
|
18
|
+
events = doc.get("events", []) if isinstance(doc, dict) else doc
|
|
19
|
+
mapping = load_map(mapping_path)
|
|
20
|
+
ops: List[Op] = []
|
|
21
|
+
for i, ev in enumerate(events):
|
|
22
|
+
t = (ev.get("type") or ev.get("_type") or "").replace("Event", "").lower()
|
|
23
|
+
t = {"toolusagefinished": "tool_usage_finished", "toolusageerror": "tool_usage_error",
|
|
24
|
+
"taskcompleted": "task_completed"}.get(t, t)
|
|
25
|
+
if t in ("tool_usage_finished", "tool_usage_error"):
|
|
26
|
+
ok = t == "tool_usage_finished" and not bool(ev.get("failure"))
|
|
27
|
+
op = op_from_tool(ev.get("tool_name", ""), ev.get("tool_args"), ok, i, mapping, source=t)
|
|
28
|
+
if op is not None:
|
|
29
|
+
ops.append(op)
|
|
30
|
+
elif t == "task_completed":
|
|
31
|
+
name = ev.get("task_name") or ev.get("task") or f"task_{i}"
|
|
32
|
+
out = ev.get("output") or ev.get("raw") or ""
|
|
33
|
+
ops.append(Op("set", "task", str(name), value=str(out), ok=True, step=i, source="task_completed"))
|
|
34
|
+
return ops
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DBOS step streams: the inputs and outputs of each step in a workflow, in order.
|
|
3
|
+
|
|
4
|
+
Input: {"workflow_id": "...", "steps": [{"step_name", "args", "result", "ok"}]} or a bare list.
|
|
5
|
+
Step names map to ops through the tool mapping (pass --map for your own step names).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, List
|
|
10
|
+
|
|
11
|
+
from ..ops import Op
|
|
12
|
+
from ._tools import op_from_tool, load_map
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load(doc: Any, mapping_path: str = None, **_) -> List[Op]:
|
|
16
|
+
steps = doc.get("steps", []) if isinstance(doc, dict) else doc
|
|
17
|
+
mapping = load_map(mapping_path)
|
|
18
|
+
ops: List[Op] = []
|
|
19
|
+
for i, s in enumerate(steps):
|
|
20
|
+
ok = bool(s.get("ok", True)) and not bool(s.get("error"))
|
|
21
|
+
args = s.get("args") or s.get("inputs") or {}
|
|
22
|
+
op = op_from_tool(s.get("step_name") or s.get("name", ""), args, ok, i, mapping, source="step")
|
|
23
|
+
if op is not None:
|
|
24
|
+
ops.append(op)
|
|
25
|
+
return ops
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coding-agent edit logs: str_replace-style edits over a set of files.
|
|
3
|
+
|
|
4
|
+
Input: {"initial_files": {path: content}, "edits": [{"tool": "str_replace_editor",
|
|
5
|
+
"args": {"path", "old_str", "new_str"}, "ok": true}, ...], "done": true}
|
|
6
|
+
or a bare list of edit records. The adapter folds each successful edit onto the file it
|
|
7
|
+
targets and emits one "set" op per edit with the file's new content, so the read can see what
|
|
8
|
+
the agent actually left in each file. A rejected edit changes nothing.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from ..ops import Op
|
|
15
|
+
|
|
16
|
+
EDIT_TOOLS = {"str_replace_editor", "edit_file", "str_replace", "apply_patch"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(doc: Any, **_) -> List[Op]:
|
|
20
|
+
if isinstance(doc, list):
|
|
21
|
+
doc = {"edits": doc}
|
|
22
|
+
files: Dict[str, str] = dict(doc.get("initial_files", {}))
|
|
23
|
+
ops: List[Op] = []
|
|
24
|
+
step = 0
|
|
25
|
+
for path, content in files.items():
|
|
26
|
+
ops.append(Op("set", "file", path, value=content, ok=True, step=step, source="initial"))
|
|
27
|
+
step += 1
|
|
28
|
+
for e in doc.get("edits", []):
|
|
29
|
+
tool = (e.get("tool") or e.get("name") or "").strip().lower()
|
|
30
|
+
args = e.get("args") or e.get("parameters") or {}
|
|
31
|
+
ok = bool(e.get("ok", True)) and not bool(e.get("error"))
|
|
32
|
+
if tool in EDIT_TOOLS:
|
|
33
|
+
path, old, new = args.get("path"), args.get("old_str"), args.get("new_str")
|
|
34
|
+
applied = False
|
|
35
|
+
if ok and path in files and old and old in files[path]:
|
|
36
|
+
files[path] = files[path].replace(old, new or "")
|
|
37
|
+
applied = True
|
|
38
|
+
ops.append(Op("set", "file", str(path), value=files.get(str(path)), ok=applied, step=step, source=tool))
|
|
39
|
+
elif tool in ("write_file", "create_file"):
|
|
40
|
+
if ok:
|
|
41
|
+
files[str(args.get("path"))] = str(args.get("content", ""))
|
|
42
|
+
ops.append(Op("set", "file", str(args.get("path")), value=str(args.get("content", "")), ok=ok, step=step, source=tool))
|
|
43
|
+
elif tool in ("delete_file",):
|
|
44
|
+
if ok:
|
|
45
|
+
files.pop(str(args.get("path")), None)
|
|
46
|
+
ops.append(Op("remove", "file", str(args.get("path")), ok=ok, step=step, source=tool))
|
|
47
|
+
else:
|
|
48
|
+
step += 1
|
|
49
|
+
continue
|
|
50
|
+
step += 1
|
|
51
|
+
return ops
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Native format: a JSON list (or {"ops": [...]}) of op dicts, or JSON Lines with one op per line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, List
|
|
6
|
+
|
|
7
|
+
from ..ops import Op
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load(doc: Any, **_) -> List[Op]:
|
|
11
|
+
if isinstance(doc, dict):
|
|
12
|
+
doc = doc.get("ops", [])
|
|
13
|
+
return [Op.from_dict(d) for d in doc]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_jsonl(text: str) -> List[Op]:
|
|
17
|
+
return [Op.from_dict(json.loads(line)) for line in text.splitlines() if line.strip()]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangGraph state history: the checkpoint lineage from graph.get_state_history(config).
|
|
3
|
+
|
|
4
|
+
Input: a list of snapshots, each with "values" (the channel values at that step), newest
|
|
5
|
+
first as LangGraph returns them, or {"snapshots": [...]}. The adapter diffs consecutive
|
|
6
|
+
snapshots and emits one op per changed channel: a scalar or string channel that changed is a
|
|
7
|
+
"set"; a list channel that grew is an "add" per new element. Every checkpoint is a
|
|
8
|
+
successful commit, so ok is always true here; the read then checks what those commits say
|
|
9
|
+
against one another.
|
|
10
|
+
|
|
11
|
+
To export from a running graph:
|
|
12
|
+
import json
|
|
13
|
+
history = [{"values": s.values, "step": s.metadata.get("step")} for s in graph.get_state_history(config)]
|
|
14
|
+
json.dump(history, open("history.json", "w"), default=str)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, List
|
|
20
|
+
|
|
21
|
+
from ..ops import Op
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_text(v: Any) -> str:
|
|
25
|
+
return v if isinstance(v, str) else json.dumps(v, sort_keys=True, default=str)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(doc: Any, **_) -> List[Op]:
|
|
29
|
+
snaps = doc.get("snapshots", doc.get("history", [])) if isinstance(doc, dict) else doc
|
|
30
|
+
snaps = list(snaps)
|
|
31
|
+
if len(snaps) > 1:
|
|
32
|
+
# get_state_history returns newest first; put them in run order.
|
|
33
|
+
s0 = snaps[0].get("step", snaps[0].get("metadata", {}).get("step"))
|
|
34
|
+
s1 = snaps[-1].get("step", snaps[-1].get("metadata", {}).get("step"))
|
|
35
|
+
if s0 is None or s1 is None or s0 > s1:
|
|
36
|
+
snaps = snaps[::-1]
|
|
37
|
+
ops: List[Op] = []
|
|
38
|
+
prev: dict = {}
|
|
39
|
+
step = 0
|
|
40
|
+
for snap in snaps:
|
|
41
|
+
values = snap.get("values", {}) or {}
|
|
42
|
+
for chan, val in values.items():
|
|
43
|
+
old = prev.get(chan)
|
|
44
|
+
if isinstance(val, list) and isinstance(old, list) and len(val) >= len(old) and val[:len(old)] == old:
|
|
45
|
+
for item in val[len(old):]:
|
|
46
|
+
ops.append(Op("add", "channel", chan, value=_as_text(item), step=step, source="checkpoint"))
|
|
47
|
+
step += 1
|
|
48
|
+
elif isinstance(val, list) and old is None:
|
|
49
|
+
for item in val:
|
|
50
|
+
ops.append(Op("add", "channel", chan, value=_as_text(item), step=step, source="checkpoint"))
|
|
51
|
+
step += 1
|
|
52
|
+
elif val != old:
|
|
53
|
+
ops.append(Op("set", "channel", chan, value=_as_text(val), step=step, source="checkpoint"))
|
|
54
|
+
step += 1
|
|
55
|
+
prev = values
|
|
56
|
+
return ops
|