diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 5297b105..2d72e15d 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1178,14 +1178,18 @@ async def run_browser_agent( browser_history.prune_stale_page_state(messages) browser_history.place_cache_marker(messages) p_llm_t0 = time.monotonic() - response = await p_cancellable(client.messages.create( - model=api_model, - max_tokens=4096, - # Cache the ~4k-token fixed prefix (system + tool schema) so it's reprocessed once, not on every turn: big TTFT + cost win on the first run, which is dominated by turns x per-turn prefill. The trailing cache_control marker is what Anthropic keys on; on non-Anthropic routes (9router) the marker is harmlessly ignored. - system=p_cached_system, - tools=p_cached_tools, - messages=messages, - )) + + # STREAM, don't .create(): 9Router returns non-Anthropic lanes (Gemini/OpenRouter/Antigravity) as a REAL multi-event SSE stream that the non-streaming client parses to empty content, silently breaking tool-use on every non-Claude provider (measured: only cc/ emitted tool_use before this). The streaming parser reconstructs tool_use identically for ALL providers, so this is the model-independence fix, not a UX tweak. Cache marker still rides p_cached_system (Anthropic keys on it; other routes ignore it harmlessly). + async def p_stream_turn(): + async with client.messages.stream( + model=api_model, + max_tokens=4096, + system=p_cached_system, + tools=p_cached_tools, + messages=messages, + ) as p_s: + return await p_s.get_final_message() + response = await p_cancellable(p_stream_turn()) if response is None: break p_llm_ms = int((time.monotonic() - p_llm_t0) * 1000) diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index df46d21c..eb6fcefe 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -30,17 +30,32 @@ class Resp: self.usage = type("U", (), {"input_tokens": 1, "output_tokens": 1})() +class FakeStream: + # mirrors anthropic's messages.stream(): async CM whose get_final_message() returns the turn + def __init__(self, resp): self.resp = resp + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + async def get_final_message(self): return self.resp + + class FakeLLM: def __init__(self, scripted): self.scripted = scripted; self.turn = 0; self.calls = [] self.messages = self - async def create(self, **kw): + def p_next(self, kw): self.calls.append(kw) i = min(self.turn, len(self.scripted) - 1) self.turn += 1 return self.scripted[i] + async def create(self, **kw): + return self.p_next(kw) + + def stream(self, **kw): + # the loop now streams; return an async-CM yielding the scripted turn + return FakeStream(self.p_next(kw)) + class FakeAux: def __init__(self):