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.
- agent_forensics/__init__.py +38 -0
- agent_forensics/core.py +183 -0
- agent_forensics/dashboard.py +267 -0
- agent_forensics/integrations/__init__.py +0 -0
- agent_forensics/integrations/crewai.py +108 -0
- agent_forensics/integrations/langchain.py +162 -0
- agent_forensics/integrations/openai_agents.py +180 -0
- agent_forensics/report.py +423 -0
- agent_forensics/store.py +126 -0
- agent_forensics-0.1.0.dist-info/METADATA +186 -0
- agent_forensics-0.1.0.dist-info/RECORD +14 -0
- agent_forensics-0.1.0.dist-info/WHEEL +5 -0
- agent_forensics-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_forensics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|
agent_forensics/core.py
ADDED
|
@@ -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
|
+
))
|