P1: host-agnostic quercus-core with agent loop, providers, and tool schemas

- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep)
- Config: ~/.quercus/config.toml with 0600 perms, env var override for CI
- LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint
  (covers custom gateways and llama.cpp server mode); graceful degradation
  without API keys
- Tool schemas: 26 curated tools with JSON Schema and read_only flags
- AgentLoop: sync dialogue loop with confirmation gate (read-only batches
  bypass, rejected mutations are reported back to the LLM), PNG frame
  feedback for multimodal providers
- Session log: JSONL recording with frame sha256 hashes, replayable
- HostAdapter ABC (signatures only, aligned with OPP/1 method families)
- Mock LLM provider (scripted/replay/record) for network-free CI

Tests: 69 passed via uv run pytest (no network access)
This commit is contained in:
2026-08-25 02:06:10 +08:00
parent c9870b4faf
commit e0db5ab2cf
33 changed files with 3957 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "quercus-core"
version = "0.1.0"
description = "Quercus AI 剪辑助手 — 宿主无关的 AI 核心(LLMProvider / Agent / 工具 schema / 会话)"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27,<1.0",
"anthropic>=0.40,<1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/quercus_core"]
+3
View File
@@ -0,0 +1,3 @@
"""Quercus AI 剪辑助手 — 宿主无关的 AI 核心(P1 范围)。"""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
"""Agent 编排:对话 loop、工具派发、确认门、视觉闭环。"""
from quercus_core.agent.loop import AgentLoop, AgentResult, ToolExecutor
__all__ = ["AgentLoop", "AgentResult", "ToolExecutor"]
+132
View File
@@ -0,0 +1,132 @@
"""Agent 编排:同步对话 loop + 工具派发 + 确认门 + 视觉闭环。
流程:user 消息 → provider → 有 tool_calls 则整段包成 ActionBatch 进待确认
清单 → 经注入的 confirm 回调批准后执行变更类工具(只读工具直接执行)→
工具结果(可含 PNG 帧图)回喂 provider → 直到无 tool_call。
LLM 看不到事务/快照细节(计划文档 §3.2–3.3)。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Protocol
from uuid import uuid4
from quercus_core.providers.base import AssistantTurn, Message, Role, ToolCall, ToolSchema
from quercus_core.session.log import SessionLog
from quercus_core.tools.schemas import TOOLS, tool_read_only
from quercus_core.types import Action, ActionBatch, ToolResult
class ToolExecutor(Protocol):
"""工具执行协议。P1 不提供真实宿主实现,测试用内存 stub。"""
def execute(self, action: Action) -> ToolResult: ...
@dataclass
class AgentResult:
final_text: str
turns: int
tool_calls_executed: int
class AgentLoop:
def __init__(
self,
*,
provider,
executor: ToolExecutor,
confirm: Callable[[ActionBatch], bool] | None = None,
session: SessionLog | None = None,
tool_schemas: list[ToolSchema] | None = None,
max_turns: int = 12,
) -> None:
self._provider = provider
self._executor = executor
self._confirm = confirm # None => 默认全开(计划文档 P1.3 "默认全开")
self._session = session
self._schemas = list(tool_schemas) if tool_schemas is not None else list(TOOLS.values())
self._max_turns = max_turns
# 供测试/审计使用
self.batch_decisions: list[tuple[ActionBatch, bool]] = []
def run(self, user_text: str) -> AgentResult:
"""执行一轮对话,返回最终助手文本与统计。"""
messages: list[Message] = [Message(role=Role.USER, text=user_text)]
if self._session:
self._session.log_user(user_text)
executed = 0
for turn_no in range(1, self._max_turns + 1):
turn = self._provider.generate(messages, tools=self._schemas)
if self._session:
self._session.log_assistant(turn)
if not turn.tool_calls:
messages.append(Message(role=Role.ASSISTANT, text=turn.text))
return AgentResult(final_text=turn.text, turns=turn_no, tool_calls_executed=executed)
messages.append(
Message(role=Role.ASSISTANT, text=turn.text, tool_calls=turn.tool_calls)
)
batch = self._build_batch(turn.tool_calls)
approved = self._approve(batch)
for action in batch.actions:
result = self._dispatch(action, approved=approved)
if self._session:
self._session.log_tool_result(result)
messages.append(
Message(
role=Role.TOOL,
text=result.summary,
images=result.images,
tool_call_id=action.call_id,
name=action.tool,
)
)
executed += 1
# 达到 max_turns 仍未结束
return AgentResult(final_text="", turns=self._max_turns, tool_calls_executed=executed)
# ---- 内部 ----
@staticmethod
def _build_batch(calls: tuple[ToolCall, ...]) -> ActionBatch:
actions = [
Action(tool=tc.name, params=dict(tc.arguments), call_id=tc.id) for tc in calls
]
label = "、".join(sorted({a.tool for a in actions}))
return ActionBatch(label=f"AI 动作:{label}", actions=actions, id=uuid4().hex)
def _approve(self, batch: ActionBatch) -> bool:
"""确认门:整段 ActionBatch 经 confirm 回调批准后才执行变更类工具。
纯只读批次无需确认;未注入 confirm 回调时默认全开。
"""
if all(tool_read_only(a.tool) for a in batch.actions):
approved = True
elif self._confirm is None:
approved = True
else:
approved = bool(self._confirm(batch))
self.batch_decisions.append((batch, approved))
if self._session:
self._session.log_batch_decision(batch, approved)
return approved
def _dispatch(self, action: Action, *, approved: bool) -> ToolResult:
tool = TOOLS.get(action.tool)
if tool is None:
return ToolResult(
tool=action.tool, ok=False, summary=f"未知工具 {action.tool!r}"
)
if tool.read_only:
return self._executor.execute(action) # 只读工具可直接执行
if not approved:
return ToolResult(
tool=action.tool,
ok=False,
summary=f"{action.tool} 未执行:动作被用户拒绝(已进入待确认清单)",
)
return self._executor.execute(action)
+126
View File
@@ -0,0 +1,126 @@
"""配置与密钥管理。
- 配置文件:``~/.quercus/config.toml``(权限 0600,不入库)。
- 环境变量仅作 CI/无头覆盖,优先级:环境变量 > 配置文件。
- 密钥绝不写日志、绝不写任何宿主侧文件(设计铁律 4)。
- 无 key 时优雅降级:provider 注册表报告 unavailable(见 providers/)。
"""
from __future__ import annotations
import json
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
# 环境变量名(计划文档 P1 交付项 2)。
ENV_ANTHROPIC_API_KEY = "QUERCUS_ANTHROPIC_API_KEY"
ENV_OPENAI_API_KEY = "QUERCUS_OPENAI_API_KEY"
ENV_OPENAI_BASE_URL = "QUERCUS_OPENAI_BASE_URL"
# QUERCUS_HOME 仅用于测试/CI 覆盖 ~ 的解析,与配置密钥无关。
ENV_QUERCUS_HOME = "QUERCUS_HOME"
# 配置文件键(与 pyproject 无关,纯用户配置)。
_KEY_ANTHROPIC = "anthropic_api_key"
_KEY_OPENAI = "openai_api_key"
_KEY_OPENAI_BASE_URL = "openai_base_url"
_CONFIG_KEYS = (_KEY_ANTHROPIC, _KEY_OPENAI, _KEY_OPENAI_BASE_URL)
# 环境变量 -> 配置键 的覆盖表(保持唯一来源)。
_ENV_OVERRIDES = {
ENV_ANTHROPIC_API_KEY: _KEY_ANTHROPIC,
ENV_OPENAI_API_KEY: _KEY_OPENAI,
ENV_OPENAI_BASE_URL: _KEY_OPENAI_BASE_URL,
}
@dataclass
class Config:
"""合并后的配置;密钥字段可为 None 表示未配置。"""
anthropic_api_key: str | None = None
openai_api_key: str | None = None
openai_base_url: str | None = None
path: Path | None = None # 来源文件(若存在)
@property
def has_anthropic_key(self) -> bool:
return bool(self.anthropic_api_key)
@property
def has_openai_key(self) -> bool:
return bool(self.openai_api_key)
def config_home() -> Path:
"""``~/.quercus`` 目录(QUERCUS_HOME 仅供测试覆盖 HOME)。"""
base = os.environ.get(ENV_QUERCUS_HOME) or os.path.expanduser("~")
return Path(base) / ".quercus"
def default_config_path() -> Path:
return config_home() / "config.toml"
def default_sessions_dir() -> Path:
return config_home() / "sessions"
def _enforce_0600(path: Path) -> None:
"""把文件权限收敛为 0600(读与写都做,自愈)。"""
path.chmod(0o600)
def load_config(
path: Path | None = None, env: dict[str, str] | None = None
) -> Config:
"""读取配置并叠加环境变量覆盖(环境变量 > 配置文件)。"""
path = Path(path) if path else default_config_path()
cfg = Config(path=path)
if path.exists():
data = tomllib.loads(path.read_text(encoding="utf-8"))
cfg.anthropic_api_key = data.get(_KEY_ANTHROPIC)
cfg.openai_api_key = data.get(_KEY_OPENAI)
cfg.openai_base_url = data.get(_KEY_OPENAI_BASE_URL)
_enforce_0600(path)
env = os.environ if env is None else env
for env_key, cfg_key in _ENV_OVERRIDES.items():
value = env.get(env_key)
if value:
setattr(cfg, cfg_key, value)
return cfg
def _dump_toml(cfg: Config) -> str:
"""最小 TOML 写入器:扁平字符串键;None 值不落盘(读取时缺省为 None)。
TOML 无 null 字面量,故跳过未配置的键。
"""
lines = ["# Quercus 配置(权限 0600)。环境变量可覆盖以下键。"]
for key in _CONFIG_KEYS:
value = getattr(cfg, key)
if value is not None:
lines.append(f"{key} = {json.dumps(value)}")
return "\n".join(lines) + "\n"
def save_config(cfg: Config, path: Path | None = None) -> Path:
"""写入配置(目录自建、权限 0600)。"""
path = Path(path) if path else default_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(_dump_toml(cfg))
_enforce_0600(path)
return path
def config_summary(cfg: Config) -> dict[str, str | bool]:
"""只报告"配了哪些 provider",绝不泄露密钥值(用于日志/面板状态)。"""
return {
"anthropic_configured": cfg.has_anthropic_key,
"openai_configured": cfg.has_openai_key,
"openai_base_url": cfg.openai_base_url if cfg.openai_base_url else "(默认)",
}
+4
View File
@@ -0,0 +1,4 @@
"""HostAdapter 抽象接口(§3.1)。P1 只定义 ABC,不提供实现。"""
from quercus_core.host.adapter import HostAdapter
__all__ = ["HostAdapter"]
+119
View File
@@ -0,0 +1,119 @@
"""HostAdapter 抽象基类(计划文档 §3.1 接口形状)。
P1 只定义方法签名与 docstring,不含实现;MockHostAdapter 与真实宿主
适配层(Resolve / Premiere / Oak)属 P2+。方法族刻意对齐 OPP/1 §8,
使未来 Oak 适配层只是 oakxp SDK 的薄封装。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from quercus_core.types import (
ActionBatch,
Capability,
Limits,
PngBytes,
Rational,
Size,
TimeRange,
)
from quercus_core.host.types import (
BatchResult,
FootageId,
JobHandle,
Levels,
MediaInfo,
PlaybackState,
ProjectOverview,
SeqId,
Target,
Timeline,
)
class HostAdapter(ABC):
"""core 操作宿主的唯一边界(设计铁律 1:协议/接口是唯一边界)。"""
# ---- 会话与能力 ----
@abstractmethod
def capabilities(self) -> set[Capability]:
"""返回宿主能力位集合(如 PROJECT_READ / TIMELINE_EDIT)。"""
@abstractmethod
def limits(self) -> Limits:
"""自报资源预算:取帧分辨率/速率上限、轮询间隔、快照数量上限。"""
# ---- 工程 / 媒体 / 时间线 ----
@abstractmethod
def open_project(self, path: str) -> None:
"""打开(或新建)工程。"""
@abstractmethod
def save_project(self, path: str | None = None) -> None:
"""保存工程,可另存到 path。"""
@abstractmethod
def get_project_overview(self) -> ProjectOverview:
"""一次返回工程概览(序列/轨道/块树)。"""
@abstractmethod
def probe_media(self, path: str) -> MediaInfo:
"""探测媒体文件元数据。"""
@abstractmethod
def import_footage(self, paths: list[str]) -> list[FootageId]:
"""导入素材到媒体池,返回不透明 id。"""
@abstractmethod
def get_timeline_structure(self, seq: SeqId) -> Timeline:
"""获取时间线结构。"""
# ---- 取帧(视觉闭环生死通路) ----
@abstractmethod
def get_frame(self, target: Target, time: Rational, max_size: Size) -> PngBytes:
"""按时间点取一帧,返回 PNG 字节(core 不解析像素,只流转)。"""
@abstractmethod
def get_thumbnails(
self, target: Target, range: TimeRange, count: int
) -> list[PngBytes]:
"""在时间范围内等间隔采样多帧(contact sheet 输入)。"""
@abstractmethod
def get_audio_levels(self, seq: SeqId, range: TimeRange, resolution: int) -> Levels:
"""获取时间段内音频电平数据。"""
# ---- 回放 / 导出 ----
@abstractmethod
def play(self, speed: float = 1.0) -> None:
"""开始回放(可指定倍速)。"""
@abstractmethod
def pause(self) -> None:
"""暂停回放。"""
@abstractmethod
def seek(self, time: Rational) -> None:
"""移动播放头。"""
@abstractmethod
def get_state(self) -> PlaybackState:
"""读取回放状态(播放中/播放头位置)。"""
@abstractmethod
def export(self, seq: SeqId, output: str, preset: str) -> JobHandle:
"""启动导出任务,进度经回调上报。"""
# ---- 编辑(ActionBatch 语义,计划文档 §3.3) ----
@abstractmethod
def execute(self, batch: ActionBatch) -> BatchResult:
"""执行整段 ActionBatch(宿主有事务用事务,无事务用快照+补偿)。"""
@abstractmethod
def undo_last(self) -> None:
"""撤销上一个 AI 动作(仅用户明确要求时)。"""
+110
View File
@@ -0,0 +1,110 @@
"""HostAdapter 接口依赖的宿主侧领域类型(§3.1)。
P1 只定义形状;MockHostAdapter 与真实适配层属 P2/P3+。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from quercus_core.types import EntityId, PngBytes, Rational, TimeRange, ToolResult
# 素材 / 序列的不透明 id。
FootageId = EntityId
SeqId = EntityId
@dataclass
class ProjectOverview:
"""工程概览:一次返回序列/轨道/块树,供 LLM 建立上下文。"""
project_id: str
name: str
fps: float
timeline_ids: list[SeqId] = field(default_factory=list)
duration: Rational | None = None
@dataclass
class MediaInfo:
"""媒体探测结果。"""
path: str
duration: Rational | None = None
width: int | None = None
height: int | None = None
fps: float | None = None
codec: str | None = None
@dataclass
class Clip:
"""时间线上的一个片段(块)。"""
id: EntityId
name: str
source: str | None = None
start: Rational = Rational(0)
duration: Rational = Rational(0)
in_point: Rational | None = None
out_point: Rational | None = None
@dataclass
class Track:
"""时间线轨道。"""
index: int
kind: str = "video" # video | audio | subtitle
clips: list[Clip] = field(default_factory=list)
@dataclass
class Timeline:
"""时间线结构。"""
id: SeqId
name: str
fps: float
duration: Rational = Rational(0)
tracks: list[Track] = field(default_factory=list)
@dataclass(frozen=True)
class Target:
"""取帧目标:时间线或片段。"""
kind: str # "timeline" | "clip"
id: str
@dataclass
class Levels:
"""音频电平数据(等间隔采样,单位 dB)。"""
values: list[float] = field(default_factory=list)
min_db: float = -60.0
max_db: float = 0.0
@dataclass(frozen=True)
class JobHandle:
"""导出任务句柄;进度经回调上报。"""
job_id: str
@dataclass
class BatchResult:
"""ActionBatch 执行结果(编辑侧)。"""
ok: bool
message: str = ""
results: list[ToolResult] = field(default_factory=list)
@dataclass
class PlaybackState:
"""回放状态。"""
playing: bool = False
position: Rational = Rational(0)
@@ -0,0 +1,70 @@
"""Provider 注册表:报告可用性(无 key 优雅降级)并按名创建 provider。"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from quercus_core.config import Config
from quercus_core.providers.base import LLMProvider, ProviderUnavailable
from quercus_core.providers.claude import DEFAULT_CLAUDE_MODEL, ClaudeProvider
from quercus_core.providers.openai_compat import (
DEFAULT_OPENAI_BASE_URL,
DEFAULT_OPENAI_MODEL,
OpenAICompatProvider,
)
CLAUDE = "claude"
OPENAI_COMPAT = "openai_compat"
@dataclass(frozen=True)
class ProviderInfo:
"""注册表条目:可用性 + 原因(原因不含密钥值)。"""
name: str
available: bool
reason: str = ""
model: str = ""
def provider_status(config: Config) -> dict[str, ProviderInfo]:
"""返回各 provider 的可用状态,供面板/CLI 优雅降级展示。"""
return {
CLAUDE: ProviderInfo(
name=CLAUDE,
available=config.has_anthropic_key,
reason="" if config.has_anthropic_key else f"{config.path or 'config'}: 未配置 QUERCUS_ANTHROPIC_API_KEY",
model=DEFAULT_CLAUDE_MODEL if config.has_anthropic_key else "",
),
OPENAI_COMPAT: ProviderInfo(
name=OPENAI_COMPAT,
available=config.has_openai_key,
reason="" if config.has_openai_key else "未配置 QUERCUS_OPENAI_API_KEY",
model=DEFAULT_OPENAI_MODEL if config.has_openai_key else "",
),
}
def create_provider(
name: str,
config: Config,
*,
http_client: httpx.Client | None = None,
) -> LLMProvider:
"""按名创建 provider;未配置密钥时抛 ProviderUnavailable。"""
if name == CLAUDE:
if not config.has_anthropic_key:
raise ProviderUnavailable("claude: 未配置 QUERCUS_ANTHROPIC_API_KEY")
return ClaudeProvider(
api_key=config.anthropic_api_key or "", http_client=http_client
)
if name == OPENAI_COMPAT:
if not config.has_openai_key:
raise ProviderUnavailable("openai_compat: 未配置 QUERCUS_OPENAI_API_KEY")
return OpenAICompatProvider(
api_key=config.openai_api_key or "",
base_url=config.openai_base_url or DEFAULT_OPENAI_BASE_URL,
client=http_client,
)
raise ValueError(f"未知 provider: {name!r}(可选: {CLAUDE}, {OPENAI_COMPAT})")
+83
View File
@@ -0,0 +1,83 @@
"""LLMProvider 抽象与消息/工具调用类型。
输入:消息(文本 + PNG 图片);输出:文本 + tool_calls(设计文档 §5.1)。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from quercus_core.types import PngBytes
class ProviderError(Exception):
"""provider 调用失败(网络、协议、限额等)。"""
class ProviderUnavailable(ProviderError):
"""provider 因缺少密钥等配置不可用。"""
# 消息角色常量(避免魔法串)。
class Role:
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
SYSTEM = "system"
@dataclass(frozen=True)
class Message:
"""对话消息;``role="tool"`` 时经 tool_call_id 关联对应的 tool_call。
``images`` 为 PNG 字节(core 内唯一的图片载体)。
"""
role: str
text: str = ""
images: tuple[PngBytes, ...] = ()
tool_call_id: str | None = None # role="tool" 时必须指向某次 tool_call
tool_calls: tuple[ToolCall, ...] = () # role="assistant" 时携带已发出的 tool_call
name: str | None = None # 工具名(role="tool" 时)
@dataclass(frozen=True)
class ToolCall:
"""模型发起的工具调用。"""
id: str
name: str
arguments: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class ToolSchema:
"""暴露给 LLM 的工具声明(name/description/JSON Schema parameters)。"""
name: str
description: str
parameters: dict[str, Any] # JSON Schema 对象(有理秒用 {num, den})
read_only: bool = False # 只读工具可自动执行,变更类进待确认清单
@dataclass(frozen=True)
class AssistantTurn:
"""provider 单次生成的回复:文本 + 若干 tool_calls。"""
text: str = ""
tool_calls: tuple[ToolCall, ...] = ()
class LLMProvider(ABC):
"""LLM 后端统一接口。实现须为同步、可注入 HTTP 客户端(便于测试)。"""
name: str = "provider"
@abstractmethod
def generate(
self,
messages: list[Message],
tools: list[ToolSchema] | None = None,
) -> AssistantTurn:
"""给定消息历史(可含 PNG 图片)与工具声明,返回文本与 tool_calls。"""
+128
View File
@@ -0,0 +1,128 @@
"""Claude(anthropic SDK)后端:多模态图片消息 + tool use。"""
from __future__ import annotations
import base64
from typing import Any
import httpx
from quercus_core.providers.base import (
AssistantTurn,
LLMProvider,
Message,
Role,
ToolCall,
ToolSchema,
)
DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-5"
try:
import anthropic
except ImportError: # pragma: no cover - 依赖缺失时给出明确错误
anthropic = None # type: ignore[assignment]
class ClaudeProvider(LLMProvider):
"""经 anthropic SDK 调用 Claude Messages API(图片用 base64 image 块)。"""
name = "claude"
def __init__(
self,
*,
api_key: str,
model: str = DEFAULT_CLAUDE_MODEL,
max_tokens: int = 4096,
http_client: httpx.Client | None = None,
) -> None:
if anthropic is None:
raise ImportError("缺少依赖 anthropic,请先 uv pip install anthropic")
self._model = model
self._max_tokens = max_tokens
self._client = anthropic.Anthropic(api_key=api_key, http_client=http_client)
def generate(
self,
messages: list[Message],
tools: list[ToolSchema] | None = None,
) -> AssistantTurn:
system = "\n".join(m.text for m in messages if m.role == Role.SYSTEM)
kwargs: dict[str, Any] = {
"model": self._model,
"max_tokens": self._max_tokens,
"messages": self._to_anthropic_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self._to_tool(t) for t in tools]
resp = self._client.messages.create(**kwargs)
text = "".join(b.text for b in resp.content if b.type == "text")
calls = tuple(
ToolCall(id=b.id, name=b.name, arguments=dict(b.input))
for b in resp.content
if b.type == "tool_use"
)
return AssistantTurn(text=text, tool_calls=calls)
# ---- 转换 ----
def _to_tool(self, t: ToolSchema) -> dict[str, Any]:
return {"name": t.name, "description": t.description, "input_schema": t.parameters}
@staticmethod
def _image_block(png: bytes) -> dict[str, Any]:
return {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.b64encode(png).decode("ascii"),
},
}
def _to_anthropic_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
pending_tool_results: list[dict[str, Any]] | None = None
def flush() -> None:
nonlocal pending_tool_results
if pending_tool_results is not None:
out.append({"role": Role.USER, "content": pending_tool_results})
pending_tool_results = None
for m in messages:
if m.role == Role.SYSTEM:
continue
if m.role == Role.TOOL:
if pending_tool_results is None:
pending_tool_results = []
content: list[dict[str, Any]] = []
if m.text:
content.append({"type": "text", "text": m.text})
for img in m.images:
content.append(self._image_block(img))
pending_tool_results.append(
{"type": "tool_result", "tool_use_id": m.tool_call_id, "content": content}
)
continue
flush()
if m.role == Role.USER:
blocks: list[dict[str, Any]] = []
if m.text:
blocks.append({"type": "text", "text": m.text})
for img in m.images:
blocks.append(self._image_block(img))
out.append({"role": Role.USER, "content": blocks})
elif m.role == Role.ASSISTANT:
blocks = []
if m.text:
blocks.append({"type": "text", "text": m.text})
for tc in m.tool_calls:
blocks.append(
{"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}
)
out.append({"role": Role.ASSISTANT, "content": blocks})
flush()
return out
@@ -0,0 +1,163 @@
"""OpenAI 兼容 ``/v1/chat/completions`` 后端。
同一套通路覆盖:官方 OpenAI、自定义网关/企业代理、llama.cpp 本地 server
(OpenAI 兼容接口,不直接绑定 llama.cpp C API)。HTTP 层直接用 httpx,
不引入 openai SDK,保持依赖最小。
"""
from __future__ import annotations
import base64
import json
from typing import Any
import httpx
from quercus_core.providers.base import (
AssistantTurn,
LLMProvider,
Message,
ProviderError,
Role,
ToolCall,
ToolSchema,
)
DEFAULT_OPENAI_MODEL = "gpt-4o"
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
class OpenAICompatProvider(LLMProvider):
name = "openai_compat"
def __init__(
self,
*,
api_key: str,
base_url: str = DEFAULT_OPENAI_BASE_URL,
model: str = DEFAULT_OPENAI_MODEL,
client: httpx.Client | None = None,
) -> None:
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._model = model
# 外部注入的 client(如 MockTransport)自带 base_url,勿覆盖其配置。
self._client = client or httpx.Client(timeout=60.0)
@property
def endpoint(self) -> str:
return f"{self._base_url}/chat/completions"
def generate(
self,
messages: list[Message],
tools: list[ToolSchema] | None = None,
) -> AssistantTurn:
payload: dict[str, Any] = {
"model": self._model,
"messages": self._to_openai_messages(messages),
}
if tools:
payload["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
payload["tool_choice"] = "auto"
resp = self._client.post(
self.endpoint,
json=payload,
headers={"Authorization": f"Bearer {self._api_key}"},
)
if resp.status_code != 200:
raise ProviderError(
f"OpenAI 兼容端点返回 {resp.status_code}: {resp.text[:300]!r}"
)
try:
data = resp.json()
choice = data["choices"][0]
message = choice.get("message", {})
except (KeyError, IndexError, ValueError) as exc:
raise ProviderError(f"无法解析 OpenAI 兼容响应: {exc}") from exc
text = self._extract_text(message.get("content"))
calls = tuple(self._parse_tool_call(tc) for tc in message.get("tool_calls") or [])
return AssistantTurn(text=text, tool_calls=calls)
# ---- 解析 ----
@staticmethod
def _extract_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list): # 新规范:content 为 content-parts 列表
parts = [p.get("text", "") for p in content if isinstance(p, dict)]
return "".join(parts)
return str(content)
@staticmethod
def _parse_tool_call(tc: dict[str, Any]) -> ToolCall:
fn = tc.get("function", {}) or {}
raw = fn.get("arguments") or "{}"
try:
args = json.loads(raw)
except json.JSONDecodeError:
args = {}
return ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args)
# ---- 转换 ----
@staticmethod
def _image_url_part(png: bytes) -> dict[str, Any]:
return {
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}"
},
}
def _to_openai_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for m in messages:
if m.role == Role.SYSTEM:
out.append({"role": Role.SYSTEM, "content": m.text})
elif m.role == Role.USER:
parts: list[dict[str, Any]] = []
if m.text:
parts.append({"type": "text", "text": m.text})
for img in m.images:
parts.append(self._image_url_part(img))
out.append({"role": Role.USER, "content": parts})
elif m.role == Role.ASSISTANT:
msg: dict[str, Any] = {"role": Role.ASSISTANT, "content": m.text or None}
if m.tool_calls:
msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments),
},
}
for tc in m.tool_calls
]
out.append(msg)
elif m.role == Role.TOOL:
out.append(
{"role": Role.TOOL, "tool_call_id": m.tool_call_id, "content": m.text}
)
# OpenAI tool 消息只允许文本;帧图以随后的 user 消息回喂,
# 维持视觉闭环(llama.cpp 等本地端点不支持图片时可忽略此步)。
if m.images:
parts = [self._image_url_part(img) for img in m.images]
if m.text:
parts.insert(0, {"type": "text", "text": f"工具 {m.name} 返回的帧图"})
out.append({"role": Role.USER, "content": parts})
return out
@@ -0,0 +1,4 @@
"""会话状态与回放日志。"""
from quercus_core.session.log import SessionEvent, SessionLog, frame_sha256, replay
__all__ = ["SessionEvent", "SessionLog", "frame_sha256", "replay"]
+117
View File
@@ -0,0 +1,117 @@
"""会话状态与回放:事件 JSONL 落盘 + ``replay`` 读回(测试夹具同格式)。
- 落盘位置:``~/.quercus/sessions/<id>.jsonl``。
- 记录:user/assistant 消息、tool_call、tool_result 摘要、帧图 sha256 哈希
(不落图片本体,计划文档 §7.5)。
- 密钥绝不入日志;本模块只接收摘要文本,不接触配置密钥。
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from quercus_core.config import default_sessions_dir
from quercus_core.providers.base import AssistantTurn
from quercus_core.types import ActionBatch, PngBytes, ToolResult
_SUMMARY_MAX = 2000
def frame_sha256(png: PngBytes) -> str:
"""帧图 sha256 十六进制(仅记录哈希,不落图片本体)。"""
return hashlib.sha256(png).hexdigest()
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class SessionEvent:
"""一条会话事件。payload 为该事件的自有字段。"""
session_id: str
kind: str
at: str
payload: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {"session_id": self.session_id, "kind": self.kind, "at": self.at, **self.payload}
@classmethod
def from_dict(cls, data: dict) -> SessionEvent:
reserved = {"session_id", "kind", "at"}
payload = {k: v for k, v in data.items() if k not in reserved}
return cls(
session_id=data["session_id"],
kind=data["kind"],
at=data["at"],
payload=payload,
)
class SessionLog:
"""追加式 JSONL 会话日志。"""
def __init__(self, path: Path | str, session_id: str | None = None) -> None:
self.path = Path(path)
self.session_id = session_id or uuid.uuid4().hex
self.path.parent.mkdir(parents=True, exist_ok=True)
@classmethod
def create_default(cls) -> SessionLog:
"""在默认会话目录新建一个会话日志。"""
return cls(default_sessions_dir() / f"{uuid.uuid4().hex}.jsonl")
# ---- 事件 ----
def _append(self, kind: str, **payload) -> None:
event = SessionEvent(
session_id=self.session_id, kind=kind, at=_now(), payload=payload
)
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
def log_user(self, text: str) -> None:
self._append("user_message", text=text)
def log_assistant(self, turn: AssistantTurn) -> None:
calls = [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
for tc in turn.tool_calls
]
self._append("assistant_turn", text=turn.text, tool_calls=calls)
def log_tool_result(self, result: ToolResult) -> None:
self._append(
"tool_result",
tool=result.tool,
ok=result.ok,
summary=result.summary[:_SUMMARY_MAX],
image_hashes=[frame_sha256(img) for img in result.images],
)
def log_batch_decision(self, batch: ActionBatch, approved: bool) -> None:
self._append(
"batch_decision",
batch_id=batch.id,
label=batch.label,
approved=approved,
action_count=len(batch.actions),
)
def replay(path: Path | str) -> list[SessionEvent]:
"""读回会话日志(每行一条 JSON 事件)。"""
events: list[SessionEvent] = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
events.append(SessionEvent.from_dict(json.loads(line)))
return events
+4
View File
@@ -0,0 +1,4 @@
"""工具 schema 注册表与只读判定。"""
from quercus_core.tools.schemas import TOOLS, TOOL_LIST, get_tool, tool_read_only
__all__ = ["TOOLS", "TOOL_LIST", "get_tool", "tool_read_only"]
+330
View File
@@ -0,0 +1,330 @@
"""工具 schema 注册表:约 26 个策展工具的 JSON Schema(设计文档 §3 表)。
每个工具标注 ``read_only``:只读工具(get_*/probe/list/scan/play/pause/seek)
可自动执行;变更类工具整段进"待确认清单"(计划文档 §3.2)。
有理秒参数一律用 ``{num, den}`` 对象(决策 D5)。
"""
from __future__ import annotations
from quercus_core.providers.base import ToolSchema
# 有理秒参数的 JSON Schema 片段。
RATIONAL_SCHEMA = {
"type": "object",
"properties": {"num": {"type": "integer"}, "den": {"type": "integer"}},
"required": ["num", "den"],
"additionalProperties": False,
"description": "有理秒,num/den",
}
TIME_RANGE_SCHEMA = {
"type": "object",
"properties": {"start": RATIONAL_SCHEMA, "end": RATIONAL_SCHEMA},
"required": ["start", "end"],
"additionalProperties": False,
"description": "闭区间时间段(有理秒)",
}
def _t(
name: str,
description: str,
parameters: dict,
read_only: bool,
) -> ToolSchema:
return ToolSchema(
name=name,
description=description,
parameters=parameters,
read_only=read_only,
)
def _obj(properties: dict, required: list[str] | None = None) -> dict:
"""构造 JSON Schema 对象:type=object + properties + required。"""
schema: dict = {
"type": "object",
"properties": properties,
"additionalProperties": False,
}
if required:
schema["required"] = required
return schema
TOOLS: dict[str, ToolSchema] = {}
def _reg(tool: ToolSchema) -> None:
TOOLS[tool.name] = tool
# ---- 工程 ----
_reg(_t(
"open_project",
"打开(或新建)一个工程。变更类工具。",
_obj({"path": {"type": "string", "description": "工程路径;省略则为默认工程"}}, ["path"]),
read_only=False,
))
_reg(_t(
"save_project",
"保存当前工程。变更类工具。",
_obj({"path": {"type": "string", "description": "另存路径(可选)"}}),
read_only=False,
))
_reg(_t(
"get_project_overview",
"获取工程概览:序列/轨道/块树,供 LLM 建立上下文。只读。",
_obj({}),
read_only=True,
))
# ---- 媒体 ----
_reg(_t(
"probe_media",
"探测媒体文件的时长/分辨率/帧率等信息。只读。",
_obj({"path": {"type": "string"}}, ["path"]),
read_only=True,
))
_reg(_t(
"import_footage",
"把媒体文件导入工程媒体池。变更类工具。",
_obj({"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1}}, ["paths"]),
read_only=False,
))
_reg(_t(
"list_footage",
"列出媒体池中的素材。只读。",
_obj({}),
read_only=True,
))
# ---- 时间线 ----
_reg(_t(
"add_track",
"向时间线添加轨道。变更类工具。",
_obj(
{
"type": {"type": "string", "enum": ["video", "audio", "subtitle"]},
"index": {"type": "integer", "description": "目标轨道序号(可选)"},
},
["type"],
),
read_only=False,
))
_reg(_t(
"place_clip",
"把素材放到指定轨道与时间点。变更类工具。",
_obj(
{
"clip_id": {"type": "string"},
"track_index": {"type": "integer"},
"time": RATIONAL_SCHEMA,
"in_point": RATIONAL_SCHEMA,
"out_point": RATIONAL_SCHEMA,
},
["clip_id", "track_index", "time"],
),
read_only=False,
))
_reg(_t(
"split_clip",
"在给定时间点把片段一分为二。变更类工具。",
_obj({"clip_id": {"type": "string"}, "time": RATIONAL_SCHEMA}, ["clip_id", "time"]),
read_only=False,
))
_reg(_t(
"trim_clip",
"调整片段入点/出点或时长。变更类工具。",
_obj(
{
"clip_id": {"type": "string"},
"in_point": RATIONAL_SCHEMA,
"out_point": RATIONAL_SCHEMA,
"duration": RATIONAL_SCHEMA,
},
["clip_id"],
),
read_only=False,
))
_reg(_t(
"move_clip",
"把片段移动到另一轨道/时间点。变更类工具。",
_obj(
{
"clip_id": {"type": "string"},
"track_index": {"type": "integer"},
"time": RATIONAL_SCHEMA,
},
["clip_id", "track_index", "time"],
),
read_only=False,
))
_reg(_t(
"ripple_delete",
"波纹删除片段(删除后闭合空隙)。变更类工具,破坏性操作。",
_obj(
{
"clip_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"range": TIME_RANGE_SCHEMA,
},
["clip_ids"],
),
read_only=False,
))
_reg(_t(
"add_transition",
"为片段添加转场。变更类工具。",
_obj(
{
"clip_id": {"type": "string"},
"transition_type": {"type": "string"},
"duration": RATIONAL_SCHEMA,
},
["clip_id", "transition_type", "duration"],
),
read_only=False,
))
_reg(_t(
"add_marker",
"在时间线添加标记(可携带 AI 元数据)。变更类工具。",
_obj(
{
"time": RATIONAL_SCHEMA,
"label": {"type": "string"},
"color": {"type": "string", "enum": ["red", "orange", "yellow", "green", "blue", "violet"]},
"custom_data": {"type": "object"},
},
["time"],
),
read_only=False,
))
# ---- 效果 / 关键帧 ----
_reg(_t(
"add_effect",
"为片段添加效果。变更类工具。",
_obj(
{"clip_id": {"type": "string"}, "effect_type": {"type": "string"}, "preset": {"type": "string"}},
["clip_id", "effect_type"],
),
read_only=False,
))
_reg(_t(
"set_param",
"设置效果参数(数值/字符串/布尔)。变更类工具。",
_obj(
{
"effect_id": {"type": "string"},
"param": {"type": "string"},
"value": {"oneOf": [{"type": "number"}, {"type": "string"}, {"type": "boolean"}]},
},
["effect_id", "param", "value"],
),
read_only=False,
))
_reg(_t(
"set_keyframe",
"为效果参数在指定时间点打关键帧。变更类工具。",
_obj(
{
"effect_id": {"type": "string"},
"param": {"type": "string"},
"time": RATIONAL_SCHEMA,
"value": {"type": "number"},
},
["effect_id", "param", "time", "value"],
),
read_only=False,
))
_reg(_t(
"list_effects",
"列出可用效果类型(或某片段已用效果)。只读。",
_obj({"clip_id": {"type": "string"}, "category": {"type": "string"}}),
read_only=True,
))
# ---- 取帧(视觉闭环口) ----
_reg(_t(
"get_frame",
"按时间点取一帧,返回 PNG 图给模型看图判断。只读。",
_obj(
{
"time": RATIONAL_SCHEMA,
"max_size": {
"type": "object",
"properties": {"width": {"type": "integer"}, "height": {"type": "integer"}},
"required": ["width", "height"],
"additionalProperties": False,
},
"target": {"type": "string", "description": "目标对象 id(缺省为当前时间线)"},
},
["time", "max_size"],
),
read_only=True,
))
_reg(_t(
"scan_timeline",
"在时间范围等间隔采样 n 帧,拼成 contact sheet 回喂。只读。",
_obj(
{"range": TIME_RANGE_SCHEMA, "count": {"type": "integer", "minimum": 1, "maximum": 64}},
["range", "count"],
),
read_only=True,
))
_reg(_t(
"get_audio_levels",
"获取时间段内的音频电平数据。只读。",
_obj({"range": TIME_RANGE_SCHEMA, "resolution": {"type": "integer", "minimum": 1}}),
read_only=True,
))
# ---- 回放 ----
_reg(_t(
"play",
"播放(可指定倍速)。只读(不改动编辑内容)。",
_obj({"speed": {"type": "number", "description": "倍速,1.0 为正常"}}),
read_only=True,
))
_reg(_t(
"pause",
"暂停回放。只读。",
_obj({}),
read_only=True,
))
_reg(_t(
"seek",
"移动播放头到指定时间。只读。",
_obj({"time": RATIONAL_SCHEMA}, ["time"]),
read_only=True,
))
# ---- 导出 / 撤销 ----
_reg(_t(
"export_render",
"按预设导出渲染。变更类工具(确认类)。",
_obj(
{"output": {"type": "string"}, "preset": {"type": "string"}},
["output"],
),
read_only=False,
))
_reg(_t(
"undo_last_action",
"撤销上一个 AI 动作(仅用户明确要求时)。变更类工具。",
_obj({}),
read_only=False,
))
# 保持注册顺序稳定(列表形态)。
TOOL_LIST: list[ToolSchema] = list(TOOLS.values())
def get_tool(name: str) -> ToolSchema | None:
return TOOLS.get(name)
def tool_read_only(name: str) -> bool:
"""变更类工具是否需要确认门。未知工具保守视为变更类。"""
tool = TOOLS.get(name)
return tool.read_only if tool else False
+231
View File
@@ -0,0 +1,231 @@
"""核心类型:有理秒时间模型、不透明 id、能力与限制、ActionBatch。
全系统唯一时间表示是有理秒 ``Rational``(num/den),宿主侧的帧号/ticks
一律在适配层换算(计划文档 §2.3 决策 D5)。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Any, NewType
# 帧图在 core 内始终以 PNG 字节流转;core 不依赖 Pillow。
PngBytes = NewType("PngBytes", bytes)
# 不透明字符串 id(对齐 OPP/1 §5),适配层维护 id ↔ 宿主对象映射。
EntityId = NewType("EntityId", str)
@dataclass(frozen=True, eq=True)
class Rational:
"""有理秒。构造后自动约分并保证分母为正,作为字典键安全。
排序按数值(num1*den2 交叉相乘),非字段字典序。
"""
num: int
den: int = 1
def __post_init__(self) -> None:
if self.den == 0:
raise ValueError("Rational 分母不能为 0")
if self.num == 0:
object.__setattr__(self, "den", 1)
return
if self.den < 0:
object.__setattr__(self, "num", -self.num)
object.__setattr__(self, "den", -self.den)
g = math.gcd(abs(self.num), self.den)
if g > 1:
object.__setattr__(self, "num", self.num // g)
object.__setattr__(self, "den", self.den // g)
@classmethod
def from_float(cls, seconds: float) -> Rational:
"""从浮点秒构造(有限十进制即可精确)。"""
f = Fraction(seconds).limit_denominator(1_000_000)
return cls(f.numerator, f.denominator)
def to_float(self) -> float:
return self.num / self.den
def __lt__(self, other: object) -> bool:
"""按数值比较(num1*den2 < num2*den1)。"""
if not isinstance(other, Rational):
return NotImplemented
return self.num * other.den < other.num * self.den
def __le__(self, other: object) -> bool:
"""按数值比较(<=)。``>=``/``>`` 经反射由 ``__le__``/``__lt__`` 派生。"""
if not isinstance(other, Rational):
return NotImplemented
return self.num * other.den <= other.num * self.den
def __add__(self, other: Rational) -> Rational:
return Rational(
self.num * other.den + other.num * self.den, self.den * other.den
)
def __sub__(self, other: Rational) -> Rational:
return Rational(
self.num * other.den - other.num * self.den, self.den * other.den
)
def __mul__(self, other: Rational) -> Rational:
return Rational(self.num * other.num, self.den * other.den)
def __truediv__(self, other: Rational) -> Rational:
if other.num == 0:
raise ZeroDivisionError("Rational 不能除以 0")
return Rational(self.num * other.den, self.den * other.num)
def __repr__(self) -> str: # noqa: D105 - 简短调试表示
return f"Rational({self.num}, {self.den})"
def to_json(self) -> dict[str, int]:
"""序列化为 ``{num, den}``(工具 schema 与日志共用此形状)。"""
return {"num": self.num, "den": self.den}
@classmethod
def from_json(cls, data: dict[str, Any] | Any) -> Rational:
if isinstance(data, Rational):
return data
if not isinstance(data, dict) or "num" not in data or "den" not in data:
raise ValueError(f"无法从 {data!r} 解析 Rational")
return cls(int(data["num"]), int(data["den"]))
@dataclass(frozen=True, order=True)
class TimeRange:
"""闭区间 [start, end] 的有理秒时间段。"""
start: Rational
end: Rational
def __post_init__(self) -> None:
if self.end < self.start:
raise ValueError(f"TimeRange 起点不得晚于终点: {self.start} > {self.end}")
@property
def duration(self) -> Rational:
return self.end - self.start
def contains(self, t: Rational) -> bool:
return self.start <= t <= self.end
def to_json(self) -> dict[str, dict[str, int]]:
return {"start": self.start.to_json(), "end": self.end.to_json()}
@classmethod
def from_json(cls, data: dict[str, Any]) -> TimeRange:
return cls(Rational.from_json(data["start"]), Rational.from_json(data["end"]))
@dataclass(frozen=True, order=True)
class Size:
"""像素尺寸。"""
width: int
height: int
def __post_init__(self) -> None:
if self.width <= 0 or self.height <= 0:
raise ValueError(f"Size 必须为正: {self}")
@property
def max_side(self) -> int:
return max(self.width, self.height)
def to_json(self) -> dict[str, int]:
return {"width": self.width, "height": self.height}
@classmethod
def from_json(cls, data: dict[str, Any]) -> Size:
return cls(int(data["width"]), int(data["height"]))
# 能力位(对齐设计文档 §5.2 的最小必要集)。
Capability = NewType("Capability", str)
class Capabilities:
"""能力位常量(字符串即 JSON-RPC 能力名,避免魔法串)。"""
PROJECT_READ = Capability("project.read")
MEDIA_READ = Capability("media.read")
MEDIA_IMPORT = Capability("media.import")
TIMELINE_READ = Capability("timeline.read")
TIMELINE_EDIT = Capability("timeline.edit")
NODE_READ = Capability("node.read")
NODE_EDIT = Capability("node.edit")
RENDER_FRAME = Capability("render.frame")
PLAYBACK = Capability("playback")
EXPORT = Capability("export")
@dataclass(frozen=True)
class Limits:
"""宿主自报的资源预算,core 侧据此限流(计划文档 §3.1 / §6 约束 4)。"""
max_frame_width: int = 1920
max_frame_height: int = 1080
max_frame_rate: float = 8.0 # 每秒取帧上限
max_scan_frames: int = 64 # 单次 scan_timeline 的采样数上限
max_snapshot_count: int = 16 # 快照时间线数量上限
poll_interval_seconds: float = 0.5 # 宿主无事件源时适配层轮询间隔
def to_json(self) -> dict[str, Any]:
return {
"max_frame_width": self.max_frame_width,
"max_frame_height": self.max_frame_height,
"max_frame_rate": self.max_frame_rate,
"max_scan_frames": self.max_scan_frames,
"max_snapshot_count": self.max_snapshot_count,
"poll_interval_seconds": self.poll_interval_seconds,
}
@dataclass(frozen=True)
class Action:
"""单次工具调用(一次 LLM 动作的组成部分)。"""
tool: str
params: dict[str, Any] = field(default_factory=dict)
call_id: str = "" # provider 返回的 tool_call id,用于结果回喂
def to_json(self) -> dict[str, Any]:
return {"tool": self.tool, "params": self.params, "call_id": self.call_id}
@dataclass
class ActionBatch:
"""一次 LLM 动作 = 一组工具调用 + 标签。
整段先入"待确认清单",经确认回调批准后才执行变更类工具;
LLM 看不到任何事务/快照细节(计划文档 §3.2–3.3)。
"""
label: str
actions: list[Action]
id: str = "" # 由调用方(loop)分配
created_at: str = "" # ISO 时间戳
def to_json(self) -> dict[str, Any]:
return {
"label": self.label,
"actions": [a.to_json() for a in self.actions],
"id": self.id,
"created_at": self.created_at,
}
@dataclass(frozen=True)
class ToolResult:
"""工具执行结果:给 LLM 的文本摘要 + 可选的 PNG 帧图(回喂多模态)。"""
tool: str
ok: bool
summary: str
images: tuple[PngBytes, ...] = ()
error: str | None = None