prompture 0.0.50.dev1__py3-none-any.whl → 0.0.51__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.
prompture/history.py ADDED
@@ -0,0 +1,299 @@
1
+ """History export and filtering utilities for Agent results.
2
+
3
+ Provides functions to filter, search, and analyze agent execution history.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from .agent_types import AgentResult, AgentStep, StepType
13
+
14
+
15
+ def filter_steps(
16
+ steps: list[AgentStep],
17
+ *,
18
+ step_type: StepType | list[StepType] | None = None,
19
+ tool_name: str | list[str] | None = None,
20
+ after_timestamp: float | None = None,
21
+ before_timestamp: float | None = None,
22
+ ) -> list[AgentStep]:
23
+ """Filter steps by type, tool name, or timestamp range.
24
+
25
+ Args:
26
+ steps: List of AgentStep objects to filter.
27
+ step_type: Filter by step type (single or list).
28
+ tool_name: Filter by tool name (single or list). Only applies
29
+ to tool_call and tool_result step types.
30
+ after_timestamp: Only include steps after this Unix timestamp.
31
+ before_timestamp: Only include steps before this Unix timestamp.
32
+
33
+ Returns:
34
+ Filtered list of AgentStep objects.
35
+
36
+ Example::
37
+
38
+ from prompture.history import filter_steps
39
+ from prompture import StepType
40
+
41
+ # Get only tool call steps
42
+ tool_calls = filter_steps(result.steps, step_type=StepType.tool_call)
43
+
44
+ # Get steps for a specific tool
45
+ search_steps = filter_steps(result.steps, tool_name="search")
46
+
47
+ # Get steps in a time range
48
+ recent = filter_steps(result.steps, after_timestamp=start_time)
49
+ """
50
+ result: list[AgentStep] = []
51
+
52
+ # Normalize step_type to a set
53
+ type_filter: set[StepType] | None = None
54
+ if step_type is not None:
55
+ if isinstance(step_type, list):
56
+ type_filter = set(step_type)
57
+ else:
58
+ type_filter = {step_type}
59
+
60
+ # Normalize tool_name to a set
61
+ tool_filter: set[str] | None = None
62
+ if tool_name is not None:
63
+ if isinstance(tool_name, list):
64
+ tool_filter = set(tool_name)
65
+ else:
66
+ tool_filter = {tool_name}
67
+
68
+ for step in steps:
69
+ # Filter by type
70
+ if type_filter is not None and step.step_type not in type_filter:
71
+ continue
72
+
73
+ # Filter by tool name
74
+ if tool_filter is not None and (step.tool_name is None or step.tool_name not in tool_filter):
75
+ continue
76
+
77
+ # Filter by timestamp range
78
+ if after_timestamp is not None and step.timestamp <= after_timestamp:
79
+ continue
80
+ if before_timestamp is not None and step.timestamp >= before_timestamp:
81
+ continue
82
+
83
+ result.append(step)
84
+
85
+ return result
86
+
87
+
88
+ def search_messages(
89
+ messages: list[dict[str, Any]],
90
+ *,
91
+ role: str | list[str] | None = None,
92
+ content_contains: str | None = None,
93
+ has_tool_calls: bool | None = None,
94
+ ) -> list[dict[str, Any]]:
95
+ """Search messages by role or content.
96
+
97
+ Args:
98
+ messages: List of message dictionaries from AgentResult.messages.
99
+ role: Filter by role (single or list, e.g., "user", "assistant", "tool").
100
+ content_contains: Filter to messages whose content contains this substring
101
+ (case-insensitive).
102
+ has_tool_calls: If True, only return messages with tool_calls.
103
+ If False, only return messages without tool_calls.
104
+
105
+ Returns:
106
+ Filtered list of message dictionaries.
107
+
108
+ Example::
109
+
110
+ from prompture.history import search_messages
111
+
112
+ # Get all assistant messages
113
+ assistant_msgs = search_messages(result.messages, role="assistant")
114
+
115
+ # Search for messages mentioning "error"
116
+ error_msgs = search_messages(result.messages, content_contains="error")
117
+
118
+ # Get messages with tool calls
119
+ tool_msgs = search_messages(result.messages, has_tool_calls=True)
120
+ """
121
+ result: list[dict[str, Any]] = []
122
+
123
+ # Normalize role to a set
124
+ role_filter: set[str] | None = None
125
+ if role is not None:
126
+ if isinstance(role, list):
127
+ role_filter = set(role)
128
+ else:
129
+ role_filter = {role}
130
+
131
+ for msg in messages:
132
+ # Filter by role
133
+ if role_filter is not None:
134
+ msg_role = msg.get("role", "")
135
+ if msg_role not in role_filter:
136
+ continue
137
+
138
+ # Filter by content
139
+ if content_contains is not None:
140
+ content = msg.get("content", "")
141
+ if content is None:
142
+ content = ""
143
+ if content_contains.lower() not in content.lower():
144
+ continue
145
+
146
+ # Filter by tool calls
147
+ if has_tool_calls is not None:
148
+ msg_has_tools = bool(msg.get("tool_calls"))
149
+ if msg_has_tools != has_tool_calls:
150
+ continue
151
+
152
+ result.append(msg)
153
+
154
+ return result
155
+
156
+
157
+ def get_tool_call_summary(result: AgentResult) -> list[dict[str, Any]]:
158
+ """Get a summary of all tool calls from an AgentResult.
159
+
160
+ Returns a list of dictionaries, each containing:
161
+ - name: The tool name
162
+ - arguments: The arguments passed to the tool
163
+ - result: The tool's return value (if available)
164
+ - timestamp: When the call was made
165
+
166
+ Args:
167
+ result: AgentResult from an agent run.
168
+
169
+ Returns:
170
+ List of tool call summary dictionaries.
171
+
172
+ Example::
173
+
174
+ from prompture.history import get_tool_call_summary
175
+
176
+ summary = get_tool_call_summary(result)
177
+ for call in summary:
178
+ print(f"{call['name']}: {call['arguments']} -> {call.get('result', 'N/A')}")
179
+ """
180
+ summaries: list[dict[str, Any]] = []
181
+
182
+ # Pair up tool_call steps with their corresponding tool_result steps
183
+ tool_calls = [s for s in result.steps if s.step_type == StepType.tool_call]
184
+ tool_results = [s for s in result.steps if s.step_type == StepType.tool_result]
185
+
186
+ for i, call_step in enumerate(tool_calls):
187
+ summary: dict[str, Any] = {
188
+ "name": call_step.tool_name,
189
+ "arguments": call_step.tool_args or {},
190
+ "timestamp": call_step.timestamp,
191
+ }
192
+
193
+ # Try to find the corresponding result
194
+ if i < len(tool_results):
195
+ result_step = tool_results[i]
196
+ summary["result"] = result_step.content
197
+
198
+ summaries.append(summary)
199
+
200
+ return summaries
201
+
202
+
203
+ def calculate_cost_breakdown(run_usage: dict[str, Any]) -> dict[str, Any]:
204
+ """Calculate a detailed cost breakdown from run_usage.
205
+
206
+ Args:
207
+ run_usage: The run_usage dictionary from AgentResult.
208
+
209
+ Returns:
210
+ Dictionary with cost breakdown:
211
+ - prompt_tokens: Total prompt tokens used
212
+ - completion_tokens: Total completion tokens used
213
+ - total_tokens: Total tokens used
214
+ - prompt_cost: Cost for prompt tokens
215
+ - completion_cost: Cost for completion tokens
216
+ - total_cost: Total cost
217
+ - call_count: Number of API calls
218
+ - error_count: Number of errors
219
+
220
+ Example::
221
+
222
+ from prompture.history import calculate_cost_breakdown
223
+
224
+ breakdown = calculate_cost_breakdown(result.run_usage)
225
+ print(f"Total cost: ${breakdown['total_cost']:.4f}")
226
+ print(f"Calls: {breakdown['call_count']}")
227
+ """
228
+ return {
229
+ "prompt_tokens": run_usage.get("prompt_tokens", 0),
230
+ "completion_tokens": run_usage.get("completion_tokens", 0),
231
+ "total_tokens": run_usage.get("total_tokens", 0),
232
+ "prompt_cost": run_usage.get("prompt_cost", 0.0),
233
+ "completion_cost": run_usage.get("completion_cost", 0.0),
234
+ "total_cost": run_usage.get("total_cost", 0.0),
235
+ "call_count": run_usage.get("call_count", 0),
236
+ "error_count": run_usage.get("error_count", 0),
237
+ }
238
+
239
+
240
+ def export_result_json(result: AgentResult, include_messages: bool = True) -> str:
241
+ """Export an AgentResult to a JSON string.
242
+
243
+ Args:
244
+ result: AgentResult to export.
245
+ include_messages: Whether to include the full message history.
246
+
247
+ Returns:
248
+ JSON string representation of the result.
249
+
250
+ Example::
251
+
252
+ from prompture.history import export_result_json
253
+
254
+ json_str = export_result_json(result)
255
+ with open("agent_history.json", "w") as f:
256
+ f.write(json_str)
257
+ """
258
+ data = result_to_dict(result, include_messages=include_messages)
259
+ return json.dumps(data, indent=2, default=str)
260
+
261
+
262
+ def result_to_dict(result: AgentResult, include_messages: bool = True) -> dict[str, Any]:
263
+ """Convert an AgentResult to a dictionary.
264
+
265
+ Args:
266
+ result: AgentResult to convert.
267
+ include_messages: Whether to include the full message history.
268
+
269
+ Returns:
270
+ Dictionary representation of the result.
271
+ """
272
+ data: dict[str, Any] = {
273
+ "output": str(result.output) if result.output is not None else None,
274
+ "output_text": result.output_text,
275
+ "state": result.state.value if hasattr(result.state, "value") else str(result.state),
276
+ "usage": result.usage,
277
+ "run_usage": result.run_usage,
278
+ "steps": [_step_to_dict(s) for s in result.steps],
279
+ "all_tool_calls": result.all_tool_calls,
280
+ "exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
281
+ }
282
+
283
+ if include_messages:
284
+ data["messages"] = result.messages
285
+
286
+ return data
287
+
288
+
289
+ def _step_to_dict(step: AgentStep) -> dict[str, Any]:
290
+ """Convert an AgentStep to a dictionary."""
291
+ return {
292
+ "step_type": step.step_type.value if hasattr(step.step_type, "value") else str(step.step_type),
293
+ "timestamp": step.timestamp,
294
+ "content": step.content,
295
+ "tool_name": step.tool_name,
296
+ "tool_args": step.tool_args,
297
+ "tool_result": step.tool_result,
298
+ "duration_ms": step.duration_ms,
299
+ }
@@ -0,0 +1,31 @@
1
+ """Python sandbox module for safe code execution.
2
+
3
+ Provides a restricted Python execution environment with configurable
4
+ import restrictions, filesystem path restrictions, and resource limits.
5
+ """
6
+
7
+ from .exceptions import (
8
+ ImportViolationError,
9
+ PathViolationError,
10
+ ResourceLimitError,
11
+ SandboxError,
12
+ SandboxTimeoutError,
13
+ )
14
+ from .resource_limits import ResourceContext, ResourceLimits
15
+ from .restrictions import ALWAYS_BLOCKED_IMPORTS, ImportRestrictions, PathRestrictions
16
+ from .sandbox import PythonSandbox, SandboxResult
17
+
18
+ __all__ = [
19
+ "ALWAYS_BLOCKED_IMPORTS",
20
+ "ImportRestrictions",
21
+ "ImportViolationError",
22
+ "PathRestrictions",
23
+ "PathViolationError",
24
+ "PythonSandbox",
25
+ "ResourceContext",
26
+ "ResourceLimitError",
27
+ "ResourceLimits",
28
+ "SandboxError",
29
+ "SandboxResult",
30
+ "SandboxTimeoutError",
31
+ ]
@@ -0,0 +1,54 @@
1
+ """Sandbox exceptions.
2
+
3
+ Custom exceptions for sandbox violations and errors.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class SandboxError(Exception):
10
+ """Base exception for all sandbox-related errors."""
11
+
12
+ pass
13
+
14
+
15
+ class ImportViolationError(SandboxError):
16
+ """Raised when code attempts to import a blocked module."""
17
+
18
+ def __init__(self, module_name: str, reason: str | None = None) -> None:
19
+ self.module_name = module_name
20
+ self.reason = reason
21
+ message = f"Import of '{module_name}' is not allowed"
22
+ if reason:
23
+ message += f": {reason}"
24
+ super().__init__(message)
25
+
26
+
27
+ class PathViolationError(SandboxError):
28
+ """Raised when code attempts to access a restricted path."""
29
+
30
+ def __init__(self, path: str, operation: str = "access") -> None:
31
+ self.path = path
32
+ self.operation = operation
33
+ super().__init__(f"Cannot {operation} path '{path}': outside allowed directories")
34
+
35
+
36
+ class SandboxTimeoutError(SandboxError):
37
+ """Raised when code execution exceeds the timeout limit."""
38
+
39
+ def __init__(self, timeout_seconds: float) -> None:
40
+ self.timeout_seconds = timeout_seconds
41
+ super().__init__(f"Execution exceeded timeout of {timeout_seconds} seconds")
42
+
43
+
44
+ class ResourceLimitError(SandboxError):
45
+ """Raised when code exceeds resource limits (memory, etc.)."""
46
+
47
+ def __init__(self, resource: str, limit: int | float, used: int | float | None = None) -> None:
48
+ self.resource = resource
49
+ self.limit = limit
50
+ self.used = used
51
+ message = f"Resource limit exceeded for {resource}: limit={limit}"
52
+ if used is not None:
53
+ message += f", used={used}"
54
+ super().__init__(message)
@@ -0,0 +1,128 @@
1
+ """Resource limits for sandbox execution.
2
+
3
+ Provides timeout and memory limit handling.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import signal
10
+ from collections.abc import Generator
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from .exceptions import SandboxTimeoutError
16
+
17
+
18
+ @dataclass
19
+ class ResourceLimits:
20
+ """Configuration for resource limits in the sandbox.
21
+
22
+ Attributes:
23
+ timeout_seconds: Maximum execution time in seconds.
24
+ max_memory_bytes: Maximum memory usage in bytes (Unix only via resource module).
25
+ Note: Memory limits are only enforced on Unix systems.
26
+ max_output_bytes: Maximum output size in bytes.
27
+ """
28
+
29
+ timeout_seconds: float = 30.0
30
+ max_memory_bytes: int | None = None
31
+ max_output_bytes: int = 1024 * 1024 # 1 MB default
32
+
33
+
34
+ class TimeoutHandler:
35
+ """Context manager for handling execution timeouts.
36
+
37
+ Uses signal.SIGALRM on Unix systems, threading-based approach on Windows.
38
+ """
39
+
40
+ def __init__(self, seconds: float) -> None:
41
+ self.seconds = seconds
42
+ self._old_handler: Any = None
43
+ self._is_unix = platform.system() != "Windows"
44
+
45
+ def _timeout_handler(self, signum: int, frame: Any) -> None:
46
+ """Signal handler for timeout."""
47
+ raise SandboxTimeoutError(self.seconds)
48
+
49
+ def __enter__(self) -> TimeoutHandler:
50
+ if self._is_unix and self.seconds > 0:
51
+ self._old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
52
+ signal.setitimer(signal.ITIMER_REAL, self.seconds)
53
+ return self
54
+
55
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
56
+ if self._is_unix and self.seconds > 0:
57
+ signal.setitimer(signal.ITIMER_REAL, 0)
58
+ if self._old_handler is not None:
59
+ signal.signal(signal.SIGALRM, self._old_handler)
60
+
61
+
62
+ class ResourceContext:
63
+ """Context manager for resource limits.
64
+
65
+ Applies timeout and optionally memory limits during code execution.
66
+
67
+ Note:
68
+ Memory limits via the resource module are only available on Unix.
69
+ On Windows, only timeout limits are enforced (and with limitations).
70
+ """
71
+
72
+ def __init__(self, limits: ResourceLimits) -> None:
73
+ self.limits = limits
74
+ self._timeout_handler: TimeoutHandler | None = None
75
+ self._old_memory_limit: tuple[int, int] | None = None
76
+ self._is_unix = platform.system() != "Windows"
77
+
78
+ def __enter__(self) -> ResourceContext:
79
+ # Set up timeout
80
+ if self.limits.timeout_seconds > 0:
81
+ self._timeout_handler = TimeoutHandler(self.limits.timeout_seconds)
82
+ self._timeout_handler.__enter__()
83
+
84
+ # Set up memory limit (Unix only)
85
+ if self._is_unix and self.limits.max_memory_bytes is not None:
86
+ try:
87
+ import resource as res
88
+
89
+ self._old_memory_limit = res.getrlimit(res.RLIMIT_AS)
90
+ res.setrlimit(res.RLIMIT_AS, (self.limits.max_memory_bytes, self.limits.max_memory_bytes))
91
+ except (ImportError, ValueError, OSError):
92
+ # resource module not available or limit can't be set
93
+ self._old_memory_limit = None
94
+
95
+ return self
96
+
97
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
98
+ # Clean up timeout
99
+ if self._timeout_handler is not None:
100
+ self._timeout_handler.__exit__(exc_type, exc_val, exc_tb)
101
+
102
+ # Clean up memory limit
103
+ if self._is_unix and self._old_memory_limit is not None:
104
+ try:
105
+ import resource as res
106
+
107
+ res.setrlimit(res.RLIMIT_AS, self._old_memory_limit)
108
+ except (ImportError, ValueError, OSError):
109
+ pass
110
+
111
+
112
+ @contextmanager
113
+ def enforce_limits(limits: ResourceLimits) -> Generator[None, None, None]:
114
+ """Context manager to enforce resource limits.
115
+
116
+ Args:
117
+ limits: ResourceLimits configuration.
118
+
119
+ Yields:
120
+ None - use this as a context manager around code execution.
121
+
122
+ Raises:
123
+ SandboxTimeoutError: If execution exceeds timeout.
124
+ ResourceLimitError: If memory limit is exceeded (Unix only).
125
+ """
126
+ ctx = ResourceContext(limits)
127
+ with ctx:
128
+ yield