agentdynamics 0.4.1__tar.gz → 0.5.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.
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/PKG-INFO +39 -7
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/README.md +38 -6
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/__init__.py +3 -3
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/__main__.py +58 -11
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/analysis.py +61 -9
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/autotrace.py +36 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/langsmith.py +8 -3
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/otlp.py +19 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/spans.py +30 -3
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/engine.py +24 -2
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/govern.py +69 -28
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/server.py +74 -7
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/store.py +45 -6
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/app.js +53 -7
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/PKG-INFO +39 -7
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/SOURCES.txt +5 -1
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/pyproject.toml +1 -1
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_aegis_integration.py +1 -1
- agentdynamics-0.5.0/tests/test_console_ui.py +253 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_core.py +51 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_ecosystem.py +12 -2
- agentdynamics-0.5.0/tests/test_outcomes.py +288 -0
- agentdynamics-0.5.0/tests/test_policy_coverage.py +222 -0
- agentdynamics-0.5.0/tests/test_token_accounting.py +145 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/LICENSE +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/__init__.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/aegis_audit.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/claude_code.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/generic.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/inbox.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/collectors/langfuse.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/config.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/flows.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/integrations/__init__.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/integrations/aegis.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/phases.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/pricing.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/privacy.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/sdk.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/slo.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/charts.js +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/index.html +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics/web/style.css +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/entry_points.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/requires.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/agentdynamics.egg-info/top_level.txt +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/setup.cfg +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_autotrace.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_integrations.py +0 -0
- {agentdynamics-0.4.1 → agentdynamics-0.5.0}/tests/test_live_anthropic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdynamics
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
|
|
@@ -61,9 +61,15 @@ def handle_ticket(question):
|
|
|
61
61
|
plan = client.messages.create(...)
|
|
62
62
|
with agentdynamics.span("act"):
|
|
63
63
|
lookup_order(order_id) # decorate tools with @agentdynamics.tool
|
|
64
|
+
if escalated:
|
|
65
|
+
agentdynamics.outcome("failed", reason="customer escalated") # state it, don't let it be guessed
|
|
64
66
|
...
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
|
|
70
|
+
which outcomes were stated and which were guessed; grades can also be posted afterwards by a person or an
|
|
71
|
+
eval pipeline (`POST /api/outcomes`).
|
|
72
|
+
|
|
67
73
|
### 2. Python, zero code changes
|
|
68
74
|
|
|
69
75
|
```bash
|
|
@@ -107,6 +113,31 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
107
113
|
|
|
108
114
|
---
|
|
109
115
|
|
|
116
|
+
## What it looks like
|
|
117
|
+
|
|
118
|
+
The Flow Map: where work goes, what each hop costs, and what is failing. Red edges are errors.
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
The Overview: cost, Apdex and flow health against baselines learned from your own history.
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
Governance: what Aegis refused, which rule refused it, and which grants are never used.
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
Reproduce these locally from synthetic traffic, no API keys needed:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
|
|
134
|
+
LANGSMITH_ENDPOINT=http://127.0.0.1:8790/langsmith python examples/langgraph_style_app.py 70
|
|
135
|
+
python examples/otel_multiagent.py 45 http://127.0.0.1:8790
|
|
136
|
+
AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
110
141
|
## What you get
|
|
111
142
|
|
|
112
143
|
| | |
|
|
@@ -118,7 +149,7 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
118
149
|
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
|
|
119
150
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
120
151
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
121
|
-
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export |
|
|
152
|
+
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
|
|
122
153
|
| **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
|
|
123
154
|
| **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
|
|
124
155
|
|
|
@@ -137,7 +168,7 @@ That call sets up four flows:
|
|
|
137
168
|
1. **Every Aegis decision appears in its task.** Denials carry the rule id, and the Aegis audit log carries the task's run id so the two logs join.
|
|
138
169
|
2. **Model calls are charged to the Aegis budget before they're sent.** When the budget runs out, the call is refused and never made.
|
|
139
170
|
3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
|
|
140
|
-
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour
|
|
171
|
+
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour. `aegis ratify` and `aegis drift` verify its declaration; `agentdynamics policy check` replays the traffic that actually ran through it and reports how much it would refuse, so a policy change can be gated in CI.
|
|
141
172
|
|
|
142
173
|
The console's **Governance** page shows denials, budget stops, revocations, unused grants and budget headroom per policy version. See [docs/GOVERNANCE.md](docs/GOVERNANCE.md).
|
|
143
174
|
|
|
@@ -184,12 +215,13 @@ See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
|
184
215
|
## Development
|
|
185
216
|
|
|
186
217
|
```bash
|
|
187
|
-
|
|
188
|
-
python -m
|
|
189
|
-
python examples/langgraph_style_app.py 60 # LangGraph-style demo traffic
|
|
190
|
-
python examples/otel_multiagent.py 60 http://127.0.0.1:8790
|
|
218
|
+
pip install -e ".[test,toml]" && pip install "aegis-kernel>=0.4.0"
|
|
219
|
+
python -m unittest discover tests # real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
|
|
191
220
|
```
|
|
192
221
|
|
|
222
|
+
Demo traffic is under **What it looks like** above. [CLAUDE.md](CLAUDE.md) has the architecture and the
|
|
223
|
+
invariants to work within; [ROADMAP.md](ROADMAP.md) is what is planned and what is deliberately not.
|
|
224
|
+
|
|
193
225
|
## License
|
|
194
226
|
|
|
195
227
|
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -38,9 +38,15 @@ def handle_ticket(question):
|
|
|
38
38
|
plan = client.messages.create(...)
|
|
39
39
|
with agentdynamics.span("act"):
|
|
40
40
|
lookup_order(order_id) # decorate tools with @agentdynamics.tool
|
|
41
|
+
if escalated:
|
|
42
|
+
agentdynamics.outcome("failed", reason="customer escalated") # state it, don't let it be guessed
|
|
41
43
|
...
|
|
42
44
|
```
|
|
43
45
|
|
|
46
|
+
Without `outcome()`, success is inferred from errors, interrupts and follow-ups. The console always shows
|
|
47
|
+
which outcomes were stated and which were guessed; grades can also be posted afterwards by a person or an
|
|
48
|
+
eval pipeline (`POST /api/outcomes`).
|
|
49
|
+
|
|
44
50
|
### 2. Python, zero code changes
|
|
45
51
|
|
|
46
52
|
```bash
|
|
@@ -84,6 +90,31 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
84
90
|
|
|
85
91
|
---
|
|
86
92
|
|
|
93
|
+
## What it looks like
|
|
94
|
+
|
|
95
|
+
The Flow Map: where work goes, what each hop costs, and what is failing. Red edges are errors.
|
|
96
|
+
|
|
97
|
+

|
|
98
|
+
|
|
99
|
+
The Overview: cost, Apdex and flow health against baselines learned from your own history.
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
103
|
+
Governance: what Aegis refused, which rule refused it, and which grants are never used.
|
|
104
|
+
|
|
105
|
+

|
|
106
|
+
|
|
107
|
+
Reproduce these locally from synthetic traffic, no API keys needed:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
agentdynamics --data .demo-data --claude-root "" serve --port 8790 &
|
|
111
|
+
LANGSMITH_ENDPOINT=http://127.0.0.1:8790/langsmith python examples/langgraph_style_app.py 70
|
|
112
|
+
python examples/otel_multiagent.py 45 http://127.0.0.1:8790
|
|
113
|
+
AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
87
118
|
## What you get
|
|
88
119
|
|
|
89
120
|
| | |
|
|
@@ -95,7 +126,7 @@ The console's **Get started** page shows the same snippets and turns green when
|
|
|
95
126
|
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
|
|
96
127
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
97
128
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
98
|
-
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export |
|
|
129
|
+
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
|
|
99
130
|
| **Process Review** | Scores how well the agent works (efficiency, focus, reliability, verification, context, autonomy, compliance) and suggests fixes |
|
|
100
131
|
| **Analytics & Compare** | Any metric by any dimension; compare models, prompts or releases side by side |
|
|
101
132
|
|
|
@@ -114,7 +145,7 @@ That call sets up four flows:
|
|
|
114
145
|
1. **Every Aegis decision appears in its task.** Denials carry the rule id, and the Aegis audit log carries the task's run id so the two logs join.
|
|
115
146
|
2. **Model calls are charged to the Aegis budget before they're sent.** When the budget runs out, the call is refused and never made.
|
|
116
147
|
3. **A watchdog revokes the grant** of an agent that keeps probing a boundary, for example after a prompt injection.
|
|
117
|
-
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour
|
|
148
|
+
4. **`agentdynamics policy export` writes a tighter, least-privilege policy** from observed behaviour. `aegis ratify` and `aegis drift` verify its declaration; `agentdynamics policy check` replays the traffic that actually ran through it and reports how much it would refuse, so a policy change can be gated in CI.
|
|
118
149
|
|
|
119
150
|
The console's **Governance** page shows denials, budget stops, revocations, unused grants and budget headroom per policy version. See [docs/GOVERNANCE.md](docs/GOVERNANCE.md).
|
|
120
151
|
|
|
@@ -161,12 +192,13 @@ See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
|
161
192
|
## Development
|
|
162
193
|
|
|
163
194
|
```bash
|
|
164
|
-
|
|
165
|
-
python -m
|
|
166
|
-
python examples/langgraph_style_app.py 60 # LangGraph-style demo traffic
|
|
167
|
-
python examples/otel_multiagent.py 60 http://127.0.0.1:8790
|
|
195
|
+
pip install -e ".[test,toml]" && pip install "aegis-kernel>=0.4.0"
|
|
196
|
+
python -m unittest discover tests # real LangSmith/Anthropic/OpenAI SDKs, Aegis kernel, OTLP protobuf, pull connectors
|
|
168
197
|
```
|
|
169
198
|
|
|
199
|
+
Demo traffic is under **What it looks like** above. [CLAUDE.md](CLAUDE.md) has the architecture and the
|
|
200
|
+
invariants to work within; [ROADMAP.md](ROADMAP.md) is what is planned and what is deliberately not.
|
|
201
|
+
|
|
170
202
|
## License
|
|
171
203
|
|
|
172
204
|
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
See agentdynamics.autotrace for @trace / @tool / span.
|
|
7
7
|
"""
|
|
8
|
-
__version__ = "0.
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
9
|
|
|
10
|
-
from .autotrace import flush, init, llm_call, record_llm, span, tool, trace # noqa: E402,F401
|
|
10
|
+
from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
|
|
11
11
|
|
|
12
|
-
__all__ = ["init", "trace", "tool", "span", "llm_call", "record_llm", "flush", "__version__"]
|
|
12
|
+
__all__ = ["init", "trace", "tool", "span", "llm_call", "record_llm", "outcome", "flush", "__version__"]
|
|
@@ -191,6 +191,22 @@ def cmd_policy(a, eng):
|
|
|
191
191
|
for r in g["by_rule"][:8]:
|
|
192
192
|
print(f" {r['n']:>5} {r['rule']}")
|
|
193
193
|
return 0
|
|
194
|
+
if a.action == "check":
|
|
195
|
+
if not a.candidate:
|
|
196
|
+
print("policy check needs --candidate <policy.yaml>", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
try:
|
|
199
|
+
from aegis import dump_policy, load_policy
|
|
200
|
+
except ImportError:
|
|
201
|
+
print("policy check needs aegis-kernel: pip install aegis-kernel", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
q["candidate_doc"] = dump_policy(load_policy(a.candidate))
|
|
204
|
+
res = api.check_policy(q)
|
|
205
|
+
if res.get("error"):
|
|
206
|
+
print(res["error"], file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
print(f"{a.candidate} against {res['tasks']} governed task(s) in scope:")
|
|
209
|
+
return _report_coverage(res["coverage"], a.max_denied_fraction, sys.stdout)
|
|
194
210
|
res = api.export_policy(q)
|
|
195
211
|
if res.get("error"):
|
|
196
212
|
print(res["error"], file=sys.stderr)
|
|
@@ -203,16 +219,41 @@ def cmd_policy(a, eng):
|
|
|
203
219
|
print(f" - {c}", file=sys.stderr)
|
|
204
220
|
else:
|
|
205
221
|
print(res["yaml"])
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
if res.get("coverage") is None:
|
|
223
|
+
return 0
|
|
224
|
+
print("", file=sys.stderr)
|
|
225
|
+
print("Against the traffic it was generated from:", file=sys.stderr)
|
|
226
|
+
return _report_coverage(res["coverage"], a.max_denied_fraction, sys.stderr)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _report_coverage(cov, max_fraction, out):
|
|
230
|
+
"""Print how much observed traffic a candidate would refuse; non-zero exit past the threshold.
|
|
231
|
+
|
|
232
|
+
This is the one check that catches a policy which is strictly narrower than its base, ratifies
|
|
233
|
+
cleanly and shows no drift, and still refuses production traffic -- `aegis ratify` and
|
|
234
|
+
`aegis drift` compare declarations and never see a single call.
|
|
235
|
+
"""
|
|
236
|
+
w = lambda *x: print(*x, file=out) # noqa: E731
|
|
237
|
+
if not cov["calls"]:
|
|
238
|
+
w(" no governed calls in scope to check against")
|
|
239
|
+
return 0
|
|
240
|
+
w(f" would deny {cov['denied']} of {cov['calls']} calls that were allowed ({cov['denied_fraction']:.1%})")
|
|
241
|
+
rows = [r for r in cov["by_tool"] if r["denied"]]
|
|
242
|
+
if rows:
|
|
243
|
+
width = max(len(r["tool"]) for r in rows)
|
|
244
|
+
for r in rows:
|
|
245
|
+
why = ", ".join(f"{k} x{n}" for k, n in sorted(r["rules"].items(), key=lambda kv: -kv[1]))
|
|
246
|
+
w(f" {r['tool']:<{width}} {r['denied']:>5} of {r['calls']:<5} {r['denied_fraction']:>6.1%} {why}")
|
|
247
|
+
for ex in cov["examples"][:5]:
|
|
248
|
+
if ex.get("arg"):
|
|
249
|
+
w(f" e.g. {ex['tool']}({ex['arg']}={ex['value']!r}) -> {ex['rule']}")
|
|
250
|
+
else: # a missing argument or a dropped tool: Aegis's own reason says which
|
|
251
|
+
w(f" e.g. {ex['tool']} -> {ex['rule']}: {ex.get('reason') or ''}")
|
|
252
|
+
if cov["args_unrecorded"]:
|
|
253
|
+
w(f" {cov['args_unrecorded']} call(s) had no recorded arguments (content capture off); "
|
|
254
|
+
f"only their tool grant was checked")
|
|
255
|
+
if cov["denied_fraction"] > max_fraction:
|
|
256
|
+
w(f" FAIL: {cov['denied_fraction']:.1%} exceeds --max-denied-fraction {max_fraction:.1%}")
|
|
216
257
|
return 1
|
|
217
258
|
return 0
|
|
218
259
|
|
|
@@ -257,7 +298,9 @@ def main(argv=None):
|
|
|
257
298
|
k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
|
|
258
299
|
k.add_argument("--name", default=None)
|
|
259
300
|
po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
|
|
260
|
-
po.add_argument("action", choices=["export", "report"]
|
|
301
|
+
po.add_argument("action", choices=["export", "report", "check"],
|
|
302
|
+
help="export: generate a tightened policy; report: governance summary; "
|
|
303
|
+
"check: judge --candidate against recorded traffic")
|
|
261
304
|
po.add_argument("--workflow")
|
|
262
305
|
po.add_argument("--project")
|
|
263
306
|
po.add_argument("--environment")
|
|
@@ -266,6 +309,10 @@ def main(argv=None):
|
|
|
266
309
|
po.add_argument("--base", help="Aegis policy file to tighten (needs aegis-kernel installed)")
|
|
267
310
|
po.add_argument("--headroom", type=float, default=1.5)
|
|
268
311
|
po.add_argument("--out", help="write the YAML here (default: stdout)")
|
|
312
|
+
po.add_argument("--candidate", help="policy file to judge (check)")
|
|
313
|
+
po.add_argument("--max-denied-fraction", type=float, default=0.0,
|
|
314
|
+
help="fail if the policy would refuse more than this share of observed, allowed calls "
|
|
315
|
+
"(export, check; default 0: any refusal fails)")
|
|
269
316
|
sub.add_parser("ingest", help="scan sources once and rebuild the database")
|
|
270
317
|
rp = sub.add_parser("report", help="print a text summary")
|
|
271
318
|
rp.add_argument("--project")
|
|
@@ -40,23 +40,34 @@ CORRECTION = re.compile(
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def classify_task(prompt_kind, text):
|
|
43
|
+
return classify_task_detail(prompt_kind, text)[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def classify_task_detail(prompt_kind, text):
|
|
47
|
+
"""(task_type, how it was decided, what matched).
|
|
48
|
+
|
|
49
|
+
how: "prompt kind" when the prompt says what it is (a slash command, a scheduled job, a subagent's
|
|
50
|
+
brief); "follow-up" for a short "continue"/"yes" that carries on the previous task; "keywords" when an
|
|
51
|
+
intent rule matched, with the matched text; "unmatched" when nothing did. Keyword typing is a guess
|
|
52
|
+
about what someone meant, so it says so -- the same way outcome_source separates graded from inferred.
|
|
53
|
+
"""
|
|
43
54
|
if prompt_kind == "scheduled":
|
|
44
|
-
return "scheduled job"
|
|
55
|
+
return "scheduled job", "prompt kind", None
|
|
45
56
|
if prompt_kind == "delegated":
|
|
46
|
-
return "subagent"
|
|
57
|
+
return "subagent", "prompt kind", None
|
|
47
58
|
t = (text or "").strip()
|
|
48
59
|
low = t.lower()
|
|
49
60
|
if prompt_kind == "command":
|
|
50
|
-
return "slash command"
|
|
61
|
+
return "slash command", "prompt kind", None
|
|
51
62
|
if len(t) < 60 and FOLLOWUP.search(t):
|
|
52
|
-
return "follow-up"
|
|
63
|
+
return "follow-up", "follow-up", None
|
|
53
64
|
# The earliest intent keyword wins: "Create X, then test it" is a build, not a testing task.
|
|
54
65
|
best = None
|
|
55
66
|
for rank, (name, pat) in enumerate(TYPE_RULES):
|
|
56
67
|
m = re.search(pat, low[:600])
|
|
57
68
|
if m and (best is None or (m.start(), rank) < best[0]):
|
|
58
|
-
best = ((m.start(), rank), name)
|
|
59
|
-
return best[1] if best else "other"
|
|
69
|
+
best = ((m.start(), rank), name, m.group(0).strip())
|
|
70
|
+
return (best[1], "keywords", best[2][:40]) if best else ("other", "unmatched", None)
|
|
60
71
|
|
|
61
72
|
|
|
62
73
|
# ---------------------------------------------------------------- helpers
|
|
@@ -146,9 +157,10 @@ def task_metrics(run, idx, seg):
|
|
|
146
157
|
in_side = t["input_tokens"] + t["cache_read"] + t["cache_write"]
|
|
147
158
|
t["cache_hit"] = round(t["cache_read"] / in_side, 3) if in_side else None
|
|
148
159
|
t["tool_error_rate"] = round(t["tool_errors"] / len(tools), 3) if tools else 0
|
|
149
|
-
t["task_type"] =
|
|
160
|
+
t["task_type"], t["task_type_source"], t["task_type_match"] = classify_task_detail(t["prompt_kind"], t["prompt"])
|
|
150
161
|
if run.get("workflow") and run.get("source") != "claude-code":
|
|
151
|
-
|
|
162
|
+
# traced apps: the entry point names the business transaction, which beats any guess
|
|
163
|
+
t["task_type"], t["task_type_source"], t["task_type_match"] = run["workflow"], "workflow", None
|
|
152
164
|
|
|
153
165
|
# --- phase attribution: split each LLM call's cost across the tool calls it issued
|
|
154
166
|
phase_calls = Counter(s["phase"] for s in tools)
|
|
@@ -301,6 +313,9 @@ def governance_metrics(t, run, steps, tools):
|
|
|
301
313
|
t["repeated_denials"] = best
|
|
302
314
|
|
|
303
315
|
|
|
316
|
+
OUTCOMES = ("completed", "failed", "rework", "interrupted") # what a grade may state (store.OUTCOMES)
|
|
317
|
+
FEEDBACK_PASS = 0.5 # a feedback score at or above this is a pass
|
|
318
|
+
|
|
304
319
|
TRUNCATION = {"max_tokens", "length", "max_output_tokens", "MAX_TOKENS"}
|
|
305
320
|
REFUSAL = {"refusal", "content_filter", "SAFETY", "safety", "blocked"}
|
|
306
321
|
RATE_HINTS = ("429", "rate limit", "rate_limit", "overloaded", "529", "too many requests", "quota")
|
|
@@ -314,6 +329,13 @@ def flow_metrics(t, run, steps, llm, tools):
|
|
|
314
329
|
t["framework"] = run.get("framework") or run.get("source")
|
|
315
330
|
t["workflow"] = run.get("workflow") or (t["task_type"] if run.get("source") == "claude-code" else None)
|
|
316
331
|
t["steps_total"] = len(llm) + len(tools)
|
|
332
|
+
# model calls whose cache accounting rests on a guess (collectors/spans.uncached_input); None is
|
|
333
|
+
# a source that is exact by construction, like the SDK, so only an explicit False counts
|
|
334
|
+
t["tokens_unverified"] = sum(1 for s in llm if s.get("tokens_verified") is False)
|
|
335
|
+
# agentdynamics.outcome() records a notice; the last one in the task wins. Kept on a transient
|
|
336
|
+
# key -- finalize decides precedence, and write_analysis persists only TASK_COLS.
|
|
337
|
+
graded = [s for s in notices if s.get("name") == "outcome" and s.get("outcome") in OUTCOMES]
|
|
338
|
+
t["_sdk_grade"] = {"outcome": graded[-1]["outcome"], "reason": graded[-1].get("text")} if graded else None
|
|
317
339
|
t["llm_errors"] = sum(1 for s in llm if s.get("is_error"))
|
|
318
340
|
t["truncations"] = sum(1 for s in llm if s.get("stop_reason") in TRUNCATION)
|
|
319
341
|
t["refusals"] = sum(1 for s in llm if s.get("stop_reason") in REFUSAL)
|
|
@@ -538,6 +560,7 @@ def _outcomes_claude(ts, now):
|
|
|
538
560
|
# "continue" / "yes" carries on the previous request, so it belongs to that task type
|
|
539
561
|
if t["task_type"] == "follow-up" and i > 0 and ts[i - 1]["task_type"] not in ("follow-up",):
|
|
540
562
|
t["task_type"] = ts[i - 1]["task_type"]
|
|
563
|
+
t["task_type_source"], t["task_type_match"] = "follow-up", None # inherited, not re-guessed
|
|
541
564
|
if t["source"] == "claude-code":
|
|
542
565
|
t["workflow"] = t["task_type"]
|
|
543
566
|
nxt = next((x for x in ts[i + 1:] if x["prompt_kind"] in ("human", "command")), None)
|
|
@@ -573,7 +596,34 @@ def _outcome_trace(t, run, now):
|
|
|
573
596
|
t["root_error"] = run.get("root_error")
|
|
574
597
|
|
|
575
598
|
|
|
576
|
-
def
|
|
599
|
+
def _apply_grades(tasks, grades):
|
|
600
|
+
"""Settle each outcome by the strongest evidence available, and record which one it was.
|
|
601
|
+
|
|
602
|
+
stated after the fact (API) > stated in the run (agentdynamics.outcome) > recorded feedback
|
|
603
|
+
> inference from signals.
|
|
604
|
+
|
|
605
|
+
Inference is a guess built from errors, interrupts and corrections. Apdex, success rate and the
|
|
606
|
+
process score all inherit it, so anything that states the outcome outright must win, and the
|
|
607
|
+
console has to be able to say how much of a success rate is guessed. Idempotent: the cached
|
|
608
|
+
per-run tasks are re-finalized on every refresh, so nothing here may consume its input.
|
|
609
|
+
"""
|
|
610
|
+
for t in tasks:
|
|
611
|
+
api, sdk = grades.get(t["id"]), t.get("_sdk_grade")
|
|
612
|
+
if api:
|
|
613
|
+
t["outcome"], t["outcome_source"] = api["outcome"], "graded"
|
|
614
|
+
t["outcome_reason"] = api.get("reason") or f"graded by {api.get('graded_by') or 'api'}"
|
|
615
|
+
elif sdk:
|
|
616
|
+
t["outcome"], t["outcome_source"] = sdk["outcome"], "graded"
|
|
617
|
+
t["outcome_reason"] = sdk.get("reason") or "graded in the run"
|
|
618
|
+
elif t.get("feedback_score") is not None:
|
|
619
|
+
ok = t["feedback_score"] >= FEEDBACK_PASS
|
|
620
|
+
t["outcome"] = "completed" if ok else "rework"
|
|
621
|
+
t["outcome_source"], t["outcome_reason"] = "feedback", f"feedback score {t['feedback_score']:g}"
|
|
622
|
+
else:
|
|
623
|
+
t["outcome_source"], t["outcome_reason"] = "inferred", None
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def finalize(runs, tasks_by_run, rules=None, now=None, grades=None):
|
|
577
627
|
"""Cross-run analysis: outcomes, subagent roll-up, baselines, scores, events."""
|
|
578
628
|
rules = rules or DEFAULT_RULES
|
|
579
629
|
now = now or time.time()
|
|
@@ -599,6 +649,8 @@ def finalize(runs, tasks_by_run, rules=None, now=None):
|
|
|
599
649
|
if not (run["source"] == "claude-code" or not run.get("workflow")):
|
|
600
650
|
for t in tasks_by_run[run["id"]]:
|
|
601
651
|
_outcome_trace(t, run, now)
|
|
652
|
+
# before the roll-up, so baselines, scores and health events all see the settled outcome
|
|
653
|
+
_apply_grades(all_tasks, grades or {})
|
|
602
654
|
|
|
603
655
|
# subagent roll-up: link child runs to the parent task that spawned them
|
|
604
656
|
task_by_id = {t["id"]: t for t in all_tasks}
|
|
@@ -215,6 +215,10 @@ class trace:
|
|
|
215
215
|
"""Attach a user/eval score (0..1) to this task."""
|
|
216
216
|
self.run.feedback.append({"key": key, "score": score})
|
|
217
217
|
|
|
218
|
+
def outcome(self, result, reason=None):
|
|
219
|
+
"""State how this task ended. See agentdynamics.outcome."""
|
|
220
|
+
_record_outcome(self.run, result, reason)
|
|
221
|
+
|
|
218
222
|
# decorator
|
|
219
223
|
def __call__(self, *args, **kwargs):
|
|
220
224
|
if self._fn is None: # used as @trace("name")
|
|
@@ -402,6 +406,38 @@ class llm_call:
|
|
|
402
406
|
return False
|
|
403
407
|
|
|
404
408
|
|
|
409
|
+
OUTCOMES = ("completed", "failed", "rework", "interrupted")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _record_outcome(run, result, reason):
|
|
413
|
+
if result not in OUTCOMES:
|
|
414
|
+
# telemetry never breaks the agent: a typo is a warning, not an exception in their code path
|
|
415
|
+
_warn(f"outcome:{result!r}", f"outcome({result!r}) ignored: must be one of {', '.join(OUTCOMES)}")
|
|
416
|
+
return
|
|
417
|
+
run.add({"kind": "notice", "name": "outcome", "outcome": result, "ts": time.time(),
|
|
418
|
+
"text": _clip(str(reason), 500) if reason is not None else None})
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def outcome(result, reason=None):
|
|
422
|
+
"""State how the current task ended, instead of leaving it to inference.
|
|
423
|
+
|
|
424
|
+
@agentdynamics.trace
|
|
425
|
+
def handle(ticket):
|
|
426
|
+
...
|
|
427
|
+
if not resolved:
|
|
428
|
+
agentdynamics.outcome("failed", reason="customer escalated")
|
|
429
|
+
|
|
430
|
+
`result` is completed | failed | rework | interrupted. The last call in a task wins, and it
|
|
431
|
+
overrides both inference and recorded feedback; only a grade set later through the API ranks
|
|
432
|
+
above it. Outside a traced task there is nothing to grade, so it warns once and does nothing.
|
|
433
|
+
"""
|
|
434
|
+
run = _current.get()
|
|
435
|
+
if run is None:
|
|
436
|
+
_warn("outcome:no-run", "agentdynamics.outcome() called outside a traced task; ignored")
|
|
437
|
+
return
|
|
438
|
+
_record_outcome(run, result, reason)
|
|
439
|
+
|
|
440
|
+
|
|
405
441
|
def record_llm(model, input_tokens=0, output_tokens=0, cache_read=0, cache_write=0, stop_reason=None,
|
|
406
442
|
start=None, end=None, text="", provider="custom"):
|
|
407
443
|
"""Record a model call after the fact (no gating). Prefer `llm_call` when you can wrap the call."""
|
|
@@ -83,11 +83,15 @@ def _usage(outputs, run):
|
|
|
83
83
|
det = um.get("input_token_details") or {}
|
|
84
84
|
cr = det.get("cache_read") or 0
|
|
85
85
|
cw = det.get("cache_creation") or 0
|
|
86
|
+
# langchain-core UsageMetadata.input_tokens: "Sum of all input token types" -- cache reads and
|
|
87
|
+
# cache writes are already inside it. The legacy llm_output.token_usage path carries no cache
|
|
88
|
+
# breakdown, so there is nothing to double count and no convention to claim.
|
|
89
|
+
conv = "inclusive" if um else None
|
|
86
90
|
tu = (o.get("llm_output") or {}).get("token_usage") or (o.get("llm_output") or {}).get("usage") or {}
|
|
87
91
|
if tu and not (it or ot):
|
|
88
92
|
it = tu.get("prompt_tokens") or tu.get("input_tokens") or 0
|
|
89
93
|
ot = tu.get("completion_tokens") or tu.get("output_tokens") or 0
|
|
90
|
-
return it, ot, cr, cw, stop, model
|
|
94
|
+
return it, ot, cr, cw, stop, model, conv
|
|
91
95
|
|
|
92
96
|
|
|
93
97
|
KIND = {"llm": "llm", "tool": "tool", "retriever": "retriever", "embedding": "embedding", "chain": "chain", "prompt": "chain", "parser": "chain"}
|
|
@@ -108,9 +112,9 @@ def to_span(r):
|
|
|
108
112
|
if r.get("name") in ("__interrupt__",) or "GraphInterrupt" in str(r.get("error") or ""):
|
|
109
113
|
kind = "human"
|
|
110
114
|
it = ot = cr = cw = 0
|
|
111
|
-
stop = model_out = None
|
|
115
|
+
stop = model_out = conv = None
|
|
112
116
|
if kind in ("llm", "embedding"):
|
|
113
|
-
it, ot, cr, cw, stop, model_out = _usage(r.get("outputs"), r)
|
|
117
|
+
it, ot, cr, cw, stop, model_out, conv = _usage(r.get("outputs"), r)
|
|
114
118
|
start = parse_time(r.get("start_time"))
|
|
115
119
|
ttft = None
|
|
116
120
|
for ev in r.get("events") or []:
|
|
@@ -136,6 +140,7 @@ def to_span(r):
|
|
|
136
140
|
"status": "error" if err else ("ok" if r.get("end_time") else "unset"), "error": err,
|
|
137
141
|
"model": md.get("ls_model_name") or inv.get("model") or inv.get("model_name") or model_out,
|
|
138
142
|
"provider": md.get("ls_provider"), "input_tokens": it, "output_tokens": ot, "cache_read": cr, "cache_write": cw,
|
|
143
|
+
"input_convention": conv,
|
|
139
144
|
"cost": r.get("total_cost") if kind == "llm" and r.get("total_cost") is not None else None,
|
|
140
145
|
"stop_reason": stop, "ttft_ms": ttft, "input": r.get("inputs"), "output": r.get("outputs"),
|
|
141
146
|
"node": node, "agent": md.get("agent_name") or md.get("lc_agent_name"),
|
|
@@ -204,6 +204,24 @@ def decode_json(doc):
|
|
|
204
204
|
|
|
205
205
|
# ---------------------------------------------------------------- semantic-convention mapping
|
|
206
206
|
|
|
207
|
+
def _input_convention(a):
|
|
208
|
+
"""Whether this span's input_tokens already counts its cache reads and writes, per the documented
|
|
209
|
+
contract of whichever attribute supplied it. Mirrors _g's order so it describes the same key.
|
|
210
|
+
|
|
211
|
+
gen_ai.usage.input_tokens GenAI semconv: "SHOULD include all types of input tokens, including
|
|
212
|
+
cached tokens."
|
|
213
|
+
llm.token_count.prompt OpenInference: cache reads and writes are "tokens in the prompt".
|
|
214
|
+
anything else (gen_ai.usage.prompt_tokens from older semconv / OpenLLMetry, llm.usage.*)
|
|
215
|
+
has no documented rule we can cite: None, and the call is reported as
|
|
216
|
+
unverified if it involves cache tokens.
|
|
217
|
+
"""
|
|
218
|
+
for k, conv in (("gen_ai.usage.input_tokens", "inclusive"), ("gen_ai.usage.prompt_tokens", None),
|
|
219
|
+
("llm.token_count.prompt", "inclusive"), ("llm.usage.prompt_tokens", None)):
|
|
220
|
+
if a.get(k) not in (None, "", []):
|
|
221
|
+
return conv
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
207
225
|
def _g(a, *keys):
|
|
208
226
|
for k in keys:
|
|
209
227
|
v = a.get(k)
|
|
@@ -319,6 +337,7 @@ def map_span(res, scope, sp):
|
|
|
319
337
|
"model": _g(a, "gen_ai.response.model", "gen_ai.request.model", "llm.model_name", "llm.request.model"),
|
|
320
338
|
"provider": _g(a, "gen_ai.system", "gen_ai.provider.name", "llm.provider", "llm.system"),
|
|
321
339
|
"input_tokens": _num(_g(a, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "llm.token_count.prompt", "llm.usage.prompt_tokens")) or 0,
|
|
340
|
+
"input_convention": _input_convention(a),
|
|
322
341
|
"output_tokens": _num(_g(a, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "llm.token_count.completion", "llm.usage.completion_tokens")) or 0,
|
|
323
342
|
"cache_read": _num(_g(a, "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_read_input_tokens", "llm.token_count.prompt_details.cache_read",
|
|
324
343
|
"gen_ai.usage.input_tokens.cached")) or 0,
|
|
@@ -22,6 +22,34 @@ STRUCTURAL = {"agent", "chain", "node", "guardrail", "evaluator", "human", "span
|
|
|
22
22
|
RATE_LIMIT_HINTS = ("429", "rate limit", "rate_limit", "ratelimit", "overloaded", "529", "too many requests", "quota")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def uncached_input(input_tokens, cache_read, cache_write, convention):
|
|
26
|
+
"""Split a reported input count into its uncached part, and say whether we know it's right.
|
|
27
|
+
|
|
28
|
+
Canonical steps keep input_tokens, cache_read and cache_write disjoint, because each is priced
|
|
29
|
+
differently. Sources disagree on whether their input count already contains the cache tokens, and
|
|
30
|
+
getting it wrong double counts them in the cost column.
|
|
31
|
+
|
|
32
|
+
inclusive documented as containing reads and writes (GenAI semconv, OpenInference,
|
|
33
|
+
LangChain usage_metadata): subtract both.
|
|
34
|
+
exclusive documented as not containing them: leave it.
|
|
35
|
+
None no rule we can cite. Fall back to the old guess -- inclusive of reads unless that is
|
|
36
|
+
arithmetically impossible -- and report the call as unverified, the way a model with
|
|
37
|
+
no known price is reported as unpriced rather than guessed.
|
|
38
|
+
|
|
39
|
+
A declared-inclusive count smaller than its own cache tokens cannot be inclusive; the emitter is
|
|
40
|
+
breaking its format's contract. That falls back too, and is reported rather than absorbed.
|
|
41
|
+
Returns (uncached_input, verified).
|
|
42
|
+
"""
|
|
43
|
+
if not (cache_read or cache_write):
|
|
44
|
+
return input_tokens, True # nothing cached, nothing to disagree about
|
|
45
|
+
if convention == "exclusive":
|
|
46
|
+
return input_tokens, True
|
|
47
|
+
if convention == "inclusive" and input_tokens >= cache_read + cache_write:
|
|
48
|
+
return input_tokens - cache_read - cache_write, True
|
|
49
|
+
if cache_read and input_tokens >= cache_read:
|
|
50
|
+
return input_tokens - cache_read, False
|
|
51
|
+
return input_tokens, False
|
|
52
|
+
|
|
25
53
|
def preview(v, n=600):
|
|
26
54
|
if v is None:
|
|
27
55
|
return ""
|
|
@@ -156,9 +184,7 @@ def build_run(trace_id, spans, source):
|
|
|
156
184
|
model = s.get("model") or s.get("name")
|
|
157
185
|
it, ot = int(s.get("input_tokens") or 0), int(s.get("output_tokens") or 0)
|
|
158
186
|
cr, cw = int(s.get("cache_read") or 0), int(s.get("cache_write") or 0)
|
|
159
|
-
|
|
160
|
-
if cr and it >= cr:
|
|
161
|
-
it -= cr
|
|
187
|
+
it, verified = uncached_input(it, cr, cw, s.get("input_convention"))
|
|
162
188
|
cost = s.get("cost")
|
|
163
189
|
if cost is None:
|
|
164
190
|
cost = pricing.cost(model, it, ot, cr, cw, 0)
|
|
@@ -168,6 +194,7 @@ def build_run(trace_id, spans, source):
|
|
|
168
194
|
"context_tokens": it + cr + cw, "stop_reason": s.get("stop_reason"), "ttft_ms": s.get("ttft_ms"),
|
|
169
195
|
"text": preview(s.get("output"), 600), "tool_calls": 0, "thinking_tokens": int(s.get("thinking_tokens") or 0),
|
|
170
196
|
"rate_limited": bool(err) and any(h in err.lower() for h in RATE_LIMIT_HINTS),
|
|
197
|
+
"tokens_verified": verified,
|
|
171
198
|
})
|
|
172
199
|
last_llm = st
|
|
173
200
|
elif kind in ("tool", "retriever"):
|
|
@@ -60,6 +60,10 @@ class Engine:
|
|
|
60
60
|
# health has to be judged on the failures too, not just on a timestamp existing.
|
|
61
61
|
self.last_refresh_error = None
|
|
62
62
|
self.failed_refreshes = 0
|
|
63
|
+
# A grade changes no run, so without this a new grade would sit unapplied until unrelated
|
|
64
|
+
# traffic happened to dirty something. force=True would also work, but it throws away every
|
|
65
|
+
# cache and re-parses every source to re-settle outcomes that only need re-finalizing.
|
|
66
|
+
self._regrade = False
|
|
63
67
|
self.stats = {"spans_ingested": 0, "refreshes": 0, "alerts_sent": 0}
|
|
64
68
|
self.sources = {}
|
|
65
69
|
if claude_root:
|
|
@@ -120,6 +124,22 @@ class Engine:
|
|
|
120
124
|
time.sleep(interval)
|
|
121
125
|
threading.Thread(target=loop, daemon=True, name=f"src-{name}").start()
|
|
122
126
|
|
|
127
|
+
# ------------------------------------------------------------------ outcome grades
|
|
128
|
+
def grade(self, task_id, outcome, reason=None, graded_by=None):
|
|
129
|
+
"""State a task's outcome, overriding inference. Durable: survives schema rebuilds."""
|
|
130
|
+
with self.lock:
|
|
131
|
+
store.set_grade(self.con, task_id, outcome, reason, graded_by)
|
|
132
|
+
self._regrade = True
|
|
133
|
+
self._wake.set()
|
|
134
|
+
|
|
135
|
+
def ungrade(self, task_id):
|
|
136
|
+
"""Drop a stated outcome; the task falls back to feedback, then to inference."""
|
|
137
|
+
with self.lock:
|
|
138
|
+
n = store.delete_grade(self.con, task_id)
|
|
139
|
+
self._regrade = True
|
|
140
|
+
self._wake.set()
|
|
141
|
+
return n
|
|
142
|
+
|
|
123
143
|
# ------------------------------------------------------------------ push ingestion
|
|
124
144
|
def ingest_spans(self, spans_, source, count_source=True):
|
|
125
145
|
"""Canonical spans (OTLP, Langfuse, inbox) -> durable store."""
|
|
@@ -318,8 +338,9 @@ class Engine:
|
|
|
318
338
|
removed.append(rid)
|
|
319
339
|
dirty.pop(rid, None)
|
|
320
340
|
dirty = {k: v for k, v in dirty.items() if v["steps"]}
|
|
321
|
-
if not dirty and not removed and not force and not self._first:
|
|
341
|
+
if not dirty and not removed and not force and not self._first and not self._regrade:
|
|
322
342
|
return False
|
|
343
|
+
self._regrade = False
|
|
323
344
|
for rid in removed:
|
|
324
345
|
self._runs.pop(rid, None)
|
|
325
346
|
self._tasks.pop(rid, None)
|
|
@@ -330,7 +351,8 @@ class Engine:
|
|
|
330
351
|
self._runs[rid] = run
|
|
331
352
|
self._tasks[rid] = analysis.run_tasks(run)
|
|
332
353
|
runs = list(self._runs.values())
|
|
333
|
-
tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules()
|
|
354
|
+
tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules(),
|
|
355
|
+
grades=store.get_grades(self.con))
|
|
334
356
|
insights = analysis.process_insights(tasks)
|
|
335
357
|
meta = {"refreshed": time.time(), "runs": len(runs), "tasks": len(tasks), "insights": insights}
|
|
336
358
|
store.write_runs(self.con, list(dirty.values()), removed, self.redactor)
|