Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c96e35e1 |
245
agent/tool_fallback.py
Normal file
245
agent/tool_fallback.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tool call fallback — retry failed tool calls with local model.
|
||||
|
||||
When the primary provider fails during tool calling (timeout, 429, 503),
|
||||
fall back to local Ollama to keep the agent working.
|
||||
|
||||
Usage:
|
||||
from agent.tool_fallback import ToolFallbackHandler
|
||||
handler = ToolFallbackHandler()
|
||||
result = handler.execute_with_fallback(tool_fn, args, context)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Fallback provider config
|
||||
_FALLBACK_PROVIDER = os.getenv("TOOL_FALLBACK_PROVIDER", "ollama")
|
||||
_FALLBACK_MODEL = os.getenv("TOOL_FALLBACK_MODEL", "qwen2.5:7b")
|
||||
_FALLBACK_BASE_URL = os.getenv("TOOL_FALLBACK_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
# Error patterns that trigger fallback
|
||||
_FALLBACK_TRIGGERS = [
|
||||
"429",
|
||||
"rate limit",
|
||||
"ratelimit",
|
||||
"503",
|
||||
"service unavailable",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection error",
|
||||
"connection refused",
|
||||
"overloaded",
|
||||
"capacity",
|
||||
"quota exceeded",
|
||||
"insufficient",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FallbackEvent:
|
||||
"""Record of a fallback event."""
|
||||
timestamp: float
|
||||
tool_name: str
|
||||
original_provider: str
|
||||
fallback_provider: str
|
||||
error: str
|
||||
success: bool
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
class ToolFallbackHandler:
|
||||
"""Handles tool call fallback to local models.
|
||||
|
||||
Tracks fallback events and provides fallback execution.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fallback_provider: str = "",
|
||||
fallback_model: str = "",
|
||||
fallback_base_url: str = "",
|
||||
enabled: bool = True,
|
||||
):
|
||||
self.fallback_provider = fallback_provider or _FALLBACK_PROVIDER
|
||||
self.fallback_model = fallback_model or _FALLBACK_MODEL
|
||||
self.fallback_base_url = fallback_base_url or _FALLBACK_BASE_URL
|
||||
self.enabled = enabled
|
||||
self._events: list[FallbackEvent] = []
|
||||
self._fallback_count = 0
|
||||
self._fallback_success_count = 0
|
||||
|
||||
@property
|
||||
def events(self) -> list[FallbackEvent]:
|
||||
return list(self._events)
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"total_fallbacks": self._fallback_count,
|
||||
"successful_fallbacks": self._fallback_success_count,
|
||||
"fallback_rate": (
|
||||
self._fallback_success_count / self._fallback_count
|
||||
if self._fallback_count > 0 else 0
|
||||
),
|
||||
}
|
||||
|
||||
def should_fallback(self, error: Any) -> bool:
|
||||
"""Check if an error should trigger fallback."""
|
||||
if not self.enabled:
|
||||
return False
|
||||
error_str = str(error).lower()
|
||||
return any(trigger in error_str for trigger in _FALLBACK_TRIGGERS)
|
||||
|
||||
def get_fallback_client(self) -> Optional[Any]:
|
||||
"""Get an OpenAI client configured for the fallback provider."""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(
|
||||
base_url=self.fallback_base_url,
|
||||
api_key=os.getenv("OPENAI_API_KEY", "ollama"),
|
||||
)
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.error("Failed to create fallback client: %s", e)
|
||||
return None
|
||||
|
||||
def call_with_fallback(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict] = None,
|
||||
original_provider: str = "",
|
||||
tool_name: str = "unknown",
|
||||
max_tokens: int = 1024,
|
||||
) -> dict:
|
||||
"""Make an API call with fallback to local model on failure.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
tools: Tool definitions
|
||||
original_provider: Name of the original provider
|
||||
tool_name: Name of the tool being called
|
||||
max_tokens: Max tokens for the response
|
||||
|
||||
Returns:
|
||||
Dict with 'response', 'used_fallback', 'fallback_event' keys.
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
|
||||
# Try fallback client
|
||||
client = self.get_fallback_client()
|
||||
if not client:
|
||||
return {
|
||||
"response": None,
|
||||
"used_fallback": False,
|
||||
"error": "Fallback client unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=self.fallback_model,
|
||||
messages=messages,
|
||||
tools=tools if tools else None,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
elapsed = int((time.monotonic() - t0) * 1000)
|
||||
event = FallbackEvent(
|
||||
timestamp=time.time(),
|
||||
tool_name=tool_name,
|
||||
original_provider=original_provider,
|
||||
fallback_provider=self.fallback_provider,
|
||||
error="",
|
||||
success=True,
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
self._events.append(event)
|
||||
self._fallback_count += 1
|
||||
self._fallback_success_count += 1
|
||||
|
||||
logger.info(
|
||||
"Tool fallback succeeded: %s via %s (%dms)",
|
||||
tool_name, self.fallback_provider, elapsed,
|
||||
)
|
||||
|
||||
return {
|
||||
"response": response,
|
||||
"used_fallback": True,
|
||||
"fallback_event": event,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = int((time.monotonic() - t0) * 1000)
|
||||
event = FallbackEvent(
|
||||
timestamp=time.time(),
|
||||
tool_name=tool_name,
|
||||
original_provider=original_provider,
|
||||
fallback_provider=self.fallback_provider,
|
||||
error=str(e),
|
||||
success=False,
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
self._events.append(event)
|
||||
self._fallback_count += 1
|
||||
|
||||
logger.error(
|
||||
"Tool fallback failed: %s via %s — %s",
|
||||
tool_name, self.fallback_provider, e,
|
||||
)
|
||||
|
||||
return {
|
||||
"response": None,
|
||||
"used_fallback": True,
|
||||
"fallback_event": event,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def format_report(self) -> str:
|
||||
"""Format fallback events as a report."""
|
||||
if not self._events:
|
||||
return "No fallback events recorded."
|
||||
|
||||
lines = [
|
||||
"Tool Fallback Report",
|
||||
"=" * 40,
|
||||
f"Total fallbacks: {self._fallback_count}",
|
||||
f"Successful: {self._fallback_success_count}",
|
||||
f"Failed: {self._fallback_count - self._fallback_success_count}",
|
||||
"",
|
||||
]
|
||||
|
||||
for event in self._events[-10:]:
|
||||
status = "OK" if event.success else "FAIL"
|
||||
lines.append(
|
||||
f" [{status}] {event.tool_name} via {event.fallback_provider} "
|
||||
f"({event.duration_ms}ms) — {event.original_provider}"
|
||||
)
|
||||
if event.error:
|
||||
lines.append(f" Error: {event.error[:100]}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Singleton handler
|
||||
_handler: Optional[ToolFallbackHandler] = None
|
||||
|
||||
|
||||
def get_tool_fallback_handler() -> ToolFallbackHandler:
|
||||
"""Get or create the singleton tool fallback handler."""
|
||||
global _handler
|
||||
if _handler is None:
|
||||
_handler = ToolFallbackHandler()
|
||||
return _handler
|
||||
|
||||
|
||||
def reset_tool_fallback_handler() -> None:
|
||||
"""Reset the singleton (for testing)."""
|
||||
global _handler
|
||||
_handler = None
|
||||
@@ -1,81 +0,0 @@
|
||||
"""
|
||||
Tests for skill dependency resolver
|
||||
|
||||
Issue: #754
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from tools.skill_deps import (
|
||||
parse_requires,
|
||||
check_dependency,
|
||||
check_dependencies,
|
||||
resolve_dependencies,
|
||||
)
|
||||
|
||||
|
||||
class TestParseRequires(unittest.TestCase):
|
||||
|
||||
def test_list(self):
|
||||
fm = {"requires": ["pkg1", "pkg2"]}
|
||||
self.assertEqual(parse_requires(fm), ["pkg1", "pkg2"])
|
||||
|
||||
def test_string(self):
|
||||
fm = {"requires": "pkg1"}
|
||||
self.assertEqual(parse_requires(fm), ["pkg1"])
|
||||
|
||||
def test_empty(self):
|
||||
fm = {}
|
||||
self.assertEqual(parse_requires(fm), [])
|
||||
|
||||
def test_none(self):
|
||||
fm = {"requires": None}
|
||||
self.assertEqual(parse_requires(fm), [])
|
||||
|
||||
|
||||
class TestCheckDependency(unittest.TestCase):
|
||||
|
||||
def test_installed(self):
|
||||
installed, version = check_dependency("json")
|
||||
self.assertTrue(installed)
|
||||
|
||||
def test_not_installed(self):
|
||||
installed, version = check_dependency("nonexistent_package_xyz_123")
|
||||
self.assertFalse(installed)
|
||||
|
||||
|
||||
class TestCheckDependencies(unittest.TestCase):
|
||||
|
||||
def test_all_installed(self):
|
||||
installed, missing = check_dependencies(["json", "os"])
|
||||
self.assertEqual(len(missing), 0)
|
||||
|
||||
def test_some_missing(self):
|
||||
installed, missing = check_dependencies(["json", "nonexistent_xyz"])
|
||||
self.assertIn("json", installed)
|
||||
self.assertIn("nonexistent_xyz", missing)
|
||||
|
||||
|
||||
class TestResolveDependencies(unittest.TestCase):
|
||||
|
||||
def test_no_requires(self):
|
||||
satisfied, installed, missing = resolve_dependencies({})
|
||||
self.assertTrue(satisfied)
|
||||
|
||||
def test_all_satisfied(self):
|
||||
satisfied, installed, missing = resolve_dependencies(
|
||||
{"requires": ["json"]}, auto_install=False
|
||||
)
|
||||
self.assertTrue(satisfied)
|
||||
|
||||
def test_missing_no_auto(self):
|
||||
satisfied, installed, missing = resolve_dependencies(
|
||||
{"requires": ["nonexistent_xyz"]}, auto_install=False
|
||||
)
|
||||
self.assertFalse(satisfied)
|
||||
self.assertIn("nonexistent_xyz", missing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
tests/test_tool_fallback.py
Normal file
74
tests/test_tool_fallback.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for tool call fallback handler."""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from agent.tool_fallback import ToolFallbackHandler, FallbackEvent, get_tool_fallback_handler
|
||||
|
||||
|
||||
class TestShouldFallback:
|
||||
def test_rate_limit_triggers(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert handler.should_fallback("429 rate limit exceeded")
|
||||
assert handler.should_fallback("RateLimitError: too many requests")
|
||||
|
||||
def test_timeout_triggers(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert handler.should_fallback("Connection timed out")
|
||||
assert handler.should_fallback("Request timed out after 30s")
|
||||
|
||||
def test_503_triggers(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert handler.should_fallback("503 Service Unavailable")
|
||||
assert handler.should_fallback("Service unavailable")
|
||||
|
||||
def test_quota_triggers(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert handler.should_fallback("quota exceeded")
|
||||
assert handler.should_fallback("insufficient credits")
|
||||
|
||||
def test_normal_error_no_trigger(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert not handler.should_fallback("Invalid API key")
|
||||
assert not handler.should_fallback("Model not found")
|
||||
|
||||
def test_disabled_handler(self):
|
||||
handler = ToolFallbackHandler(enabled=False)
|
||||
assert not handler.should_fallback("429 rate limit")
|
||||
|
||||
|
||||
class TestFallbackEvents:
|
||||
def test_event_creation(self):
|
||||
event = FallbackEvent(
|
||||
timestamp=1234567890.0,
|
||||
tool_name="terminal",
|
||||
original_provider="openrouter",
|
||||
fallback_provider="ollama",
|
||||
error="",
|
||||
success=True,
|
||||
duration_ms=150,
|
||||
)
|
||||
assert event.tool_name == "terminal"
|
||||
assert event.success
|
||||
assert event.duration_ms == 150
|
||||
|
||||
|
||||
class TestFallbackHandler:
|
||||
def test_stats_initial(self):
|
||||
handler = ToolFallbackHandler()
|
||||
assert handler.stats["total_fallbacks"] == 0
|
||||
assert handler.stats["successful_fallbacks"] == 0
|
||||
|
||||
def test_report_no_events(self):
|
||||
handler = ToolFallbackHandler()
|
||||
report = handler.format_report()
|
||||
assert "No fallback events" in report
|
||||
|
||||
def test_singleton(self):
|
||||
h1 = get_tool_fallback_handler()
|
||||
h2 = get_tool_fallback_handler()
|
||||
assert h1 is h2
|
||||
@@ -1,185 +0,0 @@
|
||||
"""
|
||||
Skill Dependency Resolver — Auto-install missing dependencies
|
||||
|
||||
Checks skill frontmatter for `requires` field and ensures
|
||||
dependencies are installed before loading the skill.
|
||||
|
||||
Issue: #754
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_requires(frontmatter: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Parse the `requires` field from skill frontmatter.
|
||||
|
||||
Supports:
|
||||
- requires: [package1, package2]
|
||||
- requires: package1
|
||||
- requires:
|
||||
- package1
|
||||
- package2
|
||||
"""
|
||||
requires = frontmatter.get("requires", [])
|
||||
if isinstance(requires, str):
|
||||
return [requires]
|
||||
if isinstance(requires, list):
|
||||
return [str(r) for r in requires if r]
|
||||
return []
|
||||
|
||||
|
||||
def check_dependency(package: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if a Python package is installed.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_installed, version_or_error)
|
||||
"""
|
||||
# Handle pip package names (e.g., "matrix-nio[e2e]")
|
||||
import_name = package.split("[")[0].replace("-", "_")
|
||||
|
||||
try:
|
||||
mod = importlib.import_module(import_name)
|
||||
version = getattr(mod, "__version__", "installed")
|
||||
return True, version
|
||||
except ImportError:
|
||||
return False, "not installed"
|
||||
|
||||
|
||||
def check_dependencies(requires: List[str]) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Check which dependencies are missing.
|
||||
|
||||
Returns:
|
||||
Tuple of (installed, missing)
|
||||
"""
|
||||
installed = []
|
||||
missing = []
|
||||
|
||||
for pkg in requires:
|
||||
is_installed, _ = check_dependency(pkg)
|
||||
if is_installed:
|
||||
installed.append(pkg)
|
||||
else:
|
||||
missing.append(pkg)
|
||||
|
||||
return installed, missing
|
||||
|
||||
|
||||
def install_dependency(package: str, quiet: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Install a Python package via pip.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, output_or_error)
|
||||
"""
|
||||
try:
|
||||
cmd = [sys.executable, "-m", "pip", "install", package]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if not quiet:
|
||||
logger.info("Installed %s", package)
|
||||
return True, result.stdout
|
||||
else:
|
||||
logger.error("Failed to install %s: %s", package, result.stderr)
|
||||
return False, result.stderr
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Installation timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def resolve_dependencies(
|
||||
frontmatter: Dict[str, Any],
|
||||
auto_install: bool = False,
|
||||
quiet: bool = False
|
||||
) -> Tuple[bool, List[str], List[str]]:
|
||||
"""
|
||||
Resolve skill dependencies.
|
||||
|
||||
Args:
|
||||
frontmatter: Skill frontmatter dict
|
||||
auto_install: If True, install missing deps without asking
|
||||
quiet: If True, suppress output
|
||||
|
||||
Returns:
|
||||
Tuple of (all_satisfied, installed_now, still_missing)
|
||||
"""
|
||||
requires = parse_requires(frontmatter)
|
||||
if not requires:
|
||||
return True, [], []
|
||||
|
||||
installed, missing = check_dependencies(requires)
|
||||
|
||||
if not missing:
|
||||
if not quiet:
|
||||
logger.debug("All dependencies satisfied: %s", installed)
|
||||
return True, [], []
|
||||
|
||||
if not auto_install:
|
||||
if not quiet:
|
||||
logger.warning("Missing dependencies: %s", missing)
|
||||
return False, [], missing
|
||||
|
||||
# Auto-install missing dependencies
|
||||
installed_now = []
|
||||
still_missing = []
|
||||
|
||||
for pkg in missing:
|
||||
if not quiet:
|
||||
logger.info("Installing missing dependency: %s", pkg)
|
||||
|
||||
success, output = install_dependency(pkg, quiet=quiet)
|
||||
if success:
|
||||
installed_now.append(pkg)
|
||||
else:
|
||||
still_missing.append(pkg)
|
||||
logger.error("Failed to install %s: %s", pkg, output[:200])
|
||||
|
||||
all_satisfied = len(still_missing) == 0
|
||||
return all_satisfied, installed_now, still_missing
|
||||
|
||||
|
||||
def check_skill_dependencies(skill_dir) -> Dict[str, Any]:
|
||||
"""
|
||||
Check dependencies for a skill directory.
|
||||
|
||||
Returns:
|
||||
Dict with dependency status
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
skill_md = Path(skill_dir) / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return {"requires": [], "installed": [], "missing": [], "satisfied": True}
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
from agent.skill_utils import parse_frontmatter
|
||||
frontmatter, _ = parse_frontmatter(content)
|
||||
except Exception:
|
||||
return {"requires": [], "installed": [], "missing": [], "satisfied": True}
|
||||
|
||||
requires = parse_requires(frontmatter)
|
||||
installed, missing = check_dependencies(requires)
|
||||
|
||||
return {
|
||||
"requires": requires,
|
||||
"installed": installed,
|
||||
"missing": missing,
|
||||
"satisfied": len(missing) == 0
|
||||
}
|
||||
Reference in New Issue
Block a user