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:
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user