diff --git a/ga.py b/ga.py index e1ee909f2..17d58b106 100644 --- a/ga.py +++ b/ga.py @@ -483,7 +483,7 @@ def do_no_tool(self, args, response): if not response or (not content.strip() and not thinking.strip()): yield "[Warn] LLM returned an empty response. Retrying...\n" return self._retry_or_exit("[ERROR] Blank response, regenerate and tooluse") - if '[!!! 流异常中断' in content[-100:] or '!!!Error:' in content[50:][-100:] or (content.endswith('') and len(content) < 100): + if getattr(response, 'error', None) or (content.endswith('') and len(content) < 100): return self._retry_or_exit("[ERROR] Incomplete response. Regenerate and tooluse.") if 'max_tokens !!!]' in content[-100:]: return self._retry_or_exit("[ERROR] max_tokens limit reached. Use multi small steps to do it.") diff --git a/llmcore.py b/llmcore.py index 423503248..3cbc15280 100644 --- a/llmcore.py +++ b/llmcore.py @@ -131,11 +131,14 @@ def auto_make_url(base, path): if b.endswith(p): return b return f"{b}/{p}" if re.search(r'/v\d+(/|$)', b) else f"{b}/v1/{p}" +class LLMError(str): + """Internal diagnostic chunk, never inferred from model-generated text.""" + def _parse_claude_json(data): if data.get("stop_reason") == "refusal": - err = "[Error: Claude refusal]" + err = LLMError("[Error: Claude refusal]") yield err - return [{"type": "text", "text": err}] + return [{"type": "text", "text": err, "error": str(err)}] content_blocks = data.get("content", []) _record_usage(data.get("usage", {}), "messages") for b in content_blocks: @@ -205,11 +208,11 @@ def _parse_claude_sse(resp_lines): err = evt.get("error", {}) emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) _raise_if_retryable_overload(emsg) # 走 _stream_with_retry,避免落到 ga 应用层 - warn = f"\n\n!!!Error: SSE {emsg}"; break + warn = LLMError(f"\n\n!!!Error: SSE {emsg}"); break if not warn: - if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]" - elif stop_reason == "max_tokens": warn = "\n\n[!!! Response truncated: max_tokens !!!]" - elif stop_reason == "refusal": warn = "\n\n[Error: Claude refusal]" + if not got_message_stop and not stop_reason: warn = LLMError("\n\n[!!! 流异常中断,未收到完整响应 !!!]") + elif stop_reason == "max_tokens": warn = LLMError("\n\n[!!! Response truncated: max_tokens !!!]") + elif stop_reason == "refusal": warn = LLMError("\n\n[Error: Claude refusal]") if current_block: if current_block["type"] == "tool_use": try: current_block["input"] = json.loads(tool_json_buf) if tool_json_buf else {} @@ -281,7 +284,7 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"): err = evt.get("error", {}) emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) _raise_if_retryable_overload(emsg) - if emsg: content_text += f"!!!Error: {emsg}"; yield f"!!!Error: {emsg}" + if emsg: content_text += f"!!!Error: {emsg}"; yield LLMError(f"!!!Error: {emsg}") break elif etype == "response.completed": usage = evt.get("response", {}).get("usage", {}) @@ -303,7 +306,7 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"): err = ((evt.get("response") or {}).get("error") or {}) emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) _raise_if_retryable_overload(emsg) - if emsg: content_text += f"!!!Error: {emsg}"; yield f"!!!Error: {emsg}" + if emsg: content_text += f"!!!Error: {emsg}"; yield LLMError(f"!!!Error: {emsg}") break blocks = [] if reasoning_text: blocks.append({"type": "thinking", "thinking": reasoning_text}) @@ -404,7 +407,7 @@ def _parse_openai_json(data, api_mode="chat_completions"): err = data.get("error") or {} emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) _raise_if_retryable_overload(emsg) - if emsg: blocks.append({"type": "text", "text": f"!!!Error: {emsg}"}); yield f"!!!Error: {emsg}" + if emsg: blocks.append({"type": "text", "text": f"!!!Error: {emsg}"}); yield LLMError(f"!!!Error: {emsg}") elif status == "incomplete" and not any(b.get("type") == "text" for b in blocks): reason = ((data.get("incomplete_details") or {}).get("reason", "")) or "unknown" marker = f"[!!! output truncated: {reason}]" @@ -455,7 +458,7 @@ def _sleep(d): # interruptible sleep; True if aborted return _stopped() for attempt in range(sess.max_retries + 1): if _stopped(): return [] - streamed = False + streamed = False; error = None STATS.update(t_start=time.time(), t_ttft=None) if not sess.stream: STATS['t_ttft'] = STATS['t_start'] try: @@ -471,8 +474,8 @@ def _sleep(d): # interruptible sleep; True if aborted continue try: body = r.text.strip()[:500] except: body = "" - err = f"!!!Error: HTTP {r.status_code}" + (f" (retry-after > {cap:.0f}s)" if d is None and r.status_code in _RETRYABLE and attempt < sess.max_retries else "") + (f": {body}" if body else "") - yield err; return [{"type": "text", "text": err}] + err = LLMError(f"!!!Error: HTTP {r.status_code}" + (f" (retry-after > {cap:.0f}s)" if d is None and r.status_code in _RETRYABLE and attempt < sess.max_retries else "") + (f": {body}" if body else "")) + yield err; return [{"type": "text", "text": err, "error": str(err)}] gen = parse_fn(r) try: while True: @@ -480,24 +483,27 @@ def _sleep(d): # interruptible sleep; True if aborted STATS['t_end'] = time.time(); return [] chunk = next(gen) if chunk and STATS.get('t_ttft') is None: STATS['t_ttft'] = time.time() + if isinstance(chunk, LLMError): error = str(chunk) streamed = True; yield chunk except StopIteration as e: if not e.value and not streamed: raise requests.ConnectionError("empty response") STATS['t_end'] = time.time() STATS['tps'] = STATS.get('out', 0) / max(1e-9, STATS['t_end'] - max(STATS['t_ttft'] or 0, STATS['t_start'])) - return e.value or [] + blocks = e.value or [] + if error: blocks.append({"type": "text", "text": "", "error": error}) + return blocks except (requests.Timeout, requests.ConnectionError, requests.exceptions.ChunkedEncodingError) as e: - err = f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}" + err = LLMError(f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}") if getattr(sess, 'should_stop', None) and sess.should_stop(): return [] if attempt < sess.max_retries: d = _delay(None, attempt) print(f"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})") if _sleep(d): return [] continue - yield err; return [{"type": "text", "text": err}] + yield err; return [{"type": "text", "text": err, "error": str(err)}] except Exception as e: - err = f"\n\n[!!! 流异常中断 {type(e).__name__}: {e} !!!]" if streamed else f"!!!Error: {type(e).__name__}: {e}" - yield err; return [{"type": "text", "text": err}] + err = LLMError(f"\n\n[!!! 流异常中断 {type(e).__name__}: {e} !!!]" if streamed else f"!!!Error: {type(e).__name__}: {e}") + yield err; return [{"type": "text", "text": err, "error": str(err)}] def _openai_stream(sess, messages): model, api_mode = sess.model, sess.api_mode @@ -681,17 +687,20 @@ def _ask_gen(): self.history.append({"role": "user", "content": [{"type": "text", "text": prompt}]}) trim_messages_history(self.history, self) messages = self.make_messages(self.history) - content_blocks = None; content = '' + content_blocks = None; content = ''; error = None gen = self.raw_ask(messages) try: - while True: chunk = next(gen); content += chunk; yield chunk + while True: + chunk = next(gen) + if isinstance(chunk, LLMError): error = str(chunk) + content += chunk; yield chunk except StopIteration as e: content_blocks = e.value or [] if len(content_blocks) > 1: print(f"[DEBUG BaseSession.ask] content_blocks: {content_blocks}") for block in (content_blocks or []): if block.get('type', '') == 'tool_use': tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})} yield f'{json.dumps(tu, ensure_ascii=False)}' - if content.strip() and not content.startswith("!!!Error:"): self.history.append({"role": "assistant", "content": [{"type": "text", "text": content}]}) + if content.strip() and not error: self.history.append({"role": "assistant", "content": [{"type": "text", "text": content}]}) return _ask_gen() def _keep_claude_block(b): return not isinstance(b, dict) or b.get("type") != "thinking" or b.get("signature") @@ -839,12 +848,16 @@ def ask(self, msg): while True: yield next(gen) except StopIteration as e: content_blocks = e.value or [] if content_blocks and (_injected := _ensure_text_block(content_blocks)): yield _injected - if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get("text", "").startswith("!!!Error:")): + error = next((b["error"] for b in content_blocks if b.get("error")), None) + if content_blocks and not error: history_blocks = content_blocks if self.omit_thinking: history_blocks = [b for b in content_blocks if b.get("type") != "thinking"] self.history.append({"role": "assistant", "content": history_blocks}) text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"] content = "\n".join(text_parts).strip() + if error: + raw = "[" + ",\n".join(repr(b) for b in content_blocks) + "]" + return MockResponse("", content, [], raw, error=error) tool_calls = [MockToolCall(b["name"], b.get("input", {}), id=b.get("id", "")) for b in content_blocks if b.get("type") == "tool_use"] if not tool_calls: tool_calls, content = _parse_text_tool_calls(content) thinking_parts = [b["thinking"] for b in content_blocks if b.get("type") == "thinking"] @@ -856,7 +869,7 @@ def ask(self, msg): thinking = think_match.group(1).strip() content = re.sub(think_pattern, "", content, flags=re.DOTALL) raw = "[" + ",\n".join(repr(b) for b in content_blocks) + "]" - return MockResponse(thinking, content, tool_calls, raw) + return MockResponse(thinking, content, [] if error else tool_calls, raw, error=error) class NativeOAISession(NativeClaudeSession): native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)" @@ -884,7 +897,8 @@ def __init__(self, name, args, id=''): self.function = MockFunction(name, arg_str); self.id = id class MockResponse: - def __init__(self, thinking, content, tool_calls, raw, stop_reason='end_turn'): + def __init__(self, thinking, content, tool_calls, raw, stop_reason='end_turn', error=None): + self.error = error self.thinking = thinking; self.content = content self.tool_calls = tool_calls; self.raw = raw self.stop_reason = 'tool_use' if tool_calls else stop_reason @@ -914,10 +928,12 @@ def chat(self, messages, tools=None): print("Full prompt length:", len(full_prompt), 'chars') gen = self.backend.ask(full_prompt) _write_llm_log('Prompt', full_prompt, self.log_path) - raw_text = '' + raw_text = ''; error = None for chunk in gen: + if isinstance(chunk, LLMError): error = str(chunk) raw_text += chunk; yield chunk _write_llm_log('Response', raw_text, self.log_path, model=self.backend.model) + if error: return MockResponse("", raw_text, [], raw_text, error=error) return self._parse_mixed_response(raw_text) def _prepare_tool_instruction(self, tools): @@ -1119,7 +1135,7 @@ def _prepare(self, idx, messages): return messages if self._native else session.make_messages(messages) def raw_ask(self, messages): base, n = self._pick(), len(self._sessions) - test_error = lambda x: isinstance(x, str) and x.lstrip().startswith(('!!!Error:', '[Error:')) + test_error = lambda x: isinstance(x, LLMError) for attempt in range(self._retries + 1): idx = (base + attempt) % n session = self._sessions[idx] @@ -1132,10 +1148,10 @@ def raw_ask(self, messages): if not yielded and test_error(chunk): continue yield chunk; yielded = True except StopIteration as e: return_val = e.value or [] - is_err = test_error(last_chunk) + is_err = test_error(last_chunk) and not yielded if not is_err: if attempt > 0: self._cur_idx = idx; self._switched_at = time.time() - elif isinstance(last_chunk, str) and '[!!! 流异常中断' in last_chunk and n > 1: + elif test_error(last_chunk) and n > 1: self._cur_idx = (idx + 1) % n; self._switched_at = time.time() print(f'[MixinSession] Partial failure, next call → s{self._cur_idx} ({self.current.name})') return return_val diff --git a/tests/test_error_provenance.py b/tests/test_error_provenance.py new file mode 100644 index 000000000..e1e2efdeb --- /dev/null +++ b/tests/test_error_provenance.py @@ -0,0 +1,126 @@ +import json +import threading +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import llmcore as core +from agent_loop import exhaust + + +TEXT = "!!!Error: ConnectionError: empty response" + + +def stream(chunks, blocks): + yield from chunks + return blocks + + +class ErrorProvenanceTests(unittest.TestCase): + def transport(self, parser, response=None): + response = response or SimpleNamespace(status_code=200) + response.__enter__ = lambda: response + sess = SimpleNamespace(name="test", max_retries=0, stream=True, + connect_timeout=1, read_timeout=1, + proxies=None, verify=True) + with patch.object(core.requests, "post") as post: + post.return_value.__enter__.return_value = response + chunks = [] + gen = core._stream_with_retry(sess, "https://unused", {}, {}, parser) + try: + while True: + chunks.append(next(gen)) + except StopIteration as exc: + return chunks, exc.value + + def native(self, chunks, blocks, cls=core.NativeClaudeSession): + sess = cls.__new__(cls) + sess.lock = threading.Lock() + sess.history = [] + sess.omit_thinking = False + sess.raw_ask = lambda messages: stream(chunks, blocks) + with patch.object(core, "trim_messages_history"): + result = exhaust(sess.ask({"role": "user", "content": []})) + return result, sess.history + + def test_empty_transport_is_typed(self): + chunks, blocks = self.transport(lambda r: stream([], [])) + self.assertIsInstance(chunks[-1], core.LLMError) + self.assertEqual(str(chunks[-1]), TEXT) + self.assertEqual(blocks[-1]["error"], TEXT) + + def test_same_text_native_response_and_history(self): + for cls in (core.NativeClaudeSession, core.NativeOAISession): + for failed in (False, True): + with self.subTest(cls=cls, failed=failed): + chunk = core.LLMError(TEXT) if failed else TEXT + chunks, blocks = self.transport(lambda r: stream([chunk], [{"type": "text", "text": TEXT}])) + response, history = self.native(chunks, blocks, cls) + self.assertEqual(response.content, TEXT) + self.assertEqual(bool(response.error), failed) + self.assertEqual(len(history), 1 if failed else 2) + + def test_same_text_protocol_client_and_history(self): + for failed in (False, True): + with self.subTest(failed=failed): + sess = core.BaseSession.__new__(core.BaseSession) + sess.lock = threading.Lock() + sess.history = [] + sess.name = sess.model = "test" + sess.make_messages = lambda messages: messages + chunk = core.LLMError(TEXT) if failed else TEXT + sess.raw_ask = lambda messages: stream([chunk], [{"type": "text", "text": TEXT}]) + client = core.ToolClient(sess) + client._build_protocol_prompt = lambda messages, tools: "test" + with patch.object(core, "trim_messages_history"): + response = exhaust(client.chat([])) + self.assertEqual(response.content, TEXT) + self.assertEqual(bool(response.error), failed) + self.assertEqual(len(sess.history), 1 if failed else 2) + + def test_native_error_does_not_execute_partial_tool(self): + chunks, blocks = self.transport(lambda r: stream([core.LLMError(TEXT)], [ + {"type": "tool_use", "id": "t", "name": "danger", "input": {}}, + {"type": "text", "text": TEXT}])) + response, history = self.native(chunks, blocks) + self.assertTrue(response.error) + self.assertEqual(response.tool_calls, []) + self.assertEqual(len(history), 1) + + def test_mixin_only_falls_back_for_typed_errors(self): + for failed in (False, True): + with self.subTest(failed=failed): + first = core.LLMError(TEXT) if failed else TEXT + calls = [] + def raw(index, chunk): + calls.append(index) + yield chunk + return [{"type": "text", "text": str(chunk)}] + mix = core.MixinSession.__new__(core.MixinSession) + mix.__dict__.update(_sessions=[ + SimpleNamespace(name="a", raw_ask=lambda m: raw(0, first)), + SimpleNamespace(name="b", raw_ask=lambda m: raw(1, "OK"))], + _retries=1, _base_delay=0, _native=True, _cur_idx=0, _switched_at=0) + mix._pick = lambda: 0 + mix._prepare = lambda idx, messages: messages + with patch.object(core.time, "sleep"): + chunks = list(mix.raw_ask([])) + self.assertEqual(calls, [0, 1] if failed else [0]) + self.assertEqual(chunks, ["OK"] if failed else [TEXT]) + + def test_provider_error_events_are_typed(self): + cases = [ + lambda: core._parse_claude_sse([b'data: {"type":"error","error":{"message":"bad request"}}']), + lambda: core._parse_claude_sse([]), + lambda: core._parse_openai_sse([b'data: {"type":"error","error":{"message":"bad request"}}'], "responses"), + lambda: core._parse_openai_json({"status": "failed", "error": {"message": "bad request"}}, "responses"), + ] + for parser in cases: + with self.subTest(parser=parser): + chunks, blocks = self.transport(lambda r: parser()) + self.assertTrue(any(isinstance(c, core.LLMError) for c in chunks)) + self.assertTrue(any(b.get("error") for b in blocks)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_no_tool_retry.py b/tests/test_no_tool_retry.py new file mode 100644 index 000000000..0d3c74986 --- /dev/null +++ b/tests/test_no_tool_retry.py @@ -0,0 +1,46 @@ +import unittest +from types import SimpleNamespace + +from agent_loop import exhaust +from ga import GenericAgentHandler + + +class NoToolRetryTests(unittest.TestCase): + def setUp(self): + self.handler = GenericAgentHandler.__new__(GenericAgentHandler) + self.handler._in_plan_mode = lambda: False + + def respond(self, content, thinking="", error=None): + return exhaust(self.handler.do_no_tool({}, SimpleNamespace(content=content, thinking=thinking, error=error))) + + def test_short_error_retries(self): + outcome = self.respond("!!!Error: ConnectionError: empty response", error="transport") + self.assertIsNotNone(outcome.next_prompt) + self.assertFalse(outcome.should_exit) + + def test_error_prefix_retries_with_long_details(self): + self.assertIsNotNone(self.respond("!!!Error: ConnectionError: " + "x" * 150, error="transport").next_prompt) + + def test_error_retries_are_bounded(self): + for attempt in range(3): + outcome = self.respond("!!!Error: ConnectionError: empty response", error="transport") + self.assertEqual(outcome.should_exit, attempt == 2) + self.assertEqual(outcome.next_prompt is None, attempt == 2) + + def test_existing_retry_cases(self): + for content in ("", + "Working", "max_tokens !!!]"): + with self.subTest(content=content): + self.setUp() + self.assertIsNotNone(self.respond(content).next_prompt) + + def test_normal_and_quoted_responses_finish(self): + for content, thinking in (("!!!Error: ConnectionError: empty response", ""), ("x" * 60 + "!!!Error: disconnected", ""), ("Done.", ""), ("", "Reasoning only"), + ("The marker is '!!!Error:'.", "")): + with self.subTest(content=content): + self.assertIsNone(self.respond(content, thinking).next_prompt) + self.assertFalse(hasattr(self.handler, "_empty_ct")) + + +if __name__ == "__main__": + unittest.main()