From f76139f961b880ef3a54319d2bc7b69e671ac2e5 Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:25:26 -0400 Subject: [PATCH] feat(agent): Agent Phase 1 runtime foundation - Introduce the Agent domain model, run/workspace/checkpoint repositories, and a file-backed workspace store with event journaling. - Wire the runtime service into Tauri commands and expose the frontend Agent API for starting, observing, cancelling, reading, and committing runs. - Derive workspaces from stable chat identity while keeping each run isolated, and make unsupported Phase 1 paths fail fast. - Document the Agent architecture, API contract, workspace model, LLM gateway, tool/MCP boundaries, and implementation plan. --- docs/API/Agent.md | 311 +++++++ docs/API/MCP.md | 144 +++ docs/API/README.md | 10 +- docs/Agent/LlmGateway.md | 204 ++++ docs/Agent/McpSkill.md | 203 ++++ docs/Agent/ProfilesAndPreset.md | 416 +++++++++ docs/Agent/README.md | 23 + docs/Agent/RunEventJournal.md | 395 ++++++++ docs/Agent/TestingStrategy.md | 222 +++++ docs/Agent/ToolSystem.md | 407 ++++++++ docs/Agent/Workspace.md | 427 +++++++++ docs/AgentArchitecture.md | 410 ++++++++ docs/AgentContract.md | 437 +++++++++ docs/AgentImplementPlan.md | 433 +++++++++ docs/CurrentState/AgentFramework.md | 65 ++ docs/CurrentState/README.md | 4 + docs/FrontendHostContract.md | 11 + docs/README.md | 4 + src-tauri/src/app.rs | 3 + src-tauri/src/app/bootstrap.rs | 26 + src-tauri/src/application/dto/agent_dto.rs | 128 +++ src-tauri/src/application/dto/mod.rs | 1 + .../services/agent_runtime_service.rs | 876 ++++++++++++++++++ src-tauri/src/application/services/mod.rs | 1 + src-tauri/src/domain/models/agent/mod.rs | 297 ++++++ src-tauri/src/domain/models/mod.rs | 1 + .../repositories/agent_run_repository.rs | 35 + .../repositories/checkpoint_repository.rs | 15 + src-tauri/src/domain/repositories/mod.rs | 3 + .../repositories/workspace_repository.rs | 38 + .../repositories/file_agent_repository.rs | 653 +++++++++++++ .../src/infrastructure/repositories/mod.rs | 1 + .../presentation/commands/agent_commands.rs | 96 ++ src-tauri/src/presentation/commands/mod.rs | 1 + .../src/presentation/commands/registry.rs | 7 + src/tauri/main/api/agent.js | 321 +++++++ src/tauri/main/bootstrap.js | 3 +- src/types.d.ts | 74 ++ 38 files changed, 6704 insertions(+), 2 deletions(-) create mode 100644 docs/API/Agent.md create mode 100644 docs/API/MCP.md create mode 100644 docs/Agent/LlmGateway.md create mode 100644 docs/Agent/McpSkill.md create mode 100644 docs/Agent/ProfilesAndPreset.md create mode 100644 docs/Agent/README.md create mode 100644 docs/Agent/RunEventJournal.md create mode 100644 docs/Agent/TestingStrategy.md create mode 100644 docs/Agent/ToolSystem.md create mode 100644 docs/Agent/Workspace.md create mode 100644 docs/AgentArchitecture.md create mode 100644 docs/AgentContract.md create mode 100644 docs/AgentImplementPlan.md create mode 100644 docs/CurrentState/AgentFramework.md create mode 100644 src-tauri/src/application/dto/agent_dto.rs create mode 100644 src-tauri/src/application/services/agent_runtime_service.rs create mode 100644 src-tauri/src/domain/models/agent/mod.rs create mode 100644 src-tauri/src/domain/repositories/agent_run_repository.rs create mode 100644 src-tauri/src/domain/repositories/checkpoint_repository.rs create mode 100644 src-tauri/src/domain/repositories/workspace_repository.rs create mode 100644 src-tauri/src/infrastructure/repositories/file_agent_repository.rs create mode 100644 src-tauri/src/presentation/commands/agent_commands.rs create mode 100644 src/tauri/main/api/agent.js diff --git a/docs/API/Agent.md b/docs/API/Agent.md new file mode 100644 index 00000000..1a3cfc51 --- /dev/null +++ b/docs/API/Agent.md @@ -0,0 +1,311 @@ +# `window.__TAURITAVERN__.api.agent` — Agent API Draft + +本文档是 Agent Host ABI 草案。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。 + +状态:Phase 1 已实现最小骨架,本文仍包含后续阶段 API 草案。 + +## 1. 入口 + +```js +await (window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__); + +const agent = window.__TAURITAVERN__.api.agent; +``` + +Agent API 必须挂在 `window.__TAURITAVERN__.api.agent`。不要新增散落全局。 + +## 2. 方法概览 + +```ts +type TauriTavernAgentApi = { + startRun(input: AgentStartRunInput): Promise; + subscribe(runId: string, handler: (event: AgentRunEvent) => void, options?: AgentSubscribeOptions): Promise; + cancel(runId: string): Promise; + approveToolCall(input: AgentApproveToolCallInput): Promise; + listRuns(input: AgentListRunsInput): Promise; + readEvents(input: AgentReadEventsInput): Promise; + readWorkspaceFile(input: AgentReadWorkspaceFileInput): Promise; + readDiff(input: AgentReadDiffInput): Promise; + rollback(input: AgentRollbackInput): Promise; + commit(input: AgentCommitInput): Promise; +}; +``` + +`subscribe()` 返回的 unsubscribe 必须幂等。 + +## 3. startRun + +```ts +type AgentStartRunInput = { + chatRef: AgentChatRef; + stableChatId?: string; + generationType: 'normal' | 'regenerate' | 'swipe' | 'continue' | 'quiet' | 'impersonate'; + profileId?: string; + promptSnapshot?: unknown; + generationIntent?: AgentGenerationIntent; + workspaceMode?: 'new-run' | 'resume-run'; + resumeRunId?: string; + options?: { + autoCommit?: boolean; + stream?: boolean; + }; +}; + +type AgentRunHandle = { + runId: string; + status: AgentRunStatus; + workspaceId: string; + stableChatId: string; +}; +``` + +身份语义: + +- `stableChatId` 是聊天的长期稳定身份。 +- `workspaceId` 必须由 `kind + stableChatId` 派生,不得由可变的 `chatRef` 文件名直接决定。 +- `runId` 是一次 Agent 执行身份,每次 normal/regenerate/swipe/continue 都必须生成新的 `runId`。 +- 同一稳定聊天的多次 run 应共享同一个 chat workspace,但各自拥有独立 run workspace。 + +Public Host ABI 可以允许调用方省略 `stableChatId`,但 `api.agent.startRun()` 必须在调用 Rust command 前通过 `api.chat.open(chatRef).stableId()` 解析并校验。Rust command 不应自行读取 SillyTavern metadata。 + +短期允许 `promptSnapshot`,长期目标是 `generationIntent`。 + +要求: + +- `stableChatId` 进入 backend DTO 前必须非空;无法解析时 fail-fast。 +- `promptSnapshot` 与 `generationIntent` 至少提供一个。 +- `resume-run` 必须提供 `resumeRunId`。 +- `autoCommit` 默认由 profile/output policy 决定。 +- 参数无效必须 reject,不静默回退 Legacy Generate。 + +## 4. subscribe + +```ts +type AgentSubscribeOptions = { + replay?: 'none' | 'from-seq'; + afterSeq?: number; +}; +``` + +语义: + +- 默认不复播全部历史。 +- UI 首次打开 run 页面应先 `readEvents()`,再 `subscribe()`。 +- 如果订阅期间漏事件,调用方可用 `afterSeq` 补拉。 +- 底层 Tauri 事件名不是 Public Contract。 + +## 5. cancel + +```ts +await agent.cancel(runId); +``` + +语义: + +- 写 `run_cancel_requested`。 +- 尽力取消当前模型请求或工具调用。 +- Cancel 不是 failure。 +- Cancel 后不能自动 commit。 + +## 6. approveToolCall + +```ts +type AgentApproveToolCallInput = { + runId: string; + callId: string; + approved: boolean; + reason?: string; +}; +``` + +语义: + +- 审批结果写 journal。 +- 拒绝工具不等同 run failure;具体后续由 plan/profile policy 决定。 + +## 7. readEvents + +```ts +type AgentReadEventsInput = { + runId: string; + afterSeq?: number; + beforeSeq?: number; + limit?: number; +}; + +type AgentReadEventsResult = { + runId: string; + events: AgentRunEvent[]; + hasMoreBefore: boolean; + hasMoreAfter: boolean; +}; +``` + +要求: + +- `limit` 必须有上限。 +- 移动端 UI 不应一次读取完整巨大 journal。 + +## 8. readWorkspaceFile + +```ts +type AgentReadWorkspaceFileInput = { + runId: string; + path: string; + checkpointId?: string; +}; + +type AgentWorkspaceFile = { + runId: string; + path: string; + text?: string; + bytes?: number; + mime?: string; + sha256?: string; +}; +``` + +路径必须是 workspace relative path。非法路径直接 reject。 + +## 9. readDiff + +```ts +type AgentReadDiffInput = { + runId: string; + fromCheckpointId?: string; + toCheckpointId?: string; + paths?: string[]; +}; + +type AgentDiff = { + runId: string; + fromCheckpointId?: string; + toCheckpointId?: string; + files: Array<{ + path: string; + status: 'added' | 'modified' | 'deleted' | 'unchanged'; + unifiedDiff?: string; + }>; +}; +``` + +第一期可以只支持文本 artifact 的 diff。 + +## 10. rollback + +```ts +type AgentRollbackInput = { + runId: string; + checkpointId: string; + scope?: 'workspace' | 'committed-message'; +}; +``` + +语义: + +- `workspace`:只恢复 run workspace,不修改 chat。 +- `committed-message`:重组 artifact 并修改已提交聊天消息,必须走 chat 保存契约。 + +## 11. commit + +```ts +type AgentCommitInput = { + runId: string; + checkpointId?: string; +}; + +type AgentCommitResult = { + runId: string; + chatRef: AgentChatRef; + stableChatId: string; + messageIndex?: number; + messageId?: string; +}; +``` + +Commit 必须: + +- 读取 manifest。 +- 校验 required artifact。 +- 校验当前 active chat 与 run 的 `stableChatId` 一致。 +- 通过既有 chat 保存契约写入。 +- 写 agent metadata。 +- 追加 `run_committed` event。 + +## 12. Event Envelope + +```ts +type AgentRunEvent = { + seq: number; + id: string; + runId: string; + timestamp: string; + level: 'debug' | 'info' | 'warn' | 'error'; + type: AgentRunEventType; + payload: unknown; +}; +``` + +事件类型见 `docs/Agent/RunEventJournal.md`。 + +Agent event 不等同 SillyTavern `eventSource` 事件,不得伪装成 `GENERATION_*` 或 `TOOL_CALLS_*`。 + +## 13. Errors + +错误建议结构: + +```ts +type AgentApiError = { + code: string; + message: string; + runId?: string; + eventSeq?: number; + retryable?: boolean; + details?: unknown; +}; +``` + +常见 code: + +```text +agent.invalid_intent +agent.invalid_profile +agent.policy_violation +agent.not_found +workspace.path_denied +workspace.required_artifact_missing +model.request_failed +tool.policy_denied +commit.cursor_integrity +commit.save_failed +``` + +## 14. Rust Command 草案 + +```text +start_agent_run(dto, channel?) +cancel_agent_run(run_id) +approve_agent_tool_call(dto) +list_agent_runs(chat_ref) +read_agent_run_events(dto) +read_agent_workspace_file(dto) +read_agent_diff(dto) +rollback_agent_run(dto) +commit_agent_run(dto) +``` + +Command 层必须是薄封装。Agent loop 不写在 command 内。 + +## 15. Compatibility + +Agent Mode off: + +- `Generate()` 行为不变。 +- `ToolManager` 行为不变。 +- `api.chat` 行为不变。 + +Agent Mode on: + +- 短期可使用 dryRun 生成 prompt snapshot。 +- dryRun 不是纯函数,调用方必须理解它仍会触发上游事件。 +- Agent tool loop 不递归 `Generate()`。 diff --git a/docs/API/MCP.md b/docs/API/MCP.md new file mode 100644 index 00000000..a95f6eae --- /dev/null +++ b/docs/API/MCP.md @@ -0,0 +1,144 @@ +# `window.__TAURITAVERN__.api.mcp` — MCP API Draft + +本文档是 MCP Host ABI 草案。MCP 是独立平台能力,Agent 只是它的消费者之一。 + +状态:规划中,尚未实现。 + +## 1. 入口 + +```js +await (window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__); + +const mcp = window.__TAURITAVERN__.api.mcp; +``` + +## 2. 方法概览 + +```ts +type TauriTavernMcpApi = { + listServers(): Promise; + connectServer(input: McpConnectServerInput): Promise; + disconnectServer(serverId: string): Promise; + listTools(serverId: string): Promise; + callTool(input: McpCallToolInput): Promise; + listResources(serverId: string): Promise; + readResource(input: McpReadResourceInput): Promise; + listPrompts(serverId: string): Promise; + getPrompt(input: McpGetPromptInput): Promise; +}; +``` + +## 3. Server + +```ts +type McpServerSummary = { + id: string; + displayName: string; + transport: 'stdio' | 'http' | 'sse'; + enabled: boolean; + connected: boolean; + capabilities: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + sampling?: boolean; + }; +}; +``` + +`connectServer()` 不应接受任意 command 字符串作为扩展/Agent 可传入参数。stdio server 配置必须来自用户设置或 allowlist。 + +## 4. Tools + +```ts +type McpToolSummary = { + serverId: string; + name: string; + title?: string; + description?: string; + inputSchema?: unknown; + annotations?: unknown; + approvalRequired?: boolean; +}; + +type McpCallToolInput = { + serverId: string; + name: string; + arguments?: unknown; + approvalToken?: string; +}; +``` + +语义: + +- 危险工具默认需要审批。 +- call result 可以被 Agent 映射为 `ToolResult`。 +- call 必须有超时与取消策略。 + +## 5. Resources + +```ts +type McpResourceSummary = { + serverId: string; + uri: string; + name?: string; + description?: string; + mimeType?: string; +}; + +type McpReadResourceInput = { + serverId: string; + uri: string; +}; +``` + +MCP resource 不应自动进入 prompt。宿主、用户、profile 或 preset 决定是否纳入 ContextFrame。 + +## 6. Prompts + +```ts +type McpPromptSummary = { + serverId: string; + name: string; + title?: string; + description?: string; + arguments?: unknown; +}; + +type McpGetPromptInput = { + serverId: string; + name: string; + arguments?: unknown; +}; +``` + +MCP prompt 可以成为 PromptComponent,但不能覆盖 TauriTavern preset policy。 + +## 7. Security Contract + +禁止: + +- Agent/Preset/角色卡/世界书直接写 MCP stdio command。 +- 从远端 config 自动创建本地 stdio server。 +- 初期启用 MCP Sampling 自动模型调用。 +- 未经审批调用 destructive/high-cost tool。 + +要求: + +- server/command/args 对用户可见。 +- per-server capability allowlist。 +- per-tool approval。 +- MCP call 写入 Agent journal 或 MCP audit log。 + +## 8. Agent Integration + +Agent 看到的是: + +```text +mcp.. -> ToolRegistry ToolSpec +mcp resource -> WorkspaceResource / ContextFrame +mcp prompt -> PromptComponent +``` + +Agent 不直接操作 MCP config。 + diff --git a/docs/API/README.md b/docs/API/README.md index df48e43e..93b73a18 100644 --- a/docs/API/README.md +++ b/docs/API/README.md @@ -30,6 +30,12 @@ const api = host?.api; - `api.extension.store` - 面向需要**全局持久化**的扩展(不绑定 chat)。 - 提供 Extension KV JSON + Blob 存储,支持多 table。 +- `api.agent`(规划中) + - 面向 Agent Run / timeline / workspace / checkpoint / approval。 + - 提供启动 run、订阅事件、取消、审批、读取 workspace、rollback/commit 等能力。 +- `api.mcp`(规划中) + - 面向 MCP server/tool/resource/prompt 的独立平台能力。 + - Agent 可以消费 MCP,但 MCP 不依附 Agent Mode。 ## 文档 @@ -40,10 +46,12 @@ const api = host?.api; | [Dev.md](Dev.md) | `api.dev` 完整参考 | | [WorldInfo.md](WorldInfo.md) | `api.worldInfo` 完整参考 | | [ExtensionStore.md](Extension.md) | `api.extension.store` 完整参考 | +| [Agent.md](Agent.md) | `api.agent` 草案(Agent Run / workspace / timeline) | +| [MCP.md](MCP.md) | `api.mcp` 草案(MCP server/tool/resource/prompt) | | [Migration.md](Migration.md) | 从 SillyTavern 扩展迁移到 TauriTavern 的适配指南 | ## 契约说明 - API 类型定义见 `src/types.d.ts` - 宿主契约与稳定性边界见 `docs/FrontendHostContract.md` -- `docs/TauriTavernHostExtensionApiPlan.md` 记录了设计意图,但请以本目录和 Host Contract 为准 +- Agent/MCP 仍处于规划阶段;实现前请以 `docs/API/Agent.md`、`docs/API/MCP.md` 与 `docs/FrontendHostContract.md` 的草案约束为准 diff --git a/docs/Agent/LlmGateway.md b/docs/Agent/LlmGateway.md new file mode 100644 index 00000000..6d629126 --- /dev/null +++ b/docs/Agent/LlmGateway.md @@ -0,0 +1,204 @@ +# TauriTavern Agent LLM Gateway + +本文档定义 Agent Runtime 与现有 LLM 调用链之间的边界。 + +结论先行:Agent 需要 provider-agnostic `LlmGateway`,但第一阶段必须复用现有 `ChatCompletionService`,不能新建一套绕过现有 policy、日志、proxy、secret、prompt cache 的 HTTP 路径。 + +## 1. Ground of Truth + +当前后端 LLM 事实: + +- Provider source 定义在 `ChatCompletionSource`,代码已覆盖 OpenAI、OpenRouter、Custom、Claude、Makersuite/Gemini、VertexAI、DeepSeek、Cohere、Groq、Moonshot、NanoGPT、Chutes、SiliconFlow、Zai 等。见 `src-tauri/src/domain/repositories/chat_completion_repository.rs:8`。 +- Payload builder 由 `application/services/chat_completion_service/payload/mod.rs` 按 provider 分发。见 `src-tauri/src/application/services/chat_completion_service/payload/mod.rs:25`。 +- `ChatCompletionService` 负责 source 解析、iOS policy、endpoint override、feature policy、settings、secret、prompt caching、payload build、generate/generate_stream/cancel。见 `src-tauri/src/application/services/chat_completion_service/mod.rs:302`、`src-tauri/src/application/services/chat_completion_service/mod.rs:358`。 +- LLM API 日志依赖 `LoggingChatCompletionRepository` wrapper。见 `src-tauri/src/app/bootstrap.rs:372`。 +- Custom Native API 文档强调 tool_call_id 透明性与 native metadata 保真。见 `docs/CurrentState/NativeApiFormats.md:145`。 + +因此 Agent 文档不要复制旧 provider 列表,也不要以 `docs/BackendStructure.md` 中较旧的 provider 描述作为完整事实源。以代码与 `docs/CurrentState/NativeApiFormats.md` 为准。 + +## 2. Gateway 目标 + +`LlmGatewayService` 的目标: + +- 给 Agent Runtime 提供统一的 `ModelRequest` / `ModelResponse` / `ModelDelta`。 +- 隔离 OpenAI、Claude、Gemini、Responses、OpenAI-compatible、Custom Native 等格式差异。 +- 保留 native metadata、tool_call_id、reasoning、prompt cache、provider-specific flags。 +- 复用现有 `ChatCompletionService` 的 policy/logging/cancel 能力。 +- 让 Profile 切换模型时,Agent loop 不关心 provider payload 细节。 + +它不是: + +- 新 HTTP client。 +- 新 provider registry。 +- 绕过 `ChatCompletionService` 的捷径。 +- MCP Sampling 的实现。 + +## 3. Phase 1 Wrapper + +Phase 1 建议: + +```text +AgentRuntimeService + -> LlmGatewayService + -> ChatCompletionService.generate / generate_stream + -> ChatCompletionRepository + -> LoggingChatCompletionRepository + -> HttpChatCompletionRepository +``` + +输入仍可由 `PromptSnapshot` 提供,并转为现有 `ChatCompletionGenerateRequestDto`。 + +要求: + +- 使用现有 source/model/settings/payload 字段。 +- 使用现有 cancellation registry 或等价 watch channel。 +- 使用现有 logging wrapper。 +- 使用现有 prompt cache policy。 +- 保留 provider-native metadata。 +- 失败时写 Agent journal。 + +禁止: + +- 直接调用 `HttpChatCompletionRepository`。 +- 绕过 `HttpClientPool`。 +- 自行读取 secret。 +- 自行拼 endpoint override。 +- 因 Agent Mode 而关闭 LLM API log。 + +## 4. 长期 ModelRequest + +长期建议领域模型: + +```rust +ModelRequest { + request_id, + profile_id, + model, + messages, + tools, + tool_choice, + response_format, + generation_config, + cache_policy, + metadata, +} +``` + +`ModelMessage`: + +```rust +ModelMessage { + role, + name, + content, + native, +} +``` + +`ContentPart`: + +```text +Text +Image +File +Resource +ToolCall +ToolResult +Reasoning +Native +``` + +`native` 必须能保留 provider-specific block,不得清洗未知字段。 + +## 5. Tool Call 透明性 + +Tool call id 必须当作不透明字符串。 + +禁止: + +- 假设 tool_call_id 是 OpenAI UUID。 +- 重写 provider 返回的 call id。 +- 丢弃 Claude/Gemini/Responses 的 native tool metadata。 +- 把 Gemini thought signature/native blocks 压平成普通文本。 + +原因:某些 provider 的后续 stateless history 或 tool loop 需要原样回放 native metadata。 + +## 6. Streaming 边界 + +Agent 有两种事件流: + +```text +Provider stream + 来自 ChatCompletionService/Repository 的 SSE data 或 normalized chunk。 + +Agent run event stream + AgentRunEvent:model_delta、tool_call_requested、checkpoint_created 等语义事件。 +``` + +两者不能混用。 + +当前 `ChatCompletionStreamEvent::Chunk { data }` 只是 provider SSE data 桥接,不是 Agent timeline event。Agent UI 必须订阅 `api.agent.subscribe(runId, handler)` 的 run event。 + +## 7. Cancellation + +Agent cancel 应传播到当前模型请求。 + +要求: + +- 每个 model request 有 request id。 +- request id 遵守现有后端 cancel id 风格:短、稳定、可校验。 +- run cancel 写 `run_cancel_requested`,再 signal model request。 +- model cancelled 不是 failure,run 进入 `Cancelled` 或按 state machine 停止。 + +如果模型请求已经完成但 commit 未开始,cancel 必须阻止 commit。 + +## 8. Policy + +Gateway 必须遵守: + +- iOS policy source allowlist。 +- iOS policy endpoint override。 +- web search/request image capability。 +- settings 中的 provider 配置。 +- secret 暴露策略。 +- prompt caching opt-in/opt-out。 +- model/profile tool support 声明。 + +Policy denied 必须 fail-fast 并写 journal,不允许静默降级为另一个 provider 或另一个模型。 + +## 9. Prompt Cache + +Prompt cache 是 provider 能力,不是 Agent 自己随意拼 header。 + +Agent 可以在 `ModelRequest.cache_policy` 中表达意图,但具体是否启用、如何写 header、是否需要 beta header,必须由 existing provider logic 或正式 adapter 决定。 + +Custom Claude Messages 的 header 兼容策略尤其不能被 Agent 硬编码覆盖。 + +## 10. Error Contract + +Gateway 错误建议: + +```text +model.provider_denied +model.unsupported_tool_call +model.request_build_failed +model.request_failed +model.stream_failed +model.cancelled +model.native_metadata_lost +``` + +`model.native_metadata_lost` 应作为开发期 fail-fast 或测试失败,不应在生产静默发生。 + +## 11. Tests + +最低测试: + +- Agent gateway 通过 mock `ChatCompletionService` 验证不直接打 HTTP。 +- provider source denied 时 run failed。 +- stream chunk 转为 `model_delta`,但不泄漏为 raw provider event。 +- tool_call_id opaque round-trip。 +- Gemini native metadata round-trip。 +- cancel propagates。 +- LLM API log 不因 Agent 关闭。 + diff --git a/docs/Agent/McpSkill.md b/docs/Agent/McpSkill.md new file mode 100644 index 00000000..13766d0e --- /dev/null +++ b/docs/Agent/McpSkill.md @@ -0,0 +1,203 @@ +# TauriTavern Agent MCP and Skill + +本文档定义 MCP 与 Skill 在 Agent 系统中的位置。 + +结论: + +- MCP 是独立平台集成能力,不是 Agent Runtime 本体。 +- Skill 是渐进披露的文本/资源包,不是自动吞入 prompt 的大文件。 +- Agent 可以消费 MCP 和 Skill,但必须经过 ToolRegistry、ContextFrame、Policy 与 Journal。 + +## 1. MCP 边界 + +MCP 模块建议提供: + +```text +McpClientService + list_servers + connect_server + disconnect_server + list_tools + call_tool + list_resources + read_resource + list_prompts + get_prompt +``` + +Agent 消费方式: + +```text +MCP Tools -> ToolRegistry +MCP Resources -> WorkspaceResource / ContextFrame +MCP Prompts -> PromptComponent / Preset macro / slash command +``` + +Agent Runtime 不应建在 MCP 上。MCP server 只是外部能力来源之一。 + +## 2. MCP Host ABI + +非 Agent 模式下也可以提供: + +```js +window.__TAURITAVERN__.api.mcp +``` + +用途: + +- 用户在设置或工具面板中查看 server。 +- 用户显式调用 tool。 +- 扩展读取 MCP resource/prompt。 +- Agent Mode 通过同一底层服务消费 MCP。 + +API 草案见 `docs/API/MCP.md`。 + +## 3. MCP 安全底线 + +必须禁止: + +- Agent/Preset/角色卡/世界书直接写 MCP stdio command。 +- 远端配置自动创建本地 stdio server。 +- prompt 修改 MCP config 后自动 reload。 +- 初期允许 MCP Sampling 自动发起模型调用。 +- 未经审批调用 destructive/high-cost tool。 + +必须要求: + +- server 来自用户设置、系统内置 allowlist 或签名/可信来源。 +- command/args 对用户可见。 +- per-server capability allowlist。 +- per-tool approval policy。 +- 所有 MCP tool call 写 journal。 + +## 4. MCP Tool Result + +MCP tool result 应映射为 Agent `ToolResult`: + +```text +Text content -> ToolContentBlock::Text +Image/audio/file -> ResourceRef / FileRef +Structured content -> structured JSON +Resource link -> WorkspaceResource ref +Error -> is_error = true +``` + +大结果不能直接塞进 journal;应写 resource ref。 + +## 5. MCP Resources + +MCP resources 默认是 application-driven: + +- 宿主决定是否读取。 +- ContextAssembly 决定是否纳入 prompt。 +- Profile/Preset 决定预算与可见性。 + +Agent 不应因为 server 暴露 resource 就自动读取全文。 + +## 6. MCP Prompts + +MCP prompts 可以成为: + +- PromptComponent。 +- Preset macro 的来源。 +- 用户显式插入的片段。 + +它们不应自动覆盖 TauriTavern preset,也不应绕过创作者 policy。 + +## 7. Skill 定义 + +Skill 是本地或扩展提供的渐进式知识包。 + +建议结构: + +```text +skills/ + long-form-romance/ + SKILL.md + examples/ + assets/ +``` + +`SKILL.md` frontmatter 示例: + +```yaml +--- +name: long-form-romance +description: 长篇恋爱剧情写作技巧 +allowed-tools: + - workspace.read_file + - workspace.apply_patch +when-to-use: 当需要扩写情感铺垫和细腻心理描写时 +context: lazy +--- +``` + +## 8. Skill 读取策略 + +Agent 默认只看到 skill 索引: + +```text +name +description +when-to-use +allowed-tools +``` + +读取全文必须通过: + +```text +skill.read(name, section?, budget?) +``` + +`skill.read` 是 tool call,必须写 journal。Preset 可以声明某些 skill 自动进入 context,但仍受 budget 管控。 + +## 9. Skill 来源 + +Skill 可以来自: + +- 用户本地目录。 +- Preset 包。 +- 角色卡附带资源。 +- 扩展。 +- 未来 marketplace。 + +来源不同,信任级别不同。扩展/远端 skill 默认不应获得更高工具权限。 + +## 10. Skill 与 Workspace + +Skill 可以表现为 workspace resource: + +```text +skills//SKILL.md +skills//examples/foo.md +``` + +但它默认是 read-only virtual resource。Agent 不能修改原始 skill;如果需要摘录或改写,应写入 `scratch/` 或 `summaries/`。 + +## 11. Agent Context + +Skill 进入 prompt 的路径: + +```text +skill.list / preset auto include + -> SkillService + -> WorkspaceResource or PromptComponent::Skill + -> ContextAssemblyService + -> ModelRequest +``` + +不要把所有 Skill 全文塞进 system prompt。 + +## 12. MVP + +Phase 2/6 最小实现: + +- `SkillRepository` 列出本地 skill。 +- `skill.list`。 +- `skill.read`。 +- profile/preset 控制可见 skill。 +- skill content 受 budget 限制。 +- tool result 写 journal。 + +MCP 最小实现应晚于内置工具系统稳定之后。 + diff --git a/docs/Agent/ProfilesAndPreset.md b/docs/Agent/ProfilesAndPreset.md new file mode 100644 index 00000000..11865ea2 --- /dev/null +++ b/docs/Agent/ProfilesAndPreset.md @@ -0,0 +1,416 @@ +# Agent Profiles, Preset Schema, and Plan Policy + +本文档定义 Agent Profile、Preset Agent 扩展字段、ContextFrame、Prompt Component 与 Plan Policy。 + +SillyTavern 的核心优势之一是 prompt/preset 的创作者自由。Agent Mode 必须继承这一点,但自由度要进入可维护的 runtime policy,而不是散落在字符串 prompt 中。 + +## 1. Agent Profile + +Agent Profile 是: + +```text +Preset + Model/API + Context Policy + Tool Policy + Plan Policy + Output Policy +``` + +不是单纯的模型选择。 + +建议领域模型: + +```rust +AgentProfile { + id, + display_name, + preset_ref, + model_ref, + prompt_policy, + visible_resource_policy, + tool_policy, + plan_policy, + summary_policy, + switch_policy, + output_policy, + budget_policy, +} +``` + +### 1.1 Profile 来源 + +Profile 可以来自: + +- 用户手动创建。 +- Preset 内嵌 agent schema。 +- 角色卡推荐配置。 +- 扩展提供的 profile template。 + +最终运行时必须 resolve 为一个完整、可检查的 `ResolvedAgentProfile`。 + +### 1.2 Profile Resolution + +解析顺序建议: + +```text +Built-in defaults + < Preset agent schema + < Character agent schema + < User profile override + < Per-run override +``` + +冲突规则必须明确: + +- deny 优先于 allow。 +- plan node policy 优先于 profile global policy。 +- user explicit deny 优先级最高。 +- missing required field fail-fast。 + +## 2. Preset Agent Schema + +第一版可以使用 JSON-compatible schema,不必立刻引入 YAML。 + +示例: + +```json +{ + "agent": { + "enabled": true, + "profiles": [ + { + "id": "writer", + "displayName": "Writer", + "model": { "source": "openai", "model": "..." }, + "context": { + "historyBudgetTokens": 12000, + "workspaceFileBudgetTokens": 6000, + "toolResultBudgetTokens": 2000, + "include": [ + "chat.history", + "world.activated", + "character.instructions", + "workspace.output", + "agent.plan", + "agent.tool_results" + ], + "exclude": ["workspace.scratch.private"] + }, + "tools": { + "allow": [ + "workspace.read_file", + "workspace.apply_patch", + "chat.search", + "skill.read" + ], + "deny": ["shell.*"], + "requireApproval": ["mcp.*", "workspace.commit"] + }, + "output": { + "artifacts": [ + { "id": "main", "path": "output/main.md", "kind": "body", "target": "message_body" }, + { "id": "status", "path": "output/status.md", "kind": "status", "target": { "message_extra": "status_bar" } } + ] + } + } + ], + "defaultProfile": "writer" + } +} +``` + +第一期可以只支持: + +- `enabled` +- `defaultProfile` +- `profiles[].id` +- `profiles[].model` +- `profiles[].context` +- `profiles[].tools` +- `profiles[].output.artifacts` + +更复杂的 plan/profile switch 可以 Phase 3 加。 + +## 3. ContextFrame + +ContextFrame 是 Agent Mode 的 prompt 组织真相。 + +它应该表达 typed components: + +```text +SystemInstruction +ChatHistory +WorldInfo +CharacterCard +UserProfile +PresetGuide +WorkspaceTree +WorkspaceFile +ToolDefinitions +ToolResults +Plan +DiffSummary +Skill +``` + +每个 component 至少包含: + +```text +id +kind +source +visibility +tokenBudget +priority +contentRef or inlineContent +metadata +``` + +ContextFrame 不是 provider payload。Provider adapter 只消费编译后的 `ModelRequest`。 + +## 4. Prompt 宏 + +创作者看到的是宏: + +```text +{{agent.plan}} +{{agent.workspace.tree}} +{{agent.file "output/main.md"}} +{{agent.file "scratch/notes.md" budget=800}} +{{agent.tools.available}} +{{agent.tool_results mode="summary" budget=1200}} +{{agent.diff.latest}} +{{agent.skill "long-form-romance"}} +``` + +宏展开必须生成 typed component 或 component reference,而不是简单字符串替换。 + +原因: + +- provider adapter 可以决定 system/user/tool/resource 位置。 +- component 可以独立预算和摘要。 +- hidden/private resource 可以被 policy 拒绝。 +- prompt cache 可以按 component 做。 +- tool result 可以与 chat history 平级。 + +## 5. Context Budget + +Budget 必须可组合: + +```text +totalContextBudget +historyBudgetTokens +workspaceFileBudgetTokens +toolResultBudgetTokens +skillBudgetTokens +worldInfoBudgetTokens +summaryBudgetTokens +``` + +超预算策略: + +```text +truncate +summarize +drop_optional +fail +``` + +默认建议: + +- required component 超预算:fail-fast。 +- optional component 超预算:按 priority drop,并写 `context_component_skipped` event。 +- tool result 超预算:优先摘要。 +- chat history 超预算:使用 windowed read/search + summary。 + +## 6. Tool Policy + +Tool policy 应能表达: + +```json +{ + "allow": ["workspace.*", "chat.search"], + "deny": ["shell.*"], + "requireApproval": ["mcp.*"], + "maxCallsPerRun": 20, + "maxCallsPerTool": { + "chat.search": 5 + } +} +``` + +解析规则: + +1. user deny 最高。 +2. plan node deny/allow 覆盖 profile global allow。 +3. deny 优先 allow。 +4. requireApproval 不等于 deny。 +5. 未显式 allow 的工具默认不可见,除非 profile 选择 permissive mode。 + +建议默认 conservative mode:不在 allow list 的工具不可见。 + +## 7. Visible Resource Policy + +资源可见性要与工具独立。 + +示例: + +```json +{ + "include": [ + "chat.history.tail", + "world.activated", + "workspace.output", + "agent.plan" + ], + "exclude": [ + "workspace.scratch.private", + "user.secrets", + "mcp.resource.private" + ] +} +``` + +Agent 不能通过 `workspace.read_file` 绕过 hidden resource policy。 + +## 8. Plan Policy + +Plan Mode 有三种: + +```text +free +strict +hybrid +``` + +### 8.1 Free Plan + +Agent 可以创建和修改计划。 + +运行时仍必须要求: + +- 先产出 plan。 +- 每个阶段结束 checkpoint。 +- 完成前检查 artifact manifest。 +- 不能突破全局 tool/resource/budget policy。 + +### 8.2 Strict Plan + +Preset/创作者提供固定节点。 + +Agent 不能: + +- 改节点顺序。 +- 跳过 locked 节点。 +- 使用节点外工具。 +- 切换到节点外 profile。 +- 写节点外 expected artifact,除非 output policy 允许。 + +违反必须 fail-fast 或进入 approval,不能静默继续。 + +### 8.3 Hybrid Plan + +部分 locked,部分 free。 + +推荐作为高级默认模式: + +```text +outline locked +write free +polish locked +``` + +它同时保留创作者控制和模型发挥空间。 + +## 9. Plan Node + +建议模型: + +```rust +PlanNode { + id, + title, + locked, + profile_id, + allowed_tools, + visible_files, + max_rounds, + context_budget, + expected_artifacts, + approval_required, +} +``` + +Plan node 开始/完成必须写 journal: + +```text +plan_node_started +plan_node_completed +``` + +## 10. Profile Switch + +Profile switch 可以来自: + +- plan node 指定。 +- model request。 +- runtime policy。 +- user override。 + +必须检查: + +- current plan node 是否允许 switch。 +- target profile 是否存在。 +- target profile 的 tool/resource/model policy 是否满足平台限制。 +- switch 次数是否超 budget。 + +结果必须写 journal: + +```text +profile_switch_requested +profile_switched +profile_switch_denied +``` + +## 11. 创作者自由与安全边界 + +创作者可以控制: + +- Agent 可见哪些内容。 +- Agent 可用哪些工具。 +- 输出有哪些 artifact。 +- 哪些阶段严格,哪些阶段自由。 +- 是否需要审批。 +- token/tool/context budget。 +- profile/model 切换策略。 + +创作者不能控制: + +- workspace root 之外的文件访问。 +- MCP stdio command。 +- 平台 policy 禁用的 provider/source/endpoint override。 +- 用户显式 deny 的工具。 +- journal 是否记录副作用。 +- commit 是否绕过保存契约。 + +## 12. MVP Profile + +Phase 1 最小 profile 可以硬编码或来自设置: + +```json +{ + "id": "default-writer", + "modelRef": { "source": "openai", "model": "current" }, + "contextPolicy": { + "mode": "prompt_snapshot" + }, + "toolPolicy": { + "allow": [], + "deny": ["*"] + }, + "outputPolicy": { + "artifacts": [ + { "id": "main", "path": "output/main.md", "target": "message_body", "required": true } + ] + } +} +``` + +这个 profile 足以支撑 one-step Agent,同时给 Phase 2/3 留出自然扩展点。 + diff --git a/docs/Agent/README.md b/docs/Agent/README.md new file mode 100644 index 00000000..9c0a9a46 --- /dev/null +++ b/docs/Agent/README.md @@ -0,0 +1,23 @@ +# TauriTavern Agent Detail Docs + +本目录保存 Agent 框架的细节设计文档。高层入口仍保留在 `docs/` 根目录: + +1. `docs/AgentArchitecture.md`:系统边界、分层、数据流。 +2. `docs/AgentContract.md`:不可破坏的不变量与 fail-fast 约束。 +3. `docs/AgentImplementPlan.md`:分阶段实施计划与验收标准。 + +## 细节文档 + +| 文档 | 内容 | +| --- | --- | +| [Workspace.md](Workspace.md) | Workspace、Artifact、Checkpoint、commit/rollback 语义 | +| [RunEventJournal.md](RunEventJournal.md) | Run Event、状态机、订阅、恢复、取消与审批 | +| [ProfilesAndPreset.md](ProfilesAndPreset.md) | Agent Profile、Preset agent schema、ContextFrame、Plan Policy | +| [ToolSystem.md](ToolSystem.md) | ToolSpec、ToolResult、Tool Registry、Policy、审批与 Legacy ToolManager 边界 | +| [LlmGateway.md](LlmGateway.md) | provider-agnostic LLM gateway 与现有 `ChatCompletionService` 的复用边界 | +| [McpSkill.md](McpSkill.md) | MCP 独立集成、Skill 渐进披露、安全边界 | +| [TestingStrategy.md](TestingStrategy.md) | Domain/Application/Frontend/Security/Performance 测试矩阵 | + +## 进度跟踪 + +实时开发进度不写在本目录;请更新 `docs/CurrentState/AgentFramework.md`。 diff --git a/docs/Agent/RunEventJournal.md b/docs/Agent/RunEventJournal.md new file mode 100644 index 00000000..68294793 --- /dev/null +++ b/docs/Agent/RunEventJournal.md @@ -0,0 +1,395 @@ +# TauriTavern Agent Run Event Journal + +本文档定义 Agent Run 的事件日志、状态机、订阅、恢复、取消与审批语义。 + +Run Journal 是 Agent 系统的真相源。没有 journal 的 Agent 只是一个难以调试的异步流程。 + +## 1. 原则 + +1. Append-only:事件只能追加,不能原地修改。 +2. Ordered:每个 run 内 event seq 单调递增。 +3. Durable:关键副作用前后必须落 journal。 +4. Replayable:UI timeline、debug、resume 应尽量从 journal 重建。 +5. User-visible:用户能看到工具调用、审批、diff、checkpoint、错误。 + +## 2. 文件格式 + +第一期建议 JSONL: + +```text +events.jsonl +``` + +每行一个 event envelope: + +```json +{ + "seq": 12, + "id": "evt_...", + "runId": "run_...", + "timestamp": "2026-04-26T00:00:00Z", + "level": "info", + "type": "tool_call_completed", + "payload": {}, + "causality": { + "parentEventId": "evt_...", + "requestId": "model_req_..." + } +} +``` + +要求: + +- `seq` 由 repository 分配。 +- `id` 全局唯一或 run 内唯一均可,但必须稳定。 +- `type` 使用 snake_case。 +- payload 必须可反序列化为 tagged enum。 +- 大文本、二进制、长 tool result 不直接塞进 event,使用 resource ref。 + +## 3. Run Status + +建议状态: + +```text +Created +InitializingWorkspace +AssemblingContext +Planning +Running +CallingModel +AwaitingApproval +DispatchingTool +ApplyingWorkspacePatch +CreatingCheckpoint +AssemblingArtifacts +Committing +Completed +Cancelling +Cancelled +Failed +``` + +终态: + +```text +Completed +Cancelled +Failed +``` + +状态迁移必须通过 event 记录,例如: + +```text +status_changed { from, to, reason } +``` + +## 4. Event 类型 + +### 4.1 Run Lifecycle + +```text +run_started +status_changed +run_completed +run_cancel_requested +run_cancelled +run_failed +``` + +`run_failed` payload: + +```json +{ + "code": "tool_policy_denied", + "message": "Tool mcp.foo is not allowed by current plan node", + "retryable": false, + "details": {} +} +``` + +### 4.2 Workspace + +```text +workspace_initialized +workspace_file_written +workspace_patch_requested +workspace_patch_applied +workspace_patch_failed +workspace_file_deleted +workspace_rollback_completed +``` + +Workspace event 不应内联大文件全文。应记录 path、sha256、bytes、patch ref。 + +### 4.3 Context + +```text +context_assembly_started +context_component_added +context_component_skipped +context_assembled +context_assembly_failed +``` + +`context_component_skipped` 必须有 reason,例如 budget、policy hidden、empty。 + +Policy 拒绝不是 skipped,而是 failure。 + +### 4.4 Model + +```text +model_request_created +model_request_sent +model_delta +model_tool_call_delta +model_completed +model_failed +``` + +`model_request_created` 应记录 request ref、profile id、provider/source、model、token estimate,不应默认记录完整 prompt。完整 prompt 是否保存取决于调试设置与隐私策略。 + +### 4.5 Tool + +```text +tool_call_requested +tool_call_awaiting_approval +tool_call_approved +tool_call_denied +tool_call_started +tool_call_completed +tool_call_failed +``` + +`tool_call_requested`: + +```json +{ + "callId": "call_...", + "toolName": "workspace.apply_patch", + "displayName": "Apply patch", + "argumentsRef": "events/blobs/call_..._args.json", + "approvalRequired": false, + "policy": { + "source": "plan_node", + "allowed": true + } +} +``` + +`tool_call_completed`: + +```json +{ + "callId": "call_...", + "toolName": "workspace.apply_patch", + "resultRef": "tool-results/call_....json", + "isError": false, + "durationMs": 120, + "usage": {} +} +``` + +### 4.6 Checkpoint / Diff + +```text +checkpoint_created +checkpoint_pruned +diff_created +``` + +Checkpoint event must include checkpoint id、reason、file count、bytes。 + +### 4.7 Plan / Profile + +```text +plan_created +plan_updated +plan_node_started +plan_node_completed +plan_policy_violation +profile_selected +profile_switch_requested +profile_switched +profile_switch_denied +``` + +Locked plan violation 必须失败或等待用户决策,不能静默忽略。 + +### 4.8 Artifact / Commit + +```text +artifact_assembly_started +artifact_assembled +artifact_assembly_failed +commit_started +run_committed +commit_failed +committed_message_rollback_completed +``` + +Commit event must include chat ref、message index/id、checkpoint id、artifact set id。 + +## 5. 事件与副作用的顺序 + +推荐顺序: + +```text +意图事件 + -> 执行副作用 + -> 结果事件 +``` + +例如 tool call: + +```text +tool_call_requested +tool_call_started +tool dispatch +tool_call_completed / tool_call_failed +``` + +例如 workspace patch: + +```text +workspace_patch_requested +apply patch +workspace_patch_applied +checkpoint_created +``` + +如果副作用前需要保证恢复后不会重复执行,可以先写 pending event,再由恢复逻辑检查 pending 状态。这一点对 MCP/外部副作用尤其重要。 + +## 6. 实时订阅 + +前端订阅 API: + +```js +const unsubscribe = await window.__TAURITAVERN__.api.agent.subscribe(runId, event => {}); +``` + +要求: + +- subscribe 不复播全部历史,除非 options 指定。 +- UI 首次进入 run 页面应先 `readEvents(runId, { afterSeq })`,再 subscribe。 +- unsubscribe 必须幂等。 +- 事件丢失时,UI 可通过 `afterSeq` 补拉。 + +## 7. 分页读取 + +Journal 读取需要支持: + +```text +readEvents(runId, { afterSeq, limit }) +readEvents(runId, { beforeSeq, limit }) +``` + +移动端 timeline 不应该一次读取巨大 journal。 + +## 8. Cancel + +Cancel 是用户意图,不是 failure。 + +流程: + +```text +cancel_agent_run(runId) + -> run_cancel_requested + -> signal cancellation token + -> 当前可取消操作停止 + -> run_cancelled +``` + +约束: + +- LLM call 必须尽量复用现有 cancellation registry 或等价 watch channel。 +- Tool dispatch 需要声明是否 cancellable。 +- Cancel 后不能 commit。 +- Cancel 后 workspace 与 checkpoint 保留。 + +## 9. Approval + +危险工具、MCP tool、commit、profile switch 可以要求审批。 + +流程: + +```text +tool_call_requested +tool_call_awaiting_approval +approveToolCall({ approved: true/false }) +tool_call_approved / tool_call_denied +``` + +审批必须记录: + +- requested tool +- arguments summary/ref +- policy reason +- user decision +- decision timestamp + +审批拒绝不是系统错误。它可以进入模型可见 tool error,或让 run 暂停/失败,取决于 plan policy。 + +## 10. Resume + +第一期可以不实现自动 resume,但 journal 设计必须支持。 + +Resume 的基本策略: + +- 读取最后状态。 +- 如果终态,拒绝 resume。 +- 如果 pending model call,没有 result event,标记 previous attempt interrupted,重新发起或让用户选择。 +- 如果 pending external tool call,默认不重复执行,要求人工确认。 +- 如果 pending workspace patch,检查 patch result/ref 决定是否重放。 + +外部副作用必须谨慎,不能因为恢复而重复调用付费 API 或危险工具。 + +## 11. Error 分类 + +建议错误 code 分层: + +```text +agent.invalid_intent +agent.invalid_profile +agent.policy_violation +agent.cancelled +workspace.path_denied +workspace.required_artifact_missing +workspace.patch_failed +context.budget_exceeded +model.provider_denied +model.request_failed +tool.not_found +tool.policy_denied +tool.execution_failed +mcp.server_denied +mcp.tool_denied +commit.cursor_integrity +commit.save_failed +journal.append_failed +``` + +`journal.append_failed` 是严重错误。没有 journal 就不能继续执行副作用。 + +## 12. MVP Event Set + +Phase 1 至少实现: + +```text +run_started +status_changed +workspace_initialized +context_assembled +model_request_created +model_completed +workspace_file_written +checkpoint_created +artifact_assembled +commit_started +run_committed +run_cancel_requested +run_cancelled +run_failed +``` + +这套最小事件已经足够支撑 one-step run、timeline、cancel、debug 和 rollback 基础。 + diff --git a/docs/Agent/TestingStrategy.md b/docs/Agent/TestingStrategy.md new file mode 100644 index 00000000..43398b7b --- /dev/null +++ b/docs/Agent/TestingStrategy.md @@ -0,0 +1,222 @@ +# TauriTavern Agent Testing Strategy + +本文档定义 Agent 系统的测试策略。Agent 涉及生成、文件、工具、外部协议、保存与兼容事件,测试必须从第一阶段就建立。 + +## 1. 测试目标 + +测试要守住: + +- Legacy Generate 兼容。 +- Clean Architecture 边界。 +- Workspace path 安全。 +- Journal 完整性。 +- Windowed payload 保存契约。 +- LLM gateway 不绕过现有 policy/logging。 +- Tool policy 与 approval。 +- MCP 安全边界。 +- 移动端内存与分页读取。 + +## 2. Domain Tests + +覆盖: + +```text +WorkspacePath normalization +WorkspacePath traversal rejection +Artifact manifest validation +Required artifact missing +AgentRunStatus transitions +AgentRunEvent serialization +PlanPolicy strict/free/hybrid +ToolPolicy allow/deny/approval +Profile resolution precedence +Checkpoint metadata +``` + +Domain tests 不应需要 Tauri、文件系统或 HTTP。 + +## 3. Application Tests + +使用 mock repositories/gateway/tools。 + +覆盖: + +```text +one-step run success +one-step run model failure +cancel before model call +cancel during model call +workspace write creates checkpoint +artifact assembly success/failure +commit service called through expected boundary +tool loop success +tool recoverable error +tool policy denied +plan locked node violation +profile switch allowed/denied +``` + +关键断言: + +- 每个副作用都有 journal event。 +- failure 后状态为 `Failed`。 +- cancel 后状态为 `Cancelled`。 +- required artifact 缺失不 commit。 + +## 4. Infrastructure Tests + +覆盖: + +```text +file event journal append/read pagination +checkpoint snapshot restore +workspace repository rejects symlink escape +workspace repository handles unicode relative paths +file sizes and retention +MCP config allowlist +SkillRepository list/read +``` + +文件测试应使用临时目录,并覆盖 macOS/Linux/Windows path 差异。 + +## 5. LLM Gateway Tests + +覆盖: + +```text +gateway calls ChatCompletionService, not HttpChatCompletionRepository +source denied by iOS policy +endpoint override denied +prompt cache hints preserved +LLM API log wrapper remains in path +stream chunk becomes model_delta event +cancel propagates +tool_call_id opaque round-trip +native metadata round-trip +``` + +特别要覆盖 `docs/CurrentState/NativeApiFormats.md` 中的契约: + +- tool_call_id 不透明。 +- Gemini native metadata 保真。 +- Custom Claude header 策略不被硬编码覆盖。 + +## 6. Frontend Contract Tests + +覆盖: + +```text +window.__TAURITAVERN__.api.agent exists after ready +window.__TAURITAVERN__.api.mcp exists after ready +subscribe returns idempotent unsubscribe +Agent API uses safeInvoke, not raw command dependency in public caller +types.d.ts includes agent/mcp types +``` + +Legacy 回归: + +```text +Agent mode off: Generate signature unchanged +Agent mode off: GENERATION_STARTED order unchanged +Agent mode off: GENERATE_AFTER_DATA dryRun still emitted +Agent mode off: ToolManager legacy behavior unchanged +Agent event does not emit fake GENERATION_* events +``` + +## 7. Windowed Payload Integration Tests + +覆盖: + +```text +Agent reads history through windowed/search APIs +Agent does not expand UI chat window +Agent commit uses chat save contract +Agent commit does not trigger cursor mismatch under serialized saves +cursor mismatch fails clearly +force does not bypass cursor signature +rollback committed message uses save contract +``` + +## 8. Security Tests + +覆盖: + +```text +../ path rejected +absolute path rejected +Windows drive path rejected +symlink escape rejected +hidden resource not in context +denied tool not visible +denied tool call fails +MCP arbitrary stdio command rejected +Agent cannot edit MCP config +extension tool without authorization hidden +provider source denied by policy +``` + +## 9. Performance Tests + +覆盖: + +```text +large chat history remains virtual +journal pagination does not load full file +workspace tree lazy read +checkpoint retention cap +tool result budget truncation/summary +mobile default budgets +``` + +指标建议: + +- Phase 1 one-step run workspace 初始化耗时。 +- Journal append/read latency。 +- Large history Agent start memory growth。 +- Timeline first render event count。 + +## 10. Golden Fixtures + +建议建立 fixtures: + +```text +fixtures/agent/ + prompt_snapshot_openai.json + prompt_snapshot_claude.json + run_events_one_step.jsonl + manifest_main_only.json + manifest_multi_artifact.json + tool_result_chat_search.json + checkpoint_snapshot/ +``` + +Golden fixtures 应尽量脱敏,不包含真实 API key 或私人聊天。 + +## 11. Phase Gates + +Phase 1 不合并,除非: + +- one-step run 测试通过。 +- cancel 测试通过。 +- artifact commit 测试通过。 +- Agent Mode off smoke tests 通过。 + +Phase 2 不合并,除非: + +- tool loop 测试通过。 +- tool result 不写 chat message。 +- policy denied 测试通过。 +- workspace path security 测试通过。 + +Phase 3 不合并,除非: + +- profile resolution 测试通过。 +- strict/free/hybrid plan 测试通过。 +- profile switch journal 测试通过。 + +Phase 5 不合并,除非: + +- MCP stdio command allowlist 测试通过。 +- dangerous tool approval 测试通过。 +- Agent 不能编辑 MCP config。 + diff --git a/docs/Agent/ToolSystem.md b/docs/Agent/ToolSystem.md new file mode 100644 index 00000000..a9f96ac3 --- /dev/null +++ b/docs/Agent/ToolSystem.md @@ -0,0 +1,407 @@ +# TauriTavern Agent Tool System + +本文档定义 Agent Tool System 的 Registry、Policy、Tool Call、Tool Result、审批与前端/扩展/MCP 边界。 + +Agent 的能力上限很大程度由工具决定,但第一期更重要的是工具架构正确,而不是工具数量多。 + +## 1. 目标 + +工具系统必须做到: + +- 工具可发现。 +- 工具可按 profile/plan/policy 控制可见性。 +- 工具调用可审计。 +- 工具结果能进入 ContextFrame。 +- 工具错误能被模型和用户理解。 +- 工具副作用可 checkpoint/rollback 或明确不可回滚。 +- MCP/extension/内置工具使用同一抽象。 + +## 2. 非目标 + +第一期不做: + +- shell 工具。 +- 任意后端 JS 执行。 +- 世界书动态脚本作为后端工具。 +- 任意远端工具自动注册。 +- MCP Sampling 自动模型调用。 +- 大而全的插件市场。 + +## 3. ToolSpec + +建议模型: + +```rust +ToolSpec { + name, + title, + description, + input_schema, + output_schema, + annotations, + visibility, + permission, + budget, + source, +} +``` + +字段说明: + +- `name`:稳定 ID,例如 `workspace.apply_patch`。 +- `title`:UI 展示名。 +- `description`:给模型和用户看的能力说明。 +- `input_schema`:JSON Schema。 +- `output_schema`:可选 JSON Schema。 +- `annotations`:side effect、read only、destructive、idempotent、cost 等。 +- `visibility`:模型是否可见、是否只对用户可见。 +- `permission`:always allow、approval required、deny。 +- `budget`:最大调用次数、最大输出 token、超时。 +- `source`:built_in、mcp、extension、skill。 + +## 4. ToolResult + +建议模型: + +```rust +ToolResult { + call_id, + content, + structured, + is_error, + resource_refs, + usage, +} +``` + +`content` 支持: + +```text +Text +Json +ImageRef +AudioRef +FileRef +ResourceRef +DiffRef +``` + +原则: + +- 大结果写 resource ref,不内联到 journal。 +- `is_error = true` 可以是模型可恢复错误,不一定让 run Failed。 +- 系统级错误,如 workspace path escape、journal append failed,必须让 run Failed。 + +## 5. Tool Call 生命周期 + +```text +model emits tool call + ↓ +parse to ToolCall + ↓ +policy resolve + ↓ +maybe approval + ↓ +dispatch + ↓ +write result + ↓ +append journal + ↓ +ContextFrame includes ToolResults if policy allows +``` + +Journal: + +```text +tool_call_requested +tool_call_awaiting_approval +tool_call_approved / tool_call_denied +tool_call_started +tool_call_completed / tool_call_failed +``` + +## 6. Policy Resolution + +输入: + +- user global policy +- profile tool policy +- plan node tool policy +- tool source policy +- platform policy +- runtime budget + +输出: + +```text +visible: bool +callable: bool +approvalRequired: bool +reason +budget +``` + +规则: + +- user deny 最高。 +- platform deny 不可覆盖。 +- plan node deny/allow 优先于 profile allow。 +- deny 优先 allow。 +- approval 不是 deny。 +- 未允许工具默认不可见。 + +Policy violation 必须写 journal 并 fail-fast,除非这是模型可恢复的 denied tool result 策略。 + +## 7. 内置工具 + +Phase 2 建议第一批: + +```text +workspace.list_files +workspace.read_file +workspace.write_file +workspace.apply_patch +workspace.create_checkpoint +workspace.finish + +chat.search +chat.read_history_tail +chat.read_history_before + +skill.list +skill.read + +worldinfo.read_activated +``` + +### 7.1 Workspace Tools + +`workspace.list_files` + +- Read-only。 +- 返回 workspace tree。 +- 可按 path prefix。 + +`workspace.read_file` + +- Read-only。 +- 只能读 visible resource。 +- 受 token/byte budget 控制。 + +`workspace.write_file` + +- Mutating。 +- 只能写 generated/materialized writable path。 +- 写后应 checkpoint。 + +`workspace.apply_patch` + +- Mutating。 +- 应使用明确 patch 格式。 +- patch 失败返回可恢复 tool error。 +- path escape 是 system failure。 + +`workspace.create_checkpoint` + +- Mutating metadata。 +- 可由 runtime 自动调用,也可暴露给模型。 + +`workspace.finish` + +- 控制工具。 +- 表示模型认为 artifact 已完成。 +- Runtime 仍必须校验 manifest。 + +### 7.2 Chat Tools + +`chat.search` + +- Read-only。 +- 通过 Rust chat repository/search 能力实现。 +- 不能把完整 history 拉入前端。 + +`chat.read_history_tail` + +- Read-only。 +- 读取最近消息窗口。 + +`chat.read_history_before` + +- Read-only。 +- 使用 cursor/page 语义读取更早历史。 + +### 7.3 Skill Tools + +`skill.list` + +- Read-only。 +- 返回当前 profile 可见 skill 的摘要。 + +`skill.read` + +- Read-only。 +- 支持 section/budget。 +- 结果可进入 ContextFrame。 + +### 7.4 WorldInfo Tools + +`worldinfo.read_activated` + +- Read-only。 +- 读取最近一次最终激活结果或本次 run materialized 的 world info。 +- 不暴露 world info 扫描中间循环状态为 Public Contract。 + +## 8. Provider Tool Call Adapter + +不同 provider tool call 格式不同。Tool System 内部必须使用统一格式: + +```text +ToolCall { + id, + name, + arguments, + providerMetadata, +} +``` + +Provider adapter 负责: + +- 把 `ToolSpec` 转成 provider schema。 +- 把 provider-native tool call 转回 `ToolCall`。 +- 保留必要 native metadata,例如 reasoning signature、tool call id。 + +上层 runtime 不应关心 OpenAI/Claude/Gemini 的工具字段差异。 + +## 9. Tool Result 进入 Prompt + +工具结果不写 chat message。 + +工具结果进入后续模型请求的路径: + +```text +ToolResult store + ↓ +ContextAssemblyService + ↓ +PromptComponentKind::ToolResults + ↓ +ModelRequest +``` + +Preset 可以控制: + +- tool result 是否可见。 +- 原文还是摘要。 +- 预算。 +- 与 chat history/world info/workspace file 的顺序。 + +## 10. Approval + +需要审批的工具: + +- MCP tools。 +- destructive tools。 +- commit/rollback。 +- external network side effects。 +- 高成本模型/采样工具。 + +Approval UI 至少展示: + +- tool name/title。 +- arguments summary。 +- side effect annotation。 +- source。 +- policy reason。 + +审批结果必须写 journal。 + +## 11. Error Semantics + +工具错误分三类: + +```text +RecoverableToolError + 模型可读结果,run 可继续。 + +PolicyDenied + 根据 policy 决定 fail-fast 或返回 denied tool result。 + +SystemFailure + runtime 失败,run 进入 Failed。 +``` + +例子: + +- `chat.search` 查不到结果:recoverable result。 +- `workspace.apply_patch` patch context mismatch:recoverable error。 +- `workspace.apply_patch` path traversal:system failure。 +- denied MCP tool:policy denied。 +- journal append failed:system failure。 + +## 12. 与 Legacy ToolManager 的关系 + +当前前端 `ToolManager` 是 Legacy Generate 的工具系统。它: + +- 在前端注册工具。 +- 直接调用 JS action。 +- 把结果保存成 `is_system` chat message。 +- 递归调用 `Generate()`。 + +Agent Tool System 不能复用它作为运行时真相。 + +可以借鉴: + +- function tool 的作者体验。 +- display name / format message。 +- provider tool schema 注册经验。 + +禁止继承: + +- 工具结果写 chat 楼层。 +- 递归 Generate 驱动循环。 +- 后端执行任意 JS。 + +## 13. Extension Tool Bridge + +未来扩展工具应通过受控 bridge: + +```text +extension registers tool metadata + ↓ +ToolRegistry marks source=extension + ↓ +Agent requests tool + ↓ +frontend bridge asks extension to execute + ↓ +result returns to backend journal +``` + +要求: + +- extension tool 默认需要用户或 profile 授权。 +- bridge 调用必须有 timeout。 +- result 必须结构化。 +- extension 不得直接写 Agent workspace,必须通过 tool result 或受控 workspace tool。 + +## 14. MVP Tool System + +Phase 1 可以没有 model-callable tool。 + +Phase 2 最小可合并集: + +- ToolSpec/ToolResult domain model。 +- ToolRegistryService。 +- workspace.read_file。 +- workspace.apply_patch。 +- workspace.finish。 +- chat.search。 +- skill.read。 +- journal events。 +- policy allow/deny。 +- 无审批或只支持简单 approval。 + +这已经足以验证真正多轮 Agent loop。 + diff --git a/docs/Agent/Workspace.md b/docs/Agent/Workspace.md new file mode 100644 index 00000000..b9d705c7 --- /dev/null +++ b/docs/Agent/Workspace.md @@ -0,0 +1,427 @@ +# TauriTavern Agent Workspace + +本文档定义 Agent Workspace 的存储模型、路径约束、Artifact Assembly、Checkpoint 与回滚语义。 + +Workspace 是 Agent Mode 的中心抽象。Agent 不直接写聊天消息,而是在 workspace 中像编辑项目文件一样多轮修改输出,最后由 runtime 提交 artifact。 + +## 1. 核心目标 + +Workspace 解决四个问题: + +1. 多轮编辑:允许模型反复修改草稿、计划、状态栏、小剧场等文件。 +2. 可审计:每次写入、patch、checkpoint 都能追踪。 +3. 可回滚:用户不满意时回到某个 checkpoint,而不是只能重新生成。 +4. 可组合:最终聊天消息可以由多个 artifact 组装,而不是只有单个 output。 + +## 2. 两级 Workspace + +推荐使用两级结构: + +```text +agent-workspaces/ + chats/ + / + resources/ + world/ + character/ + preset/ + user/ + skills/ + memory/ + runs/ + / + manifest.json + input/ + output/ + plan/ + scratch/ + summaries/ + checkpoints/ + patches/ + events.jsonl +``` + +实际物理根目录为数据根下的 `_tauritavern/agent-workspaces`。它是 TauriTavern 运行产物空间,不属于上游 SillyTavern `default-user` 聊天文件布局。 + +`` 必须由稳定聊天身份派生: + +```text +stableChatId = window.__TAURITAVERN__.api.chat.open(chatRef).stableId() +chatWorkspaceId = "chat_" + sha256({ kind, stableChatId })[0..16] +``` + +它不得直接使用可变的 chat file name、角色显示名或完整 `chatRef` hash。聊天重命名、角色卡显示名变化、前端当前引用变化,不应该让同一个稳定聊天分裂到新的 chat workspace。 + +### 2.1 Chat Workspace + +对话级 workspace 保存长期资源引用或 materialized 快照: + +- 当前角色卡的 Agent 指导文件。 +- 当前 preset 的 Agent policy。 +- 用户侧写。 +- 可用 skill 索引。 +- 长期 memory/resource 索引。 + +它不应该被某一次 regenerate/swipe 污染。 + +同一稳定聊天的 normal、regenerate、swipe、continue 等多次 Agent run 共享同一个 chat workspace,但每次 run 必须有独立 run workspace 与独立 journal。 + +### 2.2 Run Workspace + +一次 Agent Run 拥有独立 run workspace: + +- 本次输入快照。 +- 本次计划。 +- 本次 scratch。 +- 本次输出 artifact。 +- 本次 checkpoint。 +- 本次 journal。 + +这样不同 run 可以比较、回滚、删除或保留,不互相覆盖。 + +## 3. Resource 类型 + +“万物皆文件”是 Agent 视角的抽象,不代表所有数据都必须物理复制。 + +Workspace resource 分三类: + +```text +MaterializedFile + 已落盘到 run workspace 的文本或二进制文件。 + +VirtualResource + 看起来像文件,但内容由 repository/tool 按需读取。 + +GeneratedArtifact + Agent 生成或修改、准备参与提交的输出文件。 +``` + +示例: + +```text +input/prompt_snapshot.json MaterializedFile +input/world/activated.md MaterializedFile +input/preset/instructions.md MaterializedFile +input/character/card.md MaterializedFile +chat/history.tail.md VirtualResource +chat/history.search://query=... VirtualResource +skills/long-form-romance/SKILL.md VirtualResource or MaterializedFile +output/main.md GeneratedArtifact +output/status.md GeneratedArtifact +``` + +原则: + +- 大历史、大世界书、大记忆库默认 virtual。 +- 本次生成必须固定的输入可以 materialize,但 “materialize input/context” 不等于复制完整上下文。 +- 模型要改的文件必须是 materialized/generated,不能直接修改 virtual resource。 + +## 4. 路径契约 + +WorkspacePath 必须是逻辑路径,不直接等同系统路径。 + +必须满足: + +- 相对路径。 +- 使用 `/` 作为分隔符。 +- 非空。 +- UTF-8。 +- normalize 后不能包含 `..`。 +- 不能是绝对路径。 +- 不能包含 NUL。 +- 不能包含 Windows drive prefix。 +- 不能逃出 workspace root。 + +禁止: + +```text +../secrets.json +/Users/me/file +C:\Users\me\file +output/../../chat.jsonl +scratch/\0bad +``` + +所有 workspace file operation 必须经由 WorkspaceService 统一校验。工具、MCP、extension bridge 不得自行拼接文件系统路径。 + +## 5. Manifest + +每个 run workspace 必须有 `manifest.json`。 + +建议结构: + +```json +{ + "workspaceVersion": 1, + "runId": "run_...", + "stableChatId": "stable-chat-id", + "chatRef": { + "kind": "character", + "characterId": "...", + "fileName": "..." + }, + "createdAt": "2026-04-26T00:00:00Z", + "input": { + "mode": "prompt_snapshot", + "promptSnapshotPath": "input/prompt_snapshot.json" + }, + "artifacts": [ + { + "id": "main", + "path": "output/main.md", + "kind": "body", + "target": "message_body", + "required": true, + "assemblyOrder": 10 + }, + { + "id": "status", + "path": "output/status.md", + "kind": "status", + "target": { "message_extra": "status_bar" }, + "required": false, + "assemblyOrder": 20 + }, + { + "id": "theater", + "path": "output/theater.md", + "kind": "side_scene", + "target": "combined_markdown", + "required": false, + "assemblyOrder": 30 + } + ], + "commitPolicy": { + "defaultTarget": "message_body", + "combineTemplate": "{{main}}\n\n---\n\n{{theater}}", + "storeArtifactsInExtra": true + } +} +``` + +Manifest 是 runtime contract: + +- required artifact 缺失必须 fail-fast。 +- unknown target 必须 fail-fast。 +- artifact path 违反 WorkspacePath 必须 fail-fast。 +- commitPolicy 不合法必须 fail-fast。 + +## 6. 目录职责 + +```text +input/ + 本次 run 的不可变输入快照。 + +output/ + 准备提交给聊天消息或 extra 的 artifact。 + +plan/ + runtime 可检查的计划文件和用户/模型可读计划。 + +scratch/ + Agent 私有草稿。默认不提交,是否进入 context 由 policy 决定。 + +summaries/ + 对历史、工具结果、前序步骤的摘要。 + +checkpoints/ + checkpoint snapshot 与 manifest。 + +patches/ + 可选 patch 记录。第一期可以只做 snapshot。 + +events.jsonl + append-only run journal。 +``` + +## 7. Artifact Assembly + +Artifact Assembly 把多个 workspace 文件组装为 chat message。 + +Artifact target: + +```text +MessageBody + 写入 chat message `mes`。 + +MessageExtra(key) + 写入 chat message extra 的 TauriTavern namespace。 + +CombinedMarkdown(template) + 按模板合并到 message body。 + +HiddenRunArtifact + 不进入 chat,仅保留在 run workspace。 +``` + +建议优先级: + +1. 第一版只要求 `output/main.md` -> `mes`。 +2. 同时保留 manifest 能力,允许后续扩展状态栏、小剧场、变量。 +3. optional artifact 缺失时跳过。 +4. required artifact 缺失时 fail-fast。 + +## 8. Commit 语义 + +Commit 是 workspace 到 chat 的边界。 + +必须: + +- 在 commit 前创建 checkpoint。 +- 读取 manifest。 +- assemble artifacts。 +- 通过现有 chat 保存契约写入。 +- 写入 agent metadata。 +- journal 记录 `artifact_assembled` 与 `run_committed`。 + +禁止: + +- 直接写 JSONL。 +- 绕过 windowed payload cursor 保护。 +- 由工具自行 commit。 +- commit 半成品 artifact。 + +Commit metadata 建议: + +```json +{ + "tauritavern": { + "agent": { + "runId": "run_...", + "stableChatId": "stable-chat-id", + "checkpointId": "ckpt_...", + "profileId": "writer", + "artifactSetId": "artifact_set_...", + "artifacts": [ + { "id": "main", "kind": "body", "path": "output/main.md" } + ] + } + } +} +``` + +## 9. Checkpoint + +Checkpoint 是 run 内回滚和 commit 后追踪的基础。 + +第一期建议 snapshot,不引入 git。 + +结构: + +```text +checkpoints/ + 000001/ + checkpoint.json + manifest.json + output/ + main.md + status.md + plan/ + plan.md + summaries/ + ... +``` + +`checkpoint.json`: + +```json +{ + "id": "ckpt_...", + "seq": 1, + "runId": "run_...", + "createdAt": "2026-04-26T00:00:00Z", + "reason": "after_workspace_patch", + "eventSeq": 42, + "files": [ + { "path": "output/main.md", "sha256": "...", "bytes": 1024 } + ] +} +``` + +Checkpoint 时机: + +- workspace 初始化后。 +- plan 创建后。 +- 每次 workspace-mutating tool 后。 +- 每个 plan node 完成后。 +- artifact assembly 前。 +- commit 前。 + +## 10. 回滚 + +### 10.1 Run 内回滚 + +Run 内回滚只恢复 workspace: + +```text +rollback(runId, checkpointId) + -> restore workspace files + -> append rollback event + -> status remains Running/AwaitingApproval or becomes Paused +``` + +它不修改 chat。 + +### 10.2 Commit 后回滚 + +Commit 后回滚修改 chat message: + +```text +rollbackCommittedMessage(runId, checkpointId) + -> assemble artifacts from checkpoint + -> replace/delete committed chat message + -> save through chat save contract + -> append rollback committed event +``` + +必须遵守 windowed payload 保存串行化。 + +## 11. Retention + +默认 retention 应保守: + +- Completed run 可以保留完整 workspace。 +- Failed/Cancelled run 默认保留,便于 debug。 +- 移动端可以限制 checkpoint 数量或总大小,但删除必须明确记录。 +- 用户删除聊天时,关联 workspace 的清理策略需要独立设计,不能静默泄漏大量文件。 + +建议后续提供设置: + +```text +keepCompletedRuns +keepFailedRuns +maxRunWorkspacesPerChat +maxCheckpointBytesPerRun +autoPruneAfterDays +``` + +## 12. 性能约束 + +- 不复制完整聊天历史。 +- 不把大 virtual resource materialize 到每个 run。 +- checkpoint 第一版只 snapshot 小文本文件;大文件使用引用或跳过,并在 manifest 声明。 +- workspace tree 展示应懒加载。 +- timeline 读取 journal 应支持分页。 + +## 13. MVP Workspace + +Phase 1 最小文件: + +```text +runs// + manifest.json + input/prompt_snapshot.json + output/main.md + checkpoints/000001/checkpoint.json + checkpoints/000001/output/main.md + events.jsonl +``` + +这个结构足以支撑: + +- one-step run +- journal +- checkpoint +- artifact commit +- rollback 基础 +- 后续工具循环增量接入 diff --git a/docs/AgentArchitecture.md b/docs/AgentArchitecture.md new file mode 100644 index 00000000..c06c49b0 --- /dev/null +++ b/docs/AgentArchitecture.md @@ -0,0 +1,410 @@ +# TauriTavern Agent Architecture + +本文档定义 TauriTavern Agent 系统的总体架构。它不是功能清单,而是后续实现必须遵守的架构地图。 + +配套阅读顺序: + +1. `docs/AgentArchitecture.md`:系统边界、分层、数据流。 +2. `docs/AgentContract.md`:不可破坏的不变量与 fail-fast 约束。 +3. `docs/AgentImplementPlan.md`:分阶段实施计划与验收标准。 +4. `docs/Agent/Workspace.md`:Workspace、Artifact、Checkpoint 的存储语义。 +5. `docs/Agent/RunEventJournal.md`:Run Event、状态机、恢复/取消语义。 +6. `docs/Agent/ProfilesAndPreset.md`:Preset、Agent Profile、Plan Policy。 +7. `docs/Agent/ToolSystem.md`:Tool Registry、Tool Result、权限与审批。 +8. `docs/Agent/LlmGateway.md`:provider-agnostic LLM gateway 与现有 ChatCompletionService 的复用边界。 +9. `docs/Agent/McpSkill.md`:MCP 与 Skill 的边界。 +10. `docs/API/Agent.md`:前端 Host ABI 与 Tauri command 草案。 +11. `docs/API/MCP.md`:非 Agent 模式下的 MCP Host ABI 草案。 +12. `docs/Agent/TestingStrategy.md`:测试矩阵与回归守护。 + +## 1. 核心定义 + +Agent Mode 的核心定义是: + +> 一次生成不是 LLM 返回一段字符串,而是 Agent 对一个受策略约束的 Workspace 进行一组可审计、可回滚的编辑,最后由运行时把 Artifact 组装并提交为聊天消息。 + +因此 Agent 不是更复杂的 `Generate()`,也不是 SillyTavern 工具调用递归的后端移植版。Agent 是一条新的生成路径: + +```text +Legacy Generate + 保持 SillyTavern 1.16.0 的事件语义、扩展兼容和 one-shot 体验 + +Agent Generate + Rust-owned Workspace Runtime + Profile + Plan + Tools + Journal + Checkpoint + +MCP / Tool Direct Call + 非 Agent 模式下也可显式调用 MCP 或工具,但不拥有 Agent Run +``` + +三条路径可以共享 LLM gateway、tool registry、MCP client、chat repository、windowed payload 和 tokenization service,但不能互相污染。 + +## 2. Ground of Truth + +本架构以当前仓库代码和文档为准: + +- 后端已经采用 Clean Architecture,依赖方向是外层依赖内层、内层定义接口、外层实现接口。见 `docs/BackendStructure.md:7`、`docs/BackendStructure.md:40`。 +- 应用服务由 `AppState` 管理并在 `bootstrap::build_services()` 装配。见 `src-tauri/src/app.rs:36`、`src-tauri/src/app/bootstrap.rs:150`。 +- 当前 LLM 请求经过 `ChatCompletionService`,该服务负责 provider 解析、iOS policy、endpoint override policy、payload build、prompt caching 和取消注册。见 `src-tauri/src/application/services/chat_completion_service/mod.rs:32`、`src-tauri/src/application/services/chat_completion_service/mod.rs:302`、`src-tauri/src/application/services/chat_completion_service/mod.rs:358`。 +- 当前 LLM API 日志依赖 bootstrap 中装配的 `LoggingChatCompletionRepository` wrapper;Agent 不得直接调用 `HttpChatCompletionRepository` 绕过日志、proxy、secret 或 policy。见 `src-tauri/src/app/bootstrap.rs:372`。 +- 当前 chat payload 分片读写由 `ChatService` 和 `ChatRepository` 承担,windowed save/patch 是正式契约。见 `src-tauri/src/application/services/chat_service.rs:495`、`src-tauri/src/application/services/chat_service.rs:563`、`src-tauri/src/domain/repositories/chat_repository.rs:145`、`src-tauri/src/domain/repositories/chat_repository.rs:162`。 +- 前端 Public ABI 的统一入口是 `window.__TAURITAVERN__`,应保持小而稳定。见 `docs/FrontendHostContract.md:92`、`src/tauri/main/bootstrap.js:139`。 +- 当前 `Generate()` 支持 dryRun,并在 `GENERATE_AFTER_DATA` 提供生成请求数据。见 `src/script.js:4660`、`src/script.js:5743`。 +- 当前 SillyTavern 工具调用会递归回 `Generate()`,并把工具调用结果保存成 `is_system` chat message。这是 Agent 必须摆脱的旧结构。见 `src/script.js:5826`、`src/script.js:5847`、`src/script.js:5959`、`src/scripts/tool-calling.js:868`、`src/scripts/tool-calling.js:887`。 + +## 3. 设计目标 + +Agent 系统必须同时满足五个目标: + +1. 兼容:Agent Mode off 时,Legacy Generate 的行为、事件、扩展注入、世界书语义不变。 +2. 可维护:Agent runtime 位于 Rust application layer,流程可测试,基础设施通过 trait 注入。 +3. 可审计:所有 LLM call、tool call、file write、checkpoint、profile switch、commit 都进入 append-only journal。 +4. 可回滚:Agent 修改 workspace,不直接写 chat;commit 后仍可根据 artifact/checkpoint 回滚聊天消息。 +5. 创作者自由:Preset/角色卡/扩展/Skill 能控制可见内容、工具、预算、计划模式和输出结构,但不能突破宿主安全边界。 + +## 4. 非目标 + +第一阶段不追求这些事项: + +- 不重写完整 SillyTavern PromptManager。 +- 不把所有历史消息复制到 workspace。 +- 不把工具结果继续保存为 chat 楼层。 +- 不引入 shell 工具作为默认能力。 +- 不把 MCP 当作 Agent Runtime 本体。 +- 不执行世界书/角色卡/扩展提供的任意后端脚本。 +- 不把多个 Agent 过早实体化为互相争夺最终回复的独立运行时。 + +## 5. 总体数据流 + +短期过渡流: + +```text +用户发送消息 + ↓ +前端保持旧 Generate 前置语义 + ↓ +Generate(type, options, dryRun = true) + ↓ +PromptSnapshot / GenerationIntent + ↓ +解析 stableChatId + ↓ +window.__TAURITAVERN__.api.agent.startRun() + ↓ +Rust AgentRunService + ↓ +Workspace 初始化 + ↓ +ContextFrame 初步组装 + ↓ +Agent Run State Machine + ├─ model call + ├─ tool call + ├─ workspace patch + ├─ checkpoint + ├─ optional plan/profile switch/summary + └─ finish + ↓ +ArtifactAssembler + ↓ +Committer 通过 chat service/windowed payload 保存 + ↓ +聊天 UI 展示最终消息,timeline 展示 Agent 过程 +``` + +中长期目标流: + +```text +GenerationIntent + ↓ +Rust ContextAssemblyService + ├─ chat history windowed read/search + ├─ world info activation result + ├─ preset/character/user profile + ├─ workspace files + ├─ skill snippets + └─ tool definitions/results + ↓ +Provider-agnostic ModelRequest + ↓ +LLM Gateway / provider adapter +``` + +短期接受 `PromptSnapshot` 是兼容策略,不是最终架构。任何实现都必须让后续从 `PromptSnapshot` 迁移到结构化 `GenerationIntent + ContextFrame` 成为增量演进,而不是重写。 + +注意:`Generate(..., dryRun = true)` 不是纯函数。它会触发上游事件、执行 prompt assembly、触发部分 world info 扫描与工具注册判断;它只是跳过实际模型请求、聊天写入和工具执行等关键副作用。Agent 文档和代码中都不能把 dryRun 描述为“无副作用”。 + +### 5.1 Run 与 Workspace 身份 + +Agent 身份必须分层: + +```text +stableChatId 聊天长期身份,由 Host Chat API 解析 +workspaceId 对话级 workspace 身份,由 kind + stableChatId 派生 +runId 单次 Agent 执行身份,由 runtime 生成 UUID +chatRef 当前可定位引用,用于读取/commit guard +``` + +`workspaceId` 不能由完整 `chatRef` hash 决定。`chatRef` 中的文件名、角色名和当前打开状态可能变化;`stableChatId` 才是跨重命名、导入后修复和长期清理策略可依赖的身份。swipe/regenerate 应创建新的 `runId`,但仍落在同一个稳定聊天的 chat workspace 下。 + +## 6. 后端分层 + +建议模块边界: + +```text +src-tauri/src/ + domain/ + models/agent/ + run.rs + event.rs + workspace.rs + artifact.rs + checkpoint.rs + profile.rs + plan.rs + policy.rs + tool.rs + model.rs + repositories/ + agent_run_repository.rs + workspace_repository.rs + checkpoint_repository.rs + skill_repository.rs + mcp_repository.rs + + application/ + dto/ + agent_dto.rs + mcp_dto.rs + services/ + agent_runtime/ + mod.rs + run_state_machine.rs + context_assembly.rs + artifact_assembly.rs + commit.rs + profile_router.rs + plan.rs + workspace_service/ + tool_registry_service/ + tool_dispatch_service/ + llm_gateway_service/ + mcp_client_service/ + skill_service/ + + infrastructure/ + repositories/ + file_agent_run_repository/ + file_workspace_repository/ + file_checkpoint_repository/ + file_skill_repository/ + apis/ + mcp/ + diff/ + + presentation/ + commands/ + agent_commands.rs + mcp_commands.rs +``` + +关键规则: + +- `presentation` 只做 DTO 校验、权限/通道参数拆解、调用 application service、错误映射。 +- `application/services/agent_runtime` 是编排中心,但它不直接操作文件系统、不直接发 HTTP、不直接管理 MCP subprocess。 +- `domain` 定义纯模型与 repository/tool/gateway trait,不依赖 Tauri、tokio process、WebView、HTTP client。 +- `infrastructure` 实现文件存储、MCP client、diff、外部 API 适配。 +- 新服务必须在 `bootstrap::build_services()` 装配,并挂入 `AppState`,与现有服务生命周期一致。 + +## 7. 运行时核心组件 + +### 7.1 AgentRunService + +职责: + +- 创建 run。 +- 驱动状态机。 +- 追加 journal event。 +- 调用 workspace、context、tool、LLM、checkpoint、artifact、commit 服务。 +- 管理 cancel / await approval / resume。 + +`AgentRunService` 不应该知道具体文件路径如何落盘,也不应该直接拼 provider-specific payload。 + +### 7.2 WorkspaceService + +职责: + +- 创建对话级 workspace root 与 run workspace。 +- 管理 materialized file、virtual resource、generated artifact。 +- 应用 patch/write。 +- 做 path normalization 与 traversal 拒绝。 +- 生成 checkpoint snapshot/diff 所需文件视图。 + +详见 `docs/Agent/Workspace.md`。 + +### 7.3 ContextAssemblyService + +职责: + +- 把 chat history、world info、preset、character、user profile、workspace、skill、tool definitions、tool results、plan、diff summary 组织为 typed components。 +- 根据 profile/preset policy 决定可见性、顺序和 token budget。 +- 输出 provider-agnostic `ContextFrame` 或 `ModelRequestDraft`。 + +它是 SillyTavern prompt 灵活性的 Agent 版本,不能退化成硬编码 system/user 字符串。 + +### 7.4 LlmGatewayService + +职责: + +- 接受 provider-agnostic `ModelRequest`。 +- 复用现有 provider 能力、policy 检查、prompt caching、logging、proxy/client 配置、cancellation。 +- 输出 provider-agnostic `ModelResponse` / streaming delta / tool call。 + +第一期必须通过适配现有 `ChatCompletionService` 完成,而不是直接调用 `HttpChatCompletionRepository` 或新建 HTTP client。长期目标是在不破坏现有 chat completion API 的前提下抽出更明确的 model gateway。 + +当前 `ChatCompletionStreamEvent::Chunk` 只是 provider SSE `data` 字符串的桥接,不是 Agent timeline 语义事件。Agent 必须定义自己的 `AgentRunEvent`,不能把 provider stream chunk 当作 run event。 + +### 7.5 ToolRegistryService / ToolDispatchService + +职责: + +- 注册内置工具、MCP 工具、未来 extension bridge 工具。 +- 根据 profile/preset/plan/run policy 解析可见工具与审批要求。 +- 派发 tool call。 +- 把 tool result 写入 journal 与 context store,不写入 chat message。 + +详见 `docs/Agent/ToolSystem.md`。 + +### 7.6 ArtifactAssemblyService / Committer + +职责: + +- 根据 workspace manifest 读取一个或多个 artifact。 +- 组装 message body 与 message extra。 +- 通过现有 chat 保存路径提交。 +- commit 后写入 `agentRunId`、`agentCheckpointId`、`agentArtifacts` 等 metadata。 + +Commit 必须遵守 windowed payload 与保存串行化契约,不能直接写 JSONL 文件。 + +如果第一期由前端完成 commit,必须复用现有 `enqueueChatSave()` 串行化路径。如果后续改为后端直接 commit,后端必须提供等价的 per-chat 串行化、cursor/CAS/resync 语义,并仍通过 ChatService/GroupChatService 的正式保存边界。 + +## 8. 前端边界 + +Agent 前端能力必须挂在: + +```js +window.__TAURITAVERN__.api.agent +``` + +MCP 直连能力必须挂在: + +```js +window.__TAURITAVERN__.api.mcp +``` + +禁止新增散落的 `window.__TAURITAVERN_AGENT_*` 全局函数,除非是临时调试且不进入 Public Contract。 + +前端职责: + +- 提供 Agent Mode 开关与最小 timeline UI。 +- 在短期通过 `Generate(..., dryRun = true)` 生成 `PromptSnapshot`。 +- 调用 `api.agent.startRun()`、订阅 event、展示状态、发起 cancel/approve/rollback/commit。 +- Agent Mode off 时不改变 Legacy Generate。 + +前端不拥有 Agent 状态机,不递归调用 `Generate()` 实现工具循环,不把工具结果写进 chat 楼层。 + +Agent 自己的 run/timeline/tool events 必须通过 `api.agent.subscribe(...)` 或等价 Host ABI 通道暴露。除非实际执行 Legacy Generate 兼容路径,否则不得伪装成 `GENERATION_STARTED`、`TOOL_CALLS_PERFORMED` 等 SillyTavern 上游事件。 + +## 9. 状态机 + +最小状态: + +```text +Created + ↓ +InitializingWorkspace + ↓ +AssemblingContext + ↓ +Planning? + ↓ +Running + ├─ CallingModel + ├─ AwaitingToolApproval + ├─ DispatchingTool + ├─ ApplyingWorkspacePatch + ├─ CreatingCheckpoint + ├─ Summarizing? + └─ SwitchingProfile? + ↓ +AssemblingArtifacts + ↓ +Committing + ↓ +Completed +``` + +终止状态: + +```text +Completed +Cancelled +Failed +``` + +状态变化必须以 journal event 记录。UI 的实时事件可以从 journal 派生,但不能成为唯一真相源。 + +## 10. 兼容策略 + +### 10.1 Legacy Generate 不变 + +Agent Mode off 时: + +- `Generate()` 入口、事件顺序、dryRun 语义、工具调用旧行为、自动继续、swipe/regenerate/impersonate/quiet 等行为不应改变。 +- 第三方扩展依赖的事件仍按旧语义触发。 +- `window.__TAURITAVERN__.api.chat` 不因 Agent 引入而改变。 + +### 10.2 Agent Mode 使用干净旁路 + +Agent Mode on 时: + +- 可以复用 Legacy Generate 的前置 prompt assembly 作为过渡输入。 +- 不应复用 Legacy ToolManager 的“工具结果保存成系统楼层”语义。 +- 不应让 Agent tool loop 通过递归 `Generate()` 驱动。 + +### 10.3 上游语义保留 + +SillyTavern 上游的事件和 chat message 结构仍是兼容层的基础。Agent metadata 必须放在 `extra` 的 TauriTavern namespace 下,新增字段必须向后兼容,不能让旧 UI/扩展读取消息时崩溃。 + +## 11. 性能策略 + +- Chat history 默认是 virtual resource,通过 Rust chat repository/windowed payload 按需读取。 +- Workspace 不复制完整历史、完整世界书、完整记忆库。 +- Context assembly 必须有 token/resource budget。 +- Tool result 进入 journal 后,进入 prompt 前可以摘要、裁剪或按需读取。 +- 移动端默认更小预算、更短 checkpoint retention、更保守的并发。 +- Streaming event 应该可节流,journal 应该可 append-only 顺序写入。 + +## 12. 安全策略 + +- Workspace path 必须相对 root 规范化,拒绝 `..`、绝对路径、symlink escape。 +- MCP stdio server 只能来自用户显式配置或 allowlist,Agent/Preset/角色卡/世界书不能写 command。 +- 危险工具必须审批,审批请求必须记录 journal。 +- Hidden/private resource 不能被 context assembly 读入模型请求。 +- Provider endpoint override 必须继续遵守现有 iOS policy 与 settings policy。 +- 任何 policy violation 必须 fail-fast,不允许静默降级为“工具不可见但继续跑”之类的模糊行为。 + +## 13. 最小 MVP + +第一个可合并 Agent MVP 应只包含: + +1. `api.agent.startRun()`。 +2. run workspace。 +3. `events.jsonl`。 +4. `output/main.md`。 +5. checkpoint snapshot。 +6. 单次 LLM call。 +7. artifact commit 到 chat。 +8. 最小 timeline UI。 +9. Agent Mode off 行为完全不变。 + +这个 MVP 的价值不是炫技,而是验证 runtime boundary、journal、workspace、commit、rollback 这些后续能力赖以存在的骨架。 diff --git a/docs/AgentContract.md b/docs/AgentContract.md new file mode 100644 index 00000000..9376ffbd --- /dev/null +++ b/docs/AgentContract.md @@ -0,0 +1,437 @@ +# TauriTavern Agent Contract + +本文档是 Agent 系统的约束文档。后续实现如果和本文冲突,应先修改设计并同步文档,而不是在代码里绕过。 + +本文使用以下词语: + +- 必须:不可破坏的 contract。 +- 应该:默认路径;偏离时需要在 PR/文档中解释原因。 +- 可以:允许的扩展点。 +- 禁止:会破坏兼容性、安全性或可维护性的做法。 + +## 1. 不变量 + +### 1.1 Agent 必须写 Workspace,不直接写 Chat + +Agent 的中间产物必须写入 workspace: + +```text +output/main.md +output/status.md +output/theater.md +plan/plan.md +scratch/*.md +summaries/*.md +``` + +最终聊天消息只能由 ArtifactAssembly + Committer 统一提交。 + +禁止: + +- LLM response 直接变成 chat message。 +- Tool result 直接插入 chat 楼层。 +- workspace-mutating tool 绕过 WorkspaceService 写文件。 + +理由:只有 workspace 才能提供 checkpoint、diff、rollback、artifact assembly 与 timeline 审计。 + +### 1.2 Run Journal 必须是真相源 + +所有副作用都必须写入 append-only journal: + +- run started / status changed +- context assembled +- model request created / model completed / model error +- tool call requested / approved / completed / failed +- workspace patch applied +- checkpoint created +- profile switched +- summary created +- artifacts assembled +- commit completed / rollback completed +- run cancelled / failed + +禁止存在没有 journal 记录的 LLM call、tool call、MCP request、file write、commit。 + +UI 可以订阅实时 event,但实时 event 只是 journal 的投影。恢复、调试、timeline、回滚必须能以 journal 为核心重建。 + +### 1.3 Tool Call 是 Run Event,不是 Chat Message + +当前 SillyTavern 工具调用会把工具结果保存成 `is_system` chat message,并递归调用 `Generate()`。见 `src/scripts/tool-calling.js:868` 与 `src/script.js:5847`。 + +Agent 系统必须改为: + +```text +ToolCallRequested -> approval? -> ToolCallCompleted -> ContextFrame.ToolResults +``` + +工具结果需要进入后续 prompt 时,由 ContextAssemblyService 以 `ToolResults` component 纳入,而不是污染 chat history。 + +### 1.4 Context Flexibility 属于 ContextFrame,不属于 Provider Payload + +Preset 的自由度必须表达为 typed prompt components: + +```text +ChatHistory +WorldInfo +PresetInstructions +CharacterInstructions +UserProfile +WorkspaceTree +WorkspaceFile +Skill +ToolDefinitions +ToolResults +Plan +DiffSummary +``` + +Provider adapter 只能把已经编译好的 `ModelRequest` 转换为 OpenAI/Claude/Gemini/DeepSeek/Cohere 等 provider 格式。 + +禁止在 provider payload builder 中硬编码 TauriTavern Agent 的 prompt 结构。 + +### 1.5 Agent Profile 是运行策略,不只是模型选择 + +Agent Profile 必须至少能表达: + +- preset ref +- model ref +- prompt/context policy +- visible resource policy +- tool policy +- plan policy +- summary policy +- switch policy +- output/artifact policy +- budget policy + +Profile 切换必须记录 journal。Plan node 若锁定 profile,则 runtime 必须拒绝模型自行切换。 + +### 1.6 Plan 是 Workflow Contract + +Plan 不能只是 prompt 里的自由文本。Strict/Hybrid plan 必须转化为 runtime 可检查的节点: + +```text +id +locked +profileId +allowedTools +visibleFiles +maxRounds +contextBudget +expectedArtifacts +approvalRequired +``` + +Plan policy violation 必须 fail-fast。 + +### 1.7 Commit 必须走现有 Chat 保存契约 + +Agent commit 不能直接写 chat JSONL。 + +必须通过现有 ChatService/ChatRepository 或前端既有保存队列等价路径保存,并遵守 windowed payload 语义: + +- UI 只持有 tail window。 +- Prompt/history backfill 不扩大 UI chat。 +- windowed save/patch 保持 cursor CAS 保护。 +- 应用内并发保存必须串行化。 + +现有前端保存队列见 `src/script.js:536`。windowed payload 保存串行化要求见 `docs/CurrentState/WindowedPayload.md:151`。 + +### 1.8 Fail Fast,不静默降级 + +以下情况必须返回明确错误并写 journal: + +- workspace path traversal +- missing required artifact +- profile/preset schema invalid +- denied tool requested +- plan locked node 被修改 +- hidden resource 被请求进入 context +- cursor integrity conflict +- MCP stdio command 不在 allowlist +- provider/source 被平台 policy 禁用 +- provider native metadata、tool call id 或 reasoning signature 被丢弃 +- journal append 失败 + +禁止把这些错误静默转成普通文本、空结果或“继续用默认设置跑”。 + +### 1.9 LLM 调用必须复用现有服务边界 + +Agent 第一阶段的 LLM 调用必须经过 `ChatCompletionService` 或它的正式 gateway wrapper。 + +禁止: + +- 直接调用 `HttpChatCompletionRepository`。 +- 新建独立 HTTP client 绕过 `HttpClientPool`。 +- 绕过 `LoggingChatCompletionRepository` 导致 LLM API log 丢失。 +- 绕过 secret/settings/iOS policy/endpoint override/prompt cache。 + +长期可以抽象 `LlmGatewayService`,但 gateway 的实现必须保留这些现有能力,而不是重写一套较弱路径。 + +## 2. Clean Architecture Contract + +### 2.1 Domain + +Domain 可以定义: + +- `AgentRun` +- `AgentRunEvent` +- `AgentRunStatus` +- `WorkspaceRef` +- `WorkspacePath` +- `WorkspaceResource` +- `ArtifactSpec` +- `Checkpoint` +- `AgentProfile` +- `PlanPolicy` +- `ToolSpec` +- `ToolResult` +- repository trait +- gateway/dispatcher trait + +Domain 禁止依赖: + +- Tauri command/channel/window +- concrete filesystem API +- HTTP client implementation +- MCP subprocess implementation +- frontend event names + +### 2.2 Application + +Application service 可以编排: + +- run state machine +- context assembly +- LLM gateway call +- tool dispatch +- workspace patch/checkpoint +- artifact assembly +- commit/rollback + +Application service 必须依赖 domain trait 或现有 application service,不直接依赖 infrastructure concrete type。 + +### 2.3 Infrastructure + +Infrastructure 可以实现: + +- file workspace repository +- file event journal repository +- checkpoint snapshot/diff store +- skill repository +- MCP client manager +- provider adapter + +Infrastructure 必须把平台错误转换为 domain/application error,不允许向上泄露任意底层异常结构。 + +### 2.4 Presentation + +Tauri command 只能做: + +- DTO 反序列化与基础校验 +- 获取 `AppState` +- 调用 service +- Channel/event 转发 +- 错误映射 + +禁止把 Agent loop 写在 command 函数里。 + +## 3. Frontend Host ABI Contract + +Agent API 必须挂载到: + +```js +window.__TAURITAVERN__.api.agent +``` + +MCP API 必须挂载到: + +```js +window.__TAURITAVERN__.api.mcp +``` + +安装方式必须沿用现有 API 模式: + +- `installHostAbi()` 创建 `window.__TAURITAVERN__`。 +- 各 API installer 确保 `hostAbi.api` 存在。 +- 通过 `context.safeInvoke` 调用 Rust command。 +- `subscribe()` 返回幂等 unsubscribe。 + +禁止: + +- 新增公共 `window.__TAURITAVERN_AGENT_*` 散落全局。 +- 让第三方直接依赖 Rust command 名。 +- 把内部 service object 暴露给 `window.__TAURITAVERN__.api`。 + +新增类型必须同步 `src/types.d.ts`。 + +## 4. Legacy Generate Compatibility Contract + +Agent Mode off 时必须保持: + +- `Generate(type, options, dryRun)` 调用语义。 +- `GENERATION_STARTED`、`GENERATION_AFTER_COMMANDS`、`GENERATE_AFTER_DATA` 等事件的可观察顺序。 +- slash command、world info、extension prompt、prompt itemization、swipe、regenerate、continue、quiet、impersonate 行为。 +- 旧 ToolManager 行为,除非后续单独做兼容迁移并有测试保护。 + +Agent Mode on 的短期桥接可以使用 dryRun 获取 `PromptSnapshot`。但 Agent tool loop 禁止通过递归 `Generate()` 实现。 + +dryRun 不能被视为纯函数。它仍会触发上游事件、prompt 组合、World Info dry-run scan、OpenAI prompt ready 等兼容行为。Agent 只能把它当作短期 prompt snapshot 生产器,不能依赖它没有任何副作用。 + +Agent run/timeline/tool event 不得伪装成上游 `GENERATION_*` 或 `TOOL_CALLS_*` 事件。上游事件属于 Legacy Generate 兼容面。 + +## 5. Windowed Payload Contract + +Agent 读取历史必须优先使用后端 chat repository/windowed payload/search 能力。 + +禁止: + +- 把完整 chat history 注入前端常驻 `chat`。 +- 为了 Agent 生成而扩大 UI window。 +- 在 workspace 中复制完整 JSONL 作为默认行为。 +- 绕过 cursor integrity 直接覆盖聊天文件。 + +允许: + +- 将 chat history 暴露为 virtual resource。 +- 按 token budget 读取 tail/before/search。 +- 在 ContextFrame 中放入历史摘要或检索片段。 + +`force` 只能用于既有 integrity 覆盖语义,不能跳过 cursor 签名校验。Cursor mismatch 代表 CAS 保护正在阻止不安全写入,不能被静默忽略。 + +## 6. Workspace Contract + +### 6.1 Workspace Identity + +Workspace identity 必须基于稳定聊天身份,而不是当前可变引用。 + +```text +runId = run_ +stableChatId = api.chat.open(chatRef).stableId() +workspaceId = chat_ +``` + +要求: + +- `stableChatId` 必须在前端 Host ABI 层解析并传入 backend DTO。 +- backend 可以校验 `stableChatId` 非空并派生 `workspaceId`,但不应直接读取 SillyTavern metadata。 +- 每次 normal/regenerate/swipe/continue 都必须创建新的 `runId`。 +- 同一 `kind + stableChatId` 的 run 必须共享同一个 chat workspace。 +- `chatRef` 只表示当前可定位引用与 commit guard,不得作为 workspace 长期身份。 + +禁止: + +- 用角色显示名、chat file name、完整 `chatRef` hash 作为长期 workspace identity。 +- 让 swipe/regenerate 覆盖已有 run journal。 +- 因聊天重命名导致同一稳定聊天分裂到多个 chat workspace。 + +### 6.2 Workspace Path + +Workspace path 必须满足: + +- UTF-8 relative path。 +- 使用 `/` 作为逻辑分隔符。 +- 禁止空路径、绝对路径、Windows drive prefix、`..`、NUL。 +- 禁止 symlink escape。 +- workspace root 外文件不可见。 + +Workspace resource 分三类: + +```text +MaterializedFile +VirtualResource +GeneratedArtifact +``` + +“万物皆文件”是 Agent 视角的抽象,不代表每个资源都物理复制到 run 目录。 + +## 7. Tool Contract + +工具必须有 `ToolSpec`: + +- stable name +- title/display name +- description +- input schema +- optional output schema +- visibility +- permission +- budget +- source + +工具结果必须有 `ToolResult`: + +- call id +- content blocks +- structured value +- is_error +- resource refs +- usage/cost/duration + +工具错误必须能被模型看到,也必须能被用户 timeline 看到。系统错误与模型可恢复错误应区分。 + +## 8. MCP Contract + +MCP 是独立模块,不依附 Agent Mode。 + +Agent 可以消费 MCP: + +```text +MCP Tools -> ToolRegistry +MCP Resources -> WorkspaceResource / ContextFrame +MCP Prompts -> PromptComponent +``` + +禁止: + +- Agent/Preset/角色卡/世界书直接创建或修改 MCP stdio command。 +- 从远端配置自动创建 stdio server。 +- 初期启用 MCP Sampling 的自动模型调用。 +- 未经审批调用危险 MCP tool。 + +## 9. Error Contract + +错误返回必须足够结构化,至少包含: + +```text +code +message +runId? +eventSeq? +retryable? +details? +``` + +错误消息可以适合用户阅读,但 code 必须适合测试与扩展判断。 + +run 失败时必须: + +1. journal 追加 `run_failed`。 +2. 状态进入 `Failed`。 +3. 保留 workspace 与最后 checkpoint。 +4. 前端 timeline 可读到失败原因。 + +cancel 不是 failure,必须进入 `Cancelled`。 + +## 10. Metadata Contract + +Agent commit 到 chat message 时,metadata 必须放入 `extra` 下的 TauriTavern namespace,建议: + +```json +{ + "extra": { + "tauritavern": { + "agent": { + "runId": "...", + "stableChatId": "...", + "checkpointId": "...", + "profileId": "...", + "artifactSetId": "...", + "artifacts": [] + } + } + } +} +``` + +不要污染上游常用字段名。新增字段必须向后兼容,旧 SillyTavern 或旧扩展忽略后不应崩溃。 diff --git a/docs/AgentImplementPlan.md b/docs/AgentImplementPlan.md new file mode 100644 index 00000000..7ce413b3 --- /dev/null +++ b/docs/AgentImplementPlan.md @@ -0,0 +1,433 @@ +# TauriTavern Agent Implementation Plan + +本文档把 Agent 架构拆成可合并、可验证、低技术债的阶段。阶段顺序按重要性和依赖关系排列。 + +原则: + +- 先边界,后能力。 +- 先 journal/workspace/commit,再 tool loop。 +- 先内置高 ROI 工具,再 MCP/extension tool。 +- 先兼容旧 prompt,再逐步后移 Context Assembly。 +- 每个阶段都必须有可运行验收,不接受“只搭了一半抽象”的合并。 + +## Phase 0:文档、契约、测试守护 + +目标:先把实现者不能越过的边界写清楚。 + +产物: + +- `docs/AgentArchitecture.md` +- `docs/AgentContract.md` +- `docs/AgentImplementPlan.md` +- `docs/Agent/README.md` +- `docs/Agent/Workspace.md` +- `docs/Agent/RunEventJournal.md` +- `docs/Agent/ProfilesAndPreset.md` +- `docs/Agent/ToolSystem.md` +- `docs/Agent/LlmGateway.md` +- `docs/Agent/McpSkill.md` +- `docs/API/Agent.md` +- `docs/API/MCP.md` +- `docs/Agent/TestingStrategy.md` +- `docs/FrontendHostContract.md` 增补 `api.agent` / `api.mcp` +- `docs/API/README.md` 增补 Agent/MCP API 索引 +- `docs/CurrentState/AgentFramework.md` 增补实时开发进度跟踪入口 + +验收: + +- 文档之间边界清晰,不重复定义同一概念的不同版本。 +- 所有重要约束都能追溯到现有 Ground of Truth 或 Agent 设计目标。 +- 明确 Agent Mode off 的回归守护项。 + +ROI:极高。 +风险:低。 + +## Phase 1:Workspace + Journal + One-Step Agent + +目标:建立 Agent Runtime 骨架,但不急着做多轮工具循环。 + +范围: + +```text +startRun +resolve stableChatId +create run workspace +materialize minimal input +append events.jsonl +single model call +write output/main.md +create checkpoint +assemble artifact +commit chat message +read run events +cancel before/while model call +minimal timeline UI +``` + +后端建议: + +- 新增 domain models: + - `AgentRun` + - `AgentRunStatus` + - `AgentRunEvent` + - `WorkspacePath` + - `WorkspaceManifest` + - `ArtifactSpec` + - `Checkpoint` +- 新增 repository traits: + - `AgentRunRepository` + - `WorkspaceRepository` + - `CheckpointRepository` +- 新增 application services: + - `AgentRuntimeService` + - `WorkspaceService` + - `ArtifactAssemblyService` + - `AgentCommitService` +- 新增 infrastructure: + - file-backed run repository + - file-backed workspace repository + - snapshot checkpoint repository +- 新增 presentation: + - `agent_commands.rs` + - `start_agent_run` + - `cancel_agent_run` + - `read_agent_run_events` + - `read_agent_workspace_file` + +前端建议: + +- 新增 `src/tauri/main/api/agent.js`。 +- 在 `bootstrapTauriMain()` 安装 `installAgentApi(context)`。 +- `window.__TAURITAVERN__.api.agent.startRun()` 先通过 `api.chat.open(chatRef).stableId()` 解析 `stableChatId`,再调用 Tauri command。 +- 最小 timeline UI 可先只显示状态、model delta、checkpoint、commit。 + +LLM 调用策略: + +- 第一阶段优先复用现有 `ChatCompletionService`。 +- 输入可以来自 `PromptSnapshot`,由前端 dryRun 生成;但 dryRun 不是纯函数,只能作为兼容桥。 +- 该阶段不重写 provider adapter。 +- 禁止直接调用 `HttpChatCompletionRepository` 或绕过 LLM API log/proxy/policy。 + +验收: + +- Agent Mode off,现有生成完全不变。 +- Agent Mode on,能完成一次 one-step generation 并提交为聊天消息。 +- 同一稳定聊天的 regenerate/swipe 产生独立 `runId`,但共享由 `stableChatId` 派生的 `workspaceId`。 +- `events.jsonl` 包含 run started、workspace initialized、context assembled、model completed、workspace write、checkpoint created、artifact assembled、run committed。 +- `output/main.md` 可在 workspace 中读取。 +- commit 后 message extra 包含 agent metadata。 +- cancel 能让 run 进入 `Cancelled`,不提交半成品。 +- 必需 artifact 缺失时 run fail-fast。 +- Agent stream/timeline event 使用 `AgentRunEvent`,不复用 `ChatCompletionStreamEvent::Chunk` 作为语义事件。 + +风险: + +- Commit 与 windowed payload 保存路径容易冲突。 +- `PromptSnapshot` 结构过于 provider-specific。 + +缓解: + +- Commit 必须通过既有 ChatService/保存队列契约。 +- 文档和 DTO 明确 `PromptSnapshot` 是过渡输入,不是长期模型。 + +ROI:极高。 +风险:中低。 + +## Phase 2:最小工具循环 + +目标:从 one-step 进入真正 Agent loop,但工具范围保持克制。 + +内置工具: + +```text +workspace.list_files +workspace.read_file +workspace.write_file +workspace.apply_patch +workspace.create_checkpoint +workspace.finish +chat.search +chat.read_history_tail +chat.read_history_before +skill.list +skill.read +worldinfo.read_activated +``` + +后端新增: + +- `ToolSpec` +- `ToolCall` +- `ToolResult` +- `ToolRegistryService` +- `ToolDispatchService` +- `ToolPolicyResolver` +- tool event journal +- tool approval state + +模型输出解释: + +- 第一阶段可以支持 provider-native tool call。 +- 对不支持 tool call 的 provider,不应静默模拟;应根据 profile 明确拒绝或使用受控文本协议,并在 profile 中声明。 + +验收: + +- 模型请求工具时,run 进入 tool dispatch 流程。 +- 工具参数、结果、错误、耗时写 journal。 +- 工具结果不写入 chat message。 +- 工具结果可作为 `ToolResults` component 进入下一轮模型请求。 +- denied tool fail-fast,并记录 policy violation。 +- workspace-mutating tool 后创建 checkpoint。 +- `workspace.finish` 结束 loop 并进入 artifact assembly。 + +风险: + +- provider tool schema 差异。 +- 工具错误被吞掉导致模型无法自我修复。 + +缓解: + +- 引入 provider-agnostic `ToolCall` / `ToolResult`。 +- 错误分为 recoverable tool error 与 system failure。 + +ROI:高。 +风险:中。 + +## Phase 3:Agent Profile + Preset Agent Schema + Plan Policy + +目标:把创作者自由度接入 runtime。 + +新增能力: + +```text +AgentProfile +Preset agent schema +Prompt/Context policy +Tool policy +Budget policy +PlanPolicy +ProfileRouter +Strict / Free / Hybrid plan +``` + +Profile 最小字段: + +```text +id +displayName +presetRef +modelRef +contextPolicy +visibleResourcePolicy +toolPolicy +planPolicy +summaryPolicy +switchPolicy +outputPolicy +budgetPolicy +``` + +Plan node 最小字段: + +```text +id +title +locked +profileId +allowedTools +visibleFiles +maxRounds +contextBudget +expectedArtifacts +approvalRequired +``` + +验收: + +- Preset 可以声明 Agent Profile。 +- Strict plan 节点顺序和工具限制被 runtime 强制执行。 +- Free plan 可以让模型创建/修改计划,但仍受全局 policy 限制。 +- Hybrid plan 支持 locked 与 free 节点混合。 +- profile switch 写 journal。 +- plan policy violation fail-fast。 + +风险: + +- 过早把 schema 做得过大。 +- Preset 作者体验被复杂字段压垮。 + +缓解: + +- 第一版 schema 保持最小稳定核心。 +- UI 可以提供简单模式,高级字段保留给作者。 + +ROI:高。 +风险:中高。 + +## Phase 4:Backend Context Assembly 后移 + +目标:逐步减少对前端 dryRun PromptSnapshot 的依赖。 + +新增能力: + +- Rust 侧 `GenerationIntent`。 +- Rust 侧读取 chat history/windowed payload。 +- Rust 侧读取 preset/character/world info/user profile。 +- Typed `ContextFrame`。 +- Prompt components 与宏。 +- Context budget resolver。 + +过渡策略: + +```text +Phase 1-3: + PromptSnapshot + minimal ContextFrame + +Phase 4: + GenerationIntent + structured ContextFrame + legacy PromptSnapshot only as compatibility fallback +``` + +验收: + +- Agent 可在不复制完整 chat 的前提下读取历史。 +- `ChatHistory`、`WorldInfo`、`WorkspaceFile`、`ToolResults` 等 component 可独立预算。 +- Provider adapter 不知道 TauriTavern prompt component 的业务来源。 +- Legacy Generate 仍不变。 + +风险: + +- 世界书、扩展 prompt、PromptManager 语义复杂,容易破坏上游兼容。 + +缓解: + +- 先迁移可独立验证的 component。 +- 对世界书/扩展 prompt 保留 PromptSnapshot 兼容桥,直到测试足够。 + +ROI:中高。 +风险:高。 + +## Phase 5:MCP 独立集成 + +目标:实现 MCP 作为独立平台能力,再让 Agent 消费它。 + +新增 API: + +```text +api.mcp.listServers +api.mcp.connectServer +api.mcp.listTools +api.mcp.callTool +api.mcp.listResources +api.mcp.readResource +api.mcp.listPrompts +api.mcp.getPrompt +``` + +后端新增: + +- `McpClientService` +- `McpRepository` +- server config store +- per-server capability allowlist +- approval policy + +安全底线: + +- stdio command 必须来自用户设置或 allowlist。 +- Agent/Preset/角色卡/世界书不能写 MCP command。 +- 初期禁用或强审批 MCP Sampling。 +- 危险 tool call 必须用户审批。 + +验收: + +- 非 Agent 模式下可以列出和手动调用 MCP tool/resource/prompt。 +- Agent 可以把 MCP tool 纳入 ToolRegistry。 +- MCP resource 可作为 WorkspaceResource 或 ContextFrame component。 +- denied MCP tool fail-fast。 +- arbitrary stdio command 被拒绝。 + +ROI:中高。 +风险:高。 + +## Phase 6:Skill 系统与创作者资源包 + +目标:用渐进披露的纯文本 Skill 扩展 Agent 写作能力。 + +Skill 结构: + +```text +skills/ + skill-name/ + SKILL.md + examples/ + assets/ +``` + +能力: + +- `skill.list` +- `skill.read(name, section?)` +- Preset/角色卡/扩展声明可见 skill。 +- Skill 可作为 virtual/materialized workspace resource。 + +验收: + +- Agent 默认只看到 skill 摘要,不自动吞全文。 +- profile/preset 可以控制 skill 可见性和预算。 +- skill.read 结果写 journal。 +- 缺失 skill fail-fast 或返回 recoverable tool error,取决于 tool call 语义。 + +ROI:中。 +风险:中。 + +## Phase 7:Extension Tool Provider / 多模态 / 图记忆 + +目标:在核心 runtime 稳定后开放生态扩展。 + +候选能力: + +- extension 注册工具。 +- frontend tool bridge。 +- WASM/QuickJS sandbox 工具。 +- 多模态 artifact。 +- 图片/图记忆。 +- graph memory search。 + +准入条件: + +- Tool Registry/Policy/Journaling 已稳定。 +- Approval UI 已稳定。 +- Security tests 已覆盖 path、policy、MCP、extension boundary。 + +不建议在 Phase 1-4 提前做这些能力。 + +ROI:取决于生态。 +风险:中高到高。 + +## 持续验收清单 + +每个阶段合并前都必须回答: + +- Agent Mode off 是否完全不影响 Legacy Generate? +- 新增 API 是否挂在 `window.__TAURITAVERN__.api.*`? +- 新增后端流程是否符合 Clean Architecture? +- 所有副作用是否都有 journal? +- workspace path 是否被统一校验? +- 工具结果是否没有进入 chat 楼层? +- commit 是否遵守 windowed payload 保存契约? +- policy violation 是否 fail-fast? +- 移动端内存/历史读取是否没有倒退? +- 测试是否覆盖本阶段新增 contract? + +## 建议优先级 + +最推荐的近期顺序: + +1. 完成 Phase 0。 +2. 做 Phase 1,但 UI 只做最小 timeline。 +3. 做 Phase 2 内置 workspace/chat/skill 工具。 +4. 做 Phase 3 Profile/Plan。 +5. 再决定 Phase 4 与 Phase 5 的先后。 + +如果团队资源有限,不要先做 MCP 或扩展工具。没有 Workspace + Journal + Commit 这三个地基,任何工具生态都会快速堆成难维护的临时补丁。 diff --git a/docs/CurrentState/AgentFramework.md b/docs/CurrentState/AgentFramework.md new file mode 100644 index 00000000..588a8fee --- /dev/null +++ b/docs/CurrentState/AgentFramework.md @@ -0,0 +1,65 @@ +# Agent Framework 当前进度 + +本文档用于后续记录 Agent 框架的实时开发进度。它不是架构设计源文档,也不替代 `docs/AgentArchitecture.md`、`docs/AgentContract.md`、`docs/AgentImplementPlan.md` 与 `docs/Agent/` 下的细节设计。 + +## 当前状态 + +截至 2026-04-26: + +- Agent Runtime 尚未实现。 +- `window.__TAURITAVERN__.api.agent` 尚未实现。 +- `window.__TAURITAVERN__.api.mcp` 尚未实现。 +- 已完成第一轮架构/契约/实施计划/API 草案文档整理。 +- Agent 细节文档已收拢到 `docs/Agent/`。 + +## 文档入口 + +- 高层架构:`docs/AgentArchitecture.md` +- 硬契约:`docs/AgentContract.md` +- 实施计划:`docs/AgentImplementPlan.md` +- 细节文档目录:`docs/Agent/README.md` +- Agent API 草案:`docs/API/Agent.md` +- MCP API 草案:`docs/API/MCP.md` + +## 进度台账 + +| 日期 | Phase | 状态 | 变更/PR | 备注 | +| --- | --- | --- | --- | --- | +| 2026-04-26 | Phase 0 | 规划完成 | 文档整理 | 建立 Agent 架构、契约、实施计划、API 草案与细节文档目录 | + +## 实施检查表 + +| 项目 | 状态 | 代码入口 | 测试/验证 | 备注 | +| --- | --- | --- | --- | --- | +| Agent domain models | 未开始 | - | - | `AgentRun` / `AgentRunEvent` / `WorkspacePath` 等 | +| AgentRuntimeService | 未开始 | - | - | 应位于 `src-tauri/src/application/services/agent_runtime/` | +| Workspace repository | 未开始 | - | - | 需先确定物理根目录与同步语义 | +| Journal repository | 未开始 | - | - | append-only JSONL,支持分页 | +| LLM gateway wrapper | 未开始 | - | - | 必须复用 `ChatCompletionService` | +| `api.agent` Host ABI | 未开始 | - | - | 需更新 `src/types.d.ts` | +| 最小 timeline UI | 未开始 | - | - | 不伪装成 SillyTavern `GENERATION_*` 事件 | +| ToolRegistry/ToolDispatch | 未开始 | - | - | Phase 2 | +| `api.mcp` Host ABI | 未开始 | - | - | Phase 5,MCP 独立于 Agent Mode | + +## 每次 Agent 相关变更必须更新 + +新增或修改 Agent 相关实现时,请在本文件补充: + +- 当前 phase 与状态变化。 +- 涉及的 Rust/前端文件路径。 +- 新增或变更的 Host ABI。 +- 是否影响 Legacy Generate。 +- 是否影响 windowed payload 保存契约。 +- 新增测试与验证命令。 +- 已知风险和后续待办。 + +## 守护契约 + +后续进度记录必须显式关注: + +- Agent Mode off 时 Legacy `Generate()` 行为不变。 +- LLM 调用不绕过 `ChatCompletionService`、LLM API log、proxy、secret、iOS policy。 +- Agent 工具结果不写入 chat 楼层。 +- Agent run/timeline event 不伪装成 SillyTavern `GENERATION_*` / `TOOL_CALLS_*` 事件。 +- Commit/rollback 遵守 windowed payload 与保存串行化契约。 +- MCP stdio command 不由 Agent/Preset/角色卡/世界书直接写入。 diff --git a/docs/CurrentState/README.md b/docs/CurrentState/README.md index dcc34c59..9b2b6737 100644 --- a/docs/CurrentState/README.md +++ b/docs/CurrentState/README.md @@ -63,3 +63,7 @@ 12. `docs/CurrentState/iOSPolicy.md` - iOS/iPadOS-only 分发 Policy(profile + capabilities snapshot)当前实现快照 - 包含 settings 契约、baseline 矩阵、后端裁决点与前端 UI 投影的维护约束 + +13. `docs/CurrentState/AgentFramework.md` + - Agent 框架实时开发进度跟踪 + - 当前仅记录规划/实施准备状态;具体架构与细节设计见 `docs/AgentArchitecture.md`、`docs/AgentContract.md`、`docs/AgentImplementPlan.md` 与 `docs/Agent/` diff --git a/docs/FrontendHostContract.md b/docs/FrontendHostContract.md index 9285ec84..8a63e264 100644 --- a/docs/FrontendHostContract.md +++ b/docs/FrontendHostContract.md @@ -150,6 +150,17 @@ - 不把扫描循环控制、预算内部状态、可变中间态对象直接升格为 Public Contract。 - `openEntry()` 必须复用上游 World Info 模块自身的导航能力;宿主 ABI 层不得直接依赖 `#WorldInfo`、`#world_editor_select`、`[uid=\"...\"]` 等 DOM 细节。 +- `api.agent`:TauriTavern Agent Run API。用于启动 Agent Run、订阅 run event、取消、审批工具、读取 workspace 文件/diff、rollback/commit。 + - 详细草案见:`docs/API/Agent.md`。 + - `startRun()` 必须在调用 backend 前解析 `stableChatId`;`workspaceId` 由 `kind + stableChatId` 派生,`runId` 仍表示单次执行。 + - Agent event 属于 Agent Run journal/timeline 投影,不得伪装成上游 SillyTavern `GENERATION_*` / `TOOL_CALLS_*` 事件。 + - `subscribe()` 必须返回幂等 `unsubscribe`;底层 Tauri 事件名与 Rust command 名属于 Internal,不是第三方 Public Contract。 + - Agent Mode off 时,Legacy `Generate()`、`ToolManager`、`api.chat` 行为必须不变。 + +- `api.mcp`(规划中):MCP Server/Tool/Resource/Prompt 的独立平台 API。Agent Mode 可以消费 MCP,但 MCP 不依附 Agent Mode。 + - 详细草案见:`docs/API/MCP.md`。 + - MCP stdio command/config 不得由 Agent/Preset/角色卡/世界书直接写入;危险工具调用必须经过 capability policy 与审批。 + > 注意:`window.__TAURITAVERN__` 是“平台 ABI”,应保持**小而稳定**;不要把内部实现对象整个暴露出去。 --- diff --git a/docs/README.md b/docs/README.md index c6182597..6ece7c48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ 8. [iOS 端开发说明](./iOSDevelopment.md) - WKWebView 行为差异、safe-area/viewport-fit 与底部死区修复 9. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束 10. [扩展 API 文档](./API/README.md) - `window.__TAURITAVERN__.api.*` 的参考与适配指南(面向扩展作者) +11. [Agent 架构文档](./AgentArchitecture.md) - Agent Runtime 的高层架构入口 +12. [Agent 细节文档](./Agent/README.md) - Workspace、Journal、Tool、LLM Gateway、MCP/SKILL 与测试策略 ## 项目概述 @@ -27,3 +29,5 @@ TauriTavern是SillyTavern的Tauri重构版本,旨在通过Tauri和Rust重写 其中: - `docs/CurrentState/` 记录“当前已经落地的实现状态”和后续维护约束 +- Agent 系统仍处于规划/实施准备阶段,高层文档放在 `docs/AgentArchitecture.md` / `docs/AgentContract.md` / `docs/AgentImplementPlan.md`,细节文档放在 `docs/Agent/` +- Agent 的实时开发进度跟踪见 `docs/CurrentState/AgentFramework.md` diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 931404dd..8fe5b561 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; +use crate::application::services::agent_runtime_service::AgentRuntimeService; use crate::application::services::avatar_service::AvatarService; use crate::application::services::background_service::BackgroundService; use crate::application::services::character_service::CharacterService; @@ -50,6 +51,7 @@ pub struct AppState { pub theme_service: Arc, pub preset_service: Arc, pub quick_reply_service: Arc, + pub agent_runtime_service: Arc, pub chat_completion_service: Arc, pub tokenization_service: Arc, pub stable_diffusion_service: Arc, @@ -96,6 +98,7 @@ impl AppState { theme_service: services.theme_service, preset_service: services.preset_service, quick_reply_service: services.quick_reply_service, + agent_runtime_service: services.agent_runtime_service, chat_completion_service: services.chat_completion_service, tokenization_service: services.tokenization_service, stable_diffusion_service: services.stable_diffusion_service, diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index f21ed4e3..812568f7 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Semaphore; +use crate::application::services::agent_runtime_service::AgentRuntimeService; use crate::application::services::avatar_service::AvatarService; use crate::application::services::background_service::BackgroundService; use crate::application::services::character_service::CharacterService; @@ -30,11 +31,13 @@ use crate::application::services::user_directory_service::UserDirectoryService; use crate::application::services::user_service::UserService; use crate::application::services::world_info_service::WorldInfoService; use crate::domain::errors::DomainError; +use crate::domain::repositories::agent_run_repository::AgentRunRepository; use crate::domain::repositories::avatar_repository::AvatarRepository; use crate::domain::repositories::background_repository::BackgroundRepository; use crate::domain::repositories::character_repository::CharacterRepository; use crate::domain::repositories::chat_completion_repository::ChatCompletionRepository; use crate::domain::repositories::chat_repository::ChatRepository; +use crate::domain::repositories::checkpoint_repository::CheckpointRepository; use crate::domain::repositories::content_repository::ContentRepository; use crate::domain::repositories::extension_repository::ExtensionRepository; use crate::domain::repositories::extension_store_repository::ExtensionStoreRepository; @@ -53,6 +56,7 @@ use crate::domain::repositories::tts_repository::TtsRepository; use crate::domain::repositories::update_repository::UpdateRepository; use crate::domain::repositories::user_directory_repository::UserDirectoryRepository; use crate::domain::repositories::user_repository::UserRepository; +use crate::domain::repositories::workspace_repository::WorkspaceRepository; use crate::domain::repositories::world_info_repository::WorldInfoRepository; use crate::infrastructure::apis::github_update_repository::GitHubUpdateRepository; use crate::infrastructure::apis::http_chat_completion_repository::HttpChatCompletionRepository; @@ -65,6 +69,7 @@ use crate::infrastructure::logging::llm_api_logs::{ LlmApiLogStore, LoggingChatCompletionRepository, }; use crate::infrastructure::persistence::file_system::DataDirectory; +use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository; use crate::infrastructure::repositories::file_avatar_repository::FileAvatarRepository; use crate::infrastructure::repositories::file_background_repository::FileBackgroundRepository; use crate::infrastructure::repositories::file_character_repository::FileCharacterRepository; @@ -100,6 +105,7 @@ pub(super) struct AppServices { pub theme_service: Arc, pub preset_service: Arc, pub quick_reply_service: Arc, + pub agent_runtime_service: Arc, pub chat_completion_service: Arc, pub tokenization_service: Arc, pub stable_diffusion_service: Arc, @@ -130,6 +136,9 @@ struct AppRepositories { theme_repository: Arc, preset_repository: Arc, quick_reply_repository: Arc, + agent_run_repository: Arc, + workspace_repository: Arc, + checkpoint_repository: Arc, chat_completion_repository: Arc, tokenizer_repository: Arc, stable_diffusion_repository: Arc, @@ -198,6 +207,12 @@ pub(super) async fn build_services( repositories.prompt_cache_repository.clone(), ios_policy.clone(), )); + let agent_runtime_service = Arc::new(AgentRuntimeService::new( + repositories.agent_run_repository.clone(), + repositories.workspace_repository.clone(), + repositories.checkpoint_repository.clone(), + chat_completion_service.clone(), + )); let tokenization_service = Arc::new(TokenizationService::new(repositories.tokenizer_repository)); let stable_diffusion_service = Arc::new(StableDiffusionService::new( @@ -269,6 +284,7 @@ pub(super) async fn build_services( theme_service, preset_service, quick_reply_service, + agent_runtime_service, chat_completion_service, tokenization_service, stable_diffusion_service, @@ -369,6 +385,13 @@ fn build_repositories( FileQuickReplyRepository::new(data_directory.default_user().join("QuickReplies")), ); + let file_agent_repository = Arc::new(FileAgentRepository::new( + data_root.join("_tauritavern").join("agent-workspaces"), + )); + let agent_run_repository: Arc = file_agent_repository.clone(); + let workspace_repository: Arc = file_agent_repository.clone(); + let checkpoint_repository: Arc = file_agent_repository; + let llm_api_log_store = app_handle.state::>().inner().clone(); let chat_completion_repository: Arc = Arc::new(LoggingChatCompletionRepository::new( @@ -416,6 +439,9 @@ fn build_repositories( theme_repository, preset_repository, quick_reply_repository, + agent_run_repository, + workspace_repository, + checkpoint_repository, chat_completion_repository, tokenizer_repository, stable_diffusion_repository, diff --git a/src-tauri/src/application/dto/agent_dto.rs b/src-tauri/src/application/dto/agent_dto.rs new file mode 100644 index 00000000..6f541971 --- /dev/null +++ b/src-tauri/src/application/dto/agent_dto.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::domain::models::agent::{AgentChatRef, AgentRunEvent, AgentRunStatus, Checkpoint}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStartRunDto { + pub chat_ref: AgentChatRef, + #[serde(default, alias = "stableId")] + pub stable_chat_id: String, + #[serde(default = "default_generation_type")] + pub generation_type: String, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub prompt_snapshot: Option, + #[serde(default)] + pub generation_intent: Option, + #[serde(default)] + pub options: AgentStartRunOptionsDto, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AgentStartRunOptionsDto { + #[serde(default)] + pub auto_commit: bool, + #[serde(default)] + pub stream: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunHandleDto { + pub run_id: String, + pub workspace_id: String, + pub stable_chat_id: String, + pub status: AgentRunStatus, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCancelRunDto { + pub run_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadEventsDto { + pub run_id: String, + #[serde(default)] + pub after_seq: Option, + #[serde(default)] + pub before_seq: Option, + #[serde(default = "default_event_limit")] + pub limit: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadEventsResultDto { + pub events: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadWorkspaceFileDto { + pub run_id: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentWorkspaceFileDto { + pub path: String, + pub text: String, + pub bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPrepareCommitDto { + pub run_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommitDraftDto { + pub run_id: String, + pub stable_chat_id: String, + pub chat_ref: AgentChatRef, + pub generation_type: String, + pub checkpoint: Checkpoint, + pub message: AgentCommitMessageDto, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommitMessageDto { + pub mes: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentFinalizeCommitDto { + pub run_id: String, + #[serde(default)] + pub message_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommitResultDto { + pub run_id: String, + pub status: AgentRunStatus, +} + +fn default_generation_type() -> String { + "normal".to_string() +} + +fn default_event_limit() -> usize { + 100 +} diff --git a/src-tauri/src/application/dto/mod.rs b/src-tauri/src/application/dto/mod.rs index a957d730..9d2a3baa 100644 --- a/src-tauri/src/application/dto/mod.rs +++ b/src-tauri/src/application/dto/mod.rs @@ -1,4 +1,5 @@ // Data Transfer Objects +pub mod agent_dto; pub mod background_dto; pub mod bootstrap_dto; pub mod character_dto; diff --git a/src-tauri/src/application/services/agent_runtime_service.rs b/src-tauri/src/application/services/agent_runtime_service.rs new file mode 100644 index 00000000..95c6cf73 --- /dev/null +++ b/src-tauri/src/application/services/agent_runtime_service.rs @@ -0,0 +1,876 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::{RwLock, watch}; +use uuid::Uuid; + +use crate::application::dto::agent_dto::{ + AgentCancelRunDto, AgentCommitDraftDto, AgentCommitMessageDto, AgentCommitResultDto, + AgentFinalizeCommitDto, AgentPrepareCommitDto, AgentReadEventsDto, AgentReadEventsResultDto, + AgentReadWorkspaceFileDto, AgentRunHandleDto, AgentStartRunDto, AgentWorkspaceFileDto, +}; +use crate::application::dto::chat_completion_dto::ChatCompletionGenerateRequestDto; +use crate::application::errors::ApplicationError; +use crate::application::services::chat_completion_service::ChatCompletionService; +use crate::domain::errors::DomainError; +use crate::domain::models::agent::{ + AgentRun, AgentRunEventLevel, AgentRunStatus, ArtifactSpec, ArtifactTarget, CommitPolicy, + WorkspaceInputManifest, WorkspaceManifest, WorkspacePath, +}; +use crate::domain::repositories::agent_run_repository::{ + AgentRunEventReadQuery, AgentRunRepository, +}; +use crate::domain::repositories::checkpoint_repository::CheckpointRepository; +use crate::domain::repositories::workspace_repository::WorkspaceRepository; + +type AgentCancelReceiver = watch::Receiver; + +pub struct AgentRuntimeService { + run_repository: Arc, + workspace_repository: Arc, + checkpoint_repository: Arc, + chat_completion_service: Arc, + active_runs: RwLock>>, +} + +impl AgentRuntimeService { + pub fn new( + run_repository: Arc, + workspace_repository: Arc, + checkpoint_repository: Arc, + chat_completion_service: Arc, + ) -> Self { + Self { + run_repository, + workspace_repository, + checkpoint_repository, + chat_completion_service, + active_runs: RwLock::new(HashMap::new()), + } + } + + pub async fn start_run( + self: &Arc, + dto: AgentStartRunDto, + ) -> Result { + if dto.options.stream { + return Err(ApplicationError::ValidationError( + "agent.phase1_stream_unsupported: Agent Phase 1 only supports non-streaming model calls" + .to_string(), + )); + } + if dto.options.auto_commit { + return Err(ApplicationError::ValidationError( + "agent.phase1_auto_commit_unsupported: commit is owned by the frontend adapter in Agent Phase 1" + .to_string(), + )); + } + + let Some(prompt_snapshot) = dto.prompt_snapshot else { + return Err(ApplicationError::ValidationError( + "agent.prompt_snapshot_required: Phase 1 requires a concrete prompt snapshot" + .to_string(), + )); + }; + let request = request_from_prompt_snapshot(&prompt_snapshot)?; + reject_phase1_tool_request(&request.payload)?; + + if dto.generation_type.trim().is_empty() { + return Err(ApplicationError::ValidationError( + "agent.invalid_generation_type: generationType cannot be empty".to_string(), + )); + } + + let stable_chat_id = validate_stable_chat_id(&dto.stable_chat_id)?; + let run_id = format!("run_{}", Uuid::new_v4().simple()); + let workspace_id = workspace_id_for_stable_chat_id(&dto.chat_ref, &stable_chat_id)?; + let now = Utc::now(); + let run = AgentRun { + id: run_id.clone(), + workspace_id: workspace_id.clone(), + stable_chat_id: stable_chat_id.clone(), + chat_ref: dto.chat_ref, + generation_type: dto.generation_type, + profile_id: dto.profile_id, + status: AgentRunStatus::Created, + created_at: now, + updated_at: now, + }; + + self.run_repository.create_run(&run).await?; + self.event( + &run_id, + AgentRunEventLevel::Info, + "run_created", + json!({ + "workspaceId": workspace_id.clone(), + "stableChatId": stable_chat_id.clone(), + }), + ) + .await?; + if let Some(generation_intent) = dto.generation_intent { + self.event( + &run_id, + AgentRunEventLevel::Info, + "generation_intent_recorded", + generation_intent, + ) + .await?; + } + + let (cancel_sender, cancel_receiver) = watch::channel(false); + self.active_runs + .write() + .await + .insert(run_id.clone(), cancel_sender); + + let service = self.clone(); + let background_run_id = run_id.clone(); + tokio::spawn(async move { + service + .execute_one_step_run(background_run_id, prompt_snapshot, request, cancel_receiver) + .await; + }); + + Ok(AgentRunHandleDto { + run_id, + workspace_id, + stable_chat_id, + status: AgentRunStatus::Created, + }) + } + + pub async fn cancel_run( + &self, + dto: AgentCancelRunDto, + ) -> Result { + let run = self.run_repository.load_run(&dto.run_id).await?; + match run.status { + AgentRunStatus::Completed | AgentRunStatus::Cancelled | AgentRunStatus::Failed => { + return Ok(AgentRunHandleDto { + run_id: run.id, + workspace_id: run.workspace_id, + stable_chat_id: run.stable_chat_id, + status: run.status, + }); + } + _ => {} + } + + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "run_cancel_requested", + Value::Null, + ) + .await?; + + let sender = self.active_runs.read().await.get(&dto.run_id).cloned(); + + let next = match run.status { + AgentRunStatus::AwaitingCommit | AgentRunStatus::Committing => { + self.active_runs.write().await.remove(&dto.run_id); + let cancelled = self + .transition_status(&dto.run_id, AgentRunStatus::Cancelled) + .await?; + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "run_cancelled", + Value::Null, + ) + .await?; + cancelled + } + _ => { + if let Some(sender) = sender { + let _ = sender.send(true); + self.transition_status(&dto.run_id, AgentRunStatus::Cancelling) + .await? + } else { + let cancelled = self + .transition_status(&dto.run_id, AgentRunStatus::Cancelled) + .await?; + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "run_cancelled", + Value::Null, + ) + .await?; + cancelled + } + } + }; + + Ok(AgentRunHandleDto { + run_id: next.id, + workspace_id: next.workspace_id, + stable_chat_id: next.stable_chat_id, + status: next.status, + }) + } + + pub async fn read_events( + &self, + dto: AgentReadEventsDto, + ) -> Result { + let events = self + .run_repository + .read_events( + &dto.run_id, + AgentRunEventReadQuery { + after_seq: dto.after_seq, + before_seq: dto.before_seq, + limit: dto.limit, + }, + ) + .await?; + + Ok(AgentReadEventsResultDto { events }) + } + + pub async fn read_workspace_file( + &self, + dto: AgentReadWorkspaceFileDto, + ) -> Result { + let path = WorkspacePath::parse(dto.path)?; + let file = self + .workspace_repository + .read_text(&dto.run_id, &path) + .await?; + + Ok(AgentWorkspaceFileDto { + path: file.path.as_str().to_string(), + text: file.text, + bytes: file.bytes, + sha256: file.sha256, + }) + } + + pub async fn prepare_commit( + &self, + dto: AgentPrepareCommitDto, + ) -> Result { + let run = self.run_repository.load_run(&dto.run_id).await?; + let run = match run.status { + AgentRunStatus::AwaitingCommit => { + self.transition_status(&dto.run_id, AgentRunStatus::Committing) + .await? + } + AgentRunStatus::Committing => run, + status => { + return Err(ApplicationError::ValidationError(format!( + "agent.invalid_commit_state: expected awaiting_commit or committing, got {:?}", + status + ))); + } + }; + let commit_event = self + .event( + &dto.run_id, + AgentRunEventLevel::Info, + "commit_started", + Value::Null, + ) + .await?; + + let manifest = self.workspace_repository.read_manifest(&dto.run_id).await?; + let message_artifact = manifest + .artifacts + .iter() + .find(|artifact| matches!(artifact.target, ArtifactTarget::MessageBody)) + .ok_or_else(|| { + ApplicationError::ValidationError( + "workspace.message_body_artifact_missing: manifest does not declare a message body artifact" + .to_string(), + ) + })?; + let artifact_path = WorkspacePath::parse(&message_artifact.path)?; + let file = self + .workspace_repository + .read_text(&dto.run_id, &artifact_path) + .await?; + if message_artifact.required && file.text.trim().is_empty() { + return Err(ApplicationError::ValidationError( + "workspace.required_artifact_empty: output/main.md is empty".to_string(), + )); + } + + let required_paths = manifest + .artifacts + .iter() + .filter(|artifact| artifact.required) + .map(|artifact| WorkspacePath::parse(&artifact.path)) + .collect::, _>>()?; + let checkpoint = self + .checkpoint_repository + .create_checkpoint( + &dto.run_id, + "commit_prepare", + commit_event.seq, + &required_paths, + ) + .await?; + + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "commit_draft_created", + json!({ + "checkpointId": checkpoint.id, + "artifactPath": file.path.as_str(), + "bytes": file.bytes, + "sha256": file.sha256, + }), + ) + .await?; + + let extra = json!({ + "tauritavern": { + "agent": { + "version": 1, + "runId": run.id, + "workspaceId": run.workspace_id, + "stableChatId": run.stable_chat_id.clone(), + "profileId": run.profile_id, + "checkpointId": checkpoint.id, + "artifacts": [{ + "id": message_artifact.id, + "path": file.path.as_str(), + "kind": message_artifact.kind, + "target": "message_body", + "bytes": file.bytes, + "sha256": file.sha256 + }] + } + } + }); + + Ok(AgentCommitDraftDto { + run_id: dto.run_id, + stable_chat_id: run.stable_chat_id, + chat_ref: run.chat_ref, + generation_type: run.generation_type, + checkpoint, + message: AgentCommitMessageDto { + mes: file.text, + extra: Some(extra), + }, + }) + } + + pub async fn finalize_commit( + &self, + dto: AgentFinalizeCommitDto, + ) -> Result { + let run = self.run_repository.load_run(&dto.run_id).await?; + if run.status != AgentRunStatus::Committing { + return Err(ApplicationError::ValidationError(format!( + "agent.invalid_finalize_state: expected committing, got {:?}", + run.status + ))); + } + + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "run_committed", + json!({ "messageId": dto.message_id }), + ) + .await?; + let completed = self + .transition_status(&dto.run_id, AgentRunStatus::Completed) + .await?; + self.event( + &dto.run_id, + AgentRunEventLevel::Info, + "run_completed", + Value::Null, + ) + .await?; + self.active_runs.write().await.remove(&dto.run_id); + + Ok(AgentCommitResultDto { + run_id: completed.id, + status: completed.status, + }) + } + + async fn execute_one_step_run( + self: Arc, + run_id: String, + prompt_snapshot: Value, + request: ChatCompletionGenerateRequestDto, + mut cancel: AgentCancelReceiver, + ) { + let result = self + .execute_one_step_run_inner(&run_id, prompt_snapshot, request, &mut cancel) + .await; + + match result { + Ok(()) => {} + Err(ApplicationError::Cancelled(message)) => { + let _ = self + .transition_status(&run_id, AgentRunStatus::Cancelled) + .await; + let _ = self + .event( + &run_id, + AgentRunEventLevel::Info, + "run_cancelled", + json!({ "message": message }), + ) + .await; + self.active_runs.write().await.remove(&run_id); + } + Err(error) => { + let _ = self + .transition_status(&run_id, AgentRunStatus::Failed) + .await; + let _ = self + .event( + &run_id, + AgentRunEventLevel::Error, + "run_failed", + json!({ "message": error.to_string() }), + ) + .await; + self.active_runs.write().await.remove(&run_id); + } + } + } + + async fn execute_one_step_run_inner( + &self, + run_id: &str, + prompt_snapshot: Value, + request: ChatCompletionGenerateRequestDto, + cancel: &mut AgentCancelReceiver, + ) -> Result<(), ApplicationError> { + let run = self + .transition_status(run_id, AgentRunStatus::InitializingWorkspace) + .await?; + let manifest = build_phase1_manifest(&run); + self.workspace_repository + .initialize_run(&run, &manifest, &prompt_snapshot) + .await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "workspace_initialized", + json!({ + "workspaceId": run.workspace_id, + "stableChatId": run.stable_chat_id, + }), + ) + .await?; + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::AssemblingContext) + .await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "context_assembled", + request_summary(&request.payload), + ) + .await?; + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::CallingModel) + .await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "model_request_created", + request_summary(&request.payload), + ) + .await?; + + let response = self + .chat_completion_service + .generate_with_cancel(request, cancel.clone()) + .await?; + reject_phase1_tool_response(&response)?; + let text = extract_response_text(&response)?; + if text.trim().is_empty() { + return Err(ApplicationError::ValidationError( + "model.empty_response_text: model response text is empty".to_string(), + )); + } + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::ApplyingWorkspacePatch) + .await?; + let artifact_path = WorkspacePath::parse("output/main.md")?; + let artifact = self + .workspace_repository + .write_text(run_id, &artifact_path, &text) + .await?; + let artifact_event = self + .event( + run_id, + AgentRunEventLevel::Info, + "workspace_file_written", + json!({ + "path": artifact.path.as_str(), + "bytes": artifact.bytes, + "sha256": artifact.sha256, + }), + ) + .await?; + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::CreatingCheckpoint) + .await?; + let checkpoint = self + .checkpoint_repository + .create_checkpoint( + run_id, + "model_response", + artifact_event.seq, + &[artifact_path], + ) + .await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "checkpoint_created", + json!({ "checkpointId": checkpoint.id }), + ) + .await?; + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::AssemblingArtifacts) + .await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "artifact_assembled", + json!({ + "id": "main", + "path": artifact.path.as_str(), + "checkpointId": checkpoint.id, + }), + ) + .await?; + self.ensure_not_cancelled(cancel)?; + + self.transition_status(run_id, AgentRunStatus::AwaitingCommit) + .await?; + Ok(()) + } + + async fn transition_status( + &self, + run_id: &str, + status: AgentRunStatus, + ) -> Result { + let mut run = self.run_repository.load_run(run_id).await?; + run.status = status; + run.updated_at = Utc::now(); + self.run_repository.save_run(&run).await?; + self.event( + run_id, + AgentRunEventLevel::Info, + "status_changed", + json!({ "status": status }), + ) + .await?; + Ok(run) + } + + async fn event( + &self, + run_id: &str, + level: AgentRunEventLevel, + event_type: &str, + payload: Value, + ) -> Result { + self.run_repository + .append_event(run_id, level, event_type, payload) + .await + .map_err(ApplicationError::from) + } + + fn ensure_not_cancelled(&self, cancel: &AgentCancelReceiver) -> Result<(), ApplicationError> { + if *cancel.borrow() { + return Err(DomainError::generation_cancelled_by_user().into()); + } + Ok(()) + } +} + +fn build_phase1_manifest(run: &AgentRun) -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: 1, + run_id: run.id.clone(), + stable_chat_id: run.stable_chat_id.clone(), + chat_ref: run.chat_ref.clone(), + created_at: Utc::now(), + input: WorkspaceInputManifest { + mode: "prompt_snapshot".to_string(), + prompt_snapshot_path: "input/prompt_snapshot.json".to_string(), + }, + artifacts: vec![ArtifactSpec { + id: "main".to_string(), + path: "output/main.md".to_string(), + kind: "markdown".to_string(), + target: ArtifactTarget::MessageBody, + required: true, + assembly_order: 0, + }], + commit_policy: CommitPolicy { + default_target: ArtifactTarget::MessageBody, + combine_template: None, + store_artifacts_in_extra: true, + }, + } +} + +fn workspace_id_for_stable_chat_id( + chat_ref: &crate::domain::models::agent::AgentChatRef, + stable_chat_id: &str, +) -> Result { + let kind = match chat_ref { + crate::domain::models::agent::AgentChatRef::Character { .. } => "character", + crate::domain::models::agent::AgentChatRef::Group { .. } => "group", + }; + let json = serde_json::to_vec(&json!({ + "kind": kind, + "stableChatId": stable_chat_id, + })) + .map_err(|error| { + ApplicationError::ValidationError(format!("agent.invalid_chat_ref: {error}")) + })?; + let digest = Sha256::digest(json); + let mut suffix = String::with_capacity(16); + for byte in digest.iter().take(8) { + suffix.push_str(&format!("{byte:02x}")); + } + Ok(format!("chat_{suffix}")) +} + +fn validate_stable_chat_id(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(ApplicationError::ValidationError( + "agent.stable_chat_id_required: stableChatId is required".to_string(), + )); + } + if value.len() > 512 { + return Err(ApplicationError::ValidationError( + "agent.invalid_stable_chat_id: stableChatId is too long".to_string(), + )); + } + Ok(value.to_string()) +} + +fn request_from_prompt_snapshot( + prompt_snapshot: &Value, +) -> Result { + let payload = find_payload_object(prompt_snapshot).ok_or_else(|| { + ApplicationError::ValidationError( + "agent.invalid_prompt_snapshot: expected a chat completion payload object".to_string(), + ) + })?; + let mut payload = payload.clone(); + + payload.insert("stream".to_string(), Value::Bool(false)); + if !payload.contains_key("chat_completion_source") { + payload.insert( + "chat_completion_source".to_string(), + Value::String("openai".to_string()), + ); + } + + if !payload.contains_key("messages") && !payload.contains_key("prompt") { + return Err(ApplicationError::ValidationError( + "agent.invalid_prompt_snapshot: payload must contain messages or prompt".to_string(), + )); + } + + Ok(ChatCompletionGenerateRequestDto { payload }) +} + +fn find_payload_object(value: &Value) -> Option> { + let object = value.as_object()?; + + for key in [ + "chatCompletionPayload", + "chat_completion_payload", + "generateData", + "generate_data", + ] { + if let Some(payload) = object.get(key).and_then(Value::as_object) { + return Some(payload.clone()); + } + } + + if object.contains_key("messages") || object.contains_key("prompt") { + return Some(object.clone()); + } + + None +} + +fn reject_phase1_tool_request(payload: &Map) -> Result<(), ApplicationError> { + let has_tools = payload + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()); + if has_tools { + return Err(ApplicationError::ValidationError( + "model.tool_call_unsupported_phase1: Agent Phase 1 does not execute model tool calls" + .to_string(), + )); + } + Ok(()) +} + +fn reject_phase1_tool_response(response: &Value) -> Result<(), ApplicationError> { + let choices = response + .get("choices") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + + for choice in choices { + if choice.get("finish_reason").and_then(Value::as_str) == Some("tool_calls") { + return Err(ApplicationError::ValidationError( + "model.tool_call_unsupported_phase1: model returned tool_calls finish reason" + .to_string(), + )); + } + if choice + .pointer("/message/tool_calls") + .and_then(Value::as_array) + .is_some_and(|tool_calls| !tool_calls.is_empty()) + { + return Err(ApplicationError::ValidationError( + "model.tool_call_unsupported_phase1: model returned tool calls".to_string(), + )); + } + } + + if response + .pointer("/message/tool_calls") + .and_then(Value::as_array) + .is_some_and(|tool_calls| !tool_calls.is_empty()) + { + return Err(ApplicationError::ValidationError( + "model.tool_call_unsupported_phase1: model returned tool calls".to_string(), + )); + } + + Ok(()) +} + +fn extract_response_text(response: &Value) -> Result { + if let Some(text) = response.as_str() { + return Ok(text.to_string()); + } + + for pointer in [ + "/choices/0/message/content", + "/choices/0/text", + "/text", + "/message/content", + "/message/tool_plan", + "/output", + "/content", + ] { + if let Some(text) = text_from_value(response.pointer(pointer)) { + return Ok(text); + } + } + + Err(ApplicationError::ValidationError( + "model.empty_response_text: could not extract assistant message text from model response" + .to_string(), + )) +} + +fn text_from_value(value: Option<&Value>) -> Option { + match value? { + Value::String(text) => Some(text.clone()), + Value::Array(parts) => { + let mut output = String::new(); + for part in parts { + match part { + Value::String(text) => output.push_str(text), + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("tool_use") { + return None; + } + if let Some(text) = object.get("text").and_then(Value::as_str) { + output.push_str(text); + } else if let Some(text) = object.get("content").and_then(Value::as_str) { + output.push_str(text); + } + } + _ => {} + } + } + Some(output) + } + _ => None, + } +} + +fn request_summary(payload: &Map) -> Value { + json!({ + "chatCompletionSource": payload.get("chat_completion_source").and_then(Value::as_str), + "model": payload.get("model").and_then(Value::as_str), + "messageCount": payload.get("messages").and_then(Value::as_array).map(|messages| messages.len()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_openai_message_content() { + let response = json!({ + "choices": [{ + "message": { "content": "hello" } + }] + }); + + assert_eq!(extract_response_text(&response).unwrap(), "hello"); + } + + #[test] + fn rejects_tool_call_response() { + let response = json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { "tool_calls": [{ "id": "call_1" }] } + }] + }); + + assert!(reject_phase1_tool_response(&response).is_err()); + } + + #[test] + fn workspace_id_uses_stable_chat_id_not_character_chat_file_name() { + let first = crate::domain::models::agent::AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "old-chat".to_string(), + }; + let second = crate::domain::models::agent::AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "renamed-chat".to_string(), + }; + + let first_id = workspace_id_for_stable_chat_id(&first, "stable-chat").unwrap(); + let second_id = workspace_id_for_stable_chat_id(&second, "stable-chat").unwrap(); + + assert_eq!(first_id, second_id); + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index a0a86df8..fcc3a38b 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,4 +1,5 @@ // Application services +pub mod agent_runtime_service; pub mod avatar_service; pub mod background_service; pub mod character_service; diff --git a/src-tauri/src/domain/models/agent/mod.rs b/src-tauri/src/domain/models/agent/mod.rs new file mode 100644 index 00000000..2dd2ec3c --- /dev/null +++ b/src-tauri/src/domain/models/agent/mod.rs @@ -0,0 +1,297 @@ +use std::path::Component; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::domain::errors::DomainError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum AgentChatRef { + Character { + character_id: String, + file_name: String, + }, + Group { + chat_id: String, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentRunStatus { + Created, + InitializingWorkspace, + AssemblingContext, + CallingModel, + ApplyingWorkspacePatch, + CreatingCheckpoint, + AssemblingArtifacts, + AwaitingCommit, + Committing, + Completed, + Cancelling, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRun { + pub id: String, + pub workspace_id: String, + pub stable_chat_id: String, + pub chat_ref: AgentChatRef, + pub generation_type: String, + pub profile_id: Option, + pub status: AgentRunStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunEvent { + pub seq: u64, + pub id: String, + pub run_id: String, + pub timestamp: DateTime, + pub level: AgentRunEventLevel, + #[serde(rename = "type")] + pub event_type: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentRunEventLevel { + Debug, + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct WorkspacePath(String); + +impl WorkspacePath { + pub fn parse(raw: impl AsRef) -> Result { + let raw = raw.as_ref(); + if raw.is_empty() { + return Err(DomainError::InvalidData( + "Workspace path cannot be empty".to_string(), + )); + } + if raw.contains('\0') { + return Err(DomainError::InvalidData( + "Workspace path cannot contain NUL".to_string(), + )); + } + if raw.starts_with('/') || raw.starts_with('\\') { + return Err(DomainError::InvalidData( + "Workspace path must be relative".to_string(), + )); + } + if raw.len() >= 2 && raw.as_bytes()[1] == b':' && raw.as_bytes()[0].is_ascii_alphabetic() { + return Err(DomainError::InvalidData( + "Workspace path cannot use a Windows drive prefix".to_string(), + )); + } + + let normalized = raw.replace('\\', "/"); + let path = std::path::Path::new(&normalized); + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(value) => { + let segment = value.to_string_lossy(); + if segment.is_empty() { + continue; + } + parts.push(segment.to_string()); + } + Component::CurDir => {} + Component::ParentDir => { + return Err(DomainError::InvalidData( + "Workspace path cannot contain ..".to_string(), + )); + } + Component::RootDir | Component::Prefix(_) => { + return Err(DomainError::InvalidData( + "Workspace path must be relative".to_string(), + )); + } + } + } + + if parts.is_empty() { + return Err(DomainError::InvalidData( + "Workspace path cannot be empty".to_string(), + )); + } + + Ok(Self(parts.join("/"))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceManifest { + pub workspace_version: u32, + pub run_id: String, + pub stable_chat_id: String, + pub chat_ref: AgentChatRef, + pub created_at: DateTime, + pub input: WorkspaceInputManifest, + pub artifacts: Vec, + pub commit_policy: CommitPolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceInputManifest { + pub mode: String, + pub prompt_snapshot_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactSpec { + pub id: String, + pub path: String, + pub kind: String, + pub target: ArtifactTarget, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub assembly_order: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactTarget { + MessageBody, + MessageExtra { key: String }, + CombinedMarkdown, + HiddenRunArtifact, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommitPolicy { + pub default_target: ArtifactTarget, + #[serde(default)] + pub combine_template: Option, + #[serde(default)] + pub store_artifacts_in_extra: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Checkpoint { + pub id: String, + pub seq: u64, + pub run_id: String, + pub created_at: DateTime, + pub reason: String, + pub event_seq: u64, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointFile { + pub path: String, + pub sha256: String, + pub bytes: u64, +} + +#[cfg(test)] +mod tests { + use super::{AgentChatRef, WorkspacePath}; + use serde_json::json; + + #[test] + fn chat_ref_accepts_frontend_abi_shape() { + let ref_from_frontend: AgentChatRef = serde_json::from_value(json!({ + "kind": "character", + "characterId": "Seraphina", + "fileName": "chapter-1" + })) + .expect("frontend character ref"); + + assert_eq!( + ref_from_frontend, + AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "chapter-1".to_string(), + } + ); + + let group_ref: AgentChatRef = serde_json::from_value(json!({ + "kind": "group", + "chatId": "group-chat" + })) + .expect("frontend group ref"); + + assert_eq!( + group_ref, + AgentChatRef::Group { + chat_id: "group-chat".to_string(), + } + ); + } + + #[test] + fn chat_ref_serializes_to_frontend_abi_shape() { + let value = serde_json::to_value(AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "chapter-1".to_string(), + }) + .expect("serialize character ref"); + + assert_eq!( + value, + json!({ + "kind": "character", + "characterId": "Seraphina", + "fileName": "chapter-1" + }) + ); + } + + #[test] + fn chat_ref_rejects_internal_field_names_at_abi_boundary() { + let result = serde_json::from_value::(json!({ + "kind": "character", + "character_id": "Seraphina", + "file_name": "chapter-1" + })); + + assert!(result.is_err()); + } + + #[test] + fn workspace_path_normalizes_relative_paths() { + let path = WorkspacePath::parse("output/./main.md").expect("valid path"); + assert_eq!(path.as_str(), "output/main.md"); + } + + #[test] + fn workspace_path_rejects_escape_paths() { + assert!(WorkspacePath::parse("../secrets.json").is_err()); + assert!(WorkspacePath::parse("/tmp/file").is_err()); + assert!(WorkspacePath::parse("C:\\Users\\me\\file").is_err()); + assert!(WorkspacePath::parse("scratch/\0bad").is_err()); + } +} diff --git a/src-tauri/src/domain/models/mod.rs b/src-tauri/src/domain/models/mod.rs index 21eb10ad..cbe14179 100644 --- a/src-tauri/src/domain/models/mod.rs +++ b/src-tauri/src/domain/models/mod.rs @@ -1,4 +1,5 @@ // Domain models +pub mod agent; pub mod avatar; pub mod background; pub mod character; diff --git a/src-tauri/src/domain/repositories/agent_run_repository.rs b/src-tauri/src/domain/repositories/agent_run_repository.rs new file mode 100644 index 00000000..33a1f087 --- /dev/null +++ b/src-tauri/src/domain/repositories/agent_run_repository.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; +use serde_json::Value; + +use crate::domain::errors::DomainError; +use crate::domain::models::agent::{AgentRun, AgentRunEvent, AgentRunEventLevel}; + +#[derive(Debug, Clone, Copy)] +pub struct AgentRunEventReadQuery { + pub after_seq: Option, + pub before_seq: Option, + pub limit: usize, +} + +#[async_trait] +pub trait AgentRunRepository: Send + Sync { + async fn create_run(&self, run: &AgentRun) -> Result<(), DomainError>; + + async fn load_run(&self, run_id: &str) -> Result; + + async fn save_run(&self, run: &AgentRun) -> Result<(), DomainError>; + + async fn append_event( + &self, + run_id: &str, + level: AgentRunEventLevel, + event_type: &str, + payload: Value, + ) -> Result; + + async fn read_events( + &self, + run_id: &str, + query: AgentRunEventReadQuery, + ) -> Result, DomainError>; +} diff --git a/src-tauri/src/domain/repositories/checkpoint_repository.rs b/src-tauri/src/domain/repositories/checkpoint_repository.rs new file mode 100644 index 00000000..cc204b62 --- /dev/null +++ b/src-tauri/src/domain/repositories/checkpoint_repository.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; + +use crate::domain::errors::DomainError; +use crate::domain::models::agent::{Checkpoint, WorkspacePath}; + +#[async_trait] +pub trait CheckpointRepository: Send + Sync { + async fn create_checkpoint( + &self, + run_id: &str, + reason: &str, + event_seq: u64, + paths: &[WorkspacePath], + ) -> Result; +} diff --git a/src-tauri/src/domain/repositories/mod.rs b/src-tauri/src/domain/repositories/mod.rs index a8fae3b6..ae5d2428 100644 --- a/src-tauri/src/domain/repositories/mod.rs +++ b/src-tauri/src/domain/repositories/mod.rs @@ -1,10 +1,12 @@ // Repository interfaces +pub mod agent_run_repository; pub mod avatar_repository; pub mod background_repository; pub mod character_repository; pub mod chat_completion_repository; pub mod chat_repository; pub mod chat_types; +pub mod checkpoint_repository; pub mod content_repository; pub mod extension_repository; pub mod extension_store_repository; @@ -23,4 +25,5 @@ pub mod tts_repository; pub mod update_repository; pub mod user_directory_repository; pub mod user_repository; +pub mod workspace_repository; pub mod world_info_repository; diff --git a/src-tauri/src/domain/repositories/workspace_repository.rs b/src-tauri/src/domain/repositories/workspace_repository.rs new file mode 100644 index 00000000..377beb5c --- /dev/null +++ b/src-tauri/src/domain/repositories/workspace_repository.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; +use serde_json::Value; + +use crate::domain::errors::DomainError; +use crate::domain::models::agent::{AgentRun, WorkspaceManifest, WorkspacePath}; + +#[derive(Debug, Clone)] +pub struct WorkspaceFile { + pub path: WorkspacePath, + pub text: String, + pub bytes: u64, + pub sha256: String, +} + +#[async_trait] +pub trait WorkspaceRepository: Send + Sync { + async fn initialize_run( + &self, + run: &AgentRun, + manifest: &WorkspaceManifest, + prompt_snapshot: &Value, + ) -> Result<(), DomainError>; + + async fn read_manifest(&self, run_id: &str) -> Result; + + async fn write_text( + &self, + run_id: &str, + path: &WorkspacePath, + text: &str, + ) -> Result; + + async fn read_text( + &self, + run_id: &str, + path: &WorkspacePath, + ) -> Result; +} diff --git a/src-tauri/src/infrastructure/repositories/file_agent_repository.rs b/src-tauri/src/infrastructure/repositories/file_agent_repository.rs new file mode 100644 index 00000000..aedbf092 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_agent_repository.rs @@ -0,0 +1,653 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::fs::{self, OpenOptions}; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::domain::errors::DomainError; +use crate::domain::models::agent::{ + AgentRun, AgentRunEvent, AgentRunEventLevel, Checkpoint, CheckpointFile, WorkspaceManifest, + WorkspacePath, +}; +use crate::domain::repositories::agent_run_repository::{ + AgentRunEventReadQuery, AgentRunRepository, +}; +use crate::domain::repositories::checkpoint_repository::CheckpointRepository; +use crate::domain::repositories::workspace_repository::{WorkspaceFile, WorkspaceRepository}; +use crate::infrastructure::persistence::file_system::{ + read_json_file, replace_file_with_fallback, unique_temp_path, write_json_file, +}; + +pub struct FileAgentRepository { + root: PathBuf, + event_lock: Arc>, + checkpoint_lock: Arc>, +} + +impl FileAgentRepository { + pub fn new(root: PathBuf) -> Self { + Self { + root, + event_lock: Arc::new(Mutex::new(())), + checkpoint_lock: Arc::new(Mutex::new(())), + } + } + + fn index_run_path(&self, run_id: &str) -> Result { + validate_segment(run_id, "run_id")?; + Ok(self + .root + .join("index") + .join("runs") + .join(format!("{run_id}.json"))) + } + + fn run_dir(&self, run: &AgentRun) -> Result { + validate_segment(&run.workspace_id, "workspace_id")?; + validate_segment(&run.id, "run_id")?; + Ok(self + .root + .join("chats") + .join(&run.workspace_id) + .join("runs") + .join(&run.id)) + } + + async fn load_run_dir(&self, run_id: &str) -> Result { + let run = self.load_run(run_id).await?; + self.run_dir(&run) + } + + async fn write_json_atomic( + path: &Path, + value: &T, + ) -> Result<(), DomainError> { + write_json_file(path, value).await + } + + async fn read_json(path: &Path) -> Result { + read_json_file(path).await + } + + async fn safe_workspace_path( + &self, + run_id: &str, + workspace_path: &WorkspacePath, + create_parent: bool, + ) -> Result { + let run_dir = self.load_run_dir(run_id).await?; + let target = run_dir.join(workspace_path.as_str()); + + let canonical_run_dir = fs::canonicalize(&run_dir).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to resolve agent workspace root {}: {}", + run_dir.display(), + error + )) + })?; + + if let Some(parent) = target.parent() { + if create_parent { + fs::create_dir_all(parent).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to create workspace parent {}: {}", + parent.display(), + error + )) + })?; + } + + let canonical_parent = fs::canonicalize(parent).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!( + "Workspace path parent not found: {}", + workspace_path.as_str() + )) + } else { + DomainError::InternalError(format!( + "Failed to resolve workspace parent {}: {}", + parent.display(), + error + )) + } + })?; + if !canonical_parent.starts_with(&canonical_run_dir) { + return Err(DomainError::InvalidData(format!( + "Workspace path escapes run directory: {}", + workspace_path.as_str() + ))); + } + } + + match fs::symlink_metadata(&target).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(DomainError::InvalidData(format!( + "Workspace path targets a symlink: {}", + workspace_path.as_str() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(DomainError::InternalError(format!( + "Failed to inspect workspace path {}: {}", + target.display(), + error + ))); + } + } + + Ok(target) + } + + async fn read_all_events(&self, run_id: &str) -> Result, DomainError> { + let events_path = self.load_run_dir(run_id).await?.join("events.jsonl"); + let contents = match fs::read_to_string(&events_path).await { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => { + return Err(DomainError::InternalError(format!( + "Failed to read agent event journal {}: {}", + events_path.display(), + error + ))); + } + }; + + contents + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line).map_err(|error| { + DomainError::InvalidData(format!( + "Invalid agent event in {}: {}", + events_path.display(), + error + )) + }) + }) + .collect() + } +} + +#[async_trait] +impl AgentRunRepository for FileAgentRepository { + async fn create_run(&self, run: &AgentRun) -> Result<(), DomainError> { + let run_dir = self.run_dir(run)?; + fs::create_dir_all(&run_dir).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to create agent run directory {}: {}", + run_dir.display(), + error + )) + })?; + + Self::write_json_atomic(&run_dir.join("run.json"), run).await?; + Self::write_json_atomic(&self.index_run_path(&run.id)?, run).await + } + + async fn load_run(&self, run_id: &str) -> Result { + Self::read_json(&self.index_run_path(run_id)?).await + } + + async fn save_run(&self, run: &AgentRun) -> Result<(), DomainError> { + let run_dir = self.run_dir(run)?; + Self::write_json_atomic(&run_dir.join("run.json"), run).await?; + Self::write_json_atomic(&self.index_run_path(&run.id)?, run).await + } + + async fn append_event( + &self, + run_id: &str, + level: AgentRunEventLevel, + event_type: &str, + payload: Value, + ) -> Result { + let _guard = self.event_lock.lock().await; + let run_dir = self.load_run_dir(run_id).await?; + let events_path = run_dir.join("events.jsonl"); + if let Some(parent) = events_path.parent() { + fs::create_dir_all(parent).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to create agent event journal parent {}: {}", + parent.display(), + error + )) + })?; + } + + let seq = self + .read_all_events(run_id) + .await? + .last() + .map(|event| event.seq + 1) + .unwrap_or(1); + + let event = AgentRunEvent { + seq, + id: format!("evt_{}", Uuid::new_v4().simple()), + run_id: run_id.to_string(), + timestamp: Utc::now(), + level, + event_type: event_type.to_string(), + payload, + }; + + let line = serde_json::to_string(&event).map_err(|error| { + DomainError::InvalidData(format!("Failed to serialize agent event: {error}")) + })?; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&events_path) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to open agent event journal {}: {}", + events_path.display(), + error + )) + })?; + file.write_all(line.as_bytes()).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to append agent event journal {}: {}", + events_path.display(), + error + )) + })?; + file.write_all(b"\n").await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to append agent event journal newline {}: {}", + events_path.display(), + error + )) + })?; + file.flush().await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to flush agent event journal {}: {}", + events_path.display(), + error + )) + })?; + + Ok(event) + } + + async fn read_events( + &self, + run_id: &str, + query: AgentRunEventReadQuery, + ) -> Result, DomainError> { + let limit = query.limit.clamp(1, 500); + let mut events = self.read_all_events(run_id).await?; + + if let Some(before_seq) = query.before_seq { + events.retain(|event| event.seq < before_seq); + let start = events.len().saturating_sub(limit); + return Ok(events.into_iter().skip(start).collect()); + } + + if let Some(after_seq) = query.after_seq { + events.retain(|event| event.seq > after_seq); + } + + events.truncate(limit); + Ok(events) + } +} + +#[async_trait] +impl WorkspaceRepository for FileAgentRepository { + async fn initialize_run( + &self, + run: &AgentRun, + manifest: &WorkspaceManifest, + prompt_snapshot: &Value, + ) -> Result<(), DomainError> { + let run_dir = self.run_dir(run)?; + fs::create_dir_all(run_dir.join("input")) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create agent run input directory {}: {}", + run_dir.join("input").display(), + error + )) + })?; + fs::create_dir_all(run_dir.join("output")) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create agent run output directory {}: {}", + run_dir.join("output").display(), + error + )) + })?; + + Self::write_json_atomic(&run_dir.join("manifest.json"), manifest).await?; + Self::write_json_atomic( + &run_dir.join("input").join("prompt_snapshot.json"), + prompt_snapshot, + ) + .await + } + + async fn read_manifest(&self, run_id: &str) -> Result { + Self::read_json(&self.load_run_dir(run_id).await?.join("manifest.json")).await + } + + async fn write_text( + &self, + run_id: &str, + path: &WorkspacePath, + text: &str, + ) -> Result { + let target = self.safe_workspace_path(run_id, path, true).await?; + let temp_path = unique_temp_path(&target, "workspace.txt"); + fs::write(&temp_path, text.as_bytes()) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to write workspace temp file {}: {}", + temp_path.display(), + error + )) + })?; + replace_file_with_fallback(&temp_path, &target).await?; + + workspace_file_from_text(path.clone(), text.to_string()) + } + + async fn read_text( + &self, + run_id: &str, + path: &WorkspacePath, + ) -> Result { + let target = self.safe_workspace_path(run_id, path, false).await?; + let text = fs::read_to_string(&target).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("Workspace file not found: {}", path.as_str())) + } else { + DomainError::InternalError(format!( + "Failed to read workspace file {}: {}", + target.display(), + error + )) + } + })?; + + workspace_file_from_text(path.clone(), text) + } +} + +#[async_trait] +impl CheckpointRepository for FileAgentRepository { + async fn create_checkpoint( + &self, + run_id: &str, + reason: &str, + event_seq: u64, + paths: &[WorkspacePath], + ) -> Result { + let _guard = self.checkpoint_lock.lock().await; + let run_dir = self.load_run_dir(run_id).await?; + let checkpoints_dir = run_dir.join("checkpoints"); + fs::create_dir_all(&checkpoints_dir) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create checkpoint directory {}: {}", + checkpoints_dir.display(), + error + )) + })?; + + let mut next_seq = 1_u64; + let mut entries = fs::read_dir(&checkpoints_dir).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to read checkpoint directory {}: {}", + checkpoints_dir.display(), + error + )) + })?; + while let Some(entry) = entries.next_entry().await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to read checkpoint directory entry {}: {}", + checkpoints_dir.display(), + error + )) + })? { + if entry + .file_type() + .await + .map(|file_type| file_type.is_dir()) + .unwrap_or(false) + { + next_seq += 1; + } + } + + let checkpoint_id = format!("cp_{next_seq:06}"); + let checkpoint_dir = checkpoints_dir.join(&checkpoint_id); + fs::create_dir_all(&checkpoint_dir).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to create checkpoint {}: {}", + checkpoint_dir.display(), + error + )) + })?; + + let mut files = Vec::new(); + for path in paths { + let source = self.safe_workspace_path(run_id, path, false).await?; + let bytes = fs::read(&source).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!( + "Required checkpoint file not found: {}", + path.as_str() + )) + } else { + DomainError::InternalError(format!( + "Failed to read checkpoint source {}: {}", + source.display(), + error + )) + } + })?; + + let target = checkpoint_dir.join(path.as_str()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to create checkpoint file parent {}: {}", + parent.display(), + error + )) + })?; + } + fs::write(&target, &bytes).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to write checkpoint file {}: {}", + target.display(), + error + )) + })?; + + files.push(CheckpointFile { + path: path.as_str().to_string(), + sha256: sha256_hex(&bytes), + bytes: bytes.len() as u64, + }); + } + + let checkpoint = Checkpoint { + id: checkpoint_id, + seq: next_seq, + run_id: run_id.to_string(), + created_at: Utc::now(), + reason: reason.to_string(), + event_seq, + files, + }; + Self::write_json_atomic(&checkpoint_dir.join("checkpoint.json"), &checkpoint).await?; + + Ok(checkpoint) + } +} + +fn validate_segment(value: &str, label: &str) -> Result<(), DomainError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err(DomainError::InvalidData(format!( + "Invalid agent storage segment {label}: {value}" + ))); + } + Ok(()) +} + +fn workspace_file_from_text( + path: WorkspacePath, + text: String, +) -> Result { + let bytes = text.as_bytes().to_vec(); + Ok(WorkspaceFile { + path, + text, + bytes: bytes.len() as u64, + sha256: sha256_hex(&bytes), + }) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + output.push_str(&format!("{byte:02x}")); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::models::agent::{ + AgentChatRef, AgentRunStatus, ArtifactSpec, ArtifactTarget, CommitPolicy, + WorkspaceInputManifest, + }; + + fn temp_root() -> PathBuf { + std::env::temp_dir().join(format!("tauritavern-agent-repo-{}", Uuid::new_v4())) + } + + fn sample_run() -> AgentRun { + AgentRun { + id: "run_test".to_string(), + workspace_id: "chat_test".to_string(), + stable_chat_id: "stable_chat_test".to_string(), + chat_ref: AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "Seraphina.png".to_string(), + }, + generation_type: "normal".to_string(), + profile_id: None, + status: AgentRunStatus::Created, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + fn sample_manifest(run: &AgentRun) -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: 1, + run_id: run.id.clone(), + stable_chat_id: run.stable_chat_id.clone(), + chat_ref: run.chat_ref.clone(), + created_at: Utc::now(), + input: WorkspaceInputManifest { + mode: "prompt_snapshot".to_string(), + prompt_snapshot_path: "input/prompt_snapshot.json".to_string(), + }, + artifacts: vec![ArtifactSpec { + id: "main".to_string(), + path: "output/main.md".to_string(), + kind: "markdown".to_string(), + target: ArtifactTarget::MessageBody, + required: true, + assembly_order: 0, + }], + commit_policy: CommitPolicy { + default_target: ArtifactTarget::MessageBody, + combine_template: None, + store_artifacts_in_extra: true, + }, + } + } + + #[tokio::test] + async fn repository_round_trips_run_workspace_event_and_checkpoint() { + let root = temp_root(); + let repository = FileAgentRepository::new(root.clone()); + let run = sample_run(); + let manifest = sample_manifest(&run); + + repository.create_run(&run).await.expect("create run"); + repository + .initialize_run(&run, &manifest, &serde_json::json!({"messages": []})) + .await + .expect("initialize workspace"); + + let path = WorkspacePath::parse("output/main.md").expect("workspace path"); + let written = repository + .write_text(&run.id, &path, "hello") + .await + .expect("write text"); + assert_eq!(written.sha256.len(), 64); + + let event = repository + .append_event( + &run.id, + AgentRunEventLevel::Info, + "artifact_written", + Value::Null, + ) + .await + .expect("append event"); + assert_eq!(event.seq, 1); + + let events = repository + .read_events( + &run.id, + AgentRunEventReadQuery { + after_seq: Some(0), + before_seq: None, + limit: 10, + }, + ) + .await + .expect("read events"); + assert_eq!(events.len(), 1); + + let checkpoint = repository + .create_checkpoint(&run.id, "test", event.seq, &[path]) + .await + .expect("checkpoint"); + assert_eq!(checkpoint.files[0].bytes, 5); + + fs::remove_dir_all(root).await.expect("cleanup"); + } +} diff --git a/src-tauri/src/infrastructure/repositories/mod.rs b/src-tauri/src/infrastructure/repositories/mod.rs index 38d2cdc0..11b04d28 100644 --- a/src-tauri/src/infrastructure/repositories/mod.rs +++ b/src-tauri/src/infrastructure/repositories/mod.rs @@ -1,4 +1,5 @@ // Repository implementations +pub mod file_agent_repository; pub mod file_avatar_repository; pub mod file_background_repository; pub mod file_character_repository; diff --git a/src-tauri/src/presentation/commands/agent_commands.rs b/src-tauri/src/presentation/commands/agent_commands.rs new file mode 100644 index 00000000..0948e7ad --- /dev/null +++ b/src-tauri/src/presentation/commands/agent_commands.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use tauri::State; + +use crate::app::AppState; +use crate::application::dto::agent_dto::{ + AgentCancelRunDto, AgentCommitDraftDto, AgentCommitResultDto, AgentFinalizeCommitDto, + AgentPrepareCommitDto, AgentReadEventsDto, AgentReadEventsResultDto, AgentReadWorkspaceFileDto, + AgentRunHandleDto, AgentStartRunDto, AgentWorkspaceFileDto, +}; +use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::errors::CommandError; + +#[tauri::command] +pub async fn start_agent_run( + dto: AgentStartRunDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("start_agent_run"); + + app_state + .agent_runtime_service + .start_run(dto) + .await + .map_err(map_command_error("Failed to start agent run")) +} + +#[tauri::command] +pub async fn cancel_agent_run( + dto: AgentCancelRunDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("cancel_agent_run"); + + app_state + .agent_runtime_service + .cancel_run(dto) + .await + .map_err(map_command_error("Failed to cancel agent run")) +} + +#[tauri::command] +pub async fn read_agent_run_events( + dto: AgentReadEventsDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("read_agent_run_events"); + + app_state + .agent_runtime_service + .read_events(dto) + .await + .map_err(map_command_error("Failed to read agent run events")) +} + +#[tauri::command] +pub async fn read_agent_workspace_file( + dto: AgentReadWorkspaceFileDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("read_agent_workspace_file"); + + app_state + .agent_runtime_service + .read_workspace_file(dto) + .await + .map_err(map_command_error("Failed to read agent workspace file")) +} + +#[tauri::command] +pub async fn prepare_agent_run_commit( + dto: AgentPrepareCommitDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("prepare_agent_run_commit"); + + app_state + .agent_runtime_service + .prepare_commit(dto) + .await + .map_err(map_command_error("Failed to prepare agent run commit")) +} + +#[tauri::command] +pub async fn finalize_agent_run_commit( + dto: AgentFinalizeCommitDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("finalize_agent_run_commit"); + + app_state + .agent_runtime_service + .finalize_commit(dto) + .await + .map_err(map_command_error("Failed to finalize agent run commit")) +} diff --git a/src-tauri/src/presentation/commands/mod.rs b/src-tauri/src/presentation/commands/mod.rs index 3f34a909..069de463 100644 --- a/src-tauri/src/presentation/commands/mod.rs +++ b/src-tauri/src/presentation/commands/mod.rs @@ -1,4 +1,5 @@ // Tauri commands +pub mod agent_commands; pub mod avatar_commands; pub mod background_commands; pub mod bootstrap_commands; diff --git a/src-tauri/src/presentation/commands/registry.rs b/src-tauri/src/presentation/commands/registry.rs index c2eb40e0..bca89cf5 100644 --- a/src-tauri/src/presentation/commands/registry.rs +++ b/src-tauri/src/presentation/commands/registry.rs @@ -234,6 +234,13 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen // Quick reply commands super::quick_reply_commands::save_quick_reply_set, super::quick_reply_commands::delete_quick_reply_set, + // Agent runtime commands + super::agent_commands::start_agent_run, + super::agent_commands::cancel_agent_run, + super::agent_commands::read_agent_run_events, + super::agent_commands::read_agent_workspace_file, + super::agent_commands::prepare_agent_run_commit, + super::agent_commands::finalize_agent_run_commit, // Chat completion commands super::chat_completion_commands::get_chat_completions_status, super::chat_completion_commands::generate_chat_completion, diff --git a/src/tauri/main/api/agent.js b/src/tauri/main/api/agent.js new file mode 100644 index 00000000..d72f391f --- /dev/null +++ b/src/tauri/main/api/agent.js @@ -0,0 +1,321 @@ +// @ts-check + +const DEFAULT_EVENT_POLL_MS = 500; + +/** + * @typedef {{ kind: 'character'; characterId: string; fileName: string }} CharacterChatRef + * @typedef {{ kind: 'group'; chatId: string }} GroupChatRef + * @typedef {CharacterChatRef | GroupChatRef} ChatRef + */ + +/** + * @param {{ safeInvoke: (command: string, args?: any) => Promise }} deps + */ +function createAgentApi({ safeInvoke }) { + async function startRun(input) { + const dto = await normalizeStartRunInput(input); + return safeInvoke('start_agent_run', { dto }); + } + + async function cancel(runId) { + const normalizedRunId = requireRunId(runId); + return safeInvoke('cancel_agent_run', { dto: { runId: normalizedRunId } }); + } + + async function readEvents(input) { + const runId = requireRunId(input?.runId); + return safeInvoke('read_agent_run_events', { + dto: { + runId, + afterSeq: input?.afterSeq, + beforeSeq: input?.beforeSeq, + limit: input?.limit, + }, + }); + } + + async function readWorkspaceFile(input) { + const runId = requireRunId(input?.runId); + const path = String(input?.path || '').trim(); + if (!path) { + throw new Error('path is required'); + } + + return safeInvoke('read_agent_workspace_file', { dto: { runId, path } }); + } + + function subscribe(runId, handler, options = {}) { + const normalizedRunId = requireRunId(runId); + if (typeof handler !== 'function') { + throw new Error('handler is required'); + } + + const intervalMs = normalizePollInterval(options?.intervalMs); + let afterSeq = Number(options?.afterSeq || 0); + let stopped = false; + let timer = null; + + const tick = async () => { + if (stopped) { + return; + } + + try { + const result = await readEvents({ + runId: normalizedRunId, + afterSeq, + limit: options?.limit || 100, + }); + const events = Array.isArray(result?.events) ? result.events : []; + for (const event of events) { + afterSeq = Math.max(afterSeq, Number(event?.seq || 0)); + handler(event); + } + } catch (error) { + if (typeof options?.onError === 'function') { + options.onError(error); + } else { + queueMicrotask(() => { + throw error; + }); + } + } finally { + if (!stopped) { + timer = setTimeout(tick, intervalMs); + } + } + }; + + timer = setTimeout(tick, 0); + + return function unsubscribe() { + stopped = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + } + + async function commit(input) { + const runId = requireRunId(input?.runId); + const draft = await safeInvoke('prepare_agent_run_commit', { dto: { runId } }); + await assertCurrentChat(draft?.chatRef, draft?.stableChatId); + + const script = await import('../../../script.js'); + if (typeof script.saveReply !== 'function') { + throw new Error('saveReply is not available'); + } + + await script.saveReply({ + type: draft?.generationType || 'normal', + getMessage: String(draft?.message?.mes ?? ''), + }); + + const messageId = mergeAgentExtraIntoActiveMessage(script.chat, draft?.message?.extra); + await persistActiveChat(script); + + return safeInvoke('finalize_agent_run_commit', { + dto: { + runId, + messageId: String(input?.messageId ?? messageId), + }, + }); + } + + return { + startRun, + cancel, + readEvents, + readWorkspaceFile, + subscribe, + commit, + prepareCommit(input) { + const runId = requireRunId(input?.runId); + return safeInvoke('prepare_agent_run_commit', { dto: { runId } }); + }, + finalizeCommit(input) { + const runId = requireRunId(input?.runId); + return safeInvoke('finalize_agent_run_commit', { + dto: { + runId, + messageId: input?.messageId, + }, + }); + }, + approveToolCall() { + throw new Error('approveToolCall is not implemented in Agent Phase 1'); + }, + listRuns() { + throw new Error('listRuns is not implemented in Agent Phase 1'); + }, + readDiff() { + throw new Error('readDiff is not implemented in Agent Phase 1'); + }, + rollback() { + throw new Error('rollback is not implemented in Agent Phase 1'); + }, + }; +} + +async function normalizeStartRunInput(input) { + if (!input || typeof input !== 'object') { + throw new Error('Agent startRun input is required'); + } + + const chatRef = input.chatRef || window.__TAURITAVERN__?.api?.chat?.current?.ref?.(); + if (!chatRef || typeof chatRef !== 'object') { + throw new Error('chatRef is required'); + } + + const stableChatId = String(input.stableChatId || '').trim() || await resolveStableChatId(chatRef); + if (!stableChatId) { + throw new Error('stableChatId is required'); + } + + return { + ...input, + chatRef, + stableChatId, + }; +} + +async function resolveStableChatId(chatRef) { + const chatApi = window.__TAURITAVERN__?.api?.chat; + if (!chatApi || typeof chatApi.open !== 'function') { + throw new Error('api.chat is required to resolve stableChatId'); + } + + const handle = chatApi.open(chatRef); + if (!handle || typeof handle.stableId !== 'function') { + throw new Error('api.chat.open(ref).stableId is required to resolve stableChatId'); + } + + return String(await handle.stableId()).trim(); +} + +function requireRunId(value) { + const runId = String(value || '').trim(); + if (!runId) { + throw new Error('runId is required'); + } + return runId; +} + +function normalizePollInterval(value) { + const intervalMs = Number(value || DEFAULT_EVENT_POLL_MS); + if (!Number.isFinite(intervalMs) || intervalMs < 100) { + return DEFAULT_EVENT_POLL_MS; + } + return intervalMs; +} + +async function assertCurrentChat(expectedRef, expectedStableChatId = null) { + const currentRef = window.__TAURITAVERN__?.api?.chat?.current?.ref?.(); + if (!sameChatRef(currentRef, expectedRef)) { + const expectedStable = String(expectedStableChatId || '').trim(); + if (expectedStable) { + const currentStable = await resolveStableChatId(currentRef); + if (currentStable === expectedStable) { + return; + } + } + + throw new Error('agent.commit_chat_mismatch: active chat changed before commit'); + } +} + +/** + * @param {ChatRef | null | undefined} a + * @param {ChatRef | null | undefined} b + */ +function sameChatRef(a, b) { + if (!a || !b || a.kind !== b.kind) { + return false; + } + if (a.kind === 'character') { + return String(a.characterId || '') === String(b.characterId || '') + && String(a.fileName || '') === String(b.fileName || ''); + } + return String(a.chatId || '') === String(b.chatId || ''); +} + +function mergeAgentExtraIntoActiveMessage(chat, extra) { + if (!Array.isArray(chat) || chat.length === 0) { + throw new Error('Cannot commit agent output because the active chat is empty'); + } + + const messageId = chat.length - 1; + const message = chat[messageId]; + if (!message || typeof message !== 'object') { + throw new Error('Cannot commit agent output because the active message is invalid'); + } + + message.extra = mergePlainObject(message.extra, extra); + + const swipeId = Number(message.swipe_id); + if (Array.isArray(message.swipe_info) && Number.isInteger(swipeId) && message.swipe_info[swipeId]) { + message.swipe_info[swipeId].extra = structuredClone(message.extra); + } + + return messageId; +} + +function mergePlainObject(base, patch) { + const output = isPlainObject(base) ? { ...base } : {}; + if (!isPlainObject(patch)) { + return output; + } + + for (const [key, value] of Object.entries(patch)) { + if (isPlainObject(value) && isPlainObject(output[key])) { + output[key] = mergePlainObject(output[key], value); + } else { + output[key] = value; + } + } + + return output; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +async function persistActiveChat(script) { + const groupChats = await import('../../../scripts/group-chats.js'); + if (groupChats.selected_group) { + if (typeof groupChats.saveGroupChat !== 'function') { + throw new Error('saveGroupChat is not available'); + } + await groupChats.saveGroupChat(groupChats.selected_group, true); + return; + } + + if (typeof script.saveChat !== 'function') { + throw new Error('saveChat is not available'); + } + await script.saveChat(); +} + +/** + * @param {any} context + */ +export function installAgentApi(context) { + const hostWindow = /** @type {any} */ (window); + const hostAbi = hostWindow.__TAURITAVERN__; + if (!hostAbi || typeof hostAbi !== 'object') { + throw new Error('Host ABI __TAURITAVERN__ is missing'); + } + + const safeInvoke = context?.safeInvoke; + if (typeof safeInvoke !== 'function') { + throw new Error('Tauri main context safeInvoke is missing'); + } + + if (!hostAbi.api || typeof hostAbi.api !== 'object') { + hostAbi.api = {}; + } + + hostAbi.api.agent = createAgentApi({ safeInvoke }); +} diff --git a/src/tauri/main/bootstrap.js b/src/tauri/main/bootstrap.js index b281cfeb..90094742 100644 --- a/src/tauri/main/bootstrap.js +++ b/src/tauri/main/bootstrap.js @@ -21,6 +21,7 @@ import { isAbortError } from './kernel/abort-error.js'; import { installMainApiOptionParking } from './adapters/st/main-api-selector-option-parking.js'; import { installWorldInfoGlobalSelectorSelect2Enforcer } from './adapters/st/world-info-global-selector-select2-enforcer.js'; import { installChatApi } from './api/chat.js'; +import { installAgentApi } from './api/agent.js'; import { installDevApi } from './api/dev.js'; import { installExtensionStoreApi } from './api/extension-store.js'; import { installLayoutApi } from './api/layout.js'; @@ -271,7 +272,7 @@ export function bootstrapTauriMain() { installNativeShareBridge(); const context = createTauriMainContext({ invoke, convertFileSrc }); - installHostAbi(context); installLayoutApi(context); installChatApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi(); + installHostAbi(context); installLayoutApi(context); installChatApi(context); installAgentApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi(); installMainApiOptionParking(); installWorldInfoGlobalSelectorSelect2Enforcer(); if (perfEnabled) { diff --git a/src/types.d.ts b/src/types.d.ts index b4921429..6c6e937f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -87,6 +87,79 @@ type TauriTavernChatApi = { }; }; +type TauriTavernAgentRunStatus = + | 'created' + | 'initializing_workspace' + | 'assembling_context' + | 'calling_model' + | 'applying_workspace_patch' + | 'creating_checkpoint' + | 'assembling_artifacts' + | 'awaiting_commit' + | 'committing' + | 'completed' + | 'cancelling' + | 'cancelled' + | 'failed'; + +type TauriTavernAgentRunEvent = { + seq: number; + id: string; + runId: string; + timestamp: string; + level: 'debug' | 'info' | 'warn' | 'error'; + type: string; + payload?: any; +}; + +type TauriTavernAgentApi = { + startRun: (input: { + chatRef: TauriTavernChatRef; + stableChatId?: string; + generationType?: string; + profileId?: string | null; + promptSnapshot: any; + generationIntent?: any; + options?: { autoCommit?: boolean; stream?: boolean }; + }) => Promise<{ + runId: string; + workspaceId: string; + stableChatId: string; + status: TauriTavernAgentRunStatus; + }>; + cancel: (runId: string) => Promise<{ + runId: string; + workspaceId: string; + stableChatId: string; + status: TauriTavernAgentRunStatus; + }>; + readEvents: (input: { + runId: string; + afterSeq?: number; + beforeSeq?: number; + limit?: number; + }) => Promise<{ events: TauriTavernAgentRunEvent[] }>; + readWorkspaceFile: (input: { + runId: string; + path: string; + }) => Promise<{ path: string; text: string; bytes: number; sha256: string }>; + subscribe: ( + runId: string, + handler: (event: TauriTavernAgentRunEvent) => void, + options?: { afterSeq?: number; limit?: number; intervalMs?: number; onError?: (error: unknown) => void }, + ) => TauriTavernHostUnsubscribe; + commit: (input: { runId: string; messageId?: string | number }) => Promise<{ + runId: string; + status: TauriTavernAgentRunStatus; + }>; + prepareCommit: (input: { runId: string }) => Promise; + finalizeCommit: (input: { runId: string; messageId?: string | number }) => Promise; + approveToolCall: () => never; + listRuns: () => never; + readDiff: () => never; + rollback: () => never; +}; + type TauriTavernFrontendLogsApi = { list: (options?: { limit?: number }) => Promise; subscribe: ( @@ -198,6 +271,7 @@ type TauriTavernLayoutApi = { type TauriTavernHostApi = { chat?: TauriTavernChatApi; + agent?: TauriTavernAgentApi; layout?: TauriTavernLayoutApi; dev?: TauriTavernDevApi; worldInfo?: TauriTavernWorldInfoApi;