mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-12 12:37:32 +00:00
d16b849c3e
* fix: harden stabilization attachment and agent guards * fix(uploads): preserve durable references during cleanup * fix(uploads): close cleanup and compaction races
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""Regression: stream_agent_loop emits `rounds_exhausted` only when the round
|
|
cap is hit while still working, and NOT on a normal finish.
|
|
|
|
The decision is a `for/else` in the loop: the `else` runs only if no `break`
|
|
fired (break = done / budget / error). A refactor that adds a stray break or
|
|
return, or moves the done-break, could silently flip this. See PR #1999 / #1997.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import src.agent_loop as al
|
|
|
|
|
|
def _collect(gen):
|
|
async def _run():
|
|
return [c async for c in gen]
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def _types(chunks):
|
|
out = []
|
|
for c in chunks:
|
|
if c.startswith("data: ") and not c.startswith("data: [DONE]"):
|
|
try:
|
|
out.append(json.loads(c[6:]))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _patch_common(monkeypatch):
|
|
# Skip RAG/tool-index, MCP, and settings lookups; keep the real loop body,
|
|
# _resolve_tool_blocks, and parse_tool_blocks.
|
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
|
|
|
async def _fake_exec(block, *a, **k):
|
|
return ("bash", {"output": "ok", "exit_code": 0})
|
|
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
|
|
|
|
|
|
def _run_loop(monkeypatch, round_text, max_rounds=2):
|
|
async def _fake_stream(_candidates, messages, **kwargs):
|
|
yield f'data: {json.dumps({"delta": round_text})}\n\n'
|
|
yield "data: [DONE]\n\n"
|
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
|
|
|
gen = al.stream_agent_loop(
|
|
"http://x/v1", "m",
|
|
[{"role": "user", "content": "do a long multi-step task"}],
|
|
max_rounds=max_rounds,
|
|
relevant_tools={"bash"},
|
|
)
|
|
return _types(_collect(gen))
|
|
|
|
|
|
def test_emits_rounds_exhausted_when_cap_hit_mid_task(monkeypatch):
|
|
_patch_common(monkeypatch)
|
|
# Every round returns a tool block -> never "done" -> loop exhausts the cap.
|
|
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=2)
|
|
assert any(e.get("type") == "rounds_exhausted" for e in events), events
|
|
|
|
|
|
def test_no_rounds_exhausted_on_normal_finish(monkeypatch):
|
|
_patch_common(monkeypatch)
|
|
# A plain answer (no tool block) -> done-break on round 1 -> no event.
|
|
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=2)
|
|
assert not any(e.get("type") == "rounds_exhausted" for e in events), events
|
|
|
|
|
|
def test_emits_intent_nudge_exhausted_when_cap_is_exhausted(monkeypatch):
|
|
_patch_common(monkeypatch)
|
|
|
|
events = _run_loop(monkeypatch, "Let me check the logs", max_rounds=5)
|
|
|
|
guard = next((e for e in events if e.get("type") == "intent_nudge_exhausted"), None)
|
|
assert guard is not None, events
|
|
assert guard["reason"] == "intent_without_action_nudge_cap"
|
|
assert guard["nudges"] == 2
|
|
|
|
|
|
def test_emits_loop_breaker_triggered_when_loop_breaker_trips(monkeypatch):
|
|
_patch_common(monkeypatch)
|
|
|
|
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=6)
|
|
|
|
guard = next((e for e in events if e.get("type") == "loop_breaker_triggered"), None)
|
|
assert guard is not None, events
|
|
assert guard["reason"] == "loop_breaker_stall"
|