reactifact 0.6.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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
6
|
+
<title>Trace #__RUN_ID__ · reactifact</title>
|
|
7
|
+
<script>(function(){try{var s=localStorage.getItem('reactifact-theme');var d=s?s==='dark':true;document.documentElement.classList.toggle('dark',d);}catch(e){document.documentElement.classList.add('dark');}})();</script>
|
|
8
|
+
<style>
|
|
9
|
+
:root{
|
|
10
|
+
--bg:#f7f7f9;--panel:#ffffff;--panel-alt:#fafafc;--border:#e6e6ee;--border-strong:#d3d3de;
|
|
11
|
+
--text:#17171f;--muted:#6f6f7e;--accent:#4f46e5;--accent-soft:#eef0ff;
|
|
12
|
+
--ok:#15803d;--err:#b91c1c;--ok-soft:#e9f7ef;--err-soft:#fdeeee;
|
|
13
|
+
--hover:#f4f4f8;--shadow:0 1px 2px rgba(16,16,30,.04),0 1px 6px rgba(16,16,30,.05);
|
|
14
|
+
--pre-bg:#fafafc;--pre-border:#ececf2;
|
|
15
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
16
|
+
}
|
|
17
|
+
html.dark{
|
|
18
|
+
--bg:#0f1016;--panel:#171820;--panel-alt:#1d1e27;--border:#262733;--border-strong:#33344a;
|
|
19
|
+
--text:#e7e7f0;--muted:#9a9aae;--accent:#8b93ff;--accent-soft:#232448;
|
|
20
|
+
--ok:#3ecf7c;--err:#f97c7c;--ok-soft:#123522;--err-soft:#3c1718;
|
|
21
|
+
--hover:#1f202b;--shadow:0 1px 2px rgba(0,0,0,.4),0 1px 8px rgba(0,0,0,.3);
|
|
22
|
+
--pre-bg:#101116;--pre-border:#2a2b38;
|
|
23
|
+
}
|
|
24
|
+
*{box-sizing:border-box;}
|
|
25
|
+
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,sans-serif;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
|
|
26
|
+
header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border);backdrop-filter:saturate(1.4) blur(6px);}
|
|
27
|
+
.header-inner{max-width:1240px;margin:0 auto;padding:0 28px;height:56px;display:flex;align-items:center;gap:14px;}
|
|
28
|
+
.logo{display:flex;align-items:center;gap:9px;color:var(--text);text-decoration:none;}
|
|
29
|
+
.logo b{font-size:15px;letter-spacing:.1px;}
|
|
30
|
+
.logo .sub{color:var(--muted);font-family:var(--mono);font-size:12px;font-weight:400;}
|
|
31
|
+
.spacer{flex:1;}
|
|
32
|
+
#theme{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;}
|
|
33
|
+
#theme:hover{border-color:var(--accent);color:var(--accent);}
|
|
34
|
+
main{max-width:1240px;margin:0 auto;padding:28px;}
|
|
35
|
+
.crumbs{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:16px;color:var(--muted);}
|
|
36
|
+
.crumbs a{color:var(--muted);text-decoration:none;font-weight:550;}
|
|
37
|
+
.crumbs a:hover{color:var(--accent);}
|
|
38
|
+
.crumbs .sep{opacity:.5;} .crumbs b{color:var(--text);font-family:var(--mono);}
|
|
39
|
+
.head-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;}
|
|
40
|
+
.head-row h1{font-size:21px;margin:0;font-weight:650;font-family:var(--mono);letter-spacing:-.2px;}
|
|
41
|
+
.badge{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:600;border-radius:20px;padding:3px 12px;}
|
|
42
|
+
.badge.ok{background:var(--ok-soft);color:var(--ok);} .badge.err{background:var(--err-soft);color:var(--err);}
|
|
43
|
+
.badge .dot{width:8px;height:8px;border-radius:50%;} .badge.ok .dot{background:var(--ok);} .badge.err .dot{background:var(--err);}
|
|
44
|
+
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:18px 20px;margin-bottom:20px;}
|
|
45
|
+
.card h2{font-size:11px;font-weight:650;letter-spacing:.7px;text-transform:uppercase;color:var(--muted);margin:0 0 14px;}
|
|
46
|
+
.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px 22px;}
|
|
47
|
+
.meta-item{display:flex;flex-direction:column;gap:2px;}
|
|
48
|
+
.meta-item .k{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;}
|
|
49
|
+
.meta-item .v{font-size:13.5px;font-weight:550;font-variant-numeric:tabular-nums;word-break:break-all;}
|
|
50
|
+
.meta-item .v.mono{font-family:var(--mono);}
|
|
51
|
+
.agents{display:flex;flex-direction:column;gap:12px;}
|
|
52
|
+
details.agent{border:1px solid var(--border);border-radius:12px;background:var(--panel);box-shadow:var(--shadow);overflow:hidden;}
|
|
53
|
+
details.agent>summary{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;font-size:14px;}
|
|
54
|
+
details.agent>summary:hover{background:var(--hover);}
|
|
55
|
+
.caret{color:var(--muted);transition:transform .15s;} details[open]>.caret,.caret.open{transform:rotate(90deg);}
|
|
56
|
+
.agent .muted,.spans-stat{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums;}
|
|
57
|
+
.agent-body{padding:6px 16px 16px;border-top:1px solid var(--border);background:var(--panel-alt);}
|
|
58
|
+
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0;}
|
|
59
|
+
.chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-soft);color:var(--accent);border-radius:12px;padding:2px 9px;font-family:var(--mono);font-size:11.5px;}
|
|
60
|
+
.spans{display:flex;flex-direction:column;gap:8px;}
|
|
61
|
+
details.node{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;background:var(--panel);overflow:hidden;}
|
|
62
|
+
details.node summary{display:flex;align-items:center;gap:10px;padding:9px 14px;cursor:pointer;font-size:13px;}
|
|
63
|
+
details.node summary:hover{background:var(--hover);}
|
|
64
|
+
.s-dot{width:9px;height:9px;border-radius:50%;flex:none;} .s-dot.ok{background:var(--ok);} .s-dot.err{background:var(--err);}
|
|
65
|
+
.nid{font-family:var(--mono);font-weight:600;}
|
|
66
|
+
.ntype{color:var(--muted);font-family:var(--mono);font-size:12px;}
|
|
67
|
+
.stat{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;margin-left:auto;white-space:nowrap;}
|
|
68
|
+
.node-body{padding:0 14px 14px;border-top:1px solid var(--border);}
|
|
69
|
+
.io-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;}
|
|
70
|
+
@media(max-width:700px){.io-grid{grid-template-columns:1fr;}}
|
|
71
|
+
.io h3{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 8px;}
|
|
72
|
+
.io{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px 12px;}
|
|
73
|
+
details.io-more summary{cursor:pointer;font-size:11.5px;color:var(--muted);font-family:var(--mono);}
|
|
74
|
+
.art{font-family:var(--mono);font-size:12.5px;padding:3px 0;color:var(--text);}
|
|
75
|
+
.art-head{display:flex;gap:8px;align-items:baseline;}
|
|
76
|
+
.art .tag{color:var(--accent);background:var(--accent-soft);border-radius:5px;padding:0 6px;font-size:11px;}
|
|
77
|
+
.art .atype{color:var(--muted);}
|
|
78
|
+
.art code{color:var(--muted);}
|
|
79
|
+
.art .ver{color:var(--muted);}
|
|
80
|
+
.adata{background:var(--pre-bg);border:1px solid var(--pre-border);color:var(--text);border-radius:6px;padding:6px 8px;margin:4px 0 0;font-size:11.5px;white-space:pre-wrap;word-break:break-word;max-height:140px;overflow:auto;}
|
|
81
|
+
.muted{color:var(--muted);font-family:var(--mono);font-size:12px;}
|
|
82
|
+
.llm h3{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 8px;}
|
|
83
|
+
details.llm-call{border:1px solid var(--border);border-radius:8px;margin-bottom:8px;background:var(--panel);}
|
|
84
|
+
details.llm-call summary{display:flex;align-items:center;gap:10px;padding:8px 12px;cursor:pointer;font-size:13px;}
|
|
85
|
+
details.llm-call summary:hover{background:var(--hover);}
|
|
86
|
+
.role{padding:6px 10px;border-left:2px solid var(--border);margin:6px 0;font-size:12.5px;white-space:pre-wrap;word-break:break-word;}
|
|
87
|
+
.role.user{border-left-color:var(--accent);}
|
|
88
|
+
.role.assistant{border-left-color:var(--ok);}
|
|
89
|
+
.role.tool{border-left-color:var(--muted);}
|
|
90
|
+
.role-label{font-size:10.5px;font-weight:650;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:block;margin-bottom:2px;}
|
|
91
|
+
.err-box{margin-top:12px;background:var(--err-soft);border:1px solid var(--err);color:var(--err);border-radius:8px;padding:10px 12px;font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;}
|
|
92
|
+
.empty{padding:32px;text-align:center;color:var(--muted);}
|
|
93
|
+
.diag-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
|
|
94
|
+
#copy-mermaid{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;}
|
|
95
|
+
#copy-mermaid:hover{border-color:var(--accent);color:var(--accent);}
|
|
96
|
+
.diag-hint{color:var(--muted);font-size:12px;}
|
|
97
|
+
#mermaid{background:var(--panel-alt);border:1px solid var(--border);border-radius:10px;padding:14px;overflow:auto;}
|
|
98
|
+
#mermaid:empty{display:none;}
|
|
99
|
+
.diag-fallback{color:var(--muted);font-size:12.5px;}
|
|
100
|
+
.diag-error{color:var(--err);font-size:12.5px;}
|
|
101
|
+
#mermaid-src{margin-top:10px;}
|
|
102
|
+
#mermaid-src summary{cursor:pointer;font-size:11.5px;color:var(--muted);font-family:var(--mono);}
|
|
103
|
+
#mermaid-pre{background:var(--pre-bg);border:1px solid var(--pre-border);color:var(--text);border-radius:6px;padding:10px;font-family:var(--mono);font-size:11.5px;white-space:pre-wrap;word-break:break-word;margin:8px 0 0;max-height:360px;overflow:auto;}
|
|
104
|
+
</style>
|
|
105
|
+
</head>
|
|
106
|
+
<body>
|
|
107
|
+
<header>
|
|
108
|
+
<div class="header-inner">
|
|
109
|
+
<a class="logo" href="/traces">
|
|
110
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
111
|
+
<circle cx="5" cy="12" r="2.6" fill="currentColor"/>
|
|
112
|
+
<circle cx="19" cy="5" r="2.6" fill="currentColor"/>
|
|
113
|
+
<circle cx="19" cy="19" r="2.6" fill="currentColor"/>
|
|
114
|
+
<path d="M7.4 11 16.6 6M7.4 13l9.2 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
115
|
+
</svg>
|
|
116
|
+
<b>reactifact <span style="color:var(--accent)">traces</span></b>
|
|
117
|
+
<span class="sub">artifact run observer</span>
|
|
118
|
+
</a>
|
|
119
|
+
<div class="spacer"></div>
|
|
120
|
+
<button id="theme" title="toggle dark mode">dark</button>
|
|
121
|
+
</div>
|
|
122
|
+
</header>
|
|
123
|
+
<main>
|
|
124
|
+
<div class="crumbs"><a href="/traces">traces</a><span class="sep">/</span><b id="cid">#…</b></div>
|
|
125
|
+
<div class="head-row" id="head"></div>
|
|
126
|
+
<section class="card"><h2>Run</h2><div class="meta-grid" id="meta"></div></section>
|
|
127
|
+
<section class="card"><h2>Work by agent</h2><div class="agents" id="agents"></div></section>
|
|
128
|
+
<section class="card"><h2>Sequence</h2><div class="diag-head"><button id="copy-mermaid">copy</button><span class="diag-hint">mermaid — rendered live; paste the source into mermaid.live / GitHub</span></div><div id="mermaid" class="mermaid"></div><details id="mermaid-src"><summary>show source</summary><pre id="mermaid-pre"></pre></details></section>
|
|
129
|
+
<section class="card"><h2>Evidence graph</h2><div class="diag-head"><button id="copy-graph">copy</button><span class="diag-hint">provenance: written artifacts + patch.link edges (§34)</span></div><div id="mermaid-graph" class="mermaid"></div><details id="graph-src"><summary>show source</summary><pre id="graph-pre"></pre></details></section>
|
|
130
|
+
</main>
|
|
131
|
+
<script>
|
|
132
|
+
const RUN_ID=__RUN_ID__;
|
|
133
|
+
const MERMAID_SRC=__MERMAID__;
|
|
134
|
+
const MERMAID_GRAPH_SRC=__MERMAID_GRAPH__;
|
|
135
|
+
function applyTheme(dark){document.documentElement.classList.toggle('dark',dark);document.getElementById('theme').textContent=dark?'light':'dark';try{localStorage.setItem('reactifact-theme',dark?'dark':'light');}catch(_){}}
|
|
136
|
+
function initTheme(){let s=null;try{s=localStorage.getItem('reactifact-theme');}catch(_){}applyTheme(s?s==='dark':true);}
|
|
137
|
+
document.getElementById('theme').addEventListener('click',()=>applyTheme(!document.documentElement.classList.contains('dark')));
|
|
138
|
+
|
|
139
|
+
async function load(){
|
|
140
|
+
const res=await fetch('/api/traces/'+RUN_ID);
|
|
141
|
+
if(!res.ok){document.getElementById('cid').textContent='#'+RUN_ID+' — not found';return;}
|
|
142
|
+
render(await res.json());
|
|
143
|
+
}
|
|
144
|
+
function meta(k,v,mono){return '<div class="meta-item"><span class="k">'+k+'</span><span class="v'+(mono?' mono':'')+'">'+v+'</span></div>';}
|
|
145
|
+
function fmtTime(t){try{const d=typeof t==='number'?new Date(t*1000):new Date(t);return d.toLocaleString();}catch(_){return '—';}}
|
|
146
|
+
function escapeHtml(s){return String(s??'').replace(/[&<>"']/g,(ch)=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));}
|
|
147
|
+
|
|
148
|
+
function typeCounts(arts){const m={};for(const a of arts||[]){const k=a.data_type||'artifact';m[k]=(m[k]||0)+1;}return m;}
|
|
149
|
+
function chipsHtml(arts){
|
|
150
|
+
return Object.entries(typeCounts(arts)).map(([t,n])=>'<span class="chip">'+escapeHtml(t)+' '+n+'</span>').join('');
|
|
151
|
+
}
|
|
152
|
+
const SHOWN=6;
|
|
153
|
+
function artsBlock(arts){
|
|
154
|
+
const list=arts||[]; const n=list.length;
|
|
155
|
+
if(!n) return '<div class="art" style="color:var(--muted)">—</div>';
|
|
156
|
+
const chips=chipsHtml(list);
|
|
157
|
+
if(n<=SHOWN) return chips+'<div class="art-list">'+list.map(artHtml).join('')+'</div>';
|
|
158
|
+
const first=list.slice(0,SHOWN).map(artHtml).join('');
|
|
159
|
+
const rest=list.slice(SHOWN).map(artHtml).join('');
|
|
160
|
+
return chips+'<div class="art-list">'+first+'<details class="io-more" open><summary>… + '+(n-SHOWN)+' more</summary>'+rest+'</details></div>';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function render(t){
|
|
164
|
+
document.title='Trace #'+t.id+' · reactifact';
|
|
165
|
+
document.getElementById('cid').textContent='#'+t.id;
|
|
166
|
+
const cls=t.outcome==='completed'?'ok':'err';
|
|
167
|
+
document.getElementById('head').innerHTML='<h1>#'+escapeHtml(t.id)+'</h1><span class="badge '+cls+'"><span class="dot"></span>'+escapeHtml(t.outcome)+'</span>';
|
|
168
|
+
document.getElementById('meta').innerHTML=
|
|
169
|
+
meta('session',escapeHtml(t.session_id||'—'),true)+
|
|
170
|
+
meta('duration',Number(t.duration_ms).toFixed(1)+' <small>ms</small>')+
|
|
171
|
+
meta('spans',String((t.spans||[]).length))+
|
|
172
|
+
meta('created',fmtTime(t.started_at));
|
|
173
|
+
|
|
174
|
+
const spans=t.spans||[];
|
|
175
|
+
const byAgent={};
|
|
176
|
+
for(const s of spans){(byAgent[s.agent]=byAgent[s.agent]||[]).push(s);}
|
|
177
|
+
const keys=Object.keys(byAgent);
|
|
178
|
+
document.getElementById('agents').innerHTML = keys.length
|
|
179
|
+
? keys.map(agent=>agentHtml(agent,byAgent[agent])).join('')
|
|
180
|
+
: '<div class="empty">no spans recorded</div>';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function agentHtml(agent,list){
|
|
184
|
+
const ms=list.reduce((a,s)=>a+Number(s.latency_ms||0),0)/list.length;
|
|
185
|
+
let writes=0,llm=0;
|
|
186
|
+
const readsAgg={};
|
|
187
|
+
for(const s of list){
|
|
188
|
+
writes+=(s.writes||[]).length; llm+=(s.llm_calls||[]).length;
|
|
189
|
+
for(const r of s.reads||[]){const t=r.data_type||'artifact';readsAgg[t]=(readsAgg[t]||0)+1;}
|
|
190
|
+
}
|
|
191
|
+
const chips=Object.entries(readsAgg)
|
|
192
|
+
.map(([t,n])=>'<span class="chip">'+escapeHtml(t)+' '+n+'</span>').join('');
|
|
193
|
+
return '<details class="agent" open>'+
|
|
194
|
+
'<summary><span class="caret">▸</span><b>'+escapeHtml(agent)+'</b>'+
|
|
195
|
+
'<span class="muted">'+list.length+' spans · '+ms.toFixed(1)+' ms avg</span>'+
|
|
196
|
+
'<span class="stat">'+writes+' writes · '+llm+' llm · '+'<span class="spans-stat">'+(Object.keys(readsAgg).join(', '))+'</span></span></summary>'+
|
|
197
|
+
'<div class="agent-body">'+
|
|
198
|
+
(chips?'<div class="chips">'+chips+'</div>':'')+
|
|
199
|
+
'<div class="spans">'+list.map(spanHtml).join('')+'</div>'+
|
|
200
|
+
'</div></details>';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function spanHtml(s){
|
|
204
|
+
const ok=!s.error;
|
|
205
|
+
const errBlock=s.error?'<div class="err-box">'+escapeHtml(s.error)+'</div>':'';
|
|
206
|
+
const reads=(s.reads||[]).length?('<div class="io"><h3>reads</h3>'+artsBlock(s.reads)+'</div>'):'';
|
|
207
|
+
const writes=(s.writes||[]).length?('<div class="io"><h3>writes</h3>'+artsBlock(s.writes)+'</div>'):'';
|
|
208
|
+
const io=(reads||writes)?'<div class="io-grid">'+reads+writes+'</div>':'';
|
|
209
|
+
const llm=(s.llm_calls||[]).map(llmHtml).join('');
|
|
210
|
+
return '<details class="node">'+
|
|
211
|
+
'<summary><span class="caret">▸</span><span class="s-dot '+(ok?'ok':'err')+'"></span>'+
|
|
212
|
+
'<span class="nid">'+escapeHtml(s.agent)+'</span>'+
|
|
213
|
+
'<span class="ntype">'+escapeHtml(s.event_type)+'</span>'+
|
|
214
|
+
'<span class="stat">'+Number(s.latency_ms).toFixed(1)+' ms · '+(s.llm_calls||[]).length+' llm'+(ok?'':' · failed')+'</span></summary>'+
|
|
215
|
+
'<div class="node-body">'+errBlock+io+(llm?'<div class="llm"><h3>LLM calls</h3>'+llm+'</div>':'')+'</div></details>';
|
|
216
|
+
}
|
|
217
|
+
function artHtml(a){
|
|
218
|
+
const op=('<span class="tag">'+escapeHtml(a.op_type||'read')+'</span>');
|
|
219
|
+
const type=a.data_type?'<span class="atype">['+escapeHtml(a.data_type)+']</span>':'';
|
|
220
|
+
const data=a.data?'<pre class="adata">'+escapeHtml(a.data)+'</pre>':'';
|
|
221
|
+
return '<div class="art"><div class="art-head">'+op+' '+type+' <code>'+escapeHtml(a.artifact_id.slice(0,8))+'</code> <span class="ver">v'+a.version+'</span></div>'+data+'</div>';
|
|
222
|
+
}
|
|
223
|
+
function llmHtml(c){
|
|
224
|
+
const detail=(c.messages||[]).map(m=>'<div class="role '+escapeHtml(m.role)+'"><span class="role-label">'+escapeHtml(m.role)+'</span>'+escapeHtml(m.content||'')+'</div>').join('');
|
|
225
|
+
return '<details class="llm-call"><summary><span class="caret">▸</span><b>'+escapeHtml(c.provider||'llm')+'</b> <span class="muted">'+escapeHtml(c.model||'')+'</span> <span class="stat">'+c.prompt_tokens+' in / '+c.completion_tokens+' out · '+Number(c.latency_ms).toFixed(1)+' ms'+(c.error?' · err':'')+'</span></summary>'+
|
|
226
|
+
'<div class="node-body">'+(c.error?'<div class="err-box">'+escapeHtml(c.error)+'</div>':'')+detail+(c.response?'<div class="role assistant"><span class="role-label">response</span>'+escapeHtml(c.response)+'</div>':'')+'</div></details>';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function mountMermaid(src, nodeId, preId){
|
|
230
|
+
const pre=document.getElementById(preId);
|
|
231
|
+
pre.textContent=src||'no provenance recorded';
|
|
232
|
+
if(!src) return;
|
|
233
|
+
const el=document.getElementById(nodeId);
|
|
234
|
+
const s=document.createElement('script');
|
|
235
|
+
s.src='https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
|
236
|
+
s.onload=()=>{
|
|
237
|
+
const dark=document.documentElement.classList.contains('dark');
|
|
238
|
+
window.mermaid.initialize({startOnLoad:false,theme:dark?'dark':'default'});
|
|
239
|
+
el.textContent=src;
|
|
240
|
+
try{window.mermaid.run({nodes:[el]});}
|
|
241
|
+
catch(err){el.innerHTML='<div class="diag-error">Mermaid could not render: '+escapeHtml(err&&err.message?err.message:String(err))+'</div>';}
|
|
242
|
+
};
|
|
243
|
+
s.onerror=()=>{el.innerHTML='<div class="diag-fallback">Mermaid JS unavailable (offline) — the diagram source is shown below.</div>';};
|
|
244
|
+
document.head.appendChild(s);
|
|
245
|
+
}
|
|
246
|
+
function fallbackCopy(text){
|
|
247
|
+
const ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();
|
|
248
|
+
try{document.execCommand('copy');}catch(_){}document.body.removeChild(ta);
|
|
249
|
+
}
|
|
250
|
+
function initCopy(){
|
|
251
|
+
const bind=(btnId,src)=>{const btn=document.getElementById(btnId);btn.addEventListener('click',()=>{
|
|
252
|
+
const done=()=>{btn.textContent='copied';setTimeout(()=>btn.textContent='copy',900);};
|
|
253
|
+
const text=src||'';
|
|
254
|
+
if(navigator.clipboard){navigator.clipboard.writeText(text).then(done).catch(()=>{fallbackCopy(text);done();});}
|
|
255
|
+
else{fallbackCopy(text);done();}
|
|
256
|
+
});};
|
|
257
|
+
bind('copy-mermaid',MERMAID_SRC);
|
|
258
|
+
bind('copy-graph',MERMAID_GRAPH_SRC);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
initTheme(); load(); mountMermaid(MERMAID_SRC,'mermaid','mermaid-pre'); mountMermaid(MERMAID_GRAPH_SRC,'mermaid-graph','graph-pre'); initCopy();
|
|
262
|
+
</script>
|
|
263
|
+
</body>
|
|
264
|
+
</html>
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Trace delivery: `Tracer` (observer) and `CompositeTracer` (fan-out).
|
|
2
|
+
|
|
3
|
+
`Tracer` is what the Runtime sees as the source of run events. It is configured
|
|
4
|
+
with one or more sinks (`TraceStore`, later Langfuse/Postgres) and on
|
|
5
|
+
`on_turn_end` pushes the collected `RunTrace` to each sink:
|
|
6
|
+
|
|
7
|
+
Runtime(ctx, agents, tracer=Tracer(store=TraceStore("traces.db")))
|
|
8
|
+
|
|
9
|
+
`RecordingLLM` is a wrapper over the LLM provider: while tracing is enabled, it intercepts
|
|
10
|
+
`complete()` and writes `LLMCall` (tokens from `usage`, attribution to the agent via
|
|
11
|
+
`asyncio.current_task()`). Producers know nothing about it — they simply
|
|
12
|
+
call `context.resources.llm` as before.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import AsyncIterator, Callable, Iterable
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from ..patches import Create, Link, Update
|
|
26
|
+
from ..providers import LLMProvider, LLMRequest, LLMResponse, LLMResponseChunk
|
|
27
|
+
from .models import AgentSpan, ArtifactRef, LLMCall, RelationRef, RunTrace
|
|
28
|
+
from .store import TraceSink, TraceStore
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..agents import Agent
|
|
32
|
+
from ..commit import Read, Write
|
|
33
|
+
from ..context import Context
|
|
34
|
+
from ..events import Event
|
|
35
|
+
from ..patches import Patch
|
|
36
|
+
|
|
37
|
+
#: Up to what size to truncate artifact/response data in a trace.
|
|
38
|
+
TRACE_TRUNCATE = 1500
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clip(value: str, limit: int = TRACE_TRUNCATE) -> str:
|
|
42
|
+
if len(value) <= limit:
|
|
43
|
+
return value
|
|
44
|
+
return value[:limit] + "…"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clip_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
"role": msg.get("role", ""),
|
|
51
|
+
"content": _clip(str(msg.get("content") or "")),
|
|
52
|
+
}
|
|
53
|
+
for msg in messages
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Tracer:
|
|
58
|
+
"""Observer of runs for the Runtime (§54).
|
|
59
|
+
|
|
60
|
+
Runtime invokes `on_turn_begin` / `on_span` (sync, no I/O) and
|
|
61
|
+
`on_turn_end` (async — the sinks write to SQLite/Postgres/Langfuse).
|
|
62
|
+
`on_turn_end` awaits each sink's `export`, so sinks are async stores.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
sink: TraceSink | None = None,
|
|
68
|
+
*,
|
|
69
|
+
store: TraceStore | None = None,
|
|
70
|
+
sinks: Iterable[TraceSink] | None = None,
|
|
71
|
+
):
|
|
72
|
+
self.sinks: list[TraceSink] = []
|
|
73
|
+
if sink is not None:
|
|
74
|
+
self.sinks.append(sink)
|
|
75
|
+
if store is not None:
|
|
76
|
+
self.sinks.append(store)
|
|
77
|
+
if sinks is not None:
|
|
78
|
+
self.sinks.extend(sinks)
|
|
79
|
+
|
|
80
|
+
def on_turn_begin(
|
|
81
|
+
self, run_id: str, *, session_id: str, started_at: datetime
|
|
82
|
+
) -> None:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def on_span(self, span: AgentSpan) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
async def on_turn_end(self, trace: RunTrace) -> None:
|
|
89
|
+
for sink in self.sinks:
|
|
90
|
+
await sink.export(trace)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RecordingLLM(LLMProvider):
|
|
94
|
+
"""LLM wrapper: records calls (prompt/response/tokens) into `on_call`."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
inner: LLMProvider,
|
|
99
|
+
on_call: Callable[[LLMCall], None],
|
|
100
|
+
agent_of: Callable[[], str],
|
|
101
|
+
provider: str = "",
|
|
102
|
+
):
|
|
103
|
+
self._inner = inner
|
|
104
|
+
self._on_call = on_call
|
|
105
|
+
self._agent_of = agent_of
|
|
106
|
+
self._provider = provider or type(inner).__name__
|
|
107
|
+
|
|
108
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
109
|
+
started = time.monotonic()
|
|
110
|
+
try:
|
|
111
|
+
response = await self._inner.complete(request)
|
|
112
|
+
except Exception as exc: # noqa: BLE001 — record and re-raise
|
|
113
|
+
self._record(request, None, (time.monotonic() - started) * 1000, str(exc))
|
|
114
|
+
raise
|
|
115
|
+
self._record(request, response, (time.monotonic() - started) * 1000, None)
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
def stream(self, request: LLMRequest) -> AsyncIterator[LLMResponseChunk]:
|
|
119
|
+
return self._inner.stream(request)
|
|
120
|
+
|
|
121
|
+
def _record(
|
|
122
|
+
self,
|
|
123
|
+
request: LLMRequest,
|
|
124
|
+
response: LLMResponse | None,
|
|
125
|
+
latency_ms: float,
|
|
126
|
+
error: str | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
usage = dict(response.usage if response is not None else {})
|
|
129
|
+
call = LLMCall(
|
|
130
|
+
agent=self._agent_of(),
|
|
131
|
+
provider=self._provider,
|
|
132
|
+
model=str(getattr(self._inner, "model", "") or ""),
|
|
133
|
+
messages=_clip_messages(
|
|
134
|
+
[
|
|
135
|
+
m.model_dump() if hasattr(m, "model_dump") else m.__dict__
|
|
136
|
+
for m in request.messages
|
|
137
|
+
]
|
|
138
|
+
),
|
|
139
|
+
response=_clip(response.text) if response is not None else "",
|
|
140
|
+
prompt_tokens=int(usage.get("prompt_tokens") or usage.get("prompt") or 0),
|
|
141
|
+
completion_tokens=int(
|
|
142
|
+
usage.get("completion_tokens") or usage.get("completion") or 0
|
|
143
|
+
),
|
|
144
|
+
latency_ms=round(latency_ms, 1),
|
|
145
|
+
error=error,
|
|
146
|
+
)
|
|
147
|
+
self._on_call(call)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CompositeTracer:
|
|
151
|
+
"""Distributes events to all passed observers (local + Langfuse)."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, tracers: Iterable[Tracer]):
|
|
154
|
+
self.tracers = list(tracers)
|
|
155
|
+
|
|
156
|
+
def on_turn_begin(
|
|
157
|
+
self, run_id: str, *, session_id: str, started_at: datetime
|
|
158
|
+
) -> None:
|
|
159
|
+
for tracer in self.tracers:
|
|
160
|
+
tracer.on_turn_begin(run_id, session_id=session_id, started_at=started_at)
|
|
161
|
+
|
|
162
|
+
def on_span(self, span: AgentSpan) -> None:
|
|
163
|
+
for tracer in self.tracers:
|
|
164
|
+
tracer.on_span(span)
|
|
165
|
+
|
|
166
|
+
async def on_turn_end(self, trace: RunTrace) -> None:
|
|
167
|
+
for tracer in self.tracers:
|
|
168
|
+
await tracer.on_turn_end(trace)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class RunTracer:
|
|
172
|
+
"""Runtime's tracing collaborator (§54).
|
|
173
|
+
|
|
174
|
+
Builds `ArtifactRef`/`RelationRef`/`AgentSpan`/`RunTrace` and delivers
|
|
175
|
+
them to the configured `Tracer`, so `Runtime` only calls into this at a
|
|
176
|
+
few well-defined points instead of interleaving span-building with the
|
|
177
|
+
dispatch loop. Owns: wrapping `resources.llm` in `RecordingLLM` on
|
|
178
|
+
construction, the task→agent attribution that wrapper needs to attribute
|
|
179
|
+
an LLM call to the agent that made it, the per-turn span buffer, and the
|
|
180
|
+
artifact-data truncation cache (`ArtifactRef.data` is memoized per
|
|
181
|
+
`(artifact_id, version)` for one turn, since the same artifact is often
|
|
182
|
+
referenced — as a read — by several spans in a generation).
|
|
183
|
+
|
|
184
|
+
A no-op when `tracer` is None (the common case, no tracing configured):
|
|
185
|
+
every method still works, `record_span` just returns `None` and nothing
|
|
186
|
+
is buffered or sent anywhere.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, context: Context, tracer: Tracer | CompositeTracer | None):
|
|
190
|
+
self._context = context
|
|
191
|
+
self.tracer = tracer
|
|
192
|
+
self._agent_by_task: dict[asyncio.Task[Any], str] = {}
|
|
193
|
+
self._pending_llm: dict[str, list[LLMCall]] = {}
|
|
194
|
+
self._trace_data_cache: dict[tuple[str, int], str] = {}
|
|
195
|
+
self.run_id = ""
|
|
196
|
+
self.spans: list[AgentSpan] = []
|
|
197
|
+
if self.tracer is not None and context.resources.llm is not None:
|
|
198
|
+
context.resources.llm = RecordingLLM(
|
|
199
|
+
context.resources.llm,
|
|
200
|
+
on_call=self._record_llm,
|
|
201
|
+
agent_of=self._current_agent_name,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def enabled(self) -> bool:
|
|
206
|
+
return self.tracer is not None
|
|
207
|
+
|
|
208
|
+
# ---- task -> agent attribution, for RecordingLLM.agent_of ----
|
|
209
|
+
|
|
210
|
+
def register_task(self, task: asyncio.Task[Any] | None, agent_name: str) -> None:
|
|
211
|
+
if task is not None:
|
|
212
|
+
self._agent_by_task[task] = agent_name
|
|
213
|
+
|
|
214
|
+
def unregister_task(self, task: asyncio.Task[Any] | None) -> None:
|
|
215
|
+
if task is not None:
|
|
216
|
+
self._agent_by_task.pop(task, None)
|
|
217
|
+
|
|
218
|
+
def _current_agent_name(self) -> str:
|
|
219
|
+
task = asyncio.current_task()
|
|
220
|
+
if task is None:
|
|
221
|
+
return ""
|
|
222
|
+
return self._agent_by_task.get(task, "")
|
|
223
|
+
|
|
224
|
+
def _record_llm(self, call: LLMCall) -> None:
|
|
225
|
+
self._pending_llm.setdefault(call.agent, []).append(call)
|
|
226
|
+
|
|
227
|
+
# ---- ArtifactRef / RelationRef builders ----
|
|
228
|
+
|
|
229
|
+
def _artifact_data(self, artifact_id: str) -> Any | None:
|
|
230
|
+
artifact = self._context.get(artifact_id)
|
|
231
|
+
return artifact.data if artifact is not None else None
|
|
232
|
+
|
|
233
|
+
def artifact_ref(
|
|
234
|
+
self,
|
|
235
|
+
artifact_id: str,
|
|
236
|
+
version: int,
|
|
237
|
+
op_type: str,
|
|
238
|
+
model: Any | None,
|
|
239
|
+
) -> ArtifactRef:
|
|
240
|
+
data: str | None = None
|
|
241
|
+
if model is not None:
|
|
242
|
+
key = (artifact_id, version)
|
|
243
|
+
data = self._trace_data_cache.get(key)
|
|
244
|
+
if data is None:
|
|
245
|
+
data = _clip(
|
|
246
|
+
json.dumps(model.model_dump(mode="json"), ensure_ascii=False)
|
|
247
|
+
)
|
|
248
|
+
self._trace_data_cache[key] = data
|
|
249
|
+
return ArtifactRef(
|
|
250
|
+
artifact_id=artifact_id,
|
|
251
|
+
version=version,
|
|
252
|
+
op_type=op_type,
|
|
253
|
+
data_type=type(model).__name__ if model is not None else "",
|
|
254
|
+
data=data,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _type_name(artifact_id: str, context: Context | None) -> str:
|
|
259
|
+
artifact = context.get(artifact_id) if context is not None else None
|
|
260
|
+
data = artifact.data if artifact is not None else None
|
|
261
|
+
return type(data).__name__ if data is not None else ""
|
|
262
|
+
|
|
263
|
+
def read_refs(self, reads: list[Read]) -> list[ArtifactRef]:
|
|
264
|
+
return [
|
|
265
|
+
self.artifact_ref(
|
|
266
|
+
read.artifact_id,
|
|
267
|
+
read.version,
|
|
268
|
+
"read",
|
|
269
|
+
self._artifact_data(read.artifact_id),
|
|
270
|
+
)
|
|
271
|
+
for read in reads
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
def write_refs(self, patch: Patch, writes: list[Write]) -> list[ArtifactRef]:
|
|
275
|
+
ops_by_id: dict[str, tuple[str, Any | None]] = {}
|
|
276
|
+
for op in patch.operations:
|
|
277
|
+
artifact_id = getattr(op, "artifact_id", None)
|
|
278
|
+
if artifact_id is None:
|
|
279
|
+
continue
|
|
280
|
+
if isinstance(op, Create):
|
|
281
|
+
model: Any | None = op.data
|
|
282
|
+
elif isinstance(op, Update):
|
|
283
|
+
model = op.new_data
|
|
284
|
+
else:
|
|
285
|
+
model = None
|
|
286
|
+
ops_by_id[artifact_id] = (op.to_dict().get("type", ""), model)
|
|
287
|
+
return [
|
|
288
|
+
self.artifact_ref(
|
|
289
|
+
w.artifact_id,
|
|
290
|
+
w.version,
|
|
291
|
+
ops_by_id.get(w.artifact_id, ("", None))[0],
|
|
292
|
+
ops_by_id.get(w.artifact_id, ("", None))[1],
|
|
293
|
+
)
|
|
294
|
+
for w in writes
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
def relation_refs(self, patch: Patch) -> list[RelationRef]:
|
|
298
|
+
"""Provenance edges (`patch.link`) recorded for the span (§34)."""
|
|
299
|
+
refs: list[RelationRef] = []
|
|
300
|
+
for op in patch.operations:
|
|
301
|
+
if not isinstance(op, Link):
|
|
302
|
+
continue
|
|
303
|
+
refs.append(
|
|
304
|
+
RelationRef(
|
|
305
|
+
source_id=op.artifact_id,
|
|
306
|
+
relation=op.relation,
|
|
307
|
+
target_id=op.target_id,
|
|
308
|
+
source_type=self._type_name(op.artifact_id, self._context),
|
|
309
|
+
target_type=self._type_name(op.target_id, self._context),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
return refs
|
|
313
|
+
|
|
314
|
+
# ---- per-turn lifecycle ----
|
|
315
|
+
|
|
316
|
+
def begin_turn(self, *, session_id: str) -> None:
|
|
317
|
+
if self.tracer is None:
|
|
318
|
+
return
|
|
319
|
+
self.run_id = str(uuid.uuid4())
|
|
320
|
+
self.spans = []
|
|
321
|
+
self._trace_data_cache = {}
|
|
322
|
+
self.tracer.on_turn_begin(
|
|
323
|
+
self.run_id, session_id=session_id, started_at=datetime.now(UTC)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def record_span(
|
|
327
|
+
self,
|
|
328
|
+
agent: Agent,
|
|
329
|
+
event: Event,
|
|
330
|
+
reads: list[Read],
|
|
331
|
+
latency_ms: float,
|
|
332
|
+
*,
|
|
333
|
+
error: BaseException | None = None,
|
|
334
|
+
) -> AgentSpan | None:
|
|
335
|
+
"""Builds, buffers and delivers one span — or a no-op if disabled.
|
|
336
|
+
|
|
337
|
+
`error is not None` covers the isolated-error path; the success path
|
|
338
|
+
(a span whose `writes`/`relations` are filled in once the patch is
|
|
339
|
+
applied, see `Runtime._commit_patches_to_apply`) passes it as `None`.
|
|
340
|
+
"""
|
|
341
|
+
if self.tracer is None:
|
|
342
|
+
return None
|
|
343
|
+
span = AgentSpan(
|
|
344
|
+
agent=agent.name,
|
|
345
|
+
event_type=event.type.value,
|
|
346
|
+
reads=self.read_refs(reads),
|
|
347
|
+
latency_ms=latency_ms,
|
|
348
|
+
llm_calls=self._pending_llm.pop(agent.name, []),
|
|
349
|
+
error=f"{type(error).__name__}: {error}" if error is not None else None,
|
|
350
|
+
started_at=datetime.now(UTC),
|
|
351
|
+
)
|
|
352
|
+
self.spans.append(span)
|
|
353
|
+
self.tracer.on_span(span)
|
|
354
|
+
return span
|
|
355
|
+
|
|
356
|
+
async def end_turn(
|
|
357
|
+
self, *, session_id: str, duration_ms: float, outcome: str
|
|
358
|
+
) -> None:
|
|
359
|
+
if self.tracer is None:
|
|
360
|
+
return
|
|
361
|
+
await self.tracer.on_turn_end(
|
|
362
|
+
RunTrace(
|
|
363
|
+
id=self.run_id,
|
|
364
|
+
session_id=session_id,
|
|
365
|
+
started_at=datetime.now(UTC),
|
|
366
|
+
duration_ms=duration_ms,
|
|
367
|
+
outcome=outcome,
|
|
368
|
+
spans=self.spans,
|
|
369
|
+
)
|
|
370
|
+
)
|