mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
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:
parent
5e3430ceda
commit
f76139f961
38 changed files with 6704 additions and 2 deletions
311
docs/API/Agent.md
Normal file
311
docs/API/Agent.md
Normal 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
144
docs/API/MCP.md
Normal 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。
|
||||
|
||||
|
|
@ -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
204
docs/Agent/LlmGateway.md
Normal 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
203
docs/Agent/McpSkill.md
Normal 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 最小实现应晚于内置工具系统稳定之后。
|
||||
|
||||
416
docs/Agent/ProfilesAndPreset.md
Normal file
416
docs/Agent/ProfilesAndPreset.md
Normal 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
23
docs/Agent/README.md
Normal 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`。
|
||||
395
docs/Agent/RunEventJournal.md
Normal file
395
docs/Agent/RunEventJournal.md
Normal 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 基础。
|
||||
|
||||
222
docs/Agent/TestingStrategy.md
Normal file
222
docs/Agent/TestingStrategy.md
Normal 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
407
docs/Agent/ToolSystem.md
Normal 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
427
docs/Agent/Workspace.md
Normal 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
410
docs/AgentArchitecture.md
Normal 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
437
docs/AgentContract.md
Normal 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
433
docs/AgentImplementPlan.md
Normal 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 这三个地基,任何工具生态都会快速堆成难维护的临时补丁。
|
||||
65
docs/CurrentState/AgentFramework.md
Normal file
65
docs/CurrentState/AgentFramework.md
Normal 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/角色卡/世界书直接写入。
|
||||
|
|
@ -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/`
|
||||
|
|
|
|||
|
|
@ -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”,应保持**小而稳定**;不要把内部实现对象整个暴露出去。
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
128
src-tauri/src/application/dto/agent_dto.rs
Normal file
128
src-tauri/src/application/dto/agent_dto.rs
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// Data Transfer Objects
|
||||
pub mod agent_dto;
|
||||
pub mod background_dto;
|
||||
pub mod bootstrap_dto;
|
||||
pub mod character_dto;
|
||||
|
|
|
|||
876
src-tauri/src/application/services/agent_runtime_service.rs
Normal file
876
src-tauri/src/application/services/agent_runtime_service.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// Application services
|
||||
pub mod agent_runtime_service;
|
||||
pub mod avatar_service;
|
||||
pub mod background_service;
|
||||
pub mod character_service;
|
||||
|
|
|
|||
297
src-tauri/src/domain/models/agent/mod.rs
Normal file
297
src-tauri/src/domain/models/agent/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// Domain models
|
||||
pub mod agent;
|
||||
pub mod avatar;
|
||||
pub mod background;
|
||||
pub mod character;
|
||||
|
|
|
|||
35
src-tauri/src/domain/repositories/agent_run_repository.rs
Normal file
35
src-tauri/src/domain/repositories/agent_run_repository.rs
Normal 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>;
|
||||
}
|
||||
15
src-tauri/src/domain/repositories/checkpoint_repository.rs
Normal file
15
src-tauri/src/domain/repositories/checkpoint_repository.rs
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
38
src-tauri/src/domain/repositories/workspace_repository.rs
Normal file
38
src-tauri/src/domain/repositories/workspace_repository.rs
Normal 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>;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
96
src-tauri/src/presentation/commands/agent_commands.rs
Normal file
96
src-tauri/src/presentation/commands/agent_commands.rs
Normal 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"))
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// Tauri commands
|
||||
pub mod agent_commands;
|
||||
pub mod avatar_commands;
|
||||
pub mod background_commands;
|
||||
pub mod bootstrap_commands;
|
||||
|
|
|
|||
|
|
@ -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
321
src/tauri/main/api/agent.js
Normal 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 });
|
||||
}
|
||||
3
src/tauri/main/bootstrap.js
vendored
3
src/tauri/main/bootstrap.js
vendored
|
|
@ -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
74
src/types.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue