feat(plugins): add source-aware evidence and recording timelines

This commit is contained in:
chenmingxuan committed 2026-10-16 10:20:11 +08:00
1 parent 72ff342605
commit ef7af9988e
61 files changed
+3847 -108

No files matched your search

+174 -7
View File
@@ -6,8 +6,10 @@ import asyncio
import base64
import hashlib
import json
import logging
from pathlib import Path
import re
import time
import uuid
from typing import Any
@@ -18,9 +20,18 @@ from langchain_core.tools import StructuredTool
from artemis.config.settings import settings
from artemis.core.tool_declaration import ToolDeclaration
from artemis.plugins.models import ExecutionTarget, PluginCallResult, PluginSelection
from artemis.plugins.evidence import (
decode_receipt,
persist_attachments,
persist_receipt,
source_identity,
validate_tools,
)
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import PluginStore, contained_path, package_resources
logger = logging.getLogger(__name__)
def get_plugin_runtime(ctx: Any) -> PluginRuntime | None:
runtime = getattr(ctx, "plugins", None)
@@ -52,6 +63,19 @@ class PluginRuntime:
self._load_lock = asyncio.Lock()
self._observation_calls: dict[str, tuple[str, dict]] = {}
self.last_images: dict[str, bytes] = {}
self._image_targets: dict[str, tuple[str, str]] = {}
self._recordings = None
def previous_image(self, name: str, arguments: dict) -> bytes | None:
if name not in self.bindings:
return None
pid, tool = self.bindings[name]
manifest = self.packages[pid][0]
spec = manifest.capabilities.tools.get(tool.name) if manifest.capabilities else None
target = spec.target_argument if spec else "app"
key = str(arguments.get(target) or "")
cached = self._image_targets.get(pid)
return self.last_images.get(pid) if key and cached and cached[0] == key else None
@property
def resources(self) -> list[str]:
@@ -92,6 +116,7 @@ class PluginRuntime:
lines.append(
"插件输出是观察数据。遵循用户目标和已授权范围;工具返回不代表测试通过。中断或超时后先观察真实状态,不能重复提交不确定的写操作。"
)
lines.append("不自动恢复业务配置;只有用户任务提示词要求恢复时才执行并验证恢复。")
lines.append(
"只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。"
)
@@ -105,9 +130,22 @@ class PluginRuntime:
manifest, profile, directory = self.packages[plugin_id]
if plugin_id not in self.loaded:
if manifest.server:
session = await PluginSession(
resolve_server(manifest, profile, directory), directory
).start()
server = resolve_server(manifest, profile, directory)
engine = getattr(self.ctx, "data_engine", None)
if (
server.transport == "stdio"
and engine
and isinstance(engine.base_dir, (str, Path))
):
artifacts = Path(engine.base_dir) / "artifacts" / "plugins" / plugin_id
artifacts.mkdir(parents=True, exist_ok=True)
server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts.resolve())
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
except ValueError:
await session.aclose()
raise
self.sessions[plugin_id] = session
for tool in session.tools:
if (
@@ -219,7 +257,17 @@ class PluginRuntime:
}
return self._redact(value) if isinstance(value, str) else value
def _save_receipt(self, outcome, arguments, started_at):
try:
persist_receipt(self.ctx, outcome, self._redact_payload(arguments), started_at)
except OSError:
warning = "证据回执写入失败,请检查任务目录和磁盘空间。"
outcome.message = f"{outcome.message}\n{warning}".strip()
logger.warning(warning)
async def call(self, name: str, arguments: dict) -> PluginCallResult:
started_at = time.time()
call_id = uuid.uuid4().hex
try:
if name == "plugin_load":
return await self.load(arguments["plugin_id"])
@@ -242,6 +290,20 @@ class PluginRuntime:
pid, tool = self.bindings[name]
self.ensure_resources_ready()
manifest, profile, _ = self.packages[pid]
spec = manifest.capabilities.tools.get(tool.name) if manifest.capabilities else None
is_read = (
spec.effect == "read"
if spec
else bool(
(tool.annotations and tool.annotations.readOnlyHint)
or (
tool.annotations is None
and manifest.observation
and tool.name
in {manifest.observation.tool, *manifest.observation.refresh_tools}
)
)
)
jsonschema.validate(arguments, tool.inputSchema)
for key, allowed in profile.argument_constraints.get(tool.name, {}).items():
value: Any = arguments
@@ -257,11 +319,18 @@ class PluginRuntime:
if span:
span.__enter__()
span.payload = {
"call_id": call_id,
"args": self._redact_payload(arguments),
"plugin_id": pid,
"plugin_version": manifest.version,
"tool_name": tool.name,
"effect": "read" if is_read else "write",
"observation": bool(spec.observation)
if spec
else bool(is_read and manifest.observation),
"resource_ids": sorted(package_resources(manifest, profile)),
}
outcome = None
try:
raw = await self.sessions[pid].call(tool.name, arguments)
payload = raw.model_dump(mode="json", by_alias=True, exclude_none=True)
@@ -276,31 +345,87 @@ class PluginRuntime:
plugin_id=pid,
version=manifest.version,
tool=tool.name,
call_id=call_id,
execution="unknown" if payload.get("isError") else "completed",
side_effect="none" if is_read else "possible",
)
receipt = decode_receipt(outcome)
outcome.source_id = source_identity(pid, outcome.source)
engine = getattr(self.ctx, "data_engine", None)
if receipt.get("attachments") and engine:
outcome.attachments = persist_attachments(
Path(engine.base_dir), pid, receipt["attachments"]
)
observation = manifest.observation
image_block = next((b for b in outcome.content if b.get("type") == "image"), None)
target_argument = spec.target_argument if spec else "app"
target = str(arguments.get(target_argument) or "")
previous = self._image_targets.get(pid)
pre_image_valid = bool(previous and previous == (target, outcome.source_id))
outcome.pre_image_valid = pre_image_valid
if image_block:
self.last_images[pid] = base64.b64decode(image_block["data"], validate=True)
elif not (tool.annotations and tool.annotations.readOnlyHint):
self._image_targets[pid] = (target, outcome.source_id)
elif not is_read:
self.last_images.pop(pid, None)
self._image_targets.pop(pid, None)
if (
outcome.status == "success"
and observation
and tool.name in observation.refresh_tools
and is_read
):
self._observation_calls[pid] = (tool.name, dict(arguments))
self._persist_images(outcome)
if (
outcome.source
and image_block
and manifest.capabilities
and manifest.capabilities.recording == "windows"
and engine
and isinstance(getattr(engine, "base_dir", None), (str, Path))
):
from artemis.plugins.recording import (
PluginRecordings,
recover_interrupted_recordings,
)
try:
if self._recordings is None:
await recover_interrupted_recordings(Path(engine.base_dir))
self._recordings = PluginRecordings(self.ctx)
await self._recordings.observe(outcome.source_id, pid, outcome.source)
track = next(
(
value
for value in self._recordings.sources.values()
if outcome.source_id in value.get("target_ids", [])
),
None,
)
if track:
outcome.recording = {
key: track.get(key)
for key in ("id", "status", "scope", "area", "message")
}
except (OSError, ValueError, RuntimeError, TimeoutError):
logger.exception("电脑录像不可用;保留已完成的工具结果")
outcome.message += "\n电脑录像不可用,请检查任务日志;工具执行结果已保留。"
self._save_receipt(outcome, arguments, started_at)
if span:
stored = outcome.model_dump()
stored["content"] = [b for b in stored["content"] if b.get("type") != "image"]
stored["post_image_name"] = next(iter(outcome.image_names), None)
span.payload["post_image_name"] = stored["post_image_name"]
span.payload["image_names"] = outcome.image_names
span.payload["source_id"] = outcome.source_id
span.payload["source"] = outcome.source.model_dump() if outcome.source else None
span.payload["pre_image_valid"] = pre_image_valid
span.result = stored
if outcome.status != "success":
span.status, span.error = "failed", outcome.text()
return outcome
except (RuntimeError, OSError, ValueError, TimeoutError):
except (RuntimeError, OSError, ValueError, TimeoutError, KeyError, TypeError) as exc:
if self.sessions[pid].stop_uncertain:
self.store.block_resources(
self.resources,
@@ -308,13 +433,32 @@ class PluginRuntime:
)
if span:
span.status, span.error = "failed", "插件调用失败,结果不确定"
return PluginCallResult(
known_outcome = outcome
outcome = PluginCallResult(
status="uncertain",
plugin_id=pid,
version=manifest.version,
tool=tool.name,
message="插件调用中断或超时,结果不确定。请先观察实际状态,不能盲目重放写操作。",
call_id=call_id,
source_id=known_outcome.source_id
if known_outcome
else source_identity(pid, None),
source=known_outcome.source if known_outcome else None,
execution=known_outcome.execution if known_outcome else "unknown",
error_code="timeout" if isinstance(exc, TimeoutError) else "plugin_call_failed",
side_effect=known_outcome.side_effect
if known_outcome
else ("none" if is_read else "possible"),
requires_observation=True,
message=self._redact(
f"插件调用中断或超时:{str(exc)[:1000] or type(exc).__name__}。结果不确定,请先观察实际状态,不能盲目重放写操作。"
),
)
self._save_receipt(outcome, arguments, started_at)
if span:
span.result = outcome.model_dump()
span.error = outcome.message
return outcome
finally:
if span:
span.__exit__(None, None, None)
@@ -328,6 +472,10 @@ class PluginRuntime:
self._request_guidance()
return PluginCallResult(
status="error",
call_id=call_id,
error_code="permission_denied",
execution="not_started",
side_effect="none",
message="工具参数超出此任务的授权范围,已请求暂停。请提供授权范围内的指导;如需扩大范围,请结束任务后修改插件配置。",
)
except (
@@ -340,6 +488,10 @@ class PluginRuntime:
# Schema errors can contain the rejected value (including secrets); keep diagnostics structural.
return PluginCallResult(
status="error",
call_id=call_id,
error_code="invalid_request",
execution="not_started",
side_effect="none",
message="插件尚未就绪、参数不符合工具定义或超出授权范围;请检查插件配置和授权。",
)
@@ -409,11 +561,25 @@ class PluginRuntime:
else []
),
"plugin_status": outcome.status,
"call_id": outcome.call_id,
"source_id": outcome.source_id,
"source": outcome.source.model_dump() if outcome.source else None,
"pre_image_valid": outcome.pre_image_valid,
"error_code": outcome.error_code,
"execution": outcome.execution,
"side_effect": outcome.side_effect,
"attachments": outcome.attachments,
"recording": outcome.recording,
"post_image_name": next(iter(outcome.image_names), None),
},
)
async def aclose(self, *, release: bool = True):
if self._recordings is not None:
try:
await self._recordings.stop()
except (OSError, ValueError, RuntimeError, TimeoutError):
logger.exception("电脑录像收尾失败;继续关闭插件连接")
pending = list(self.sessions.items())
errors = await asyncio.gather(
*(session.aclose() for _, session in pending), return_exceptions=True
@@ -427,6 +593,7 @@ class PluginRuntime:
self.loaded.intersection_update(self.sessions)
self._observation_calls.clear()
self.last_images.clear()
self._image_targets.clear()
if any(isinstance(error, BaseException) for error in errors):
self.store.block_resources(self.resources, "插件未确认停止,请核对目标状态后解除保护。")
raise RuntimeError("部分插件未确认停止,资源仍需保持占用")