This commit is contained in:
toot
2026-09-16 18:11:48 +08:00
parent 9e01cbbec0
commit ada97a7497
27 changed files with 661 additions and 45 deletions
@@ -95,6 +95,9 @@ def _run_powershell(script: str, environment: dict[str, str] | None = None) -> s
timeout=_POWERSHELL_TIMEOUT,
check=False,
env={**os.environ, **(environment or {})},
# Built-in desktop helpers also use PowerShell. They must remain
# invisible just like model-requested full-access commands.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
output = "\n".join(part for part in ((process.stdout or "").strip(), (process.stderr or "").strip()) if part)
if process.returncode:
@@ -54,6 +54,7 @@ class ControllerSession:
self.config_snapshot_received = EventHook()
self.skill_snapshot_received = EventHook()
self.mcp_snapshot_received = EventHook()
self.restart_requested = EventHook()
def update_agent_name(self, agent_name: str) -> None:
self._config = replace(self._config, agent_name=agent_name)
@@ -90,6 +91,12 @@ class ControllerSession:
self._cancel_reconnect()
self._close_socket()
def resume_after_failed_restart(self) -> None:
"""Re-open the authenticated session when no replacement was created."""
self._stopping.clear()
self._reconnect_disabled = False
self.reconnect()
def _close_socket(self) -> None:
self._heartbeat_stop.set()
with self._socket_lock:
@@ -177,6 +184,8 @@ class ControllerSession:
self._handle_task_dispatch(payload)
elif message_type == "task_cancel":
self._handle_task_cancel(payload)
elif message_type == "agent_restart":
self._handle_restart(payload)
elif message_type == "config_snapshot":
self.config_snapshot_received.emit(payload)
elif message_type == "skill_snapshot":
@@ -335,6 +344,13 @@ class ControllerSession:
return
self.task_cancelled.emit(task_id, reason if isinstance(reason, str) else "任务已由总控取消。")
def _handle_restart(self, payload: dict[str, object]) -> None:
request_id = payload.get("request_id")
if not isinstance(request_id, str) or not request_id.strip() or len(request_id) > 128:
self.connection_error.emit("总控下发了无效重启指令,已忽略。")
return
self.restart_requested.emit(request_id)
def send_task_event(self, event: object) -> None:
if not isinstance(event, dict):
return
@@ -369,6 +385,16 @@ class ControllerSession:
payload["error"] = error[:1000]
self._send(payload)
def send_restart_ack(self, request_id: str, status: str, error: str = "") -> None:
payload: dict[str, object] = {"type": "agent_restart_ack", "request_id": request_id, "status": status}
if error:
payload["error"] = error[:1000]
if not self._send(payload):
# A failed replacement attempt happens after the old socket was
# deliberately closed. Preserve the acknowledgement and replay it
# after this still-running Agent reconnects.
self._database.enqueue_event(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), datetime.now(UTC).isoformat())
def _flush_pending_task_events(self) -> None:
for event_id, raw in self._database.pending_events():
try:
@@ -71,6 +71,14 @@ class AgentDatabase:
row = connection.execute("SELECT status FROM task_runs WHERE task_id = ?", (task_id,)).fetchone()
return str(row[0]) if row is not None else None
def active_task_ids(self) -> list[str]:
"""Return only work which can still be stopped by this Agent."""
with sqlite3.connect(self._database_path) as connection:
rows = connection.execute(
"SELECT task_id FROM task_runs WHERE status IN ('accepted', 'running')"
).fetchall()
return [str(row[0]) for row in rows]
def create_task(self, task_id: str, instruction: str, received_at: str) -> None:
with sqlite3.connect(self._database_path) as connection:
connection.execute(
@@ -79,6 +87,15 @@ class AgentDatabase:
)
self._record_event(connection, task_id, "accepted", "任务已接收。", received_at)
def cancel_unreceived_task(self, task_id: str, reason: str, occurred_at: str) -> None:
"""Store a cancellation tombstone when cancel overtakes dispatch."""
with sqlite3.connect(self._database_path) as connection:
connection.execute(
"INSERT OR IGNORE INTO task_runs(task_id, instruction, status, received_at, finished_at, error) VALUES (?, '', 'cancelled', ?, ?, ?)",
(task_id, occurred_at, occurred_at, reason),
)
self._record_event(connection, task_id, "cancelled", reason, occurred_at)
def get_meta(self, key: str) -> str:
with sqlite3.connect(self._database_path) as connection:
row = connection.execute("SELECT value FROM application_meta WHERE key = ?", (key,)).fetchone()
@@ -53,6 +53,10 @@ class FullAccessTool:
text=True,
encoding="utf-8",
errors="replace",
# The Agent is a desktop application, not a terminal application.
# Keep model-requested PowerShell work in the background so each
# tool call does not steal focus or flash a console window.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
with self._lock:
self._processes[task_id] = process
@@ -19,6 +19,11 @@ class ProviderError(RuntimeError):
_THINK_BLOCK = re.compile(r"<think(?:\s[^>]*)?>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
_UNCLOSED_THINK = re.compile(r"<think(?:\s[^>]*)?>.*$", re.IGNORECASE | re.DOTALL)
# Desktop UI workflows often require several observe → act → verify cycles.
# Keep a finite guard against runaway model loops, while allowing complete
# multi-step workflows such as image generation to finish.
_MAX_TOOL_ROUNDS = 200
def visible_model_output(content: str) -> str:
"""Remove provider reasoning traces before any task result leaves the Agent."""
@@ -66,8 +71,14 @@ class OpenAICompatibleProvider:
for spec in toolbox.specs()
]
request_body["tool_choice"] = "auto"
for _ in range(12):
for _ in range(_MAX_TOOL_ROUNDS):
if toolbox is not None and not toolbox.is_active():
# A request in flight cannot always be aborted by its provider,
# but it must never lead to a further request or tool call.
return ""
response_payload = self._request_json(request_body, api_key)
if toolbox is not None and not toolbox.is_active():
return ""
try:
message = response_payload["choices"][0]["message"]
except (KeyError, IndexError, TypeError) as error:
@@ -90,6 +101,8 @@ class OpenAICompatibleProvider:
}
)
for call in tool_calls:
if not toolbox.is_active():
return ""
if not isinstance(call, dict):
continue
call_id = call.get("id")
@@ -0,0 +1,36 @@
"""Start a replacement Agent process without exposing a console window."""
from __future__ import annotations
import os
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
def start_replacement(
popen: Callable[..., object] = subprocess.Popen,
executable: str | None = None,
arguments: list[str] | None = None,
) -> bool:
"""Return only after the replacement process has been created successfully.
The controller never calls this: it runs solely inside the authenticated
Agent. ``CREATE_NO_WINDOW`` prevents a console flash on Windows.
"""
program = executable or sys.executable
argv = [program, *(arguments if arguments is not None else sys.argv[1:])]
try:
popen(
argv,
cwd=str(Path.cwd()),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=os.name != "nt",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError:
return False
return True
@@ -14,7 +14,9 @@ from mulit_agent_app.infrastructure.openai_compatible_provider import (
)
from mulit_agent_app.infrastructure.tool_registry import as_toolbox, system_prompt
_MAX_TOOL_ROUNDS = 12
# Desktop UI workflows often require several observe → act → verify cycles.
# This remains finite to protect against runaway model loops.
_MAX_TOOL_ROUNDS = 200
def build_provider(provider: str, base_url: str, model: str, credential_store: CredentialStore):
@@ -127,10 +127,12 @@ class Toolbox:
bridges: tuple[McpToolBridge, ...] = (),
with_builtin: bool = True,
on_progress: Callable[[str], None] | None = None,
is_active: Callable[[], bool] | None = None,
) -> None:
self._task_id = task_id
self._full_access = full_access
self._on_progress = on_progress
self._is_active = is_active or (lambda: True)
self.used_full_access = False
self.used_mcp = False
self.used_builtin = False
@@ -168,6 +170,10 @@ class Toolbox:
def call(self, name: str, arguments: object) -> str:
"""Dispatch one model tool call. Tool failures never take down the task."""
# A model response may arrive after cancellation. Do not turn that
# late response into another local/MCP side effect.
if not self.is_active():
return "任务已取消;忽略迟到的工具调用。"
tool = self._tools.get(name)
handler = self._handlers.get(name)
if tool is None or handler is None:
@@ -184,6 +190,12 @@ class Toolbox:
self._report(f"{name} 返回:{_summarise_result(result)}")
return result
def is_active(self) -> bool:
try:
return bool(self._is_active())
except Exception: # noqa: BLE001 - fail closed around cancellation.
return False
def _report(self, message: str) -> None:
"""Stream one step to the controller. Reporting must never break a task."""
if self._on_progress is None: