tactus 0.31.2__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.
- tactus/__init__.py +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.2.dist-info/METADATA +1809 -0
- tactus-0.31.2.dist-info/RECORD +160 -0
- tactus-0.31.2.dist-info/WHEEL +4 -0
- tactus-0.31.2.dist-info/entry_points.txt +2 -0
- tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message history management for per-agent conversation histories.
|
|
3
|
+
|
|
4
|
+
Manages conversation histories with filtering capabilities for
|
|
5
|
+
token budgets, message limits, and custom filters.
|
|
6
|
+
|
|
7
|
+
Aligned with pydantic-ai's message_history concept.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from pydantic_ai.messages import ModelMessage
|
|
14
|
+
except ImportError:
|
|
15
|
+
# Fallback if pydantic_ai not available
|
|
16
|
+
ModelMessage = dict
|
|
17
|
+
|
|
18
|
+
from .registry import MessageHistoryConfiguration
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MessageHistoryManager:
|
|
22
|
+
"""Manages per-agent message histories with filtering.
|
|
23
|
+
|
|
24
|
+
Aligned with pydantic-ai's message_history concept - this manager
|
|
25
|
+
maintains the message_history lists that get passed to agent.run_sync().
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
"""Initialize message history manager."""
|
|
30
|
+
self.histories: dict[str, list[ModelMessage]] = {}
|
|
31
|
+
self.shared_history: list[ModelMessage] = []
|
|
32
|
+
|
|
33
|
+
def get_history_for_agent(
|
|
34
|
+
self,
|
|
35
|
+
agent_name: str,
|
|
36
|
+
message_history_config: Optional[MessageHistoryConfiguration] = None,
|
|
37
|
+
context: Optional[Any] = None,
|
|
38
|
+
) -> list[ModelMessage]:
|
|
39
|
+
"""
|
|
40
|
+
Get filtered message history for an agent.
|
|
41
|
+
|
|
42
|
+
This returns the message_history list that will be passed to
|
|
43
|
+
pydantic-ai's agent.run_sync(message_history=...).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
agent_name: Name of the agent
|
|
47
|
+
message_history_config: Message history configuration (source, filter)
|
|
48
|
+
context: Runtime context for filter functions
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of messages for the agent (message_history for pydantic-ai)
|
|
52
|
+
"""
|
|
53
|
+
if message_history_config is None:
|
|
54
|
+
# Default: own history, no filter
|
|
55
|
+
return self.histories.get(agent_name, [])
|
|
56
|
+
|
|
57
|
+
# Determine source
|
|
58
|
+
if message_history_config.source == "own":
|
|
59
|
+
messages = self.histories.get(agent_name, [])
|
|
60
|
+
elif message_history_config.source == "shared":
|
|
61
|
+
messages = self.shared_history
|
|
62
|
+
else:
|
|
63
|
+
# Another agent's history
|
|
64
|
+
messages = self.histories.get(message_history_config.source, [])
|
|
65
|
+
|
|
66
|
+
# Apply filter if specified
|
|
67
|
+
if message_history_config.filter:
|
|
68
|
+
messages = self._apply_filter(messages, message_history_config.filter, context)
|
|
69
|
+
|
|
70
|
+
return messages
|
|
71
|
+
|
|
72
|
+
def add_message(
|
|
73
|
+
self,
|
|
74
|
+
agent_name: str,
|
|
75
|
+
message: ModelMessage,
|
|
76
|
+
also_shared: bool = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Add a message to an agent's history.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
agent_name: Name of the agent
|
|
83
|
+
message: Message to add
|
|
84
|
+
also_shared: Also add to shared history
|
|
85
|
+
"""
|
|
86
|
+
if agent_name not in self.histories:
|
|
87
|
+
self.histories[agent_name] = []
|
|
88
|
+
|
|
89
|
+
self.histories[agent_name].append(message)
|
|
90
|
+
|
|
91
|
+
if also_shared:
|
|
92
|
+
self.shared_history.append(message)
|
|
93
|
+
|
|
94
|
+
def clear_agent_history(self, agent_name: str) -> None:
|
|
95
|
+
"""Clear an agent's history."""
|
|
96
|
+
self.histories[agent_name] = []
|
|
97
|
+
|
|
98
|
+
def clear_shared_history(self) -> None:
|
|
99
|
+
"""Clear shared history."""
|
|
100
|
+
self.shared_history = []
|
|
101
|
+
|
|
102
|
+
def _apply_filter(
|
|
103
|
+
self,
|
|
104
|
+
messages: list[ModelMessage],
|
|
105
|
+
filter_spec: Any,
|
|
106
|
+
context: Optional[Any],
|
|
107
|
+
) -> list[ModelMessage]:
|
|
108
|
+
"""
|
|
109
|
+
Apply declarative or function filter.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
messages: Messages to filter
|
|
113
|
+
filter_spec: Filter specification (tuple or callable)
|
|
114
|
+
context: Runtime context
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Filtered messages
|
|
118
|
+
"""
|
|
119
|
+
# If it's a callable (Lua function), call it
|
|
120
|
+
if callable(filter_spec):
|
|
121
|
+
try:
|
|
122
|
+
return filter_spec(messages, context)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
# If filter fails, return unfiltered
|
|
125
|
+
print(f"Warning: Filter function failed: {e}")
|
|
126
|
+
return messages
|
|
127
|
+
|
|
128
|
+
# Otherwise it's a tuple (filter_type, filter_arg)
|
|
129
|
+
if not isinstance(filter_spec, tuple) or len(filter_spec) < 2:
|
|
130
|
+
return messages
|
|
131
|
+
|
|
132
|
+
filter_type = filter_spec[0]
|
|
133
|
+
filter_arg = filter_spec[1]
|
|
134
|
+
|
|
135
|
+
if filter_type == "last_n":
|
|
136
|
+
return self._filter_last_n(messages, filter_arg)
|
|
137
|
+
elif filter_type == "token_budget":
|
|
138
|
+
return self._filter_by_token_budget(messages, filter_arg)
|
|
139
|
+
elif filter_type == "by_role":
|
|
140
|
+
return self._filter_by_role(messages, filter_arg)
|
|
141
|
+
elif filter_type == "compose":
|
|
142
|
+
# Apply multiple filters in sequence
|
|
143
|
+
result = messages
|
|
144
|
+
for f in filter_arg:
|
|
145
|
+
result = self._apply_filter(result, f, context)
|
|
146
|
+
return result
|
|
147
|
+
else:
|
|
148
|
+
# Unknown filter type, return unfiltered
|
|
149
|
+
return messages
|
|
150
|
+
|
|
151
|
+
def _filter_last_n(
|
|
152
|
+
self,
|
|
153
|
+
messages: list[ModelMessage],
|
|
154
|
+
n: int,
|
|
155
|
+
) -> list[ModelMessage]:
|
|
156
|
+
"""Keep only the last N messages."""
|
|
157
|
+
return messages[-n:] if n > 0 else []
|
|
158
|
+
|
|
159
|
+
def _filter_by_token_budget(
|
|
160
|
+
self,
|
|
161
|
+
messages: list[ModelMessage],
|
|
162
|
+
max_tokens: int,
|
|
163
|
+
) -> list[ModelMessage]:
|
|
164
|
+
"""
|
|
165
|
+
Filter messages to stay within token budget.
|
|
166
|
+
|
|
167
|
+
Uses a simple heuristic: ~4 characters per token.
|
|
168
|
+
Keeps most recent messages that fit within budget.
|
|
169
|
+
"""
|
|
170
|
+
if max_tokens <= 0:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
# Rough estimate: 4 chars per token
|
|
174
|
+
max_chars = max_tokens * 4
|
|
175
|
+
|
|
176
|
+
result = []
|
|
177
|
+
current_chars = 0
|
|
178
|
+
|
|
179
|
+
# Work backwards from most recent
|
|
180
|
+
for message in reversed(messages):
|
|
181
|
+
# Estimate message size
|
|
182
|
+
message_chars = self._estimate_message_chars(message)
|
|
183
|
+
|
|
184
|
+
if current_chars + message_chars > max_chars:
|
|
185
|
+
# Would exceed budget, stop here
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
result.insert(0, message)
|
|
189
|
+
current_chars += message_chars
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def _filter_by_role(
|
|
194
|
+
self,
|
|
195
|
+
messages: list[ModelMessage],
|
|
196
|
+
role: str,
|
|
197
|
+
) -> list[ModelMessage]:
|
|
198
|
+
"""Keep only messages with specified role."""
|
|
199
|
+
return [m for m in messages if self._get_message_role(m) == role]
|
|
200
|
+
|
|
201
|
+
def _estimate_message_chars(self, message: ModelMessage) -> int:
|
|
202
|
+
"""Estimate character count of a message."""
|
|
203
|
+
if isinstance(message, dict):
|
|
204
|
+
# Dict-based message
|
|
205
|
+
content = message.get("content", "")
|
|
206
|
+
if isinstance(content, str):
|
|
207
|
+
return len(content)
|
|
208
|
+
elif isinstance(content, list):
|
|
209
|
+
# Multiple content parts
|
|
210
|
+
total = 0
|
|
211
|
+
for part in content:
|
|
212
|
+
if isinstance(part, dict):
|
|
213
|
+
total += len(str(part.get("text", "")))
|
|
214
|
+
else:
|
|
215
|
+
total += len(str(part))
|
|
216
|
+
return total
|
|
217
|
+
return len(str(content))
|
|
218
|
+
else:
|
|
219
|
+
# Pydantic AI ModelMessage object
|
|
220
|
+
try:
|
|
221
|
+
# Try to access content attribute
|
|
222
|
+
content = getattr(message, "content", "")
|
|
223
|
+
return len(str(content))
|
|
224
|
+
except Exception:
|
|
225
|
+
# Fallback: convert to string
|
|
226
|
+
return len(str(message))
|
|
227
|
+
|
|
228
|
+
def _get_message_role(self, message: ModelMessage) -> str:
|
|
229
|
+
"""Get role from a message."""
|
|
230
|
+
if isinstance(message, dict):
|
|
231
|
+
return message.get("role", "")
|
|
232
|
+
else:
|
|
233
|
+
try:
|
|
234
|
+
return getattr(message, "role", "")
|
|
235
|
+
except Exception:
|
|
236
|
+
return ""
|
tactus/core/mocking.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mocking infrastructure for Tactus.
|
|
3
|
+
|
|
4
|
+
Provides comprehensive mocking capabilities including:
|
|
5
|
+
- Static mocks (always return same value)
|
|
6
|
+
- Temporal mocks (different returns per call)
|
|
7
|
+
- Conditional mocks (based on input parameters)
|
|
8
|
+
- Mock state tracking and assertions
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MockCall:
|
|
20
|
+
"""Record of a mock tool call."""
|
|
21
|
+
|
|
22
|
+
tool_name: str
|
|
23
|
+
args: Dict[str, Any]
|
|
24
|
+
result: Any
|
|
25
|
+
call_number: int
|
|
26
|
+
timestamp: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MockConfig:
|
|
31
|
+
"""Configuration for a mocked tool."""
|
|
32
|
+
|
|
33
|
+
tool_name: str
|
|
34
|
+
|
|
35
|
+
# Static mock - always returns this
|
|
36
|
+
static_result: Optional[Any] = None
|
|
37
|
+
|
|
38
|
+
# Temporal mocks - return different values per call
|
|
39
|
+
temporal_results: List[Any] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
# Conditional mocks - return based on args
|
|
42
|
+
conditional_mocks: List[Dict[str, Any]] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
# Error simulation
|
|
45
|
+
error: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
# Whether this mock is enabled
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MockManager:
|
|
52
|
+
"""
|
|
53
|
+
Manages mock state and temporal mocking for testing.
|
|
54
|
+
|
|
55
|
+
This is injected into the runtime to intercept tool calls
|
|
56
|
+
and return mock responses when configured.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
"""Initialize mock manager."""
|
|
61
|
+
self.mocks: Dict[str, MockConfig] = {}
|
|
62
|
+
self.call_history: Dict[str, List[MockCall]] = {}
|
|
63
|
+
self.call_counts: Dict[str, int] = {}
|
|
64
|
+
self.enabled = True # Global mock enable/disable
|
|
65
|
+
|
|
66
|
+
def register_mock(self, tool_name: str, config: Union[MockConfig, Dict[str, Any]]) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Register a mock configuration for a tool.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tool_name: Name of the tool to mock
|
|
72
|
+
config: MockConfig or dict with mock settings
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(config, dict):
|
|
75
|
+
# Convert dict to MockConfig
|
|
76
|
+
if "output" in config:
|
|
77
|
+
# Simple static mock
|
|
78
|
+
mock_config = MockConfig(tool_name=tool_name, static_result=config["output"])
|
|
79
|
+
elif "error" in config:
|
|
80
|
+
# Error simulation
|
|
81
|
+
mock_config = MockConfig(tool_name=tool_name, error=config["error"])
|
|
82
|
+
elif "temporal" in config:
|
|
83
|
+
# Temporal mocking
|
|
84
|
+
mock_config = MockConfig(tool_name=tool_name, temporal_results=config["temporal"])
|
|
85
|
+
else:
|
|
86
|
+
# Full config
|
|
87
|
+
mock_config = MockConfig(tool_name=tool_name, **config)
|
|
88
|
+
else:
|
|
89
|
+
mock_config = config
|
|
90
|
+
|
|
91
|
+
self.mocks[tool_name] = mock_config
|
|
92
|
+
logger.info(f"Registered mock for tool '{tool_name}'")
|
|
93
|
+
|
|
94
|
+
def get_mock_response(self, tool_name: str, args: Dict[str, Any]) -> Optional[Any]:
|
|
95
|
+
"""
|
|
96
|
+
Get mock response for a tool call.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tool_name: Name of the tool being called
|
|
100
|
+
args: Arguments passed to the tool
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Mock response if configured, None if tool should run normally
|
|
104
|
+
"""
|
|
105
|
+
if not self.enabled:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if tool_name not in self.mocks:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
mock_config = self.mocks[tool_name]
|
|
112
|
+
if not mock_config.enabled:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# Get call number for this tool
|
|
116
|
+
call_number = self.call_counts.get(tool_name, 0) + 1
|
|
117
|
+
|
|
118
|
+
# Check for error simulation
|
|
119
|
+
if mock_config.error:
|
|
120
|
+
raise RuntimeError(mock_config.error)
|
|
121
|
+
|
|
122
|
+
# Check temporal mocks first
|
|
123
|
+
if mock_config.temporal_results:
|
|
124
|
+
# Return based on call number (1-indexed)
|
|
125
|
+
if call_number <= len(mock_config.temporal_results):
|
|
126
|
+
result = mock_config.temporal_results[call_number - 1]
|
|
127
|
+
logger.debug(
|
|
128
|
+
f"Mock '{tool_name}' returning temporal result for call {call_number}: {result}"
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
else:
|
|
132
|
+
# Fallback to last result if we've exceeded temporal results
|
|
133
|
+
result = mock_config.temporal_results[-1]
|
|
134
|
+
logger.debug(
|
|
135
|
+
f"Mock '{tool_name}' returning last temporal result (call {call_number}): {result}"
|
|
136
|
+
)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
# Check conditional mocks
|
|
140
|
+
for conditional in mock_config.conditional_mocks:
|
|
141
|
+
condition = conditional.get("when", {})
|
|
142
|
+
if self._matches_condition(args, condition):
|
|
143
|
+
result = conditional.get("return")
|
|
144
|
+
logger.debug(f"Mock '{tool_name}' matched condition, returning: {result}")
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
# Return static result if configured
|
|
148
|
+
if mock_config.static_result is not None:
|
|
149
|
+
logger.debug(f"Mock '{tool_name}' returning static result: {mock_config.static_result}")
|
|
150
|
+
return mock_config.static_result
|
|
151
|
+
|
|
152
|
+
# No mock response configured
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def record_call(self, tool_name: str, args: Dict[str, Any], result: Any) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Record a tool call for assertions.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
tool_name: Name of the tool that was called
|
|
161
|
+
args: Arguments passed to the tool
|
|
162
|
+
result: Result returned by the tool (or mock)
|
|
163
|
+
"""
|
|
164
|
+
import time
|
|
165
|
+
|
|
166
|
+
# Update call count
|
|
167
|
+
self.call_counts[tool_name] = self.call_counts.get(tool_name, 0) + 1
|
|
168
|
+
|
|
169
|
+
# Create call record
|
|
170
|
+
call = MockCall(
|
|
171
|
+
tool_name=tool_name,
|
|
172
|
+
args=args,
|
|
173
|
+
result=result,
|
|
174
|
+
call_number=self.call_counts[tool_name],
|
|
175
|
+
timestamp=time.time(),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Add to history
|
|
179
|
+
if tool_name not in self.call_history:
|
|
180
|
+
self.call_history[tool_name] = []
|
|
181
|
+
self.call_history[tool_name].append(call)
|
|
182
|
+
|
|
183
|
+
logger.debug(f"Recorded call to '{tool_name}' (call #{call.call_number})")
|
|
184
|
+
|
|
185
|
+
def get_call_count(self, tool_name: str) -> int:
|
|
186
|
+
"""
|
|
187
|
+
Get the number of times a tool was called.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
tool_name: Name of the tool
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Number of calls to the tool
|
|
194
|
+
"""
|
|
195
|
+
return self.call_counts.get(tool_name, 0)
|
|
196
|
+
|
|
197
|
+
def get_call_history(self, tool_name: str) -> List[MockCall]:
|
|
198
|
+
"""
|
|
199
|
+
Get the call history for a tool.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
tool_name: Name of the tool
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of MockCall records
|
|
206
|
+
"""
|
|
207
|
+
return self.call_history.get(tool_name, [])
|
|
208
|
+
|
|
209
|
+
def reset(self) -> None:
|
|
210
|
+
"""Reset all mock state."""
|
|
211
|
+
self.call_history.clear()
|
|
212
|
+
self.call_counts.clear()
|
|
213
|
+
logger.debug("Mock manager state reset")
|
|
214
|
+
|
|
215
|
+
def _matches_condition(self, args: Dict[str, Any], condition: Dict[str, Any]) -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Check if args match a condition.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
args: Tool arguments to check
|
|
221
|
+
condition: Condition dict with patterns to match
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if condition matches
|
|
225
|
+
"""
|
|
226
|
+
for key, pattern in condition.items():
|
|
227
|
+
if key not in args:
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
arg_value = args[key]
|
|
231
|
+
|
|
232
|
+
# Handle different pattern types
|
|
233
|
+
if isinstance(pattern, str):
|
|
234
|
+
# Check for operators
|
|
235
|
+
if pattern.startswith("contains:"):
|
|
236
|
+
substring = pattern[9:].strip()
|
|
237
|
+
if substring not in str(arg_value):
|
|
238
|
+
return False
|
|
239
|
+
elif pattern.startswith("startswith:"):
|
|
240
|
+
prefix = pattern[11:].strip()
|
|
241
|
+
if not str(arg_value).startswith(prefix):
|
|
242
|
+
return False
|
|
243
|
+
elif pattern.startswith("endswith:"):
|
|
244
|
+
suffix = pattern[9:].strip()
|
|
245
|
+
if not str(arg_value).endswith(suffix):
|
|
246
|
+
return False
|
|
247
|
+
else:
|
|
248
|
+
# Exact match
|
|
249
|
+
if arg_value != pattern:
|
|
250
|
+
return False
|
|
251
|
+
else:
|
|
252
|
+
# Direct comparison
|
|
253
|
+
if arg_value != pattern:
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
def enable_mock(self, tool_name: Optional[str] = None) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Enable mocking for a specific tool or all tools.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
tool_name: Specific tool to enable, or None for all
|
|
264
|
+
"""
|
|
265
|
+
if tool_name:
|
|
266
|
+
if tool_name in self.mocks:
|
|
267
|
+
self.mocks[tool_name].enabled = True
|
|
268
|
+
logger.info(f"Enabled mock for tool '{tool_name}'")
|
|
269
|
+
else:
|
|
270
|
+
self.enabled = True
|
|
271
|
+
logger.info("Enabled all mocks")
|
|
272
|
+
|
|
273
|
+
def disable_mock(self, tool_name: Optional[str] = None) -> None:
|
|
274
|
+
"""
|
|
275
|
+
Disable mocking for a specific tool or all tools.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
tool_name: Specific tool to disable, or None for all
|
|
279
|
+
"""
|
|
280
|
+
if tool_name:
|
|
281
|
+
if tool_name in self.mocks:
|
|
282
|
+
self.mocks[tool_name].enabled = False
|
|
283
|
+
logger.info(f"Disabled mock for tool '{tool_name}'")
|
|
284
|
+
else:
|
|
285
|
+
self.enabled = False
|
|
286
|
+
logger.info("Disabled all mocks")
|