omnimancer-cli 0.2.15__tar.gz → 0.2.19__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.
Files changed (156) hide show
  1. {omnimancer_cli-0.2.15/omnimancer_cli.egg-info → omnimancer_cli-0.2.19}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/tool_handler.py +12 -0
  3. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/file_system_manager.py +41 -4
  4. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/models.py +23 -0
  5. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/azure.py +7 -3
  6. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/mistral.py +7 -3
  7. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/openai.py +6 -7
  8. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/openrouter.py +11 -4
  9. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/xai.py +7 -3
  10. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19/omnimancer_cli.egg-info}/PKG-INFO +1 -1
  11. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/pyproject.toml +1 -1
  12. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_models.py +114 -65
  13. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_openrouter_provider.py +97 -0
  14. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_tool_handler.py +15 -0
  15. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_xai_provider.py +5 -3
  16. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/LICENSE +0 -0
  17. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/README.md +0 -0
  18. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/__init__.py +0 -0
  19. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/__main__.py +0 -0
  20. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/__init__.py +0 -0
  21. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/agent_loop.py +0 -0
  22. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_formatter.py +0 -0
  23. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_integration.py +0 -0
  24. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_prompt.py +0 -0
  25. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/command_dispatch.py +0 -0
  26. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/commands.py +0 -0
  27. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/completion.py +0 -0
  28. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/display.py +0 -0
  29. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/dynamic_commands.py +0 -0
  30. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/handlers/__init__.py +0 -0
  31. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/headless.py +0 -0
  32. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/interface.py +0 -0
  33. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/subagent.py +0 -0
  34. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/cli/system_prompts.py +0 -0
  35. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/__init__.py +0 -0
  36. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/__init__.py +0 -0
  37. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_context.py +0 -0
  38. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_interface.py +0 -0
  39. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_manager.py +0 -0
  40. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/config.py +0 -0
  41. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/program_executor.py +0 -0
  42. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  43. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/status_core.py +0 -0
  44. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/status_manager.py +0 -0
  45. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/tool_definitions.py +0 -0
  46. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/types.py +0 -0
  47. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/web_client.py +0 -0
  48. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  49. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent_engine.py +0 -0
  50. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent_managers.py +0 -0
  51. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/agent_mode_manager.py +0 -0
  52. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/chat_manager.py +0 -0
  53. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/config_manager.py +0 -0
  54. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/config_migration.py +0 -0
  55. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/config_validator.py +0 -0
  56. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/conversation_manager.py +0 -0
  57. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/engine.py +0 -0
  58. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/env_loader.py +0 -0
  59. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/fallback_manager.py +0 -0
  60. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/health_monitor.py +0 -0
  61. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/history_manager.py +0 -0
  62. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/hooks.py +0 -0
  63. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/mcp_integration_layer.py +0 -0
  64. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/provider_capabilities.py +0 -0
  65. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/provider_initializer.py +0 -0
  66. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/provider_registry.py +0 -0
  67. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/rate_limit_fallback.py +0 -0
  68. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/__init__.py +0 -0
  69. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/approval_workflow.py +0 -0
  70. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/audit_logger.py +0 -0
  71. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/permission_controller.py +0 -0
  72. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/permission_rules.py +0 -0
  73. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/sandbox_manager.py +0 -0
  74. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/security/security_manager.py +0 -0
  75. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/core/signal_handler.py +0 -0
  76. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/mcp/__init__.py +0 -0
  77. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/mcp/client.py +0 -0
  78. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/mcp/manager.py +0 -0
  79. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/__init__.py +0 -0
  80. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/base.py +0 -0
  81. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/bedrock.py +0 -0
  82. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/claude.py +0 -0
  83. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/claude_code.py +0 -0
  84. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/claude_credentials.py +0 -0
  85. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/cohere.py +0 -0
  86. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/digitalocean.py +0 -0
  87. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/factory.py +0 -0
  88. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/gemini.py +0 -0
  89. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/ollama.py +0 -0
  90. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/perplexity.py +0 -0
  91. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/providers/vertex.py +0 -0
  92. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/ui/__init__.py +0 -0
  93. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/ui/cancellation_handler.py +0 -0
  94. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/ui/progress_indicator.py +0 -0
  95. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/ui/streaming_display.py +0 -0
  96. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/utils/__init__.py +0 -0
  97. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/utils/error_handler.py +0 -0
  98. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/utils/errors.py +0 -0
  99. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer/utils/retry.py +0 -0
  100. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/SOURCES.txt +0 -0
  101. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  102. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  103. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/requires.txt +0 -0
  104. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/top_level.txt +0 -0
  105. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/setup.cfg +0 -0
  106. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_approval_integration_compatibility.py +0 -0
  107. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_approval_preview_format.py +0 -0
  108. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_approval_remember.py +0 -0
  109. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_approval_system.py +0 -0
  110. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_azure_provider.py +0 -0
  111. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_bedrock_provider.py +0 -0
  112. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_claude_code_provider.py +0 -0
  113. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_claude_credentials.py +0 -0
  114. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_claude_provider.py +0 -0
  115. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_cohere_provider.py +0 -0
  116. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_command_approval_security.py +0 -0
  117. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_config_comprehensive.py +0 -0
  118. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_conversation_history.py +0 -0
  119. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_conversation_manager.py +0 -0
  120. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_core_engine.py +0 -0
  121. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_digitalocean_provider.py +0 -0
  122. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_enhanced_model_info.py +0 -0
  123. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_env_loader.py +0 -0
  124. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_error_handling_robustness.py +0 -0
  125. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_execution_timeout_errors.py +0 -0
  126. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_fallback_manager.py +0 -0
  127. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_fixtures.py +0 -0
  128. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_gemini_provider.py +0 -0
  129. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_hooks.py +0 -0
  130. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_httpx_compatibility.py +0 -0
  131. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_import_validation.py +0 -0
  132. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_interface_smoke_tests.py +0 -0
  133. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_mcp_integration.py +0 -0
  134. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_mcp_integration_layer.py +0 -0
  135. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_mcp_management_commands.py +0 -0
  136. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_mistral_provider.py +0 -0
  137. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_ollama_provider.py +0 -0
  138. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_openai_context_fit.py +0 -0
  139. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_openai_provider_tools.py +0 -0
  140. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_permission_rules.py +0 -0
  141. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_perplexity_provider.py +0 -0
  142. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_program_executor.py +0 -0
  143. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_provider_base.py +0 -0
  144. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_provider_capability_consistency.py +0 -0
  145. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_provider_discovery.py +0 -0
  146. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_provider_integration.py +0 -0
  147. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_provider_registry.py +0 -0
  148. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_rate_limit_fallback.py +0 -0
  149. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_retry.py +0 -0
  150. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_security_validation.py +0 -0
  151. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_system_integration.py +0 -0
  152. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_system_prompts.py +0 -0
  153. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_tool_calling_integration.py +0 -0
  154. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_vertex_provider.py +0 -0
  155. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_web_request_markers.py +0 -0
  156. {omnimancer_cli-0.2.15 → omnimancer_cli-0.2.19}/tests/test_workflows_comprehensive.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.15
3
+ Version: 0.2.19
4
4
  Summary: A unified command-line interface for multiple AI language models
5
5
  Author-email: Kellan Strong <kellan@jite.ai>
6
6
  License: MIT
@@ -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:
@@ -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 file exists and is accessible, False otherwise
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
- result = await self.check_file_exists(path)
260
- return result["exists"] and result["error"] is None
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
- content = message.get("content", "")
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
 
@@ -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
- content = message.get("content", "")
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
 
@@ -332,7 +332,8 @@ class OpenAIProvider(BaseProvider):
332
332
 
333
333
  if choices and len(choices) > 0:
334
334
  message = choices[0].get("message", {})
335
- content = message.get("content", "")
335
+ # Tool-call responses carry "content": null — coalesce to "".
336
+ content = message.get("content") or ""
336
337
  usage = data.get("usage", {})
337
338
 
338
339
  return ChatResponse(
@@ -462,18 +463,16 @@ class OpenAIProvider(BaseProvider):
462
463
  tool_calls = None
463
464
  raw_tool_calls = msg.get("tool_calls")
464
465
  if raw_tool_calls:
465
- import json
466
-
467
466
  tool_calls = []
468
467
  for i, tc in enumerate(raw_tool_calls):
469
468
  func = tc.get("function", {})
470
- args = func.get("arguments", "{}")
471
- if isinstance(args, str):
472
- args = json.loads(args)
473
469
  tool_calls.append(
474
470
  ToolCall(
471
+ # ToolCall normalizes JSON-string arguments
472
+ # (including double-encoded ones) on
473
+ # construction.
475
474
  name=func.get("name", ""),
476
- arguments=args,
475
+ arguments=func.get("arguments", {}),
477
476
  # Some OpenAI-compatible servers omit ids;
478
477
  # synthesize one so results can pair to calls.
479
478
  id=tc.get("id") or f"call_{i}",
@@ -377,7 +377,9 @@ class OpenRouterProvider(BaseProvider):
377
377
 
378
378
  if choices and len(choices) > 0:
379
379
  message = choices[0].get("message", {})
380
- content = message.get("content", "")
380
+ # Tool-call responses carry "content": null — coalesce to ""
381
+ # so the fallback-warning concatenation below can't TypeError.
382
+ content = message.get("content") or ""
381
383
  usage = data.get("usage", {})
382
384
 
383
385
  # Extract model used (OpenRouter may route to different models)
@@ -446,19 +448,24 @@ class OpenRouterProvider(BaseProvider):
446
448
 
447
449
  if choices and len(choices) > 0:
448
450
  message = choices[0].get("message", {})
449
- content = message.get("content", "")
451
+ # Tool-call responses carry "content": null — coalesce to ""
452
+ # so the fallback-warning concatenation below can't TypeError.
453
+ content = message.get("content") or ""
450
454
  usage = data.get("usage", {})
451
455
 
452
- # Extract tool calls if present
456
+ # Extract tool calls if present. The wire protocol sends
457
+ # function.arguments as a JSON string; ToolCall normalizes
458
+ # it to a dict on construction.
453
459
  tool_calls = []
454
460
  if "tool_calls" in message:
455
- for tool_call in message["tool_calls"]:
461
+ for i, tool_call in enumerate(message["tool_calls"]):
456
462
  if tool_call.get("type") == "function":
457
463
  function = tool_call.get("function", {})
458
464
  tool_calls.append(
459
465
  ToolCall(
460
466
  name=function.get("name", ""),
461
467
  arguments=function.get("arguments", {}),
468
+ id=tool_call.get("id") or f"call_{i}",
462
469
  )
463
470
  )
464
471
 
@@ -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
- content = message.get("content", "")
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.15
3
+ Version: 0.2.19
4
4
  Summary: A unified command-line interface for multiple AI language models
5
5
  Author-email: Kellan Strong <kellan@jite.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "omnimancer-cli"
7
- version = "0.2.15"
7
+ version = "0.2.19"
8
8
  description = "A unified command-line interface for multiple AI language models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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]"
@@ -613,6 +613,103 @@ class TestOpenRouterProviderToolCalling:
613
613
  assert tools[0]["function"]["name"] == "fetch_url"
614
614
 
615
615
 
616
+ class TestOpenRouterProviderToolResponseParsing:
617
+ """Parsing of tool-call responses (OpenAI wire protocol)."""
618
+
619
+ def test_string_arguments_parsed_to_dict(
620
+ self, openrouter_provider, mock_tool_response
621
+ ):
622
+ """function.arguments arrives as a JSON string — it must reach the
623
+ tool handler as a dict, not crash it with 'str' has no 'get'."""
624
+ mock_response = MagicMock()
625
+ mock_response.status_code = 200
626
+ mock_response.json.return_value = mock_tool_response
627
+
628
+ response = openrouter_provider._handle_response_with_tools(mock_response)
629
+
630
+ assert response.tool_calls[0].arguments == {"url": "https://example.com"}
631
+
632
+ def test_tool_call_id_preserved_or_synthesized(self, openrouter_provider):
633
+ mock_response = MagicMock()
634
+ mock_response.status_code = 200
635
+ mock_response.json.return_value = {
636
+ "choices": [
637
+ {
638
+ "message": {
639
+ "role": "assistant",
640
+ "content": None,
641
+ "tool_calls": [
642
+ {
643
+ "id": "call_abc",
644
+ "type": "function",
645
+ "function": {"name": "Glob", "arguments": "{}"},
646
+ },
647
+ {
648
+ "type": "function",
649
+ "function": {"name": "Grep", "arguments": "{}"},
650
+ },
651
+ ],
652
+ }
653
+ }
654
+ ],
655
+ "model": "anthropic/claude-3.5-sonnet",
656
+ "usage": {"total_tokens": 10},
657
+ }
658
+
659
+ response = openrouter_provider._handle_response_with_tools(mock_response)
660
+
661
+ assert response.tool_calls[0].id == "call_abc"
662
+ assert response.tool_calls[1].id == "call_1"
663
+
664
+ def test_null_content_with_model_fallback_warning(self, openrouter_provider):
665
+ """Tool-call responses carry content:null; combined with OpenRouter
666
+ routing to a different model this used to raise TypeError
667
+ ('can only concatenate str (not "NoneType") to str')."""
668
+ mock_response = MagicMock()
669
+ mock_response.status_code = 200
670
+ mock_response.json.return_value = {
671
+ "choices": [
672
+ {
673
+ "message": {
674
+ "role": "assistant",
675
+ "content": None,
676
+ "tool_calls": [
677
+ {
678
+ "id": "call_1",
679
+ "type": "function",
680
+ "function": {
681
+ "name": "Glob",
682
+ "arguments": '{"pattern": "*.py"}',
683
+ },
684
+ }
685
+ ],
686
+ }
687
+ }
688
+ ],
689
+ "model": "some/other-model",
690
+ "usage": {"total_tokens": 12},
691
+ }
692
+
693
+ response = openrouter_provider._handle_response_with_tools(mock_response)
694
+
695
+ assert "Model Fallback Notice" in response.content
696
+ assert response.tool_calls[0].arguments == {"pattern": "*.py"}
697
+
698
+ def test_null_content_without_tools_fallback_warning(self, openrouter_provider):
699
+ mock_response = MagicMock()
700
+ mock_response.status_code = 200
701
+ mock_response.json.return_value = {
702
+ "choices": [{"message": {"role": "assistant", "content": None}}],
703
+ "model": "some/other-model",
704
+ "usage": {"total_tokens": 3},
705
+ }
706
+
707
+ response = openrouter_provider._handle_response(mock_response)
708
+
709
+ assert isinstance(response.content, str)
710
+ assert "Model Fallback Notice" in response.content
711
+
712
+
616
713
  # ---------------------------------------------------------------------------
617
714
  # TestOpenRouterProviderCredentialValidation
618
715
  # ---------------------------------------------------------------------------
@@ -353,6 +353,21 @@ class TestToolHandlerExecution:
353
353
 
354
354
  assert result.error == "Unknown tool: unknown"
355
355
 
356
+ @pytest.mark.asyncio
357
+ async def test_non_dict_arguments_return_corrective_error(
358
+ self, tool_handler, mock_agent_engine
359
+ ):
360
+ """A provider that ships unparseable arguments must yield a clear
361
+ error the model can act on — not "'str' object has no attribute
362
+ 'get'" from inside the handler."""
363
+ tc = ToolCall(name="Glob", arguments="**/*.py")
364
+ result = await tool_handler.execute_tool_call(tc)
365
+
366
+ assert result.error is not None
367
+ assert "JSON object" in result.error
368
+ assert "attribute" not in result.error
369
+ mock_agent_engine.execute_with_approval.assert_not_called()
370
+
356
371
  @pytest.mark.asyncio
357
372
  async def test_execute_multiple_tool_calls(self, tool_handler, mock_agent_engine):
358
373
  tool_calls = [
@@ -763,9 +763,11 @@ class TestXAIProviderResponseHandling:
763
763
  assert response.tool_calls is not None
764
764
  assert len(response.tool_calls) == 1
765
765
  assert response.tool_calls[0].name == "web_search"
766
- assert '"query": "latest AI developments 2024"' in str(
767
- response.tool_calls[0].arguments
768
- )
766
+ # The wire protocol sends arguments as a JSON string; the provider
767
+ # must deliver them as a dict.
768
+ assert response.tool_calls[0].arguments == {
769
+ "query": "latest AI developments 2024"
770
+ }
769
771
 
770
772
  def test_handle_response_with_error_json(self, xai_provider):
771
773
  """Test handling response with JSON error message."""
File without changes