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
+114
View File
@@ -1227,6 +1227,120 @@ class NativeRuntimeV2Tests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.status, TaskStatus.DONE)
self.assertIn("Recovered after provider tool protocol fallback.", result.content)
@staticmethod
def _make_provider_reject_llm(fail_times: int):
"""LLM stub whose stream fails ``fail_times`` times with an
unclassified provider rejection (content-filter style), then answers."""
class _ProviderRejectLLM:
def __init__(self) -> None:
self.config = type("Cfg", (), {"max_tokens": 2048})()
self.stream_calls = 0
self.seen_notice_payloads: list[list[str]] = []
def prepare_user_message_content(self, content: str, attachment_refs=None):
_ = attachment_refs
return content
def get_tool_definitions(self, tools):
return tools
def is_context_overflow_error(self, error: Exception) -> bool:
_ = error
return False
def is_tool_protocol_error(self, error: Exception) -> bool:
_ = error
return False
def sanitize_tool_call_history(self, messages):
return list(messages)
async def chat_stream(self, messages, tools=None):
_ = tools
self.stream_calls += 1
self.seen_notice_payloads.append([
str(m.get("content", ""))
for m in messages
if m.get("role") == "system" and "[runtime notice]" in str(m.get("content", ""))
])
if self.stream_calls <= fail_times:
yield type("Evt", (), {"event_type": "message_start", "payload": {}, "model": "stub"})()
raise RuntimeError(
"litellm.BadRequestError: OpenAIException - The request failed "
"because the input may contain sensitive information."
)
yield type("Evt", (), {"event_type": "message_start", "payload": {}, "model": "stub"})()
yield type("Evt", (), {
"event_type": "assistant_delta",
"payload": {"text": "Rephrased and continued."},
"model": "stub",
})()
yield type("Evt", (), {"event_type": "message_stop", "payload": {}, "model": "stub"})()
async def chat(self, messages, tools=None):
raise AssertionError("non-stream fallback must not be used for unclassified errors")
return _ProviderRejectLLM()
async def test_unclassified_provider_error_feeds_error_back_and_recovers(self) -> None:
llm = self._make_provider_reject_llm(fail_times=1)
runtime = NativeRuntimeV2(
llm=llm,
tool_registry=ToolRegistry(),
memory_manager=_StubMemoryManager(_StubStore()),
config=OPCConfig(),
max_iterations=8,
)
result = await runtime.run(
system_prompt="You are a resilient runtime.",
user_message="Complete the task.",
task=Task(
title="provider-reject-recover",
description="provider-reject-recover",
session_id="sess-provider-reject-recover",
project_id="proj1",
metadata={"mode": "task"},
),
)
self.assertEqual(result.status, TaskStatus.DONE)
self.assertIn("Rephrased and continued.", result.content)
self.assertEqual(llm.stream_calls, 2)
# The retry request must contain the provider's error text as a notice
retry_notices = llm.seen_notice_payloads[1]
self.assertEqual(len(retry_notices), 1)
self.assertIn("sensitive information", retry_notices[0])
self.assertIn("not a user action", retry_notices[0])
async def test_unclassified_provider_error_retries_are_bounded_then_fail(self) -> None:
llm = self._make_provider_reject_llm(fail_times=99)
runtime = NativeRuntimeV2(
llm=llm,
tool_registry=ToolRegistry(),
memory_manager=_StubMemoryManager(_StubStore()),
config=OPCConfig(),
max_iterations=20,
)
result = await runtime.run(
system_prompt="You are a resilient runtime.",
user_message="Complete the task.",
task=Task(
title="provider-reject-bounded",
description="provider-reject-bounded",
session_id="sess-provider-reject-bounded",
project_id="proj1",
metadata={"mode": "task"},
),
)
self.assertEqual(result.status, TaskStatus.FAILED)
self.assertIn("sensitive information", result.content)
# 1 initial + 2 feedback retries + 1 context-reset retry = 4, never 20
self.assertLessEqual(llm.stream_calls, 4)
async def test_todo_write_normalizes_openopc_task_ledger_shape(self) -> None:
runtime = NativeRuntimeV2(
llm=_StubLLM(),