Backend retries only transient failures, history cleaned of nameless tool calls, stale dir pins ignored, @molodetz routes to devplace/molodetz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:51:47 +02:00
co-authored by Claude Sonnet 5.5
parent 07f3b36e32
commit 1298a4fe5d
2 changed files with 79 additions and 5 deletions
+52 -2
View File
@@ -151,6 +151,7 @@ POLLINATIONS_BASE = "https://text.pollinations.ai/openai/v1"
POLLINATIONS_MODELS = ("openai",)
PROVIDER_LABELS = (MUSE_LABEL, GROK_LABEL, CLAUDE_LABEL, CODEX_LABEL, GEMINI_LABEL, OPENCODE_LABEL, OLLAMA_LABEL, OPENROUTER_LABEL, POLLINATIONS_LABEL, ZEN_LABEL, DEVPLACE_LABEL)
MENTION_AUTO = "auto"
MENTION_ROUTES = {"molodetz": (DEVPLACE_LABEL, PREFERRED_MODEL)}
DIR_DEFAULTS_CAP = 512
OPENCODE_USER_AGENT = "opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15"
OPENCODE_CLIENT_NAME = "cli"
@@ -384,6 +385,8 @@ def resolve_tag(tag, agent, directory):
return ("bot", target)
if lowered in PROVIDER_LABELS:
return ("provider", lowered)
if lowered in MENTION_ROUTES:
return ("route", MENTION_ROUTES[lowered])
for backend in agent.config.backends or []:
if ((backend or {}).get("label") or "").strip().lower() == lowered:
return ("provider", lowered)
@@ -1011,6 +1014,35 @@ def tool_name_clean(raw_name):
return str(raw_name or "").split("<|", 1)[0].strip()
TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$")
PERMANENT_STATUSES = (400, 401, 402, 403, 404, 405, 410, 413, 422)
def clean_history(messages):
"""Copy of messages that strict backends accept: tool calls with an invalid name
are dropped together with their results, and an assistant turn left empty goes."""
dropped = set()
kept = []
for message in messages:
calls = message.get("tool_calls") if message.get("role") == "assistant" else None
if calls:
good = [call for call in calls if TOOL_NAME_RE.match(str((call.get("function") or {}).get("name") or ""))]
dropped.update(call.get("id") for call in calls if call not in good)
if len(good) != len(calls):
message = dict(message, tool_calls=good or None)
if not good and not message.get("content"):
continue
kept.append(message)
return [message for message in kept if not (message.get("role") == "tool" and message.get("tool_call_id") in dropped)]
def transient_failure(exc):
"""True when asking again later can succeed: timeouts, rate limits, 5xx, network."""
if getattr(exc, "status", 0) in PERMANENT_STATUSES:
return False
return "CLI not found" not in str(exc)
def tool_args_text(raw_arguments):
if not raw_arguments:
return "{}"
@@ -5108,6 +5140,7 @@ class ChatClient:
self.scope = ("default", "main")
self.fixed_key = None
self.provider_pin = None
self.round_transient = False
self.pin_fallback_said = False
self.model_pin = None
self.prefer = (DEVPLACE_LABEL, PREFERRED_MODEL)
@@ -5258,6 +5291,9 @@ class ChatClient:
pinned = [cand for cand in pinned if (cand["provider"] or "").lower() == self.provider_pin]
if self.model_pin:
pinned = [cand for cand in pinned if cand["model"] == self.model_pin]
if not pinned and self.provider_pin == DEVPLACE_LABEL and self.model_pin and getattr(self.config, "devplace_key", ""):
base = self.config.devplace_base
pinned = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": self.model_pin, "profile": ""}]
if not pinned and cands and not self.pin_fallback_said:
self.pin_fallback_said = True
print(paint("%s: trying every other backend" % self.pin_note(), Ansi.DIM), file=sys.stderr)
@@ -5324,6 +5360,7 @@ class ChatClient:
return text[:width]
def complete(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None):
messages = clean_history(messages)
payload = {"model": self.config.model, "messages": messages}
if tools:
payload["tools"] = tools
@@ -5335,6 +5372,7 @@ class ChatClient:
round_no = 0
while True:
errors = []
self.round_transient = False
cands = self.ordered_candidates() if round_no == 0 else self.all_candidates()
if not cands:
raise BackendError(self.pin_note())
@@ -5342,6 +5380,8 @@ class ChatClient:
reply = self.try_candidate(cand, messages, tools, payload, stream_sink, ephemeral, errors)
if reply is not None:
return reply
if not self.round_transient:
raise BackendError("no backend can take this request, retrying will not help (%s)" % self.summarize_errors(errors, shown=6, width=900))
pause = RETRY_BACKOFF[min(round_no, len(RETRY_BACKOFF) - 1)]
round_no += 1
if give_up is not None and time.time() + pause > give_up:
@@ -5446,10 +5486,12 @@ class ChatClient:
if pos == 0 and exc.status == 400 and len(tries) > 1:
continue
self.health.record(label, model, False)
self.round_transient = self.round_transient or transient_failure(exc)
errors.append("%s/%s: %s" % (label, model, exc))
return None
except Exception as exc:
self.health.record(label, model, False)
self.round_transient = True
errors.append("%s/%s: %s" % (label, model, short_error(exc)))
return None
return None
@@ -15155,6 +15197,9 @@ class Agent:
if kind == "file":
files.append(value)
continue
if kind == "route":
selectors["provider"], selectors["model"] = value
continue
selectors[kind] = value
if cleared or selectors["provider"] is not None:
self.chat.provider_pin = selectors["provider"]
@@ -15254,6 +15299,7 @@ class Agent:
print(paint("(via %s)" % reply["backend"], Ansi.DIM))
if loud and reply["reasoning"] and not reply["content"] and not reply["tool_calls"]:
print(paint(reply["reasoning"][:2000], Ansi.GRAY))
reply["tool_calls"] = [call for call in reply["tool_calls"] if TOOL_NAME_RE.match(call["name"])]
history_calls = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}} for call in reply["tool_calls"]]
self.messages.append({"role": "assistant", "content": reply["content"] or None, "tool_calls": history_calls or None})
if not reply["tool_calls"]:
@@ -16670,8 +16716,12 @@ def apply_dir_defaults(agent, directory=None):
return ""
bits = []
if saved.get("provider"):
agent.chat.provider_pin = saved["provider"]
bits.append("provider=%s" % saved["provider"])
offered = set((cand["provider"] or "").lower() for cand in agent.chat.candidates())
if saved["provider"] in offered:
agent.chat.provider_pin = saved["provider"]
bits.append("provider=%s" % saved["provider"])
else:
print(paint("dir default provider '%s' is not available here, ignoring it (@auto clears it)" % saved["provider"], Ansi.DIM), file=sys.stderr)
if saved.get("model") and not getattr(agent.config, "explicit_model", ""):
agent.chat.model_pin = saved["model"]
bits.append("model=%s" % saved["model"])
+27 -3
View File
@@ -2305,6 +2305,7 @@ class MentionTests(unittest.TestCase):
self.assertEqual(tai.resolve_tag("opencode", self.agent, os.getcwd()), ("provider", "opencode"))
self.assertEqual(tai.resolve_tag("mimo-v2.6-flash-free", self.agent, os.getcwd()), ("model", "mimo-v2.6-flash-free"))
self.assertEqual(tai.resolve_tag("auto", self.agent, os.getcwd()), ("auto", "auto"))
self.assertEqual(tai.resolve_tag("molodetz", self.agent, os.getcwd()), ("route", ("devplace", "molodetz")))
kind, value = tai.resolve_tag("note.txt", self.agent, os.getcwd())
self.assertEqual(kind, "file")
self.assertTrue(value.endswith("note.txt"))
@@ -4734,10 +4735,11 @@ class RetrySummaryTests(unittest.TestCase):
calls.append(cand["provider"])
if len(calls) > len(cands):
return {"role": "assistant", "content": "ok", "reasoning": "", "tool_calls": [], "backend": cand["provider"]}
chat.round_transient = True
if cand["provider"] == "muse":
errors.append("muse/muse: muse CLI not found (MUSE_BIN or PATH)")
else:
errors.append("openrouter/m1: HTTP 401: No auth credentials found")
errors.append("openrouter/m1: HTTP 503: No auth credentials found")
return None
with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \
@@ -4753,7 +4755,7 @@ class RetrySummaryTests(unittest.TestCase):
os.environ.pop("TAI_HOME", None)
out = err.getvalue()
self.assertIn("all 2 candidates across 2 backends failed (round 1)", out)
self.assertIn("openrouter/m1: HTTP 401", out)
self.assertIn("openrouter/m1: HTTP 503", out)
self.assertNotIn("retrying in 2s: muse/muse", out)
def test_complete_give_up_error_summarizes(self):
@@ -4773,7 +4775,8 @@ class RetrySummaryTests(unittest.TestCase):
]
def fake_try(cand, messages, tools, payload, stream_sink, ephemeral, errors):
errors.append("%s/%s: %s" % (cand["provider"], cand["model"], "muse CLI not found (MUSE_BIN or PATH)" if cand["provider"] == "muse" else "HTTP 401: No auth"))
chat.round_transient = True
errors.append("%s/%s: %s" % (cand["provider"], cand["model"], "muse CLI not found (MUSE_BIN or PATH)" if cand["provider"] == "muse" else "HTTP 503: No auth"))
return None
with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \
@@ -4785,6 +4788,27 @@ class RetrySummaryTests(unittest.TestCase):
self.assertIn("after 1 rounds", str(raised.exception))
self.assertTrue(str(raised.exception).index("openrouter/m1") < str(raised.exception).index("missing CLI skipped"), str(raised.exception))
def test_permanent_failures_fail_fast(self):
self.assertFalse(tai.transient_failure(tai.BackendError("HTTP 400: bad name", 400)))
self.assertFalse(tai.transient_failure(tai.BackendError("muse CLI not found (MUSE_BIN or PATH)")))
self.assertTrue(tai.transient_failure(tai.BackendError("HTTP 503: down", 503)))
self.assertTrue(tai.transient_failure(tai.BackendError("timed out")))
def test_clean_history_drops_nameless_calls_and_results(self):
history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "a", "type": "function", "function": {"name": "", "arguments": "{}"}},
{"id": "b", "type": "function", "function": {"name": "shell", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "a", "content": "x"},
{"role": "tool", "tool_call_id": "b", "content": "y"},
]
cleaned = tai.clean_history(history)
self.assertEqual([m["role"] for m in cleaned], ["user", "assistant", "tool"])
self.assertEqual(len(cleaned[1]["tool_calls"]), 1)
self.assertEqual(cleaned[2]["tool_call_id"], "b")
self.assertEqual(len(history[1]["tool_calls"]), 2)
class TuiLayoutTests(unittest.TestCase):
def make_app(self, rows=10, cols=20):