fix(agent): close length recovery lifecycle gaps

This commit is contained in:
chengyongru
2026-07-27 01:39:46 +08:00
committed by Xubin Ren
parent 27a00c7a4f
commit e6baecafcd
7 changed files with 195 additions and 7 deletions
+14 -1
View File
@@ -1070,7 +1070,12 @@ class AgentLoop:
# Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty.
if on_stream and on_stream_end and should_stream:
await on_stream(result.final_content or "")
stream_content = (
result.pending_stream_content
if result.pending_stream_content is not None
else result.final_content or ""
)
await on_stream(stream_content)
await on_stream_end(resuming=False)
elif result.stop_reason == "error":
logger.error("LLM returned error: {}", (result.final_content or "")[:200])
@@ -1217,6 +1222,14 @@ class AgentLoop:
for _, coordinator in self._automation_turn_coordinators:
coordinator.complete(msg, error=asyncio.CancelledError())
logger.info("Task cancelled for session {}", session_key)
try:
await delivery.abort_stream()
except Exception:
logger.debug(
"Could not close stream for cancelled session {}",
session_key,
exc_info=True,
)
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
# messages accumulated before /stop. The checkpoint was
+17 -5
View File
@@ -113,6 +113,8 @@ class AgentRunResult:
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
had_injections: bool = False
# Terminal tail to emit when the preceding final-content prefix was already streamed.
pending_stream_content: str | None = None
class AgentRunner:
@@ -398,6 +400,7 @@ class AgentRunner:
had_injections = False
injection_cycles = 0
compacted_tool_call_ids: set[str] = set()
pending_stream_content: str | None = None
governance_config = ContextGovernanceConfig(
provider=spec.runtime.provider,
model=spec.runtime.model,
@@ -718,17 +721,25 @@ class AgentRunner:
)
if drained_after_max_iterations:
had_injections = True
final_content = None
terminal_content = None
if spec.finalize_on_max_iterations:
final_content = await self._try_finalize_after_max_iterations(
terminal_content = await self._try_finalize_after_max_iterations(
spec,
hook,
messages,
usage,
)
if final_content is None:
final_content = self._max_iterations_fallback(spec)
self._append_final_message(messages, final_content)
if terminal_content is None:
terminal_content = self._max_iterations_fallback(spec)
if length_recovery_parts:
terminal_tail = f"\n\n{terminal_content.lstrip()}"
final_content = (
"".join(length_recovery_parts).rstrip() + terminal_tail
).strip()
pending_stream_content = terminal_tail
else:
final_content = terminal_content
self._append_final_message(messages, terminal_content)
return AgentRunResult(
final_content=final_content,
@@ -739,6 +750,7 @@ class AgentRunner:
error=error,
tool_events=tool_events,
had_injections=had_injections,
pending_stream_content=pending_stream_content,
)
def _build_request_kwargs(
+8
View File
@@ -126,6 +126,7 @@ class TurnDelivery:
lifecycle_message: InboundMessage = field(init=False)
_stream_base_id: str | None = field(init=False, default=None)
_stream_segment: int = field(init=False, default=0)
_stream_open: bool = field(init=False, default=False)
def __post_init__(self) -> None:
self.delivery_message = dataclasses.replace(
@@ -284,6 +285,7 @@ class TurnDelivery:
metadata=self.delivery_message.metadata,
)
)
self._stream_open = True
async def _publish_stream_end(
self,
@@ -303,5 +305,11 @@ class TurnDelivery:
metadata=self.delivery_message.metadata,
)
)
self._stream_open = merge_next
if not merge_next:
self._stream_segment += 1
async def abort_stream(self) -> None:
"""Close an interrupted stream so stateful channels can release its buffer."""
if self._stream_open:
await self._publish_stream_end()
+5 -1
View File
@@ -533,7 +533,11 @@ class MattermostChannel(BaseChannel):
final += delta
if resuming:
self._clear_stream_state(stream_id)
if merge_next:
self._stream_buffers[stream_id] = final
self._stream_committed[stream_id] = final
else:
self._clear_stream_state(stream_id)
return
if final and not meta.get("_progress"):
@@ -582,6 +582,33 @@ async def test_stream_end_keyword_resuming_does_not_post_or_mark_done():
assert "s1" not in channel._stream_buffers
@pytest.mark.asyncio
async def test_stream_end_merge_next_preserves_buffer_until_final_end():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta("chan_1", "first ", stream_id="s1")
await channel.send_delta(
"chan_1",
"boundary ",
stream_id="s1",
stream_end=True,
resuming=True,
merge_next=True,
)
assert channel._stream_buffers["s1"] == "first boundary "
await channel.send_delta("chan_1", "second", stream_id="s1")
await channel.send_delta("chan_1", "", stream_id="s1", stream_end=True)
posts = [call for call in fake.post_calls if call["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["message"] == "first boundary second"
assert "s1" not in channel._stream_buffers
@pytest.mark.asyncio
async def test_stream_end_failure_keeps_buffer_for_retry():
channel, fake = _make_channel()