agent-forensics 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """
2
+ Agent Forensics — Black box for AI agents.
3
+
4
+ Usage:
5
+ from agent_forensics import Forensics
6
+
7
+ # 1. Initialize
8
+ f = Forensics(session="order-123", agent="shopping-agent")
9
+
10
+ # 2a. Manual recording (framework-agnostic)
11
+ f.decision("search_products", input={"query": "mouse"}, reasoning="User request")
12
+ f.tool_call("search_api", input={"q": "mouse"}, output={"results": [...]})
13
+ f.error("purchase_failed", output={"reason": "Out of stock"})
14
+
15
+ # 2b. LangChain auto-recording
16
+ agent.invoke(..., config={"callbacks": [f.langchain()]})
17
+
18
+ # 2c. OpenAI Agents SDK auto-recording
19
+ agent = Agent(name="...", hooks=f.openai_agents())
20
+
21
+ # 2d. CrewAI auto-recording
22
+ hooks = f.crewai()
23
+ agent = Agent(role="...", step_callback=hooks.step_callback)
24
+
25
+ # 3. Report
26
+ print(f.report()) # Markdown
27
+ f.save_markdown() # forensics-report-order-123.md
28
+ f.save_pdf() # forensics-report-order-123.pdf
29
+
30
+ # 4. Dashboard
31
+ f.dashboard(port=8080) # http://localhost:8080
32
+ """
33
+
34
+ from .core import Forensics
35
+ from .store import Event, EventStore
36
+
37
+ __version__ = "0.1.0"
38
+ __all__ = ["Forensics", "Event", "EventStore"]
@@ -0,0 +1,183 @@
1
+ """
2
+ Forensics — Main interface for AI agent forensics.
3
+
4
+ Provides all functionality through a single class.
5
+ Framework-agnostic, with integrations for LangChain/CrewAI/OpenAI and more.
6
+ """
7
+
8
+ from .store import EventStore, Event, now
9
+ from .report import generate_report, save_report, save_pdf
10
+
11
+
12
+ class Forensics:
13
+ """AI Agent Forensics — Black box + report generator."""
14
+
15
+ def __init__(
16
+ self,
17
+ session: str = "default",
18
+ agent: str = "default-agent",
19
+ db_path: str = "forensics.db",
20
+ ):
21
+ self.session = session
22
+ self.agent = agent
23
+ self.store = EventStore(db_path)
24
+
25
+ # -- Manual Recording API --
26
+
27
+ def decision(self, action: str, *, input: dict = None, reasoning: str = "") -> str:
28
+ """Record when the agent makes a decision."""
29
+ return self.store.save(Event(
30
+ timestamp=now(),
31
+ event_type="decision",
32
+ agent_id=self.agent,
33
+ action=action,
34
+ input_data=input or {},
35
+ output_data={},
36
+ reasoning=reasoning,
37
+ session_id=self.session,
38
+ ))
39
+
40
+ def tool_call(self, action: str, *, input: dict = None, output: dict = None, reasoning: str = "") -> str:
41
+ """Record a tool call."""
42
+ # start
43
+ self.store.save(Event(
44
+ timestamp=now(),
45
+ event_type="tool_call_start",
46
+ agent_id=self.agent,
47
+ action=f"tool:{action}",
48
+ input_data=input or {},
49
+ output_data={},
50
+ reasoning=reasoning or f"Calling tool: {action}",
51
+ session_id=self.session,
52
+ ))
53
+ # end
54
+ return self.store.save(Event(
55
+ timestamp=now(),
56
+ event_type="tool_call_end",
57
+ agent_id=self.agent,
58
+ action="tool_result",
59
+ input_data={},
60
+ output_data=output or {},
61
+ reasoning="Tool execution completed",
62
+ session_id=self.session,
63
+ ))
64
+
65
+ def llm_call(self, *, input: dict = None, output: str = "", reasoning: str = "") -> str:
66
+ """Record an LLM call."""
67
+ self.store.save(Event(
68
+ timestamp=now(),
69
+ event_type="llm_call_start",
70
+ agent_id=self.agent,
71
+ action="llm_call",
72
+ input_data=input or {},
73
+ output_data={},
74
+ reasoning=reasoning or "LLM call",
75
+ session_id=self.session,
76
+ ))
77
+ return self.store.save(Event(
78
+ timestamp=now(),
79
+ event_type="llm_call_end",
80
+ agent_id=self.agent,
81
+ action="llm_response",
82
+ input_data={},
83
+ output_data={"response": output},
84
+ reasoning="LLM response",
85
+ session_id=self.session,
86
+ ))
87
+
88
+ def error(self, action: str, *, output: dict = None, reasoning: str = "") -> str:
89
+ """Record an error/incident."""
90
+ return self.store.save(Event(
91
+ timestamp=now(),
92
+ event_type="error",
93
+ agent_id=self.agent,
94
+ action=action,
95
+ input_data={},
96
+ output_data=output or {},
97
+ reasoning=reasoning or f"Error occurred: {action}",
98
+ session_id=self.session,
99
+ ))
100
+
101
+ def finish(self, output: str = "", *, reasoning: str = "") -> str:
102
+ """Record the agent's final result."""
103
+ return self.store.save(Event(
104
+ timestamp=now(),
105
+ event_type="final_decision",
106
+ agent_id=self.agent,
107
+ action="agent_finish",
108
+ input_data={},
109
+ output_data={"response": output},
110
+ reasoning=reasoning or "Agent determined final answer",
111
+ session_id=self.session,
112
+ ))
113
+
114
+ def record(self, event_type: str, action: str, *, input: dict = None, output: dict = None, reasoning: str = "") -> str:
115
+ """Record a generic event."""
116
+ return self.store.save(Event(
117
+ timestamp=now(),
118
+ event_type=event_type,
119
+ agent_id=self.agent,
120
+ action=action,
121
+ input_data=input or {},
122
+ output_data=output or {},
123
+ reasoning=reasoning,
124
+ session_id=self.session,
125
+ ))
126
+
127
+ # -- Report API --
128
+
129
+ def report(self) -> str:
130
+ """Return the Markdown forensics report as a string."""
131
+ return generate_report(self.store, self.session)
132
+
133
+ def save_markdown(self, path: str = None) -> str:
134
+ """Save the Markdown report to a file."""
135
+ return save_report(self.store, self.session, output_dir=path or ".")
136
+
137
+ def save_pdf(self, path: str = None) -> str:
138
+ """Save the PDF report to a file."""
139
+ return save_pdf(self.store, self.session, output_dir=path or ".")
140
+
141
+ def events(self) -> list[Event]:
142
+ """Return all events for the current session."""
143
+ return self.store.get_session_events(self.session)
144
+
145
+ def sessions(self) -> list[str]:
146
+ """Return a list of all sessions."""
147
+ return self.store.get_all_sessions()
148
+
149
+ # -- Framework Integrations --
150
+
151
+ def langchain(self):
152
+ """Return a LangChain callback handler. agent.invoke(..., config={"callbacks": [f.langchain()]})"""
153
+ from .integrations.langchain import ForensicsCollector
154
+ return ForensicsCollector(
155
+ store=self.store,
156
+ session_id=self.session,
157
+ agent_id=self.agent,
158
+ )
159
+
160
+ def openai_agents(self):
161
+ """Return OpenAI Agents SDK hooks. Agent(hooks=f.openai_agents())"""
162
+ from .integrations.openai_agents import ForensicsAgentHooks
163
+ return ForensicsAgentHooks(
164
+ store=self.store,
165
+ session_id=self.session,
166
+ agent_id=self.agent,
167
+ )
168
+
169
+ def crewai(self):
170
+ """Return CrewAI callback collection. Agent(step_callback=hooks.step_callback)"""
171
+ from .integrations.crewai import ForensicsCrewAIHooks
172
+ return ForensicsCrewAIHooks(
173
+ store=self.store,
174
+ session_id=self.session,
175
+ agent_id=self.agent,
176
+ )
177
+
178
+ # -- Dashboard --
179
+
180
+ def dashboard(self, port: int = 8080):
181
+ """Launch the web dashboard."""
182
+ from .dashboard import run_dashboard
183
+ run_dashboard(self.store, port=port)
@@ -0,0 +1,267 @@
1
+ """
2
+ Forensics Dashboard — Visually inspect agent forensics data in the browser.
3
+
4
+ Runs at http://localhost:8080.
5
+ Uses only Python's built-in http.server with no external dependencies.
6
+ """
7
+
8
+ import json
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+ from urllib.parse import urlparse, parse_qs
11
+
12
+ from .store import EventStore
13
+
14
+ STORE = None # Injected by run_dashboard()
15
+
16
+
17
+ def get_dashboard_html():
18
+ """Main dashboard HTML."""
19
+ sessions = STORE.get_all_sessions()
20
+ return f"""<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8">
24
+ <title>Agent Forensics Dashboard</title>
25
+ <style>
26
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
27
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e0e0e0; }}
28
+ .header {{ background: #111; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }}
29
+ .header h1 {{ font-size: 20px; color: #fff; }}
30
+ .header .badge {{ background: #1a73e8; color: #fff; padding: 4px 12px; border-radius: 12px; font-size: 12px; }}
31
+ .container {{ max-width: 1200px; margin: 0 auto; padding: 30px 40px; }}
32
+ .session-list {{ display: flex; gap: 12px; margin-bottom: 30px; flex-wrap: wrap; }}
33
+ .session-btn {{ padding: 10px 20px; border: 1px solid #333; background: #1a1a1a; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 14px; transition: all 0.2s; }}
34
+ .session-btn:hover {{ border-color: #1a73e8; color: #fff; }}
35
+ .session-btn.active {{ background: #1a73e8; border-color: #1a73e8; color: #fff; }}
36
+ .session-btn.incident {{ border-color: #d93025; }}
37
+ .session-btn.incident.active {{ background: #d93025; }}
38
+ .summary {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }}
39
+ .stat {{ background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 20px; }}
40
+ .stat .label {{ font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; }}
41
+ .stat .value {{ font-size: 28px; font-weight: 700; margin-top: 8px; color: #fff; }}
42
+ .stat.error .value {{ color: #d93025; }}
43
+ .stat.ok .value {{ color: #34a853; }}
44
+ .section {{ margin-bottom: 30px; }}
45
+ .section h2 {{ font-size: 16px; color: #888; margin-bottom: 16px; text-transform: uppercase; letter-spacing: 1px; }}
46
+ .timeline {{ position: relative; }}
47
+ .event {{ display: flex; gap: 16px; margin-bottom: 2px; padding: 12px 16px; background: #1a1a1a; border-left: 3px solid #333; transition: background 0.2s; }}
48
+ .event:hover {{ background: #222; }}
49
+ .event.decision {{ border-left-color: #1a73e8; }}
50
+ .event.error {{ border-left-color: #d93025; background: #1a0a0a; }}
51
+ .event.tool_call_start {{ border-left-color: #f9ab00; }}
52
+ .event.tool_call_end {{ border-left-color: #34a853; }}
53
+ .event.final_decision {{ border-left-color: #a142f4; }}
54
+ .event.llm_call_start {{ border-left-color: #555; }}
55
+ .event.llm_call_end {{ border-left-color: #555; }}
56
+ .event .time {{ font-size: 11px; color: #666; font-family: monospace; min-width: 90px; }}
57
+ .event .type {{ font-size: 11px; font-weight: 600; min-width: 100px; padding: 2px 8px; border-radius: 4px; text-align: center; }}
58
+ .type-decision {{ background: #1a3a5c; color: #5b9bd5; }}
59
+ .type-error {{ background: #3a1a1a; color: #e06666; }}
60
+ .type-tool_call_start {{ background: #3a3010; color: #f9ab00; }}
61
+ .type-tool_call_end {{ background: #1a3a1a; color: #6aa84f; }}
62
+ .type-final_decision {{ background: #2a1a3a; color: #b48fe0; }}
63
+ .type-llm {{ background: #2a2a2a; color: #888; }}
64
+ .event .detail {{ font-size: 13px; flex: 1; }}
65
+ .event .detail .action {{ font-weight: 600; color: #ccc; }}
66
+ .event .detail .reasoning {{ color: #888; margin-top: 4px; font-size: 12px; }}
67
+ .causal {{ background: #111; border: 1px solid #333; border-radius: 10px; padding: 24px; font-family: monospace; font-size: 13px; line-height: 1.8; overflow-x: auto; }}
68
+ .causal .node {{ margin: 4px 0; }}
69
+ .causal .decision-node {{ color: #5b9bd5; font-weight: bold; }}
70
+ .causal .tool-node {{ color: #f9ab00; padding-left: 24px; }}
71
+ .causal .result-ok {{ color: #34a853; padding-left: 48px; }}
72
+ .causal .result-error {{ color: #d93025; padding-left: 48px; font-weight: bold; }}
73
+ .causal .final-node {{ color: #b48fe0; font-weight: bold; margin-top: 8px; }}
74
+ .causal .error-node {{ color: #d93025; padding-left: 24px; font-weight: bold; }}
75
+ #session-content {{ min-height: 400px; }}
76
+ .compliance {{ background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 20px; font-size: 13px; color: #888; }}
77
+ .compliance strong {{ color: #ccc; }}
78
+ </style>
79
+ </head>
80
+ <body>
81
+
82
+ <div class="header">
83
+ <h1>Agent Forensics Dashboard</h1>
84
+ <span class="badge">PoC v0.1</span>
85
+ </div>
86
+
87
+ <div class="container">
88
+ <div class="session-list" id="session-list">
89
+ {''.join(f'<button class="session-btn" onclick="loadSession(this, \'{s}\')">{s}</button>' for s in sessions)}
90
+ </div>
91
+ <div id="session-content">
92
+ <p style="color:#666; text-align:center; padding:60px;">Select a session</p>
93
+ </div>
94
+ </div>
95
+
96
+ <script>
97
+ async function loadSession(btn, sessionId) {{
98
+ document.querySelectorAll('.session-btn').forEach(b => b.classList.remove('active'));
99
+ btn.classList.add('active');
100
+
101
+ const res = await fetch('/api/session?id=' + sessionId);
102
+ const data = await res.json();
103
+ renderSession(data);
104
+ }}
105
+
106
+ function renderSession(data) {{
107
+ const events = data.events;
108
+ const decisions = events.filter(e => e.event_type === 'decision');
109
+ const errors = events.filter(e => e.event_type === 'error');
110
+ const hasIncident = errors.length > 0 || events.some(e =>
111
+ JSON.stringify(e.output_data).toLowerCase().includes('error') ||
112
+ JSON.stringify(e.output_data).toLowerCase().includes('fail')
113
+ );
114
+
115
+ let html = '';
116
+
117
+ // Summary
118
+ html += '<div class="summary">';
119
+ html += `<div class="stat"><div class="label">Total Events</div><div class="value">${{events.length}}</div></div>`;
120
+ html += `<div class="stat"><div class="label">Decisions</div><div class="value">${{decisions.length}}</div></div>`;
121
+ html += `<div class="stat ${{errors.length > 0 ? 'error' : 'ok'}}"><div class="label">Errors</div><div class="value">${{errors.length}}</div></div>`;
122
+ html += `<div class="stat ${{hasIncident ? 'error' : 'ok'}}"><div class="label">Status</div><div class="value">${{hasIncident ? 'INCIDENT' : 'OK'}}</div></div>`;
123
+ html += '</div>';
124
+
125
+ // Timeline
126
+ html += '<div class="section"><h2>Timeline</h2><div class="timeline">';
127
+ events.forEach((e, i) => {{
128
+ const time = e.timestamp.split('T')[1].split('+')[0].substring(0, 12);
129
+ const typeClass = e.event_type.startsWith('llm') ? 'llm' : e.event_type;
130
+ const typeLabel = {{
131
+ 'llm_call_start': 'LLM REQ',
132
+ 'llm_call_end': 'LLM RES',
133
+ 'tool_call_start': 'TOOL REQ',
134
+ 'tool_call_end': 'TOOL RES',
135
+ 'decision': 'DECISION',
136
+ 'final_decision': 'FINAL',
137
+ 'error': 'ERROR'
138
+ }}[e.event_type] || e.event_type;
139
+
140
+ const detail = extractDetail(e);
141
+
142
+ html += `<div class="event ${{e.event_type}}">`;
143
+ html += `<span class="time">${{time}}</span>`;
144
+ html += `<span class="type type-${{typeClass}}">${{typeLabel}}</span>`;
145
+ html += `<div class="detail"><div class="action">${{e.action}}</div><div class="reasoning">${{detail}}</div></div>`;
146
+ html += '</div>';
147
+ }});
148
+ html += '</div></div>';
149
+
150
+ // Causal Chain
151
+ if (hasIncident) {{
152
+ html += '<div class="section"><h2>Causal Chain (Root Cause Analysis)</h2><div class="causal">';
153
+ events.forEach(e => {{
154
+ if (e.event_type === 'decision') {{
155
+ html += `<div class="node decision-node">[DECISION] ${{e.action}}</div>`;
156
+ html += `<div class="node" style="padding-left:24px;color:#666">${{truncate(e.reasoning, 150)}}</div>`;
157
+ }} else if (e.event_type === 'tool_call_start') {{
158
+ html += `<div class="node tool-node">→ [TOOL] ${{e.action}}</div>`;
159
+ }} else if (e.event_type === 'tool_call_end') {{
160
+ const result = JSON.stringify(e.output_data);
161
+ const isErr = result.toLowerCase().includes('error') || result.toLowerCase().includes('fail');
162
+ html += `<div class="node ${{isErr ? 'result-error' : 'result-ok'}}">${{isErr ? '✗' : '✓'}} ${{truncate(result, 120)}}</div>`;
163
+ }} else if (e.event_type === 'error') {{
164
+ html += `<div class="node error-node">✗ ERROR: ${{truncate(JSON.stringify(e.output_data), 150)}}</div>`;
165
+ }} else if (e.event_type === 'final_decision') {{
166
+ html += `<div class="node final-node">[FINAL] ${{truncate(e.output_data.response || JSON.stringify(e.output_data), 150)}}</div>`;
167
+ }}
168
+ }});
169
+ html += '</div></div>';
170
+ }}
171
+
172
+ // Compliance
173
+ html += '<div class="section"><h2>Compliance</h2><div class="compliance">';
174
+ html += '<p><strong>EU AI Act Article 14</strong> — Human Oversight requirement supported.</p>';
175
+ html += `<p>All ${{decisions.length}} decision points recorded. ${{errors.length}} errors captured.</p>`;
176
+ html += '</div></div>';
177
+
178
+ document.getElementById('session-content').innerHTML = html;
179
+
180
+ // Update button style
181
+ document.querySelectorAll('.session-btn.active').forEach(btn => {{
182
+ if (hasIncident) btn.classList.add('incident');
183
+ else btn.classList.remove('incident');
184
+ }});
185
+ }}
186
+
187
+ function extractDetail(e) {{
188
+ if (e.event_type === 'llm_call_start') {{
189
+ const msgs = e.input_data.messages || [];
190
+ if (msgs.length > 0) {{
191
+ const last = msgs[msgs.length - 1];
192
+ return truncate(`[${{last.role}}] ${{last.content}}`, 150);
193
+ }}
194
+ return '';
195
+ }}
196
+ if (e.event_type === 'decision') return truncate(e.reasoning, 150);
197
+ if (e.event_type === 'final_decision') return truncate(e.output_data.response || e.reasoning, 150);
198
+ if (e.event_type === 'tool_call_start') return truncate(JSON.stringify(e.input_data), 150);
199
+ if (e.event_type === 'tool_call_end') return truncate(JSON.stringify(e.output_data), 150);
200
+ if (e.event_type === 'error') return truncate(JSON.stringify(e.output_data), 150);
201
+ return truncate(e.reasoning, 150);
202
+ }}
203
+
204
+ function truncate(text, max) {{
205
+ if (!text) return '';
206
+ return text.length > max ? text.substring(0, max) + '...' : text;
207
+ }}
208
+ </script>
209
+ </body>
210
+ </html>"""
211
+
212
+
213
+ class DashboardHandler(BaseHTTPRequestHandler):
214
+ def do_GET(self):
215
+ parsed = urlparse(self.path)
216
+
217
+ if parsed.path == "/" or parsed.path == "":
218
+ self.send_response(200)
219
+ self.send_header("Content-Type", "text/html; charset=utf-8")
220
+ self.end_headers()
221
+ self.wfile.write(get_dashboard_html().encode("utf-8"))
222
+
223
+ elif parsed.path == "/api/session":
224
+ params = parse_qs(parsed.query)
225
+ session_id = params.get("id", [""])[0]
226
+ events = STORE.get_session_events(session_id)
227
+
228
+ event_dicts = []
229
+ for e in events:
230
+ event_dicts.append({
231
+ "event_id": e.event_id,
232
+ "timestamp": e.timestamp,
233
+ "event_type": e.event_type,
234
+ "agent_id": e.agent_id,
235
+ "action": e.action,
236
+ "input_data": e.input_data,
237
+ "output_data": e.output_data,
238
+ "reasoning": e.reasoning,
239
+ })
240
+
241
+ self.send_response(200)
242
+ self.send_header("Content-Type", "application/json; charset=utf-8")
243
+ self.end_headers()
244
+ self.wfile.write(json.dumps({"events": event_dicts}, ensure_ascii=False).encode("utf-8"))
245
+
246
+ else:
247
+ self.send_response(404)
248
+ self.end_headers()
249
+
250
+ def log_message(self, format, *args):
251
+ """Suppress log output."""
252
+ pass
253
+
254
+
255
+ def run_dashboard(store: EventStore, port: int = 8080):
256
+ """Start the dashboard server."""
257
+ global STORE
258
+ STORE = store
259
+ server = HTTPServer(("0.0.0.0", port), DashboardHandler)
260
+ print(f"\n Agent Forensics Dashboard")
261
+ print(f" http://localhost:{port}")
262
+ print(f" Press Ctrl+C to stop\n")
263
+ server.serve_forever()
264
+
265
+
266
+ if __name__ == "__main__":
267
+ run_dashboard(EventStore("forensics.db"))
File without changes
@@ -0,0 +1,108 @@
1
+ """
2
+ CrewAI integration — Captures all actions via step_callback and task_callback.
3
+
4
+ Usage:
5
+ from agent_forensics import Forensics
6
+ from crewai import Agent, Task, Crew
7
+
8
+ f = Forensics(session="order-123")
9
+ hooks = f.crewai()
10
+
11
+ agent = Agent(
12
+ role="shopper",
13
+ goal="...",
14
+ step_callback=hooks.step_callback, # Capture every step
15
+ )
16
+
17
+ task = Task(
18
+ description="...",
19
+ agent=agent,
20
+ callback=hooks.task_callback, # Capture on task completion
21
+ )
22
+
23
+ crew = Crew(
24
+ agents=[agent],
25
+ tasks=[task],
26
+ step_callback=hooks.step_callback, # Also available at Crew level
27
+ )
28
+ """
29
+
30
+ from ..store import EventStore, Event, now
31
+
32
+
33
+ class ForensicsCrewAIHooks:
34
+ """Forensics callback collection for CrewAI."""
35
+
36
+ def __init__(self, store: EventStore, session_id: str, agent_id: str = "crewai-agent"):
37
+ self.store = store
38
+ self.session_id = session_id
39
+ self.agent_id = agent_id
40
+
41
+ def step_callback(self, step_output) -> None:
42
+ """
43
+ Called on every agent step.
44
+ step_output is an AgentAction, ToolResult, or other intermediate result.
45
+ """
46
+ output_str = str(step_output)[:1000]
47
+
48
+ # AgentAction case (tool call decision)
49
+ if hasattr(step_output, "tool") and hasattr(step_output, "tool_input"):
50
+ self.store.save(Event(
51
+ timestamp=now(),
52
+ event_type="decision",
53
+ agent_id=self.agent_id,
54
+ action=f"agent_decision:{step_output.tool}",
55
+ input_data={"tool_input": str(step_output.tool_input)[:500]},
56
+ output_data={},
57
+ reasoning=getattr(step_output, "log", "")[:500] or f"Agent decided to use tool {step_output.tool}",
58
+ session_id=self.session_id,
59
+ ))
60
+ return
61
+
62
+ # ToolResult case
63
+ if hasattr(step_output, "result"):
64
+ result = str(step_output.result)[:1000]
65
+ is_error = "error" in result.lower() or "fail" in result.lower()
66
+ self.store.save(Event(
67
+ timestamp=now(),
68
+ event_type="error" if is_error else "tool_call_end",
69
+ agent_id=self.agent_id,
70
+ action="tool_result",
71
+ input_data={},
72
+ output_data={"result": result},
73
+ reasoning="Tool execution failed" if is_error else "Tool execution completed",
74
+ session_id=self.session_id,
75
+ ))
76
+ return
77
+
78
+ # Other (LLM response, etc.)
79
+ self.store.save(Event(
80
+ timestamp=now(),
81
+ event_type="llm_call_end",
82
+ agent_id=self.agent_id,
83
+ action="step_output",
84
+ input_data={},
85
+ output_data={"output": output_str},
86
+ reasoning="Agent step completed",
87
+ session_id=self.session_id,
88
+ ))
89
+
90
+ def task_callback(self, task_output) -> None:
91
+ """
92
+ Called on task completion.
93
+ task_output is a TaskOutput object.
94
+ """
95
+ description = getattr(task_output, "description", "unknown task")
96
+ raw = getattr(task_output, "raw", str(task_output))
97
+ key = getattr(task_output, "key", "")
98
+
99
+ self.store.save(Event(
100
+ timestamp=now(),
101
+ event_type="final_decision",
102
+ agent_id=self.agent_id,
103
+ action=f"task_complete:{key}" if key else "task_complete",
104
+ input_data={"task_description": str(description)[:500]},
105
+ output_data={"result": str(raw)[:1000]},
106
+ reasoning=f"Task completed: {str(description)[:200]}",
107
+ session_id=self.session_id,
108
+ ))