Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
251 lines
9.7 KiB
Python
251 lines
9.7 KiB
Python
"""Versioned plugin contracts shared by the installer, API and task workers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
import platform
|
|
from typing import Any, Annotated, Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
from packaging.specifiers import SpecifierSet
|
|
from packaging.version import Version
|
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
|
|
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
|
|
|
|
|
|
class PluginModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ServerConfig(PluginModel):
|
|
transport: Literal["stdio", "streamable_http"] = "stdio"
|
|
command: str = ""
|
|
args: list[str] = Field(default_factory=list)
|
|
env: dict[str, str] = Field(default_factory=dict)
|
|
url: str = ""
|
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_transport(self):
|
|
if self.transport == "stdio" and not self.command.strip():
|
|
raise ValueError("stdio 插件必须提供启动命令")
|
|
if self.transport == "streamable_http":
|
|
parsed = urlsplit(self.url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
raise ValueError("MCP 地址必须是 HTTP(S) 地址")
|
|
if parsed.username or parsed.password or parsed.fragment:
|
|
raise ValueError("MCP 地址不能包含凭据或片段;请使用密钥配置")
|
|
return self
|
|
|
|
|
|
class ConfigField(PluginModel):
|
|
label: str = Field(min_length=1, max_length=100)
|
|
description: str = Field(default="", max_length=1000)
|
|
secret: bool = False
|
|
required: bool = False
|
|
default: str = ""
|
|
|
|
|
|
ExecutionTarget = Literal["android", "computer", "mixed"]
|
|
|
|
|
|
class PluginObservation(PluginModel):
|
|
tool: str = Field(min_length=1)
|
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
refresh_tools: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class PluginToolContract(PluginModel):
|
|
"""Host-enforced capability description; independent of a tool's prose Skill."""
|
|
|
|
effect: Literal["read", "write"]
|
|
observation: bool = False
|
|
idempotent: bool = False
|
|
target_argument: str = ""
|
|
cancellation: Literal["confirmed", "unknown"] = "unknown"
|
|
evidence: list[Literal["text", "image", "file", "state"]] = Field(default_factory=list)
|
|
|
|
@model_validator(mode="after")
|
|
def observation_is_read_only(self):
|
|
if self.observation and self.effect != "read":
|
|
raise ValueError("自动观察工具必须声明为只读")
|
|
return self
|
|
|
|
|
|
class PluginCapabilities(PluginModel):
|
|
kind: Literal["visual", "terminal", "api", "file", "other"] = "other"
|
|
recording: Literal["none", "windows"] = "none"
|
|
tools: dict[str, PluginToolContract] = Field(default_factory=dict)
|
|
required_tools: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class EvidenceSource(PluginModel):
|
|
kind: Literal["android", "computer", "terminal", "api", "file", "other"] = "other"
|
|
target_id: str = Field(min_length=1, max_length=256)
|
|
label: str = Field(default="", max_length=500)
|
|
captured_at: float | None = None
|
|
width: int = Field(default=0, ge=0, le=32768)
|
|
height: int = Field(default=0, ge=0, le=32768)
|
|
# Physical desktop coordinates, never normalized model coordinates.
|
|
window: dict[str, int] | None = None
|
|
screen: dict[str, int] | None = None
|
|
|
|
|
|
class PluginManifest(PluginModel):
|
|
schema_version: Literal[1, 2] = 1
|
|
id: Identifier
|
|
name: str = Field(min_length=1, max_length=100)
|
|
version: str
|
|
description: str = Field(default="", max_length=4000)
|
|
artemis_version: str = ">=2.0.0,<3.0.0"
|
|
platforms: list[Literal["win32", "linux", "darwin"]] = Field(default_factory=lambda: ["win32"])
|
|
architectures: list[Literal["amd64", "arm64"]] = Field(
|
|
default_factory=lambda: ["amd64", "arm64"]
|
|
)
|
|
source: str = ""
|
|
license: str = ""
|
|
server: ServerConfig | None = None
|
|
skill: str | None = None
|
|
config: dict[Identifier, ConfigField] = Field(default_factory=dict)
|
|
resources: list[str] = Field(default_factory=list)
|
|
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
|
|
observation: PluginObservation | None = None
|
|
capabilities: PluginCapabilities | None = None
|
|
|
|
@field_validator("version")
|
|
@classmethod
|
|
def valid_version(cls, value: str) -> str:
|
|
Version(value)
|
|
if not re.fullmatch(r"[0-9][a-zA-Z0-9.+-]{0,63}", value):
|
|
raise ValueError("插件版本格式无效")
|
|
return value
|
|
|
|
@field_validator("artemis_version")
|
|
@classmethod
|
|
def valid_specifier(cls, value: str) -> str:
|
|
SpecifierSet(value)
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def has_capability(self):
|
|
if self.server is None and self.skill is None:
|
|
raise ValueError("插件至少需要 MCP 服务或 Skill")
|
|
for name, field in self.config.items():
|
|
if field.secret and field.default:
|
|
raise ValueError(f"密钥字段 {name} 不允许默认值")
|
|
if self.schema_version == 1 and self.capabilities is not None:
|
|
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
|
|
if self.schema_version == 2:
|
|
if self.capabilities is None:
|
|
raise ValueError("第二版插件必须声明 capabilities")
|
|
if self.observation:
|
|
for tool in {self.observation.tool, *self.observation.refresh_tools}:
|
|
spec = self.capabilities.tools.get(tool)
|
|
if spec is None or not spec.observation or spec.effect != "read":
|
|
raise ValueError(f"观察工具 {tool} 缺少只读能力声明")
|
|
if self.capabilities.recording != "none" and self.capabilities.kind != "visual":
|
|
raise ValueError("只有视觉插件可以声明屏幕录像")
|
|
return self
|
|
|
|
def check_compatible(self, artemis_version: str | None = None) -> None:
|
|
from artemis._version import __version__
|
|
|
|
artemis_version = artemis_version or __version__
|
|
if Version(artemis_version) not in SpecifierSet(self.artemis_version):
|
|
raise ValueError(f"插件不兼容 ARTEMIS {artemis_version}")
|
|
if sys.platform not in self.platforms:
|
|
raise ValueError(f"插件不支持当前平台 {sys.platform}")
|
|
architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(
|
|
platform.machine().lower(), platform.machine().lower()
|
|
)
|
|
if architecture not in self.architectures:
|
|
raise ValueError(f"插件不支持当前处理器架构 {architecture}")
|
|
|
|
|
|
class PluginProfile(PluginModel):
|
|
name: Identifier = "default"
|
|
label: str = "默认配置"
|
|
is_default: bool = False
|
|
values: dict[str, str] = Field(default_factory=dict)
|
|
# Only a write payload contains secrets; read responses expose configured names.
|
|
secrets: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
|
|
allowed_tools: list[str] | None = None
|
|
argument_constraints: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
|
|
resources: list[str] = Field(default_factory=list)
|
|
authorized: bool = False
|
|
|
|
|
|
class PluginSelection(PluginModel):
|
|
id: Identifier
|
|
profile: Identifier = "default"
|
|
version: str | None = None
|
|
|
|
|
|
class CatalogEntry(PluginModel):
|
|
manifest: PluginManifest
|
|
release_stage: Literal["stable", "preview"] = "stable"
|
|
archive_url: str
|
|
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
verification: str = Field(min_length=1, max_length=4000)
|
|
|
|
@field_validator("archive_url")
|
|
@classmethod
|
|
def https_archive(cls, value: str) -> str:
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
|
raise ValueError("市场安装包必须使用不含凭据的 HTTPS 地址")
|
|
return value
|
|
|
|
|
|
class PluginCatalog(PluginModel):
|
|
schema_version: Literal[1] = 1
|
|
plugins: list[CatalogEntry] = Field(default_factory=list)
|
|
|
|
|
|
class PluginCallResult(PluginModel):
|
|
status: Literal["success", "error", "uncertain"]
|
|
content: list[dict[str, Any]] = Field(default_factory=list)
|
|
structured_content: dict[str, Any] | None = None
|
|
message: str = ""
|
|
plugin_id: str = ""
|
|
version: str = ""
|
|
tool: str = ""
|
|
image_names: list[str] = Field(default_factory=list)
|
|
call_id: str = ""
|
|
text_ref: str = ""
|
|
source: EvidenceSource | None = None
|
|
source_id: str = ""
|
|
error_code: str = ""
|
|
execution: Literal["not_started", "completed", "partial", "unknown"] = "unknown"
|
|
side_effect: Literal["none", "possible", "confirmed"] = "possible"
|
|
retryable: bool = False
|
|
requires_observation: bool = False
|
|
attachments: list[dict[str, Any]] = Field(default_factory=list)
|
|
pre_image_valid: bool = False
|
|
recording: dict[str, Any] | None = None
|
|
|
|
def text(self) -> str:
|
|
import json
|
|
|
|
parts = list(
|
|
dict.fromkeys(
|
|
str(block["text"]) for block in self.content if block.get("type") == "text"
|
|
)
|
|
)
|
|
if self.structured_content is not None:
|
|
# MCP commonly returns the same accessibility tree in both channels.
|
|
# Keep one exact copy, without changing the persisted raw response.
|
|
visible_text = "\n".join(parts)
|
|
structured = {
|
|
key: "[identical text already included above]"
|
|
if isinstance(value, str) and len(value) >= 256 and value in visible_text
|
|
else value
|
|
for key, value in self.structured_content.items()
|
|
}
|
|
parts.append(json.dumps(structured, ensure_ascii=False))
|
|
if self.message:
|
|
parts.append(self.message)
|
|
return "\n".join(parts) or "工具已返回;请根据证据判断业务结果。"
|