Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contributors/emails/yuzilong.leif@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
yuzilongleif-collab
# PR #71898
211 changes: 210 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5704,6 +5704,14 @@ def __init__(self, config: Optional[GatewayConfig] = None):
self._completion_deliveries_inflight: set[tuple[str, str, object]] = set()
self._completion_deliveries_delivered: "OrderedDict[tuple[str, str, object], None]" = OrderedDict()
self._completion_delivery_retention = 2048
# Agent-triggered terminal completions from one conversation often land
# in the same scheduler tick. Hold them briefly so the agent receives
# one synthetic turn instead of one turn per process (#70300).
self._completion_notification_batches: dict[tuple[str, ...], list[tuple[str, dict, asyncio.Future]]] = {}
self._completion_notification_batch_tasks: dict[tuple[str, ...], asyncio.Task] = {}
self._completion_notification_batch_flush_tasks: set[asyncio.Task] = set()
self._completion_notification_batch_window = 0.1
self._completion_notification_batches_stopping = False

# Cache AIAgent instances per session to preserve prompt caching.
# Without this, a new AIAgent is created per message, rebuilding the
Expand Down Expand Up @@ -12220,6 +12228,16 @@ def _phase_elapsed() -> float:
_agent, context="shutdown idle-cache"
)

# Completion flush tasks can be sleeping in their fan-in window or
# blocked in adapter delivery. Cancel and await them while adapters
# are still alive so every watcher receives a retryable result
# before platform teardown begins.
cancel_completion_batches = getattr(
self, "_cancel_process_completion_batch_tasks", None
)
if cancel_completion_batches is not None:
await cancel_completion_batches()

for platform, adapter in list(self.adapters.items()):
await self._bounded_adapter_teardown(adapter, platform)

Expand Down Expand Up @@ -21170,6 +21188,197 @@ async def _deliver_completion_notification(
except Exception:
logger.debug("Could not release durable completion claim", exc_info=True)

@staticmethod
def _completion_notification_batch_key(evt: dict) -> tuple[str, ...]:
"""Return a routing-complete key for short-window process fan-in."""
return tuple(str(evt.get(field) or "") for field in (
"session_key",
"platform",
"chat_type",
"chat_id",
"thread_id",
"user_id",
))

@staticmethod
def _format_coalesced_process_completions(entries: list[tuple[str, dict, asyncio.Future]]) -> str:
"""Build one bounded synthetic event from several redacted completions."""
lines = [
f"[IMPORTANT: {len(entries)} background processes completed for this session.",
"Treat these results as one completion batch and send at most one "
"consolidated user-facing response.",
]
shown = entries[:10]
for _text, evt, _future in shown:
session_id = str(evt.get("session_id") or "unknown")
exit_code = evt.get("exit_code")
reason = str(evt.get("completion_reason") or "exited")
# Completion-event output is normally passed through the terminal
# redactor at the producer seam, but that redactor is deliberately
# configurable. This synthetic turn is gateway user-facing input,
# so keep the unconditional gateway floor here as defence in depth.
# Redact before slicing: truncating first can leave a credential
# fragment that no longer matches the authoritative patterns.
output = _redact_gateway_user_facing_secrets(
str(evt.get("output") or "")
).strip()
if len(output) > 800:
output = f"[… truncated …]\n{output[-800:]}"
lines.append(
f"\n- {session_id}: exit_code={exit_code}, reason={reason}"
)
if output:
lines.append(output)
omitted = len(entries) - len(shown)
if omitted:
lines.append(
f"\n- … and {omitted} more completion(s); inspect them with "
"the process tool if they affect the conclusion."
)
lines.append(
"If a result does not change the current conclusion, absorb it silently.]"
)
return "\n".join(lines)

def _record_coalesced_completion_siblings(self, events: list[dict]) -> None:
"""Extend a successful primary delivery claim to its batched siblings."""
with self._completion_delivery_lock:
for evt in events:
identity = self._completion_delivery_identity(evt)
if identity is None:
continue
self._completion_deliveries_inflight.discard(identity)
self._completion_deliveries_delivered[identity] = None
while (
len(self._completion_deliveries_delivered)
> self._completion_delivery_retention
):
self._completion_deliveries_delivered.popitem(last=False)

async def _flush_process_completion_batch(self, key: tuple[str, ...]) -> None:
"""Deliver one short-window completion batch and resolve its waiters."""
current_task = asyncio.current_task()
entries: list[tuple[str, dict, asyncio.Future]] = []
delivered: Optional[bool] = False
try:
await asyncio.sleep(self._completion_notification_batch_window)
entries = self._completion_notification_batches.pop(key, [])
# Detach before adapter delivery. A completion that arrives while
# this batch is in flight must be able to schedule the next flush.
if self._completion_notification_batch_tasks.get(key) is current_task:
self._completion_notification_batch_tasks.pop(key, None)
if not entries:
return
if len(entries) == 1:
synth_text = entries[0][0]
else:
synth_text = self._format_coalesced_process_completions(entries)

# A duplicate primary can legitimately return None from the
# lifecycle dedupe seam. Try the next batch identity so a
# fresh sibling is never discarded with that duplicate.
delivered = None
for _text, candidate_evt, _future in entries:
delivered = await self._deliver_completion_notification(
synth_text, candidate_evt,
)
if delivered is not None:
break
if delivered is True and len(entries) > 1:
self._record_coalesced_completion_siblings(
[evt for _text, evt, _future in entries]
)
except asyncio.CancelledError:
# Shutdown may cancel us either during the fan-in window or while
# adapter delivery is blocked. Recover entries that have not yet
# detached and resolve every waiter as retryable before adapters
# are torn down.
delivered = False
if not entries:
entries = self._completion_notification_batches.pop(key, [])
raise
except Exception:
logger.exception("Coalesced process completion delivery failed")
delivered = False
finally:
# Never strand watcher futures if formatting, delivery, or task
# cancellation interrupts a batch. False follows the existing
# watcher retry path; None remains the ordinary dedupe result.
for _text, _evt, future in entries:
if not future.done():
future.set_result(delivered)
# Do not remove a newer flush task that reused the same route key.
if self._completion_notification_batch_tasks.get(key) is current_task:
self._completion_notification_batch_tasks.pop(key, None)

async def _cancel_process_completion_batch_tasks(self) -> None:
"""Settle pending completion batches before adapter teardown."""
self._completion_notification_batches_stopping = True
tasks = {
task
for task in getattr(
self, "_completion_notification_batch_flush_tasks", set()
)
if not task.done()
}
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)

# Defensive cleanup for an orphaned queue with no live flush task.
batches = getattr(self, "_completion_notification_batches", {})
for entries in batches.values():
for _text, _evt, future in entries:
if not future.done():
future.set_result(False)
batches.clear()
getattr(self, "_completion_notification_batch_tasks", {}).clear()
getattr(self, "_completion_notification_batch_flush_tasks", set()).clear()

async def _enqueue_process_completion_notification(
self, synth_text: str, evt: dict,
) -> Optional[bool]:
"""Fan in concurrent process completions that share one conversation."""
# Some unit tests construct GatewayRunner with object.__new__. Keep the
# batching seam lazy so those focused lifecycle tests remain valid.
if not hasattr(self, "_completion_notification_batches"):
self._completion_notification_batches = {}
if not hasattr(self, "_completion_notification_batch_tasks"):
self._completion_notification_batch_tasks = {}
if not hasattr(self, "_completion_notification_batch_flush_tasks"):
self._completion_notification_batch_flush_tasks = set()
if not hasattr(self, "_completion_notification_batch_window"):
self._completion_notification_batch_window = 0.1
if not hasattr(self, "_completion_notification_batches_stopping"):
self._completion_notification_batches_stopping = False

if self._completion_notification_batches_stopping:
return False

key = self._completion_notification_batch_key(evt)
future = asyncio.get_running_loop().create_future()
self._completion_notification_batches.setdefault(key, []).append(
(synth_text, evt, future)
)
if key not in self._completion_notification_batch_tasks:
task = asyncio.create_task(
self._flush_process_completion_batch(key)
)
self._completion_notification_batch_tasks[key] = task
# Keep the flush alive and include it in the gateway's normal
# lifecycle accounting. Focused tests that construct a runner via
# object.__new__ lazily receive the same ownership set.
if not hasattr(self, "_background_tasks"):
self._background_tasks = set()
self._background_tasks.add(task)
self._completion_notification_batch_flush_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
task.add_done_callback(
self._completion_notification_batch_flush_tasks.discard
)
return await future

def _enrich_async_delegation_routing(self, evt: dict) -> None:
"""Fill platform/chat_id/thread_id/chat_type on an async-delegation event.

Expand Down Expand Up @@ -21340,7 +21549,7 @@ async def _run_process_watcher(self, watcher: dict) -> None:
synth_text = format_process_notification(completion_evt)
if not synth_text:
break
delivered = await self._deliver_completion_notification(
delivered = await self._enqueue_process_completion_notification(
synth_text, completion_evt,
)
if delivered is False:
Expand Down
Loading
Loading