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.
- tracereplay/__init__.py +240 -0
- tracereplay/__main__.py +8 -0
- tracereplay/agent/__init__.py +13 -0
- tracereplay/agent/tool_recorder.py +213 -0
- tracereplay/agent/tracker.py +192 -0
- tracereplay/chaos/__init__.py +193 -0
- tracereplay/chaos/engine.py +450 -0
- tracereplay/chaos/models.py +89 -0
- tracereplay/chaos/native.py +136 -0
- tracereplay/cli.py +196 -0
- tracereplay/clock/__init__.py +18 -0
- tracereplay/clock/engine.py +264 -0
- tracereplay/clock/models.py +86 -0
- tracereplay/config.py +144 -0
- tracereplay/core/__init__.py +13 -0
- tracereplay/core/recorder.py +84 -0
- tracereplay/core/replayer.py +287 -0
- tracereplay/core/session.py +451 -0
- tracereplay/exceptions.py +348 -0
- tracereplay/guardrails/__init__.py +78 -0
- tracereplay/guardrails/engine.py +345 -0
- tracereplay/guardrails/models.py +137 -0
- tracereplay/guardrails/pricing.py +121 -0
- tracereplay/matcher/__init__.py +14 -0
- tracereplay/matcher/diff.py +29 -0
- tracereplay/matcher/normalizer.py +106 -0
- tracereplay/matcher/rules.py +181 -0
- tracereplay/models.py +207 -0
- tracereplay/providers/__init__.py +74 -0
- tracereplay/providers/anthropic.py +221 -0
- tracereplay/providers/base.py +34 -0
- tracereplay/providers/gemini.py +241 -0
- tracereplay/providers/generic.py +70 -0
- tracereplay/providers/groq.py +23 -0
- tracereplay/providers/mistral.py +211 -0
- tracereplay/providers/openai.py +240 -0
- tracereplay/providers/patcher.py +353 -0
- tracereplay/py.typed +0 -0
- tracereplay/pytest_plugin.py +252 -0
- tracereplay/sanitizer.py +82 -0
- tracereplay/semantic/__init__.py +47 -0
- tracereplay/semantic/diff.py +91 -0
- tracereplay/semantic/engine.py +249 -0
- tracereplay/semantic/models.py +161 -0
- tracereplay/semantic/similarity.py +132 -0
- tracereplay/storage/__init__.py +12 -0
- tracereplay/storage/json_storage.py +130 -0
- tracereplay/storage/resolver.py +60 -0
- tracereplay-0.1.0.dist-info/METADATA +442 -0
- tracereplay-0.1.0.dist-info/RECORD +53 -0
- tracereplay-0.1.0.dist-info/WHEEL +4 -0
- tracereplay-0.1.0.dist-info/entry_points.txt +5 -0
- tracereplay-0.1.0.dist-info/licenses/LICENSE +21 -0
tracereplay/__init__.py
ADDED
|
@@ -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
|
+
]
|
tracereplay/__main__.py
ADDED
|
@@ -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
|