feat(plugins): add source-aware evidence and recording timelines
This commit is contained in:
1 parent
72ff342605
commit
ef7af9988e
61 files changed
+3847
-108
No files matched your search
+174
-7
@@ -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("部分插件未确认停止,资源仍需保持占用")
|
||||
|
||||
Reference in new issue
Block a user