omnimancer-cli 0.2.15__tar.gz → 0.2.20__tar.gz
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.
- {omnimancer_cli-0.2.15/omnimancer_cli.egg-info → omnimancer_cli-0.2.20}/PKG-INFO +1 -1
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/tool_handler.py +12 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/file_system_manager.py +41 -4
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/models.py +23 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/azure.py +7 -3
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/base.py +30 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/mistral.py +7 -3
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/openai.py +38 -19
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/openrouter.py +16 -6
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/xai.py +7 -3
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20/omnimancer_cli.egg-info}/PKG-INFO +1 -1
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/SOURCES.txt +1 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/pyproject.toml +1 -1
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_models.py +114 -65
- omnimancer_cli-0.2.20/tests/test_openai_request_timeout.py +129 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openrouter_provider.py +97 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_tool_handler.py +15 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_xai_provider.py +5 -3
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/LICENSE +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/README.md +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/__main__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/agent_loop.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_formatter.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_integration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/approval_prompt.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/command_dispatch.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/commands.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/completion.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/display.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/dynamic_commands.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/handlers/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/headless.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/interface.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/subagent.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/cli/system_prompts.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_context.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_interface.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/approval_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/config.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/program_executor.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/read_before_write_errors.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_core.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/status_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/tool_definitions.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/types.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/web_client.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_engine.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_managers.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent_mode_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/chat_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_migration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/config_validator.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/conversation_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/engine.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/env_loader.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/fallback_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/health_monitor.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/history_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/hooks.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_capabilities.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_initializer.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/provider_registry.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/rate_limit_fallback.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/approval_workflow.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/audit_logger.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_controller.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/permission_rules.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/sandbox_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/security/security_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/signal_handler.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/client.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/mcp/manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/bedrock.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_code.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/claude_credentials.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/cohere.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/digitalocean.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/factory.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/gemini.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/ollama.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/perplexity.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/providers/vertex.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/cancellation_handler.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/progress_indicator.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/ui/streaming_display.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/__init__.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/error_handler.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/errors.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/utils/retry.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/entry_points.txt +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/requires.txt +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer_cli.egg-info/top_level.txt +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/setup.cfg +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_integration_compatibility.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_preview_format.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_remember.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_approval_system.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_azure_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_bedrock_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_code_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_credentials.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_claude_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_cohere_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_command_approval_security.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_config_comprehensive.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_conversation_history.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_conversation_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_core_engine.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_digitalocean_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_enhanced_model_info.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_env_loader.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_error_handling_robustness.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_execution_timeout_errors.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_fallback_manager.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_fixtures.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_gemini_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_hooks.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_httpx_compatibility.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_import_validation.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_interface_smoke_tests.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_integration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mcp_management_commands.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_mistral_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_ollama_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openai_context_fit.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_openai_provider_tools.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_permission_rules.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_perplexity_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_program_executor.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_base.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_capability_consistency.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_discovery.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_integration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_provider_registry.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_rate_limit_fallback.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_retry.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_security_validation.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_system_integration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_system_prompts.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_tool_calling_integration.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_vertex_provider.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_web_request_markers.py +0 -0
- {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/tests/test_workflows_comprehensive.py +0 -0
|
@@ -150,6 +150,18 @@ class ToolHandler:
|
|
|
150
150
|
return list(CODING_AGENT_TOOLS)
|
|
151
151
|
|
|
152
152
|
async def execute_tool_call(self, tool_call: ToolCall) -> ToolResult:
|
|
153
|
+
# ToolCall normalizes JSON-string arguments on construction; anything
|
|
154
|
+
# still not a dict here is malformed — tell the model instead of
|
|
155
|
+
# crashing on args.get(...) inside a handler.
|
|
156
|
+
if not isinstance(tool_call.arguments, dict):
|
|
157
|
+
return ToolResult(
|
|
158
|
+
content="",
|
|
159
|
+
error=(
|
|
160
|
+
f"Malformed arguments for {tool_call.name}: expected a "
|
|
161
|
+
f"JSON object, got: {str(tool_call.arguments)[:200]}. "
|
|
162
|
+
"Re-issue the call with the parameters as a JSON object."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
153
165
|
try:
|
|
154
166
|
# Read and Edit need post-processing / multi-step logic.
|
|
155
167
|
if tool_call.name in _EDIT_TOOLS:
|
{omnimancer_cli-0.2.15 → omnimancer_cli-0.2.20}/omnimancer/core/agent/file_system_manager.py
RENAMED
|
@@ -250,14 +250,51 @@ class FileSystemManager:
|
|
|
250
250
|
"""
|
|
251
251
|
Simple boolean check for file existence.
|
|
252
252
|
|
|
253
|
+
Performs a lightweight path-validation check followed by an async
|
|
254
|
+
filesystem probe. Unlike :meth:`check_file_exists`, this method does
|
|
255
|
+
*not* invoke the full security-audit pipeline (permission logging, risk
|
|
256
|
+
assessment, approval workflow). It is intended for high-frequency
|
|
257
|
+
callers (e.g. batch operations) that need a fast boolean answer.
|
|
258
|
+
|
|
259
|
+
.. warning::
|
|
260
|
+
This method enforces path-level access controls — hard-restricted
|
|
261
|
+
system paths (``/etc``, ``~/.ssh``, cloud-credential stores, etc.)
|
|
262
|
+
and sensitive-name patterns are still blocked via
|
|
263
|
+
:meth:`~omnimancer.core.security.permission_controller.PermissionController.validate_path_access`.
|
|
264
|
+
However, it skips the per-operation audit-log entries produced by
|
|
265
|
+
:meth:`~omnimancer.core.security.SecurityManager.secure_file_access`.
|
|
266
|
+
Do not use this method where per-access audit trails are required.
|
|
267
|
+
|
|
253
268
|
Args:
|
|
254
|
-
path: Path to check for existence
|
|
269
|
+
path: Path to check for existence.
|
|
255
270
|
|
|
256
271
|
Returns:
|
|
257
|
-
True if
|
|
272
|
+
True if the path passes security validation and exists on the
|
|
273
|
+
filesystem; False if the path is security-denied or does not exist.
|
|
258
274
|
"""
|
|
259
|
-
|
|
260
|
-
|
|
275
|
+
try:
|
|
276
|
+
resolved = Path(path).resolve()
|
|
277
|
+
except (OSError, ValueError) as e:
|
|
278
|
+
logger.warning("file_exists: invalid path %r: %s", path, e)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
# Validate path against security policy without invoking the expensive
|
|
282
|
+
# audit pipeline. This blocks hard-restricted system paths and
|
|
283
|
+
# sensitive-name patterns (e.g. /etc, ~/.ssh, *.db) the same way the
|
|
284
|
+
# full pipeline would.
|
|
285
|
+
if not self.security.permissions.validate_path_access(str(resolved), "read"):
|
|
286
|
+
logger.warning(
|
|
287
|
+
"file_exists: access denied by security policy for %s", resolved
|
|
288
|
+
)
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
exists = bool(await aiofiles.os.path.exists(resolved))
|
|
293
|
+
logger.debug("file_exists: %s → %s", resolved, exists)
|
|
294
|
+
return exists
|
|
295
|
+
except OSError as e:
|
|
296
|
+
logger.warning("file_exists: OS error checking %s: %s", resolved, e)
|
|
297
|
+
return False
|
|
261
298
|
|
|
262
299
|
async def write_file_with_confirmation(
|
|
263
300
|
self,
|
|
@@ -113,6 +113,29 @@ class ToolCall:
|
|
|
113
113
|
# Provider-assigned call id (OpenAI protocol pairs results to calls by id).
|
|
114
114
|
id: Optional[str] = None
|
|
115
115
|
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
"""Normalize arguments that arrive as a JSON-encoded string.
|
|
118
|
+
|
|
119
|
+
OpenAI-protocol servers send ``function.arguments`` as a JSON string,
|
|
120
|
+
and some models double-encode it. A value that doesn't decode to an
|
|
121
|
+
object is left as-is so the tool handler can report it to the model.
|
|
122
|
+
"""
|
|
123
|
+
import json
|
|
124
|
+
|
|
125
|
+
if self.arguments is None:
|
|
126
|
+
self.arguments = {}
|
|
127
|
+
return
|
|
128
|
+
decoded: Any = self.arguments
|
|
129
|
+
for _ in range(2):
|
|
130
|
+
if not isinstance(decoded, str):
|
|
131
|
+
break
|
|
132
|
+
try:
|
|
133
|
+
decoded = json.loads(decoded)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return
|
|
136
|
+
if isinstance(decoded, dict):
|
|
137
|
+
self.arguments = decoded
|
|
138
|
+
|
|
116
139
|
|
|
117
140
|
@dataclass
|
|
118
141
|
class ToolResultRecord:
|
|
@@ -363,19 +363,23 @@ class AzureProvider(BaseProvider):
|
|
|
363
363
|
|
|
364
364
|
if choices and len(choices) > 0:
|
|
365
365
|
message = choices[0].get("message", {})
|
|
366
|
-
|
|
366
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
367
|
+
content = message.get("content") or ""
|
|
367
368
|
usage = data.get("usage", {})
|
|
368
369
|
|
|
369
|
-
# Extract tool calls if present
|
|
370
|
+
# Extract tool calls if present. The wire protocol sends
|
|
371
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
372
|
+
# it to a dict on construction.
|
|
370
373
|
tool_calls = []
|
|
371
374
|
if "tool_calls" in message:
|
|
372
|
-
for tool_call in message["tool_calls"]:
|
|
375
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
373
376
|
if tool_call.get("type") == "function":
|
|
374
377
|
function = tool_call.get("function", {})
|
|
375
378
|
tool_calls.append(
|
|
376
379
|
ToolCall(
|
|
377
380
|
name=function.get("name", ""),
|
|
378
381
|
arguments=function.get("arguments", {}),
|
|
382
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
379
383
|
)
|
|
380
384
|
)
|
|
381
385
|
|
|
@@ -6,6 +6,8 @@ to ensure consistent behavior across different AI services.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
9
11
|
from abc import ABC, abstractmethod
|
|
10
12
|
from typing import Any, AsyncIterator, Dict, List, Union
|
|
11
13
|
|
|
@@ -19,6 +21,8 @@ from ..core.models import (
|
|
|
19
21
|
ToolDefinition,
|
|
20
22
|
)
|
|
21
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
class BaseProvider(ABC):
|
|
24
28
|
"""
|
|
@@ -28,6 +32,32 @@ class BaseProvider(ABC):
|
|
|
28
32
|
the required abstract methods to ensure consistent behavior.
|
|
29
33
|
"""
|
|
30
34
|
|
|
35
|
+
# Default chat-completion timeout. Large models on serverless backends
|
|
36
|
+
# (e.g. 300B+ models on DigitalOcean inference) can take well over a
|
|
37
|
+
# minute to first byte with tool-heavy agent prompts — hardcoded 30s/60s
|
|
38
|
+
# timeouts made those models unusable in headless runs.
|
|
39
|
+
DEFAULT_REQUEST_TIMEOUT = 120.0
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _resolve_request_timeout(cls, configured: Any) -> float:
|
|
43
|
+
"""
|
|
44
|
+
Resolve the chat-completion timeout: explicit config wins, then the
|
|
45
|
+
OMNIMANCER_REQUEST_TIMEOUT environment variable, then the default.
|
|
46
|
+
Invalid or non-positive values fall back to the default.
|
|
47
|
+
"""
|
|
48
|
+
for raw in (configured, os.environ.get("OMNIMANCER_REQUEST_TIMEOUT")):
|
|
49
|
+
if raw is None or raw == "":
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
value = float(raw)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
logger.warning("Ignoring invalid request timeout %r", raw)
|
|
55
|
+
continue
|
|
56
|
+
if value > 0:
|
|
57
|
+
return value
|
|
58
|
+
logger.warning("Ignoring non-positive request timeout %r", raw)
|
|
59
|
+
return cls.DEFAULT_REQUEST_TIMEOUT
|
|
60
|
+
|
|
31
61
|
def __init__(self, api_key: str, model: str, **kwargs: Any) -> None:
|
|
32
62
|
"""
|
|
33
63
|
Initialize the provider.
|
|
@@ -324,19 +324,23 @@ class MistralProvider(BaseProvider):
|
|
|
324
324
|
|
|
325
325
|
if choices and len(choices) > 0:
|
|
326
326
|
message = choices[0].get("message", {})
|
|
327
|
-
|
|
327
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
328
|
+
content = message.get("content") or ""
|
|
328
329
|
usage = data.get("usage", {})
|
|
329
330
|
|
|
330
|
-
# Extract tool calls if present
|
|
331
|
+
# Extract tool calls if present. The wire protocol sends
|
|
332
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
333
|
+
# it to a dict on construction.
|
|
331
334
|
tool_calls = []
|
|
332
335
|
if "tool_calls" in message:
|
|
333
|
-
for tool_call in message["tool_calls"]:
|
|
336
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
334
337
|
if tool_call.get("type") == "function":
|
|
335
338
|
function = tool_call.get("function", {})
|
|
336
339
|
tool_calls.append(
|
|
337
340
|
ToolCall(
|
|
338
341
|
name=function.get("name", ""),
|
|
339
342
|
arguments=function.get("arguments", {}),
|
|
343
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
340
344
|
)
|
|
341
345
|
)
|
|
342
346
|
|
|
@@ -57,13 +57,18 @@ class OpenAIProvider(BaseProvider):
|
|
|
57
57
|
Args:
|
|
58
58
|
api_key: OpenAI API key
|
|
59
59
|
model: OpenAI model to use (e.g., 'gpt-4', 'gpt-4o', 'gpt-3.5-turbo')
|
|
60
|
-
**kwargs: Additional configuration
|
|
60
|
+
**kwargs: Additional configuration. Supports ``request_timeout``
|
|
61
|
+
(seconds) for chat completions; the ``OMNIMANCER_REQUEST_TIMEOUT``
|
|
62
|
+
environment variable is used when the kwarg is not given.
|
|
61
63
|
"""
|
|
62
64
|
super().__init__(api_key, model or "gpt-4", **kwargs)
|
|
63
65
|
# Allow overriding the API endpoint for OpenAI-compatible services
|
|
64
66
|
self.base_url = (kwargs.get("base_url") or self.BASE_URL).rstrip("/")
|
|
65
67
|
self.max_tokens = kwargs.get("max_tokens", 4096)
|
|
66
68
|
self.temperature = kwargs.get("temperature", 0.7)
|
|
69
|
+
self.request_timeout = self._resolve_request_timeout(
|
|
70
|
+
kwargs.get("request_timeout")
|
|
71
|
+
)
|
|
67
72
|
|
|
68
73
|
async def send_message(self, message: str, context: ChatContext) -> ChatResponse:
|
|
69
74
|
"""
|
|
@@ -88,7 +93,7 @@ class OpenAIProvider(BaseProvider):
|
|
|
88
93
|
"max_tokens": self.max_tokens,
|
|
89
94
|
"temperature": self.temperature,
|
|
90
95
|
},
|
|
91
|
-
timeout=
|
|
96
|
+
timeout=self.request_timeout,
|
|
92
97
|
)
|
|
93
98
|
|
|
94
99
|
# Handle response
|
|
@@ -249,6 +254,19 @@ class OpenAIProvider(BaseProvider):
|
|
|
249
254
|
provider=provider,
|
|
250
255
|
)
|
|
251
256
|
|
|
257
|
+
async def _post_once(self, body: Dict[str, Any], timeout: float) -> httpx.Response:
|
|
258
|
+
"""Single POST to the chat completions endpoint."""
|
|
259
|
+
async with httpx.AsyncClient() as client:
|
|
260
|
+
return await client.post(
|
|
261
|
+
f"{self.base_url}/chat/completions",
|
|
262
|
+
headers={
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
265
|
+
},
|
|
266
|
+
json=body,
|
|
267
|
+
timeout=timeout,
|
|
268
|
+
)
|
|
269
|
+
|
|
252
270
|
async def _post_chat(
|
|
253
271
|
self, request_body: Dict[str, Any], timeout: float
|
|
254
272
|
) -> httpx.Response:
|
|
@@ -264,16 +282,18 @@ class OpenAIProvider(BaseProvider):
|
|
|
264
282
|
self._require_api_key()
|
|
265
283
|
|
|
266
284
|
async def _do_post(body: Dict[str, Any]) -> httpx.Response:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
# Serverless backends (notably DigitalOcean inference) sporadically
|
|
286
|
+
# time out on an otherwise-fine request; one retry absorbs those
|
|
287
|
+
# transient stalls instead of failing the whole agent run.
|
|
288
|
+
try:
|
|
289
|
+
return await self._post_once(body, timeout)
|
|
290
|
+
except httpx.TimeoutException:
|
|
291
|
+
logger.warning(
|
|
292
|
+
"%s chat completion timed out after %.0fs — retrying once",
|
|
293
|
+
self.PROVIDER_LABEL,
|
|
294
|
+
timeout,
|
|
276
295
|
)
|
|
296
|
+
return await self._post_once(body, timeout)
|
|
277
297
|
|
|
278
298
|
body = request_body
|
|
279
299
|
response = await _do_post(body)
|
|
@@ -332,7 +352,8 @@ class OpenAIProvider(BaseProvider):
|
|
|
332
352
|
|
|
333
353
|
if choices and len(choices) > 0:
|
|
334
354
|
message = choices[0].get("message", {})
|
|
335
|
-
|
|
355
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
356
|
+
content = message.get("content") or ""
|
|
336
357
|
usage = data.get("usage", {})
|
|
337
358
|
|
|
338
359
|
return ChatResponse(
|
|
@@ -413,7 +434,7 @@ class OpenAIProvider(BaseProvider):
|
|
|
413
434
|
if tools:
|
|
414
435
|
request_body["tools"] = tools
|
|
415
436
|
|
|
416
|
-
response = await self._post_chat(request_body, timeout=
|
|
437
|
+
response = await self._post_chat(request_body, timeout=self.request_timeout)
|
|
417
438
|
|
|
418
439
|
return self._handle_response_with_tools(response)
|
|
419
440
|
|
|
@@ -462,18 +483,16 @@ class OpenAIProvider(BaseProvider):
|
|
|
462
483
|
tool_calls = None
|
|
463
484
|
raw_tool_calls = msg.get("tool_calls")
|
|
464
485
|
if raw_tool_calls:
|
|
465
|
-
import json
|
|
466
|
-
|
|
467
486
|
tool_calls = []
|
|
468
487
|
for i, tc in enumerate(raw_tool_calls):
|
|
469
488
|
func = tc.get("function", {})
|
|
470
|
-
args = func.get("arguments", "{}")
|
|
471
|
-
if isinstance(args, str):
|
|
472
|
-
args = json.loads(args)
|
|
473
489
|
tool_calls.append(
|
|
474
490
|
ToolCall(
|
|
491
|
+
# ToolCall normalizes JSON-string arguments
|
|
492
|
+
# (including double-encoded ones) on
|
|
493
|
+
# construction.
|
|
475
494
|
name=func.get("name", ""),
|
|
476
|
-
arguments=
|
|
495
|
+
arguments=func.get("arguments", {}),
|
|
477
496
|
# Some OpenAI-compatible servers omit ids;
|
|
478
497
|
# synthesize one so results can pair to calls.
|
|
479
498
|
id=tc.get("id") or f"call_{i}",
|
|
@@ -67,6 +67,9 @@ class OpenRouterProvider(BaseProvider):
|
|
|
67
67
|
self.top_p = kwargs.get("top_p", 1.0)
|
|
68
68
|
self.frequency_penalty = kwargs.get("frequency_penalty", 0.0)
|
|
69
69
|
self.presence_penalty = kwargs.get("presence_penalty", 0.0)
|
|
70
|
+
self.request_timeout = self._resolve_request_timeout(
|
|
71
|
+
kwargs.get("request_timeout")
|
|
72
|
+
)
|
|
70
73
|
|
|
71
74
|
# Cost optimization settings
|
|
72
75
|
self.enable_fallback = kwargs.get("enable_fallback", True)
|
|
@@ -115,7 +118,7 @@ class OpenRouterProvider(BaseProvider):
|
|
|
115
118
|
f"{self.base_url}/chat/completions",
|
|
116
119
|
headers=self._get_headers(),
|
|
117
120
|
json=payload,
|
|
118
|
-
timeout=
|
|
121
|
+
timeout=self.request_timeout,
|
|
119
122
|
)
|
|
120
123
|
|
|
121
124
|
# If we get here, the request succeeded
|
|
@@ -201,7 +204,7 @@ class OpenRouterProvider(BaseProvider):
|
|
|
201
204
|
f"{self.base_url}/chat/completions",
|
|
202
205
|
headers=self._get_headers(),
|
|
203
206
|
json=payload,
|
|
204
|
-
timeout=
|
|
207
|
+
timeout=self.request_timeout,
|
|
205
208
|
)
|
|
206
209
|
|
|
207
210
|
# If we get here, the request succeeded
|
|
@@ -377,7 +380,9 @@ class OpenRouterProvider(BaseProvider):
|
|
|
377
380
|
|
|
378
381
|
if choices and len(choices) > 0:
|
|
379
382
|
message = choices[0].get("message", {})
|
|
380
|
-
|
|
383
|
+
# Tool-call responses carry "content": null — coalesce to ""
|
|
384
|
+
# so the fallback-warning concatenation below can't TypeError.
|
|
385
|
+
content = message.get("content") or ""
|
|
381
386
|
usage = data.get("usage", {})
|
|
382
387
|
|
|
383
388
|
# Extract model used (OpenRouter may route to different models)
|
|
@@ -446,19 +451,24 @@ class OpenRouterProvider(BaseProvider):
|
|
|
446
451
|
|
|
447
452
|
if choices and len(choices) > 0:
|
|
448
453
|
message = choices[0].get("message", {})
|
|
449
|
-
|
|
454
|
+
# Tool-call responses carry "content": null — coalesce to ""
|
|
455
|
+
# so the fallback-warning concatenation below can't TypeError.
|
|
456
|
+
content = message.get("content") or ""
|
|
450
457
|
usage = data.get("usage", {})
|
|
451
458
|
|
|
452
|
-
# Extract tool calls if present
|
|
459
|
+
# Extract tool calls if present. The wire protocol sends
|
|
460
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
461
|
+
# it to a dict on construction.
|
|
453
462
|
tool_calls = []
|
|
454
463
|
if "tool_calls" in message:
|
|
455
|
-
for tool_call in message["tool_calls"]:
|
|
464
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
456
465
|
if tool_call.get("type") == "function":
|
|
457
466
|
function = tool_call.get("function", {})
|
|
458
467
|
tool_calls.append(
|
|
459
468
|
ToolCall(
|
|
460
469
|
name=function.get("name", ""),
|
|
461
470
|
arguments=function.get("arguments", {}),
|
|
471
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
462
472
|
)
|
|
463
473
|
)
|
|
464
474
|
|
|
@@ -327,19 +327,23 @@ class XAIProvider(BaseProvider):
|
|
|
327
327
|
|
|
328
328
|
if choices and len(choices) > 0:
|
|
329
329
|
message = choices[0].get("message", {})
|
|
330
|
-
|
|
330
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
331
|
+
content = message.get("content") or ""
|
|
331
332
|
usage = data.get("usage", {})
|
|
332
333
|
|
|
333
|
-
# Extract tool calls if present
|
|
334
|
+
# Extract tool calls if present. The wire protocol sends
|
|
335
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
336
|
+
# it to a dict on construction.
|
|
334
337
|
tool_calls = []
|
|
335
338
|
if "tool_calls" in message:
|
|
336
|
-
for tool_call in message["tool_calls"]:
|
|
339
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
337
340
|
if tool_call.get("type") == "function":
|
|
338
341
|
function = tool_call.get("function", {})
|
|
339
342
|
tool_calls.append(
|
|
340
343
|
ToolCall(
|
|
341
344
|
name=function.get("name", ""),
|
|
342
345
|
arguments=function.get("arguments", {}),
|
|
346
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
343
347
|
)
|
|
344
348
|
)
|
|
345
349
|
|
|
@@ -132,6 +132,7 @@ tests/test_models.py
|
|
|
132
132
|
tests/test_ollama_provider.py
|
|
133
133
|
tests/test_openai_context_fit.py
|
|
134
134
|
tests/test_openai_provider_tools.py
|
|
135
|
+
tests/test_openai_request_timeout.py
|
|
135
136
|
tests/test_openrouter_provider.py
|
|
136
137
|
tests/test_permission_rules.py
|
|
137
138
|
tests/test_perplexity_provider.py
|
|
@@ -591,68 +591,117 @@ class TestStreamEvent:
|
|
|
591
591
|
|
|
592
592
|
event = StreamEvent(type=StreamEventType.ERROR, error="Connection lost")
|
|
593
593
|
assert event.error == "Connection lost"
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
class TestParseDescribedToolCalls:
|
|
597
|
-
"""parse_described_tool_calls recovers calls a model emitted as text.
|
|
598
|
-
|
|
599
|
-
Weak models mimic the "[Called tools: ...]" history notation instead of
|
|
600
|
-
issuing native tool calls; the parser turns that text back into ToolCall
|
|
601
|
-
objects so the agent loop can keep going.
|
|
602
|
-
"""
|
|
603
|
-
|
|
604
|
-
def test_round_trips_describe_output(self):
|
|
605
|
-
from omnimancer.core.models import (
|
|
606
|
-
ToolCall,
|
|
607
|
-
describe_tool_calls,
|
|
608
|
-
parse_described_tool_calls,
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
calls = [
|
|
612
|
-
ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
|
|
613
|
-
ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
|
|
614
|
-
]
|
|
615
|
-
parsed = parse_described_tool_calls(describe_tool_calls(calls))
|
|
616
|
-
|
|
617
|
-
assert [(c.name, c.arguments) for c in parsed] == [
|
|
618
|
-
(c.name, c.arguments) for c in calls
|
|
619
|
-
]
|
|
620
|
-
|
|
621
|
-
def test_plain_text_returns_empty(self):
|
|
622
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
623
|
-
|
|
624
|
-
assert parse_described_tool_calls("The auth flow looks correct.") == []
|
|
625
|
-
assert parse_described_tool_calls("") == []
|
|
626
|
-
assert parse_described_tool_calls(None) == []
|
|
627
|
-
|
|
628
|
-
def test_nested_json_with_braces_and_commas(self):
|
|
629
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
630
|
-
|
|
631
|
-
text = (
|
|
632
|
-
'[Called tools: Grep({"glob": "*.{ts,tsx}", '
|
|
633
|
-
'"output_mode": "files_with_matches", '
|
|
634
|
-
'"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
|
|
635
|
-
)
|
|
636
|
-
parsed = parse_described_tool_calls(text)
|
|
637
|
-
|
|
638
|
-
assert len(parsed) == 1
|
|
639
|
-
assert parsed[0].name == "Grep"
|
|
640
|
-
assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
|
|
641
|
-
|
|
642
|
-
def test_marker_embedded_in_other_text(self):
|
|
643
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
644
|
-
|
|
645
|
-
text = (
|
|
646
|
-
"Let me search for that.\n"
|
|
647
|
-
'[Called tools: Bash({"command": "git log --oneline -5"})]'
|
|
648
|
-
)
|
|
649
|
-
parsed = parse_described_tool_calls(text)
|
|
650
|
-
|
|
651
|
-
assert len(parsed) == 1
|
|
652
|
-
assert parsed[0].name == "Bash"
|
|
653
|
-
assert parsed[0].arguments == {"command": "git log --oneline -5"}
|
|
654
|
-
|
|
655
|
-
def test_malformed_json_is_skipped(self):
|
|
656
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
657
|
-
|
|
658
|
-
assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class TestParseDescribedToolCalls:
|
|
597
|
+
"""parse_described_tool_calls recovers calls a model emitted as text.
|
|
598
|
+
|
|
599
|
+
Weak models mimic the "[Called tools: ...]" history notation instead of
|
|
600
|
+
issuing native tool calls; the parser turns that text back into ToolCall
|
|
601
|
+
objects so the agent loop can keep going.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
def test_round_trips_describe_output(self):
|
|
605
|
+
from omnimancer.core.models import (
|
|
606
|
+
ToolCall,
|
|
607
|
+
describe_tool_calls,
|
|
608
|
+
parse_described_tool_calls,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
calls = [
|
|
612
|
+
ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
|
|
613
|
+
ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
|
|
614
|
+
]
|
|
615
|
+
parsed = parse_described_tool_calls(describe_tool_calls(calls))
|
|
616
|
+
|
|
617
|
+
assert [(c.name, c.arguments) for c in parsed] == [
|
|
618
|
+
(c.name, c.arguments) for c in calls
|
|
619
|
+
]
|
|
620
|
+
|
|
621
|
+
def test_plain_text_returns_empty(self):
|
|
622
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
623
|
+
|
|
624
|
+
assert parse_described_tool_calls("The auth flow looks correct.") == []
|
|
625
|
+
assert parse_described_tool_calls("") == []
|
|
626
|
+
assert parse_described_tool_calls(None) == []
|
|
627
|
+
|
|
628
|
+
def test_nested_json_with_braces_and_commas(self):
|
|
629
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
630
|
+
|
|
631
|
+
text = (
|
|
632
|
+
'[Called tools: Grep({"glob": "*.{ts,tsx}", '
|
|
633
|
+
'"output_mode": "files_with_matches", '
|
|
634
|
+
'"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
|
|
635
|
+
)
|
|
636
|
+
parsed = parse_described_tool_calls(text)
|
|
637
|
+
|
|
638
|
+
assert len(parsed) == 1
|
|
639
|
+
assert parsed[0].name == "Grep"
|
|
640
|
+
assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
|
|
641
|
+
|
|
642
|
+
def test_marker_embedded_in_other_text(self):
|
|
643
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
644
|
+
|
|
645
|
+
text = (
|
|
646
|
+
"Let me search for that.\n"
|
|
647
|
+
'[Called tools: Bash({"command": "git log --oneline -5"})]'
|
|
648
|
+
)
|
|
649
|
+
parsed = parse_described_tool_calls(text)
|
|
650
|
+
|
|
651
|
+
assert len(parsed) == 1
|
|
652
|
+
assert parsed[0].name == "Bash"
|
|
653
|
+
assert parsed[0].arguments == {"command": "git log --oneline -5"}
|
|
654
|
+
|
|
655
|
+
def test_malformed_json_is_skipped(self):
|
|
656
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
657
|
+
|
|
658
|
+
assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class TestToolCallArgumentNormalization:
|
|
662
|
+
"""ToolCall normalizes arguments that arrive as JSON-encoded strings.
|
|
663
|
+
|
|
664
|
+
OpenAI-protocol servers send function.arguments as a JSON string (and
|
|
665
|
+
some models double-encode it). Providers that passed the raw value
|
|
666
|
+
through crashed the tool handler with "'str' object has no attribute
|
|
667
|
+
'get'".
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
def test_json_string_arguments_become_dict(self):
|
|
671
|
+
from omnimancer.core.models import ToolCall
|
|
672
|
+
|
|
673
|
+
tc = ToolCall(name="Glob", arguments='{"pattern": "**/*.py"}')
|
|
674
|
+
assert tc.arguments == {"pattern": "**/*.py"}
|
|
675
|
+
|
|
676
|
+
def test_double_encoded_arguments_become_dict(self):
|
|
677
|
+
import json
|
|
678
|
+
|
|
679
|
+
from omnimancer.core.models import ToolCall
|
|
680
|
+
|
|
681
|
+
double = json.dumps(json.dumps({"pattern": "TODO"}))
|
|
682
|
+
tc = ToolCall(name="Grep", arguments=double)
|
|
683
|
+
assert tc.arguments == {"pattern": "TODO"}
|
|
684
|
+
|
|
685
|
+
def test_none_arguments_become_empty_dict(self):
|
|
686
|
+
from omnimancer.core.models import ToolCall
|
|
687
|
+
|
|
688
|
+
tc = ToolCall(name="Read", arguments=None)
|
|
689
|
+
assert tc.arguments == {}
|
|
690
|
+
|
|
691
|
+
def test_dict_arguments_unchanged(self):
|
|
692
|
+
from omnimancer.core.models import ToolCall
|
|
693
|
+
|
|
694
|
+
tc = ToolCall(name="Read", arguments={"file_path": "/src/main.py"})
|
|
695
|
+
assert tc.arguments == {"file_path": "/src/main.py"}
|
|
696
|
+
|
|
697
|
+
def test_unparseable_string_left_for_handler_to_report(self):
|
|
698
|
+
from omnimancer.core.models import ToolCall
|
|
699
|
+
|
|
700
|
+
tc = ToolCall(name="Glob", arguments="not json at all")
|
|
701
|
+
assert tc.arguments == "not json at all"
|
|
702
|
+
|
|
703
|
+
def test_non_object_json_left_for_handler_to_report(self):
|
|
704
|
+
from omnimancer.core.models import ToolCall
|
|
705
|
+
|
|
706
|
+
tc = ToolCall(name="Glob", arguments="[1, 2, 3]")
|
|
707
|
+
assert tc.arguments == "[1, 2, 3]"
|