mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
fix(gateway): deliver the first message when the deferred agent build outlives 30s (#63078)
Leg 2 of #63078: prompt.submit returns {"status":"streaming"} immediately and runs _start_agent_build + _wait_agent(timeout=30s) behind it. The deferred build (MCP discovery with per-server retry backoff, synchronous model-metadata HTTP, skills scanning) routinely outlives 30s on cold starts; on timeout run_after_agent_ready emitted an error EVENT and returned without ever calling _run_prompt_submit — the user's first message was permanently discarded while the build finished successfully in the background. The desktop's optimistic row eventually cleared with no visible error: the blank first session. New _wait_agent_for_prompt replaces the flat cliff for the deferred prompt path only (_sess()'s RPC-blocking _wait_agent keeps its 30s contract): - The pending prompt stays attached to the (already off-RPC) run thread and is delivered the moment the still-running build completes — a slow build is no longer message loss. - The wait runs in 5s slices so a cancel (session.interrupt / churn) is honored promptly; the cancelled path returns None and defers to the caller's cancel branch (the #65567 emit) for user-visible messaging. - Past 30s the client gets ONE keyed notification.show ('Still starting the agent…', key=agent-build-slow, desktop toast / TUI status bar), cleared on delivery — patient, never silent. - Permanent failure only when the build itself fails: agent_error set at ready, the build thread died without signalling ready (fail fast via the new _agent_build_thread handle instead of sitting out the cap on a corpse), or the bounded cap expired on a genuinely hung build. The cap defaults to 600s and is tunable via agent.build_wait_timeout in config.yaml (no new env vars); the error message states the message was not sent. Tests: slow-build delivery with zero error events; the keyed progress notice shown once and cleared; build-failure surfacing exactly one error event with the real reason; dead-thread fail-fast; cancel honored mid-wait; config override + fallback semantics; cap expiry message. The compute-host fallback test stubs the new waiter alongside _wait_agent.
This commit is contained in:
parent
b20b235206
commit
60c8fc6290
4 changed files with 471 additions and 2 deletions
|
|
@ -799,6 +799,14 @@ agent:
|
|||
# window on /restart, and keep it well under systemd's TimeoutStopSec.
|
||||
# restart_drain_timeout: 0
|
||||
|
||||
# Upper bound (seconds) a submitted prompt waits for the deferred agent
|
||||
# build (MCP discovery, model metadata, skills scan) before failing with a
|
||||
# visible error. The wait is patient — the message is delivered as soon as
|
||||
# the build completes, and a progress notice is shown past 30s — so this cap
|
||||
# only fires on a genuinely hung build. Raise it for deployments with many
|
||||
# slow or unreachable MCP servers. Default 600.
|
||||
# build_wait_timeout: 600
|
||||
|
||||
# Max app-level retry attempts for API errors (connection drops, provider
|
||||
# timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this
|
||||
# to 1 if you use fallback providers and want fast failover on flaky
|
||||
|
|
|
|||
|
|
@ -966,6 +966,14 @@ DEFAULT_CONFIG = {
|
|||
# Set a positive value in config.yaml only if you explicitly want a
|
||||
# grace window on /restart (and keep it well under TimeoutStopSec).
|
||||
"restart_drain_timeout": 0,
|
||||
# Upper bound (seconds) a submitted prompt waits for the deferred
|
||||
# agent build (MCP discovery, model metadata, skills scan) before
|
||||
# failing with a visible error (#63078). The gateway's wait is
|
||||
# patient — the prompt is delivered the moment the build completes
|
||||
# and a progress notice is emitted past 30s — so this cap only fires
|
||||
# on a genuinely hung build. Raise it for deployments with many slow
|
||||
# or unreachable MCP servers.
|
||||
"build_wait_timeout": 600,
|
||||
# Max app-level retry attempts for API errors (connection drops,
|
||||
# provider timeouts, 5xx, etc.) before the agent surfaces the
|
||||
# failure. The OpenAI SDK already does its own low-level retries
|
||||
|
|
|
|||
|
|
@ -293,6 +293,8 @@ def test_prompt_submit_fails_open_inline_when_compute_host_dispatch_breaks(monke
|
|||
monkeypatch.setattr(server, "_persist_branch_seed", lambda _session: None)
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: None)
|
||||
monkeypatch.setattr(server, "_wait_agent", lambda _session, _rid: None)
|
||||
# The deferred inline-fallback thread now waits via the patient variant.
|
||||
monkeypatch.setattr(server, "_wait_agent_for_prompt", lambda _session, _rid, _sid: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
|
|
@ -8101,6 +8103,329 @@ def test_session_not_running_before_agent_ready_emits_error_event(monkeypatch):
|
|||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_slow_agent_build_delivers_prompt_instead_of_timing_out(monkeypatch):
|
||||
"""#63078 server-side half: a deferred build slower than the old 30s
|
||||
``_wait_agent`` cliff must NOT eat the first message. The patient wait
|
||||
keeps the pending prompt attached and delivers it as soon as the
|
||||
still-running build completes."""
|
||||
threads = []
|
||||
emitted = []
|
||||
calls = {"run_prompt": 0}
|
||||
|
||||
class _FakeThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self.target = target
|
||||
threads.append(self)
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
ready = threading.Event()
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
server._sessions["sid"] = session
|
||||
|
||||
# The build "completes" only after the wait loop has already gone through
|
||||
# several empty slices — i.e. well past what a single fixed-timeout wait
|
||||
# slice would tolerate.
|
||||
slices = {"n": 0}
|
||||
|
||||
class _SlowReady:
|
||||
def wait(self, timeout=None):
|
||||
slices["n"] += 1
|
||||
if slices["n"] >= 3:
|
||||
ready.set()
|
||||
session["agent"] = types.SimpleNamespace()
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_set(self):
|
||||
return ready.is_set()
|
||||
|
||||
session["agent_ready"] = _SlowReady()
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server.threading, "Thread", _FakeThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
|
||||
monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
|
||||
monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda *args, **kwargs: calls.__setitem__(
|
||||
"run_prompt", calls["run_prompt"] + 1
|
||||
),
|
||||
)
|
||||
|
||||
submit = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "first message"},
|
||||
}
|
||||
)
|
||||
assert submit.get("result"), f"got error: {submit.get('error')}"
|
||||
|
||||
threads[0].target()
|
||||
|
||||
# The message was DELIVERED, not dropped, and no error event fired.
|
||||
assert calls["run_prompt"] == 1
|
||||
error_events = [e for e in emitted if e and e[0] == "error"]
|
||||
assert not error_events, f"unexpected error events: {error_events}"
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_slow_agent_build_emits_keyed_progress_notice(monkeypatch):
|
||||
"""Past the slow threshold the patient wait must tell the user once
|
||||
(keyed notification.show) and clear the notice when the build lands —
|
||||
a long wait is acceptable, a silent one is not."""
|
||||
threads = []
|
||||
emitted = []
|
||||
calls = {"run_prompt": 0}
|
||||
|
||||
class _FakeThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self.target = target
|
||||
threads.append(self)
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
ready = threading.Event()
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
server._sessions["sid"] = session
|
||||
|
||||
slices = {"n": 0}
|
||||
|
||||
class _SlowReady:
|
||||
def wait(self, timeout=None):
|
||||
slices["n"] += 1
|
||||
if slices["n"] >= 3:
|
||||
ready.set()
|
||||
session["agent"] = types.SimpleNamespace()
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_set(self):
|
||||
return ready.is_set()
|
||||
|
||||
session["agent_ready"] = _SlowReady()
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server.threading, "Thread", _FakeThread)
|
||||
# Every wait slice lands past the slow threshold.
|
||||
monkeypatch.setattr(server, "_AGENT_BUILD_SLOW_NOTICE_AFTER", 0.0)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
|
||||
monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
|
||||
monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda *args, **kwargs: calls.__setitem__(
|
||||
"run_prompt", calls["run_prompt"] + 1
|
||||
),
|
||||
)
|
||||
|
||||
submit = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "first message"},
|
||||
}
|
||||
)
|
||||
assert submit.get("result"), f"got error: {submit.get('error')}"
|
||||
|
||||
threads[0].target()
|
||||
|
||||
assert calls["run_prompt"] == 1
|
||||
shows = [e for e in emitted if e and e[0] == "notification.show" and e[1] == "sid"]
|
||||
clears = [e for e in emitted if e and e[0] == "notification.clear" and e[1] == "sid"]
|
||||
# Exactly one keyed notice, replaced-in-place semantics, then cleared.
|
||||
assert len(shows) == 1, f"expected one slow-build notice, got: {shows}"
|
||||
assert shows[0][2].get("key") == server._AGENT_BUILD_SLOW_NOTICE_KEY
|
||||
assert len(clears) == 1 and clears[0][2].get("key") == server._AGENT_BUILD_SLOW_NOTICE_KEY
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_agent_build_failure_surfaces_error_and_drops_turn(monkeypatch):
|
||||
"""When the build itself FAILS (agent_error set when ready fires), the
|
||||
prompt must not run and the failure must reach the client as a visible
|
||||
error event — never a silent drop."""
|
||||
threads = []
|
||||
emitted = []
|
||||
calls = {"run_prompt": 0}
|
||||
|
||||
class _FakeThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self.target = target
|
||||
threads.append(self)
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
ready = threading.Event()
|
||||
ready.set() # build finished...
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
session["agent_error"] = "No LLM provider configured" # ...but failed
|
||||
server._sessions["sid"] = session
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server.threading, "Thread", _FakeThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
|
||||
monkeypatch.setattr(server, "_ensure_session_db_row", lambda session: None)
|
||||
monkeypatch.setattr(server, "_persist_branch_seed", lambda session: None)
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_run_prompt_submit",
|
||||
lambda *args, **kwargs: calls.__setitem__(
|
||||
"run_prompt", calls["run_prompt"] + 1
|
||||
),
|
||||
)
|
||||
|
||||
submit = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "first message"},
|
||||
}
|
||||
)
|
||||
assert submit.get("result"), f"got error: {submit.get('error')}"
|
||||
|
||||
threads[0].target()
|
||||
|
||||
assert calls["run_prompt"] == 0
|
||||
assert session["running"] is False
|
||||
error_events = [e for e in emitted if e and e[0] == "error" and e[1] == "sid"]
|
||||
assert len(error_events) == 1, f"expected one error event, got: {emitted}"
|
||||
assert "No LLM provider configured" in error_events[0][2].get("message", "")
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_dead_build_thread_fails_fast_not_full_cap(monkeypatch):
|
||||
"""A build thread that died without setting agent_ready means the build
|
||||
died hard — the waiter must fail promptly with a visible error instead of
|
||||
sitting out the full wait cap on a corpse."""
|
||||
emitted = []
|
||||
|
||||
class _DeadThread:
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
ready = threading.Event() # never set
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
session["running"] = True
|
||||
session["_agent_build_thread"] = _DeadThread()
|
||||
session["agent_error"] = "agent init failed: boom"
|
||||
server._sessions["sid"] = session
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: emitted.append(args))
|
||||
# Short slices so the test is fast; the dead-thread check fires on the
|
||||
# first empty slice, far below the cap.
|
||||
monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)
|
||||
|
||||
start = time.monotonic()
|
||||
err = server._wait_agent_for_prompt(session, "rid-1", "sid")
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert err is not None
|
||||
assert "boom" in (err.get("error") or {}).get("message", "")
|
||||
assert elapsed < 5.0, f"dead-thread detection took {elapsed:.1f}s"
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_wait_agent_for_prompt_honors_cancel_mid_wait(monkeypatch):
|
||||
"""A cancel arriving during the patient wait must end it promptly and
|
||||
return None (the caller's cancel branch owns the user-visible event)."""
|
||||
ready = threading.Event() # never set
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
session["running"] = True
|
||||
server._sessions["sid"] = session
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)
|
||||
|
||||
def cancel_soon():
|
||||
time.sleep(0.05)
|
||||
with session["history_lock"]:
|
||||
session["_turn_cancel_requested"] = True
|
||||
|
||||
canceller = threading.Thread(target=cancel_soon)
|
||||
canceller.start()
|
||||
start = time.monotonic()
|
||||
err = server._wait_agent_for_prompt(session, "rid-1", "sid")
|
||||
elapsed = time.monotonic() - start
|
||||
canceller.join()
|
||||
|
||||
assert err is None
|
||||
assert elapsed < 5.0, f"cancel honored only after {elapsed:.1f}s"
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_agent_build_wait_cap_config_override(monkeypatch):
|
||||
"""agent.build_wait_timeout in config.yaml overrides the default cap;
|
||||
invalid/absent values fall back to 600s."""
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": 90}})
|
||||
assert server._agent_build_wait_cap() == 90.0
|
||||
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {}})
|
||||
assert server._agent_build_wait_cap() == 600.0
|
||||
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": 0}})
|
||||
assert server._agent_build_wait_cap() == 600.0
|
||||
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"build_wait_timeout": "nonsense"}})
|
||||
assert server._agent_build_wait_cap() == 600.0
|
||||
|
||||
|
||||
def test_wait_agent_for_prompt_expires_at_cap(monkeypatch):
|
||||
"""A genuinely hung build (thread alive, never ready) still fails at the
|
||||
bounded cap with a message that tells the user their text was not sent."""
|
||||
class _AliveThread:
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
ready = threading.Event() # never set
|
||||
session = _session(agent_ready=ready)
|
||||
session["agent"] = None
|
||||
session["running"] = True
|
||||
session["_agent_build_thread"] = _AliveThread()
|
||||
server._sessions["sid"] = session
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(server, "_AGENT_BUILD_WAIT_SLICE", 0.01)
|
||||
monkeypatch.setattr(server, "_agent_build_wait_cap", lambda: 0.05)
|
||||
|
||||
err = server._wait_agent_for_prompt(session, "rid-1", "sid")
|
||||
|
||||
assert err is not None
|
||||
message = (err.get("error") or {}).get("message", "")
|
||||
assert "timed out" in message and "was not sent" in message
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_clear_pending_without_sid_clears_all():
|
||||
"""_clear_pending(None) is the shutdown path — must still release
|
||||
every pending prompt regardless of owning session."""
|
||||
|
|
|
|||
|
|
@ -1670,6 +1670,124 @@ def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
|
|||
return _err(rid, 5032, err) if err else None
|
||||
|
||||
|
||||
# The deferred prompt path waits in short slices so a cancel is honored
|
||||
# promptly and a slow build can be reported to the client exactly once.
|
||||
_AGENT_BUILD_WAIT_SLICE = 5.0
|
||||
_AGENT_BUILD_SLOW_NOTICE_AFTER = 30.0
|
||||
_AGENT_BUILD_SLOW_NOTICE_KEY = "agent-build-slow"
|
||||
|
||||
|
||||
def _agent_build_wait_cap() -> float:
|
||||
"""Upper bound (seconds) a submitted prompt waits for the deferred agent
|
||||
build before failing permanently. ``agent.build_wait_timeout`` in
|
||||
config.yaml overrides the 600s default (raise it for deployments with
|
||||
many slow/unreachable MCP servers or high-latency provider metadata)."""
|
||||
try:
|
||||
agent_cfg = _load_cfg().get("agent") or {}
|
||||
raw = agent_cfg.get("build_wait_timeout")
|
||||
if raw is not None:
|
||||
value = float(raw)
|
||||
if value > 0:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return 600.0
|
||||
|
||||
|
||||
def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None:
|
||||
"""Patient variant of ``_wait_agent`` for the deferred prompt.submit path.
|
||||
|
||||
The flat 30s ``_wait_agent`` ceiling was a message-eating cliff (#63078):
|
||||
``prompt.submit`` has already returned ``{"status": "streaming"}``, the
|
||||
user's first message IS the turn in flight, and the deferred agent build
|
||||
(MCP discovery with per-server retry backoff, synchronous model-metadata
|
||||
HTTP, skills scanning) routinely outlives 30 seconds on cold starts. On
|
||||
timeout the old path emitted an error EVENT and returned without ever
|
||||
calling ``_run_prompt_submit`` — the first message was permanently
|
||||
discarded while the build finished successfully in the background, leaving
|
||||
the blank first session.
|
||||
|
||||
This wait instead:
|
||||
- keeps the pending prompt attached to this (already off-RPC) thread and
|
||||
delivers it the moment the still-running build completes;
|
||||
- waits in short slices so a cancel (session.interrupt / session churn)
|
||||
is honored promptly instead of after the full timeout;
|
||||
- tells the client once, via a keyed notice, when the build outlives
|
||||
``_AGENT_BUILD_SLOW_NOTICE_AFTER`` — the wait is patient but never
|
||||
silent;
|
||||
- fails permanently only when the build itself fails: the build thread
|
||||
died without signalling ready, or the bounded cap
|
||||
(``agent.build_wait_timeout``, default 600s — no infinite waits)
|
||||
expired on a genuinely hung build.
|
||||
|
||||
Returns ``None`` on success OR when the turn was cancelled mid-wait (the
|
||||
caller's cancel branch owns that messaging), an ``_err`` dict otherwise.
|
||||
"""
|
||||
ready = session.get("agent_ready")
|
||||
if ready is None:
|
||||
return None
|
||||
start = time.monotonic()
|
||||
cap = _agent_build_wait_cap()
|
||||
notified_slow = False
|
||||
while not ready.wait(timeout=_AGENT_BUILD_WAIT_SLICE):
|
||||
with session["history_lock"]:
|
||||
cancelled = session.get("_turn_cancel_requested") or not session.get(
|
||||
"running"
|
||||
)
|
||||
if cancelled:
|
||||
# The caller's cancel/not-running branch emits the user-visible
|
||||
# event for this — bail without an error of our own.
|
||||
return None
|
||||
waited = time.monotonic() - start
|
||||
if waited >= cap:
|
||||
return _err(
|
||||
rid,
|
||||
5032,
|
||||
f"agent initialization timed out after {int(waited)}s — "
|
||||
"your message was not sent; retry once the session is ready",
|
||||
)
|
||||
build_thread = session.get("_agent_build_thread")
|
||||
if (
|
||||
build_thread is not None
|
||||
and not build_thread.is_alive()
|
||||
and not ready.is_set()
|
||||
):
|
||||
# _build's ``finally`` guarantees ready.set(); a dead thread with
|
||||
# ready still unset means the build died hard (interpreter-level
|
||||
# kill) — don't wait on a corpse for the rest of the cap.
|
||||
return _err(
|
||||
rid,
|
||||
5032,
|
||||
session.get("agent_error")
|
||||
or "agent initialization failed before completing",
|
||||
)
|
||||
if not notified_slow and waited >= _AGENT_BUILD_SLOW_NOTICE_AFTER:
|
||||
# One keyed, replace-in-place notice: the desktop shows it as a
|
||||
# toast, the TUI in its status bar. Without this the extended wait
|
||||
# would be exactly the silent hang this function exists to fix.
|
||||
notified_slow = True
|
||||
_emit(
|
||||
"notification.show",
|
||||
sid,
|
||||
{
|
||||
"text": (
|
||||
"Still starting the agent (tool discovery / model "
|
||||
"setup) — your message will be sent as soon as it's "
|
||||
"ready."
|
||||
),
|
||||
"level": "info",
|
||||
"kind": "agent",
|
||||
"ttl_ms": None,
|
||||
"key": _AGENT_BUILD_SLOW_NOTICE_KEY,
|
||||
"id": _AGENT_BUILD_SLOW_NOTICE_KEY,
|
||||
},
|
||||
)
|
||||
if notified_slow:
|
||||
_emit("notification.clear", sid, {"key": _AGENT_BUILD_SLOW_NOTICE_KEY})
|
||||
err = session.get("agent_error")
|
||||
return _err(rid, 5032, err) if err else None
|
||||
|
||||
|
||||
def _start_agent_build(sid: str, session: dict) -> None:
|
||||
"""Start building the real AIAgent for a TUI session, once.
|
||||
|
||||
|
|
@ -1846,7 +1964,12 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
pass
|
||||
ready.set()
|
||||
|
||||
threading.Thread(target=_build, daemon=True).start()
|
||||
build_thread = threading.Thread(target=_build, daemon=True)
|
||||
# Handle for _wait_agent_for_prompt: a dead build thread with agent_ready
|
||||
# still unset means the build died hard — waiters must not sit out the
|
||||
# full cap on a corpse.
|
||||
session["_agent_build_thread"] = build_thread
|
||||
build_thread.start()
|
||||
|
||||
|
||||
def _sess_nowait(params, rid):
|
||||
|
|
@ -10338,7 +10461,12 @@ def _(rid, params: dict) -> dict:
|
|||
_start_agent_build(sid, session)
|
||||
|
||||
def run_after_agent_ready() -> None:
|
||||
err = _wait_agent(session, rid)
|
||||
# Patient wait (#63078): the user's message is already the accepted
|
||||
# in-flight turn, so a slow deferred build must not eat it. The wait
|
||||
# delivers the prompt when the still-running build completes, honors a
|
||||
# cancel promptly, notices the user once past the slow threshold, and
|
||||
# only errors when the build itself fails or the bounded cap expires.
|
||||
err = _wait_agent_for_prompt(session, rid, sid)
|
||||
if err:
|
||||
_emit(
|
||||
"error",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue