omnimancer-cli 0.2.7__tar.gz → 0.2.10__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.7/omnimancer_cli.egg-info → omnimancer_cli-0.2.10}/PKG-INFO +5 -2
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/README.md +4 -1
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/approval_integration.py +44 -19
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/command_dispatch.py +297 -29
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/commands.py +5 -1
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/headless.py +63 -22
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/interface.py +159 -32
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/tool_handler.py +66 -3
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/chat_manager.py +18 -4
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/engine.py +257 -8
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/models.py +114 -1
- omnimancer_cli-0.2.10/omnimancer/core/rate_limit_fallback.py +245 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/base.py +10 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/openai.py +53 -8
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10/omnimancer_cli.egg-info}/PKG-INFO +5 -2
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer_cli.egg-info/SOURCES.txt +2 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/pyproject.toml +1 -1
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_approval_preview_format.py +72 -1
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_core_engine.py +186 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_models.py +65 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_openai_provider_tools.py +141 -0
- omnimancer_cli-0.2.10/tests/test_rate_limit_fallback.py +578 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_tool_calling_integration.py +244 -11
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_tool_handler.py +82 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/LICENSE +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/__main__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/agent_loop.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/approval_formatter.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/approval_prompt.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/completion.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/display.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/dynamic_commands.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/handlers/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/subagent.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/cli/system_prompts.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/approval_context.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/approval_interface.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/approval_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/config.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/file_system_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/program_executor.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/read_before_write_errors.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/status_core.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/status_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/tool_definitions.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/types.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/web_client.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent_engine.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent_managers.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/agent_mode_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/config_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/config_migration.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/config_validator.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/conversation_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/env_loader.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/fallback_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/health_monitor.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/history_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/hooks.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/provider_capabilities.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/provider_initializer.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/provider_registry.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/approval_workflow.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/audit_logger.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/permission_controller.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/permission_rules.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/sandbox_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/security/security_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/core/signal_handler.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/mcp/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/mcp/client.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/mcp/manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/azure.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/bedrock.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/claude.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/claude_code.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/claude_credentials.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/cohere.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/digitalocean.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/factory.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/gemini.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/mistral.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/ollama.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/openrouter.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/perplexity.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/vertex.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/providers/xai.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/ui/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/ui/cancellation_handler.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/ui/progress_indicator.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/ui/streaming_display.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/utils/__init__.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/utils/error_handler.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/utils/errors.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer/utils/retry.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer_cli.egg-info/entry_points.txt +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer_cli.egg-info/requires.txt +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/omnimancer_cli.egg-info/top_level.txt +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/setup.cfg +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_approval_integration_compatibility.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_approval_remember.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_approval_system.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_azure_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_claude_credentials.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_claude_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_cohere_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_command_approval_security.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_config_comprehensive.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_conversation_history.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_conversation_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_digitalocean_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_enhanced_model_info.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_env_loader.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_error_handling_robustness.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_execution_timeout_errors.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_fallback_manager.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_fixtures.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_gemini_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_hooks.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_httpx_compatibility.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_import_validation.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_interface_smoke_tests.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_mcp_integration.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_mistral_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_ollama_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_openai_context_fit.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_permission_rules.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_perplexity_provider.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_program_executor.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_provider_base.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_provider_capability_consistency.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_provider_integration.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_provider_registry.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_retry.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_security_validation.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_system_integration.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_system_prompts.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_workflows_comprehensive.py +0 -0
- {omnimancer_cli-0.2.7 → omnimancer_cli-0.2.10}/tests/test_xai_provider.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omnimancer-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
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
|
|
@@ -96,6 +96,9 @@ omn -p "fix the failing tests" --dangerously-skip-permissions
|
|
|
96
96
|
|
|
97
97
|
# Verbose output / explicit config file
|
|
98
98
|
omn -p "audit the security module" --verbose --config ~/.omnimancer/config.json
|
|
99
|
+
|
|
100
|
+
# Raise the tool-iteration cap for big jobs (default: 25)
|
|
101
|
+
omn -p "migrate every test file to pytest fixtures" --max-iterations 100
|
|
99
102
|
```
|
|
100
103
|
|
|
101
104
|
The `--provider`, `--model`, and `--base-url` flags are **session overrides**: they
|
|
@@ -240,7 +243,7 @@ Tool names match the agent's toolset (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `
|
|
|
240
243
|
| `/help [command]` | Show help (optionally for a specific command) |
|
|
241
244
|
| `/quit` | Exit (also: `/exit`, Ctrl+D) |
|
|
242
245
|
| `/clear` | Clear terminal screen |
|
|
243
|
-
| `/switch <provider> [model]` | Switch provider or model |
|
|
246
|
+
| `/switch <provider> [model]` | Switch provider or model (uncataloged models are registered on the fly) |
|
|
244
247
|
| `/models [filter]` | List available models (alias: `/model`) |
|
|
245
248
|
| `/providers` | List all providers with status |
|
|
246
249
|
| `/agent on\|off\|status` | Toggle agent mode |
|
|
@@ -45,6 +45,9 @@ omn -p "fix the failing tests" --dangerously-skip-permissions
|
|
|
45
45
|
|
|
46
46
|
# Verbose output / explicit config file
|
|
47
47
|
omn -p "audit the security module" --verbose --config ~/.omnimancer/config.json
|
|
48
|
+
|
|
49
|
+
# Raise the tool-iteration cap for big jobs (default: 25)
|
|
50
|
+
omn -p "migrate every test file to pytest fixtures" --max-iterations 100
|
|
48
51
|
```
|
|
49
52
|
|
|
50
53
|
The `--provider`, `--model`, and `--base-url` flags are **session overrides**: they
|
|
@@ -189,7 +192,7 @@ Tool names match the agent's toolset (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `
|
|
|
189
192
|
| `/help [command]` | Show help (optionally for a specific command) |
|
|
190
193
|
| `/quit` | Exit (also: `/exit`, Ctrl+D) |
|
|
191
194
|
| `/clear` | Clear terminal screen |
|
|
192
|
-
| `/switch <provider> [model]` | Switch provider or model |
|
|
195
|
+
| `/switch <provider> [model]` | Switch provider or model (uncataloged models are registered on the fly) |
|
|
193
196
|
| `/models [filter]` | List available models (alias: `/model`) |
|
|
194
197
|
| `/providers` | List all providers with status |
|
|
195
198
|
| `/agent on\|off\|status` | Toggle agent mode |
|
|
@@ -13,7 +13,11 @@ from typing import Any, Dict, List, Optional
|
|
|
13
13
|
|
|
14
14
|
from rich.console import Console
|
|
15
15
|
|
|
16
|
-
from ..core.agent.approval_manager import
|
|
16
|
+
from ..core.agent.approval_manager import (
|
|
17
|
+
BatchApprovalRequest,
|
|
18
|
+
ChangePreview,
|
|
19
|
+
EnhancedApprovalManager,
|
|
20
|
+
)
|
|
17
21
|
from ..core.agent.types import Operation, OperationType
|
|
18
22
|
from ..core.security.approval_workflow import ApprovalRequest, RiskLevel
|
|
19
23
|
from ..core.security.permission_controller import PermissionController
|
|
@@ -181,24 +185,7 @@ class CLIApprovalIntegration:
|
|
|
181
185
|
risk_level=RiskLevel.MEDIUM, # Default, can be enhanced
|
|
182
186
|
)
|
|
183
187
|
|
|
184
|
-
|
|
185
|
-
# Map OperationType to ChangeType (they have matching values)
|
|
186
|
-
from ..core.agent.approval_manager import ChangePreview, ChangeType
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
change_type = ChangeType(operation.type.value)
|
|
190
|
-
except ValueError:
|
|
191
|
-
# Default to FILE_MODIFY if type not in ChangeType enum
|
|
192
|
-
change_type = ChangeType.FILE_MODIFY
|
|
193
|
-
|
|
194
|
-
change_preview = ChangePreview(
|
|
195
|
-
change_type=change_type,
|
|
196
|
-
description=operation.description,
|
|
197
|
-
# Use string preview as proposed_state
|
|
198
|
-
proposed_state=operation.preview,
|
|
199
|
-
metadata=operation.data,
|
|
200
|
-
reversible=operation.reversible,
|
|
201
|
-
)
|
|
188
|
+
change_preview = await self._build_change_preview(operation)
|
|
202
189
|
|
|
203
190
|
# Present approval dialog and get user decision
|
|
204
191
|
decision = await self.prompt_handler.prompt_for_approval(
|
|
@@ -226,6 +213,44 @@ class CLIApprovalIntegration:
|
|
|
226
213
|
logger.error(f"Error in single approval handler: {e}", exc_info=True)
|
|
227
214
|
return (False, False)
|
|
228
215
|
|
|
216
|
+
async def _build_change_preview(self, operation: Operation) -> ChangePreview:
|
|
217
|
+
"""Build the ChangePreview shown in the approval dialog.
|
|
218
|
+
|
|
219
|
+
File writes and deletes go through the approval manager's rich
|
|
220
|
+
generators, which read the current file and produce a real unified
|
|
221
|
+
diff. Other operation types get a generic preview built from the
|
|
222
|
+
operation's own description/preview strings.
|
|
223
|
+
"""
|
|
224
|
+
from ..core.agent.approval_manager import ChangePreview, ChangeType
|
|
225
|
+
|
|
226
|
+
if operation.type in (OperationType.FILE_WRITE, OperationType.FILE_DELETE):
|
|
227
|
+
try:
|
|
228
|
+
preview = await self.approval_manager.generate_operation_preview(
|
|
229
|
+
operation
|
|
230
|
+
)
|
|
231
|
+
# The operation's description ("Edit file: ...") is more
|
|
232
|
+
# specific than the generator's generic one.
|
|
233
|
+
if operation.description:
|
|
234
|
+
preview.description = operation.description
|
|
235
|
+
return preview
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.debug(f"Rich preview failed, using generic preview: {e}")
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
change_type = ChangeType(operation.type.value)
|
|
241
|
+
except ValueError:
|
|
242
|
+
# Default to FILE_MODIFY if type not in ChangeType enum
|
|
243
|
+
change_type = ChangeType.FILE_MODIFY
|
|
244
|
+
|
|
245
|
+
return ChangePreview(
|
|
246
|
+
change_type=change_type,
|
|
247
|
+
description=operation.description,
|
|
248
|
+
# Use string preview as proposed_state
|
|
249
|
+
proposed_state=operation.preview,
|
|
250
|
+
metadata=operation.data,
|
|
251
|
+
reversible=operation.reversible,
|
|
252
|
+
)
|
|
253
|
+
|
|
229
254
|
async def _handle_batch_approval(
|
|
230
255
|
self, batch_request: BatchApprovalRequest
|
|
231
256
|
) -> Dict[str, Any]:
|
|
@@ -19,7 +19,7 @@ from rich.prompt import Confirm
|
|
|
19
19
|
from rich.table import Table
|
|
20
20
|
|
|
21
21
|
# Internal imports - Core
|
|
22
|
-
from ..core.models import EnhancedModelInfo
|
|
22
|
+
from ..core.models import EnhancedModelInfo, FallbackConfig
|
|
23
23
|
from ..providers.factory import ProviderFactory
|
|
24
24
|
|
|
25
25
|
# Internal imports - CLI
|
|
@@ -124,6 +124,8 @@ class CommandDispatchMixin:
|
|
|
124
124
|
await self._handle_prompts_command(command)
|
|
125
125
|
elif slash_cmd == SlashCommand.SUBAGENTS:
|
|
126
126
|
await self._handle_subagents_command(command)
|
|
127
|
+
elif slash_cmd == SlashCommand.FALLBACK:
|
|
128
|
+
await self._handle_fallback_command(command)
|
|
127
129
|
elif slash_cmd is not None:
|
|
128
130
|
self._show_info(f"Command {slash_cmd.value} is not yet implemented")
|
|
129
131
|
|
|
@@ -284,6 +286,16 @@ class CommandDispatchMixin:
|
|
|
284
286
|
except Exception:
|
|
285
287
|
continue
|
|
286
288
|
|
|
289
|
+
# Merge in custom models (added via /add-model or /switch) —
|
|
290
|
+
# they live in config, not in any provider's static catalog.
|
|
291
|
+
try:
|
|
292
|
+
for custom in self.engine.config_manager.get_custom_models():
|
|
293
|
+
bucket = all_models.setdefault(custom.provider, [])
|
|
294
|
+
if not any(m.name == custom.name for m in bucket):
|
|
295
|
+
bucket.append(custom)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
287
299
|
# Apply filtering
|
|
288
300
|
if filter_type == "provider" and filter_value:
|
|
289
301
|
all_models = {
|
|
@@ -411,6 +423,36 @@ class CommandDispatchMixin:
|
|
|
411
423
|
except Exception as e:
|
|
412
424
|
return f"Error displaying models: {e}"
|
|
413
425
|
|
|
426
|
+
def _register_model_on_the_fly(self, provider_name: str, model_name: str) -> None:
|
|
427
|
+
"""Register an uncataloged model as a custom model so /switch works.
|
|
428
|
+
|
|
429
|
+
The entry persists in config, shows up in /models, and can be removed
|
|
430
|
+
with /remove-model if the endpoint turns out to reject it.
|
|
431
|
+
"""
|
|
432
|
+
model_info = EnhancedModelInfo(
|
|
433
|
+
name=model_name,
|
|
434
|
+
provider=provider_name,
|
|
435
|
+
description="Added on the fly via /switch",
|
|
436
|
+
max_tokens=4096,
|
|
437
|
+
cost_per_million_input=0.0,
|
|
438
|
+
cost_per_million_output=0.0,
|
|
439
|
+
swe_score=50.0,
|
|
440
|
+
available=True,
|
|
441
|
+
supports_tools=True,
|
|
442
|
+
supports_multimodal=False,
|
|
443
|
+
latest_version=False,
|
|
444
|
+
deprecated=False,
|
|
445
|
+
release_date=datetime.now(),
|
|
446
|
+
context_window=4096,
|
|
447
|
+
is_free=False,
|
|
448
|
+
)
|
|
449
|
+
self.engine.config_manager.add_custom_model(model_info)
|
|
450
|
+
self._show_warning(
|
|
451
|
+
f"Model '{model_name}' isn't in the {provider_name} catalog — "
|
|
452
|
+
"registered it as a custom model and switching anyway. "
|
|
453
|
+
f"(/remove-model {model_name} {provider_name} to undo)"
|
|
454
|
+
)
|
|
455
|
+
|
|
414
456
|
async def _handle_switch_command(self, command: Command) -> None:
|
|
415
457
|
"""Handle switch command with enhanced provider type support."""
|
|
416
458
|
args = command.args
|
|
@@ -484,34 +526,10 @@ class CommandDispatchMixin:
|
|
|
484
526
|
all_model_names = model_names + custom_model_names
|
|
485
527
|
|
|
486
528
|
if model_name not in all_model_names:
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
)
|
|
492
|
-
if all_model_names:
|
|
493
|
-
# Show suggestions for similar model names
|
|
494
|
-
suggestions = [
|
|
495
|
-
m
|
|
496
|
-
for m in all_model_names
|
|
497
|
-
if model_name.lower() in m.lower()
|
|
498
|
-
or m.lower() in model_name.lower()
|
|
499
|
-
]
|
|
500
|
-
if suggestions:
|
|
501
|
-
models_str = ", ".join(suggestions[:5])
|
|
502
|
-
self._show_info(
|
|
503
|
-
"Available models for"
|
|
504
|
-
f" {provider_name}:"
|
|
505
|
-
f" {models_str}"
|
|
506
|
-
)
|
|
507
|
-
else:
|
|
508
|
-
models_str = ", ".join(all_model_names[:5])
|
|
509
|
-
self._show_info(
|
|
510
|
-
"Available models for"
|
|
511
|
-
f" {provider_name}:"
|
|
512
|
-
f" {models_str}"
|
|
513
|
-
)
|
|
514
|
-
return
|
|
529
|
+
# Static catalogs are forever stale and the endpoint
|
|
530
|
+
# accepts any model string — register it on the fly
|
|
531
|
+
# instead of refusing.
|
|
532
|
+
self._register_model_on_the_fly(provider_name, model_name)
|
|
515
533
|
|
|
516
534
|
success = await self.engine.switch_model(provider_name, model_name)
|
|
517
535
|
|
|
@@ -1842,3 +1860,253 @@ Newest Entry: {stats['newest_entry'] or 'None'}"""
|
|
|
1842
1860
|
|
|
1843
1861
|
except Exception as e:
|
|
1844
1862
|
self._show_error(f"Failed to list custom models: {e}")
|
|
1863
|
+
|
|
1864
|
+
# ------------------------------------------------------------------
|
|
1865
|
+
# /fallback
|
|
1866
|
+
# ------------------------------------------------------------------
|
|
1867
|
+
|
|
1868
|
+
# --- helpers ----------------------------------------------------------
|
|
1869
|
+
|
|
1870
|
+
@staticmethod
|
|
1871
|
+
def _validate_toggle_arg(arg: str) -> Optional[bool]:
|
|
1872
|
+
"""Return True/False for 'on'/'off', or None if the value is invalid.
|
|
1873
|
+
|
|
1874
|
+
Centralises validation of boolean toggle arguments so individual
|
|
1875
|
+
subhandlers don't duplicate the same ``if toggle not in ("on", "off")``
|
|
1876
|
+
check.
|
|
1877
|
+
"""
|
|
1878
|
+
if arg == "on":
|
|
1879
|
+
return True
|
|
1880
|
+
if arg == "off":
|
|
1881
|
+
return False
|
|
1882
|
+
return None
|
|
1883
|
+
|
|
1884
|
+
def _persist_fallback_config(self, fb: FallbackConfig) -> None:
|
|
1885
|
+
"""Persist *fb* to disk and immediately sync the engine.
|
|
1886
|
+
|
|
1887
|
+
This single method replaces the repeated trio of:
|
|
1888
|
+
``config.fallback = fb``
|
|
1889
|
+
``self.engine.config_manager.save_config()``
|
|
1890
|
+
``self.engine.configure_fallback()``
|
|
1891
|
+
across every subcommand handler.
|
|
1892
|
+
"""
|
|
1893
|
+
config = self.engine.config_manager.get_config()
|
|
1894
|
+
config.fallback = fb
|
|
1895
|
+
self.engine.config_manager.save_config()
|
|
1896
|
+
# Sync the engine's runtime handler immediately so changes take effect
|
|
1897
|
+
# without requiring a restart.
|
|
1898
|
+
self.engine.configure_fallback()
|
|
1899
|
+
|
|
1900
|
+
# --- dispatcher -------------------------------------------------------
|
|
1901
|
+
|
|
1902
|
+
async def _handle_fallback_command(self, command: Command) -> None:
|
|
1903
|
+
"""Handle the /fallback command family.
|
|
1904
|
+
|
|
1905
|
+
Sub-commands
|
|
1906
|
+
------------
|
|
1907
|
+
/fallback show current fallback configuration
|
|
1908
|
+
/fallback status same as above
|
|
1909
|
+
/fallback auto [on|off] toggle or query auto-fallback
|
|
1910
|
+
/fallback order <p1> <p2> … set the ordered fallback provider list
|
|
1911
|
+
/fallback order clear clear the fallback order (use any)
|
|
1912
|
+
/fallback on-rate-limit [on|off] toggle fallback on 429 errors
|
|
1913
|
+
/fallback on-quota [on|off] toggle fallback on quota errors
|
|
1914
|
+
/fallback help show this help text
|
|
1915
|
+
"""
|
|
1916
|
+
try:
|
|
1917
|
+
config = self.engine.config_manager.get_config()
|
|
1918
|
+
# Use a fresh FallbackConfig() default if the config object does
|
|
1919
|
+
# not yet have a fallback section (e.g., existing installs).
|
|
1920
|
+
fb: FallbackConfig = getattr(config, "fallback", FallbackConfig())
|
|
1921
|
+
|
|
1922
|
+
args = list(command.args) if command.args else []
|
|
1923
|
+
sub = args[0].lower() if args else "status"
|
|
1924
|
+
|
|
1925
|
+
dispatch: dict[str, Any] = {
|
|
1926
|
+
"status": lambda: self._show_fallback_status(fb),
|
|
1927
|
+
"": lambda: self._show_fallback_status(fb),
|
|
1928
|
+
"help": self._show_fallback_help,
|
|
1929
|
+
"auto": lambda: self._handle_auto_subcommand(fb, args),
|
|
1930
|
+
"order": lambda: self._handle_order_subcommand(fb, args),
|
|
1931
|
+
"on-rate-limit": lambda: self._handle_on_rate_limit_subcommand(
|
|
1932
|
+
fb, args
|
|
1933
|
+
),
|
|
1934
|
+
"on-quota": lambda: self._handle_on_quota_subcommand(fb, args),
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
handler = dispatch.get(sub)
|
|
1938
|
+
if handler is None:
|
|
1939
|
+
self._show_error(
|
|
1940
|
+
f"Unknown sub-command '{sub}'. "
|
|
1941
|
+
"Run [cyan]/fallback help[/cyan] for usage."
|
|
1942
|
+
)
|
|
1943
|
+
else:
|
|
1944
|
+
result = handler()
|
|
1945
|
+
# Subhandlers may be coroutines in the future; await if needed.
|
|
1946
|
+
if asyncio.iscoroutine(result):
|
|
1947
|
+
await result
|
|
1948
|
+
|
|
1949
|
+
except ValueError as exc:
|
|
1950
|
+
# Raised by subhandlers for invalid user input.
|
|
1951
|
+
self._show_error(str(exc))
|
|
1952
|
+
except Exception as exc:
|
|
1953
|
+
logger.debug("Fallback command error", exc_info=True)
|
|
1954
|
+
self._show_error(f"Failed to update fallback config: {exc}")
|
|
1955
|
+
|
|
1956
|
+
# --- subhandlers ------------------------------------------------------
|
|
1957
|
+
|
|
1958
|
+
def _handle_auto_subcommand(self, fb: FallbackConfig, args: list[str]) -> None:
|
|
1959
|
+
"""Handle ``/fallback auto [on|off]``."""
|
|
1960
|
+
if len(args) < 2:
|
|
1961
|
+
state = "on" if fb.auto_fallback else "off"
|
|
1962
|
+
self._show_info(
|
|
1963
|
+
f"Auto-fallback is currently [bold]{state}[/bold]. "
|
|
1964
|
+
f"Use [cyan]/fallback auto on[/cyan] or "
|
|
1965
|
+
f"[cyan]/fallback auto off[/cyan] to change."
|
|
1966
|
+
)
|
|
1967
|
+
return
|
|
1968
|
+
|
|
1969
|
+
enabled = self._validate_toggle_arg(args[1].lower())
|
|
1970
|
+
if enabled is None:
|
|
1971
|
+
raise ValueError("Usage: /fallback auto [on|off]")
|
|
1972
|
+
|
|
1973
|
+
fb.auto_fallback = enabled
|
|
1974
|
+
self._persist_fallback_config(fb)
|
|
1975
|
+
label = "enabled" if enabled else "disabled"
|
|
1976
|
+
self._show_success(f"Auto-fallback {label}.")
|
|
1977
|
+
|
|
1978
|
+
def _handle_order_subcommand(self, fb: FallbackConfig, args: list[str]) -> None:
|
|
1979
|
+
"""Handle ``/fallback order [<p1> <p2> … | clear]``."""
|
|
1980
|
+
if len(args) < 2:
|
|
1981
|
+
order_str = " → ".join(fb.fallback_order) if fb.fallback_order else "(any)"
|
|
1982
|
+
self._show_info(f"Current fallback order: {order_str}")
|
|
1983
|
+
return
|
|
1984
|
+
|
|
1985
|
+
if args[1].lower() == "clear":
|
|
1986
|
+
fb.fallback_order = []
|
|
1987
|
+
self._persist_fallback_config(fb)
|
|
1988
|
+
self._show_success(
|
|
1989
|
+
"Fallback order cleared (will use any available provider)."
|
|
1990
|
+
)
|
|
1991
|
+
return
|
|
1992
|
+
|
|
1993
|
+
# Validate that each name is a known provider.
|
|
1994
|
+
known = set(self.engine.providers.keys())
|
|
1995
|
+
new_order: list[str] = []
|
|
1996
|
+
unknown: list[str] = []
|
|
1997
|
+
for name in args[1:]:
|
|
1998
|
+
if name in known:
|
|
1999
|
+
new_order.append(name)
|
|
2000
|
+
else:
|
|
2001
|
+
unknown.append(name)
|
|
2002
|
+
|
|
2003
|
+
if unknown:
|
|
2004
|
+
self._show_warning(
|
|
2005
|
+
f"Unknown provider(s) skipped: {', '.join(unknown)}. "
|
|
2006
|
+
f"Known: {', '.join(sorted(known)) or 'none'}"
|
|
2007
|
+
)
|
|
2008
|
+
if not new_order:
|
|
2009
|
+
raise ValueError("No valid providers specified. Nothing changed.")
|
|
2010
|
+
|
|
2011
|
+
fb.fallback_order = new_order
|
|
2012
|
+
self._persist_fallback_config(fb)
|
|
2013
|
+
self._show_success(f"Fallback order set: {' → '.join(new_order)}")
|
|
2014
|
+
|
|
2015
|
+
def _handle_on_rate_limit_subcommand(
|
|
2016
|
+
self, fb: FallbackConfig, args: list[str]
|
|
2017
|
+
) -> None:
|
|
2018
|
+
"""Handle ``/fallback on-rate-limit [on|off]``."""
|
|
2019
|
+
if len(args) < 2:
|
|
2020
|
+
state = "on" if fb.fallback_on_rate_limit else "off"
|
|
2021
|
+
self._show_info(f"Fallback on rate-limit (429) is [bold]{state}[/bold].")
|
|
2022
|
+
return
|
|
2023
|
+
|
|
2024
|
+
enabled = self._validate_toggle_arg(args[1].lower())
|
|
2025
|
+
if enabled is None:
|
|
2026
|
+
raise ValueError("Usage: /fallback on-rate-limit [on|off]")
|
|
2027
|
+
|
|
2028
|
+
fb.fallback_on_rate_limit = enabled
|
|
2029
|
+
self._persist_fallback_config(fb)
|
|
2030
|
+
label = "enabled" if enabled else "disabled"
|
|
2031
|
+
self._show_success(f"Fallback on rate-limit {label}.")
|
|
2032
|
+
|
|
2033
|
+
def _handle_on_quota_subcommand(self, fb: FallbackConfig, args: list[str]) -> None:
|
|
2034
|
+
"""Handle ``/fallback on-quota [on|off]``."""
|
|
2035
|
+
if len(args) < 2:
|
|
2036
|
+
state = "on" if fb.fallback_on_quota else "off"
|
|
2037
|
+
self._show_info(f"Fallback on quota errors is [bold]{state}[/bold].")
|
|
2038
|
+
return
|
|
2039
|
+
|
|
2040
|
+
enabled = self._validate_toggle_arg(args[1].lower())
|
|
2041
|
+
if enabled is None:
|
|
2042
|
+
raise ValueError("Usage: /fallback on-quota [on|off]")
|
|
2043
|
+
|
|
2044
|
+
fb.fallback_on_quota = enabled
|
|
2045
|
+
self._persist_fallback_config(fb)
|
|
2046
|
+
label = "enabled" if enabled else "disabled"
|
|
2047
|
+
self._show_success(f"Fallback on quota errors {label}.")
|
|
2048
|
+
|
|
2049
|
+
# --- display helpers --------------------------------------------------
|
|
2050
|
+
|
|
2051
|
+
def _show_fallback_status(self, fb: FallbackConfig) -> None:
|
|
2052
|
+
"""Print a Rich table with the current fallback configuration."""
|
|
2053
|
+
table = Table(
|
|
2054
|
+
title="Fallback Configuration", show_header=True, header_style="bold cyan"
|
|
2055
|
+
)
|
|
2056
|
+
table.add_column("Setting", style="bold")
|
|
2057
|
+
table.add_column("Value")
|
|
2058
|
+
table.add_column("Description", style="dim")
|
|
2059
|
+
|
|
2060
|
+
auto_label = "[green]on[/green]" if fb.auto_fallback else "[yellow]off[/yellow]"
|
|
2061
|
+
rl_label = (
|
|
2062
|
+
"[green]on[/green]" if fb.fallback_on_rate_limit else "[yellow]off[/yellow]"
|
|
2063
|
+
)
|
|
2064
|
+
q_label = (
|
|
2065
|
+
"[green]on[/green]" if fb.fallback_on_quota else "[yellow]off[/yellow]"
|
|
2066
|
+
)
|
|
2067
|
+
order_str = (
|
|
2068
|
+
" [dim]→[/dim] ".join(fb.fallback_order)
|
|
2069
|
+
if fb.fallback_order
|
|
2070
|
+
else "[dim](any available)[/dim]"
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
table.add_row("auto", auto_label, "Switch without asking")
|
|
2074
|
+
table.add_row("on-rate-limit", rl_label, "Fallback on 429 / rate-limit")
|
|
2075
|
+
table.add_row("on-quota", q_label, "Fallback on quota exceeded")
|
|
2076
|
+
table.add_row("order", order_str, "Ordered list of providers to try")
|
|
2077
|
+
|
|
2078
|
+
self.console.print(table)
|
|
2079
|
+
self.console.print(
|
|
2080
|
+
"\n[dim]Tip: [cyan]/fallback auto on[/cyan] to auto-switch silently, "
|
|
2081
|
+
"[cyan]/fallback order claude openai[/cyan] to set priority.[/dim]"
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
def _show_fallback_help(self) -> None:
|
|
2085
|
+
"""Print /fallback usage."""
|
|
2086
|
+
help_text = (
|
|
2087
|
+
"[bold cyan]/fallback[/bold cyan] — manage rate-limit fallback "
|
|
2088
|
+
"behaviour\n\n"
|
|
2089
|
+
"[bold]Sub-commands[/bold]\n"
|
|
2090
|
+
" [cyan]/fallback[/cyan] "
|
|
2091
|
+
"show current configuration\n"
|
|
2092
|
+
" [cyan]/fallback auto on|off[/cyan] "
|
|
2093
|
+
"auto-switch on rate limit (no prompt)\n"
|
|
2094
|
+
" [cyan]/fallback order <p1> <p2> …[/cyan] "
|
|
2095
|
+
"set ordered fallback providers\n"
|
|
2096
|
+
" [cyan]/fallback order clear[/cyan] "
|
|
2097
|
+
"clear order (use any available)\n"
|
|
2098
|
+
" [cyan]/fallback on-rate-limit on|off[/cyan] "
|
|
2099
|
+
"enable/disable fallback on 429\n"
|
|
2100
|
+
" [cyan]/fallback on-quota on|off[/cyan] "
|
|
2101
|
+
"enable/disable fallback on quota errors\n\n"
|
|
2102
|
+
"[bold]Examples[/bold]\n"
|
|
2103
|
+
" [dim]/fallback auto on[/dim] "
|
|
2104
|
+
"silently switch when rate-limited\n"
|
|
2105
|
+
" [dim]/fallback order claude openai gemini[/dim] "
|
|
2106
|
+
"try in this order\n"
|
|
2107
|
+
" [dim]/fallback order clear[/dim] "
|
|
2108
|
+
"reset to default (any provider)\n\n"
|
|
2109
|
+
"When [bold]auto[/bold] is off (default), omnimancer will pause and ask:\n"
|
|
2110
|
+
" [yellow]⚠ Rate limit hit on claude. Fall back to openai? [Y/n][/yellow]"
|
|
2111
|
+
)
|
|
2112
|
+
self.console.print(Panel(help_text, title="Fallback Help", border_style="cyan"))
|
|
@@ -62,6 +62,7 @@ class SlashCommand(Enum):
|
|
|
62
62
|
PERMISSIONS = "/permissions"
|
|
63
63
|
PROMPTS = "/prompts"
|
|
64
64
|
SUBAGENTS = "/subagents"
|
|
65
|
+
FALLBACK = "/fallback"
|
|
65
66
|
|
|
66
67
|
@classmethod
|
|
67
68
|
def from_string(cls, command_str: str) -> Optional["SlashCommand"]:
|
|
@@ -390,7 +391,10 @@ def _validate_command_args(command: SlashCommand, args: List[str]) -> List[str]:
|
|
|
390
391
|
if filter_type not in valid_filters:
|
|
391
392
|
filters_str = ", ".join(valid_filters)
|
|
392
393
|
raise ValueError(
|
|
393
|
-
"Invalid filter type. Valid
|
|
394
|
+
f"Invalid filter type. Valid filters: {filters_str}. "
|
|
395
|
+
"To register a new model use "
|
|
396
|
+
"/add-model <model> <provider> — or just "
|
|
397
|
+
"/switch <provider> <model>."
|
|
394
398
|
)
|
|
395
399
|
|
|
396
400
|
# Validate second argument based on filter type
|
|
@@ -10,9 +10,19 @@ import uuid
|
|
|
10
10
|
from enum import Enum
|
|
11
11
|
from typing import Any, Dict, Optional
|
|
12
12
|
|
|
13
|
-
from ..core.models import
|
|
13
|
+
from ..core.models import (
|
|
14
|
+
ChatResponse,
|
|
15
|
+
ToolResult,
|
|
16
|
+
ToolResultRecord,
|
|
17
|
+
parse_described_tool_calls,
|
|
18
|
+
)
|
|
14
19
|
from .system_prompts import build_agent_prompt
|
|
15
|
-
from .tool_handler import
|
|
20
|
+
from .tool_handler import (
|
|
21
|
+
DUPLICATE_CALL_NUDGE,
|
|
22
|
+
MAX_TOOL_ITERATIONS,
|
|
23
|
+
RepeatedCallTracker,
|
|
24
|
+
ToolHandler,
|
|
25
|
+
)
|
|
16
26
|
|
|
17
27
|
|
|
18
28
|
class OutputFormat(Enum):
|
|
@@ -212,10 +222,14 @@ class HeadlessRunner:
|
|
|
212
222
|
output_format: OutputFormat = OutputFormat.TEXT,
|
|
213
223
|
no_approval: bool = False,
|
|
214
224
|
verbose: bool = False,
|
|
225
|
+
max_iterations: Optional[int] = None,
|
|
215
226
|
) -> None:
|
|
216
227
|
self._engine = engine
|
|
217
228
|
self._no_approval = no_approval
|
|
218
229
|
self._verbose = verbose
|
|
230
|
+
# Headless runs are unattended, so a cap (not a check-in prompt) is
|
|
231
|
+
# the safety net; --max-iterations sizes it to the job.
|
|
232
|
+
self._max_iterations = max_iterations or MAX_TOOL_ITERATIONS
|
|
219
233
|
session_id = str(uuid.uuid4())
|
|
220
234
|
self._emitter = HeadlessOutputEmitter(output_format, session_id, verbose)
|
|
221
235
|
self._tokens = TokenAccumulator()
|
|
@@ -242,10 +256,11 @@ class HeadlessRunner:
|
|
|
242
256
|
# Record of the actions the agent took, surfaced in the JSON result.
|
|
243
257
|
tool_log: list = []
|
|
244
258
|
turns = 0
|
|
245
|
-
#
|
|
246
|
-
|
|
259
|
+
# Identical repeated calls get skipped with a corrective nudge; the
|
|
260
|
+
# run is aborted only if the model keeps repeating despite nudges.
|
|
261
|
+
repeat_tracker = RepeatedCallTracker()
|
|
247
262
|
|
|
248
|
-
for iteration in range(
|
|
263
|
+
for iteration in range(self._max_iterations):
|
|
249
264
|
turns = iteration + 1
|
|
250
265
|
response = await self._engine.send_message_with_tools(
|
|
251
266
|
current_message, tools
|
|
@@ -269,29 +284,37 @@ class HeadlessRunner:
|
|
|
269
284
|
last_stop_reason,
|
|
270
285
|
)
|
|
271
286
|
|
|
287
|
+
# The model may mimic the "[Called tools: ...]" history notation
|
|
288
|
+
# as plain text instead of issuing native tool calls — recover
|
|
289
|
+
# those so the run doesn't end with the work undone.
|
|
272
290
|
if not response.tool_calls:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if seen_calls[sig] >= 3:
|
|
282
|
-
repeated = True
|
|
283
|
-
if repeated:
|
|
291
|
+
recovered = parse_described_tool_calls(response.content)
|
|
292
|
+
if not recovered:
|
|
293
|
+
break
|
|
294
|
+
response.tool_calls = recovered
|
|
295
|
+
|
|
296
|
+
repeat_tracker.record(response.tool_calls)
|
|
297
|
+
offender = repeat_tracker.abort_offender(response.tool_calls)
|
|
298
|
+
if offender is not None:
|
|
284
299
|
if not last_content:
|
|
285
300
|
last_content = (
|
|
286
|
-
"Stopped: the model
|
|
301
|
+
f"Stopped: the model repeated the same tool call "
|
|
302
|
+
f"{repeat_tracker.count(offender)} times "
|
|
303
|
+
f"({offender.name}) despite duplicate warnings."
|
|
287
304
|
)
|
|
288
305
|
break
|
|
289
306
|
|
|
290
307
|
result_parts = []
|
|
291
|
-
|
|
308
|
+
records = []
|
|
309
|
+
for i, tc in enumerate(response.tool_calls):
|
|
292
310
|
self._emitter.emit_tool_use(tc.name, tc.arguments)
|
|
293
311
|
|
|
294
|
-
|
|
312
|
+
# Skip exact repeats with a nudge so the model can recover
|
|
313
|
+
# instead of the run being killed.
|
|
314
|
+
if repeat_tracker.is_duplicate(tc):
|
|
315
|
+
result = ToolResult(content=DUPLICATE_CALL_NUDGE)
|
|
316
|
+
else:
|
|
317
|
+
result = await tool_handler.execute_tool_call(tc)
|
|
295
318
|
self._emitter.emit_tool_result(
|
|
296
319
|
tc.name,
|
|
297
320
|
result.content or "",
|
|
@@ -305,13 +328,29 @@ class HeadlessRunner:
|
|
|
305
328
|
}
|
|
306
329
|
)
|
|
307
330
|
|
|
331
|
+
# Label results with the call's arguments so the model can
|
|
332
|
+
# tell which call each result belongs to.
|
|
333
|
+
label = f"{tc.name}({json.dumps(tc.arguments, default=str)})"
|
|
308
334
|
if result.error:
|
|
309
|
-
result_parts.append(f"[{
|
|
335
|
+
result_parts.append(f"[{label}] Error: {result.error}")
|
|
310
336
|
else:
|
|
311
|
-
result_parts.append(f"[{
|
|
337
|
+
result_parts.append(f"[{label}] Result: {result.content}")
|
|
338
|
+
records.append(
|
|
339
|
+
ToolResultRecord(
|
|
340
|
+
tool_call_id=tc.id or f"call_{i}",
|
|
341
|
+
content=result.error or result.content,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
312
344
|
|
|
313
345
|
# Feed results back without forcing the model to "continue".
|
|
314
|
-
|
|
346
|
+
results_text = "Tool results:\n\n" + "\n\n".join(result_parts)
|
|
347
|
+
if self._engine.provider_supports_native_tool_history() is True:
|
|
348
|
+
# Native protocol: structured history + empty continuation
|
|
349
|
+
# message (see interface._handle_tool_calling_flow).
|
|
350
|
+
self._engine.record_tool_results(results_text, records)
|
|
351
|
+
current_message = ""
|
|
352
|
+
else:
|
|
353
|
+
current_message = results_text
|
|
315
354
|
|
|
316
355
|
usage = self._tokens.total
|
|
317
356
|
self._emitter.emit_result(
|
|
@@ -335,6 +374,7 @@ async def run_headless(
|
|
|
335
374
|
provider: Optional[str] = None,
|
|
336
375
|
model: Optional[str] = None,
|
|
337
376
|
base_url: Optional[str] = None,
|
|
377
|
+
max_iterations: Optional[int] = None,
|
|
338
378
|
) -> int:
|
|
339
379
|
from ..core.config_manager import ConfigManager
|
|
340
380
|
from ..core.engine import CoreEngine
|
|
@@ -370,5 +410,6 @@ async def run_headless(
|
|
|
370
410
|
output_format=fmt,
|
|
371
411
|
no_approval=no_approval,
|
|
372
412
|
verbose=verbose,
|
|
413
|
+
max_iterations=max_iterations,
|
|
373
414
|
)
|
|
374
415
|
return await runner.run(prompt)
|