tracereplay 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.
Files changed (53) hide show
  1. tracereplay/__init__.py +240 -0
  2. tracereplay/__main__.py +8 -0
  3. tracereplay/agent/__init__.py +13 -0
  4. tracereplay/agent/tool_recorder.py +213 -0
  5. tracereplay/agent/tracker.py +192 -0
  6. tracereplay/chaos/__init__.py +193 -0
  7. tracereplay/chaos/engine.py +450 -0
  8. tracereplay/chaos/models.py +89 -0
  9. tracereplay/chaos/native.py +136 -0
  10. tracereplay/cli.py +196 -0
  11. tracereplay/clock/__init__.py +18 -0
  12. tracereplay/clock/engine.py +264 -0
  13. tracereplay/clock/models.py +86 -0
  14. tracereplay/config.py +144 -0
  15. tracereplay/core/__init__.py +13 -0
  16. tracereplay/core/recorder.py +84 -0
  17. tracereplay/core/replayer.py +287 -0
  18. tracereplay/core/session.py +451 -0
  19. tracereplay/exceptions.py +348 -0
  20. tracereplay/guardrails/__init__.py +78 -0
  21. tracereplay/guardrails/engine.py +345 -0
  22. tracereplay/guardrails/models.py +137 -0
  23. tracereplay/guardrails/pricing.py +121 -0
  24. tracereplay/matcher/__init__.py +14 -0
  25. tracereplay/matcher/diff.py +29 -0
  26. tracereplay/matcher/normalizer.py +106 -0
  27. tracereplay/matcher/rules.py +181 -0
  28. tracereplay/models.py +207 -0
  29. tracereplay/providers/__init__.py +74 -0
  30. tracereplay/providers/anthropic.py +221 -0
  31. tracereplay/providers/base.py +34 -0
  32. tracereplay/providers/gemini.py +241 -0
  33. tracereplay/providers/generic.py +70 -0
  34. tracereplay/providers/groq.py +23 -0
  35. tracereplay/providers/mistral.py +211 -0
  36. tracereplay/providers/openai.py +240 -0
  37. tracereplay/providers/patcher.py +353 -0
  38. tracereplay/py.typed +0 -0
  39. tracereplay/pytest_plugin.py +252 -0
  40. tracereplay/sanitizer.py +82 -0
  41. tracereplay/semantic/__init__.py +47 -0
  42. tracereplay/semantic/diff.py +91 -0
  43. tracereplay/semantic/engine.py +249 -0
  44. tracereplay/semantic/models.py +161 -0
  45. tracereplay/semantic/similarity.py +132 -0
  46. tracereplay/storage/__init__.py +12 -0
  47. tracereplay/storage/json_storage.py +130 -0
  48. tracereplay/storage/resolver.py +60 -0
  49. tracereplay-0.1.0.dist-info/METADATA +442 -0
  50. tracereplay-0.1.0.dist-info/RECORD +53 -0
  51. tracereplay-0.1.0.dist-info/WHEEL +4 -0
  52. tracereplay-0.1.0.dist-info/entry_points.txt +5 -0
  53. tracereplay-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,240 @@
1
+ """TraceReplay: Record once. Replay reliably. Test without repeated API calls.
2
+
3
+ TraceReplay is an open-source testing platform for applications powered by
4
+ Large Language Models (LLMs) and autonomous AI agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from tracereplay import chaos, clock, guardrails, semantic
10
+ from tracereplay.agent import AgentStep, AgentStepType, AgentTracker, record_tool
11
+ from tracereplay.chaos import ChaosConfig, ChaosEngine, Fault, FaultType
12
+ from tracereplay.clock import (
13
+ ClockConfig,
14
+ ClockState,
15
+ DeterministicClock,
16
+ freeze_time,
17
+ )
18
+ from tracereplay.config import TraceReplayConfig, configure, get_config, reset_config
19
+ from tracereplay.core import (
20
+ Recorder,
21
+ Replayer,
22
+ TraceReplaySession,
23
+ get_current_session,
24
+ session,
25
+ )
26
+ from tracereplay.exceptions import (
27
+ BudgetExceededError,
28
+ ChaosContextLengthError,
29
+ ChaosError,
30
+ ChaosMalformedOutputError,
31
+ ChaosRateLimitError,
32
+ ChaosServerError,
33
+ ChaosTimeoutError,
34
+ ConfigurationError,
35
+ CostBudgetExceededError,
36
+ FixtureNotFoundError,
37
+ InvalidFixtureError,
38
+ LatencyBudgetExceededError,
39
+ LiveFallbackProhibitedError,
40
+ RecordingError,
41
+ RequestMismatchError,
42
+ SemanticDriftError,
43
+ SequenceMismatchError,
44
+ TokenBudgetExceededError,
45
+ ToolExecutionError,
46
+ TraceReplayError,
47
+ UnsupportedOperationError,
48
+ )
49
+ from tracereplay.guardrails import (
50
+ GuardrailAction,
51
+ GuardrailConfig,
52
+ GuardrailEngine,
53
+ GuardrailReport,
54
+ GuardrailType,
55
+ GuardrailViolation,
56
+ calculate_token_cost,
57
+ cost_budget,
58
+ latency_budget,
59
+ token_budget,
60
+ )
61
+ from tracereplay.matcher import (
62
+ MatchResult,
63
+ RequestMatcher,
64
+ RequestNormalizer,
65
+ compute_request_hash,
66
+ generate_diff,
67
+ )
68
+ from tracereplay.models import (
69
+ Interaction,
70
+ OperationType,
71
+ ReplayMode,
72
+ RequestPayload,
73
+ ResponsePayload,
74
+ SessionRecord,
75
+ TokenUsage,
76
+ ToolCallRecord,
77
+ )
78
+ from tracereplay.providers import (
79
+ AnthropicAdapter,
80
+ AnthropicMessageResponse,
81
+ BaseProviderAdapter,
82
+ ChatCompletionResponse,
83
+ DeepSeekAdapter,
84
+ GeminiAdapter,
85
+ GeminiGenerateContentResponse,
86
+ GenericOpenAIAdapter,
87
+ GroqAdapter,
88
+ MistralAdapter,
89
+ MistralChatCompletionResponse,
90
+ OllamaAdapter,
91
+ OpenAIAdapter,
92
+ patch,
93
+ unpatch,
94
+ )
95
+ from tracereplay.sanitizer import Sanitizer, sanitize_data
96
+ from tracereplay.semantic import (
97
+ DriftAction,
98
+ DriftReport,
99
+ DriftStatus,
100
+ SemanticConfig,
101
+ SemanticDriftRecord,
102
+ SemanticMatcher,
103
+ calculate_semantic_similarity,
104
+ colorize_diff,
105
+ cosine_similarity_vectors,
106
+ generate_inline_token_diff,
107
+ generate_semantic_diff,
108
+ levenshtein_similarity,
109
+ token_cosine_similarity,
110
+ token_jaccard_similarity,
111
+ tokenize_text,
112
+ )
113
+ from tracereplay.storage import FixtureResolver, JsonStorageEngine, sanitize_filename
114
+
115
+ __version__ = "0.1.0"
116
+ __author__ = "TraceReplay Contributors"
117
+ __license__ = "MIT"
118
+
119
+ __all__ = [
120
+ # Metadata
121
+ "__version__",
122
+ "__author__",
123
+ "__license__",
124
+ # Configuration
125
+ "TraceReplayConfig",
126
+ "get_config",
127
+ "configure",
128
+ "reset_config",
129
+ # Core Engines & Sessions
130
+ "session",
131
+ "get_current_session",
132
+ "TraceReplaySession",
133
+ "Recorder",
134
+ "Replayer",
135
+ # Providers & Patching
136
+ "patch",
137
+ "unpatch",
138
+ "BaseProviderAdapter",
139
+ "OpenAIAdapter",
140
+ "AnthropicAdapter",
141
+ "GeminiAdapter",
142
+ "GroqAdapter",
143
+ "DeepSeekAdapter",
144
+ "OllamaAdapter",
145
+ "MistralAdapter",
146
+ "GenericOpenAIAdapter",
147
+ "ChatCompletionResponse",
148
+ "MistralChatCompletionResponse",
149
+ "AnthropicMessageResponse",
150
+ "GeminiGenerateContentResponse",
151
+ # Models & Enums
152
+ "ReplayMode",
153
+ "OperationType",
154
+ "TokenUsage",
155
+ "ToolCallRecord",
156
+ "RequestPayload",
157
+ "ResponsePayload",
158
+ "Interaction",
159
+ "SessionRecord",
160
+ # Exceptions
161
+ "TraceReplayError",
162
+ "ConfigurationError",
163
+ "FixtureNotFoundError",
164
+ "RequestMismatchError",
165
+ "InvalidFixtureError",
166
+ "LiveFallbackProhibitedError",
167
+ "UnsupportedOperationError",
168
+ "SequenceMismatchError",
169
+ "RecordingError",
170
+ "ToolExecutionError",
171
+ "ChaosError",
172
+ "ChaosRateLimitError",
173
+ "ChaosTimeoutError",
174
+ "ChaosServerError",
175
+ "ChaosContextLengthError",
176
+ "ChaosMalformedOutputError",
177
+ "BudgetExceededError",
178
+ "TokenBudgetExceededError",
179
+ "CostBudgetExceededError",
180
+ "LatencyBudgetExceededError",
181
+ "SemanticDriftError",
182
+ # Chaos Engineering
183
+ "chaos",
184
+ "ChaosConfig",
185
+ "ChaosEngine",
186
+ "Fault",
187
+ "FaultType",
188
+ # Deterministic Clock & Freeze Time
189
+ "clock",
190
+ "ClockConfig",
191
+ "ClockState",
192
+ "DeterministicClock",
193
+ "freeze_time",
194
+ # Guardrails
195
+ "guardrails",
196
+ "GuardrailConfig",
197
+ "GuardrailAction",
198
+ "GuardrailType",
199
+ "GuardrailViolation",
200
+ "GuardrailReport",
201
+ "GuardrailEngine",
202
+ "calculate_token_cost",
203
+ "token_budget",
204
+ "cost_budget",
205
+ "latency_budget",
206
+ # Semantic Matching & Drift Detection
207
+ "semantic",
208
+ "DriftAction",
209
+ "DriftReport",
210
+ "DriftStatus",
211
+ "SemanticConfig",
212
+ "SemanticDriftRecord",
213
+ "SemanticMatcher",
214
+ "calculate_semantic_similarity",
215
+ "colorize_diff",
216
+ "cosine_similarity_vectors",
217
+ "generate_inline_token_diff",
218
+ "generate_semantic_diff",
219
+ "levenshtein_similarity",
220
+ "token_cosine_similarity",
221
+ "token_jaccard_similarity",
222
+ "tokenize_text",
223
+ # Storage & Sanitizer
224
+ "Sanitizer",
225
+ "sanitize_data",
226
+ "JsonStorageEngine",
227
+ "FixtureResolver",
228
+ "sanitize_filename",
229
+ # Matcher
230
+ "RequestMatcher",
231
+ "RequestNormalizer",
232
+ "MatchResult",
233
+ "compute_request_hash",
234
+ "generate_diff",
235
+ # Agent & Tool Recording
236
+ "AgentTracker",
237
+ "AgentStep",
238
+ "AgentStepType",
239
+ "record_tool",
240
+ ]
@@ -0,0 +1,8 @@
1
+ """Entry point for python -m tracereplay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tracereplay.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,13 @@
1
+ """Multi-turn agent execution tracker and tool recording package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tracereplay.agent.tool_recorder import record_tool
6
+ from tracereplay.agent.tracker import AgentStep, AgentStepType, AgentTracker
7
+
8
+ __all__ = [
9
+ "AgentStep",
10
+ "AgentStepType",
11
+ "AgentTracker",
12
+ "record_tool",
13
+ ]
@@ -0,0 +1,213 @@
1
+ """Decorator and capture utilities for recording and replaying local Python tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import inspect
8
+ import time
9
+ from collections.abc import Callable
10
+ from typing import Any, TypeVar, cast, overload
11
+
12
+ from tracereplay.core.session import get_current_session
13
+ from tracereplay.models import ReplayMode, ToolCallRecord
14
+
15
+ F = TypeVar("F", bound=Callable[..., Any])
16
+
17
+
18
+ @overload
19
+ def record_tool(func: F) -> F: ...
20
+
21
+
22
+ @overload
23
+ def record_tool(
24
+ func: None = None,
25
+ *,
26
+ name: str | None = None,
27
+ ignore_args: list[str] | None = None,
28
+ ) -> Callable[[F], F]: ...
29
+
30
+
31
+ def record_tool(
32
+ func: F | None = None,
33
+ *,
34
+ name: str | None = None,
35
+ ignore_args: list[str] | None = None,
36
+ ) -> F | Callable[[F], F]:
37
+ """Decorator to intercept, record, and re-execute local Python tool functions.
38
+
39
+ In RECORD mode:
40
+ Executes the genuine Python function, captures arguments and return value,
41
+ and persists the execution to the session fixture.
42
+
43
+ In REPLAY mode:
44
+ Bypasses the actual Python function completely, verifying sequential
45
+ arguments and returning the deterministic recorded output.
46
+
47
+ Usage:
48
+ @record_tool
49
+ def calculate_tax(amount: float, rate: float = 0.05) -> float:
50
+ return amount * rate
51
+
52
+ @record_tool(name="fetch_user_data", ignore_args=["db_session"])
53
+ async def fetch_user(user_id: str, db_session: Any = None) -> dict:
54
+ return await db.query(user_id)
55
+ """
56
+
57
+ def decorator(fn: F) -> F:
58
+ tool_name = name or fn.__name__
59
+ ignored = set(ignore_args or [])
60
+ sig = inspect.signature(fn)
61
+
62
+ def _extract_args(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
63
+ bound = sig.bind(*args, **kwargs)
64
+ bound.apply_defaults()
65
+ extracted: dict[str, Any] = {}
66
+ for k, v in bound.arguments.items():
67
+ if k not in ignored:
68
+ extracted[k] = v
69
+ return extracted
70
+
71
+ if asyncio.iscoroutinefunction(fn):
72
+
73
+ @functools.wraps(fn)
74
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
75
+ sess = get_current_session()
76
+ if sess is not None:
77
+ sess.check_fork_trigger(tool_name=tool_name)
78
+ if sess is None or sess.mode == ReplayMode.PASSTHROUGH:
79
+ res = await fn(*args, **kwargs)
80
+ if sess is not None:
81
+ sess.notify_tool_call_completed()
82
+ return res
83
+
84
+ call_args = _extract_args(args, kwargs)
85
+
86
+ # AI Chaos Engineering check
87
+ if sess is not None and sess.chaos_engine is not None:
88
+ await sess.chaos_engine.aevaluate_tool_call(
89
+ tool_name=tool_name,
90
+ arguments=call_args,
91
+ step_index=sess._tool_step_count,
92
+ )
93
+
94
+ if sess.mode == ReplayMode.REPLAY:
95
+ start = time.perf_counter()
96
+ res = await sess.areplay_tool_call(tool_name, call_args)
97
+ duration_ms = (time.perf_counter() - start) * 1000.0
98
+ if sess.guardrail_engine is not None:
99
+ sess.guardrail_engine.evaluate_tool_execution(
100
+ tool_name=tool_name,
101
+ duration_ms=duration_ms,
102
+ step_index=sess._total_step_count,
103
+ )
104
+ sess.notify_tool_call_completed()
105
+ return res
106
+
107
+ # RECORD mode
108
+ start = time.perf_counter()
109
+ try:
110
+ res = await fn(*args, **kwargs)
111
+ duration_ms = (time.perf_counter() - start) * 1000.0
112
+ record = ToolCallRecord(
113
+ function_name=tool_name,
114
+ arguments=call_args,
115
+ result=res,
116
+ duration_ms=duration_ms,
117
+ )
118
+ sess.record_tool_call(record)
119
+ if sess.guardrail_engine is not None:
120
+ sess.guardrail_engine.evaluate_tool_execution(
121
+ tool_name=tool_name,
122
+ duration_ms=duration_ms,
123
+ step_index=sess._total_step_count,
124
+ )
125
+ sess.notify_tool_call_completed()
126
+ return res
127
+ except Exception as err:
128
+ duration_ms = (time.perf_counter() - start) * 1000.0
129
+ record = ToolCallRecord(
130
+ function_name=tool_name,
131
+ arguments=call_args,
132
+ result=None,
133
+ duration_ms=duration_ms,
134
+ error=str(err),
135
+ )
136
+ sess.record_tool_call(record)
137
+ raise
138
+
139
+ return cast(F, async_wrapper)
140
+
141
+ else:
142
+
143
+ @functools.wraps(fn)
144
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
145
+ sess = get_current_session()
146
+ if sess is not None:
147
+ sess.check_fork_trigger(tool_name=tool_name)
148
+ if sess is None or sess.mode == ReplayMode.PASSTHROUGH:
149
+ res = fn(*args, **kwargs)
150
+ if sess is not None:
151
+ sess.notify_tool_call_completed()
152
+ return res
153
+
154
+ call_args = _extract_args(args, kwargs)
155
+
156
+ # AI Chaos Engineering check
157
+ if sess is not None and sess.chaos_engine is not None:
158
+ sess.chaos_engine.evaluate_tool_call(
159
+ tool_name=tool_name,
160
+ arguments=call_args,
161
+ step_index=sess._tool_step_count,
162
+ )
163
+
164
+ if sess.mode == ReplayMode.REPLAY:
165
+ start = time.perf_counter()
166
+ res = sess.replay_tool_call(tool_name, call_args)
167
+ duration_ms = (time.perf_counter() - start) * 1000.0
168
+ if sess.guardrail_engine is not None:
169
+ sess.guardrail_engine.evaluate_tool_execution(
170
+ tool_name=tool_name,
171
+ duration_ms=duration_ms,
172
+ step_index=sess._total_step_count,
173
+ )
174
+ sess.notify_tool_call_completed()
175
+ return res
176
+
177
+ # RECORD mode
178
+ start = time.perf_counter()
179
+ try:
180
+ res = fn(*args, **kwargs)
181
+ duration_ms = (time.perf_counter() - start) * 1000.0
182
+ record = ToolCallRecord(
183
+ function_name=tool_name,
184
+ arguments=call_args,
185
+ result=res,
186
+ duration_ms=duration_ms,
187
+ )
188
+ sess.record_tool_call(record)
189
+ if sess.guardrail_engine is not None:
190
+ sess.guardrail_engine.evaluate_tool_execution(
191
+ tool_name=tool_name,
192
+ duration_ms=duration_ms,
193
+ step_index=sess._total_step_count,
194
+ )
195
+ sess.notify_tool_call_completed()
196
+ return res
197
+ except Exception as err:
198
+ duration_ms = (time.perf_counter() - start) * 1000.0
199
+ record = ToolCallRecord(
200
+ function_name=tool_name,
201
+ arguments=call_args,
202
+ result=None,
203
+ duration_ms=duration_ms,
204
+ error=str(err),
205
+ )
206
+ sess.record_tool_call(record)
207
+ raise
208
+
209
+ return cast(F, sync_wrapper)
210
+
211
+ if func is not None:
212
+ return decorator(func)
213
+ return decorator
@@ -0,0 +1,192 @@
1
+ """Agent execution tracker modeling multi-turn decisions and tool execution flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+
12
+ def _utc_now() -> datetime:
13
+ return datetime.now(timezone.utc)
14
+
15
+
16
+ class AgentStepType(str, Enum):
17
+ """Classification of an agent execution step in a multi-turn graph."""
18
+
19
+ USER_INPUT = "user_input"
20
+ LLM_DECISION = "llm_decision"
21
+ TOOL_EXECUTION = "tool_execution"
22
+ TOOL_RESULT = "tool_result"
23
+ FINAL_ANSWER = "final_answer"
24
+
25
+
26
+ class AgentStep(BaseModel):
27
+ """Single transition node within an agent reasoning and execution graph."""
28
+
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ step_index: int
32
+ step_type: AgentStepType
33
+ name: str
34
+ data: dict[str, Any] = Field(default_factory=dict)
35
+ duration_ms: float = 0.0
36
+ timestamp: datetime = Field(default_factory=_utc_now)
37
+
38
+
39
+ class AgentTracker:
40
+ """Models and validates multi-turn autonomous AI agent execution graphs.
41
+
42
+ Graph flow:
43
+ User Input -> LLM Decision -> Tool Execution -> Tool Result -> Next Decision -> Final Answer
44
+ """
45
+
46
+ def __init__(self, agent_name: str = "default_agent") -> None:
47
+ self.agent_name = agent_name
48
+ self.steps: list[AgentStep] = []
49
+
50
+ @property
51
+ def total_steps(self) -> int:
52
+ return len(self.steps)
53
+
54
+ def add_step(
55
+ self,
56
+ step_type: AgentStepType,
57
+ name: str,
58
+ data: dict[str, Any] | None = None,
59
+ duration_ms: float = 0.0,
60
+ ) -> AgentStep:
61
+ """Append a new execution step to the graph."""
62
+ step = AgentStep(
63
+ step_index=len(self.steps),
64
+ step_type=step_type,
65
+ name=name,
66
+ data=data or {},
67
+ duration_ms=duration_ms,
68
+ )
69
+ self.steps.append(step)
70
+ return step
71
+
72
+ def add_user_input(
73
+ self,
74
+ prompt: str,
75
+ metadata: dict[str, Any] | None = None,
76
+ ) -> AgentStep:
77
+ """Record the initial or follow-up user prompt initiating a reasoning turn."""
78
+ data: dict[str, Any] = {"prompt": prompt}
79
+ if metadata:
80
+ data.update(metadata)
81
+ return self.add_step(AgentStepType.USER_INPUT, name="user_input", data=data)
82
+
83
+ def add_llm_decision(
84
+ self,
85
+ decision: Any,
86
+ tool_calls: list[Any] | None = None,
87
+ metadata: dict[str, Any] | None = None,
88
+ ) -> AgentStep:
89
+ """Record an LLM reasoning output or tool invocation decision."""
90
+ data: dict[str, Any] = {"decision": str(decision)}
91
+ if tool_calls is not None:
92
+ data["tool_calls"] = tool_calls
93
+ if metadata:
94
+ data.update(metadata)
95
+ return self.add_step(AgentStepType.LLM_DECISION, name="llm_decision", data=data)
96
+
97
+ def add_tool_execution(
98
+ self,
99
+ tool_name: str,
100
+ arguments: dict[str, Any],
101
+ metadata: dict[str, Any] | None = None,
102
+ ) -> AgentStep:
103
+ """Record the invocation of an external or local tool with arguments."""
104
+ data: dict[str, Any] = {"tool_name": tool_name, "arguments": arguments}
105
+ if metadata:
106
+ data.update(metadata)
107
+ return self.add_step(
108
+ AgentStepType.TOOL_EXECUTION,
109
+ name=f"execute_{tool_name}",
110
+ data=data,
111
+ )
112
+
113
+ def add_tool_result(
114
+ self,
115
+ tool_name: str,
116
+ result: Any,
117
+ duration_ms: float = 0.0,
118
+ error: str | None = None,
119
+ metadata: dict[str, Any] | None = None,
120
+ ) -> AgentStep:
121
+ """Record the return value or failure of a completed tool execution."""
122
+ data: dict[str, Any] = {"tool_name": tool_name, "result": result}
123
+ if error:
124
+ data["error"] = error
125
+ if metadata:
126
+ data.update(metadata)
127
+ return self.add_step(
128
+ AgentStepType.TOOL_RESULT,
129
+ name=f"result_{tool_name}",
130
+ data=data,
131
+ duration_ms=duration_ms,
132
+ )
133
+
134
+ def add_final_answer(
135
+ self,
136
+ answer: str,
137
+ metadata: dict[str, Any] | None = None,
138
+ ) -> AgentStep:
139
+ """Record the agent's completed final response returned to the user."""
140
+ data: dict[str, Any] = {"answer": answer}
141
+ if metadata:
142
+ data.update(metadata)
143
+ return self.add_step(AgentStepType.FINAL_ANSWER, name="final_answer", data=data)
144
+
145
+ def visualize_flow(self) -> str:
146
+ """Generate a human-readable diagram of the agent's execution flow."""
147
+ if not self.steps:
148
+ return f"Agent Flow [{self.agent_name}]: (empty)"
149
+
150
+ lines: list[str] = [f"Agent Flow [{self.agent_name}]:"]
151
+ for i, step in enumerate(self.steps):
152
+ detail = ""
153
+ if step.step_type == AgentStepType.USER_INPUT:
154
+ prompt_preview = str(step.data.get("prompt", ""))[:40]
155
+ detail = f'prompt="{prompt_preview}"'
156
+ elif step.step_type == AgentStepType.LLM_DECISION:
157
+ calls = step.data.get("tool_calls")
158
+ detail = f"invokes {len(calls)} tool(s)" if calls else "generates response"
159
+ elif step.step_type == AgentStepType.TOOL_EXECUTION:
160
+ tname = step.data.get("tool_name", "")
161
+ args = step.data.get("arguments", {})
162
+ detail = f"{tname}({args})"
163
+ elif step.step_type == AgentStepType.TOOL_RESULT:
164
+ tname = step.data.get("tool_name", "")
165
+ res = str(step.data.get("result", ""))[:30]
166
+ detail = f"{tname} -> {res} ({step.duration_ms:.1f}ms)"
167
+ elif step.step_type == AgentStepType.FINAL_ANSWER:
168
+ ans_preview = str(step.data.get("answer", ""))[:40]
169
+ detail = f'answer="{ans_preview}"'
170
+
171
+ step_line = f" [{step.step_index}] {step.step_type.value.upper()}: {detail}"
172
+ lines.append(step_line)
173
+ if i < len(self.steps) - 1:
174
+ lines.append(" |")
175
+ lines.append(" v")
176
+
177
+ return "\n".join(lines)
178
+
179
+ def to_dict(self) -> dict[str, Any]:
180
+ """Serialize the complete agent execution trace to dictionary format."""
181
+ return {
182
+ "agent_name": self.agent_name,
183
+ "steps": [step.model_dump(mode="json") for step in self.steps],
184
+ }
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: dict[str, Any]) -> AgentTracker:
188
+ """Reconstruct an AgentTracker instance from serialized dictionary data."""
189
+ tracker = cls(agent_name=data.get("agent_name", "default_agent"))
190
+ for s in data.get("steps", []):
191
+ tracker.steps.append(AgentStep.model_validate(s))
192
+ return tracker