mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 00:32:22 +01:00
- Enforce vision probe SSRF safe local host validation, SafeProbeBackend TOCTOU elimination, and session owner authorization - Authorize session titling endpoints against registered owner configuration - Sanitize sensitive metadata logging from agent loop debug logs - Add async non-blocking execution for spec router tools and thinking decisions - Harden email cache invalidation fencing and thread-safe mutation - Add regression test suites for legacy browser authority, spec contracts, spec router request scoping, and task MCP delivery
196 lines
7.6 KiB
Python
196 lines
7.6 KiB
Python
"""Concurrent Spec decisions must remain bound to their own requests."""
|
|
import io
|
|
import json
|
|
import threading
|
|
import asyncio
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import pytest
|
|
|
|
from src import agent_loop, clean_agent_preview, spec_router
|
|
|
|
|
|
def test_concurrent_identical_requests_do_not_share_router_decisions(monkeypatch):
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
|
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
|
|
completed = threading.Barrier(2)
|
|
first_done = threading.Event()
|
|
|
|
def response(request, timeout):
|
|
if threading.current_thread().name.endswith('_0'):
|
|
choice, probability = 'answer_directly', .99
|
|
else:
|
|
assert first_done.wait(2)
|
|
choice, probability = 't1', .99
|
|
return io.BytesIO(json.dumps({'answers': {'tool': {
|
|
'choice': choice, 'probabilities': {choice: probability},
|
|
}}}).encode())
|
|
|
|
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
|
|
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
|
|
|
def request(owner):
|
|
spec_router.spec_route_tools('same private question', schemas, return_decision=True)
|
|
if owner == 'alice':
|
|
first_done.set()
|
|
completed.wait(timeout=2)
|
|
return clean_agent_preview.spec_direct_no_tools_p(
|
|
'same private question', 'http://fixture', 'fixture', owner, {})
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
alice = pool.submit(request, 'alice')
|
|
bob = pool.submit(request, 'bob')
|
|
assert alice.result() == .99
|
|
assert bob.result() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_async_sessions_keep_their_own_decisions(monkeypatch):
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
|
|
monkeypatch.setattr(agent_loop, '_configured_model_tool_surface_raw', lambda *a, **k: 'spec')
|
|
|
|
def response(request, timeout):
|
|
body = json.loads(request.data)
|
|
choice = 'answer_directly' if body['state']['user_message'] == 'alice' else 't1'
|
|
return io.BytesIO(json.dumps({'answers': {'tool': {
|
|
'choice': choice, 'probabilities': {choice: .99},
|
|
}}}).encode())
|
|
|
|
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', response)
|
|
ready = asyncio.Event()
|
|
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
|
|
|
async def session(owner):
|
|
spec_router.spec_route_tools(owner, schemas, return_decision=True)
|
|
if owner == 'alice':
|
|
await ready.wait()
|
|
else:
|
|
ready.set()
|
|
return clean_agent_preview.spec_direct_no_tools_p(owner, 'http://fixture', 'fixture', owner, {})
|
|
|
|
assert await asyncio.gather(session('alice'), session('bob')) == [.99, None]
|
|
|
|
|
|
@pytest.mark.parametrize('response', [
|
|
{'choice': 't0', 'probabilities': {'t0': .99}},
|
|
{'choice': 't1', 'probabilities': {'t1': .99, 't999': .8}},
|
|
{'choice': 't1', 'probabilities': {'t1': 1.5}},
|
|
])
|
|
def test_invalid_router_response_cannot_choose_a_tool(monkeypatch, response):
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
|
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
|
|
json.dumps({'answers': {'tool': response}}).encode()))
|
|
schemas = [{'function': {'name': 'manage_notes', 'parameters': {'properties': {}}}}]
|
|
picks, decided = spec_router.spec_route_tools('List notes', schemas, return_decision=True)
|
|
assert picks == set()
|
|
assert decided is False
|
|
|
|
|
|
def test_disabled_router_clears_previous_turn_decision(monkeypatch):
|
|
monkeypatch.setenv('ODYSSEUS_SPEC_ROUTER_URL', 'http://router.invalid')
|
|
monkeypatch.setattr(spec_router.urllib.request, 'urlopen', lambda *a, **k: io.BytesIO(
|
|
json.dumps({'answers': {'tool': {'choice': 'answer_directly',
|
|
'probabilities': {'answer_directly': .99}}}}).encode()))
|
|
spec_router.spec_route_tools('first request', [], return_decision=True)
|
|
assert spec_router.latest_decision()['choice'] == 'answer_directly'
|
|
monkeypatch.delenv('ODYSSEUS_SPEC_ROUTER_URL')
|
|
assert spec_router.spec_route_tools('second request', [], return_decision=True) == (set(), False)
|
|
assert spec_router.latest_decision() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_router_does_not_block_event_loop(monkeypatch):
|
|
"""When the spec router is stalled or retrying, the asyncio event loop remains
|
|
responsive and concurrent requests can run and complete without blocking."""
|
|
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
|
|
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "2.0")
|
|
|
|
stall_started = asyncio.Event()
|
|
unblock_router = asyncio.Event()
|
|
timeline = []
|
|
|
|
class _FakeAsyncResponse:
|
|
status_code = 200
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"answers": {
|
|
"tool": {
|
|
"choice": "t1",
|
|
"probabilities": {"t1": 0.95},
|
|
}
|
|
}
|
|
}
|
|
|
|
async def fake_async_post(client, url, json=None, headers=None, timeout=None):
|
|
timeline.append("router_post_start")
|
|
stall_started.set()
|
|
# Simulate network latency or waiting for router:
|
|
# Await unblock event so other tasks must run on the loop while this waits
|
|
await unblock_router.wait()
|
|
timeline.append("router_post_finish")
|
|
return _FakeAsyncResponse()
|
|
|
|
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", fake_async_post)
|
|
schemas = [{"function": {"name": "manage_notes", "parameters": {"properties": {}}}}]
|
|
|
|
async def stalled_routing_task():
|
|
timeline.append("task_a_start")
|
|
picks, decided = await spec_router.spec_route_tools_async(
|
|
"Find notes", schemas, return_decision=True
|
|
)
|
|
timeline.append("task_a_end")
|
|
dec = spec_router.latest_decision()
|
|
return picks, decided, dec
|
|
|
|
async def concurrent_responsive_task():
|
|
# Wait until the router has started waiting on the event loop
|
|
await stall_started.wait()
|
|
timeline.append("task_b_executed")
|
|
# Task B does work and finishes immediately while router is still stalled!
|
|
unblock_router.set()
|
|
return "task_b_done"
|
|
|
|
res_b, res_a = await asyncio.gather(
|
|
concurrent_responsive_task(),
|
|
stalled_routing_task(),
|
|
)
|
|
|
|
assert res_b == "task_b_done"
|
|
picks, decided, dec = res_a
|
|
assert picks == {"manage_notes"}
|
|
assert decided is True
|
|
assert dec is not None
|
|
assert dec.get("choice") == "t1"
|
|
# Verify task_b ran and finished while task_a was waiting on the post
|
|
assert timeline == [
|
|
"task_a_start",
|
|
"router_post_start",
|
|
"task_b_executed",
|
|
"router_post_finish",
|
|
"task_a_end",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_router_cancellation_propagates(monkeypatch):
|
|
"""When a request cancels, spec_route_tools_async cancels cleanly."""
|
|
monkeypatch.setenv("ODYSSEUS_SPEC_ROUTER_URL", "http://router.stall.invalid")
|
|
|
|
async def hanging_post(*a, **k):
|
|
await asyncio.sleep(10.0)
|
|
|
|
monkeypatch.setattr(spec_router.httpx.AsyncClient, "post", hanging_post)
|
|
task = asyncio.create_task(
|
|
spec_router.spec_route_tools_async("hello", [], return_decision=True)
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|