++
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user