feat(agent): apply run retention pruning from the history panel

This commit is contained in:
Darkatse 2026-06-14 14:00:36 +08:00
parent 6bdaa330e3
commit fe107710bb
23 changed files with 1039 additions and 91 deletions

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 run history listing、Agent run retention facade / dry-run plan preview,以及 Agent Profile 独立 preset / 独立 model 的 Frontend PromptAssemblyBroker 链路。Agent System 扩展开关开启时,普通发送、`/trigger`、regenerate 与 overswipe 新候选生成会通过 Legacy Generate 兼容桥启动 Agent;普通切换已有 swipe 候选不启动 Agent。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval 等未来设计。
状态:当前已实现 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,以及 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。
@ -36,6 +36,10 @@ type TauriTavernAgentApi = {
retention?: AgentRunRetentionSettings;
detailLimit?: number;
}): Promise<AgentRunPrunePlan>;
applyPrune(input?: {
retention?: AgentRunRetentionSettings;
detailLimit?: number;
}): Promise<AgentRunPruneApplyResult>;
};
profiles: {
list(): Promise<AgentListProfilesResult>;
@ -439,6 +443,10 @@ type AgentRunPruneBlockedRun = AgentRunPruneCandidate & {
message?: string;
};
type AgentRunPruneFailedRun = AgentRunPruneCandidate & {
message: string;
};
type AgentRunPrunePlan = {
retention: AgentRunRetentionSettings;
detailLimit: number;
@ -460,11 +468,24 @@ type AgentRunPrunePlan = {
candidateDetailsTruncated: boolean;
blockedDetailsTruncated: boolean;
};
type AgentRunPruneApplyResult = {
retention: AgentRunRetentionSettings;
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 形态返回。`retention.updateSettings()` 只保存策略,不触发清理;两个数量必须是 `0..10000` 的整数,且 `keepFullRecentRuns <= keepRecentTerminalRuns`。
`retention.planPrune()` 是前端访问 `plan_agent_run_prune(dto)` 的 facade。它只返回 dry-run plan,可使用当前设置,也可传入一次性 `retention` override;`detailLimit` 只限制返回明细,不影响 totals。当前没有公共 apply prune Host ABI,UI 不应把 plan preview 表达为已经删除文件。
`retention.planPrune()` 是前端访问 `plan_agent_run_prune(dto)` 的 facade。它只返回 dry-run plan,可使用当前设置,也可传入一次性 `retention` override;`detailLimit` 只限制返回明细,不影响 totals。`retention.applyPrune()` 访问 `apply_agent_run_prune(dto)`,后端会用同一 retention 重新规划全量候选再执行,不信任前端预览列表;同一服务实例内 apply 会串行执行,避免并发清理同一批 run 造成假失败。单个 run 执行失败会进入 `failedRuns` 并继续处理后续 candidate,结构性规划错误仍 fail-fast。结果包含 caller `detailLimit` 下的 `afterPlan`,供 UI 展示清理后的事实状态。
## 13. profiles / promptAssembly / tools
@ -669,6 +690,7 @@ 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)
@ -676,6 +698,8 @@ resolve_agent_chat_commit(dto)
`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

View file

@ -400,9 +400,10 @@ rollbackCommittedMessage(runId, checkpointId)
- `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()` 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。
- dry-run 使用 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 范围。
- 前端只通过 `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 由上述两个窗口推导动作:

View file

@ -25,7 +25,7 @@
- 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。前端实现将 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`:`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()` facade,Agent System Runs 面板可保存策略并显示 dry-run 清理预览。后端已提供只读 `plan_agent_run_prune(dto)` command,用当前设置或一次性 override 生成 `slim_heavy_artifacts` / `delete_run` 候选与 files/bytes 统计;`slim_heavy_artifacts` 基于 Agent run storage class,路径归属词汇与 TT-Sync Agent run datasets 对齐,但 retention 不依赖同步 profile。明细受 `detailLimit` 限制但 totals 不截断,active run、缺失 terminal event、journal/storage 异常会进入 `blockedRuns`。当前不会自动触发 run prune,也不执行删除。
- Agent run retention policy 已进入 `tauritavern-settings.agent.retention`:`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 模式,在后端重新规划全量候选,不信任前端预览明细,且同一服务实例内串行执行。`slim_heavy_artifacts` 基于 Agent run storage class,路径归属词汇与 TT-Sync Agent run datasets 对齐,但 retention 不依赖同步 profile。明细受 `detailLimit` 限制但 totals 不截断,active run、缺失 terminal event、journal/storage 异常会进入 `blockedRuns`;单个执行失败会进入 `failedRuns`。当前不会自动触发 run prune。
- 输入框 Agent 快捷开关 `#ttas_agent_send_toggle` 可以在 Agent System 扩展抽屉中隐藏;该状态只是扩展 UI 偏好,不改变 `agentModeEnabled`、生成路由、Agent Host ABI、journal 或 Rust runtime。
## 当前 Host ABI
@ -41,6 +41,7 @@ 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)

View file

@ -308,6 +308,15 @@ pub struct AgentPlanRunPruneDto {
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 {
@ -372,6 +381,38 @@ pub struct AgentRunPruneBlockedRunDto {
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 {

View file

@ -1,17 +1,21 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::application::dto::agent_dto::{
AgentListRunsCursorDto, AgentListRunsDto, AgentListRunsResultDto, AgentPlanRunPruneDto,
AgentRunCommittedMessageDto, AgentRunPruneActionDto, AgentRunPruneBlockReasonDto,
AgentRunPruneBlockedRunDto, AgentRunPruneCandidateDto, AgentRunPrunePlanDto,
AgentApplyRunPruneDto, AgentListRunsCursorDto, AgentListRunsDto, AgentListRunsResultDto,
AgentPlanRunPruneDto, AgentRunCommittedMessageDto, AgentRunPruneActionDto,
AgentRunPruneApplyResultDto, AgentRunPruneBlockReasonDto, AgentRunPruneBlockedRunDto,
AgentRunPruneCandidateDto, AgentRunPruneFailedRunDto, AgentRunPrunePlanDto,
AgentRunPruneReasonDto, AgentRunPruneRetentionDto, AgentRunSummaryDto,
};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_run_retention_planner::{
AgentRunPruneAction, AgentRunPruneBlockReason, AgentRunPruneBlockedRun, AgentRunPruneCandidate,
AgentRunPruneReason, AgentRunRetentionPlan, AgentRunRetentionPlanInput,
AgentRunRetentionPlanner, MAX_AGENT_RUN_PRUNE_DETAIL_LIMIT, is_terminal_run_event,
AgentRunPruneReason, AgentRunRetentionPlan, AgentRunRetentionPlanDetailMode,
AgentRunRetentionPlanInput, AgentRunRetentionPlanner, MAX_AGENT_RUN_PRUNE_DETAIL_LIMIT,
is_terminal_run_event,
};
use crate::application::services::agent_workspace_lifecycle_service::AgentRunActivity;
use crate::domain::models::agent::{
@ -20,7 +24,7 @@ use crate::domain::models::agent::{
};
use crate::domain::models::settings::AgentRunRetentionSettings;
use crate::domain::repositories::agent_run_repository::{
AgentRunListCursor, AgentRunListQuery, AgentRunRepository,
AgentRunListCursor, AgentRunListQuery, AgentRunRepository, AgentRunStorageEntryStats,
};
use crate::domain::repositories::settings_repository::SettingsRepository;
@ -30,6 +34,7 @@ pub struct AgentRunHistoryService {
run_repository: Arc<dyn AgentRunRepository>,
settings_repository: Arc<dyn SettingsRepository>,
run_activity: Arc<dyn AgentRunActivity>,
run_prune_apply_lock: Mutex<()>,
}
impl AgentRunHistoryService {
@ -42,6 +47,7 @@ impl AgentRunHistoryService {
run_repository,
settings_repository,
run_activity,
run_prune_apply_lock: Mutex::new(()),
}
}
@ -96,22 +102,88 @@ impl AgentRunHistoryService {
) -> Result<AgentRunPrunePlanDto, ApplicationError> {
let retention = self.resolve_prune_retention(dto.retention).await?;
let detail_limit = normalize_prune_detail_limit(dto.detail_limit)?;
let active_run_ids = self
.run_activity
.active_run_ids()
.await?
.into_iter()
.collect::<BTreeSet<_>>();
let planner = AgentRunRetentionPlanner::new(self.run_repository.as_ref());
let plan = planner
.plan(AgentRunRetentionPlanInput {
retention,
detail_limit,
active_run_ids,
})
let plan = self
.build_preview_run_prune_plan(retention, detail_limit)
.await?;
Ok(AgentRunPrunePlanDto::from_plan(plan))
Ok(AgentRunPrunePlanDto::from_preview_plan(plan, detail_limit))
}
pub async fn apply_run_prune(
&self,
dto: AgentApplyRunPruneDto,
) -> Result<AgentRunPruneApplyResultDto, ApplicationError> {
let detail_limit = normalize_prune_detail_limit(dto.detail_limit)?;
let _guard = self.run_prune_apply_lock.lock().await;
let retention = self.resolve_prune_retention(dto.retention).await?;
let retention_dto = AgentRunPruneRetentionDto::from(retention.clone());
let execution_plan = self
.build_execution_run_prune_plan(retention.clone())
.await?;
let mut slimmed_run_count = 0;
let mut deleted_run_count = 0;
let mut failed_run_count = 0;
let mut removed_file_count = 0;
let mut removed_byte_count = 0;
let mut failed_details_truncated = false;
let mut failed_runs = Vec::new();
for candidate in execution_plan.candidates {
let result = match candidate.action {
AgentRunPruneAction::SlimHeavyArtifacts => {
self.run_repository
.slim_run_heavy_artifacts(&candidate.run)
.await
}
AgentRunPruneAction::DeleteRun => {
self.run_repository.delete_run(&candidate.run).await
}
};
match result {
Ok(stats) => {
match candidate.action {
AgentRunPruneAction::SlimHeavyArtifacts => slimmed_run_count += 1,
AgentRunPruneAction::DeleteRun => deleted_run_count += 1,
}
accumulate_apply_stats(
&mut removed_file_count,
&mut removed_byte_count,
stats,
)?;
}
Err(error) => {
failed_run_count += 1;
if failed_runs.len() < detail_limit {
failed_runs.push(AgentRunPruneFailedRunDto::from_candidate(
candidate,
error.to_string(),
));
} else {
failed_details_truncated = true;
}
}
}
}
let after_plan = self
.build_preview_run_prune_plan(retention, detail_limit)
.await
.map(|plan| AgentRunPrunePlanDto::from_preview_plan(plan, detail_limit))?;
Ok(AgentRunPruneApplyResultDto {
retention: retention_dto,
detail_limit,
slimmed_run_count,
deleted_run_count,
failed_run_count,
removed_file_count,
removed_byte_count,
failed_details_truncated,
failed_runs,
after_plan,
})
}
async fn resolve_prune_retention(
@ -135,6 +207,51 @@ impl AgentRunHistoryService {
Ok(retention)
}
async fn build_preview_run_prune_plan(
&self,
retention: AgentRunRetentionSettings,
detail_limit: usize,
) -> Result<AgentRunRetentionPlan, ApplicationError> {
self.build_run_prune_plan(
retention,
AgentRunRetentionPlanDetailMode::Preview { detail_limit },
)
.await
}
async fn build_execution_run_prune_plan(
&self,
retention: AgentRunRetentionSettings,
) -> Result<AgentRunRetentionPlan, ApplicationError> {
self.build_run_prune_plan(retention, AgentRunRetentionPlanDetailMode::Execution)
.await
}
async fn build_run_prune_plan(
&self,
retention: AgentRunRetentionSettings,
detail_mode: AgentRunRetentionPlanDetailMode,
) -> Result<AgentRunRetentionPlan, ApplicationError> {
let active_run_ids = self.active_run_id_set().await?;
let planner = AgentRunRetentionPlanner::new(self.run_repository.as_ref());
planner
.plan(AgentRunRetentionPlanInput {
retention,
detail_mode,
active_run_ids,
})
.await
}
async fn active_run_id_set(&self) -> Result<BTreeSet<String>, ApplicationError> {
Ok(self
.run_activity
.active_run_ids()
.await?
.into_iter()
.collect())
}
async fn summary_projection_for_run(
&self,
run: &AgentRun,
@ -169,10 +286,10 @@ impl From<AgentRunRetentionSettings> for AgentRunPruneRetentionDto {
}
impl AgentRunPrunePlanDto {
fn from_plan(plan: AgentRunRetentionPlan) -> Self {
fn from_preview_plan(plan: AgentRunRetentionPlan, detail_limit: usize) -> Self {
Self {
retention: AgentRunPruneRetentionDto::from(plan.retention),
detail_limit: plan.detail_limit,
detail_limit,
terminal_run_count: plan.terminal_run_count,
non_terminal_run_count: plan.non_terminal_run_count,
blocked_run_count: plan.blocked_run_count,
@ -238,6 +355,25 @@ impl AgentRunPruneBlockedRunDto {
}
}
impl AgentRunPruneFailedRunDto {
fn from_candidate(candidate: AgentRunPruneCandidate, message: String) -> Self {
Self {
run_id: candidate.run.id,
workspace_id: candidate.run.workspace_id,
stable_chat_id: candidate.run.stable_chat_id,
chat_ref: candidate.run.chat_ref,
status: candidate.run.status,
created_at: candidate.run.created_at,
updated_at: candidate.run.updated_at,
action: AgentRunPruneActionDto::from(candidate.action),
reason: AgentRunPruneReasonDto::from(candidate.reason),
file_count: candidate.stats.file_count,
byte_count: candidate.stats.byte_count,
message,
}
}
}
impl From<AgentRunPruneAction> for AgentRunPruneActionDto {
fn from(action: AgentRunPruneAction) -> Self {
match action {
@ -403,6 +539,20 @@ fn normalize_prune_detail_limit(limit: usize) -> Result<usize, ApplicationError>
Ok(limit)
}
fn accumulate_apply_stats(
file_count: &mut usize,
byte_count: &mut u64,
stats: AgentRunStorageEntryStats,
) -> Result<(), ApplicationError> {
*file_count = file_count.checked_add(stats.file_count).ok_or_else(|| {
ApplicationError::InternalError("agent.run_prune_file_count_overflow".to_string())
})?;
*byte_count = byte_count.checked_add(stats.byte_count).ok_or_else(|| {
ApplicationError::InternalError("agent.run_prune_byte_count_overflow".to_string())
})?;
Ok(())
}
fn normalize_cursor(
cursor: AgentListRunsCursorDto,
) -> Result<AgentRunListCursor, ApplicationError> {
@ -862,4 +1012,174 @@ mod tests {
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
#[tokio::test]
async fn apply_run_prune_executes_all_candidates_when_detail_limit_truncates() {
let agent_root = temp_root("agent-apply-detail-limit");
let settings_root = temp_root("settings-apply-detail-limit");
let run_repository = Arc::new(FileAgentRepository::new(agent_root.clone()));
let settings_repository = Arc::new(FileSettingsRepository::new(settings_root.clone()));
for index in 0..3 {
let run = run_with_id(
&format!("run_prune_apply_detail_limit_{index}"),
instant(&format!("2026-01-0{}T00:00:00Z", index + 1)),
AgentRunStatus::Completed,
);
run_repository.create_run(&run).await.expect("create run");
append_terminal_event(&run_repository, &run).await;
seed_heavy_file(&agent_root, &run, b"heavy").await;
}
let service = AgentRunHistoryService::new(
run_repository,
settings_repository,
TestRunActivity::none(),
);
let result = service
.apply_run_prune(AgentApplyRunPruneDto {
retention: Some(AgentRunPruneRetentionDto {
keep_recent_terminal_runs: 0,
keep_full_recent_runs: 0,
}),
detail_limit: 1,
})
.await
.expect("apply prune");
assert_eq!(result.deleted_run_count, 3);
assert_eq!(result.failed_run_count, 0);
assert!(result.removed_file_count >= 3);
assert_eq!(result.after_plan.delete_candidate_count, 0);
assert_eq!(result.after_plan.candidates.len(), 0);
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
#[tokio::test]
async fn apply_run_prune_serializes_concurrent_calls_without_false_failures() {
let agent_root = temp_root("agent-apply-concurrent");
let settings_root = temp_root("settings-apply-concurrent");
let run_repository = Arc::new(FileAgentRepository::new(agent_root.clone()));
let settings_repository = Arc::new(FileSettingsRepository::new(settings_root.clone()));
let run = run_with_id(
"run_prune_apply_concurrent",
instant("2026-01-04T00:00:00Z"),
AgentRunStatus::Completed,
);
run_repository.create_run(&run).await.expect("create run");
append_terminal_event(&run_repository, &run).await;
seed_heavy_file(&agent_root, &run, b"heavy").await;
let service = Arc::new(AgentRunHistoryService::new(
run_repository,
settings_repository,
TestRunActivity::none(),
));
let dto = AgentApplyRunPruneDto {
retention: Some(AgentRunPruneRetentionDto {
keep_recent_terminal_runs: 0,
keep_full_recent_runs: 0,
}),
detail_limit: 8,
};
let left_service = service.clone();
let right_service = service.clone();
let left_dto = dto.clone();
let (left, right) = tokio::join!(
async move { left_service.apply_run_prune(left_dto).await },
async move { right_service.apply_run_prune(dto).await },
);
let left = left.expect("left apply prune");
let right = right.expect("right apply prune");
assert_eq!(left.failed_run_count + right.failed_run_count, 0);
assert_eq!(left.deleted_run_count + right.deleted_run_count, 1);
assert_eq!(left.after_plan.delete_candidate_count, 0);
assert_eq!(right.after_plan.delete_candidate_count, 0);
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
#[tokio::test]
async fn apply_run_prune_slims_core_window_and_deletes_history_window() {
let agent_root = temp_root("agent-apply-windows");
let settings_root = temp_root("settings-apply-windows");
let run_repository = Arc::new(FileAgentRepository::new(agent_root.clone()));
let settings_repository = Arc::new(FileSettingsRepository::new(settings_root.clone()));
let newest = run_with_id(
"run_prune_apply_newest",
instant("2026-01-04T00:00:00Z"),
AgentRunStatus::Completed,
);
let middle = run_with_id(
"run_prune_apply_middle",
instant("2026-01-03T00:00:00Z"),
AgentRunStatus::Failed,
);
let oldest = run_with_id(
"run_prune_apply_oldest",
instant("2026-01-02T00:00:00Z"),
AgentRunStatus::Cancelled,
);
for run in [&newest, &middle, &oldest] {
run_repository.create_run(run).await.expect("create run");
append_terminal_event(&run_repository, run).await;
seed_heavy_file(&agent_root, run, b"heavy").await;
}
let service = AgentRunHistoryService::new(
run_repository,
settings_repository,
TestRunActivity::none(),
);
let result = service
.apply_run_prune(AgentApplyRunPruneDto {
retention: Some(AgentRunPruneRetentionDto {
keep_recent_terminal_runs: 2,
keep_full_recent_runs: 1,
}),
detail_limit: 8,
})
.await
.expect("apply prune");
let run_dir = |run: &AgentRun| {
agent_root
.join("chats")
.join(&run.workspace_id)
.join("runs")
.join(&run.id)
};
assert_eq!(result.slimmed_run_count, 1);
assert_eq!(result.deleted_run_count, 1);
assert_eq!(result.failed_run_count, 0);
assert_eq!(result.after_plan.slim_candidate_count, 0);
assert_eq!(result.after_plan.delete_candidate_count, 0);
assert!(
run_dir(&newest)
.join("input")
.join("prompt_snapshot.json")
.exists()
);
assert!(run_dir(&middle).join("run.json").exists());
assert!(run_dir(&middle).join("events.jsonl").exists());
assert!(
!run_dir(&middle)
.join("input")
.join("prompt_snapshot.json")
.exists()
);
assert!(!run_dir(&oldest).exists());
assert!(
!agent_root
.join("index/runs/run_prune_apply_oldest.json")
.exists()
);
let _ = fs::remove_dir_all(agent_root).await;
let _ = fs::remove_dir_all(settings_root).await;
}
}

View file

@ -11,14 +11,13 @@ pub const MAX_AGENT_RUN_PRUNE_DETAIL_LIMIT: usize = 1_000;
pub struct AgentRunRetentionPlanInput {
pub retention: AgentRunRetentionSettings,
pub detail_limit: usize,
pub detail_mode: AgentRunRetentionPlanDetailMode,
pub active_run_ids: BTreeSet<String>,
}
#[derive(Debug)]
pub struct AgentRunRetentionPlan {
pub retention: AgentRunRetentionSettings,
pub detail_limit: usize,
pub terminal_run_count: usize,
pub non_terminal_run_count: usize,
pub blocked_run_count: usize,
@ -33,6 +32,7 @@ pub struct AgentRunRetentionPlan {
pub candidates: Vec<AgentRunPruneCandidate>,
pub blocked_details_truncated: bool,
pub blocked_runs: Vec<AgentRunPruneBlockedRun>,
detail_mode: AgentRunRetentionPlanDetailMode,
}
#[derive(Debug)]
@ -72,6 +72,12 @@ pub enum AgentRunPruneBlockReason {
InvalidStorage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunRetentionPlanDetailMode {
Preview { detail_limit: usize },
Execution,
}
pub struct AgentRunRetentionPlanner<'a> {
run_repository: &'a dyn AgentRunRepository,
}
@ -95,7 +101,7 @@ impl<'a> AgentRunRetentionPlanner<'a> {
let runs = self.run_repository.list_all_runs().await?;
let mut terminal_runs = Vec::new();
let mut plan = AgentRunRetentionPlan::new(input.retention, input.detail_limit);
let mut plan = AgentRunRetentionPlan::new(input.retention, input.detail_mode);
for run in runs {
if run.status.is_terminal() {
terminal_runs.push(run);
@ -278,10 +284,12 @@ impl<'a> AgentRunRetentionPlanner<'a> {
}
impl AgentRunRetentionPlan {
fn new(retention: AgentRunRetentionSettings, detail_limit: usize) -> Self {
fn new(
retention: AgentRunRetentionSettings,
detail_mode: AgentRunRetentionPlanDetailMode,
) -> Self {
Self {
retention,
detail_limit,
terminal_run_count: 0,
non_terminal_run_count: 0,
blocked_run_count: 0,
@ -296,6 +304,7 @@ impl AgentRunRetentionPlan {
candidates: Vec::new(),
blocked_details_truncated: false,
blocked_runs: Vec::new(),
detail_mode,
}
}
@ -314,20 +323,32 @@ impl AgentRunRetentionPlan {
}
}
if self.candidates.len() < self.detail_limit {
self.candidates.push(candidate);
} else {
self.candidate_details_truncated = true;
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;
if self.blocked_runs.len() < self.detail_limit {
self.blocked_runs.push(blocked);
} else {
self.blocked_details_truncated = true;
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 => {}
}
}
}

View file

@ -58,6 +58,13 @@ pub trait AgentRunRepository: Send + Sync {
run: &AgentRun,
) -> Result<AgentRunStorageStats, DomainError>;
async fn slim_run_heavy_artifacts(
&self,
run: &AgentRun,
) -> Result<AgentRunStorageEntryStats, DomainError>;
async fn delete_run(&self, run: &AgentRun) -> Result<AgentRunStorageEntryStats, DomainError>;
async fn load_run_summary_projection(
&self,
run_id: &str,

View file

@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use tokio::fs;
@ -17,18 +17,74 @@ impl FileAgentRepository {
let run_dir = self.run_dir(run)?;
let mut stats = AgentRunStorageStats::default();
inspect_run_dir(&run_dir, &mut stats).await?;
add_run_scan_stats(scan_run_storage(&run_dir).await?, &mut stats)?;
add_required_index_file(&self.index_run_path(&run.id)?, &mut stats).await?;
add_optional_index_file(&self.index_run_summary_path(&run.id)?, &mut stats).await?;
Ok(stats)
}
pub(super) async fn slim_run_heavy_artifacts(
&self,
run: &AgentRun,
) -> Result<AgentRunStorageEntryStats, DomainError> {
let run_dir = self.run_dir(run)?;
let scan = scan_run_storage(&run_dir).await?;
let mut removed = AgentRunStorageEntryStats::default();
for file in scan.files {
if !file.storage_class.is_slim_artifact() {
continue;
}
fs::remove_file(&file.path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to delete agent run artifact {}: {}",
file.path.display(),
error
))
})?;
add_file(&mut removed, file.bytes, &file.path)?;
}
remove_empty_run_dirs(&run_dir, scan.dirs).await?;
Ok(removed)
}
pub(super) async fn delete_run(
&self,
run: &AgentRun,
) -> Result<AgentRunStorageEntryStats, DomainError> {
let stats = self.inspect_run_storage(run).await?.total;
let run_dir = self.run_dir(run)?;
fs::remove_dir_all(&run_dir).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to delete agent run workspace {}: {}",
run_dir.display(),
error
))
})?;
remove_index_file_if_exists(&self.index_run_path(&run.id)?, "agent run index").await?;
remove_index_file_if_exists(&self.index_run_summary_path(&run.id)?, "agent run summary")
.await?;
Ok(stats)
}
}
async fn inspect_run_dir(
run_dir: &Path,
stats: &mut AgentRunStorageStats,
) -> Result<(), DomainError> {
struct AgentRunStorageScan {
files: Vec<AgentRunStorageFile>,
dirs: Vec<PathBuf>,
}
struct AgentRunStorageFile {
path: PathBuf,
storage_class: AgentRunStorageClass,
bytes: u64,
}
async fn scan_run_storage(run_dir: &Path) -> Result<AgentRunStorageScan, DomainError> {
let metadata = fs::symlink_metadata(run_dir).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
DomainError::InvalidData(format!(
@ -50,6 +106,8 @@ async fn inspect_run_dir(
)));
}
let mut files = Vec::new();
let mut dirs = Vec::new();
let mut pending = vec![(run_dir.to_path_buf(), String::new())];
while let Some((dir, relative_dir)) = pending.pop() {
let mut entries = fs::read_dir(&dir).await.map_err(|error| {
@ -105,13 +163,68 @@ async fn inspect_run_dir(
}
let storage_class = AgentRunStorageClass::from_run_relative_path(&relative_path);
add_file_for_class(stats, storage_class, metadata.len(), &path)?;
files.push(AgentRunStorageFile {
path,
storage_class,
bytes: metadata.len(),
});
}
if dir != run_dir {
dirs.push(dir);
}
}
Ok(AgentRunStorageScan { files, dirs })
}
fn add_run_scan_stats(
scan: AgentRunStorageScan,
stats: &mut AgentRunStorageStats,
) -> Result<(), DomainError> {
for file in scan.files {
add_file_for_class(stats, file.storage_class, file.bytes, &file.path)?;
}
Ok(())
}
async fn remove_empty_run_dirs(run_dir: &Path, mut dirs: Vec<PathBuf>) -> Result<(), DomainError> {
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
for dir in dirs {
if dir == run_dir {
continue;
}
match fs::remove_dir(&dir).await {
Ok(()) => {}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
) => {}
Err(error) => {
return Err(DomainError::InternalError(format!(
"Failed to remove empty agent run artifact directory {}: {}",
dir.display(),
error
)));
}
}
}
Ok(())
}
async fn remove_index_file_if_exists(path: &Path, label: &str) -> Result<(), DomainError> {
match fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(DomainError::InternalError(format!(
"Failed to delete {label} {}: {}",
path.display(),
error
))),
}
}
async fn add_required_index_file(
path: &Path,
stats: &mut AgentRunStorageStats,

View file

@ -13,7 +13,7 @@ use crate::domain::models::agent::{
};
use crate::domain::repositories::agent_run_repository::{
AgentRunEventReadQuery, AgentRunListCursor, AgentRunListQuery, AgentRunRepository,
AgentRunStorageStats, event_belongs_to_invocation,
AgentRunStorageEntryStats, AgentRunStorageStats, event_belongs_to_invocation,
};
#[async_trait]
@ -57,6 +57,17 @@ impl AgentRunRepository for FileAgentRepository {
FileAgentRepository::inspect_run_storage(self, run).await
}
async fn slim_run_heavy_artifacts(
&self,
run: &AgentRun,
) -> Result<AgentRunStorageEntryStats, DomainError> {
FileAgentRepository::slim_run_heavy_artifacts(self, run).await
}
async fn delete_run(&self, run: &AgentRun) -> Result<AgentRunStorageEntryStats, DomainError> {
FileAgentRepository::delete_run(self, run).await
}
async fn load_run_summary_projection(
&self,
run_id: &str,

View file

@ -560,6 +560,126 @@ async fn inspect_run_storage_counts_total_and_heavy_artifacts() {
fs::remove_dir_all(root).await.expect("cleanup");
}
#[tokio::test]
async fn slim_run_heavy_artifacts_removes_only_non_core_run_files() {
let root = temp_root();
let repository = FileAgentRepository::new(root.clone());
let run = sample_run_with_id("run_prune_slim");
repository.create_run(&run).await.expect("create run");
repository
.append_event(
&run.id,
AgentRunEventLevel::Info,
"run_completed",
Value::Null,
)
.await
.expect("append event");
let run_dir = root
.join("chats")
.join(&run.workspace_id)
.join("runs")
.join(&run.id);
fs::write(run_dir.join("manifest.json"), b"{}")
.await
.expect("write manifest");
fs::create_dir_all(run_dir.join("input"))
.await
.expect("create input");
fs::write(run_dir.join("input").join("prompt_snapshot.json"), b"12345")
.await
.expect("write prompt snapshot");
fs::create_dir_all(run_dir.join("output"))
.await
.expect("create output");
fs::write(run_dir.join("output").join("main.md"), b"hi")
.await
.expect("write output");
repository
.save_run_summary_projection(&AgentRunSummaryProjection {
schema_version: AGENT_RUN_SUMMARY_PROJECTION_SCHEMA_VERSION,
run_id: run.id.clone(),
source_run_updated_at: run.updated_at,
commit_count: 0,
committed_message: None,
terminal_at: Some(run.updated_at),
})
.await
.expect("save summary");
let removed = repository
.slim_run_heavy_artifacts(&run)
.await
.expect("slim heavy artifacts");
assert_eq!(removed.file_count, 3);
assert_eq!(removed.byte_count, 9);
assert!(run_dir.join("run.json").exists());
assert!(run_dir.join("events.jsonl").exists());
assert!(root.join("index/runs/run_prune_slim.json").exists());
assert!(
root.join("index/run-summaries/run_prune_slim.json")
.exists()
);
assert!(!run_dir.join("manifest.json").exists());
assert!(!run_dir.join("input").exists());
assert!(!run_dir.join("output").exists());
fs::remove_dir_all(root).await.expect("cleanup");
}
#[tokio::test]
async fn delete_run_removes_workspace_index_and_summary_projection() {
let root = temp_root();
let repository = FileAgentRepository::new(root.clone());
let run = sample_run_with_id("run_prune_delete");
repository.create_run(&run).await.expect("create run");
repository
.append_event(&run.id, AgentRunEventLevel::Info, "run_failed", Value::Null)
.await
.expect("append event");
repository
.save_run_summary_projection(&AgentRunSummaryProjection {
schema_version: AGENT_RUN_SUMMARY_PROJECTION_SCHEMA_VERSION,
run_id: run.id.clone(),
source_run_updated_at: run.updated_at,
commit_count: 0,
committed_message: None,
terminal_at: Some(run.updated_at),
})
.await
.expect("save summary");
let run_dir = root
.join("chats")
.join(&run.workspace_id)
.join("runs")
.join(&run.id);
fs::create_dir_all(run_dir.join("input"))
.await
.expect("create input");
fs::write(
run_dir.join("input").join("prompt_snapshot.json"),
b"payload",
)
.await
.expect("write prompt snapshot");
let removed = repository.delete_run(&run).await.expect("delete run");
assert!(removed.file_count >= 5);
assert!(!run_dir.exists());
assert!(!root.join("index/runs/run_prune_delete.json").exists());
assert!(
!root
.join("index/run-summaries/run_prune_delete.json")
.exists()
);
fs::remove_dir_all(root).await.expect("cleanup");
}
#[tokio::test]
async fn list_runs_rejects_index_file_with_mismatched_run_id() {
let root = temp_root();

View file

@ -7,17 +7,17 @@ use tauri::State;
use crate::app::AppState;
use crate::application::dto::agent_dto::{
AgentCancelRunDto, AgentListProfilesResultDto, AgentListRunsDto, AgentListRunsResultDto,
AgentListToolSpecsResultDto, AgentLoadProfileResultDto, AgentModelTurnDisplayDto,
AgentPlanRunPruneDto, AgentPreparePromptAssemblyDto, AgentPreparePromptAssemblyResultDto,
AgentProfileIdDto, AgentPromptAssemblyBrokerRequestDto, AgentPruneChatPersistentStatesDto,
AgentPruneChatPersistentStatesResultDto, AgentReadEventsDto, AgentReadEventsResultDto,
AgentReadModelTurnDto, AgentReadPromptAssemblyRequestDto, AgentReadWorkspaceFileDto,
AgentRepairProfileFileDto, AgentResolveChatCommitDto,
AgentApplyRunPruneDto, AgentCancelRunDto, AgentListProfilesResultDto, AgentListRunsDto,
AgentListRunsResultDto, AgentListToolSpecsResultDto, AgentLoadProfileResultDto,
AgentModelTurnDisplayDto, AgentPlanRunPruneDto, AgentPreparePromptAssemblyDto,
AgentPreparePromptAssemblyResultDto, AgentProfileIdDto, AgentPromptAssemblyBrokerRequestDto,
AgentPruneChatPersistentStatesDto, AgentPruneChatPersistentStatesResultDto, AgentReadEventsDto,
AgentReadEventsResultDto, AgentReadModelTurnDto, AgentReadPromptAssemblyRequestDto,
AgentReadWorkspaceFileDto, AgentRepairProfileFileDto, AgentResolveChatCommitDto,
AgentResolvePersistentStateMetadataUpdateDto, AgentResolvePromptAssemblyDto,
AgentResolveSystemPromptDto, AgentResolveSystemPromptResultDto, AgentRetargetPresetRefsDto,
AgentRetargetPresetRefsResultDto, AgentRunHandleDto, AgentRunPrunePlanDto, AgentSaveProfileDto,
AgentStartRunDto, AgentWorkspaceFileDto,
AgentRetargetPresetRefsResultDto, AgentRunHandleDto, AgentRunPruneApplyResultDto,
AgentRunPrunePlanDto, AgentSaveProfileDto, AgentStartRunDto, AgentWorkspaceFileDto,
};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_workspace_lifecycle_service::AgentChatWorkspaceTarget;
@ -261,6 +261,20 @@ pub async fn plan_agent_run_prune(
.map_err(map_command_error("Failed to plan agent run prune"))
}
#[tauri::command]
pub async fn apply_agent_run_prune(
dto: AgentApplyRunPruneDto,
app_state: State<'_, Arc<AppState>>,
) -> Result<AgentRunPruneApplyResultDto, CommandError> {
log_command("apply_agent_run_prune");
app_state
.agent_run_history_service
.apply_run_prune(dto)
.await
.map_err(map_command_error("Failed to apply agent run prune"))
}
#[tauri::command]
pub async fn read_agent_run_events(
dto: AgentReadEventsDto,

View file

@ -300,6 +300,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::agent_commands::cancel_agent_run,
super::agent_commands::list_agent_runs,
super::agent_commands::plan_agent_run_prune,
super::agent_commands::apply_agent_run_prune,
super::agent_commands::read_agent_run_events,
super::agent_commands::read_agent_workspace_file,
super::agent_commands::read_agent_model_turn,

View file

@ -158,20 +158,25 @@
"agent_system.runRetentionActionDelete": "删除运行",
"agent_system.runRetentionActionSlim": "瘦身产物",
"agent_system.runRetentionAnalyze": "分析清理",
"agent_system.runRetentionApply": "执行清理",
"agent_system.runRetentionApplied": "Agent 运行清理已移除 {bytes},共 {files}。",
"agent_system.runRetentionAppliedWithFailures": "Agent 运行清理已移除 {bytes},共 {files};{count} 次运行需要处理。",
"agent_system.runRetentionApplying": "正在清理...",
"agent_system.runRetentionApplyConfirm": "从 Agent 运行历史中清理 {bytes},共 {files}?",
"agent_system.runRetentionBlockActive": "运行中",
"agent_system.runRetentionBlockInvalidJournal": "日志无效",
"agent_system.runRetentionBlockInvalidStorage": "存储无效",
"agent_system.runRetentionBlockMissingTerminal": "缺少终态事件",
"agent_system.runRetentionCandidatesTruncated": "还有更多候选未显示",
"agent_system.runRetentionCoreKept": "核心",
"agent_system.runRetentionCoreSummary": "最近 {count} 次保留核心历史",
"agent_system.runRetentionCoreKept": "可回看",
"agent_system.runRetentionCoreSummary": "最近 {count} 次可在历史中回看",
"agent_system.runRetentionDeleteCandidates": "删除",
"agent_system.runRetentionDryRunOnly": "预览",
"agent_system.runRetentionFullExceedsHistory": "完整详情保留数必须位于核心历史保留数以内。",
"agent_system.runRetentionFullKept": "完整",
"agent_system.runRetentionFullSummary": "最近 {count} 次保留完整详情",
"agent_system.runRetentionKeepFull": "保留完整详情",
"agent_system.runRetentionKeepHistory": "保留核心历史",
"agent_system.runRetentionFullExceedsHistory": "排查详情保留数必须位于可回看历史保留数以内。",
"agent_system.runRetentionFullKept": "排查详情",
"agent_system.runRetentionFullSummary": "最近 {count} 次保留排查详情",
"agent_system.runRetentionKeepFull": "保留排查详情",
"agent_system.runRetentionKeepHistory": "保留可回看历史",
"agent_system.runRetentionNothingToClean": "暂无可清理内容",
"agent_system.runRetentionReasonOutsideFull": "超出完整详情窗口",
"agent_system.runRetentionReasonOutsideHistory": "超出历史保留窗口",

View file

@ -158,20 +158,25 @@
"agent_system.runRetentionActionDelete": "刪除執行",
"agent_system.runRetentionActionSlim": "瘦身產物",
"agent_system.runRetentionAnalyze": "分析清理",
"agent_system.runRetentionApply": "執行清理",
"agent_system.runRetentionApplied": "Agent 執行清理已移除 {bytes},共 {files}。",
"agent_system.runRetentionAppliedWithFailures": "Agent 執行清理已移除 {bytes},共 {files};{count} 次執行需要處理。",
"agent_system.runRetentionApplying": "正在清理...",
"agent_system.runRetentionApplyConfirm": "從 Agent 執行歷史中清理 {bytes},共 {files}?",
"agent_system.runRetentionBlockActive": "執行中",
"agent_system.runRetentionBlockInvalidJournal": "日誌無效",
"agent_system.runRetentionBlockInvalidStorage": "儲存無效",
"agent_system.runRetentionBlockMissingTerminal": "缺少終態事件",
"agent_system.runRetentionCandidatesTruncated": "還有更多候選未顯示",
"agent_system.runRetentionCoreKept": "核心",
"agent_system.runRetentionCoreSummary": "最近 {count} 次保留核心歷史",
"agent_system.runRetentionCoreKept": "可回看",
"agent_system.runRetentionCoreSummary": "最近 {count} 次可在歷史中回看",
"agent_system.runRetentionDeleteCandidates": "刪除",
"agent_system.runRetentionDryRunOnly": "預覽",
"agent_system.runRetentionFullExceedsHistory": "完整詳情保留數必須位於核心歷史保留數以內。",
"agent_system.runRetentionFullKept": "完整",
"agent_system.runRetentionFullSummary": "最近 {count} 次保留完整詳情",
"agent_system.runRetentionKeepFull": "保留完整詳情",
"agent_system.runRetentionKeepHistory": "保留核心歷史",
"agent_system.runRetentionFullExceedsHistory": "排查詳情保留數必須位於可回看歷史保留數以內。",
"agent_system.runRetentionFullKept": "排查詳情",
"agent_system.runRetentionFullSummary": "最近 {count} 次保留排查詳情",
"agent_system.runRetentionKeepFull": "保留排查詳情",
"agent_system.runRetentionKeepHistory": "保留可回看歷史",
"agent_system.runRetentionNothingToClean": "暫無可清理內容",
"agent_system.runRetentionReasonOutsideFull": "超出完整詳情視窗",
"agent_system.runRetentionReasonOutsideHistory": "超出歷史保留視窗",

View file

@ -253,7 +253,7 @@ export const RunHistoryPanel = {
</div>
</header>
<RunRetentionPanel />
<RunRetentionPanel @pruned="refreshRuns" />
<div v-if="error" class="ttas-error">
<i class="fa-solid fa-triangle-exclamation"></i>

View file

@ -1,4 +1,4 @@
import { errorText, requireHostApi } from './host-api.js';
import { confirmAction, errorText, requireHostApi } from './host-api.js';
import { translateAgentSystem as tr } from './i18n.js';
const RUN_PRUNE_DETAIL_LIMIT = 8;
@ -10,6 +10,7 @@ export const RunRetentionPanel = {
loading: false,
saving: false,
planning: false,
applying: false,
error: '',
retention: null,
draft: {
@ -21,7 +22,7 @@ export const RunRetentionPanel = {
},
computed: {
busy() {
return this.loading || this.saving || this.planning;
return this.loading || this.saving || this.planning || this.applying;
},
draftIsDirty() {
if (!this.retention) {
@ -40,24 +41,29 @@ export const RunRetentionPanel = {
|| Number(this.plan?.slimCandidateCount || 0) > 0
|| Number(this.plan?.deleteCandidateCount || 0) > 0;
},
canApplyPrune() {
return Boolean(this.plan && this.planHasWork && !this.busy);
},
planStats() {
const plan = this.plan;
if (!plan) {
return [];
}
const fullRetainedRunCount = Number(plan.fullRetainedRunCount || 0);
const reviewableRunCount = fullRetainedRunCount + Number(plan.coreRetainedRunCount || 0);
return [
{
key: 'full',
icon: 'fa-box-archive',
label: tr('runRetentionFullKept'),
value: this.formatCount(plan.fullRetainedRunCount),
value: this.formatCount(fullRetainedRunCount),
tone: 'full',
},
{
key: 'core',
icon: 'fa-scroll',
label: tr('runRetentionCoreKept'),
value: this.formatCount(plan.coreRetainedRunCount),
value: this.formatCount(reviewableRunCount),
tone: 'core',
},
{
@ -96,7 +102,8 @@ export const RunRetentionPanel = {
const api = requireHostApi('agent').retention;
if (typeof api?.readSettings !== 'function'
|| typeof api.updateSettings !== 'function'
|| typeof api.planPrune !== 'function') {
|| typeof api.planPrune !== 'function'
|| typeof api.applyPrune !== 'function') {
throw new Error(tr('hostAgentRetentionApiUnavailable'));
}
return api;
@ -144,6 +151,58 @@ export const RunRetentionPanel = {
this.planning = false;
}
},
async applyPrune() {
if (!this.canApplyPrune) {
return;
}
this.error = '';
let confirmed;
try {
confirmed = await confirmAction(tr('runRetentionApplyConfirm', {
bytes: this.formatBytes(this.plan.totalCandidateByteCount),
files: this.formatFiles(this.plan.totalCandidateFileCount),
}));
} catch (error) {
this.error = errorText(error);
return;
}
if (!confirmed) {
return;
}
this.applying = true;
try {
const retention = this.retentionApi();
const result = normalizePruneApplyResult(await retention.applyPrune({
retention: this.normalizedDraft(),
detailLimit: RUN_PRUNE_DETAIL_LIMIT,
}));
this.plan = result.afterPlan;
const toastParams = {
bytes: this.formatBytes(result.removedByteCount),
files: this.formatFiles(result.removedFileCount),
count: Number(result.failedRunCount || 0),
};
if (Number(result.failedRunCount || 0) > 0) {
window.toastr?.warning?.(
tr('runRetentionAppliedWithFailures', toastParams),
tr('agentSystem'),
);
} else {
window.toastr?.success?.(
tr('runRetentionApplied', toastParams),
tr('agentSystem'),
);
}
this.$emit?.('pruned', result);
} catch (error) {
this.error = errorText(error);
} finally {
this.applying = false;
}
},
applyRetention(value) {
const retention = normalizeRetentionSettings(value);
this.retention = retention;
@ -255,6 +314,10 @@ export const RunRetentionPanel = {
<i class="fa-solid" :class="planning ? 'fa-spinner fa-spin' : 'fa-broom'"></i>
<span>{{ tr('runRetentionAnalyze') }}</span>
</button>
<button type="button" class="menu_button menu_button_icon ttas-danger-button ttas-retention-apply-button" :disabled="!canApplyPrune" @click="applyPrune">
<i class="fa-solid" :class="applying ? 'fa-spinner fa-spin' : 'fa-trash-can'"></i>
<span>{{ applying ? tr('runRetentionApplying') : tr('runRetentionApply') }}</span>
</button>
</div>
</header>
@ -393,6 +456,20 @@ function normalizePrunePlan(value) {
return value;
}
function normalizePruneApplyResult(value) {
if (!plainObject(value)) {
throw new Error('agent.run_prune_apply_invalid: result must be an object');
}
if (!Array.isArray(value.failedRuns)) {
throw new Error('agent.run_prune_apply_invalid: result.failedRuns must be an array');
}
normalizeRetentionSettings(value.retention);
return {
...value,
afterPlan: normalizePrunePlan(value.afterPlan),
};
}
function stripChatFileName(value) {
return String(value || '')
.trim()

View file

@ -240,20 +240,25 @@ const DEFAULT_MESSAGES = Object.freeze({
runRetentionActionDelete: 'Delete run',
runRetentionActionSlim: 'Slim artifacts',
runRetentionAnalyze: 'Analyze cleanup',
runRetentionApply: 'Execute cleanup',
runRetentionApplied: 'Agent run cleanup removed {bytes} across {files}.',
runRetentionAppliedWithFailures: 'Agent run cleanup removed {bytes} across {files}. {count} runs need attention.',
runRetentionApplying: 'Cleaning...',
runRetentionApplyConfirm: 'Clean {bytes} across {files} from Agent run history?',
runRetentionBlockActive: 'Active run',
runRetentionBlockInvalidJournal: 'Invalid journal',
runRetentionBlockInvalidStorage: 'Invalid storage',
runRetentionBlockMissingTerminal: 'Missing terminal event',
runRetentionCandidatesTruncated: 'More candidates not shown',
runRetentionCoreKept: 'Core',
runRetentionCoreSummary: 'Core history for latest {count} runs',
runRetentionCoreKept: 'Reviewable',
runRetentionCoreSummary: 'Latest {count} runs stay reviewable',
runRetentionDeleteCandidates: 'Delete',
runRetentionDryRunOnly: 'Preview',
runRetentionFullExceedsHistory: 'Full retention must be within history retention.',
runRetentionFullKept: 'Full',
runRetentionFullSummary: 'Full details for latest {count} runs',
runRetentionKeepFull: 'Keep full details',
runRetentionKeepHistory: 'Keep core history',
runRetentionFullExceedsHistory: 'Diagnostic details must be kept within review history.',
runRetentionFullKept: 'Diagnostics',
runRetentionFullSummary: 'Diagnostic details for latest {count} runs',
runRetentionKeepFull: 'Keep diagnostic details',
runRetentionKeepHistory: 'Keep review history',
runRetentionNothingToClean: 'Nothing to clean',
runRetentionReasonOutsideFull: 'outside full window',
runRetentionReasonOutsideHistory: 'outside history window',

View file

@ -3098,6 +3098,27 @@ body:is([data-generating="true"], [data-swiping="true"]) .ttas-agent-send-toggle
margin: 0;
}
.ttas-retention-actions .ttas-retention-apply-button:not(:disabled) {
position: relative;
overflow: hidden;
isolation: isolate;
}
.ttas-retention-actions .ttas-retention-apply-button:not(:disabled)::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ttas-danger) 16%, transparent), transparent);
transform: translateX(-120%);
transition: transform 420ms ease;
}
.ttas-retention-actions .ttas-retention-apply-button:not(:disabled):hover::after,
.ttas-retention-actions .ttas-retention-apply-button:not(:disabled):focus-visible::after {
transform: translateX(120%);
}
.ttas-retention-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -97,6 +97,24 @@ export function createAgentRunRuntimeApi({ safeInvoke }) {
});
}
async function applyRunPrune(input = {}) {
if (!isPlainObject(input)) {
throw new Error('Agent applyRunPrune input must be an object');
}
const retention = input.retention == null
? undefined
: normalizeRetentionSettings(input.retention);
const detailLimit = normalizeRunPruneDetailLimit(input.detailLimit ?? input.detail_limit);
return safeInvoke('apply_agent_run_prune', {
dto: {
...(retention ? { retention } : {}),
...(detailLimit == null ? {} : { detailLimit }),
},
});
}
async function readEvents(input) {
const runId = requireRunId(input?.runId);
const hasInvocationId = Object.prototype.hasOwnProperty.call(input || {}, 'invocationId');
@ -234,6 +252,7 @@ export function createAgentRunRuntimeApi({ safeInvoke }) {
readSettings: readRetentionSettings,
updateSettings: updateRetentionSettings,
planPrune: planRunPrune,
applyPrune: applyRunPrune,
},
readEvents,
readWorkspaceFile,

View file

@ -11,6 +11,7 @@ export {};
*
* @typedef {(
* | 'apply_native_regex_batch'
* | 'apply_agent_run_prune'
* | 'build_openai_logit_bias'
* | 'bulk_merge_character_card_data'
* | 'cancel_chat_completion_generation'

21
src/types.d.ts vendored
View file

@ -227,6 +227,10 @@ type TauriTavernAgentRunPruneBlockedRun = TauriTavernAgentRunPruneCandidate & {
message?: string;
};
type TauriTavernAgentRunPruneFailedRun = TauriTavernAgentRunPruneCandidate & {
message: string;
};
type TauriTavernAgentRunPrunePlan = {
retention: TauriTavernAgentRunRetentionSettings;
detailLimit: number;
@ -249,6 +253,19 @@ type TauriTavernAgentRunPrunePlan = {
blockedRuns: TauriTavernAgentRunPruneBlockedRun[];
};
type TauriTavernAgentRunPruneApplyResult = {
retention: TauriTavernAgentRunRetentionSettings;
detailLimit: number;
slimmedRunCount: number;
deletedRunCount: number;
failedRunCount: number;
removedFileCount: number;
removedByteCount: number;
failedDetailsTruncated: boolean;
failedRuns: TauriTavernAgentRunPruneFailedRun[];
afterPlan: TauriTavernAgentRunPrunePlan;
};
type TauriTavernAgentModelTurn = {
runId: string;
round: number;
@ -468,6 +485,10 @@ type TauriTavernAgentRetentionApi = {
retention?: TauriTavernAgentRunRetentionSettings;
detailLimit?: number;
}) => Promise<TauriTavernAgentRunPrunePlan>;
applyPrune: (input?: {
retention?: TauriTavernAgentRunRetentionSettings;
detailLimit?: number;
}) => Promise<TauriTavernAgentRunPruneApplyResult>;
};
type TauriTavernAgentApi = {

View file

@ -292,7 +292,7 @@ test('api.agent.listRuns fails fast on invalid history filters', async () => {
assert.deepEqual(calls, []);
});
test('api.agent.retention forwards settings and prune plan contracts', async () => {
test('api.agent.retention forwards settings and prune contracts', async () => {
const calls = [];
const { agent } = await installHarness({
safeInvoke: async (command, args) => {
@ -339,6 +339,13 @@ test('api.agent.retention forwards settings and prune plan contracts', async ()
},
detailLimit: 8,
});
await agent.retention.applyPrune({
retention: {
keepRecentTerminalRuns: 80,
keepFullRecentRuns: 12,
},
detailLimit: 8,
});
assert.deepEqual(calls, [
{
@ -370,6 +377,18 @@ test('api.agent.retention forwards settings and prune plan contracts', async ()
},
},
},
{
command: 'apply_agent_run_prune',
args: {
dto: {
retention: {
keepRecentTerminalRuns: 80,
keepFullRecentRuns: 12,
},
detailLimit: 8,
},
},
},
]);
});
@ -409,6 +428,23 @@ test('api.agent.retention fails fast on invalid retention inputs', async () => {
}),
/keepFullRecentRuns must be less than or equal to keepRecentTerminalRuns/,
);
await assert.rejects(
() => agent.retention.applyPrune(null),
/Agent applyRunPrune input must be an object/,
);
await assert.rejects(
() => agent.retention.applyPrune({ detailLimit: -1 }),
/detailLimit must be an integer between 0 and 1000/,
);
await assert.rejects(
() => agent.retention.applyPrune({
retention: {
keepRecentTerminalRuns: 10,
keepFullRecentRuns: 11,
},
}),
/keepFullRecentRuns must be less than or equal to keepRecentTerminalRuns/,
);
assert.deepEqual(calls, []);
});

View file

@ -492,10 +492,11 @@ test('Agent run retention panel uses the host retention facade', async () => {
assert.match(source, /retention\.readSettings/);
assert.match(source, /retention\.updateSettings/);
assert.match(source, /retention\.planPrune/);
assert.doesNotMatch(source, /safeInvoke|plan_agent_run_prune|update_tauritavern_settings|localStorage/);
assert.match(source, /retention\.applyPrune/);
assert.doesNotMatch(source, /safeInvoke|plan_agent_run_prune|apply_agent_run_prune|update_tauritavern_settings|localStorage/);
const calls = [];
installWindow({
const window = installWindow({
agent: {
retention: {
async readSettings() {
@ -513,15 +514,58 @@ test('Agent run retention panel uses the host retention facade', async () => {
calls.push({ method: 'planPrune', input });
return {
retention: input.retention,
candidates: [],
candidates: [{
runId: 'run-preview',
action: 'delete_run',
reason: 'outside_history_retention_window',
fileCount: 1,
byteCount: 5,
}],
blockedRuns: [],
totalCandidateFileCount: 0,
totalCandidateByteCount: 0,
totalCandidateFileCount: 1,
totalCandidateByteCount: 5,
};
},
async applyPrune(input) {
calls.push({ method: 'applyPrune', input });
return {
retention: input.retention,
detailLimit: input.detailLimit,
slimmedRunCount: 0,
deletedRunCount: 1,
failedRunCount: 0,
removedFileCount: 1,
removedByteCount: 5,
failedDetailsTruncated: false,
failedRuns: [],
afterPlan: {
retention: input.retention,
candidates: [],
blockedRuns: [],
totalCandidateFileCount: 0,
totalCandidateByteCount: 0,
},
};
},
},
},
});
window.SillyTavern = {
getContext() {
return {
Popup: {
show: {
async confirm() {
return 'affirmative';
},
},
},
POPUP_RESULT: {
AFFIRMATIVE: 'affirmative',
},
};
},
};
const { RunRetentionPanel } = await importFresh('src/scripts/extensions/agent-system/src/RunRetentionPanel.js');
const vm = createComponentHarness(RunRetentionPanel);
@ -529,6 +573,7 @@ test('Agent run retention panel uses the host retention facade', async () => {
vm.setDraftValue('keepRecentTerminalRuns', { target: { value: '80' } });
await vm.saveRetentionSettings();
await vm.analyzePrune();
await vm.applyPrune();
assert.deepEqual(calls, [
{ method: 'readSettings' },
@ -549,11 +594,50 @@ test('Agent run retention panel uses the host retention facade', async () => {
detailLimit: 8,
},
},
{
method: 'applyPrune',
input: {
retention: {
keepRecentTerminalRuns: 80,
keepFullRecentRuns: 20,
},
detailLimit: 8,
},
},
]);
assert.equal(vm.error, '');
assert.deepEqual(vm.plan.candidates, []);
});
test('Agent run retention stats show review history as the total reviewable window', async () => {
const { RunRetentionPanel } = await importFresh('src/scripts/extensions/agent-system/src/RunRetentionPanel.js');
const vm = createComponentHarness(RunRetentionPanel);
vm.plan = {
fullRetainedRunCount: 240,
coreRetainedRunCount: 0,
slimCandidateCount: 0,
deleteCandidateCount: 0,
totalSlimByteCount: 0,
totalDeleteByteCount: 0,
};
let stats = Object.fromEntries(vm.planStats.map((stat) => [stat.key, stat]));
assert.equal(stats.full.value, '240');
assert.equal(stats.core.value, '240');
vm.plan = {
fullRetainedRunCount: 20,
coreRetainedRunCount: 220,
slimCandidateCount: 0,
deleteCandidateCount: 0,
totalSlimByteCount: 0,
totalDeleteByteCount: 0,
};
stats = Object.fromEntries(vm.planStats.map((stat) => [stat.key, stat]));
assert.equal(stats.full.value, '20');
assert.equal(stats.core.value, '240');
});
test('Embedded Agent Skill import isolates per-item preview and install failures', async () => {
const source = await readFile(path.join(
REPO_ROOT,