Files
devplacepy/tests/unit/services/devii/agentic/loop.py
T
retoorandClaude Sonnet 5 569f1dcc64 Add OpenCode Zen support, model health/stats dashboard, and gateway fallback fixes
AI gateway:
- Add a generic, admin-selectable `client_profile` field on gateway_providers
  (e.g. "opencode") so a provider needing special request headers (OpenCode
  Zen's client-identity spoofing) is configured like any other provider, not
  hardcoded by name.
- Track per-(provider, model) reliability/speed/latency health in memory,
  seeded from the existing gateway_usage_ledger at startup - purely
  observational, never influences routing.
- New Stats tab on /admin/gateway: request volume, latency, per-model
  breakdowns, and reliability weight, charted with a vendored Chart.js and
  devplace's own theme tokens.
- Record which model a failed request actually fell back to
  (fallback_used_route), surfaced in the Recent Failures table.
- Stop excluding context_length errors from fallback, and skip a primary
  attempt outright when its known context window is already too small for
  the estimated request size, going straight to the fallback.
- gateway_usage_ledger's provider/fallback_used_route columns and indexes
  are ensured centrally in database/schema.py's init_db(), the single point
  of truth for this table's schema.
- Non-OpenAI upstream routing and client-model passthrough; trust only the
  upstream's own X-Gateway-Model header for served-model attribution.

Devii agent:
- Fix a real lockup: plan/verify tools could be individually disabled via
  the admin tool toggles while still being required by the protocol gate,
  permanently bricking any task that needed tools. They can no longer be
  disabled, and the gate now also checks the tool is actually offered.
- Fix compaction being silently calibrated for a 1M-token model while
  running a much smaller one: context budget is now percentage-based and
  the summarizer's own request is sized to fit the real model.
- Give a specific, actionable retry message when plan()'s own arguments get
  cut off by the output limit, and tighten its schema to discourage
  overlong plans.

Other:
- Backup service: offload completed backups to a remote Hetzner Storage Box.
- Container manager: fix orphan blob leaks from sync races, add a two-phase
  plan/execute `system prune` CLI command.
- Admin gateway UI: replace the JS-rendered model/provider tables with
  server-rendered forms and pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhmEkvutuwtzFVcLbTrhdo
2026-09-09 07:38:24 +02:00

237 lines
7.2 KiB
Python

# retoor <retoor@molodetz.nl>
import dataclasses
import json
from devplacepy.services.devii.agentic.compaction import context_size
from devplacepy.services.devii.agentic.loop import (
MAX_CONTEXT_OVERFLOW_RETRIES,
_run_tool_call,
react_loop,
)
from devplacepy.services.devii.agentic.state import AgentState
from devplacepy.services.devii.config import load_settings
from devplacepy.services.devii.errors import LLMError
from tests.conftest import run_async
class _FakeDispatcher:
def __init__(self):
self.calls = []
async def dispatch(self, name, arguments):
self.calls.append((name, arguments))
return json.dumps({"status": "ok"})
def _run(call, dispatcher=None):
return json.loads(run_async(_run_tool_call(dispatcher, call)))
def test_truncated_arguments_reported_not_dispatched():
dispatcher = _FakeDispatcher()
call = {
"function": {
"name": "project_write_file",
"arguments": '{"path":"a.md","content":"# hi',
}
}
out = _run(call, dispatcher)
assert out["error"] == "tool_input_truncated"
assert "one write tool call per turn" in out["message"]
assert dispatcher.calls == []
def test_non_object_arguments_rejected():
dispatcher = _FakeDispatcher()
out = _run({"function": {"name": "x", "arguments": '"a string"'}}, dispatcher)
assert out["error"] == "tool_input_error"
assert dispatcher.calls == []
def test_valid_string_arguments_dispatched():
dispatcher = _FakeDispatcher()
out = _run({"function": {"name": "vote", "arguments": '{"value":1}'}}, dispatcher)
assert out["status"] == "ok"
assert dispatcher.calls == [("vote", {"value": 1})]
def test_valid_dict_arguments_dispatched():
dispatcher = _FakeDispatcher()
out = _run({"function": {"name": "vote", "arguments": {"value": -1}}}, dispatcher)
assert out["status"] == "ok"
assert dispatcher.calls == [("vote", {"value": -1})]
def test_missing_arguments_defaults_to_empty_object():
dispatcher = _FakeDispatcher()
out = _run({"function": {"name": "auth_status"}}, dispatcher)
assert out["status"] == "ok"
assert dispatcher.calls == [("auth_status", {})]
_CONTEXT_LENGTH_BODY = json.dumps(
{
"error": {
"message": (
"This endpoint's maximum context length is 131072 tokens. "
"However, you requested about 403355 tokens. Please reduce "
"the length."
),
"code": 400,
}
}
)
def _context_length_error():
return LLMError(
"Model endpoint returned 400: over limit", status=400, body=_CONTEXT_LENGTH_BODY
)
def _settings_for_test(keep_tail=4, threshold=10**9):
return dataclasses.replace(
load_settings(),
context_compact_threshold=threshold,
context_keep_tail=keep_tail,
)
def _long_message_history(count=10):
messages = [{"role": "system", "content": "system prompt"}]
for i in range(count):
role = "user" if i % 2 == 0 else "assistant"
messages.append({"role": role, "content": f"turn {i}"})
return messages
class _FakeLLM:
def __init__(self, complete_results):
self._complete_results = list(complete_results)
self.complete_calls = 0
self.summarize_calls = 0
async def complete(self, messages, tools):
self.complete_calls += 1
result = self._complete_results[
min(self.complete_calls, len(self._complete_results)) - 1
]
if isinstance(result, Exception):
raise result
return result
async def summarize(self, text):
self.summarize_calls += 1
return "compacted summary"
def test_context_overflow_triggers_compaction_and_retries():
llm = _FakeLLM(
[_context_length_error(), {"role": "assistant", "content": "Recovered answer"}]
)
messages = _long_message_history()
result = run_async(
react_loop(
llm,
_FakeDispatcher(),
messages,
tools=[],
state=AgentState(),
settings=_settings_for_test(),
max_iterations=5,
plan_required=False,
verify_required=False,
)
)
assert result == "Recovered answer"
assert llm.complete_calls == 2
assert llm.summarize_calls == 1
assert not result.startswith("[model error]")
def test_context_overflow_gives_up_after_max_retries_with_clear_message():
llm = _FakeLLM([_context_length_error()])
messages = _long_message_history()
result = run_async(
react_loop(
llm,
_FakeDispatcher(),
messages,
tools=[],
state=AgentState(),
settings=_settings_for_test(),
max_iterations=10,
plan_required=False,
verify_required=False,
)
)
assert result.startswith("[model error]")
assert "context length" in result.lower() or "400" in result
assert llm.complete_calls == MAX_CONTEXT_OVERFLOW_RETRIES + 1
assert 0 < llm.summarize_calls <= MAX_CONTEXT_OVERFLOW_RETRIES
def test_non_context_length_error_never_triggers_compaction():
llm = _FakeLLM([LLMError("Model endpoint returned 500: boom", status=500, body="{}")])
messages = _long_message_history()
result = run_async(
react_loop(
llm,
_FakeDispatcher(),
messages,
tools=[],
state=AgentState(),
settings=_settings_for_test(),
max_iterations=5,
plan_required=False,
verify_required=False,
)
)
assert result == "[model error] Model endpoint returned 500: boom"
assert llm.complete_calls == 1
assert llm.summarize_calls == 0
class _FakeLLMWithRealLimit:
def __init__(self, simulated_limit_chars):
self.simulated_limit_chars = simulated_limit_chars
self.complete_calls = 0
self.summarize_calls = 0
async def complete(self, messages, tools):
self.complete_calls += 1
if context_size(messages) > self.simulated_limit_chars:
raise _context_length_error()
return {"role": "assistant", "content": "Recovered answer"}
async def summarize(self, text):
self.summarize_calls += 1
return "short summary"
def test_one_oversized_tail_message_alone_still_recovers():
giant = "X" * 300_000
messages = _long_message_history(16)
messages.append(
{"role": "tool", "tool_call_id": "1", "name": "big_tool", "content": giant}
)
messages.append({"role": "user", "content": "please continue"})
llm = _FakeLLMWithRealLimit(simulated_limit_chars=30_000)
result = run_async(
react_loop(
llm,
_FakeDispatcher(),
messages,
tools=[],
state=AgentState(),
settings=_settings_for_test(keep_tail=4),
max_iterations=10,
plan_required=False,
verify_required=False,
)
)
assert result == "Recovered answer"
assert llm.complete_calls > MAX_CONTEXT_OVERFLOW_RETRIES - 1
giant_message = next(m for m in messages if m.get("name") == "big_tool")
assert len(giant_message["content"]) < len(giant)
assert "truncated" in giant_message["content"]