fix: route internal-turn approval cards to visible channels; feed provider errors back to the model

Approval cards raised by company-mode internal scheduling turns (review/report
work items, session ids like `<root>:review::<wid>::vN`) were posted to the
turn's own session channel, which the UI deliberately hides. The card silently
timed out after 300s and the work item parked on AWAITING_HUMAN, so users saw
only the gate card and never the approval prompt. ws_handler now detects these
internal turns and routes their escalation cards to origin_task_id, the root
session's primary task channel, or the activity channel — never the hidden one.
Also unblocks the previously dead origin/session fallbacks in the resolver.

Unclassified LLM stream failures (e.g. provider content-filter rejections like
"input may contain sensitive information") used to hit a blind truncate-retry
loop that replayed the identical payload for a dozen-plus consecutive failures.
runtime_v2 now feeds the provider's verbatim error text back into the
conversation as a "[runtime notice]" system message so the model can adapt
(rephrase, drop quotes, change tack), bounded at 2 feedback retries (counter
resets on any successful stream) plus one context-reset attempt, then fails
honestly with the real error. The blind truncate path remains only for
classified tool-protocol errors.

Verified: new end-to-end tests for recover-after-notice and bounded-failure;
runtime_v2 + ws_handler + escalation/approval + company-mode suites all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-07 18:22:49 +08:00
parent 12817a4e60
commit a30fa7588d
3 changed files with 238 additions and 4 deletions
+82 -4
View File
@@ -270,6 +270,13 @@ class NativeRuntimeV2:
1,
int(self.config.system.native_runtime.reactive_compaction.max_overflow_retries or 1),
) if self.config.system.native_runtime.reactive_compaction.enabled else 1
# Unclassified provider failures (content filters, transient rejects)
# get bounded retries with the provider's error text fed back into the
# conversation so the model can adapt; the counter resets after every
# successful stream so long runs are not penalized for sporadic blips.
stream_error_feedback_retries = 0
max_stream_error_feedback_retries = 2
stream_error_context_reset_attempted = False
compaction_boundaries: list[dict[str, Any]] = []
await self._save_runtime_session(
@@ -566,20 +573,55 @@ class NativeRuntimeV2:
)
else:
await self._cancel_early_tool_runs(early_tool_runs)
truncated = self._truncate_to_last_clean_user_turn(messages, base_prefix_len)
if truncated and len(truncated) > base_prefix_len:
if self.llm.is_tool_protocol_error(exc):
truncated = self._truncate_to_last_clean_user_turn(messages, base_prefix_len)
if truncated and len(truncated) > base_prefix_len:
await self._emit_runtime_event(
runtime_session_id,
task,
"tool_protocol_retry",
{
"iteration": iteration + 1,
"strategy": "truncate",
"message": str(exc),
},
)
messages = truncated
continue
elif stream_error_feedback_retries < max_stream_error_feedback_retries:
stream_error_feedback_retries += 1
messages.append(self._provider_error_feedback_message(exc))
await self._emit_runtime_event(
runtime_session_id,
task,
"tool_protocol_retry",
{
"iteration": iteration + 1,
"strategy": "truncate",
"strategy": "provider_error_feedback",
"attempt": stream_error_feedback_retries,
"message": str(exc),
},
)
messages = truncated
continue
elif not stream_error_context_reset_attempted:
stream_error_context_reset_attempted = True
truncated = self._truncate_to_last_clean_user_turn(messages, base_prefix_len)
if truncated and base_prefix_len < len(truncated) < len(messages):
truncated.append(
self._provider_error_feedback_message(exc, context_reset=True)
)
await self._emit_runtime_event(
runtime_session_id,
task,
"tool_protocol_retry",
{
"iteration": iteration + 1,
"strategy": "provider_error_context_reset",
"message": str(exc),
},
)
messages = truncated
continue
await self._emit_runtime_event(
runtime_session_id,
task,
@@ -607,6 +649,7 @@ class NativeRuntimeV2:
token_usage=total_usage,
)
stream_error_feedback_retries = 0
tool_calls = self._finalize_tool_calls(tool_call_chunks)
assistant_message = {"role": "assistant", "content": assistant_text}
if tool_calls:
@@ -1697,6 +1740,41 @@ class NativeRuntimeV2:
break
return None
@staticmethod
def _provider_error_feedback_message(
exc: Exception,
*,
context_reset: bool = False,
) -> dict[str, str]:
"""Conversation message telling the model why the last request failed.
Unclassified provider rejections (content filters, transient 4xx) never
produce model output, so without this the model has no way to know the
request failed or why. Feeding the provider's own error text back lets
the model decide how to proceed (rephrase, drop a quote, change tack)
instead of the runtime blindly replaying an identical payload.
"""
error_text = " ".join(str(exc).split())[:600]
if context_reset:
detail = (
"The previous LLM request kept failing at the model provider, so the "
"intermediate steps of the current turn were dropped from the request."
)
else:
detail = (
"The previous LLM request failed at the model provider before any "
"output was produced."
)
return {
"role": "system",
"content": (
f"[runtime notice] {detail} Provider error: {error_text}. "
"This was not a user action. Adjust your next step accordingly — for "
"example rephrase sensitive wording, avoid quoting flagged content "
"verbatim, or choose another way to make progress — then continue the task."
),
}
def _truncate_to_last_clean_user_turn(
self,
messages: list[dict[str, Any]],