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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. 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)=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
+ )