++
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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], ...]] = []
|
||||
|
||||
Reference in New Issue
Block a user