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.
This commit is contained in:
Darkatse 2026-04-26 14:25:26 -04:00
parent 5e3430ceda
commit f76139f961
38 changed files with 6704 additions and 2 deletions

311
docs/API/Agent.md Normal file
View file

@ -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<AgentRunHandle>;
subscribe(runId: string, handler: (event: AgentRunEvent) => void, options?: AgentSubscribeOptions): Promise<TauriTavernHostUnsubscribe>;
cancel(runId: string): Promise<void>;
approveToolCall(input: AgentApproveToolCallInput): Promise<void>;
listRuns(input: AgentListRunsInput): Promise<AgentRunSummary[]>;
readEvents(input: AgentReadEventsInput): Promise<AgentReadEventsResult>;
readWorkspaceFile(input: AgentReadWorkspaceFileInput): Promise<AgentWorkspaceFile>;
readDiff(input: AgentReadDiffInput): Promise<AgentDiff>;
rollback(input: AgentRollbackInput): Promise<AgentRollbackResult>;
commit(input: AgentCommitInput): Promise<AgentCommitResult>;
};
```
`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()`。

144
docs/API/MCP.md Normal file
View file

@ -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<McpServerSummary[]>;
connectServer(input: McpConnectServerInput): Promise<McpServerStatus>;
disconnectServer(serverId: string): Promise<void>;
listTools(serverId: string): Promise<McpToolSummary[]>;
callTool(input: McpCallToolInput): Promise<McpToolResult>;
listResources(serverId: string): Promise<McpResourceSummary[]>;
readResource(input: McpReadResourceInput): Promise<McpResourceContent>;
listPrompts(serverId: string): Promise<McpPromptSummary[]>;
getPrompt(input: McpGetPromptInput): Promise<McpPromptContent>;
};
```
## 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.<server>.<tool> -> ToolRegistry ToolSpec
mcp resource -> WorkspaceResource / ContextFrame
mcp prompt -> PromptComponent
```
Agent 不直接操作 MCP config。

View file

@ -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` 的草案约束为准

204
docs/Agent/LlmGateway.md Normal file
View file

@ -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 关闭。

203
docs/Agent/McpSkill.md Normal file
View file

@ -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/<name>/SKILL.md
skills/<name>/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 最小实现应晚于内置工具系统稳定之后。

View file

@ -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 留出自然扩展点。

23
docs/Agent/README.md Normal file
View file

@ -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`。

View file

@ -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 基础。

View file

@ -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。

407
docs/Agent/ToolSystem.md Normal file
View file

@ -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。

427
docs/Agent/Workspace.md Normal file
View file

@ -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/
<chat-workspace-id>/
resources/
world/
character/
preset/
user/
skills/
memory/
runs/
<run-id>/
manifest.json
input/
output/
plan/
scratch/
summaries/
checkpoints/
patches/
events.jsonl
```
实际物理根目录为数据根下的 `_tauritavern/agent-workspaces`。它是 TauriTavern 运行产物空间,不属于上游 SillyTavern `default-user` 聊天文件布局。
`<chat-workspace-id>` 必须由稳定聊天身份派生:
```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/<run-id>/
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 基础
- 后续工具循环增量接入

410
docs/AgentArchitecture.md Normal file
View file

@ -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 这些后续能力赖以存在的骨架。

437
docs/AgentContract.md Normal file
View file

@ -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_<uuid-v4>
stableChatId = api.chat.open(chatRef).stableId()
workspaceId = chat_<sha256(kind + stableChatId)>
```
要求:
- `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 或旧扩展忽略后不应崩溃。

433
docs/AgentImplementPlan.md Normal file
View file

@ -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 这三个地基,任何工具生态都会快速堆成难维护的临时补丁。

View file

@ -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/角色卡/世界书直接写入。

View file

@ -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/`

View file

@ -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”,应保持**小而稳定**;不要把内部实现对象整个暴露出去。
---

View file

@ -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`

View file

@ -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<ThemeService>,
pub preset_service: Arc<PresetService>,
pub quick_reply_service: Arc<QuickReplyService>,
pub agent_runtime_service: Arc<AgentRuntimeService>,
pub chat_completion_service: Arc<ChatCompletionService>,
pub tokenization_service: Arc<TokenizationService>,
pub stable_diffusion_service: Arc<StableDiffusionService>,
@ -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,

View file

@ -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<ThemeService>,
pub preset_service: Arc<PresetService>,
pub quick_reply_service: Arc<QuickReplyService>,
pub agent_runtime_service: Arc<AgentRuntimeService>,
pub chat_completion_service: Arc<ChatCompletionService>,
pub tokenization_service: Arc<TokenizationService>,
pub stable_diffusion_service: Arc<StableDiffusionService>,
@ -130,6 +136,9 @@ struct AppRepositories {
theme_repository: Arc<dyn ThemeRepository>,
preset_repository: Arc<dyn PresetRepository>,
quick_reply_repository: Arc<dyn QuickReplyRepository>,
agent_run_repository: Arc<dyn AgentRunRepository>,
workspace_repository: Arc<dyn WorkspaceRepository>,
checkpoint_repository: Arc<dyn CheckpointRepository>,
chat_completion_repository: Arc<dyn ChatCompletionRepository>,
tokenizer_repository: Arc<dyn TokenizerRepository>,
stable_diffusion_repository: Arc<dyn StableDiffusionRepository>,
@ -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<dyn AgentRunRepository> = file_agent_repository.clone();
let workspace_repository: Arc<dyn WorkspaceRepository> = file_agent_repository.clone();
let checkpoint_repository: Arc<dyn CheckpointRepository> = file_agent_repository;
let llm_api_log_store = app_handle.state::<Arc<LlmApiLogStore>>().inner().clone();
let chat_completion_repository: Arc<dyn ChatCompletionRepository> =
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,

View file

@ -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<String>,
#[serde(default)]
pub prompt_snapshot: Option<Value>,
#[serde(default)]
pub generation_intent: Option<Value>,
#[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<u64>,
#[serde(default)]
pub before_seq: Option<u64>,
#[serde(default = "default_event_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentReadEventsResultDto {
pub events: Vec<AgentRunEvent>,
}
#[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<Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentFinalizeCommitDto {
pub run_id: String,
#[serde(default)]
pub message_id: Option<String>,
}
#[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
}

View file

@ -1,4 +1,5 @@
// Data Transfer Objects
pub mod agent_dto;
pub mod background_dto;
pub mod bootstrap_dto;
pub mod character_dto;

View file

@ -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<bool>;
pub struct AgentRuntimeService {
run_repository: Arc<dyn AgentRunRepository>,
workspace_repository: Arc<dyn WorkspaceRepository>,
checkpoint_repository: Arc<dyn CheckpointRepository>,
chat_completion_service: Arc<ChatCompletionService>,
active_runs: RwLock<HashMap<String, watch::Sender<bool>>>,
}
impl AgentRuntimeService {
pub fn new(
run_repository: Arc<dyn AgentRunRepository>,
workspace_repository: Arc<dyn WorkspaceRepository>,
checkpoint_repository: Arc<dyn CheckpointRepository>,
chat_completion_service: Arc<ChatCompletionService>,
) -> Self {
Self {
run_repository,
workspace_repository,
checkpoint_repository,
chat_completion_service,
active_runs: RwLock::new(HashMap::new()),
}
}
pub async fn start_run(
self: &Arc<Self>,
dto: AgentStartRunDto,
) -> Result<AgentRunHandleDto, ApplicationError> {
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<AgentRunHandleDto, ApplicationError> {
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<AgentReadEventsResultDto, ApplicationError> {
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<AgentWorkspaceFileDto, ApplicationError> {
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<AgentCommitDraftDto, ApplicationError> {
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::<Result<Vec<_>, _>>()?;
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<AgentCommitResultDto, ApplicationError> {
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<Self>,
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<AgentRun, ApplicationError> {
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<crate::domain::models::agent::AgentRunEvent, ApplicationError> {
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<String, ApplicationError> {
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<String, ApplicationError> {
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<ChatCompletionGenerateRequestDto, ApplicationError> {
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<Map<String, Value>> {
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<String, Value>) -> 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<String, ApplicationError> {
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<String> {
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<String, Value>) -> 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);
}
}

View file

@ -1,4 +1,5 @@
// Application services
pub mod agent_runtime_service;
pub mod avatar_service;
pub mod background_service;
pub mod character_service;

View file

@ -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<String>,
pub status: AgentRunStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<Utc>,
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<str>) -> Result<Self, DomainError> {
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<Utc>,
pub input: WorkspaceInputManifest,
pub artifacts: Vec<ArtifactSpec>,
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<String>,
#[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<Utc>,
pub reason: String,
pub event_seq: u64,
pub files: Vec<CheckpointFile>,
}
#[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::<AgentChatRef>(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());
}
}

View file

@ -1,4 +1,5 @@
// Domain models
pub mod agent;
pub mod avatar;
pub mod background;
pub mod character;

View file

@ -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<u64>,
pub before_seq: Option<u64>,
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<AgentRun, DomainError>;
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<AgentRunEvent, DomainError>;
async fn read_events(
&self,
run_id: &str,
query: AgentRunEventReadQuery,
) -> Result<Vec<AgentRunEvent>, DomainError>;
}

View file

@ -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<Checkpoint, DomainError>;
}

View file

@ -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;

View file

@ -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<WorkspaceManifest, DomainError>;
async fn write_text(
&self,
run_id: &str,
path: &WorkspacePath,
text: &str,
) -> Result<WorkspaceFile, DomainError>;
async fn read_text(
&self,
run_id: &str,
path: &WorkspacePath,
) -> Result<WorkspaceFile, DomainError>;
}

View file

@ -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<Mutex<()>>,
checkpoint_lock: Arc<Mutex<()>>,
}
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<PathBuf, DomainError> {
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<PathBuf, DomainError> {
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<PathBuf, DomainError> {
let run = self.load_run(run_id).await?;
self.run_dir(&run)
}
async fn write_json_atomic<T: Serialize + ?Sized>(
path: &Path,
value: &T,
) -> Result<(), DomainError> {
write_json_file(path, value).await
}
async fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, DomainError> {
read_json_file(path).await
}
async fn safe_workspace_path(
&self,
run_id: &str,
workspace_path: &WorkspacePath,
create_parent: bool,
) -> Result<PathBuf, DomainError> {
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<Vec<AgentRunEvent>, 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::<AgentRunEvent>(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<AgentRun, DomainError> {
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<AgentRunEvent, DomainError> {
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<Vec<AgentRunEvent>, 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<WorkspaceManifest, DomainError> {
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<WorkspaceFile, DomainError> {
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<WorkspaceFile, DomainError> {
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<Checkpoint, DomainError> {
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<WorkspaceFile, DomainError> {
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");
}
}

View file

@ -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;

View file

@ -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<AppState>>,
) -> Result<AgentRunHandleDto, CommandError> {
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<AppState>>,
) -> Result<AgentRunHandleDto, CommandError> {
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<AppState>>,
) -> Result<AgentReadEventsResultDto, CommandError> {
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<AppState>>,
) -> Result<AgentWorkspaceFileDto, CommandError> {
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<AppState>>,
) -> Result<AgentCommitDraftDto, CommandError> {
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<AppState>>,
) -> Result<AgentCommitResultDto, CommandError> {
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"))
}

View file

@ -1,4 +1,5 @@
// Tauri commands
pub mod agent_commands;
pub mod avatar_commands;
pub mod background_commands;
pub mod bootstrap_commands;

View file

@ -234,6 +234,13 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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,

321
src/tauri/main/api/agent.js Normal file
View file

@ -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<any> }} 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 });
}

View file

@ -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) {

74
src/types.d.ts vendored
View file

@ -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<any>;
finalizeCommit: (input: { runId: string; messageId?: string | number }) => Promise<any>;
approveToolCall: () => never;
listRuns: () => never;
readDiff: () => never;
rollback: () => never;
};
type TauriTavernFrontendLogsApi = {
list: (options?: { limit?: number }) => Promise<TauriTavernFrontendLogEntry[]>;
subscribe: (
@ -198,6 +271,7 @@ type TauriTavernLayoutApi = {
type TauriTavernHostApi = {
chat?: TauriTavernChatApi;
agent?: TauriTavernAgentApi;
layout?: TauriTavernLayoutApi;
dev?: TauriTavernDevApi;
worldInfo?: TauriTavernWorldInfoApi;