diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 208dddc5..a3662225 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -4614,7 +4614,12 @@ class AgentManager: session.closed_at = None self.sessions[session_id] = session - _delete_session_file(session_id) + # Do NOT delete the disk file here. The history list (get_history) + # reads from disk; deleting on resume meant every click on a past + # chat permanently removed it from history on the next restart. + # The disk copy stays as the durable record; subsequent turn + # completions and close_session calls overwrite it via + # _save_session, so memory and disk stay in sync. await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/apps/agents/ws_manager.py b/backend/apps/agents/ws_manager.py index e6fec824..49fdd848 100644 --- a/backend/apps/agents/ws_manager.py +++ b/backend/apps/agents/ws_manager.py @@ -103,6 +103,7 @@ class ConnectionManager: # reconcile_on_startup also marks waiting_approval sessions as # stopped so there's nothing to answer anyway. events = self._filter_stale_approvals(events) + events = self._strip_replayed_closes(events) for s in events: try: await websocket.send_text(s) @@ -130,6 +131,30 @@ class ConnectionManager: "current_seq": newest if newest is not None else 0, } + def _strip_replayed_closes(self, events: list[str]) -> list[str]: + """Drop `agent:closed` events from a replay buffer. + + agent:closed is a transition event ("session JUST closed") whose + frontend reducer (closeSessionFromWs) destructively deletes the + session from state.sessions. Replaying it on a fresh client (e.g. + a user who just clicked the closed chat in history) deletes the + session they're trying to open. The current closed state is + already conveyed by the REST hydrate (status=stopped, closed_at + set) and by the latest agent:status event in the replay, so + suppressing the transition replay is non-lossy. + """ + out: list[str] = [] + for payload_str in events: + try: + parsed = json.loads(payload_str) + except (ValueError, TypeError): + out.append(payload_str) + continue + if parsed.get("event") == "agent:closed": + continue + out.append(payload_str) + return out + def _filter_stale_approvals(self, events: list[str]) -> list[str]: """Return events minus any `agent:approval_request` whose request_id is no longer in pending_futures. JSON parse is per-event but replay diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index ec31b685..85d9c5a2 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -132,6 +132,7 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O "last_run_id": run.id, }) + session = None try: steps = [s.text for s in wf.steps if s.text and s.text.strip()] if not steps: @@ -207,6 +208,16 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O "last_run_at": run.finished_at, }) finally: + # Close the workflow's agent session so closed_at is set and the + # run shows up in chat history (get_history sorts by closed_at; + # sessions with closed_at=None sort to the bottom and fall off + # the first page). close_session also drops in-memory state and + # persists the final snapshot to disk. + if session is not None: + try: + await agent_manager.close_session(session.id) + except Exception: + logger.exception("close_session failed for workflow run %s", run.id) async with _running_lock: _running.pop(wf.id, None) diff --git a/backend/main.py b/backend/main.py index 4de8251a..cddc3ca4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -517,11 +517,38 @@ async def mcp_meta(action: str, request: Request): # Auto-continue: read at turn boundary in _run_agent_loop (race-free); collapses the typical 3-prompt flow into 1. session.pending_continuation = True + # Enumerate the just-activated server's callable tool names so the + # continuation turn can call them directly. Without this the model + # often burns a turn on tool-discovery guesses (Bash "mcp list", + # Ls /toolbox, ToolSearch fallbacks) before landing on the right + # mcp__server__action name. Cap at 16 + clip descriptions so the + # prompt stays bounded for kitchen-sink servers (google-workspace + # exposes ~30 tools). Best-effort; any lookup failure silently + # falls back to the same prompt this code shipped with before. + tool_hint = "" + try: + for t in load_all_tools(): + if _sanitize_server_name(t.name) != server_name: + continue + descs = (t.tool_permissions or {}).get("_tool_descriptions", {}) or {} + if not descs: + break + lines: list[str] = [] + for sub_name, desc in list(descs.items())[:16]: + short = (desc or "").strip().split("\n", 1)[0][:120] + visible = f"mcp__{server_name}__{sub_name}" + lines.append(f"- `{visible}`: {short}" if short else f"- `{visible}`") + if lines: + more = "" if len(descs) <= 16 else f"\n(+ {len(descs) - 16} more; call ToolSearch with the server name for the rest)" + tool_hint = "\n\nCallable tools on this server:\n" + "\n".join(lines) + more + break + except Exception: + logger.exception("activate: failed to build tool hint for %s", server_name) session.pending_continuation_prompt = ( "[mcp:auto-continue] The MCP server you requested has been " f"activated (`{server_name}`). Continue with the user's original " "request now using the newly-available tools; do NOT ask " - "for confirmation." + "for confirmation." + tool_hint ) return JSONResponse({"status": "activated", "server_name": server_name, "auto_continue": True})