Merge pull request #98 from Darkatse/dev

Bump version to 2.1.1
This commit is contained in:
C.J. Wu 2026-06-19 11:51:15 -04:00 • committed by GitHub
commit cf7fce2e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
429 changed files with 47516 additions and 10019 deletions

View file

@ -48,8 +48,8 @@ src/
├── tauri-main.js # 轻量引导入口
├── init.js # 启动编排器
├── lib.js # 库门面(ESM 导出)
├── dist/lib.core.bundle.js # webpack 构建的核心依赖包(启动必需)
├── dist/lib.optional.bundle.js # webpack 构建的可选依赖包(按需加载)
├── dist/lib.core.bundle.js # Rspack 构建的核心依赖包(启动必需)
├── dist/lib.optional.bundle.js # Rspack 构建的可选依赖包(按需加载)
└── tauri/main/
├── bootstrap.js # 组合根(composition root)
├── context.js # 兼容 shim(re-export `context/index`)
@ -75,7 +75,7 @@ src/
前置要求:
- Rust stable
- Node.js 18+
- Node.js 22.12+
- pnpm
- Tauri CLI
@ -91,7 +91,7 @@ pnpm install
```bash
pnpm run check # guardrails + host kernel 类型检查(推荐每次改动后先跑)
pnpm run web:build # 构建前端资源包(webpack)
pnpm run web:build # 构建前端资源包(Rspack)
pnpm run dev # 桌面开发模式(等价 tauri:dev)
pnpm run tauri:dev # 桌面开发模式
pnpm run tauri:build # 构建桌面发行包

View file

@ -46,8 +46,8 @@ src/
├── tauri-main.js # thin bootstrap entry
├── init.js # startup orchestrator
├── lib.js # library facade (ESM exports)
├── dist/lib.core.bundle.js # webpack-built core vendor bundle (startup-critical)
├── dist/lib.optional.bundle.js # webpack-built optional vendor bundle (on-demand)
├── dist/lib.core.bundle.js # Rspack-built core vendor bundle (startup-critical)
├── dist/lib.optional.bundle.js # Rspack-built optional vendor bundle (on-demand)
└── tauri/main/
├── bootstrap.js # composition root
├── context.js # compatibility shim (re-export `context/index`)
@ -73,7 +73,7 @@ src/
Prerequisites:
- Rust stable
- Node.js 18+
- Node.js 22.12+
- pnpm
- Tauri CLI
@ -89,7 +89,7 @@ Common commands:
```bash
pnpm run check # guardrails + host-kernel type checks (recommended)
pnpm run web:build # build frontend bundles (webpack)
pnpm run web:build # build frontend bundles (Rspack)
pnpm run dev # desktop dev mode (alias of tauri:dev)
pnpm run tauri:dev # desktop dev mode
pnpm run tauri:build # build desktop installers

View file

@ -2,7 +2,7 @@
本文档是 Agent Host ABI 当前参考。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。
状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill tools、workspace 读改工具循环、前端 dryRun adapter,以及 Agent Profile 独立 preset / 独立 model 的 Frontend PromptAssemblyBroker 链路。Agent System 扩展开关开启时,普通发送、`/trigger`、regenerate 与 overswipe 新候选生成会通过 Legacy Generate 兼容桥启动 Agent;普通切换已有 swipe 候选不启动 Agent。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval/listRuns 等未来设计。
状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill tools、workspace 读改工具循环、前端 dryRun adapter、Agent run history listing、Agent run retention facade / dry-run plan preview / manual apply prune / backend auto prune,以及 Agent Profile 独立 preset / 独立 model 的 Frontend PromptAssemblyBroker 链路。Agent System 扩展开关开启时,普通发送、`/trigger`、regenerate 与 overswipe 新候选生成会通过 Legacy Generate 兼容桥启动 Agent;普通切换已有 swipe 候选不启动 Agent。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval 等未来设计。
`provider_state` 是 Rust 后端内部 continuation contract,不是 Host ABI。前端/扩展不应读写 `_tauritavern_provider_state`;需要诊断时通过 run events、`modelResponsePath` 与 LLM API log 观察。
模型回合详情必须通过 `readModelTurn()` 读取后端投影 DTO;前端不解析 `model-responses/` raw JSON。
@ -25,13 +25,33 @@ type TauriTavernAgentApi = {
startRunWithPromptSnapshot(input: AgentStartRunWithPromptSnapshotInput): Promise<AgentRunHandle>;
subscribe(runId: string, handler: (event: AgentRunEvent) => void, options?: AgentSubscribeOptions): TauriTavernHostUnsubscribe;
cancel(runId: string): Promise<AgentRunHandle>;
submitGuidance(input: AgentSubmitGuidanceInput): Promise<AgentSubmitGuidanceResult>;
readEvents(input: AgentReadEventsInput): Promise<AgentReadEventsResult>;
readWorkspaceFile(input: AgentReadWorkspaceFileInput): Promise<AgentWorkspaceFile>;
readModelTurn(input: AgentReadModelTurnInput): Promise<AgentModelTurn>;
pruneChatPersistentStates(input: AgentPruneChatPersistentStatesInput): Promise<AgentPruneChatPersistentStatesResult>;
retention: {
readSettings(): Promise<AgentRunRetentionSettings>;
updateSettings(input: Partial<AgentRunRetentionSettings>): Promise<AgentRunRetentionSettings>;
planPrune(input?: {
retention?: AgentRunPruneRetention | AgentRunRetentionSettings;
detailLimit?: number;
}): Promise<AgentRunPrunePlan>;
applyPrune(input?: {
retention?: AgentRunPruneRetention | AgentRunRetentionSettings;
detailLimit?: number;
}): Promise<AgentRunPruneApplyResult>;
};
profiles: {
list(): Promise<{ profiles: AgentProfileSummary[] }>;
list(): Promise<AgentListProfilesResult>;
load(input: string | { profileId: string }): Promise<{ profile: AgentProfileDefinition | null }>;
diagnose(input: string | { profileId: string }): Promise<AgentProfileHealth>;
resolveSystemPrompt(input?: string | { profileId?: string | null }): Promise<{ agentSystemPrompt: string }>;
repairFile(input: { profileId: string; action: 'delete' | 'normalizeIdentity' }): Promise<void>;
retargetPresetRefs(input: {
from: { apiId: string; name: string };
to: { apiId: string; name: string };
}): Promise<{ updated: number; profileIds: string[] }>;
save(input: AgentProfileDefinition | { profile: AgentProfileDefinition }): Promise<void>;
delete(input: string | { profileId: string }): Promise<void>;
};
@ -44,7 +64,16 @@ type TauriTavernAgentApi = {
};
approveToolCall(): never;
listRuns(): never;
listRuns(input?: {
chatRef?: AgentChatRef;
stableChatId?: string;
statuses?: AgentRunStatus[];
before?: { createdAt: string; runId: string };
limit?: number;
}): Promise<{
runs: AgentRunSummary[];
nextCursor?: { createdAt: string; runId: string };
}>;
readDiff(): never;
rollback(): never;
};
@ -57,7 +86,7 @@ type TauriTavernAgentApi = {
- `startRunFromLegacyGenerate()`:从当前 Legacy Generate dryRun 兼容桥启动。
- `startRunWithPromptSnapshot()`:调用方已经持有 prompt snapshot 时启动。
`approveToolCall()`、`listRuns()`、`readDiff()`、`rollback()` 已预留名称,但当前实现会显式 throw,避免静默降级。
`approveToolCall()`、`readDiff()`、`rollback()` 已预留名称,但当前实现会显式 throw,避免静默降级。
## 3. startRunFromLegacyGenerate
@ -135,8 +164,37 @@ type AgentRunHandle = {
stableChatId: string;
generationType: string;
};
type AgentRunSummary = {
runId: string;
workspaceId: string;
stableChatId: string;
chatRef: AgentChatRef;
generationType: string;
profileId?: string;
skillScopeRefs?: {
preset?: { apiId: string; name: string };
characterId?: string;
};
persistBaseStateId?: string;
inputMessageCount?: number;
presentation: 'foreground' | 'background';
status: AgentRunStatus;
createdAt: string;
updatedAt: string;
commitCount: number;
committedMessage?: {
commitId: string;
messageId: string;
messageIndex?: number;
committedAt: string;
};
terminalAt?: string;
};
```
`AgentRunSummary.committedMessage.messageIndex` 是 host commit 当时的零基消息索引快照,由 `chat_commit_completed.messageId` 派生;它不承诺当前聊天仍在该位置。旧 run 没有可解析 `messageId` 时该字段为空,前端不应显示楼层定位。summary projection 是可从 journal 重建的缓存,只在已经写入 terminal event 的终态 run 上复用/落盘。
身份语义:
- `stableChatId` 是聊天的长期稳定身份。
@ -191,6 +249,43 @@ await agent.cancel(runId);
- Cancel 后不能自动 commit。
- 返回最新 `AgentRunHandle`。
## 6.1 submitGuidance
```ts
await agent.submitGuidance({
runId,
text: "Prefer the quieter ending and keep the character's agency clear.",
clientGuidanceId: "optional-client-correlation-id",
});
```
```ts
type AgentSubmitGuidanceInput = {
runId: string;
text: string;
clientGuidanceId?: string;
};
type AgentSubmitGuidanceResult = {
runId: string;
guidanceId: string;
clientGuidanceId?: string;
status: 'queued';
preview: string;
chars: number;
words: number;
pendingCount: number;
};
```
语义:
- `submitGuidance()` 是 active AgentRun 的 run-scoped 输入通道,不是普通聊天发送,不写入 SillyTavern chat history,也不会启动新的 Generate。
- Runtime 会先写 `user_guidance_submitted`,再将 guidance 放入当前 active run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,pending guidance 会合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。
- 已经发出的 provider request 不会被热修改。若当前模型调用正在进行,guidance 只影响后续模型请求边界。
- `cancel()`、run finish、run failure / partial success 会关闭 mailbox;尚未应用的 guidance 写 `user_guidance_discarded`。
- 空文本、过长文本、非 active run、`finishing` / `cancelling` / terminal run、mailbox 已满都会 fail-fast,不做静默丢弃。
## 7. approveToolCall
当前未实现审批流程;`approveToolCall()` 会显式 throw。
@ -217,10 +312,44 @@ type AgentReadEventsInput = {
afterSeq?: number;
beforeSeq?: number;
limit?: number;
invocationId?: string;
includeTimelineProjection?: boolean;
};
type AgentReadEventsResult = {
events: AgentRunEvent[];
timelineProjection?: AgentRunTimelineProjection;
};
type AgentRunTimelineProjection = {
foregroundInvocationIds: string[];
invocations: AgentRunTimelineInvocation[];
delegationEdges: AgentRunTimelineDelegationEdge[];
};
type AgentRunTimelineInvocation = {
invocationId: string;
parentInvocationId?: string;
profileId: string;
kind: 'root' | 'subagent' | 'handoff';
status: 'created' | 'running' | 'completed' | 'failed' | 'cancelled' | 'transferred';
exitPolicy: 'run_finish_allowed' | 'task_return_required';
createdAt: string;
updatedAt: string;
};
type AgentRunTimelineDelegationEdge = {
taskId: string;
sourceInvocationId: string;
targetInvocationId: string;
targetProfileId: string;
workspaceKey: string;
continuation: 'return_to_parent' | 'transfer_control';
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
resultRef?: string;
error?: string;
createdAt: string;
updatedAt: string;
};
```
@ -228,6 +357,10 @@ type AgentReadEventsResult = {
- `limit` 必须有上限。
- 移动端 UI 不应一次读取完整巨大 journal;推荐先用 `beforeSeq` 读取最新页,再在用户向上回看时继续用 `beforeSeq` 补拉更早事件,同时用 `afterSeq` 追新。
- `invocationId` 可选。传入时,后端先按 invocation 归属过滤事件流,再应用 `afterSeq` / `beforeSeq` / `limit`;因此分页窗口表达的是该 invocation 自己的历史页,不是全局页的二次筛选。该能力用于 SubAgent / handoff 局部 timeline,避免移动端为查看一个子 Agent 搬运完整 run journal。
- 新事件使用 canonical event scope:`payload.eventScope.invocationId` 表示主归属 invocation,`payload.eventScope.relatedInvocationIds` 表示相关 invocation。`readEvents({ invocationId })` 会返回主归属或相关列表命中的事件;旧 journal 没有 canonical scope 时,后端仅为兼容读取历史字段。
- `timelineProjection` 仅在 `includeTimelineProjection = true` 时返回。它是面向 Timeline UI 的轻量结构投影,不是 journal event;它来自 run 的 invocation/task repository,用于在分页事件缺少 SubAgent task 或 handoff 起点时仍能识别 run 内 Agent graph。普通 polling / subscribe 不应请求该投影。
- `timelineProjection` 不受 `invocationId` 过滤影响;调用方可以同时读取局部事件页和全局 Agent graph。
- 当前暂不返回 `hasMoreBefore/hasMoreAfter`。
## 9. readWorkspaceFile
@ -278,6 +411,13 @@ type AgentModelTurn = {
totalWords: number;
truncated: boolean;
};
narration?: {
source: 'assistantText';
text: string;
totalChars: number;
totalWords: number;
truncated: boolean;
} | null;
reasoning: Array<{
source: string;
text: string;
@ -293,16 +433,120 @@ type AgentModelTurn = {
};
```
`assistant.text` 与 `reasoning[].text` 会按 `maxChars` 截断;`totalChars` / `totalWords` 始终表示截断前完整文本的字词统计。
`narration` 是带工具调用的模型回合中可展示给用户的 assistant visible text 投影,用于表达模型在工具调用前后的简短叙述、意图或转场。它不是 runtime status,不从 reasoning / thinking / thought 提取,也不解析 assistant text 内部的 JSON 字段。无工具调用或空文本时为 `null` 或缺省。
`assistant.text`、`narration.text` 与 `reasoning[].text` 会按 `maxChars` 截断;`totalChars` / `totalWords` 始终表示截断前完整文本的字词统计。
`round` 必须大于 0。`maxChars` 省略时由后端使用默认上限;传入时必须大于 0。
`invocationId` 省略时读取 root invocation;读取 SubAgent / handoff invocation 的模型回合时必须传入对应 invocation id。
该方法返回面向 UI 的白名单投影:assistant 输出、可见/摘要化 reasoning、工具调用摘要与 provider 摘要。它不会暴露完整 raw response、provider-private native continuation、签名或 encrypted reasoning。需要完整诊断时仍使用 run workspace 中的 `modelResponsePath` 与 LLM API log。
该方法返回面向 UI 的白名单投影:assistant 输出、narration、可见/摘要化 reasoning、工具调用摘要与 provider 摘要。它不会暴露完整 raw response、provider-private native continuation、签名或 encrypted reasoning。需要完整诊断时仍使用 run workspace 中的 `modelResponsePath` 与 LLM API log。
## 11. profiles / promptAssembly / tools
## 11. pruneChatPersistentStates
`profiles.*` 是当前 Agent Profile 管理入口。`profiles.list()` 的 summary 包含 `directRunnable`,供前端区分可直接启动的 root-run Profile 与只能作为 SubAgent / handoff target 的 Profile。Profile JSON 中的 `preset.mode = "ref"` 与 `model.mode = "connectionRef"` 会影响 prompt assembly 和最终模型连接;`model.mode = "requiresConfiguration"` 表示 Profile 需要本机重新选择模型,可保存但不可运行;`run.directRunnable = false` 表示该 Profile 不能直接启动,只能通过已实现的非直接入口运行(当前为 return-mode SubAgent)。前端“可作为子 Agent”会写入该非直接运行语义。保存时无效 schema 必须 fail-fast。
```ts
type AgentPruneChatPersistentStatesInput = {
chatRef?: AgentChatRef;
stableChatId?: string;
candidateStateIds: string[];
};
type AgentPruneChatPersistentStatesResult = {
workspaceId: string;
removedStateIds: string[];
};
```
`pruneChatPersistentStates()` 是消息/Swipe 删除后的 Host cleanup 入口,不是全量 GC。调用方必须显式传入从被删除消息或被删除 swipe metadata 中收集到的 `candidateStateIds`;缺失、非数组或空字符串 state id 必须 reject。
后端会重新读取当前完整 chat payload,收集仍被当前聊天消息或 swipe 引用的 retained state ids,只删除 `candidateStateIds - retainedStateIds`。未被本次删除动作明确列为 candidate 的孤儿 state 必须保留,避免第三方总结、隐藏楼层、windowed save 或 metadata 损坏把整个 `persistent-states` 目录误清空。
当前只支持 character chat;group chat persistent state prune 会 fail-fast。删除整个 chat / group chat 时,生命周期服务仍删除对应的完整 Agent chat workspace,这不是本方法的职责。
## 12. retention
```ts
type AgentRunPruneRetention = {
keepRecentTerminalRuns: number;
keepFullRecentRuns: number;
};
type AgentRunRetentionSettings = AgentRunPruneRetention & {
autoPruneEnabled: boolean;
};
type AgentRunPruneCandidate = {
runId: string;
workspaceId: string;
stableChatId: string;
chatRef: AgentChatRef;
status: AgentRunStatus;
createdAt: string;
updatedAt: string;
action: 'slim_heavy_artifacts' | 'delete_run';
reason: 'outside_full_retention_window' | 'outside_history_retention_window';
fileCount: number;
byteCount: number;
};
type AgentRunPruneBlockedRun = AgentRunPruneCandidate & {
blockReason: 'active_run' | 'missing_terminal_event' | 'invalid_journal' | 'invalid_storage';
message?: string;
};
type AgentRunPruneFailedRun = AgentRunPruneCandidate & {
message: string;
};
type AgentRunPrunePlan = {
retention: AgentRunPruneRetention;
detailLimit: number;
terminalRunCount: number;
nonTerminalRunCount: number;
blockedRunCount: number;
fullRetainedRunCount: number;
coreRetainedRunCount: number;
slimCandidateCount: number;
deleteCandidateCount: number;
totalSlimFileCount: number;
totalSlimByteCount: number;
totalDeleteFileCount: number;
totalDeleteByteCount: number;
totalCandidateFileCount: number;
totalCandidateByteCount: number;
candidates: AgentRunPruneCandidate[];
blockedRuns: AgentRunPruneBlockedRun[];
candidateDetailsTruncated: boolean;
blockedDetailsTruncated: boolean;
};
type AgentRunPruneApplyResult = {
retention: AgentRunPruneRetention;
detailLimit: number;
slimmedRunCount: number;
deletedRunCount: number;
failedRunCount: number;
removedFileCount: number;
removedByteCount: number;
failedDetailsTruncated: boolean;
failedRuns: AgentRunPruneFailedRun[];
afterPlan: AgentRunPrunePlan;
};
```
`retention.readSettings()` 读取 `tauritavern-settings.agent.retention` 并以 Host ABI 的 camelCase 形态返回。`autoPruneEnabled` 默认 `false`,表示 Rust 后端在 TauriTavern 进程运行期间按当前保留策略进行周期性 Agent run 清理。`retention.updateSettings()` 只保存策略并唤醒后端调度器重读配置,不同步执行清理;两个数量必须是 `0..10000` 的整数,且 `keepFullRecentRuns <= keepRecentTerminalRuns`。
`retention.planPrune()` 是前端访问 `plan_agent_run_prune(dto)` 的 facade。它只返回 dry-run plan,可使用当前设置,也可传入一次性 `retention` override;override 中的 `autoPruneEnabled` 不参与规划,真正的候选只由两个保留数量决定;`detailLimit` 只限制返回明细,不影响 totals。`retention.applyPrune()` 访问 `apply_agent_run_prune(dto)`,后端会用同一 retention 重新规划全量候选再执行,不信任前端预览列表;同一服务实例内 apply 会串行执行,避免并发清理同一批 run 造成假失败。单个 run 执行失败会进入 `failedRuns` 并继续处理后续 candidate,结构性规划错误仍 fail-fast。结果包含 caller `detailLimit` 下的 `afterPlan`,供 UI 展示清理后的事实状态。
自动清理由 Rust `AgentRunRetentionAutomationService` 拥有生命周期,不依赖 Agent System 面板打开,也不使用前端 `setInterval`。当前策略是启动/设置变更后延迟一次执行,之后固定周期维护;它复用 `apply_agent_run_prune` 的后端规划与执行路径,不定义第二套删除规则。
## 13. profiles / promptAssembly / tools
`profiles.*` 是当前 Agent Profile 管理入口。`profiles.list()` 的 summary 包含 `directRunnable`,供前端区分可直接启动的 root-run Profile 与只能作为 SubAgent / handoff target 的 Profile。列表扫描会返回可加载 Profile,同时把单个本地 Profile JSON 文件的内容损坏放入 `issues`,避免一个坏文件阻塞整个面板;`invalidJson` 建议用户确认后删除,`invalidFileIdentity` 可通过 `profiles.repairFile({ profileId, action: "normalizeIdentity" })` 尝试规范化文件 header / identity 键(`schemaVersion`、`kind`、`id`),其它 Profile 内容保持原样。若修复后整份 JSON 仍不能按 Agent Profile 契约读取,则拒绝写回并报告错误。`invalidProfile` 表示主体结构损坏,需要手动修复,不会自动替换为默认 Profile。目录读取失败、非法文件名等仓储契约错误仍然 fail-fast。Profile JSON 中的 `preset.mode = "ref"` 与 `model.mode = "connectionRef"` 会影响 prompt assembly 和最终模型连接;`model.mode = "requiresConfiguration"` 表示 Profile 需要本机重新选择模型,可保存但不可运行;`run.directRunnable = false` 表示该 Profile 不能直接启动,只能通过已实现的非直接入口运行(当前为 return-mode SubAgent)。前端“可作为子 Agent”会写入该非直接运行语义。保存时无效 schema 必须 fail-fast。
`profiles.retargetPresetRefs()` 是管理态引用迁移 API,用于 preset rename 生命周期。它只更新 `preset.mode = "ref"` 且精确匹配 `from` 的 Profile;`to` preset 必须已经存在,且不能跨 `apiId` retarget。`from` 可以已经 dangling。该 API 不会让运行态静默降级;运行和 prompt assembly 仍按 Profile 契约 fail-fast。该操作逐个 Profile 写回,失败后可用同一请求重试;preset rename 流程必须在依赖迁移完成后再删除旧 preset。
`profiles.diagnose()` 是管理态健康检查 API。它面向“Profile 可加载但外部资源引用不可用”的情况,返回结构化 diagnostics,而不是让面板从异常字符串推断。该 API 不替代运行态 resolver:`promptAssembly.prepare()`、root run 与 SubAgent 仍按严格 Profile / preset / model contract fail-fast,且不会静默回退当前 UI preset/model。当前第一期覆盖 preset ref 缺失或不支持、`model.requiresConfiguration`、LLM Connection 缺失或无效;腐坏 JSON、文件 identity 错误等 storage health 仍由 `profiles.list().issues` 表达。
```ts
type AgentProfileSummary = {
@ -311,6 +555,49 @@ type AgentProfileSummary = {
description?: string;
directRunnable: boolean;
};
type AgentProfileStorageIssue = {
profileId: string;
fileName: string;
kind: 'invalidJson' | 'invalidFileIdentity' | 'invalidProfile';
recommendedAction?: 'delete' | 'normalizeIdentity';
message: string;
};
type AgentListProfilesResult = {
profiles: AgentProfileSummary[];
issues: AgentProfileStorageIssue[];
};
type AgentProfileHealth = {
profileId: string;
previewAvailable: boolean;
promptAssemblyAvailable: boolean;
directRunAvailable: boolean;
subAgentAvailable: boolean;
diagnostics: AgentProfileDiagnostic[];
};
type AgentProfileDiagnostic = {
code: string;
severity: 'error';
path: string;
message: string;
resource?: {
kind: 'preset' | 'llmConnection' | 'model';
apiId?: string;
name?: string;
id?: string;
modelId?: string;
};
blocks?: Array<'preview' | 'promptAssembly' | 'directRun' | 'subAgent'>;
repairActions?: Array<
'selectPreset'
| 'selectModel'
| 'setModelRequiresConfiguration'
| 'openJsonEditor'
>;
};
```
`promptAssembly.prepare()` 调用 Rust `prepare_agent_prompt_assembly`,返回 `currentPromptSnapshot` 或 `frontendPromptAssembly`。`promptAssembly.buildSnapshot()` 是前端 broker:它只能使用 `frozenRunInputSnapshot` 内的 `promptInputs`、`worldInfoActivation`、`macroContext`,并调用真实 SillyTavern PromptManager 组装 `promptSnapshot.chatCompletionPayload`。该 API 是 Agent orchestration 内部边界,不是第三方扩展任意改写 prompt 的入口。
@ -332,7 +619,7 @@ type AgentToolSpec = {
`tools.list()` 返回当前后端 Agent Tool Registry 的 canonical specs。Profile 面板可以用它编辑 `tools.toolDescriptions`,但不得把返回值当作可修改的 registry。
## 12. readDiff
## 14. readDiff
当前未实现 diff;`readDiff()` 会显式 throw。
@ -358,7 +645,7 @@ type AgentDiff = {
第一期可以只支持文本 artifact 的 diff。
## 13. rollback
## 15. rollback
当前未实现 rollback;`rollback()` 会显式 throw。
@ -375,7 +662,7 @@ type AgentRollbackInput = {
- `workspace`:只恢复 run workspace,不修改 chat。
- `committed-message`:重组 artifact 并修改已提交聊天消息,必须走 chat 保存契约。
## 14. commit
## 16. commit
```ts
type AgentCommitInput = {
@ -400,7 +687,7 @@ Chat commit 不是公开 Host API 方法,而是 Agent tool 与 host bridge 的
- `append` 在本 run 尚无 commit 时不会报错,会创建本 run 的消息楼层。
- 前台 run 在 `workspace.finish` 前必须至少成功 commit 一次;后台 run 可无 chat commit 完成。
## 15. Event Envelope
## 17. Event Envelope
```ts
type AgentRunEvent = {
@ -418,7 +705,7 @@ type AgentRunEvent = {
Agent event 不等同 SillyTavern `eventSource` 事件,不得伪装成 `GENERATION_*` 或 `TOOL_CALLS_*`。
## 16. Errors
## 18. Errors
错误建议结构:
@ -448,29 +735,33 @@ commit.cursor_integrity
commit.save_failed
```
## 17. Rust Command
## 19. Rust Command
```text
start_agent_run(dto)
list_agent_tool_specs()
cancel_agent_run(dto)
list_agent_runs(dto)
plan_agent_run_prune(dto)
apply_agent_run_prune(dto)
read_agent_run_events(dto)
read_agent_workspace_file(dto)
resolve_agent_chat_commit(dto)
```
Command 层必须是薄封装。Agent loop 不写在 command 内。
`plan_agent_run_prune(dto)` 是后端 dry-run command:它按 `tauritavern-settings.agent.retention` 或调用方传入的一次性 retention override 计算 `slim_heavy_artifacts` / `delete_run` 候选和 files/bytes,不执行删除。`slim_heavy_artifacts` 使用后端 Agent run storage class 统计,分类边界与 TT-Sync 的 Agent run dataset 词汇对齐,但不读取同步 profile 或 dataset selection。`dto.detailLimit` 控制返回的 candidate/blocked 明细数量,计数与 bytes totals 不受截断影响;`blockedRuns` 会显式报告 active run、缺失 terminal event、journal/storage 异常等不能安全清理的对象。Command 层必须是薄封装。Agent loop 不写在 command 内。
`apply_agent_run_prune(dto)` 使用同一 planner 的 execution 模式重新生成执行计划,并以完整候选集执行 `slim_heavy_artifacts` / `delete_run`;同一服务实例内 apply 串行化。`slim_heavy_artifacts` 仅删除 run workspace 内非核心 history 的 artifact,保留 `run.json`、`events.jsonl`、run index 与 run summary projection;`delete_run` 删除 run workspace、run index 与 run summary projection。稳定 `persistent-states/` 不属于 run prune 范围。执行结果会返回删除文件/字节统计、失败 run 明细和执行后的 dry-run plan。
后续命令:
```text
approve_agent_tool_call(dto)
list_agent_runs(chat_ref)
read_agent_diff(dto)
rollback_agent_run(dto)
```
## 18. Compatibility
## 20. Compatibility
Agent Mode off:
@ -485,7 +776,7 @@ Agent Mode on:
- dryRun 不返回 payload;Agent adapter 通过事件捕获 payload。
- Agent tool loop 不递归 `Generate()`。
## 17. 当前工具与手动验证
## 21. 当前工具与手动验证
当前开放十四个非 delegation 内建工具:

View file

@ -71,6 +71,20 @@ Agent Profile 的持久化字段仍然是:
Profile 不保存 Connection Manager 的 `modelTargetId`。Profile 面板可以把用户保存的 Model Target 物化为一个 LLM Connection,再把 Profile 指向 `connectionRef + modelId`。这样 runtime 只依赖 Agent domain 的 LLM Connection contract,Connection Manager 只是 UI 输入来源。
## Connection Manager Model Target 生命周期
Agent System 负责把 Connection Manager 中的 chat-completion Model Target 同步为 `id = "model-target-" + target.id` 的 LLM Connection:
- Agent System 启动时会 reconcile 当前保存的 Model Target,并覆盖写入对应 LLM Connection。
- Model Target 创建或更新后,会立即重新物化对应 LLM Connection;更新 API key 时,新的 `secretRef.id` 因此会进入 Agent domain 的 connection definition。
- 启动 reconcile 或 Model Target 更新无法物化时,Agent System 会删除对应 `model-target-*` LLM Connection,让 Profile 诊断和运行按 missing connection fail-fast,而不是继续使用旧连接。
- Profile 保存前会重新读取当前 Model Target 列表,再按 `connectionRef + modelId` 找到对应 target 并物化,避免打开面板后的旧快照覆盖新 connection。
- Agent run 启动前会对当前 Profile 的 `model-target-*` binding 再执行一次同样的物化,确保 prompt assembly 与 runtime 看到 Connection Manager 中最新的 endpoint/provider/API key;该步骤按 `connectionRef` 找源 Model Target,不改写 Profile 的 `modelId`。
- 删除 Model Target 不会自动删除已经物化的 LLM Connection。Profile 是否继续可运行由 `connectionRef` 指向的 LLM Connection 是否存在决定,避免 UI 清理操作隐式破坏已有 Profile。
- `modelId` 属于 Profile binding,不属于 LLM Connection。更新 Model Target 的模型名不会静默改写已有 Profile;需要用户在 Profile 面板重新选择该 Model Target 才会采纳新的 `modelId`。
运行中 invocation 不热替换已解析的 model binding;新的 LLM Connection 会在后续 profile/model binding resolution 中生效。
当 Profile 被导出或嵌入到 Preset/Character 时,`connectionRef + modelId` 必须改写为:
```json

View file

@ -30,10 +30,10 @@ const api = host?.api;
- `api.extension.store`
- 面向需要**全局持久化**的扩展(不绑定 chat)。
- 提供 Extension KV JSON + Blob 存储,支持多 table。
- `api.agent`(已落地 canonical model IR、provider_state continuation、上下文只读工具与 workspace 读改工具循环)
- `api.agent`(已落地 canonical model IR、provider_state continuation、上下文只读工具、workspace 读改工具循环与 run history listing)
- 面向 Agent Run / timeline / workspace / checkpoint / commit。
- 当前提供启动 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read、skill list/read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。
- approval、listRuns、readDiff、rollback 仍是后续工作;当前入口显式 throw。
- 当前提供启动 run、列出历史 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read、skill list/read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。
- approval、readDiff、rollback 仍是后续工作;当前入口显式 throw。
- `api.llmConnections`
- 面向 Agent Profile / 扩展侧的 LLM 连接定义管理。
- 当前提供 list、load、save、delete;Profile 通过 `connectionRef + modelId` 引用连接,不直接依赖 Connection Manager 的 Model Target id。

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

@ -0,0 +1,427 @@
# TauriTavern Agent Handoff Runtime
本文档记录当前 `agent.handoff` 的实现基线、核心流程、数据结构、模型可见语言边界与代码定位。后续开发 handoff 链、profile routing、task cancel 或多 Agent timeline 前,应先读本文,再读 `SubAgent.md`、`ToolSystem.md` 与 `PromptAssembly.md`。
当前状态截至 2026-06-06:handoff MVP 已落地。root / handoff foreground owner 可以把同一个 `AgentRun` 的下一阶段交给目标 Agent。handoff 使用 `AgentTaskRecord + AgentInvocation` 的统一 delegation 模型,但不进入后台 scheduler,也不会把结果返回给调用方;executor 会在当前 invocation 转为 `Transferred` 后继续运行目标 `Handoff` invocation。
## 1. 设计目标
Handoff 解决的是写作协作中的接力问题:
```text
root writer drafts and commits
-> handoff to editor
editor revises and commits
-> handoff to finalizer
finalizer verifies, commits, finish
```
它不同于 return-mode SubAgent:
| 能力 | `agent.delegate` | `agent.handoff` |
| --- | --- | --- |
| 模型意图 | 请另一个 Agent 做一个任务,并把结果交还给你 | 请另一个 Agent 接手下一阶段 |
| continuation | `ReturnToParent` | `TransferControl` |
| 执行方式 | run-scoped scheduler 后台 worker | executor 前台串接 |
| 调用方后续 | 可继续工作,可 `agent.await` | 成功后当前 invocation 结束为 `Transferred` |
| 结果回流 | `task.return` result capsule | 不回流给调用方 |
| chat commit | child 不可 commit | handoff owner 可 commit / finish |
对 TauriTavern 来说,handoff 的价值不是抢占式调度,而是让创作者定义不同身份、提示词、preset、模型和工具权限的 Agent,并让这些 Agent 按写作阶段接力。已经 commit 过的 Agent 仍允许 handoff;最终 Agent 可以继续修订、再次 commit 并 `workspace.finish`。
## 2. 核心不变量
- Handoff 仍在同一个 `AgentRun` 内运行,共享 run workspace、journal、cancel、checkpoint、commit ledger 与 persistent projection。
- 每个 handoff stage 是独立 `AgentInvocation`,拥有自己的 provider session id、target Profile、工具面、Skill 解析与 prompt assembly。
- `agent.handoff` 是模型可见工具,`TransferControl` 是 runtime 内部 continuation。不要把 `continuation`、`invocationId` 或 provider state 暴露给模型参数。
- 当前 Agent 成功 handoff 后必须停止工具调用。runtime 将当前 invocation 记为 `Transferred`。
- Handoff task 不进入 `AgentTaskScheduler`。scheduler 只运行 `ReturnToParent` child tasks。
- Handoff 前当前 invocation 不能有未完成的 return-mode delegated task。MVP 只支持 `pendingTaskPolicy = denyIfPending`。
- Handoff owner 使用 `RunFinishAllowed`,可根据 Profile 工具面继续 `workspace.commit` / `workspace.finish` / `agent.handoff`。
- Tool result 不写入 SillyTavern chat 楼层。最终聊天输出仍只通过 `workspace.commit` 进入 host commit bridge。
## 3. 模型可见工具
当前 `agent.handoff` ToolSpec 位于:
```text
src-tauri/src/application/services/agent_tools/agent/specs.rs
```
模型看到的是调用 Agent 视角的工具:
```json
{
"agentId": "final-editor",
"handoff": {
"title": "Final revision pass",
"reason": "I have drafted and committed the main scene; a revision-focused Agent should polish it.",
"objective": "Revise output/main.md for rhythm, dialogue flow, and final coherence, then commit and finish when ready.",
"contextSummary": "The draft has been written and committed once. Keep the existing plot beats, but improve prose and pacing.",
"workspaceRefs": ["output/main.md", "persist/story_state.md"],
"mustPreserve": [
"Do not change the core plot outcome.",
"Keep the established character voice."
],
"completionCriteria": [
"output/main.md is polished.",
"The final chat message has been committed.",
"The run is finished if no further Agent is needed."
]
},
"pendingTaskPolicy": "denyIfPending"
}
```
字段说明:
- `agentId`:目标 Agent id,通常来自 `agent_list({ "purpose": "handoff" })`。
- `handoff`:传给下一个 Agent 的 brief。它是模型可见协作协议,不是 runtime packet。
- `handoff.objective`:唯一必填字段,必须是非空字符串。
- `handoff.title` / `reason` / `contextSummary`:可选字符串,提供时会校验长度。
- `handoff.workspaceRefs` / `mustPreserve` / `completionCriteria`:schema 引导字段,renderer 会按 markdown section 渲染。当前 runtime 不对它们做强类型校验,以保留创作者扩展空间。
- 其它 `handoff` 字段:允许存在,会渲染到 `Additional Instructions`。新增字段前仍应遵守 Agent-friendly 原则,确认它是下一个 Agent 完成任务必须知道的信息。
- `pendingTaskPolicy`:当前只支持 `denyIfPending`。省略时默认 `denyIfPending`。
成功 tool result 的模型可见 content 是轻量确认:
```text
Handoff accepted for Agent final-editor. Your part is complete.
```
该结果通常不会再进入当前 Agent 的下一轮模型思考,因为 loop runner 收到 `HandoffAccepted` effect 后会结束当前 invocation。
## 4. Runtime 存储结构
`agent.handoff` 参数先反序列化为内部 `AgentHandoffArgs`:
```rust
struct AgentHandoffArgs {
agent_id: String,
handoff: Value,
pending_task_policy: Option<PendingTaskPolicy>,
}
```
校验通过后,runtime 创建一个 `AgentTaskRecord` 和一个后继 `AgentInvocation`。
Handoff task 形态:
```json
{
"id": "handoff_<uuid>",
"runId": "<run-id>",
"parentInvocationId": "<current-invocation-id>",
"childInvocationId": "inv_<uuid>",
"targetProfileId": "final-editor",
"workspaceKey": "final-editor",
"continuation": "transfer_control",
"status": "queued",
"task": {
"title": "Final revision pass",
"objective": "Revise output/main.md...",
"contextSummary": "...",
"workspaceRefs": ["output/main.md"]
},
"budget": null,
"createdByToolCallId": "<tool-call-id>",
"resultRef": null,
"error": null
}
```
后继 invocation 形态:
```json
{
"id": "inv_<uuid>",
"runId": "<run-id>",
"parentInvocationId": "<current-invocation-id>",
"profileId": "final-editor",
"kind": "handoff",
"status": "created",
"exitPolicy": "run_finish_allowed"
}
```
`childInvocationId` 是统一 delegation model 的字段名。对 handoff 来说,它表示 successor invocation,而不是 return-mode child worker。不要为了语义纯度单独复制一套 handoff record;当前统一模型能保持 repository、journal、prompt assembly 与 timeline 复用。
## 5. 执行流程
### 5.1 Tool dispatch
入口:
```text
src-tauri/src/application/services/agent_runtime_service/delegation/handoff_tool.rs
```
流程:
```text
current Agent calls agent_handoff
-> parse AgentHandoffArgs
-> validate handoff packet
-> default pendingTaskPolicy to denyIfPending
-> event agent_handoff_requested
-> check source can_handoff
-> check no pending ReturnToParent child tasks
-> check max_handoff_depth
-> parse and resolve target profile
-> validate target callable / allowAsHandoffTarget / allowedCallers / model configured
-> create TransferControl task + Handoff invocation
-> event agent_handoff_accepted
-> return AgentToolEffect::HandoffAccepted
```
当前 pending child 检查只看 `continuation = ReturnToParent` 且状态为 `Queued | Running` 的 task。Handoff task 本身不属于这个阻塞集合。
### 5.2 Loop exit
`run_tool_loop()` 收到 `AgentToolEffect::HandoffAccepted` 后:
```text
record handoff task id + new invocation id
finish current invocation as Transferred
emit agent_loop_finished
return AgentLoopExit::Transferred { task_id, new_invocation_id }
```
实现位置:
```text
src-tauri/src/application/services/agent_runtime_service/loop_runner.rs
```
### 5.3 Executor 串接
`execute_active_invocation_chain()` 处理 `AgentLoopExit::Transferred`:
```text
if current invocation itself came from an incoming handoff task:
mark that incoming task Completed
prepare_handoff_invocation(task_id, new_invocation_id)
-> transition handoff task to Running
-> start new invocation
-> resolve target profile
-> assemble context for target invocation
continue loop with:
invocation_id = new_invocation_id
request = target request
profile = target profile
effective_skills = target effective skills
```
实现位置:
```text
src-tauri/src/application/services/agent_runtime_service/executor.rs
src-tauri/src/application/services/agent_runtime_service/delegation/child_runtime.rs
```
当最终 handoff owner 调用 `workspace.finish` 并成功收尾时,executor 会把它的 incoming handoff task 标记为 `Completed`。如果 handoff owner 再次 handoff,旧 incoming handoff task 也会先标记为 `Completed`,然后继续下一段 handoff invocation。
## 6. 下一个 Agent 收到什么
下一个 Agent 不直接看到裸 JSON。`handoff` Value 会通过 `render_handoff_task_prompt()` 渲染为 markdown brief:
```markdown
# Handoff Brief
You are now responsible for the next stage of this run.
Continue from the shared workspace paths and constraints below.
## Title
Final revision pass
## Reason
I have drafted and committed the main scene; a revision-focused Agent should polish it.
## Objective
Revise output/main.md for rhythm, dialogue flow, and final coherence, then commit and finish when ready.
## Context Summary
The draft has been written and committed once. Keep the existing plot beats, but improve prose and pacing.
## Workspace References
- output/main.md
- persist/story_state.md
## Must Preserve
- Do not change the core plot outcome.
- Keep the established character voice.
## Completion Criteria
- output/main.md is polished.
- The final chat message has been committed.
- The run is finished if no further Agent is needed.
## Working Notes
- Inspect the referenced workspace files before editing existing content.
- Preserve previous decisions and committed text unless this brief asks you to revise them.
- If commit and finish tools are available to you, use them only when this run is ready to end.
- If another Agent should continue after your stage, hand off with a clear brief.
```
渲染入口:
```text
src-tauri/src/application/services/agent_runtime_service/delegation/rendering.rs
```
Prompt assembly 路径:
- target Profile `preset.mode = "ref"`:runtime 注册 invocation-scoped `prompt_assembly_requested`,前端 PromptAssemblyBroker 使用 target Profile 的 preset、system prompt、`agentTaskPrompt` 与 frozen input 重新组装。
- target Profile `preset.mode = "currentPromptSnapshot" | "none"`:后端兼容路径使用 root run 的 `input/prompt_snapshot.json`,替换为 target Profile 的 materialized system prompt + handoff brief。
两条路径都会:
- 应用 target Profile 的 model binding。
- 注入 target Profile 可见工具。
- 使用 `provider_state.sessionId = runId:invocationId`。
- 按 target Profile 解析 Skill scope,并写入 `input/invocations/<invocationId>/resolved_skills.json`。
## 7. Profile 与工具面策略
Source Agent 要能 handoff:
- `delegation.canHandoff = true`
- `tools.allow` 显式包含 `agent.handoff`
- 当前 invocation 是 root / active foreground owner,而不是 return-mode child
Target Agent 要能接手:
- `delegation.callable = true`
- `delegation.allowAsHandoffTarget = true`
- `delegation.allowedCallers` 允许 source Profile id 或 `*`
- model binding 已配置,不是 unresolved `requiresConfiguration`
Target handoff invocation 使用自己的 Profile 工具面。常见配置:
- 最终收尾 Agent:暴露 `workspace.commit` + `workspace.finish`。
- 中间修订 Agent:可暴露 `workspace.commit` + `agent.handoff`,允许先提交阶段性文本再交给下一位。
- 只接力不收尾 Agent:可以没有 `workspace.finish`,但必须有后续可用的 handoff 路径,否则模型会没有正常结束工具。
默认 system prompt 会根据实际工具面提示:
- 有 `workspace.finish`:不要 plain text answer,最终通过 finish 收口。
- 没有 `workspace.finish` 但有 `agent.handoff`:完成自己的部分后调用 handoff。
- 有 `agent.handoff`:handoff 成功后不要继续调用工具。
## 8. Commit 与 finish 语义
Handoff 链共享同一个 `RunCommitLedger`。因此:
- 前一个 Agent 已经 `workspace.commit` 后,后续 Agent 可以继续修订并再次 commit。
- `workspace.finish` 的前台 commit 要求是 run-level,不是 invocation-local。只要同一个 run 已经有成功 commit,后续 handoff owner 可以 finish。
- 仍建议最终 Agent 在做实质修订后再次 commit,避免用户看到的是上一个阶段的输出。
- `workspace.finish` 当前会取消当前 parent 拥有的 unfinished return-mode child tasks,并在 run 收尾时取消剩余 unfinished child tasks。handoff task 代表 active chain,不进入这类默认取消集合。
## 9. Event 序列
一次成功 handoff 的典型 journal 序列:
```text
agent_handoff_requested
agent_invocation_created # kind = handoff
agent_task_registered # continuation = transfer_control
agent_handoff_accepted
agent_invocation_transferred # source invocation
agent_loop_finished # source invocation loop ends
agent_task_started # handoff task transitions Running
agent_invocation_started # target invocation starts
prompt_assembly_requested? # only when target preset.mode = ref
context_assembled
skill_scopes_resolved
...
agent_task_completed # incoming handoff task completed when target finishes or hands off again
agent_invocation_completed # final owner finish path
run_completed
```
Timeline UI 不应只依赖当前分页内的 journal 事件推断 active chain。`readEvents({ includeTimelineProjection: true })` 会随事件页返回 `timelineProjection.foregroundInvocationIds`、`timelineProjection.invocations` 与 `timelineProjection.delegationEdges`;该 projection 来自结构化 invocation/task repository,用于在 `agent_handoff_accepted`、`agent_delegate_started` 或 `agent_invocation_started` 已不在当前页时仍展示同一 run 内的 Agent graph。projection 不是新的 journal event,不能作为审计日志来源;普通 event polling 不应请求它。
失败和拒绝路径:
- 参数错误、policy denied、pending delegated tasks、depth exhausted:返回 recoverable tool error 给当前 Agent,run 不立即失败。
- 创建 task / invocation、repository、journal、prompt assembly、provider state、serialization 等宿主契约错误:fail-fast,按 active invocation 失败处理。
- target handoff invocation 启动失败:`mark_active_invocation_failed()` 会标记新 invocation,并把 handoff task 标记为 failed。
## 10. 代码定位
模型可见工具:
```text
src-tauri/src/application/services/agent_tools/agent/specs.rs
src-tauri/src/application/services/agent_tools/agent/mod.rs
src-tauri/src/application/services/agent_tools/registry.rs
src-tauri/src/application/services/agent_tools/dispatcher.rs
```
Handoff dispatch / policy / rendering:
```text
src-tauri/src/application/services/agent_runtime_service/delegation/handoff_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/policy.rs
src-tauri/src/application/services/agent_runtime_service/delegation/rendering.rs
src-tauri/src/application/services/agent_runtime_service/delegation/child_runtime.rs
```
Invocation / executor / loop:
```text
src-tauri/src/application/services/agent_runtime_service/invocation.rs
src-tauri/src/application/services/agent_runtime_service/loop_runner.rs
src-tauri/src/application/services/agent_runtime_service/executor.rs
src-tauri/src/application/services/agent_runtime_service/tool_execution.rs
```
Prompt assembly:
```text
src-tauri/src/application/services/agent_runtime_service/prompt_assembly.rs
src/tauri/main/api/agent-prompt-assembly-bridge.js
src/tauri/main/api/agent-prompt-assembly.js
```
Profile validation:
```text
src-tauri/src/application/services/agent_profile_service/validation.rs
src-tauri/src/domain/models/agent/profile.rs
```
Tests:
```text
src-tauri/src/application/services/agent_runtime_service/tests.rs
src-tauri/src/application/services/agent_profile_service/tests.rs
src-tauri/src/application/services/agent_tools/registry.rs
```
重点测试名:
```text
agent_handoff_continues_after_prior_commit
agent_handoff_denies_pending_delegated_tasks
default_agent_system_prompt_describes_handoff_from_current_agent_view
agent_tool_specs_keep_runtime_terms_out_of_model_descriptions
handoff_target_profiles_do_not_require_finish_tool
```
## 11. 后续扩展边界
当前实现刻意保持小而完整。后续扩展时优先保持这些边界:
- 不要把 `agent.delegate` 和 `agent.handoff` 合并成带 enum 的单工具。模型可见层应继续保持分离,runtime 内部继续统一。
- 不要让 handoff target 继承 source Agent 的 system prompt、tool surface、Skill policy 或 provider continuation。
- 不要让 handoff 成功后旧 invocation 继续执行副作用工具。
- 不要把 handoff brief 渲染为裸 JSON。目标 Agent 应收到 markdown briefing。
- 不要把 `taskId`、`invocationId`、`profileId`、physical workspace path 或 CAS 细节塞进模型 prompt。
- 如果未来支持 pending child task transfer,应显式设计 task ownership transfer,而不是放宽 `denyIfPending` 静默交接。
- 如果未来支持模型可见 cancel,应先保证 `agent.await`、finish 默认取消与 handoff deny policy 的语义不冲突。
- 如果未来引入 Plan Mode,handoff 应成为 plan edge 的一种实现,而不是绕过 `AgentInvocation` / `AgentTaskRecord` 的独立调度系统。
Agent-facing 文案必须持续从当前 Agent 或目标 Agent 的视角书写。多 Agent 框架是为 Agent 服务的;实现细节只有在 Agent 完成任务必须知道时才进入 prompt、tool description 或 tool result。

View file

@ -11,6 +11,7 @@
- `model.connectionRef + modelId` 与 preset provider source 解耦;preset 不拥有 endpoint、secret、最终 source/model。
- Profile 导出或嵌入到 Preset/Character 时必须移除本机 `connectionRef + modelId`,写为 `model.mode = "requiresConfiguration"`。
- 错误必须 fail-fast,不允许静默回退 Legacy Generate 或当前 UI preset/model。
- Connection Manager Model Target 只是 Profile 面板的 UI 输入来源;Agent System 会在启动、Model Target 创建/更新、Profile 保存和 Agent run 启动前物化对应 LLM Connection,Rust prompt assembly 与 runtime 只读取 LLM Connection contract。
## Profile 绑定语义
@ -41,6 +42,8 @@
- `connectionRef`:通过 `LlmConnectionService` 解析 connection 与 `modelId`,覆盖组装 settings 与最终 runtime payload。
- `requiresConfiguration`:可保存、可导入、可展示的未配置模型状态;运行或 prompt assembly 前必须 fail-fast,用户需在本机重新选择模型后才能使用。
`modelId` 是 Profile binding 的一部分,不随 Model Target 更新自动漂移。更新 Model Target 的 API key、endpoint、provider source 会通过重新物化 LLM Connection 对后续 resolution 生效;Agent run 启动前会按当前保存的 `connectionRef` 再物化一次,避免 prompt assembly 或 runtime 使用旧的派生 connection,但不会改写 Profile 的 `modelId`。若更新后的 Model Target 无法保真物化,Agent System 会删除对应派生 LLM Connection,让后续 resolution fail-fast。更新 Model Target 的模型名需要用户在 Profile 面板重新选择后才会写入 Profile。
独立 preset 当前只支持 OpenAI/chat-completion preset,因为真实组装入口复用 `src/scripts/openai.js` 的 PromptManager 链路。
## FrozenRunInputSnapshot
@ -144,7 +147,7 @@ runtime-time `prompt_assembly_requested` event 不携带完整 request,只包
- `src/scripts/tauritavern/agent/frozen-run-input-snapshot.js`:冻结输入结构与 normalization。
- `src/tauri/main/api/agent-prompt-assembly-run.js`:prepare/buildSnapshot 编排。
- `src/tauri/main/api/agent-prompt-assembly.js`:Frontend PromptAssemblyBroker。
- `src/tauri/main/api/agent-prompt-assembly-bridge.js`:runtime-time child invocation prompt assembly host bridge,按 `assemblyId` 读取 pending broker request。
- `src/tauri/main/api/agent-prompt-assembly-bridge.js`:runtime-time child / handoff invocation prompt assembly host bridge,按 `assemblyId` 读取 pending broker request。
- `src/scripts/openai.js`:headless PromptManager、settings normalization、真实 OpenAI/chat-completion prompt assembly。
- `src-tauri/src/application/services/prompt_assembly_service.rs`:Rust PromptAssemblyService、preset 加载、broker request、组装阶段 model overlay。
- `src-tauri/src/application/services/agent_runtime_service/prompt_assembly.rs`:invocation-scoped prompt assembly request/resolve、snapshot 持久化。
@ -159,11 +162,11 @@ runtime-time `prompt_assembly_requested` event 不携带完整 request,只包
- headless broker 默认不触发普通 `CHAT_COMPLETION_PROMPT_READY`;依赖该事件改写 prompt 的扩展不会参与 Agent 独立组装。
- 动态 extension prompts 在 frozen snapshot 创建时已经物化;broker 组装阶段不会重新执行动态 filter。
- Agent runtime 拒绝外部 `tools`、`tool_choice`、已有 `role: "tool"` 或 assistant `tool_calls`。
- return-mode child invocation 在 target Profile 使用 `preset.ref` 时走 runtime-time PromptAssemblyBroker handshake;`currentPromptSnapshot` / `none` Profile 保持后端兼容组装路径。
- return-mode child invocation 与 handoff invocation 在 target Profile 使用 `preset.ref` 时走 runtime-time PromptAssemblyBroker handshake;`currentPromptSnapshot` / `none` Profile 保持后端兼容组装路径。
## 后续开发注意
- 不要把独立 preset 实现为临时切换 UI preset 后 dryRun。
- 不要在 Rust 中手写简化 prompt builder 来替代 PromptManager。
- 新增 provider source 时,需要同步 `PromptAssemblyService::prompt_model_setting_key` 与 LLM connection payload overlay。
- 后续 handoff 应复用 `FrozenRunInputSnapshot`、`agentTask` marker 与 invocation-scoped broker contract;若要扩展 task/handoff packet,优先扩展 broker request 的 `scope` 与 task prompt 渲染,而不是在 Rust 中手写 PromptManager 替代品。
- child / handoff 已复用 `FrozenRunInputSnapshot`、`agentTask` marker 与 invocation-scoped broker contract;后续若要扩展 task/handoff packet,优先扩展 broker request 的 `scope` 与 task prompt 渲染,而不是在 Rust 中手写 PromptManager 替代品。Handoff 的具体 brief 结构、handoff invocation 入口与 event 序列见 `docs/Agent/Handoff.md`。

View file

@ -16,6 +16,7 @@
| [PromptAssembly.md](PromptAssembly.md) | Agent 独立 Preset / 独立 Model、FrozenRunInputSnapshot 与前后端 prompt assembly 链路 |
| [ToolSystem.md](ToolSystem.md) | ToolSpec、ToolResult、Tool Registry、Policy、审批与 Legacy ToolManager 边界 |
| [SubAgent.md](SubAgent.md) | return-mode SubAgent、AgentInvocation / AgentTask、shared workspace 语义与开发定位 |
| [Handoff.md](Handoff.md) | `agent.handoff` 接力流程、TransferControl task、Handoff invocation、prompt brief 与代码定位 |
| [LlmGateway.md](LlmGateway.md) | provider-agnostic LLM gateway 与现有 `ChatCompletionService` 的复用边界 |
| [McpSkill.md](McpSkill.md) | MCP 独立集成、Skill 渐进披露、安全边界 |
| [Skill.md](Skill.md) | 当前 Skill 格式、存储、导入导出、Agent tool 与安全边界 |
@ -25,4 +26,4 @@
实时开发进度不写在本目录;请更新 `docs/CurrentState/AgentFramework.md`。
截至 2026-05-28,canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill 管理与读取、workspace 读改工具循环、前端 dryRun adapter、Phase 3 Agent Profile 基线、独立 Preset 与 `model.connectionRef + modelId` 组装链路、return-mode SubAgent MVP、run-scoped SubAgent scheduler 基线已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md`、`docs/CurrentState/AgentProviderState.md`、`docs/Agent/PromptAssembly.md` 与 `docs/Agent/SubAgent.md` 为准;MCP、diff/rollback、`agent.handoff`、模型可见 task cancel、运行中 subagent/handoff 完整 prompt assembly 与 Plan Mode runtime 仍是后续设计。
截至 2026-06-06,canonical model IR、provider native metadata 保真、invocation-scoped provider_state continuation、上下文只读工具、Skill 管理与读取、workspace 读改工具循环、前端 dryRun adapter、Phase 3 Agent Profile 基线、独立 Preset 与 `model.connectionRef + modelId` 组装链路、return-mode SubAgent MVP、run-scoped SubAgent scheduler 基线、`agent.handoff` foreground 接力已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md`、`docs/CurrentState/AgentProviderState.md`、`docs/Agent/PromptAssembly.md`、`docs/Agent/SubAgent.md` 与 `docs/Agent/Handoff.md` 为准;MCP、diff/rollback、模型可见 task cancel、Plan Mode runtime 仍是后续设计。

View file

@ -117,6 +117,7 @@ agent_invocation_started
agent_invocation_completed
agent_invocation_failed
agent_invocation_cancelled
agent_invocation_transferred
agent_task_registered
agent_task_queued
agent_task_started
@ -124,6 +125,8 @@ agent_task_completed
agent_task_failed
agent_task_cancelled
agent_delegate_started
agent_handoff_requested
agent_handoff_accepted
agent_await_started
agent_await_completed
task_return_completed
@ -161,8 +164,42 @@ drift_recovery_attempted
run_failed
```
多 invocation 事件必须写入 canonical event scope:
```json
{
"eventScope": {
"invocationId": "inv_root",
"relatedInvocationIds": ["inv_child"]
}
}
```
`eventScope.invocationId` 是事件主归属 invocation;`eventScope.relatedInvocationIds` 是同一事件关联的其它 invocation。`readEvents({ invocationId })` 先按该 event scope 过滤,再应用 seq 分页。旧 journal 没有 canonical scope 时,后端可以用历史 payload 字段兼容读取,但新事件不应依赖字段名推断作为主契约。
以下小节同时包含当前已落地事件和后续阶段设计事件;实现新事件时必须更新 `docs/CurrentState/AgentFramework.md`。
### Handoff event sequence
当前 `agent.handoff` 的典型事件序列:
```text
agent_handoff_requested
agent_invocation_created # kind = handoff
agent_task_registered # continuation = transfer_control
agent_handoff_accepted
agent_invocation_transferred # source invocation
agent_loop_finished
agent_task_started # handoff task starts when target invocation is prepared
agent_invocation_started # target invocation
context_assembled
skill_scopes_resolved
...
agent_task_completed # incoming handoff task completes when target finishes or hands off again
```
Handoff 的 task / invocation 结构、prompt brief 与失败边界见 `docs/Agent/Handoff.md`。
### 4.1 Run Lifecycle
```text
@ -252,6 +289,20 @@ run_failed
- 恢复失败(模型再次返回 0 tool_calls 且已没有下一轮预算)→ 若没有成功 chat commit,写 `run_failed`(`userRetryable=true`);若已有成功 chat commit,写 `run_partial_success` 并保留输出
- 上述 rollback / partial-success 语义与前端入口无关;普通发送、`/trigger`、regenerate 与 overswipe 只要进入 Agent run,都必须遵守同一 journal 与 host commit 契约。
### 4.1.1 User Guidance
```text
user_guidance_submitted
user_guidance_applied
user_guidance_discarded
```
User guidance 是 active AgentRun 的 run-scoped 用户输入,不是普通聊天消息。提交成功后 runtime 必须先写 `user_guidance_submitted`,再将 guidance 放入当前 run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,runtime 将 pending guidance 合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。
已经发出的 provider request 不可被热修改。若 guidance 在模型调用、工具调用、host commit 等过程中提交,只能影响后续安全模型请求边界。run cancel、finish、failure / partial success 会关闭 mailbox,并对尚未应用的 guidance 写 `user_guidance_discarded`。
事件 payload 必须包含 `guidanceId` 或 `guidanceIds`、`chars`、`words`、`preview`、`status`,并带 `invocationId` / canonical `eventScope` 以便 Timeline 可以归入前台 Agent 链。`user_guidance_submitted` 在 V1 直接内联受长度限制的完整 `text`,便于 Timeline detail 和审计读取;applied / discarded 事件用 ids、统计和 preview 关联,不重复写全文。guidance 文本不应写入普通 chat history。
### 4.2 Workspace
```text
@ -310,12 +361,21 @@ model_failed
"hasAssistantText": true,
"assistantTextChars": 26,
"assistantTextWords": 5,
"narration": {
"source": "assistantText",
"text": "I will write the artifact.",
"totalChars": 26,
"totalWords": 5,
"truncated": false
},
"hasReasoning": true,
"reasoningChars": 30,
"reasoningWords": 6
}
```
`narration` 是可选字段,仅在模型回合包含工具调用且存在可展示 assistant visible text 时写入;它是模型轮次的展示投影,不是新的 run status,也不从 reasoning / thinking / thought 提取。事件中只保存短 preview;完整文本仍通过 `readModelTurn({ runId, round })` 的白名单 DTO 读取。
前端读取详情时使用 Host ABI `readModelTurn({ runId, round })`,不直接解析 `modelResponsePath` 指向的 raw 文件。
### 4.5 Tool
@ -391,13 +451,14 @@ Locked plan violation 必须失败或等待用户决策,不能静默忽略。
artifact_assembly_started
artifact_assembled
artifact_assembly_failed
commit_started
run_committed
commit_failed
chat_commit_started
chat_commit_requested
chat_commit_completed
chat_commit_failed
committed_message_rollback_completed
```
Commit event must include chat ref、message index/id、checkpoint id、artifact set id。
Commit event 必须包含 chat ref、checkpoint id、artifact path,并在 host bridge 已确认时包含 message id。`chat_commit_completed.messageId` 是 host bridge 在提交当时返回的聊天消息 id;当前 run 还会从数字型 message id 派生零基 `messageIndex`。这是提交时快照,不是对当前聊天消息位置的反向查询。
## 5. 事件与副作用的顺序

View file

@ -1,8 +1,8 @@
# TauriTavern SubAgent Runtime
本文档记录当前 return-mode SubAgent 的实现基线、核心契约、Agent-friendly 设计原则与代码定位。后续开发多 Agent、handoff、task cancel 与 invocation-scoped prompt assembly 前,应先读本文。
本文档记录当前 return-mode SubAgent 的实现基线、核心契约、Agent-friendly 设计原则与代码定位。`agent.handoff` 使用同一套 `AgentTaskRecord + AgentInvocation` 地基,但属于 foreground 接力流程;handoff 细节以 `docs/Agent/Handoff.md` 为准。后续开发多 Agent、task cancel 与 invocation-scoped prompt assembly 前,应先读本文和 `Handoff.md`。
当前状态截至 2026-05-29:已实现 `agent.list`、`agent.delegate`、`agent.await`、return-mode child invocation 的 `task.return`,run-scoped `ActiveRunHandle` / `AgentTaskScheduler` 后台 worker 基线,以及 `preset.ref` child invocation 的 invocation-scoped PromptAssemblyBroker handshake。`agent.handoff` 与模型可见 task cancel 工具仍是后续计划,当前没有模型可见 `agent.handoff` / `agent.cancel_task`。
当前状态截至 2026-06-06:已实现 `agent.list`、`agent.delegate`、`agent.await`、return-mode child invocation 的 `task.return`,run-scoped `ActiveRunHandle` / `AgentTaskScheduler` 后台 worker 基线,`agent.handoff` foreground 接力,以及 child / handoff invocation 的 PromptAssemblyBroker handshake。模型可见 task cancel 工具仍是后续计划,当前没有模型可见 `agent.cancel_task`。
## 1. 设计目标
@ -52,7 +52,9 @@ root Agent may also call agent.await to wait for selected tasks before continuin
父 Agent 无论是否显式调用 `agent.await`,只要它创建的 child task 已经 terminal,runtime 都会在下一次父 Agent tool turn 之后,把尚未在本轮上下文中出现过的结果作为 synthetic user message 注入下一轮模型请求。这个交付状态不写入 task record;当前只在 parent loop 内用内存集合去避免重复注入。这样避免把“已交付给父 Agent”固化成长期状态,同时保持 provider continuation 顺序清晰。
`workspace.finish` 当前允许在仍有 unfinished child task 时结束 root run。finish 会默认取消当前 parent 拥有的 unfinished tasks;run 收尾也会取消 run 内剩余 unfinished child tasks。这样不会因为缺少模型可见 cancel 工具或某个子任务卡住而拖长生成。
`agent.handoff` 创建 `continuation = TransferControl` 的 task 与 `kind = Handoff` 的后继 invocation。它不提交给 scheduler,也不返回 summary 给调用方;当前 invocation 记录完 handoff tool result 后转为 `Transferred`,executor 在同一个 `AgentRun` 内继续运行目标 Agent。已经 commit 过的 root / handoff owner 仍允许 handoff,最终 owner 可继续修订、再次 commit 并 `workspace.finish`。
`workspace.finish` 当前允许在仍有 unfinished return-mode child task 时结束 root / handoff owner 的 run stage。finish 会默认取消当前 parent 拥有的 unfinished return-mode tasks;run 收尾也会取消 run 内剩余 unfinished child tasks。这样不会因为缺少模型可见 cancel 工具或某个子任务卡住而拖长生成。handoff task 不在这个默认取消范围内;它代表当前接力链上的后继 invocation。
## 3. 核心模型
@ -85,7 +87,7 @@ AgentTaskRecord {
}
```
当前只创建 `continuation = ReturnToParent` 的 task。`TransferControl`、`Handoff`、`Transferred` 是为后续 handoff 保留的统一 delegation edge,不代表 handoff 已落地。
`continuation = ReturnToParent` 的 task 由 scheduler 后台执行,并最终通过 `task.return` 把结果交回调用者。`continuation = TransferControl` 的 task 表示 handoff:它不进入后台 scheduler,而是在当前 foreground invocation 转为 `Transferred` 后,由 executor 串接启动 `Handoff` invocation。
Invocation 与 task 文件由 `AgentInvocationRepository` 管理,当前文件实现位于:
@ -113,6 +115,7 @@ summaries/<workspace-key>-result.md
| --- | --- | --- | --- |
| `agent.list` | `agent_list` | 允许 delegation 的 root/active invocation | 列出当前 Agent 可调用的 Agent 目录 |
| `agent.delegate` | `agent_delegate` | `delegation.canDelegate = true` | 创建 return-mode 子任务并提交后台 worker |
| `agent.handoff` | `agent_handoff` | `delegation.canHandoff = true` | 创建 TransferControl task,并让目标 Agent 接手同一 run 的下一阶段 |
| `agent.await` | `agent_await` | `delegation.canDelegate = true` | 查询或等待自己创建的子任务结果 |
| `task.return` | `task_return` | runtime 只注入 return-mode child invocation | 提交 delegated task 结果并结束 child work |
@ -137,7 +140,7 @@ summaries/<workspace-key>-result.md
`task.title` 是可选展示名;只有 `task.objective` 承载必须完成的任务目标。
没有 `execution`、`continuation` 或 `invocationId` 参数。工具名已经表达了 continuation:`agent.delegate` 永远是 return-to-parent。
没有 `execution`、`continuation` 或 `invocationId` 参数。工具名已经表达了 continuation:`agent.delegate` 永远是 return-to-parent,`agent.handoff` 永远是 transfer-control。
## 5. Child Invocation Policy
@ -146,7 +149,7 @@ return-mode child Agent 必须遵守更窄的执行契约:
- `run.presentation = background`。
- 前端“可作为子 Agent”会写入 `run.directRunnable = false`;直接启动入口会 fail-fast,return-mode child invocation 仍可通过 `agent.delegate` 运行。
- 移除 `workspace.commit`、`workspace.finish`。
- 移除 `agent.list`、`agent.delegate`、`agent.await`。
- 移除 `agent.list`、`agent.delegate`、`agent.handoff`、`agent.await`。
- 注入 `task.return`。
- `exit_policy = TaskReturnRequired`。
- 可使用 target Agent Profile 的 model binding 与工具预算;delegate call 可进一步收窄 `maxRounds` / `maxToolCalls`。
@ -248,6 +251,7 @@ SubAgent 主干入口:
src-tauri/src/application/services/agent_runtime_service/delegation.rs
src-tauri/src/application/services/agent_runtime_service/delegation/list_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/delegate_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/handoff_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/await_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/task_return_tool.rs
src-tauri/src/application/services/agent_runtime_service/delegation/child_runtime.rs
@ -298,8 +302,9 @@ src-tauri/src/infrastructure/repositories/file_agent_repository/tests.rs
- scheduler 后台运行 child worker,完成后写 terminal task / invocation 状态。
- `agent.await` 选择 task、等待或查询 scheduler 结果、渲染 result capsule。
- 父 Agent 下一次 tool turn 后自动收到尚未出现过的 terminal child results。
- `workspace.finish` 会取消 unfinished child tasks,而不会被其阻塞。
- child invocation tool surface:无 commit/finish/delegate/await,有 task.return。
- `workspace.finish` 会取消 unfinished return-mode child tasks,而不会被其阻塞。
- handoff 创建 TransferControl task,不进入 scheduler;当前 invocation 转为 Transferred 后,同一 run 串接执行目标 handoff invocation。详细流程见 `Handoff.md`。
- child invocation tool surface:无 commit/finish/delegate/handoff/await,有 task.return。
- child system prompt 与 tool descriptions 不泄露不必要 runtime 细节。
- child workspace policy 只调整 root 可见/可写权限,不映射路径。
- task.return artifact path normalizing 与 result summary 写入。
@ -308,9 +313,9 @@ src-tauri/src/infrastructure/repositories/file_agent_repository/tests.rs
当前不是最终多 Agent runtime:
- 没有 `agent.handoff`。
- 没有模型可见 `agent.cancel_task`;当前只有 run cancel 与 finish 默认取消 unfinished child tasks。
- return-mode child 默认不能 nested delegation,即使 profile schema 已有 `allowNestedDelegation` 字段。
- return-mode child 不能 handoff;handoff 只属于 root / active foreground owner。handoff 的字段、事件、prompt brief 与扩展边界见 `Handoff.md`。
- 没有跨 child 的主动通信;多个 child 只能通过同一个 workspace 中的普通文件和 `task.return` 结果协作。
- 没有独立的 task timeout 取消边界;child worker 使用当前 scheduler / run cancellation path。

View file

@ -168,7 +168,12 @@ chat deletion fails clearly while the corresponding Agent workspace has an activ
skill_search respects visible/deny policy and read budget
skill_read supports line and character ranges
recoverable tool errors are returned to the model instead of failing the run
future APIs approveToolCall/listRuns/readDiff/rollback throw explicitly
listRuns returns paginated Agent run history summaries
future APIs approveToolCall/readDiff/rollback throw explicitly
run timeline/detail view switching has a single explicit state source and does not derive detailsOpen from scrollLeft
run timeline main panel does not use horizontal scroll-snap or smooth scroll as a view state machine
run timeline closes detail by resetting detail state and does not measure or auto-stick hidden timeline scrollers
run timeline mobile view gesture uses Pointer Events only as an input shortcut and commits through openDetails/showTimeline
```
Provider normalizer tests 必须覆盖可见 reasoning 提取:Claude `thinking`、Gemini `thought` 文本、OpenAI Responses reasoning summary 进入 `reasoning_content`;signature / encrypted continuation 仍作为 native/provider state 保留,不能作为可展示文本。

View file

@ -158,14 +158,15 @@ Policy violation 必须写 journal 并 fail-fast,除非这是模型可恢复
### 7.0 当前实现
截至 2026-05-30,当前 registry 开放 agent / chat / world info / dice / skill / workspace 六类内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换。Profile 会在运行时过滤 visible tool list,并可替换 model-facing ToolSpec copy 的工具 description 与参数 description;canonical specs 不被修改。return-mode child invocation 会在 runtime 层收窄工具面:移除 chat commit / run finish / delegation tools,并注入 runtime-only `task.return`。child 与请求它的 Agent 使用同一套逻辑 workspace path;runtime 只按 target Profile workspace policy 调整当前 invocation 的 visible/writable roots,不做 child 专用路径映射。
截至 2026-06-06,当前 registry 开放 agent / chat / world info / dice / skill / workspace 六类内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换。Profile 会在运行时过滤 visible tool list,并可替换 model-facing ToolSpec copy 的工具 description 与参数 description;canonical specs 不被修改。return-mode child invocation 会在 runtime 层收窄工具面:移除 chat commit / run finish / delegation tools,并注入 runtime-only `task.return`。handoff invocation 使用目标 Profile 的 foreground 工具面接手同一 run 的下一阶段。child 与请求它的 Agent 使用同一套逻辑 workspace path;runtime 只按 target Profile workspace policy 调整当前 invocation 的 visible/writable roots,不做 child 专用路径映射。
Agent-facing 文案必须从调用或执行 Agent 的角度描述可操作路径:`agent.delegate` 鼓励在 task brief 中给出相关 workspace path 与期望 artifact;return-mode workspace tools 只提示 visible/writable roots 与任务中的普通 workspace path,不暴露 physical mapping、CAS 参数或 runtime id。
Agent-facing 文案必须从调用或执行 Agent 的角度描述可操作路径:`agent.delegate` 鼓励在 task brief 中给出相关 workspace path 与期望 artifact;`agent.handoff` 鼓励给出 objective、workspace refs、context、constraints 与 completion criteria;return-mode workspace tools 只提示 visible/writable roots 与任务中的普通 workspace path,不暴露 physical mapping、CAS 参数或 runtime id。
| Canonical name | Model-facing alias | Side effect | 状态 |
| --- | --- | --- | --- |
| `agent.list` | `agent_list` | 只读列出当前 Agent 可调用的 Agent Profile 目录 | 已落地 |
| `agent.delegate` | `agent_delegate` | 创建 return-mode 子任务,不立即返回结果 | 已落地 |
| `agent.handoff` | `agent_handoff` | 创建 TransferControl task 与 Handoff invocation,让目标 Agent 接手同一 run 的下一阶段 | 已落地 |
| `agent.await` | `agent_await` | 查询或等待当前 invocation 创建的 delegated task 结果 | 已落地 |
| `task.return` | `task_return` | return-mode child invocation 提交任务结果并结束 child work;runtime-only,不允许写入 profile tools.allow | 已落地 |
| `chat.search` | `chat_search` | 只读搜索当前聊天,返回 message index、snippet 与 ref | 已落地 |
@ -199,7 +200,7 @@ persist/
`workspace.apply_patch` 使用 Claude Code 风格的 `old_string` / `new_string` 单文件精确替换。`old_string` 必须来自模型本 run 已读到的文本片段,或来自本 run 创建/完整替换后已经完整已知的文件;runtime 仍会读取当前完整文件检查版本与全文件唯一匹配,但不会把完整文件隐式塞回模型上下文。版本变化、匹配 0 次或多次会作为 recoverable tool error 返回模型;基于部分读取的 patch 一旦失败,同文件后续 patch 必须先完整读取,避免模型在不确定上下文上反复试错。`replace_all=true` 可能修改未读位置,因此必须在完整读取后使用。`workspace.write_file` 支持 `mode = replace | append`,默认 `replace`。`replace` 对已存在文件复用同一个 session read-state 做 CAS:模型不需要传 `expectedSha256`,schema 不暴露 overwrite policy;若文件在最近读取/写入后被其他 invocation 修改,会返回可恢复的 stale-file 工具错误,要求重新读取后再写。`append` 会把 `content` 原样追加到文件末尾,目标缺失时创建文件;不会自动补换行,模型需要新行时应把前导 `\n` 放进 `content`。`append` 工具调用本身只在新建文件或追加前文件已完整读入且版本匹配时更新完整 read-state,避免未读既有内容在同一轮内被隐式授权为后续 rewrite/patch 的依据。模型传入的非法 path、空 path、非法 mode、不可见/不可写 path 也作为可恢复工具错误回填;目标 path 实际指向目录的读写请求会作为 `workspace.path_is_directory` 业务错误回填,提示模型改用 `workspace_list_files`。repository 内部 escape/symlink/journal、checkpoint、序列化、取消和模型响应结构错误仍 fail-fast。
`workspace.commit` 与 `workspace.finish` 的契约:模型可以多次 commit;当全部修订与 commit 完成后,必须用 `workspace.finish` 收口,不能用纯文本代替最终 answer。`workspace.commit` 工具的返回字符串只做温和提醒,提示模型可继续修订并再次 commit,但最终不要忘记 finish。
`workspace.commit` 与 `workspace.finish` 的契约:模型可以多次 commit;当全部修订与 commit 完成后,必须用 `workspace.finish` 收口,不能用纯文本代替最终 answer。foreground `workspace.finish` 要求同一 run 已经有至少一次成功 chat commit;在 handoff 链中,这个 commit 可以来自前一个 foreground owner。`workspace.commit` 工具的返回字符串只做温和提醒,提示模型可继续修订并再次 commit,但最终不要忘记 finish。
如果模型一回合内仍然返回 0 个 tool_calls(drift),loop runner 会做"软纠正"(issue #64):把模型的纯文本回复捕获到 workspace 的 `direct_output.md`(默认 `output/direct_output.md`,实际跟随当前 profile 的 messageBody artifact root),写 `direct_output_captured` 与 checkpoint;再把该 assistant 回复推进 history,并追加一条 `user` 角色的合成提醒,让模型在下一轮通过当前 invocation 的结束工具补回流程。root run 使用 `workspace_commit` / `workspace_finish`,return-mode child 使用 `task_return`。direct output 本身没有独立的一次性上限;只要仍有下一轮模型调用预算,就会继续纠偏。每次都会写一条 `drift_recovery_attempted` 事件。
@ -208,7 +209,7 @@ persist/
违反此契约的其它形态(`agent.tool_after_finish` / `agent.max_tool_rounds_exceeded`)目前不走软纠正,直接进入同一终态分类:没有成功 chat commit 时 fail-fast;已有成功 chat commit 时 `run_partial_success`。细节见 `RunEventJournal.md`。
当前没有 MCP、shell、extension bridge、profile routing、Plan Mode runtime、模型可见 task cancel、`agent.handoff` 或审批工具。
当前没有 MCP、shell、extension bridge、profile routing、Plan Mode runtime、模型可见 task cancel 或审批工具。
### 7.1 Agent Delegation Tools
@ -217,7 +218,6 @@ persist/
- Read-only。
- 根据当前 Profile delegation policy 与 target Profile callable policy,列出可调用 Agent。
- 支持 `purpose = any | delegate | handoff`、`query`、`limit`。
- 当前 handoff 仍是后续计划;在 `agent.handoff` 落地前,生产 Profile 不应把 handoff 能力作为可执行路径交给模型。
`agent.delegate`
@ -226,6 +226,16 @@ persist/
- 参数只包含 `agentId`、`task` 与可选 `budget`;不接受 `execution`、`continuation`、`invocationId` 等 runtime 字段。
- 子任务提交给当前 run 的 `AgentTaskScheduler` 后台执行;父 Agent 不需要阻塞在 `agent.delegate` 上。
`agent.handoff`
- Mutating/control。
- 创建 transfer-control `AgentTaskRecord` 与 `Handoff` invocation;不进入后台 scheduler,也不返回 summary 给当前 Agent。
- 参数只包含 `agentId` 与 `handoff` brief;不接受 `execution`、`continuation`、`invocationId` 等 runtime 字段。
- 仅在当前 Profile `delegation.canHandoff = true` 且工具显式可见时可用;target Profile 必须 `callable = true`、`allowAsHandoffTarget = true`,并通过 `allowedCallers` 与 `maxHandoffDepth` 校验。
- 若当前 invocation 仍有未 terminal 的 return-mode delegated task,`agent.handoff` 返回可恢复工具错误;模型必须先 `agent.await`、等待任务结束,或通过当前可用路径收口。
- 一次 handoff 成功后,调用方 invocation 必须停止继续调用工具;executor 会在同一 run 内串接运行目标 Agent。
- 详细流程、结构体、event 序列与测试入口见 `docs/Agent/Handoff.md`。
`agent.await`
- Read-only/control。

View file

@ -397,15 +397,26 @@ rollbackCommittedMessage(runId, checkpointId)
- 删除角色且级联删除聊天、删除群组时,会批量清理对应聊天 workspace。
- 若 workspace 仍有关联 active run,删除必须 fail-fast;用户应先取消 run 再删除聊天。
- 旧聊天缺少稳定 `integrity` 时,不存在可可靠定位的 Agent workspace,保持普通聊天删除语义。
- `tauritavern-settings.agent.retention.auto_prune_enabled` 默认 `false`,控制 Rust 后端是否在 TauriTavern 进程运行期间周期性执行自动清理。
- `tauritavern-settings.agent.retention.keep_recent_terminal_runs` 是 terminal run 核心历史窗口,默认 100。
- `tauritavern-settings.agent.retention.keep_full_recent_runs` 是完整 workspace/debug artifacts 窗口,默认 20,必须小于等于 `keep_recent_terminal_runs`。
- 两个 retention 数量都允许为 0,最大 10000;run prune 只作用于 terminal runs,active/non-terminal run 保持不可清理。
- 前端只通过 `api.agent.retention.readSettings()` / `updateSettings()` / `planPrune()` / `applyPrune()` facade 接触该策略;`updateSettings()` 只保存设置并唤醒后端调度器重读配置,不同步执行清理。
- `plan_agent_run_prune(dto)` 是 dry-run command:读取当前设置或调用方传入的一次性 retention override,生成候选动作、原因、文件数与字节数,不删除 run workspace 或重型 artifacts。`detailLimit` 只截断返回明细,不截断 totals;active run、缺失 terminal event、journal/storage 异常会进入 `blockedRuns`,不会被计为可执行 candidate。
- `apply_agent_run_prune(dto)` 使用同一 planner 的 execution 模式在后端重新生成全量执行计划,不信任前端 preview candidates;同一服务实例内 apply 串行执行。它执行 `slim_heavy_artifacts` / `delete_run` 后返回删除统计、`failedRuns` 和 caller `detailLimit` 下的 `afterPlan`;单个 run 删除失败会显式返回并继续后续 candidate,结构性规划错误仍 fail-fast。
- dry-run 和 apply 都使用 Agent run storage class 判断清理范围,并与 TT-Sync 的 Agent dataset 边界保持同一套路径归属词汇。核心 history 是 `run_journal`(run 目录根级 `run.json` / `events.jsonl` 与 index run)和本地 `run_summary_projection`;`slim_heavy_artifacts` 删除 `run_context`、`run_workspace_projection`、`run_tool_io`、`workspace_outputs`、`workspace_scratch`、`tasks`、`model_responses`、`checkpoints` 以及未知 run artifact。`delete_run` 删除完整 run 目录加 index run/summary 文件。稳定 `persistent-states/` 不属于 run prune 范围。
建议后续提供设置:
run prune 由上述两个窗口推导动作:
```text
keepCompletedRuns
keepFailedRuns
maxRunWorkspacesPerChat
maxCheckpointBytesPerRun
autoPruneAfterDays
rank < keep_full_recent_runs:
keep full workspace/debug artifacts
keep_full_recent_runs <= rank < keep_recent_terminal_runs:
keep core history, slim heavy artifacts
rank >= keep_recent_terminal_runs:
delete terminal run history
```
## 12. 性能约束

View file

@ -50,7 +50,7 @@ MCP / Tool Direct Call
- 当前 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` 绕过日志、secret 或 policy。Responses WebSocket 建连已复用 `HttpClientPool` 的 ChatCompletion WebSocket profile,不应扩散成第二套 LLM 调用链。见 `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`。
- 前端 Public ABI 的统一入口是 `window.__TAURITAVERN__`,应保持小而稳定。见 `docs/FrontendHostContract.md` 第 3.6 节、`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`。

View file

@ -59,7 +59,6 @@ api.agent.tools.list()
```ts
approveToolCall()
listRuns()
readDiff()
rollback()
```
@ -314,7 +313,7 @@ Phase 3 基线已在 Agent tool 层接入 Skill 可见性、deny policy 与 read
内容:
- Agent run 控制入口与状态摘要。
- 最小 timeline/event viewer。
- Run timeline / detail viewer;主面板使用互斥视图状态机,不依赖横向 scroll-snap 或 scroll 事件反写状态。
- workspace artifact viewer。
- tool error / recovery 状态展示。
- commit preview 与手动提交。

View file

@ -151,6 +151,30 @@ https://v2.tauri.app/develop/resources/#android
- 平台差异被收敛到一个模块,不向业务层扩散;
- 未来若 iOS 出现类似目录异常,可在同一模块增加 `cfg(target_os = "ios")` 分支,不需要修改各仓储。
### 3.4 公共 Downloads 导出不是应用数据目录问题
角色卡、Preset、WorldInfo 等前端普通导出遵循浏览器下载语义:用户点击导出后,文件应出现在 Android 公共下载目录(通常显示为 `/storage/emulated/0/Download`),而不是 app-scoped 外部目录。
需要特别区分:
- `appDataDir/localDataDir` 问题处理的是应用数据根目录;
- Tauri Android 的 `downloadDir()` 可能解析到 `/storage/emulated/0/Android/data/<package>/files/Download`,这对应用私有文件是合理位置,但不是用户导出文件的成功目标;
- Tauri fs capability 只表达 Tauri 插件 allowlist,不等价于 Android scoped storage 的系统级公共目录写入授权。
当前方案:
- 普通 Blob 下载主链仍收口在前端 `download()` / `downloadBlobWithRuntime()`,保持 SillyTavern 上游调用语义;
- 前端先把 Blob 分块写入 app cache 下的 `tauritavern-export-staging`,native bridge 只接受该 staging 根下的 canonical file;
- Android 10+ 使用 native `MediaStore.Downloads` 写入公共 Downloads;
- Android 7-9 无可靠的无权限公共 Downloads 裸路径写入语义,回退到 SAF `ACTION_CREATE_DOCUMENT`,由用户选择保存目标;
- 不再把 app-scoped `Download` 作为普通导出的 fallback;公共下载写入失败必须向前端暴露错误。
维护原则:
- 不要通过修改 `infrastructure/paths.rs` 或强行拼接 `/storage/emulated/0/Download` 来实现普通导出;
- 不要给普通导出引入 `MANAGE_EXTERNAL_STORAGE` 或宽泛存储权限;
- 如需新增 Android 用户可见文件导出,优先复用 native public download bridge,而不是直接使用 Tauri `downloadDir()`。
---
## 4. 与上述问题相关的关键架构调整

View file

@ -6,7 +6,7 @@
## 当前基线
截至 2026-05-30,Agent 当前基线:
截至 2026-06-06,Agent 当前基线:
- Rust 后端已有 Agent domain model、runtime、workspace、journal、checkpoint、commit bridge。
- 前端已挂载 `window.__TAURITAVERN__.api.agent` Host ABI。
@ -14,15 +14,19 @@
- LLM 调用仍复用 `ChatCompletionService::generate_exchange_with_cancel()`,不得绕过现有 provider、secret、日志、endpoint policy、iOS policy、prompt cache 或取消链路。Responses WebSocket 建连已收敛到 `HttpClientPool` 的 ChatCompletion WebSocket profile,见 `docs/CurrentState/NativeApiFormats.md`。
- Agent runtime 已不再把 OpenAI-compatible raw JSON 当作内部事实;运行时使用 canonical `AgentModelRequest` / `AgentModelResponse` / `AgentModelMessage` / `AgentModelContentPart`。
- `AgentModelGateway` 在 Agent canonical IR 与现有 `ChatCompletionGenerateRequestDto` 之间转换;provider-native metadata 作为 opaque `Native` part 保留。
- `provider_state` 已是 run-scoped continuation contract;OpenAI Responses 使用它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。
- `provider_state` 已是 run-owned、invocation-scoped continuation contract;OpenAI Responses 使用它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。
- Agent Skill 管理、导入导出、embedded skill 提示导入、`skill.list` / `skill.search` / `skill.read` 已落地。
- Phase 3 Agent Profile 基线已落地:`profileId` 会解析为 `ResolvedAgentProfile`,驱动 tools、Skill、workspace roots、output artifact、tool budget、max rounds 与 model-facing prompt/tool descriptions。
- PromptManager 已为 Agent Mode 提供 `agentSystemPrompt` 组装位置与 reserved no-op `agentResults` 位置标记;`agentSystemPrompt` 内容只由 Agent Profile 提供,前端在该 PromptManager index materialize,runtime 只消费最终 messages 并拒绝内部 marker 泄漏;`agentResults` 不再向模型注入历史 commit 内容。
- Profile 已能通过 `preset.mode = "ref"` 使用独立 OpenAI/chat-completion preset,并通过 `model.mode = "connectionRef"` + `modelId` 使用独立 LLM Connection。当前完整 PromptAssemblyBroker 组装只覆盖 root run 启动前;return-mode child invocation 会使用 target Profile 的 system prompt 与 model binding,但运行中 subagent/handoff 的完整 preset assembly handshake 仍待实现。
- Return-mode SubAgent MVP 已落地:root/active invocation 可通过 `agent.list`、`agent.delegate`、`agent.await` 创建、查看或等待子任务;child invocation 使用 `task.return` 结束,不能直接 `workspace.commit` 或 `workspace.finish`。当前 child task 已由 run-scoped scheduler 后台并行执行;`agent.await` 只等待/查询结果,未显式 await 的 terminal results 会在父 Agent 下一次 tool turn 后注入下一轮模型请求。
- Profile 已能通过 `preset.mode = "ref"` 使用独立 OpenAI/chat-completion preset,并通过 `model.mode = "connectionRef"` + `modelId` 使用独立 LLM Connection。当前完整 PromptAssemblyBroker 组装覆盖 root run 启动前、return-mode child invocation 与 handoff invocation;运行中 invocation 仍走 host bridge handshake,Rust runtime 不手写替代 PromptManager。
- Return-mode SubAgent MVP 已落地:root/active invocation 可通过 `agent.list`、`agent.delegate`、`agent.await` 创建、查看或等待子任务;child invocation 使用 `task.return` 结束,不能直接 `workspace.commit`、`workspace.finish` 或 `agent.handoff`。当前 child task 已由 run-scoped scheduler 后台并行执行;`agent.await` 只等待/查询结果,未显式 await 的 terminal results 会在父 Agent 下一次 tool turn 后注入下一轮模型请求。
- Handoff MVP 已落地:具备 `delegation.canHandoff` 且显式暴露 `agent.handoff` 的 foreground owner 可把同一 `AgentRun` 的下一阶段交给 `allowAsHandoffTarget` 的 target Profile。handoff 创建 `TransferControl` task 与 `Handoff` invocation,不进入后台 scheduler;当前 invocation 记为 `Transferred` 后,executor 在同一 run 内继续目标 Agent 的工具循环。已经 `workspace.commit` 过的 Agent 仍允许 handoff,最终 handoff owner 可继续修订、再次 commit 并 `workspace.finish`。实现框架、结构体与事件序列见 `docs/Agent/Handoff.md`。
- 当前工具循环是非 streaming;provider stream 仍不是 Agent timeline event。
- Agent System 扩展开关开启时,当前前端会把普通发送、regenerate 与 overswipe 新候选生成接入 Agent;实际 root run 使用扩展设置中的 `activeProfileId`。Profile 面板的 `editingProfileId` 只表示当前正在编辑的配置档案,不影响生成。Agent Mode off 时上游 SillyTavern 生成、事件和保存语义不变。
- Agent System 前端已提供 run timeline / detail panel;timeline 以 `readEvents(beforeSeq, limit)` 读取最新页并按需补拉更早 journal 页,默认视图只投影用户可见操作,DOM 使用窗口化渲染避免长 run 在低端移动设备上堆积节点;详情面板顶部可拖动调整高度,高度仅作为扩展 UI 偏好保存,不进入 Agent Host ABI、journal 或 Rust runtime。
- Agent System 前端已提供 run timeline / detail panel;timeline 以 `readEvents(beforeSeq, limit)` 读取最新页并按需补拉更早 journal 页,默认视图只投影用户可见操作,DOM 使用窗口化渲染避免长 run 在低端移动设备上堆积节点;Timeline / Detail 使用 `detailsOpen` 单一事实源驱动互斥视图,不再依赖横向 scroll-snap、smooth scroll 或 scroll 事件反写状态,避免低端 WebView 在切换中进入半页卡住状态;移动端横向滑动只作为 Pointer Events 输入快捷方式,最终仍调用 `openDetails()` / `showTimeline()`,并通过 `touch-action: pan-y pinch-zoom` 保留原生纵向滚动与缩放;关闭 Detail 会 reset detail state 并取消 stale detail load,Detail 打开期间不测量或贴底隐藏的 timeline scroller;详情面板顶部可拖动调整高度,高度仅作为扩展 UI 偏好保存,不进入 Agent Host ABI、journal 或 Rust runtime。前端实现将 timeline 数据会话、projection 规范化、详情读取、显示格式与 Vue 外壳拆分:运行中贴片、历史回看弹窗与 SubAgent 局部 timeline 共享同一 session/detail primitives,但各自仍通过 Host ABI 表达运行态、只读历史态与 `invocationId` 局部事件页。
- Agent run history 已提供只读 `listRuns()` / `list_agent_runs(dto)` 入口;首版从 `_tauritavern/agent-workspaces/index/runs/*.json` 读取 `AgentRun` 摘要,支持 `chatRef`、`stableChatId`、`statuses`、时间游标与 limit。列表会维护 `_tauritavern/agent-workspaces/index/run-summaries/<run-id>.json` projection,从 `chat_commit_completed.messageId` 派生提交当时的 `messageIndex`;该索引是提交时快照,不扫描聊天消息、不反查当前楼层。只有已经写入 terminal event 的终态 run 会复用/落盘 summary projection,运行中或终态事件尚未出现的窗口只即时投影。
- Agent run retention policy 已进入 `tauritavern-settings.agent.retention`:`auto_prune_enabled` 默认 `false`,控制 Rust 后端是否在 TauriTavern 进程运行期间周期性执行自动清理;`keep_recent_terminal_runs` 默认 100,表示保留最近 terminal run 的核心历史;`keep_full_recent_runs` 默认 20,表示其中保留完整 workspace/debug artifacts 的窗口。两个数量允许为 0,最大 10000,且 full 窗口必须是 history 窗口子集。前端 Host ABI 已提供 `api.agent.retention.readSettings()` / `updateSettings()` / `planPrune()` / `applyPrune()` facade,Agent System Runs 面板可保存策略、显示 dry-run 清理预览,并在用户确认后执行手动清理。后端已提供 `plan_agent_run_prune(dto)` 与 `apply_agent_run_prune(dto)` command,用当前设置或一次性 override 生成并执行 `slim_heavy_artifacts` / `delete_run` 候选;`apply` 使用显式 execution plan 模式,在后端重新规划全量候选,不信任前端预览明细,且同一服务实例内串行执行。自动清理由 `AgentRunRetentionAutomationService` 拥有生命周期,不依赖 Agent System 面板打开;保存 retention 设置只唤醒调度器重读配置,不同步执行清理。`slim_heavy_artifacts` 基于 Agent run storage class,路径归属词汇与 TT-Sync Agent run datasets 对齐,但 retention 不依赖同步 profile。明细受 `detailLimit` 限制但 totals 不截断,active run、缺失 terminal event、journal/storage 异常会进入 `blockedRuns`;单个执行失败会进入 `failedRuns`。
- 输入框 Agent 快捷开关 `#ttas_agent_send_toggle` 可以在 Agent System 扩展抽屉中隐藏;该状态只是扩展 UI 偏好,不改变 `agentModeEnabled`、生成路由、Agent Host ABI、journal 或 Rust runtime。
## 当前 Host ABI
@ -33,6 +37,12 @@ api.agent.startRunFromLegacyGenerate(input?)
api.agent.startRunWithPromptSnapshot(input)
api.agent.subscribe(runId, handler, options?)
api.agent.cancel(runId)
api.agent.submitGuidance(input)
api.agent.listRuns(input?)
api.agent.retention.readSettings()
api.agent.retention.updateSettings(input)
api.agent.retention.planPrune(input?)
api.agent.retention.applyPrune(input?)
api.agent.readEvents(input)
api.agent.readWorkspaceFile(input)
api.agent.readModelTurn(input)
@ -40,6 +50,7 @@ api.agent.promptAssembly.prepare(input)
api.agent.promptAssembly.buildSnapshot(input)
api.agent.profiles.list()
api.agent.profiles.load(input)
api.agent.profiles.diagnose(input)
api.agent.profiles.resolveSystemPrompt(input?)
api.agent.profiles.save(input)
api.agent.profiles.delete(input)
@ -50,7 +61,7 @@ api.llmConnections.save(input)
api.llmConnections.delete(input)
```
`startRunFromLegacyGenerate()` / `startRunWithPromptSnapshot()` 支持可选 `profileId`。Profile 管理、工具列表与 prompt assembly broker API 已封装到 `window.__TAURITAVERN__.api.agent` 与 `src/types.d.ts`;LLM Connection 管理已封装到 `window.__TAURITAVERN__.api.llmConnections`。Agent Profile 面板已提供独立 preset / model 选择,model 选择以 Connection Manager 的 Model Target 作为 UI 输入来源,保存时物化为 Agent domain 的 LLM Connection,并在 Profile 中持久化 `connectionRef + modelId`。
`startRunFromLegacyGenerate()` / `startRunWithPromptSnapshot()` 支持可选 `profileId`。Profile 管理、工具列表与 prompt assembly broker API 已封装到 `window.__TAURITAVERN__.api.agent` 与 `src/types.d.ts`;LLM Connection 管理已封装到 `window.__TAURITAVERN__.api.llmConnections`。Agent Profile 面板已提供独立 preset / model 选择,model 选择以 Connection Manager 的 Model Target 作为 UI 输入来源,Agent System 启动、Model Target 创建/更新、Profile 保存和 Agent run 启动前会物化为 Agent domain 的 LLM Connection;Profile 中只持久化 `connectionRef + modelId`,保存前会重新读取当前 Model Target,避免旧 UI 快照覆盖新 connection。
Skill 管理 API 已落地:
@ -66,7 +77,7 @@ api.skill.export(input)
`api.skill` 是用户/UI/扩展侧的 Skill 管理入口;Agent run 内只通过 `skill.list` / `skill.search` / `skill.read` 工具消费已安装 Skill。
`readModelTurn()` 读取指定 run/round/invocation 的模型回合显示 DTO:assistant 输出、可见/摘要化 reasoning、工具调用摘要与 provider 摘要;`invocationId` 省略时读取 root invocation。前端 Timeline 不直接解析 `model-responses/` raw 文件。
`readModelTurn()` 读取指定 run/round/invocation 的模型回合显示 DTO:assistant 输出、narration、可见/摘要化 reasoning、工具调用摘要与 provider 摘要;`invocationId` 省略时读取 root invocation。前端 Timeline 不直接解析 `model-responses/` raw 文件。
明确不存在公共 `api.agent.startRun()` alias。启动入口必须表达 prompt 来源:
@ -96,7 +107,7 @@ _tauritavern/agent-profiles/
- 非缺省 `profileId` 不存在时 fail-fast,不创建 run。
- `instructions.agentSystemPrompt` 省略或为 `null` 时使用 resolved profile 默认 Agent system prompt;设置为非空字符串时完整替换默认 prompt;空白字符串 fail-fast。Preset 控制 `agentSystemPrompt` 的位置与 role,不能编辑其内容。
- `preset.mode = "ref"` 会加载指定 OpenAI/chat-completion preset,经 Frontend PromptAssemblyBroker 真实复用 SillyTavern PromptManager 组装;`currentPromptSnapshot` / `none` 保留兼容路径。
- `model.mode = "connectionRef"` 要求 `connectionRef` 与 `modelId`,组装阶段会把 source/model 覆盖到 prompt settings,runtime 发送前会再次以 LLM Connection 权威覆盖 payload。
- `model.mode = "connectionRef"` 要求 `connectionRef` 与 `modelId`;若引用 `model-target-*`,Agent run 启动前会按当前保存 Model Target 重新物化 LLM Connection,但不会改写 Profile 的 `modelId`。组装阶段会把 source/model 覆盖到 prompt settings,runtime 发送前会再次以 LLM Connection 权威覆盖 payload。
- `model.mode = "requiresConfiguration"` 表示外部导入/分享后的 Profile 需要本机重新选择模型;该状态可保存、可展示,但 root run、SubAgent 与 prompt assembly 都会 fail-fast。
- `context.initialChatHistoryMessages` 只作用于 PromptManager 组装期的初始聊天历史窗口:`-1` 不做显式楼数裁剪,`0` 不向初始 prompt 注入真实聊天楼层,正数最多注入最近 N 楼。`FrozenRunInputSnapshot.promptInputs.messages` 保留 SillyTavern PromptManager 的 latest-first 完整候选历史;组装期只能 materialize 工作副本后裁剪、注入和反转,不能污染 frozen input,便于 child / handoff 按 target Profile 重新组装;`chat.read_messages`、`chat.search` 与 persist base 仍按 Rust runtime 的 run input prefix 工作。
- `tools.allow` / `tools.deny` 决定模型可见工具,dispatcher 会二次拦截不可见工具。
@ -104,10 +115,11 @@ _tauritavern/agent-profiles/
- `skills.visible` / `skills.deny` 控制 `skill.list`、`skill.search` 与 `skill.read`,`maxReadCharsPerCall` / `maxReadCharsPerRun` 控制 Skill 读取预算。
- 每个 invocation 按 `global -> preset -> profile -> character` 解析 active Skill scopes。root run 会固化 ambient `skillScopeRefs`;return-mode child 使用 target Profile 的 Skill policy,并按 target preset / run ambient character 解析可读 Skill。
- `workspace.visibleRoots` / `workspace.writableRoots` 只能收窄 root universe:`output`、`scratch`、`plan`、`summaries`、`persist`。
- `run.presentation` 区分 `foreground` / `background`,默认 built-in profile 为前台;`run.directRunnable` 控制 Profile 是否可被用户直接启动。直接可运行 Profile 必须暴露 `workspace.finish`,前台直接运行还必须暴露 `workspace.commit`;前端“可作为子 Agent”会将 Profile 设为 `directRunnable = false`,该模式当前要求同时允许作为 return-mode SubAgent。
- `run.presentation` 区分 `foreground` / `background`,默认 built-in profile 为前台;`run.directRunnable` 控制 Profile 是否可被用户直接启动。直接可运行 Profile 必须暴露 `workspace.finish`,前台直接运行还必须暴露 `workspace.commit`;非直接运行 Profile 必须可作为 return-mode SubAgent 或 handoff target。仅作为 handoff target 的前台 Profile 可以不暴露 `workspace.finish`,只要它仍能继续 handoff;若要成为最终收尾 Agent,则应暴露 `workspace.commit` / `workspace.finish`。
- `profiles.list()` 的 summary 暴露 `directRunnable`,Agent System UI 只允许直接可运行 Profile 成为 `activeProfileId`。保存或删除当前生效 Profile 导致其不可直接运行时,前端会把 `activeProfileId` 显式切回 built-in `default-writer`;不会把当前正在编辑的 Profile 自动设为生效。
- `profiles.diagnose()` 返回管理态 `AgentProfileHealth`,用于展示可加载 Profile 的外部资源健康度。第一期覆盖 missing/unsupported preset ref、`model.requiresConfiguration`、LLM Connection 缺失或无效;该 API 不改变 run / prompt assembly 的 fail-fast 语义,也不会让运行静默回退当前 UI preset/model。
- `run.modelRetry` 控制单次模型调用的瞬时错误重试;默认 `maxRetries = 3`、`intervalMs = 3000`。当前只重试 rate limit / transient transport-provider 错误,不重试 prompt/schema/native metadata/tool id 等契约错误。
- `delegation` 控制多 Agent 能力:`canDelegate` 决定当前 Agent 是否可见 `agent.delegate` / `agent.await`,`callable`、`allowAsSubagent`、`allowedCallers` 决定该 Profile 是否可被其他 Agent 作为 return-mode SubAgent 调用。`canHandoff` / `allowAsHandoffTarget` / `maxHandoffDepth` 已在 schema 中存在,但 `agent.handoff` 仍未实现。
- `delegation` 控制多 Agent 能力:`canDelegate` 决定当前 Agent 是否可见 `agent.delegate` / `agent.await`,`callable`、`allowAsSubagent`、`allowedCallers` 决定该 Profile 是否可被其他 Agent 作为 return-mode SubAgent 调用;`canHandoff` 决定当前 Agent 是否可见 `agent.handoff`,`allowAsHandoffTarget`、`allowedCallers` 与 `maxHandoffDepth` 决定该 Profile 是否可作为接力目标以及接力深度上限。
- `output.artifacts` 当前必须包含且只能包含一个 `messageBody` artifact;`workspace.commit` 默认发布该 artifact 的 path。
- Plan Mode schema 已存在,但当前只支持 `plan.mode = "none"`;其他 mode fail-fast。
- 每个 run 会在 `input/resolved_profile.json` 固化解析结果。
@ -122,6 +134,7 @@ Agent run 创建时,Rust runtime 会冻结本 run 的输入历史前缀:`swi
| --- | --- | --- | --- |
| `agent.list` | `agent_list` | read-only | 列出当前 Profile policy 允许调用的 Agent 目录;用于软渐进式披露可委派 Agent。 |
| `agent.delegate` | `agent_delegate` | control/mutating | 创建 return-mode 子任务与 child invocation,并提交 run-scoped scheduler 后台执行。 |
| `agent.handoff` | `agent_handoff` | control/mutating | 创建 TransferControl task 与 handoff invocation,让目标 Agent 接手同一 run 的下一阶段;调用方 handoff 后必须停止继续调用工具。 |
| `agent.await` | `agent_await` | read-only/control | 查询或等待当前 invocation 创建的 delegated task;不驱动 child task 执行。 |
| `task.return` | `task_return` | control/mutating | runtime-only child invocation 工具,提交 delegated task 结果并结束 child work。 |
| `chat.search` | `chat_search` | read-only | 搜索当前 run 绑定的聊天。只有 `query` 必填;可选 `limit`、`role`、`start_message`、`end_message`、`scan_limit`。 |
@ -137,9 +150,9 @@ Agent run 创建时,Rust runtime 会冻结本 run 的输入历史前缀:`swi
| `workspace.write_file` | `workspace_write_file` | mutating | 写 UTF-8 文件;`mode` 默认为完整替换,`append` 会原样追加并在缺失时创建文件;成功后按内容可知性更新 read-state 并创建 checkpoint。 |
| `workspace.apply_patch` | `workspace_apply_patch` | mutating | 单文件 `old_string` / `new_string` 精确替换;`old_string` 必须来自本 run 已读文本或本 run 创建/完整替换的文件;失败后同文件再次 patch 必须先全文读取。 |
| `workspace.commit` | `workspace_commit` | control/mutating | 将可见 workspace 文件提交到当前聊天;无参数等价于 `replace output/main.md`,`append` 首次创建消息、后续追加同一消息。 |
| `workspace.finish` | `workspace_finish` | control | 结束 root/active 工具循环;前台 run 必须已成功 commit,后台 run 可无 commit;return-mode child invocation 不可用;当前会取消 unfinished child tasks 而不阻塞完成。 |
| `workspace.finish` | `workspace_finish` | control | 结束 root/active 工具循环;前台 run 必须已有至少一次 run-level 成功 commit,后台 run 可无 commit;return-mode child invocation 不可用;当前会取消 unfinished return-mode child tasks 而不阻塞完成。 |
当前没有 MCP 工具、shell 工具、外部 extension tools、tool approval、profile routing、模型可见 task cancel 或 `agent.handoff`。
当前没有 MCP 工具、shell 工具、外部 extension tools、tool approval、profile routing 或模型可见 task cancel。
## Model Gateway 当前事实
@ -364,7 +377,7 @@ run_failed
Provider stream chunk 不是 Agent run event。Agent UI 必须订阅 `api.agent.subscribe(runId, handler)` 的 run event。
`model_completed` payload 当前包含 `round`、`modelResponsePath`、`toolCallCount`、assistant/reasoning 字节摘要与 `hasAssistantText` / `hasReasoning`。工具相关事件携带同一 `round`,便于 UI 从任意工具事件跳回本轮模型回合。
`model_completed` payload 当前包含 `round`、`modelResponsePath`、`toolCallCount`、assistant/reasoning 字节摘要与 `hasAssistantText` / `hasReasoning`。带工具调用且存在可展示 assistant visible text 的模型回合会额外携带可选 `narration` preview;它是模型轮次展示投影,不是 runtime status,不从 reasoning / thinking / thought 提取。工具相关事件携带同一 `round`,便于 UI 从任意工具事件跳回本轮模型回合。
`run_partial_success` 是 warning 级终态:当 run 已经有 host-confirmed `workspace.commit`,但之后因 drift、dispatch、persistent commit 或 persistent metadata 写回错误未能干净完成时,保留已提交 chat 输出,并在 payload 中暴露原始错误与 `preservedCommits`。它不是 `run_completed`,也不触发自动 rollback。partial success 消息不会带可复用的 `persistStateId`;下一轮 Agent run 会跳过它,继续寻找更早的 committed persistent state。
@ -475,17 +488,17 @@ const stop = agent.subscribe(run.runId, event => console.log(event));
- `pnpm run check:frontend`
- `pnpm run check:types`
- `pnpm run check:contracts`:218 passed
- `pnpm run check:contracts`:570 passed
- `git diff --check`
## 已知待办
- 将 `PromptSnapshot` 过渡输入逐步替换为 `GenerationIntent + ContextFrame`。
- 将 Profile overlay 扩展到 preset / character resolver。
- 为 return-mode subagent / handoff 增加完整运行中 prompt assembly handshake 与 invocation-scoped prompt snapshot;当前 child 已有独立 provider_state 与 model binding。
- 实现 `agent.handoff`、模型可见 task cancel 与更完整的 scheduler policy。
- 继续完善运行中 prompt assembly 的恢复/诊断体验;当前 child / handoff 已有独立 provider_state、model binding 与 host bridge prompt assembly handshake。
- 实现模型可见 task cancel 与更完整的 scheduler policy。
- 明确多 Agent provider/model switch policy;root run 的 `connectionRef` 模型绑定已经可用。
- 实现 readDiff、rollback、listRuns、resume-run、streaming 的明确策略。
- 实现 readDiff、rollback、resume-run、streaming 的明确策略。
## 每次 Agent 相关变更必须更新

View file

@ -139,7 +139,7 @@ Agent run event 当前会记录:
- `provider_state_updated`:只记录摘要字段,便于诊断 continuation。
- `model_response_stored`:保存完整 `AgentModelResponse` 到 `model-responses/round-XXX.json`。
- `model_completed`:包含 `round`、`modelResponsePath`、工具调用数与 assistant/reasoning 字节摘要。
- `model_completed`:包含 `round`、`modelResponsePath`、工具调用数、assistant/reasoning 字节摘要;带工具调用且存在可展示 assistant visible text 时包含可选 `narration` preview。
`model-responses/` 是 run workspace 内部诊断目录,不属于模型工具可见 roots。前端 Timeline 通过 `api.agent.readModelTurn({ runId, round })` 获取显示 DTO,不直接解析该目录中的 raw 文件。

View file

@ -0,0 +1,198 @@
# 角色身份契约现状
本文档记录 TauriTavern 当前已经落地的角色身份契约,重点覆盖角色头像文件名、聊天目录、旧错误目录兼容、以及后续开发时不能误改的边界。
目标读者:后续继续修改角色导入、编辑、聊天保存、聊天导出、Agent profile/skill 导入链路的开发者。
---
## 1. 范围与结论
TauriTavern 前端来源于 SillyTavern。角色相关接口必须遵循上游契约:
> `/api/characters/get` 返回和前端传回的 `avatar_url` 是一个精确的头像文件名,不是 URL,不是路径,也不是可被解析的资源地址。
例如:
- 合法身份值:`Alice#1.png`
- 对应 Rust 内部 character stem:`Alice#1`
- 对应 canonical chat directory key:`Alice#1`
因此任何角色身份链路都不能再对 `avatar_url` 做以下处理:
- `trim`
- `decodeURIComponent` / percent decode
- 去掉 `?query` 或 `#hash`
- 按 `/` 或 `\` 取 basename
- 把它当成 `/thumbnail?file=...` 一类资源 URL
这些变换只允许出现在后端 legacy resolver 内部,用来寻找历史版本已经错误创建的聊天目录。
---
## 2. 前端/API 边界
JS 侧的边界规则:
- `avatar_url` 表示 exact avatar filename。
- 当前只接受 `.png` 角色卡文件名。
- 文件名不能包含路径分隔符、查询/片段语义、控制字符或平台非法字符。
- 从 `avatar_url` 得到 character stem 时,只移除最后一个 `.png` 扩展名。
核心实现位置:
- `src/tauri/main/services/characters/character-identity.js`
持续开发约束:
- 路由、service、adapter、Agent 导入链路都应共用该身份模块。
- 不要在调用点重新写一套“宽松解析”。
- 如果调用方拿到的是资源 URL,应先回到产生该 URL 的业务上下文,不能把 URL 传进角色身份模块。
---
## 3. Rust 内部 key
Rust chat repository 当前接收的是 character stem,而不是 avatar filename。
也就是说:
- JS/API 边界:`Alice#1.png`
- Rust chat repository 参数:`Alice#1`
- 磁盘 canonical chat directory:`default-user/chats/Alice#1/`
核心实现位置:
- `src-tauri/src/infrastructure/repositories/chat_directory_identity.rs`
- `src-tauri/src/infrastructure/repositories/file_chat_repository/chat_dir_resolver.rs`
- `src-tauri/src/infrastructure/repositories/file_character_repository/helpers.rs`
持续开发约束:
- Rust resolver 的输入名虽然叫 `character_name`,实际语义是内部 stem/key。
- Rust resolver 不负责解析 avatar filename,也不负责解析 URL/path。
- Rust resolver 内部的 legacy candidate 生成,只是为了读取旧错误目录,不是新身份规则。
---
## 4. 聊天目录解析
当前角色聊天目录解析顺序:
1. 计算 canonical key。
2. 如果 alias store 已经记录该 key 的 legacy directory,并且目录仍存在,优先使用 alias。
3. 如果 canonical directory 已存在,使用 canonical directory。
4. 如果 canonical 不存在,尝试 safe legacy discovery。
5. 如果没有 legacy 命中,返回 canonical directory。
alias 优先于 canonical 是有意设计:
- 一旦系统确认 `Alice#1 -> Alice` 是旧错误目录,后续读写要继续落到同一个物理目录。
- 这样可以避免 canonical directory 后来出现时把历史聊天分裂成两份。
---
## 5. Legacy Discovery
legacy discovery 只服务于历史兼容。
它复现的是旧错误 normalizer 可能造成的目录名:
- `trim`
- 去掉 `?` 或 `#` 后的内容
- percent decode 后再去 query/hash
- percent decode 后再取 basename
命中条件:
- candidate directory 必须存在。
- candidate directory 至少包含一个 `.jsonl` 聊天文件。
- candidate 不能等于 canonical。
- 如果 `characters/<candidate>.png` 存在,则认为该目录属于另一个真实角色,不能抢占。
- 多个 legacy candidate 同时命中时 fail-fast,返回错误,不猜测。
这条规则避免了升级时全量扫描和批量迁移,对低端 Android/iOS 更安全:只有用户实际访问相关角色时才做一次小范围解析。
---
## 6. Alias Store
alias 文件位置:
- `default-user/user/cache/chat_aliases_v1.json`
alias key 是 Rust 内部 character stem,不是 avatar filename。
示例:
```json
{
"version": 1,
"aliases": {
"Alice#1": {
"dir": "Alice",
"reason": "legacy-avatar-url-normalizer",
"created_at": "2026-06-06T00:00:00Z"
}
}
}
```
当前生产 bootstrap 会把同一个 shared alias store 注入 character repository 和 chat repository:
- `FileCharacterRepository::with_chat_aliases(...)`
- `FileChatRepository::with_chat_aliases(...)`
这样两个 repository 不再各自持有独立 alias cache,避免并发懒发现时互相覆盖 alias 文件。
生产 bootstrap 会显式创建 shared alias store,并通过 `with_chat_aliases(...)` 注入给 character/chat repositories。`new(...)` 只是 isolated/single-repository 便捷构造;当同一运行时同时创建两个 repository 时,必须使用同一个 shared store。
---
## 7. Rename / Delete 当前语义
角色 rename:
- 新身份使用新 canonical key。
- 如果旧聊天目录通过 canonical/alias/legacy resolver 找到,并且新 canonical 目录不存在,则尝试把旧目录 rename 到新 canonical 目录。
- 如果文件系统 rename 失败,错误直接暴露。
角色 delete:
- 删除角色卡。
- 当 `delete_chats=true` 时,通过 resolver 找到当前身份对应的物理聊天目录并删除。
- 这包括已经确认的 alias 目录,也包括 safe legacy discovery 找到的历史错误目录。
这意味着 delete 语义偏向用户意图:“删除这个角色及其聊天”,而不是只删除 canonical directory。
---
## 8. 不支持的行为
当前不做启动期全量迁移:
- 不扫描全部 `characters/` 和 `chats/`。
- 不在升级时批量 rename 目录。
- 不自动合并多个可能相关的聊天目录。
- 不在 ambiguous legacy 情况下静默选择一个目录。
如需批量清理或显式迁移,应单独实现维护工具,并展示冲突项让用户确认。
---
## 9. 最容易误改的契约
后续开发时尤其不要做这些事:
1. 把 `avatar_url` 当成 URL/path 解析。
2. 在路由或 service 里临时写 `trim/decode/basename`。
3. 在 Rust resolver 里把 legacy candidate 当作新规则。
4. 让 character repository 和 chat repository 在生产路径各自创建 alias store。
5. 升级时递归扫描并重命名大量聊天目录。
6. ambiguous legacy 时静默降级到某个目录。
正确方向是:
- 新数据严格使用 exact avatar filename -> stem -> canonical directory。
- 旧错误目录只通过 alias/lazy resolver 兼容。
- 发现歧义时 fail-fast。

View file

@ -109,7 +109,7 @@ Android 说明:
当前策略:
- **Admission**:仅观察 `document.body` 的直系子节点新增/移除(`subtree: false`),并对带 `script_id` 的 portal root 进一步扫描其子树(JS-Slash-Runner 常见挂载形态)。
- **生命周期**:对已经进入跟踪集的候选 surface,只监听其自身 `class/style/hidden/open/aria-hidden` 属性变化,用于撤销/恢复 host-admitted contract;属性重算按 animation frame 合并。稳定的 `free-window` 只响应 inline lifecycle style(`display/visibility/position/pointer-events/cursor/touch-action`)变化,几何类 style 写入保持在拖动热路径之外;仍不做全局 subtree/style observer。
- **生命周期**:对已经进入跟踪集的候选 surface,只监听其自身 `class/style/hidden/open/aria-hidden` 属性变化,用于撤销/恢复 host-admitted contract;属性重算按 animation frame 合并。稳定 surface 只响应 inline lifecycle style(`display/visibility/position/pointer-events/cursor/touch-action`)变化,几何类 style 写入与宿主 contract 变量写入不会重新进入分类;仍不做全局 subtree/style observer。
- **判定**:对符合条件且当前可见的 `position: fixed` 节点进行 surface 分类(backdrop / viewport-host / fullscreen-window / free-window / edge-window)。
- **输出**:不再直接写入 `top`;改为输出契约属性:
- `data-tt-mobile-surface="backdrop|viewport-host|fullscreen-window|free-window|edge-window"`

View file

@ -70,3 +70,7 @@
14. `docs/CurrentState/AgentProviderState.md`
- Agent `provider_state` 当前契约
- 包含 run-scoped continuation、OpenAI Responses persistent WebSocket / incremental input / `previous_response_id`、内部字段剥离、native metadata fail-fast 与可观测性约束
15. `docs/CurrentState/CharacterIdentityContract.md`
- 角色身份契约当前实现快照
- 包含 `avatar_url` exact filename 契约、Rust stem key、chat directory alias/lazy resolver、rename/delete 当前语义与持续开发约束

View file

@ -148,7 +148,7 @@
### 6.1 产物与入口
- webpack 入口:`webpack.config.js`
- Rspack 入口:`rspack.config.js`
- `lib.core` → `src/lib-bundle-core.js` → `src/dist/lib.core.bundle.js`
- `lib.optional` → `src/lib-bundle-optional.js` → `src/dist/lib.optional.bundle.js`
@ -159,7 +159,7 @@
- 可选库按需加载:
- `getHljs()`:动态 import optional bundle;并注册 `stscript` 语言(`src/scripts/slash-commands/stscript-hljs-language.js`)
- `getReadability()`:动态 import optional bundle
- `initLibraryShims()`:将少量库挂到 `window`(用于第三方扩展兼容)
- `initLibraryShims()`:将少量库挂到 `window`(用于第三方扩展兼容);其中 `window._ = lodash` 是正式 ABI,需先于 third-party 扩展模块求值完成
### 6.3 代码高亮(延迟执行)
@ -206,7 +206,7 @@ Panel Runtime 会在 `APP_READY` 后安装,用于在抽屉关闭时把部分
- 主页尽早可见可点(Shell 先到)。
- `APP_READY` 表示主应用已可交互;late-loaded third-party 扩展依赖其 auto-fire 语义继续完成初始化。
- third-party 扩展资源同源加载与 `window.hljs` 兼容(见上)。
- third-party 扩展资源同源加载,并提供 `window._`(lodash)与 `window.hljs` 兼容(见上)。
不保证(需要按需扩展白名单/契约):

View file

@ -1,4 +1,4 @@
# 同步(LAN Sync v1 / TT-Sync v2)当前落地状态
# 同步(LAN Sync v1 / LAN Sync v2 / TT-Sync v2)当前落地状态
本文档描述 **当前已经落地** 的同步能力现状:它解决什么问题、端到端链路如何工作、明确支持/不支持的边界、以及后续开发最容易误改的契约。
@ -8,16 +8,19 @@
## 1. 范围与结论
TauriTavern 当前存在两套同步链路:
TauriTavern 当前存在三种同步协议形态:
- **LAN Sync(v1)**:局域网内设备间同步(协议与事件语义对齐上游 SillyTavern)。
- **TT-Sync(v2)**:公网远端同步(TauriTavern ⇄ TT-Sync 服务端),语义与 LAN Sync 对齐,但协议独立演进。
- **LAN Sync v1**:局域网内设备间同步的遗留 HTTP 协议;保留兼容,不再扩展新同步能力。
- **LAN Sync v2**:局域网 HTTPS/SPKI peer 协议;复用 TT-Sync v2 的 session、manifest、plan、bundle 与 DatasetPolicy 语义。
- **TT-Sync v2**:远端同步(TauriTavern ⇄ TT-Sync 服务端);与 LAN Sync v2 共享协议语义,但拓扑是 remote hub。
关键结论(后续改动优先守住这些):
1. **同步语义以“用户数据一致性”为中心**:scope/exclude、`(size_bytes, modified_ms)` 增量判定、Mirror delete 的时序、原子写入与 mtime 保留。
2. **同步作业全局串行**:LAN Sync 与 TT-Sync 共用同一个 `Semaphore(1)`(即同一时刻只能跑一个同步作业),避免两条链路并发写入相同数据目录导致破坏性竞态(见 `src-tauri/src/app/bootstrap.rs`)。
3. **TT-Sync 已落地 Bundle 传输形态**:在公网高 RTT 场景下把 N 个 per-file 请求收敛为 1 个 bundle 请求,并可选 zstd 压缩;旧的 per-file 端点仍保留作为 fallback。
2. **同步作业全局串行**:LAN Sync v1/v2 与 TT-Sync v2 共用同一个 `Semaphore(1)`(即同一时刻只能跑一个同步作业),避免多条链路并发写入相同数据目录导致破坏性竞态(见 `src-tauri/src/app/bootstrap.rs`)。
3. **长期同步 scope 由 TT-Sync `DatasetPolicy` 定义**:LAN Sync v2 与 TT-Sync v2 消费同一份策略;LAN Sync v1 冻结旧 allowlist,不再扩展 Agent 等新范围。
4. **v2 协议已落地 Bundle + zstd 传输形态**:把 N 个 per-file 请求收敛为 1 个 bundle 请求,并可选 zstd 压缩;旧的 per-file 端点仍保留作为 fallback。
5. **Sync Panel 入口默认走 v2 scoped sync**:前端持久化一份 `DatasetSelection` 作为后续 LAN v2 / TT-Sync v2 默认范围,并要求对端支持 `bundle_v1 + zstd_v1`;旧 LAN v1 设备只保留兼容,不再作为面板主路径。
---
@ -28,33 +31,93 @@ TauriTavern 当前存在两套同步链路:
当前目录结构(默认用户目录下):
- LAN Sync 状态:`default-user/user/lan-sync/`
- `config.json` / `identity.json` / `paired-devices.json`(见 `src-tauri/src/infrastructure/lan_sync/store.rs`)
- v1:`config.json` / `identity.json` / `paired-devices.json`(见 `src-tauri/src/infrastructure/lan_sync/store.rs`)
- 自动同步本地配置:`automation.json`(随 App 启动开端口、运行期自动上传目标/间隔/范围;见 `src-tauri/src/infrastructure/sync_automation_store.rs`)
- v2:`v2/identity.json` / `v2/peers.json` / v2 TLS 状态(见 `src-tauri/src/infrastructure/lan_sync/v2/store.rs`)
- TT-Sync v2 状态:`default-user/user/lan-sync/tt-sync-v2/`
- `identity.json` / `paired-servers.json`(见 `src-tauri/src/infrastructure/tt_sync/store.rs`)
TT-Sync 的 manifest 扫描严格遵循 `ttsync_core::scope`,并且会排除 LAN/TT 同步状态目录(见 `src-tauri/src/infrastructure/tt_sync/fs.rs`)。
LAN Sync v2 与 TT-Sync v2 的 manifest 扫描严格遵循 `ttsync_core::dataset::ResolvedDatasetPolicy`,并且会排除 LAN/TT 同步状态目录(见 `src-tauri/src/infrastructure/tt_sync/fs.rs`)。当前 TauriTavern 默认数据集还会排除 `_tauritavern/prompt-cache/`、`_tauritavern/.ios-policy.json`、`_cache/`、`.staging` 与同步临时文件。
默认 TauriTavern 数据集已经覆盖 Agent 连续性数据:
- `_tauritavern/agent-profiles/profiles/**`
- `_tauritavern/llm-connections/**`
- `_tauritavern/skills/{installed,index}/**`
- `_tauritavern/agent-workspaces/chats/**/persistent-states/**`
- `_tauritavern/agent-workspaces/index/runs/**`
- `_tauritavern/agent-workspaces/chats/**/runs/*/{run.json,events.jsonl}`
`default-user/secrets.json`、Agent `model-responses/`、`checkpoints/`、`backups/`、`vectors/`、`thumbnails/` 被保留为独立数据集,不在 TauriTavern 推荐默认同步中;用户可以在 Sync Panel 的“Sync content”范围选择弹窗里显式勾选。
Agent run history 只同步终态运行。扫描器会读取 `run.json` / run index JSON 的 `status`,仅纳入 `completed`、`partial_success`、`cancelled`、`failed`;运行中的 `calling_model` 等状态不会进入 manifest。
Agent run retention 复用同一套 run storage class 词汇来描述 `run_journal`、`run_context`、`run_workspace_projection`、`run_tool_io` 等路径归属,但 prune 策略不读取 `DatasetSelection`,同步 scope 仍只由 TT-Sync `DatasetPolicy` 决定。
LAN Sync v1 仍使用原有固定 allowlist 和裸 `LanSyncManifest` plan 请求。它不会同步 Agent 数据,也不会接入 DatasetPolicy;新范围应进入 LAN Sync v2/TT-Sync v2。
---
## 3. 事件语义(前端可观测契约)
两套同步都对前端暴露“阶段(phase)+ 进度(files/bytes)”事件,语义上保持一致:
两类产品入口都对前端暴露“阶段(phase)+ 进度(files/bytes)”事件,语义上保持一致:
- LAN Sync:
- LAN Sync(v1/v2 共用事件通道):
- pairing 请求事件:`lan_sync:pair_request`
- 进度/完成/错误:`lan_sync:progress` / `lan_sync:completed` / `lan_sync:error`
- runtime:`src-tauri/src/infrastructure/lan_sync/runtime.rs`
- TT-Sync:
- 进度/完成/错误:`tt_sync:progress` / `tt_sync:completed` / `tt_sync:error`
- runtime:`src-tauri/src/infrastructure/tt_sync/runtime.rs`
- 自动同步:
- 状态/提示:`sync_auto:status` / `sync_auto:toast`
- 自动 TT-Sync push 的 `tt_sync:*` payload 会额外带 `origin: "auto"`;前端监听器据此不打开手动进度弹窗,也不触发手动 pull 完成后的 reload。
**不要破坏事件时序**:允许提升并发与吞吐,但不应改动“哪个阶段会发什么事件、完成/错误何时发”的外部语义。
---
## 4. TT-Sync v2:端到端链路(现在如何工作)
## 4. 前端 Sync Panel 契约
### 4.1 Pair(绑定服务端)
Sync Panel 是 TauriTavern 自有设置面板,不属于上游 SillyTavern 事件 ABI。它遵循现有 host wrapper 边界:
- `src/scripts/tauri/setting/sync-app/**` 只做 Vue 展示组件,不直接访问 Tauri invoke、Popup、扫码服务或 SillyTavern host API。
- `src/scripts/tauri/setting/setting-panel/sync-popup.js` 拥有 popup / Tauri invoke / QR 扫码能力,并负责把 UI 选择转换为命令参数。
- `sync_v2_get_dataset_catalog` 返回当前 `DatasetPolicy` 版本、支持的数据集 ID、profile ID 与 TauriTavern 默认范围;前端只持久化 dataset ID,不持久化路径。
- “Sync content”是独立持久化设置,保存在 localStorage。保存后所有 Sync Panel 发起的 LAN v2 pull、LAN v2 push-request、TT-Sync pull/push 默认都携带同一份 `DatasetSelection`。
- 自动同步配置保存在后端本地 `automation.json`,不进入同步 scope。Sync Panel 保存自动同步设置或同步范围时,会把当前 `DatasetSelection` 写入这份本地配置,供面板关闭后的 Rust 调度器使用。
- Sync Panel 展示并复制 LAN v2 pairing URI/QR;粘贴 LAN pairing URI 时只接受 `tauritavern://lan-sync/pair?v=2`。已存在的 LAN v1 设备会显示为 legacy,面板禁用其 pull/push 操作并要求重新 v2 配对。
- Sync Panel 发起 v2 同步时传入 `require_bundle_zstd: true`。如果对端缺少 `bundle_v1` 或 `zstd_v1`,操作 fail-fast,不静默降级到 per-file 或 LAN v1。
### 4.1 自动同步契约
- 自动同步由 Rust 后端 `SyncAutomationService` 拥有生命周期,不依赖 Sync Panel 打开,也不使用前端 `setInterval`。
- 自动同步只在 App 进程运行期间工作;冷启动后延迟 **45 秒** 才允许第一次自动上传。
- 自动同步只做上传:
- TT-Sync 目标:执行 v2 push。
- LAN v2 目标:发送 v2 pull-request,让对端从本机回拉;本机只能确认“上传请求已发送”,实际写入发生在目标设备。
- TT-Sync 自动上传复用本机当前 Sync mode;Incremental / Mirror 都允许。LAN v2 自动上传沿用现有 pull-request 语义,实际下载与 Mirror delete 由目标设备执行,因此删除行为取决于目标设备的有效 Sync mode。Mirror 可能删除目标端不存在于源端的文件,面板固定提示同步期间不要在目标设备上使用或编辑数据。
- 自动同步最小间隔为 5 分钟,最大间隔为 1440 分钟。配置启用时必须选择目标;LAN 自动目标必须是 v2 peer,TT-Sync 自动目标必须具备 write 权限。
- “随 App 启动开启同步端口”只启动 LAN server / LAN v2 HTTPS server,不自动开启配对。
- 自动同步成功通过 SillyTavern `toastr.info` 提示;失败通过 `toastr.warning` 提示。手动同步仍使用原来的进度弹窗、完成弹窗与必要 reload。
---
## 5. v2 同步链路(现在如何工作)
LAN Sync v2 与 TT-Sync v2 共享 `/v2/*` 协议族:
- `GET /v2/status`
- `POST /v2/session/open`
- `POST /v2/sync/pull-plan`
- `POST /v2/sync/push-plan`
- `GET/PUT /v2/plans/{plan_id}/files/{path_b64}`
- `GET/PUT /v2/plans/{plan_id}/bundle`
- `POST /v2/plans/{plan_id}/commit`
两者差异主要在拓扑与配对入口:TT-Sync v2 绑定远端服务端;LAN Sync v2 由本机启动 HTTPS peer server,并在 LAN pairing URI 中携带 SPKI pin。
### 5.1 TT-Sync v2 Pair(绑定远端服务端)
入口:`tt_sync_pair`(`src-tauri/src/presentation/commands/tt_sync_commands.rs`)→ `TtSyncService::pair`(`src-tauri/src/application/services/tt_sync_service.rs`)。
@ -69,7 +132,7 @@ TT-Sync 的 manifest 扫描严格遵循 `ttsync_core::scope`,并且会排除 L
- `base_url` **必须是 https**,并进行 **SPKI pinning**(见 `src-tauri/src/infrastructure/tt_sync/v2_api.rs`)。
- Pair 只建立信任与权限,不传输用户数据。
### 4.2 Push / Pull(同步)
### 5.2 TT-Sync v2 Push / Pull(远端同步)
入口:`tt_sync_push` / `tt_sync_pull`(`src-tauri/src/presentation/commands/tt_sync_commands.rs`)。
@ -77,14 +140,15 @@ TT-Sync 的 manifest 扫描严格遵循 `ttsync_core::scope`,并且会排除 L
1. **全局 permit**:尝试获取同步许可;失败则发 error 事件并直接返回(见 `src-tauri/src/application/services/tt_sync_service.rs`)。
2. `POST /v2/session/open`:用 Ed25519 对 canonical request 签名,获得 `session_token` 与 `granted_permissions`。
3. Scanning:扫描本地 manifest(`src-tauri/src/infrastructure/tt_sync/fs.rs`)。
4. Diffing:请求 plan:
3. Status:读取 `GET /v2/status`,必须支持 `dataset_scope_v1` 且 `dataset_policy_version` 匹配;旧 server 会 fail-fast,避免静默漏同步 Agent 数据。
4. Scanning:按调用方传入的 `DatasetSelection` 扫描本地 manifest;未显式传入时使用 TauriTavern default selection(`src-tauri/src/infrastructure/tt_sync/fs.rs`)。
5. Diffing:携带同一份 `DatasetSelection` 请求 plan:
- pull:`POST /v2/sync/pull-plan`
- push:`POST /v2/sync/push-plan`
5. Transfer:
- **优先 bundle**(需服务端 `features` 声明支持;见 5.x)
6. Transfer:
- **优先 bundle**(需服务端 `features` 声明支持;见 6.x)
- 否则 fallback 到 per-file 并发传输
6. Deleting(仅 Mirror):
7. Deleting(仅 Mirror):
- pull:本地按 plan.delete 删除
- push:在 commit 后由服务端应用删除(Mirror 语义)
@ -96,23 +160,38 @@ push 的额外步骤:
- push 在上传完毕后 `POST /v2/plans/{plan_id}/commit`,Mirror delete 只在 commit 阶段生效(保持语义一致性)。
### 5.3 LAN Sync v2 Pair / Pull / Push(局域网 peer)
入口仍是现有 LAN Sync 命令面(`src-tauri/src/presentation/commands/lan_sync_commands.rs`):
1. `lan_sync_start_server` 会同时启动 v1 HTTP server 与 v2 HTTPS server。
2. `lan_sync_enable_pairing` / `lan_sync_get_pairing_info` 同时返回 v1 与 v2 pairing URI/QR;v2 URI 包含 `base_url`、pair token、过期时间与 `spki_sha256`。Sync Panel 默认只展示 v2 URI/QR。
3. `lan_sync_request_pairing` 可解析 v1/v2 URI。v2 pairing 通过 `POST /v2/lan/pair/complete` 建立 Ed25519 身份、SPKI pin 与 peer grant。
4. `lan_sync_sync_from_device` 优先识别 v2 peer,并走 LAN Sync v2 pull;找不到 v2 peer 时,只有未传 `SyncV2OperationOptions` 的旧调用才 fallback 到 v1 pull。Sync Panel 总是传 options,因此不会静默落回 v1。
5. `lan_sync_push_to_device` 对 v2 peer 不直接上传文件,而是 `POST /v2/lan/pull-request` 请求对端回拉;pull-request body 会携带同一份 `SyncV2OperationOptions`,实际数据传输仍发生在对端的 v2 pull 链路。对端需声明 `lan_pull_request_selection_v1` 才能接受 Sync Panel 的 scoped push-request。
LAN Sync v2 默认权限是 `read: true`、`mirror_delete: true`、`write: false`。也就是说 peer 可以从本机读取并按 Mirror 语义计算删除,但不能直接向本机 PUT 写入;局域网“push”通过通知对端 pull 来保持写入方向清晰。
---
## 5. TT-Sync v2:传输形态(per-file vs bundle)
## 6. v2 传输形态(per-file vs bundle)
### 5.1 能力协商(features)
### 6.1 能力协商(features)
客户端会先调用 `GET /v2/status` 获取 `features`(失败则当作不支持,走 fallback):
客户端会先调用 `GET /v2/status` 获取 `features` 与 DatasetPolicy 版本;状态请求失败、`dataset_scope_v1` 缺失或策略版本不匹配都会 fail-fast:
- `bundle_v1`:支持 bundle 端点
- `zstd_v1`:支持 bundle 的 zstd 编解码
- `dataset_scope_v1`:支持携带 `DatasetSelection` 的 scope-aware plan/delete
- `lan_pull_request_selection_v1`:LAN v2 peer 支持在 `/v2/lan/pull-request` body 中携带 `DatasetSelection`
客户端策略(见 `src-tauri/src/infrastructure/tt_sync/push.rs`、`src-tauri/src/infrastructure/tt_sync/pull.rs`):
客户端策略(见 `src-tauri/src/infrastructure/tt_sync/push.rs`、`src-tauri/src/infrastructure/tt_sync/pull.rs`、`src-tauri/src/infrastructure/lan_sync/v2/pull.rs`):
- 仅当存在 `bundle_v1` 才启用 bundle。
- 仅当同时存在 `bundle_v1` + `zstd_v1` 才启用 zstd(push 不能“试一试”,必须先确认)。
- `dataset_scope_v1` 缺失或策略版本不匹配时直接报错。
- 未要求严格传输形态的旧调用:仅当存在 `bundle_v1` 才启用 bundle;仅当同时存在 `bundle_v1` + `zstd_v1` 才启用 zstd。
- Sync Panel 调用:传入 `require_bundle_zstd: true`,缺少 `bundle_v1` 或 `zstd_v1` 都会 fail-fast。
### 5.2 per-file(fallback,兼容路径)
### 6.2 per-file(fallback,兼容路径)
端点:`GET/PUT /v2/plans/{plan_id}/files/{path_b64}`。
@ -122,7 +201,7 @@ push 的额外步骤:
- TT-Sync 使用更高并发(桌面 16 / 移动 8):`src-tauri/src/infrastructure/tt_sync/transfer.rs`
- 所有写入都走原子写入并保留 mtime:`src-tauri/src/infrastructure/sync_fs.rs`
### 5.3 bundle(bundle_v1:把 N 个文件合并为 1 个请求)
### 6.3 bundle(bundle_v1:把 N 个文件合并为 1 个请求)
端点:
@ -133,7 +212,7 @@ push 的额外步骤:
- `Content-Type: application/x-ttsync-bundle`
wire framing(见 `src-tauri/src/infrastructure/tt_sync/bundle.rs`):
wire framing(见 `src-tauri/src/infrastructure/sync_bundle.rs`):
1. `path_len: u32`(大端)
2. `path: [u8; path_len]`(UTF-8;必须能构造为 `SyncPath`)
@ -144,38 +223,44 @@ wire framing(见 `src-tauri/src/infrastructure/tt_sync/bundle.rs`):
- `path_len` 上限为 **16KiB**(避免异常请求造成内存放大)。
- 服务端必须拒绝“提前结束/缺文件/重复文件/不在 plan 内”的 bundle(保证 Mirror commit 不会在部分上传时发生)。
- TauriTavern v2 客户端当前显式偏向 HTTP/1.1。bundle 是单个长流,现有 reqwest/hyper HTTP/2 默认 flow-control 在局域网实测下不如 HTTP/1.1;协议本身仍只要求 HTTPS + SPKI pinning,不把 HTTP 版本暴露为外部契约。
### 5.4 zstd(zstd_v1:端到端流式压缩)
### 6.4 zstd(zstd_v1:端到端流式压缩)
压缩只作用于 **bundle 流整体**:
- pull:客户端发送 `Accept-Encoding: zstd`;服务端返回 `Content-Encoding: zstd` 或 identity
- push:客户端仅在确认 `zstd_v1` 后才发送 `Content-Encoding: zstd`
当前 LAN Sync v2 pull 与 TT-Sync v2 pull 共用 `src-tauri/src/infrastructure/sync_bundle.rs` 解包路径;TT-Sync v2 push 也复用同一组 bundle framing helper。
---
## 6. 正确性与断线重试(稳定性边界)
## 7. 正确性与断线重试(稳定性边界)
当前实现 **不做 byte-range resume**,但保证“断线不会破坏数据”,并提供可接受的重试语义:
1. **每文件精确读取**:bundle 解包按 plan 的 `size_bytes` 精确读取;若底层流提前 EOF,会报错并中止(见 `ExactSizeReader`:`src-tauri/src/infrastructure/tt_sync/bundle.rs`)。
1. **每文件精确读取**:bundle 解包按 plan 的 `size_bytes` 精确读取;若底层流提前 EOF,会报错并中止(见 `ExactSizeReader`:`src-tauri/src/infrastructure/sync_bundle.rs`)。
2. **原子写入**:每文件都走 `tmp → rename → set mtime`;断线发生在写入过程中只会留下 tmp,不会覆盖目标文件(`src-tauri/src/infrastructure/sync_fs.rs`)。
3. **自然续传**:失败后重新扫描 manifest 并重新计算 plan;已成功写入的文件会因为 `(size_bytes, modified_ms)` 匹配而不再出现在新 plan.transfer 中。
---
## 7. 明确不支持(避免误解的非目标)
## 8. 明确不支持(避免误解的非目标)
- 同步 scope 内 **不支持 symlink**(扫描时直接报错,见 `src-tauri/src/infrastructure/tt_sync/fs.rs`)。
- TT-Sync v2 **不提供** bundle 内的 byte-range/断点续传;重试依赖“自然续传”。
- 不允许 LAN Sync 与 TT-Sync 并发执行(全局 permit 设计即为此)。
- v2 协议 **不提供** bundle 内的 byte-range/断点续传;重试依赖“自然续传”。
- 不允许 LAN Sync v1/v2 与 TT-Sync v2 并发执行(全局 permit 设计即为此)。
---
## 8. 后续开发最容易误改的点(约束清单)
## 9. 后续开发最容易误改的点(约束清单)
1. **不要把 sync state 纳入 scope**:`default-user/user/lan-sync/**` 必须长期保持 excluded。
2. **不要改变 Mirror delete 的时序**:删除只能在 Mirror 且 commit/删除阶段发生,避免数据不一致。
3. **不要破坏 mtime 语义**:增量 diff 依赖 `(size_bytes, modified_ms)`,写入必须保留 `modified_ms`。
4. **不要改动事件语义**:阶段划分与完成/错误时序对前端是契约。
5. **不要把 iOS policy 本地缓存纳入 scope**:`_tauritavern/.ios-policy.json` 属于 iOS-only 宿主本地状态,用于避免同步覆盖 `tauritavern-settings.json` 时丢失已解锁的 policy。
6. **不要在 v2 链路重新引入手写 scope 数组**:新增同步目录必须先进入 TT-Sync `DatasetPolicy`,再由 LAN Sync v2/TT-Sync v2 消费。
7. **不要把敏感/重型 Agent 数据默认并入无选择同步**:`model-responses/`、`checkpoints/` 与密钥文件需要保持独立数据集。
8. **不要绕过 Sync Panel 的持久化 selection**:前端显示、保存、命令参数必须围绕 `DatasetSelection`;不要在 UI 中复制路径规则或用 manifest omission 伪装范围选择。

View file

@ -61,7 +61,7 @@
额外兼容层:
- `src/lib.js` 会把部分上游常用库挂到 `window`
- `src/lib.js` 会把部分上游常用库挂到 `window`;其中 `window._`(lodash)是正式兼容 ABI,因为 JS-Slash-Runner、ST-Prompt-Template、MagVarUpdate 等生态扩展会在模块求值阶段直接访问 `_`
- `src/tauri/main/compat/mobile/mobile-runtime-compat.js` 负责旧 WebView 缺失 JS API 的 polyfills(仅 Tauri mobile)
- 第三方浮层/窗口 mobile surface compat(仅 Tauri mobile):
- 分类/契约输出:`src/tauri/main/compat/mobile/mobile-overlay-surface-admission.js`

View file

@ -89,7 +89,17 @@
- `push()`:注入分享 payload(url 或 png)。
- `subscribe()`:订阅消费;若早到则进入 backlog,首次订阅会 drain backlog。
### 3.5 平台 ABI(Public,新)
### 3.5 上游库兼容全局(Public)
由 `src/lib.js:initLibraryShims()` 安装:
- `window._ : lodash`
- SillyTavern 生态中的 third-party 扩展可能把 lodash external 为 `_`,并在 ESM 模块求值阶段直接访问。
- 该符号必须在 third-party 扩展模块加载前可用;不得依赖 webpack/Rspack 等打包器偶然泄漏全局。
该 ABI 属于 SillyTavern 兼容层,不放入 `window.__TAURITAVERN__.api`。新 TauriTavern 代码仍应从 `src/lib.js` 显式 import `lodash`。
### 3.6 平台 ABI(Public,新)
为避免未来继续扩散 `window.__TAURITAVERN_*` 零散符号,宿主层额外提供一个**统一出口**:
@ -171,6 +181,7 @@
- 当前已落地 Host ABI:`list()`、`load()`、`save()`、`delete()`。
- Profile 只保存 `model.mode = "connectionRef"`、`connectionRef` 与 `modelId`,或保存分享/导入用的 `model.mode = "requiresConfiguration"`;不直接保存 Connection Manager 的 Model Target id。
- Connection Manager Model Target 可以作为 UI 输入来源,但转换成 LLM Connection 时必须保真;无法表达的字段必须显式报错,不得静默丢弃。
- Agent System 负责在启动、Model Target 创建/更新、Profile 保存和 Agent run 启动前同步 `model-target-*` LLM Connection;启动 reconcile 或更新无法保真物化时会删除对应派生 connection;删除 Model Target 不隐式删除已物化 LLM Connection。
- Rust command 名与 repository/file layout 属于 Internal 实现细节,不是第三方 Public Contract。
- `api.skill`:TauriTavern Agent Skill 管理 API。用于列出、预览导入、安装、读取和导出本地 Skill。

View file

@ -30,7 +30,7 @@
- **框架**: 保留SillyTavern原有的前端代码
- **核心技术**: HTML, CSS, JavaScript (jQuery)
- **构建工具**: Webpack (用于库打包)
- **构建工具**: Rspack (用于前端资源打包)
## 2. 后端技术栈
@ -78,8 +78,7 @@
### 3.2 前端构建工具
- **Webpack**: 用于打包第三方库
- **Babel**: JavaScript转译
- **Rspack**: 用于打包第三方库与 TauriTavern 前端入口
### 3.3 前端与后端通信
@ -160,7 +159,7 @@
- **资源加载**: 优化资源加载顺序
- **库注入链路**: `lib.js` 静态导入 `src/dist/lib.core.bundle.js`;重库通过 `getHljs()/getReadability()` 动态加载 `src/dist/lib.optional.bundle.js`
- **延迟加载**: 非关键资源延迟加载
- **构建缓存**: webpack filesystem cache 提升增量构建速度
- **构建缓存**: Rspack persistent cache 提升增量构建速度
## 8. 兼容性和迁移

View file

@ -229,7 +229,7 @@ fn is_desktop_distributable(path: &Path) -> bool {
fn normalize_android_abi(abi: &str) -> &str {
match abi {
"arm" => "armeabi-v7a",
"arm64" => "arm64",
"arm64" => "arm64-v8a",
"x86" => "x86",
"x86_64" => "x86_64",
other => other,

View file

@ -1,7 +1,7 @@
{
"name": "tauritavern",
"private": true,
"version": "2.1.0",
"version": "2.1.1",
"type": "module",
"scripts": {
"tauri": "tauri",
@ -16,8 +16,8 @@
"check:contracts": "pnpm run test:contracts",
"test": "pnpm run test:contracts",
"test:contracts": "node --test --test-concurrency=1 \"tests/**/*.test.mjs\"",
"web:build": "webpack --config webpack.config.js",
"web:dev": "webpack --config webpack.config.js --watch",
"web:build": "rspack build --config rspack.config.js",
"web:dev": "rspack build --config rspack.config.js --watch",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:portable": "node scripts/build-portable.mjs",
@ -32,19 +32,19 @@
"fastools:run": "cargo run --manifest-path fastools/Cargo.toml"
},
"devDependencies": {
"@rspack/cli": "^2.0.8",
"@rspack/core": "^2.0.8",
"@tauri-apps/cli": "^2.10.0",
"@types/jquery": "^3.5.33",
"@types/jquery-cropper": "^1.0.4",
"@types/jquery.transit": "^0.9.33",
"@types/jqueryui": "^1.12.24",
"@types/lodash": "^4.17.24",
"@types/node": "^18.19.84",
"@types/node": "^20.19.43",
"@types/select2": "^4.0.63",
"@types/toastr": "^2.1.43",
"es-module-lexer": "^2.0.0",
"typescript": "^5.9.3",
"webpack": "^5.105.4",
"webpack-cli": "^5.1.4"
"typescript": "^5.9.3"
},
"dependencies": {
"@adobe/css-tools": "^4.4.4",
@ -70,6 +70,6 @@
"vue": "^3.5.33"
},
"engines": {
"node": ">= 20"
"node": "^20.19.0 || >=22.12.0"
}
}

1126
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

237
rspack.config.js Normal file
View file

@ -0,0 +1,237 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import * as rspack from '@rspack/core';
// Get the directory name of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const cacheEnvironment = `${process.platform}-${process.arch}-node${process.versions.node.split('.')[0]}`;
const commonCacheInputs = [
'rspack.config.js',
'package.json',
'pnpm-lock.yaml',
];
const libraryCacheInputs = [
...commonCacheInputs,
'src/lib.js',
'src/lib-bundle-core.js',
'src/lib-bundle-optional.js',
];
const agentSystemCacheInputs = [
...commonCacheInputs,
...listJavaScriptFiles('src/scripts/extensions/agent-system/src'),
...listJavaScriptFiles('src/scripts/tauritavern/agent'),
];
const tauriSettingUiCacheInputs = [
...commonCacheInputs,
...listJavaScriptFiles('src/scripts/tauri/setting/settings-app'),
...listJavaScriptFiles('src/scripts/tauri/setting/dev-logs-app'),
...listJavaScriptFiles('src/scripts/tauri/setting/sync-app'),
];
function resolveRepoPath(file) {
return path.resolve(__dirname, file);
}
function listJavaScriptFiles(relativeDir) {
const root = resolveRepoPath(relativeDir);
const results = [];
const stack = [root];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.isFile() && path.extname(entry.name) === '.js') {
results.push(path.relative(__dirname, fullPath).replace(/\\/g, '/'));
}
}
}
return results.sort();
}
function buildCacheVersion(name, inputFiles) {
const hash = crypto.createHash('sha256');
hash.update(`name=${name}\n`);
hash.update(`platform=${process.platform}\n`);
hash.update(`arch=${process.arch}\n`);
hash.update(`node=${process.versions.node}\n`);
hash.update(`rspack=${rspack.rspackVersion}\n`);
for (const file of inputFiles) {
hash.update(`file=${file}\n`);
hash.update(fs.readFileSync(resolveRepoPath(file)));
hash.update('\n');
}
return hash.digest('hex');
}
function createPersistentCache(name, inputFiles) {
return {
type: 'persistent',
version: buildCacheVersion(name, inputFiles),
buildDependencies: commonCacheInputs.map(resolveRepoPath),
storage: {
type: 'filesystem',
directory: path.resolve(__dirname, '.cache/rspack', cacheEnvironment, name),
},
};
}
const sharedResolve = {
extensions: ['.js'],
alias: {
'/lib.js': path.resolve(__dirname, 'src/lib.js'),
'/script.js': path.resolve(__dirname, 'src/script.js'),
'/scripts': path.resolve(__dirname, 'src/scripts'),
},
fallback: {
"path": false,
"fs": false,
"crypto": false,
"stream": false,
"buffer": false,
"util": false,
"assert": false,
"os": false,
"http": false,
"https": false,
"url": false
}
};
const sharedOptimization = {
moduleIds: 'deterministic',
chunkIds: 'deterministic',
};
const sharedPerformance = {
hints: false,
maxEntrypointSize: 5120000,
maxAssetSize: 5120000
};
const sharedStats = {
preset: 'normal',
assets: true,
chunks: true,
modules: true,
entrypoints: true,
timings: true,
builtAt: true,
logging: 'warn',
cachedAssets: false,
cachedModules: false,
chunkModules: false,
assetsSort: '!size',
modulesSort: '!size',
assetsSpace: 20,
modulesSpace: 20,
};
function createVueDefinePlugin() {
return new rspack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false),
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false),
});
}
const coreConfig = {
name: 'vendor-libs',
mode: 'production',
bail: true,
target: ['web', 'es2020'],
cache: createPersistentCache('vendor-libs', libraryCacheInputs),
entry: {
'lib.core': './src/lib-bundle-core.js',
'lib.optional': './src/lib-bundle-optional.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'src/dist'),
module: true,
library: {
type: 'module'
}
},
resolve: sharedResolve,
optimization: sharedOptimization,
performance: sharedPerformance,
stats: sharedStats,
};
const agentSystemConfig = {
name: 'agent-system',
dependencies: ['vendor-libs'],
mode: 'production',
bail: true,
target: ['web', 'es2020'],
cache: createPersistentCache('agent-system', agentSystemCacheInputs),
entry: {
index: './src/scripts/extensions/agent-system/src/index.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'src/scripts/extensions/agent-system/dist'),
module: true,
library: {
type: 'module'
},
clean: true,
},
resolve: sharedResolve,
optimization: sharedOptimization,
performance: sharedPerformance,
stats: sharedStats,
plugins: [
createVueDefinePlugin(),
],
};
const tauriTavernSettingsConfig = {
name: 'tauritavern-settings',
dependencies: ['vendor-libs'],
mode: 'production',
bail: true,
target: ['web', 'es2020'],
cache: createPersistentCache('tauritavern-settings', tauriSettingUiCacheInputs),
entry: {
settings: './src/scripts/tauri/setting/settings-app/index.js',
'dev-logs': './src/scripts/tauri/setting/dev-logs-app/index.js',
sync: './src/scripts/tauri/setting/sync-app/index.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'src/scripts/tauri/setting/dist'),
module: true,
library: {
type: 'module'
},
clean: true,
},
resolve: sharedResolve,
optimization: sharedOptimization,
performance: sharedPerformance,
stats: sharedStats,
plugins: [
createVueDefinePlugin(),
],
};
export default [coreConfig, agentSystemConfig, tauriTavernSettingsConfig];

View file

@ -14,7 +14,7 @@ function printHelp() {
Options:
--target <triple> Rust target triple, e.g. x86_64-pc-windows-msvc
--output-dir <path> Portable output directory (default: release)
--skip-web-build Skip frontend webpack build
--skip-web-build Skip frontend bundle build
--help Show this help message
`);
}

166
src-tauri/Cargo.lock generated
View file

@ -67,6 +67,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -269,13 +278,13 @@ dependencies = [
[[package]]
name = "axum"
version = "0.7.9"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
@ -289,35 +298,57 @@ dependencies = [
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-server"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
dependencies = [
"arc-swap",
"bytes",
"either",
"fs-err",
"http",
"http-body",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
@ -1041,7 +1072,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1243,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1403,6 +1434,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-err"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
dependencies = [
"autocfg",
"tokio",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@ -2676,9 +2717,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
@ -2939,7 +2980,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -3340,6 +3381,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -3723,7 +3774,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"heck 0.4.1",
"itertools",
"log",
"multimap",
@ -3876,7 +3927,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@ -4041,6 +4092,19 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -4241,7 +4305,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -4271,6 +4335,15 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@ -4299,7 +4372,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5392,11 +5465,12 @@ dependencies = [
[[package]]
name = "tauritavern"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"async-compression",
"async-trait",
"axum",
"axum-server",
"base64 0.22.1",
"block2",
"bytes",
@ -5449,6 +5523,7 @@ dependencies = [
"tracing-subscriber",
"ttsync-contract",
"ttsync-core",
"ttsync-http",
"typed-path",
"url",
"uuid",
@ -5467,7 +5542,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5809,6 +5884,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5847,6 +5923,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -5930,9 +6007,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttsync-contract"
version = "1.0.0"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c676651619c641d33124f4bea2719913480527eb07b76a4ae82f6128627a6599"
checksum = "aa805c8c74317544b9f24a5b61aa671eebb4fdb5bde3a5b20bd09b7429ba1385"
dependencies = [
"base64 0.22.1",
"serde",
@ -5942,9 +6019,9 @@ dependencies = [
[[package]]
name = "ttsync-core"
version = "1.0.0"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7935aae284eee368f06a7ac1d468fad23b627114f242aede210fe9210ee939f6"
checksum = "dc1522f0bb05d97d155c2015328201246a849adea8e07cd2c0aba4a04d370902"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
@ -5959,6 +6036,34 @@ dependencies = [
"uuid",
]
[[package]]
name = "ttsync-http"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0249128c8d22fed7c9535845f21810b35a3a3eaf378c8e6e4c4aa90d8a9c2e30"
dependencies = [
"async-compression",
"axum",
"axum-server",
"base64 0.22.1",
"futures-util",
"rcgen",
"reqwest",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"sha2",
"tokio",
"tokio-util",
"tracing",
"ttsync-contract",
"ttsync-core",
"url",
"uuid",
"webpki-roots",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@ -6490,7 +6595,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7096,6 +7201,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"

View file

@ -1,6 +1,6 @@
[package]
name = "tauritavern"
version = "2.1.0"
version = "2.1.1"
description = "SillyTavern backend rebuilt with Tauri and Rust"
authors = ["TauriTavern Team"]
edition = "2024"
@ -62,16 +62,18 @@ flate2 = "1.1.9"
crc32fast = "1"
filetime = "0.2"
miktik = { version = "0.2.0", default-features = false, features = ["openai", "huggingface", "sentencepiece"] }
axum = { version = "0.7", default-features = false, features = ["http1", "json", "multipart", "tokio"] }
axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "multipart", "query", "tokio"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
local-ip-address = "0.6"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
rustls = "0.23"
webpki-roots = "1"
dunce = "1"
# TT-Sync v2 shared contract/scope (crates.io)
ttsync-contract = "~1.0.0"
ttsync-core = "~1.0.0"
# TT-Sync v2 shared protocol crates. (crates.io)
ttsync-contract = "2"
ttsync-core = "2"
ttsync-http = "2"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-dialog = "2.7.0"

View file

@ -35,12 +35,6 @@
},
{
"path": "$DOCUMENT/**"
},
{
"path": "/storage/emulated/0/Download/**"
},
{
"path": "/sdcard/Download/**"
}
]
}

View file

@ -20,6 +20,10 @@
@android.webkit.JavascriptInterface <methods>;
}
-keepclassmembers class com.tauritavern.client.AndroidPublicDownloadJsBridge {
@android.webkit.JavascriptInterface <methods>;
}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

View file

@ -0,0 +1,59 @@
package com.tauritavern.client
import android.content.ContentResolver
import android.net.Uri
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.channels.FileChannel
object AndroidContentFileTransfer {
private const val COPY_BUFFER_BYTES = 4 * 1024 * 1024
fun copyContentUriToFile(
contentResolver: ContentResolver,
contentUri: Uri,
targetFile: File,
): String {
targetFile.parentFile?.mkdirs()
requireNotNull(contentResolver.openInputStream(contentUri)).use { input ->
targetFile.outputStream().use { output -> input.copyTo(output, COPY_BUFFER_BYTES) }
}
return targetFile.absolutePath
}
fun copyFileToContentUri(
contentResolver: ContentResolver,
sourceFile: File,
targetUri: Uri,
failureMessage: String,
): String {
FileInputStream(sourceFile).channel.use { source ->
requireNotNull(contentResolver.openFileDescriptor(targetUri, "wt")).use { descriptor ->
FileOutputStream(descriptor.fileDescriptor).channel.use { target ->
transferFile(source, target, failureMessage)
target.force(true)
}
}
}
return targetUri.toString()
}
private fun transferFile(
source: FileChannel,
target: FileChannel,
failureMessage: String,
) {
val size = source.size()
var position = 0L
while (position < size) {
val transferred = source.transferTo(position, size - position, target)
check(transferred > 0L) { failureMessage }
position += transferred
}
}
}

View file

@ -4,9 +4,6 @@ import android.content.ContentResolver
import android.net.Uri
import android.webkit.JavascriptInterface
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.channels.FileChannel
class AndroidImportArchiveJsBridge(
private val contentResolver: ContentResolver,
@ -32,13 +29,8 @@ class AndroidImportArchiveJsBridge(
): String {
val uri = Uri.parse(requireNotNull(contentUri).trim())
val targetFile = File(requireNotNull(targetPath).trim())
targetFile.parentFile?.mkdirs()
requireNotNull(contentResolver.openInputStream(uri)).use { input ->
targetFile.outputStream().use { output -> input.copyTo(output, COPY_BUFFER_BYTES) }
}
return targetFile.absolutePath
return AndroidContentFileTransfer.copyContentUriToFile(contentResolver, uri, targetFile)
}
@JavascriptInterface
@ -49,34 +41,15 @@ class AndroidImportArchiveJsBridge(
val sourceFile = File(requireNotNull(sourcePath).trim())
val targetUri = Uri.parse(requireNotNull(contentUri).trim())
FileInputStream(sourceFile).channel.use { source ->
requireNotNull(contentResolver.openFileDescriptor(targetUri, "w")).use { descriptor ->
FileOutputStream(descriptor.fileDescriptor).channel.use { target ->
transferFile(source, target)
target.force(true)
}
}
}
return targetUri.toString()
}
private fun transferFile(
source: FileChannel,
target: FileChannel,
) {
val size = source.size()
var position = 0L
while (position < size) {
val transferred = source.transferTo(position, size - position, target)
check(transferred > 0L) { "Failed to copy export archive to destination URI" }
position += transferred
}
return AndroidContentFileTransfer.copyFileToContentUri(
contentResolver = contentResolver,
sourceFile = sourceFile,
targetUri = targetUri,
failureMessage = "Failed to copy export archive to destination URI",
)
}
companion object {
private const val COPY_BUFFER_BYTES = 4 * 1024 * 1024
private const val DEFAULT_EXPORT_FILE_NAME = "tauritavern-data.zip"
const val INTERFACE_NAME = "TauriTavernAndroidImportArchiveBridge"
}

View file

@ -0,0 +1,157 @@
package com.tauritavern.client
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.JavascriptInterface
import org.json.JSONObject
import java.io.File
import java.util.Locale
class AndroidPublicDownloadJsBridge(
private val contentResolver: ContentResolver,
private val exportStagingRoot: File,
private val launchCreateDocumentPicker: (String, String) -> Unit,
) {
@JavascriptInterface
fun supportsDirectPublicDownloads(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
@JavascriptInterface
fun requestCreateDocumentPicker(
suggestedName: String?,
mimeType: String?,
) {
launchCreateDocumentPicker(
normalizeDisplayName(suggestedName),
normalizeMimeType(mimeType),
)
}
@JavascriptInterface
fun copyFileToContentUri(
sourcePath: String?,
contentUri: String?,
): String {
val sourceFile = resolveSourceFile(sourcePath)
val targetUri = Uri.parse(requireNotNull(contentUri).trim())
return AndroidContentFileTransfer.copyFileToContentUri(
contentResolver = contentResolver,
sourceFile = sourceFile,
targetUri = targetUri,
failureMessage = "Failed to copy export file to destination URI",
)
}
@JavascriptInterface
fun saveFileToDownloads(
sourcePath: String?,
displayName: String?,
mimeType: String?,
): String {
require(supportsDirectPublicDownloads()) {
"Direct public Downloads export requires Android 10 or newer"
}
val sourceFile = resolveSourceFile(sourcePath)
val normalizedName = normalizeDisplayName(displayName)
val normalizedMimeType = normalizeMimeType(mimeType)
val values =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, normalizedName)
put(MediaStore.MediaColumns.MIME_TYPE, normalizedMimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_PENDING, 1)
put(MediaStore.MediaColumns.SIZE, sourceFile.length())
}
val uri =
requireNotNull(contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)) {
"Failed to create public Downloads entry"
}
var committed = false
try {
AndroidContentFileTransfer.copyFileToContentUri(
contentResolver = contentResolver,
sourceFile = sourceFile,
targetUri = uri,
failureMessage = "Failed to copy export file to public Downloads",
)
val publishValues =
ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}
val updated = contentResolver.update(uri, publishValues, null, null)
check(updated > 0) { "Failed to publish public Downloads entry" }
committed = true
return JSONObject()
.apply {
put("uri", uri.toString())
put("display_name", normalizedName)
put("mime_type", normalizedMimeType)
put("relative_path", "${Environment.DIRECTORY_DOWNLOADS}/$normalizedName")
put("saved_path", publicDownloadDisplayPath(normalizedName))
}.toString()
} finally {
if (!committed) {
contentResolver.delete(uri, null, null)
}
}
}
private fun resolveSourceFile(sourcePath: String?): File {
val sourceFile = File(requireNotNull(sourcePath).trim()).canonicalFile
val stagingRoot = exportStagingRoot.canonicalFile
require(sourceFile.isFile) { "Export staging file not found: ${sourceFile.absolutePath}" }
require(sourceFile.isDescendantOf(stagingRoot)) {
"Export source file is outside the public download staging directory"
}
return sourceFile
}
private fun normalizeDisplayName(value: String?): String {
val normalizedName = value?.trim().orEmpty().ifBlank { DEFAULT_DOWNLOAD_FILE_NAME }
require(!normalizedName.contains('/') && !normalizedName.contains('\\')) {
"Export filename must not contain path separators"
}
return normalizedName
}
private fun normalizeMimeType(value: String?): String {
val mimeType = value
?.substringBefore(';')
?.trim()
.orEmpty()
return if (MIME_TYPE_PATTERN.matches(mimeType)) {
mimeType.lowercase(Locale.ROOT)
} else {
DEFAULT_MIME_TYPE
}
}
private fun File.isDescendantOf(directory: File): Boolean {
val directoryPath = directory.path
return path.startsWith("$directoryPath${File.separator}")
}
@Suppress("DEPRECATION")
private fun publicDownloadDisplayPath(displayName: String): String =
File(Environment.getExternalStorageDirectory(), "${Environment.DIRECTORY_DOWNLOADS}/$displayName")
.absolutePath
companion object {
private const val DEFAULT_DOWNLOAD_FILE_NAME = "download.bin"
private const val DEFAULT_MIME_TYPE = "application/octet-stream"
private val MIME_TYPE_PATTERN =
Regex("^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$")
const val EXPORT_STAGING_ROOT_NAME = "tauritavern-export-staging"
const val INTERFACE_NAME = "TauriTavernAndroidPublicDownloadBridge"
}
}

View file

@ -1,5 +1,6 @@
package com.tauritavern.client
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
@ -13,6 +14,7 @@ import android.webkit.WebChromeClient
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
@ -49,6 +51,10 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
handleExportArchivePickerResult(uri)
}
private val publicDownloadPickerLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handlePublicDownloadPickerResult(result.resultCode, result.data?.data)
}
private val importArchiveJsBridge: AndroidImportArchiveJsBridge by lazy {
AndroidImportArchiveJsBridge(
contentResolver = contentResolver,
@ -56,6 +62,15 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
launchExportArchivePicker = { suggestedName -> launchExportArchivePicker(suggestedName) },
)
}
private val publicDownloadJsBridge: AndroidPublicDownloadJsBridge by lazy {
AndroidPublicDownloadJsBridge(
contentResolver = contentResolver,
exportStagingRoot = File(cacheDir, AndroidPublicDownloadJsBridge.EXPORT_STAGING_ROOT_NAME),
launchCreateDocumentPicker = { suggestedName, mimeType ->
launchPublicDownloadDocumentPicker(suggestedName, mimeType)
},
)
}
private val readinessPoller: WebViewReadinessPoller by lazy {
WebViewReadinessPoller(webViewProvider = { webView }, isDestroyed = { isActivityDestroyed })
@ -122,6 +137,10 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
importArchiveJsBridge,
AndroidImportArchiveJsBridge.INTERFACE_NAME,
)
webView.addJavascriptInterface(
publicDownloadJsBridge,
AndroidPublicDownloadJsBridge.INTERFACE_NAME,
)
insetsBridge.onWebViewAvailable()
sharePayloadDispatcher.requestDispatch()
}
@ -218,6 +237,25 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
}
}
private fun launchPublicDownloadDocumentPicker(
suggestedName: String,
mimeType: String,
) {
mainHandler.post {
if (isActivityDestroyed) {
return@post
}
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, suggestedName)
}
publicDownloadPickerLauncher.launch(intent)
}
}
private fun handleImportArchivePickerResult(uri: Uri?) {
if (uri == null) {
dispatchPickerResult(
@ -252,6 +290,26 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
)
}
private fun handlePublicDownloadPickerResult(
resultCode: Int,
uri: Uri?,
) {
if (resultCode != Activity.RESULT_OK || uri == null) {
dispatchPickerResult(
receiverName = PUBLIC_DOWNLOAD_PICKER_RECEIVER,
contentUri = "",
error = "Export destination selection cancelled",
)
return
}
dispatchPickerResult(
receiverName = PUBLIC_DOWNLOAD_PICKER_RECEIVER,
contentUri = uri.toString(),
error = "",
)
}
private fun dispatchPickerResult(
receiverName: String,
contentUri: String,
@ -280,5 +338,6 @@ class MainActivity : TauriActivity(), AndroidWebFullscreenHost {
companion object {
private const val IMPORT_ARCHIVE_PICKER_RECEIVER = "__TAURITAVERN_IMPORT_ARCHIVE_PICKER__"
private const val EXPORT_ARCHIVE_PICKER_RECEIVER = "__TAURITAVERN_EXPORT_ARCHIVE_PICKER__"
private const val PUBLIC_DOWNLOAD_PICKER_RECEIVER = "__TAURITAVERN_PUBLIC_DOWNLOAD_PICKER__"
}
}

View file

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.1.0</string>
<string>2.1.1</string>
<key>CFBundleVersion</key>
<string>2.1.0</string>
<string>2.1.1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSCameraUsageDescription</key>

View file

@ -2,7 +2,10 @@ use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use crate::application::services::agent_profile_diagnostic_service::AgentProfileDiagnosticService;
use crate::application::services::agent_profile_service::AgentProfileService;
use crate::application::services::agent_run_history_service::AgentRunHistoryService;
use crate::application::services::agent_run_retention_automation_service::AgentRunRetentionAutomationService;
use crate::application::services::agent_runtime_service::AgentRuntimeService;
use crate::application::services::asset_service::AssetService;
use crate::application::services::avatar_service::AvatarService;
@ -27,6 +30,7 @@ use crate::application::services::secret_service::SecretService;
use crate::application::services::settings_service::SettingsService;
use crate::application::services::skill_service::SkillService;
use crate::application::services::stable_diffusion_service::StableDiffusionService;
use crate::application::services::sync_automation_service::SyncAutomationService;
use crate::application::services::theme_service::ThemeService;
use crate::application::services::tokenization_service::TokenizationService;
use crate::application::services::translate_service::TranslateService;
@ -63,7 +67,10 @@ pub struct AppState {
pub preset_service: Arc<PresetService>,
pub quick_reply_service: Arc<QuickReplyService>,
pub agent_profile_service: Arc<AgentProfileService>,
pub agent_profile_diagnostic_service: Arc<AgentProfileDiagnosticService>,
pub prompt_assembly_service: Arc<PromptAssemblyService>,
pub agent_run_history_service: Arc<AgentRunHistoryService>,
pub agent_run_retention_automation_service: Arc<AgentRunRetentionAutomationService>,
pub agent_runtime_service: Arc<AgentRuntimeService>,
pub chat_completion_service: Arc<ChatCompletionService>,
pub llm_connection_service: Arc<LlmConnectionService>,
@ -75,6 +82,7 @@ pub struct AppState {
pub world_info_service: Arc<WorldInfoService>,
pub lan_sync_service: Arc<LanSyncService>,
pub tt_sync_service: Arc<TtSyncService>,
pub sync_automation_service: Arc<SyncAutomationService>,
pub update_service: Arc<UpdateService>,
pub native_regex_service: Arc<NativeRegexService>,
pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport,
@ -118,7 +126,10 @@ impl AppState {
preset_service: services.preset_service,
quick_reply_service: services.quick_reply_service,
agent_profile_service: services.agent_profile_service,
agent_profile_diagnostic_service: services.agent_profile_diagnostic_service,
prompt_assembly_service: services.prompt_assembly_service,
agent_run_history_service: services.agent_run_history_service,
agent_run_retention_automation_service: services.agent_run_retention_automation_service,
agent_runtime_service: services.agent_runtime_service,
chat_completion_service: services.chat_completion_service,
llm_connection_service: services.llm_connection_service,
@ -130,6 +141,7 @@ impl AppState {
world_info_service: services.world_info_service,
lan_sync_service: services.lan_sync_service,
tt_sync_service: services.tt_sync_service,
sync_automation_service: services.sync_automation_service,
update_service: services.update_service,
native_regex_service: services.native_regex_service,
ios_policy: services.ios_policy,
@ -170,6 +182,18 @@ pub fn spawn_initialization(app_handle: AppHandle, runtime_paths: RuntimePaths)
Err(error) => tracing::warn!("Failed to initialize default content: {}", error),
}
let sync_automation_service = app_handle
.state::<Arc<AppState>>()
.sync_automation_service
.clone();
sync_automation_service.start();
let agent_run_retention_automation_service = app_handle
.state::<Arc<AppState>>()
.agent_run_retention_automation_service
.clone();
agent_run_retention_automation_service.start();
match app_handle.emit("app-ready", ()) {
Ok(_) => tracing::debug!("Application is ready"),
Err(error) => tracing::error!("Failed to emit app-ready event: {}", error),

View file

@ -5,7 +5,10 @@ use tauri::{AppHandle, Manager};
use tokio::sync::Semaphore;
use crate::application::services::agent_model_gateway::ChatCompletionAgentModelGateway;
use crate::application::services::agent_profile_diagnostic_service::AgentProfileDiagnosticService;
use crate::application::services::agent_profile_service::AgentProfileService;
use crate::application::services::agent_run_history_service::AgentRunHistoryService;
use crate::application::services::agent_run_retention_automation_service::AgentRunRetentionAutomationService;
use crate::application::services::agent_runtime_service::AgentRuntimeService;
use crate::application::services::agent_workspace_lifecycle_service::{
AgentRunActivity, AgentWorkspaceLifecycleService,
@ -33,6 +36,7 @@ use crate::application::services::secret_service::SecretService;
use crate::application::services::settings_service::SettingsService;
use crate::application::services::skill_service::SkillService;
use crate::application::services::stable_diffusion_service::StableDiffusionService;
use crate::application::services::sync_automation_service::SyncAutomationService;
use crate::application::services::theme_service::ThemeService;
use crate::application::services::tokenization_service::TokenizationService;
use crate::application::services::translate_service::TranslateService;
@ -45,6 +49,7 @@ use crate::application::services::world_info_service::WorldInfoService;
use crate::domain::errors::DomainError;
use crate::domain::repositories::agent_invocation_repository::AgentInvocationRepository;
use crate::domain::repositories::agent_profile_repository::AgentProfileRepository;
use crate::domain::repositories::agent_profile_storage_health_repository::AgentProfileStorageHealthRepository;
use crate::domain::repositories::agent_run_repository::AgentRunRepository;
use crate::domain::repositories::agent_workspace_lifecycle_repository::AgentWorkspaceLifecycleRepository;
use crate::domain::repositories::asset_repository::AssetRepository;
@ -90,6 +95,7 @@ use crate::infrastructure::logging::llm_api_logs::{
LlmApiLogStore, LoggingChatCompletionRepository,
};
use crate::infrastructure::persistence::file_system::DataDirectory;
use crate::infrastructure::repositories::chat_directory_identity::new_shared_chat_alias_store_for_user_dir;
use crate::infrastructure::repositories::file_agent_profile_repository::FileAgentProfileRepository;
use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository;
use crate::infrastructure::repositories::file_asset_repository::FileAssetRepository;
@ -135,7 +141,10 @@ pub(super) struct AppServices {
pub preset_service: Arc<PresetService>,
pub quick_reply_service: Arc<QuickReplyService>,
pub agent_profile_service: Arc<AgentProfileService>,
pub agent_profile_diagnostic_service: Arc<AgentProfileDiagnosticService>,
pub prompt_assembly_service: Arc<PromptAssemblyService>,
pub agent_run_history_service: Arc<AgentRunHistoryService>,
pub agent_run_retention_automation_service: Arc<AgentRunRetentionAutomationService>,
pub agent_runtime_service: Arc<AgentRuntimeService>,
pub chat_completion_service: Arc<ChatCompletionService>,
pub llm_connection_service: Arc<LlmConnectionService>,
@ -147,6 +156,7 @@ pub(super) struct AppServices {
pub world_info_service: Arc<WorldInfoService>,
pub lan_sync_service: Arc<LanSyncService>,
pub tt_sync_service: Arc<TtSyncService>,
pub sync_automation_service: Arc<SyncAutomationService>,
pub update_service: Arc<UpdateService>,
pub native_regex_service: Arc<NativeRegexService>,
pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport,
@ -174,6 +184,7 @@ struct AppRepositories {
preset_repository: Arc<dyn PresetRepository>,
quick_reply_repository: Arc<dyn QuickReplyRepository>,
agent_profile_repository: Arc<dyn AgentProfileRepository>,
agent_profile_storage_health_repository: Arc<dyn AgentProfileStorageHealthRepository>,
agent_run_repository: Arc<dyn AgentRunRepository>,
agent_invocation_repository: Arc<dyn AgentInvocationRepository>,
agent_workspace_lifecycle_repository: Arc<dyn AgentWorkspaceLifecycleRepository>,
@ -252,8 +263,14 @@ pub(super) async fn build_services(
));
let agent_profile_service = Arc::new(AgentProfileService::new(
repositories.agent_profile_repository.clone(),
repositories.agent_profile_storage_health_repository.clone(),
repositories.preset_repository.clone(),
));
let agent_profile_diagnostic_service = Arc::new(AgentProfileDiagnosticService::new(
agent_profile_service.clone(),
repositories.preset_repository.clone(),
llm_connection_service.clone(),
));
let prompt_assembly_service = Arc::new(PromptAssemblyService::new(
agent_profile_service.clone(),
repositories.preset_repository.clone(),
@ -286,6 +303,15 @@ pub(super) async fn build_services(
llm_connection_service.clone(),
prompt_assembly_service.clone(),
));
let agent_run_history_service = Arc::new(AgentRunHistoryService::new(
repositories.agent_run_repository.clone(),
repositories.settings_repository.clone(),
agent_runtime_service.clone() as Arc<dyn AgentRunActivity>,
));
let agent_run_retention_automation_service = Arc::new(AgentRunRetentionAutomationService::new(
repositories.settings_repository.clone(),
agent_run_history_service.clone(),
));
let agent_workspace_lifecycle_service = Arc::new(AgentWorkspaceLifecycleService::new(
repositories.agent_workspace_lifecycle_repository.clone(),
agent_runtime_service.clone() as Arc<dyn AgentRunActivity>,
@ -350,6 +376,13 @@ pub(super) async fn build_services(
data_directory.default_user().to_path_buf(),
sync_permit,
));
let sync_automation_service = Arc::new(SyncAutomationService::new(
app_handle.clone(),
data_directory.default_user().to_path_buf(),
lan_sync_service.clone(),
tt_sync_service.clone(),
ios_policy.capabilities.sync.lan,
));
let secret_service = Arc::new(SecretService::new(
repositories.secret_repository,
@ -377,7 +410,10 @@ pub(super) async fn build_services(
preset_service,
quick_reply_service,
agent_profile_service,
agent_profile_diagnostic_service,
prompt_assembly_service,
agent_run_history_service,
agent_run_retention_automation_service,
agent_runtime_service,
chat_completion_service,
llm_connection_service,
@ -389,6 +425,7 @@ pub(super) async fn build_services(
world_info_service,
lan_sync_service,
tt_sync_service,
sync_automation_service,
update_service,
native_regex_service,
ios_policy,
@ -402,9 +439,10 @@ fn build_repositories(
let http_client_pool = app_handle.state::<Arc<HttpClientPool>>().inner().clone();
let data_root = data_directory.root().to_path_buf();
let default_user_dir = data_directory.default_user().to_path_buf();
let chat_aliases = new_shared_chat_alias_store_for_user_dir(data_directory.default_user());
let character_repository: Arc<dyn CharacterRepository> =
Arc::new(FileCharacterRepository::new(
Arc::new(FileCharacterRepository::with_chat_aliases(
data_directory.characters().to_path_buf(),
data_directory.chats().to_path_buf(),
data_directory
@ -412,13 +450,15 @@ fn build_repositories(
.join("thumbnails")
.join("avatar"),
data_directory.default_avatar().to_path_buf(),
chat_aliases.clone(),
));
let file_chat_repository = Arc::new(FileChatRepository::new(
let file_chat_repository = Arc::new(FileChatRepository::with_chat_aliases(
data_directory.characters().to_path_buf(),
data_directory.chats().to_path_buf(),
data_directory.group_chats().to_path_buf(),
data_directory.backups().to_path_buf(),
chat_aliases,
));
let chat_repository: Arc<dyn ChatRepository> = file_chat_repository.clone();
let group_chat_repository: Arc<dyn GroupChatRepository> = file_chat_repository;
@ -500,9 +540,13 @@ fn build_repositories(
let quick_reply_repository: Arc<dyn QuickReplyRepository> = Arc::new(
FileQuickReplyRepository::new(data_directory.default_user().join("QuickReplies")),
);
let agent_profile_repository: Arc<dyn AgentProfileRepository> = Arc::new(
FileAgentProfileRepository::new(data_root.join("_tauritavern").join("agent-profiles")),
);
let agent_profile_file_repository = Arc::new(FileAgentProfileRepository::new(
data_root.join("_tauritavern").join("agent-profiles"),
));
let agent_profile_repository: Arc<dyn AgentProfileRepository> =
agent_profile_file_repository.clone();
let agent_profile_storage_health_repository: Arc<dyn AgentProfileStorageHealthRepository> =
agent_profile_file_repository;
let llm_connection_repository: Arc<dyn LlmConnectionRepository> = Arc::new(
FileLlmConnectionRepository::new(data_root.join("_tauritavern").join("llm-connections")),
);
@ -572,6 +616,7 @@ fn build_repositories(
preset_repository,
quick_reply_repository,
agent_profile_repository,
agent_profile_storage_health_repository,
agent_run_repository,
agent_invocation_repository,
agent_workspace_lifecycle_repository,

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -5,7 +6,12 @@ use crate::domain::models::agent::profile::{
AgentContextPolicy, AgentPresetRef, AgentProfileDefinition, AgentProfileSummary,
};
use crate::domain::models::agent::{
AgentChatRef, AgentRunEvent, AgentRunPresentation, AgentRunStatus, AgentToolSpec,
AgentChatRef, AgentDelegationContinuation, AgentInvocationExitPolicy, AgentInvocationKind,
AgentInvocationStatus, AgentRunEvent, AgentRunPresentation, AgentRunSkillScopeRefs,
AgentRunStatus, AgentTaskStatus, AgentToolSpec,
};
use crate::domain::repositories::agent_profile_storage_health_repository::{
AgentProfileStorageIssue, AgentProfileStorageRepairAction,
};
#[derive(Debug, Clone, Deserialize)]
@ -166,10 +172,33 @@ pub struct AgentSaveProfileDto {
pub profile: AgentProfileDefinition,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRetargetPresetRefsDto {
pub from: AgentPresetRef,
pub to: AgentPresetRef,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRetargetPresetRefsResultDto {
pub updated: usize,
pub profile_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentListProfilesResultDto {
pub profiles: Vec<AgentProfileSummary>,
#[serde(default)]
pub issues: Vec<AgentProfileStorageIssue>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRepairProfileFileDto {
pub profile_id: String,
pub action: AgentProfileStorageRepairAction,
}
#[derive(Debug, Clone, Serialize)]
@ -203,6 +232,233 @@ pub struct AgentRunHandleDto {
pub status: AgentRunStatus,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSubmitGuidanceDto {
pub run_id: String,
pub text: String,
#[serde(default)]
pub client_guidance_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSubmitGuidanceResultDto {
pub run_id: String,
pub guidance_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_guidance_id: Option<String>,
pub status: String,
pub preview: String,
pub chars: usize,
pub words: usize,
pub pending_count: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentListRunsDto {
#[serde(default)]
pub chat_ref: Option<AgentChatRef>,
#[serde(default, alias = "stableId")]
pub stable_chat_id: Option<String>,
#[serde(default)]
pub statuses: Vec<AgentRunStatus>,
#[serde(default)]
pub before: Option<AgentListRunsCursorDto>,
#[serde(default = "default_run_list_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentListRunsCursorDto {
pub created_at: DateTime<Utc>,
pub run_id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunSummaryDto {
pub run_id: String,
pub workspace_id: String,
pub stable_chat_id: String,
pub chat_ref: AgentChatRef,
pub generation_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
#[serde(default, skip_serializing_if = "AgentRunSkillScopeRefs::is_empty")]
pub skill_scope_refs: AgentRunSkillScopeRefs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persist_base_state_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_message_count: Option<usize>,
pub presentation: AgentRunPresentation,
pub status: AgentRunStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub commit_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub committed_message: Option<AgentRunCommittedMessageDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunCommittedMessageDto {
pub commit_id: String,
pub message_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_index: Option<usize>,
pub committed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentListRunsResultDto {
pub runs: Vec<AgentRunSummaryDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<AgentListRunsCursorDto>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPlanRunPruneDto {
#[serde(default)]
pub retention: Option<AgentRunPruneRetentionDto>,
#[serde(default = "default_run_prune_detail_limit")]
pub detail_limit: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentApplyRunPruneDto {
#[serde(default)]
pub retention: Option<AgentRunPruneRetentionDto>,
#[serde(default = "default_run_prune_detail_limit")]
pub detail_limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPruneRetentionDto {
pub keep_recent_terminal_runs: u32,
pub keep_full_recent_runs: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPrunePlanDto {
pub retention: AgentRunPruneRetentionDto,
pub detail_limit: usize,
pub terminal_run_count: usize,
pub non_terminal_run_count: usize,
pub blocked_run_count: usize,
pub full_retained_run_count: usize,
pub core_retained_run_count: usize,
pub slim_candidate_count: usize,
pub delete_candidate_count: usize,
pub total_slim_file_count: usize,
pub total_slim_byte_count: u64,
pub total_delete_file_count: usize,
pub total_delete_byte_count: u64,
pub total_candidate_file_count: usize,
pub total_candidate_byte_count: u64,
pub candidate_details_truncated: bool,
pub candidates: Vec<AgentRunPruneCandidateDto>,
pub blocked_details_truncated: bool,
pub blocked_runs: Vec<AgentRunPruneBlockedRunDto>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPruneCandidateDto {
pub run_id: String,
pub workspace_id: String,
pub stable_chat_id: String,
pub chat_ref: AgentChatRef,
pub status: AgentRunStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub action: AgentRunPruneActionDto,
pub reason: AgentRunPruneReasonDto,
pub file_count: usize,
pub byte_count: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPruneBlockedRunDto {
pub run_id: String,
pub workspace_id: String,
pub stable_chat_id: String,
pub chat_ref: AgentChatRef,
pub status: AgentRunStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub action: AgentRunPruneActionDto,
pub reason: AgentRunPruneReasonDto,
pub block_reason: AgentRunPruneBlockReasonDto,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPruneFailedRunDto {
pub run_id: String,
pub workspace_id: String,
pub stable_chat_id: String,
pub chat_ref: AgentChatRef,
pub status: AgentRunStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub action: AgentRunPruneActionDto,
pub reason: AgentRunPruneReasonDto,
pub file_count: usize,
pub byte_count: u64,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunPruneApplyResultDto {
pub retention: AgentRunPruneRetentionDto,
pub detail_limit: usize,
pub slimmed_run_count: usize,
pub deleted_run_count: usize,
pub failed_run_count: usize,
pub removed_file_count: usize,
pub removed_byte_count: u64,
pub failed_details_truncated: bool,
pub failed_runs: Vec<AgentRunPruneFailedRunDto>,
pub after_plan: AgentRunPrunePlanDto,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentRunPruneActionDto {
SlimHeavyArtifacts,
DeleteRun,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentRunPruneReasonDto {
OutsideFullRetentionWindow,
OutsideHistoryRetentionWindow,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentRunPruneBlockReasonDto {
ActiveRun,
MissingTerminalEvent,
InvalidJournal,
InvalidStorage,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCancelRunDto {
@ -219,12 +475,58 @@ pub struct AgentReadEventsDto {
pub before_seq: Option<u64>,
#[serde(default = "default_event_limit")]
pub limit: usize,
#[serde(default)]
pub invocation_id: Option<String>,
#[serde(default)]
pub include_timeline_projection: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentReadEventsResultDto {
pub events: Vec<AgentRunEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeline_projection: Option<AgentRunTimelineProjectionDto>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunTimelineProjectionDto {
pub foreground_invocation_ids: Vec<String>,
pub invocations: Vec<AgentRunTimelineInvocationDto>,
pub delegation_edges: Vec<AgentRunTimelineDelegationEdgeDto>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunTimelineInvocationDto {
pub invocation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_invocation_id: Option<String>,
pub profile_id: String,
pub kind: AgentInvocationKind,
pub status: AgentInvocationStatus,
pub exit_policy: AgentInvocationExitPolicy,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRunTimelineDelegationEdgeDto {
pub task_id: String,
pub source_invocation_id: String,
pub target_invocation_id: String,
pub target_profile_id: String,
pub workspace_key: String,
pub continuation: AgentDelegationContinuation,
pub status: AgentTaskStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
@ -263,6 +565,7 @@ pub struct AgentModelTurnDisplayDto {
pub model_response_path: String,
pub provider: AgentModelTurnProviderDto,
pub assistant: AgentModelTurnTextDto,
pub narration: Option<AgentModelTurnNarrationDto>,
pub reasoning: Vec<AgentModelTurnReasoningDto>,
pub tool_calls: Vec<AgentModelTurnToolCallDto>,
}
@ -296,6 +599,16 @@ pub struct AgentModelTurnReasoningDto {
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelTurnNarrationDto {
pub source: String,
pub text: String,
pub total_chars: usize,
pub total_words: usize,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelTurnToolCallDto {
@ -344,6 +657,7 @@ pub struct AgentPruneChatPersistentStatesDto {
pub chat_ref: AgentChatRef,
#[serde(default, alias = "stableId")]
pub stable_chat_id: String,
pub candidate_state_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@ -361,6 +675,14 @@ fn default_event_limit() -> usize {
100
}
fn default_run_list_limit() -> usize {
50
}
fn default_run_prune_detail_limit() -> usize {
200
}
fn default_model_turn_text_limit() -> usize {
40_000
}

View file

@ -1,6 +1,8 @@
use crate::domain::json_merge::merge_json_value;
use crate::domain::models::character::{Character, CharacterExtensions};
use crate::domain::repositories::character_repository::{CharacterChat, ImageCrop};
use crate::domain::repositories::character_repository::{
CharacterChat, CharacterCreateResult, CharacterCreateWarning, ImageCrop,
};
use chrono::{SecondsFormat, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -35,12 +37,42 @@ pub struct CharacterDto {
pub json_data: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterCreateWarningDto {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCharacterWithAvatarResultDto {
pub character: CharacterDto,
pub warnings: Vec<CharacterCreateWarningDto>,
}
fn format_timestamp_millis(timestamp_millis: i64) -> Option<String> {
Utc.timestamp_millis_opt(timestamp_millis)
.single()
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
}
impl From<CharacterCreateWarning> for CharacterCreateWarningDto {
fn from(warning: CharacterCreateWarning) -> Self {
Self {
code: warning.code,
message: warning.message,
}
}
}
impl From<CharacterCreateResult> for CreateCharacterWithAvatarResultDto {
fn from(result: CharacterCreateResult) -> Self {
Self {
character: CharacterDto::from(result.character),
warnings: result.warnings.into_iter().map(Into::into).collect(),
}
}
}
/// Character creation DTO
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCharacterDto {
@ -95,6 +127,37 @@ pub struct UpdateCharacterCardDataDto {
pub crop: Option<ImageCropDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckCharacterLorebookConflictDto {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterLorebookConflictDto {
pub conflict: bool,
pub world: String,
pub embedded_name: Option<String>,
pub current_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CharacterLorebookConflictResolution {
Current,
Embedded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveCharacterLorebookConflictDto {
pub name: String,
pub resolution: CharacterLorebookConflictResolution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveCharacterLorebookConflictResultDto {
pub world: String,
}
/// Raw character card merge DTO used by upstream-compatible HTTP routes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeCharacterCardDataDto {

View file

@ -172,6 +172,7 @@ pub struct SaveChatWindowedDto {
pub cursor: ChatPayloadCursor,
pub header: String,
pub lines: Vec<String>,
pub expected_window_line_count: usize,
pub force: Option<bool>,
}
@ -184,9 +185,32 @@ pub struct PatchChatWindowedDto {
pub cursor: ChatPayloadCursor,
pub header: String,
pub patch: ChatPayloadPatchOp,
pub expected_window_line_count: usize,
pub force: Option<bool>,
}
/// DTO for toggling the hidden flag on messages before the window cursor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HideChatBeforeCursorDto {
#[serde(rename = "ch_name")]
pub character_name: String,
pub file_name: String,
pub cursor: ChatPayloadCursor,
pub hide: bool,
pub name_filter: Option<String>,
pub expected_window_line_count: usize,
}
/// DTO for toggling the hidden flag on group chat messages before the window cursor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HideGroupChatBeforeCursorDto {
pub id: String,
pub cursor: ChatPayloadCursor,
pub hide: bool,
pub name_filter: Option<String>,
pub expected_window_line_count: usize,
}
/// DTO for saving a group chat payload from an existing JSONL file path.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveGroupChatFromFileDto {
@ -202,6 +226,7 @@ pub struct SaveGroupChatWindowedDto {
pub cursor: ChatPayloadCursor,
pub header: String,
pub lines: Vec<String>,
pub expected_window_line_count: usize,
pub force: Option<bool>,
}
@ -212,6 +237,7 @@ pub struct PatchGroupChatWindowedDto {
pub cursor: ChatPayloadCursor,
pub header: String,
pub patch: ChatPayloadPatchOp,
pub expected_window_line_count: usize,
pub force: Option<bool>,
}

View file

@ -1,7 +1,8 @@
use crate::domain::models::settings::{
ChatHistoryMode, ClaudeModelSettings, DevLoggingSettings, DynamicThemeSettings, ModelSettings,
PromptCacheTtl, RequestProxySettings, SettingsSnapshot, StartupUpdatePopupSettings,
TauriTavernSettings, TauriTavernUpdateSettings, UserSettings,
AgentRunRetentionSettings, AgentSettings, ChatHistoryMode, ClaudeModelSettings,
DevLoggingSettings, DynamicThemeSettings, ModelSettings, PromptCacheTtl, RequestProxySettings,
SettingsSnapshot, StartupUpdatePopupSettings, TauriTavernSettings, TauriTavernUpdateSettings,
UserSettings,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -21,6 +22,7 @@ pub struct TauriTavernSettingsDto {
pub dev: DevLoggingSettingsDto,
pub dynamic_theme: DynamicThemeSettingsDto,
pub models: ModelSettingsDto,
pub agent: AgentSettingsDto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -48,6 +50,31 @@ pub struct UpdateTauriTavernSettingsDto {
pub dev: Option<UpdateDevLoggingSettingsDto>,
pub dynamic_theme: Option<UpdateDynamicThemeSettingsDto>,
pub models: Option<UpdateModelSettingsDto>,
pub agent: Option<UpdateAgentSettingsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSettingsDto {
pub retention: AgentRunRetentionSettingsDto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateAgentSettingsDto {
pub retention: Option<UpdateAgentRunRetentionSettingsDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRunRetentionSettingsDto {
pub auto_prune_enabled: bool,
pub keep_recent_terminal_runs: u32,
pub keep_full_recent_runs: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateAgentRunRetentionSettingsDto {
pub auto_prune_enabled: Option<bool>,
pub keep_recent_terminal_runs: Option<u32>,
pub keep_full_recent_runs: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -67,6 +94,9 @@ pub struct DynamicThemeSettingsDto {
pub enabled: bool,
pub day_theme: String,
pub night_theme: String,
pub wallpaper_enabled: bool,
pub day_wallpaper: String,
pub night_wallpaper: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -74,6 +104,9 @@ pub struct UpdateDynamicThemeSettingsDto {
pub enabled: Option<bool>,
pub day_theme: Option<String>,
pub night_theme: Option<String>,
pub wallpaper_enabled: Option<bool>,
pub day_wallpaper: Option<String>,
pub night_wallpaper: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -182,6 +215,25 @@ impl From<TauriTavernSettings> for TauriTavernSettingsDto {
dev: DevLoggingSettingsDto::from(settings.dev),
dynamic_theme: DynamicThemeSettingsDto::from(settings.dynamic_theme),
models: ModelSettingsDto::from(settings.models),
agent: AgentSettingsDto::from(settings.agent),
}
}
}
impl From<AgentSettings> for AgentSettingsDto {
fn from(settings: AgentSettings) -> Self {
Self {
retention: AgentRunRetentionSettingsDto::from(settings.retention),
}
}
}
impl From<AgentRunRetentionSettings> for AgentRunRetentionSettingsDto {
fn from(settings: AgentRunRetentionSettings) -> Self {
Self {
auto_prune_enabled: settings.auto_prune_enabled,
keep_recent_terminal_runs: settings.keep_recent_terminal_runs,
keep_full_recent_runs: settings.keep_full_recent_runs,
}
}
}
@ -221,6 +273,9 @@ impl From<DynamicThemeSettings> for DynamicThemeSettingsDto {
enabled: settings.enabled,
day_theme: settings.day_theme,
night_theme: settings.night_theme,
wallpaper_enabled: settings.wallpaper_enabled,
day_wallpaper: settings.day_wallpaper,
night_wallpaper: settings.night_wallpaper,
}
}
}

View file

@ -22,6 +22,18 @@ pub struct GetWorldInfosBatchResponseDto {
pub items: Vec<GetWorldInfosBatchItemDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizeWorldInfoNameDto {
pub name: String,
#[serde(default)]
pub import_filename: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizeWorldInfoNameResponseDto {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveWorldInfoDto {
pub name: String,

View file

@ -1,6 +1,7 @@
use thiserror::Error;
use crate::domain::errors::DomainError;
use crate::domain::models::upstream_failure::UpstreamFailure;
#[derive(Error, Debug)]
pub enum ApplicationError {
@ -10,6 +11,9 @@ pub enum ApplicationError {
#[error("{0}")]
Transient(String),
#[error("{0}")]
UpstreamFailure(UpstreamFailure),
#[error("{0}")]
Cancelled(String),
@ -39,6 +43,7 @@ impl From<DomainError> for ApplicationError {
DomainError::InternalError(msg) => ApplicationError::InternalError(msg),
DomainError::RateLimited { message } => ApplicationError::RateLimited(message),
DomainError::Transient(msg) => ApplicationError::Transient(msg),
DomainError::UpstreamFailure(failure) => ApplicationError::UpstreamFailure(failure),
DomainError::WorkspacePathIsDirectory { path } => {
ApplicationError::ValidationError(format!("Workspace path is a directory: {path}"))
}

View file

@ -0,0 +1,741 @@
use std::sync::Arc;
use crate::application::errors::ApplicationError;
use crate::application::services::agent_profile_service::{
AgentProfileResolveInput, AgentProfileService, preset_exists_for_type,
};
use crate::application::services::llm_connection_service::LlmConnectionService;
use crate::domain::models::agent::AgentToolSpec;
use crate::domain::models::agent::profile::{
AgentModelBindingMode, AgentPresetBindingMode, AgentPresetRef, AgentProfileId,
ResolvedAgentProfile,
};
use crate::domain::models::agent::profile_diagnostic::{
AgentProfileDiagnostic, AgentProfileDiagnosticBlock, AgentProfileDiagnosticRepairAction,
AgentProfileDiagnosticResource, AgentProfileDiagnosticResourceKind,
AgentProfileDiagnosticSeverity, AgentProfileHealth,
};
use crate::domain::models::preset::PresetType;
use crate::domain::repositories::preset_repository::PresetRepository;
pub struct AgentProfileDiagnosticService {
profile_service: Arc<AgentProfileService>,
preset_repository: Arc<dyn PresetRepository>,
llm_connection_service: Arc<LlmConnectionService>,
}
impl AgentProfileDiagnosticService {
pub fn new(
profile_service: Arc<AgentProfileService>,
preset_repository: Arc<dyn PresetRepository>,
llm_connection_service: Arc<LlmConnectionService>,
) -> Self {
Self {
profile_service,
preset_repository,
llm_connection_service,
}
}
pub async fn diagnose_profile(
&self,
profile_id: &str,
known_tools: &[AgentToolSpec],
) -> Result<AgentProfileHealth, ApplicationError> {
let profile_id =
AgentProfileId::parse(profile_id).map_err(ApplicationError::ValidationError)?;
let profile = match self
.profile_service
.resolve_profile_for_preview(AgentProfileResolveInput {
profile_id: Some(profile_id.as_str()),
known_tools,
})
.await
{
Ok(profile) => profile,
Err(ApplicationError::ValidationError(message)) => {
return Ok(AgentProfileHealth {
profile_id,
preview_available: false,
prompt_assembly_available: false,
direct_run_available: false,
sub_agent_available: false,
diagnostics: vec![profile_contract_invalid_diagnostic(message)],
});
}
Err(error) => return Err(error),
};
let mut diagnostics = Vec::new();
self.diagnose_preset_binding(&profile, &mut diagnostics)
.await?;
self.diagnose_model_binding(&profile, &mut diagnostics)
.await?;
Ok(AgentProfileHealth {
profile_id: profile.id.clone(),
preview_available: true,
prompt_assembly_available: !blocks(
&diagnostics,
AgentProfileDiagnosticBlock::PromptAssembly,
),
direct_run_available: profile.run.direct_runnable
&& !blocks(&diagnostics, AgentProfileDiagnosticBlock::DirectRun),
sub_agent_available: profile.delegation.callable
&& profile.delegation.allow_as_subagent
&& !blocks(&diagnostics, AgentProfileDiagnosticBlock::SubAgent),
diagnostics,
})
}
async fn diagnose_preset_binding(
&self,
profile: &ResolvedAgentProfile,
diagnostics: &mut Vec<AgentProfileDiagnostic>,
) -> Result<(), ApplicationError> {
if profile.preset.mode != AgentPresetBindingMode::Ref {
return Ok(());
}
let ref_ = profile
.preset
.ref_
.as_ref()
.expect("validated preset ref binding must include preset.ref");
let preset_type = PresetType::from_api_id(ref_.api_id.as_str())
.expect("validated preset ref binding must include a supported preset apiId");
if preset_type != PresetType::OpenAI {
diagnostics.push(preset_api_unsupported_diagnostic(ref_));
return Ok(());
}
if !preset_exists_for_type(
self.preset_repository.as_ref(),
ref_.name.as_str(),
&preset_type,
)
.await?
{
diagnostics.push(preset_missing_diagnostic(ref_));
}
Ok(())
}
async fn diagnose_model_binding(
&self,
profile: &ResolvedAgentProfile,
diagnostics: &mut Vec<AgentProfileDiagnostic>,
) -> Result<(), ApplicationError> {
match profile.model.mode {
AgentModelBindingMode::CurrentPromptSnapshot => Ok(()),
AgentModelBindingMode::RequiresConfiguration => {
diagnostics.push(model_requires_configuration_diagnostic(profile));
Ok(())
}
AgentModelBindingMode::ConnectionRef => {
let connection_ref = profile
.model
.connection_ref
.as_deref()
.expect("validated connectionRef binding must include model.connectionRef")
.trim();
let model_id = profile
.model
.model_id
.as_deref()
.expect("validated connectionRef binding must include model.modelId")
.trim();
match self
.llm_connection_service
.resolve_model_binding(connection_ref, model_id)
.await
{
Ok(_) => Ok(()),
Err(ApplicationError::NotFound(message)) => {
diagnostics.push(model_connection_missing_diagnostic(
profile,
connection_ref,
model_id,
message,
));
Ok(())
}
Err(ApplicationError::ValidationError(message)) => {
diagnostics.push(model_connection_invalid_diagnostic(
profile,
connection_ref,
model_id,
message,
));
Ok(())
}
Err(error) => Err(error),
}
}
}
}
}
fn profile_contract_invalid_diagnostic(message: String) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_contract_invalid".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$".to_string(),
message,
resource: None,
blocks: vec![
AgentProfileDiagnosticBlock::Preview,
AgentProfileDiagnosticBlock::PromptAssembly,
AgentProfileDiagnosticBlock::DirectRun,
AgentProfileDiagnosticBlock::SubAgent,
],
repair_actions: vec![AgentProfileDiagnosticRepairAction::OpenJsonEditor],
}
}
fn preset_api_unsupported_diagnostic(ref_: &AgentPresetRef) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_preset_api_unsupported".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$.preset.ref.apiId".to_string(),
message: format!(
"agent.profile_preset_api_unsupported: independent Agent prompt assembly currently requires an openai preset, got `{}`",
ref_.api_id
),
resource: Some(preset_resource(ref_)),
blocks: run_and_prompt_assembly_blocks(),
repair_actions: vec![
AgentProfileDiagnosticRepairAction::SelectPreset,
AgentProfileDiagnosticRepairAction::OpenJsonEditor,
],
}
}
fn preset_missing_diagnostic(ref_: &AgentPresetRef) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_preset_missing".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$.preset.ref.name".to_string(),
message: format!(
"agent.profile_preset_missing: required preset `{}` for apiId `{}` does not exist",
ref_.name, ref_.api_id
),
resource: Some(preset_resource(ref_)),
blocks: run_and_prompt_assembly_blocks(),
repair_actions: vec![
AgentProfileDiagnosticRepairAction::SelectPreset,
AgentProfileDiagnosticRepairAction::OpenJsonEditor,
],
}
}
fn model_requires_configuration_diagnostic(
profile: &ResolvedAgentProfile,
) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_model_requires_configuration".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$.model.mode".to_string(),
message: format!(
"agent.profile_model_requires_configuration: Agent profile `{}` requires a local model selection before it can run",
profile.id.as_str()
),
resource: Some(AgentProfileDiagnosticResource {
kind: AgentProfileDiagnosticResourceKind::Model,
api_id: None,
name: None,
id: None,
model_id: None,
}),
blocks: run_and_prompt_assembly_blocks(),
repair_actions: vec![AgentProfileDiagnosticRepairAction::SelectModel],
}
}
fn model_connection_missing_diagnostic(
profile: &ResolvedAgentProfile,
connection_ref: &str,
model_id: &str,
message: String,
) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_model_connection_missing".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$.model.connectionRef".to_string(),
message,
resource: Some(model_connection_resource(connection_ref, model_id)),
blocks: model_binding_blocks(profile),
repair_actions: vec![
AgentProfileDiagnosticRepairAction::SelectModel,
AgentProfileDiagnosticRepairAction::SetModelRequiresConfiguration,
],
}
}
fn model_connection_invalid_diagnostic(
profile: &ResolvedAgentProfile,
connection_ref: &str,
model_id: &str,
message: String,
) -> AgentProfileDiagnostic {
AgentProfileDiagnostic {
code: "agent.profile_model_connection_invalid".to_string(),
severity: AgentProfileDiagnosticSeverity::Error,
path: "$.model.connectionRef".to_string(),
message,
resource: Some(model_connection_resource(connection_ref, model_id)),
blocks: model_binding_blocks(profile),
repair_actions: vec![
AgentProfileDiagnosticRepairAction::SelectModel,
AgentProfileDiagnosticRepairAction::SetModelRequiresConfiguration,
],
}
}
fn preset_resource(ref_: &AgentPresetRef) -> AgentProfileDiagnosticResource {
AgentProfileDiagnosticResource {
kind: AgentProfileDiagnosticResourceKind::Preset,
api_id: Some(ref_.api_id.clone()),
name: Some(ref_.name.clone()),
id: None,
model_id: None,
}
}
fn model_connection_resource(
connection_ref: &str,
model_id: &str,
) -> AgentProfileDiagnosticResource {
AgentProfileDiagnosticResource {
kind: AgentProfileDiagnosticResourceKind::LlmConnection,
api_id: None,
name: None,
id: Some(connection_ref.to_string()),
model_id: Some(model_id.to_string()),
}
}
fn run_and_prompt_assembly_blocks() -> Vec<AgentProfileDiagnosticBlock> {
vec![
AgentProfileDiagnosticBlock::PromptAssembly,
AgentProfileDiagnosticBlock::DirectRun,
AgentProfileDiagnosticBlock::SubAgent,
]
}
fn model_binding_blocks(profile: &ResolvedAgentProfile) -> Vec<AgentProfileDiagnosticBlock> {
let mut blocks = vec![
AgentProfileDiagnosticBlock::DirectRun,
AgentProfileDiagnosticBlock::SubAgent,
];
if profile.preset.mode == AgentPresetBindingMode::Ref {
blocks.insert(0, AgentProfileDiagnosticBlock::PromptAssembly);
}
blocks
}
fn blocks(diagnostics: &[AgentProfileDiagnostic], block: AgentProfileDiagnosticBlock) -> bool {
diagnostics
.iter()
.any(|diagnostic| diagnostic.blocks.contains(&block))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use uuid::Uuid;
use super::*;
use crate::application::services::agent_tools::BuiltinAgentToolRegistry;
use crate::domain::errors::DomainError;
use crate::domain::models::agent::profile::{
AgentModelBindingMode, AgentPresetBindingMode, AgentPresetRef,
};
use crate::domain::models::llm_connection::{
LlmConnectionDefinition, LlmConnectionId, LlmConnectionSummary,
};
use crate::domain::models::preset::{DefaultPreset, Preset};
use crate::domain::repositories::agent_profile_repository::AgentProfileRepository;
use crate::domain::repositories::agent_profile_storage_health_repository::AgentProfileStorageHealthRepository;
use crate::domain::repositories::llm_connection_repository::LlmConnectionRepository;
use crate::infrastructure::repositories::file_agent_profile_repository::FileAgentProfileRepository;
#[tokio::test]
async fn default_profile_is_healthy() {
let root = temp_root("healthy-default");
let (profile_service, diagnostic_service, registry) = test_services(
&root,
TestPresetRepository::default(),
TestLlmConnectionRepository::default(),
);
let health = diagnostic_service
.diagnose_profile("default-writer", registry.specs())
.await
.expect("diagnose default profile");
assert!(health.preview_available);
assert!(health.prompt_assembly_available);
assert!(health.direct_run_available);
assert!(health.diagnostics.is_empty());
cleanup(root).await;
drop(profile_service);
}
#[tokio::test]
async fn dangling_openai_preset_is_diagnostic_not_preview_failure() {
let root = temp_root("dangling-preset");
let (profile_service, diagnostic_service, registry) = test_services(
&root,
TestPresetRepository::default(),
TestLlmConnectionRepository::default(),
);
let mut profile = profile_service
.load_profile("default-writer")
.await
.expect("load default")
.expect("default profile");
profile.id =
crate::domain::models::agent::profile::AgentProfileId::parse("dangling-writer")
.expect("profile id");
profile.preset.mode = AgentPresetBindingMode::Ref;
profile.preset.ref_ = Some(AgentPresetRef {
api_id: "openai".to_string(),
name: "Missing Writer Preset".to_string(),
});
profile.preset.required = true;
profile_service
.save_profile(profile, registry.specs())
.await
.expect("dangling preset profile remains editable");
let health = diagnostic_service
.diagnose_profile("dangling-writer", registry.specs())
.await
.expect("diagnose dangling profile");
assert!(health.preview_available);
assert!(!health.prompt_assembly_available);
assert!(!health.direct_run_available);
let diagnostic = only_diagnostic(&health);
assert_eq!(diagnostic.code, "agent.profile_preset_missing");
assert!(
diagnostic
.blocks
.contains(&AgentProfileDiagnosticBlock::PromptAssembly)
);
assert!(
diagnostic
.repair_actions
.contains(&AgentProfileDiagnosticRepairAction::SelectPreset)
);
cleanup(root).await;
}
#[tokio::test]
async fn non_openai_preset_ref_is_diagnostic_for_independent_prompt_assembly() {
let root = temp_root("unsupported-preset-api");
let (profile_service, diagnostic_service, registry) = test_services(
&root,
TestPresetRepository::default(),
TestLlmConnectionRepository::default(),
);
let mut profile = profile_service
.load_profile("default-writer")
.await
.expect("load default")
.expect("default profile");
profile.id = crate::domain::models::agent::profile::AgentProfileId::parse("textgen-writer")
.expect("profile id");
profile.preset.mode = AgentPresetBindingMode::Ref;
profile.preset.ref_ = Some(AgentPresetRef {
api_id: "textgenerationwebui".to_string(),
name: "TextGen Preset".to_string(),
});
profile.preset.required = true;
profile_service
.save_profile(profile, registry.specs())
.await
.expect("supported preset type remains editable even if assembly cannot use it");
let health = diagnostic_service
.diagnose_profile("textgen-writer", registry.specs())
.await
.expect("diagnose textgen preset profile");
assert!(health.preview_available);
assert!(!health.prompt_assembly_available);
let diagnostic = only_diagnostic(&health);
assert_eq!(diagnostic.code, "agent.profile_preset_api_unsupported");
assert_eq!(
diagnostic
.resource
.as_ref()
.and_then(|resource| resource.api_id.as_deref()),
Some("textgenerationwebui")
);
cleanup(root).await;
}
#[tokio::test]
async fn requires_configuration_is_diagnostic_and_blocks_run() {
let root = temp_root("requires-configuration");
let (profile_service, diagnostic_service, registry) = test_services(
&root,
TestPresetRepository::default(),
TestLlmConnectionRepository::default(),
);
let mut profile = profile_service
.load_profile("default-writer")
.await
.expect("load default")
.expect("default profile");
profile.id =
crate::domain::models::agent::profile::AgentProfileId::parse("imported-writer")
.expect("profile id");
profile.model.mode = AgentModelBindingMode::RequiresConfiguration;
profile.model.connection_ref = None;
profile.model.model_id = None;
profile_service
.save_profile(profile, registry.specs())
.await
.expect("requiresConfiguration profile remains editable");
let health = diagnostic_service
.diagnose_profile("imported-writer", registry.specs())
.await
.expect("diagnose imported profile");
assert!(health.preview_available);
assert!(!health.prompt_assembly_available);
assert!(!health.direct_run_available);
let diagnostic = only_diagnostic(&health);
assert_eq!(
diagnostic.code,
"agent.profile_model_requires_configuration"
);
assert!(
diagnostic
.repair_actions
.contains(&AgentProfileDiagnosticRepairAction::SelectModel)
);
cleanup(root).await;
}
#[tokio::test]
async fn missing_connection_blocks_run_without_blocking_current_snapshot_prompt_assembly() {
let root = temp_root("missing-connection");
let (profile_service, diagnostic_service, registry) = test_services(
&root,
TestPresetRepository::default(),
TestLlmConnectionRepository::default(),
);
let mut profile = profile_service
.load_profile("default-writer")
.await
.expect("load default")
.expect("default profile");
profile.id =
crate::domain::models::agent::profile::AgentProfileId::parse("connection-writer")
.expect("profile id");
profile.model.mode = AgentModelBindingMode::ConnectionRef;
profile.model.connection_ref = Some("missing-connection".to_string());
profile.model.model_id = Some("test-model".to_string());
profile_service
.save_profile(profile, registry.specs())
.await
.expect("missing connection profile remains editable");
let health = diagnostic_service
.diagnose_profile("connection-writer", registry.specs())
.await
.expect("diagnose connection profile");
assert!(health.preview_available);
assert!(health.prompt_assembly_available);
assert!(!health.direct_run_available);
let diagnostic = only_diagnostic(&health);
assert_eq!(diagnostic.code, "agent.profile_model_connection_missing");
assert!(
!diagnostic
.blocks
.contains(&AgentProfileDiagnosticBlock::PromptAssembly)
);
assert!(
diagnostic
.blocks
.contains(&AgentProfileDiagnosticBlock::DirectRun)
);
cleanup(root).await;
}
fn test_services(
root: &Path,
preset_repository: TestPresetRepository,
llm_connection_repository: TestLlmConnectionRepository,
) -> (
Arc<AgentProfileService>,
AgentProfileDiagnosticService,
BuiltinAgentToolRegistry,
) {
let profile_repository =
Arc::new(FileAgentProfileRepository::new(root.join("agent-profiles")));
let profile_repository_trait: Arc<dyn AgentProfileRepository> = profile_repository.clone();
let profile_health_repository: Arc<dyn AgentProfileStorageHealthRepository> =
profile_repository;
let preset_repository: Arc<dyn PresetRepository> = Arc::new(preset_repository);
let llm_connection_service = Arc::new(LlmConnectionService::new(Arc::new(
llm_connection_repository,
)));
let profile_service = Arc::new(AgentProfileService::new(
profile_repository_trait,
profile_health_repository,
preset_repository.clone(),
));
let diagnostic_service = AgentProfileDiagnosticService::new(
profile_service.clone(),
preset_repository,
llm_connection_service,
);
(
profile_service,
diagnostic_service,
BuiltinAgentToolRegistry::phase2c(),
)
}
fn only_diagnostic(health: &AgentProfileHealth) -> &AgentProfileDiagnostic {
assert_eq!(health.diagnostics.len(), 1);
&health.diagnostics[0]
}
fn temp_root(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"tauritavern-agent-profile-diagnostic-{label}-{}",
Uuid::new_v4().simple()
))
}
async fn cleanup(root: std::path::PathBuf) {
match tokio::fs::remove_dir_all(root).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!("cleanup: {error}"),
}
}
#[derive(Default)]
struct TestPresetRepository {
user_openai: BTreeSet<String>,
default_openai: BTreeSet<String>,
}
#[async_trait]
impl PresetRepository for TestPresetRepository {
async fn save_preset(&self, _preset: &Preset) -> Result<(), DomainError> {
Ok(())
}
async fn delete_preset(
&self,
_name: &str,
_preset_type: &PresetType,
) -> Result<(), DomainError> {
Ok(())
}
async fn preset_exists(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<bool, DomainError> {
Ok(*preset_type == PresetType::OpenAI && self.user_openai.contains(name))
}
async fn get_preset(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<Option<Preset>, DomainError> {
if *preset_type == PresetType::OpenAI && self.user_openai.contains(name) {
return Ok(Some(Preset::new(
name.to_string(),
preset_type.clone(),
json!({}),
)));
}
Ok(None)
}
async fn list_presets(&self, preset_type: &PresetType) -> Result<Vec<String>, DomainError> {
if *preset_type != PresetType::OpenAI {
return Ok(Vec::new());
}
Ok(self.user_openai.iter().cloned().collect())
}
async fn get_default_preset(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<Option<DefaultPreset>, DomainError> {
if *preset_type == PresetType::OpenAI && self.default_openai.contains(name) {
return Ok(Some(DefaultPreset {
filename: format!("{name}.json"),
name: name.to_string(),
preset_type: preset_type.clone(),
is_default: true,
data: json!({}),
}));
}
Ok(None)
}
}
#[derive(Default)]
struct TestLlmConnectionRepository {
connection: Option<LlmConnectionDefinition>,
}
#[async_trait]
impl LlmConnectionRepository for TestLlmConnectionRepository {
async fn list_connections(&self) -> Result<Vec<LlmConnectionSummary>, DomainError> {
Ok(self
.connection
.as_ref()
.map(|connection| vec![connection.summary()])
.unwrap_or_default())
}
async fn load_connection(
&self,
id: &LlmConnectionId,
) -> Result<Option<LlmConnectionDefinition>, DomainError> {
Ok(self
.connection
.as_ref()
.filter(|connection| connection.id == *id)
.cloned())
}
async fn save_connection(
&self,
_connection: &LlmConnectionDefinition,
) -> Result<(), DomainError> {
Ok(())
}
async fn delete_connection(&self, _id: &LlmConnectionId) -> Result<(), DomainError> {
Ok(())
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
use crate::domain::models::agent::ArtifactTarget;
pub(super) const WORKSPACE_ROOT_UNIVERSE: [&str; 5] =
["output", "scratch", "plan", "summaries", "persist"];
pub(super) const MESSAGE_BODY_ARTIFACT_TARGET: ArtifactTarget = ArtifactTarget::MessageBody;
pub(super) const AGENT_AWAIT_TOOL: &str = "agent.await";
pub(super) const AGENT_DELEGATE_TOOL: &str = "agent.delegate";
pub(super) const AGENT_HANDOFF_TOOL: &str = "agent.handoff";
pub(super) const AGENT_LIST_TOOL: &str = "agent.list";
pub(super) const TASK_RETURN_TOOL: &str = "task.return";

View file

@ -0,0 +1,108 @@
use std::collections::BTreeMap;
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::AgentRunPresentation;
use crate::domain::models::agent::plan::{AgentPlanMode, AgentPlanPolicy, DEFAULT_AGENT_PLAN_BETA};
use crate::domain::models::agent::profile::{
AGENT_PROFILE_KIND, AGENT_PROFILE_SCHEMA_VERSION, AgentContextPolicy, AgentDelegationPolicy,
AgentModelBinding, AgentModelBindingMode, AgentOutputArtifact, AgentOutputArtifactTarget,
AgentOutputPolicy, AgentPresetBinding, AgentPresetBindingMode, AgentProfileDefinition,
AgentProfileId, AgentProfileInstructions, AgentRunPolicy, AgentSkillPolicy, AgentToolPolicy,
AgentWorkspacePolicy, DEFAULT_AGENT_PROFILE_ID, DEFAULT_AGENT_SKILL_MAX_READ_CHARS_PER_CALL,
DEFAULT_AGENT_SKILL_MAX_READ_CHARS_PER_RUN, DEFAULT_AGENT_TOOL_MAX_CALLS_PER_RUN,
DEFAULT_AGENT_TOOL_MAX_ROUNDS,
};
use super::constants::{
AGENT_AWAIT_TOOL, AGENT_DELEGATE_TOOL, AGENT_LIST_TOOL, WORKSPACE_ROOT_UNIVERSE,
};
pub(super) fn default_writer_profile() -> Result<AgentProfileDefinition, ApplicationError> {
Ok(AgentProfileDefinition {
schema_version: AGENT_PROFILE_SCHEMA_VERSION,
kind: AGENT_PROFILE_KIND.to_string(),
id: AgentProfileId::parse(DEFAULT_AGENT_PROFILE_ID)
.map_err(ApplicationError::ValidationError)?,
display_name: "Default Writer".to_string(),
description: Some("General creative writing Agent profile.".to_string()),
preset: AgentPresetBinding {
mode: AgentPresetBindingMode::CurrentPromptSnapshot,
ref_: None,
required: false,
},
model: AgentModelBinding {
mode: AgentModelBindingMode::CurrentPromptSnapshot,
connection_ref: None,
model_id: None,
},
run: AgentRunPolicy {
presentation: AgentRunPresentation::Foreground,
direct_runnable: true,
model_retry: Default::default(),
},
context: AgentContextPolicy::default(),
instructions: AgentProfileInstructions {
agent_system_prompt: None,
},
delegation: AgentDelegationPolicy {
can_delegate: true,
..Default::default()
},
tools: AgentToolPolicy {
allow: vec![
AGENT_LIST_TOOL.to_string(),
AGENT_DELEGATE_TOOL.to_string(),
AGENT_AWAIT_TOOL.to_string(),
"chat.search".to_string(),
"chat.read_messages".to_string(),
"worldinfo.read_activated".to_string(),
"skill.list".to_string(),
"skill.search".to_string(),
"skill.read".to_string(),
"workspace.list_files".to_string(),
"workspace.search_files".to_string(),
"workspace.read_file".to_string(),
"workspace.write_file".to_string(),
"workspace.apply_patch".to_string(),
"workspace.commit".to_string(),
"workspace.finish".to_string(),
],
deny: Vec::new(),
tool_descriptions: BTreeMap::new(),
max_rounds: DEFAULT_AGENT_TOOL_MAX_ROUNDS,
max_calls_per_run: DEFAULT_AGENT_TOOL_MAX_CALLS_PER_RUN,
max_calls_per_tool: BTreeMap::new(),
},
skills: AgentSkillPolicy {
visible: vec!["*".to_string()],
deny: Vec::new(),
max_read_chars_per_call: DEFAULT_AGENT_SKILL_MAX_READ_CHARS_PER_CALL,
max_read_chars_per_run: DEFAULT_AGENT_SKILL_MAX_READ_CHARS_PER_RUN,
},
workspace: AgentWorkspacePolicy {
visible_roots: WORKSPACE_ROOT_UNIVERSE
.iter()
.map(|root| root.to_string())
.collect(),
writable_roots: WORKSPACE_ROOT_UNIVERSE
.iter()
.map(|root| root.to_string())
.collect(),
},
plan: AgentPlanPolicy {
mode: AgentPlanMode::None,
beta: DEFAULT_AGENT_PLAN_BETA,
nodes: Vec::new(),
},
output: AgentOutputPolicy {
artifacts: vec![AgentOutputArtifact {
id: "main".to_string(),
path: "output/main.md".to_string(),
kind: "markdown".to_string(),
target: AgentOutputArtifactTarget::MessageBody,
required: true,
assembly_order: 0,
}],
},
})
}

View file

@ -0,0 +1,27 @@
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::profile::{AgentModelBindingMode, ResolvedAgentProfile};
pub fn profile_model_requires_configuration(profile: &ResolvedAgentProfile) -> bool {
matches!(
profile.model.mode,
AgentModelBindingMode::RequiresConfiguration
)
}
pub fn profile_model_configuration_error(profile: &ResolvedAgentProfile) -> String {
format!(
"agent.profile_model_requires_configuration: Agent profile `{}` requires a local model selection before it can run",
profile.id.as_str()
)
}
pub fn ensure_profile_model_configured(
profile: &ResolvedAgentProfile,
) -> Result<(), ApplicationError> {
if profile_model_requires_configuration(profile) {
return Err(ApplicationError::ValidationError(
profile_model_configuration_error(profile),
));
}
Ok(())
}

View file

@ -0,0 +1,116 @@
use std::collections::BTreeSet;
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::profile::{
AgentOutputArtifactTarget, AgentOutputPolicy, AgentWorkspacePolicy, ResolvedAgentOutputPolicy,
};
use crate::domain::models::agent::{ArtifactSpec, WorkspacePath};
use super::constants::MESSAGE_BODY_ARTIFACT_TARGET;
pub(super) fn resolve_output_policy(
policy: &AgentOutputPolicy,
workspace: &AgentWorkspacePolicy,
) -> Result<ResolvedAgentOutputPolicy, ApplicationError> {
if policy.artifacts.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_output_empty: output.artifacts cannot be empty".to_string(),
));
}
let visible = workspace
.visible_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
let writable = workspace
.writable_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
let mut ids = BTreeSet::new();
let mut paths = BTreeSet::new();
let mut message_body_artifact = None;
let mut artifacts = Vec::with_capacity(policy.artifacts.len());
for artifact in &policy.artifacts {
validate_artifact_id(&artifact.id)?;
if !ids.insert(artifact.id.as_str()) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_output_duplicate_id: duplicate artifact id `{}`",
artifact.id
)));
}
if artifact.kind.trim().is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_output_kind_required: artifact `{}` kind cannot be empty",
artifact.id
)));
}
let path = WorkspacePath::parse(&artifact.path)?;
if !paths.insert(path.as_str().to_string()) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_output_duplicate_path: duplicate artifact path `{}`",
path.as_str()
)));
}
let root = path.as_str().split('/').next().unwrap_or_default();
if !visible.contains(root) || !writable.contains(root) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_output_path_denied: artifact `{}` path `{}` must be visible and writable",
artifact.id,
path.as_str()
)));
}
let target = match artifact.target {
AgentOutputArtifactTarget::MessageBody => {
if message_body_artifact.is_some() {
return Err(ApplicationError::ValidationError(
"agent.profile_output_duplicate_message_body: only one messageBody artifact is supported"
.to_string(),
));
}
message_body_artifact = Some((artifact.id.clone(), path.as_str().to_string()));
MESSAGE_BODY_ARTIFACT_TARGET
}
};
artifacts.push(ArtifactSpec {
id: artifact.id.clone(),
path: path.as_str().to_string(),
kind: artifact.kind.trim().to_string(),
target,
required: artifact.required,
assembly_order: artifact.assembly_order,
});
}
let Some((message_body_artifact_id, message_body_path)) = message_body_artifact else {
return Err(ApplicationError::ValidationError(
"agent.profile_output_message_body_missing: output.artifacts must include one messageBody artifact"
.to_string(),
));
};
Ok(ResolvedAgentOutputPolicy {
artifacts,
message_body_artifact_id,
message_body_path,
})
}
fn validate_artifact_id(id: &str) -> Result<(), ApplicationError> {
let id = id.trim();
if id.is_empty()
|| id.len() > 128
|| !id.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
})
{
return Err(ApplicationError::ValidationError(format!(
"agent.profile_artifact_id_invalid: invalid artifact id `{id}`"
)));
}
Ok(())
}

View file

@ -0,0 +1,182 @@
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::profile::{
AgentPresetBinding, AgentPresetBindingMode, AgentPresetRef,
};
use crate::domain::models::preset::PresetType;
use crate::domain::repositories::preset_repository::PresetRepository;
use super::validation::{
migrate_profile_schema, normalize_context_policy, validate_profile_header,
};
use super::{
AgentProfileExternalReferencePolicy, AgentProfilePresetRetargetResult, AgentProfileService,
};
impl AgentProfileService {
pub async fn retarget_preset_refs(
&self,
from: AgentPresetRef,
to: AgentPresetRef,
) -> Result<AgentProfilePresetRetargetResult, ApplicationError> {
let (from, to) =
validate_preset_retarget_pair(from, to, self.preset_repository.as_ref()).await?;
let summaries = self.list_profiles().await?.profiles;
let mut updated = Vec::new();
for summary in summaries {
let Some(mut profile) = self
.profile_repository
.load_profile(&summary.id)
.await
.map_err(ApplicationError::from)?
else {
continue;
};
migrate_profile_schema(&mut profile)?;
validate_profile_header(&profile)?;
if profile.preset.mode != AgentPresetBindingMode::Ref {
continue;
}
let matches_from = profile
.preset
.ref_
.as_ref()
.is_some_and(|ref_| ref_.api_id == from.api_id && ref_.name == from.name);
if !matches_from {
continue;
}
validate_preset_binding(
&profile.preset,
self.preset_repository.as_ref(),
AgentProfileExternalReferencePolicy::AllowDangling,
)
.await?;
normalize_context_policy(&mut profile.context)?;
let ref_ = profile
.preset
.ref_
.as_mut()
.expect("matching preset ref must exist");
ref_.api_id = to.api_id.clone();
ref_.name = to.name.clone();
self.profile_repository
.save_profile(&profile)
.await
.map_err(ApplicationError::from)?;
updated.push(profile.id);
}
Ok(AgentProfilePresetRetargetResult {
profile_ids: updated,
})
}
}
pub(super) async fn validate_preset_binding(
binding: &AgentPresetBinding,
preset_repository: &dyn PresetRepository,
external_reference_policy: AgentProfileExternalReferencePolicy,
) -> Result<(), ApplicationError> {
match binding.mode {
AgentPresetBindingMode::CurrentPromptSnapshot | AgentPresetBindingMode::None => Ok(()),
AgentPresetBindingMode::Ref => {
let Some(ref_) = binding.ref_.as_ref() else {
return Err(ApplicationError::ValidationError(
"agent.profile_preset_ref_required: preset.ref is required when preset.mode is ref"
.to_string(),
));
};
let preset_type = PresetType::from_api_id(ref_.api_id.as_str()).ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.profile_preset_api_invalid: unsupported preset apiId `{}`",
ref_.api_id
))
})?;
if ref_.name.trim().is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_preset_name_required: preset.ref.name cannot be empty"
.to_string(),
));
}
if !binding.required
|| external_reference_policy == AgentProfileExternalReferencePolicy::AllowDangling
{
return Ok(());
}
if !preset_exists_for_type(preset_repository, ref_.name.as_str(), &preset_type).await? {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_preset_missing: required preset `{}` for apiId `{}` does not exist",
ref_.name, ref_.api_id
)));
}
Ok(())
}
}
}
async fn validate_preset_retarget_pair(
from: AgentPresetRef,
to: AgentPresetRef,
preset_repository: &dyn PresetRepository,
) -> Result<(AgentPresetRef, AgentPresetRef), ApplicationError> {
let from = normalize_preset_ref(from, "from")?;
let to = normalize_preset_ref(to, "to")?;
if from == to {
return Err(ApplicationError::ValidationError(
"agent.profile_preset_retarget_same_ref: from and to preset refs must differ"
.to_string(),
));
}
if from.api_id != to.api_id {
return Err(ApplicationError::ValidationError(
"agent.profile_preset_retarget_api_mismatch: preset refs cannot be retargeted across apiId"
.to_string(),
));
}
let preset_type = PresetType::from_api_id(to.api_id.as_str()).ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.profile_preset_api_invalid: unsupported preset apiId `{}`",
to.api_id
))
})?;
if !preset_exists_for_type(preset_repository, to.name.as_str(), &preset_type).await? {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_preset_retarget_target_missing: target preset `{}` for apiId `{}` does not exist",
to.name, to.api_id
)));
}
Ok((from, to))
}
pub(crate) async fn preset_exists_for_type(
preset_repository: &dyn PresetRepository,
name: &str,
preset_type: &PresetType,
) -> Result<bool, ApplicationError> {
Ok(preset_repository.preset_exists(name, preset_type).await?
|| preset_repository
.get_default_preset(name, preset_type)
.await?
.is_some())
}
fn normalize_preset_ref(
mut ref_: AgentPresetRef,
label: &str,
) -> Result<AgentPresetRef, ApplicationError> {
ref_.api_id = ref_.api_id.trim().to_string();
ref_.name = ref_.name.trim().to_string();
if ref_.api_id.is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_preset_retarget_{label}_api_required: {label}.apiId cannot be empty"
)));
}
if ref_.name.is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_preset_retarget_{label}_name_required: {label}.name cannot be empty"
)));
}
Ok(ref_)
}

View file

@ -0,0 +1,153 @@
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::AgentToolSpec;
use crate::domain::models::agent::profile::{
AgentProfileDefinition, AgentProfileId, AgentProfileSourceTrace, DEFAULT_AGENT_PROFILE_ID,
ResolvedAgentProfile,
};
use super::defaults::default_writer_profile;
use super::output_policy::resolve_output_policy;
use super::preset_refs::validate_preset_binding;
use super::validation::{
migrate_profile_schema, normalize_context_policy, validate_delegation_policy,
validate_instructions, validate_model_binding, validate_plan_policy, validate_profile_header,
validate_run_policy, validate_skill_policy, validate_tool_policy, validate_workspace_policy,
};
use super::{AgentProfileExternalReferencePolicy, AgentProfileResolveInput, AgentProfileService};
impl AgentProfileService {
pub async fn resolve_profile(
&self,
input: AgentProfileResolveInput<'_>,
) -> Result<ResolvedAgentProfile, ApplicationError> {
let (definition, source) = self.load_definition(input.profile_id).await?;
self.resolve_definition(
definition,
source,
input.known_tools,
AgentProfileExternalReferencePolicy::Strict,
)
.await
}
pub async fn resolve_profile_for_preview(
&self,
input: AgentProfileResolveInput<'_>,
) -> Result<ResolvedAgentProfile, ApplicationError> {
let (definition, source) = self.load_definition(input.profile_id).await?;
self.resolve_definition(
definition,
source,
input.known_tools,
AgentProfileExternalReferencePolicy::AllowDangling,
)
.await
}
pub async fn list_resolved_profiles_for_discovery(
&self,
known_tools: &[AgentToolSpec],
) -> Result<Vec<ResolvedAgentProfile>, ApplicationError> {
let summaries = self.list_profiles().await?.profiles;
let mut profiles = Vec::with_capacity(summaries.len());
for summary in summaries {
match self
.resolve_profile(AgentProfileResolveInput {
profile_id: Some(summary.id.as_str()),
known_tools,
})
.await
{
Ok(profile) => profiles.push(profile),
Err(error) if profile_discovery_can_omit(&error) => continue,
Err(error) => return Err(error),
}
}
Ok(profiles)
}
async fn load_definition(
&self,
profile_id: Option<&str>,
) -> Result<(AgentProfileDefinition, String), ApplicationError> {
let requested = profile_id.map(str::trim).filter(|value| !value.is_empty());
let (definition, source) = match requested {
Some(raw_id) => {
let id =
AgentProfileId::parse(raw_id).map_err(ApplicationError::ValidationError)?;
match self.profile_repository.load_profile(&id).await? {
Some(profile) => (profile, format!("file:{}", id.as_str())),
None if id.as_str() == DEFAULT_AGENT_PROFILE_ID => {
(default_writer_profile()?, "built_in".to_string())
}
None => {
return Err(ApplicationError::NotFound(format!(
"agent.profile_not_found: Agent profile `{}` does not exist",
id.as_str()
)));
}
}
}
None => (default_writer_profile()?, "built_in".to_string()),
};
Ok((definition, source))
}
pub(super) async fn resolve_definition(
&self,
mut definition: AgentProfileDefinition,
source: String,
known_tools: &[AgentToolSpec],
external_reference_policy: AgentProfileExternalReferencePolicy,
) -> Result<ResolvedAgentProfile, ApplicationError> {
migrate_profile_schema(&mut definition)?;
validate_profile_header(&definition)?;
validate_preset_binding(
&definition.preset,
self.preset_repository.as_ref(),
external_reference_policy,
)
.await?;
validate_model_binding(&definition.model)?;
normalize_context_policy(&mut definition.context)?;
validate_instructions(&definition.instructions)?;
validate_plan_policy(&definition.plan)?;
validate_tool_policy(&definition.tools, known_tools)?;
validate_delegation_policy(&definition.delegation, &definition.tools)?;
validate_run_policy(&definition.run, &definition.delegation, &definition.tools)?;
validate_skill_policy(&definition.skills)?;
validate_workspace_policy(&definition.workspace)?;
let output = resolve_output_policy(&definition.output, &definition.workspace)?;
Ok(ResolvedAgentProfile {
schema_version: definition.schema_version,
kind: definition.kind,
id: definition.id,
display_name: definition.display_name,
description: definition.description,
preset: definition.preset,
model: definition.model,
run: definition.run,
context: definition.context,
delegation: definition.delegation,
instructions: definition.instructions,
tools: definition.tools,
skills: definition.skills,
workspace: definition.workspace,
plan: definition.plan,
output,
source_trace: AgentProfileSourceTrace {
profile_source: source,
},
})
}
}
fn profile_discovery_can_omit(error: &ApplicationError) -> bool {
matches!(
error,
ApplicationError::ValidationError(_) | ApplicationError::NotFound(_)
)
}

View file

@ -0,0 +1,100 @@
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::profile::{
AgentProfileDefinition, AgentProfileId, DEFAULT_AGENT_PROFILE_ID,
};
use crate::domain::repositories::agent_profile_storage_health_repository::AgentProfileStorageRepairAction;
use super::defaults::default_writer_profile;
use super::validation::{migrate_profile_schema, normalize_context_policy};
use super::{AgentProfileExternalReferencePolicy, AgentProfileList, AgentProfileService};
impl AgentProfileService {
pub async fn list_profiles(&self) -> Result<AgentProfileList, ApplicationError> {
let scan = self
.profile_storage_health_repository
.scan_profiles()
.await
.map_err(ApplicationError::from)?;
let mut list = AgentProfileList {
profiles: scan.profiles,
issues: scan.issues,
};
if list
.profiles
.iter()
.all(|profile| profile.id.as_str() != DEFAULT_AGENT_PROFILE_ID)
{
list.profiles.insert(0, default_writer_profile()?.summary());
}
Ok(list)
}
pub async fn load_profile(
&self,
profile_id: &str,
) -> Result<Option<AgentProfileDefinition>, ApplicationError> {
let id = AgentProfileId::parse(profile_id).map_err(ApplicationError::ValidationError)?;
let profile = self
.profile_repository
.load_profile(&id)
.await
.map_err(ApplicationError::from)?;
if profile.is_none() && id.as_str() == DEFAULT_AGENT_PROFILE_ID {
return Ok(Some(default_writer_profile()?));
}
profile
.map(|mut profile| {
migrate_profile_schema(&mut profile)?;
Ok(profile)
})
.transpose()
}
pub async fn save_profile(
&self,
mut profile: AgentProfileDefinition,
known_tools: &[crate::domain::models::agent::AgentToolSpec],
) -> Result<(), ApplicationError> {
migrate_profile_schema(&mut profile)?;
normalize_context_policy(&mut profile.context)?;
self.resolve_definition(
profile.clone(),
format!("file:{}", profile.id.as_str()),
known_tools,
AgentProfileExternalReferencePolicy::AllowDangling,
)
.await?;
self.profile_repository
.save_profile(&profile)
.await
.map_err(ApplicationError::from)
}
pub async fn delete_profile(&self, profile_id: &str) -> Result<(), ApplicationError> {
let id = AgentProfileId::parse(profile_id).map_err(ApplicationError::ValidationError)?;
self.profile_repository
.delete_profile(&id)
.await
.map_err(ApplicationError::from)
}
pub async fn repair_profile_file(
&self,
profile_id: &str,
action: AgentProfileStorageRepairAction,
) -> Result<(), ApplicationError> {
let id = AgentProfileId::parse(profile_id).map_err(ApplicationError::ValidationError)?;
match action {
AgentProfileStorageRepairAction::Delete => self
.profile_repository
.delete_profile(&id)
.await
.map_err(ApplicationError::from),
AgentProfileStorageRepairAction::NormalizeIdentity => self
.profile_storage_health_repository
.normalize_profile_file_identity(&id)
.await
.map_err(ApplicationError::from),
}
}
}

View file

@ -0,0 +1,338 @@
use crate::application::services::agent_workspace_scope::format_model_workspace_roots;
use crate::domain::models::agent::AgentToolSpec;
use crate::domain::models::agent::profile::ResolvedAgentProfile;
use super::constants::{
AGENT_AWAIT_TOOL, AGENT_DELEGATE_TOOL, AGENT_HANDOFF_TOOL, AGENT_LIST_TOOL, TASK_RETURN_TOOL,
};
pub fn materialize_agent_system_prompt(
tools: &[AgentToolSpec],
profile: &ResolvedAgentProfile,
) -> String {
if let Some(prompt) = profile.instructions.agent_system_prompt.as_ref() {
return prompt.clone();
}
let mut lines = vec![
"---".to_string(),
"tool_choice: required".to_string(),
"tools:".to_string(),
];
lines.extend(
tools
.iter()
.map(|tool| format!("- {}", tool.model_name.as_str())),
);
lines.extend([
"---".to_string(),
String::new(),
"# Agent Mode is active.".to_string(),
"- Work using the available agent tools. Tool results are working context, not chat messages.".to_string(),
String::new(),
]);
if has_tool(tools, "chat.search") {
lines.push(format!(
"- When more context is needed, use {} to find relevant prior messages. Provide only the search query.",
model_name(tools, "chat.search")
));
}
if has_tool(tools, "chat.read_messages") {
let source_hint = if has_tool(tools, "chat.search") {
format!(
"the message indices returned by {}",
model_name(tools, "chat.search")
)
} else {
"exact indexes you already know".to_string()
};
lines.push(format!(
"- Use {} with {source_hint} for review. For longer messages, use start_char and max_chars to read smaller ranges.",
model_name(tools, "chat.read_messages")
));
}
if has_tool(tools, "worldinfo.read_activated") {
lines.push(format!(
"- When activated world information is relevant to this run, use {}.",
model_name(tools, "worldinfo.read_activated")
));
}
if has_tool(tools, "dice.roll") {
lines.push(format!(
"- Use {} only when an explicit random roll, chance check, or tabletop/roleplay check is needed. Do not invent roll results.",
model_name(tools, "dice.roll")
));
}
if has_tool(tools, "skill.list") {
lines.push(format!(
"- Use {} to discover visible agent skills when reusable writing, editing, planning, style, or character guidance may be helpful.",
model_name(tools, "skill.list")
));
}
if has_tool(tools, AGENT_LIST_TOOL) {
lines.push(format!(
"- Use {} to find other Agents that can help with a focused writing, critique, planning, or style task. This tool only lists Agents; it does not start any work.",
model_name(tools, AGENT_LIST_TOOL)
));
}
if has_tool(tools, AGENT_DELEGATE_TOOL) {
if has_tool(tools, AGENT_AWAIT_TOOL) {
lines.push(format!(
"- Use {} to ask another Agent to handle a self-contained task. You can continue working after delegating; use {} when you need a delegated result or status before deciding.",
model_name(tools, AGENT_DELEGATE_TOOL),
model_name(tools, AGENT_AWAIT_TOOL)
));
lines.push(
"- If delegated task results are provided later, review them before finalizing."
.to_string(),
);
} else {
lines.push(format!(
"- Use {} to ask another Agent to handle a self-contained task. You can continue working after delegating.",
model_name(tools, AGENT_DELEGATE_TOOL)
));
}
}
if has_tool(tools, AGENT_HANDOFF_TOOL) {
lines.push(format!(
"- Use {} when you have finished your part and another Agent should continue. Provide a self-contained handoff brief with the objective, relevant workspace paths, decisions, constraints, and what done looks like.",
model_name(tools, AGENT_HANDOFF_TOOL)
));
lines.push(format!(
"- After {} succeeds, your part is done; do not call more tools.",
model_name(tools, AGENT_HANDOFF_TOOL)
));
}
if has_tool(tools, "skill.search") {
lines.push(format!(
"- Before reading exact ranges, use {} to locate relevant text within larger visible skill files.",
model_name(tools, "skill.search")
));
}
if has_tool(tools, "skill.read") {
lines.push(format!(
"- Use {} to read SKILL.md first, then only read referenced skill files or specified ranges within them when necessary.",
model_name(tools, "skill.read")
));
}
if has_tool(tools, "workspace.list_files") {
lines.push(format!(
"- Use {} to inspect visible workspace files.",
model_name(tools, "workspace.list_files")
));
}
if has_tool(tools, "workspace.search_files") {
lines.push(format!(
"- Before reading exact ranges, use {} to find relevant text within visible workspace files (e.g., persist/ memory).",
model_name(tools, "workspace.search_files")
));
}
if has_tool(tools, "workspace.read_file") {
lines.push(format!(
"- Use {} before modifying an existing file. Read the exact text you want to replace; if a patch fails, fully read the file before retrying. Read content includes line numbers; never include line number prefixes in old_string or new_string.",
model_name(tools, "workspace.read_file")
));
}
if has_tool(tools, "workspace.apply_patch") {
lines.push(format!(
"- Use {} to perform precise edits on existing files. old_string must match exactly and be unique unless replace_all is true.",
model_name(tools, "workspace.apply_patch")
));
}
if has_tool(tools, "workspace.write_file") {
lines.push(format!(
"- Use {} to create files, append to files, or perform complete rewrites.",
model_name(tools, "workspace.write_file")
));
}
if has_tool(tools, "workspace.commit") {
lines.push(format!(
"- Use {} to publish visible workspace files into the current chat message. Without arguments, it will replace the current run's chat message with {}; mode append will append to the same message, creating it if this run has not committed yet.",
model_name(tools, "workspace.commit"),
profile.output.message_body_path
));
}
if profile
.workspace
.visible_roots
.iter()
.any(|root| root == "persist")
&& profile
.workspace
.writable_roots
.iter()
.any(|root| root == "persist")
{
lines.push("- Use persist/ to store concise information that should carry over into subsequent runs of the same chat, such as persistent plot facts, unresolved threads, relationship states, and user style preferences.".to_string());
lines.push(
"- **Do not** copy full chat history, final replies, tool results, or temporary reasoning into persist/."
.to_string(),
);
}
if has_tool(tools, TASK_RETURN_TOOL) {
lines.push(
"- Delegated task workspace: use the same logical workspace paths as the Agent that asked for this task. Do not invent private path mappings."
.to_string(),
);
lines.push(
"- Use the workspace paths named in the task brief. Write supporting notes or artifacts only under writable roots."
.to_string(),
);
lines.push(format!(
"- Visible workspace roots for this task: {}.",
format_model_workspace_roots(&profile.workspace.visible_roots)
));
lines.push(format!(
"- Writable workspace roots for this task: {}.",
format_model_workspace_roots(&profile.workspace.writable_roots)
));
lines.push(format!(
"# **Important**: You are completing a delegated task. Return your result only by calling {} with a concise result for the requesting Agent.",
model_name(tools, TASK_RETURN_TOOL)
));
lines.push(
"- If useful, write supporting notes or requested artifacts, then reference those workspace paths in task_return."
.to_string(),
);
} else {
lines.push(format!(
"- Visible workspace roots: {}.",
format_model_workspace_roots(&profile.workspace.visible_roots)
));
lines.push(format!(
"- Writable workspace roots: {}.",
format_model_workspace_roots(&profile.workspace.writable_roots)
));
lines.push(format!(
"- **Never** read {} before commit",
profile.output.message_body_path
));
lines.push(
"> You may encounter: \"No visible workspace files found.\" This happens because there are no persisted files; please continue."
.to_string(),
);
match (
profile.run.presentation,
has_tool(tools, "workspace.finish"),
has_tool(tools, "workspace.commit"),
has_tool(tools, AGENT_HANDOFF_TOOL),
) {
(
crate::domain::models::agent::AgentRunPresentation::Foreground,
true,
true,
_,
) => lines.push(format!(
"# **Important**: Before calling {}, you **must successfully call {} at least once** so that the user can see the final chat message.",
model_name(tools, "workspace.finish"),
model_name(tools, "workspace.commit")
)),
(
crate::domain::models::agent::AgentRunPresentation::Foreground,
true,
false,
_,
) => lines.push(format!(
"# **Important**: Call {} only when this foreground stage can end without you publishing a new chat commit.",
model_name(tools, "workspace.finish")
)),
(crate::domain::models::agent::AgentRunPresentation::Background, true, _, _) => {
lines.push(format!(
"# Background runs may call {} without committing a chat message.",
model_name(tools, "workspace.finish")
));
}
(_, false, _, true) => lines.push(format!(
"# **Important**: You cannot finish the run directly with the available tools. When your part is complete, call {}.",
model_name(tools, AGENT_HANDOFF_TOOL)
)),
(_, false, _, false) => lines.push(
"# **Important**: You do not have a finish or handoff tool. Use another available Agent tool to move the work forward."
.to_string(),
),
}
if has_tool(tools, "workspace.finish") {
lines.push(format!(
"# **Important**: Do not answer in plain text. Finish by calling {}.",
model_name(tools, "workspace.finish")
));
} else if has_tool(tools, AGENT_HANDOFF_TOOL) {
lines.push(format!(
"# **Important**: Do not answer in plain text. Continue by calling {}.",
model_name(tools, AGENT_HANDOFF_TOOL)
));
}
}
if has_tool(tools, "workspace.commit") && has_tool(tools, "workspace.finish") {
lines.extend([
String::new(),
format!(
"# Basic tool calling flow (adjusted based on the actual situation, but the flow must include {} + {}):",
model_name(tools, "workspace.commit"),
model_name(tools, "workspace.finish")
),
String::new(),
"A simple template you can follow:".to_string(),
" (thoughts before actions)".to_string(),
" (call tools)(optional)".to_string(),
String::new(),
format!(
" Now I need to call \"{}\" once.",
model_name(tools, "workspace.commit")
),
format!(
" Good, it has been committed. Finally, don't forget to call \"{}\".",
model_name(tools, "workspace.finish")
),
String::new(),
"You also can follow commit-N-times template:".to_string(),
" (thoughts before actions)".to_string(),
]);
if has_tool(tools, "workspace.read_file") {
lines.push(format!(
" ({})",
model_name(tools, "workspace.read_file")
));
}
if has_tool(tools, "worldinfo.read_activated") {
lines.push(format!(
" ({})",
model_name(tools, "worldinfo.read_activated")
));
}
if has_tool(tools, "skill.list") {
lines.push(format!(" ({})", model_name(tools, "skill.list")));
}
lines.extend([
format!(
" (call {} with append mode)",
model_name(tools, "workspace.commit")
),
" (think)".to_string(),
" (edit if necessary)".to_string(),
format!(
" ({} with append mode)",
model_name(tools, "workspace.commit")
),
String::new(),
]);
}
lines.push("Anyway: TOOLS&SKILLS IS ALL YOU NEED".to_string());
lines.join("\n")
}
fn has_tool(tools: &[AgentToolSpec], name: &str) -> bool {
tools.iter().any(|tool| tool.name == name)
}
fn model_name<'a>(tools: &'a [AgentToolSpec], name: &'a str) -> &'a str {
tools
.iter()
.find(|tool| tool.name == name)
.map(|tool| tool.model_name.as_str())
.unwrap_or(name)
}

View file

@ -0,0 +1,732 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use uuid::Uuid;
use crate::application::services::agent_tools::BuiltinAgentToolRegistry;
use crate::domain::errors::DomainError;
use crate::domain::models::agent::AgentToolSpec;
use crate::domain::models::agent::profile::{
AgentContextPolicy, AgentModelBinding, AgentModelBindingMode, AgentPresetBindingMode,
AgentPresetRef, AgentProfileId, ResolvedAgentProfile,
};
use crate::domain::models::preset::{DefaultPreset, Preset, PresetType};
use crate::domain::repositories::preset_repository::PresetRepository;
use crate::infrastructure::repositories::file_agent_profile_repository::FileAgentProfileRepository;
use super::{AgentProfileService, materialize_agent_system_prompt};
#[test]
fn materialized_agent_system_prompt_uses_profile_override_exactly() {
let profile = test_profile(
Some("Custom Agent System Prompt.\nKeep this exact."),
"foreground",
);
let prompt =
materialize_agent_system_prompt(&[tool("workspace.finish", "finish_alias")], &profile);
assert_eq!(prompt, "Custom Agent System Prompt.\nKeep this exact.");
}
#[test]
fn default_agent_system_prompt_uses_visible_tool_model_names() {
let profile = test_profile(None, "foreground");
let tools = vec![
tool("chat.search", "chat_search_alias"),
tool("workspace.commit", "workspace_commit_alias"),
tool("workspace.finish", "workspace_finish_alias"),
];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains("tool_choice: required"));
assert!(prompt.contains("- chat_search_alias"));
assert!(prompt.contains("- workspace_commit_alias"));
assert!(prompt.contains("- workspace_finish_alias"));
assert!(!prompt.contains("TauriTavern"));
assert!(!prompt.contains("runtime"));
assert!(prompt.contains("use chat_search_alias to find relevant prior messages"));
assert!(prompt.contains(
"Before calling workspace_finish_alias, you **must successfully call workspace_commit_alias at least once**"
));
assert!(
prompt.contains("Do not answer in plain text. Finish by calling workspace_finish_alias.")
);
assert!(!prompt.contains("workspace_read_file"));
}
#[test]
fn requires_configuration_model_binding_is_valid_but_not_configured() {
let binding = AgentModelBinding {
mode: AgentModelBindingMode::RequiresConfiguration,
connection_ref: None,
model_id: None,
};
super::validation::validate_model_binding(&binding).expect("requiresConfiguration is saveable");
let mut profile = test_profile(None, "background");
profile.model = binding;
let error = super::ensure_profile_model_configured(&profile)
.expect_err("requiresConfiguration cannot run");
assert!(
error
.to_string()
.contains("agent.profile_model_requires_configuration")
);
}
#[test]
fn requires_configuration_rejects_local_connection_fields() {
let binding = AgentModelBinding {
mode: AgentModelBindingMode::RequiresConfiguration,
connection_ref: Some("local-main".to_string()),
model_id: Some("secret-model".to_string()),
};
let error = super::validation::validate_model_binding(&binding)
.expect_err("requiresConfiguration must not carry local fields");
assert!(
error
.to_string()
.contains("agent.profile_model_requires_configuration_extra_fields")
);
}
#[test]
fn context_policy_allows_empty_initial_history_window() {
let mut policy = AgentContextPolicy {
initial_chat_history_messages: 0,
include_activated_world_info: true,
};
super::validation::normalize_context_policy(&mut policy)
.expect("zero means no initial chat history");
assert_eq!(policy.initial_chat_history_messages, 0);
}
#[test]
fn context_policy_normalizes_negative_history_window_to_full_history() {
let mut policy = AgentContextPolicy {
initial_chat_history_messages: -42,
include_activated_world_info: true,
};
super::validation::normalize_context_policy(&mut policy).expect("negative values normalize");
assert_eq!(policy.initial_chat_history_messages, -1);
}
#[test]
fn default_agent_system_prompt_reflects_profile_workspace_policy() {
let mut profile = test_profile(None, "background");
profile.workspace.visible_roots = vec!["output".to_string()];
profile.workspace.writable_roots = vec!["output".to_string()];
let tools = vec![tool("workspace.finish", "workspace_finish_alias")];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains("- Visible workspace roots: output/."));
assert!(prompt.contains("- Writable workspace roots: output/."));
assert!(prompt.contains(
"# Background runs may call workspace_finish_alias without committing a chat message."
));
assert!(!prompt.contains("Use persist/"));
assert!(!prompt.contains("must successfully call"));
}
#[test]
fn default_agent_system_prompt_makes_await_optional_and_decision_driven() {
let profile = test_profile(None, "background");
let tools = vec![
tool("agent.delegate", "agent_delegate_alias"),
tool("agent.await", "agent_await_alias"),
tool("workspace.finish", "workspace_finish_alias"),
];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains("You can continue working after delegating"));
assert!(prompt.contains(
"use agent_await_alias when you need a delegated result or status before deciding"
));
assert!(prompt.contains("If delegated task results are provided later"));
assert!(!prompt.contains("collect delegated task results before finalizing"));
}
#[test]
fn default_agent_system_prompt_does_not_mention_hidden_await_tool() {
let profile = test_profile(None, "background");
let tools = vec![
tool("agent.delegate", "agent_delegate_alias"),
tool("workspace.finish", "workspace_finish_alias"),
];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains("Use agent_delegate_alias"));
assert!(prompt.contains("You can continue working after delegating"));
assert!(!prompt.contains("agent.await"));
assert!(!prompt.contains("agent_await"));
}
#[test]
fn default_agent_system_prompt_describes_handoff_from_current_agent_view() {
let profile = test_profile(None, "foreground");
let tools = vec![tool("agent.handoff", "agent_handoff_alias")];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains(
"Use agent_handoff_alias when you have finished your part and another Agent should continue"
));
assert!(prompt.contains(
"After agent_handoff_alias succeeds, your part is done; do not call more tools."
));
assert!(prompt.contains("You cannot finish the run directly with the available tools"));
assert!(
prompt.contains("Do not answer in plain text. Continue by calling agent_handoff_alias.")
);
assert!(!prompt.contains("This Agent"));
assert!(!prompt.contains("this Agent"));
assert!(!prompt.contains("Agent invocation"));
assert!(!prompt.contains("active run stage"));
}
#[test]
fn delegated_task_system_prompt_uses_shared_workspace_paths() {
let mut profile = test_profile(None, "background");
profile.workspace.visible_roots = vec!["output".to_string(), "persist".to_string()];
profile.workspace.writable_roots = vec!["output".to_string(), "persist".to_string()];
let tools = vec![
tool("workspace.write_file", "workspace_write_file"),
tool("task.return", "task_return"),
];
let prompt = materialize_agent_system_prompt(&tools, &profile);
assert!(prompt.contains("Delegated task workspace"));
assert!(prompt.contains("same logical workspace paths"));
assert!(!prompt.contains("summaries/parent/"));
assert!(!prompt.contains("summaries/agents/"));
assert!(prompt.contains("- Visible workspace roots for this task: output/, persist/."));
assert!(prompt.contains("- Writable workspace roots for this task: output/, persist/."));
assert!(!prompt.contains("- Visible workspace roots: output/, persist/."));
assert!(!prompt.contains("- Writable workspace roots: output/, persist/."));
assert!(!prompt.contains("Never"));
assert!(prompt.contains("task_return"));
}
#[test]
fn direct_runnable_profiles_require_finish_tool() {
let run = crate::domain::models::agent::profile::AgentRunPolicy {
presentation: crate::domain::models::agent::AgentRunPresentation::Background,
direct_runnable: true,
model_retry: Default::default(),
};
let delegation = crate::domain::models::agent::profile::AgentDelegationPolicy::default();
let tools = test_tool_policy(&["workspace.write_file"]);
let error = super::validation::validate_run_policy(&run, &delegation, &tools)
.expect_err("direct runnable profile without finish should fail");
assert!(error.to_string().contains("agent.profile_finish_required"));
}
#[test]
fn subagent_only_profiles_do_not_require_finish_tool() {
let run = crate::domain::models::agent::profile::AgentRunPolicy {
presentation: crate::domain::models::agent::AgentRunPresentation::Background,
direct_runnable: false,
model_retry: Default::default(),
};
let delegation = crate::domain::models::agent::profile::AgentDelegationPolicy {
callable: true,
allow_as_subagent: true,
..Default::default()
};
let tools = test_tool_policy(&["workspace.write_file"]);
super::validation::validate_run_policy(&run, &delegation, &tools)
.expect("subagent-only profile should not require workspace.finish");
}
#[test]
fn handoff_target_profiles_do_not_require_finish_tool() {
let run = crate::domain::models::agent::profile::AgentRunPolicy {
presentation: crate::domain::models::agent::AgentRunPresentation::Foreground,
direct_runnable: false,
model_retry: Default::default(),
};
let delegation = crate::domain::models::agent::profile::AgentDelegationPolicy {
callable: true,
allow_as_handoff_target: true,
..Default::default()
};
let tools = test_tool_policy(&["workspace.write_file", "agent.handoff"]);
super::validation::validate_run_policy(&run, &delegation, &tools)
.expect("handoff target profile should not require workspace.finish");
}
#[test]
fn default_writer_does_not_enable_dice_roll() {
let profile = super::defaults::default_writer_profile().expect("default writer profile");
assert!(
!profile.tools.allow.iter().any(|tool| tool == "dice.roll"),
"dice.roll must stay opt-in so normal Agent flows do not roll accidentally"
);
}
#[test]
fn direct_runnable_false_requires_subagent_entrypoint() {
let run = crate::domain::models::agent::profile::AgentRunPolicy {
presentation: crate::domain::models::agent::AgentRunPresentation::Background,
direct_runnable: false,
model_retry: Default::default(),
};
let delegation = crate::domain::models::agent::profile::AgentDelegationPolicy::default();
let tools = test_tool_policy(&["workspace.write_file"]);
let error = super::validation::validate_run_policy(&run, &delegation, &tools)
.expect_err("non-direct profiles need a implemented non-direct entrypoint");
assert!(
error
.to_string()
.contains("agent.profile_direct_runnable_disabled_requires_delegation_target")
);
}
#[tokio::test]
async fn profile_preset_retarget_updates_only_matching_refs() {
let root = temp_profile_root("retarget");
let profile_service = test_profile_service_with_presets(
&root,
TestPresetRepository::with_user_openai("New Writer Preset"),
);
save_profile_with_preset_ref(&profile_service, "writer", "openai", "Old Writer Preset").await;
save_profile_with_preset_ref(&profile_service, "critic", "openai", "Other Preset").await;
let result = profile_service
.retarget_preset_refs(
preset_ref("openai", "Old Writer Preset"),
preset_ref("openai", "New Writer Preset"),
)
.await
.expect("retarget profile preset refs");
assert_eq!(result.profile_ids.len(), 1);
assert_eq!(result.profile_ids[0].as_str(), "writer");
assert_eq!(
loaded_preset_name(&profile_service, "writer").await,
"New Writer Preset"
);
assert_eq!(
loaded_preset_name(&profile_service, "critic").await,
"Other Preset"
);
cleanup_profile_root(root).await;
}
#[tokio::test]
async fn profile_preset_retarget_accepts_default_target_preset() {
let root = temp_profile_root("retarget-default-target");
let profile_service = test_profile_service_with_presets(
&root,
TestPresetRepository::with_default_openai("Built In Writer Preset"),
);
save_profile_with_preset_ref(&profile_service, "writer", "openai", "Old Writer Preset").await;
profile_service
.retarget_preset_refs(
preset_ref("openai", "Old Writer Preset"),
preset_ref("openai", "Built In Writer Preset"),
)
.await
.expect("default target preset is a valid retarget destination");
assert_eq!(
loaded_preset_name(&profile_service, "writer").await,
"Built In Writer Preset"
);
cleanup_profile_root(root).await;
}
#[tokio::test]
async fn profile_preset_retarget_requires_existing_target_preset() {
let root = temp_profile_root("retarget-missing-target");
let profile_service = test_profile_service_with_presets(&root, TestPresetRepository::default());
save_profile_with_preset_ref(&profile_service, "writer", "openai", "Old Writer Preset").await;
let error = profile_service
.retarget_preset_refs(
preset_ref("openai", "Old Writer Preset"),
preset_ref("openai", "Missing Writer Preset"),
)
.await
.expect_err("missing target preset should fail");
assert!(
error
.to_string()
.contains("agent.profile_preset_retarget_target_missing")
);
assert_eq!(
loaded_preset_name(&profile_service, "writer").await,
"Old Writer Preset"
);
cleanup_profile_root(root).await;
}
#[tokio::test]
async fn profile_preset_retarget_ignores_unmatched_malformed_preset_refs() {
let root = temp_profile_root("retarget-unmatched-malformed");
let profile_service = test_profile_service_with_presets(
&root,
TestPresetRepository::with_user_openai("New Writer Preset"),
);
save_profile_with_preset_ref(&profile_service, "writer", "openai", "Old Writer Preset").await;
let mut unrelated = profile_service
.load_profile("default-writer")
.await
.expect("load default profile")
.expect("default profile exists");
unrelated.id = AgentProfileId::parse("unrelated").expect("profile id");
unrelated.preset.mode = AgentPresetBindingMode::Ref;
unrelated.preset.ref_ = Some(preset_ref("unsupported-api", "Unrelated Preset"));
unrelated.preset.required = true;
profile_service
.profile_repository
.save_profile(&unrelated)
.await
.expect("save malformed unrelated profile");
let result = profile_service
.retarget_preset_refs(
preset_ref("openai", "Old Writer Preset"),
preset_ref("openai", "New Writer Preset"),
)
.await
.expect("unmatched malformed profile should not block retarget");
assert_eq!(result.profile_ids.len(), 1);
assert_eq!(
loaded_preset_name(&profile_service, "writer").await,
"New Writer Preset"
);
assert_eq!(
loaded_preset_api_id(&profile_service, "unrelated").await,
"unsupported-api"
);
cleanup_profile_root(root).await;
}
#[tokio::test]
async fn profile_preset_retarget_rejects_same_or_cross_api_refs() {
let root = temp_profile_root("retarget-invalid-pair");
let profile_service = test_profile_service_with_presets(&root, TestPresetRepository::default());
let same_error = profile_service
.retarget_preset_refs(
preset_ref("openai", "Writer Preset"),
preset_ref("openai", "Writer Preset"),
)
.await
.expect_err("same refs are not a rename");
assert!(
same_error
.to_string()
.contains("agent.profile_preset_retarget_same_ref")
);
let cross_api_error = profile_service
.retarget_preset_refs(
preset_ref("openai", "Writer Preset"),
preset_ref("textgenerationwebui", "Writer Preset"),
)
.await
.expect_err("preset refs cannot cross api groups");
assert!(
cross_api_error
.to_string()
.contains("agent.profile_preset_retarget_api_mismatch")
);
cleanup_profile_root(root).await;
}
fn temp_profile_root(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"tauritavern-agent-profile-{label}-{}",
Uuid::new_v4().simple()
))
}
async fn cleanup_profile_root(root: std::path::PathBuf) {
match tokio::fs::remove_dir_all(root).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!("cleanup: {error}"),
}
}
fn test_profile_service_with_presets(
root: &std::path::Path,
preset_repository: TestPresetRepository,
) -> Arc<AgentProfileService> {
let profile_repository = Arc::new(FileAgentProfileRepository::new(root.join("agent-profiles")));
Arc::new(AgentProfileService::new(
profile_repository.clone(),
profile_repository,
Arc::new(preset_repository),
))
}
async fn save_profile_with_preset_ref(
profile_service: &AgentProfileService,
profile_id: &str,
api_id: &str,
preset_name: &str,
) {
let mut profile = profile_service
.load_profile("default-writer")
.await
.expect("load default profile")
.expect("default profile exists");
profile.id = AgentProfileId::parse(profile_id).expect("profile id");
profile.preset.mode = AgentPresetBindingMode::Ref;
profile.preset.ref_ = Some(preset_ref(api_id, preset_name));
profile.preset.required = true;
let registry = BuiltinAgentToolRegistry::phase2c();
profile_service
.save_profile(profile, registry.specs())
.await
.expect("save profile");
}
async fn loaded_preset_name(profile_service: &AgentProfileService, profile_id: &str) -> String {
profile_service
.load_profile(profile_id)
.await
.expect("load profile")
.expect("profile exists")
.preset
.ref_
.expect("profile preset ref")
.name
}
async fn loaded_preset_api_id(profile_service: &AgentProfileService, profile_id: &str) -> String {
profile_service
.load_profile(profile_id)
.await
.expect("load profile")
.expect("profile exists")
.preset
.ref_
.expect("profile preset ref")
.api_id
}
fn preset_ref(api_id: &str, name: &str) -> AgentPresetRef {
AgentPresetRef {
api_id: api_id.to_string(),
name: name.to_string(),
}
}
#[derive(Default)]
struct TestPresetRepository {
user_openai: Vec<String>,
default_openai: Vec<String>,
}
impl TestPresetRepository {
fn with_user_openai(name: &str) -> Self {
Self {
user_openai: vec![name.to_string()],
default_openai: Vec::new(),
}
}
fn with_default_openai(name: &str) -> Self {
Self {
user_openai: Vec::new(),
default_openai: vec![name.to_string()],
}
}
}
#[async_trait]
impl PresetRepository for TestPresetRepository {
async fn save_preset(&self, _preset: &Preset) -> Result<(), DomainError> {
Ok(())
}
async fn delete_preset(
&self,
_name: &str,
_preset_type: &PresetType,
) -> Result<(), DomainError> {
Ok(())
}
async fn preset_exists(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<bool, DomainError> {
Ok(*preset_type == PresetType::OpenAI
&& self.user_openai.iter().any(|preset| preset == name))
}
async fn get_preset(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<Option<Preset>, DomainError> {
if self.preset_exists(name, preset_type).await? {
return Ok(Some(Preset::new(
name.to_string(),
preset_type.clone(),
json!({ "chat_completion_source": "openai" }),
)));
}
Ok(None)
}
async fn list_presets(&self, preset_type: &PresetType) -> Result<Vec<String>, DomainError> {
if *preset_type == PresetType::OpenAI {
return Ok(self.user_openai.clone());
}
Ok(Vec::new())
}
async fn get_default_preset(
&self,
name: &str,
preset_type: &PresetType,
) -> Result<Option<DefaultPreset>, DomainError> {
if *preset_type != PresetType::OpenAI
|| !self.default_openai.iter().any(|preset| preset == name)
{
return Ok(None);
}
Ok(Some(DefaultPreset {
filename: format!("{name}.json"),
name: name.to_string(),
preset_type: PresetType::OpenAI,
is_default: true,
data: json!({ "chat_completion_source": "openai" }),
}))
}
}
fn tool(name: &str, model_name: &str) -> AgentToolSpec {
AgentToolSpec {
name: name.to_string(),
model_name: model_name.to_string(),
title: name.to_string(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
annotations: json!({}),
source: "test".to_string(),
}
}
fn test_tool_policy(allow: &[&str]) -> crate::domain::models::agent::profile::AgentToolPolicy {
crate::domain::models::agent::profile::AgentToolPolicy {
allow: allow.iter().map(|name| name.to_string()).collect(),
deny: Vec::new(),
tool_descriptions: Default::default(),
max_rounds: 1,
max_calls_per_run: 1,
max_calls_per_tool: Default::default(),
}
}
fn test_profile(agent_system_prompt: Option<&str>, presentation: &str) -> ResolvedAgentProfile {
let instructions = match agent_system_prompt {
Some(prompt) => json!({ "agentSystemPrompt": prompt }),
None => json!({}),
};
serde_json::from_value(json!({
"schemaVersion": 1,
"kind": "tauritavern.agentProfile",
"id": "test",
"displayName": "Test",
"preset": {
"mode": "none",
"required": false
},
"model": {
"mode": "currentPromptSnapshot"
},
"run": {
"presentation": presentation,
"modelRetry": {
"maxRetries": 0,
"intervalMs": 3000
}
},
"context": {
"initialChatHistoryMessages": -1,
"includeActivatedWorldInfo": true
},
"instructions": instructions,
"tools": {
"allow": ["workspace.finish"],
"deny": [],
"toolDescriptions": {},
"maxRounds": 1,
"maxCallsPerRun": 1,
"maxCallsPerTool": {}
},
"skills": {
"visible": ["*"],
"deny": [],
"maxReadCharsPerCall": 1,
"maxReadCharsPerRun": 1
},
"workspace": {
"visibleRoots": ["output", "persist"],
"writableRoots": ["output", "persist"]
},
"plan": {
"mode": "none",
"beta": true,
"nodes": []
},
"output": {
"artifacts": [{
"id": "main",
"path": "output/main.md",
"kind": "markdown",
"target": "message_body",
"required": true,
"assemblyOrder": 0
}],
"messageBodyArtifactId": "main",
"messageBodyPath": "output/main.md"
},
"sourceTrace": {
"profileSource": "test"
}
}))
.expect("test profile")
}

View file

@ -0,0 +1,524 @@
use std::collections::BTreeSet;
use serde_json::Value;
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::AgentRunPresentation;
use crate::domain::models::agent::AgentToolSpec;
use crate::domain::models::agent::plan::{AgentPlanMode, AgentPlanPolicy};
use crate::domain::models::agent::profile::{
AGENT_PROFILE_KIND, AGENT_PROFILE_SCHEMA_VERSION, AgentContextPolicy, AgentDelegationPolicy,
AgentModelBinding, AgentModelBindingMode, AgentProfileDefinition, AgentProfileId,
AgentProfileInstructions, AgentRunPolicy, AgentSkillPolicy, AgentToolDescriptionOverride,
AgentToolPolicy, AgentWorkspacePolicy,
};
use super::constants::{
AGENT_AWAIT_TOOL, AGENT_DELEGATE_TOOL, AGENT_HANDOFF_TOOL, AGENT_LIST_TOOL, TASK_RETURN_TOOL,
WORKSPACE_ROOT_UNIVERSE,
};
pub(super) fn validate_profile_header(
profile: &AgentProfileDefinition,
) -> Result<(), ApplicationError> {
if profile.schema_version != AGENT_PROFILE_SCHEMA_VERSION {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_schema_unsupported: schemaVersion {} is unsupported",
profile.schema_version
)));
}
if profile.kind != AGENT_PROFILE_KIND {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_kind_invalid: kind must be {AGENT_PROFILE_KIND}"
)));
}
if profile.display_name.trim().is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_display_name_required: displayName cannot be empty".to_string(),
));
}
Ok(())
}
pub(super) fn migrate_profile_schema(
profile: &mut AgentProfileDefinition,
) -> Result<(), ApplicationError> {
match profile.schema_version {
1 => {
profile.schema_version = AGENT_PROFILE_SCHEMA_VERSION;
Ok(())
}
AGENT_PROFILE_SCHEMA_VERSION => Ok(()),
version => Err(ApplicationError::ValidationError(format!(
"agent.profile_schema_unsupported: schemaVersion {version} is unsupported"
))),
}
}
pub(super) fn validate_model_binding(binding: &AgentModelBinding) -> Result<(), ApplicationError> {
match binding.mode {
AgentModelBindingMode::CurrentPromptSnapshot => {
if binding
.connection_ref
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| binding
.model_id
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
{
return Err(ApplicationError::ValidationError(
"agent.profile_model_current_snapshot_extra_fields: connectionRef/modelId are only valid when model.mode is connectionRef"
.to_string(),
));
}
Ok(())
}
AgentModelBindingMode::RequiresConfiguration => {
if binding
.connection_ref
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| binding
.model_id
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
{
return Err(ApplicationError::ValidationError(
"agent.profile_model_requires_configuration_extra_fields: connectionRef/modelId must be empty when model.mode is requiresConfiguration"
.to_string(),
));
}
Ok(())
}
AgentModelBindingMode::ConnectionRef => {
if binding
.connection_ref
.as_ref()
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
return Err(ApplicationError::ValidationError(
"agent.profile_model_connection_ref_required: model.connectionRef is required when model.mode is connectionRef"
.to_string(),
));
}
if binding
.model_id
.as_ref()
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
return Err(ApplicationError::ValidationError(
"agent.profile_model_id_required: model.modelId is required when model.mode is connectionRef"
.to_string(),
));
}
Ok(())
}
}
}
pub(super) fn normalize_context_policy(
policy: &mut AgentContextPolicy,
) -> Result<(), ApplicationError> {
if policy.initial_chat_history_messages < 0 {
policy.initial_chat_history_messages = -1;
}
Ok(())
}
pub(super) fn validate_instructions(
instructions: &AgentProfileInstructions,
) -> Result<(), ApplicationError> {
if instructions
.agent_system_prompt
.as_ref()
.is_some_and(|prompt| prompt.trim().is_empty())
{
return Err(ApplicationError::ValidationError(
"agent.profile_system_prompt_empty: instructions.agentSystemPrompt cannot be empty"
.to_string(),
));
}
Ok(())
}
pub(super) fn validate_plan_policy(plan: &AgentPlanPolicy) -> Result<(), ApplicationError> {
if plan.mode != AgentPlanMode::None {
return Err(ApplicationError::ValidationError(
"agent.plan_mode_unsupported: Phase 3 foundation only supports plan.mode = none"
.to_string(),
));
}
if !plan.nodes.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.plan_nodes_unsupported: plan.nodes must be empty when plan.mode = none"
.to_string(),
));
}
Ok(())
}
pub(super) fn validate_tool_policy(
policy: &AgentToolPolicy,
known_tools: &[AgentToolSpec],
) -> Result<(), ApplicationError> {
if policy.max_rounds == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_max_rounds_invalid: tools.maxRounds must be > 0".to_string(),
));
}
if policy.max_calls_per_run == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_max_calls_invalid: tools.maxCallsPerRun must be > 0".to_string(),
));
}
let known = known_tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<BTreeSet<_>>();
let allow = policy
.allow
.iter()
.map(|name| name.as_str())
.collect::<BTreeSet<_>>();
let deny = policy
.deny
.iter()
.map(|name| name.as_str())
.collect::<BTreeSet<_>>();
if allow.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_tools_empty: tools.allow cannot be empty".to_string(),
));
}
for name in allow.iter().chain(deny.iter()) {
if !known.contains(name) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_unknown_tool: unknown tool `{name}`"
)));
}
}
let visible = allow.difference(&deny).copied().collect::<BTreeSet<_>>();
if !visible.contains("workspace.write_file") {
return Err(ApplicationError::ValidationError(
"agent.profile_output_writer_required: workspace.write_file must be visible so the Agent can create the required message body artifact"
.to_string(),
));
}
for (name, override_) in &policy.tool_descriptions {
if !visible.contains(name.as_str()) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_tool_description_invisible: `{name}` is not visible"
)));
}
let spec = known_tools
.iter()
.find(|tool| tool.name == *name)
.expect("known tool already checked");
validate_tool_description_override(spec, override_)?;
}
for (name, max) in &policy.max_calls_per_tool {
if !visible.contains(name.as_str()) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_tool_budget_invisible: `{name}` is not visible"
)));
}
if *max == 0 {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_tool_budget_invalid: maxCallsPerTool.{name} must be > 0"
)));
}
}
Ok(())
}
pub(super) fn validate_delegation_policy(
policy: &AgentDelegationPolicy,
tools: &AgentToolPolicy,
) -> Result<(), ApplicationError> {
if policy.max_concurrent_invocations == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_concurrency_invalid: delegation.maxConcurrentInvocations must be > 0"
.to_string(),
));
}
if policy.max_invocations_per_run == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_run_budget_invalid: delegation.maxInvocationsPerRun must be > 0"
.to_string(),
));
}
if policy.result_budget_tokens == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_result_budget_invalid: delegation.resultBudgetTokens must be > 0"
.to_string(),
));
}
if policy.max_handoff_depth == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_handoff_depth_invalid: delegation.maxHandoffDepth must be > 0"
.to_string(),
));
}
if policy.allowed_callers.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_callers_empty: delegation.allowedCallers cannot be empty"
.to_string(),
));
}
for caller in &policy.allowed_callers {
if caller == "*" {
continue;
}
AgentProfileId::parse(caller).map_err(ApplicationError::ValidationError)?;
}
if policy
.description_for_agents
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_description_empty: delegation.descriptionForAgents cannot be empty"
.to_string(),
));
}
if !policy.callable && (policy.allow_as_subagent || policy.allow_as_handoff_target) {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_callable_required: delegation.callable must be true before allowing this profile as a subagent or handoff target"
.to_string(),
));
}
if policy.callable && !policy.allow_as_subagent && !policy.allow_as_handoff_target {
return Err(ApplicationError::ValidationError(
"agent.profile_delegation_target_mode_required: callable profiles must allow subagent and/or handoff targeting"
.to_string(),
));
}
let agent_list_visible = tools.allow.iter().any(|name| name == AGENT_LIST_TOOL)
&& !tools.deny.iter().any(|name| name == AGENT_LIST_TOOL);
let agent_delegate_visible = tools.allow.iter().any(|name| name == AGENT_DELEGATE_TOOL)
&& !tools.deny.iter().any(|name| name == AGENT_DELEGATE_TOOL);
let agent_await_visible = tools.allow.iter().any(|name| name == AGENT_AWAIT_TOOL)
&& !tools.deny.iter().any(|name| name == AGENT_AWAIT_TOOL);
let agent_handoff_visible = tools.allow.iter().any(|name| name == AGENT_HANDOFF_TOOL)
&& !tools.deny.iter().any(|name| name == AGENT_HANDOFF_TOOL);
if agent_list_visible && !policy.can_delegate && !policy.can_handoff {
return Err(ApplicationError::ValidationError(
"agent.profile_agent_list_requires_delegation: agent.list requires delegation.canDelegate or delegation.canHandoff"
.to_string(),
));
}
if (agent_delegate_visible || agent_await_visible) && !policy.can_delegate {
return Err(ApplicationError::ValidationError(
"agent.profile_agent_delegate_requires_delegation: agent.delegate/agent.await require delegation.canDelegate"
.to_string(),
));
}
if agent_handoff_visible && !policy.can_handoff {
return Err(ApplicationError::ValidationError(
"agent.profile_agent_handoff_requires_handoff: agent.handoff requires delegation.canHandoff"
.to_string(),
));
}
if tools.allow.iter().any(|name| name == TASK_RETURN_TOOL) {
return Err(ApplicationError::ValidationError(
"agent.profile_task_return_runtime_only: task.return is added by the runtime for child invocations and must not be listed in profile tools.allow"
.to_string(),
));
}
Ok(())
}
pub(super) fn validate_run_policy(
run: &AgentRunPolicy,
delegation: &AgentDelegationPolicy,
tools: &AgentToolPolicy,
) -> Result<(), ApplicationError> {
if run.model_retry.max_retries > 0 && run.model_retry.interval_ms == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_model_retry_invalid: run.modelRetry.intervalMs must be > 0 when retries are enabled"
.to_string(),
));
}
if !run.direct_runnable {
if !delegation.allow_as_handoff_target
&& run.presentation != AgentRunPresentation::Background
{
return Err(ApplicationError::ValidationError(
"agent.profile_subagent_only_background_required: run.presentation must be background when run.directRunnable is false"
.to_string(),
));
}
if !delegation.callable
|| (!delegation.allow_as_subagent && !delegation.allow_as_handoff_target)
{
return Err(ApplicationError::ValidationError(
"agent.profile_direct_runnable_disabled_requires_delegation_target: run.directRunnable=false requires delegation.callable and allowAsSubagent or allowAsHandoffTarget"
.to_string(),
));
}
return Ok(());
}
if !tool_is_visible(tools, "workspace.finish") {
return Err(ApplicationError::ValidationError(
"agent.profile_finish_required: workspace.finish must be visible for direct runnable profiles"
.to_string(),
));
}
if run.presentation == AgentRunPresentation::Foreground
&& !tool_is_visible(tools, "workspace.commit")
{
return Err(ApplicationError::ValidationError(
"agent.profile_commit_required: foreground direct runnable profiles must expose workspace.commit"
.to_string(),
));
}
Ok(())
}
pub(super) fn validate_skill_policy(policy: &AgentSkillPolicy) -> Result<(), ApplicationError> {
if policy.visible.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_skill_visible_empty: skills.visible cannot be empty".to_string(),
));
}
if policy.max_read_chars_per_call == 0 || policy.max_read_chars_per_run == 0 {
return Err(ApplicationError::ValidationError(
"agent.profile_skill_budget_invalid: skill read budgets must be > 0".to_string(),
));
}
if policy.max_read_chars_per_call > policy.max_read_chars_per_run {
return Err(ApplicationError::ValidationError(
"agent.profile_skill_budget_invalid: maxReadCharsPerCall cannot exceed maxReadCharsPerRun"
.to_string(),
));
}
for name in &policy.visible {
if name == "*" {
continue;
}
validate_skill_name(name)?;
}
for name in &policy.deny {
if name == "*" {
continue;
}
validate_skill_name(name)?;
}
Ok(())
}
pub(super) fn validate_workspace_policy(
policy: &AgentWorkspacePolicy,
) -> Result<(), ApplicationError> {
let universe = WORKSPACE_ROOT_UNIVERSE.into_iter().collect::<BTreeSet<_>>();
let visible = policy
.visible_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
let writable = policy
.writable_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
if visible.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.profile_workspace_visible_empty: workspace.visibleRoots cannot be empty"
.to_string(),
));
}
for root in visible.iter().chain(writable.iter()) {
if !universe.contains(root) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_workspace_root_invalid: `{root}` is not an Agent workspace root"
)));
}
}
for root in &writable {
if !visible.contains(root) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_workspace_root_invalid: writable root `{root}` is not visible"
)));
}
}
Ok(())
}
fn tool_is_visible(tools: &AgentToolPolicy, name: &str) -> bool {
tools.allow.iter().any(|tool| tool == name) && !tools.deny.iter().any(|tool| tool == name)
}
fn validate_tool_description_override(
spec: &AgentToolSpec,
override_: &AgentToolDescriptionOverride,
) -> Result<(), ApplicationError> {
if let Some(description) = override_.description.as_ref() {
if description.trim().is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_tool_description_empty: description for `{}` cannot be empty",
spec.name
)));
}
}
if override_.properties.is_empty() {
return Ok(());
}
let properties = spec
.input_schema
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.profile_tool_properties_invalid: `{}` has no object properties",
spec.name
))
})?;
for (property, description) in &override_.properties {
if !properties.contains_key(property) {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_unknown_tool_property: `{}` has no property `{property}`",
spec.name
)));
}
if description.trim().is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.profile_tool_property_description_empty: `{}` property `{property}` cannot be empty",
spec.name
)));
}
}
Ok(())
}
fn validate_skill_name(name: &str) -> Result<(), ApplicationError> {
let name = name.trim();
if name.is_empty()
|| name.len() > 128
|| !name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
})
{
return Err(ApplicationError::ValidationError(format!(
"agent.profile_skill_name_invalid: invalid Skill name `{name}`"
)));
}
Ok(())
}

View file

@ -0,0 +1,59 @@
use std::collections::BTreeSet;
use crate::domain::models::agent::profile::ResolvedAgentProfile;
use crate::domain::models::agent::{
ArtifactTarget, CommitPolicy, WorkspaceRootCommit, WorkspaceRootLifecycle, WorkspaceRootMount,
WorkspaceRootScope, WorkspaceRootSpec,
};
use super::constants::WORKSPACE_ROOT_UNIVERSE;
pub fn workspace_roots_from_profile(profile: &ResolvedAgentProfile) -> Vec<WorkspaceRootSpec> {
let visible = profile
.workspace
.visible_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
let writable = profile
.workspace
.writable_roots
.iter()
.map(|root| root.as_str())
.collect::<BTreeSet<_>>();
WORKSPACE_ROOT_UNIVERSE
.iter()
.map(|root| {
if *root == "persist" {
WorkspaceRootSpec {
path: root.to_string(),
lifecycle: WorkspaceRootLifecycle::Persistent,
scope: WorkspaceRootScope::Chat,
mount: WorkspaceRootMount::ProjectedOverlay,
visible: visible.contains(*root),
writable: writable.contains(*root),
commit: WorkspaceRootCommit::OnRunCompleted,
}
} else {
WorkspaceRootSpec {
path: root.to_string(),
lifecycle: WorkspaceRootLifecycle::Run,
scope: WorkspaceRootScope::Run,
mount: WorkspaceRootMount::Materialized,
visible: visible.contains(*root),
writable: writable.contains(*root),
commit: WorkspaceRootCommit::Never,
}
}
})
.collect()
}
pub fn commit_policy_from_profile(_profile: &ResolvedAgentProfile) -> CommitPolicy {
CommitPolicy {
default_target: ArtifactTarget::MessageBody,
combine_template: None,
store_artifacts_in_extra: true,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,325 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Notify;
use tokio::time::{Duration, sleep};
use crate::application::dto::agent_dto::{AgentApplyRunPruneDto, AgentRunPruneRetentionDto};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_run_history_service::AgentRunHistoryService;
use crate::domain::repositories::settings_repository::SettingsRepository;
const AGENT_RUN_RETENTION_AUTO_COLD_START_DELAY_SECS: u64 = 45;
const AGENT_RUN_RETENTION_AUTO_INTERVAL_SECS: u64 = 30 * 60;
const AGENT_RUN_RETENTION_AUTO_RETRY_DELAY_SECS: u64 = 60;
pub struct AgentRunRetentionAutomationService {
settings_repository: Arc<dyn SettingsRepository>,
run_history_service: Arc<AgentRunHistoryService>,
notify: Notify,
started: AtomicBool,
}
impl AgentRunRetentionAutomationService {
pub fn new(
settings_repository: Arc<dyn SettingsRepository>,
run_history_service: Arc<AgentRunHistoryService>,
) -> Self {
Self {
settings_repository,
run_history_service,
notify: Notify::new(),
started: AtomicBool::new(false),
}
}
pub fn start(self: &Arc<Self>) {
if self.started.swap(true, Ordering::AcqRel) {
return;
}
let service = self.clone();
tauri::async_runtime::spawn(async move {
service.scheduler_loop().await;
});
}
pub fn notify_settings_changed(&self) {
self.notify.notify_waiters();
}
async fn run_once_if_enabled(&self) -> Result<bool, ApplicationError> {
let retention = self
.settings_repository
.load_tauritavern_settings()
.await?
.agent
.retention;
if !retention.auto_prune_enabled {
return Ok(false);
}
let Some(result) = self
.run_history_service
.try_apply_run_prune_for_automation(AgentApplyRunPruneDto {
retention: Some(AgentRunPruneRetentionDto::from(retention)),
detail_limit: 0,
})
.await?
else {
tracing::debug!(
"Agent run auto cleanup skipped because prune apply is already running"
);
return Ok(false);
};
if result.removed_file_count > 0 || result.failed_run_count > 0 {
tracing::info!(
slimmed_run_count = result.slimmed_run_count,
deleted_run_count = result.deleted_run_count,
failed_run_count = result.failed_run_count,
removed_file_count = result.removed_file_count,
removed_byte_count = result.removed_byte_count,
"Agent run auto cleanup completed"
);
}
Ok(true)
}
async fn scheduler_loop(self: Arc<Self>) {
let mut delay = Duration::from_secs(AGENT_RUN_RETENTION_AUTO_COLD_START_DELAY_SECS);
loop {
let enabled = match self.auto_prune_enabled().await {
Ok(enabled) => enabled,
Err(error) => {
tracing::warn!("Failed to load Agent run retention settings: {}", error);
sleep(Duration::from_secs(
AGENT_RUN_RETENTION_AUTO_RETRY_DELAY_SECS,
))
.await;
continue;
}
};
if !enabled {
self.notify.notified().await;
delay = Duration::from_secs(AGENT_RUN_RETENTION_AUTO_COLD_START_DELAY_SECS);
continue;
}
let wait = sleep(delay);
tokio::pin!(wait);
tokio::select! {
_ = &mut wait => {}
_ = self.notify.notified() => {
delay = Duration::from_secs(AGENT_RUN_RETENTION_AUTO_COLD_START_DELAY_SECS);
continue;
}
}
match self.run_once_if_enabled().await {
Ok(_) => {}
Err(error) => {
tracing::warn!("Agent run auto cleanup failed: {}", error);
}
}
delay = Duration::from_secs(AGENT_RUN_RETENTION_AUTO_INTERVAL_SECS);
}
}
async fn auto_prune_enabled(&self) -> Result<bool, ApplicationError> {
Ok(self
.settings_repository
.load_tauritavern_settings()
.await?
.agent
.retention
.auto_prune_enabled)
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::path::PathBuf;
use tokio::fs;
use super::*;
use crate::application::services::agent_workspace_lifecycle_service::AgentRunActivity;
use crate::domain::models::agent::{
AgentChatRef, AgentRun, AgentRunEventLevel, AgentRunPresentation, AgentRunSkillScopeRefs,
AgentRunStatus,
};
use crate::domain::models::settings::{
AgentRunRetentionSettings, AgentSettings, TauriTavernSettings,
};
use crate::domain::repositories::agent_run_repository::AgentRunRepository;
use crate::domain::repositories::settings_repository::SettingsRepository;
use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository;
use crate::infrastructure::repositories::file_settings_repository::FileSettingsRepository;
struct TestRunActivity;
#[async_trait]
impl AgentRunActivity for TestRunActivity {
async fn active_run_ids(&self) -> Result<Vec<String>, ApplicationError> {
Ok(Vec::new())
}
async fn active_run_ids_for_workspace(
&self,
_workspace_id: &str,
) -> Result<Vec<String>, ApplicationError> {
Ok(Vec::new())
}
}
fn instant(value: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(value)
.expect("valid timestamp")
.with_timezone(&Utc)
}
fn temp_root(label: &str) -> PathBuf {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"tauritavern-agent-run-retention-auto-{label}-{}-{suffix}",
std::process::id()
))
}
fn completed_run(id: &str) -> AgentRun {
AgentRun {
id: id.to_string(),
workspace_id: "chat_auto_prune".to_string(),
stable_chat_id: "stable_auto_prune".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,
skill_scope_refs: AgentRunSkillScopeRefs::default(),
persist_base_state_id: None,
input_message_count: Some(1),
presentation: AgentRunPresentation::Background,
status: AgentRunStatus::Completed,
created_at: instant("2026-01-01T00:00:00Z"),
updated_at: instant("2026-01-01T00:05:00Z"),
}
}
async fn seed_heavy_file(root: &std::path::Path, run: &AgentRun) {
let input_dir = root
.join("chats")
.join(&run.workspace_id)
.join("runs")
.join(&run.id)
.join("input");
fs::create_dir_all(&input_dir)
.await
.expect("create heavy input dir");
fs::write(input_dir.join("prompt_snapshot.json"), b"heavy")
.await
.expect("write heavy input file");
}
async fn append_terminal_event(repository: &FileAgentRepository, run: &AgentRun) {
repository
.append_event(
run.id.as_str(),
AgentRunEventLevel::Info,
"run_completed",
Value::Null,
)
.await
.expect("append terminal event");
}
fn build_service(
settings_repository: Arc<FileSettingsRepository>,
run_repository: Arc<FileAgentRepository>,
) -> Arc<AgentRunRetentionAutomationService> {
let history_service = Arc::new(AgentRunHistoryService::new(
run_repository,
settings_repository.clone(),
Arc::new(TestRunActivity),
));
Arc::new(AgentRunRetentionAutomationService::new(
settings_repository,
history_service,
))
}
#[tokio::test]
async fn run_once_skips_when_auto_prune_is_disabled() {
let agent_root = temp_root("agent-disabled");
let settings_root = temp_root("settings-disabled");
let run_repository = Arc::new(FileAgentRepository::new(agent_root.clone()));
let settings_repository = Arc::new(FileSettingsRepository::new(settings_root.clone()));
let service = build_service(settings_repository, run_repository);
let ran = service
.run_once_if_enabled()
.await
.expect("run once should load default settings");
assert!(!ran);
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
#[tokio::test]
async fn run_once_applies_retention_when_auto_prune_is_enabled() {
let agent_root = temp_root("agent-enabled");
let settings_root = temp_root("settings-enabled");
let run_repository = Arc::new(FileAgentRepository::new(agent_root.clone()));
let settings_repository = Arc::new(FileSettingsRepository::new(settings_root.clone()));
let mut settings = TauriTavernSettings::default();
settings.agent = AgentSettings {
retention: AgentRunRetentionSettings {
auto_prune_enabled: true,
keep_recent_terminal_runs: 0,
keep_full_recent_runs: 0,
},
};
settings_repository
.save_tauritavern_settings(&settings)
.await
.expect("save settings");
let run = completed_run("run_auto_prune_delete");
run_repository.create_run(&run).await.expect("create run");
append_terminal_event(&run_repository, &run).await;
seed_heavy_file(&agent_root, &run).await;
let service = build_service(settings_repository, run_repository);
let ran = service
.run_once_if_enabled()
.await
.expect("run once should apply retention");
assert!(ran);
assert!(
!agent_root
.join("chats")
.join(&run.workspace_id)
.join("runs")
.join(&run.id)
.exists()
);
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
}

View file

@ -0,0 +1,410 @@
use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::AgentRun;
use crate::domain::models::settings::AgentRunRetentionSettings;
use crate::domain::repositories::agent_run_repository::{
AgentRunRepository, AgentRunStorageEntryStats,
};
pub const MAX_AGENT_RUN_PRUNE_DETAIL_LIMIT: usize = 1_000;
pub struct AgentRunRetentionPlanInput {
pub retention: AgentRunRetentionSettings,
pub detail_mode: AgentRunRetentionPlanDetailMode,
pub active_run_ids: BTreeSet<String>,
}
#[derive(Debug)]
pub struct AgentRunRetentionPlan {
pub retention: AgentRunRetentionSettings,
pub terminal_run_count: usize,
pub non_terminal_run_count: usize,
pub blocked_run_count: usize,
pub full_retained_run_count: usize,
pub core_retained_run_count: usize,
pub slim_candidate_count: usize,
pub delete_candidate_count: usize,
pub total_slim: AgentRunStorageEntryStats,
pub total_delete: AgentRunStorageEntryStats,
pub total_candidate: AgentRunStorageEntryStats,
pub candidate_details_truncated: bool,
pub candidates: Vec<AgentRunPruneCandidate>,
pub blocked_details_truncated: bool,
pub blocked_runs: Vec<AgentRunPruneBlockedRun>,
detail_mode: AgentRunRetentionPlanDetailMode,
}
#[derive(Debug)]
pub struct AgentRunPruneCandidate {
pub run: AgentRun,
pub action: AgentRunPruneAction,
pub reason: AgentRunPruneReason,
pub stats: AgentRunStorageEntryStats,
}
#[derive(Debug)]
pub struct AgentRunPruneBlockedRun {
pub run: AgentRun,
pub action: AgentRunPruneAction,
pub reason: AgentRunPruneReason,
pub block_reason: AgentRunPruneBlockReason,
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunPruneAction {
SlimHeavyArtifacts,
DeleteRun,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunPruneReason {
OutsideFullRetentionWindow,
OutsideHistoryRetentionWindow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunPruneBlockReason {
ActiveRun,
MissingTerminalEvent,
InvalidJournal,
InvalidStorage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunRetentionPlanDetailMode {
Preview { detail_limit: usize },
Execution,
}
pub struct AgentRunRetentionPlanner<'a> {
run_repository: &'a dyn AgentRunRepository,
}
impl<'a> AgentRunRetentionPlanner<'a> {
pub fn new(run_repository: &'a dyn AgentRunRepository) -> Self {
Self { run_repository }
}
pub async fn plan(
&self,
input: AgentRunRetentionPlanInput,
) -> Result<AgentRunRetentionPlan, ApplicationError> {
input
.retention
.validate()
.map_err(|error| ApplicationError::ValidationError(error.message()))?;
let keep_recent_terminal_runs = input.retention.keep_recent_terminal_runs as usize;
let keep_full_recent_runs = input.retention.keep_full_recent_runs as usize;
let runs = self.run_repository.list_all_runs().await?;
let mut terminal_runs = Vec::new();
let mut plan = AgentRunRetentionPlan::new(input.retention, input.detail_mode);
for run in runs {
if run.status.is_terminal() {
terminal_runs.push(self.rank_terminal_run(run).await);
} else {
plan.non_terminal_run_count += 1;
}
}
plan.terminal_run_count = terminal_runs.len();
terminal_runs.sort_by(|left, right| {
right
.sort_timestamp()
.cmp(&left.sort_timestamp())
.then_with(|| right.run.created_at.cmp(&left.run.created_at))
.then_with(|| right.run.id.cmp(&left.run.id))
});
for (rank, run) in terminal_runs.into_iter().enumerate() {
if rank < keep_full_recent_runs {
plan.full_retained_run_count += 1;
continue;
}
if rank < keep_recent_terminal_runs {
plan.core_retained_run_count += 1;
self.plan_slim_candidate(&mut plan, &input.active_run_ids, run)
.await?;
continue;
}
self.plan_delete_candidate(&mut plan, &input.active_run_ids, run)
.await?;
}
plan.total_candidate = AgentRunStorageEntryStats {
file_count: checked_usize_sum(
plan.total_slim.file_count,
plan.total_delete.file_count,
)?,
byte_count: checked_u64_sum(plan.total_slim.byte_count, plan.total_delete.byte_count)?,
};
Ok(plan)
}
async fn plan_slim_candidate(
&self,
plan: &mut AgentRunRetentionPlan,
active_run_ids: &BTreeSet<String>,
run: RankedTerminalRun,
) -> Result<(), ApplicationError> {
let action = AgentRunPruneAction::SlimHeavyArtifacts;
let reason = AgentRunPruneReason::OutsideFullRetentionWindow;
if self.blocked_by_active_run(plan, active_run_ids, &run.run, action, reason) {
return Ok(());
}
if self.blocked_by_terminal_journal(plan, &run, action, reason) {
return Ok(());
}
let storage = match self.run_repository.inspect_run_storage(&run.run).await {
Ok(storage) => storage,
Err(error) => {
plan.push_blocked(AgentRunPruneBlockedRun {
run: run.run,
action,
reason,
block_reason: AgentRunPruneBlockReason::InvalidStorage,
message: Some(error.to_string()),
});
return Ok(());
}
};
if storage.heavy_artifacts.file_count == 0 {
return Ok(());
}
plan.push_candidate(AgentRunPruneCandidate {
run: run.run,
action,
reason,
stats: storage.heavy_artifacts,
})
}
async fn plan_delete_candidate(
&self,
plan: &mut AgentRunRetentionPlan,
active_run_ids: &BTreeSet<String>,
run: RankedTerminalRun,
) -> Result<(), ApplicationError> {
let action = AgentRunPruneAction::DeleteRun;
let reason = AgentRunPruneReason::OutsideHistoryRetentionWindow;
if self.blocked_by_active_run(plan, active_run_ids, &run.run, action, reason) {
return Ok(());
}
if self.blocked_by_terminal_journal(plan, &run, action, reason) {
return Ok(());
}
let storage = match self.run_repository.inspect_run_storage(&run.run).await {
Ok(storage) => storage,
Err(error) => {
plan.push_blocked(AgentRunPruneBlockedRun {
run: run.run,
action,
reason,
block_reason: AgentRunPruneBlockReason::InvalidStorage,
message: Some(error.to_string()),
});
return Ok(());
}
};
plan.push_candidate(AgentRunPruneCandidate {
run: run.run,
action,
reason,
stats: storage.total,
})
}
fn blocked_by_active_run(
&self,
plan: &mut AgentRunRetentionPlan,
active_run_ids: &BTreeSet<String>,
run: &AgentRun,
action: AgentRunPruneAction,
reason: AgentRunPruneReason,
) -> bool {
if !active_run_ids.contains(&run.id) {
return false;
}
plan.push_blocked(AgentRunPruneBlockedRun {
run: run.clone(),
action,
reason,
block_reason: AgentRunPruneBlockReason::ActiveRun,
message: None,
});
true
}
async fn rank_terminal_run(&self, run: AgentRun) -> RankedTerminalRun {
match self.run_repository.read_all_events(&run.id).await {
Ok(events) => RankedTerminalRun {
run,
terminal_event_at: events
.iter()
.filter(|event| is_terminal_run_event(&event.event_type))
.map(|event| event.timestamp)
.last(),
terminal_event_error: None,
},
Err(error) => RankedTerminalRun {
run,
terminal_event_at: None,
terminal_event_error: Some(error.to_string()),
},
}
}
fn blocked_by_terminal_journal(
&self,
plan: &mut AgentRunRetentionPlan,
run: &RankedTerminalRun,
action: AgentRunPruneAction,
reason: AgentRunPruneReason,
) -> bool {
if let Some(message) = &run.terminal_event_error {
plan.push_blocked(AgentRunPruneBlockedRun {
run: run.run.clone(),
action,
reason,
block_reason: AgentRunPruneBlockReason::InvalidJournal,
message: Some(message.clone()),
});
return true;
}
if run.terminal_event_at.is_some() {
return false;
}
plan.push_blocked(AgentRunPruneBlockedRun {
run: run.run.clone(),
action,
reason,
block_reason: AgentRunPruneBlockReason::MissingTerminalEvent,
message: None,
});
true
}
}
struct RankedTerminalRun {
run: AgentRun,
terminal_event_at: Option<DateTime<Utc>>,
terminal_event_error: Option<String>,
}
impl RankedTerminalRun {
fn sort_timestamp(&self) -> DateTime<Utc> {
self.terminal_event_at.unwrap_or(self.run.updated_at)
}
}
impl AgentRunRetentionPlan {
fn new(
retention: AgentRunRetentionSettings,
detail_mode: AgentRunRetentionPlanDetailMode,
) -> Self {
Self {
retention,
terminal_run_count: 0,
non_terminal_run_count: 0,
blocked_run_count: 0,
full_retained_run_count: 0,
core_retained_run_count: 0,
slim_candidate_count: 0,
delete_candidate_count: 0,
total_slim: AgentRunStorageEntryStats::default(),
total_delete: AgentRunStorageEntryStats::default(),
total_candidate: AgentRunStorageEntryStats::default(),
candidate_details_truncated: false,
candidates: Vec::new(),
blocked_details_truncated: false,
blocked_runs: Vec::new(),
detail_mode,
}
}
fn push_candidate(
&mut self,
candidate: AgentRunPruneCandidate,
) -> Result<(), ApplicationError> {
match candidate.action {
AgentRunPruneAction::SlimHeavyArtifacts => {
self.slim_candidate_count += 1;
accumulate_stats(&mut self.total_slim, candidate.stats)?;
}
AgentRunPruneAction::DeleteRun => {
self.delete_candidate_count += 1;
accumulate_stats(&mut self.total_delete, candidate.stats)?;
}
}
match self.detail_mode {
AgentRunRetentionPlanDetailMode::Preview { detail_limit } => {
if self.candidates.len() < detail_limit {
self.candidates.push(candidate);
} else {
self.candidate_details_truncated = true;
}
}
AgentRunRetentionPlanDetailMode::Execution => {
self.candidates.push(candidate);
}
}
Ok(())
}
fn push_blocked(&mut self, blocked: AgentRunPruneBlockedRun) {
self.blocked_run_count += 1;
match self.detail_mode {
AgentRunRetentionPlanDetailMode::Preview { detail_limit } => {
if self.blocked_runs.len() < detail_limit {
self.blocked_runs.push(blocked);
} else {
self.blocked_details_truncated = true;
}
}
AgentRunRetentionPlanDetailMode::Execution => {}
}
}
}
pub(crate) fn is_terminal_run_event(event_type: &str) -> bool {
matches!(
event_type,
"run_completed" | "run_partial_success" | "run_cancelled" | "run_failed"
)
}
fn accumulate_stats(
target: &mut AgentRunStorageEntryStats,
source: AgentRunStorageEntryStats,
) -> Result<(), ApplicationError> {
target.file_count = checked_usize_sum(target.file_count, source.file_count)?;
target.byte_count = checked_u64_sum(target.byte_count, source.byte_count)?;
Ok(())
}
fn checked_usize_sum(left: usize, right: usize) -> Result<usize, ApplicationError> {
left.checked_add(right).ok_or_else(|| {
ApplicationError::InternalError("agent.run_prune_file_count_overflow".to_string())
})
}
fn checked_u64_sum(left: u64, right: u64) -> Result<u64, ApplicationError> {
left.checked_add(right).ok_or_else(|| {
ApplicationError::InternalError("agent.run_prune_byte_count_overflow".to_string())
})
}

View file

@ -10,8 +10,8 @@ use crate::application::services::agent_profile_service::{
AgentProfileResolveInput, AgentProfileService, materialize_agent_system_prompt,
};
use crate::application::services::agent_tools::{
AGENT_AWAIT, AGENT_DELEGATE, AGENT_LIST, AgentToolDispatcher, BuiltinAgentToolRegistry,
TASK_RETURN,
AGENT_AWAIT, AGENT_DELEGATE, AGENT_HANDOFF, AGENT_LIST, AgentToolDispatcher,
BuiltinAgentToolRegistry, TASK_RETURN,
};
use crate::application::services::llm_connection_service::LlmConnectionService;
use crate::application::services::prompt_assembly_service::PromptAssemblyService;
@ -31,6 +31,7 @@ mod commit_ledger;
mod delegation;
mod error_payload;
mod executor;
mod guidance;
mod input_context;
mod invocation;
mod journal;
@ -44,6 +45,7 @@ mod prompt_assembly;
mod prompt_snapshot;
mod scheduler;
mod skill_scope;
mod timeline_projection;
mod tool_execution;
#[cfg(test)]
@ -225,6 +227,7 @@ impl AgentRuntimeService {
| "workspace.finish"
| AGENT_LIST
| AGENT_DELEGATE
| AGENT_HANDOFF
| AGENT_AWAIT
)
});
@ -252,7 +255,7 @@ impl AgentRuntimeService {
) -> Result<String, ApplicationError> {
let profile = self
.profile_service
.resolve_profile(AgentProfileResolveInput {
.resolve_profile_for_preview(AgentProfileResolveInput {
profile_id,
known_tools: self.tool_registry.specs(),
})

View file

@ -250,6 +250,7 @@ impl AgentRuntimeService {
match host_result {
Ok(result) => {
let message_index = message_index_from_message_id(result.message_id.as_deref());
commit_ledger.record(&path, mode, result.message_id.clone(), round);
self.transition_status(run_id, AgentRunStatus::DispatchingTool)
.await?;
@ -264,6 +265,7 @@ impl AgentRuntimeService {
"path": path.as_str(),
"mode": mode,
"messageId": result.message_id.as_deref(),
"messageIndex": message_index,
}),
)
.await?;
@ -285,6 +287,7 @@ impl AgentRuntimeService {
"path": path.as_str(),
"mode": mode,
"messageId": result.message_id.as_deref(),
"messageIndex": message_index,
"chars": file_metrics.chars,
"words": file_metrics.words,
}),
@ -325,10 +328,17 @@ impl AgentRuntimeService {
pub(super) async fn finish_run(
&self,
run_id: &str,
final_invocation_id: &str,
commit_ledger: &RunCommitLedger,
cancel: &mut AgentCancelReceiver,
) -> Result<(), ApplicationError> {
self.cancel_unfinished_child_tasks(run_id).await?;
self.close_guidance_mailbox_for_run(
run_id,
"run_finished_before_next_model_request",
AgentRunEventLevel::Info,
)
.await?;
self.transition_status(run_id, AgentRunStatus::Finishing)
.await?;
let run = self.run_repository.load_run(run_id).await?;
@ -382,8 +392,12 @@ impl AgentRuntimeService {
.await?;
self.active_runs.write().await.remove(run_id);
self.clear_pending_host_requests_for_run(run_id).await;
self.finish_root_invocation(run_id, AgentInvocationStatus::Completed)
.await?;
self.finish_invocation(
run_id,
final_invocation_id,
AgentInvocationStatus::Completed,
)
.await?;
Ok(())
}
@ -528,6 +542,10 @@ fn persistent_change_payloads(changes: &WorkspacePersistentChangeSet) -> Vec<Val
.collect()
}
fn message_index_from_message_id(message_id: Option<&str>) -> Option<usize> {
message_id?.trim().parse::<usize>().ok()
}
fn recoverable_tool_error(
call: &AgentToolCall,
code: &str,

View file

@ -1,6 +1,7 @@
mod await_tool;
mod child_runtime;
mod delegate_tool;
mod handoff_tool;
mod list_tool;
mod policy;
mod rendering;

View file

@ -1,7 +1,8 @@
use serde_json::{Map, Value, json};
use super::super::loop_runner::AgentLoopExit;
use super::policy::apply_child_invocation_policy;
use super::rendering::render_child_task_prompt;
use super::rendering::{render_child_task_prompt, render_handoff_task_prompt};
use super::task_status::task_is_terminal;
use crate::application::dto::agent_dto::AgentPromptAssemblyScopeDto;
use crate::application::errors::ApplicationError;
@ -19,11 +20,20 @@ use crate::application::services::agent_runtime_service::skill_scope::{
};
use crate::domain::models::agent::profile::{AgentPresetBindingMode, ResolvedAgentProfile};
use crate::domain::models::agent::{
AgentInvocationExitPolicy, AgentInvocationStatus, AgentRunEventLevel, AgentRunSkillScopeRefs,
AgentDelegationContinuation, AgentInvocationExitPolicy, AgentInvocationStatus,
AgentModelRequest, AgentRunEventLevel, AgentRunSkillScopeRefs, AgentTaskRecord,
AgentTaskStatus, WorkspacePath,
};
use crate::domain::models::skill::{SkillIndexEntry, SkillScope};
pub(in crate::application::services::agent_runtime_service) struct PreparedDelegatedInvocationContext
{
pub(in crate::application::services::agent_runtime_service) profile: ResolvedAgentProfile,
pub(in crate::application::services::agent_runtime_service) request: AgentModelRequest,
pub(in crate::application::services::agent_runtime_service) effective_skills:
Vec<SkillIndexEntry>,
}
impl AgentRuntimeService {
pub(in crate::application::services::agent_runtime_service) async fn has_pending_child_tasks(
&self,
@ -37,6 +47,7 @@ impl AgentRuntimeService {
.into_iter()
.any(|task| {
task.parent_invocation_id == invocation_id
&& task.continuation == AgentDelegationContinuation::ReturnToParent
&& matches!(
task.status,
AgentTaskStatus::Queued | AgentTaskStatus::Running
@ -117,16 +128,121 @@ impl AgentRuntimeService {
}
self.start_child_invocation(run_id, invocation_id).await?;
let mut profile = self
.profile_service
let mut profile = self.resolve_task_profile(&task).await?;
ensure_profile_model_configured(&profile)?;
apply_child_invocation_policy(&mut profile, task.budget)?;
let PreparedDelegatedInvocationContext {
profile,
request,
effective_skills,
} = self
.prepare_delegated_invocation_context(
run_id,
invocation_id,
&task,
profile,
AgentInvocationExitPolicy::TaskReturnRequired,
"subagent",
"taskReturnRequired",
render_child_task_prompt(&task),
cancel,
)
.await?;
let mut child_commit_ledger = RunCommitLedger::default();
let exit = self
.run_tool_loop(
run_id,
invocation_id,
AgentInvocationExitPolicy::TaskReturnRequired,
request,
&profile,
&effective_skills,
&mut child_commit_ledger,
cancel,
)
.await?
.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.max_tool_rounds_exceeded: task.return was not called within {} rounds",
profile.tools.max_rounds
))
})?;
if let AgentLoopExit::Transferred { .. } = exit {
return Err(ApplicationError::ValidationError(
"agent.child_handoff_denied: delegated tasks cannot hand off".to_string(),
));
}
let task = self
.invocation_repository
.load_task(run_id, task_id)
.await?;
if !task_is_terminal(task.status) {
return Err(ApplicationError::ValidationError(format!(
"agent.child_invocation_missing_return: child invocation `{invocation_id}` ended without terminal task status"
)));
}
self.finish_child_invocation(run_id, invocation_id, AgentInvocationStatus::Completed)
.await?;
Ok(())
}
pub(in crate::application::services::agent_runtime_service) async fn resolve_task_profile(
&self,
task: &AgentTaskRecord,
) -> Result<ResolvedAgentProfile, ApplicationError> {
self.profile_service
.resolve_profile(AgentProfileResolveInput {
profile_id: Some(task.target_profile_id.as_str()),
known_tools: self.tool_registry.specs(),
})
.await?;
ensure_profile_model_configured(&profile)?;
apply_child_invocation_policy(&mut profile, task.budget)?;
.await
}
pub(in crate::application::services::agent_runtime_service) async fn prepare_handoff_invocation(
&self,
run_id: &str,
task_id: &str,
invocation_id: &str,
cancel: &mut AgentCancelReceiver,
) -> Result<PreparedDelegatedInvocationContext, ApplicationError> {
let task = self
.transition_child_task(run_id, task_id, AgentTaskStatus::Running, None, None)
.await?;
if task.status != AgentTaskStatus::Running {
return Err(ApplicationError::Cancelled(format!(
"Handoff task `{task_id}` was cancelled before it started"
)));
}
self.start_invocation(run_id, invocation_id).await?;
let profile = self.resolve_task_profile(&task).await?;
ensure_profile_model_configured(&profile)?;
self.prepare_delegated_invocation_context(
run_id,
invocation_id,
&task,
profile,
AgentInvocationExitPolicy::RunFinishAllowed,
"handoff",
"runFinishAllowed",
render_handoff_task_prompt(&task),
cancel,
)
.await
}
pub(in crate::application::services::agent_runtime_service) async fn prepare_delegated_invocation_context(
&self,
run_id: &str,
invocation_id: &str,
task: &AgentTaskRecord,
profile: ResolvedAgentProfile,
exit_policy: AgentInvocationExitPolicy,
invocation_kind: &str,
exit_policy_label: &str,
task_prompt: String,
cancel: &mut AgentCancelReceiver,
) -> Result<PreparedDelegatedInvocationContext, ApplicationError> {
let prompt_snapshot = self
.workspace_repository
.read_text(run_id, &WorkspacePath::parse("input/prompt_snapshot.json")?)
@ -136,13 +252,9 @@ impl AgentRuntimeService {
"agent.invalid_prompt_snapshot: input/prompt_snapshot.json is invalid JSON: {error}"
))
})?;
let visible_tools = self.visible_tool_specs_for_invocation(
&profile,
AgentInvocationExitPolicy::TaskReturnRequired,
)?;
let visible_tools = self.visible_tool_specs_for_invocation(&profile, exit_policy)?;
let run = self.run_repository.load_run(run_id).await?;
let child_task_prompt = render_child_task_prompt(&task);
let child_prompt_snapshot = if profile.preset.mode == AgentPresetBindingMode::Ref {
let invocation_prompt_snapshot = if profile.preset.mode == AgentPresetBindingMode::Ref {
self.assemble_invocation_prompt_snapshot(
run_id,
invocation_id,
@ -153,20 +265,20 @@ impl AgentRuntimeService {
AgentPromptAssemblyScopeDto {
run_id: run_id.to_string(),
invocation_id: invocation_id.to_string(),
invocation_kind: "subagent".to_string(),
invocation_kind: invocation_kind.to_string(),
parent_invocation_id: Some(task.parent_invocation_id.clone()),
task_id: Some(task.id.clone()),
exit_policy: Some("taskReturnRequired".to_string()),
exit_policy: Some(exit_policy_label.to_string()),
},
child_task_prompt.clone(),
task_prompt.clone(),
cancel,
)
.await?
} else {
None
};
let mut request = if let Some(child_prompt_snapshot) = child_prompt_snapshot {
request_from_prompt_snapshot(&child_prompt_snapshot)?
let mut request = if let Some(invocation_prompt_snapshot) = invocation_prompt_snapshot {
request_from_prompt_snapshot(&invocation_prompt_snapshot)?
} else {
let mut request = request_from_prompt_snapshot(&prompt_snapshot)?;
let system_prompt = materialize_agent_system_prompt(&visible_tools, &profile);
@ -179,7 +291,7 @@ impl AgentRuntimeService {
},
{
"role": "user",
"content": child_task_prompt
"content": task_prompt
}
]),
);
@ -233,37 +345,12 @@ impl AgentRuntimeService {
}),
)
.await?;
let mut child_commit_ledger = RunCommitLedger::default();
self.run_tool_loop(
run_id,
invocation_id,
AgentInvocationExitPolicy::TaskReturnRequired,
request,
&profile,
&effective_skills,
&mut child_commit_ledger,
cancel,
)
.await?
.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.max_tool_rounds_exceeded: task.return was not called within {} rounds",
profile.tools.max_rounds
))
})?;
let task = self
.invocation_repository
.load_task(run_id, task_id)
.await?;
if !task_is_terminal(task.status) {
return Err(ApplicationError::ValidationError(format!(
"agent.child_invocation_missing_return: child invocation `{invocation_id}` ended without terminal task status"
)));
}
self.finish_child_invocation(run_id, invocation_id, AgentInvocationStatus::Completed)
.await?;
Ok(())
Ok(PreparedDelegatedInvocationContext {
profile,
request,
effective_skills,
})
}
async fn resolve_child_effective_skills(

View file

@ -17,6 +17,8 @@ use crate::domain::models::agent::{
AgentRunEventLevel, AgentTaskBudget, AgentTaskStatus, AgentToolCall, AgentToolResult,
};
const MAX_AGENT_DELEGATION_TASK_FIELD_CHARS: usize = 8_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgentDelegateArgs {
@ -221,7 +223,7 @@ impl AgentRuntimeService {
Ok(Ok(()))
}
async fn allocate_child_workspace_key(
pub(in crate::application::services::agent_runtime_service) async fn allocate_child_workspace_key(
&self,
run_id: &str,
target_profile_id: &str,
@ -273,8 +275,10 @@ fn validate_optional_task_string(value: &Value, key: &str) -> Result<(), String>
}
fn validate_task_string_len(value: &str, key: &str) -> Result<(), String> {
if value.len() > 4_000 {
return Err(format!("task.{key} must be <= 4000 chars"));
if value.len() > MAX_AGENT_DELEGATION_TASK_FIELD_CHARS {
return Err(format!(
"task.{key} must be <= {MAX_AGENT_DELEGATION_TASK_FIELD_CHARS} chars"
));
}
Ok(())
}
@ -344,4 +348,25 @@ mod tests {
assert_eq!(error, "task.title must be a string when provided");
}
#[test]
fn delegate_task_packet_accepts_8000_char_fields() {
let task = json!({
"title": "Critique",
"objective": "a".repeat(8_000)
});
assert!(validate_delegate_task_packet(&task).is_ok());
}
#[test]
fn delegate_task_packet_rejects_fields_over_8000_chars() {
let error = validate_delegate_task_packet(&json!({
"title": "Critique",
"objective": "a".repeat(8_001)
}))
.expect_err("overlong objective should fail");
assert_eq!(error, "task.objective must be <= 8000 chars");
}
}

View file

@ -0,0 +1,306 @@
use std::time::Instant;
use serde::Deserialize;
use serde_json::{Map, Value, json};
use uuid::Uuid;
use super::policy::validate_handoff_target;
use super::tool_error::tool_error_outcome;
use crate::application::errors::ApplicationError;
use crate::application::services::agent_profile_service::AgentProfileResolveInput;
use crate::application::services::agent_runtime_service::AgentRuntimeService;
use crate::application::services::agent_tools::{AgentToolDispatchOutcome, AgentToolEffect};
use crate::domain::models::agent::profile::{AgentProfileId, ResolvedAgentProfile};
use crate::domain::models::agent::{
AgentDelegationContinuation, AgentRunEventLevel, AgentToolCall, AgentToolResult,
};
const MAX_AGENT_HANDOFF_FIELD_CHARS: usize = 8_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgentHandoffArgs {
agent_id: String,
handoff: Value,
#[serde(default)]
pending_task_policy: Option<PendingTaskPolicy>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum PendingTaskPolicy {
DenyIfPending,
}
impl AgentRuntimeService {
pub(in crate::application::services::agent_runtime_service) async fn dispatch_agent_handoff_tool(
&self,
run_id: &str,
invocation_id: &str,
call: &AgentToolCall,
profile: &ResolvedAgentProfile,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
let started = Instant::now();
let args = match serde_json::from_value::<AgentHandoffArgs>(call.arguments.clone()) {
Ok(args) => args,
Err(error) => {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
&format!("invalid agent.handoff arguments: {error}"),
started.elapsed().as_millis(),
));
}
};
if let Err(message) = validate_handoff_packet(&args.handoff) {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
&message,
started.elapsed().as_millis(),
));
}
let pending_task_policy = args
.pending_task_policy
.unwrap_or(PendingTaskPolicy::DenyIfPending);
if pending_task_policy != PendingTaskPolicy::DenyIfPending {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
"pendingTaskPolicy currently supports only denyIfPending",
started.elapsed().as_millis(),
));
}
self.event(
run_id,
AgentRunEventLevel::Info,
"agent_handoff_requested",
json!({
"sourceInvocationId": invocation_id,
"targetProfileId": args.agent_id.as_str(),
"pendingTaskPolicy": "denyIfPending",
}),
)
.await?;
if !profile.delegation.can_handoff {
return Ok(tool_error_outcome(
call,
"agent.handoff_policy_denied",
&format!(
"agent.profile_cannot_handoff: your current Agent configuration `{}` does not allow handoff",
profile.id.as_str()
),
started.elapsed().as_millis(),
));
}
if self.has_pending_child_tasks(run_id, invocation_id).await? {
return Ok(tool_error_outcome(
call,
"agent.handoff_pending_tasks",
"You still have unfinished delegated tasks. Use agent.await before handing off.",
started.elapsed().as_millis(),
));
}
if let Err(message) = self.validate_handoff_depth(run_id, profile).await? {
return Ok(tool_error_outcome(
call,
"agent.handoff_depth_exhausted",
&message,
started.elapsed().as_millis(),
));
}
let target_id = match AgentProfileId::parse(&args.agent_id) {
Ok(target_id) => target_id,
Err(message) => {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
&message,
started.elapsed().as_millis(),
));
}
};
let target = match self
.profile_service
.resolve_profile(AgentProfileResolveInput {
profile_id: Some(target_id.as_str()),
known_tools: self.tool_registry.specs(),
})
.await
{
Ok(target) => target,
Err(ApplicationError::NotFound(message)) => {
return Ok(tool_error_outcome(
call,
"agent.target_profile_not_found",
&message,
started.elapsed().as_millis(),
));
}
Err(error) => return Err(error),
};
if let Err(message) = validate_handoff_target(profile, &target) {
return Ok(tool_error_outcome(
call,
"agent.handoff_policy_denied",
&message,
started.elapsed().as_millis(),
));
}
let task_id = format!("handoff_{}", Uuid::new_v4().simple());
let child_invocation_id = format!("inv_{}", Uuid::new_v4().simple());
let workspace_key = self
.allocate_child_workspace_key(run_id, target.id.as_str())
.await?;
let task = self
.create_handoff_task(
run_id,
invocation_id,
child_invocation_id.clone(),
task_id.clone(),
target.id.as_str().to_string(),
workspace_key,
call.id.clone(),
args.handoff.clone(),
)
.await?;
self.event(
run_id,
AgentRunEventLevel::Info,
"agent_handoff_accepted",
json!({
"taskId": task.id.as_str(),
"sourceInvocationId": invocation_id,
"newInvocationId": task.child_invocation_id.as_str(),
"targetProfileId": task.target_profile_id.as_str(),
"workspaceKey": task.workspace_key.as_str(),
}),
)
.await?;
let structured = json!({
"handoffId": task.id.as_str(),
"taskId": task.id.as_str(),
"status": "accepted",
"agentId": target.id.as_str(),
});
Ok(AgentToolDispatchOutcome {
result: AgentToolResult {
call_id: call.id.clone(),
name: call.name.clone(),
content: format!(
"Handoff accepted for Agent {}. Your part is complete.",
target.id.as_str()
),
structured,
is_error: false,
error_code: None,
resource_refs: Vec::new(),
},
effect: AgentToolEffect::HandoffAccepted {
task_id,
new_invocation_id: child_invocation_id,
},
elapsed_ms: started.elapsed().as_millis(),
})
}
async fn validate_handoff_depth(
&self,
run_id: &str,
profile: &ResolvedAgentProfile,
) -> Result<Result<(), String>, ApplicationError> {
let handoff_count = self
.invocation_repository
.list_tasks(run_id)
.await?
.into_iter()
.filter(|task| task.continuation == AgentDelegationContinuation::TransferControl)
.count();
if handoff_count >= profile.delegation.max_handoff_depth {
return Ok(Err(format!(
"agent.max_handoff_depth_exhausted: this run has reached the handoff limit for your current Agent configuration ({})",
profile.delegation.max_handoff_depth
)));
}
Ok(Ok(()))
}
}
fn validate_handoff_packet(handoff: &Value) -> Result<(), String> {
let object = handoff
.as_object()
.ok_or_else(|| "handoff must be an object".to_string())?;
validate_required_handoff_string(object, "objective")?;
for key in ["title", "reason", "contextSummary"] {
if let Some(value) = object.get(key) {
validate_optional_handoff_string(value, key)?;
}
}
Ok(())
}
fn validate_required_handoff_string(object: &Map<String, Value>, key: &str) -> Result<(), String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("handoff.{key} must be a non-empty string"))?;
validate_handoff_string_len(value, key)
}
fn validate_optional_handoff_string(value: &Value, key: &str) -> Result<(), String> {
if value.is_null() {
return Ok(());
}
let value = value
.as_str()
.ok_or_else(|| format!("handoff.{key} must be a string when provided"))?
.trim();
if value.is_empty() {
return Ok(());
}
validate_handoff_string_len(value, key)
}
fn validate_handoff_string_len(value: &str, key: &str) -> Result<(), String> {
if value.len() > MAX_AGENT_HANDOFF_FIELD_CHARS {
return Err(format!(
"handoff.{key} must be <= {MAX_AGENT_HANDOFF_FIELD_CHARS} chars"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::validate_handoff_packet;
#[test]
fn handoff_packet_accepts_8000_char_fields() {
let handoff = json!({
"title": "Revision pass",
"objective": "a".repeat(8_000),
"contextSummary": "Ready for the next stage."
});
assert!(validate_handoff_packet(&handoff).is_ok());
}
#[test]
fn handoff_packet_rejects_fields_over_8000_chars() {
let error = validate_handoff_packet(&json!({
"title": "Revision pass",
"objective": "a".repeat(8_001)
}))
.expect_err("overlong objective should fail");
assert_eq!(error, "handoff.objective must be <= 8000 chars");
}
}

View file

@ -94,7 +94,7 @@ impl AgentRuntimeService {
let profiles = self
.profile_service
.list_resolved_profiles(self.tool_registry.specs())
.list_resolved_profiles_for_discovery(self.tool_registry.specs())
.await?;
let mut agents = profiles
.into_iter()
@ -150,14 +150,14 @@ fn validate_agent_list_capability(
profile.id.as_str()
)),
AgentListPurpose::Handoff if !profile.delegation.can_handoff => Err(format!(
"agent.profile_cannot_handoff: profile `{}` cannot hand off control",
"agent.profile_cannot_handoff: your current Agent configuration `{}` does not allow handoff",
profile.id.as_str()
)),
AgentListPurpose::Any
if !profile.delegation.can_delegate && !profile.delegation.can_handoff =>
{
Err(format!(
"agent.profile_cannot_delegate: profile `{}` cannot delegate or hand off control",
"agent.profile_cannot_delegate: your current Agent configuration `{}` does not allow delegation or handoff",
profile.id.as_str()
))
}

View file

@ -62,6 +62,34 @@ pub(super) fn validate_subagent_target(
Ok(())
}
pub(super) fn validate_handoff_target(
source: &ResolvedAgentProfile,
target: &ResolvedAgentProfile,
) -> Result<(), String> {
if !target.delegation.callable {
return Err(format!(
"agent.target_not_callable: Agent `{}` is not available for handoff",
target.id.as_str()
));
}
if !target.delegation.allow_as_handoff_target {
return Err(format!(
"agent.target_not_handoff: Agent `{}` is not available as a handoff target",
target.id.as_str()
));
}
if !caller_allowed(source, target) {
return Err(format!(
"agent.target_caller_denied: you are not allowed to hand off to Agent `{}`",
target.id.as_str()
));
}
if profile_model_requires_configuration(target) {
return Err(profile_model_configuration_error(target));
}
Ok(())
}
pub(super) fn validate_delegate_budget(
budget: Option<AgentDelegateBudget>,
target: &ResolvedAgentProfile,
@ -104,6 +132,7 @@ pub(super) fn apply_child_invocation_policy(
&& name != "workspace.finish"
&& name != "agent.list"
&& name != "agent.delegate"
&& name != "agent.handoff"
&& name != "agent.await"
});
if let Some(budget) = budget {

View file

@ -93,6 +93,95 @@ pub(super) fn render_child_task_prompt(task: &AgentTaskRecord) -> String {
lines.join("\n")
}
pub(super) fn render_handoff_task_prompt(task: &AgentTaskRecord) -> String {
let object = task.task.as_object();
let mut lines = vec![
"# Handoff Brief".to_string(),
String::new(),
"You are now responsible for the next stage of this run.".to_string(),
"Continue from the shared workspace paths and constraints below.".to_string(),
String::new(),
];
push_task_section(
&mut lines,
"Title",
object.and_then(|object| object.get("title")),
);
push_task_section(
&mut lines,
"Reason",
object.and_then(|object| object.get("reason")),
);
push_task_section(
&mut lines,
"Objective",
object.and_then(|object| object.get("objective")),
);
push_task_section(
&mut lines,
"Context Summary",
object.and_then(|object| object.get("contextSummary")),
);
push_task_section(
&mut lines,
"Workspace References",
object.and_then(|object| object.get("workspaceRefs")),
);
push_task_section(
&mut lines,
"Must Preserve",
object.and_then(|object| object.get("mustPreserve")),
);
push_task_section(
&mut lines,
"Completion Criteria",
object.and_then(|object| object.get("completionCriteria")),
);
if let Some(object) = object {
let extras = object
.iter()
.filter(|(key, _)| {
!matches!(
key.as_str(),
"title"
| "reason"
| "objective"
| "contextSummary"
| "workspaceRefs"
| "mustPreserve"
| "completionCriteria"
)
})
.collect::<Vec<_>>();
if !extras.is_empty() {
lines.push("## Additional Instructions".to_string());
for (key, value) in extras {
lines.push(format!("- **{}**:", key));
lines.push(indent_lines(&render_markdown_value(value, 0), 2));
}
lines.push(String::new());
}
} else if !task.task.is_null() {
lines.push("## Handoff Details".to_string());
lines.push(render_markdown_value(&task.task, 0));
lines.push(String::new());
}
lines.extend([
"## Working Notes".to_string(),
"- Inspect the referenced workspace files before editing existing content.".to_string(),
"- Preserve previous decisions and committed text unless this brief asks you to revise them."
.to_string(),
"- If commit and finish tools are available to you, use them only when this run is ready to end.".to_string(),
"- If another Agent should continue after your stage, hand off with a clear brief."
.to_string(),
]);
lines.join("\n")
}
pub(super) fn render_task_return_summary(result_doc: &Value) -> String {
let summary = result_doc
.get("summary")

View file

@ -150,6 +150,7 @@ impl AgentRuntimeService {
"task_return_completed",
json!({
"taskId": task.id.as_str(),
"parentInvocationId": task.parent_invocation_id.as_str(),
"childInvocationId": invocation_id,
"status": task.status,
"resultRef": result_ref.as_str(),

View file

@ -56,6 +56,9 @@ fn agent_error_code_and_message(error: &ApplicationError) -> (String, String) {
ApplicationError::Transient(message) => {
structured_code_and_message(message, "agent.transient")
}
ApplicationError::UpstreamFailure(failure) => {
(failure.code.clone(), failure.fallback_message().to_string())
}
ApplicationError::Cancelled(message) => {
structured_code_and_message(message, "agent.cancelled")
}
@ -100,7 +103,9 @@ fn is_error_code(value: &str) -> bool {
fn is_retryable(error: &ApplicationError) -> bool {
matches!(
error,
ApplicationError::RateLimited(_) | ApplicationError::Transient(_)
ApplicationError::RateLimited(_)
| ApplicationError::Transient(_)
| ApplicationError::UpstreamFailure(_)
)
}

View file

@ -6,6 +6,7 @@ use super::artifacts::build_agent_manifest;
use super::commit_ledger::RunCommitLedger;
use super::error_payload::{run_failure_payload, run_partial_success_payload};
use super::invocation::model_session_id;
use super::loop_runner::AgentLoopExit;
use super::prompt_snapshot::{prepare_agent_tool_request, request_summary};
use super::{AgentCancelReceiver, AgentRuntimeService};
use crate::application::dto::chat_completion_dto::ChatCompletionGenerateRequestDto;
@ -55,6 +56,13 @@ impl AgentRuntimeService {
match result {
Ok(()) => {}
Err(ApplicationError::Cancelled(message)) => {
let _ = self
.close_guidance_mailbox_for_run(
run_id,
"run_cancelled_before_next_model_request",
AgentRunEventLevel::Info,
)
.await;
let _ = self.cancel_unfinished_child_tasks(run_id).await;
let _ = self
.finish_root_invocation(run_id, AgentInvocationStatus::Cancelled)
@ -74,6 +82,18 @@ impl AgentRuntimeService {
self.active_runs.write().await.remove(run_id);
}
Err(error) => {
let guidance_discard_reason = if commit_ledger.is_empty() {
"run_failed_before_next_model_request"
} else {
"run_partial_success_before_next_model_request"
};
let _ = self
.close_guidance_mailbox_for_run(
run_id,
guidance_discard_reason,
AgentRunEventLevel::Warn,
)
.await;
let _ = self.cancel_unfinished_child_tasks(run_id).await;
let _ = self
.finish_root_invocation(run_id, AgentInvocationStatus::Failed)
@ -264,26 +284,171 @@ impl AgentRuntimeService {
.await?;
self.ensure_not_cancelled(cancel)?;
self.run_tool_loop(
self.execute_active_invocation_chain(
run_id,
invocation_id.as_str(),
AgentInvocationExitPolicy::RunFinishAllowed,
invocation_id,
request,
&resolved_profile,
&effective_skills,
resolved_profile,
effective_skills,
commit_ledger,
cancel,
)
.await?
.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.max_tool_rounds_exceeded: workspace.finish was not called within {} rounds",
resolved_profile.tools.max_rounds
))
})?;
.await?;
self.ensure_not_cancelled(cancel)?;
self.finish_run(run_id, commit_ledger, cancel).await?;
Ok(())
}
async fn execute_active_invocation_chain(
&self,
run_id: &str,
mut invocation_id: String,
mut request: crate::domain::models::agent::AgentModelRequest,
mut profile: ResolvedAgentProfile,
mut effective_skills: Vec<SkillIndexEntry>,
commit_ledger: &mut RunCommitLedger,
cancel: &mut AgentCancelReceiver,
) -> Result<(), ApplicationError> {
let mut incoming_handoff_task_id: Option<String> = None;
loop {
let exit = match self
.run_tool_loop(
run_id,
invocation_id.as_str(),
AgentInvocationExitPolicy::RunFinishAllowed,
request,
&profile,
&effective_skills,
commit_ledger,
cancel,
)
.await
{
Ok(Some(exit)) => exit,
Ok(None) => {
let error = ApplicationError::ValidationError(format!(
"agent.max_tool_rounds_exceeded: workspace.finish or agent.handoff was not called within {} rounds",
profile.tools.max_rounds
));
self.mark_active_invocation_failed(
run_id,
invocation_id.as_str(),
incoming_handoff_task_id.as_deref(),
&error,
)
.await?;
return Err(error);
}
Err(error) => {
self.mark_active_invocation_failed(
run_id,
invocation_id.as_str(),
incoming_handoff_task_id.as_deref(),
&error,
)
.await?;
return Err(error);
}
};
match exit {
AgentLoopExit::Finished => {
if let Err(error) = self
.finish_run(run_id, invocation_id.as_str(), commit_ledger, cancel)
.await
{
self.mark_active_invocation_failed(
run_id,
invocation_id.as_str(),
incoming_handoff_task_id.as_deref(),
&error,
)
.await?;
return Err(error);
}
if let Some(task_id) = incoming_handoff_task_id.as_deref() {
self.transition_child_task(
run_id,
task_id,
crate::domain::models::agent::AgentTaskStatus::Completed,
None,
None,
)
.await?;
}
return Ok(());
}
AgentLoopExit::Transferred {
task_id,
new_invocation_id,
} => {
if let Some(incoming_task_id) = incoming_handoff_task_id.as_deref() {
self.transition_child_task(
run_id,
incoming_task_id,
crate::domain::models::agent::AgentTaskStatus::Completed,
None,
None,
)
.await?;
}
let prepared = match self
.prepare_handoff_invocation(
run_id,
task_id.as_str(),
new_invocation_id.as_str(),
cancel,
)
.await
{
Ok(prepared) => prepared,
Err(error) => {
self.mark_active_invocation_failed(
run_id,
new_invocation_id.as_str(),
Some(task_id.as_str()),
&error,
)
.await?;
return Err(error);
}
};
invocation_id = new_invocation_id;
incoming_handoff_task_id = Some(task_id);
request = prepared.request;
profile = prepared.profile;
effective_skills = prepared.effective_skills;
}
}
}
}
async fn mark_active_invocation_failed(
&self,
run_id: &str,
invocation_id: &str,
incoming_handoff_task_id: Option<&str>,
error: &ApplicationError,
) -> Result<(), ApplicationError> {
let was_cancelled = matches!(error, ApplicationError::Cancelled(_));
let task_status = if was_cancelled {
crate::domain::models::agent::AgentTaskStatus::Cancelled
} else {
crate::domain::models::agent::AgentTaskStatus::Failed
};
let invocation_status = if was_cancelled {
AgentInvocationStatus::Cancelled
} else {
AgentInvocationStatus::Failed
};
if let Some(task_id) = incoming_handoff_task_id {
self.transition_child_task(run_id, task_id, task_status, None, Some(error.to_string()))
.await?;
}
if invocation_id != ROOT_AGENT_INVOCATION_ID {
self.finish_invocation(run_id, invocation_id, invocation_status)
.await?;
}
Ok(())
}

View file

@ -0,0 +1,412 @@
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use uuid::Uuid;
use super::AgentRuntimeService;
use crate::application::dto::agent_dto::{AgentSubmitGuidanceDto, AgentSubmitGuidanceResultDto};
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{
AgentModelContentPart, AgentModelMessage, AgentModelRequest, AgentModelRole,
AgentRunEventLevel, AgentRunStatus, ROOT_AGENT_INVOCATION_ID,
};
use crate::domain::text_metrics::TextMetrics;
const MAX_GUIDANCE_TEXT_CHARS: usize = 16_000;
const MAX_PENDING_GUIDANCE_ITEMS: usize = 8;
const MAX_PENDING_GUIDANCE_CHARS: usize = 64_000;
const GUIDANCE_PREVIEW_CHARS: usize = 240;
const USER_GUIDANCE_OPEN_TAG: &str = "<user_guidance>";
const USER_GUIDANCE_CLOSE_TAG: &str = "</user_guidance>";
#[derive(Debug, Clone)]
pub(super) struct AgentGuidanceItem {
pub(super) guidance_id: String,
pub(super) client_guidance_id: Option<String>,
pub(super) text: String,
pub(super) preview: String,
pub(super) metrics: TextMetrics,
pub(super) submitted_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
pub(super) struct AgentGuidanceMailbox {
state: Mutex<AgentGuidanceMailboxState>,
}
#[derive(Debug, Default)]
struct AgentGuidanceMailboxState {
queue: VecDeque<AgentGuidanceItem>,
pending_chars: usize,
closed: bool,
}
impl AgentGuidanceMailbox {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) async fn ensure_can_accept(&self, chars: usize) -> Result<(), ApplicationError> {
let state = self.state.lock().await;
validate_accepts(&state, chars)
}
pub(super) async fn enqueue(&self, item: AgentGuidanceItem) -> Result<usize, ApplicationError> {
let mut state = self.state.lock().await;
validate_accepts(&state, item.metrics.chars)?;
state.pending_chars += item.metrics.chars;
state.queue.push_back(item);
Ok(state.queue.len())
}
pub(super) async fn drain(&self) -> Vec<AgentGuidanceItem> {
let mut state = self.state.lock().await;
let items = state.queue.drain(..).collect::<Vec<_>>();
state.pending_chars = 0;
items
}
pub(super) async fn close_and_drain(&self) -> Vec<AgentGuidanceItem> {
let mut state = self.state.lock().await;
state.closed = true;
let items = state.queue.drain(..).collect::<Vec<_>>();
state.pending_chars = 0;
items
}
}
impl AgentRuntimeService {
pub async fn submit_guidance(
&self,
dto: AgentSubmitGuidanceDto,
) -> Result<AgentSubmitGuidanceResultDto, ApplicationError> {
let run_id = normalize_required("runId", &dto.run_id)?;
let text = normalize_guidance_text(&dto.text)?;
let metrics = TextMetrics::from_text(&text);
if metrics.chars > MAX_GUIDANCE_TEXT_CHARS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_too_large: guidance is {} chars; maximum is {}",
metrics.chars, MAX_GUIDANCE_TEXT_CHARS
)));
}
let run = self.run_repository.load_run(run_id.as_str()).await?;
if !run_status_accepts_guidance(run.status) {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_run_not_accepting: run `{}` is {} and cannot accept guidance",
run.id,
serde_json::to_string(&run.status)
.unwrap_or_else(|_| "unknown".to_string())
.trim_matches('"')
)));
}
let active_handle = self.active_runs.read().await.get(&run.id).cloned();
let active_handle = active_handle.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.guidance_active_run_missing: run `{}` is not active",
run.id
))
})?;
active_handle
.guidance_mailbox
.ensure_can_accept(metrics.chars)
.await?;
let item = AgentGuidanceItem {
guidance_id: format!("guidance_{}", Uuid::new_v4().simple()),
client_guidance_id: normalize_optional(dto.client_guidance_id.as_deref()),
preview: guidance_preview(&text),
text,
metrics,
submitted_at: Utc::now(),
};
self.event(
&run.id,
AgentRunEventLevel::Info,
"user_guidance_submitted",
json!({
"guidanceId": item.guidance_id.as_str(),
"clientGuidanceId": item.client_guidance_id.as_deref(),
"invocationId": ROOT_AGENT_INVOCATION_ID,
"chars": item.metrics.chars,
"words": item.metrics.words,
"preview": item.preview.as_str(),
"text": item.text.as_str(),
"submittedAt": item.submitted_at,
"status": "queued",
}),
)
.await?;
let pending_count = match active_handle.guidance_mailbox.enqueue(item.clone()).await {
Ok(pending_count) => pending_count,
Err(error) => {
self.emit_guidance_discarded(
&run.id,
&[item],
"submit_rejected_after_journal_append",
AgentRunEventLevel::Warn,
)
.await?;
return Err(error);
}
};
Ok(AgentSubmitGuidanceResultDto {
run_id: run.id,
guidance_id: item.guidance_id,
client_guidance_id: item.client_guidance_id,
status: "queued".to_string(),
preview: item.preview,
chars: item.metrics.chars,
words: item.metrics.words,
pending_count,
})
}
pub(super) async fn apply_pending_guidance_to_request(
&self,
run_id: &str,
invocation_id: &str,
round: usize,
request: &mut AgentModelRequest,
) -> Result<(), ApplicationError> {
let items = self.drain_pending_guidance(run_id).await;
if items.is_empty() {
return Ok(());
}
let message_index = request.messages.len();
let message_text = build_guidance_message(&items);
request.messages.push(AgentModelMessage {
role: AgentModelRole::User,
parts: vec![AgentModelContentPart::Text { text: message_text }],
provider_metadata: Value::Null,
});
let summary = guidance_batch_summary(&items);
self.event(
run_id,
AgentRunEventLevel::Info,
"user_guidance_applied",
json!({
"guidanceIds": summary.guidance_ids,
"clientGuidanceIds": summary.client_guidance_ids,
"invocationId": invocation_id,
"round": round,
"count": summary.count,
"chars": summary.chars,
"words": summary.words,
"preview": summary.preview,
"messageIndex": message_index,
"requestMessageCount": request.messages.len(),
"status": "applied",
}),
)
.await?;
Ok(())
}
pub(super) async fn close_guidance_mailbox_for_run(
&self,
run_id: &str,
reason: &str,
level: AgentRunEventLevel,
) -> Result<(), ApplicationError> {
let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else {
return Ok(());
};
let items = handle.guidance_mailbox.close_and_drain().await;
if items.is_empty() {
return Ok(());
}
self.emit_guidance_discarded(run_id, &items, reason, level)
.await
}
async fn drain_pending_guidance(&self, run_id: &str) -> Vec<AgentGuidanceItem> {
let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else {
return Vec::new();
};
handle.guidance_mailbox.drain().await
}
async fn emit_guidance_discarded(
&self,
run_id: &str,
items: &[AgentGuidanceItem],
reason: &str,
level: AgentRunEventLevel,
) -> Result<(), ApplicationError> {
let summary = guidance_batch_summary(items);
self.event(
run_id,
level,
"user_guidance_discarded",
json!({
"guidanceIds": summary.guidance_ids,
"clientGuidanceIds": summary.client_guidance_ids,
"invocationId": ROOT_AGENT_INVOCATION_ID,
"count": summary.count,
"chars": summary.chars,
"words": summary.words,
"preview": summary.preview,
"reason": reason,
"status": "discarded",
}),
)
.await?;
Ok(())
}
}
fn validate_accepts(
state: &AgentGuidanceMailboxState,
incoming_chars: usize,
) -> Result<(), ApplicationError> {
if state.closed {
return Err(ApplicationError::ValidationError(
"agent.guidance_mailbox_closed: run is no longer accepting guidance".to_string(),
));
}
if state.queue.len() >= MAX_PENDING_GUIDANCE_ITEMS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_mailbox_full: pending guidance item limit {} reached",
MAX_PENDING_GUIDANCE_ITEMS
)));
}
if state.pending_chars + incoming_chars > MAX_PENDING_GUIDANCE_CHARS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_mailbox_full: pending guidance char limit {} exceeded",
MAX_PENDING_GUIDANCE_CHARS
)));
}
Ok(())
}
fn run_status_accepts_guidance(status: AgentRunStatus) -> bool {
!matches!(
status,
AgentRunStatus::Finishing
| AgentRunStatus::Completed
| AgentRunStatus::PartialSuccess
| AgentRunStatus::Cancelling
| AgentRunStatus::Cancelled
| AgentRunStatus::Failed
)
}
fn normalize_required(field: &str, value: &str) -> Result<String, ApplicationError> {
let value = value.trim();
if value.is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_invalid: {field} is required"
)));
}
Ok(value.to_string())
}
fn normalize_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn normalize_guidance_text(value: &str) -> Result<String, ApplicationError> {
let text = value.trim();
if text.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.guidance_empty: guidance text cannot be empty".to_string(),
));
}
Ok(text.to_string())
}
fn guidance_preview(text: &str) -> String {
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_chars(&collapsed, GUIDANCE_PREVIEW_CHARS)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
text.chars().take(max_chars).collect::<String>()
}
fn build_guidance_message(items: &[AgentGuidanceItem]) -> String {
let mut text = String::from(USER_GUIDANCE_OPEN_TAG);
text.push_str(
"\nThe user sent the following guidance while you were working. \
Apply the guidance in order as the user's latest direction for your next step, \
within your existing instructions and tool rules.",
);
if items.len() == 1 {
text.push_str("\n\n");
text.push_str(&items[0].text);
text.push('\n');
text.push_str(USER_GUIDANCE_CLOSE_TAG);
return text;
}
for (index, item) in items.iter().enumerate() {
text.push_str(&format!(
"\n\n<guidance index=\"{}\">\n{}\n</guidance>",
index + 1,
item.text
));
}
text.push('\n');
text.push_str(USER_GUIDANCE_CLOSE_TAG);
text
}
struct GuidanceBatchSummary {
guidance_ids: Vec<String>,
client_guidance_ids: Vec<String>,
count: usize,
chars: usize,
words: usize,
preview: String,
}
fn guidance_batch_summary(items: &[AgentGuidanceItem]) -> GuidanceBatchSummary {
let guidance_ids = items
.iter()
.map(|item| item.guidance_id.clone())
.collect::<Vec<_>>();
let client_guidance_ids = items
.iter()
.filter_map(|item| item.client_guidance_id.clone())
.collect::<Vec<_>>();
let chars = items.iter().map(|item| item.metrics.chars).sum();
let words = items.iter().map(|item| item.metrics.words).sum();
let preview = if items.len() == 1 {
items[0].preview.clone()
} else {
guidance_preview(
&items
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>()
.join("\n\n"),
)
};
GuidanceBatchSummary {
guidance_ids,
client_guidance_ids,
count: items.len(),
chars,
words,
preview,
}
}

View file

@ -141,6 +141,66 @@ impl AgentRuntimeService {
created_by_tool_call_id: String,
task: serde_json::Value,
budget: Option<AgentTaskBudget>,
) -> Result<AgentTaskRecord, ApplicationError> {
self.create_delegation_task(
run_id,
parent_invocation_id,
child_invocation_id,
task_id,
target_profile_id,
workspace_key,
created_by_tool_call_id,
task,
budget,
AgentInvocationKind::Subagent,
AgentInvocationExitPolicy::TaskReturnRequired,
AgentDelegationContinuation::ReturnToParent,
)
.await
}
pub(super) async fn create_handoff_task(
&self,
run_id: &str,
parent_invocation_id: &str,
child_invocation_id: String,
task_id: String,
target_profile_id: String,
workspace_key: String,
created_by_tool_call_id: String,
task: serde_json::Value,
) -> Result<AgentTaskRecord, ApplicationError> {
self.create_delegation_task(
run_id,
parent_invocation_id,
child_invocation_id,
task_id,
target_profile_id,
workspace_key,
created_by_tool_call_id,
task,
None,
AgentInvocationKind::Handoff,
AgentInvocationExitPolicy::RunFinishAllowed,
AgentDelegationContinuation::TransferControl,
)
.await
}
async fn create_delegation_task(
&self,
run_id: &str,
parent_invocation_id: &str,
child_invocation_id: String,
task_id: String,
target_profile_id: String,
workspace_key: String,
created_by_tool_call_id: String,
task: serde_json::Value,
budget: Option<AgentTaskBudget>,
invocation_kind: AgentInvocationKind,
exit_policy: AgentInvocationExitPolicy,
continuation: AgentDelegationContinuation,
) -> Result<AgentTaskRecord, ApplicationError> {
let now = Utc::now();
let invocation = AgentInvocation {
@ -148,9 +208,9 @@ impl AgentRuntimeService {
run_id: run_id.to_string(),
parent_invocation_id: Some(parent_invocation_id.to_string()),
profile_id: target_profile_id.clone(),
kind: AgentInvocationKind::Subagent,
kind: invocation_kind,
status: AgentInvocationStatus::Created,
exit_policy: AgentInvocationExitPolicy::TaskReturnRequired,
exit_policy,
created_at: now,
updated_at: now,
};
@ -161,7 +221,7 @@ impl AgentRuntimeService {
child_invocation_id,
target_profile_id,
workspace_key,
continuation: AgentDelegationContinuation::ReturnToParent,
continuation,
status: AgentTaskStatus::Queued,
task,
budget,
@ -214,6 +274,14 @@ impl AgentRuntimeService {
&self,
run_id: &str,
invocation_id: &str,
) -> Result<AgentInvocation, ApplicationError> {
self.start_invocation(run_id, invocation_id).await
}
pub(super) async fn start_invocation(
&self,
run_id: &str,
invocation_id: &str,
) -> Result<AgentInvocation, ApplicationError> {
let mut invocation = self
.invocation_repository
@ -310,6 +378,15 @@ impl AgentRuntimeService {
run_id: &str,
invocation_id: &str,
status: AgentInvocationStatus,
) -> Result<(), ApplicationError> {
self.finish_invocation(run_id, invocation_id, status).await
}
pub(super) async fn finish_invocation(
&self,
run_id: &str,
invocation_id: &str,
status: AgentInvocationStatus,
) -> Result<(), ApplicationError> {
let mut invocation = self
.invocation_repository

View file

@ -1,5 +1,5 @@
use chrono::Utc;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::{AgentCancelReceiver, AgentRuntimeService};
use crate::application::errors::ApplicationError;
@ -35,6 +35,7 @@ impl AgentRuntimeService {
event_type: &str,
payload: Value,
) -> Result<AgentRunEvent, ApplicationError> {
let payload = with_canonical_event_scope(event_type, payload)?;
self.run_repository
.append_event(run_id, level, event_type, payload)
.await
@ -81,3 +82,125 @@ impl AgentRuntimeService {
Ok(())
}
}
fn with_canonical_event_scope(event_type: &str, payload: Value) -> Result<Value, ApplicationError> {
let mut payload = match payload {
Value::Object(payload) => payload,
payload => return Ok(payload),
};
let mut event_scope = match payload.remove("eventScope") {
Some(Value::Object(event_scope)) => event_scope,
Some(_) => {
return Err(ApplicationError::ValidationError(
"agent.event_scope_invalid: payload.eventScope must be an object".to_string(),
));
}
None => Map::new(),
};
let primary = event_primary_invocation_id(event_type, &payload, &event_scope);
let Some(primary) = primary else {
if !event_scope.is_empty() {
payload.insert("eventScope".to_string(), Value::Object(event_scope));
}
return Ok(Value::Object(payload));
};
event_scope.insert("invocationId".to_string(), json!(primary.as_str()));
let mut related = scope_related_invocation_ids(&event_scope);
for field in [
"invocationId",
"parentInvocationId",
"sourceInvocationId",
"childInvocationId",
"newInvocationId",
] {
if let Some(invocation_id) = payload_string_field(&payload, field) {
push_related_invocation(&mut related, invocation_id, &primary);
}
}
if related.is_empty() {
event_scope.remove("relatedInvocationIds");
} else {
event_scope.insert("relatedInvocationIds".to_string(), json!(related));
}
payload.insert("eventScope".to_string(), Value::Object(event_scope));
Ok(Value::Object(payload))
}
fn scope_invocation_id(scope: &Map<String, Value>) -> Option<String> {
scope
.get("invocationId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn scope_related_invocation_ids(scope: &Map<String, Value>) -> Vec<String> {
scope
.get("relatedInvocationIds")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn payload_string_field(payload: &Map<String, Value>, field: &str) -> Option<String> {
payload
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn event_primary_invocation_id(
event_type: &str,
payload: &Map<String, Value>,
scope: &Map<String, Value>,
) -> Option<String> {
scope_invocation_id(scope)
.or_else(|| match event_type {
"agent_delegate_started" => payload_string_field(payload, "parentInvocationId"),
"agent_handoff_requested" | "agent_handoff_accepted" => {
payload_string_field(payload, "sourceInvocationId")
}
"task_return_completed" => payload_string_field(payload, "childInvocationId"),
event_type if event_type.starts_with("agent_task_") => {
payload_string_field(payload, "parentInvocationId")
}
_ => None,
})
.or_else(|| payload_string_field(payload, "invocationId"))
.or_else(|| payload_string_field(payload, "parentInvocationId"))
.or_else(|| payload_string_field(payload, "sourceInvocationId"))
.or_else(|| payload_string_field(payload, "childInvocationId"))
.or_else(|| payload_string_field(payload, "newInvocationId"))
.or_else(|| run_level_event_scope(event_type))
}
fn push_related_invocation(related: &mut Vec<String>, invocation_id: String, primary: &str) {
if invocation_id == primary || related.iter().any(|existing| existing == &invocation_id) {
return;
}
related.push(invocation_id);
}
fn run_level_event_scope(event_type: &str) -> Option<String> {
if event_type.starts_with("run_") || event_type == "status_changed" {
return Some(crate::domain::models::agent::ROOT_AGENT_INVOCATION_ID.to_string());
}
None
}

View file

@ -13,6 +13,7 @@ use super::prompt_snapshot::{
use super::skill_scope::{
resolve_run_skill_scope_refs, skill_event_summary, skill_scope_order_for_profile,
};
use super::timeline_projection::build_run_timeline_projection;
use crate::application::dto::agent_dto::{
AgentCancelRunDto, AgentReadEventsDto, AgentReadEventsResultDto, AgentReadWorkspaceFileDto,
AgentRunHandleDto, AgentStartRunDto, AgentWorkspaceFileDto,
@ -254,6 +255,12 @@ impl AgentRuntimeService {
let active_handle = self.active_runs.read().await.get(&dto.run_id).cloned();
let next = if let Some(handle) = active_handle {
self.close_guidance_mailbox_for_run(
&dto.run_id,
"run_cancel_requested",
AgentRunEventLevel::Info,
)
.await?;
let _ = handle.cancel_sender.send(true);
handle.scheduler.cancel_all_unfinished().await?;
self.transition_status(&dto.run_id, AgentRunStatus::Cancelling)
@ -285,6 +292,7 @@ impl AgentRuntimeService {
&self,
dto: AgentReadEventsDto,
) -> Result<AgentReadEventsResultDto, ApplicationError> {
let invocation_id = normalize_read_events_invocation_id(dto.invocation_id.as_deref())?;
let events = self
.run_repository
.read_events(
@ -293,11 +301,25 @@ impl AgentRuntimeService {
after_seq: dto.after_seq,
before_seq: dto.before_seq,
limit: dto.limit,
invocation_id,
},
)
.await?;
let timeline_projection = if dto.include_timeline_projection {
let invocations = self
.invocation_repository
.list_invocations(&dto.run_id)
.await?;
let tasks = self.invocation_repository.list_tasks(&dto.run_id).await?;
Some(build_run_timeline_projection(&invocations, &tasks)?)
} else {
None
};
Ok(AgentReadEventsResultDto { events })
Ok(AgentReadEventsResultDto {
events,
timeline_projection,
})
}
pub async fn read_workspace_file(
@ -321,19 +343,46 @@ impl AgentRuntimeService {
}
}
fn normalize_read_events_invocation_id(
value: Option<&str>,
) -> Result<Option<String>, ApplicationError> {
let Some(value) = value else {
return Ok(None);
};
let invocation_id = value.trim();
if invocation_id.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.read_events_invocation_id_invalid: invocationId cannot be empty".to_string(),
));
}
if invocation_id.contains('/') || invocation_id.contains('\\') {
return Err(ApplicationError::ValidationError(
"agent.read_events_invocation_id_invalid: invocationId must not contain path separators"
.to_string(),
));
}
Ok(Some(invocation_id.to_string()))
}
#[async_trait::async_trait]
impl AgentRunActivity for AgentRuntimeService {
async fn active_run_ids_for_workspace(
&self,
workspace_id: &str,
) -> Result<Vec<String>, ApplicationError> {
let run_ids = self
async fn active_run_ids(&self) -> Result<Vec<String>, ApplicationError> {
let mut run_ids = self
.active_runs
.read()
.await
.keys()
.cloned()
.collect::<Vec<_>>();
run_ids.sort();
Ok(run_ids)
}
async fn active_run_ids_for_workspace(
&self,
workspace_id: &str,
) -> Result<Vec<String>, ApplicationError> {
let run_ids = self.active_run_ids().await?;
let mut active = Vec::new();
for run_id in run_ids {
let run = self.run_repository.load_run(&run_id).await?;

View file

@ -10,17 +10,27 @@ use super::model_turn_display::model_turn_event_summary;
use super::prompt_snapshot::request_summary;
use super::{AgentCancelReceiver, AgentRuntimeService};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_tools::{AGENT_AWAIT, AgentToolEffect, AgentToolSession};
use crate::application::services::agent_tools::{
AGENT_AWAIT, AGENT_HANDOFF, AgentToolEffect, AgentToolSession,
};
use crate::domain::models::agent::profile::ResolvedAgentProfile;
use crate::domain::models::agent::{
AgentInvocationExitPolicy, AgentModelContentPart, AgentModelMessage, AgentModelRequest,
AgentModelResponse, AgentModelRole, AgentRunEventLevel, AgentRunStatus, AgentToolResult,
WorkspacePath,
AgentInvocationExitPolicy, AgentInvocationStatus, AgentModelContentPart, AgentModelMessage,
AgentModelRequest, AgentModelResponse, AgentModelRole, AgentRunEventLevel, AgentRunStatus,
AgentToolResult, WorkspacePath,
};
use crate::domain::models::skill::SkillIndexEntry;
use crate::domain::text_metrics::TextMetrics;
pub(super) enum AgentLoopExit {
Finished,
Transferred {
task_id: String,
new_invocation_id: String,
},
}
impl AgentRuntimeService {
pub(super) async fn run_tool_loop(
&self,
@ -32,16 +42,20 @@ impl AgentRuntimeService {
effective_skills: &[SkillIndexEntry],
commit_ledger: &mut RunCommitLedger,
cancel: &mut AgentCancelReceiver,
) -> Result<Option<usize>, ApplicationError> {
) -> Result<Option<AgentLoopExit>, ApplicationError> {
let mut tool_session = AgentToolSession::new(effective_skills.to_vec());
let mut seen_child_result_task_ids = HashSet::new();
let mut commit_count = 0_usize;
let mut commit_count = commit_ledger.len();
// Counts soft drift recovery nudges for model-facing text and
// journal events. It is intentionally not a separate budget: the
// existing maxRounds loop remains the only retry boundary.
let mut drift_recovery_attempts: usize = 0;
for round in 1..=profile.tools.max_rounds {
let updates_run_status = exit_policy == AgentInvocationExitPolicy::RunFinishAllowed;
if updates_run_status {
self.apply_pending_guidance_to_request(run_id, invocation_id, round, &mut request)
.await?;
}
if updates_run_status {
self.transition_status(run_id, AgentRunStatus::CallingModel)
.await?;
@ -134,6 +148,7 @@ impl AgentRuntimeService {
drift_recovery_attempts,
direct_output_path.as_ref(),
exit_policy,
profile,
);
request.messages.push(response.message.clone());
request.messages.push(AgentModelMessage {
@ -161,20 +176,22 @@ impl AgentRuntimeService {
continue;
}
return Err(ApplicationError::ValidationError(format!(
"model.tool_call_required: model must use Agent tools and finish through {}",
completion_tool_name(exit_policy)
"model.tool_call_required: model must use Agent tools and complete through {}",
completion_tool_name(exit_policy, profile)
)));
}
let assistant_message = assistant_message_for_next_turn(&response)?;
let mut tool_results = Vec::with_capacity(tool_calls.len());
let mut finished = false;
let mut handoff = None;
let mut completion_tool = completion_tool_name(exit_policy, profile);
for call in tool_calls {
if finished {
return Err(ApplicationError::ValidationError(format!(
"agent.tool_after_finish: model requested additional tools after {}",
completion_tool_name(exit_policy)
completion_tool
)));
}
@ -285,6 +302,21 @@ impl AgentRuntimeService {
.await?;
finished = true;
}
AgentToolEffect::HandoffAccepted {
task_id,
new_invocation_id,
..
} => {
handoff = Some((task_id.clone(), new_invocation_id.clone()));
self.finish_invocation(
run_id,
invocation_id,
AgentInvocationStatus::Transferred,
)
.await?;
completion_tool = "agent_handoff";
finished = true;
}
AgentToolEffect::None => {}
}
@ -304,7 +336,14 @@ impl AgentRuntimeService {
}),
)
.await?;
return Ok(Some(commit_count));
return Ok(Some(if let Some((task_id, new_invocation_id)) = handoff {
AgentLoopExit::Transferred {
task_id,
new_invocation_id,
}
} else {
AgentLoopExit::Finished
}));
}
remember_seen_child_results_from_await(&tool_results, &mut seen_child_result_task_ids);
@ -404,13 +443,29 @@ fn remember_seen_child_results_from_await(
}
}
fn completion_tool_name(exit_policy: AgentInvocationExitPolicy) -> &'static str {
fn completion_tool_name(
exit_policy: AgentInvocationExitPolicy,
profile: &ResolvedAgentProfile,
) -> &'static str {
match exit_policy {
AgentInvocationExitPolicy::RunFinishAllowed => "workspace_finish",
AgentInvocationExitPolicy::RunFinishAllowed => {
if profile_tool_visible(profile, "workspace.finish") {
"workspace_finish"
} else if profile_tool_visible(profile, AGENT_HANDOFF) {
"agent_handoff"
} else {
"an available Agent control tool"
}
}
AgentInvocationExitPolicy::TaskReturnRequired => "task_return",
}
}
fn profile_tool_visible(profile: &ResolvedAgentProfile, tool_name: &str) -> bool {
profile.tools.allow.iter().any(|name| name == tool_name)
&& !profile.tools.deny.iter().any(|name| name == tool_name)
}
fn drift_recovery_attempt_limit(max_rounds: usize) -> usize {
max_rounds.saturating_sub(1)
}
@ -419,13 +474,12 @@ fn drift_recovery_attempt_limit(max_rounds: usize) -> usize {
/// turn with zero tool calls. The phrasing covers the common drift modes:
///
/// * **Post-commit drift** (committed_count > 0): model committed a chat
/// message but then replied with plain text instead of calling
/// `workspace_finish`. We tell it that clean completion still requires
/// `workspace_finish`, and that workspace edits only affect the chat after
/// another `workspace_commit`.
/// message but then replied with plain text instead of using the current
/// stage completion tool. We tell it to complete with `workspace_finish`
/// when available, or continue with `agent_handoff` for handoff-only stages.
/// * **No-commit drift** (committed_count == 0): model bypassed the tool
/// workflow entirely. We tell it that every turn must use a tool until
/// `workspace_finish`.
/// the stage is finished or transferred.
/// * **Child drift** (TaskReturnRequired): return-mode subagents cannot
/// commit or finish the run, so we direct them back to `task_return`.
///
@ -434,36 +488,71 @@ fn build_drift_recovery_nudge(
attempt: usize,
direct_output_path: Option<&WorkspacePath>,
exit_policy: AgentInvocationExitPolicy,
profile: &ResolvedAgentProfile,
) -> String {
match exit_policy {
AgentInvocationExitPolicy::RunFinishAllowed => {
let direct_output_hint = direct_output_path
.map(|path| {
format!(
" I saved your direct text to {}. If that text is the intended reply, call workspace_commit with path \"{}\" before workspace_finish.",
path.as_str(),
path.as_str()
)
})
.unwrap_or_default();
if profile_tool_visible(profile, "workspace.finish") {
let direct_output_hint = direct_output_path
.map(|path| {
format!(
" I saved your direct text to {}. If that text is the intended reply, call workspace_commit with path \"{}\" before workspace_finish.",
path.as_str(),
path.as_str()
)
})
.unwrap_or_default();
if committed_count > 0 {
format!(
"[system reminder, direct output recovery attempt {attempt}] You replied with \
plain text but the run is still open. You have committed {committed_count} \
message(s) to the chat via workspace_commit; complete cleanly by calling \
workspace_finish. If you need to revise the committed content, update the workspace file with \
workspace_apply_patch or workspace_write_file, then call workspace_commit again \
before workspace_finish.{direct_output_hint} Do NOT repeat the content in plain text; \
continue through Agent tools."
)
} else {
format!(
"[system reminder, direct output recovery attempt {attempt}] You replied with \
plain text, but this run must continue through Agent tools until workspace_finish. \
Inspect the workspace if needed, produce the answer through workspace_write_file \
and workspace_commit, then call workspace_finish.{direct_output_hint} \
Do NOT answer directly in plain text."
)
}
} else if profile_tool_visible(profile, AGENT_HANDOFF) {
let direct_output_hint = direct_output_path
.map(|path| {
format!(
" I saved your direct text to {}. If it is useful, mention that path in the handoff brief.",
path.as_str()
)
})
.unwrap_or_default();
if committed_count > 0 {
format!(
"[system reminder, direct output recovery attempt {attempt}] You replied with \
plain text but the run is still open. You have committed {committed_count} \
message(s) to the chat via workspace_commit; complete cleanly by calling \
workspace_finish. If you need to revise the committed content, update the workspace file with \
workspace_apply_patch or workspace_write_file, then call workspace_commit again \
before workspace_finish.{direct_output_hint} Do NOT repeat the content in plain text; \
continue through Agent tools."
plain text, but this Agent stage cannot finish the run directly. Continue by \
calling agent_handoff with a clear objective, context summary, workspace references, \
and preservation constraints for the next Agent.{direct_output_hint} Do NOT answer \
directly in plain text."
)
} else {
let direct_output_hint = direct_output_path
.map(|path| {
format!(
" I saved your direct text to {}. If it is useful, reference that path when continuing.",
path.as_str()
)
})
.unwrap_or_default();
format!(
"[system reminder, direct output recovery attempt {attempt}] You replied with \
plain text, but this run must continue through Agent tools until workspace_finish. \
Inspect the workspace if needed, produce the answer through workspace_write_file \
and workspace_commit, then call workspace_finish.{direct_output_hint} \
Do NOT answer directly in plain text."
plain text, but this run must continue through Agent tools. Use an available \
Agent control tool to continue or complete the stage.{direct_output_hint} Do NOT \
answer directly in plain text."
)
}
}

View file

@ -3,8 +3,9 @@ use serde_json::{Value, json};
use super::AgentRuntimeService;
use crate::application::dto::agent_dto::{
AgentModelTurnDisplayDto, AgentModelTurnProviderDto, AgentModelTurnReasoningDto,
AgentModelTurnTextDto, AgentModelTurnToolCallDto, AgentReadModelTurnDto,
AgentModelTurnDisplayDto, AgentModelTurnNarrationDto, AgentModelTurnProviderDto,
AgentModelTurnReasoningDto, AgentModelTurnTextDto, AgentModelTurnToolCallDto,
AgentReadModelTurnDto,
};
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{
@ -12,6 +13,9 @@ use crate::domain::models::agent::{
};
use crate::domain::text_metrics::TextMetrics;
const MODEL_TURN_NARRATION_EVENT_MAX_CHARS: usize = 280;
const NARRATION_SOURCE_ASSISTANT_TEXT: &str = "assistantText";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredModelResponse {
@ -115,14 +119,23 @@ pub(super) fn model_turn_event_summary(response: &AgentModelResponse) -> Value {
}
});
json!({
let mut payload = json!({
"hasAssistantText": !response.text.trim().is_empty(),
"assistantTextChars": assistant_metrics.chars,
"assistantTextWords": assistant_metrics.words,
"hasReasoning": reasoning_metrics.chars > 0,
"reasoningChars": reasoning_metrics.chars,
"reasoningWords": reasoning_metrics.words,
})
});
if let Some(narration) = model_turn_narration(response, MODEL_TURN_NARRATION_EVENT_MAX_CHARS) {
payload
.as_object_mut()
.expect("model turn event summary must be a JSON object")
.insert("narration".to_string(), json!(narration));
}
payload
}
fn project_model_turn(
@ -138,6 +151,7 @@ fn project_model_turn(
model_response_path: model_response_path.to_string(),
provider: project_provider(response),
assistant: text_dto(&response.text, max_chars),
narration: model_turn_narration(response, max_chars),
reasoning: response
.message
.parts
@ -160,6 +174,29 @@ fn project_model_turn(
}
}
fn model_turn_narration(
response: &AgentModelResponse,
max_chars: usize,
) -> Option<AgentModelTurnNarrationDto> {
if response.tool_calls.is_empty() {
return None;
}
let text = response.text.trim();
if text.is_empty() {
return None;
}
let display = text_dto(text, max_chars);
Some(AgentModelTurnNarrationDto {
source: NARRATION_SOURCE_ASSISTANT_TEXT.to_string(),
text: display.text,
total_chars: display.total_chars,
total_words: display.total_words,
truncated: display.truncated,
})
}
fn project_provider(response: &AgentModelResponse) -> AgentModelTurnProviderDto {
AgentModelTurnProviderDto {
source: string_field(&response.provider_metadata, "chatCompletionSource"),
@ -238,3 +275,85 @@ fn string_field(value: &Value, key: &str) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::models::agent::{AgentModelMessage, AgentModelRole, AgentToolCall};
#[test]
fn narration_uses_assistant_text_for_tool_turns() {
let response = response_with_text(" I will write the artifact. ", vec![tool_call()]);
let narration = model_turn_narration(&response, 40).expect("narration");
assert_eq!(narration.source, "assistantText");
assert_eq!(narration.text, "I will write the artifact.");
assert_eq!(narration.total_chars, 26);
assert_eq!(narration.total_words, 5);
assert!(!narration.truncated);
let summary = model_turn_event_summary(&response);
assert_eq!(summary["narration"]["source"], "assistantText");
assert_eq!(summary["narration"]["text"], "I will write the artifact.");
assert_eq!(summary["narration"]["totalChars"], json!(26));
}
#[test]
fn narration_treats_json_assistant_text_as_plain_text() {
let raw_text = r#"{"context":"draft is ready","ignored":"raw"}"#;
let response = response_with_text(raw_text, vec![tool_call()]);
let narration = model_turn_narration(&response, 80).expect("narration");
assert_eq!(narration.source, "assistantText");
assert_eq!(narration.text, raw_text);
assert!(!narration.truncated);
let summary = model_turn_event_summary(&response);
assert_eq!(summary["narration"]["source"], "assistantText");
assert_eq!(summary["narration"]["text"], raw_text);
}
#[test]
fn narration_is_absent_without_tool_turn_or_text() {
let plain_response = response_with_text("Final answer.", Vec::new());
assert!(model_turn_narration(&plain_response, 40).is_none());
assert!(
model_turn_event_summary(&plain_response)
.get("narration")
.is_none()
);
let empty_response = response_with_text(" ", vec![tool_call()]);
assert!(model_turn_narration(&empty_response, 40).is_none());
assert!(
model_turn_event_summary(&empty_response)
.get("narration")
.is_none()
);
}
fn response_with_text(text: &str, tool_calls: Vec<AgentToolCall>) -> AgentModelResponse {
AgentModelResponse {
message: AgentModelMessage {
role: AgentModelRole::Assistant,
parts: vec![AgentModelContentPart::Text {
text: text.to_string(),
}],
provider_metadata: Value::Null,
},
tool_calls,
text: text.to_string(),
provider_metadata: Value::Null,
raw_response: Value::Null,
}
}
fn tool_call() -> AgentToolCall {
AgentToolCall {
id: "call_1".to_string(),
name: "workspace.write_file".to_string(),
arguments: json!({}),
provider_metadata: Value::Null,
}
}
}

View file

@ -5,12 +5,16 @@ use std::sync::{Arc, Mutex, Weak};
use tokio::sync::watch;
use super::AgentRuntimeService;
use super::guidance::AgentGuidanceMailbox;
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{AgentInvocationStatus, AgentTaskRecord, AgentTaskStatus};
use crate::domain::models::agent::{
AgentDelegationContinuation, AgentInvocationStatus, AgentTaskRecord, AgentTaskStatus,
};
pub(super) struct ActiveRunHandle {
pub(super) cancel_sender: watch::Sender<bool>,
pub(super) scheduler: Arc<AgentTaskScheduler>,
pub(super) guidance_mailbox: Arc<AgentGuidanceMailbox>,
}
impl ActiveRunHandle {
@ -22,6 +26,7 @@ impl ActiveRunHandle {
Self {
cancel_sender,
scheduler: Arc::new(AgentTaskScheduler::new(service, run_id)),
guidance_mailbox: Arc::new(AgentGuidanceMailbox::new()),
}
}
}
@ -107,6 +112,7 @@ impl AgentTaskScheduler {
let tasks = tasks
.into_iter()
.filter(|task| task.parent_invocation_id == parent_invocation_id)
.filter(|task| task.continuation == AgentDelegationContinuation::ReturnToParent)
.filter(task_is_unfinished)
.collect::<Vec<_>>();
self.cancel_unfinished_tasks(
@ -125,6 +131,7 @@ impl AgentTaskScheduler {
.await?;
let tasks = tasks
.into_iter()
.filter(|task| task.continuation == AgentDelegationContinuation::ReturnToParent)
.filter(task_is_unfinished)
.collect::<Vec<_>>();
self.cancel_unfinished_tasks(&service, tasks, "cancelled because the Agent run stopped")

View file

@ -0,0 +1,127 @@
use std::collections::HashSet;
use crate::application::dto::agent_dto::{
AgentRunTimelineDelegationEdgeDto, AgentRunTimelineInvocationDto, AgentRunTimelineProjectionDto,
};
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{
AgentDelegationContinuation, AgentInvocation, AgentInvocationKind, AgentTaskRecord,
ROOT_AGENT_INVOCATION_ID,
};
pub(super) fn build_run_timeline_projection(
invocations: &[AgentInvocation],
tasks: &[AgentTaskRecord],
) -> Result<AgentRunTimelineProjectionDto, ApplicationError> {
validate_projection_graph(invocations, tasks)?;
Ok(AgentRunTimelineProjectionDto {
foreground_invocation_ids: foreground_invocation_ids(invocations, tasks),
invocations: invocation_nodes(invocations),
delegation_edges: delegation_edges(tasks),
})
}
fn validate_projection_graph(
invocations: &[AgentInvocation],
tasks: &[AgentTaskRecord],
) -> Result<(), ApplicationError> {
let invocation_ids = invocations
.iter()
.map(|invocation| invocation.id.as_str())
.collect::<HashSet<_>>();
for task in tasks {
if !invocation_ids.contains(task.parent_invocation_id.as_str()) {
return Err(ApplicationError::ValidationError(format!(
"agent.timeline_projection_invalid: task `{}` references missing parent invocation `{}`",
task.id, task.parent_invocation_id
)));
}
if !invocation_ids.contains(task.child_invocation_id.as_str()) {
return Err(ApplicationError::ValidationError(format!(
"agent.timeline_projection_invalid: task `{}` references missing target invocation `{}`",
task.id, task.child_invocation_id
)));
}
}
Ok(())
}
fn foreground_invocation_ids(
invocations: &[AgentInvocation],
tasks: &[AgentTaskRecord],
) -> Vec<String> {
let mut candidates = Vec::new();
for task in tasks {
if task.continuation == AgentDelegationContinuation::TransferControl
&& task.child_invocation_id != ROOT_AGENT_INVOCATION_ID
{
candidates.push((task.child_invocation_id.clone(), task.created_at));
}
}
for invocation in invocations {
if invocation.kind == AgentInvocationKind::Handoff
&& invocation.id != ROOT_AGENT_INVOCATION_ID
{
candidates.push((invocation.id.clone(), invocation.created_at));
}
}
candidates.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
let mut ids = vec![ROOT_AGENT_INVOCATION_ID.to_string()];
for (invocation_id, _) in candidates {
if !ids.iter().any(|existing| existing == &invocation_id) {
ids.push(invocation_id);
}
}
ids
}
fn invocation_nodes(invocations: &[AgentInvocation]) -> Vec<AgentRunTimelineInvocationDto> {
let mut nodes = invocations.iter().collect::<Vec<_>>();
nodes.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
});
nodes
.into_iter()
.map(|invocation| AgentRunTimelineInvocationDto {
invocation_id: invocation.id.clone(),
parent_invocation_id: invocation.parent_invocation_id.clone(),
profile_id: invocation.profile_id.clone(),
kind: invocation.kind,
status: invocation.status,
exit_policy: invocation.exit_policy,
created_at: invocation.created_at,
updated_at: invocation.updated_at,
})
.collect()
}
fn delegation_edges(tasks: &[AgentTaskRecord]) -> Vec<AgentRunTimelineDelegationEdgeDto> {
let mut edges = tasks.iter().collect::<Vec<_>>();
edges.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
});
edges
.into_iter()
.map(|task| AgentRunTimelineDelegationEdgeDto {
task_id: task.id.clone(),
source_invocation_id: task.parent_invocation_id.clone(),
target_invocation_id: task.child_invocation_id.clone(),
target_profile_id: task.target_profile_id.clone(),
workspace_key: task.workspace_key.clone(),
continuation: task.continuation,
status: task.status,
result_ref: task.result_ref.clone(),
error: task.error.clone(),
created_at: task.created_at,
updated_at: task.updated_at,
})
.collect()
}

View file

@ -9,8 +9,8 @@ use super::delegation::workspace_policy::InvocationWorkspaceRepository;
use crate::application::errors::ApplicationError;
use crate::application::services::agent_tools::{
AGENT_AWAIT, AGENT_DELEGATE, AGENT_LIST, AgentToolDispatchOutcome, AgentToolEffect,
AgentToolSession, TASK_RETURN,
AGENT_AWAIT, AGENT_DELEGATE, AGENT_HANDOFF, AGENT_LIST, AgentToolDispatchOutcome,
AgentToolEffect, AgentToolSession, TASK_RETURN,
};
use crate::domain::models::agent::profile::ResolvedAgentProfile;
use crate::domain::models::agent::{
@ -169,6 +169,9 @@ impl AgentRuntimeService {
cancel,
)
.await
} else if call.name == AGENT_HANDOFF {
self.dispatch_agent_handoff_tool(run_id, invocation_id, call, profile)
.await
} else if call.name == TASK_RETURN {
self.dispatch_task_return_tool(run_id, invocation_id, call, exit_policy, profile)
.await
@ -382,6 +385,7 @@ fn tool_is_visible(
|| name == "workspace.finish"
|| name == AGENT_LIST
|| name == AGENT_DELEGATE
|| name == AGENT_HANDOFF
|| name == AGENT_AWAIT
{
return false;

View file

@ -14,5 +14,5 @@ pub use dispatcher::{AgentToolDispatchOutcome, AgentToolDispatcher, AgentToolEff
pub use registry::BuiltinAgentToolRegistry;
pub use session::AgentToolSession;
pub(crate) use agent::{AGENT_AWAIT, AGENT_DELEGATE, AGENT_LIST, TASK_RETURN};
pub(crate) use agent::{AGENT_AWAIT, AGENT_DELEGATE, AGENT_HANDOFF, AGENT_LIST, TASK_RETURN};
pub(crate) use common::{WORKSPACE_PATH_IS_DIRECTORY_CODE, workspace_path_is_directory_message};

View file

@ -1,8 +1,11 @@
mod specs;
pub(super) use specs::{agent_await_spec, agent_delegate_spec, agent_list_spec, task_return_spec};
pub(super) use specs::{
agent_await_spec, agent_delegate_spec, agent_handoff_spec, agent_list_spec, task_return_spec,
};
pub(crate) const AGENT_AWAIT: &str = "agent.await";
pub(crate) const AGENT_DELEGATE: &str = "agent.delegate";
pub(crate) const AGENT_HANDOFF: &str = "agent.handoff";
pub(crate) const AGENT_LIST: &str = "agent.list";
pub(crate) const TASK_RETURN: &str = "task.return";

View file

@ -1,10 +1,11 @@
use serde_json::json;
use super::{AGENT_AWAIT, AGENT_DELEGATE, AGENT_LIST, TASK_RETURN};
use super::{AGENT_AWAIT, AGENT_DELEGATE, AGENT_HANDOFF, AGENT_LIST, TASK_RETURN};
use crate::domain::models::agent::AgentToolSpec;
const MODEL_AGENT_AWAIT: &str = "agent_await";
const MODEL_AGENT_DELEGATE: &str = "agent_delegate";
const MODEL_AGENT_HANDOFF: &str = "agent_handoff";
const MODEL_AGENT_LIST: &str = "agent_list";
const MODEL_TASK_RETURN: &str = "task_return";
@ -134,6 +135,73 @@ pub(in crate::application::services::agent_tools) fn agent_await_spec() -> Agent
}
}
pub(in crate::application::services::agent_tools) fn agent_handoff_spec() -> AgentToolSpec {
AgentToolSpec {
name: AGENT_HANDOFF.to_string(),
model_name: MODEL_AGENT_HANDOFF.to_string(),
title: "Agent Handoff".to_string(),
description: "Ask another Agent to take over the next stage of this run. Use this when you have done your part and the next Agent should continue from the shared workspace.".to_string(),
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"agentId": {
"type": "string",
"description": "Agent id returned by agent_list with purpose handoff."
},
"handoff": {
"type": "object",
"description": "Brief the next Agent so it can continue without asking you. Include the objective, relevant workspace paths, context, constraints, and completion criteria.",
"additionalProperties": true,
"properties": {
"title": {
"type": "string",
"description": "Optional short handoff name for display."
},
"reason": {
"type": "string",
"description": "Why you are handing off now."
},
"objective": {
"type": "string",
"description": "What you want the next Agent to accomplish."
},
"contextSummary": {
"type": "string",
"description": "What you have done, what matters next, and any decisions or constraints the next Agent needs."
},
"workspaceRefs": {
"type": "array",
"items": { "type": "string" },
"description": "Workspace paths the next Agent should inspect or continue from."
},
"mustPreserve": {
"type": "array",
"items": { "type": "string" },
"description": "Facts, style constraints, plot points, or edits that must not be lost."
},
"completionCriteria": {
"type": "array",
"items": { "type": "string" },
"description": "What done looks like for the next Agent."
}
},
"required": ["objective"]
},
"pendingTaskPolicy": {
"type": "string",
"enum": ["denyIfPending"],
"description": "Use denyIfPending so handoff waits until delegated tasks you started are finished."
}
},
"required": ["agentId", "handoff"]
}),
output_schema: None,
annotations: json!({ "readOnly": false, "sourceKind": "agent" }),
source: "builtin".to_string(),
}
}
pub(in crate::application::services::agent_tools) fn task_return_spec() -> AgentToolSpec {
AgentToolSpec {
name: TASK_RETURN.to_string(),

View file

@ -55,6 +55,10 @@ pub enum AgentToolEffect {
result_ref: WorkspacePath,
summary: String,
},
HandoffAccepted {
task_id: String,
new_invocation_id: String,
},
Finish,
}

View file

@ -1,4 +1,6 @@
use super::agent::{agent_await_spec, agent_delegate_spec, agent_list_spec, task_return_spec};
use super::agent::{
agent_await_spec, agent_delegate_spec, agent_handoff_spec, agent_list_spec, task_return_spec,
};
use super::chat::{chat_read_messages_spec, chat_search_spec};
use super::dice::dice_roll_spec;
use super::skill::{SKILL_READ, skill_list_spec, skill_read_spec, skill_search_spec};
@ -25,6 +27,7 @@ impl BuiltinAgentToolRegistry {
specs: vec![
agent_list_spec(),
agent_delegate_spec(),
agent_handoff_spec(),
agent_await_spec(),
task_return_spec(),
chat_search_spec(),
@ -347,7 +350,9 @@ fn property_schema_object_mut<'a>(
mod tests {
use std::collections::BTreeMap;
use super::super::agent::{AGENT_AWAIT, AGENT_DELEGATE, AGENT_LIST, TASK_RETURN};
use super::super::agent::{
AGENT_AWAIT, AGENT_DELEGATE, AGENT_HANDOFF, AGENT_LIST, TASK_RETURN,
};
use super::super::dice::DICE_ROLL;
use super::super::workspace::{WORKSPACE_FINISH, WORKSPACE_READ_FILE, WORKSPACE_WRITE_FILE};
use super::*;
@ -370,11 +375,13 @@ mod tests {
assert_eq!(tools[0].name, AGENT_LIST);
assert_eq!(tools[1].model_name, "agent_delegate");
assert_eq!(tools[1].name, AGENT_DELEGATE);
assert_eq!(tools[2].model_name, "agent_await");
assert_eq!(tools[2].name, AGENT_AWAIT);
assert_eq!(tools[3].model_name, "task_return");
assert_eq!(tools[3].name, TASK_RETURN);
assert_eq!(tools[4].model_name, "chat_search");
assert_eq!(tools[2].model_name, "agent_handoff");
assert_eq!(tools[2].name, AGENT_HANDOFF);
assert_eq!(tools[3].model_name, "agent_await");
assert_eq!(tools[3].name, AGENT_AWAIT);
assert_eq!(tools[4].model_name, "task_return");
assert_eq!(tools[4].name, TASK_RETURN);
assert_eq!(tools[5].model_name, "chat_search");
assert_eq!(
tools
.iter()
@ -481,7 +488,7 @@ mod tests {
.filter(|tool| {
matches!(
tool.name.as_str(),
AGENT_LIST | AGENT_DELEGATE | AGENT_AWAIT | TASK_RETURN
AGENT_LIST | AGENT_DELEGATE | AGENT_HANDOFF | AGENT_AWAIT | TASK_RETURN
)
})
.collect::<Vec<_>>();
@ -495,9 +502,14 @@ mod tests {
assert!(!text.contains("invocation"), "{}", tool.name);
assert!(!text.contains("parent Agent"), "{}", tool.name);
assert!(!text.contains("child Agent"), "{}", tool.name);
assert!(!text.contains("This Agent"), "{}", tool.name);
assert!(!text.contains("active control"), "{}", tool.name);
assert!(!text.contains("active owner"), "{}", tool.name);
assert!(!text.contains("delegated result to you"), "{}", tool.name);
assert!(!text.contains("workspace_finish"), "{}", tool.name);
assert!(!text.contains("to collect it"), "{}", tool.name);
assert!(!text.contains("before finalizing"), "{}", tool.name);
assert!(!text.contains("first version"), "{}", tool.name);
}
}

View file

@ -9,7 +9,8 @@ use crate::application::services::agent_identity::{
};
use crate::domain::models::agent::AgentChatRef;
use crate::domain::repositories::agent_workspace_lifecycle_repository::{
AgentChatWorkspaceDeletion, AgentPersistentStatePrune, AgentWorkspaceLifecycleRepository,
AgentChatWorkspaceDeletion, AgentPersistentStatePrune, AgentPersistentStatePruneRequest,
AgentWorkspaceLifecycleRepository,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -20,6 +21,8 @@ pub struct AgentChatWorkspaceTarget {
#[async_trait]
pub trait AgentRunActivity: Send + Sync {
async fn active_run_ids(&self) -> Result<Vec<String>, ApplicationError>;
async fn active_run_ids_for_workspace(
&self,
workspace_id: &str,
@ -144,12 +147,12 @@ impl AgentWorkspaceLifecycleService {
pub async fn prune_persistent_states(
&self,
target: &AgentChatWorkspaceTarget,
retained_state_ids: &[String],
request: AgentPersistentStatePruneRequest,
) -> Result<AgentPersistentStatePrune, ApplicationError> {
self.ensure_chat_workspace_inactive(target).await?;
let workspace_id = self.workspace_id(target)?;
self.repository
.prune_persistent_states(&workspace_id, retained_state_ids)
.prune_persistent_states(&workspace_id, request)
.await
.map_err(Into::into)
}
@ -190,7 +193,7 @@ mod tests {
async fn prune_persistent_states(
&self,
workspace_id: &str,
_retained_state_ids: &[String],
_request: AgentPersistentStatePruneRequest,
) -> Result<AgentPersistentStatePrune, DomainError> {
Ok(AgentPersistentStatePrune {
workspace_id: workspace_id.to_string(),
@ -205,6 +208,10 @@ mod tests {
#[async_trait]
impl AgentRunActivity for MockRunActivity {
async fn active_run_ids(&self) -> Result<Vec<String>, ApplicationError> {
Ok(self.active_run_ids.clone())
}
async fn active_run_ids_for_workspace(
&self,
_workspace_id: &str,

View file

@ -3,11 +3,13 @@ mod lorebook_codec;
use crate::application::dto::character_dto::{
BulkMergeCharacterCardDataDto, BulkMergeCharacterCardDataResultDto, CharacterChatDto,
CharacterDto, CreateCharacterDto, CreateWithAvatarDto, DeleteCharacterDto,
DuplicateCharacterDto, ExportCharacterContentDto, ExportCharacterContentResultDto,
ExportCharacterDto, GetCharacterChatsDto, ImportCharacterDto, MergeCharacterCardDataDto,
RenameCharacterDto, UpdateAvatarDto, UpdateCharacterCardDataDto, UpdateCharacterDto,
merge_character_extensions,
CharacterDto, CharacterLorebookConflictDto, CharacterLorebookConflictResolution,
CheckCharacterLorebookConflictDto, CreateCharacterDto, CreateCharacterWithAvatarResultDto,
CreateWithAvatarDto, DeleteCharacterDto, DuplicateCharacterDto, ExportCharacterContentDto,
ExportCharacterContentResultDto, ExportCharacterDto, GetCharacterChatsDto, ImportCharacterDto,
MergeCharacterCardDataDto, RenameCharacterDto, ResolveCharacterLorebookConflictDto,
ResolveCharacterLorebookConflictResultDto, UpdateAvatarDto, UpdateCharacterCardDataDto,
UpdateCharacterDto, merge_character_extensions,
};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_workspace_lifecycle_service::{
@ -103,14 +105,14 @@ impl CharacterService {
.create_with_avatar(&character, None, None)
.await?;
Ok(CharacterDto::from(created))
Ok(CharacterDto::from(created.character))
}
/// Create a character with an avatar
pub async fn create_with_avatar(
&self,
dto: CreateWithAvatarDto,
) -> Result<CharacterDto, ApplicationError> {
) -> Result<CreateCharacterWithAvatarResultDto, ApplicationError> {
logger::debug(&format!(
"Creating character with avatar: {}",
dto.character.name
@ -138,7 +140,7 @@ impl CharacterService {
.create_with_avatar(&character, avatar_path_ref, crop)
.await?;
Ok(CharacterDto::from(created))
Ok(CreateCharacterWithAvatarResultDto::from(created))
}
/// Update a character
@ -316,6 +318,42 @@ impl CharacterService {
Ok(CharacterDto::from(updated))
}
pub async fn check_lorebook_conflict(
&self,
dto: CheckCharacterLorebookConflictDto,
) -> Result<CharacterLorebookConflictDto, ApplicationError> {
logger::debug(&format!(
"Checking character lorebook conflict: {}",
dto.name
));
let character = self.repository.find_by_name(&dto.name).await?;
self.character_lorebook_conflict(&character).await
}
pub async fn resolve_lorebook_conflict(
&self,
dto: ResolveCharacterLorebookConflictDto,
) -> Result<ResolveCharacterLorebookConflictResultDto, ApplicationError> {
logger::debug(&format!(
"Resolving character lorebook conflict: {} ({:?})",
dto.name, dto.resolution
));
let world = match dto.resolution {
CharacterLorebookConflictResolution::Current => {
self.resolve_lorebook_conflict_with_current_world(&dto.name)
.await?
}
CharacterLorebookConflictResolution::Embedded => {
self.resolve_lorebook_conflict_with_embedded_book(&dto.name)
.await?
}
};
Ok(ResolveCharacterLorebookConflictResultDto { world })
}
/// Merge raw attributes into a stored character card using upstream-compatible deep merge semantics.
pub async fn merge_character_card_data(
&self,
@ -757,11 +795,145 @@ impl CharacterService {
self.validate_character_name(name)
}
async fn character_lorebook_conflict(
&self,
character: &Character,
) -> Result<CharacterLorebookConflictDto, ApplicationError> {
let world_name = character.data.extensions.world.clone();
let embedded_name = character
.data
.character_book
.as_ref()
.and_then(Self::character_book_display_name);
let Some(embedded_book) = character.data.character_book.as_ref() else {
return Ok(CharacterLorebookConflictDto {
conflict: false,
world: world_name,
embedded_name,
current_available: false,
});
};
if world_name.is_empty() {
return Ok(CharacterLorebookConflictDto {
conflict: false,
world: world_name,
embedded_name,
current_available: false,
});
}
let Some(world_info) = self
.world_info_repository
.get_world_info(&world_name, false)
.await?
else {
return Ok(CharacterLorebookConflictDto {
conflict: true,
world: world_name,
embedded_name,
current_available: false,
});
};
let embedded_canonical = Self::canonical_character_book_for_compare(embedded_book)?;
let current_canonical = Self::canonical_world_info_for_compare(&world_info)?;
Ok(CharacterLorebookConflictDto {
conflict: embedded_canonical != current_canonical,
world: world_name,
embedded_name,
current_available: true,
})
}
async fn resolve_lorebook_conflict_with_current_world(
&self,
name: &str,
) -> Result<String, ApplicationError> {
let raw_json = self.repository.read_character_card_json(name).await?;
let mut card_value = card_contract::parse_character_card_json(&raw_json)?;
let world_name = card_value
.pointer("/data/extensions/world")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if world_name.is_empty() {
return Err(ApplicationError::ValidationError(
"Character has no linked world info".to_string(),
));
}
self.materialize_primary_lorebook_value(&mut card_value)
.await?;
self.write_character_card_value(
name,
card_value,
None,
None,
CharacterCardValidationMode::ReadableOnly,
CharacterCardLorebookMaterializationMode::Skip,
)
.await?;
Ok(world_name)
}
async fn resolve_lorebook_conflict_with_embedded_book(
&self,
name: &str,
) -> Result<String, ApplicationError> {
let character = self.repository.find_by_name(name).await?;
let world_name = character.data.extensions.world.clone();
if world_name.is_empty() {
return Err(ApplicationError::ValidationError(
"Character has no linked world info".to_string(),
));
}
let Some(embedded_book) = character.data.character_book.as_ref() else {
return Err(ApplicationError::ValidationError(
"Character has no embedded world info".to_string(),
));
};
let world_info = character_book_to_world_info(embedded_book)?;
self.world_info_repository
.save_world_info(&world_name, &world_info)
.await?;
Ok(world_name)
}
fn character_book_display_name(character_book: &Value) -> Option<String> {
character_book
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
}
fn canonical_character_book_for_compare(character_book: &Value) -> Result<Value, DomainError> {
let world_info = character_book_to_world_info(character_book)?;
Self::canonical_world_info_for_compare(&world_info)
}
fn canonical_world_info_for_compare(world_info: &Value) -> Result<Value, DomainError> {
let mut character_book = world_info_to_character_book("", world_info)?;
if let Some(character_book_object) = character_book.as_object_mut() {
character_book_object.remove("name");
}
Ok(character_book)
}
async fn materialize_primary_lorebook(
&self,
character: &mut Character,
) -> Result<bool, DomainError> {
let world_name = character.data.extensions.world.trim().to_string();
let world_name = character.data.extensions.world.clone();
if world_name.is_empty() {
let removed = character.data.character_book.take().is_some();
return Ok(removed);
@ -775,10 +947,7 @@ impl CharacterService {
character: &mut Character,
primary_lorebook: Option<&str>,
) -> Result<(), DomainError> {
let Some(world_name) = primary_lorebook
.map(str::trim)
.filter(|value| !value.is_empty())
else {
let Some(world_name) = primary_lorebook.filter(|value| !value.is_empty()) else {
return Ok(());
};
@ -864,14 +1033,13 @@ impl CharacterService {
}
fn resolve_embedded_world_name(character: &Character, character_book: &Value) -> String {
if !character.data.extensions.world.trim().is_empty() {
return character.data.extensions.world.trim().to_string();
if !character.data.extensions.world.is_empty() {
return character.data.extensions.world.clone();
}
if let Some(book_name) = character_book.get("name").and_then(Value::as_str) {
let trimmed = book_name.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
if !book_name.is_empty() {
return book_name.to_string();
}
}
@ -977,8 +1145,7 @@ impl CharacterService {
let world_name = export_value
.pointer("/data/extensions/world")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
.unwrap_or("");
if world_name.is_empty() {
if let Some(data_object) = export_value.get_mut("data").and_then(Value::as_object_mut) {

View file

@ -1,8 +1,10 @@
use super::CharacterService;
use crate::application::dto::character_dto::{
BulkMergeCharacterCardDataDto, BulkMergeCharacterCardDataFilterDto, CreateCharacterDto,
BulkMergeCharacterCardDataDto, BulkMergeCharacterCardDataFilterDto,
CharacterLorebookConflictResolution, CheckCharacterLorebookConflictDto, CreateCharacterDto,
ExportCharacterContentDto, ExportCharacterDto, ImportCharacterDto, MergeCharacterCardDataDto,
UpdateAvatarDto, UpdateCharacterCardDataDto, UpdateCharacterDto,
ResolveCharacterLorebookConflictDto, UpdateAvatarDto, UpdateCharacterCardDataDto,
UpdateCharacterDto,
};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_workspace_lifecycle_service::{
@ -14,6 +16,7 @@ use crate::domain::repositories::world_info_repository::WorldInfoRepository;
use crate::infrastructure::persistence::png_utils::{
read_character_data_from_png, write_character_data_to_png,
};
use crate::infrastructure::repositories::chat_directory_identity::new_shared_chat_alias_store_for_user_dir;
use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository;
use crate::infrastructure::repositories::file_character_repository::FileCharacterRepository;
use crate::infrastructure::repositories::file_chat_repository::FileChatRepository;
@ -31,6 +34,10 @@ struct NoActiveAgentRuns;
#[async_trait]
impl AgentRunActivity for NoActiveAgentRuns {
async fn active_run_ids(&self) -> Result<Vec<String>, ApplicationError> {
Ok(Vec::new())
}
async fn active_run_ids_for_workspace(
&self,
_workspace_id: &str,
@ -118,25 +125,29 @@ async fn setup_service() -> (
.await
.expect("write default avatar");
let character_repository = FileCharacterRepository::new(
characters_dir,
chats_dir,
thumbnails_avatar_dir,
default_avatar,
let chat_aliases = new_shared_chat_alias_store_for_user_dir(&root);
let character_repository = FileCharacterRepository::with_chat_aliases(
characters_dir.clone(),
chats_dir.clone(),
thumbnails_avatar_dir.clone(),
default_avatar.clone(),
chat_aliases.clone(),
);
let world_info_repository = FileWorldInfoRepository::new(worlds_dir);
let service = CharacterService::new(
Arc::new(FileCharacterRepository::new(
root.join("characters"),
root.join("chats"),
root.join("thumbnails/avatar"),
root.join("default.png"),
Arc::new(FileCharacterRepository::with_chat_aliases(
characters_dir,
chats_dir.clone(),
thumbnails_avatar_dir,
default_avatar,
chat_aliases.clone(),
)),
Arc::new(FileChatRepository::new(
Arc::new(FileChatRepository::with_chat_aliases(
root.join("characters"),
root.join("chats"),
chats_dir,
root.join("group_chats"),
root.join("backups"),
chat_aliases,
)),
Arc::new(FileWorldInfoRepository::new(root.join("worlds"))),
Arc::new(AgentWorkspaceLifecycleService::new(
@ -2230,3 +2241,123 @@ async fn update_avatar_materializes_bound_lorebook_into_written_card() {
let _ = fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn lorebook_conflict_current_resolution_materializes_local_world() {
let (service, character_repository, world_info_repository, root) = setup_service().await;
let embedded_book = save_bound_world(&world_info_repository, "bound-book").await;
let mut character = Character::new(
"Conflict Current".to_string(),
"desc".to_string(),
"persona".to_string(),
"hello".to_string(),
);
character.data.extensions.world = "bound-book".to_string();
character.data.character_book = Some(embedded_book);
let mut card_payload = serde_json::to_value(character.to_v2()).expect("serialize character");
card_payload
.as_object_mut()
.expect("card payload object")
.insert("x_custom_top".to_string(), json!({ "preserved": true }));
write_character_png(&root, "Conflict Current", &card_payload).await;
let conflict = service
.check_lorebook_conflict(CheckCharacterLorebookConflictDto {
name: "Conflict Current".to_string(),
})
.await
.expect("check lorebook conflict");
assert!(conflict.conflict);
assert!(conflict.current_available);
assert_eq!(conflict.world, "bound-book");
service
.resolve_lorebook_conflict(ResolveCharacterLorebookConflictDto {
name: "Conflict Current".to_string(),
resolution: CharacterLorebookConflictResolution::Current,
})
.await
.expect("resolve with current local world");
let stored_json = character_repository
.read_character_card_json("Conflict Current")
.await
.expect("read resolved character");
let stored_value: serde_json::Value =
serde_json::from_str(&stored_json).expect("parse resolved character");
assert_eq!(
stored_value.pointer("/data/character_book/entries/0/content"),
Some(&json!("fresh"))
);
assert_eq!(
stored_value.get("x_custom_top"),
Some(&json!({ "preserved": true }))
);
let conflict = service
.check_lorebook_conflict(CheckCharacterLorebookConflictDto {
name: "Conflict Current".to_string(),
})
.await
.expect("recheck lorebook conflict");
assert!(!conflict.conflict);
let _ = fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn lorebook_conflict_embedded_resolution_overwrites_local_world() {
let (service, character_repository, world_info_repository, root) = setup_service().await;
let embedded_book = save_bound_world(&world_info_repository, "bound-book").await;
let mut character = Character::new(
"Conflict Embedded".to_string(),
"desc".to_string(),
"persona".to_string(),
"hello".to_string(),
);
character.data.extensions.world = "bound-book".to_string();
character.data.character_book = Some(embedded_book);
character_repository
.save(&character)
.await
.expect("save conflict character");
let conflict = service
.check_lorebook_conflict(CheckCharacterLorebookConflictDto {
name: "Conflict Embedded".to_string(),
})
.await
.expect("check lorebook conflict");
assert!(conflict.conflict);
assert!(conflict.current_available);
service
.resolve_lorebook_conflict(ResolveCharacterLorebookConflictDto {
name: "Conflict Embedded".to_string(),
resolution: CharacterLorebookConflictResolution::Embedded,
})
.await
.expect("resolve with embedded world");
let world_info = world_info_repository
.get_world_info("bound-book", false)
.await
.expect("read world info")
.expect("world info exists");
assert_eq!(
world_info.pointer("/entries/1/content"),
Some(&json!("content"))
);
let conflict = service
.check_lorebook_conflict(CheckCharacterLorebookConflictDto {
name: "Conflict Embedded".to_string(),
})
.await
.expect("recheck lorebook conflict");
assert!(!conflict.conflict);
let _ = fs::remove_dir_all(&root).await;
}

View file

@ -25,6 +25,7 @@ mod config;
mod custom_api_format;
mod custom_parameters;
pub(crate) mod exchange;
mod model_capabilities;
mod payload;
mod prompt_caching;
mod prompt_caching_plan;

View file

@ -0,0 +1,561 @@
use crate::application::errors::ApplicationError;
const GEMINI_FLASH_MAX_THINKING_BUDGET: i64 = 24_576;
const GEMINI_PRO_MAX_THINKING_BUDGET: i64 = 32_768;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RequestedReasoningEffort {
Auto,
None,
Minimal,
Low,
Medium,
High,
Max,
XHigh,
}
impl RequestedReasoningEffort {
pub(super) fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Some(Self::Auto),
"none" => Some(Self::None),
"min" | "minimum" | "minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"max" | "maximum" => Some(Self::Max),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum GeminiThinkingControl {
BudgetTokens(i64),
Level(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GeminiThinkingModel {
Gemini25FlashLite,
Gemini25Flash,
Gemini25Pro,
Gemini3Flash,
Gemini3ProLowHigh,
Gemini3ProMedium,
}
pub(super) fn parse_known_reasoning_effort(
value: &str,
provider: &str,
) -> Result<RequestedReasoningEffort, ApplicationError> {
RequestedReasoningEffort::parse(value)
.ok_or_else(|| unsupported_reasoning_effort(provider, value))
}
pub(super) fn unsupported_reasoning_effort(provider: &str, value: &str) -> ApplicationError {
ApplicationError::ValidationError(format!(
"Unsupported {provider} reasoning_effort: {}",
value.trim().to_ascii_lowercase()
))
}
pub(super) fn is_openrouter_claude_model_name(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
.starts_with("anthropic/claude")
}
pub(super) fn map_openrouter_reasoning_effort(
value: &str,
) -> Result<Option<&'static str>, ApplicationError> {
match parse_known_reasoning_effort(value, "OpenRouter")? {
RequestedReasoningEffort::Auto => Ok(None),
RequestedReasoningEffort::None => Ok(Some("none")),
RequestedReasoningEffort::Minimal => Ok(Some("minimal")),
RequestedReasoningEffort::Low => Ok(Some("low")),
RequestedReasoningEffort::Medium => Ok(Some("medium")),
RequestedReasoningEffort::High => Ok(Some("high")),
RequestedReasoningEffort::Max => Ok(Some("high")),
RequestedReasoningEffort::XHigh => Ok(Some("xhigh")),
}
}
pub(super) fn is_zai_reasoning_effort_model(model: &str) -> bool {
model.trim().eq_ignore_ascii_case("glm-5.2")
}
pub(super) fn map_zai_reasoning_effort(
model: &str,
value: &str,
) -> Result<Option<&'static str>, ApplicationError> {
let effort = parse_known_reasoning_effort(value, "Z.AI")?;
match effort {
RequestedReasoningEffort::Auto => Ok(None),
_ if !is_zai_reasoning_effort_model(model) => Err(ApplicationError::ValidationError(
"Z.AI reasoning_effort is only supported by glm-5.2".to_string(),
)),
RequestedReasoningEffort::None => Ok(Some("none")),
RequestedReasoningEffort::Minimal => Ok(Some("minimal")),
RequestedReasoningEffort::Low => Ok(Some("low")),
RequestedReasoningEffort::Medium => Ok(Some("medium")),
RequestedReasoningEffort::High => Ok(Some("high")),
RequestedReasoningEffort::Max => Ok(Some("max")),
RequestedReasoningEffort::XHigh => Ok(Some("xhigh")),
}
}
pub(super) fn is_gemini_thinking_config_model(model: &str) -> bool {
classify_gemini_thinking_model(model).is_some()
}
pub(super) fn map_gemini_thinking_control(
model: &str,
max_tokens: i64,
effort: RequestedReasoningEffort,
) -> Result<Option<GeminiThinkingControl>, ApplicationError> {
if effort == RequestedReasoningEffort::None {
return Err(unsupported_reasoning_effort("Gemini", "none"));
}
let Some(model) = classify_gemini_thinking_model(model) else {
return Ok(None);
};
let max_tokens = max_tokens.max(0);
let control = match model {
GeminiThinkingModel::Gemini25FlashLite => {
gemini_flash_lite_budget(max_tokens, effort).map(GeminiThinkingControl::BudgetTokens)
}
GeminiThinkingModel::Gemini25Flash => Some(GeminiThinkingControl::BudgetTokens(
gemini_flash_budget(max_tokens, effort),
)),
GeminiThinkingModel::Gemini25Pro => Some(GeminiThinkingControl::BudgetTokens(
gemini_pro_budget(max_tokens, effort),
)),
GeminiThinkingModel::Gemini3Flash => {
gemini_3_flash_level(effort).map(GeminiThinkingControl::Level)
}
GeminiThinkingModel::Gemini3ProLowHigh => {
gemini_3_pro_low_high_level(effort).map(GeminiThinkingControl::Level)
}
GeminiThinkingModel::Gemini3ProMedium => {
gemini_3_pro_medium_level(effort).map(GeminiThinkingControl::Level)
}
};
Ok(control)
}
fn classify_gemini_thinking_model(model: &str) -> Option<GeminiThinkingModel> {
let model = model.trim().to_ascii_lowercase();
if model.starts_with("gemini-2.5-") && is_gemini_image_model(&model) {
return None;
}
if model.starts_with("gemini-2.5-flash-lite") {
return Some(GeminiThinkingModel::Gemini25FlashLite);
}
if model.starts_with("gemini-2.5-flash") {
return Some(GeminiThinkingModel::Gemini25Flash);
}
if model.starts_with("gemini-2.5-pro") {
return Some(GeminiThinkingModel::Gemini25Pro);
}
if is_gemini_3_variant(&model, "flash") {
return Some(GeminiThinkingModel::Gemini3Flash);
}
if is_gemini_3_variant(&model, "pro") {
return Some(if model.starts_with("gemini-3.") {
GeminiThinkingModel::Gemini3ProMedium
} else {
GeminiThinkingModel::Gemini3ProLowHigh
});
}
None
}
fn is_gemini_image_model(model: &str) -> bool {
model.ends_with("-image") || model.ends_with("-image-preview")
}
fn is_gemini_3_variant(model: &str, variant: &str) -> bool {
let Some(rest) = model.strip_prefix("gemini-3") else {
return false;
};
let version_end = rest
.find(|character: char| character != '.' && !character.is_ascii_digit())
.unwrap_or(rest.len());
let Some(name) = rest[version_end..].strip_prefix('-') else {
return false;
};
name == variant
|| name
.strip_prefix(variant)
.is_some_and(|tail| tail.starts_with('-'))
}
fn gemini_flash_lite_budget(max_tokens: i64, effort: RequestedReasoningEffort) -> Option<i64> {
match effort {
RequestedReasoningEffort::Auto => None,
effort => Some(gemini_budget_tokens(
max_tokens,
effort,
0,
512,
GEMINI_FLASH_MAX_THINKING_BUDGET,
)),
}
}
fn gemini_flash_budget(max_tokens: i64, effort: RequestedReasoningEffort) -> i64 {
gemini_budget_tokens(max_tokens, effort, 0, 0, GEMINI_FLASH_MAX_THINKING_BUDGET)
}
fn gemini_pro_budget(max_tokens: i64, effort: RequestedReasoningEffort) -> i64 {
gemini_budget_tokens(max_tokens, effort, 128, 128, GEMINI_PRO_MAX_THINKING_BUDGET)
}
fn gemini_budget_tokens(
max_tokens: i64,
effort: RequestedReasoningEffort,
minimal_tokens: i64,
min_budget: i64,
max_budget: i64,
) -> i64 {
let tokens = match effort {
RequestedReasoningEffort::Auto => -1,
RequestedReasoningEffort::Minimal => minimal_tokens,
RequestedReasoningEffort::Low => max_tokens.saturating_mul(10) / 100,
RequestedReasoningEffort::Medium => max_tokens.saturating_mul(25) / 100,
RequestedReasoningEffort::High => max_tokens.saturating_mul(50) / 100,
RequestedReasoningEffort::Max | RequestedReasoningEffort::XHigh => max_tokens,
RequestedReasoningEffort::None => {
unreachable!("Gemini reasoning mapper rejects none")
}
};
if tokens < 0 {
tokens
} else {
tokens.clamp(min_budget, max_budget)
}
}
fn gemini_3_flash_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal => Some("minimal"),
RequestedReasoningEffort::Low => Some("low"),
RequestedReasoningEffort::Medium => Some("medium"),
RequestedReasoningEffort::High
| RequestedReasoningEffort::Max
| RequestedReasoningEffort::XHigh => Some("high"),
RequestedReasoningEffort::None => unreachable!("Gemini reasoning mapper rejects none"),
}
}
fn gemini_3_pro_low_high_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal
| RequestedReasoningEffort::Low
| RequestedReasoningEffort::Medium => Some("low"),
RequestedReasoningEffort::High
| RequestedReasoningEffort::Max
| RequestedReasoningEffort::XHigh => Some("high"),
RequestedReasoningEffort::None => unreachable!("Gemini reasoning mapper rejects none"),
}
}
fn gemini_3_pro_medium_level(effort: RequestedReasoningEffort) -> Option<&'static str> {
match effort {
RequestedReasoningEffort::Auto => None,
RequestedReasoningEffort::Minimal | RequestedReasoningEffort::Low => Some("low"),
RequestedReasoningEffort::Medium => Some("medium"),
RequestedReasoningEffort::High
| RequestedReasoningEffort::Max
| RequestedReasoningEffort::XHigh => Some("high"),
RequestedReasoningEffort::None => unreachable!("Gemini reasoning mapper rejects none"),
}
}
#[cfg(test)]
mod tests {
use super::{
GeminiThinkingControl, RequestedReasoningEffort, is_gemini_thinking_config_model,
is_openrouter_claude_model_name, is_zai_reasoning_effort_model,
map_gemini_thinking_control, map_openrouter_reasoning_effort, map_zai_reasoning_effort,
};
#[test]
fn requested_reasoning_effort_parser_normalizes_project_aliases() {
for (input, expected) in [
("auto", Some(RequestedReasoningEffort::Auto)),
("", Some(RequestedReasoningEffort::Auto)),
("none", Some(RequestedReasoningEffort::None)),
("min", Some(RequestedReasoningEffort::Minimal)),
("minimum", Some(RequestedReasoningEffort::Minimal)),
("minimal", Some(RequestedReasoningEffort::Minimal)),
("low", Some(RequestedReasoningEffort::Low)),
("medium", Some(RequestedReasoningEffort::Medium)),
("high", Some(RequestedReasoningEffort::High)),
("max", Some(RequestedReasoningEffort::Max)),
("maximum", Some(RequestedReasoningEffort::Max)),
("xhigh", Some(RequestedReasoningEffort::XHigh)),
("turbo", None),
] {
assert_eq!(RequestedReasoningEffort::parse(input), expected);
}
}
#[test]
fn openrouter_claude_classifier_matches_anthropic_route() {
assert!(is_openrouter_claude_model_name(
" anthropic/claude-sonnet-4-5 "
));
assert!(!is_openrouter_claude_model_name("openai/gpt-5.2"));
}
#[test]
fn openrouter_reasoning_effort_maps_project_aliases_to_router_enum() {
for (input, expected) in [
("auto", None),
("", None),
("none", Some("none")),
("min", Some("minimal")),
("minimum", Some("minimal")),
("minimal", Some("minimal")),
("low", Some("low")),
("medium", Some("medium")),
("high", Some("high")),
("max", Some("high")),
("maximum", Some("high")),
("xhigh", Some("xhigh")),
] {
assert_eq!(
map_openrouter_reasoning_effort(input).expect("known effort must map"),
expected
);
}
}
#[test]
fn openrouter_reasoning_effort_rejects_unknown_values() {
let error = map_openrouter_reasoning_effort("turbo")
.expect_err("unknown effort should fail locally");
assert!(
error
.to_string()
.contains("Unsupported OpenRouter reasoning_effort")
);
}
#[test]
fn zai_reasoning_effort_classifier_matches_glm52() {
assert!(is_zai_reasoning_effort_model(" glm-5.2 "));
assert!(!is_zai_reasoning_effort_model("glm-5.1"));
assert!(!is_zai_reasoning_effort_model("glm-5"));
}
#[test]
fn zai_reasoning_effort_maps_project_aliases_to_zai_enum() {
for (input, expected) in [
("auto", None),
("", None),
("none", Some("none")),
("min", Some("minimal")),
("minimum", Some("minimal")),
("minimal", Some("minimal")),
("low", Some("low")),
("medium", Some("medium")),
("high", Some("high")),
("max", Some("max")),
("maximum", Some("max")),
("xhigh", Some("xhigh")),
] {
assert_eq!(
map_zai_reasoning_effort("glm-5.2", input).expect("known effort must map"),
expected
);
}
}
#[test]
fn zai_reasoning_effort_rejects_unknown_values() {
let error = map_zai_reasoning_effort("glm-5.2", "turbo")
.expect_err("unknown effort should fail locally");
assert!(
error
.to_string()
.contains("Unsupported Z.AI reasoning_effort")
);
}
#[test]
fn zai_reasoning_effort_rejects_unsupported_models() {
let error =
map_zai_reasoning_effort("glm-5.1", "high").expect_err("unsupported model should fail");
assert!(
error
.to_string()
.contains("Z.AI reasoning_effort is only supported by glm-5.2")
);
}
#[test]
fn gemini_thinking_classifier_matches_supported_families() {
for model in [
"gemini-2.5-flash",
"gemini-2.5-flash-lite-preview-09-2025",
"gemini-2.5-pro-preview-06-05",
"gemini-3-pro-preview",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-3-pro-image-preview",
"gemini-3.1-flash-image-preview",
"gemini-3.5-flash",
] {
assert!(
is_gemini_thinking_config_model(model),
"{model} should support Gemini thinkingConfig"
);
}
for model in [
"gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview",
"gemma-3",
] {
assert!(
!is_gemini_thinking_config_model(model),
"{model} should not receive Gemini thinkingConfig"
);
}
}
#[test]
fn gemini_25_models_map_effort_to_budget_tokens() {
for (model, max_tokens, effort, expected) in [
(
"gemini-2.5-flash",
4000,
RequestedReasoningEffort::Medium,
1000,
),
(
"gemini-2.5-flash",
4000,
RequestedReasoningEffort::Minimal,
0,
),
(
"gemini-2.5-flash-lite",
4000,
RequestedReasoningEffort::Minimal,
512,
),
(
"gemini-2.5-pro",
8000,
RequestedReasoningEffort::Minimal,
128,
),
(
"gemini-2.5-pro",
8000,
RequestedReasoningEffort::XHigh,
8000,
),
("gemini-2.5-pro", 8000, RequestedReasoningEffort::Auto, -1),
] {
assert_eq!(
map_gemini_thinking_control(model, max_tokens, effort)
.expect("known Gemini effort should map"),
Some(GeminiThinkingControl::BudgetTokens(expected))
);
}
}
#[test]
fn gemini_25_flash_lite_auto_preserves_model_default() {
assert_eq!(
map_gemini_thinking_control(
"gemini-2.5-flash-lite",
4000,
RequestedReasoningEffort::Auto,
)
.expect("auto should be accepted"),
None
);
}
#[test]
fn gemini_3_models_map_effort_to_thinking_level() {
for (model, effort, expected) in [
(
"gemini-3-pro-preview",
RequestedReasoningEffort::Medium,
"low",
),
(
"gemini-3.1-pro-preview",
RequestedReasoningEffort::Medium,
"medium",
),
(
"gemini-3-flash-preview",
RequestedReasoningEffort::Minimal,
"minimal",
),
(
"gemini-3.1-flash-lite-preview",
RequestedReasoningEffort::Medium,
"medium",
),
("gemini-3.5-flash", RequestedReasoningEffort::Max, "high"),
("gemini-3.5-flash", RequestedReasoningEffort::XHigh, "high"),
] {
assert_eq!(
map_gemini_thinking_control(model, 8000, effort)
.expect("known Gemini effort should map"),
Some(GeminiThinkingControl::Level(expected))
);
}
}
#[test]
fn gemini_auto_omits_level_for_level_based_models() {
assert_eq!(
map_gemini_thinking_control(
"gemini-3.1-pro-preview",
8000,
RequestedReasoningEffort::Auto,
)
.expect("auto should be accepted"),
None
);
}
#[test]
fn gemini_rejects_none_reasoning_effort() {
let error = map_gemini_thinking_control(
"gemini-3-flash-preview",
8000,
RequestedReasoningEffort::None,
)
.expect_err("none should fail locally");
assert!(
error
.to_string()
.contains("Unsupported Gemini reasoning_effort")
);
}
}

View file

@ -2,6 +2,9 @@ use serde_json::{Map, Number, Value, json};
use crate::application::errors::ApplicationError;
use super::super::super::model_capabilities::{
RequestedReasoningEffort, parse_known_reasoning_effort, unsupported_reasoning_effort,
};
use super::super::shared::insert_if_present;
use super::contract::{ClaudeModelContract, ClaudeSamplingMode, ClaudeThinkingMode};
use super::messages::{
@ -15,70 +18,6 @@ use super::tools::{map_openai_tools_to_claude, map_tool_choice_to_claude};
const CLAUDE_THINKING_MIN_TOKENS: i64 = 1024;
const CLAUDE_THINKING_NON_STREAM_CAP: i64 = 21_333;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClaudeReasoningEffort {
Min,
Low,
Medium,
High,
Max,
XHigh,
}
impl ClaudeReasoningEffort {
fn parse(value: Option<&Value>) -> Result<Option<Self>, ApplicationError> {
let Some(value) = value.and_then(Value::as_str) else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("auto") {
return Ok(None);
}
match value.to_ascii_lowercase().as_str() {
"min" => Ok(Some(Self::Min)),
"low" => Ok(Some(Self::Low)),
"medium" => Ok(Some(Self::Medium)),
"high" => Ok(Some(Self::High)),
"max" | "maximum" => Ok(Some(Self::Max)),
"xhigh" => Ok(Some(Self::XHigh)),
other => Err(ApplicationError::ValidationError(format!(
"Unsupported Claude reasoning_effort: {other}"
))),
}
}
fn calculate_budget_tokens(self, max_tokens: i64, stream: bool) -> i64 {
let max_tokens = max_tokens.max(0);
let mut budget_tokens = match self {
Self::Min => CLAUDE_THINKING_MIN_TOKENS,
Self::Low => max_tokens.saturating_mul(10) / 100,
Self::Medium => max_tokens.saturating_mul(25) / 100,
Self::High => max_tokens.saturating_mul(50) / 100,
Self::Max | Self::XHigh => max_tokens.saturating_mul(95) / 100,
};
budget_tokens = budget_tokens.max(CLAUDE_THINKING_MIN_TOKENS);
if !stream {
budget_tokens = budget_tokens.min(CLAUDE_THINKING_NON_STREAM_CAP);
}
budget_tokens
}
fn as_output_effort(self, contract: ClaudeModelContract) -> &'static str {
match self {
Self::Min | Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
Self::XHigh if contract.supports_xhigh_output_effort => "xhigh",
Self::XHigh => "max",
}
}
}
pub(super) fn build_claude_payload(
payload: &Map<String, Value>,
) -> Result<Map<String, Value>, ApplicationError> {
@ -105,7 +44,7 @@ fn build_claude_payload_inner(
})?;
let contract = enforce_contract.then(|| ClaudeModelContract::resolve(model));
let reasoning_effort = if contract.is_some() {
ClaudeReasoningEffort::parse(payload.get("reasoning_effort"))?
parse_claude_reasoning_effort(payload.get("reasoning_effort"))?
} else {
None
};
@ -260,7 +199,7 @@ fn build_claude_payload_inner(
ClaudeThinkingMode::ManualOnly => {
if let Some(reasoning_effort) = reasoning_effort {
let budget_tokens =
reasoning_effort.calculate_budget_tokens(max_tokens, stream);
calculate_claude_budget_tokens(reasoning_effort, max_tokens, stream);
if max_tokens <= CLAUDE_THINKING_MIN_TOKENS {
max_tokens += CLAUDE_THINKING_MIN_TOKENS;
}
@ -279,7 +218,7 @@ fn build_claude_payload_inner(
request.insert(
"output_config".to_string(),
json!({
"effort": reasoning_effort.as_output_effort(contract),
"effort": claude_output_effort(reasoning_effort, contract),
}),
);
}
@ -295,7 +234,7 @@ fn build_claude_payload_inner(
request.insert(
"output_config".to_string(),
json!({
"effort": reasoning_effort.as_output_effort(contract),
"effort": claude_output_effort(reasoning_effort, contract),
}),
);
}
@ -313,6 +252,64 @@ fn build_claude_payload_inner(
Ok(request)
}
fn parse_claude_reasoning_effort(
value: Option<&Value>,
) -> Result<Option<RequestedReasoningEffort>, ApplicationError> {
let Some(raw) = value.and_then(Value::as_str) else {
return Ok(None);
};
match parse_known_reasoning_effort(raw, "Claude")? {
RequestedReasoningEffort::Auto => Ok(None),
RequestedReasoningEffort::None => Err(unsupported_reasoning_effort("Claude", raw)),
effort => Ok(Some(effort)),
}
}
fn calculate_claude_budget_tokens(
effort: RequestedReasoningEffort,
max_tokens: i64,
stream: bool,
) -> i64 {
let max_tokens = max_tokens.max(0);
let mut budget_tokens = match effort {
RequestedReasoningEffort::Minimal => CLAUDE_THINKING_MIN_TOKENS,
RequestedReasoningEffort::Low => max_tokens.saturating_mul(10) / 100,
RequestedReasoningEffort::Medium => max_tokens.saturating_mul(25) / 100,
RequestedReasoningEffort::High => max_tokens.saturating_mul(50) / 100,
RequestedReasoningEffort::Max | RequestedReasoningEffort::XHigh => {
max_tokens.saturating_mul(95) / 100
}
RequestedReasoningEffort::Auto | RequestedReasoningEffort::None => {
unreachable!("Claude reasoning parser excludes auto and none")
}
};
budget_tokens = budget_tokens.max(CLAUDE_THINKING_MIN_TOKENS);
if !stream {
budget_tokens = budget_tokens.min(CLAUDE_THINKING_NON_STREAM_CAP);
}
budget_tokens
}
fn claude_output_effort(
effort: RequestedReasoningEffort,
contract: ClaudeModelContract,
) -> &'static str {
match effort {
RequestedReasoningEffort::Minimal | RequestedReasoningEffort::Low => "low",
RequestedReasoningEffort::Medium => "medium",
RequestedReasoningEffort::High => "high",
RequestedReasoningEffort::Max => "max",
RequestedReasoningEffort::XHigh if contract.supports_xhigh_output_effort => "xhigh",
RequestedReasoningEffort::XHigh => "max",
RequestedReasoningEffort::Auto | RequestedReasoningEffort::None => {
unreachable!("Claude reasoning parser excludes auto and none")
}
}
}
fn insert_claude_sampling_params(
request: &mut Map<String, Value>,
payload: &Map<String, Value>,

View file

@ -51,6 +51,21 @@ fn claude_manual_reasoning_uses_legacy_thinking_and_clears_sampling() {
assert!(body.get("top_k").is_none());
}
#[test]
fn claude_reasoning_accepts_shared_minimal_alias() {
let mut payload = claude_payload("claude-sonnet-4-5");
payload.insert("max_tokens".to_string(), json!(4096));
payload.insert("reasoning_effort".to_string(), json!("minimal"));
let (_, upstream) = build(payload).expect("build should succeed");
assert_eq!(
upstream
.pointer("/thinking/budget_tokens")
.and_then(Value::as_i64),
Some(1024)
);
}
#[test]
fn claude_opus_4_5_uses_legacy_thinking_with_output_effort() {
let mut payload = claude_payload("claude-opus-4-5");

View file

@ -2,6 +2,9 @@ use serde_json::{Map, Value};
use crate::application::errors::ApplicationError;
use super::super::model_capabilities::{
RequestedReasoningEffort, parse_known_reasoning_effort, unsupported_reasoning_effort,
};
use super::openai;
use super::prompt_post_processing::{PromptNames, PromptProcessingType, post_process_prompt};
use super::tool_calls;
@ -87,13 +90,14 @@ fn resolve_thinking_mode(
}
fn normalize_reasoning_effort(value: &str) -> Result<Option<&'static str>, ApplicationError> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Ok(None),
"min" | "minimum" | "low" | "medium" | "high" => Ok(Some("high")),
"max" | "maximum" | "xhigh" => Ok(Some("max")),
other => Err(ApplicationError::ValidationError(format!(
"Unsupported DeepSeek reasoning_effort: {other}"
))),
match parse_known_reasoning_effort(value, "DeepSeek")? {
RequestedReasoningEffort::Auto => Ok(None),
RequestedReasoningEffort::None => Err(unsupported_reasoning_effort("DeepSeek", value)),
RequestedReasoningEffort::Minimal
| RequestedReasoningEffort::Low
| RequestedReasoningEffort::Medium
| RequestedReasoningEffort::High => Ok(Some("high")),
RequestedReasoningEffort::Max | RequestedReasoningEffort::XHigh => Ok(Some("max")),
}
}
@ -311,6 +315,26 @@ mod tests {
assert!(body.get("frequency_penalty").is_none());
}
#[test]
fn deepseek_reasoning_accepts_shared_minimal_alias() {
let payload = json!({
"model": "deepseek-v4-pro",
"messages": [{"role": "user", "content": "hello"}],
"include_reasoning": true,
"reasoning_effort": "minimal",
"chat_completion_source": "deepseek"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("payload should build");
assert_eq!(
upstream.get("reasoning_effort").and_then(Value::as_str),
Some("high")
);
}
#[test]
fn deepseek_v4_disables_thinking_without_reasoning_effort() {
let payload = json!({

View file

@ -4,15 +4,16 @@ use serde_json::{Map, Value, json};
use crate::application::errors::ApplicationError;
use super::super::model_capabilities::{
GeminiThinkingControl, RequestedReasoningEffort, is_gemini_thinking_config_model,
map_gemini_thinking_control, parse_known_reasoning_effort,
};
use super::shared::{message_content_to_text, parse_data_url};
use super::tool_calls::{
OpenAiToolCall, extract_openai_tool_calls, fallback_tool_name, message_tool_call_id,
message_tool_name, message_tool_result_text, normalize_tool_result_payload,
};
const GOOGLE_FLASH_MAX_BUDGET: i64 = 24_576;
const GOOGLE_PRO_MAX_BUDGET: i64 = 32_768;
const GOOGLE_IMAGE_GENERATION_MODELS: &[&str] = &[
"gemini-2.0-flash-exp",
"gemini-2.0-flash-exp-image-generation",
@ -209,7 +210,7 @@ fn build_google_payload(
}
}
inject_google_thinking_config(payload, model, use_vertex_ai, &mut generation_config);
inject_google_thinking_config(payload, model, use_vertex_ai, &mut generation_config)?;
let mut request = Map::new();
request.insert("model".to_string(), Value::String(model.to_string()));
@ -761,19 +762,20 @@ fn inject_google_thinking_config(
model: &str,
use_vertex_ai: bool,
generation_config: &mut Map<String, Value>,
) {
if !is_google_thinking_config_model(model) {
return;
) -> Result<(), ApplicationError> {
let reasoning_effort = match payload.get("reasoning_effort").and_then(Value::as_str) {
Some(value) => parse_known_reasoning_effort(value, "Gemini")?,
None => RequestedReasoningEffort::Auto,
};
if !is_gemini_thinking_config_model(model) {
return Ok(());
}
let include_reasoning = payload
.get("include_reasoning")
.and_then(Value::as_bool)
.unwrap_or(false);
let reasoning_effort = payload
.get("reasoning_effort")
.and_then(Value::as_str)
.unwrap_or("auto");
let max_output_tokens = generation_config
.get("maxOutputTokens")
.and_then(value_to_i64)
@ -782,10 +784,10 @@ fn inject_google_thinking_config(
let mut thinking_config = Map::new();
let mut include_thoughts = include_reasoning;
if let Some(budget) = calculate_google_budget_tokens(max_output_tokens, reasoning_effort, model)
if let Some(control) = map_gemini_thinking_control(model, max_output_tokens, reasoning_effort)?
{
match budget {
GoogleThinkingBudget::Tokens(tokens) => {
match control {
GeminiThinkingControl::BudgetTokens(tokens) => {
thinking_config.insert(
"thinkingBudget".to_string(),
Value::Number(serde_json::Number::from(tokens)),
@ -795,7 +797,7 @@ fn inject_google_thinking_config(
include_thoughts = false;
}
}
GoogleThinkingBudget::Level(level) => {
GeminiThinkingControl::Level(level) => {
thinking_config.insert(
"thinkingLevel".to_string(),
Value::String(level.to_string()),
@ -807,6 +809,7 @@ fn inject_google_thinking_config(
thinking_config.insert("includeThoughts".to_string(), Value::Bool(include_thoughts));
generation_config.insert("thinkingConfig".to_string(), Value::Object(thinking_config));
Ok(())
}
fn is_google_image_size_model(model: &str) -> bool {
@ -854,104 +857,6 @@ fn gemini_media_resolution(detail: &str) -> Option<&'static str> {
}
}
fn is_google_thinking_config_model(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
let is_gemini_25 = (model.starts_with("gemini-2.5-flash")
|| model.starts_with("gemini-2.5-pro"))
&& !model.ends_with("-image")
&& !model.ends_with("-image-preview");
let is_gemini_3 = model.starts_with("gemini-3-flash") || model.starts_with("gemini-3-pro");
is_gemini_25 || is_gemini_3
}
enum GoogleThinkingBudget {
Tokens(i64),
Level(&'static str),
}
fn calculate_google_budget_tokens(
max_tokens: i64,
reasoning_effort: &str,
model: &str,
) -> Option<GoogleThinkingBudget> {
let model = model.trim().to_ascii_lowercase();
let effort = reasoning_effort.trim().to_ascii_lowercase();
let max_tokens = max_tokens.max(0);
if model.contains("gemini-3-pro") {
let level = match effort.as_str() {
"auto" => return None,
"min" | "low" | "medium" => "low",
"high" | "max" | "xhigh" => "high",
_ => return None,
};
return Some(GoogleThinkingBudget::Level(level));
}
if model.contains("gemini-3-flash") {
let level = match effort.as_str() {
"auto" => return None,
"min" => "minimal",
"low" => "low",
"medium" => "medium",
"high" | "max" | "xhigh" => "high",
_ => return None,
};
return Some(GoogleThinkingBudget::Level(level));
}
if model.contains("flash-lite") {
let tokens = match effort.as_str() {
"auto" => return Some(GoogleThinkingBudget::Tokens(-1)),
"min" => 0,
"low" => max_tokens.saturating_mul(10) / 100,
"medium" => max_tokens.saturating_mul(25) / 100,
"high" => max_tokens.saturating_mul(50) / 100,
"max" | "xhigh" => max_tokens,
_ => return None,
};
return Some(GoogleThinkingBudget::Tokens(
tokens.clamp(512, GOOGLE_FLASH_MAX_BUDGET),
));
}
if model.contains("flash") {
let tokens = match effort.as_str() {
"auto" => return Some(GoogleThinkingBudget::Tokens(-1)),
"min" => 0,
"low" => max_tokens.saturating_mul(10) / 100,
"medium" => max_tokens.saturating_mul(25) / 100,
"high" => max_tokens.saturating_mul(50) / 100,
"max" | "xhigh" => max_tokens,
_ => return None,
};
return Some(GoogleThinkingBudget::Tokens(
tokens.clamp(0, GOOGLE_FLASH_MAX_BUDGET),
));
}
if model.contains("pro") {
let tokens = match effort.as_str() {
"auto" => return Some(GoogleThinkingBudget::Tokens(-1)),
"min" => 128,
"low" => max_tokens.saturating_mul(10) / 100,
"medium" => max_tokens.saturating_mul(25) / 100,
"high" => max_tokens.saturating_mul(50) / 100,
"max" | "xhigh" => max_tokens,
_ => return None,
};
return Some(GoogleThinkingBudget::Tokens(
tokens.clamp(128, GOOGLE_PRO_MAX_BUDGET),
));
}
None
}
fn value_to_i64(value: &Value) -> Option<i64> {
value
.as_i64()
@ -1004,6 +909,53 @@ mod tests {
);
}
#[test]
fn makersuite_25_flash_accepts_shared_minimal_alias() {
let payload = json!({
"model": "gemini-2.5-flash",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 4000,
"reasoning_effort": "minimal"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
assert_eq!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.and_then(Value::as_i64),
Some(0)
);
}
#[test]
fn makersuite_25_flash_lite_auto_omits_thinking_budget() {
let payload = json!({
"model": "gemini-2.5-flash-lite",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 4000,
"reasoning_effort": "auto"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
assert!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.is_none()
);
assert_eq!(
upstream
.pointer("/generationConfig/thinkingConfig/includeThoughts")
.and_then(Value::as_bool),
Some(false)
);
}
#[test]
fn makersuite_3_pro_sets_thinking_level() {
let payload = json!({
@ -1038,6 +990,58 @@ mod tests {
assert!(thinking.get("thinkingBudget").is_none());
}
#[test]
fn makersuite_31_pro_sets_medium_thinking_level() {
let payload = json!({
"model": "gemini-3.1-pro-preview",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 8000,
"reasoning_effort": "medium"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
assert_eq!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingLevel")
.and_then(Value::as_str),
Some("medium")
);
assert!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.is_none()
);
}
#[test]
fn makersuite_31_flash_lite_uses_level_not_budget() {
let payload = json!({
"model": "gemini-3.1-flash-lite-preview",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 8000,
"reasoning_effort": "medium"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
assert_eq!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingLevel")
.and_then(Value::as_str),
Some("medium")
);
assert!(
upstream
.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.is_none()
);
}
#[test]
fn makersuite_xhigh_behaves_like_max() {
let payload = json!({
@ -1059,6 +1063,25 @@ mod tests {
);
}
#[test]
fn makersuite_rejects_unknown_reasoning_effort() {
let payload = json!({
"model": "gemini-2.5-flash",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "turbo"
})
.as_object()
.cloned()
.expect("payload must be object");
let error = build(payload).expect_err("unknown effort should fail locally");
assert!(
error
.to_string()
.contains("Unsupported Gemini reasoning_effort")
);
}
#[test]
fn makersuite_image_model_does_not_set_thinking_config() {
let payload = json!({
@ -1082,6 +1105,41 @@ mod tests {
assert!(config.get("thinkingConfig").is_none());
}
#[test]
fn makersuite_3_image_model_sets_thinking_level() {
let payload = json!({
"model": "gemini-3-pro-image-preview",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 1024,
"reasoning_effort": "high",
"include_reasoning": true
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
let body = upstream.as_object().expect("body must be object");
let config = body
.get("generationConfig")
.and_then(Value::as_object)
.expect("generationConfig must be object");
let thinking = config
.get("thinkingConfig")
.and_then(Value::as_object)
.expect("thinkingConfig must be object");
assert_eq!(
thinking.get("thinkingLevel").and_then(Value::as_str),
Some("high")
);
assert!(thinking.get("thinkingBudget").is_none());
assert_eq!(
thinking.get("includeThoughts").and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn makersuite_tool_result_uses_previous_tool_call_name() {
let payload = json!({

Some files were not shown because too many files have changed in this diff Show more