mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 20:29:56 +02:00
185 lines
7.6 KiB
Python
185 lines
7.6 KiB
Python
"""The universal poll adapter: a real agent session verifies ANY
|
|
natural-language condition on an interval, with the same tools, MCP gate, and
|
|
admission bounds as a normal chat turn. Contract: the agent ends its reply
|
|
with EVENT:/NO_EVENT + STATE: lines; state round-trips through the cursor so
|
|
"since the last check" means something. Check sessions are plumbing, not chat
|
|
history: they're deleted after each check so they can't pollute history or
|
|
the pattern miner."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import time
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.events.models import AgentCheckSource, Event
|
|
from backend.apps.workflows.models import Workflow
|
|
|
|
CHECK_TIMEOUT_S = 240.0
|
|
|
|
|
|
@typechecked
|
|
def build_check_prompt(check: str, state: str) -> str:
|
|
return (
|
|
"You are an unattended event checker. Determine whether this event has occurred "
|
|
f"since the last check: {check.strip()}\n\n"
|
|
f"Previous check state: {state or 'none; this is the baseline check'}.\n\n"
|
|
"Observe without consuming: prefer listings, counts, and previews; avoid opening or acting on "
|
|
"items in ways that change their state (marking read/seen, dismissing) when your tools allow it.\n"
|
|
"Use your tools as needed, then END your reply in EXACTLY this format (as the final lines):\n"
|
|
"EVENT: <one factual line describing what happened>\n"
|
|
"STATE: <one line capturing what you observed, to compare against next time>\n"
|
|
"If nothing new happened, instead end with:\n"
|
|
"NO_EVENT\n"
|
|
"STATE: <one line>\n"
|
|
"On the baseline check (no previous state) always reply NO_EVENT and just record STATE."
|
|
)
|
|
|
|
|
|
@typechecked
|
|
def parse_check_reply(text: str) -> Tuple[Optional[str], str]:
|
|
"""(event line or None, state). Last occurrence wins so earlier prose echoing
|
|
the format can't fake a verdict. Raises when the contract is missing entirely."""
|
|
event_line: Optional[str] = None
|
|
state = ""
|
|
saw_verdict = False
|
|
for line in text.splitlines():
|
|
s = line.strip()
|
|
if s.upper().startswith("EVENT:"):
|
|
event_line = s[len("EVENT:"):].strip()
|
|
saw_verdict = True
|
|
elif s.upper() == "NO_EVENT":
|
|
event_line = None
|
|
saw_verdict = True
|
|
elif s.upper().startswith("STATE:"):
|
|
state = s[len("STATE:"):].strip()
|
|
if not saw_verdict:
|
|
raise ValueError("check agent returned no EVENT/NO_EVENT verdict")
|
|
return event_line, state
|
|
|
|
|
|
async def p_await_reply(session_id: str) -> str:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
|
|
deadline = time.monotonic() + CHECK_TIMEOUT_S
|
|
while time.monotonic() < deadline:
|
|
sess = agent_manager.sessions.get(session_id)
|
|
if sess is None or getattr(sess, "status", None) in ("completed", "error", "stopped"):
|
|
break
|
|
await asyncio.sleep(0.5)
|
|
sess = agent_manager.sessions.get(session_id)
|
|
if sess is None:
|
|
raise RuntimeError("check session vanished")
|
|
status = getattr(sess, "status", None)
|
|
if status == "running":
|
|
try:
|
|
await agent_manager.stop_agent(session_id)
|
|
except Exception:
|
|
pass
|
|
raise RuntimeError(f"check agent timed out after {int(CHECK_TIMEOUT_S)}s")
|
|
if status != "completed":
|
|
raise RuntimeError(f"check agent ended with status {status}")
|
|
for m in reversed(getattr(sess, "messages", []) or []):
|
|
if getattr(m, "role", None) == "assistant" and isinstance(getattr(m, "content", None), str) and m.content.strip():
|
|
return m.content
|
|
raise RuntimeError("check agent produced no reply")
|
|
|
|
|
|
async def run_check_turn(
|
|
model: str,
|
|
prompt: str,
|
|
dashboard_id: Optional[str] = None,
|
|
active_mcps: Optional[List[str]] = None,
|
|
approvals: Optional[Dict[str, str]] = None,
|
|
) -> str:
|
|
"""One ephemeral agent turn; the session file is deleted afterward.
|
|
|
|
dashboard_id makes browser delegation available (browser cards render on a
|
|
dashboard, so a logged-in-site check works whenever the app is open).
|
|
active_mcps presets session.active_mcps with what the USER pre-authorized
|
|
on the trigger; the dispatch gate itself still only spawns what that list
|
|
names. approvals replays the workflow's remembered tool answers so an
|
|
unattended check doesn't park on a prompt nobody will answer."""
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.core.models import AgentConfig
|
|
from backend.apps.agents.manager.permissions.workflow_approval import (
|
|
clear_workflow_approval_memory,
|
|
set_workflow_approval_memory,
|
|
)
|
|
from backend.apps.agents.manager.session.session_store import delete_session_file
|
|
|
|
session = await agent_manager.launch_agent(AgentConfig(
|
|
name="Event check", model=model, mode="agent", dashboard_id=dashboard_id, background=True,
|
|
))
|
|
if active_mcps:
|
|
session.active_mcps = list(active_mcps)
|
|
if approvals:
|
|
set_workflow_approval_memory(
|
|
session.id,
|
|
decisions=dict(approvals),
|
|
step_usage={},
|
|
remember=lambda tool_name, behavior: None,
|
|
ask_timeout=30.0,
|
|
)
|
|
try:
|
|
await agent_manager.send_message(session.id, prompt)
|
|
return await p_await_reply(session.id)
|
|
finally:
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
ws_manager.unmark_background(session.id)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
clear_workflow_approval_memory(session.id)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await agent_manager.close_session(session.id)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
delete_session_file(session.id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@typechecked
|
|
async def agent_check(source: AgentCheckSource, cursor: Dict, workflow: Optional[Workflow] = None) -> Tuple[List[Event], Dict]:
|
|
check = source.check.strip()
|
|
if not check:
|
|
return [], cursor
|
|
from backend.apps.settings.settings import load_settings
|
|
from backend.apps.workflows.executor import resolve_workflow_dashboard_id
|
|
|
|
baselined = bool(cursor.get("baselined")) and cursor.get("check") == check
|
|
prev_state = str(cursor.get("state") or "") if baselined else ""
|
|
model = source.model.strip() or (workflow.model if workflow else "") or (getattr(load_settings(), "default_model", None) or "sonnet")
|
|
reply = await run_check_turn(
|
|
model,
|
|
build_check_prompt(check, prev_state),
|
|
dashboard_id=resolve_workflow_dashboard_id(workflow) if workflow else None,
|
|
active_mcps=list(source.mcps),
|
|
approvals=dict(workflow.remembered_approvals) if workflow else None,
|
|
)
|
|
event_line, state = parse_check_reply(reply)
|
|
new_cursor: Dict = {"baselined": True, "check": check, "state": state or prev_state}
|
|
if cursor.get("last_event_digest"):
|
|
new_cursor["last_event_digest"] = cursor["last_event_digest"]
|
|
# Baseline never fires, even if the model ignores the instruction.
|
|
if event_line is None or not event_line.strip() or not baselined:
|
|
return [], new_cursor
|
|
digest = hashlib.sha256(event_line.strip().encode()).hexdigest()[:16]
|
|
if cursor.get("last_event_digest") == digest:
|
|
# The agent re-reported the identical event (state parroting); once is enough.
|
|
return [], new_cursor
|
|
new_cursor["last_event_digest"] = digest
|
|
return [Event(
|
|
source="agent",
|
|
event_type="check_event",
|
|
summary=event_line.strip()[:300],
|
|
dedup_key=digest,
|
|
payload={"check": check, "state": state},
|
|
)], new_cursor
|