Skip to content

[OMEGA-423] Fix nginx proxy cutting off slow LLM responses (#321) - #351

Open
Leul-Negash wants to merge 13 commits into
singnet:mainfrom
Leul-Negash:fix/llm-proxy-timeout
Open

Leul-Negash wants to merge 13 commits into
singnet:mainfrom
Leul-Negash:fix/llm-proxy-timeout

Conversation

@Leul-Negash

@Leul-Negash Leul-Negash commented Sep 15, 2026 •

Copy link
Copy Markdown

Description

Fixes #321.

In the Docker image every LLM request goes through the nginx proxy. The LLM routes use the global 120s proxy_read_timeout/proxy_send_timeout, so a response that takes longer comes back as 504. The OpenAI client retries twice, gets 504 again, and AIProvider.chat returns "", so the agent never replies. That matches the two 504s two minutes apart in the #321 logs.

This sets 600s on the six LLM routes (anthropic, asicloud, openai, asione, openaiapi, openrouter), which is how long the OpenAI client waits and the same override the OpenClaw route got in 27979ed. Channel routes keep the 120s default.

It also adds Autotests/unit/test_nginx_llm_timeouts.py, which fails if an LLM route waits less than 600s, and registers it in run_mandatory.

How Has This Been Tested?

Ran the image with the OpenAIAPI provider pointed at a local OpenAI-compatible endpoint that answers after 150s, on the test channel:

  • main: nginx logged upstream timed out and returned 504 at 120s, three times, then Exception while communicating with LLM: 504 Gateway Time-out and nothing was sent.
  • This branch: the proxy returned 200 after 150s with no retries, and the agent's reply reached the channel.

The new test fails on main (12 failed) and passes here (12 passed). Autotests/unit 73 passed, tests/ 65 passed, and nginx -t passes on the rendered template inside the image.

I couldn't test against ASICloud directly. @olegryabchikov-dot, could you retest the Dune prompt on this branch when you have a chance?

Checklist

  • PR contains autogenerated code
  • Self-review completed
  • Test scenarios above are passed with the version of the code from PR

Every LLM request in the Docker image goes through the nginx proxy, and the
LLM routes used the global 120s proxy_read_timeout and proxy_send_timeout.
When a response takes longer, nginx returns 504, the OpenAI client retries
twice with the same result, and AIProvider.chat ends up returning "", so the
agent never answers.

The OpenAI client waits up to 600s, so set 600s on the anthropic, asicloud,
openai, asione, openaiapi and openrouter routes, the same override the
OpenClaw route already has. The channel routes keep the 120s default.

Add a unit test that reads the template and fails when an LLM route waits
less than 600s, and register it in run_mandatory.

@timur-ashkenov timur-ashkenov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leul-Negash Could we handle the final provider timeout explicitly?

After the configured timeout and exhausted retries, the agent should stop waiting and send the user a clear status message, such as: “LLM request timed out. Please try again later.” Returning an empty LLM result leaves the task silently abandoned.

@paul-v-snet paul-v-snet changed the title Fix nginx proxy cutting off slow LLM responses (#321) [OMEGA-423] Fix nginx proxy cutting off slow LLM responses (#321) Sep 23, 2026
After a provider request runs out of time and the client's retries are gone,
chat() returned an empty string. The loop then had no command to run, so the
turn ended without a word and the task looked abandoned, which is the symptom
reported in #321.

A timeout now comes back as a (send ...) command carrying a short status
message, the same shape the token-limit case already uses, so the loop delivers
it on the channel the user is on. It covers the client's own timeout and the
408/504/524 statuses a gateway returns when the upstream did not answer in
time; every other failure still returns an empty string, as before.

Add unit tests for which failures count as a timeout, what chat() returns in
each case, and that the message parses into a single send command.
The unit tier runs on the CI runner with plain pytest, where openai and httpx
are not installed, so importing them collected as an error and the job failed.
Stub openai and the configuration module before loading lib_llm_ext, the same
way test_openclaw_unit.py does, and drop the httpx import.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Done in 4f9f90e.

A timed-out request now returns a (send ...) command with a status message instead of an empty string, so the loop delivers it and the turn no longer ends in silence. Same shape #337 uses for a token-limit reply. It covers the client's own timeout and the 408/504/524 gateway statuses; other failures still return an empty string.

Verified in the image with the provider pointed at an endpoint answering 504 immediately: after the retries the agent runs ((send "LLM request timed out. ...")) and the message arrives on the channel. Over 40 failed requests it was produced 27 times and delivered once, since send in channels.metta drops a message identical to the last one.

Added Autotests/unit/test_llm_timeout_message.py (13 tests) and merged main in to clear the conflict. CI is green.

One check: by "stop waiting" did you mean the total wait as well? Right now the route timeout plus two client retries can take up to ~30 minutes before the message goes out. I can shorten the client timeout or reduce the retries if you want that capped.

@timur-ashkenov

Copy link
Copy Markdown
Collaborator

@timur-ashkenov Done in 4f9f90e.

A timed-out request now returns a (send ...) command with a status message instead of an empty string, so the loop delivers it and the turn no longer ends in silence. Same shape #337 uses for a token-limit reply. It covers the client's own timeout and the 408/504/524 gateway statuses; other failures still return an empty string.

Verified in the image with the provider pointed at an endpoint answering 504 immediately: after the retries the agent runs ((send "LLM request timed out. ...")) and the message arrives on the channel. Over 40 failed requests it was produced 27 times and delivered once, since send in channels.metta drops a message identical to the last one.

Added Autotests/unit/test_llm_timeout_message.py (13 tests) and merged main in to clear the conflict. CI is green.

One check: by "stop waiting" did you mean the total wait as well? Right now the route timeout plus two client retries can take up to ~30 minutes before the message goes out. I can shorten the client timeout or reduce the retries if you want that capped.

@Leul-Negash , Yes, I meant the total wait should be bounded as well. Waiting up to ~30 minutes before notifying the user would still reproduce the poor user experience from the issue.

I suggest keeping the 600-second timeout for one legitimately slow response, but avoiding additional retries after a timeout/504. The agent should send the timeout status once that first request reaches its limit.

The client retried a timed-out request twice before raising, so with the 600s
route timeout a user could wait about half an hour before the status message
went out. Build the chat clients with max_retries=0, so one request runs to its
timeout and the message goes out then. The loop asks again on its next
iteration anyway. Embeddings are untouched, they build their own clients in
src/rag.py.

Add tests that both the proxy and the direct client are built with a single
attempt.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Understood, bounded in ed1ab86.

The chat clients are now built with max_retries=0, so one request runs to the 600s timeout and the status message goes out as soon as it gives up, instead of after three attempts. The loop asks again on its next iteration anyway. Embeddings are untouched, they build their own clients in src/rag.py.

Verified against an endpoint answering 504 immediately: no retries logged, one request per cycle, and the message reaches the channel a second after the request fails. Two more tests check both the proxy and the direct client are built with a single attempt.

@timur-ashkenov

Copy link
Copy Markdown
Collaborator

@timur-ashkenov Understood, bounded in ed1ab86.

The chat clients are now built with max_retries=0, so one request runs to the 600s timeout and the status message goes out as soon as it gives up, instead of after three attempts. The loop asks again on its next iteration anyway. Embeddings are untouched, they build their own clients in src/rag.py.

Verified against an endpoint answering 504 immediately: no retries logged, one request per cycle, and the message reaches the channel a second after the request fails. Two more tests check both the proxy and the direct client are built with a single attempt.

@Leul-Negash Could we confirm that disabling retries globally is intentional?

max_retries=0 applies to every OpenAI-compatible provider request, not only timeout/504 cases. It also disables the SDK's default recovery for transient connection errors, 408, 409, 429, and 5xx responses.

I agree that the total wait must be bounded, but this changes retry behavior across all providers. Could we either document and explicitly accept this trade-off, or use a retry policy that preserves limited retries for non-timeout transient failures?

Turning the SDK retries off bounded the wait, but it also removed their recovery
for transient failures on every provider. The SDK cannot express the split: it
decides status retries in _should_retry, yet repeats a timed-out request
unconditionally.

So the decision moves into _retrying(). Transient failures (409, 429, 500, 502,
503 and connection errors) get up to three attempts with a short backoff. A
timeout, or a gateway timeout status, is never retried: it already spent the
request timeout and the user is waiting. Nothing is retried once a 60-second
budget is gone, so a failure that already cost minutes does not restart the
wait. All three chat implementations go through it. Embeddings keep the client
defaults, they build their own clients in src/rag.py.

Add tests for the classification, the attempt limit, the budget, and that a
timeout is attempted once.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Fair point, that was too broad. Fixed in 7bd71f8.

Retries are back for transient failures and skipped only for timeouts. The SDK cannot split it that way: status retries go through _should_retry, but a timed-out request is repeated regardless. So the decision now sits in one helper, _retrying(). 409, 429, 500, 502, 503 and connection errors get up to three attempts with a short backoff. A timeout or 504 is not retried, since it already spent the request timeout. Nothing is retried once a 60s budget is gone, so a slow failure cannot restart the wait. Embeddings keep the SDK defaults, they build their own clients.

Checked in the image: the endpoint answering 504 gives one attempt per cycle and the status message on the channel, the one answering 503 gives three attempts with the backoff and no status message. Tests cover the classification, the attempt limit, the budget, and that a timeout is attempted once. CI is green.

@TossSky

TossSky commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Tested: images built from this PR at 7bd71f8 and from main 6657798, each started by scripts/omega with -p OpenAIAPI -t test. The /openaiapi/ route led to a local OpenAI-compatible stub that delays or fails each request on demand, and the replies were read on the test channel.

What I checked

  • A reply delayed by 150 s arrives after 151 s, in a single request. On main, nginx cuts each of three attempts at 120 s with a 504, and the user gets nothing.
  • A reply delayed by 700 s: after 600 s, the user gets "LLM request timed out. Please try again later.", in a single request.
  • Two 503s in a row, or two dropped connections, are retried, and the reply arrives within 2.5 s, on main and with the PR.
  • All 42 tests in test_nginx_llm_timeouts.py and test_llm_timeout_message.py pass in CI, and Phase 1 goes from 151 tests on main to 193.

529 and Retry-After now end in silence

A 529 followed by a normal answer: main retries and replies in 1.5 s. With the PR, the stub gets one request, the agent logs Error code: 529, and nothing reaches the user.

A 429 with Retry-After: 5 followed by a normal answer: main waits 5 s and replies at 6 s. With the PR, the agent makes three attempts within 1.5 s, all of them get 429, and nothing reaches the user.

Both come from TRANSIENT_STATUSES, which stops at 503, and from the fixed 0.5 s and 1 s sleeps, which do not read Retry-After. The comment above the list says it holds the statuses the SDK retries by default, but on main the SDK also retries 529 and 522.

A second timeout in a row is dropped

The timeout message is the same text every time, and send drops a message equal to the last one it sent. Right after the 700 s case, the next message hit a 504. At 05:14:17, the agent logged RESPONSE: ((send "LLM request timed out. …")) again, and nothing reached the channel in 95 s. A 504 that came after a normal reply, instead of right after another timeout, was delivered in 1 s.

A smaller issue: the message says "the retries were exhausted", while a timeout gets one request.

Verdict: FAIL
@Leul-Negash @timur-ashkenov

Retry classification followed a hand-written status list that stopped at 503, so
529 and 522 were reported as failures instead of retried. It now follows the
same rule as the SDK, 409, 429 and any 5xx, with the timeout statuses left to
_is_timeout_error so they are reported rather than retried.

The wait between attempts ignored Retry-After, so a 429 asking for five seconds
got three attempts inside 1.5s and then silence. _retry_delay uses Retry-After
when the provider sends one in seconds, falls back to the backoff for the
HTTP-date form, and refuses a retry whose wait would not fit in the budget.

The timeout notice was the same text every time, and send drops a message equal
to the last one it sent, so a second timeout in a row left that turn silent.
The notice now carries the time.

Classifying an error no longer reads the exception classes off the client module
directly. With a client that does not define them the lookup raised inside the
except block and masked the error being classified.

Also drop the claim that the retries were exhausted, since a timeout is
attempted once.

Add tests for the 5xx rule, Retry-After in seconds and as a date, a wait that
exceeds the budget, and two notices in a row differing.
@Leul-Negash

Copy link
Copy Markdown
Author

@TossSky Thanks, all four are fixed in 1004c10.

529 and 522: the status list was mine and it stopped at 503. Classification now follows the same rule as the SDK, 409, 429 and any 5xx, with 408, 504 and 524 left to the timeout path so they are reported instead of retried.

Retry-After: the sleeps were fixed at 0.5s and 1s. _retry_delay now uses Retry-After when the provider sends seconds, falls back to the backoff for the HTTP-date form, and skips the retry when that wait would not fit in the 60s budget.

Second timeout in a row: right, the text was identical and send drops a repeat. The notice now carries the time, so consecutive ones differ. I also dropped the "retries were exhausted" wording, since a timeout gets one attempt.

test_llm_budget caught something too: I was reading the exception classes off the openai module inside the except block, so with a stub that does not define them the lookup raised and masked the real error. That lookup is safe now.

Rechecked in the image with a stub that scripts the answers: 529 then a normal answer is retried after 0.5s and the reply arrives; 429 with Retry-After: 5 is retried at 5.0s and the reply arrives; repeated 504 gives one request per cycle, no retries, and six notices in a row all reached the channel with different times. Tests: 38 in test_llm_timeout_message.py, 126 in the unit tier. CI is green.

One thing worth flagging: with a gateway that answers 504 instantly the user gets a notice every cycle. In production a 504 means the proxy already waited 600s, so they are spaced out, and I left it uncapped rather than adding suppression back. Happy to add a minimum interval if you would rather have one.

Comment thread providers/lib_llm_ext.py Outdated
sent, so without it a second timeout in a row would leave that turn silent,
which is the symptom this whole change is about.
"""
message = LLM_TIMEOUT_MESSAGE.format(time=time.strftime("%H:%M:%S"))

@timur-ashkenov timur-ashkenov Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leul-Negash The timestamp is only precise to one second, so two immediate timeout/504 cycles within the same second still produce identical (send ...) commands. Since send drops a message equal to the previous one, the second user message can again be silently lost. Please use a per-notification unique value (for example, a monotonic nonce or sub-second timestamp).

@Leul-Negash Leul-Negash Sep 28, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timur-ashkenov Good catch, fixed in 76ef473.

The notice now carries the time to the millisecond, and the admin line carries a count that rises with every notice, so two of them differ even when the clock does not move between them. A test freezes the clock and checks that two notices in a row are still different; while checking by hand I hit the case for real, two notices inside the same millisecond, and they differ by the count.

Unit tests are at 39 in test_llm_timeout_message.py and 127 in the unit tier. CI is green.

The notice carried the time to the second, so two cycles failing inside the same
second rendered the same message, and send drops a message equal to the last one
it sent: that turn went silent again.

The notice now carries the time to the millisecond and a count that rises with
every notice, so two of them differ even when the clock does not move. The test
freezes the clock and checks exactly that.
@TossSky

TossSky commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Tested: image built from this PR at 76ef473, started by scripts/omega with -p OpenAIAPI -t test. The /openaiapi/ route pointed at a stub that delays or fails requests on demand, and in one run at an address that never answers.

What I checked

  • A 529 or a 522 followed by a normal answer is retried, and the reply arrives.
  • A 429 with Retry-After: 5 followed by a normal answer is retried after 5 s, and the reply arrives.
  • Timeouts in a row each reach the channel, and the notice no longer says the retries were exhausted.
  • An answer delayed by 150 s arrives, and one delayed by 700 s gets a timeout notice after 600 s.
  • All 217 Phase 1 tests pass in CI.

A notice is sent on every timed-out cycle, even with no new message

With the default config, the loop calls the model up to 50 times after startup and up to 50 times again after each new message. Every call that times out sends its own notice. With an instant 504 on every request, the channel got 50 notices in 50 s before any message was sent, and 50 more after one message. With a provider address that never answers, nginx gives up connecting after 30 s and returns 504, and a notice arrived every 31 s with no message sent. The minimum interval you offered would only space these out. Timeouts in a row need one notice, and nothing more until a call succeeds or a new message comes in.

Raising the route timeout above 600 s would not help

The notice suggests raising the route timeout in the proxy, but the SDK client is created without a timeout and keeps its default 600 s read timeout, so a request would still end at 600 s.

Verdict: FAIL
@Leul-Negash @timur-ashkenov

The loop calls the provider up to maxNewInputLoops times after startup and again
after each message, and every timed-out call sent its own notice: with an instant
504 that put 50 notices on the channel in 50 seconds.

A provider now reports a timeout once per run of them. The run ends when a call
succeeds, or when a prompt carries a human message, since that turn is waiting
for an answer of its own. In between the timeout is logged and nothing is sent.

The notice also suggested raising the route timeout in the proxy, but the client
kept its own 600 s default, so the request ended there regardless. The client is
now created with that timeout explicitly, and the notice names the number and
says both limits have to be raised to allow longer answers.
Comment thread providers/lib_llm_ext.py
Comment on lines 1 to 362
@@ -21,6 +22,43 @@
"reasoning levels need a higher token limit."
)

LLM_TIMEOUT_MESSAGE = (
"LLM request timed out at {time}. Please try again later."
"\n\n"
"If you are the Omega administrator: the provider did not answer within the "
"{timeout} s request timeout, and a timed-out request is not retried. This is "
"timeout notice {count} since the agent started. The failed request is in the "
"agent log; check the provider status. The limit is enforced by the client and "
"by the provider\'s route in the proxy, so allowing longer answers means "
"raising both."
)

# Statuses a gateway returns when the upstream did not answer in time.
GATEWAY_TIMEOUT_STATUSES = (408, 504, 524)

# The request timeout the client enforces. It matches the proxy route timeout, so
# a slow answer is cut once, by whichever limit is reached first, and the notice
# can name a single number.
CHAT_REQUEST_TIMEOUT_SECONDS = 600

# One attempt per chat request inside the SDK. Its retry loop repeats a timed-out
# request unconditionally, which would multiply the request timeout before the
# user hears anything. Transient failures are retried by _retrying() below
# instead, where a timeout can be excluded.
CHAT_MAX_RETRIES = 0

# Failures worth trying again right away. The SDK retries 409, 429 and any 5xx,
# so keep that rule rather than a list that misses one (529 and 522 both reach
# here); the timeout statuses are excluded by _is_timeout_error above, so they are
# reported instead of retried.
TRANSIENT_STATUSES = (409, 429)
# First attempt plus two retries, and only while the whole call stays inside the
# budget: a failure that already cost minutes is not "transient", and the user is
# waiting for an answer.
CHAT_ATTEMPTS = 3
CHAT_RETRY_BUDGET_SECONDS = 60
CHAT_RETRY_BACKOFF_SECONDS = 0.5


logger = get_logger(__name__)

@@ -67,6 +105,87 @@ def _llm_empty_response_command() -> str:
"""
return f"(send {quote_arg(LLM_EMPTY_RESPONSE_MESSAGE)})"

def _is_timeout_error(error: BaseException) -> bool:
"""True when the request ran out of time rather than failing outright: the
client's own timeout, or a timeout status from the gateway in front of the
provider (the proxy answers 504 when the upstream is still thinking).
"""
# The classes are looked up rather than referenced: classifying a failure must
# never raise one of its own, whatever the installed client exposes.
if isinstance(error, getattr(openai, "APITimeoutError", ())):
return True
return getattr(error, "status_code", None) in GATEWAY_TIMEOUT_STATUSES

_timeout_notices = 0

def _llm_timeout_command() -> str:
"""Return a status message as a MeTTa `send` command when the request times
out, so the turn ends with the user told instead of in silence.

`send` drops a message equal to the last one it sent, so two notices must
never render the same: a second timeout would leave that turn silent, which is
the symptom this whole change is about. The time alone does not guarantee it,
since two cycles can fail inside the same second, so the notice carries the
time to the millisecond and a count that rises with every notice.
"""
global _timeout_notices
_timeout_notices += 1
stamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
message = LLM_TIMEOUT_MESSAGE.format(
time=stamp, count=_timeout_notices, timeout=CHAT_REQUEST_TIMEOUT_SECONDS)
return f"(send {quote_arg(message)})"

def _is_transient_error(error: BaseException) -> bool:
"""True for a failure that another attempt may get past. A timeout is not
one of them: it already spent the request timeout, so retrying it only keeps
the user waiting.
"""
if _is_timeout_error(error):
return False
if isinstance(error, getattr(openai, "APIConnectionError", ())):
return True
status = getattr(error, "status_code", None)
if status is None:
return False
return status in TRANSIENT_STATUSES or status >= 500

def _retry_delay(error: BaseException, attempt: int) -> float:
"""How long to wait before the next attempt: the provider's Retry-After when
it sends one in seconds, otherwise a short exponential backoff. A Retry-After
given as an HTTP date falls back to the backoff.
"""
headers = getattr(getattr(error, "response", None), "headers", None)
value = headers.get("retry-after") if hasattr(headers, "get") else None
if value is not None:
try:
return max(0.0, float(value))
except (TypeError, ValueError):
pass
return CHAT_RETRY_BACKOFF_SECONDS * (2 ** (attempt - 1))

def _retrying(call, provider: str):
"""Run call(), retrying only transient failures and only briefly.

The SDK's own retries are off (CHAT_MAX_RETRIES), so this is the single place
that decides what gets another attempt: transient failures do, a timeout does
not, and nothing is retried once the budget is spent.
"""
started = time.monotonic()
for attempt in range(1, CHAT_ATTEMPTS + 1):
try:
return call()
except Exception as error:
if attempt == CHAT_ATTEMPTS or not _is_transient_error(error):
raise
delay = _retry_delay(error, attempt)
if time.monotonic() - started + delay >= CHAT_RETRY_BUDGET_SECONDS:
logger.warning(
f"[{provider}.chat]: retry budget spent, giving up: {error}")
raise
logger.warning(
f"[{provider}.chat]: transient failure, retrying in {delay:.1f}s: {error}")
time.sleep(delay)

def _split_system_user(content: str) -> Tuple[str, str]:
"""
MeTTa sends:
@@ -129,6 +248,39 @@ def __init__(self, name: str, var_name: str, model_name: str, base_url: str):
self._model_name = model_name
self._base_url = base_url
self._client = None # lazy initialization
self._timeout_notice_sent = False

def _start_of_turn(self, content: str) -> None:
"""A prompt carrying a human message starts a fresh turn, which deserves
its own answer even if the previous cycle already timed out.

The tail is read straight from the prompt rather than through
_split_system_user, which substitutes a placeholder when it is empty. The
loop fills the tail only on the cycle where the message is new, so any
tail here means a turn of its own.
"""
_, delimiter, tail = content.partition(PROMPT_DELIMITER)
if (tail if delimiter else content).strip():
self._timeout_notice_sent = False

def _answered(self) -> None:
"""The provider answered, so the next timeout is a new run."""
self._timeout_notice_sent = False

def _timeout_reply(self) -> str:
"""The notice for a timed-out request, once per run of timeouts.

The loop keeps calling for up to maxNewInputLoops cycles, so a notice per
cycle would fill the chat with the same text. The run ends when a call
succeeds or a new human message arrives; until then the timeout is logged
and nothing is sent.
"""
if self._timeout_notice_sent:
logger.warning(
f"[{self.name}.chat]: timed out again, the user was already told")
return ""
self._timeout_notice_sent = True
return _llm_timeout_command()

def _ensure_client(self):
"""Initialize client on first use."""
@@ -145,9 +297,13 @@ def _create_client(self) -> Optional[openai.OpenAI]:
return openai.OpenAI(
api_key="proxy",
base_url=base_url,
max_retries=CHAT_MAX_RETRIES,
timeout=CHAT_REQUEST_TIMEOUT_SECONDS,
)
if self._var_name in os.environ:
return openai.OpenAI(api_key=os.environ.get(self._var_name), base_url=self._base_url)
return openai.OpenAI(api_key=os.environ.get(self._var_name), base_url=self._base_url,
max_retries=CHAT_MAX_RETRIES,
timeout=CHAT_REQUEST_TIMEOUT_SECONDS)

return None

@@ -169,19 +325,24 @@ def _build_messages(self, content: str):

def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str:
"""Send chat request, initializing client if needed."""
self._start_of_turn(content)
self._ensure_client()

if self._client is None:
raise RuntimeError(f"{self.name} not configured (set {self._var_name})")

try:
response = self._client.chat.completions.create(
model=self._model_name,
messages=self._build_messages(content),
max_tokens=max_tokens,
**kwargs
response = _retrying(
lambda: self._client.chat.completions.create(
model=self._model_name,
messages=self._build_messages(content),
max_tokens=max_tokens,
**kwargs
),
self._name,
)

self._answered()
raw = response.choices[0].message.content or ""
finish_reason = getattr(response.choices[0], "finish_reason", None)
_log_raw(self._name, self._model_name, raw)
@@ -194,6 +355,8 @@ def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium",
return resp
except Exception as e:
logger.exception(f"[AIProvider.chat]: Exception while communicating with LLM: {e}")
if _is_timeout_error(e):
return self._timeout_reply()
return ""

def _clean_text(self, text: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leul-Negash _start_of_turn() treats every non-empty prompt tail as a new human message. With the still-supported spamShield: True, the loop sends DO NOT RE-SEND OR SPAM! on every follow-up cycle, resets _timeout_notice_sent, and resumes one timeout notice per cycle. Please reset only when the tail is an actual HUMAN-MSG: payload.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timur-ashkenov Fixed in 84a4520.

The reset now requires the loop's HUMAN-MSG: tag, so the spamShield reminder and an empty tail leave the run alone. Checked in the image with spamShield: True set in config.yaml and an endpoint answering 504 at once: 40 prompts carried the reminder, 21 failing cycles produced 2 notices and 19 suppressions, and the second notice went out a second after a real message arrived on the channel.

Two things I tightened on the way, in 4afcfa5 and 7d72e81: the OpenRouter client overrides _create_client, so it was the one client still on the library default while the notice named 600 s, and the route test now reads CHAT_REQUEST_TIMEOUT_SECONDS from the provider source instead of repeating the number. A test also checks the marker against the tag loop.metta writes, so renaming it cannot quietly break the reset.

Unit tier is at 137.

_start_of_turn treated any non-empty prompt tail as a new message. With
spamShield on, the loop puts its reminder there on every follow-up cycle, which
reset the run and brought back one notice per cycle.

The reset now requires the loop's HUMAN-MSG: tag, however the tail wraps it, so
the reminder and the empty tail leave the run alone.

The OpenRouter client is created with the request timeout as well; it overrides
_create_client, so it was the one client still relying on the library default
while the notice named that limit.

Tests cover the reminder not starting a run, the tag starting one in each form
the tail can take, and the OpenRouter client carrying the timeout.
It overrides _create_client, so the limits it passes are worth a test of their
own rather than being assumed from the base provider.
Two values were written in more than one place and could drift apart without
anything noticing: the request timeout, which the proxy route has to match, and
the HUMAN-MSG: tag, which the notice reset keys off.

The route test now reads CHAT_REQUEST_TIMEOUT_SECONDS out of the provider source
instead of repeating the number, and a test checks the marker against the tag the
loop actually writes. The source is read rather than imported, since the provider
SDK is not installed where this suite runs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OMEGA-423] Bug 22: Bot does not respond to user messages after processing a complex multi-step task.

3 participants