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.

@@ -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()]