agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Reflection loop — structured think → act → observe → reflect cycles.
|
|
2
|
+
|
|
3
|
+
Long-duration agents don't just loop `run()` forever. They think, act,
|
|
4
|
+
observe what happened, reflect on whether it worked, and adjust.
|
|
5
|
+
|
|
6
|
+
Each cycle is four LLM-backed phases:
|
|
7
|
+
1. **Think** — what should I do next, given the goal and what's happened?
|
|
8
|
+
2. **Act** — actually run a tool (fully supervised by AgentHandler).
|
|
9
|
+
3. **Observe** — what came back, and how does it compare to what I expected?
|
|
10
|
+
4. **Reflect** — did this advance the goal? Should I change approach?
|
|
11
|
+
|
|
12
|
+
Every phase is a supervised call, every decision is recorded, and the
|
|
13
|
+
reflection output feeds into the next think step. This is what
|
|
14
|
+
distinguishes "agent runs 72 hours doing useful work" from "agent runs
|
|
15
|
+
in circles for 72 hours."
|
|
16
|
+
|
|
17
|
+
Bring-your-own-LLM — same pattern as SkillBuilder, PipelineBuilder.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from agenthandler import ReflectionLoop, SessionManager, MemoryStore
|
|
21
|
+
|
|
22
|
+
store = MemoryStore()
|
|
23
|
+
mgr = SessionManager(store)
|
|
24
|
+
|
|
25
|
+
loop = ReflectionLoop(
|
|
26
|
+
manager=mgr,
|
|
27
|
+
agent_id="research-agent",
|
|
28
|
+
goal="Write a report on quantum computing in 2026",
|
|
29
|
+
llm=my_llm,
|
|
30
|
+
tools={"search": search_fn, "write": write_fn},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
result = await loop.run(max_cycles=20)
|
|
34
|
+
print(result.final_answer)
|
|
35
|
+
print(result.cycles_used)
|
|
36
|
+
print(result.reflections)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Any, Awaitable, Callable, Coroutine, Dict, List, Optional
|
|
44
|
+
|
|
45
|
+
from .errors import AgentHandlerError
|
|
46
|
+
from .session import SessionManager
|
|
47
|
+
|
|
48
|
+
LLMFn = Callable[[str], Awaitable[str]]
|
|
49
|
+
ToolFn = Callable[..., Coroutine[Any, Any, Any]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ReflectionCycle:
|
|
54
|
+
"""One think → act → observe → reflect cycle."""
|
|
55
|
+
|
|
56
|
+
cycle_number: int
|
|
57
|
+
thought: str = ""
|
|
58
|
+
tool_called: str = ""
|
|
59
|
+
tool_args: Dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
tool_output: Any = None
|
|
61
|
+
tool_succeeded: bool = True
|
|
62
|
+
observation: str = ""
|
|
63
|
+
reflection: str = ""
|
|
64
|
+
goal_progress: float = 0.0 # LLM-estimated, 0.0 to 1.0
|
|
65
|
+
should_stop: bool = False
|
|
66
|
+
error: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"cycle_number": self.cycle_number,
|
|
71
|
+
"thought": self.thought,
|
|
72
|
+
"tool_called": self.tool_called,
|
|
73
|
+
"tool_args": self.tool_args,
|
|
74
|
+
"tool_succeeded": self.tool_succeeded,
|
|
75
|
+
"observation": self.observation,
|
|
76
|
+
"reflection": self.reflection,
|
|
77
|
+
"goal_progress": self.goal_progress,
|
|
78
|
+
"should_stop": self.should_stop,
|
|
79
|
+
"error": self.error,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ReflectionResult:
|
|
85
|
+
"""Final result of a reflection loop run."""
|
|
86
|
+
|
|
87
|
+
session_id: str
|
|
88
|
+
goal: str
|
|
89
|
+
cycles: List[ReflectionCycle] = field(default_factory=list)
|
|
90
|
+
completed: bool = False
|
|
91
|
+
final_answer: str = ""
|
|
92
|
+
stopped_reason: str = ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def cycles_used(self) -> int:
|
|
96
|
+
return len(self.cycles)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def reflections(self) -> List[str]:
|
|
100
|
+
return [c.reflection for c in self.cycles if c.reflection]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def total_tool_calls(self) -> int:
|
|
104
|
+
return sum(1 for c in self.cycles if c.tool_called)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Prompt templates
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_THINK_PROMPT = """\
|
|
113
|
+
You are an AI agent working toward a goal.
|
|
114
|
+
|
|
115
|
+
Goal: {goal}
|
|
116
|
+
|
|
117
|
+
Available tools: {tools}
|
|
118
|
+
|
|
119
|
+
History of what you've done so far (most recent last):
|
|
120
|
+
{history}
|
|
121
|
+
|
|
122
|
+
What should you do next to make progress? Respond with ONLY JSON:
|
|
123
|
+
|
|
124
|
+
{{
|
|
125
|
+
"thought": "your reasoning in 1-2 sentences",
|
|
126
|
+
"tool": "tool_name_or_null_if_done",
|
|
127
|
+
"args": {{"...": "..."}},
|
|
128
|
+
"done": false
|
|
129
|
+
}}
|
|
130
|
+
|
|
131
|
+
If you believe the goal is achieved, set "done": true and "tool": null. \
|
|
132
|
+
Include a "final_answer" field with your completed work.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_OBSERVE_PROMPT = """\
|
|
137
|
+
You just called a tool. Observe the result.
|
|
138
|
+
|
|
139
|
+
Goal: {goal}
|
|
140
|
+
Tool: {tool}
|
|
141
|
+
Args: {args}
|
|
142
|
+
Result: {result}
|
|
143
|
+
|
|
144
|
+
What does this result tell you? Respond with ONLY a 1-2 sentence observation.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_REFLECT_PROMPT = """\
|
|
149
|
+
You are reflecting on the last action you took.
|
|
150
|
+
|
|
151
|
+
Goal: {goal}
|
|
152
|
+
Last thought: {thought}
|
|
153
|
+
Last action: called "{tool}" with {args}
|
|
154
|
+
Result: {result}
|
|
155
|
+
Your observation: {observation}
|
|
156
|
+
|
|
157
|
+
Reflect on this cycle:
|
|
158
|
+
- Did the action advance the goal?
|
|
159
|
+
- Was it the right choice?
|
|
160
|
+
- What should change for the next cycle?
|
|
161
|
+
- Estimate overall progress toward the goal (0.0 to 1.0).
|
|
162
|
+
- Should we stop? (only stop if goal is achieved or clearly unreachable)
|
|
163
|
+
|
|
164
|
+
Respond with ONLY JSON:
|
|
165
|
+
{{
|
|
166
|
+
"reflection": "1-3 sentences",
|
|
167
|
+
"goal_progress": 0.0-1.0,
|
|
168
|
+
"should_stop": false,
|
|
169
|
+
"reason": "why you think you should stop, if applicable"
|
|
170
|
+
}}
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# ReflectionLoop
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ReflectionLoop:
|
|
180
|
+
"""Structured think → act → observe → reflect loop.
|
|
181
|
+
|
|
182
|
+
Built on top of SessionManager + Supervisor — every cycle runs
|
|
183
|
+
through full AgentHandler supervision (budgets, policies, guardrails,
|
|
184
|
+
audit, observability).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
manager: SessionManager for the underlying session.
|
|
188
|
+
agent_id: Agent identifier.
|
|
189
|
+
goal: The goal the agent is working toward (plain English).
|
|
190
|
+
llm: Async callable for all four phases (think, observe, reflect,
|
|
191
|
+
and the final summarization). Same pattern as SkillBuilder.
|
|
192
|
+
tools: Dict mapping tool name → async callable.
|
|
193
|
+
policy_dict: Policy for the underlying session.
|
|
194
|
+
max_context_history: How many past cycles to show in think prompts.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
manager: SessionManager,
|
|
200
|
+
agent_id: str,
|
|
201
|
+
goal: str,
|
|
202
|
+
llm: LLMFn,
|
|
203
|
+
tools: Dict[str, ToolFn],
|
|
204
|
+
policy_dict: Optional[Dict[str, Any]] = None,
|
|
205
|
+
max_context_history: int = 10,
|
|
206
|
+
):
|
|
207
|
+
self._manager = manager
|
|
208
|
+
self._agent_id = agent_id
|
|
209
|
+
self._goal = goal
|
|
210
|
+
self._llm = llm
|
|
211
|
+
self._tools = tools
|
|
212
|
+
self._policy_dict = policy_dict or {"max_iterations": 100, "tool_timeout": 60}
|
|
213
|
+
self._max_history = max_context_history
|
|
214
|
+
|
|
215
|
+
async def run(self, max_cycles: int = 20) -> ReflectionResult:
|
|
216
|
+
"""Run the reflection loop for up to max_cycles."""
|
|
217
|
+
sid = self._manager.start(self._agent_id, self._policy_dict)
|
|
218
|
+
sv = self._manager.get_supervisor(sid)
|
|
219
|
+
if sv is None:
|
|
220
|
+
return ReflectionResult(
|
|
221
|
+
session_id=sid,
|
|
222
|
+
goal=self._goal,
|
|
223
|
+
stopped_reason="Failed to create supervisor",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
result = ReflectionResult(session_id=sid, goal=self._goal)
|
|
227
|
+
final_answer = ""
|
|
228
|
+
|
|
229
|
+
for cycle_num in range(1, max_cycles + 1):
|
|
230
|
+
cycle = ReflectionCycle(cycle_number=cycle_num)
|
|
231
|
+
|
|
232
|
+
# -- 1. THINK --
|
|
233
|
+
try:
|
|
234
|
+
sv.record_iteration()
|
|
235
|
+
except AgentHandlerError as e:
|
|
236
|
+
cycle.error = f"Iteration limit reached: {e}"
|
|
237
|
+
result.cycles.append(cycle)
|
|
238
|
+
result.stopped_reason = "iteration_limit"
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
history_str = self._format_history(result.cycles)
|
|
242
|
+
tools_str = ", ".join(self._tools.keys())
|
|
243
|
+
think_prompt = (
|
|
244
|
+
_THINK_PROMPT.replace("{goal}", self._goal)
|
|
245
|
+
.replace("{tools}", tools_str)
|
|
246
|
+
.replace("{history}", history_str)
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
raw = await self._llm(think_prompt)
|
|
250
|
+
think = self._parse_json(raw)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
cycle.error = f"Think phase failed: {e}"
|
|
253
|
+
result.cycles.append(cycle)
|
|
254
|
+
result.stopped_reason = "think_error"
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
cycle.thought = think.get("thought", "")
|
|
258
|
+
tool_name = think.get("tool")
|
|
259
|
+
|
|
260
|
+
# Check for completion
|
|
261
|
+
if think.get("done") or tool_name is None:
|
|
262
|
+
final_answer = think.get("final_answer", "")
|
|
263
|
+
cycle.should_stop = True
|
|
264
|
+
cycle.reflection = "Agent determined goal was achieved."
|
|
265
|
+
cycle.goal_progress = 1.0
|
|
266
|
+
result.cycles.append(cycle)
|
|
267
|
+
result.completed = True
|
|
268
|
+
result.stopped_reason = "goal_achieved"
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if tool_name not in self._tools:
|
|
272
|
+
cycle.error = f"Unknown tool: {tool_name}"
|
|
273
|
+
result.cycles.append(cycle)
|
|
274
|
+
result.stopped_reason = "unknown_tool"
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# -- 2. ACT --
|
|
278
|
+
tool_args = think.get("args", {}) or {}
|
|
279
|
+
cycle.tool_called = tool_name
|
|
280
|
+
cycle.tool_args = tool_args
|
|
281
|
+
|
|
282
|
+
tool_result = await sv.call(tool_name, self._tools[tool_name], **tool_args)
|
|
283
|
+
cycle.tool_succeeded = tool_result.succeeded
|
|
284
|
+
cycle.tool_output = tool_result.output
|
|
285
|
+
|
|
286
|
+
if not tool_result.succeeded:
|
|
287
|
+
cycle.error = (
|
|
288
|
+
tool_result.error.user_message() if tool_result.error else "unknown error"
|
|
289
|
+
)
|
|
290
|
+
# Don't stop on tool error — let the agent reflect and try again
|
|
291
|
+
cycle.observation = f"Tool call failed: {cycle.error}"
|
|
292
|
+
cycle.reflection = "The tool call failed. The next cycle should adapt."
|
|
293
|
+
result.cycles.append(cycle)
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# -- 3. OBSERVE --
|
|
297
|
+
observe_prompt = (
|
|
298
|
+
_OBSERVE_PROMPT.replace("{goal}", self._goal)
|
|
299
|
+
.replace("{tool}", tool_name)
|
|
300
|
+
.replace("{args}", json.dumps(tool_args, default=str)[:500])
|
|
301
|
+
.replace("{result}", json.dumps(tool_result.output, default=str)[:2000])
|
|
302
|
+
)
|
|
303
|
+
try:
|
|
304
|
+
cycle.observation = (await self._llm(observe_prompt)).strip()
|
|
305
|
+
except Exception as e:
|
|
306
|
+
cycle.observation = f"(observation failed: {e})"
|
|
307
|
+
|
|
308
|
+
# -- 4. REFLECT --
|
|
309
|
+
reflect_prompt = (
|
|
310
|
+
_REFLECT_PROMPT.replace("{goal}", self._goal)
|
|
311
|
+
.replace("{thought}", cycle.thought)
|
|
312
|
+
.replace("{tool}", tool_name)
|
|
313
|
+
.replace("{args}", json.dumps(tool_args, default=str)[:500])
|
|
314
|
+
.replace("{result}", json.dumps(tool_result.output, default=str)[:1000])
|
|
315
|
+
.replace("{observation}", cycle.observation)
|
|
316
|
+
)
|
|
317
|
+
try:
|
|
318
|
+
raw = await self._llm(reflect_prompt)
|
|
319
|
+
reflect = self._parse_json(raw)
|
|
320
|
+
cycle.reflection = reflect.get("reflection", "")
|
|
321
|
+
cycle.goal_progress = float(reflect.get("goal_progress", 0.0))
|
|
322
|
+
cycle.should_stop = bool(reflect.get("should_stop", False))
|
|
323
|
+
stop_reason = reflect.get("reason", "")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
cycle.reflection = f"(reflection failed: {e})"
|
|
326
|
+
stop_reason = ""
|
|
327
|
+
|
|
328
|
+
result.cycles.append(cycle)
|
|
329
|
+
|
|
330
|
+
if cycle.should_stop:
|
|
331
|
+
result.stopped_reason = stop_reason or "agent_requested_stop"
|
|
332
|
+
break
|
|
333
|
+
else:
|
|
334
|
+
# max_cycles exhausted
|
|
335
|
+
result.stopped_reason = "max_cycles_reached"
|
|
336
|
+
|
|
337
|
+
result.final_answer = final_answer
|
|
338
|
+
self._manager.stop(sid)
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
def _format_history(self, cycles: List[ReflectionCycle]) -> str:
|
|
342
|
+
"""Format recent cycles as a compact history string for the think prompt."""
|
|
343
|
+
if not cycles:
|
|
344
|
+
return "(no previous cycles)"
|
|
345
|
+
recent = cycles[-self._max_history :]
|
|
346
|
+
lines = []
|
|
347
|
+
for c in recent:
|
|
348
|
+
line = f'Cycle {c.cycle_number}: thought="{c.thought[:80]}"'
|
|
349
|
+
if c.tool_called:
|
|
350
|
+
line += f" tool={c.tool_called}"
|
|
351
|
+
if c.reflection:
|
|
352
|
+
line += f' reflection="{c.reflection[:80]}"'
|
|
353
|
+
lines.append(line)
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _parse_json(text: str) -> Dict[str, Any]:
|
|
358
|
+
"""Parse JSON from an LLM response, stripping markdown fences."""
|
|
359
|
+
t = text.strip()
|
|
360
|
+
if t.startswith("```"):
|
|
361
|
+
t = t.split("\n", 1)[1] if "\n" in t else t[3:]
|
|
362
|
+
if t.endswith("```"):
|
|
363
|
+
t = t[:-3]
|
|
364
|
+
t = t.strip()
|
|
365
|
+
if t.startswith("json\n"):
|
|
366
|
+
t = t[5:]
|
|
367
|
+
try:
|
|
368
|
+
result: Dict[str, Any] = json.loads(t)
|
|
369
|
+
return result
|
|
370
|
+
except (ValueError, json.JSONDecodeError):
|
|
371
|
+
return {}
|
agenthandler/replay.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Session replay — reconstruct an agent's timeline from its audit log.
|
|
2
|
+
|
|
3
|
+
Take any stopped or failed session and walk through it step-by-step:
|
|
4
|
+
every tool call, every decision, every guardrail check, every state
|
|
5
|
+
change. Inspect the agent's state at any point in its history.
|
|
6
|
+
|
|
7
|
+
Built on top of the existing audit log and observer metrics — no new
|
|
8
|
+
storage required.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
replay = SessionReplay(manager, session_id)
|
|
12
|
+
for frame in replay.frames():
|
|
13
|
+
print(frame.timestamp, frame.phase, frame.summary)
|
|
14
|
+
|
|
15
|
+
# Jump to a specific point
|
|
16
|
+
state = replay.state_at(frame_index=42)
|
|
17
|
+
# {"iterations": 5, "tokens_used": 1200, "last_tool": "web_search", ...}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from .session import SessionManager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ReplayFrame:
|
|
30
|
+
"""A single frame in a session replay — one audit event + state at that point."""
|
|
31
|
+
|
|
32
|
+
index: int
|
|
33
|
+
timestamp: str
|
|
34
|
+
phase: str
|
|
35
|
+
outcome: str
|
|
36
|
+
target: Optional[str] = None
|
|
37
|
+
detail: Optional[str] = None
|
|
38
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
39
|
+
# Cumulative state at this point in the timeline
|
|
40
|
+
iterations_so_far: int = 0
|
|
41
|
+
tokens_so_far: int = 0
|
|
42
|
+
tools_called: List[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"index": self.index,
|
|
47
|
+
"timestamp": self.timestamp,
|
|
48
|
+
"phase": self.phase,
|
|
49
|
+
"outcome": self.outcome,
|
|
50
|
+
"target": self.target,
|
|
51
|
+
"detail": self.detail,
|
|
52
|
+
"metadata": self.metadata,
|
|
53
|
+
"iterations_so_far": self.iterations_so_far,
|
|
54
|
+
"tokens_so_far": self.tokens_so_far,
|
|
55
|
+
"tools_called": list(self.tools_called),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def summary(self) -> str:
|
|
60
|
+
"""One-line human-readable summary for log-style output."""
|
|
61
|
+
ts = self.timestamp[11:19] if self.timestamp else ""
|
|
62
|
+
tgt = f" {self.target}" if self.target else ""
|
|
63
|
+
return f"[{ts}] #{self.index} {self.phase}/{self.outcome}{tgt}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ReplaySummary:
|
|
68
|
+
"""High-level overview of a replayed session."""
|
|
69
|
+
|
|
70
|
+
session_id: str
|
|
71
|
+
agent_id: str
|
|
72
|
+
status: str
|
|
73
|
+
total_frames: int
|
|
74
|
+
total_tool_calls: int
|
|
75
|
+
unique_tools: List[str]
|
|
76
|
+
final_iterations: int
|
|
77
|
+
final_tokens: int
|
|
78
|
+
duration_ms: int
|
|
79
|
+
failure_reason: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"session_id": self.session_id,
|
|
84
|
+
"agent_id": self.agent_id,
|
|
85
|
+
"status": self.status,
|
|
86
|
+
"total_frames": self.total_frames,
|
|
87
|
+
"total_tool_calls": self.total_tool_calls,
|
|
88
|
+
"unique_tools": self.unique_tools,
|
|
89
|
+
"final_iterations": self.final_iterations,
|
|
90
|
+
"final_tokens": self.final_tokens,
|
|
91
|
+
"duration_ms": self.duration_ms,
|
|
92
|
+
"failure_reason": self.failure_reason,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SessionReplay:
|
|
97
|
+
"""Time-travel through a session's history.
|
|
98
|
+
|
|
99
|
+
Built on the audit log — replay is lossless because every action
|
|
100
|
+
was already recorded for the audit trail.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, manager: SessionManager, session_id: str):
|
|
104
|
+
self._manager = manager
|
|
105
|
+
self._session_id = session_id
|
|
106
|
+
self._checkpoint = manager.status(session_id)
|
|
107
|
+
self._entries = manager.get_audit_entries(session_id)
|
|
108
|
+
self._frames: Optional[List[ReplayFrame]] = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def exists(self) -> bool:
|
|
112
|
+
return self._checkpoint is not None
|
|
113
|
+
|
|
114
|
+
def frames(self) -> List[ReplayFrame]:
|
|
115
|
+
"""Build the replay frames from the audit log."""
|
|
116
|
+
if self._frames is not None:
|
|
117
|
+
return self._frames
|
|
118
|
+
|
|
119
|
+
frames: List[ReplayFrame] = []
|
|
120
|
+
iterations = 0
|
|
121
|
+
tokens = 0
|
|
122
|
+
tools_called: List[str] = []
|
|
123
|
+
|
|
124
|
+
for i, entry in enumerate(self._entries):
|
|
125
|
+
phase = entry.get("phase", "")
|
|
126
|
+
outcome = entry.get("outcome", "")
|
|
127
|
+
target = entry.get("target")
|
|
128
|
+
detail = entry.get("detail")
|
|
129
|
+
metadata = entry.get("metadata") or {}
|
|
130
|
+
|
|
131
|
+
# Track cumulative state
|
|
132
|
+
if phase == "iteration_check" and outcome == "allowed":
|
|
133
|
+
iterations += 1
|
|
134
|
+
if phase == "tool_call" and outcome == "info" and target:
|
|
135
|
+
if target not in tools_called:
|
|
136
|
+
tools_called.append(target)
|
|
137
|
+
if metadata.get("tokens_used") is not None:
|
|
138
|
+
tokens = metadata["tokens_used"]
|
|
139
|
+
|
|
140
|
+
frames.append(
|
|
141
|
+
ReplayFrame(
|
|
142
|
+
index=i,
|
|
143
|
+
timestamp=entry.get("timestamp", ""),
|
|
144
|
+
phase=phase,
|
|
145
|
+
outcome=outcome,
|
|
146
|
+
target=target,
|
|
147
|
+
detail=detail,
|
|
148
|
+
metadata=metadata,
|
|
149
|
+
iterations_so_far=iterations,
|
|
150
|
+
tokens_so_far=tokens,
|
|
151
|
+
tools_called=list(tools_called),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self._frames = frames
|
|
156
|
+
return frames
|
|
157
|
+
|
|
158
|
+
def state_at(self, frame_index: int) -> Dict[str, Any]:
|
|
159
|
+
"""Return the cumulative agent state at the given frame index."""
|
|
160
|
+
frames = self.frames()
|
|
161
|
+
if frame_index >= len(frames):
|
|
162
|
+
frame_index = len(frames) - 1
|
|
163
|
+
if frame_index < 0:
|
|
164
|
+
return {"iterations": 0, "tokens": 0, "tools_called": []}
|
|
165
|
+
f = frames[frame_index]
|
|
166
|
+
return {
|
|
167
|
+
"iterations": f.iterations_so_far,
|
|
168
|
+
"tokens": f.tokens_so_far,
|
|
169
|
+
"tools_called": list(f.tools_called),
|
|
170
|
+
"phase": f.phase,
|
|
171
|
+
"outcome": f.outcome,
|
|
172
|
+
"target": f.target,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
def filter(
|
|
176
|
+
self,
|
|
177
|
+
phase: Optional[str] = None,
|
|
178
|
+
outcome: Optional[str] = None,
|
|
179
|
+
tool: Optional[str] = None,
|
|
180
|
+
) -> List[ReplayFrame]:
|
|
181
|
+
"""Filter frames by phase, outcome, or tool name."""
|
|
182
|
+
result = self.frames()
|
|
183
|
+
if phase is not None:
|
|
184
|
+
result = [f for f in result if f.phase == phase]
|
|
185
|
+
if outcome is not None:
|
|
186
|
+
result = [f for f in result if f.outcome == outcome]
|
|
187
|
+
if tool is not None:
|
|
188
|
+
result = [f for f in result if f.target == tool]
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def summary(self) -> ReplaySummary:
|
|
192
|
+
"""High-level summary of the session."""
|
|
193
|
+
cp = self._checkpoint
|
|
194
|
+
frames = self.frames()
|
|
195
|
+
tool_calls = [f for f in frames if f.phase == "tool_call" and f.outcome == "info"]
|
|
196
|
+
unique_tools = sorted({f.target for f in tool_calls if f.target})
|
|
197
|
+
|
|
198
|
+
# Duration = last frame timestamp - first frame timestamp
|
|
199
|
+
duration_ms = 0
|
|
200
|
+
if frames:
|
|
201
|
+
try:
|
|
202
|
+
from datetime import datetime
|
|
203
|
+
|
|
204
|
+
first = datetime.fromisoformat(frames[0].timestamp.replace("Z", "+00:00"))
|
|
205
|
+
last = datetime.fromisoformat(frames[-1].timestamp.replace("Z", "+00:00"))
|
|
206
|
+
duration_ms = int((last - first).total_seconds() * 1000)
|
|
207
|
+
except (ValueError, AttributeError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
return ReplaySummary(
|
|
211
|
+
session_id=self._session_id,
|
|
212
|
+
agent_id=cp.agent_id if cp else "",
|
|
213
|
+
status=cp.status.value if cp else "unknown",
|
|
214
|
+
total_frames=len(frames),
|
|
215
|
+
total_tool_calls=len(tool_calls),
|
|
216
|
+
unique_tools=unique_tools,
|
|
217
|
+
final_iterations=cp.iterations if cp else 0,
|
|
218
|
+
final_tokens=cp.tokens_used if cp else 0,
|
|
219
|
+
duration_ms=duration_ms,
|
|
220
|
+
failure_reason=cp.failure_reason if cp and cp.failure_reason else None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def to_timeline(self) -> List[Dict[str, Any]]:
|
|
224
|
+
"""Export the full replay as a list of frame dicts."""
|
|
225
|
+
return [f.to_dict() for f in self.frames()]
|