diff --git a/docs/code-index/peri-acp.md b/docs/code-index/peri-acp.md index 14c9f2b21..00fd305e1 100644 --- a/docs/code-index/peri-acp.md +++ b/docs/code-index/peri-acp.md @@ -18,7 +18,7 @@ | 改待发送队列控制与执行准入 | `src/host/requests/user_input.rs` + `src/host/user_input.rs` + `src/host/prompt_dispatch.rs` | `handle_user_input`;`ensure_mailbox` / `schedule_mailbox`;`dispatch_prompt_turn_with_input` | 四短 RPC 不等 prompt_lock,session 持 Agent Mailbox;Agent ticket 经同一执行锁启动,RunStarted/done 身份配对,Stop 精确定位,MPSC/stdio 共用请求与事件链(ARC-BOUNDARY-001 / ARC-EVENT-001) | | 改插件 marketplace 搜索 | `src/host/requests/plugin.rs` + `plugin_search_test.rs` | `handle_search` / `search_marketplace_plugins` | 经 PluginManagerPort 获取缓存目录,复用 `plugin::marketplace::find_marketplace_json` 读取根或 `.claude-plugin` 布局;名称、描述、marketplace 名均忽略大小写匹配;无匹配明确返回空数组;回归经真实 `handle_request` 读取临时磁盘目录 | | 改 compact 后失败恢复 | `src/host/prompt.rs` + `src/host/compact_recovery_test.rs` | `finish_prompt_turn` | 不按 `ok` 丢弃可信 canonical snapshot;取消/模型或 forwarder 失败仍保留已提交 Full 摘要;persistence_inconsistent 移除热会话,冷加载恢复磁盘,ARC-COMPACT-001 | -| 验证手动 compact 跨轮与取消 | `src/host/compact_command_test.rs` + `src/session/command/compact_persistence_test.rs` + `tests/compact_command_contract_test.rs` | `run_session_loop` → `finish_prompt_turn`;`TransportEventSink` | 覆盖连续手动、自动 Full 后手动、新连接冷读后手动、摘要生成中取消;完整 canonical 历史保持,done 与 PromptResponse 在真实 MPSC 通知上同一终态;原始两项复现保留为 public API 集成回归 | +| 验证手动 compact 跨轮与取消 | `src/host/compact_command_test.rs` + `src/session/command/compact_persistence_test.rs` + `src/session/command/compact_report_test.rs` + `tests/compact_command_contract_test.rs` | `run_session_loop` → `finish_prompt_turn`;`TransportEventSink` | 覆盖连续手动、自动 Full 后手动、新连接冷读后手动、摘要生成中取消,以及完整报告输入/排除、仅 reminder 和 child 边界;完整 canonical 历史保持,done 与 PromptResponse 在真实 MPSC 通知上同一终态;原始两项复现保留为 public API 集成回归 | | 改 System Reminder producer/ACP 投影 | `src/session/dynamic_mcp.rs` + `src/host/continuation.rs` + `src/session/event_sink.rs` + `src/dispatch/session_replay.rs` | `SessionDynamicMcpNotificationSink`;`enqueue_cron_trigger`;`push_system_reminder`;`send_system_reminder` | Dynamic MCP lifecycle/OAuth 与 Cron trigger 直接入 canonical queue;不改变 OAuth/cron 控制;ACP client 声明 `peri.systemReminder` 时收结构化 event,否则只收展示 fallback;load/replay 不伪装 user message;旧 Compact plain-text Human 经 `compact_reminder::legacy_compact_reminders` 生成 Legacy 通知,MPSC/stdio 共用出口 | | 新增/改会话协议方法 | `src/host/requests.rs`(注册面,`handle_request` :22,按方法分派到 `host/requests/{session_lifecycle,plugin,config_options,mcp_oauth,workflow,rewind}.rs`);`src/host/server_loop.rs`(`session/prompt` 单独处理,spawn 后台 task);`src/session/frozen_snapshot.rs`(版本化 frozen owner state);`src/dispatch/session_fork.rs`(fork payload 独立复制);stdio 侧部署装配点 `src/host/stdio/mod.rs`(`run_acp_stdio` 持有进程日志初始化,`assemble_stdio_config` 只装配配置,业务处理走统一 `run_acp_server`) | `handle_new/load/resume/fork`(requests/session_lifecycle.rs);`new_session_from_prepared`(new 的发布段:消费已定格准备输入写 meta/binding/frozen 并发布,不重读配置/插件、不重建 frozen);`handle_reset_dirty`(`peri/session_reset_dirty`,需 `peri.sessionRecoveryV1` 与显式 `accept_risk`);`fork_session`;`encode_frozen_snapshot` / `decode_frozen_snapshot`;`after_new_response`;其余 plugin/config/workflow/rewind handler | new 持久化 frozen 后才发布 session;load/resume 冷恢复原快照,未绑定 legacy 根恢复经 `requests/legacy_session.rs::prepare_for_restore` 按保存 cwd 原子接纳 binding + 缺失 frozen、loser 重读 winner;已绑定缺快照保持错误,未知/损坏版本及存储错误 fail closed;fork 继承 source frozen,并以新 `MessageId` 复制 payload/compact flags,使新 thread 独立拥有可压缩历史;new/fork 写失败补偿删除。`session/load` 保持 response 前 replay/通知;load/resume 补载驻留空历史时同步 canonical payload 与消息投影,后续 prompt/fork 从同一 payload 读取;`session/prompt` 是唯一 spawn 后台执行的方法;stdio 与 TUI 共用统一 host | | 改 prompt 执行流程(keepgoing/挂起注入/错误响应) | `src/host/prompt.rs` + `src/host/prompt_dispatch.rs` + `src/session/executor.rs` | `run_prompt`;`prompt_wire_response` / `execution_failure_to_acp_error`;`dispatch_prompt_turn`;`session/executor.rs` **仅 re-export** `peri_agent::session::exec::executor` 的执行入口(ARC-BOUNDARY-001) | 挂起时 prompt 注入 inbox;keepgoing 短路在 Agent 层;重试中的 `LlmRetrying` 是进度事件,不结束 prompt;仅 fatal `PromptResult.failure` 在历史/state/cancel-token 后处理完成后映射为 `session/prompt` JSON-RPC server error(`-32000`):message 保留脱敏限长后的 LLM/provider 原意,allowlist data 携带 `kind` 与可选 HTTP `status`、受控 diagnostic facts;ACP 不序列化完整 AgentError/ModelError/provider body;cancel/interrupted/max iterations/输出截断预算耗尽仍返回携带对应停止原因的标准 `PromptResponse`,协议成功不代表任务完成(ARC-OUTPUT-COMPLETION-001);mpsc/stdio 共用统一 host | diff --git a/docs/code-index/peri-agent.md b/docs/code-index/peri-agent.md index 629bac383..6d2002e20 100644 --- a/docs/code-index/peri-agent.md +++ b/docs/code-index/peri-agent.md @@ -27,14 +27,14 @@ | 改 Workflow agent 观测与进度 | `src/agent/workflow/agent/observation.rs` | `WorkflowObservation::{handler,report_model,snapshot}` | 单一锁持有 tool count、output usage 与最后模型;事件先累计/发送 progress,再调用 Langfuse;有效模型早报保持 None 计数;`observation_test.rs` 覆盖进度先后与关闭排空后统计 | | 改 Workflow agent 返回值与遥测终态 | `src/agent/workflow/agent/result.rs` | `project_run_result`、`completed_result`、`ProjectedResult::telemetry_outcome` | forwarder failure 优先于 loop 终态;schema 只校验输出,成功仍返回 JSON string;无 usage 时保留字节长度 token 估算,缺模型回退有效模型;`result_test.rs` 覆盖 wire、schema、失败优先和取消 | | 改 compact 展示事件 | `src/agent/stages/compact.rs` + `peri-acp-types/src/event_v2/{types,executor_mapping}.rs` + `src/session/exec/{compact_pipeline,events}.rs` | `ObserveEvent::CompactStarted` / `MessagesCompacted`;`observe_event_to_executor`;`emit_compact_started` / `emit_compact_completed` | 自动 compact 将 strategy、受影响消息数、估算节省 token、files/skills 映射到 `ExecutorEvent::CompactCompleted`;手动 `/compact` 从 pipeline 发送同一展示载荷;下游经 ACP 单路径投递,契约 ARC-EVENT-001 | -| 改 Micro/Full 执行细节 | `src/agent/compact_v2/{micro,projection,full}.rs` + `src/session/{transcript.rs,exec/executor_helpers/v2_execute.rs}` | `micro_compact`;`render_llm_view`;`full_compact_inner`;`re_inject_v2`;`MessageTranscript::{with_own_payloads,with_ancestor_payloads}` | Micro 不原地截断 transcript,而是持久化 message-level projection directive,后续模型视图由 `render_llm_view` 投影工具结果;普通 root Agent 与独立复制后的 ACP fork 跨 turn 历史属于可压缩 own region;显式继承上下文才使用只读 ancestor boundary;子会话以版本化 InheritedContext 冻结 payload 与 flags,load/resume 保持 ancestor/own 边界,普通 setter 与 lifecycle 拒绝改写祖先;Full 仅按本轮新 `excluded` transition 统计 affected messages,文件 re-inject 只从 Full 前可见 `Read` 来源收集,新 `Read` 同路径仍可注入更新内容;精确载荷污染风险见 `spec/issues/2026-09-09-p0-micro-compact-edit-write-context-corruption.md`,compact churn 调查见 `spec/issues/2026-09-10-p0-full-micro-compact-churn.md` | +| 改 Micro/Full 执行细节 | `src/agent/compact_v2/{micro,projection,full}.rs` + `src/session/{transcript.rs,exec/executor_helpers/v2_execute.rs}` | `micro_compact`;`render_persisted_llm_view` / `render_llm_view`;`full_compact_inner`;`re_inject_v2`;`MessageTranscript::{with_own_payloads,with_ancestor_payloads}` | Micro 不原地截断 transcript,而是持久化 message-level projection directive,后续模型视图由 `render_llm_view` 投影工具结果;普通 root Agent 与独立复制后的 ACP fork 跨 turn 历史属于可压缩 own region;显式继承上下文才使用只读 ancestor boundary;子会话以版本化 InheritedContext 冻结 payload 与 flags,load/resume 保持 ancestor/own 边界,普通 setter 与 lifecycle 拒绝改写祖先;Full 从与 Reason 共用的已提交模型视图构造结构化摘要请求,包含 canonical reminder 且无额外正文预览截断;没有可替换 own 历史时不调用摘要模型;正常完成的非空摘要与 own 非 System 历史(含 reminder)的 excluded transitions 原子提交;仅按本轮新 transition 统计 affected messages,文件 re-inject 只从 Full 前可见 `Read` 来源收集,新 `Read` 同路径仍可注入更新内容;精确载荷污染风险见 `spec/issues/2026-09-09-p0-micro-compact-edit-write-context-corruption.md`,compact churn 调查见 `spec/issues/2026-09-10-p0-full-micro-compact-churn.md` | | 改 Goal 自动接续与状态事件 | `peri-middlewares/src/{goal_middleware,completion_reminder}.rs` + `src/middleware/capabilities.rs` + `src/agent/agent_context.rs` + `src/session/exec/stage_builder.rs` | `CompletionReminder::admit/enqueue`;`BackgroundActivity::has_active_background_tasks`;`GoalController::increment_continuation`;`emit_goal_snapshot` | active Goal 经与 todo 共用的准入后才增长主动接续/紧迫感;已有 `block_continue` 优先,后台活动复用 Receive 的 `idle_should_wait → TaskManager::active_count`(Agent/Workflow/Shell 的 Running/Completing)。`build_stage_context` 在装配前统一补齐缺省 manager,工具、SubagentHost、probe 与 registry wake 必须同源;装配回归见 `peri-middlewares/src/assembly_test.rs::test_stage_completion_reminders_share_assembled_task_manager`。Workflow 在 `WorkflowTool::start_run` 注册,`session/workflow_completion.rs::apply_workflow_task_result` 先送完成 Defer 再提交 task 终态;终态后恢复提醒。Act 每轮结束(含错误返回)发 session Goal 快照,经 ACP 单路径投影;回归见两类 middleware 测试及 `middleware/capabilities_test.rs`、`session/workflow_completion_test.rs` | | 改循环退出 / keepgoing 判定 | `src/session/exec/executor.rs` + `src/agent/stages/mod.rs`(Receive 分支) | `executor.rs:130 is_keepgoing(&MessageContent)`;`run_session_loop`(executor.rs:221);`run_react_loop` 正常退出判断(stages/mod.rs:647 `consumed_count == 0 && !has_tool_calls`);判空底层 `peri-acp-types/src/messages/content.rs::is_empty`(:399) | 空字符串 / 空 blocks / 空 raw 内容须用 `MessageContent::is_empty()` 判空且禁止 trim 替代(纯空白字符串不算空);空历史 + 空内容 prompt 时短路 `push_done`;keepgoing 不注入 recall;cancel 与 stage error/interruption 可在 Receive 正常退出点之外终止;契约 ARC-KEEPGOING-001 | | 改 turn fatal failure 分类/传递 | `src/session/exec/executor_helpers/v2_execute.rs` + `executor_helpers.rs` + `executor_helpers/collect.rs`;契约 DTO 在 `peri-acp-types/src/session.rs` | `classify_loop_terminal`;`internal_failure_terminal`;`ExecutionFailure::from_agent_error`;`ExecOutcome.failure` → `PromptResult.failure` | transcript flush 后只采样一次 cancel;forwarder JoinError 保留到 Phase 9,在提取 transcript/recall/compaction 后覆盖为 Internal terminal;单一终态同时决定 Prompt stop reason、`TurnEnded`、fatal failure 与 cascade;LLM/provider failure 保留脱敏限长原意和可选 HTTP status,其他内部错误使用安全文案;Completed 为已提交成功,其他非成功结果中 cancel 优先;契约 ARC-EVENT-001 / ARC-CANCEL-001 | | 加工具(direct/deferred) | trait 事实源 `peri-acp-types/src/tools.rs`;注册面 = middleware 的 `collect_tools()`;组装 `src/session/exec/stage_builder/tools.rs::build_session_tool_view` | `BaseTool::is_direct()`(默认 **false** = deferred);Reason publication 在 `src/agent/stages/reason.rs`,专用 hook runner 在 `middleware_runner.rs::run_before_reason_catalog`;Dynamic MCP projection holder 由 `StageBuildInput::dynamic_mcp_projection` 从 session owner 透传;启动期候选经 `middleware_runner.rs::run_before_react_start`(:84)→ `SessionToolCatalog::replace_static_mcp_tools`(`session/tool_catalog.rs:260`)替换 static base | 每 turn 先应用 middleware disabled 与 agent allow/disallow filter 构造 session-local 视图;动态 refresh 后按 working map swap → `before_reason_catalog` → `before_model` → pin 发布,ToolSearch 在专用 hook 内重绑 Search index 与 Execute resolver;Discover/resource 的 projection lease 跨 stage build 复用并由 session close 释放;startup 提交只更新 static base,不替代 Reason boundary、不混入 dynamic overlay;不得使用静态核心白名单或等待下一 turn;契约 ARC-TOOLS-001 | | 改 PTC effective-target dispatch | `src/agent/stages/tool_dispatch/{effective_dispatcher,execution}.rs` + `peri-acp-types/src/tools.rs` | `StageEffectiveToolDispatcher::dispatch` / `dispatch_output`;`collect_tool_results` | canonical `RunPtcCode` 是 deferred-only,经 `SearchExtraTools → ExecuteExtraTool` 进入执行;从当前 pinned catalog canonical resolve,policy/HITL/event/tool card 投影 effective target,并复用 timeout/cancel;typed execution evidence 经 canonical direct/deferred wrapper 透传;嵌套调用不写 transcript 或重复执行外层 batch hook/失败计数;模型 assistant raw wrapper call 仅保留协议配对;direct tools 不受影响;PTC JavaScript tools API 仍为 string projection;旧 `run_code` 仅作搜索迁移关键词,不可执行 | | 改 cancel 链路 | `src/agent/stages/mod.rs` + `src/session/exec/executor_helpers/v2_execute.rs` + `peri-acp-types/src/session.rs` | `run_stage`(stage-local `AgentError::Interrupted` 规范化);`build_and_execute_agent_v2` / `classify_loop_terminal`;`cancel_cascade_agents` / `cancel_all_agents`;`CancelRequest` 在 `peri-acp-types/src/identity.rs` | stage 仍成对发射 `StageEnded(Error)`,loop 终态统一为 Interrupted;按 (session_id, turn_id, attempt_id) 三元组定位;幂等判定与终态归 Agent 层;clear_queue 默认 false;契约 ARC-CANCEL-001 | -| /compact 命令路径 | `src/session/exec/compact_pipeline.rs` + `src/session/exec/executor_helpers/{intercept,event_pump}.rs` | `run_compact(force=true)` → Full + re-inject;`executor_helpers::done_stop_reason` | 输入与 Host 同为 canonical 消息历史:按持久化快照校验完整 ID 和顺序,再恢复 flags 选择可见内容;命令与普通执行共用 done 终态投影,取消返回及通知均为 cancelled,不确定提交仍按 Internal/reload 收尾 | +| /compact 命令路径 | `src/session/exec/compact_pipeline.rs` + `src/session/exec/executor_helpers/{intercept,event_pump}.rs` | `run_compact(force=true)` → Full + re-inject;`executor_helpers::done_stop_reason` | 输入与 Host 同为 canonical 消息历史:普通消息 ID/顺序与持久化快照一致性校验后,恢复完整 payload(含 reminder)、flags 和 ancestor/own 边界选择可见内容;仅 reminder 历史也可压缩;命令与普通执行共用 done 终态投影,取消返回及通知均为 cancelled,不确定提交仍按 Internal/reload 收尾 | | 改 LLM 调用链路 | `src/agent/stages/reason.rs` + `src/agent/model_bridge.rs` | `run_reason`;`AgentModelBridge::build_request`;model_bridge 流式事件 v2 直发 | Reason:snapshot → LlmCallStart → before_model → generate(与 cancel 竞争)→ after_model → LlmCallEnd;bridge 每个 ModelRequest 同步读取一次当前 middleware prompt contribution,与 frozen base request-local 组合且不累加;事件契约 ARC-EVENT-001 | | 改工具执行分发 | `src/agent/stages/act.rs` + `src/agent/stages/tool_dispatch.rs` + `tool_dispatch/execution.rs` | `run_act`;`dispatch_tools`;`collect_tool_results`;`ToolResult::execution`;`ToolOutput::projected_text` | 外层一次 staging/commit 后计入含解析失败结果的 tool-growth,再执行 after_tools_batch;typed execution evidence 随统一 bounded projection 进入 live `ToolEnded`、`ToolResult` 与 `BaseMessage::Tool` 持久化;`SubagentFailure` 经 boxed error downcast 保留 child identity 与 SafeSubagentFailure,诊断 facts 同步进入模型可见 tool content;cancel/timeout error 保留 typed status,普通 legacy error 保持 unknown;PTC 内部调用不重复结算;私有执行管线保持审批 → yield → 并发完成即发 ToolEnded → after_tool → 后处理顺序,after_tool 看不到本轮待提交消息;ToolStarted 与实际执行均使用审批后参数,transcript 保留模型原始调用用于配对 | | 改 middleware 状态能力 / 消息修改 | `src/middleware/{capabilities,state}.rs` + `src/agent/agent_context.rs` + `src/agent/stages/middleware_runner.rs` | `BeforeAgentState` / `BeforeInputState` / `InputBatchState::input_message_ids` / `BeforeToolState` / `AfterToolState` / `AfterAgentState`;`MiddlewareState::replace_message`;`AgentContext::from_stage` / `reconcile_to_transcript`;`run_before_agent` / `run_before_input` | hook 不再暴露 cwd/step setter、store/thread 或无法回写的 token/context 快照;首次 Receive 按链序交错执行 before_agent / before_input,后续用户批次只执行 before_input;空批次不重读历史;替换按稳定 MessageId 查找,不增删/重排,输入准备成功或 Err 后均 reconcile;StateView 无可变 queue/catalog;队列和目录分别由 QueueState/CatalogState 提供,before_model 保留消息追加,其他 hook 无输入替换能力 | @@ -64,7 +64,7 @@ | 压力计算与计划 | compact_v2/planner.rs + projection.rs | `plan_micro` 只规划 visible own history;`estimate_projection_chars` 跳过 excluded,已隐藏历史无重复收益 | | Micro 执行(按 round 截断) | compact_v2/micro.rs | `micro_compact` | | Smart 执行(废弃中,恒 false) | compact_v2/smart.rs | `smart_compact` | -| Full 执行 + re-inject | compact_v2/full.rs | `re_inject_v2`、`extract_file_info`、`extract_skill_names` | +| Full 执行 + re-inject | compact_v2/full.rs | `full_compact_inner`(结构化上下文摘要 + own reminder 排除)、`re_inject_v2`、`extract_file_info`、`extract_skill_names`;回归 `full_report_test.rs` | | 配置 re-export | compact_v2/config.rs | `CompactConfig`(事实源 peri-acp-types)、`CONTINUATION_HINT` | | 摘要 prompt 模板 | compact_v2/descriptions/ | summary_system_prompt.md / summary_user_prompt.md | diff --git a/docs/design/message-transcript.md b/docs/design/message-transcript.md index 113bb7977..9d972fc1b 100644 --- a/docs/design/message-transcript.md +++ b/docs/design/message-transcript.md @@ -138,7 +138,7 @@ Compact 保留消息本体,通过标记改变可见性或模型投影;Micro - Micro 和 Full 两种已实现模式通过标记实现——Micro 标 `truncated`,Full 标 `excluded`。消息不删,标记可撤销。rewind 清标记恢复原状 - Smart Compact 已实现为 planner 兼容入口(`peri-agent/src/agent/compact_v2/smart.rs`):不再走独立 LLM 筛选分支,而是通过 `plan_micro` 生成计划再应用(`set_flags_projection` 统一持久化 directive),并带 deprecation warning("will be removed, converging to Micro");`compact_v2` 已目录化(原 `compact_v2.rs:57` stub 位置不复存在) -- Full 将新摘要、re-inject 消息和旧消息 excluded transitions 作为同一 lifecycle 提交。后续取消或失败不撤销已提交事务;恢复须保持摘要与 flags 配对。提交前置 pending 状态,只有存储确认成功并应用内存后才标记 committed;取消或错误留下的不确定状态由自动 executor 与手动命令 interceptor 传播给 host,不能仅以普通 writer barrier 成功确认一致性。 +- Full 从完整可见模型历史(含 canonical reminder,恢复已提交 Micro 投影)生成摘要,将新摘要、re-inject 消息和本次快照内 own region 的非 System 历史 excluded transitions 作为同一 lifecycle 提交。报告/通知不因 reminder 类型而豁免;原文留存,成功后退出活跃模型视图;未纳入快照的新结果继续等待 Receive。后续取消或失败不撤销已提交事务;恢复须保持摘要与 flags 配对。提交前置 pending 状态,只有存储确认成功并应用内存后才标记 committed;取消或错误留下的不确定状态由自动 executor 与手动命令 interceptor 传播给 host,不能仅以普通 writer barrier 成功确认一致性。 ### 2.7 与 v2 其他模块的关系 diff --git a/docs/design/micro-compact.md b/docs/design/micro-compact.md index 064d199f6..5c0713e9d 100644 --- a/docs/design/micro-compact.md +++ b/docs/design/micro-compact.md @@ -250,17 +250,25 @@ flowchart TD 压力样本由有效 provider usage generation 与 canonical 工具结果增长 generation 共同标识;同一样本只自动尝试一次。Act 提交工具结果后计入估算,下一次有效 input usage 结清估算;missing/zero usage 不清账。预算中已包含该增长,不能再次扣减 headroom。 -### 6.3 Full 后预算验证 +### 6.3 Full 的上下文快照与报告替换 -成功 Full 后,Reason 将实际请求与该 Full generation 绑定,用对应响应的有效 input usage 检查是否降到 `auto_compact_threshold` 以下。没有新增用户或工具工作时,连续两次 Full 后仍高压会返回 `CompactBudgetUnrecovered`,结束本轮;cancel 优先。AI 输出、摘要和 canonical Reminder 不重置次数,新的用户或工具工作开启新一轮验证。未知、零或过期 usage 不作为进展证据。此保护限制无工作进展的重复压缩,不删减保留的 Reminder。 +Full 与 Reason 共用 `render_persisted_llm_view` 恢复已提交的模型视图,包括 canonical reminder;派生摘要请求保留消息角色、工具配对与完整可见正文,不再使用每条 2000 字符、工具结果前三行或关键参数预览。已有 Micro 投影仍生效,避免 Full 重新展开已经隐藏的工具输出。摘要请求不开放可执行工具;冻结 prompt 与父会话的继承快照不被改写。 -### 6.4 缓存感知 +子 Agent 报告与后台任务结果参与摘要,摘要指令要求保留结论、约束与未完成工作并按通知来源归因。只有正常完成且后处理后非空的摘要可以提交;截断或仅含 analysis 的响应保留原历史。提交时将快照内 own region 的普通历史与 reminder 一起标 excluded,摘要与标记使用同一持久化事务;System 和 ancestor 不在排除集合。原文留在 canonical 存储供回查,后续模型请求及冷恢复不再发送已排除报告全文。 + +手动 `/compact` 从一次一致快照恢复完整 payload、flags 和 ancestor/own 边界;普通消息 ID 校验仅用于调用方一致性检查,不再决定摘要输入范围。仅有 reminder 的会话也可压缩。摘要期间新到达的 inbox 结果在后续 Receive 处理,不属于旧快照的排除集合。 + +### 6.4 Full 后预算验证 + +成功 Full 后,Reason 将实际请求与该 Full generation 绑定,用对应响应的有效 input usage 检查是否降到 `auto_compact_threshold` 以下。没有新增用户或工具工作时,连续两次 Full 后仍高压会返回 `CompactBudgetUnrecovered`,结束本轮;cancel 优先。AI 输出、摘要和 canonical Reminder 不重置次数,新的用户或工具工作开启新一轮验证。未知、零或过期 usage 不作为进展证据。此保护限制无工作进展的重复压缩,不自行删除内容或把历史排除数量当作真实 token 收益。 + +### 6.5 缓存感知 高缓存命中率 + 充足 headroom 的情况下,compact 可以推迟——缓存还在有效期内,提前压缩反而损失缓存带来的 token 节省。 当 `cache_aware_enabled = true` 且 `cache_hit_rate > 0.7` 且 headroom > 20% 时,跳过本次 compact。 -### 6.5 Shadow Mode +### 6.6 Shadow Mode 当 `shadow_mode_enabled = true` 时,只跑 `plan_micro()` 估算,不应用任何标记。日志输出估算值。用于校准 chars→tokens 估算模型——对比估算值与下一次真实 LLM 请求的 `input_tokens`。 diff --git a/docs/standards/architecture-contracts.md b/docs/standards/architecture-contracts.md index 1a4e8a148..8a679521e 100644 --- a/docs/standards/architecture-contracts.md +++ b/docs/standards/architecture-contracts.md @@ -36,8 +36,8 @@ ### ARC-COMPACT-001 - **Scope**:`peri-agent`、`peri-acp`、`peri-acp-types`、`peri-resources`。 -- **Rule**:Micro 计划与收益只计当前模型可见的 own region;Reason 只恢复已提交的 projection,关闭自动 compact 不改变已有投影。工具增长只在 canonical 工具结果提交后记账,新的有效 provider input usage 才结清估算。Full 的摘要与 excluded transitions 按持久化事务提交;随后取消或执行失败不能撤销已提交结果,host 仍须采纳可信快照;writer 失败或 compact 提交结果不确定须停止使用热状态、保留磁盘已提交内容并要求重新加载,禁止仅删除新增消息来伪造回滚。新子会话的继承上下文必须冻结 payload 与 flags,恢复时保持 ancestor/own 边界,祖先标记只可恢复、不可由子会话 compact 改写;ACP 独立 fork 的新 ID 复制仍属于 own region。无新增用户或工具工作时,连续成功 Full 后的真实请求仍未恢复预算须有界失败,不得靠删除 canonical reminder 或重复使用旧 usage 证明进展。 -- **Verify**:`cargo test -p peri-agent --lib test_audit_`、`cargo test -p peri-agent --lib budget_recovery`、`cargo test -p peri-agent --lib provenance`、`cargo test -p peri-acp --lib compact_recovery`、`cargo test -p peri-resources --lib inherited_context`;检查 `planner.rs`、`reason.rs`、`compact_progress.rs`、`subagent/factory.rs`、`v2_execute.rs` 与 `host/prompt.rs::finish_prompt_turn`。 +- **Rule**:Micro 计划与收益只计当前模型可见的 own region;Reason 只恢复已提交的 projection,关闭自动 compact 不改变已有投影。工具增长只在 canonical 工具结果提交后记账,新的有效 provider input usage 才结清估算。Full 从包含 canonical reminder 的已提交模型视图派生结构化摘要请求,不另做逐条有损预览;完整有效摘要与快照内 own region 的非 System 历史(含报告/通知 reminder)excluded transitions 按持久化事务提交,原文保留供历史回查。手动 `/compact` 同样恢复完整 payload 与继承边界;摘要期间新到达的 inbox 结果不属于旧快照;随后取消或执行失败不能撤销已提交结果,host 仍须采纳可信快照;writer 失败或 compact 提交结果不确定须停止使用热状态、保留磁盘已提交内容并要求重新加载,禁止仅删除新增消息来伪造回滚。新子会话的继承上下文必须冻结 payload 与 flags,恢复时保持 ancestor/own 边界,祖先标记只可恢复、不可由子会话 compact 改写;ACP 独立 fork 的新 ID 复制仍属于 own region。无新增用户或工具工作时,连续成功 Full 后的真实请求仍未恢复预算须有界失败,不得在未成功摘要时丢弃 canonical reminder,或重复使用旧 usage 证明进展。 +- **Verify**:`cargo test -p peri-agent --lib full_report`、`cargo test -p peri-acp --lib compact_report`、`cargo test -p peri-agent --lib test_audit_`、`cargo test -p peri-agent --lib budget_recovery`、`cargo test -p peri-agent --lib provenance`、`cargo test -p peri-acp --lib compact_recovery`、`cargo test -p peri-resources --lib inherited_context`;检查 `planner.rs`、`reason.rs`、`compact_progress.rs`、`subagent/factory.rs`、`v2_execute.rs` 与 `host/prompt.rs::finish_prompt_turn`。 ### ARC-SESSION-LOAD-001 diff --git a/peri-acp/src/session/command/compact_report_test.rs b/peri-acp/src/session/command/compact_report_test.rs new file mode 100644 index 000000000..c21c7fb39 --- /dev/null +++ b/peri-acp/src/session/command/compact_report_test.rs @@ -0,0 +1,259 @@ +//! 手动 Full 的完整 payload 恢复、报告排除与冷加载。 +use super::*; +use peri_acp_types::session_resources::{BindingState, ChildSnapshot, FrozenState}; +use peri_acp_types::store::InheritedContext; +use peri_acp_types::system_reminder::{ + ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, + ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, +}; +use peri_agent::session::MessageTranscript; +use peri_model::{ + Model, ModelCapabilities, ModelMessage, ModelRequest, ModelResponse, ModelResult, ModelStream, + StopReason, +}; +use tokio_util::sync::CancellationToken; + +fn report(body: &str) -> PersistedPayload { + PersistedPayload::SystemReminder { + id: MessageId::new(), + reminder: TrustedSystemReminderFactory::for_producer() + .construct(SystemReminder { + version: SYSTEM_REMINDER_VERSION, + category: ReminderCategory::Task, + source: ReminderSource("subagent".into()), + kind: "completed".into(), + severity: ReminderSeverity::Info, + delivery: ReminderDelivery::Configurable, + audiences: ReminderAudiences(vec![ReminderAudience::Model]), + body: body.to_owned(), + summary: None, + metadata: serde_json::json!({}), + }) + .unwrap(), + } +} + +#[derive(Default)] +struct CapturingSummary { + requests: Mutex>, +} + +#[async_trait] +impl Model for CapturingSummary { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream(&self, _: ModelRequest, _: CancellationToken) -> ModelResult { + unreachable!("摘要只调用 complete") + } + async fn complete( + &self, + request: ModelRequest, + _: CancellationToken, + ) -> ModelResult { + self.requests.lock().unwrap().push(request); + ModelResponse::new( + ModelMessage::assistant_text("REPORT_DECISION"), + StopReason::EndTurn, + None, + None, + ) + } +} + +/// [回归测试] 手动命令必须摘要完整报告;重复 Full 和关闭旧句柄后加载均不复活全文。 +#[tokio::test] +async fn test_compact_report_manual_full_and_cold_reload() { + let session = BoundSession::open("compact-report.db").await; + let history = vec![ + BaseMessage::human("review modules"), + BaseMessage::ai("started"), + ]; + session.append(&history).await; + let payload = report(&format!("{}REPORT_TAIL", "报告细节 ".repeat(2000))); + session + .resources + .append_history(&session.thread_id, std::slice::from_ref(&payload)) + .await + .unwrap(); + let model = Arc::new(CapturingSummary::default()); + let first = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + history, + session.cwd.clone(), + model.clone(), + Some(session.resources()), + Some(session.thread_id.clone()), + ), + ) + .await; + assert_eq!(first.feedback.as_ref().unwrap().level, FeedbackLevel::Info); + assert!(first.messages[0].content().contains("REPORT_DECISION")); + assert!(session.stored_flags().await[&payload.id()].excluded); + let second = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + session.stored_messages().await, + session.cwd.clone(), + model.clone(), + Some(session.resources()), + Some(session.thread_id.clone()), + ), + ) + .await; + assert_eq!(second.feedback.as_ref().unwrap().level, FeedbackLevel::Info); + { + let requests = model.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(serde_json::to_string(&requests[0]) + .unwrap() + .contains("REPORT_TAIL")); + assert!(!serde_json::to_string(&requests[1]) + .unwrap() + .contains("REPORT_TAIL")); + assert!(serde_json::to_string(&requests[1]) + .unwrap() + .contains("REPORT_DECISION")); + } + let db_path = session.db_path.clone(); + let thread_id = session.thread_id.clone(); + let _db = session._db; + drop(session.resources); + drop(session._lease); + let reopened = + peri_resources::sessions::SessionResourcesImpl::open_existing_read_only(&db_path) + .await + .unwrap(); + let snapshot = reopened.load_session_snapshot(&thread_id).await.unwrap(); + assert!( + snapshot.payloads.iter().any(|p| p.id() == payload.id()), + "原始报告仍存储供回查" + ); + assert!(snapshot.flags[&payload.id()].excluded); + let mut restored = MessageTranscript::new().with_own_payloads(snapshot.payloads); + restored.set_flags_batch(snapshot.flags); + let visible = restored.visible_model_messages().unwrap(); + assert_eq!(visible.len(), 1); + assert!(visible[0].content().contains("REPORT_DECISION")); + assert!(!visible[0].content().contains("REPORT_TAIL")); +} + +/// [回归测试] history 普通消息投影为空时,不能跳过仅由报告组成的持久化上下文。 +#[tokio::test] +async fn test_compact_report_only_persisted_reminders() { + let session = BoundSession::open("compact-report-only.db").await; + let payload = report("ONLY_REPORT"); + session + .resources + .append_history(&session.thread_id, std::slice::from_ref(&payload)) + .await + .unwrap(); + let model = Arc::new(CapturingSummary::default()); + let result = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + vec![], + session.cwd.clone(), + model.clone(), + Some(session.resources()), + Some(session.thread_id.clone()), + ), + ) + .await; + assert_eq!(result.feedback.as_ref().unwrap().level, FeedbackLevel::Info); + assert!(result.messages[0].content().contains("REPORT_DECISION")); + assert!(serde_json::to_string(&model.requests.lock().unwrap()[0]) + .unwrap() + .contains("ONLY_REPORT")); + assert!(session.stored_flags().await[&payload.id()].excluded); +} + +/// [回归测试] 手动压缩 child 时恢复继承快照,校验完整 history 但仅排除 own 报告。 +#[tokio::test] +async fn test_compact_report_manual_child_preserves_parent_flags() { + let session = BoundSession::open("compact-report-child.db").await; + let parent_message = BaseMessage::human("parent context"); + let parent_report = report("PARENT_REPORT"); + let inherited_payloads = vec![ + PersistedPayload::Message(parent_message.clone()), + parent_report.clone(), + ]; + session + .resources + .append_history(&session.thread_id, &inherited_payloads) + .await + .unwrap(); + let snapshot = session.snapshot().await; + let BindingState::Bound(binding) = snapshot.binding else { + panic!("fixture must be bound") + }; + let FrozenState::Present(frozen) = snapshot.frozen else { + panic!("fixture must be frozen") + }; + let child_id = "compact-report-child".to_owned(); + let child = ChildSnapshot { + target: NewSession { + thread_id: child_id.clone(), + created_at: "2026-09-28T00:00:00Z".to_owned(), + meta: NewSessionMeta { + title: None, + cwd: session.cwd.clone(), + parent_thread_id: Some(session.thread_id.clone()), + hidden: true, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding, + frozen, + }, + parent_id: session.thread_id.clone(), + root_id: session.thread_id.clone(), + inherited: InheritedContext { + payloads: inherited_payloads, + flags: HashMap::new(), + }, + }; + session + .resources + .save_child(&child, &session._lease) + .await + .unwrap(); + let own_report = report("CHILD_REPORT"); + session + .resources + .append_history(&child_id, std::slice::from_ref(&own_report)) + .await + .unwrap(); + let model = Arc::new(CapturingSummary::default()); + let result = execute_compact( + &CompactCommand, + make_ctx_with_model_and_thread( + Arc::new(MockEventSink::new()), + vec![parent_message], + session.cwd.clone(), + model.clone(), + Some(session.resources()), + Some(child_id.clone()), + ), + ) + .await; + assert_eq!(result.feedback.as_ref().unwrap().level, FeedbackLevel::Info); + let request = serde_json::to_string(&model.requests.lock().unwrap()[0]).unwrap(); + assert!(request.contains("PARENT_REPORT") && request.contains("CHILD_REPORT")); + assert!( + session.stored_flags().await.is_empty(), + "父会话不能被子会话 Full 改写" + ); + let snapshot = session + .resources + .load_session_snapshot(&child_id) + .await + .unwrap(); + assert!(snapshot.flags[&own_report.id()].excluded); + assert!(!snapshot.flags.contains_key(&parent_report.id())); + assert!(snapshot.inherited.flags.is_empty()); +} diff --git a/peri-acp/src/session/command/compact_test.rs b/peri-acp/src/session/command/compact_test.rs index d71759441..14e45c5e8 100644 --- a/peri-acp/src/session/command/compact_test.rs +++ b/peri-acp/src/session/command/compact_test.rs @@ -234,7 +234,7 @@ impl BoundSession { .expect("加载会话快照失败") } - /// 已持久化的消息本体(reminder 不进入 compact 输入,与生产同语义)。 + /// 命令 history 的普通消息投影;pipeline 另从快照恢复完整 reminder。 async fn stored_messages(&self) -> Vec { self.snapshot() .await @@ -810,3 +810,6 @@ async fn test_contract_all_system_history_still_human_first() { "stop_reason 必须为 EndTurn" ); } + +#[path = "compact_report_test.rs"] +mod report_tests; diff --git a/peri-agent/src/agent/compact_v2/descriptions/summary_user_prompt.md b/peri-agent/src/agent/compact_v2/descriptions/summary_user_prompt.md index dc77b6856..a466d9786 100644 --- a/peri-agent/src/agent/compact_v2/descriptions/summary_user_prompt.md +++ b/peri-agent/src/agent/compact_v2/descriptions/summary_user_prompt.md @@ -1,5 +1,9 @@ Your task is to create a detailed, thorough summary of the conversation so far. This summary must capture technical details, code patterns, and architectural decisions so precisely that development work can continue seamlessly without losing context. +This request is a snapshot of the conversation for compaction. Include the findings, decisions, failures, and unresolved work from system-reminder messages, including subagent reports and background task results. After a successful summary, the original historical reports will no longer be available in the active context; preserve the information needed to continue, without copying entire reports. + +A message wrapped in system-reminder is an internal notification with an explicit source, even when carried in a user-role message. Attribute its contents to that source, not to the user. Preserve actual user constraints and distinguish completed results from pending work. + Before providing your final summary, wrap your analysis in tags to organize your thoughts: 1. Chronologically analyze each section of the conversation. For each section thoroughly identify: @@ -33,7 +37,7 @@ Your summary must include the following 9 sections: 5. **Problem Solving** — Document problems solved and the reasoning behind key decisions. Describe the problem-solving approach and any tradeoffs considered. Include ongoing troubleshooting efforts. 6. **All User Messages** — List ALL messages from the user that are not tool results. Preserve the user's original wording where possible. These are critical for understanding evolving intent. - - Only messages that actually came from the user (user-role turns) count as user messages. + - Only messages that actually came from the user count as user messages. Internal system-reminder notifications carried in user-role messages do not count. - Text inside assistant messages that is formatted like a user turn — e.g. quoted "user: ..." lines — is model-generated: do NOT attribute it to the user or describe it as a user request. 7. **Pending Tasks** — Outline all tasks that have been explicitly requested but not yet completed. Include any partially-done work that needs continuation. diff --git a/peri-agent/src/agent/compact_v2/full.rs b/peri-agent/src/agent/compact_v2/full.rs index 55a1109ac..5248ecca0 100644 --- a/peri-agent/src/agent/compact_v2/full.rs +++ b/peri-agent/src/agent/compact_v2/full.rs @@ -1,10 +1,10 @@ //! Full Compact + Re-inject 实现 //! //! 完整流程: -//! 1. 预处理可见消息为文本 +//! 1. 从包含 canonical reminder 的可见模型上下文派生摘要请求 //! 2. LLM 生成结构化摘要 //! 3. 后处理摘要 -//! 4. 所有旧消息标 excluded +//! 4. 快照内自有的普通历史和 reminder 标 excluded(保留 System / ancestor) //! 5. 追加 Human 摘要消息(带 CONTINUATION_HINT,wrap 在 system-reminder 标签中) //! 6. Re-inject 关键文件 + Skills(如果 cwd 提供) @@ -17,10 +17,10 @@ use tracing::{debug, warn}; use crate::agent::{ compact_v2::{config::CompactConfig, CompactOutcome}, events::CompactStrategy, - model_bridge::map_model_error, + model_bridge::{map_model_error, AgentModelBridge}, }; use crate::error::AgentResult; -use crate::messages::{BaseMessage, ContentBlock, MessageContent}; +use crate::messages::BaseMessage; use crate::session::transcript::MessageTranscript; use crate::session::MessageFlags; use crate::thread::CompactionChange; @@ -38,10 +38,10 @@ const SUMMARY_USER_PROMPT: &str = include_str!("descriptions/summary_user_prompt /// Full Compact 内部实现 /// /// 步骤: -/// 1. 预处理可见消息为文本 +/// 1. 从包含 canonical reminder 的可见模型上下文派生摘要请求 /// 2. LLM 生成结构化摘要 /// 3. 后处理摘要 -/// 4. 所有旧消息标 excluded +/// 4. 快照内自有的普通历史和 reminder 标 excluded(保留 System / ancestor) /// 5. 追加 Human 摘要消息(带 CONTINUATION_HINT,wrap 在 system-reminder 标签中) /// 6. Re-inject 关键文件(如果 cwd 提供) pub(super) async fn full_compact_inner( @@ -50,88 +50,22 @@ pub(super) async fn full_compact_inner( config: &CompactConfig, cwd: &str, ) -> AgentResult { - let before_visible_len = transcript.visible_messages().len(); - - // 无 LLM 时降级为 Micro let llm = llm.ok_or(crate::error::AgentError::CompactNoLlm)?; - - // 收集可见消息用于预处理 - let visible: Vec<&BaseMessage> = transcript.visible_messages(); - let non_system_count = visible - .iter() - .filter(|m| !matches!(m, BaseMessage::System { .. })) - .count(); - - if non_system_count == 0 { - // 无有效对话历史——生成 fallback 摘要(保证 compact 后首条仍为 Human) - // 这是 v1 build_summary_human_message + re_inject 的不变量: - // 即使全 System history,compact 输出仍以 Human(fallback 摘要) 开头 - // 详见 peri-acp/src/session/command/compact_test.rs::test_contract_all_system_history_still_human_first - let fallback_summary = "No conversation history to compact.".to_string(); - let summary_message = build_summary_message(&fallback_summary); - transcript - .commit_compaction_lifecycle(CompactionChange { - flag_updates: Vec::new(), - appended_messages: vec![summary_message], - }) - .await?; - transcript.mark_full_compaction_committed(); - let after_visible = transcript.visible_messages().len(); - - return Ok(super::CompactResult { - strategy: CompactStrategy::Full, - affected_count: 0, - estimated_tokens_saved: 0, - before_visible_len, - after_visible_len: after_visible, - summary: Some(fallback_summary), - full_escalation_reason: None, - outcome: CompactOutcome::FullApplied, - changed_messages: 0, - changed_fields: 0, - no_op_candidates: 0, - }); - } - - // 1. 预处理消息为文本序列 - let lines = preprocess_messages_for_summary(&visible, 2000); - let conversation_text = lines.join("\n"); - - // 2. 构造 LLM 请求 - let user_content = format!( - "Compress the following conversation history:\n\n{}\n\n\n{}", - conversation_text, SUMMARY_USER_PROMPT - ); - - let request = ModelRequest::new(vec![ - ModelMessage::system_text(SUMMARY_SYSTEM_PROMPT), - ModelMessage::user_text(user_content), - ]) - .with_max_tokens(config.summary_max_tokens); - - // 3. 调用 LLM(走标准链路) - let response = llm - .complete(request, CancellationToken::new()) - .await - .map_err(map_model_error)?; - let raw_summary = response.assistant_text().unwrap_or_default(); - - if raw_summary.trim().is_empty() { - return Err(crate::error::AgentError::CompactEmptyResponse); - } - - // 4. 后处理摘要 - let summary = postprocess_summary(&raw_summary); - - // 5. 构造原子生命周期:仅排除 own region 中当前可见的非 System 原消息。 + // Full 和 Reason 读取同一已提交视图。按摘要 provider 的协议保护 reasoning;恢复器只接受 + // 与工具输入/思考独立的 ToolResult 投影,不为摘要另做有损预览。 + let visible = super::projection::render_persisted_llm_view( + transcript, + &AgentModelBridge::projection_capabilities(llm), + )?; + let before_visible_len = visible.len(); + // 先固定本次快照的 own IDs;await 期间到达 inbox 的新结果不属于本次摘要。 + // ancestor 与 System 仍由各自 owner 管理;canonical reminder 不是豁免历史。 let flag_updates: Vec<_> = transcript .entries() .iter() .skip(transcript.ancestor_len()) .filter(|entry| !transcript.flags(entry.id()).excluded) - .filter(|entry| { - matches!(entry.as_message(), Some(message) if !matches!(message, BaseMessage::System { .. })) - }) + .filter(|entry| !matches!(entry.as_message(), Some(BaseMessage::System { .. }))) .map(|entry| { ( entry.id(), @@ -143,9 +77,40 @@ pub(super) async fn full_compact_inner( }) .collect(); let affected_count = flag_updates.len(); + // 只有继承上下文时没有可替换的 own 历史,沿用空历史 fallback,避免无效摘要调用。 + let has_history = !flag_updates.is_empty() + && visible + .iter() + .any(|message| !matches!(message, BaseMessage::System { .. })); + let summary = if has_history { + // 保留历史的角色、工具配对和完整正文;摘要指令只追加到派生请求, + // 不回写原 transcript,也不提供可执行工具。 + let mut messages = AgentModelBridge::convert_messages(&visible)?; + messages.insert(0, ModelMessage::system_text(SUMMARY_SYSTEM_PROMPT)); + messages.push(ModelMessage::user_text(SUMMARY_USER_PROMPT)); + let request = ModelRequest::new(messages).with_max_tokens(config.summary_max_tokens); + let response = llm + .complete(request, CancellationToken::new()) + .await + .map_err(map_model_error)?; + if !matches!(response.stop_reason(), peri_model::StopReason::EndTurn) { + return Err(crate::error::AgentError::LlmError( + "Full Compact failed: summary response did not complete".into(), + )); + } + postprocess_summary(&response.assistant_text().unwrap_or_default()) + .ok_or(crate::error::AgentError::CompactEmptyResponse)? + } else { + // 全 System / 空历史仍保持命令输出 Human-first 的既有契约。 + "No conversation history to compact.".to_owned() + }; // 6. 先收集 re-inject 消息,随后和摘要一次性提交。 - let re_inject_result = collect_reinject_v2(transcript, config, cwd).await; + let re_inject_result = if has_history { + collect_reinject_v2(transcript, config, cwd).await + } else { + ReInjectResult::default() + }; debug!( files_injected = re_inject_result.files_injected, skills_injected = re_inject_result.skills_injected, @@ -162,7 +127,11 @@ pub(super) async fn full_compact_inner( .await?; transcript.mark_full_compaction_committed(); - let after_visible = transcript.visible_messages().len(); + let after_visible = transcript + .entries() + .iter() + .filter(|entry| !transcript.flags(entry.id()).excluded) + .count(); debug!( before_visible_len, @@ -193,147 +162,6 @@ fn build_summary_message(summary: &str) -> BaseMessage { )) } -/// 预处理消息为文本行(供 LLM 摘要使用) -/// -/// 跳过 System 消息;Image/Document 替换为占位符;按字符级截断。 -fn preprocess_messages_for_summary(messages: &[&BaseMessage], max_chars: usize) -> Vec { - let mut lines = Vec::new(); - - for msg in messages { - match msg { - BaseMessage::System { .. } => continue, - BaseMessage::Human { .. } => { - let content = replace_images_and_truncate(msg.message_content(), max_chars); - lines.push(format!("[User] {}", content)); - } - BaseMessage::Ai { tool_calls, .. } => { - let text = replace_images_and_truncate(msg.message_content(), max_chars); - let line = if tool_calls.is_empty() { - format!("[Assistant] {}", text) - } else { - let tool_summaries: Vec = - tool_calls.iter().map(format_tool_call_summary).collect(); - format!( - "[Assistant] {}(tools: {})", - text, - tool_summaries.join(", ") - ) - }; - lines.push(line); - } - BaseMessage::Tool { - tool_call_id, - is_error, - .. - } => { - let content = msg.message_content(); - lines.push(format_tool_result_summary( - tool_call_id, - content, - *is_error, - 3, - max_chars, - )); - } - } - } - - lines -} - -/// 将 content 中的 Image/Document 替换为占位符文本,并按字符级截断 -fn replace_images_and_truncate(content: &MessageContent, max_chars: usize) -> String { - let blocks = content.content_blocks(); - let parts: Vec = blocks - .iter() - .map(|b| match b { - ContentBlock::Image { .. } => "[image]".to_string(), - ContentBlock::Document { .. } => "[document]".to_string(), - ContentBlock::Text { text } => text.clone(), - ContentBlock::ToolUse { name, input, .. } => { - format!("调用 {}({})", name, input) - } - ContentBlock::Reasoning { text, .. } => text.clone(), - _ => format!("{:?}", b), - }) - .collect(); - let full = parts.join("\n"); - truncate_str(&full, max_chars) -} - -/// 工具调用摘要:保留名称和关键参数 -fn format_tool_call_summary(tc: &crate::messages::ToolCallRequest) -> String { - let args = &tc.arguments; - let key_fields = ["file_path", "path", "folder_path", "command", "pattern"]; - let mut parts = Vec::new(); - for field in &key_fields { - if let Some(val) = args.get(*field).and_then(|v| v.as_str()) { - // 字符级截断,避免 CJK panic - let truncated: String = val.chars().take(200).collect(); - let display = if truncated.chars().count() < val.chars().count() { - format!("{}...", truncated) - } else { - truncated - }; - parts.push(format!("{}=\"{}\"", field, display)); - } - } - if parts.is_empty() { - tc.name.clone() - } else { - format!("{}({})", tc.name, parts.join(", ")) - } -} - -/// 工具结果摘要:保留状态 + 首行 + 关键路径 -fn format_tool_result_summary( - tool_call_id: &str, - content: &MessageContent, - is_error: bool, - first_lines: usize, - max_chars: usize, -) -> String { - let status = if is_error { "error" } else { "ok" }; - let raw = match content - .content_blocks() - .iter() - .map(|b| match b { - ContentBlock::Text { text } => text.clone(), - ContentBlock::ToolUse { name, input, .. } => { - format!("调用 {}({})", name, input) - } - ContentBlock::Reasoning { text, .. } => text.clone(), - _ => format!("{:?}", b), - }) - .collect::>() - .join("\n") - { - s if !s.is_empty() => s, - _ => return format!("[ToolResult:{}][{}]", tool_call_id, status), - }; - - // 取前 N 行 - let head: String = raw - .lines() - .take(first_lines) - .collect::>() - .join(" | "); - - let mut out = format!("[ToolResult:{}][{}]", tool_call_id, status); - out.push_str(&format!(" {}", head)); - truncate_str(&out, max_chars) -} - -/// 按字符数截断,超出时添加 "...(truncated)" 后缀 -fn truncate_str(s: &str, max: usize) -> String { - if s.chars().count() > max { - let end: String = s.chars().take(max).collect(); - format!("{}...(truncated)", end) - } else { - s.to_string() - } -} - /// 后处理 LLM 摘要输出:移除 analysis 块,提取 summary 块,添加前缀 /// /// # Safety @@ -341,7 +169,7 @@ fn truncate_str(s: &str, max: usize) -> String { /// 本函数内部使用 `str::find` 返回的字节索引进行切片(`&text[..start]` 等)。 /// ``、``、``、`` 均为纯 ASCII 标签, /// `find()` 返回的字节索引即字符边界,不会导致 panic。 -fn postprocess_summary(raw: &str) -> String { +fn postprocess_summary(raw: &str) -> Option { let mut text = raw.to_string(); // 移除 ... 块 @@ -382,7 +210,11 @@ fn postprocess_summary(raw: &str) -> String { text = text.replace("\n\n\n", "\n\n"); } - format!("{}\n\n{}", prefix, text) + if text.is_empty() { + None + } else { + Some(format!("{}\n\n{}", prefix, text)) + } } // ─── Re-inject ────────────────────────────────────────────────────────────────── @@ -652,3 +484,7 @@ pub use peri_acp_types::compact::{extract_file_info, extract_skill_names}; #[cfg(test)] #[path = "full_test.rs"] mod tests; + +#[cfg(test)] +#[path = "full_report_test.rs"] +mod report_tests; diff --git a/peri-agent/src/agent/compact_v2/full_report_test.rs b/peri-agent/src/agent/compact_v2/full_report_test.rs new file mode 100644 index 000000000..4dd35d95f --- /dev/null +++ b/peri-agent/src/agent/compact_v2/full_report_test.rs @@ -0,0 +1,496 @@ +//! Full 报告替换:真实请求边界、长历史、失败与继承所有权。 +use super::*; +use crate::agent::stages::{compact, reason, CompactInput, ReasonInput, StageContext}; +use crate::agent::token::ContextBudget; +use crate::error::AgentError; +use crate::messages::{MessageId, ToolCallRequest}; +use crate::session::store::FrozenContext; +use crate::session::test_resources::TestSession; +use crate::session::{MessageKind, MessageQueue, MessageSource, QueuedMessage, Session}; +use peri_acp_types::store::PersistedPayload; +use peri_acp_types::system_reminder::{ + ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, + ReminderSource, SystemReminder, TrustedSystemReminder, TrustedSystemReminderFactory, + SYSTEM_REMINDER_VERSION, +}; +use peri_model::{ + Model, ModelCapabilities, ModelResponse, ModelResult, ModelStream, ModelStreamEvent, StopReason, +}; +use std::sync::{Arc, Mutex}; + +fn report(body: impl Into) -> TrustedSystemReminder { + TrustedSystemReminderFactory::for_producer() + .construct(SystemReminder { + version: SYSTEM_REMINDER_VERSION, + category: ReminderCategory::Task, + source: ReminderSource("subagent".into()), + kind: "completed".into(), + severity: ReminderSeverity::Info, + delivery: ReminderDelivery::Configurable, + audiences: ReminderAudiences(vec![ReminderAudience::Model]), + body: body.into(), + summary: None, + metadata: serde_json::json!({}), + }) + .unwrap() +} + +struct ReportModel { + requests: Mutex>, + summary: &'static str, + stop: StopReason, + incoming: Option, +} + +impl ReportModel { + fn new() -> Self { + Self { + requests: Mutex::new(Vec::new()), + summary: "REPORT_DECISION: preserve the final recommendation.", + stop: StopReason::EndTurn, + incoming: None, + } + } +} + +#[async_trait::async_trait] +impl Model for ReportModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities { + supports_streaming: true, + ..Default::default() + } + } + + async fn complete( + &self, + request: ModelRequest, + _: CancellationToken, + ) -> ModelResult { + self.requests.lock().unwrap().push(request); + if let Some(queue) = &self.incoming { + queue.push(QueuedMessage::system_reminder( + MessageKind::Defer, + MessageSource::SubAgentComplete, + report("REPORT_ARRIVED_DURING_FULL"), + )); + } + ModelResponse::new( + ModelMessage::assistant_text(self.summary), + self.stop.clone(), + None, + None, + ) + } + + async fn stream( + &self, + request: ModelRequest, + cancellation: CancellationToken, + ) -> ModelResult { + self.requests.lock().unwrap().push(request); + let response = ModelResponse::new( + ModelMessage::assistant_text("continue"), + StopReason::EndTurn, + None, + None, + )?; + Ok(ModelStream::with_parent_cancellation( + futures::stream::iter(vec![Ok(ModelStreamEvent::Completed(response))]), + cancellation, + )) + } +} + +/// [回归测试] 3000 条历史经自动 Full 后,真实 Reason 请求不再携带 23 份旧报告。 +#[tokio::test] +async fn test_full_report_long_session_releases_reports_before_next_reason_request() { + let bound = TestSession::open().await; + let mut payloads = vec![PersistedPayload::Message(BaseMessage::system( + "fixed instruction", + ))]; + for _ in 0..1488 { + payloads.push(PersistedPayload::Message(BaseMessage::human( + "inspect module", + ))); + payloads.push(PersistedPayload::Message(BaseMessage::ai( + "inspection complete", + ))); + } + let mut report_ids = Vec::new(); + for index in 0..23 { + let id = MessageId::new(); + report_ids.push(id); + payloads.push(PersistedPayload::SystemReminder { + id, + reminder: report(format!("{}REPORT_TAIL_{index}", "报告细节 ".repeat(2200))), + }); + } + assert_eq!(payloads.len(), 3000); + bound + .resources + .append_history(&bound.thread_id, &payloads) + .await + .unwrap(); + let session = Session::new( + Arc::from("/tmp"), + FrozenContext::builder().build(), + Some(bound.thread_id.clone()), + ); + *session.transcript().write() = MessageTranscript::new() + .with_own_payloads(payloads) + .with_persistence(bound.resources(), bound.thread_id.clone()); + let model = Arc::new(ReportModel::new()); + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(Arc::new(AgentModelBridge::from_arc(model.clone()))) + .with_compact_llm(model.clone()) + .with_compact_config(CompactConfig::default()) + .with_context_budget(ContextBudget::new(200_000)) + .build(); + // 固定压力仅用于驱动自动触发;不把该值当作本夹具的实测 token 数。 + context + .compact + .token_tracker + .write() + .accumulate(&peri_model::TokenUsage { + input_tokens: 196_000, + output_tokens: 0, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + assert!( + compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false + }) + .await + .unwrap() + .compacted + ); + reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + { + let requests = model.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + let summary_request = serde_json::to_string(&requests[0]).unwrap(); + for index in 0..23 { + assert!( + summary_request.contains(&format!("REPORT_TAIL_{index}")), + "摘要必须读到每份报告尾部" + ); + } + assert_eq!( + summary_request.matches("source=\\\"subagent\\\"").count(), + 23 + ); + let next_request = serde_json::to_string(&requests[1]).unwrap(); + assert!(next_request.contains("REPORT_DECISION")); + assert!(next_request.contains("fixed instruction")); + assert!(!next_request.contains("REPORT_TAIL_")); + assert!(!next_request.contains("报告细节")); + assert!(next_request.len() < 2000, "后续请求不应随历史报告正文增长"); + } + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert_eq!( + stored.payloads.len(), + 3001, + "旧报告保留在 canonical 存储供回查" + ); + assert!(report_ids.iter().all(|id| stored.flags[id].excluded)); +} + +/// [回归测试] 只有报告时仍须摘要;第二次 Full 不复活旧报告,新报告正常进入下一次摘要。 +#[tokio::test] +async fn test_full_report_only_history_and_successive_snapshots() { + let bound = TestSession::open().await; + let mut transcript = + MessageTranscript::new().with_persistence(bound.resources(), bound.thread_id.clone()); + let first = transcript.append_system_reminder(report("FIRST_REPORT")); + let model = ReportModel::new(); + let config = CompactConfig::default(); + let result = full_compact_inner(&mut transcript, Some(&model), &config, "/tmp") + .await + .unwrap(); + assert_eq!(result.affected_count, 1); + assert_eq!( + (result.before_visible_len, result.after_visible_len), + (1, 1) + ); + let later = transcript.append_system_reminder(report("LATER_REPORT")); + assert!(!transcript.flags(later).excluded); + assert!(transcript + .visible_model_messages() + .unwrap() + .iter() + .any(|m| m.id() == later)); + full_compact_inner(&mut transcript, Some(&model), &config, "/tmp") + .await + .unwrap(); + let requests = model.requests.lock().unwrap(); + assert!(serde_json::to_string(&requests[0]) + .unwrap() + .contains("FIRST_REPORT")); + let second = serde_json::to_string(&requests[1]).unwrap(); + assert!(!second.contains("FIRST_REPORT")); + assert!(second.contains("LATER_REPORT")); + assert!(second.contains("REPORT_DECISION")); + assert!(transcript.flags(first).excluded && transcript.flags(later).excluded); + assert_eq!(transcript.visible_model_messages().unwrap().len(), 1); +} + +/// [回归测试] 摘要须保留完整工具参数、结果和消息角色,不再按字段或前三行预览。 +#[tokio::test] +async fn test_full_report_request_preserves_structured_tool_history() { + let bound = TestSession::open().await; + let mut transcript = + MessageTranscript::new().with_persistence(bound.resources(), bound.thread_id.clone()); + transcript.append(BaseMessage::human("request")); + transcript.append(BaseMessage::ai_with_tool_calls( + "inspect", + vec![ToolCallRequest::new( + "inspect-call", + "Bash", + serde_json::json!({"command": format!("{}ARGUMENT_TAIL", "x".repeat(4000))}), + )], + )); + transcript.append(BaseMessage::tool_result( + "inspect-call", + "first\nsecond\nthird\nfourth\nRESULT_TAIL", + )); + let model = ReportModel::new(); + full_compact_inner( + &mut transcript, + Some(&model), + &CompactConfig::default(), + "/tmp", + ) + .await + .unwrap(); + let requests = model.requests.lock().unwrap(); + let request = &requests[0]; + assert!(matches!( + request.messages[2], + ModelMessage::Assistant { .. } + )); + assert!(matches!( + request.messages[3], + ModelMessage::ToolResult { .. } + )); + let body = serde_json::to_string(request).unwrap(); + assert!(body.contains("ARGUMENT_TAIL") && body.contains("RESULT_TAIL")); + assert!(request.tools.is_empty(), "摘要请求不得开放工具执行"); +} + +/// [回归测试] 摘要空白或截断时不排除报告、不提交占位摘要。 +#[tokio::test] +async fn test_full_report_invalid_summary_preserves_original_history() { + for (summary, stop) in [ + ("thinking only", StopReason::EndTurn), + ("partial", StopReason::MaxTokens), + ] { + let bound = TestSession::open().await; + let mut transcript = + MessageTranscript::new().with_persistence(bound.resources(), bound.thread_id.clone()); + let id = transcript.append_system_reminder(report("REPORT_MUST_SURVIVE_FAILURE")); + transcript.flush_persistence().await.unwrap(); + let model = ReportModel { + summary, + stop, + ..ReportModel::new() + }; + let error = full_compact_inner( + &mut transcript, + Some(&model), + &CompactConfig::default(), + "/tmp", + ) + .await + .unwrap_err(); + match model.stop { + StopReason::EndTurn => assert!(matches!(error, AgentError::CompactEmptyResponse)), + _ => assert!( + matches!(error, AgentError::LlmError(ref message) if message.contains("did not complete")) + ), + } + assert!(!transcript.flags(id).excluded); + assert_eq!(transcript.visible_model_messages().unwrap().len(), 1); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert_eq!(stored.payloads.len(), 1); + assert!(stored.flags.is_empty()); + } +} + +/// [回归测试] 只有继承报告的子会话没有可替换历史,不能为祖先内容调用摘要模型。 +#[tokio::test] +async fn test_full_report_inherited_only_skips_summary_model() { + let bound = TestSession::open().await; + let ancestor = MessageId::new(); + let mut transcript = MessageTranscript::new() + .with_ancestor_payloads(vec![PersistedPayload::SystemReminder { + id: ancestor, + reminder: report("ANCESTOR_REPORT"), + }]) + .with_persistence(bound.resources(), bound.thread_id.clone()); + let model = ReportModel::new(); + let result = full_compact_inner( + &mut transcript, + Some(&model), + &CompactConfig::default(), + "/tmp", + ) + .await + .unwrap(); + assert!(model.requests.lock().unwrap().is_empty()); + assert_eq!(result.affected_count, 0); + assert_eq!( + result.summary.as_deref(), + Some("No conversation history to compact.") + ); + assert!(!transcript.flags(ancestor).excluded); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(stored.flags.is_empty()); + assert_eq!(stored.payloads.len(), 1); + assert!(stored.payloads[0] + .as_message() + .unwrap() + .content() + .contains("No conversation history to compact.")); +} + +/// [回归测试] 子会话只排除自己的报告;祖先报告用于摘要但不得改写父会话标记。 +#[tokio::test] +async fn test_full_report_inherited_context_preserves_ancestor_ownership() { + let bound = TestSession::open().await; + let ancestor = MessageId::new(); + let mut transcript = MessageTranscript::new() + .with_ancestor_payloads(vec![PersistedPayload::SystemReminder { + id: ancestor, + reminder: report("ANCESTOR_REPORT"), + }]) + .with_persistence(bound.resources(), bound.thread_id.clone()); + let own = transcript.append_system_reminder(report("OWN_REPORT")); + let model = ReportModel::new(); + full_compact_inner( + &mut transcript, + Some(&model), + &CompactConfig::default(), + "/tmp", + ) + .await + .unwrap(); + let body = serde_json::to_string(&model.requests.lock().unwrap()[0]).unwrap(); + assert!(body.contains("ANCESTOR_REPORT") && body.contains("OWN_REPORT")); + assert!(!transcript.flags(ancestor).excluded); + assert!(transcript.flags(own).excluded); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(!stored.flags.contains_key(&ancestor)); + assert!(stored.flags[&own].excluded); +} + +/// [回归测试] 摘要模型调用期间到达 inbox 的新报告不在旧快照排除集合内。 +#[tokio::test] +async fn test_full_report_arriving_during_summary_stays_available() { + let bound = TestSession::open().await; + let mut transcript = + MessageTranscript::new().with_persistence(bound.resources(), bound.thread_id.clone()); + let old = transcript.append_system_reminder(report("OLD_REPORT")); + let queue = MessageQueue::new(); + let model = ReportModel { + incoming: Some(queue.clone()), + ..ReportModel::new() + }; + full_compact_inner( + &mut transcript, + Some(&model), + &CompactConfig::default(), + "/tmp", + ) + .await + .unwrap(); + let request = serde_json::to_string(&model.requests.lock().unwrap()[0]).unwrap(); + assert!(request.contains("OLD_REPORT")); + assert!(!request.contains("REPORT_ARRIVED_DURING_FULL")); + crate::agent::stages::append_messages_to_transcript(&mut transcript, queue.drain_all()); + transcript.flush_persistence().await.unwrap(); + assert!(transcript.flags(old).excluded); + let visible = transcript.visible_model_messages().unwrap(); + assert_eq!(visible.len(), 2); + assert!(visible[0].content().contains("REPORT_DECISION")); + assert!(visible[1].content().contains("REPORT_ARRIVED_DURING_FULL")); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(!stored + .flags + .get(&visible[1].id()) + .is_some_and(|flags| flags.excluded)); +} + +/// [回归测试] Full 必须沿用已提交的 Micro 视图,不能重新展开被压缩的工具输出。 +#[tokio::test] +async fn test_full_report_uses_committed_micro_view() { + let bound = TestSession::open().await; + let mut transcript = + MessageTranscript::new().with_persistence(bound.resources(), bound.thread_id.clone()); + for index in 0..8 { + let call_id = format!("micro-{index}"); + transcript.append(BaseMessage::human("inspect")); + transcript.append(BaseMessage::ai_with_tool_calls( + "read", + vec![ToolCallRequest::new( + &call_id, + "Bash", + serde_json::json!({"command": "inspect"}), + )], + )); + transcript.append(BaseMessage::tool_result(&call_id, "x".repeat(10_000))); + } + transcript.append_system_reminder(report("UNTRUNCATED_REPORT")); + let config = CompactConfig::default(); + assert!(crate::agent::compact_v2::micro_compact(&mut transcript, &config) > 0); + let view = super::super::projection::render_persisted_llm_view( + &transcript, + &super::super::projection::ProviderCapabilities::default(), + ) + .unwrap(); + let expected = AgentModelBridge::convert_messages(&view).unwrap(); + let model = ReportModel::new(); + full_compact_inner(&mut transcript, Some(&model), &config, "/tmp") + .await + .unwrap(); + let requests = model.requests.lock().unwrap(); + let messages = &requests[0].messages; + assert_eq!( + serde_json::to_value(&messages[1..messages.len() - 1]).unwrap(), + serde_json::to_value(expected).unwrap() + ); + assert!(serde_json::to_string(&messages) + .unwrap() + .contains("UNTRUNCATED_REPORT")); +} diff --git a/peri-agent/src/agent/compact_v2/full_test.rs b/peri-agent/src/agent/compact_v2/full_test.rs index a384069ce..9876977a4 100644 --- a/peri-agent/src/agent/compact_v2/full_test.rs +++ b/peri-agent/src/agent/compact_v2/full_test.rs @@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken; use super::*; use crate::agent::compact_v2::config::CompactConfig; -use crate::messages::{BaseMessage, ContentBlock, ImageSource, MessageContent}; +use crate::messages::{BaseMessage, MessageContent}; use crate::session::transcript::MessageTranscript; use crate::thread::ThreadMeta; use peri_acp_types::store::PersistedPayload; @@ -240,8 +240,9 @@ async fn full_compact_model_input_comes_from_canonical_transcript() { ); } +/// [回归测试] Full 必须摘要并排除 canonical 报告,不能永久保留全文。 #[tokio::test] -async fn full_compact_preserves_canonical_reminder_without_flags() { +async fn test_full_compact_summarizes_and_excludes_canonical_report() { use peri_acp_types::system_reminder::{ ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, @@ -260,7 +261,7 @@ async fn full_compact_preserves_canonical_reminder_without_flags() { severity: ReminderSeverity::Info, delivery: ReminderDelivery::Configurable, audiences: ReminderAudiences(vec![ReminderAudience::Model]), - body: "preserve me".into(), + body: format!("{}REPORT_TAIL_FACT", "report detail ".repeat(400)), summary: None, metadata: serde_json::json!({}), }) @@ -268,18 +269,28 @@ async fn full_compact_preserves_canonical_reminder_without_flags() { ); transcript.append(make_ai("answer")); + let model = CapturingFullModel { + requests: std::sync::Mutex::new(Vec::new()), + }; full_compact_inner( &mut transcript, - Some(&FullLifecycleModel), + Some(&model), &CompactConfig::default(), "/tmp", ) .await .unwrap(); - assert_eq!(transcript.flags(reminder_id), MessageFlags::default()); + let requests = model.requests.lock().unwrap(); + assert!( + serde_json::to_string(&requests[0]) + .unwrap() + .contains("REPORT_TAIL_FACT"), + "Full 摘要请求必须包含超过旧 2000 字符预览的报告结论" + ); + assert!(transcript.flags(reminder_id).excluded); assert!(transcript.get(reminder_id).is_some()); - assert!(transcript + assert!(!transcript .visible_model_messages() .unwrap() .iter() @@ -644,40 +655,17 @@ async fn test_full_compact_empty_transcript_skips() { // ── 辅助函数测试 ─────────────────────────────────────────────────────────── -#[test] -fn test_truncate_str_short() { - assert_eq!(truncate_str("hello", 100), "hello"); -} - -#[test] -fn test_truncate_str_exact() { - assert_eq!(truncate_str("hello", 5), "hello"); -} - -#[test] -fn test_truncate_str_long() { - let result = truncate_str("hello world", 5); - assert_eq!(result, "hello...(truncated)"); -} - -#[test] -fn test_truncate_str_cjk() { - // CJK 字符级截断不应 panic - let result = truncate_str("你好世界测试", 2); - assert_eq!(result, "你好...(truncated)"); -} - #[test] fn test_postprocess_summary_removes_analysis() { let raw = "some analysisthe summary"; - let result = postprocess_summary(raw); + let result = postprocess_summary(raw).unwrap(); assert!(!result.contains("")); } #[test] fn test_postprocess_summary_extracts_summary() { let raw = "prefix text real summary content suffix"; - let result = postprocess_summary(raw); + let result = postprocess_summary(raw).unwrap(); assert!(result.contains("real summary content")); assert!(!result.contains("")); assert!(!result.contains("prefix text")); @@ -686,74 +674,17 @@ fn test_postprocess_summary_extracts_summary() { #[test] fn test_postprocess_summary_no_tags() { let raw = "plain summary text"; - let result = postprocess_summary(raw); + let result = postprocess_summary(raw).unwrap(); assert!(result.contains("plain summary text")); } #[test] fn test_postprocess_summary_collapses_newlines() { let raw = "line1\n\n\n\n\nline2"; - let result = postprocess_summary(raw); + let result = postprocess_summary(raw).unwrap(); assert!(!result.contains("\n\n\n"), "应折叠连续空行"); } -#[test] -fn test_replace_images_and_truncate() { - let blocks = vec![ - ContentBlock::Text { - text: "some text".to_string(), - }, - ContentBlock::Image { - source: ImageSource::Url { - url: "http://example.com/img.png".to_string(), - }, - }, - ]; - let content = MessageContent::blocks(blocks); - let result = replace_images_and_truncate(&content, 100); - assert!(result.contains("[image]")); - assert!(result.contains("some text")); -} - -#[test] -fn test_format_tool_call_summary() { - let tc = crate::messages::ToolCallRequest::new( - "id1", - "Edit", - serde_json::json!({"file_path": "/tmp/test.rs", "old_string": "old"}), - ); - let result = format_tool_call_summary(&tc); - assert!(result.contains("Edit")); - assert!(result.contains("file_path")); - assert!(result.contains("/tmp/test.rs")); -} - -#[test] -fn test_format_tool_call_summary_no_key_fields() { - let tc = crate::messages::ToolCallRequest::new( - "id1", - "Bash", - serde_json::json!({"random_key": "value"}), - ); - let result = format_tool_call_summary(&tc); - assert_eq!(result, "Bash"); -} - -#[test] -fn test_format_tool_result_summary_empty() { - let content = MessageContent::text(""); - let result = format_tool_result_summary("call_1", &content, false, 3, 200); - assert!(result.contains("[ToolResult:call_1][ok]")); -} - -#[test] -fn test_format_tool_result_summary_truncates() { - let long_text = "a".repeat(500); - let content = MessageContent::text(&long_text); - let result = format_tool_result_summary("call_1", &content, false, 3, 100); - assert!(result.contains("...(truncated)"), "超长输出应被截断"); -} - // ── CompactResult 测试 ───────────────────────────────────────────────────── #[test] diff --git a/peri-agent/src/agent/compact_v2/projection.rs b/peri-agent/src/agent/compact_v2/projection.rs index c7de45ee6..0324b26ab 100644 --- a/peri-agent/src/agent/compact_v2/projection.rs +++ b/peri-agent/src/agent/compact_v2/projection.rs @@ -285,6 +285,24 @@ fn effective_tool_result_action<'a>( effective } +/// Reason 与 Full 共用的已提交模型视图;无效投影保留 canonical 内容。 +pub(crate) fn render_persisted_llm_view( + transcript: &MessageTranscript, + caps: &ProviderCapabilities, +) -> AgentResult> { + match plan_from_persisted_directives(transcript, PROJECTION_POLICY_VERSION) { + PersistedDirectiveRestore::Valid(plan) => match render_llm_view(transcript, &plan, caps) { + Ok(view) => return Ok(view), + Err(error) => tracing::warn!(%error, "已提交投影渲染失败,保留 canonical 内容"), + }, + PersistedDirectiveRestore::Invalid => { + tracing::warn!("持久化 directive 无效;保留 canonical 内容,不重新规划"); + } + PersistedDirectiveRestore::Absent => {} + } + Ok(transcript.visible_model_messages()?) +} + // ─── render_llm_view ────────────────────────────────────────────────────────── /// 根据 plan 和 provider 能力渲染 LLM 可见消息列表。 diff --git a/peri-agent/src/agent/model_bridge.rs b/peri-agent/src/agent/model_bridge.rs index b274fdff3..f5ae0ae56 100644 --- a/peri-agent/src/agent/model_bridge.rs +++ b/peri-agent/src/agent/model_bridge.rs @@ -76,7 +76,26 @@ impl AgentModelBridge { Self::convert_messages(std::slice::from_ref(message)).map(|mut messages| messages.remove(0)) } - fn convert_messages(messages: &[BaseMessage]) -> AgentResult> { + pub(crate) fn projection_capabilities(model: &dyn Model) -> ProviderCapabilities { + // 协议身份来自 prepared request 观测投影;prepare_request 失败时保守回退 Generic。 + // Anthropic 带签名 reasoning 必须整体保留(compact 投影依赖此判定),不能对 + // 所有 provider 一律报告 Generic——否则 Anthropic 的 signed reasoning 会被 + // projection 当成可截断内容处理。 + let protocol = model + .prepare_request(&ModelRequest::default()) + .map(|request| match request.protocol() { + peri_model::ProviderProtocol::OpenAiCompatible => ProviderProtocol::OpenAI, + peri_model::ProviderProtocol::Anthropic => ProviderProtocol::Anthropic, + peri_model::ProviderProtocol::Other { .. } => ProviderProtocol::Generic, + }) + .unwrap_or(ProviderProtocol::Generic); + ProviderCapabilities { + signed_reasoning_must_be_whole: protocol == ProviderProtocol::Anthropic, + protocol, + } + } + + pub(crate) fn convert_messages(messages: &[BaseMessage]) -> AgentResult> { let mut tool_names = BTreeMap::new(); for message in messages { if let BaseMessage::Ai { tool_calls, .. } = message { @@ -446,23 +465,7 @@ impl ReactLLM for AgentModelBridge { } fn provider_capabilities(&self) -> ProviderCapabilities { - // 协议身份来自 prepared request 观测投影;prepare_request 失败时保守回退 Generic。 - // Anthropic 带签名 reasoning 必须整体保留(compact 投影依赖此判定),不能对 - // 所有 provider 一律报告 Generic——否则 Anthropic 的 signed reasoning 会被 - // projection 当成可截断内容处理。 - let protocol = self - .model - .prepare_request(&ModelRequest::default()) - .map(|request| match request.protocol() { - peri_model::ProviderProtocol::OpenAiCompatible => ProviderProtocol::OpenAI, - peri_model::ProviderProtocol::Anthropic => ProviderProtocol::Anthropic, - peri_model::ProviderProtocol::Other { .. } => ProviderProtocol::Generic, - }) - .unwrap_or(ProviderProtocol::Generic); - ProviderCapabilities { - signed_reasoning_must_be_whole: protocol == ProviderProtocol::Anthropic, - protocol, - } + Self::projection_capabilities(self.model.as_ref()) } } diff --git a/peri-agent/src/agent/stages/budget_recovery_integration_test.rs b/peri-agent/src/agent/stages/budget_recovery_integration_test.rs index 7eb68a2da..11ca33651 100644 --- a/peri-agent/src/agent/stages/budget_recovery_integration_test.rs +++ b/peri-agent/src/agent/stages/budget_recovery_integration_test.rs @@ -243,12 +243,15 @@ async fn test_budget_recovery_loop_stops_after_two_committed_fulls() { { let requests = scenario.requests.lock(); for (index, request) in requests.iter().enumerate() { - assert!(request - .iter() - .any(|message| message.content().contains("initial retained reminder A"))); - assert!(request - .iter() - .any(|message| message.content().contains("initial retained reminder B"))); + for marker in ["initial retained reminder A", "initial retained reminder B"] { + assert_eq!( + request + .iter() + .any(|message| message.content().contains(marker)), + index == 0, + "历史提醒仅在 Full 前请求中保留全文;预算判定仍须相信实际 usage" + ); + } if index > 0 { assert!(request.iter().any(|message| message .content() @@ -285,7 +288,9 @@ async fn test_budget_recovery_loop_stops_after_two_committed_fulls() { 5, "初始两条、before_agent一条及两次after_agent提醒都持久保留" ); - assert!(reminder_ids.iter().all(|id| !flags.contains_key(id))); + assert!(reminder_ids + .iter() + .all(|id| flags.get(id).is_some_and(|flag| flag.excluded))); let summaries: Vec<_> = payloads .iter() .filter_map(|payload| match payload { diff --git a/peri-agent/src/agent/stages/reason.rs b/peri-agent/src/agent/stages/reason.rs index ec839e98b..f84728791 100644 --- a/peri-agent/src/agent/stages/reason.rs +++ b/peri-agent/src/agent/stages/reason.rs @@ -45,53 +45,10 @@ pub async fn run_reason(input: ReasonInput) -> AgentResult { let messages_snapshot: std::sync::Arc> = std::sync::Arc::new({ let guard = ctx.session.transcript.read(); - let visible = guard.visible_model_messages()?; - - // 已提交的投影是会话状态,与是否启用自动 Compact 无关。 - // Reason 只恢复已有投影;新计划和收益记账仅归 Compact。 - { - let caps = ctx.runtime.llm.provider_capabilities(); - // 优先使用持久化 directive,避免每 turn 重新规划 - match crate::agent::compact_v2::projection::plan_from_persisted_directives( - &guard, - crate::agent::compact_v2::PROJECTION_POLICY_VERSION, - ) { - crate::agent::compact_v2::projection::PersistedDirectiveRestore::Valid( - plan, - ) => { - // 持久化 directive 有效 → 直接渲染 - match crate::agent::compact_v2::projection::render_llm_view( - &guard, &plan, &caps, - ) { - Ok(view) => { - tracing::debug!( - action_count = plan.actions.len(), - messages_before = visible.len(), - messages_after = view.len(), - "render_llm_view (persisted directives): 投影后消息数" - ); - view - } - Err(e) => { - tracing::warn!( - error = %e, - "render_llm_view (persisted) 失败,fallback 到原始可见消息" - ); - visible - } - } - } - crate::agent::compact_v2::projection::PersistedDirectiveRestore::Absent => { - visible - } - crate::agent::compact_v2::projection::PersistedDirectiveRestore::Invalid => { - tracing::warn!( - "持久化 directive 无效;本轮使用 canonical messages,不重新规划" - ); - visible - } - } - } + crate::agent::compact_v2::projection::render_persisted_llm_view( + &guard, + &ctx.runtime.llm.provider_capabilities(), + )? }); let tools_owned: Vec> = catalog diff --git a/peri-agent/src/session/exec/compact_pipeline.rs b/peri-agent/src/session/exec/compact_pipeline.rs index 95cc40e8f..089411c79 100644 --- a/peri-agent/src/session/exec/compact_pipeline.rs +++ b/peri-agent/src/session/exec/compact_pipeline.rs @@ -93,8 +93,8 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { tracing::debug!(history_len = history.len(), "compact: pipeline started"); - // 阶段 1: 验证 history 非空(边界短路) - if history.is_empty() { + // 无普通消息不等于无上下文:绑定会话可能只有 canonical reminder。 + if history.is_empty() && (session_resources.is_none() || thread_id.is_none()) { warn!("compact: 无历史消息可压缩"); return PipelineOutcome::EarlyReturn { history, @@ -167,14 +167,23 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { }; } }; - // 与迁前的 `load_messages` 同语义:只取消息本体,reminder 不进入 compact 输入。 + // 仅用普通消息投影校验调用方身份;真正的摘要输入恢复完整 payload。 let persisted_history: Vec = snapshot + .inherited .payloads .iter() + .chain(&snapshot.payloads) .filter_map(|payload| payload.as_message().cloned()) .collect(); let mut transcript = MessageTranscript::new().with_compaction_commit_state(commit_state); - if persisted_history.is_empty() { + if snapshot.payloads.is_empty() && snapshot.inherited.payloads.is_empty() { + if history.is_empty() { + return PipelineOutcome::EarlyReturn { + history, + stop_reason: PromptStopReason::EndTurn, + message: "no history to compact".to_string(), + }; + } transcript = transcript.with_persistence(session_resources, thread_id); for message in &history { transcript.append(message.clone()); @@ -201,9 +210,10 @@ pub async fn run_pipeline(ctx: CommandContext) -> PipelineOutcome { message: "compact persistence context mismatch".to_string(), }; } - for message in persisted_history { - transcript.append(message); - } + transcript = transcript + .with_ancestor_payloads(snapshot.inherited.payloads) + .with_own_payloads(snapshot.payloads); + transcript.set_flags_batch(snapshot.inherited.flags); transcript.set_flags_batch(snapshot.flags); transcript = transcript.with_persistence(session_resources, thread_id); } diff --git a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs index 5931bc6d2..b1a091b8f 100644 --- a/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs +++ b/peri-agent/src/session/exec/executor_helpers/compact_cancel_test.rs @@ -319,6 +319,7 @@ struct Case { store: Arc, thread_id: ThreadId, history: Vec, + report_id: MessageId, result: peri_acp_types::session::PromptResult, done_count: usize, done_reasons: Vec, @@ -367,17 +368,34 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca BaseMessage::human("old manual question"), BaseMessage::ai("old manual answer"), ]; - inner - .append_history( - &thread_id, - &history - .iter() - .cloned() - .map(PersistedPayload::Message) - .collect::>(), - ) - .await - .unwrap(); + use peri_acp_types::system_reminder::{ + ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, + ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, + }; + let report_id = MessageId::new(); + let mut payloads: Vec<_> = history + .iter() + .cloned() + .map(PersistedPayload::Message) + .collect(); + payloads.push(PersistedPayload::SystemReminder { + id: report_id, + reminder: TrustedSystemReminderFactory::for_producer() + .construct(SystemReminder { + version: SYSTEM_REMINDER_VERSION, + category: ReminderCategory::Task, + source: ReminderSource("subagent".into()), + kind: "completed".into(), + severity: ReminderSeverity::Info, + delivery: ReminderDelivery::Configurable, + audiences: ReminderAudiences(vec![ReminderAudience::Model]), + body: "MANUAL_REPORT_MUST_SURVIVE_CANCEL".to_owned(), + summary: None, + metadata: serde_json::json!({}), + }) + .unwrap(), + }); + inner.append_history(&thread_id, &payloads).await.unwrap(); if pre_cancel { cancel.cancel(); } @@ -408,6 +426,7 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca &task_manager, lookup, ); + req.history_payloads = payloads; req.cwd = workspace.cwd.to_str().unwrap(); req.session_resources = Some(store.clone()); req.thread_id = Some(thread_id.clone()); @@ -429,6 +448,7 @@ async fn run_case(mode: CommitMode, pause: HandlerPause, pre_cancel: bool) -> Ca store, thread_id, history, + report_id, result, done_count: sink.push_done_count(), done_reasons, @@ -452,7 +472,14 @@ async fn assert_durable_summary(case: &Case) { assert!(payloads.iter().any(|payload| payload .as_message() .is_some_and(|message| message.content().contains("manual committed summary")))); + assert!(payloads + .iter() + .any(|payload| payload.id() == case.report_id)); let flags = stored_snapshot(case).await.flags; + assert!( + flags[&case.report_id].excluded, + "已确认或磁盘已提交的 Full 必须一并排除旧报告" + ); assert!(case .history .iter() @@ -476,6 +503,11 @@ async fn test_manual_compact_cancel_before_commit_requires_reload_without_deleti 2 ); assert!(stored_snapshot(&case).await.flags.is_empty()); + assert!(stored_snapshot(&case) + .await + .payloads + .iter() + .any(|p| p.id() == case.report_id)); assert_eq!(case.done_count, 1); } @@ -544,6 +576,12 @@ async fn test_manual_compact_precancel_preserves_verified_history_without_store_ assert_eq!(case.result.stop_reason, PromptStopReason::Cancelled); assert_eq!(case.done_reasons, ["cancelled"]); assert_eq!(case.store.calls.load(Ordering::SeqCst), 0); + assert!(stored_snapshot(&case).await.flags.is_empty()); + assert!(case + .result + .persisted_payloads + .iter() + .any(|p| p.id() == case.report_id)); assert_eq!(case.result.messages.len(), 2); assert_eq!(case.done_count, 1); } diff --git a/spec/issues/2026-09-10-p0-full-micro-compact-churn.md b/spec/issues/2026-09-10-p0-full-micro-compact-churn.md index 3b8769bbf..7d99fcb2d 100644 --- a/spec/issues/2026-09-10-p0-full-micro-compact-churn.md +++ b/spec/issues/2026-09-10-p0-full-micro-compact-churn.md @@ -293,6 +293,8 @@ FullApplied → MicroApplied → FullApplied → MicroApplied ### A5:Full 不能降低 canonical Reminder 本身的累积基线 +后续工作:[Full Compact 报告残留](2026-09-28-full-compact-retains-subagent-reports.md) 补充了 2026-09-28 本地只读观测及用户确认的目标:报告参与上下文摘要,成功后原报告退出活跃模型视图。该修复及回归验证已完成,真实 provider usage 降幅仍待现场验收;以下描述保留原审计时点的旧行为。 + **证据:静态路径与已有 `full_compact_preserves_canonical_reminder_without_flags` 测试。现场影响未量化。** `MessageTranscript::visible_messages` 不包含 canonical Reminder;Full 的摘要输入和排除集合都基于普通消息。Reason 的 `visible_model_messages` 则包含 canonical Reminder(其中包括 model audience)。现有契约测试明确要求 Full 保留 Reminder,Goal steering 等生产者又可持续追加。 diff --git a/spec/issues/2026-09-28-full-compact-retains-subagent-reports.md b/spec/issues/2026-09-28-full-compact-retains-subagent-reports.md new file mode 100644 index 000000000..fcef4a42d --- /dev/null +++ b/spec/issues/2026-09-28-full-compact-retains-subagent-reports.md @@ -0,0 +1,111 @@ +# Full Compact 遗漏系统通知中的报告,压缩后仍保留大段旧上下文 + +**状态**:代码修复及回归验证完成;待现场 provider usage 验收。用户已授权提交。 +**创建日期**:2026-09-28(Asia/Shanghai)。 +**范围**:Full Compact 的摘要输入、成功后的活跃上下文替换及持久化恢复。 + +## 用户反馈与已确认目标 + +用户反馈:会话已有约 3000 条 messages,compact 后上下文占用仍有约 50%。随后明确: + +> full compact 是 fork 上下文输出结果,所以肯定是我们处理错误了;然后compact 之后,这些报告也肯定不要的呀,记录到 issue 里面 + +本 issue 按此目标推进:Full Compact 从当前模型上下文的快照派生摘要请求,由摘要承接历史工作;子 Agent 报告等历史结果必须参与摘要,成功提交后原报告退出后续模型的活跃上下文。不能因为报告存为 `system_reminder` 就跳过摘要、永久保留全文。 + +这里的 fork 是压缩计算使用上下文快照的语义,不预先规定使用 ACP `session/fork` 或创建持久化子会话。原历史可保留在存储中供回查;“compact 后不要报告”指不再把旧报告全文重复发送给模型。 + +## 修复前已确认的实现缺陷 + +事实源: + +- [full.rs](../../peri-agent/src/agent/compact_v2/full.rs):`full_compact_inner`、原 `preprocess_messages_for_summary`(已删除)。 +- [transcript.rs](../../peri-agent/src/session/transcript.rs):`TranscriptEntry::as_message`、`visible_messages`、`visible_model_messages`。 +- [full_test.rs](../../peri-agent/src/agent/compact_v2/full_test.rs):`test_full_compact_summarizes_and_excludes_canonical_report`。 + +修复前数据流: + +1. 子 Agent 完成通知等内容以 canonical `TranscriptEntry::Reminder` 持久化,正文可能包含整份报告。 +2. Full 读取 `visible_messages()`,它通过 `as_message()` 跳过所有 canonical reminder。因此这些报告不进入摘要输入。 +3. Full 的 excluded 集合同样只接受 `as_message()` 返回的非 System 普通消息,历史报告不会被排除。 +4. Reason 的模型视图仍投影可见 reminder,所以 Full 成功后报告继续全文进入后续请求。 +5. 再次 Full 仍重复跳过相同报告;新报告持续追加,形成随会话增长、无法被 Full 回收的上下文基线。 + +此外,修复前摘要调用是把普通消息逐条预处理后拼成文本,构造独立的两条消息请求;每条正文按 2000 字符预处理。该实现不能作为“完整 fork 当前模型上下文”的证据。修复必须同时核对输入覆盖与内容完整性,不能只把 reminder 加进 excluded 集合,也不能先截掉报告关键结论再声称已摘要。 + +原 `full_compact_preserves_canonical_reminder_without_flags` 测试锁定了旧行为;现已改为 `test_full_compact_summarizes_and_excludes_canonical_report`,验证报告尾部进入摘要且成功后排除。 + +## 本地只读观测 + +2026-09-28 以 SQLite `mode=ro` 查询 `~/.peri/threads/threads.db`。样本为当前 Peri 工作区会话 `01a0e1d8-7973-7c00-82dc-4b4e747f3644`。 + +| 项目 | 数量 | +| --- | ---: | +| 持久化记录总数 | 3640 | +| 已 excluded 的普通消息 | 3542 | +| 未 excluded 的普通消息 | 7 | +| 未 excluded 的 canonical reminder | 91 | +| 其中 `subagent/completed` | 23 | +| 这 23 条完成通知的正文字符合计 | 252664 | + +91 条 reminder 全部包含 model audience;其中 90 条同时面向 TUI/diagnostics。正文长度来自解码持久化 JSON 后对 `reminder.body` 计字符,不含 JSON 外壳,不是 token 统计。查询未输出报告正文。 + +证据边界:此样本尚未被用户确认为反馈中的那个 session;未取得对应压缩前后真实 provider usage,不能据此声称已精确解释现场 50%。但“普通历史已大量排除、报告仍完整保留”的数据与上述代码路径一致。此前演示的 200k 窗口和 85k 报告等 token 数仅为说明性假设,不是观测结果。 + +## 修复契约 + +1. **摘要输入覆盖模型上下文。** 以本次 Full 的确定快照为边界,覆盖其中模型可见的历史报告及通知语义,保留身份、顺序与可信来源。不能用普通消息列表替代完整模型上下文,也不能通过无声截断遗漏报告关键内容。 +2. **成功后由摘要承接旧报告。** 快照内已纳入摘要的报告全文退出活跃模型视图,不因 canonical reminder 存储类型而豁免;后续 Full、下一轮 prompt 及冷恢复不得重新带回全文。 +3. **先摘要,再原子切换。** 沿用 durable compaction lifecycle,将摘要追加与对应排除变更在同一事务提交。摘要失败、提交前取消不得丢失原报告;已确认提交后取消仍保留结果,提交不确定仍要求 reload。 +4. **只处理本次快照。** Full 期间或之后新到达的结果不属于已摘要历史,不能被一起排除。仍有效约束应由摘要或相应权威状态承接,不能借本修复丢掉用户约束,也不能以此为由保留所有旧报告全文。 +5. **保持所有权边界。** 当前会话自己的报告可随 Full 替换;不得改写父会话的 frozen/inherited 数据。涉及继承视图时须明确派生请求与本会话投影的处理方式,不能通过修改 ancestor flags 绕过现有边界。 + +本 issue 的主因是摘要输入与压缩后替换集合不完整。调低触发阈值、降低摘要上限、清空 token 计数或只修 TUI 百分比,均不构成本问题的修复。摘要、文件、Skill 回注的统一预算可另行评估,不作为修复报告残留的前置条件。 + +## 验收与回归 + +- [x] 构造含普通消息和大段 `subagent/completed` 报告的 transcript,在捕获到的实际摘要请求中断言报告关键事实可见,包括超过当前 2000 字符截断位置的尾部结论。 +- [x] 摘要成功提交后,下一次真实 Reason 请求包含摘要且不再包含旧报告全文;断言 provider-facing 请求,不只断言 `visible_messages()` 数量下降。 +- [x] 只有报告、没有普通对话的模型上下文仍可正确摘要,不能进入 “No conversation history to compact” 分支。 +- [x] 连续两次 Full、自动 compact 与手动 `/compact` 均满足相同替换语义,不累计或复活旧报告。 +- [x] SQLite 冷加载恢复 excluded 状态与摘要,原始报告仍可作为历史回查;活跃模型视图中无全文残留。 +- [x] 覆盖摘要失败、提交前取消、提交后取消和提交结果不确定;不得先排除再丢摘要,也不得在已提交后伪回滚。 +- [x] 验证 Full 快照之后新到达的报告仍保留,摘要切换不丢失新工作;继承场景不修改父会话数据。 +- [x] 3000 条历史、23 份长报告经自动 Full 后,捕获实际 `ModelRequest`:摘要请求含每份报告尾部;下一次 Reason 请求包含摘要和 System 指令,不含报告全文;canonical 存储保留旧报告及 excluded flags。 +- [ ] 现场 provider input usage 验收:尚未调用线上模型或测量实际 token 降幅;固定模型替身只证明请求内容与生命周期,不证明摘要质量或“50% 降至多少”。 + +## 实施计划与审查重点 + +1. 先建立报告尾部内容未进入摘要、成功后报告未排除的失败回归。 +2. Full 从可见模型消息快照构建结构化摘要请求,复用正常模型消息转换与已提交 Micro 投影恢复,删除旧的逐条文本预览截断。摘要完整有效后,原子提交 own region 中普通历史与 reminder 的 excluded transitions;System 和 ancestor 的所有权不变。 +3. 手动 `/compact` 从一致存储快照恢复完整 payload、flags 与 ancestor/own 边界。普通消息 ID 校验仍保留,但不能把该校验投影当成实际摘要输入;仅有 reminder 的历史也必须可压缩。 +4. 审查失败/取消/冷恢复、仅 reminder、重复 Full、新到达结果、继承边界,以及正常 Reason 的实际请求。用确定的模型替身验证请求内容,真实 SQLite 验证持久化;不对线上会话执行试验性压缩。 +5. 同步现行契约与入口文档,运行相关 Agent/ACP 回归、格式、lint 和 doc tests,审查 staged diff 后仅提交本任务文件。未取得线上 provider usage 时保留现场验收项。 + +## 代码审查与验证结果 + +审查范围:Full 输入到原子提交、手动 pipeline 到 host 恢复、共享 Reason 投影、摘要输出有效性、ancestor/own 边界及测试断言。修正了手动 pipeline 丢 reminder 的第二处缺陷,删除独立有损预览;摘要只含 analysis 或以非 EndTurn 停止时拒绝提交,防止用空白/截断摘要替代报告。已有 Micro 投影由 Full/Reason 共用实现恢复。测试补充 Full 调用期间 inbox 新结果的边界,以及真实 SQLite 提交前/后取消和提交确认丢失。 + +| 验证 | 结果 | +| --- | --- | +| 修复前目标回归 `test_full_compact_summarizes_and_excludes_canonical_report` | 1 项按预期失败:摘要请求缺少报告尾部;exit 101 | +| `cargo test -p peri-agent --lib` | 861 passed,exit 0 | +| `cargo test -p peri-acp --lib compact` | 53 passed,exit 0 | +| `cargo test -p peri-acp --test compact_command_contract_test` | 2 passed,exit 0 | +| `cargo test -p peri-agent -p peri-acp --doc` | Agent 10 passed;ACP 无 doc tests;exit 0 | +| `cargo clippy -p peri-agent -p peri-acp --all-targets -- -D warnings` | passed,exit 0;审查中将测试锁 guard 改为词法作用域后通过 | +| `lefthook run pre-commit` | check / clippy / fmt / layer-imports / typos 全部通过,exit 0 | +| `bash scripts/check-file-size.sh` | exit 1:43 个既有超限文件(源码 5 / 测试 38);本次修改/新增的 Rust 文件全部 ≤1000 行 | + +提交 hook 首次复跑遇到共享 target 中 `libc` / `thiserror_impl` 构建产物消失,同时观察到 `cargo clean` 进程;该次提交未生成 commit。提交检查改用独立 `CARGO_TARGET_DIR`,不清理或中断其他工作。此前成功的测试与检查结果按其实际退出状态记录。 + +测试使用临时 SQLite/工作区与模型边界替身,未写入用户的本地会话库。手动冷恢复测试释放旧门面和 lease 后重新打开数据库;没有声称完成真实线上模型或跨进程端到端验收。新摘要请求携带完整可见历史,对辅助模型的上下文窗口/模态支持仍需现场确认;provider 拒绝请求时保留原历史,不回退到无声截断报告。 + +## PR 审查补充 + +[PR #174](https://github.com/KonghaYao/peri/pull/174) 的审查发现:子会话只有继承报告、没有可替换 own 历史时,恢复后的模型视图仍会触发摘要调用。已补充 `test_full_report_inherited_only_skips_summary_model`,修复前按预期因模型被调用而失败(exit 101);Full 增加 own 历史条件后沿用空历史 fallback,保留祖先标记和原有模型可见性检查。Agent 全量单元测试 862 项、ACP compact 测试 53 项及相关 crate 的 all-targets Clippy 均通过(exit 0)。 + +## 关联与文档路由 + +- [Full/Micro churn issue](2026-09-10-p0-full-micro-compact-churn.md) 的 A5 已识别 reminder 基线风险;本 issue 补充本地规模证据及用户确认的 Full 语义,独立承接报告残留修复。 +- 已同步 [ARC-COMPACT-001](../../docs/standards/architecture-contracts.md)、[Micro Compact 设计](../../docs/design/micro-compact.md) 中相关说明、[Agent code-index](../../docs/code-index/peri-agent.md) 与旧 reminder 保留测试。既有“不能靠删 reminder 伪造预算进展”仍不等于“成功摘要后必须永久保留报告”;须明确区分。 +- 初次记录仅进行了代码阅读与本地数据库只读观测;后续实施验证结果在本 issue 更新。