fix(agent): close length recovery lifecycle gaps
This commit is contained in:
+14
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user