mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 12:42:22 +02:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018foyDoK19jjbYdudfzQVkZ
235 lines
13 KiB
Python
235 lines
13 KiB
Python
"""Turn-producing message operations for AgentManager (send + queue), the ones that append a
|
|
user Message and spawn the agent loop. Editing a prior message lives in EditMessage; session-control
|
|
ops (stop / approve / branch / update) live in SessionControl. Pure relocation: self.* resolves across the MRO as before."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.core.models import AgentSession, Message
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
from backend.apps.settings.settings import load_settings
|
|
from backend.apps.agents.manager.run_browser_fast_path import run_browser_fast_path
|
|
from backend.apps.agents.manager.session.session_store import snapshot_session_now, load_session_data
|
|
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
|
|
from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names
|
|
from backend.apps.agents.manager.prompt.prompt_context import resolve_mode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol
|
|
|
|
|
|
class QueuedMessage(BaseModel):
|
|
"""A user message that arrived while a turn was live, held until that turn ends.
|
|
Carries the full send_message argument set so delivery is a faithful replay."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
prompt: str
|
|
mode: Optional[str] = None
|
|
model: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
images: Optional[List] = None
|
|
context_paths: Optional[List] = None
|
|
forced_tools: Optional[List[str]] = None
|
|
attached_skills: Optional[List] = None
|
|
hidden: bool = False
|
|
selected_browser_ids: Optional[List[str]] = None
|
|
selected_app_output_ids: Optional[List[str]] = None
|
|
selected_setting_ids: Optional[List[str]] = None
|
|
client_message_id: Optional[str] = None
|
|
|
|
|
|
class Messaging(AgentManagerProtocol):
|
|
@typechecked
|
|
async def send_message(
|
|
self,
|
|
session_id: str,
|
|
prompt: str,
|
|
mode: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
images: Optional[List] = None,
|
|
context_paths: Optional[List] = None,
|
|
forced_tools: Optional[List[str]] = None,
|
|
attached_skills: Optional[List] = None,
|
|
hidden: bool = False,
|
|
by_user: bool = False,
|
|
selected_browser_ids: Optional[List[str]] = None,
|
|
selected_app_output_ids: Optional[List[str]] = None,
|
|
selected_setting_ids: Optional[List[str]] = None,
|
|
client_message_id: Optional[str] = None,
|
|
):
|
|
"""Send a follow-up message to an existing session."""
|
|
session = self.sessions.get(session_id)
|
|
if not session:
|
|
data = load_session_data(session_id)
|
|
if data:
|
|
session = AgentSession(**data)
|
|
# This disk reload (and the closed_at wipe below) is how a late watchdog retry reopened a card the user had closed; a MACHINE send must not revive it (a human's own Resume click carries by_user and may).
|
|
if hidden and not by_user and session.ended_by_user:
|
|
return
|
|
apply_context_window(session)
|
|
session.closed_at = None
|
|
self.sessions[session_id] = session
|
|
else:
|
|
raise ValueError(f"Session {session_id} not found")
|
|
# Every automatic resume arrives hidden; a human's Stop or close outranks all of them. `hidden` only means "do not render a user bubble", so the Resume chip's own click is hidden too and used to be swallowed here, leaving the chip to reappear forever (Eric, live, 2026-08-21). Authorship is what this guard cares about, so it asks by_user.
|
|
if hidden and not by_user and session.ended_by_user:
|
|
return
|
|
if session.ended_by_user and (not hidden or by_user):
|
|
session.ended_by_user = False
|
|
|
|
existing = self.tasks.get(session_id)
|
|
if existing and not existing.done():
|
|
# A mid-turn message used to be silently dropped here (no bubble, no trace); queue it and the turn task's done callback replays it.
|
|
self.pending_messages.setdefault(session_id, []).append(QueuedMessage(
|
|
prompt=prompt, mode=mode, model=model, provider=provider, images=images,
|
|
context_paths=context_paths, forced_tools=forced_tools,
|
|
attached_skills=attached_skills, hidden=hidden,
|
|
selected_browser_ids=selected_browser_ids,
|
|
selected_app_output_ids=selected_app_output_ids,
|
|
selected_setting_ids=selected_setting_ids,
|
|
client_message_id=client_message_id,
|
|
))
|
|
return
|
|
|
|
session_changed = False
|
|
if model and model != session.model:
|
|
# Cross-provider model switches force a session fork. The CLI's resume transcript stores Anthropic-format content blocks with Anthropic tool_use_ids; replaying them on a non-Anthropic provider via 9Router's claude→openai translator corrupts history silently (fixMissingToolResponses stubs missing tool responses with placeholder text). Forking starts a new CLI session so history is re-sent fresh in whichever format the new provider expects.
|
|
from backend.apps.agents.providers.registry import get_api_type as get_api_type_for_model
|
|
if get_api_type_for_model(session.model) != get_api_type_for_model(model):
|
|
session.needs_fork = True
|
|
logger.info(f"[MCP-DEBUG] Forking session: api_type changed {session.model}→{model}")
|
|
|
|
session.model = model
|
|
apply_context_window(session)
|
|
session_changed = True
|
|
if mode and mode != session.mode:
|
|
session.mode = mode
|
|
mode_tools, _, _ = resolve_mode(mode, get_all_tool_names)
|
|
session.allowed_tools = mode_tools
|
|
session_changed = True
|
|
if session_changed:
|
|
await ws_manager.send_to_session(session_id, "agent:status", {
|
|
"session_id": session_id,
|
|
"status": session.status,
|
|
"session": session.model_dump(mode="json"),
|
|
})
|
|
|
|
# Hidden messages (nudges, lost-step retries, auth heals) go out verbatim: the attribution prefix they used to carry (ENG-326) announced harness traffic on the subscription lane, whose provider filter blocks exactly that (Eric's call, 2026-08-21); honesty about who is speaking lives in the nudge texts themselves.
|
|
skill_meta = [{"id": s["id"], "name": s["name"]} for s in (attached_skills or [])] or None
|
|
image_meta = [{"data": img["data"], "media_type": img.get("media_type", "image/png")} for img in (images or [])] or None
|
|
user_msg = Message(
|
|
role="user",
|
|
content=prompt,
|
|
branch_id=session.active_branch_id,
|
|
context_paths=context_paths if context_paths else None,
|
|
attached_skills=skill_meta,
|
|
forced_tools=forced_tools if forced_tools else None,
|
|
images=image_meta,
|
|
hidden=hidden,
|
|
client_message_id=client_message_id,
|
|
)
|
|
session.messages.append(user_msg)
|
|
# Status flips BEFORE the snapshot: the old order persisted a stale "completed" from the
|
|
# prior turn, so a dirty death during a follow-up turn was invisible to the boot-time crash
|
|
# detector (drilled live: round-2 kill -9 resumed nothing because disk said completed).
|
|
session.status = "running"
|
|
snapshot_session_now(session)
|
|
await ws_manager.send_to_session(session_id, "agent:message", {
|
|
"session_id": session_id,
|
|
"message": user_msg.model_dump(mode="json"),
|
|
})
|
|
|
|
# A real user message opens a fresh silent-quit budget; the cap only guards within one ask.
|
|
if not hidden:
|
|
# A human actively driving the session forgives its crash history (breaker reset).
|
|
session.crash_interrupt_count = 0
|
|
session.empty_finish_nudges = 0
|
|
session.empty_finish_progress_mark = 0
|
|
session.empty_finish_surfaced = False
|
|
session.auth_retry_used = False
|
|
# The repeat-quit floor and the vanishing-quit rule key on this; one false positive used to arm both for the session's life (ENG-364).
|
|
session.empty_finish_total = 0
|
|
# A human is here and driving, so an earlier outage stops counting against the next one.
|
|
session.reconnect_attempts = 0
|
|
session.awaiting_reconnect = False
|
|
# Fire a background aux LLM call to generate a 3-6 word verb-phrase describing this turn ("Auditing the pull request", "Drafting your email"). The narrator pill swaps from its heuristic verb to this label as soon as it lands, usually ~500ms-1s into the turn, which is exactly when "Thinking…" starts feeling generic. Provider-agnostic via resolve_aux_model. Non-blocking; failure is silent and the heuristic stays.
|
|
if not hidden and prompt:
|
|
try:
|
|
asyncio.create_task(
|
|
self.generate_turn_label(session_id, user_msg.id, prompt)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
is_first_message = sum(1 for m in session.messages if m.role == "user") == 1
|
|
|
|
session.status = "running"
|
|
await ws_manager.send_to_session(session_id, "agent:status", {
|
|
"session_id": session_id,
|
|
"status": "running",
|
|
"session": session.model_dump(mode="json"),
|
|
})
|
|
|
|
# Browser fast path: a plainly browser-only first message skips the orchestrator LLM entirely (it was ~2/3 of the token bill on these tasks, spent deciding "delegate to a browser" and restating the outcome). Conservative gates + a cheap aux classifier; any miss or error falls through to the normal loop.
|
|
fast_verdict = "no"
|
|
fast_brief = ""
|
|
if not hidden:
|
|
try:
|
|
from backend.apps.agents.browser import browser_fast_path
|
|
extras = bool(images or context_paths or forced_tools or attached_skills
|
|
or len(selected_browser_ids or []) > 1)
|
|
if browser_fast_path.fast_path_eligible(
|
|
prompt, session.mode or "", session.dashboard_id, is_first_message, extras,
|
|
):
|
|
from backend.apps.agents.providers.registry import get_api_type
|
|
fast_verdict, fast_brief = await browser_fast_path.classify_and_brief(
|
|
prompt, load_settings(), get_api_type(session.model),
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"[browser-fast-path] gate error, normal path: {e}")
|
|
|
|
if fast_verdict != "no":
|
|
task = asyncio.create_task(run_browser_fast_path(session, session_id, prompt, selected_browser_ids, fast_brief, fast_verdict))
|
|
else:
|
|
task = asyncio.create_task(self.run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids, selected_setting_ids=selected_setting_ids))
|
|
self.register_turn_task(session_id, task)
|
|
|
|
@typechecked
|
|
def register_turn_task(self, session_id: str, task: asyncio.Task) -> None:
|
|
"""One chokepoint for installing a turn task: every turn end (success, error, stop)
|
|
fires the done callback, so a queued mid-turn message can never strand."""
|
|
self.tasks[session_id] = task
|
|
task.add_done_callback(lambda t: self.deliver_next_queued_message(session_id))
|
|
|
|
@typechecked
|
|
def deliver_next_queued_message(self, session_id: str) -> None:
|
|
"""Replay the oldest queued mid-turn message once no turn is live. One at a time:
|
|
the delivered turn's own done callback drains the rest."""
|
|
queue = self.pending_messages.get(session_id)
|
|
if not queue:
|
|
return
|
|
live = self.tasks.get(session_id)
|
|
if live and not live.done():
|
|
return
|
|
qm = queue.pop(0)
|
|
if not queue:
|
|
self.pending_messages.pop(session_id, None)
|
|
asyncio.create_task(self.send_message(
|
|
session_id, qm.prompt, mode=qm.mode, model=qm.model, provider=qm.provider,
|
|
images=qm.images, context_paths=qm.context_paths, forced_tools=qm.forced_tools,
|
|
attached_skills=qm.attached_skills, hidden=qm.hidden,
|
|
selected_browser_ids=qm.selected_browser_ids,
|
|
selected_app_output_ids=qm.selected_app_output_ids,
|
|
selected_setting_ids=qm.selected_setting_ids,
|
|
client_message_id=qm.client_message_id,
|
|
))
|