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
@@ -2,12 +2,11 @@
from __future__ import annotations
import hashlib
import json
import re
import shutil
import tempfile
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from pathlib import Path
from threading import Lock, Thread
from urllib.parse import urlparse
@@ -18,6 +17,7 @@ from mulit_agent_app.infrastructure.controller_session import ControllerSession
from mulit_agent_app.infrastructure.credential_store import CredentialStore
from mulit_agent_app.infrastructure.database import AgentDatabase
from mulit_agent_app.infrastructure.mcp_client import McpClient, McpClientError, open_client
from mulit_agent_app.infrastructure.process_restart import start_replacement
from mulit_agent_app.infrastructure.provider_factory import build_provider
from mulit_agent_app.infrastructure.tool_registry import McpEndpoint
@@ -106,7 +106,7 @@ def _validated_tool_arguments(value: object) -> list[str]:
class AgentService:
def __init__(self, config: AgentConfig) -> None:
def __init__(self, config: AgentConfig, restart_process: Callable[[], bool] = start_replacement, on_restarted: Callable[[], None] | None = None) -> None:
self._config = config
self._credential_store = CredentialStore()
self.session = ControllerSession(config, self._credential_store)
@@ -114,11 +114,16 @@ class AgentService:
self.task_executor = TaskExecutor(self._database)
self._mcp_lock = Lock()
self._mcp_sequence = 0
self._restart_lock = Lock()
self._restart_request_id = ""
self._restart_process = restart_process
self._on_restarted = on_restarted
self._restore_controller_llm_config()
self._restore_skill_context()
self._restore_mcp_tools()
self.session.task_dispatched.connect(self.task_executor.submit)
self.session.task_cancelled.connect(self.task_executor.cancel)
self.session.restart_requested.connect(self.restart)
self.session.config_snapshot_received.connect(self.apply_config_snapshot)
self.session.skill_snapshot_received.connect(self.apply_skill_snapshot)
self.session.mcp_snapshot_received.connect(self.apply_mcp_snapshot)
@@ -168,11 +173,48 @@ class AgentService:
def reconnect(self) -> None:
self.session.reconnect()
def set_restart_callback(self, callback: Callable[[], None]) -> None:
"""Install the UI shutdown callback used after a replacement starts."""
self._on_restarted = callback
def close(self) -> None:
"""Stop the session and release every deployed MCP connection."""
self.session.close()
self.task_executor.close()
def restart(self, request_id: object) -> None:
"""Perform an authenticated, idempotent local restart sequence."""
if not isinstance(request_id, str) or not request_id:
return
with self._restart_lock:
if self._restart_request_id:
# The controller may retry the same message while the first is
# in flight. Acknowledge it without spawning another process.
self.session.send_restart_ack(request_id, "restarting")
return
self._restart_request_id = request_id
Thread(target=self._restart_worker, args=(request_id,), name="agent-restart", daemon=True).start()
def _restart_worker(self, request_id: str) -> None:
reason = "Agent 正在重启,任务已中止。"
self.task_executor.cancel_all(reason)
# Cancellation events are durable in the outbox; make a best effort to
# deliver them before the old socket is closed.
self.session.send_restart_ack(request_id, "accepted")
self.task_executor.close()
self.session.close()
if not self._restart_process():
# The old process remains alive. Reconnect it so the durable failure
# acknowledgement reaches the controller instead of leaving a
# permanently ambiguous "重启中" state.
self.session.resume_after_failed_restart()
self.session.send_restart_ack(request_id, "failed", "无法启动替代 Agent 进程。")
with self._restart_lock:
self._restart_request_id = ""
return
if self._on_restarted is not None:
self._on_restarted()
def apply_config_snapshot(self, payload: object) -> None:
if not isinstance(payload, dict):
return
@@ -235,12 +277,11 @@ class AgentService:
name = self._required_text(item, "name")
version = self._required_text(item, "version")
content = self._required_text(item, "content")
checksum = self._required_text(item, "checksum")
if len(content) > MAX_SKILL_CONTENT_CHARS or not re.fullmatch(r"[a-f0-9]{64}", checksum):
raise ValueError("Skill 内容或校验值无效。")
actual_checksum = hashlib.sha256(content.encode("utf-8")).hexdigest()
if actual_checksum != checksum:
raise ValueError("Skill 校验失败。")
if len(content) > MAX_SKILL_CONTENT_CHARS:
raise ValueError("Skill 内容过长。")
# Skill 内容以控制端本次快照为准,直接原子覆盖本地文件。
# 不校验 checksum:本地允许人工调整,且控制端下次推送应能
# 无条件覆盖,而不因换行或本地修改而拒绝新内容。
destination = self._config.data_dir / "skills" / skill_id
destination.mkdir(parents=True, exist_ok=True)
target = destination / "SKILL.md"
@@ -250,7 +291,6 @@ class AgentService:
temporary_path.replace(target)
self._database.set_meta(f"skill.{skill_id}.name", name[:80])
self._database.set_meta(f"skill.{skill_id}.version", version)
self._database.set_meta(f"skill.{skill_id}.checksum", checksum)
except (OSError, RuntimeError, ValueError) as error:
if isinstance(skill_id, str):
self.session.send_skill_ack(skill_id, "failed", str(error))
@@ -150,6 +150,7 @@ class TaskExecutor:
self._full_access_tool,
tuple(endpoint.bridge for endpoint in endpoints),
on_progress=self._report_progress(task_id),
is_active=lambda: self._database.task_status(task_id) == "running",
)
try:
@@ -205,10 +206,26 @@ class TaskExecutor:
return report
def cancel(self, task_id: str, reason: str = "任务已由总控取消。") -> None:
if self._database.task_status(task_id) not in {"accepted", "running"}:
return
self._full_access_tool.cancel(task_id)
self._finish(task_id, "cancelled", reason)
with self._lock:
current = self._database.task_status(task_id)
if current is None:
# Dispatch and cancel may cross on a reconnect. The controller
# owns the task record, so explicitly acknowledge its terminal
# cancellation even when this Agent has not accepted it yet.
self._database.cancel_unreceived_task(task_id, reason, self._now())
self.task_event.emit({"task_id": task_id, "status": "cancelled", "message": reason})
return
if current not in {"accepted", "running"}:
return
self._full_access_tool.cancel(task_id)
self._finish(task_id, "cancelled", reason)
def cancel_all(self, reason: str = "Agent 正在重启,任务已中止。") -> list[str]:
"""End every local task before lifecycle resources are closed."""
task_ids = self._database.active_task_ids()
for task_id in task_ids:
self.cancel(task_id, reason)
return task_ids
def _timeout(self, task_id: str) -> None:
if self._database.task_status(task_id) not in {"accepted", "running"}:
@@ -224,11 +241,16 @@ class TaskExecutor:
error: str | None = None,
execution_source: str | None = None,
) -> None:
self._cancel_timers(task_id)
self._cancel_deadline_timer(task_id)
with self._lock:
# Terminal state is write-once. Serialising the status check and
# write also prevents a late model result racing a user cancel.
current = self._database.task_status(task_id)
if current in {"completed", "failed", "cancelled", "timed_out"}:
return
self._cancel_timers(task_id)
self._cancel_deadline_timer(task_id)
self._deadline_windows.pop(task_id, None)
self._database.update_task(task_id, status, message, self._now(), output=output, error=error, execution_source=execution_source)
self._database.update_task(task_id, status, message, self._now(), output=output, error=error, execution_source=execution_source)
event: dict[str, object] = {"task_id": task_id, "status": status, "message": message}
if output is not None:
event["output"] = output
@@ -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:
+4
View File
@@ -12,6 +12,8 @@ from mulit_agent_app.presentation.tray import AgentTray
def main() -> int:
config = AgentConfig.load()
configure_logging(config.log_path)
# The service starts the replacement first; only then does this callback
# close the old UI/process. Tk work stays on its own event loop.
service = AgentService(config)
window = ConnectWindow(service)
tray = AgentTray()
@@ -25,6 +27,8 @@ def main() -> int:
tray.stop()
window.root.after(0, window.root.destroy)
service.set_restart_callback(quit_application)
tray.open_settings_requested.connect(lambda: window.root.after(0, window.show))
tray.reconnect_requested.connect(service.reconnect)
tray.quit_requested.connect(quit_application)
+34
View File
@@ -0,0 +1,34 @@
from threading import Event
from time import monotonic, sleep
from mulit_agent_app.application.agent_service import AgentService
from mulit_agent_app.config import AgentConfig
def _config(tmp_path) -> AgentConfig:
data = tmp_path / "agent"
return AgentConfig(data, data / "settings.json", data / "agent.sqlite3", data / "agent.log", agent_name="restart-test")
def test_duplicate_restart_requests_start_only_one_replacement_and_cancel_work(tmp_path):
started, old_exited = [], Event()
service = AgentService(_config(tmp_path), restart_process=lambda: started.append("replacement") or True, on_restarted=old_exited.set)
service.task_executor.submit("restart-task", "应因 Agent 重启中止")
service.restart("restart-1")
service.restart("restart-1")
assert old_exited.wait(2)
assert started == ["replacement"]
assert service.task_executor._database.task_status("restart-task") == "cancelled"
def test_restart_failure_does_not_exit_old_agent_or_start_another_process(tmp_path):
old_exited = Event()
service = AgentService(_config(tmp_path), restart_process=lambda: False, on_restarted=old_exited.set)
service.restart("restart-fail")
deadline = monotonic() + 2
while service._restart_request_id and monotonic() < deadline:
sleep(0.01)
assert not old_exited.is_set()
assert service._restart_request_id == ""
+23 -3
View File
@@ -1,5 +1,3 @@
import hashlib
from mulit_agent_app.application.agent_service import AgentService
from mulit_agent_app.config import AgentConfig
from mulit_agent_app.infrastructure.database import AgentDatabase
@@ -40,7 +38,8 @@ def test_chinese_skill_name_installs_and_empty_snapshot_removes_it(tmp_path):
"name": "图生图工具",
"version": "0.3.2",
"content": content,
"checksum": hashlib.sha256(content.encode("utf-8")).hexdigest(),
# 校验值只供协议兼容;Agent 必须接受控制端内容并覆盖本地文件。
"checksum": "outdated-checksum",
}
]
}
@@ -50,6 +49,27 @@ def test_chinese_skill_name_installs_and_empty_snapshot_removes_it(tmp_path):
assert service._database.get_meta(f"skill.{skill_id}.name") == "图生图工具"
assert service.session.acks == [(skill_id, "applied", "")]
# 人工本地改动不是错误;下一份控制端快照应直接覆盖它。
target = config.data_dir / "skills" / skill_id / "SKILL.md"
target.write_text("# 本地临时修改", encoding="utf-8")
replacement = "# 总控下发版本\n\n应覆盖本地内容。"
service.apply_skill_snapshot(
{
"skills": [
{
"id": skill_id,
"name": "图生图工具",
"version": "0.3.3",
"content": replacement,
"checksum": "another-outdated-checksum",
}
]
}
)
assert target.read_text(encoding="utf-8") == replacement
assert service.session.acks[-1] == (skill_id, "applied", "")
service.apply_skill_snapshot({"skills": []})
assert not (config.data_dir / "skills" / skill_id).exists()
+19
View File
@@ -15,6 +15,25 @@ from mulit_agent_app.infrastructure.tool_registry import Toolbox
_HANDLERS = {spec.name: spec.handler for spec in specs()}
def test_builtin_powershell_helpers_hide_the_console_window(monkeypatch):
calls: list[dict[str, object]] = []
class Result:
stdout = "done"
stderr = ""
returncode = 0
def fake_run(*_args, **kwargs):
calls.append(kwargs)
return Result()
monkeypatch.setattr(builtin_tools.subprocess, "run", fake_run)
monkeypatch.setattr(builtin_tools.subprocess, "CREATE_NO_WINDOW", 42, raising=False)
assert builtin_tools._run_powershell("Write-Output done") == "done"
assert calls[0]["creationflags"] == 42
def test_preset_tool_set_covers_file_open_time_process_screenshot_and_clipboard():
names = set(_HANDLERS)
assert {
@@ -18,6 +18,26 @@ def test_full_access_tool_redacts_secrets_from_command_output():
assert "[已脱敏]" in result
def test_full_access_tool_hides_the_powershell_window(monkeypatch):
calls: list[dict[str, object]] = []
class Process:
returncode = 0
def communicate(self):
return "done", ""
def fake_popen(*_args, **kwargs):
calls.append(kwargs)
return Process()
monkeypatch.setattr("mulit_agent_app.infrastructure.full_access_tool.subprocess.Popen", fake_popen)
monkeypatch.setattr("mulit_agent_app.infrastructure.full_access_tool.subprocess.CREATE_NO_WINDOW", 42, raising=False)
assert FullAccessTool().execute("task-hidden", "Write-Output done") == "done"
assert calls[0]["creationflags"] == 42
def test_full_access_tool_stages_and_removes_task_attachments():
from mulit_agent_app.domain.attachments import TaskAttachment
@@ -1,6 +1,12 @@
import json
from mulit_agent_app.infrastructure.openai_compatible_provider import OpenAICompatibleProvider
from mulit_agent_app.infrastructure.openai_compatible_provider import (
_MAX_TOOL_ROUNDS,
OpenAICompatibleProvider,
)
from mulit_agent_app.infrastructure.provider_factory import (
_MAX_TOOL_ROUNDS as PROVIDER_FACTORY_MAX_TOOL_ROUNDS,
)
class _Credentials:
@@ -19,6 +25,11 @@ class _Response:
return json.dumps({"choices": [{"message": {"content": " 模型结果 "}}]}).encode("utf-8")
def test_all_provider_tool_round_limits_allow_long_desktop_workflows():
assert _MAX_TOOL_ROUNDS == 200
assert PROVIDER_FACTORY_MAX_TOOL_ROUNDS == 200
def test_provider_posts_a_chat_completion(monkeypatch, tmp_path):
captured = {}
+25
View File
@@ -266,6 +266,31 @@ def test_executor_ignores_progress_after_the_task_ended(tmp_path):
assert len(events) == reported, "结束后的任务不应再产生中间步骤"
def test_late_model_result_cannot_overwrite_a_cancelled_terminal_state(tmp_path):
database = AgentDatabase(tmp_path / "agent.sqlite3")
started, release, returned, cancelled = Event(), Event(), Event(), Event()
def runner(_instruction: str) -> str:
started.set()
assert release.wait(2)
returned.set()
return "这是取消后才返回的模型结果"
executor = TaskExecutor(database, runner)
executor.task_event.connect(
lambda event: cancelled.set()
if isinstance(event, dict) and event.get("status") == "cancelled"
else None
)
executor.submit("late-result", "等待模型返回")
assert started.wait(2)
executor.cancel("late-result", "测试取消")
assert cancelled.wait(1)
release.set()
assert returned.wait(1)
assert database.task_status("late-result") == "cancelled"
def test_executor_passes_the_conversation_history_to_the_provider(tmp_path):
database = AgentDatabase(tmp_path / "agent.sqlite3")
received: list[tuple[tuple[str, str], ...]] = []