diff --git a/docs/code-index/peri-agent.md b/docs/code-index/peri-agent.md index 6d2002e20..2de591893 100644 --- a/docs/code-index/peri-agent.md +++ b/docs/code-index/peri-agent.md @@ -20,7 +20,7 @@ | 改输出截断恢复与未完成终态 | `src/agent/stages/{mod,act}.rs` + `src/agent/{model_bridge,react}.rs` + `src/session/exec/executor_helpers/{v2_execute,event_pump}.rs` | `run_react_loop`(mod.rs:677)、`enqueue_truncation_continuation`、`enqueue_stream_interruption_continuation`(:573)、`run_act`、`classify_loop_terminal` | 无工具 MaxTokens 保留响应并跳过完成 hook,经 Defer 最多续跑两次;连续第三次截断映射 PromptStopReason::MaxTokens。流中断保留部分正文与流式 message_id,不回灌未签名思考/半截工具、不重发文本;同样跳过完成 hook,经可信 stream_interrupted Defer 续跑;正文为空时不写空 assistant 消息(provider 拒收空 text block,判空用 `MessageContent::is_empty`)。中断预算取事件的 RetryConfig::max_attempts(含首次,累计不重置),耗尽返回 StreamRecoveryExhausted,ACP 保留底层 allowlist diagnostic;完整工具照常执行。`truncation_test.rs` 覆盖两类恢复、工具不重放、预算及取消,契约 ARC-OUTPUT-COMPLETION-001 | | 改 Full 后预算恢复判定 | `src/agent/stages/compact_progress.rs` + `src/agent/stages/reason.rs` | `CompactBudgetRecovery::{record_full_applied,begin_request,observe_response}` | 无新 Human/Tool 工作时,两次成功 Full 后的对应实际请求 usage 仍高压则返回 `CompactBudgetUnrecovered`;Reminder/AI 不重置次数,缺失/零/过期 usage 不作证据;cancel 优先;ARC-COMPACT-001 | | 改 compact 触发阈值 | `peri-acp-types/src/compact.rs`(`CompactConfig` 事实源,`apply_env_overrides` 实现在 :325;`peri-agent/src/agent/compact_v2/config.rs` 仅 re-export;`peri-acp/src/host/compact_config.rs` 是配置加载调用方) | `CompactConfig` 字段:`auto_compact_threshold`(默认 0.95)、`micro_compact_threshold`(默认 0.75)、`smart_compact_enabled`(deprecated、默认 false,但运行时仍尊重 true) | budget < `micro_compact_threshold` 跳过;达到该阈值后默认走 Micro,显式启用 deprecated Smart 时走 Smart;Micro 收益不足且 budget ≥ `auto_compact_threshold` 时升级 Full;force=true 直接 Full。注意:调低 Full 阈值时 micro 阈值必须更低,否则先走 Skip | -| 改 compact 策略选择 | `src/agent/compact_v2/mod.rs` + `src/agent/stages/compact.rs` + `src/agent/token.rs` | `determine_compact_action(budget, config)`(mod.rs:102,Skip/Micro/Smart 选择);`run_compact`(mod.rs:125 编排);阶段入口 `stages/compact.rs::run_compact`;`TokenTracker::pressure_sample_key` | Micro 计划收益不足且 `budget_pct >= auto_compact_threshold`、`reclaim_target > 0` 时先提交 Micro 再尝试 Full;自动 Compact 以有效 provider usage generation + tool-growth generation 标识压力样本,同一样本只尝试一次,新 usage 或工具增长可重新评估;LLM 缺失由 Full 执行阶段报 `CompactNoLlm`;cache-aware 仅是 Micro 分支的提前跳过条件;`planner.rs::CompactPolicy::force_full_threshold` 无消费点(遗留) | +| 改 compact 策略选择 | `src/agent/compact_v2/mod.rs` + `src/agent/stages/{compact,context_pressure}.rs` + `src/agent/token.rs` | `determine_compact_action(budget, config)`(mod.rs:102,Skip/Micro/Smart 选择);`run_compact`(mod.rs:125 编排);阶段入口 `stages/compact.rs::run_compact`;`TokenTracker::pressure_sample_key` | Micro 计划收益不足且 `budget_pct >= auto_compact_threshold`、`reclaim_target > 0` 时先提交 Micro 再尝试 Full;自动 Compact 以有效 usage、工具增长、可见请求增长标识压力样本,同一样本只评估一次;冷启动用已提交请求视图估算,Reason 准备阶段新增压力后复用无 hooks 核心补检;Full 空摘要在同次评估内有界重试,必要的 Full 失败阻止 Reason,耗尽空摘要预算返回 `CompactRetriesExhausted`;回归见 `stages/compact_retry_test.rs` 与 `tests/compact_*_adversarial_test.rs`;LLM 缺失由 Full 执行阶段报 `CompactNoLlm`;cache-aware 仅是 Micro 分支的提前跳过条件;`planner.rs::CompactPolicy::force_full_threshold` 无消费点(遗留) | | 改事件契约、通道和协议转换 | `peri-acp-types/src/event_v2/{types,bus,executor_mapping}.rs`;`src/agent/events_v2.rs` | EventBus / 三层事件 / `*_event_to_executor` | 事实源及纯契约测试在 types crate,Agent 仅 re-export;`src/agent/events_v2_test.rs` 保留公共路径和 prelude 类型 identity 检查;消费者顺序与收尾仍归各 forwarder(ARC-EVENT-001) | | 改子 Agent 事件转发与关闭排空 | `src/agent/subagent_event_forwarder.rs` | `spawn_subagent_event_forwarder`;session 使用 `spawn_subagent_event_forwarder_for_completion` | session 的 Stop 交由 lifecycle owner 在 join 后提交;observe → render → state 的 biased 优先级保持;observe Closed 只停用自身分支,三通道全部关闭且排空才退出;保留 child source 注入与生命周期事件去重;`subagent_event_forwarder_test.rs` 覆盖 producer 先关闭后的缓冲交付(ARC-EVENT-001) | | 改 Workflow agent 装配与收尾 | `src/agent/workflow/agent.rs` | `WorkflowAgentExecutor::execute`、`await_workflow_forwarder` | public Context/API 与 frozen/tool 注入入口保留;loop → drop EventBus → await forwarder → 最终统计/结果 → telemetry terminal;编排回归在 `agent/agent_test.rs` | @@ -34,8 +34,8 @@ | 加工具(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/顺序与持久化快照一致性校验后,恢复完整 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 | +| /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,Full 失败保留脱敏的类型诊断,不确定提交仍按 Internal/reload 收尾 | +| 改 LLM 调用链路 | `src/agent/stages/reason.rs` + `src/agent/model_bridge.rs` | `run_reason`;`AgentModelBridge::build_request`;model_bridge 流式事件 v2 直发 | Reason:catalog → before_model → 压力补检 → snapshot → LlmCallStart → generate(与 cancel 竞争)→ LlmCallEnd → after_model;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 无输入替换能力 | @@ -48,7 +48,7 @@ | 阶段循环入口/StageContext | stages/mod.rs | `run_react_loop`;`run_stage`;`StageContext::builder()`;`append_messages_to_transcript`。`run_stage` 先成对发射 `StageEnded`,再将 stage-local `AgentError::Interrupted` 规范化为 `LoopResult::Interrupted`;其他错误保持 `LoopResult::Error` | | Receive(排空队列 + 退出判定) | stages/receive.rs | `run_receive`;`drain_all` + `consumed_count` | | Compact(预算检查 + 触发压缩) | stages/compact.rs | `run_compact`;PreCompact/PostCompact hook | -| Reason(LLM 推理) | stages/reason.rs | `run_reason`;只恢复已提交 projection(与自动 compact 开关独立),无 directive 使用 canonical;验证 Full 后真实 usage | +| Reason(LLM 推理) | stages/reason.rs | `run_reason`;只恢复已提交 projection(与自动 compact 开关独立),无 directive 使用 canonical;准备阶段新增压力时补检 Compact,绑定最终请求预算;验证 Full 后真实 usage | | Act(工具执行或回答) | stages/act.rs | `run_act`;emit TurnCompleted | | 工具批次提交 | stages/tool_dispatch.rs | `dispatch_tools`(:73);ID/target 解析、原子转录、batch hook 与错误收敛 | | 共享调用执行 | stages/tool_dispatch/execution.rs | `collect_tool_results`(:51);审批/并发/结算,参数复用 `tools::normalize_params` | diff --git a/docs/design/micro-compact.md b/docs/design/micro-compact.md index 5c0713e9d..64b0060b7 100644 --- a/docs/design/micro-compact.md +++ b/docs/design/micro-compact.md @@ -205,7 +205,7 @@ sequenceDiagram `MicroCompactPlan` 包含: - **actions**:压缩动作列表。每条是 `(消息id, 目标粒度, 做什么动作)` 的三元组,不包含消息内容副本 -- **estimated_tokens_saved**:估算能省多少 token(chars / 4 保守估算) +- **estimated_tokens_saved**:估算能省多少 token(chars / 4 近似估算) - **target_reclaim_tokens**:需要回收的目标量 关键是 `estimated_tokens_saved`——决策流用它替代旧版 `affected_count`("标记了几条消息")。一条 10KB 的 Bash 输出估算节省 ~2500 token,一条 10 字节的 Read 输出估算节省 ~2 token,权重天差地别。 @@ -248,16 +248,20 @@ flowchart TD 当 Micro 回收量不足且预算达到 Full 阈值时,先提交有效 Micro,再尝试 Full;Full 失败时保留已经取得的 Micro 收益。planner 与收益估算都跳过 excluded 消息,且只规划 own region,不能把 Full 已排除的历史重复算作可回收量。stale round 也只从当前可见 own history 计算。 -压力样本由有效 provider usage generation 与 canonical 工具结果增长 generation 共同标识;同一样本只自动尝试一次。Act 提交工具结果后计入估算,下一次有效 input usage 结清估算;missing/zero usage 不清账。预算中已包含该增长,不能再次扣减 headroom。 +压力样本由有效 provider usage、canonical 工具结果增长和可见请求增长的 generation 共同标识;同一样本只自动评估一次,已启动的 Full 内部空摘要重试属于同一次评估。预算使用上次有效 input usage 加上对应请求之后累计的可见内容正增长,包含 assistant 正文、工具参数、用户输入、reminder 和 direct 工具 schema;工具提交估算与完整视图增长取较大值,避免双计。下一次有效 input usage 结清估算,missing/zero usage 不清账,Micro 估算缩减只移动下一次比较的位置,不能抵扣其后的新内容或替代真实 usage 证明恢复。预算中已包含该增长,不能再次扣减 headroom。 + +新 turn 和冷恢复没有 usage 时,以当前已提交模型视图及静态 system 前缀估算首个请求,不复用旧 turn 的 usage。文本采用字符启发式,二进制媒体与 data URI 使用固定占位,不能把 base64 编码长度当正文成本;文本型文档仍按正文计量。估算不能准确覆盖不同语言的 token 密度、媒体尺寸或页数、provider 包装或尚未求值的动态 system 后缀;后续有效 usage 仍是权威基线。Reason 在 `before_model` 和工具目录发布之后检查新增压力,必要时复用无 hooks 的 Compact 核心补检一次,再绑定最终发送视图;不能重复执行会产生输入的 hooks。 ### 6.3 Full 的上下文快照与报告替换 Full 与 Reason 共用 `render_persisted_llm_view` 恢复已提交的模型视图,包括 canonical reminder;派生摘要请求保留消息角色、工具配对与完整可见正文,不再使用每条 2000 字符、工具结果前三行或关键参数预览。已有 Micro 投影仍生效,避免 Full 重新展开已经隐藏的工具输出。摘要请求不开放可执行工具;冻结 prompt 与父会话的继承快照不被改写。 -子 Agent 报告与后台任务结果参与摘要,摘要指令要求保留结论、约束与未完成工作并按通知来源归因。只有正常完成且后处理后非空的摘要可以提交;截断或仅含 analysis 的响应保留原历史。提交时将快照内 own region 的普通历史与 reminder 一起标 excluded,摘要与标记使用同一持久化事务;System 和 ancestor 不在排除集合。原文留在 canonical 存储供回查,后续模型请求及冷恢复不再发送已排除报告全文。 +子 Agent 报告与后台任务结果参与摘要,摘要指令要求保留结论、约束与未完成工作并按通知来源归因。只有正常完成且后处理后非空的摘要可以提交;截断或仅含 analysis 的响应保留原历史;嵌套的分析标签与残留结束标签不能充当有效摘要;思考块之外已闭合的 `` 正文按普通文本保留,其中讨论的标签字面量不得再次被剥除。提交时将快照内 own region 的普通历史与 reminder 一起标 excluded,摘要与标记使用同一持久化事务;System 和 ancestor 不在排除集合。原文留在 canonical 存储供回查,后续模型请求及冷恢复不再发送已排除报告全文。 手动 `/compact` 从一次一致快照恢复完整 payload、flags 和 ancestor/own 边界;普通消息 ID 校验仅用于调用方一致性检查,不再决定摘要输入范围。仅有 reminder 的会话也可压缩。摘要期间新到达的 inbox 结果在后续 Receive 处理,不属于旧快照的排除集合。 +空摘要在当前 Full 调用内重试,总连续失败次数受 `max_consecutive_failures` 限制;重试前不进入 Reason,不重复应用 Micro,并保留取消边界。传输错误的重试归 provider,持久化错误不在这里重试。自动 Compact 达到 Full 阈值时,必要的 Full 失败会阻止后续 Reason:空摘要耗尽预算返回 `CompactRetriesExhausted`,缺失模型、非正常完成的摘要及 provider 错误保留各自类型。已提交的 Micro 仍保留。手动 `/compact` 通过同一结果携带安全诊断,并在错误映射前检查取消。未产生变更的尝试以 `CompactEnded` 闭合观测,只有实际变更才发出 `MessagesCompacted`。 + ### 6.4 Full 后预算验证 成功 Full 后,Reason 将实际请求与该 Full generation 绑定,用对应响应的有效 input usage 检查是否降到 `auto_compact_threshold` 以下。没有新增用户或工具工作时,连续两次 Full 后仍高压会返回 `CompactBudgetUnrecovered`,结束本轮;cancel 优先。AI 输出、摘要和 canonical Reminder 不重置次数,新的用户或工具工作开启新一轮验证。未知、零或过期 usage 不作为进展证据。此保护限制无工作进展的重复压缩,不自行删除内容或把历史排除数量当作真实 token 收益。 @@ -270,7 +274,7 @@ Full 与 Reason 共用 `render_persisted_llm_view` 恢复已提交的模型视 ### 6.6 Shadow Mode -当 `shadow_mode_enabled = true` 时,只跑 `plan_micro()` 估算,不应用任何标记。日志输出估算值。用于校准 chars→tokens 估算模型——对比估算值与下一次真实 LLM 请求的 `input_tokens`。 +自动 Compact 在 `shadow_mode_enabled = true` 时只跑 `plan_micro()` 估算,不应用任何标记;空计划且高压也不能绕过 shadow 执行 Full。手动 force 仍直接执行 Full。日志输出估算值。用于校准 chars→tokens 估算模型——对比估算值与下一次真实 LLM 请求的 `input_tokens`。 --- diff --git a/docs/standards/architecture-contracts.md b/docs/standards/architecture-contracts.md index 8a679521e..e8cbf8efc 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 从包含 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`。 +- **Rule**:Micro 计划与收益只计当前模型可见的 own region;Reason 只恢复已提交的 projection,关闭自动 compact 不改变已有投影。预算以有效 provider input usage 为权威基线,补计对应实际请求之后的可见内容正增长;没有 usage 的新 turn 或恢复会话须用当前已提交模型视图估算首个请求。工具提交估算与完整视图增长不得双计,二进制媒体不可按 base64 编码长度计为文本 token;Micro 的估算缩减不能作为真实预算恢复的证据。`before_model` 或工具目录发布新增压力后,Reason 发出请求前须复用 Compact 核心补检,不重复运行 hooks。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 证明进展。空摘要须在同一次 Compact 内有界重试;达到 Full 阈值时,必要的 Full 失败须显式结束本轮并保留安全的错误原因,禁止静默禁用后续压缩并继续推理。provider 重试预算不得在 Compact 层重新开始,手动 `/compact` 同样须保留安全诊断与取消优先语义。 +- **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 compact_retry`、`cargo test -p peri-agent --test compact_pressure_adversarial_test --test compact_failure_adversarial_test --test compact_session_adversarial_test --test compact_followup_behavior_test`、`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-types/src/error.rs b/peri-acp-types/src/error.rs index b01444987..127a3440f 100644 --- a/peri-acp-types/src/error.rs +++ b/peri-acp-types/src/error.rs @@ -56,6 +56,16 @@ pub enum AgentError { #[error("Full Compact failed: LLM returned empty summary")] CompactEmptyResponse, + #[error("Full Compact failed: summary response did not complete")] + CompactIncompleteResponse { stop_reason: peri_model::StopReason }, + + #[error("Full Compact failed after {attempts} attempts while context usage is {context_tokens}/{context_window} tokens. The turn was stopped before another model request; retry or change the compact model.")] + CompactRetriesExhausted { + attempts: u32, + context_tokens: u64, + context_window: u32, + }, + #[error("Full Compact did not restore the context budget after {full_attempts} attempts for the same work ({input_tokens}/{context_window} input tokens). Reduce retained instructions or use a larger context window.")] CompactBudgetUnrecovered { input_tokens: u32, @@ -273,6 +283,14 @@ impl AgentError { /// `Other`/`SerializationError` 保持通用描述。 pub fn user_facing_message(&self) -> String { match self { + Self::CompactIncompleteResponse { stop_reason } => { + let reason = match stop_reason { + peri_model::StopReason::MaxTokens => "output token limit", + peri_model::StopReason::ToolUse => "unexpected tool call", + _ => "unexpected stop reason", + }; + format!("Full Compact failed: the summary did not complete ({reason}). Retry or change the compact model.") + } Self::Other(_) => "An internal error occurred. Check logs for details.".to_string(), Self::LlmError(_) => { "An LLM API error occurred. Please check your API configuration.".to_string() @@ -349,6 +367,24 @@ fn model_error_facts(diagnostic: &peri_model::ModelErrorDiagnostic) -> Vec Self::new( + ExecutionFailureKind::Llm, + error.user_facing_message(), + None, + None, + ), crate::error::AgentError::ModelError(source) | crate::error::AgentError::StreamRecoveryExhausted { source, .. } => { let diagnostic = source.diagnostic(); @@ -419,3 +425,7 @@ impl Default for PromptResult { } } } + +#[cfg(test)] +#[path = "execution_test.rs"] +mod tests; diff --git a/peri-acp-types/src/session/execution_test.rs b/peri-acp-types/src/session/execution_test.rs new file mode 100644 index 000000000..9cab7d771 --- /dev/null +++ b/peri-acp-types/src/session/execution_test.rs @@ -0,0 +1,25 @@ +use super::*; +use crate::error::AgentError; + +#[test] +fn test_compact_incomplete_response_has_safe_llm_failure() { + for (stop_reason, expected) in [ + (peri_model::StopReason::MaxTokens, "output token limit"), + (peri_model::StopReason::ToolUse, "unexpected tool call"), + ( + peri_model::StopReason::Other { + value: "secret=must-not-leak".into(), + }, + "unexpected stop reason", + ), + ] { + let error = AgentError::CompactIncompleteResponse { stop_reason }; + assert!(!error.to_string().contains("must-not-leak")); + let failure = ExecutionFailure::from_agent_error(&error); + assert_eq!(failure.kind, ExecutionFailureKind::Llm); + assert!(failure.public_message.contains(expected)); + assert!(!failure.public_message.contains("must-not-leak")); + assert!(failure.diagnostic.is_none()); + assert!(failure.http_status.is_none()); + } +} diff --git a/peri-agent/src/agent/compact_v2/_test.rs b/peri-agent/src/agent/compact_v2/_test.rs index f36dea9f9..81c0b2c43 100644 --- a/peri-agent/src/agent/compact_v2/_test.rs +++ b/peri-agent/src/agent/compact_v2/_test.rs @@ -201,7 +201,15 @@ async fn test_run_compact_rerun_clears_stale_excluded_flags() { ) .await; - assert_eq!(result.strategy, CompactStrategy::Skip, "连续失败超限应跳过"); + assert_eq!( + result.strategy, + CompactStrategy::Full, + "手动 Full 失败预算耗尽应明确失败" + ); + assert!(matches!( + result.failure, + Some(crate::error::AgentError::CompactRetriesExhausted { attempts: 3, .. }) + )); assert!(t.flags(id1).excluded, "excluded 标记不再在 skip 时自动清除"); assert!(t.flags(id2).excluded, "excluded 标记不再在 skip 时自动清除"); } diff --git a/peri-agent/src/agent/compact_v2/full.rs b/peri-agent/src/agent/compact_v2/full.rs index 5248ecca0..cbb0c38f9 100644 --- a/peri-agent/src/agent/compact_v2/full.rs +++ b/peri-agent/src/agent/compact_v2/full.rs @@ -94,12 +94,20 @@ pub(super) async fn full_compact_inner( .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(), - )); + return Err(crate::error::AgentError::CompactIncompleteResponse { + stop_reason: response.stop_reason().clone(), + }); } - postprocess_summary(&response.assistant_text().unwrap_or_default()) - .ok_or(crate::error::AgentError::CompactEmptyResponse)? + let text = response.assistant_text().unwrap_or_default(); + postprocess_summary(&text).ok_or_else(|| { + // 仅记录形状与 usage,区分无正文和 analysis-only,不输出历史或摘要正文。 + warn!( + response_chars = text.chars().count(), + output_tokens = response.usage().map(|usage| usage.output_tokens), + "Full Compact response has no usable summary" + ); + crate::error::AgentError::CompactEmptyResponse + })? } else { // 全 System / 空历史仍保持命令输出 Human-first 的既有契约。 "No conversation history to compact.".to_owned() @@ -147,6 +155,7 @@ pub(super) async fn full_compact_inner( summary: Some(summary), full_escalation_reason: None, outcome: CompactOutcome::FullApplied, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, @@ -170,38 +179,7 @@ fn build_summary_message(summary: &str) -> BaseMessage { /// ``、``、``、`` 均为纯 ASCII 标签, /// `find()` 返回的字节索引即字符边界,不会导致 panic。 fn postprocess_summary(raw: &str) -> Option { - let mut text = raw.to_string(); - - // 移除 ... 块 - loop { - let start_tag = ""; - let end_tag = ""; - if let Some(start) = text.find(start_tag) { - if let Some(end) = text[start..].find(end_tag) { - let remove_end = start + end + end_tag.len(); - // Safety: 为纯 ASCII 标签,字节索引即字符边界 - text = format!("{}{}", &text[..start], &text[remove_end..]); - } else { - // Safety: 为纯 ASCII 标签,字节索引即字符边界 - text = text[..start].to_string(); - break; - } - } else { - break; - } - } - - // 提取 ... 内容 - if let Some(start) = text.find("") { - let content_start = start + "".len(); - if let Some(end) = text[content_start..].find("") { - // Safety: / 为纯 ASCII 标签,字节索引即字符边界 - text = text[content_start..content_start + end].trim().to_string(); - } else { - // Safety: 为纯 ASCII 标签,字节索引即字符边界 - text = text[content_start..].trim().to_string(); - } - } + let mut text = extract_summary_text(raw)?; let prefix = "This session continues from a previous conversation. Below is a summary of the prior dialogue."; @@ -217,6 +195,62 @@ fn postprocess_summary(raw: &str) -> Option { } } +// 只提取思考块之外的闭合 summary,正文中的标签可能是任务讨论的字面量。 +// 没有闭合 summary 时保留原有回退:剥除配对思考块及未闭合思考尾部。 +fn extract_summary_text(raw: &str) -> Option { + const TAGS: [(&str, &str, bool); 7] = [ + ("", "summary", true), + ("", "analysis", true), + ("", "analysis", false), + ("", "thinking", true), + ("", "thinking", false), + ("", "think", true), + ("", "think", false), + ]; + let mut remaining = raw; + let mut result = String::new(); + let mut stack = Vec::new(); + while let Some((position, tag, name, opening)) = TAGS + .iter() + .filter_map(|(tag, name, opening)| { + remaining + .find(tag) + .map(|position| (position, *tag, *name, *opening)) + }) + .min_by_key(|(position, ..)| *position) + { + if stack.is_empty() { + result.push_str(&remaining[..position]); + } + if name == "summary" { + let body_start = position + tag.len(); + if stack.is_empty() { + if let Some(body_len) = remaining[body_start..].find("") { + return Some(remaining[body_start..body_start + body_len].to_owned()); + } + // 未闭合 summary 仍走后续思考块过滤,再沿用正文回退。 + result.push_str(tag); + } + remaining = &remaining[body_start..]; + continue; + } + if opening { + stack.push(name); + } else if stack.pop() != Some(name) { + return None; + } + remaining = &remaining[position + tag.len()..]; + } + if stack.is_empty() { + result.push_str(remaining); + } + // 到此不存在可提取的闭合 summary;保留既有未闭合 summary 回退。 + if let Some(start) = result.find("") { + result = result[start + "".len()..].to_owned(); + } + Some(result) +} + // ─── Re-inject ────────────────────────────────────────────────────────────────── /// Full Compact 后重新注入的关键信息结果 diff --git a/peri-agent/src/agent/compact_v2/full_report_test.rs b/peri-agent/src/agent/compact_v2/full_report_test.rs index 4dd35d95f..b807f7536 100644 --- a/peri-agent/src/agent/compact_v2/full_report_test.rs +++ b/peri-agent/src/agent/compact_v2/full_report_test.rs @@ -320,9 +320,12 @@ async fn test_full_report_invalid_summary_preserves_original_history() { .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!(matches!( + error, + AgentError::CompactIncompleteResponse { + stop_reason: StopReason::MaxTokens + } + )), } assert!(!transcript.flags(id).excluded); assert_eq!(transcript.visible_model_messages().unwrap().len(), 1); diff --git a/peri-agent/src/agent/compact_v2/full_test.rs b/peri-agent/src/agent/compact_v2/full_test.rs index 9876977a4..843e61b14 100644 --- a/peri-agent/src/agent/compact_v2/full_test.rs +++ b/peri-agent/src/agent/compact_v2/full_test.rs @@ -685,6 +685,43 @@ fn test_postprocess_summary_collapses_newlines() { assert!(!result.contains("\n\n\n"), "应折叠连续空行"); } +#[test] +fn test_postprocess_summary_rejects_reasoning_only_and_malformed_blocks() { + for raw in [ + "private", + "first second", + "private", + "unfinished", + "unfinished", + "", + "private", + ] { + assert!( + postprocess_summary(raw).is_none(), + "思考标签不得变成可提交摘要:{raw}" + ); + } +} + +#[test] +fn test_postprocess_summary_preserves_body_after_nested_reasoning() { + let raw = "hidden nested hidden保留结论与 analysis 方法。unfinished tail"; + let summary = postprocess_summary(raw).unwrap(); + assert!(summary.ends_with("保留结论与 analysis 方法。")); + assert!(!summary.contains("hidden")); + assert!(!summary.contains("unfinished")); +} + +#[test] +fn test_postprocess_summary_preserves_plain_text_and_similar_tag_names() { + let body = "analysis and thinking are ordinary words; 保留正文 <analysis>"; + let summary = postprocess_summary(body).unwrap(); + assert!( + summary.ends_with(body), + "只识别精确控制标签,不剥除相近正文" + ); +} + // ── CompactResult 测试 ───────────────────────────────────────────────────── #[test] @@ -698,6 +735,7 @@ fn test_compact_result_fields() { summary: None, full_escalation_reason: None, outcome: crate::agent::compact_v2::CompactOutcome::MicroApplied, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, diff --git a/peri-agent/src/agent/compact_v2/mod.rs b/peri-agent/src/agent/compact_v2/mod.rs index ebb3596a6..1088abdf7 100644 --- a/peri-agent/src/agent/compact_v2/mod.rs +++ b/peri-agent/src/agent/compact_v2/mod.rs @@ -42,7 +42,7 @@ pub use projection::{ // ─── CompactResult ─────────────────────────────────────────────────────────────── /// Compact 执行结果 -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct CompactResult { /// 使用的策略 pub strategy: CompactStrategy, @@ -60,6 +60,8 @@ pub struct CompactResult { pub full_escalation_reason: Option, /// 本轮 Compact 的实际语义结果。 pub outcome: CompactOutcome, + /// Full 未完成的结构化原因;已应用的 Micro 仍由 outcome 与统计保留。 + pub failure: Option, /// 去重 message_id 数量(Micro 投影变更计数) pub changed_messages: usize, /// CompactToolInput 中的所有字段总数 @@ -114,7 +116,7 @@ pub fn determine_compact_action(budget: f64, config: &CompactConfig) -> CompactA /// 根据 ContextPressure 选择策略并执行 Compact /// /// 触发流程(新): -/// - 防死循环:连续失败超限则跳过 +/// - Full 重试预算在实际需要 Full 时统一检查,耗尽返回结构化失败 /// - force=true:直接 Full /// - 计算 budget_pct,判定 Micro/Smart/Skip /// - Micro:dry-run plan_micro → 检查 estimated_tokens_saved → @@ -133,9 +135,8 @@ pub async fn run_compact( ) -> CompactResult { let before_visible_len = transcript.visible_messages().len(); - // 防死循环:连续失败超限则跳过 - if *consecutive_failures >= config.max_consecutive_failures { - debug!(consecutive_failures, "Compact 降级:连续失败超限,跳过本轮"); + // 自动关闭不影响显式手动 force。 + if !force && !config.auto_compact_enabled { return CompactResult { strategy: CompactStrategy::Skip, affected_count: 0, @@ -145,6 +146,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Skipped, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, @@ -157,7 +159,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::ManualForce, @@ -186,6 +188,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Skipped, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, @@ -211,6 +214,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Skipped, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, @@ -221,37 +225,37 @@ pub async fn run_compact( // Dry-run:先用 plan_micro 估算效果(无副作用) let plan = plan_micro(transcript, config, true); + // Shadow mode:只估算不应用 + if config.shadow_mode_enabled { + info!( + estimated_saved = plan.estimated_tokens_saved, + actions_count = plan.actions.len(), + shadow = true, + "Shadow mode: 估算 compact 收益(未应用)" + ); + return CompactResult { + strategy: CompactStrategy::Skip, + affected_count: 0, + estimated_tokens_saved: plan.estimated_tokens_saved, + before_visible_len, + after_visible_len: before_visible_len, + summary: None, + full_escalation_reason: None, + outcome: CompactOutcome::Shadowed, + failure: None, + changed_messages: 0, + changed_fields: 0, + no_op_candidates: plan.no_op_candidates, + }; + } + // 空 plan → 无可 compact 消息 // 但如果 budget 已超过 Full 阈值,直接尝试 Full Compact // 否则纯对话场景下 context 会持续膨胀到远超 100% 而永远不 compact // - // 安全性:Full Compact 依赖 compact_llm 生成摘要;若 llm 为 None, - // Full 必然失败(CompactNoLlm),不应在此路径触发——否则每轮都 - // consecutive_failures++ 最终达到 max_consecutive_failures 上限, - // 导致 compact 被永久静默禁用。 + // 模型缺失同样属于 Full 失败,由 stage 显式终止高压请求。 if plan.actions.is_empty() { if budget_pct >= config.auto_compact_threshold { - if llm.is_none() { - warn!( - "Micro Compact: plan 为空且 budget 高位({:.1}%),但 compact_llm 未配置,无法执行 Full Compact。请配置 compact_llm 或启用 Micro Compact 可用工具。", - budget_pct * 100.0 - ); - return CompactResult { - strategy: CompactStrategy::Skip, - affected_count: 0, - estimated_tokens_saved: 0, - before_visible_len, - after_visible_len: before_visible_len, - summary: None, - full_escalation_reason: Some( - FullEscalationReason::ForceThresholdExceeded, - ), - outcome: CompactOutcome::Skipped, - changed_messages: 0, - changed_fields: 0, - no_op_candidates: 0, - }; - } debug!( "Micro Compact: plan 为空但 budget 高位({:.1}%),直接尝试 Full", budget_pct * 100.0 @@ -260,7 +264,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::ForceThresholdExceeded, @@ -280,35 +284,13 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Skipped, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, }; } - // Shadow mode:只估算不应用 - if config.shadow_mode_enabled { - info!( - estimated_saved = plan.estimated_tokens_saved, - actions_count = plan.actions.len(), - shadow = true, - "Shadow mode: 估算 compact 收益(未应用)" - ); - return CompactResult { - strategy: CompactStrategy::Skip, - affected_count: 0, - estimated_tokens_saved: plan.estimated_tokens_saved, - before_visible_len, - after_visible_len: before_visible_len, - summary: None, - full_escalation_reason: None, - outcome: CompactOutcome::Shadowed, - changed_messages: 0, - changed_fields: 0, - no_op_candidates: plan.no_op_candidates, - }; - } - if plan.estimated_tokens_saved >= reclaim_target && plan.has_changes() { // Micro 满足回收目标 → 应用 let affected = micro::micro_compact(transcript, config); @@ -328,6 +310,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::MicroApplied, + failure: None, changed_messages: plan.changed_messages, changed_fields: plan.changed_fields, no_op_candidates: plan.no_op_candidates, @@ -351,7 +334,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::InsufficientReclaim, @@ -393,6 +376,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::MicroApplied, + failure: None, changed_messages: plan.changed_messages, changed_fields: plan.changed_fields, no_op_candidates: plan.no_op_candidates, @@ -417,6 +401,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Shadowed, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: plan.no_op_candidates, @@ -432,31 +417,9 @@ pub async fn run_compact( // 空结果 → 无可 compact 消息 // 但如果 budget 已超过 Full 阈值,直接尝试 Full Compact // - // 安全性:Full Compact 依赖 compact_llm;若 llm 为 None,不应在此路径 - // 触发,避免 consecutive_failures 无意义增长到上限。 + // 模型缺失由 Full 返回结构化失败,不能静默放行高压请求。 if affected == 0 { if budget_pct >= config.auto_compact_threshold { - if llm.is_none() { - warn!( - "Smart Compact: 无消息可 compact 且 budget 高位({:.1}%),但 compact_llm 未配置,无法执行 Full Compact。", - budget_pct * 100.0 - ); - return CompactResult { - strategy: CompactStrategy::Skip, - affected_count: 0, - estimated_tokens_saved: 0, - before_visible_len, - after_visible_len: before_visible_len, - summary: None, - full_escalation_reason: Some( - FullEscalationReason::ForceThresholdExceeded, - ), - outcome: CompactOutcome::Skipped, - changed_messages: 0, - changed_fields: 0, - no_op_candidates: 0, - }; - } debug!( "Smart Compact: 无消息可 compact 但 budget 高位({:.1}%),直接尝试 Full", budget_pct * 100.0 @@ -465,7 +428,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::ForceThresholdExceeded, @@ -483,6 +446,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::Skipped, + failure: None, changed_messages: 0, changed_fields: 0, no_op_candidates: 0, @@ -497,7 +461,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::ForceThresholdExceeded, @@ -530,6 +494,7 @@ pub async fn run_compact( summary: None, full_escalation_reason: None, outcome: CompactOutcome::SmartApplied, + failure: None, changed_messages: plan.changed_messages, changed_fields: plan.changed_fields, no_op_candidates: plan.no_op_candidates, @@ -542,7 +507,7 @@ pub async fn run_compact( transcript, llm, config, - before_visible_len, + pressure, consecutive_failures, cwd, FullEscalationReason::InsufficientReclaim, @@ -565,39 +530,59 @@ pub async fn run_compact( } } -/// 运行 Full Compact(含失败降级逻辑) +/// 运行 Full Compact;在同一边界管理摘要重试预算并保留失败原因。 async fn run_full_or_degrade( transcript: &mut MessageTranscript, llm: Option<&dyn peri_model::Model>, config: &CompactConfig, - before_visible_len: usize, + pressure: &ContextPressure, consecutive_failures: &mut u32, cwd: &str, escalation_reason: FullEscalationReason, ) -> CompactResult { - match full::full_compact_inner(transcript, llm, config, cwd).await { - Ok(mut result) => { - *consecutive_failures = 0; - result.full_escalation_reason = Some(escalation_reason); - result + let before_visible_len = transcript.visible_messages().len(); + let error = loop { + if *consecutive_failures >= config.max_consecutive_failures { + break crate::error::AgentError::CompactRetriesExhausted { + attempts: *consecutive_failures, + context_tokens: pressure.estimated_tokens, + context_window: pressure.context_window, + }; } - Err(e) => { - warn!(error = %e, "Full Compact 失败"); - *consecutive_failures += 1; - CompactResult { - strategy: CompactStrategy::Full, - affected_count: 0, - estimated_tokens_saved: 0, - before_visible_len, - after_visible_len: transcript.visible_messages().len(), - summary: None, - full_escalation_reason: Some(escalation_reason), - outcome: CompactOutcome::FullFailed, - changed_messages: 0, - changed_fields: 0, - no_op_candidates: 0, + match full::full_compact_inner(transcript, llm, config, cwd).await { + Ok(mut result) => { + *consecutive_failures = 0; + result.full_escalation_reason = Some(escalation_reason); + return result; + } + Err(error) => { + *consecutive_failures = consecutive_failures.saturating_add(1); + warn!(%error, attempts = *consecutive_failures, "Full Compact 失败"); + // 空摘要没有提交历史替换,在当前 Compact 内恢复并共享同一预算。 + // Provider 自己负责传输重试;持久化错误与提交不确定不得在这里重试。 + if matches!(error, crate::error::AgentError::CompactEmptyResponse) + && !transcript.compaction_commit_state().is_uncertain() + { + tokio::task::yield_now().await; + continue; + } + break error; } } + }; + CompactResult { + strategy: CompactStrategy::Full, + affected_count: 0, + estimated_tokens_saved: 0, + before_visible_len, + after_visible_len: transcript.visible_messages().len(), + summary: None, + full_escalation_reason: Some(escalation_reason), + outcome: CompactOutcome::FullFailed, + failure: Some(error), + changed_messages: 0, + changed_fields: 0, + no_op_candidates: 0, } } diff --git a/peri-agent/src/agent/compact_v2/trigger_test.rs b/peri-agent/src/agent/compact_v2/trigger_test.rs index 00a42eff9..7048fa0f9 100644 --- a/peri-agent/src/agent/compact_v2/trigger_test.rs +++ b/peri-agent/src/agent/compact_v2/trigger_test.rs @@ -643,7 +643,7 @@ async fn test_smart_then_full_success_aggregates_metrics() { } #[tokio::test] -async fn test_run_compact_failure_limit_returns_explicit_skipped_outcome() { +async fn test_run_compact_failure_limit_returns_explicit_full_failure() { let mut t = MessageTranscript::new(); let config = CompactConfig { max_consecutive_failures: 3, @@ -663,15 +663,23 @@ async fn test_run_compact_failure_limit_returns_explicit_skipped_outcome() { assert_eq!( result.strategy, - CompactStrategy::Skip, - "到达失败上限必须明确跳过" + CompactStrategy::Full, + "需要 Full 且已达失败上限必须明确失败" ); assert_eq!( result.outcome(), - CompactOutcome::Skipped, + CompactOutcome::FullFailed, "到达失败上限不得表达为 MicroApplied" ); assert_eq!(result.affected_count, 0, "跳过时不应影响消息"); + assert!(matches!( + result.failure, + Some(crate::error::AgentError::CompactRetriesExhausted { + attempts: 3, + context_tokens: 196_000, + context_window: 200_000 + }) + )); assert_eq!( failures, config.max_consecutive_failures, "跳过不应改写失败计数" diff --git a/peri-agent/src/agent/model_bridge.rs b/peri-agent/src/agent/model_bridge.rs index f5ae0ae56..088522a72 100644 --- a/peri-agent/src/agent/model_bridge.rs +++ b/peri-agent/src/agent/model_bridge.rs @@ -419,6 +419,17 @@ impl AgentModelBridge { #[async_trait] impl ReactLLM for AgentModelBridge { + fn estimate_request_tokens(&self, messages: &[BaseMessage], tools: &[&dyn BaseTool]) -> u64 { + // 只读冻结前缀,不构建 provider 请求,也不第二次调用动态贡献 provider。 + // 冷启动尚未知动态后缀成本;后续有效 usage 会将其纳入权威基线。 + crate::agent::token::estimate_request_tokens(messages, tools).saturating_add( + self.system + .as_ref() + .map(|system| (system.chars().count() as u64).div_ceil(4)) + .unwrap_or(0), + ) + } + async fn generate_reasoning( &self, messages: &[BaseMessage], diff --git a/peri-agent/src/agent/model_bridge_test.rs b/peri-agent/src/agent/model_bridge_test.rs index 838de8fc2..77b158bb6 100644 --- a/peri-agent/src/agent/model_bridge_test.rs +++ b/peri-agent/src/agent/model_bridge_test.rs @@ -194,6 +194,27 @@ struct CaptureSystemModel { streamed_requests: Arc>>, } +/// [回归测试] 压力估算包含 frozen system,却不得再次读取动态贡献或构造请求。 +#[test] +fn test_bridge_pressure_estimate_is_pure_and_includes_frozen_system() { + let streamed_requests = Arc::new(Mutex::new(Vec::new())); + let provider_calls = Arc::new(AtomicUsize::new(0)); + let calls = provider_calls.clone(); + let bridge = AgentModelBridge::from_arc(Arc::new(CaptureSystemModel { + streamed_requests: streamed_requests.clone(), + })) + .with_system("base".repeat(90_000)) + .with_system_contribution_provider(Arc::new(move || { + calls.fetch_add(1, Ordering::SeqCst); + "dynamic".into() + })); + let messages = [BaseMessage::human("more".repeat(6_000))]; + assert_eq!(bridge.estimate_request_tokens(&messages, &[]), 96_000); + assert_eq!(bridge.estimate_request_tokens(&messages, &[]), 96_000); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + assert!(streamed_requests.lock().unwrap().is_empty()); +} + #[async_trait] impl Model for CaptureSystemModel { fn capabilities(&self) -> ModelCapabilities { diff --git a/peri-agent/src/agent/react.rs b/peri-agent/src/agent/react.rs index 83bf267af..4f1655799 100644 --- a/peri-agent/src/agent/react.rs +++ b/peri-agent/src/agent/react.rs @@ -278,6 +278,12 @@ pub trait ReactLLM: Send + Sync { 200_000 } + /// 纯请求大小估算,用于无 usage 的启动及相邻请求增长;不得调用 provider。 + /// 实现可计入自身持有的静态 system 前缀,但不得重复读取动态贡献。 + fn estimate_request_tokens(&self, messages: &[BaseMessage], tools: &[&dyn BaseTool]) -> u64 { + crate::agent::token::estimate_request_tokens(messages, tools) + } + /// 返回由安全 `PreparedModelRequest` 投影出的 Provider 请求体,用于受控观测。 /// /// 此方法绝不返回 headers 或认证信息。默认实现返回 None。 @@ -344,6 +350,10 @@ impl ReactLLM for Box { (**self).context_window() } + fn estimate_request_tokens(&self, messages: &[BaseMessage], tools: &[&dyn BaseTool]) -> u64 { + (**self).estimate_request_tokens(messages, tools) + } + fn observed_provider_request_body( &self, messages: &[BaseMessage], diff --git a/peri-agent/src/agent/stages/compact.rs b/peri-agent/src/agent/stages/compact.rs index a6ed6b557..cc9fccd0a 100644 --- a/peri-agent/src/agent/stages/compact.rs +++ b/peri-agent/src/agent/stages/compact.rs @@ -8,7 +8,7 @@ //! - 决策指标:estimated_tokens_saved >= reclaim_target //! - force=true:直接 Full(跳过 Micro/Smart) //! -//! Full Compact 失败时 `compact_consecutive_failures` 累加,达上限后降级跳过。 +//! 空摘要在本阶段内有界重试;高压下 Full 失败必须终止,不能静默跳过。 use super::{CompactInput, CompactOutput}; use crate::agent::compact_v2::config::CompactConfig; @@ -17,7 +17,6 @@ use crate::agent::compact_v2::planner::ContextPressure; /// 运行 Compact 阶段 pub async fn run_compact(input: CompactInput) -> crate::error::AgentResult { let ctx = &input.context; - let step = ctx.session.turn.current_step(); // PreCompact 插件 hook 回调(fire-and-forget) if let Some(ref hook) = ctx.compact.compact_pre_hook { @@ -29,6 +28,39 @@ pub async fn run_compact(input: CompactInput) -> crate::error::AgentResult 0 || output.as_ref().map(|o| o.compacted).unwrap_or(false); + hook(compacted, affected_count); + } + + output +} + +#[path = "context_pressure.rs"] +pub(super) mod context_pressure; + +/// Reason 的末次预算检查复用同一核心,不重复运行可产生新输入的 hooks。 +pub(crate) async fn run_compact_core( + input: CompactInput, +) -> crate::error::AgentResult { + compact_core(input).await.0 +} + +async fn compact_core(input: CompactInput) -> (crate::error::AgentResult, usize) { + let ctx = &input.context; + let step = ctx.session.turn.current_step(); tracing::trace!(step, has_tool_calls = input.has_tool_calls, "Compact 阶段"); // PostCompact hook 需要 affected_count;在所有 break 路径前声明 @@ -59,6 +91,10 @@ pub async fn run_compact(input: CompactInput) -> crate::error::AgentResult crate::error::AgentResult { // G6: 检查 compact 是否在 cancel 前已提交变更(通过 flag 计数对比)。 @@ -332,6 +368,7 @@ pub async fn run_compact(input: CompactInput) -> crate::error::AgentResult crate::error::AgentResult crate::error::AgentResult= config.auto_compact_threshold { + break 'compact_core Err(error); + } + } break 'compact_core Ok(CompactOutput { compacted }); }; - // after_compact hook:无论 compact 是否实际执行,均通知中间件 - if let Err(e) = super::middleware_runner::run_after_compact(ctx).await { - tracing::warn!(error = %e, "after_compact hook 失败"); - } - - // PostCompact 插件 hook 回调(所有返回路径统一触发) - if let Some(ref hook) = ctx.compact.compact_post_hook { - let compacted = output.as_ref().map(|o| o.compacted).unwrap_or(false); - hook(compacted, affected_count); - } - - output + (output, affected_count) } // An unacknowledged durable commit must stop the loop before Reason can consume the @@ -450,3 +495,7 @@ fn uncertain_compaction_error(ctx: &super::StageContext) -> crate::error::AgentE #[cfg(test)] #[path = "compact_test.rs"] mod tests; + +#[cfg(test)] +#[path = "compact_retry_test.rs"] +mod compact_retry_tests; diff --git a/peri-agent/src/agent/stages/compact_retry_test.rs b/peri-agent/src/agent/stages/compact_retry_test.rs new file mode 100644 index 000000000..2c13b7b57 --- /dev/null +++ b/peri-agent/src/agent/stages/compact_retry_test.rs @@ -0,0 +1,297 @@ +//! 同一轮工具循环中的 Full 失败恢复,不依赖下一条用户 prompt。 + +use super::*; +use crate::agent::events_v2::{EventBus, EventHandles, ObserveEvent}; +use crate::agent::react::{ReactLLM, Reasoning, StreamingContext, ToolCall}; +use crate::agent::stages::{run_react_loop, LoopResult, StageContext}; +use crate::agent::token::ContextBudget; +use crate::error::AgentError; +use crate::messages::BaseMessage; +use crate::session::store::FrozenContext; +use crate::session::test_resources::TestSession; +use crate::session::{MessageSource, QueuedMessage, Session}; +use crate::tools::{BaseTool, ToolContext}; +use peri_model::{ModelCapabilities, ModelMessage, ModelRequest, ModelResponse, ModelStream}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tokio_util::sync::CancellationToken; + +struct LoopModel { + requests: parking_lot::Mutex>>, + order: Arc>>, +} + +#[async_trait::async_trait] +impl ReactLLM for LoopModel { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> crate::error::AgentResult { + self.order.lock().push("reason"); + let mut requests = self.requests.lock(); + requests.push(messages.to_vec()); + let first = requests.len() == 1; + let mut response = if first { + Reasoning::with_tools( + "", + vec![ToolCall::new("call", "Work", serde_json::json!({}))], + ) + } else { + Reasoning::with_answer("", "done") + }; + response.usage = Some(peri_model::TokenUsage { + input_tokens: if first { 109_000 } else { 1_000 }, + output_tokens: 100, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + Ok(response) + } +} + +struct WorkTool(Arc>>); + +#[async_trait::async_trait] +impl BaseTool for WorkTool { + fn name(&self) -> &str { + "Work" + } + fn description(&self) -> &str { + "Complete one work item" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn invoke( + &self, + _: serde_json::Value, + _: ToolContext<'_>, + ) -> Result> { + self.0.lock().push("tool"); + Ok("work completed".into()) + } +} + +struct SummaryModel { + failures: usize, + calls: AtomicUsize, + order: Arc>>, + cancel: Option>, +} + +#[async_trait::async_trait] +impl peri_model::Model for SummaryModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + unreachable!("摘要只使用 complete") + } + async fn complete( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + self.order.lock().push("summary"); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(cancel) = &self.cancel { + if call == 1 { + cancel.cancel(); + std::future::pending::<()>().await; + } + } + ModelResponse::new( + ModelMessage::assistant_text(if call < self.failures { + "no usable summary" + } else { + "RECOVERED: work completed; continue the task." + }), + peri_model::StopReason::EndTurn, + None, + None, + ) + } +} + +async fn make_scenario( + failures: usize, + cancel_retry: bool, +) -> ( + TestSession, + StageContext, + Arc, + Arc, + EventHandles, +) { + let bound = TestSession::open().await; + let session = Session::new( + Arc::from("/tmp/compact-retry"), + FrozenContext::builder().build(), + Some(bound.thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut guard = transcript.write(); + *guard = std::mem::take(&mut *guard) + .with_persistence(bound.resources(), bound.thread_id.clone()); + } + let order = Arc::new(parking_lot::Mutex::new(Vec::new())); + let model = Arc::new(LoopModel { + requests: Default::default(), + order: order.clone(), + }); + let turn = session.start_turn(); + let summary = Arc::new(SummaryModel { + failures, + calls: AtomicUsize::new(0), + order: order.clone(), + cancel: cancel_retry.then(|| turn.cancel_token.clone()), + }); + let (bus, handles) = EventBus::new(Default::default()); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(model.clone()) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .with_tools(Arc::new(parking_lot::RwLock::new( + std::collections::BTreeMap::from([( + "Work".into(), + Arc::new(WorkTool(order)) as Arc, + )]), + ))) + .with_event_bus(Arc::new(bus)) + .build(); + ctx.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("original task"), + )); + (bound, ctx, model, summary, handles) +} + +/// [回归测试] 109% 时摘要先失败两次,必须在下一次 Reason 前完成 Full。 +#[tokio::test] +async fn test_compact_retry_recovers_inside_tool_loop() { + let (bound, ctx, model, summary, mut handles) = make_scenario(2, false).await; + let result = run_react_loop(ctx.clone(), 10).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!( + *model.order.lock(), + ["reason", "tool", "summary", "summary", "summary", "reason"] + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 3); + assert!(model.requests.lock()[1] + .iter() + .any(|message| message.content().contains("RECOVERED"))); + assert_eq!( + std::iter::from_fn(|| handles.try_observe()) + .filter(|e| matches!(e, ObserveEvent::MessagesCompacted { .. })) + .count(), + 1 + ); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + for message in transcript.entries().iter().take(3) { + assert!( + snapshot.flags[&message.id()].excluded, + "成功摘要必须在持久化历史中替换旧工作" + ); + } +} + +/// [回归测试] 连续空摘要不得静默禁用 Compact 并继续发送超预算请求。 +#[tokio::test] +async fn test_compact_retry_exhaustion_stops_before_next_reason() { + let (_bound, ctx, model, summary, mut handles) = make_scenario(usize::MAX, false).await; + let result = run_react_loop(ctx.clone(), 10).await; + let LoopResult::Error(error) = result else { + panic!("压缩失败必须显式终止:{result:?}"); + }; + assert!( + matches!( + error, + AgentError::CompactRetriesExhausted { + attempts: 3, + // 工具调用名称/参数的请求增量约 2 tokens,工具结果约 3。 + context_tokens: 109_005, + context_window: 100_000, + } + ), + "{error}" + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 3); + assert_eq!(model.requests.lock().len(), 1); + assert!(ctx + .session + .transcript + .read() + .visible_messages() + .iter() + .any(|m| m.content() == "original task")); + let events: Vec<_> = std::iter::from_fn(|| handles.try_observe()).collect(); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, ObserveEvent::CompactEnded { .. })) + .count(), + 1 + ); + assert!(!events + .iter() + .any(|e| matches!(e, ObserveEvent::MessagesCompacted { .. }))); +} + +/// [回归测试] 同阶段摘要重试必须继续响应用户取消。 +#[tokio::test] +async fn test_compact_retry_cancel_stops_before_next_reason() { + let (_bound, ctx, model, summary, _) = make_scenario(usize::MAX, true).await; + let result = run_react_loop(ctx.clone(), 10).await; + assert!(matches!(result, LoopResult::Interrupted), "{result:?}"); + assert_eq!(summary.calls.load(Ordering::SeqCst), 2); + assert_eq!(model.requests.lock().len(), 1); + assert!(!ctx.session.transcript.read().visible_messages().is_empty()); +} + +/// [回归测试] 已达失败上限的高压状态不能经 Skipped 继续进入 Reason。 +#[tokio::test] +async fn test_compact_retry_existing_failure_limit_is_terminal() { + let (_bound, ctx, model, summary, _) = make_scenario(0, false).await; + ctx.compact + .compact_consecutive_failures + .store(3, Ordering::Relaxed); + ctx.compact + .token_tracker + .write() + .accumulate(&peri_model::TokenUsage { + input_tokens: 109_000, + output_tokens: 0, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + let result = run_react_loop(ctx, 10).await; + assert!( + matches!( + result, + LoopResult::Error(AgentError::CompactRetriesExhausted { + attempts: 3, + context_tokens: 109_000, + context_window: 100_000, + }) + ), + "{result:?}" + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); + assert!(model.requests.lock().is_empty()); +} diff --git a/peri-agent/src/agent/stages/compact_test.rs b/peri-agent/src/agent/stages/compact_test.rs index 4e88890e8..74d62e078 100644 --- a/peri-agent/src/agent/stages/compact_test.rs +++ b/peri-agent/src/agent/stages/compact_test.rs @@ -86,16 +86,25 @@ async fn test_micro_applied_then_full_failure_does_not_reset_token_tracker() { }); let token_tracker = ctx.compact.token_tracker.clone(); - let output = run_compact(CompactInput { - context: ctx, + let error = run_compact(CompactInput { + context: ctx.clone(), has_tool_calls: true, }) .await - .unwrap(); + .err() + .expect("高压 Full 失败必须结束本轮"); + assert!(matches!(error, crate::error::AgentError::CompactNoLlm)); assert!( - output.compacted, - "已应用的 Micro 应使 Compact stage 报告 compacted" + ctx.session.transcript.read().entries().iter().any(|entry| { + ctx.session + .transcript + .read() + .flags(entry.id()) + .projection + .is_some() + }), + "Full 失败后必须保留已应用的 Micro/Smart projection" ); assert_eq!( token_tracker.read().estimated_context_tokens(), @@ -105,7 +114,7 @@ async fn test_micro_applied_then_full_failure_does_not_reset_token_tracker() { } #[tokio::test] -async fn test_compact_stage_smart_applied_then_full_failure_is_compacted_without_tracker_reset() { +async fn test_compact_stage_smart_applied_then_full_failure_stops_without_tracker_reset() { // 高压力下先应用 Smart,随后无 compact LLM 的 Full 失败。 let mut ctx = make_context(); let long_output = "x".repeat(2_000); @@ -141,16 +150,25 @@ async fn test_compact_stage_smart_applied_then_full_failure_is_compacted_without }); let token_tracker = ctx.compact.token_tracker.clone(); - let output = run_compact(CompactInput { - context: ctx, + let error = run_compact(CompactInput { + context: ctx.clone(), has_tool_calls: true, }) .await - .unwrap(); + .err() + .expect("高压 Full 失败必须结束本轮"); + assert!(matches!(error, crate::error::AgentError::CompactNoLlm)); assert!( - output.compacted, - "已应用的 Smart 应使 Compact stage 报告 compacted" + ctx.session.transcript.read().entries().iter().any(|entry| { + ctx.session + .transcript + .read() + .flags(entry.id()) + .projection + .is_some() + }), + "Full 失败后必须保留已应用的 Micro/Smart projection" ); assert_eq!( token_tracker.read().estimated_context_tokens(), @@ -360,16 +378,25 @@ async fn test_compact_stage_applied_mixed_emits_one_messages_compacted_with_snap cache_read_input_tokens: None, }); - let output = run_compact(CompactInput { - context: ctx, + let error = run_compact(CompactInput { + context: ctx.clone(), has_tool_calls: true, }) .await - .unwrap(); + .err() + .expect("高压 Full 失败必须结束本轮"); + assert!(matches!(error, crate::error::AgentError::CompactNoLlm)); assert!( - output.compacted, - "MicroAppliedThenFullFailed 仍有实际 mutation" + ctx.session.transcript.read().entries().iter().any(|entry| { + ctx.session + .transcript + .read() + .flags(entry.id()) + .projection + .is_some() + }), + "Full 失败后必须保留已应用的 Micro/Smart projection" ); let events = observe_events(&mut handles); let completions: Vec<_> = events diff --git a/peri-agent/src/agent/stages/compaction_loop_test.rs b/peri-agent/src/agent/stages/compaction_loop_test.rs new file mode 100644 index 000000000..e0e89ba24 --- /dev/null +++ b/peri-agent/src/agent/stages/compaction_loop_test.rs @@ -0,0 +1,773 @@ +//! RCRA 中的 Compact 压力代际、持久化 Full 与模型可见视图。 +use super::super::*; +use crate::session::queue::MessageSource; +use crate::session::store::FrozenContext; +use crate::session::test_resources::mock::MockSessionResources; +use crate::session::Session; + +/// 构造测试用 StageContext +fn make_stage_context() -> StageContext { + let cwd: Arc = Arc::from("/tmp/test"); + let frozen = FrozenContext::builder() + .system_prompt("You are a test agent.") + .build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + StageContext::new(turn, session.transcript(), session.queue().clone()) +} + +struct UsageChurnReactLLM { + usages: Vec, + calls: Arc, + requests: Arc>>, +} + +#[async_trait::async_trait] +impl ReactLLM for UsageChurnReactLLM { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().unwrap().push(messages.len()); + let usage = self.usages[call]; + let mut reasoning = if call + 1 == self.usages.len() { + crate::agent::react::Reasoning::with_answer("finish", "done") + } else { + crate::agent::react::Reasoning::with_tools( + format!("generation {call}"), + vec![crate::agent::react::ToolCall::new( + format!("usage-churn-{call}"), + "usage_churn_tool", + serde_json::json!({ "generation": call }), + )], + ) + }; + reasoning.usage = Some(peri_model::TokenUsage { + input_tokens: usage, + output_tokens: 100, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + reasoning.request_id = Some(format!("usage-generation-{call}")); + reasoning.model = "scripted-usage-churn".to_string(); + Ok(reasoning) + } +} + +struct UsageChurnTool; + +#[async_trait::async_trait] +impl crate::tools::BaseTool for UsageChurnTool { + fn name(&self) -> &str { + "usage_churn_tool" + } + + fn description(&self) -> &str { + "keeps the characterization loop running" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "generation": { "type": "integer" } } + }) + } + + async fn invoke( + &self, + input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(format!( + "generation {} tool output {}", + input["generation"], + "x".repeat(256) + )) + } +} + +struct CountingCompactModel { + calls: Arc, +} + +#[async_trait::async_trait] +impl peri_model::Model for CountingCompactModel { + fn capabilities(&self) -> peri_model::ModelCapabilities { + peri_model::ModelCapabilities { + supports_tools: false, + supports_reasoning: false, + supports_vision: false, + supports_streaming: true, + } + } + + async fn stream( + &self, + _request: peri_model::ModelRequest, + _cancellation: tokio_util::sync::CancellationToken, + ) -> peri_model::ModelResult { + unreachable!("compact characterization uses complete") + } + + async fn complete( + &self, + _request: peri_model::ModelRequest, + _cancellation: tokio_util::sync::CancellationToken, + ) -> peri_model::ModelResult { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + peri_model::ModelResponse::new( + peri_model::ModelMessage::assistant_text(format!( + "compact generation {call}" + )), + peri_model::StopReason::EndTurn, + None, + None, + ) + } +} + +struct SuccessfulFullReactLLM { + calls: Arc, + requests: Arc>>>, + file_path: String, +} + +#[async_trait::async_trait] +impl ReactLLM for SuccessfulFullReactLLM { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().unwrap().push( + messages + .iter() + .map(|message| message.content().to_string()) + .collect(), + ); + let mut reasoning = if call == 0 { + crate::agent::react::Reasoning::with_tools( + "original reasoning marker", + vec![crate::agent::react::ToolCall::new( + "successful-full-read", + "Read", + serde_json::json!({ "file_path": self.file_path }), + )], + ) + } else { + crate::agent::react::Reasoning::with_answer("post-full reasoning", "done") + }; + reasoning.usage = Some(peri_model::TokenUsage { + input_tokens: if call == 0 { 96_000 } else { 1_000 }, + output_tokens: 100, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + reasoning.request_id = Some(format!("scripted-successful-full-{call}")); + reasoning.model = "scripted-successful-full".to_string(); + Ok(reasoning) + } +} + +struct SuccessfulFullReadTool; + +#[async_trait::async_trait] +impl crate::tools::BaseTool for SuccessfulFullReadTool { + fn name(&self) -> &str { + "Read" + } + + fn description(&self) -> &str { + "reads the characterization fixture" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "file_path": { "type": "string" } }, + "required": ["file_path"] + }) + } + + async fn invoke( + &self, + input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(std::fs::read_to_string( + input["file_path"].as_str().unwrap(), + )?) + } +} + +/// Characterization:scripted provider usage 驱动一次真实持久化 Full lifecycle,随后 tracker +/// 接受 Full 后 Reason 返回的新低 usage 样本。 +#[tokio::test] +async fn test_run_react_loop_successful_full_replaces_history_reinjects_read_file_and_resets_usage() +{ + use crate::thread::ThreadMeta; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("full-reinject-marker.txt"); + let file_marker = "successful full reinjected file marker"; + std::fs::write(&file_path, file_marker).unwrap(); + + let store = MockSessionResources::new(); + let thread_id = store + .create_thread(ThreadMeta::new(dir.path().to_string_lossy())) + .await + .unwrap(); + let store_dyn: Arc = store.clone(); + let session = Session::new( + Arc::from(dir.path().to_string_lossy().as_ref()), + FrozenContext::builder().build(), + Some(thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut transcript = transcript.write(); + *transcript = + std::mem::take(&mut *transcript).with_persistence(store_dyn.clone(), thread_id.clone()); + } + + let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reason_requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "Read".to_string(), + Arc::new(SuccessfulFullReadTool) as Arc, + )]))); + let config = CompactConfig { + micro_compact_stale_steps: 0, + target_headroom_tokens: 50_000, + micro_field_threshold_chars: 32, + micro_field_keep_head_chars: 8, + micro_field_keep_tail_chars: 8, + ..Default::default() + }; + let mut budget = crate::agent::token::ContextBudget::new(100_000); + budget.output_reserve = 40_000; + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(SuccessfulFullReactLLM { + calls: Arc::clone(&reason_calls), + requests: Arc::clone(&reason_requests), + file_path: file_path.to_string_lossy().into_owned(), + })) + .with_tools(tools) + .with_event_bus(Arc::new(bus)) + .with_context_budget(budget) + .with_compact_config(config) + .with_compact_llm(Arc::new(CountingCompactModel { + calls: Arc::clone(&compact_calls), + })) + .build(); + let prompt_marker = "successful full original prompt marker"; + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human(prompt_marker), + )); + + assert!(matches!( + run_react_loop(context.clone(), 2).await, + LoopResult::Completed + )); + assert_eq!(reason_calls.load(Ordering::SeqCst), 2); + assert_eq!(compact_calls.load(Ordering::SeqCst), 1); + + { + let requests = reason_requests.lock().unwrap(); + assert!(requests[0] + .iter() + .any(|content| content.contains(prompt_marker))); + let post_full = &requests[1]; + assert!(post_full + .iter() + .any(|content| content.contains("compact generation 0"))); + assert!(post_full + .iter() + .any(|content| content.contains(file_marker))); + assert!(!post_full + .iter() + .any(|content| content.contains(prompt_marker))); + assert!(!post_full + .iter() + .any(|content| content.contains("original reasoning marker"))); + } + + let outcomes: Vec<_> = std::iter::from_fn(|| handles.try_observe()) + .filter_map(|event| match event { + ObserveEvent::MessagesCompacted { outcome, .. } => Some(outcome), + _ => None, + }) + .collect(); + assert_eq!( + outcomes, + vec![crate::agent::compact_v2::CompactOutcome::FullApplied] + ); + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(1_000), + "Full reset 后第二次 Reason 的低 usage 应成为权威样本" + ); + + let persist_tx = context + .session + .transcript + .read() + .persist_tx_handle() + .expect("测试 transcript 应绑定持久化 writer"); + crate::session::transcript::MessageTranscript::flush_via_tx(&persist_tx) + .await + .unwrap(); + let persisted = store.load_messages(&thread_id).await.unwrap(); + let flags = store.load_message_flags(&thread_id).await.unwrap(); + let summary_count = persisted + .iter() + .filter(|message| message.content().contains("compact generation 0")) + .count(); + let reinject_count = persisted + .iter() + .filter(|message| { + message.content().contains(file_marker) + && !flags.get(&message.id()).is_some_and(|flag| flag.excluded) + }) + .count(); + assert_eq!(summary_count, 1); + assert_eq!(reinject_count, 1); + assert!(persisted + .iter() + .filter(|message| { + message.content().contains(prompt_marker) + || message.content().contains("original reasoning marker") + || message.content() == file_marker + }) + .all(|message| flags.get(&message.id()).is_some_and(|flag| flag.excluded))); +} + +/// Characterization:每次 Reason 都返回新的 provider usage generation 时,已消费样本 guard +/// 会重新 arm;绑定真实持久化后验证 Full → Micro → Full,不依赖吞掉 Full 提交失败。 +#[tokio::test] +async fn test_run_react_loop_new_high_usage_generations_continue_full_micro_churn() { + let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reason_requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let bound = crate::session::test_resources::TestSession::open().await; + let session = Session::new( + Arc::from("/tmp/usage-churn-characterization"), + FrozenContext::builder().build(), + Some(bound.thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut guard = transcript.write(); + *guard = std::mem::take(&mut *guard) + .with_persistence(bound.resources(), bound.thread_id.clone()); + } + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "usage_churn_tool".to_string(), + Arc::new(UsageChurnTool) as Arc, + )]))); + let config = CompactConfig { + micro_compact_stale_steps: 0, + target_headroom_tokens: 50_000, + micro_field_threshold_chars: 32, + micro_field_keep_head_chars: 8, + micro_field_keep_tail_chars: 8, + ..Default::default() + }; + let mut budget = crate::agent::token::ContextBudget::new(100_000); + budget.output_reserve = 40_000; + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(UsageChurnReactLLM { + usages: vec![96_000, 80_000, 97_000, 81_000], + calls: Arc::clone(&reason_calls), + requests: Arc::clone(&reason_requests), + })) + .with_tools(tools) + .with_event_bus(Arc::new(bus)) + .with_context_budget(budget) + .with_compact_config(config) + .with_compact_llm(Arc::new(CountingCompactModel { + calls: Arc::clone(&compact_calls), + })) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("characterize usage churn"), + )); + + let result = run_react_loop(context.clone(), 4).await; + + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!(reason_calls.load(Ordering::SeqCst), 4); + assert_eq!(reason_requests.lock().unwrap().len(), 4); + let compacted: Vec<_> = std::iter::from_fn(|| handles.try_observe()) + .filter_map(|event| match event { + ObserveEvent::MessagesCompacted { + strategy, + estimated_tokens_before, + full_escalation_reason, + outcome, + .. + } => Some(( + strategy, + estimated_tokens_before, + full_escalation_reason, + outcome, + )), + _ => None, + }) + .collect(); + assert_eq!( + compacted, + vec![ + ( + crate::agent::events::CompactStrategy::Full, + 96_082, + Some(crate::agent::compact_v2::planner::FullEscalationReason::InsufficientReclaim), + crate::agent::compact_v2::CompactOutcome::FullApplied, + ), + ( + crate::agent::events::CompactStrategy::Micro, + 80_081, + None, + crate::agent::compact_v2::CompactOutcome::MicroApplied, + ), + ( + crate::agent::events::CompactStrategy::Full, + 97_082, + Some(crate::agent::compact_v2::planner::FullEscalationReason::InsufficientReclaim), + crate::agent::compact_v2::CompactOutcome::FullApplied, + ), + ], + "每个新的非零高位 usage generation 都会再次触发;Full 区间会升级尝试,Micro 区间只执行 Micro" + ); + assert_eq!( + compact_calls.load(Ordering::SeqCst), + 2, + "两次 Full 各调用一次 compact LLM;中间 Micro 不调用" + ); + let transcript = std::mem::take(&mut *context.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert_eq!( + stored + .payloads + .iter() + .filter(|payload| payload + .as_message() + .is_some_and(|message| message.content().contains("compact generation"))) + .count(), + 2, + "两次 Full 摘要必须真实提交" + ); + assert!( + stored + .payloads + .iter() + .filter_map(|payload| payload.as_message()) + .filter(|message| message.content().contains("characterize usage churn")) + .all(|message| stored.flags[&message.id()].excluded), + "旧任务原文保留并由成功摘要替代" + ); +} + +struct AuditAlternatingOutputTool; + +#[async_trait::async_trait] +impl crate::tools::BaseTool for AuditAlternatingOutputTool { + fn name(&self) -> &str { + "usage_churn_tool" + } + + fn description(&self) -> &str { + "为审计循环交替提供长短结果" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {"generation": {"type": "integer"}}}) + } + + async fn invoke( + &self, + input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(if input["generation"].as_u64().unwrap().is_multiple_of(2) { + "x".repeat(8_000) + } else { + "ok".to_string() + }) + } +} + +/// [回归测试] SQLite Full 后新高 usage 可再次 Full,但不能对 excluded 历史发 Micro。 +/// usage 数列为受控输入,只证明新高样本下的执行链,不代表现场 token 测量。 +#[tokio::test] +async fn test_run_react_loop_successful_full_does_not_recompact_excluded_history() { + use crate::agent::compact_v2::{planner::plan_micro, projection, CompactOutcome}; + use crate::thread::ThreadMeta; + let store = MockSessionResources::new(); + let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); + let session = Session::new( + Arc::from("/tmp"), + FrozenContext::builder().build(), + Some(thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut transcript = transcript.write(); + *transcript = + std::mem::take(&mut *transcript).with_persistence(store.clone(), thread_id.clone()); + } + let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "usage_churn_tool".to_string(), + Arc::new(AuditAlternatingOutputTool) as Arc, + )]))); + let config = CompactConfig { + micro_compact_stale_steps: 0, + ..Default::default() + }; + let mut budget = crate::agent::token::ContextBudget::new(100_000); + budget.output_reserve = 40_000; + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(Arc::new(UsageChurnReactLLM { + usages: vec![96_000, 80_000, 96_000, 80_000, 1_000], + calls: reason_calls.clone(), + requests: Arc::new(std::sync::Mutex::new(Vec::new())), + })) + .with_tools(tools) + .with_event_bus(Arc::new(bus)) + .with_context_budget(budget) + .with_compact_config(config.clone()) + .with_compact_llm(Arc::new(CountingCompactModel { + calls: compact_calls.clone(), + })) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("audit churn"), + )); + assert!(matches!( + run_react_loop(context.clone(), 5).await, + LoopResult::Completed + )); + assert_eq!(reason_calls.load(Ordering::SeqCst), 5); + assert_eq!(compact_calls.load(Ordering::SeqCst), 2); + let compacted: Vec<_> = std::iter::from_fn(|| handles.try_observe()) + .filter_map(|event| match event { + ObserveEvent::MessagesCompacted { + outcome, + estimated_tokens_saved, + affected_count, + .. + } => Some((outcome, estimated_tokens_saved, affected_count)), + _ => None, + }) + .collect(); + assert_eq!( + compacted.iter().map(|entry| entry.0).collect::>(), + vec![CompactOutcome::FullApplied, CompactOutcome::FullApplied,] + ); + { + let transcript = context.session.transcript.read(); + let plan = plan_micro(&transcript, &config, false); + assert!( + plan.actions.is_empty(), + "Full 后只有短结果可见,不应再次规划旧工具输出" + ); + let canonical = transcript.visible_model_messages().unwrap(); + let projected = + projection::render_llm_view(&transcript, &plan, &Default::default()).unwrap(); + assert_eq!( + serde_json::to_value(canonical).unwrap(), + serde_json::to_value(projected).unwrap(), + "无 Micro action 时维持 canonical 模型视图" + ); + } + let tx = context + .session + .transcript + .read() + .persist_tx_handle() + .unwrap(); + crate::session::transcript::MessageTranscript::flush_via_tx(&tx) + .await + .unwrap(); + let flags = store.load_message_flags(&thread_id).await.unwrap(); + assert_eq!( + flags + .values() + .filter(|flag| flag.excluded && flag.projection.is_some()) + .count(), + 0 + ); +} + +/// [回归测试] 工具结果已进入 transcript 后,下一轮 Compact 必须看见其新增压力。 +#[tokio::test] +async fn test_audit_dispatch_must_account_for_tool_output_pressure() { + let context = make_stage_context(); + context.runtime.tools.write().insert( + "usage_churn_tool".into(), + Arc::new(AuditAlternatingOutputTool), + ); + let catalog = context + .runtime + .tool_catalog + .pin_working_tools(&context.runtime.tools.read()) + .unwrap(); + context + .compact + .token_tracker + .write() + .accumulate(&peri_model::TokenUsage { + input_tokens: 74_000, + output_tokens: 100, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + let reasoning = crate::agent::react::Reasoning::with_tools( + "inspect", + vec![crate::agent::react::ToolCall::new( + "audit-output", + "usage_churn_tool", + serde_json::json!({"generation": 0}), + )], + ); + super::tool_dispatch::dispatch_tools( + &context, + &reasoning, + &catalog, + &tokio_util::sync::CancellationToken::new(), + ) + .await + .unwrap(); + assert!( + context + .session + .transcript + .read() + .visible_messages() + .iter() + .any(|message| { + matches!(message, BaseMessage::Tool { .. }) && message.content().len() == 8_000 + }), + "必须实际执行工具并写入其完整结果" + ); + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(76_000), + "74k provider input 加上已提交的 8k 字符工具结果,按 tracker 的 chars/4 应为 76k" + ); +} + +/// [回归测试] Reason 已投影的内容不能在随后 Micro 中再次报告增量回收收益。 +#[tokio::test] +async fn test_audit_micro_savings_must_change_previous_reason_view() { + let session = Session::new(Arc::from("/tmp"), FrozenContext::builder().build(), None); + { + let transcript = session.transcript(); + let mut transcript = transcript.write(); + for turn in 0..4 { + let call_id = format!("audit-reason-{turn}"); + transcript.append(BaseMessage::human("inspect")); + transcript.append(BaseMessage::ai_with_tool_calls( + "inspect", + vec![crate::messages::ToolCallRequest::new( + &call_id, + "Bash", + serde_json::json!({"command": "fixture"}), + )], + )); + transcript.append(BaseMessage::tool_result(&call_id, "x".repeat(8_000))); + } + } + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(Arc::new(UsageChurnReactLLM { + usages: vec![80_000, 80_000], + calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + requests: Arc::new(std::sync::Mutex::new(Vec::new())), + })) + .with_event_bus(Arc::new(bus)) + .with_context_budget(crate::agent::token::ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build(); + let first = super::reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + let first_tool_outputs = first + .messages_snapshot + .iter() + .filter(|message| matches!(message, BaseMessage::Tool { .. })) + .map(|message| message.content().chars().count()) + .collect::>(); + assert_eq!( + first_tool_outputs, + vec![8_000; 4], + "没有已提交 directive 时 Reason 必须发送 canonical 工具结果" + ); + super::compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + let saved = std::iter::from_fn(|| handles.try_observe()) + .find_map(|event| match event { + ObserveEvent::MessagesCompacted { + estimated_tokens_saved, + .. + } => Some(estimated_tokens_saved), + _ => None, + }) + .unwrap_or(0); + let second = super::reason::run_reason(ReasonInput { + context, + has_tool_calls: false, + }) + .await + .unwrap(); + assert!( + saved == 0 + || serde_json::to_value(&*first.messages_snapshot).unwrap() + != serde_json::to_value(&*second.messages_snapshot).unwrap(), + "Micro 报告节省 {saved} tokens,但两次真实 Reason 消息快照完全相同" + ); +} diff --git a/peri-agent/src/agent/stages/context_pressure.rs b/peri-agent/src/agent/stages/context_pressure.rs new file mode 100644 index 000000000..ea7a2c77f --- /dev/null +++ b/peri-agent/src/agent/stages/context_pressure.rs @@ -0,0 +1,32 @@ +//! 以实际可见请求视图补齐 provider usage 之间的增长及冷启动预算。 + +use crate::agent::stages::StageContext; +use crate::error::AgentResult; + +pub(crate) fn refresh(ctx: &StageContext) -> AgentResult { + let messages = { + let transcript = ctx.session.transcript.read(); + crate::agent::compact_v2::projection::render_persisted_llm_view( + &transcript, + &ctx.runtime.llm.provider_capabilities(), + )? + }; + let tools: Vec<_> = ctx + .runtime + .tools + .read() + .values() + .filter(|tool| tool.is_direct() && tool.visible_to_model()) + .cloned() + .collect(); + let tool_refs: Vec<_> = tools.iter().map(|tool| tool.as_ref()).collect(); + let estimate = ctx + .runtime + .llm + .estimate_request_tokens(&messages, &tool_refs); + Ok(ctx + .compact + .token_tracker + .write() + .refresh_input_estimate(estimate)) +} diff --git a/peri-agent/src/agent/stages/input_hooks_test.rs b/peri-agent/src/agent/stages/input_hooks_test.rs new file mode 100644 index 000000000..26cd3d9c1 --- /dev/null +++ b/peri-agent/src/agent/stages/input_hooks_test.rs @@ -0,0 +1,441 @@ +//! 输入批次准备与 before_agent hook 的调用生命周期。 +use super::super::*; +use crate::messages::MessageContent; +use crate::middleware::capabilities as hook_state; +use crate::session::queue::MessageSource; +use crate::session::store::FrozenContext; +use crate::session::Session; + +/// 构造测试用 StageContext +fn make_stage_context() -> StageContext { + let cwd: Arc = Arc::from("/tmp/test"); + let frozen = FrozenContext::builder() + .system_prompt("You are a test agent.") + .build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + StageContext::new(turn, session.transcript(), session.queue().clone()) +} + +/// Mock LLM:首轮返回 final_answer,无 tool_calls +struct FinalAnswerLLM { + answer: &'static str, +} + +#[async_trait::async_trait] +impl ReactLLM for FinalAnswerLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + Ok(crate::agent::react::Reasoning::with_answer( + "thinking", + self.answer, + )) + } + fn model_name(&self) -> String { + "mock-final-answer".to_string() + } +} + +struct InputBatchProbe { + calls: Arc, + batches: Arc>>>, +} + +#[async_trait::async_trait] +impl crate::middleware::Middleware for InputBatchProbe { + fn name(&self) -> &str { + "InputBatchProbe" + } + + async fn before_agent( + &self, + state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + let ids = state + .input_message_ids() + .expect("生产 runner 必须明确传入输入批次") + .to_vec(); + self.batches.lock().push(ids.clone()); + let inputs: Vec<_> = state + .messages() + .iter() + .filter(|message| ids.contains(&message.id())) + .cloned() + .collect(); + for input in inputs { + let content = MessageContent::text(format!("{} prepared", input.content())); + assert!(state.replace_message(input.clone_with_content(content))); + } + Ok(()) + } +} + +#[tokio::test] +async fn test_input_batch_reaches_single_before_agent_without_history_or_background() { + let mut ctx = make_stage_context(); + ctx.runtime.llm = Arc::new(FinalAnswerLLM { answer: "done" }); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let batches = Arc::new(parking_lot::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(InputBatchProbe { + calls: calls.clone(), + batches: batches.clone(), + })); + ctx.runtime.middleware_chain = Arc::new(chain); + let historical = BaseMessage::human("history @image old.png"); + ctx.session.transcript.write().append(historical.clone()); + let first = BaseMessage::human("A @image current.png"); + let second = BaseMessage::human("B @src/current.rs"); + let background = BaseMessage::human("background @image ignored.png"); + ctx.session.queue.push_batch(vec![ + QueuedMessage::prompt(MessageSource::UserInput, first.clone()), + QueuedMessage::prompt(MessageSource::UserInput, second.clone()), + QueuedMessage::prompt(MessageSource::UserInput, BaseMessage::human("")), + QueuedMessage::defer(MessageSource::SubAgentComplete, background.clone()), + ]); + + assert!(matches!( + run_react_loop(ctx.clone(), 1).await, + LoopResult::Completed + )); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "整条 before_agent 链只能执行一次" + ); + assert_eq!( + *batches.lock(), + vec![vec![first.id(), second.id()]], + "批次仅包含本次非空用户输入且顺序不变" + ); + let transcript = ctx.session.transcript.read(); + for input in [&first, &second] { + assert_eq!( + transcript.get(input.id()).unwrap().message().content(), + format!("{} prepared", input.content()), + "每条输入都经同一链准备且稳定 ID 写回" + ); + } + for untouched in [&historical, &background] { + assert_eq!( + transcript.get(untouched.id()).unwrap().message().content(), + untouched.content(), + "历史与后台 Human 不属于本批用户输入" + ); + } +} + +#[tokio::test] +async fn test_input_batch_empty_background_attempt_does_not_reprocess_history() { + let mut ctx = make_stage_context(); + ctx.runtime.llm = Arc::new(FinalAnswerLLM { answer: "done" }); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let batches = Arc::new(parking_lot::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(InputBatchProbe { + calls: calls.clone(), + batches: batches.clone(), + })); + ctx.runtime.middleware_chain = Arc::new(chain); + let historical = BaseMessage::human("history @image old.png"); + ctx.session.transcript.write().append(historical.clone()); + ctx.session.queue.push(QueuedMessage::defer( + MessageSource::SubAgentComplete, + BaseMessage::human("background result"), + )); + + assert!(matches!( + run_react_loop(ctx.clone(), 1).await, + LoopResult::Completed + )); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "后台 attempt 仍只调用一次原 hook 链" + ); + assert_eq!( + *batches.lock(), + vec![Vec::::new()], + "生产空批次明确为 Some(empty),不能按最后 Human 回退" + ); + assert_eq!( + ctx.session + .transcript + .read() + .get(historical.id()) + .unwrap() + .message() + .content(), + historical.content() + ); +} +#[tokio::test] +async fn test_p0_2_before_agent_runs_once_after_tool_round_trip() { + use std::collections::BTreeMap; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + + struct ToolRoundTripLLM(Arc); + + #[async_trait::async_trait] + impl ReactLLM for ToolRoundTripLLM { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + match self.0.fetch_add(1, Ordering::SeqCst) { + 0 => Ok(crate::agent::react::Reasoning::with_tools( + "use the local tool", + vec![crate::agent::react::ToolCall::new( + "p0-2-tool-call", + "p0_2_local_tool", + serde_json::json!({}), + )], + )), + 1 => { + assert!( + messages + .iter() + .any(|message| message.content().contains("p0-2 tool result marker")), + "second LLM call must observe the local tool result" + ); + Ok(crate::agent::react::Reasoning::with_answer("", "done")) + } + call => panic!("unexpected LLM call {call}"), + } + } + } + + struct LocalTool(Arc); + + #[async_trait::async_trait] + impl crate::tools::BaseTool for LocalTool { + fn name(&self) -> &str { + "p0_2_local_tool" + } + + fn description(&self) -> &str { + "deterministic local test tool" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok("p0-2 tool result marker".to_string()) + } + } + + struct BeforeAgentProbe { + calls: Arc, + prompt_visible: Arc>>, + } + + #[async_trait::async_trait] + impl crate::middleware::Middleware for BeforeAgentProbe { + fn name(&self) -> &str { + "BeforeAgentProbe" + } + + async fn before_agent( + &self, + state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.prompt_visible.lock().unwrap().push( + state + .messages() + .iter() + .any(|message| message.content().contains("p0-2 prompt marker")), + ); + state.push_recall("p0-2 recall marker".to_string()); + Ok(()) + } + } + + let before_agent_calls = Arc::new(AtomicUsize::new(0)); + let prompt_visible = Arc::new(Mutex::new(Vec::new())); + let llm_calls = Arc::new(AtomicUsize::new(0)); + let tool_calls = Arc::new(AtomicUsize::new(0)); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(BeforeAgentProbe { + calls: Arc::clone(&before_agent_calls), + prompt_visible: Arc::clone(&prompt_visible), + })); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "p0_2_local_tool".to_string(), + Arc::new(LocalTool(Arc::clone(&tool_calls))) as Arc, + )]))); + + let session = Session::new( + Arc::from("/tmp/p0-2-before-agent-tool-round-trip"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(ToolRoundTripLLM(Arc::clone(&llm_calls)))) + .with_tools(tools) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("p0-2 prompt marker"), + )); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + assert_eq!(llm_calls.load(Ordering::SeqCst), 2); + assert_eq!(tool_calls.load(Ordering::SeqCst), 1); + assert_eq!(before_agent_calls.load(Ordering::SeqCst), 1); + assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); + assert_eq!( + context.recall_buffer.read().as_slice(), + ["p0-2 recall marker"] + ); +} + +#[tokio::test] +async fn test_p0_2_before_agent_runs_once_after_receive_and_skips_empty_or_cancelled_turns() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + + struct CountingLLM(Arc); + + #[async_trait::async_trait] + impl ReactLLM for CountingLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(crate::agent::react::Reasoning::with_answer("", "done")) + } + } + + struct BeforeAgentProbe { + calls: Arc, + prompt_visible: Arc>>, + } + + #[async_trait::async_trait] + impl crate::middleware::Middleware for BeforeAgentProbe { + fn name(&self) -> &str { + "BeforeAgentProbe" + } + + async fn before_agent( + &self, + state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.prompt_visible.lock().unwrap().push( + state + .messages() + .iter() + .any(|message| message.content().contains("p0-2 prompt marker")), + ); + state.push_recall("p0-2 recall marker".to_string()); + Ok(()) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let prompt_visible = Arc::new(Mutex::new(Vec::new())); + let llm_calls = Arc::new(AtomicUsize::new(0)); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(BeforeAgentProbe { + calls: Arc::clone(&calls), + prompt_visible: Arc::clone(&prompt_visible), + })); + + let cwd: Arc = Arc::from("/tmp/p0-2-before-agent"); + let session = Session::new(cwd, FrozenContext::builder().build(), None); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("p0-2 prompt marker"), + )); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); + assert_eq!(llm_calls.load(Ordering::SeqCst), 1); + assert_eq!( + context.recall_buffer.read().as_slice(), + ["p0-2 recall marker"] + ); + + let empty_session = Session::new( + Arc::from("/tmp/p0-2-before-agent-empty"), + FrozenContext::builder().build(), + None, + ); + let empty_turn = empty_session.start_turn(); + let empty_context = StageContext::builder( + empty_turn, + empty_session.transcript(), + empty_session.queue().clone(), + ) + .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) + .with_middleware_chain(context.runtime.middleware_chain.clone()) + .build(); + assert!(matches!( + run_react_loop(empty_context.clone(), 10).await, + LoopResult::Completed + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(empty_context.recall_buffer.read().is_empty()); + assert_eq!(llm_calls.load(Ordering::SeqCst), 1); + + let cancelled_session = Session::new( + Arc::from("/tmp/p0-2-before-agent-cancelled"), + FrozenContext::builder().build(), + None, + ); + let cancelled_turn = cancelled_session.start_turn(); + let cancelled_context = StageContext::builder( + cancelled_turn, + cancelled_session.transcript(), + cancelled_session.queue().clone(), + ) + .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) + .with_middleware_chain(context.runtime.middleware_chain.clone()) + .build(); + cancelled_context.session.turn.cancel_token.cancel(); + assert!(matches!( + run_react_loop(cancelled_context.clone(), 10).await, + LoopResult::Interrupted + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(cancelled_context.recall_buffer.read().is_empty()); + assert_eq!(llm_calls.load(Ordering::SeqCst), 1); +} diff --git a/peri-agent/src/agent/stages/loop_iteration_test.rs b/peri-agent/src/agent/stages/loop_iteration_test.rs new file mode 100644 index 000000000..2593b28d4 --- /dev/null +++ b/peri-agent/src/agent/stages/loop_iteration_test.rs @@ -0,0 +1,503 @@ +//! 循环语义迭代预算、Receive 唤醒与 idle 重试。 +use super::super::*; +use crate::middleware::capabilities as hook_state; +use crate::session::queue::MessageSource; +use crate::session::store::FrozenContext; +use crate::session::Session; + +struct CountingFinalAnswerLLM { + calls: Arc, + answer: &'static str, +} + +#[async_trait::async_trait] +impl ReactLLM for CountingFinalAnswerLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::agent::react::Reasoning::with_answer( + "thinking", + self.answer, + )) + } +} + +struct IterationBudgetProbe { + calls: Arc, + prompt_visible: Arc>>, + prompt_marker: &'static str, + recall_marker: &'static str, +} + +#[async_trait::async_trait] +impl crate::middleware::Middleware for IterationBudgetProbe { + fn name(&self) -> &str { + "IterationBudgetProbe" + } + + async fn before_agent( + &self, + state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.prompt_visible.lock().unwrap().push( + state + .messages() + .iter() + .any(|message| message.content().contains(self.prompt_marker)), + ); + state.push_recall(self.recall_marker.to_string()); + Ok(()) + } +} + +struct OneToolCallLLM(Arc); + +#[async_trait::async_trait] +impl ReactLLM for OneToolCallLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + let call = self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(call, 0, "预算耗尽后不得发起第二次模型调用"); + Ok(crate::agent::react::Reasoning::with_tools( + "use the deterministic tool", + vec![crate::agent::react::ToolCall::new( + "iteration-budget-tool-call", + "iteration_budget_tool", + serde_json::json!({}), + )], + )) + } +} + +struct IterationBudgetTool(Arc); + +#[async_trait::async_trait] +impl crate::tools::BaseTool for IterationBudgetTool { + fn name(&self) -> &str { + "iteration_budget_tool" + } + + fn description(&self) -> &str { + "deterministic iteration budget regression tool" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok("iteration budget tool result".to_string()) + } +} + +#[derive(Debug, Default)] +struct LoopEventSummary { + stage_lifecycle: Vec<(Stage, bool)>, + llm_start_steps: Vec, + llm_end_steps: Vec, +} + +fn drain_loop_observe_events( + handles: &mut crate::agent::events_v2::EventHandles, +) -> LoopEventSummary { + let mut summary = LoopEventSummary::default(); + while let Some(event) = handles.try_observe() { + match event { + ObserveEvent::StageStarted { stage, .. } => { + summary.stage_lifecycle.push((stage, false)); + } + ObserveEvent::StageEnded { stage, status, .. } => { + assert_eq!(status, StageStatus::Done, "阶段必须以 Done 成对结束"); + summary.stage_lifecycle.push((stage, true)); + } + ObserveEvent::LlmCallStart { step, .. } => summary.llm_start_steps.push(step), + ObserveEvent::LlmCallEnd { step, .. } => summary.llm_end_steps.push(step), + _ => {} + } + } + summary +} + +fn expected_stage_lifecycle(stages: &[Stage]) -> Vec<(Stage, bool)> { + stages + .iter() + .flat_map(|stage| [(*stage, false), (*stage, true)]) + .collect() +} + +fn assert_single_turn_completed( + handles: &mut crate::agent::events_v2::EventHandles, + expected_steps: usize, +) { + let completed_steps: Vec<_> = std::iter::from_fn(|| handles.try_render()) + .filter_map(|event| match event { + crate::agent::events_v2::RenderEvent::TurnCompleted { steps, .. } => Some(steps), + _ => None, + }) + .collect(); + assert_eq!(completed_steps, vec![expected_steps]); +} + +/// [回归测试] 最后一轮语义工作产出 final answer 后,下一次 Receive 必须观察正常完成。 +/// +/// 历史背景:旧循环把整个 Receive→Act 外层 `for` 计入预算,limit=1 时 Act 已提交 +/// final answer,却在下一次 Receive 前直接误报 MaxIterationsExceeded。 +#[tokio::test] +async fn test_run_react_loop_final_answer_at_iteration_limit_completes() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let session = Session::new( + Arc::from("/tmp/iteration-budget-final"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&calls), + answer: "done at the limit", + })) + .with_event_bus(Arc::new(bus)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("final prompt"), + )); + + let result = run_react_loop(context.clone(), 1).await; + + assert!(matches!(result, LoopResult::Completed)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(context.session.turn.current_step(), 1); + let events = drain_loop_observe_events(&mut handles); + assert_eq!( + events.stage_lifecycle, + expected_stage_lifecycle(&[ + Stage::Receive, + Stage::Compact, + Stage::Reason, + Stage::Act, + Stage::Receive, + ]) + ); + assert_eq!(events.llm_start_steps, vec![1]); + assert_eq!(events.llm_end_steps, vec![1]); + assert_single_turn_completed(&mut handles, 1); +} + +#[tokio::test] +async fn test_run_react_loop_info_only_does_not_wake_model() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let session = Session::new( + Arc::from("/tmp/micro-compact-info-only"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&calls), + answer: "must not run", + })) + .build(); + context.session.queue.push(QueuedMessage::info( + MessageSource::SystemInjected, + BaseMessage::human("micro compact state update"), + )); + + let result = run_react_loop(context.clone(), 1).await; + + assert!(matches!(result, LoopResult::Completed)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(context.session.turn.current_step(), 0); + assert!(context.session.transcript.read().entries()[0] + .message() + .content() + .contains("micro compact state update")); +} + +#[tokio::test] +async fn test_run_react_loop_defer_still_continues_to_model() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let session = Session::new( + Arc::from("/tmp/defer-continuation"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&calls), + answer: "continued", + })) + .build(); + context.session.queue.push(QueuedMessage::defer( + MessageSource::SubAgentComplete, + BaseMessage::human("real deferred result"), + )); + + let result = run_react_loop(context.clone(), 1).await; + + assert!(matches!(result, LoopResult::Completed)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(context.session.turn.current_step(), 1); +} + +/// [回归测试] 零预算仍必须先进入 Receive,空队列由 Receive 唯一判定正常完成。 +/// +/// 历史背景:预算门禁若放在 Receive 前,max_iterations=0 会把无需语义工作的空 turn +/// 错误分类为超限,并破坏 Receive 作为正常退出唯一入口的架构契约。 +#[tokio::test] +async fn test_run_react_loop_empty_queue_with_zero_budget_completes_in_receive() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let session = Session::new( + Arc::from("/tmp/iteration-budget-empty-zero"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&calls), + answer: "must not run", + })) + .with_event_bus(Arc::new(bus)) + .build(); + + let result = run_react_loop(context.clone(), 0).await; + + assert!(matches!(result, LoopResult::Completed)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(context.session.turn.current_step(), 0); + let events = drain_loop_observe_events(&mut handles); + assert_eq!( + events.stage_lifecycle, + expected_stage_lifecycle(&[Stage::Receive]) + ); + assert!(events.llm_start_steps.is_empty()); + assert!(events.llm_end_steps.is_empty()); +} + +/// [回归测试] 有待处理 prompt 但语义预算为零时,只允许 Receive 消费消息。 +/// +/// 历史背景:预算检查需要位于 Receive 与语义阶段之间,既不能跳过消息消费,也不能 +/// 推进 step、运行 before_agent、调用模型或工具。 +#[tokio::test] +async fn test_run_react_loop_prompt_with_zero_budget_returns_max_iterations() { + let before_agent_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let prompt_visible = Arc::new(std::sync::Mutex::new(Vec::new())); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(IterationBudgetProbe { + calls: Arc::clone(&before_agent_calls), + prompt_visible: Arc::clone(&prompt_visible), + prompt_marker: "zero budget prompt", + recall_marker: "zero budget recall", + })); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "iteration_budget_tool".to_string(), + Arc::new(IterationBudgetTool(Arc::clone(&tool_calls))) as Arc, + )]))); + let session = Session::new( + Arc::from("/tmp/iteration-budget-prompt-zero"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&llm_calls), + answer: "must not run", + })) + .with_tools(tools) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(bus)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("zero budget prompt"), + )); + + let result = run_react_loop(context.clone(), 0).await; + + assert!(matches!( + result, + LoopResult::Error(crate::error::AgentError::MaxIterationsExceeded(0)) + )); + assert_eq!(llm_calls.load(Ordering::SeqCst), 0); + assert_eq!(tool_calls.load(Ordering::SeqCst), 0); + assert_eq!(before_agent_calls.load(Ordering::SeqCst), 0); + assert!(prompt_visible.lock().unwrap().is_empty()); + assert!(context.recall_buffer.read().is_empty()); + assert_eq!(context.session.turn.current_step(), 0); + assert_eq!(context.session.transcript.read().len(), 1); + let events = drain_loop_observe_events(&mut handles); + assert_eq!( + events.stage_lifecycle, + expected_stage_lifecycle(&[Stage::Receive]) + ); + assert!(events.llm_start_steps.is_empty()); + assert!(events.llm_end_steps.is_empty()); +} + +/// [回归测试] 工具结果确实需要下一次推理时,耗尽的预算必须拒绝新语义迭代。 +/// +/// 历史背景:final answer 的收尾 Receive 可以越过预算,但工具调用后的空 Receive +/// 仍代表需要继续 Reason,必须精确返回 MaxIterationsExceeded(limit)。 +#[tokio::test] +async fn test_run_react_loop_required_reason_beyond_limit_returns_max_iterations() { + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "iteration_budget_tool".to_string(), + Arc::new(IterationBudgetTool(Arc::clone(&tool_calls))) as Arc, + )]))); + let session = Session::new( + Arc::from("/tmp/iteration-budget-tool"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(OneToolCallLLM(Arc::clone(&llm_calls)))) + .with_tools(tools) + .with_event_bus(Arc::new(bus)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("use one tool"), + )); + + let result = run_react_loop(context.clone(), 1).await; + + assert!(matches!( + result, + LoopResult::Error(crate::error::AgentError::MaxIterationsExceeded(1)) + )); + assert_eq!(llm_calls.load(Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(Ordering::SeqCst), 1); + assert_eq!(context.session.turn.current_step(), 1); + let events = drain_loop_observe_events(&mut handles); + assert_eq!( + events.stage_lifecycle, + expected_stage_lifecycle(&[ + Stage::Receive, + Stage::Compact, + Stage::Reason, + Stage::Act, + Stage::Receive, + ]) + ); + assert_eq!(events.llm_start_steps, vec![1]); + assert_eq!(events.llm_end_steps, vec![1]); + assert_single_turn_completed(&mut handles, 1); +} + +/// [回归测试] idle await_wake 与随后重试的 Receive 不得消耗语义迭代预算。 +/// +/// 历史背景:旧外层 `for` 把首次空 Receive 的 idle 挂起算作一次迭代,limit=1 时 +/// prompt 唤醒后尚未运行 Reason 就误报超限;before_agent 也不得在挂起前提前执行。 +#[tokio::test] +async fn test_run_react_loop_idle_wake_does_not_consume_iteration_budget() { + let before_agent_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let prompt_visible = Arc::new(std::sync::Mutex::new(Vec::new())); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let should_wait = Arc::new(AtomicBool::new(true)); + let suspended = Arc::new(AtomicBool::new(false)); + let mut chain = crate::middleware::MiddlewareChain::new(); + chain.add(Box::new(IterationBudgetProbe { + calls: Arc::clone(&before_agent_calls), + prompt_visible: Arc::clone(&prompt_visible), + prompt_marker: "idle wake prompt", + recall_marker: "idle wake recall", + })); + let session = Session::new( + Arc::from("/tmp/iteration-budget-idle-wake"), + FrozenContext::builder().build(), + None, + ); + let inbox = Arc::new(crate::agent::session::SessionInbox::new(Arc::new( + session.queue().clone(), + ))); + let handle = inbox.handle(); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(CountingFinalAnswerLLM { + calls: Arc::clone(&llm_calls), + answer: "done after wake", + })) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(bus)) + .with_idle_inbox(inbox) + .with_idle_should_wait({ + let should_wait = Arc::clone(&should_wait); + Arc::new(move || should_wait.load(Ordering::Acquire)) + }) + .with_idle_suspended_flag(Arc::clone(&suspended)) + .build(); + let loop_context = context.clone(); + let loop_task = tokio::spawn(async move { run_react_loop(loop_context, 1).await }); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !suspended.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("循环必须进入可观测的 idle suspended 状态"); + assert_eq!(before_agent_calls.load(Ordering::SeqCst), 0); + should_wait.store(false, Ordering::Release); + handle.push_prompt( + MessageSource::UserInput, + BaseMessage::human("idle wake prompt"), + ); + + let result = tokio::time::timeout(std::time::Duration::from_secs(1), loop_task) + .await + .expect("唤醒后的循环必须在有界时间内结束") + .expect("循环任务不得 panic"); + + assert!(matches!(result, LoopResult::Completed)); + assert_eq!(llm_calls.load(Ordering::SeqCst), 1); + assert_eq!(before_agent_calls.load(Ordering::SeqCst), 1); + assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); + assert_eq!( + context.recall_buffer.read().as_slice(), + ["idle wake recall"] + ); + assert_eq!(context.session.turn.current_step(), 1); + assert!(!suspended.load(Ordering::Acquire)); + let events = drain_loop_observe_events(&mut handles); + assert_eq!( + events.stage_lifecycle, + expected_stage_lifecycle(&[ + Stage::Receive, + Stage::Receive, + Stage::Compact, + Stage::Reason, + Stage::Act, + Stage::Receive, + ]) + ); + assert_eq!(events.llm_start_steps, vec![1]); + assert_eq!(events.llm_end_steps, vec![1]); + assert_single_turn_completed(&mut handles, 1); +} diff --git a/peri-agent/src/agent/stages/loop_lifecycle_test.rs b/peri-agent/src/agent/stages/loop_lifecycle_test.rs new file mode 100644 index 000000000..3c7833148 --- /dev/null +++ b/peri-agent/src/agent/stages/loop_lifecycle_test.rs @@ -0,0 +1,321 @@ +//! 循环完成、取消及 idle 用户消息交接。 +use super::super::*; +use crate::messages::MessageContent; +use crate::session::queue::MessageSource; +use crate::session::store::FrozenContext; +use crate::session::Session; + +/// 构造测试用 StageContext +fn make_stage_context() -> StageContext { + let cwd: Arc = Arc::from("/tmp/test"); + let frozen = FrozenContext::builder() + .system_prompt("You are a test agent.") + .build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + StageContext::new(turn, session.transcript(), session.queue().clone()) +} + +/// Mock LLM:首轮返回 final_answer,无 tool_calls +struct FinalAnswerLLM { + answer: &'static str, +} + +#[async_trait::async_trait] +impl ReactLLM for FinalAnswerLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + Ok(crate::agent::react::Reasoning::with_answer( + "thinking", + self.answer, + )) + } + fn model_name(&self) -> String { + "mock-final-answer".to_string() + } +} + +struct InterruptibleReasonLLM { + entered: std::sync::Mutex>>, +} + +#[async_trait::async_trait] +impl ReactLLM for InterruptibleReasonLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + if let Some(entered) = self.entered.lock().unwrap().take() { + let _ = entered.send(()); + } + std::future::pending().await + } +} + +#[tokio::test] +async fn test_e2e_final_answer_no_tools() { + // e2e:推入 Prompt → run_react_loop → 直接 final_answer → Completed + let cwd: Arc = Arc::from("/tmp/e2e"); + let frozen = FrozenContext::builder().build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(FinalAnswerLLM { + answer: "task completed", + })) + .build(); + + // 推入用户输入 + ctx.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human(MessageContent::text("do the task")), + )); + + let result = run_react_loop(ctx.clone(), 10).await; + assert!( + matches!(result, LoopResult::Completed), + "expected Completed, got {:?}", + result + ); + + // transcript 应包含:[user_prompt, ai_final_answer] + let transcript = ctx.session.transcript.read(); + let visible: Vec<_> = transcript.visible_messages().into_iter().collect(); + assert_eq!( + visible.len(), + 2, + "expected 2 messages (user + ai), got {}", + visible.len() + ); + assert!(matches!(visible[0], BaseMessage::Human { .. })); + assert!(matches!(visible[1], BaseMessage::Ai { .. })); +} + +/// [回归测试] loading 期间排队的两个 prompt 在后台任务仍活跃时逐条驱动真实 loop。 +#[tokio::test] +async fn test_run_react_loop_idle_dispatches_queued_prompts_one_at_a_time() { + use crate::session::user_input_mailbox::UserInputMailbox; + use peri_acp_types::session::{EnqueueUserInputRequest, SessionInbox, UserInputState}; + struct PausedFirstAnswerLLM { + entered: parking_lot::Mutex>>, + resume: Arc, + seen: Arc>>>, + } + #[async_trait::async_trait] + impl ReactLLM for PausedFirstAnswerLLM { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + self.seen.lock().push( + messages + .iter() + .filter(|message| matches!(message, BaseMessage::Human { .. })) + .map(|message| message.content().to_string()) + .collect(), + ); + let entered = self.entered.lock().take(); + if let Some(entered) = entered { + entered.send(()).unwrap(); + self.resume.notified().await; + } + Ok(crate::agent::react::Reasoning::with_answer("", "完成")) + } + } + let mut context = make_stage_context(); + let inbox = Arc::new(SessionInbox::new(Arc::new(context.session.queue.clone()))); + let mailbox = UserInputMailbox::new("session".into(), inbox.clone(), Arc::new(|_| {})); + mailbox + .attach_external_attempt(context.session.turn.cancel_token.as_ref().clone(), false) + .unwrap(); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let resume = Arc::new(tokio::sync::Notify::new()); + let seen = Arc::new(parking_lot::Mutex::new(Vec::new())); + context.runtime.llm = Arc::new(PausedFirstAnswerLLM { + entered: parking_lot::Mutex::new(Some(entered_tx)), + resume: resume.clone(), + seen: seen.clone(), + }); + context.session.user_input_mailbox = Some(mailbox.clone()); + context.async_ctx.idle_inbox = Some(inbox.clone()); + context.async_ctx.idle_should_wait = Some({ + let seen = seen.clone(); + Arc::new(move || seen.lock().len() < 3) + }); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + context.runtime.event_bus = Arc::new(bus); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("初始任务"), + )); + let task = tokio::spawn(run_react_loop(context, 3)); + tokio::time::timeout(std::time::Duration::from_secs(1), entered_rx) + .await + .expect("初始模型调用必须开始") + .unwrap(); + let mut input_ids = Vec::new(); + for text in ["A", "B"] { + let input_id = uuid::Uuid::now_v7().to_string(); + let receipt = mailbox + .enqueue(&EnqueueUserInputRequest { + session_id: "session".into(), + generation: mailbox.generation().into(), + command_id: format!("enqueue-{text}"), + input_id: input_id.clone(), + content: MessageContent::text(text), + original_draft: text.into(), + }) + .unwrap(); + assert_eq!(receipt.results[0].state, UserInputState::Queued); + input_ids.push(input_id); + } + assert!(inbox.queue().is_empty(), "loading 期间不能交接普通待办"); + resume.notify_one(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), task) + .await + .expect("已有队列必须在 idle 自动继续,无需再次提交或等待后台结果") + .unwrap(); + assert!(matches!(result, LoopResult::Completed)); + assert_eq!( + *seen.lock(), + vec![ + vec!["初始任务"], + vec!["初始任务", "A"], + vec!["初始任务", "A", "B"] + ], + "每次进入 idle 只交接 FIFO 队首" + ); + assert!(mailbox.snapshot().items.is_empty()); + let mut delivered = Vec::new(); + while let Ok(event) = handles.render_rx.try_recv() { + if let crate::agent::events_v2::RenderEvent::UserInputDelivered { input_id, .. } = event { + delivered.push(input_id); + } + } + assert_eq!( + delivered, input_ids, + "聊天投递事件按稳定输入 ID 顺序发射且无重复" + ); + while let Ok(event) = handles.state_rx.try_recv() { + assert!( + !matches!( + event, + crate::agent::events_v2::StateEvent::TurnSuspended { .. } + ), + "已有可执行 prompt 时不发布虚假的挂起状态" + ); + } +} + +#[tokio::test] +async fn test_e2e_cancel_before_loop() { + // e2e:cancel_token 在 run_react_loop 之前触发 → Interrupted + let cwd: Arc = Arc::from("/tmp/e2e-cancel"); + let frozen = FrozenContext::builder().build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(FinalAnswerLLM { + answer: "should not reach", + })) + .build(); + + // 立即 cancel + ctx.session.turn.cancel_token.cancel(); + + let result = run_react_loop(ctx, 10).await; + assert!( + matches!(result, LoopResult::Interrupted), + "expected Interrupted, got {:?}", + result + ); +} + +/// [回归测试] Reason 内取消必须保留成对的 stage lifecycle, +/// 同时将 loop 终态规范化为 Interrupted,不得降级成 Error(Interrupted)。 +#[tokio::test] +async fn test_run_react_loop_cancel_during_reason_is_interrupted() { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let session = Session::new( + Arc::from("/tmp/e2e-cancel-during-reason"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(InterruptibleReasonLLM { + entered: std::sync::Mutex::new(Some(entered_tx)), + })) + .with_event_bus(Arc::new(bus)) + .build(); + ctx.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("cancel while reasoning"), + )); + let loop_ctx = ctx.clone(); + let task = tokio::spawn(async move { run_react_loop(loop_ctx, 10).await }); + entered_rx.await.expect("Reason LLM 必须进入调用"); + + ctx.session.turn.cancel_token.cancel(); + let result = task.await.expect("loop task 不得 panic"); + + assert!( + matches!(result, LoopResult::Interrupted), + "Reason 内取消必须返回 Interrupted,got: {result:?}" + ); + let lifecycle: Vec<_> = std::iter::from_fn(|| handles.try_observe()) + .filter_map(|event| match event { + ObserveEvent::StageStarted { stage, .. } => Some((stage, None)), + ObserveEvent::StageEnded { stage, status, .. } => Some((stage, Some(status))), + _ => None, + }) + .collect(); + assert_eq!( + lifecycle, + vec![ + (Stage::Receive, None), + (Stage::Receive, Some(StageStatus::Done)), + (Stage::Compact, None), + (Stage::Compact, Some(StageStatus::Done)), + (Stage::Reason, None), + (Stage::Reason, Some(StageStatus::Error)), + ], + "Reason 取消仍必须发射成对 StageEnded(Error),且不得进入 Act" + ); +} + +#[tokio::test] +async fn test_e2e_empty_queue_completes_immediately() { + // e2e:无 Prompt 推入 → Receive 阶段 consumed=0 → Completed + let cwd: Arc = Arc::from("/tmp/e2e-empty"); + let frozen = FrozenContext::builder().build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(FinalAnswerLLM { answer: "answer" })) + .build(); + + // 不推入 Prompt,直接跑循环(首轮 Receive consumed=0 → 直接退出) + let result = run_react_loop(ctx.clone(), 0).await; + assert!( + matches!(result, LoopResult::Completed), + "expected Completed, got {:?}", + result + ); + + // RCRA:空队列立即退出,不会进入 Reason/Act,transcript 为空 + let transcript = ctx.session.transcript.read(); + assert!( + transcript.is_empty(), + "expected empty transcript on immediate exit" + ); +} diff --git a/peri-agent/src/agent/stages/reason.rs b/peri-agent/src/agent/stages/reason.rs index f84728791..233383266 100644 --- a/peri-agent/src/agent/stages/reason.rs +++ b/peri-agent/src/agent/stages/reason.rs @@ -1,7 +1,7 @@ //! Reason 阶段 — LLM 推理 //! -//! 流程:snapshot visible_messages → emit LlmCallStart → before_model → -//! LLM.generate_reasoning(与 cancel 竞争)→ after_model → emit LlmCallEnd +//! 流程:catalog → before_model → 最终压力检查 → snapshot → LlmCallStart → +//! LLM.generate_reasoning(与 cancel 竞争)→ LlmCallEnd → after_model use super::middleware_runner::{ run_after_model, run_before_model, run_before_reason_catalog, run_on_error, @@ -19,6 +19,9 @@ pub async fn run_reason(input: ReasonInput) -> AgentResult { tracing::trace!(step, has_tool_calls = input.has_tool_calls, "Reason 阶段"); + // 固定准备阶段之前的视图;Full reset/Micro 缩减本身不应触发第二次评估。 + super::compact::context_pressure::refresh(ctx)?; + // Apply a new session capability generation only at the Reason boundary. let refreshed = match ctx.runtime.tool_catalog.refresh() { Ok(snapshot) => snapshot, @@ -40,6 +43,35 @@ pub async fn run_reason(input: ReasonInput) -> AgentResult { .map_err(|error| AgentError::Other(anyhow::Error::new(error)))? }; + // before_model 和动态工具发布在常规 Compact 之后。只有准备阶段确实新增 + // 压力且达到 Full 阈值时补检一次;不重复运行模型/compact hooks。 + let prepared_input_grew = super::compact::context_pressure::refresh(ctx)?; + let needs_compact = prepared_input_grew + && ctx + .compact + .compact_config + .as_ref() + .zip(ctx.compact.context_budget.as_ref()) + .is_some_and(|(config, budget)| { + budget.context_window > 0 + && ctx + .compact + .token_tracker + .read() + .estimated_context_tokens() + .is_some_and(|tokens| { + tokens as f64 / f64::from(budget.context_window) + >= config.auto_compact_threshold + }) + }); + if needs_compact { + super::compact::run_compact_core(super::CompactInput { + context: ctx.clone(), + has_tool_calls: input.has_tool_calls, + }) + .await?; + } + // 取出 messages 快照(避免跨 await 持有 RwLockReadGuard)。 // 直接构建为 Arc:LlmCallStart 与 LLM 调用共享同一份,避免二次深拷贝。 let messages_snapshot: std::sync::Arc> = @@ -61,6 +93,15 @@ pub async fn run_reason(input: ReasonInput) -> AgentResult { .filter(|t| t.is_direct() && t.visible_to_model()) .map(|t| t.as_ref()) .collect(); + // 绑定最终发送视图,不能使用 Compact 前或 before_model 前的估算来结清增长。 + let request_estimate = ctx + .runtime + .llm + .estimate_request_tokens(&messages_snapshot, &tool_refs); + ctx.compact + .token_tracker + .write() + .begin_request(request_estimate); // 工具数量与名称追踪(调试用;默认 filter 下不写盘) tracing::debug!( step, diff --git a/peri-agent/src/agent/stages/stages_test.rs b/peri-agent/src/agent/stages/stages_test.rs index 0dd5956b4..9401e99ce 100644 --- a/peri-agent/src/agent/stages/stages_test.rs +++ b/peri-agent/src/agent/stages/stages_test.rs @@ -1,10 +1,8 @@ -//! 从 mod.rs 分离的测试模块 +//! 阶段输入输出与消息入队契约;按职责挂载循环行为测试。 use super::*; use crate::messages::MessageContent; -use crate::middleware::capabilities as hook_state; use crate::session::queue::MessageSource; use crate::session::store::FrozenContext; -use crate::session::test_resources::mock::MockSessionResources; use crate::session::Session; /// 构造测试用 StageContext @@ -18,2502 +16,117 @@ fn make_stage_context() -> StageContext { StageContext::new(turn, session.transcript(), session.queue().clone()) } -// ── 类型契约测试 ── - -#[test] -fn test_compact_input_output_contract() { - let ctx = make_stage_context(); - let input = CompactInput { - context: ctx, - has_tool_calls: false, - }; - assert!(!input.has_tool_calls); - - let output = CompactOutput { compacted: false }; - assert!(!output.compacted); -} - -#[test] -fn test_receive_input_output_contract() { - let ctx = make_stage_context(); - let _input = ReceiveInput { context: ctx }; - let output = ReceiveOutput { - consumed_count: 0, - wake_up_count: 0, - input_message_ids: Vec::new(), - }; - assert_eq!(output.consumed_count, 0); - assert_eq!(output.wake_up_count, 0); -} - -#[test] -fn test_reason_input_output_contract() { - let ctx = make_stage_context(); - let _input = ReasonInput { - context: ctx.clone(), - has_tool_calls: false, - }; - let reasoning = crate::agent::react::Reasoning::with_answer("thinking", "answer"); - let output = ReasonOutput { - reasoning, - catalog: ctx.runtime.tool_catalog.snapshot(), - messages_snapshot: std::sync::Arc::new(vec![]), - }; - assert!(!output.reasoning.needs_tool_call()); - assert!(output.messages_snapshot.is_empty()); -} - -#[test] -fn test_act_input_output_contract() { - let ctx = make_stage_context(); - let reasoning = crate::agent::react::Reasoning::with_answer("thinking", "done"); - let _input = ActInput { - context: ctx.clone(), - reasoning, - catalog: ctx.runtime.tool_catalog.snapshot(), - }; - - let output_with_tools = ActOutput { - has_tool_calls: true, - final_answer: None, - }; - assert!(output_with_tools.has_tool_calls); - assert!(output_with_tools.final_answer.is_none()); - - let output_no_tools = ActOutput { - has_tool_calls: false, - final_answer: Some("done".to_string()), - }; - assert!(!output_no_tools.has_tool_calls); - assert_eq!(output_no_tools.final_answer.as_deref(), Some("done")); -} - -#[test] -fn test_stage_context_construction() { - let ctx = make_stage_context(); - assert_eq!(&*ctx.session.turn.cwd, "/tmp/test"); - assert_eq!(ctx.session.turn.current_step(), 0); - assert!(ctx.session.queue.is_empty()); - assert!(ctx.session.transcript.read().is_empty()); -} - -#[test] -fn test_stage_context_builder_default() { - // builder 不传 llm 时,自动 fallback 到 NullReactLLM - let cwd: Arc = Arc::from("/tmp"); - let frozen = FrozenContext::builder().build(); - let session = Session::new(cwd, frozen, None); - let turn = session.start_turn(); - let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()).build(); - assert_eq!(ctx.runtime.llm.model_name(), "null"); -} - -// ── e2e 集成测试(验证完整 v2 ReAct 循环)── - -/// Mock LLM:首轮返回 final_answer,无 tool_calls -struct FinalAnswerLLM { - answer: &'static str, -} - -struct InputBatchProbe { - calls: Arc, - batches: Arc>>>, -} - -#[async_trait::async_trait] -impl crate::middleware::Middleware for InputBatchProbe { - fn name(&self) -> &str { - "InputBatchProbe" - } - - async fn before_agent( - &self, - state: &mut dyn hook_state::BeforeAgentState, - ) -> crate::error::AgentResult<()> { - self.calls.fetch_add(1, Ordering::SeqCst); - let ids = state - .input_message_ids() - .expect("生产 runner 必须明确传入输入批次") - .to_vec(); - self.batches.lock().push(ids.clone()); - let inputs: Vec<_> = state - .messages() - .iter() - .filter(|message| ids.contains(&message.id())) - .cloned() - .collect(); - for input in inputs { - let content = MessageContent::text(format!("{} prepared", input.content())); - assert!(state.replace_message(input.clone_with_content(content))); - } - Ok(()) - } -} - -#[tokio::test] -async fn test_input_batch_reaches_single_before_agent_without_history_or_background() { - let mut ctx = make_stage_context(); - ctx.runtime.llm = Arc::new(FinalAnswerLLM { answer: "done" }); - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let batches = Arc::new(parking_lot::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(InputBatchProbe { - calls: calls.clone(), - batches: batches.clone(), - })); - ctx.runtime.middleware_chain = Arc::new(chain); - let historical = BaseMessage::human("history @image old.png"); - ctx.session.transcript.write().append(historical.clone()); - let first = BaseMessage::human("A @image current.png"); - let second = BaseMessage::human("B @src/current.rs"); - let background = BaseMessage::human("background @image ignored.png"); - ctx.session.queue.push_batch(vec![ - QueuedMessage::prompt(MessageSource::UserInput, first.clone()), - QueuedMessage::prompt(MessageSource::UserInput, second.clone()), - QueuedMessage::prompt(MessageSource::UserInput, BaseMessage::human("")), - QueuedMessage::defer(MessageSource::SubAgentComplete, background.clone()), - ]); - - assert!(matches!( - run_react_loop(ctx.clone(), 1).await, - LoopResult::Completed - )); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "整条 before_agent 链只能执行一次" - ); - assert_eq!( - *batches.lock(), - vec![vec![first.id(), second.id()]], - "批次仅包含本次非空用户输入且顺序不变" - ); - let transcript = ctx.session.transcript.read(); - for input in [&first, &second] { - assert_eq!( - transcript.get(input.id()).unwrap().message().content(), - format!("{} prepared", input.content()), - "每条输入都经同一链准备且稳定 ID 写回" - ); - } - for untouched in [&historical, &background] { - assert_eq!( - transcript.get(untouched.id()).unwrap().message().content(), - untouched.content(), - "历史与后台 Human 不属于本批用户输入" - ); - } -} - -#[tokio::test] -async fn test_input_batch_empty_background_attempt_does_not_reprocess_history() { - let mut ctx = make_stage_context(); - ctx.runtime.llm = Arc::new(FinalAnswerLLM { answer: "done" }); - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let batches = Arc::new(parking_lot::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(InputBatchProbe { - calls: calls.clone(), - batches: batches.clone(), - })); - ctx.runtime.middleware_chain = Arc::new(chain); - let historical = BaseMessage::human("history @image old.png"); - ctx.session.transcript.write().append(historical.clone()); - ctx.session.queue.push(QueuedMessage::defer( - MessageSource::SubAgentComplete, - BaseMessage::human("background result"), - )); - - assert!(matches!( - run_react_loop(ctx.clone(), 1).await, - LoopResult::Completed - )); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "后台 attempt 仍只调用一次原 hook 链" - ); - assert_eq!( - *batches.lock(), - vec![Vec::::new()], - "生产空批次明确为 Some(empty),不能按最后 Human 回退" - ); - assert_eq!( - ctx.session - .transcript - .read() - .get(historical.id()) - .unwrap() - .message() - .content(), - historical.content() - ); -} -#[async_trait::async_trait] -impl ReactLLM for FinalAnswerLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - Ok(crate::agent::react::Reasoning::with_answer( - "thinking", - self.answer, - )) - } - fn model_name(&self) -> String { - "mock-final-answer".to_string() - } -} - -struct InterruptibleReasonLLM { - entered: std::sync::Mutex>>, -} - -#[async_trait::async_trait] -impl ReactLLM for InterruptibleReasonLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - if let Some(entered) = self.entered.lock().unwrap().take() { - let _ = entered.send(()); - } - std::future::pending().await - } -} - -struct CountingFinalAnswerLLM { - calls: Arc, - answer: &'static str, -} - -#[async_trait::async_trait] -impl ReactLLM for CountingFinalAnswerLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(crate::agent::react::Reasoning::with_answer( - "thinking", - self.answer, - )) - } -} - -struct IterationBudgetProbe { - calls: Arc, - prompt_visible: Arc>>, - prompt_marker: &'static str, - recall_marker: &'static str, -} - -#[async_trait::async_trait] -impl crate::middleware::Middleware for IterationBudgetProbe { - fn name(&self) -> &str { - "IterationBudgetProbe" - } - - async fn before_agent( - &self, - state: &mut dyn hook_state::BeforeAgentState, - ) -> crate::error::AgentResult<()> { - self.calls.fetch_add(1, Ordering::SeqCst); - self.prompt_visible.lock().unwrap().push( - state - .messages() - .iter() - .any(|message| message.content().contains(self.prompt_marker)), - ); - state.push_recall(self.recall_marker.to_string()); - Ok(()) - } -} - -struct OneToolCallLLM(Arc); - -#[async_trait::async_trait] -impl ReactLLM for OneToolCallLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - let call = self.0.fetch_add(1, Ordering::SeqCst); - assert_eq!(call, 0, "预算耗尽后不得发起第二次模型调用"); - Ok(crate::agent::react::Reasoning::with_tools( - "use the deterministic tool", - vec![crate::agent::react::ToolCall::new( - "iteration-budget-tool-call", - "iteration_budget_tool", - serde_json::json!({}), - )], - )) - } -} - -struct IterationBudgetTool(Arc); - -#[async_trait::async_trait] -impl crate::tools::BaseTool for IterationBudgetTool { - fn name(&self) -> &str { - "iteration_budget_tool" - } - - fn description(&self) -> &str { - "deterministic iteration budget regression tool" - } - - fn parameters(&self) -> serde_json::Value { - serde_json::json!({}) - } - - async fn invoke( - &self, - _input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - self.0.fetch_add(1, Ordering::SeqCst); - Ok("iteration budget tool result".to_string()) - } -} - -#[derive(Debug, Default)] -struct LoopEventSummary { - stage_lifecycle: Vec<(Stage, bool)>, - llm_start_steps: Vec, - llm_end_steps: Vec, -} - -fn drain_loop_observe_events( - handles: &mut crate::agent::events_v2::EventHandles, -) -> LoopEventSummary { - let mut summary = LoopEventSummary::default(); - while let Some(event) = handles.try_observe() { - match event { - ObserveEvent::StageStarted { stage, .. } => { - summary.stage_lifecycle.push((stage, false)); - } - ObserveEvent::StageEnded { stage, status, .. } => { - assert_eq!(status, StageStatus::Done, "阶段必须以 Done 成对结束"); - summary.stage_lifecycle.push((stage, true)); - } - ObserveEvent::LlmCallStart { step, .. } => summary.llm_start_steps.push(step), - ObserveEvent::LlmCallEnd { step, .. } => summary.llm_end_steps.push(step), - _ => {} - } - } - summary -} - -fn expected_stage_lifecycle(stages: &[Stage]) -> Vec<(Stage, bool)> { - stages - .iter() - .flat_map(|stage| [(*stage, false), (*stage, true)]) - .collect() -} - -fn assert_single_turn_completed( - handles: &mut crate::agent::events_v2::EventHandles, - expected_steps: usize, -) { - let completed_steps: Vec<_> = std::iter::from_fn(|| handles.try_render()) - .filter_map(|event| match event { - crate::agent::events_v2::RenderEvent::TurnCompleted { steps, .. } => Some(steps), - _ => None, - }) - .collect(); - assert_eq!(completed_steps, vec![expected_steps]); -} - -/// [回归测试] 最后一轮语义工作产出 final answer 后,下一次 Receive 必须观察正常完成。 -/// -/// 历史背景:旧循环把整个 Receive→Act 外层 `for` 计入预算,limit=1 时 Act 已提交 -/// final answer,却在下一次 Receive 前直接误报 MaxIterationsExceeded。 -#[tokio::test] -async fn test_run_react_loop_final_answer_at_iteration_limit_completes() { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let session = Session::new( - Arc::from("/tmp/iteration-budget-final"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&calls), - answer: "done at the limit", - })) - .with_event_bus(Arc::new(bus)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("final prompt"), - )); - - let result = run_react_loop(context.clone(), 1).await; - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(context.session.turn.current_step(), 1); - let events = drain_loop_observe_events(&mut handles); - assert_eq!( - events.stage_lifecycle, - expected_stage_lifecycle(&[ - Stage::Receive, - Stage::Compact, - Stage::Reason, - Stage::Act, - Stage::Receive, - ]) - ); - assert_eq!(events.llm_start_steps, vec![1]); - assert_eq!(events.llm_end_steps, vec![1]); - assert_single_turn_completed(&mut handles, 1); -} - -#[tokio::test] -async fn test_run_react_loop_info_only_does_not_wake_model() { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let session = Session::new( - Arc::from("/tmp/micro-compact-info-only"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&calls), - answer: "must not run", - })) - .build(); - context.session.queue.push(QueuedMessage::info( - MessageSource::SystemInjected, - BaseMessage::human("micro compact state update"), - )); - - let result = run_react_loop(context.clone(), 1).await; - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(calls.load(Ordering::SeqCst), 0); - assert_eq!(context.session.turn.current_step(), 0); - assert!(context.session.transcript.read().entries()[0] - .message() - .content() - .contains("micro compact state update")); -} - -#[tokio::test] -async fn test_run_react_loop_defer_still_continues_to_model() { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let session = Session::new( - Arc::from("/tmp/defer-continuation"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&calls), - answer: "continued", - })) - .build(); - context.session.queue.push(QueuedMessage::defer( - MessageSource::SubAgentComplete, - BaseMessage::human("real deferred result"), - )); - - let result = run_react_loop(context.clone(), 1).await; - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(context.session.turn.current_step(), 1); -} - -/// [回归测试] 零预算仍必须先进入 Receive,空队列由 Receive 唯一判定正常完成。 -/// -/// 历史背景:预算门禁若放在 Receive 前,max_iterations=0 会把无需语义工作的空 turn -/// 错误分类为超限,并破坏 Receive 作为正常退出唯一入口的架构契约。 -#[tokio::test] -async fn test_run_react_loop_empty_queue_with_zero_budget_completes_in_receive() { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let session = Session::new( - Arc::from("/tmp/iteration-budget-empty-zero"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&calls), - answer: "must not run", - })) - .with_event_bus(Arc::new(bus)) - .build(); - - let result = run_react_loop(context.clone(), 0).await; - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(calls.load(Ordering::SeqCst), 0); - assert_eq!(context.session.turn.current_step(), 0); - let events = drain_loop_observe_events(&mut handles); - assert_eq!( - events.stage_lifecycle, - expected_stage_lifecycle(&[Stage::Receive]) - ); - assert!(events.llm_start_steps.is_empty()); - assert!(events.llm_end_steps.is_empty()); -} - -/// [回归测试] 有待处理 prompt 但语义预算为零时,只允许 Receive 消费消息。 -/// -/// 历史背景:预算检查需要位于 Receive 与语义阶段之间,既不能跳过消息消费,也不能 -/// 推进 step、运行 before_agent、调用模型或工具。 -#[tokio::test] -async fn test_run_react_loop_prompt_with_zero_budget_returns_max_iterations() { - let before_agent_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let prompt_visible = Arc::new(std::sync::Mutex::new(Vec::new())); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut chain = crate::middleware::MiddlewareChain::new(); - chain.add(Box::new(IterationBudgetProbe { - calls: Arc::clone(&before_agent_calls), - prompt_visible: Arc::clone(&prompt_visible), - prompt_marker: "zero budget prompt", - recall_marker: "zero budget recall", - })); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "iteration_budget_tool".to_string(), - Arc::new(IterationBudgetTool(Arc::clone(&tool_calls))) as Arc, - )]))); - let session = Session::new( - Arc::from("/tmp/iteration-budget-prompt-zero"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&llm_calls), - answer: "must not run", - })) - .with_tools(tools) - .with_middleware_chain(Arc::new(chain)) - .with_event_bus(Arc::new(bus)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("zero budget prompt"), - )); - - let result = run_react_loop(context.clone(), 0).await; - - assert!(matches!( - result, - LoopResult::Error(crate::error::AgentError::MaxIterationsExceeded(0)) - )); - assert_eq!(llm_calls.load(Ordering::SeqCst), 0); - assert_eq!(tool_calls.load(Ordering::SeqCst), 0); - assert_eq!(before_agent_calls.load(Ordering::SeqCst), 0); - assert!(prompt_visible.lock().unwrap().is_empty()); - assert!(context.recall_buffer.read().is_empty()); - assert_eq!(context.session.turn.current_step(), 0); - assert_eq!(context.session.transcript.read().len(), 1); - let events = drain_loop_observe_events(&mut handles); - assert_eq!( - events.stage_lifecycle, - expected_stage_lifecycle(&[Stage::Receive]) - ); - assert!(events.llm_start_steps.is_empty()); - assert!(events.llm_end_steps.is_empty()); -} - -/// [回归测试] 工具结果确实需要下一次推理时,耗尽的预算必须拒绝新语义迭代。 -/// -/// 历史背景:final answer 的收尾 Receive 可以越过预算,但工具调用后的空 Receive -/// 仍代表需要继续 Reason,必须精确返回 MaxIterationsExceeded(limit)。 -#[tokio::test] -async fn test_run_react_loop_required_reason_beyond_limit_returns_max_iterations() { - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "iteration_budget_tool".to_string(), - Arc::new(IterationBudgetTool(Arc::clone(&tool_calls))) as Arc, - )]))); - let session = Session::new( - Arc::from("/tmp/iteration-budget-tool"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(OneToolCallLLM(Arc::clone(&llm_calls)))) - .with_tools(tools) - .with_event_bus(Arc::new(bus)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("use one tool"), - )); - - let result = run_react_loop(context.clone(), 1).await; - - assert!(matches!( - result, - LoopResult::Error(crate::error::AgentError::MaxIterationsExceeded(1)) - )); - assert_eq!(llm_calls.load(Ordering::SeqCst), 1); - assert_eq!(tool_calls.load(Ordering::SeqCst), 1); - assert_eq!(context.session.turn.current_step(), 1); - let events = drain_loop_observe_events(&mut handles); - assert_eq!( - events.stage_lifecycle, - expected_stage_lifecycle(&[ - Stage::Receive, - Stage::Compact, - Stage::Reason, - Stage::Act, - Stage::Receive, - ]) - ); - assert_eq!(events.llm_start_steps, vec![1]); - assert_eq!(events.llm_end_steps, vec![1]); - assert_single_turn_completed(&mut handles, 1); -} - -/// [回归测试] idle await_wake 与随后重试的 Receive 不得消耗语义迭代预算。 -/// -/// 历史背景:旧外层 `for` 把首次空 Receive 的 idle 挂起算作一次迭代,limit=1 时 -/// prompt 唤醒后尚未运行 Reason 就误报超限;before_agent 也不得在挂起前提前执行。 -#[tokio::test] -async fn test_run_react_loop_idle_wake_does_not_consume_iteration_budget() { - let before_agent_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let prompt_visible = Arc::new(std::sync::Mutex::new(Vec::new())); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let should_wait = Arc::new(AtomicBool::new(true)); - let suspended = Arc::new(AtomicBool::new(false)); - let mut chain = crate::middleware::MiddlewareChain::new(); - chain.add(Box::new(IterationBudgetProbe { - calls: Arc::clone(&before_agent_calls), - prompt_visible: Arc::clone(&prompt_visible), - prompt_marker: "idle wake prompt", - recall_marker: "idle wake recall", - })); - let session = Session::new( - Arc::from("/tmp/iteration-budget-idle-wake"), - FrozenContext::builder().build(), - None, - ); - let inbox = Arc::new(crate::agent::session::SessionInbox::new(Arc::new( - session.queue().clone(), - ))); - let handle = inbox.handle(); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingFinalAnswerLLM { - calls: Arc::clone(&llm_calls), - answer: "done after wake", - })) - .with_middleware_chain(Arc::new(chain)) - .with_event_bus(Arc::new(bus)) - .with_idle_inbox(inbox) - .with_idle_should_wait({ - let should_wait = Arc::clone(&should_wait); - Arc::new(move || should_wait.load(Ordering::Acquire)) - }) - .with_idle_suspended_flag(Arc::clone(&suspended)) - .build(); - let loop_context = context.clone(); - let loop_task = tokio::spawn(async move { run_react_loop(loop_context, 1).await }); - tokio::time::timeout(std::time::Duration::from_secs(1), async { - while !suspended.load(Ordering::Acquire) { - tokio::task::yield_now().await; - } - }) - .await - .expect("循环必须进入可观测的 idle suspended 状态"); - assert_eq!(before_agent_calls.load(Ordering::SeqCst), 0); - should_wait.store(false, Ordering::Release); - handle.push_prompt( - MessageSource::UserInput, - BaseMessage::human("idle wake prompt"), - ); - - let result = tokio::time::timeout(std::time::Duration::from_secs(1), loop_task) - .await - .expect("唤醒后的循环必须在有界时间内结束") - .expect("循环任务不得 panic"); - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(llm_calls.load(Ordering::SeqCst), 1); - assert_eq!(before_agent_calls.load(Ordering::SeqCst), 1); - assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); - assert_eq!( - context.recall_buffer.read().as_slice(), - ["idle wake recall"] - ); - assert_eq!(context.session.turn.current_step(), 1); - assert!(!suspended.load(Ordering::Acquire)); - let events = drain_loop_observe_events(&mut handles); - assert_eq!( - events.stage_lifecycle, - expected_stage_lifecycle(&[ - Stage::Receive, - Stage::Receive, - Stage::Compact, - Stage::Reason, - Stage::Act, - Stage::Receive, - ]) - ); - assert_eq!(events.llm_start_steps, vec![1]); - assert_eq!(events.llm_end_steps, vec![1]); - assert_single_turn_completed(&mut handles, 1); -} - -#[tokio::test] -async fn test_e2e_final_answer_no_tools() { - // e2e:推入 Prompt → run_react_loop → 直接 final_answer → Completed - let cwd: Arc = Arc::from("/tmp/e2e"); - let frozen = FrozenContext::builder().build(); - let session = Session::new(cwd, frozen, None); - let turn = session.start_turn(); - let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(FinalAnswerLLM { - answer: "task completed", - })) - .build(); - - // 推入用户输入 - ctx.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human(MessageContent::text("do the task")), - )); - - let result = run_react_loop(ctx.clone(), 10).await; - assert!( - matches!(result, LoopResult::Completed), - "expected Completed, got {:?}", - result - ); - - // transcript 应包含:[user_prompt, ai_final_answer] - let transcript = ctx.session.transcript.read(); - let visible: Vec<_> = transcript.visible_messages().into_iter().collect(); - assert_eq!( - visible.len(), - 2, - "expected 2 messages (user + ai), got {}", - visible.len() - ); - assert!(matches!(visible[0], BaseMessage::Human { .. })); - assert!(matches!(visible[1], BaseMessage::Ai { .. })); -} - -/// [回归测试] loading 期间排队的两个 prompt 在后台任务仍活跃时逐条驱动真实 loop。 -#[tokio::test] -async fn test_run_react_loop_idle_dispatches_queued_prompts_one_at_a_time() { - use crate::session::user_input_mailbox::UserInputMailbox; - use peri_acp_types::session::{EnqueueUserInputRequest, SessionInbox, UserInputState}; - struct PausedFirstAnswerLLM { - entered: parking_lot::Mutex>>, - resume: Arc, - seen: Arc>>>, - } - #[async_trait::async_trait] - impl ReactLLM for PausedFirstAnswerLLM { - async fn generate_reasoning( - &self, - messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - self.seen.lock().push( - messages - .iter() - .filter(|message| matches!(message, BaseMessage::Human { .. })) - .map(|message| message.content().to_string()) - .collect(), - ); - let entered = self.entered.lock().take(); - if let Some(entered) = entered { - entered.send(()).unwrap(); - self.resume.notified().await; - } - Ok(crate::agent::react::Reasoning::with_answer("", "完成")) - } - } - let mut context = make_stage_context(); - let inbox = Arc::new(SessionInbox::new(Arc::new(context.session.queue.clone()))); - let mailbox = UserInputMailbox::new("session".into(), inbox.clone(), Arc::new(|_| {})); - mailbox - .attach_external_attempt(context.session.turn.cancel_token.as_ref().clone(), false) - .unwrap(); - let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); - let resume = Arc::new(tokio::sync::Notify::new()); - let seen = Arc::new(parking_lot::Mutex::new(Vec::new())); - context.runtime.llm = Arc::new(PausedFirstAnswerLLM { - entered: parking_lot::Mutex::new(Some(entered_tx)), - resume: resume.clone(), - seen: seen.clone(), - }); - context.session.user_input_mailbox = Some(mailbox.clone()); - context.async_ctx.idle_inbox = Some(inbox.clone()); - context.async_ctx.idle_should_wait = Some({ - let seen = seen.clone(); - Arc::new(move || seen.lock().len() < 3) - }); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - context.runtime.event_bus = Arc::new(bus); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("初始任务"), - )); - let task = tokio::spawn(run_react_loop(context, 3)); - tokio::time::timeout(std::time::Duration::from_secs(1), entered_rx) - .await - .expect("初始模型调用必须开始") - .unwrap(); - let mut input_ids = Vec::new(); - for text in ["A", "B"] { - let input_id = uuid::Uuid::now_v7().to_string(); - let receipt = mailbox - .enqueue(&EnqueueUserInputRequest { - session_id: "session".into(), - generation: mailbox.generation().into(), - command_id: format!("enqueue-{text}"), - input_id: input_id.clone(), - content: MessageContent::text(text), - original_draft: text.into(), - }) - .unwrap(); - assert_eq!(receipt.results[0].state, UserInputState::Queued); - input_ids.push(input_id); - } - assert!(inbox.queue().is_empty(), "loading 期间不能交接普通待办"); - resume.notify_one(); - let result = tokio::time::timeout(std::time::Duration::from_secs(1), task) - .await - .expect("已有队列必须在 idle 自动继续,无需再次提交或等待后台结果") - .unwrap(); - assert!(matches!(result, LoopResult::Completed)); - assert_eq!( - *seen.lock(), - vec![ - vec!["初始任务"], - vec!["初始任务", "A"], - vec!["初始任务", "A", "B"] - ], - "每次进入 idle 只交接 FIFO 队首" - ); - assert!(mailbox.snapshot().items.is_empty()); - let mut delivered = Vec::new(); - while let Ok(event) = handles.render_rx.try_recv() { - if let crate::agent::events_v2::RenderEvent::UserInputDelivered { input_id, .. } = event { - delivered.push(input_id); - } - } - assert_eq!( - delivered, input_ids, - "聊天投递事件按稳定输入 ID 顺序发射且无重复" - ); - while let Ok(event) = handles.state_rx.try_recv() { - assert!( - !matches!( - event, - crate::agent::events_v2::StateEvent::TurnSuspended { .. } - ), - "已有可执行 prompt 时不发布虚假的挂起状态" - ); - } -} - -#[tokio::test] -async fn test_e2e_cancel_before_loop() { - // e2e:cancel_token 在 run_react_loop 之前触发 → Interrupted - let cwd: Arc = Arc::from("/tmp/e2e-cancel"); - let frozen = FrozenContext::builder().build(); - let session = Session::new(cwd, frozen, None); - let turn = session.start_turn(); - let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(FinalAnswerLLM { - answer: "should not reach", - })) - .build(); - - // 立即 cancel - ctx.session.turn.cancel_token.cancel(); - - let result = run_react_loop(ctx, 10).await; - assert!( - matches!(result, LoopResult::Interrupted), - "expected Interrupted, got {:?}", - result - ); -} - -/// [回归测试] Reason 内取消必须保留成对的 stage lifecycle, -/// 同时将 loop 终态规范化为 Interrupted,不得降级成 Error(Interrupted)。 -#[tokio::test] -async fn test_run_react_loop_cancel_during_reason_is_interrupted() { - let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); - let session = Session::new( - Arc::from("/tmp/e2e-cancel-during-reason"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(InterruptibleReasonLLM { - entered: std::sync::Mutex::new(Some(entered_tx)), - })) - .with_event_bus(Arc::new(bus)) - .build(); - ctx.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("cancel while reasoning"), - )); - let loop_ctx = ctx.clone(); - let task = tokio::spawn(async move { run_react_loop(loop_ctx, 10).await }); - entered_rx.await.expect("Reason LLM 必须进入调用"); - - ctx.session.turn.cancel_token.cancel(); - let result = task.await.expect("loop task 不得 panic"); - - assert!( - matches!(result, LoopResult::Interrupted), - "Reason 内取消必须返回 Interrupted,got: {result:?}" - ); - let lifecycle: Vec<_> = std::iter::from_fn(|| handles.try_observe()) - .filter_map(|event| match event { - ObserveEvent::StageStarted { stage, .. } => Some((stage, None)), - ObserveEvent::StageEnded { stage, status, .. } => Some((stage, Some(status))), - _ => None, - }) - .collect(); - assert_eq!( - lifecycle, - vec![ - (Stage::Receive, None), - (Stage::Receive, Some(StageStatus::Done)), - (Stage::Compact, None), - (Stage::Compact, Some(StageStatus::Done)), - (Stage::Reason, None), - (Stage::Reason, Some(StageStatus::Error)), - ], - "Reason 取消仍必须发射成对 StageEnded(Error),且不得进入 Act" - ); -} - -#[tokio::test] -async fn test_e2e_empty_queue_completes_immediately() { - // e2e:无 Prompt 推入 → Receive 阶段 consumed=0 → Completed - let cwd: Arc = Arc::from("/tmp/e2e-empty"); - let frozen = FrozenContext::builder().build(); - let session = Session::new(cwd, frozen, None); - let turn = session.start_turn(); - let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(FinalAnswerLLM { answer: "answer" })) - .build(); - - // 不推入 Prompt,直接跑循环(首轮 Receive consumed=0 → 直接退出) - let result = run_react_loop(ctx.clone(), 0).await; - assert!( - matches!(result, LoopResult::Completed), - "expected Completed, got {:?}", - result - ); - - // RCRA:空队列立即退出,不会进入 Reason/Act,transcript 为空 - let transcript = ctx.session.transcript.read(); - assert!( - transcript.is_empty(), - "expected empty transcript on immediate exit" - ); -} - -#[tokio::test] -async fn test_p0_2_before_agent_runs_once_after_tool_round_trip() { - use std::collections::BTreeMap; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex, - }; - - struct ToolRoundTripLLM(Arc); - - #[async_trait::async_trait] - impl ReactLLM for ToolRoundTripLLM { - async fn generate_reasoning( - &self, - messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - match self.0.fetch_add(1, Ordering::SeqCst) { - 0 => Ok(crate::agent::react::Reasoning::with_tools( - "use the local tool", - vec![crate::agent::react::ToolCall::new( - "p0-2-tool-call", - "p0_2_local_tool", - serde_json::json!({}), - )], - )), - 1 => { - assert!( - messages - .iter() - .any(|message| message.content().contains("p0-2 tool result marker")), - "second LLM call must observe the local tool result" - ); - Ok(crate::agent::react::Reasoning::with_answer("", "done")) - } - call => panic!("unexpected LLM call {call}"), - } - } - } - - struct LocalTool(Arc); - - #[async_trait::async_trait] - impl crate::tools::BaseTool for LocalTool { - fn name(&self) -> &str { - "p0_2_local_tool" - } - - fn description(&self) -> &str { - "deterministic local test tool" - } - - fn parameters(&self) -> serde_json::Value { - serde_json::json!({}) - } - - async fn invoke( - &self, - _input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - self.0.fetch_add(1, Ordering::SeqCst); - Ok("p0-2 tool result marker".to_string()) - } - } - - struct BeforeAgentProbe { - calls: Arc, - prompt_visible: Arc>>, - } - - #[async_trait::async_trait] - impl crate::middleware::Middleware for BeforeAgentProbe { - fn name(&self) -> &str { - "BeforeAgentProbe" - } - - async fn before_agent( - &self, - state: &mut dyn hook_state::BeforeAgentState, - ) -> crate::error::AgentResult<()> { - self.calls.fetch_add(1, Ordering::SeqCst); - self.prompt_visible.lock().unwrap().push( - state - .messages() - .iter() - .any(|message| message.content().contains("p0-2 prompt marker")), - ); - state.push_recall("p0-2 recall marker".to_string()); - Ok(()) - } - } - - let before_agent_calls = Arc::new(AtomicUsize::new(0)); - let prompt_visible = Arc::new(Mutex::new(Vec::new())); - let llm_calls = Arc::new(AtomicUsize::new(0)); - let tool_calls = Arc::new(AtomicUsize::new(0)); - let mut chain = crate::middleware::MiddlewareChain::new(); - chain.add(Box::new(BeforeAgentProbe { - calls: Arc::clone(&before_agent_calls), - prompt_visible: Arc::clone(&prompt_visible), - })); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "p0_2_local_tool".to_string(), - Arc::new(LocalTool(Arc::clone(&tool_calls))) as Arc, - )]))); - - let session = Session::new( - Arc::from("/tmp/p0-2-before-agent-tool-round-trip"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(ToolRoundTripLLM(Arc::clone(&llm_calls)))) - .with_tools(tools) - .with_middleware_chain(Arc::new(chain)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("p0-2 prompt marker"), - )); - - assert!(matches!( - run_react_loop(context.clone(), 10).await, - LoopResult::Completed - )); - assert_eq!(llm_calls.load(Ordering::SeqCst), 2); - assert_eq!(tool_calls.load(Ordering::SeqCst), 1); - assert_eq!(before_agent_calls.load(Ordering::SeqCst), 1); - assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); - assert_eq!( - context.recall_buffer.read().as_slice(), - ["p0-2 recall marker"] - ); -} - -#[tokio::test] -async fn test_p0_2_before_agent_runs_once_after_receive_and_skips_empty_or_cancelled_turns() { - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex, - }; - - struct CountingLLM(Arc); - - #[async_trait::async_trait] - impl ReactLLM for CountingLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(crate::agent::react::Reasoning::with_answer("", "done")) - } - } - - struct BeforeAgentProbe { - calls: Arc, - prompt_visible: Arc>>, - } - - #[async_trait::async_trait] - impl crate::middleware::Middleware for BeforeAgentProbe { - fn name(&self) -> &str { - "BeforeAgentProbe" - } - - async fn before_agent( - &self, - state: &mut dyn hook_state::BeforeAgentState, - ) -> crate::error::AgentResult<()> { - self.calls.fetch_add(1, Ordering::SeqCst); - self.prompt_visible.lock().unwrap().push( - state - .messages() - .iter() - .any(|message| message.content().contains("p0-2 prompt marker")), - ); - state.push_recall("p0-2 recall marker".to_string()); - Ok(()) - } - } - - let calls = Arc::new(AtomicUsize::new(0)); - let prompt_visible = Arc::new(Mutex::new(Vec::new())); - let llm_calls = Arc::new(AtomicUsize::new(0)); - let mut chain = crate::middleware::MiddlewareChain::new(); - chain.add(Box::new(BeforeAgentProbe { - calls: Arc::clone(&calls), - prompt_visible: Arc::clone(&prompt_visible), - })); - - let cwd: Arc = Arc::from("/tmp/p0-2-before-agent"); - let session = Session::new(cwd, FrozenContext::builder().build(), None); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) - .with_middleware_chain(Arc::new(chain)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("p0-2 prompt marker"), - )); - - assert!(matches!( - run_react_loop(context.clone(), 10).await, - LoopResult::Completed - )); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(*prompt_visible.lock().unwrap(), vec![true]); - assert_eq!(llm_calls.load(Ordering::SeqCst), 1); - assert_eq!( - context.recall_buffer.read().as_slice(), - ["p0-2 recall marker"] - ); - - let empty_session = Session::new( - Arc::from("/tmp/p0-2-before-agent-empty"), - FrozenContext::builder().build(), - None, - ); - let empty_turn = empty_session.start_turn(); - let empty_context = StageContext::builder( - empty_turn, - empty_session.transcript(), - empty_session.queue().clone(), - ) - .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) - .with_middleware_chain(context.runtime.middleware_chain.clone()) - .build(); - assert!(matches!( - run_react_loop(empty_context.clone(), 10).await, - LoopResult::Completed - )); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert!(empty_context.recall_buffer.read().is_empty()); - assert_eq!(llm_calls.load(Ordering::SeqCst), 1); - - let cancelled_session = Session::new( - Arc::from("/tmp/p0-2-before-agent-cancelled"), - FrozenContext::builder().build(), - None, - ); - let cancelled_turn = cancelled_session.start_turn(); - let cancelled_context = StageContext::builder( - cancelled_turn, - cancelled_session.transcript(), - cancelled_session.queue().clone(), - ) - .with_llm(Arc::new(CountingLLM(Arc::clone(&llm_calls)))) - .with_middleware_chain(context.runtime.middleware_chain.clone()) - .build(); - cancelled_context.session.turn.cancel_token.cancel(); - assert!(matches!( - run_react_loop(cancelled_context.clone(), 10).await, - LoopResult::Interrupted - )); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert!(cancelled_context.recall_buffer.read().is_empty()); - assert_eq!(llm_calls.load(Ordering::SeqCst), 1); -} - -// ─── 启动闸门 hook(before_react_start)回归 ──────────────────────────────── -// -// 契约 2:System MCP 等启动依赖未完成前不得进入可启动 react loop。闸门在首批 -// before_agent 之后、Compact 之前调用一次;Err 终止本次 loop,Interrupted 仍按 -// 中断分类;既有 before_agent 的软失败降级不变。 - -/// 静态 MCP bridge 测试桩(名称与生产 `mcp__{server}__{tool}` 同形)。 -struct StartupGateStubTool { - name: String, - server: Option, - direct: bool, -} - -#[async_trait::async_trait] -impl crate::tools::BaseTool for StartupGateStubTool { - fn name(&self) -> &str { - &self.name - } - - fn description(&self) -> &str { - &self.name - } - - fn parameters(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - - fn mcp_server_name(&self) -> Option<&str> { - self.server.as_deref() - } - - fn is_direct(&self) -> bool { - self.direct - } - - async fn invoke( - &self, - _input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - Ok(String::new()) - } -} - -fn startup_gate_stub( - name: &str, - server: Option<&str>, - direct: bool, -) -> Arc { - Arc::new(StartupGateStubTool { - name: name.to_string(), - server: server.map(str::to_owned), - direct, - }) -} - -/// 本地目录 + working map:闸门提交的静态 MCP bridge 必须与既有条目共存。 -fn startup_gate_catalog() -> (SharedToolMap, Arc) { - let local = startup_gate_stub("startup_gate_local_tool", None, false); - let working: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "startup_gate_local_tool".to_string(), - Arc::clone(&local), - )]))); - let catalog = Arc::new(SessionToolCatalog::new( - BTreeMap::from([("startup_gate_local_tool".to_string(), local)]), - None, - )); - (working, catalog) -} - -/// 本次准入的候选:静态 bridge + 必需工具身份。 -fn startup_gate_candidate() -> crate::session::tool_catalog::StartupToolUpdate { - crate::session::tool_catalog::StartupToolUpdate { - tools: vec![startup_gate_stub( - "mcp__system__lookup", - Some("system"), - true, - )], - required: vec![crate::session::tool_catalog::StartupRequiredTool { - server_name: "system".to_string(), - original_tool_name: "lookup".to_string(), - effective_tool_name: "mcp__system__lookup".to_string(), - }], - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StartupGateOutcome { - /// 暂存候选后成功返回。 - Publish, - /// 暂存候选后返回 Err:候选必须整批丢弃。 - StageThenFail, - /// 返回 `AgentError::Interrupted`:按中断分类,不是 fatal。 - Interrupt, -} - -/// 同时实现 `before_agent` 与 `before_react_start`,用于断言调用次序。 -struct StartupGateProbe { - outcome: StartupGateOutcome, - before_agent_fails: bool, - order: Arc>>, - gate_calls: Arc, -} - -#[async_trait::async_trait] -impl crate::middleware::Middleware for StartupGateProbe { - fn name(&self) -> &str { - "StartupGateProbe" - } - - async fn before_agent( - &self, - _state: &mut dyn hook_state::BeforeAgentState, - ) -> crate::error::AgentResult<()> { - self.order.lock().unwrap().push("before_agent"); - if self.before_agent_fails { - return Err(crate::error::AgentError::MiddlewareError { - middleware: self.name().to_string(), - reason: "soft before_agent failure".to_string(), - }); - } - Ok(()) - } - - async fn before_react_start( - &self, - state: &mut dyn hook_state::StartupState, - ) -> crate::error::AgentResult<()> { - self.order.lock().unwrap().push("before_react_start"); - self.gate_calls.fetch_add(1, Ordering::SeqCst); - state.stage_startup_tools(startup_gate_candidate())?; - match self.outcome { - StartupGateOutcome::Publish => Ok(()), - StartupGateOutcome::StageThenFail => Err(crate::error::AgentError::MiddlewareError { - middleware: self.name().to_string(), - reason: "startup gate failure".to_string(), - }), - StartupGateOutcome::Interrupt => Err(crate::error::AgentError::Interrupted), - } - } -} - -/// 记录每次模型调用看到的 direct 工具名;首轮触发一次本地工具调用以产生第二轮迭代。 -struct StartupGateLLM { - calls: Arc, - seen_tools: Arc>>>, -} - -#[async_trait::async_trait] -impl ReactLLM for StartupGateLLM { - async fn generate_reasoning( - &self, - _messages: &[BaseMessage], - tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - self.seen_tools.lock().unwrap().push( - tools - .iter() - .map(|tool| tool.name().to_string()) - .collect::>(), - ); - match self.calls.fetch_add(1, Ordering::SeqCst) { - 0 => Ok(crate::agent::react::Reasoning::with_tools( - "use the local probe tool", - vec![crate::agent::react::ToolCall::new( - "startup-gate-tool-call", - "startup_gate_local_tool", - serde_json::json!({}), - )], - )), - _ => Ok(crate::agent::react::Reasoning::with_answer( - "thinking", "done", - )), - } - } - - fn model_name(&self) -> String { - "startup-gate-mock".to_string() - } -} - -#[tokio::test] -async fn test_react_start_gate_publishes_candidate_before_first_reason_and_runs_once() { - let (working, catalog) = startup_gate_catalog(); - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(StartupGateProbe { - outcome: StartupGateOutcome::Publish, - before_agent_fails: false, - order: Arc::clone(&order), - gate_calls: Arc::clone(&gate_calls), - })); - - let session = Session::new( - Arc::from("/tmp/react-start-gate-publish"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(StartupGateLLM { - calls: Arc::clone(&llm_calls), - seen_tools: Arc::clone(&seen_tools), - })) - .with_tools(working) - .with_tool_catalog(Arc::clone(&catalog)) - .with_middleware_chain(Arc::new(chain)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("startup gate prompt"), - )); - - assert!(matches!( - run_react_loop(context.clone(), 10).await, - LoopResult::Completed - )); - - assert_eq!( - *order.lock().unwrap(), - vec!["before_agent", "before_react_start"], - "闸门必须在首批 before_agent 之后执行" - ); - assert_eq!( - gate_calls.load(Ordering::SeqCst), - 1, - "闸门每次 loop 只执行一次,第二轮迭代不得重复准入" - ); - assert_eq!( - llm_calls.load(Ordering::SeqCst), - 2, - "工具往返产生第二轮迭代,闸门成功不得阻止 Reason" - ); - let snapshot = catalog.snapshot(); - assert!( - snapshot - .direct_definitions - .iter() - .any(|definition| definition.name == "mcp__system__lookup"), - "闸门候选必须作为 direct 工具进入目录" - ); - assert!( - snapshot.tools.contains_key("startup_gate_local_tool"), - "闸门提交不得覆盖既有非 MCP 条目" - ); - let seen = seen_tools.lock().unwrap(); - assert_eq!(seen.len(), 2, "两轮 Reason 都必须真正调用模型"); - assert!( - seen[0].iter().any(|name| name == "mcp__system__lookup"), - "闸门提交的 required 工具必须出现在首个模型请求的 tools 中, got {:?}", - seen[0] - ); -} - -#[tokio::test] -async fn test_react_start_gate_error_stops_before_compact_without_publishing_candidate() { - let (working, catalog) = startup_gate_catalog(); - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(StartupGateProbe { - outcome: StartupGateOutcome::StageThenFail, - before_agent_fails: false, - order: Arc::clone(&order), - gate_calls: Arc::clone(&gate_calls), - })); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - - let session = Session::new( - Arc::from("/tmp/react-start-gate-error"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(StartupGateLLM { - calls: Arc::clone(&llm_calls), - seen_tools: Arc::clone(&seen_tools), - })) - .with_tools(working) - .with_tool_catalog(Arc::clone(&catalog)) - .with_middleware_chain(Arc::new(chain)) - .with_event_bus(Arc::new(bus)) - .build(); - let published_before = catalog.snapshot(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("startup gate prompt"), - )); - - let result = run_react_loop(context.clone(), 10).await; - - match result { - LoopResult::Error(crate::error::AgentError::MiddlewareError { middleware, reason }) => { - assert_eq!(middleware, "StartupGateProbe"); - assert_eq!(reason, "startup gate failure"); - } - other => panic!("闸门 Err 必须作为 loop fatal 返回, got {other:?}"), - } - assert_eq!( - gate_calls.load(Ordering::SeqCst), - 1, - "闸门失败即终止,不得重试或重复准入" - ); - assert_eq!( - llm_calls.load(Ordering::SeqCst), - 0, - "闸门失败后不得进入 Reason 调用模型" - ); - assert_eq!( - drain_loop_observe_events(&mut handles).stage_lifecycle, - expected_stage_lifecycle(&[Stage::Receive]), - "闸门失败只允许完成 Receive,不得进入 Compact / Reason / Act" - ); - assert!( - Arc::ptr_eq(&published_before, &catalog.snapshot()), - "闸门失败必须丢弃 state 内候选,不发布部分目录" - ); -} - -#[tokio::test] -async fn test_react_start_gate_interrupted_maps_to_interrupted_not_fatal() { - let (working, catalog) = startup_gate_catalog(); - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(StartupGateProbe { - outcome: StartupGateOutcome::Interrupt, - before_agent_fails: false, - order: Arc::clone(&order), - gate_calls: Arc::clone(&gate_calls), - })); - - let session = Session::new( - Arc::from("/tmp/react-start-gate-interrupted"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(StartupGateLLM { - calls: Arc::clone(&llm_calls), - seen_tools: Arc::clone(&seen_tools), - })) - .with_tools(working) - .with_tool_catalog(Arc::clone(&catalog)) - .with_middleware_chain(Arc::new(chain)) - .build(); - let published_before = catalog.snapshot(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("startup gate prompt"), - )); - - let result = run_react_loop(context.clone(), 10).await; - - assert!( - matches!(result, LoopResult::Interrupted), - "闸门 Interrupted 必须按中断分类,不得升级为 fatal, got {result:?}" - ); - assert_eq!(gate_calls.load(Ordering::SeqCst), 1); - assert_eq!( - llm_calls.load(Ordering::SeqCst), - 0, - "中断后不得进入 Reason 调用模型" - ); - assert!( - Arc::ptr_eq(&published_before, &catalog.snapshot()), - "中断时暂存候选必须随 state 丢弃" - ); -} - -#[tokio::test] -async fn test_before_agent_soft_failure_still_reaches_reason_after_startup_gate() { - let (working, catalog) = startup_gate_catalog(); - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(StartupGateProbe { - outcome: StartupGateOutcome::Publish, - before_agent_fails: true, - order: Arc::clone(&order), - gate_calls: Arc::clone(&gate_calls), - })); - - let session = Session::new( - Arc::from("/tmp/react-start-gate-soft-before-agent"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(StartupGateLLM { - calls: Arc::clone(&llm_calls), - seen_tools: Arc::clone(&seen_tools), - })) - .with_tools(working) - .with_tool_catalog(Arc::clone(&catalog)) - .with_middleware_chain(Arc::new(chain)) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("startup gate prompt"), - )); - - assert!( - matches!( - run_react_loop(context.clone(), 10).await, - LoopResult::Completed - ), - "既有 before_agent 的软失败必须保持 warn 降级、不阻止 loop" - ); - assert_eq!( - *order.lock().unwrap(), - vec!["before_agent", "before_react_start"], - "before_agent 软失败不影响闸门按序执行" - ); - assert_eq!( - llm_calls.load(Ordering::SeqCst), - 2, - "软失败后 loop 仍必须走到 Reason 并完成工具往返" - ); - assert_eq!( - gate_calls.load(Ordering::SeqCst), - 1, - "软失败不得让闸门重复执行" - ); -} - -#[tokio::test] -async fn test_react_start_gate_skipped_when_loop_exits_at_receive() { - let (working, catalog) = startup_gate_catalog(); - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); - let mut chain = MiddlewareChain::new(); - chain.add(Box::new(StartupGateProbe { - outcome: StartupGateOutcome::Publish, - before_agent_fails: false, - order: Arc::clone(&order), - gate_calls: Arc::clone(&gate_calls), - })); - - let session = Session::new( - Arc::from("/tmp/react-start-gate-empty-queue"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(StartupGateLLM { - calls: Arc::clone(&llm_calls), - seen_tools: Arc::clone(&seen_tools), - })) - .with_tools(working) - .with_tool_catalog(Arc::clone(&catalog)) - .with_middleware_chain(Arc::new(chain)) - .build(); - - assert!(matches!( - run_react_loop(context.clone(), 10).await, - LoopResult::Completed - )); - assert_eq!( - gate_calls.load(Ordering::SeqCst), - 0, - "Receive 直接退出时不得调用启动闸门" - ); - assert!(order.lock().unwrap().is_empty()); - assert_eq!(llm_calls.load(Ordering::SeqCst), 0); -} - -struct UsageChurnReactLLM { - usages: Vec, - calls: Arc, - requests: Arc>>, +/// Mock LLM:首轮返回 final_answer,无 tool_calls +struct FinalAnswerLLM { + answer: &'static str, } #[async_trait::async_trait] -impl ReactLLM for UsageChurnReactLLM { +impl ReactLLM for FinalAnswerLLM { async fn generate_reasoning( &self, - messages: &[BaseMessage], + _messages: &[BaseMessage], _tools: &[&dyn crate::tools::BaseTool], _streaming: Option, ) -> crate::error::AgentResult { - let call = self.calls.fetch_add(1, Ordering::SeqCst); - self.requests.lock().unwrap().push(messages.len()); - let usage = self.usages[call]; - let mut reasoning = if call + 1 == self.usages.len() { - crate::agent::react::Reasoning::with_answer("finish", "done") - } else { - crate::agent::react::Reasoning::with_tools( - format!("generation {call}"), - vec![crate::agent::react::ToolCall::new( - format!("usage-churn-{call}"), - "usage_churn_tool", - serde_json::json!({ "generation": call }), - )], - ) - }; - reasoning.usage = Some(peri_model::TokenUsage { - input_tokens: usage, - output_tokens: 100, - cache_creation_input_tokens: None, - cache_read_input_tokens: None, - }); - reasoning.request_id = Some(format!("usage-generation-{call}")); - reasoning.model = "scripted-usage-churn".to_string(); - Ok(reasoning) - } -} - -struct UsageChurnTool; - -#[async_trait::async_trait] -impl crate::tools::BaseTool for UsageChurnTool { - fn name(&self) -> &str { - "usage_churn_tool" - } - - fn description(&self) -> &str { - "keeps the characterization loop running" - } - - fn parameters(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { "generation": { "type": "integer" } } - }) - } - - async fn invoke( - &self, - input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - Ok(format!( - "generation {} tool output {}", - input["generation"], - "x".repeat(256) + Ok(crate::agent::react::Reasoning::with_answer( + "thinking", + self.answer, )) } -} - -struct CountingCompactModel { - calls: Arc, -} - -#[async_trait::async_trait] -impl peri_model::Model for CountingCompactModel { - fn capabilities(&self) -> peri_model::ModelCapabilities { - peri_model::ModelCapabilities { - supports_tools: false, - supports_reasoning: false, - supports_vision: false, - supports_streaming: true, - } - } - - async fn stream( - &self, - _request: peri_model::ModelRequest, - _cancellation: tokio_util::sync::CancellationToken, - ) -> peri_model::ModelResult { - unreachable!("compact characterization uses complete") - } - - async fn complete( - &self, - _request: peri_model::ModelRequest, - _cancellation: tokio_util::sync::CancellationToken, - ) -> peri_model::ModelResult { - let call = self.calls.fetch_add(1, Ordering::SeqCst); - peri_model::ModelResponse::new( - peri_model::ModelMessage::assistant_text(format!( - "compact generation {call}" - )), - peri_model::StopReason::EndTurn, - None, - None, - ) - } -} - -struct SuccessfulFullReactLLM { - calls: Arc, - requests: Arc>>>, - file_path: String, -} - -#[async_trait::async_trait] -impl ReactLLM for SuccessfulFullReactLLM { - async fn generate_reasoning( - &self, - messages: &[BaseMessage], - _tools: &[&dyn crate::tools::BaseTool], - _streaming: Option, - ) -> crate::error::AgentResult { - let call = self.calls.fetch_add(1, Ordering::SeqCst); - self.requests.lock().unwrap().push( - messages - .iter() - .map(|message| message.content().to_string()) - .collect(), - ); - let mut reasoning = if call == 0 { - crate::agent::react::Reasoning::with_tools( - "original reasoning marker", - vec![crate::agent::react::ToolCall::new( - "successful-full-read", - "Read", - serde_json::json!({ "file_path": self.file_path }), - )], - ) - } else { - crate::agent::react::Reasoning::with_answer("post-full reasoning", "done") - }; - reasoning.usage = Some(peri_model::TokenUsage { - input_tokens: if call == 0 { 96_000 } else { 1_000 }, - output_tokens: 100, - cache_creation_input_tokens: None, - cache_read_input_tokens: None, - }); - reasoning.request_id = Some(format!("scripted-successful-full-{call}")); - reasoning.model = "scripted-successful-full".to_string(); - Ok(reasoning) + fn model_name(&self) -> String { + "mock-final-answer".to_string() } } -struct SuccessfulFullReadTool; - -#[async_trait::async_trait] -impl crate::tools::BaseTool for SuccessfulFullReadTool { - fn name(&self) -> &str { - "Read" - } - - fn description(&self) -> &str { - "reads the characterization fixture" - } +// ── 类型契约测试 ── - fn parameters(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { "file_path": { "type": "string" } }, - "required": ["file_path"] - }) - } +#[test] +fn test_compact_input_output_contract() { + let ctx = make_stage_context(); + let input = CompactInput { + context: ctx, + has_tool_calls: false, + }; + assert!(!input.has_tool_calls); - async fn invoke( - &self, - input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - Ok(std::fs::read_to_string( - input["file_path"].as_str().unwrap(), - )?) - } + let output = CompactOutput { compacted: false }; + assert!(!output.compacted); } -/// Characterization:scripted provider usage 驱动一次真实持久化 Full lifecycle,随后 tracker -/// 接受 Full 后 Reason 返回的新低 usage 样本。 -#[tokio::test] -async fn test_run_react_loop_successful_full_replaces_history_reinjects_read_file_and_resets_usage() -{ - use crate::thread::ThreadMeta; - - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("full-reinject-marker.txt"); - let file_marker = "successful full reinjected file marker"; - std::fs::write(&file_path, file_marker).unwrap(); - - let store = MockSessionResources::new(); - let thread_id = store - .create_thread(ThreadMeta::new(dir.path().to_string_lossy())) - .await - .unwrap(); - let store_dyn: Arc = store.clone(); - let session = Session::new( - Arc::from(dir.path().to_string_lossy().as_ref()), - FrozenContext::builder().build(), - Some(thread_id.clone()), - ); - { - let transcript = session.transcript(); - let mut transcript = transcript.write(); - *transcript = - std::mem::take(&mut *transcript).with_persistence(store_dyn.clone(), thread_id.clone()); - } - - let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let reason_requests = Arc::new(std::sync::Mutex::new(Vec::new())); - let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "Read".to_string(), - Arc::new(SuccessfulFullReadTool) as Arc, - )]))); - let config = CompactConfig { - micro_compact_stale_steps: 0, - target_headroom_tokens: 50_000, - micro_field_threshold_chars: 32, - micro_field_keep_head_chars: 8, - micro_field_keep_tail_chars: 8, - ..Default::default() +#[test] +fn test_receive_input_output_contract() { + let ctx = make_stage_context(); + let _input = ReceiveInput { context: ctx }; + let output = ReceiveOutput { + consumed_count: 0, + wake_up_count: 0, + input_message_ids: Vec::new(), }; - let mut budget = crate::agent::token::ContextBudget::new(100_000); - budget.output_reserve = 40_000; - let turn = session.start_turn(); - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(SuccessfulFullReactLLM { - calls: Arc::clone(&reason_calls), - requests: Arc::clone(&reason_requests), - file_path: file_path.to_string_lossy().into_owned(), - })) - .with_tools(tools) - .with_event_bus(Arc::new(bus)) - .with_context_budget(budget) - .with_compact_config(config) - .with_compact_llm(Arc::new(CountingCompactModel { - calls: Arc::clone(&compact_calls), - })) - .build(); - let prompt_marker = "successful full original prompt marker"; - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human(prompt_marker), - )); - - assert!(matches!( - run_react_loop(context.clone(), 2).await, - LoopResult::Completed - )); - assert_eq!(reason_calls.load(Ordering::SeqCst), 2); - assert_eq!(compact_calls.load(Ordering::SeqCst), 1); - - { - let requests = reason_requests.lock().unwrap(); - assert!(requests[0] - .iter() - .any(|content| content.contains(prompt_marker))); - let post_full = &requests[1]; - assert!(post_full - .iter() - .any(|content| content.contains("compact generation 0"))); - assert!(post_full - .iter() - .any(|content| content.contains(file_marker))); - assert!(!post_full - .iter() - .any(|content| content.contains(prompt_marker))); - assert!(!post_full - .iter() - .any(|content| content.contains("original reasoning marker"))); - } - - let outcomes: Vec<_> = std::iter::from_fn(|| handles.try_observe()) - .filter_map(|event| match event { - ObserveEvent::MessagesCompacted { outcome, .. } => Some(outcome), - _ => None, - }) - .collect(); - assert_eq!( - outcomes, - vec![crate::agent::compact_v2::CompactOutcome::FullApplied] - ); - assert_eq!( - context - .compact - .token_tracker - .read() - .estimated_context_tokens(), - Some(1_000), - "Full reset 后第二次 Reason 的低 usage 应成为权威样本" - ); - - let persist_tx = context - .session - .transcript - .read() - .persist_tx_handle() - .expect("测试 transcript 应绑定持久化 writer"); - crate::session::transcript::MessageTranscript::flush_via_tx(&persist_tx) - .await - .unwrap(); - let persisted = store.load_messages(&thread_id).await.unwrap(); - let flags = store.load_message_flags(&thread_id).await.unwrap(); - let summary_count = persisted - .iter() - .filter(|message| message.content().contains("compact generation 0")) - .count(); - let reinject_count = persisted - .iter() - .filter(|message| { - message.content().contains(file_marker) - && !flags.get(&message.id()).is_some_and(|flag| flag.excluded) - }) - .count(); - assert_eq!(summary_count, 1); - assert_eq!(reinject_count, 1); - assert!(persisted - .iter() - .filter(|message| { - message.content().contains(prompt_marker) - || message.content().contains("original reasoning marker") - || message.content() == file_marker - }) - .all(|message| flags.get(&message.id()).is_some_and(|flag| flag.excluded))); + assert_eq!(output.consumed_count, 0); + assert_eq!(output.wake_up_count, 0); } -/// Characterization:每次 Reason 都返回新的 provider usage generation 时,已消费样本 guard -/// 会重新 arm;策略由新的高位值决定,而不是由上一次 Compact 的结果决定。 -#[tokio::test] -async fn test_run_react_loop_new_high_usage_generations_continue_full_micro_churn() { - let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let reason_requests = Arc::new(std::sync::Mutex::new(Vec::new())); - let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let session = Session::new( - Arc::from("/tmp/usage-churn-characterization"), - FrozenContext::builder().build(), - None, - ); - let turn = session.start_turn(); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "usage_churn_tool".to_string(), - Arc::new(UsageChurnTool) as Arc, - )]))); - let config = CompactConfig { - micro_compact_stale_steps: 0, - target_headroom_tokens: 50_000, - micro_field_threshold_chars: 32, - micro_field_keep_head_chars: 8, - micro_field_keep_tail_chars: 8, - ..Default::default() +#[test] +fn test_reason_input_output_contract() { + let ctx = make_stage_context(); + let _input = ReasonInput { + context: ctx.clone(), + has_tool_calls: false, }; - let mut budget = crate::agent::token::ContextBudget::new(100_000); - budget.output_reserve = 40_000; - let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) - .with_llm(Arc::new(UsageChurnReactLLM { - usages: vec![96_000, 80_000, 97_000, 81_000], - calls: Arc::clone(&reason_calls), - requests: Arc::clone(&reason_requests), - })) - .with_tools(tools) - .with_event_bus(Arc::new(bus)) - .with_context_budget(budget) - .with_compact_config(config) - .with_compact_llm(Arc::new(CountingCompactModel { - calls: Arc::clone(&compact_calls), - })) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("characterize usage churn"), - )); - - let result = run_react_loop(context, 4).await; - - assert!(matches!(result, LoopResult::Completed)); - assert_eq!(reason_calls.load(Ordering::SeqCst), 4); - assert_eq!(reason_requests.lock().unwrap().len(), 4); - let compacted: Vec<_> = std::iter::from_fn(|| handles.try_observe()) - .filter_map(|event| match event { - ObserveEvent::MessagesCompacted { - strategy, - estimated_tokens_before, - full_escalation_reason, - outcome, - .. - } => Some(( - strategy, - estimated_tokens_before, - full_escalation_reason, - outcome, - )), - _ => None, - }) - .collect(); - assert_eq!( - compacted, - vec![ - ( - crate::agent::events::CompactStrategy::Micro, - 96_070, - Some(crate::agent::compact_v2::planner::FullEscalationReason::InsufficientReclaim), - crate::agent::compact_v2::CompactOutcome::MicroAppliedThenFullFailed, - ), - ( - crate::agent::events::CompactStrategy::Micro, - 80_070, - None, - crate::agent::compact_v2::CompactOutcome::MicroApplied, - ), - ( - crate::agent::events::CompactStrategy::Micro, - 97_070, - Some(crate::agent::compact_v2::planner::FullEscalationReason::InsufficientReclaim), - crate::agent::compact_v2::CompactOutcome::MicroAppliedThenFullFailed, - ), - ], - "每个新的非零高位 usage generation 都会再次触发;Full 区间会升级尝试,Micro 区间只执行 Micro" - ); - assert_eq!( - compact_calls.load(Ordering::SeqCst), - 2, - "两次 Full 各调用一次 compact LLM;中间 Micro 不调用" - ); + let reasoning = crate::agent::react::Reasoning::with_answer("thinking", "answer"); + let output = ReasonOutput { + reasoning, + catalog: ctx.runtime.tool_catalog.snapshot(), + messages_snapshot: std::sync::Arc::new(vec![]), + }; + assert!(!output.reasoning.needs_tool_call()); + assert!(output.messages_snapshot.is_empty()); } -struct AuditAlternatingOutputTool; - -#[async_trait::async_trait] -impl crate::tools::BaseTool for AuditAlternatingOutputTool { - fn name(&self) -> &str { - "usage_churn_tool" - } - - fn description(&self) -> &str { - "为审计循环交替提供长短结果" - } - - fn parameters(&self) -> serde_json::Value { - serde_json::json!({"type": "object", "properties": {"generation": {"type": "integer"}}}) - } +#[test] +fn test_act_input_output_contract() { + let ctx = make_stage_context(); + let reasoning = crate::agent::react::Reasoning::with_answer("thinking", "done"); + let _input = ActInput { + context: ctx.clone(), + reasoning, + catalog: ctx.runtime.tool_catalog.snapshot(), + }; - async fn invoke( - &self, - input: serde_json::Value, - _ctx: crate::tools::ToolContext<'_>, - ) -> Result> { - Ok(if input["generation"].as_u64().unwrap().is_multiple_of(2) { - "x".repeat(8_000) - } else { - "ok".to_string() - }) - } -} + let output_with_tools = ActOutput { + has_tool_calls: true, + final_answer: None, + }; + assert!(output_with_tools.has_tool_calls); + assert!(output_with_tools.final_answer.is_none()); -/// [回归测试] SQLite Full 后新高 usage 可再次 Full,但不能对 excluded 历史发 Micro。 -/// usage 数列为受控输入,只证明新高样本下的执行链,不代表现场 token 测量。 -#[tokio::test] -async fn test_run_react_loop_successful_full_does_not_recompact_excluded_history() { - use crate::agent::compact_v2::{planner::plan_micro, projection, CompactOutcome}; - use crate::thread::ThreadMeta; - let store = MockSessionResources::new(); - let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap(); - let session = Session::new( - Arc::from("/tmp"), - FrozenContext::builder().build(), - Some(thread_id.clone()), - ); - { - let transcript = session.transcript(); - let mut transcript = transcript.write(); - *transcript = - std::mem::take(&mut *transcript).with_persistence(store.clone(), thread_id.clone()); - } - let reason_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let compact_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let tools: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( - "usage_churn_tool".to_string(), - Arc::new(AuditAlternatingOutputTool) as Arc, - )]))); - let config = CompactConfig { - micro_compact_stale_steps: 0, - ..Default::default() + let output_no_tools = ActOutput { + has_tool_calls: false, + final_answer: Some("done".to_string()), }; - let mut budget = crate::agent::token::ContextBudget::new(100_000); - budget.output_reserve = 40_000; - let context = StageContext::builder( - session.start_turn(), - session.transcript(), - session.queue().clone(), - ) - .with_llm(Arc::new(UsageChurnReactLLM { - usages: vec![96_000, 80_000, 96_000, 80_000, 1_000], - calls: reason_calls.clone(), - requests: Arc::new(std::sync::Mutex::new(Vec::new())), - })) - .with_tools(tools) - .with_event_bus(Arc::new(bus)) - .with_context_budget(budget) - .with_compact_config(config.clone()) - .with_compact_llm(Arc::new(CountingCompactModel { - calls: compact_calls.clone(), - })) - .build(); - context.session.queue.push(QueuedMessage::prompt( - MessageSource::UserInput, - BaseMessage::human("audit churn"), - )); - assert!(matches!( - run_react_loop(context.clone(), 5).await, - LoopResult::Completed - )); - assert_eq!(reason_calls.load(Ordering::SeqCst), 5); - assert_eq!(compact_calls.load(Ordering::SeqCst), 2); - let compacted: Vec<_> = std::iter::from_fn(|| handles.try_observe()) - .filter_map(|event| match event { - ObserveEvent::MessagesCompacted { - outcome, - estimated_tokens_saved, - affected_count, - .. - } => Some((outcome, estimated_tokens_saved, affected_count)), - _ => None, - }) - .collect(); - assert_eq!( - compacted.iter().map(|entry| entry.0).collect::>(), - vec![CompactOutcome::FullApplied, CompactOutcome::FullApplied,] - ); - { - let transcript = context.session.transcript.read(); - let plan = plan_micro(&transcript, &config, false); - assert!( - plan.actions.is_empty(), - "Full 后只有短结果可见,不应再次规划旧工具输出" - ); - let canonical = transcript.visible_model_messages().unwrap(); - let projected = - projection::render_llm_view(&transcript, &plan, &Default::default()).unwrap(); - assert_eq!( - serde_json::to_value(canonical).unwrap(), - serde_json::to_value(projected).unwrap(), - "无 Micro action 时维持 canonical 模型视图" - ); - } - let tx = context - .session - .transcript - .read() - .persist_tx_handle() - .unwrap(); - crate::session::transcript::MessageTranscript::flush_via_tx(&tx) - .await - .unwrap(); - let flags = store.load_message_flags(&thread_id).await.unwrap(); - assert_eq!( - flags - .values() - .filter(|flag| flag.excluded && flag.projection.is_some()) - .count(), - 0 - ); + assert!(!output_no_tools.has_tool_calls); + assert_eq!(output_no_tools.final_answer.as_deref(), Some("done")); } -/// [回归测试] 工具结果已进入 transcript 后,下一轮 Compact 必须看见其新增压力。 -#[tokio::test] -async fn test_audit_dispatch_must_account_for_tool_output_pressure() { - let context = make_stage_context(); - context.runtime.tools.write().insert( - "usage_churn_tool".into(), - Arc::new(AuditAlternatingOutputTool), - ); - let catalog = context - .runtime - .tool_catalog - .pin_working_tools(&context.runtime.tools.read()) - .unwrap(); - context - .compact - .token_tracker - .write() - .accumulate(&peri_model::TokenUsage { - input_tokens: 74_000, - output_tokens: 100, - cache_creation_input_tokens: None, - cache_read_input_tokens: None, - }); - let reasoning = crate::agent::react::Reasoning::with_tools( - "inspect", - vec![crate::agent::react::ToolCall::new( - "audit-output", - "usage_churn_tool", - serde_json::json!({"generation": 0}), - )], - ); - super::tool_dispatch::dispatch_tools( - &context, - &reasoning, - &catalog, - &tokio_util::sync::CancellationToken::new(), - ) - .await - .unwrap(); - assert!( - context - .session - .transcript - .read() - .visible_messages() - .iter() - .any(|message| { - matches!(message, BaseMessage::Tool { .. }) && message.content().len() == 8_000 - }), - "必须实际执行工具并写入其完整结果" - ); - assert_eq!( - context - .compact - .token_tracker - .read() - .estimated_context_tokens(), - Some(76_000), - "74k provider input 加上已提交的 8k 字符工具结果,按 tracker 的 chars/4 应为 76k" - ); +#[test] +fn test_stage_context_construction() { + let ctx = make_stage_context(); + assert_eq!(&*ctx.session.turn.cwd, "/tmp/test"); + assert_eq!(ctx.session.turn.current_step(), 0); + assert!(ctx.session.queue.is_empty()); + assert!(ctx.session.transcript.read().is_empty()); } -/// [回归测试] Reason 已投影的内容不能在随后 Micro 中再次报告增量回收收益。 -#[tokio::test] -async fn test_audit_micro_savings_must_change_previous_reason_view() { - let session = Session::new(Arc::from("/tmp"), FrozenContext::builder().build(), None); - { - let transcript = session.transcript(); - let mut transcript = transcript.write(); - for turn in 0..4 { - let call_id = format!("audit-reason-{turn}"); - transcript.append(BaseMessage::human("inspect")); - transcript.append(BaseMessage::ai_with_tool_calls( - "inspect", - vec![crate::messages::ToolCallRequest::new( - &call_id, - "Bash", - serde_json::json!({"command": "fixture"}), - )], - )); - transcript.append(BaseMessage::tool_result(&call_id, "x".repeat(8_000))); - } - } - let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); - let context = StageContext::builder( - session.start_turn(), - session.transcript(), - session.queue().clone(), - ) - .with_llm(Arc::new(UsageChurnReactLLM { - usages: vec![80_000, 80_000], - calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), - requests: Arc::new(std::sync::Mutex::new(Vec::new())), - })) - .with_event_bus(Arc::new(bus)) - .with_context_budget(crate::agent::token::ContextBudget::new(100_000)) - .with_compact_config(CompactConfig::default()) - .build(); - let first = super::reason::run_reason(ReasonInput { - context: context.clone(), - has_tool_calls: false, - }) - .await - .unwrap(); - let first_tool_outputs = first - .messages_snapshot - .iter() - .filter(|message| matches!(message, BaseMessage::Tool { .. })) - .map(|message| message.content().chars().count()) - .collect::>(); - assert_eq!( - first_tool_outputs, - vec![8_000; 4], - "没有已提交 directive 时 Reason 必须发送 canonical 工具结果" - ); - super::compact::run_compact(CompactInput { - context: context.clone(), - has_tool_calls: false, - }) - .await - .unwrap(); - let saved = std::iter::from_fn(|| handles.try_observe()) - .find_map(|event| match event { - ObserveEvent::MessagesCompacted { - estimated_tokens_saved, - .. - } => Some(estimated_tokens_saved), - _ => None, - }) - .unwrap_or(0); - let second = super::reason::run_reason(ReasonInput { - context, - has_tool_calls: false, - }) - .await - .unwrap(); - assert!( - saved == 0 - || serde_json::to_value(&*first.messages_snapshot).unwrap() - != serde_json::to_value(&*second.messages_snapshot).unwrap(), - "Micro 报告节省 {saved} tokens,但两次真实 Reason 消息快照完全相同" - ); +#[test] +fn test_stage_context_builder_default() { + // builder 不传 llm 时,自动 fallback 到 NullReactLLM + let cwd: Arc = Arc::from("/tmp"); + let frozen = FrozenContext::builder().build(); + let session = Session::new(cwd, frozen, None); + let turn = session.start_turn(); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()).build(); + assert_eq!(ctx.runtime.llm.model_name(), "null"); } #[test] @@ -2644,3 +257,18 @@ async fn test_e2e_defer_consumed_in_receive() { combined ); } + +#[path = "loop_iteration_test.rs"] +mod loop_iteration_tests; + +#[path = "loop_lifecycle_test.rs"] +mod loop_lifecycle_tests; + +#[path = "input_hooks_test.rs"] +mod input_hooks_tests; + +#[path = "startup_gate_test.rs"] +mod startup_gate_tests; + +#[path = "compaction_loop_test.rs"] +mod compaction_loop_tests; diff --git a/peri-agent/src/agent/stages/startup_gate_test.rs b/peri-agent/src/agent/stages/startup_gate_test.rs new file mode 100644 index 000000000..c7374a170 --- /dev/null +++ b/peri-agent/src/agent/stages/startup_gate_test.rs @@ -0,0 +1,524 @@ +//! before_react_start 闸门准入、失败与工具发布。 +use super::super::*; +use crate::middleware::capabilities as hook_state; +use crate::session::queue::MessageSource; +use crate::session::store::FrozenContext; +use crate::session::Session; + +#[derive(Debug, Default)] +struct LoopEventSummary { + stage_lifecycle: Vec<(Stage, bool)>, + llm_start_steps: Vec, + llm_end_steps: Vec, +} + +fn drain_loop_observe_events( + handles: &mut crate::agent::events_v2::EventHandles, +) -> LoopEventSummary { + let mut summary = LoopEventSummary::default(); + while let Some(event) = handles.try_observe() { + match event { + ObserveEvent::StageStarted { stage, .. } => { + summary.stage_lifecycle.push((stage, false)); + } + ObserveEvent::StageEnded { stage, status, .. } => { + assert_eq!(status, StageStatus::Done, "阶段必须以 Done 成对结束"); + summary.stage_lifecycle.push((stage, true)); + } + ObserveEvent::LlmCallStart { step, .. } => summary.llm_start_steps.push(step), + ObserveEvent::LlmCallEnd { step, .. } => summary.llm_end_steps.push(step), + _ => {} + } + } + summary +} + +fn expected_stage_lifecycle(stages: &[Stage]) -> Vec<(Stage, bool)> { + stages + .iter() + .flat_map(|stage| [(*stage, false), (*stage, true)]) + .collect() +} + +// ─── 启动闸门 hook(before_react_start)回归 ──────────────────────────────── +// +// 契约 2:System MCP 等启动依赖未完成前不得进入可启动 react loop。闸门在首批 +// before_agent 之后、Compact 之前调用一次;Err 终止本次 loop,Interrupted 仍按 +// 中断分类;既有 before_agent 的软失败降级不变。 + +/// 静态 MCP bridge 测试桩(名称与生产 `mcp__{server}__{tool}` 同形)。 +struct StartupGateStubTool { + name: String, + server: Option, + direct: bool, +} + +#[async_trait::async_trait] +impl crate::tools::BaseTool for StartupGateStubTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.name + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + + fn mcp_server_name(&self) -> Option<&str> { + self.server.as_deref() + } + + fn is_direct(&self) -> bool { + self.direct + } + + async fn invoke( + &self, + _input: serde_json::Value, + _ctx: crate::tools::ToolContext<'_>, + ) -> Result> { + Ok(String::new()) + } +} + +fn startup_gate_stub( + name: &str, + server: Option<&str>, + direct: bool, +) -> Arc { + Arc::new(StartupGateStubTool { + name: name.to_string(), + server: server.map(str::to_owned), + direct, + }) +} + +/// 本地目录 + working map:闸门提交的静态 MCP bridge 必须与既有条目共存。 +fn startup_gate_catalog() -> (SharedToolMap, Arc) { + let local = startup_gate_stub("startup_gate_local_tool", None, false); + let working: SharedToolMap = Arc::new(parking_lot::RwLock::new(BTreeMap::from([( + "startup_gate_local_tool".to_string(), + Arc::clone(&local), + )]))); + let catalog = Arc::new(SessionToolCatalog::new( + BTreeMap::from([("startup_gate_local_tool".to_string(), local)]), + None, + )); + (working, catalog) +} + +/// 本次准入的候选:静态 bridge + 必需工具身份。 +fn startup_gate_candidate() -> crate::session::tool_catalog::StartupToolUpdate { + crate::session::tool_catalog::StartupToolUpdate { + tools: vec![startup_gate_stub( + "mcp__system__lookup", + Some("system"), + true, + )], + required: vec![crate::session::tool_catalog::StartupRequiredTool { + server_name: "system".to_string(), + original_tool_name: "lookup".to_string(), + effective_tool_name: "mcp__system__lookup".to_string(), + }], + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StartupGateOutcome { + /// 暂存候选后成功返回。 + Publish, + /// 暂存候选后返回 Err:候选必须整批丢弃。 + StageThenFail, + /// 返回 `AgentError::Interrupted`:按中断分类,不是 fatal。 + Interrupt, +} + +/// 同时实现 `before_agent` 与 `before_react_start`,用于断言调用次序。 +struct StartupGateProbe { + outcome: StartupGateOutcome, + before_agent_fails: bool, + order: Arc>>, + gate_calls: Arc, +} + +#[async_trait::async_trait] +impl crate::middleware::Middleware for StartupGateProbe { + fn name(&self) -> &str { + "StartupGateProbe" + } + + async fn before_agent( + &self, + _state: &mut dyn hook_state::BeforeAgentState, + ) -> crate::error::AgentResult<()> { + self.order.lock().unwrap().push("before_agent"); + if self.before_agent_fails { + return Err(crate::error::AgentError::MiddlewareError { + middleware: self.name().to_string(), + reason: "soft before_agent failure".to_string(), + }); + } + Ok(()) + } + + async fn before_react_start( + &self, + state: &mut dyn hook_state::StartupState, + ) -> crate::error::AgentResult<()> { + self.order.lock().unwrap().push("before_react_start"); + self.gate_calls.fetch_add(1, Ordering::SeqCst); + state.stage_startup_tools(startup_gate_candidate())?; + match self.outcome { + StartupGateOutcome::Publish => Ok(()), + StartupGateOutcome::StageThenFail => Err(crate::error::AgentError::MiddlewareError { + middleware: self.name().to_string(), + reason: "startup gate failure".to_string(), + }), + StartupGateOutcome::Interrupt => Err(crate::error::AgentError::Interrupted), + } + } +} + +/// 记录每次模型调用看到的 direct 工具名;首轮触发一次本地工具调用以产生第二轮迭代。 +struct StartupGateLLM { + calls: Arc, + seen_tools: Arc>>>, +} + +#[async_trait::async_trait] +impl ReactLLM for StartupGateLLM { + async fn generate_reasoning( + &self, + _messages: &[BaseMessage], + tools: &[&dyn crate::tools::BaseTool], + _streaming: Option, + ) -> crate::error::AgentResult { + self.seen_tools.lock().unwrap().push( + tools + .iter() + .map(|tool| tool.name().to_string()) + .collect::>(), + ); + match self.calls.fetch_add(1, Ordering::SeqCst) { + 0 => Ok(crate::agent::react::Reasoning::with_tools( + "use the local probe tool", + vec![crate::agent::react::ToolCall::new( + "startup-gate-tool-call", + "startup_gate_local_tool", + serde_json::json!({}), + )], + )), + _ => Ok(crate::agent::react::Reasoning::with_answer( + "thinking", "done", + )), + } + } + + fn model_name(&self) -> String { + "startup-gate-mock".to_string() + } +} + +#[tokio::test] +async fn test_react_start_gate_publishes_candidate_before_first_reason_and_runs_once() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-publish"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + + assert_eq!( + *order.lock().unwrap(), + vec!["before_agent", "before_react_start"], + "闸门必须在首批 before_agent 之后执行" + ); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "闸门每次 loop 只执行一次,第二轮迭代不得重复准入" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 2, + "工具往返产生第二轮迭代,闸门成功不得阻止 Reason" + ); + let snapshot = catalog.snapshot(); + assert!( + snapshot + .direct_definitions + .iter() + .any(|definition| definition.name == "mcp__system__lookup"), + "闸门候选必须作为 direct 工具进入目录" + ); + assert!( + snapshot.tools.contains_key("startup_gate_local_tool"), + "闸门提交不得覆盖既有非 MCP 条目" + ); + let seen = seen_tools.lock().unwrap(); + assert_eq!(seen.len(), 2, "两轮 Reason 都必须真正调用模型"); + assert!( + seen[0].iter().any(|name| name == "mcp__system__lookup"), + "闸门提交的 required 工具必须出现在首个模型请求的 tools 中, got {:?}", + seen[0] + ); +} + +#[tokio::test] +async fn test_react_start_gate_error_stops_before_compact_without_publishing_candidate() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::StageThenFail, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + let (bus, mut handles) = crate::agent::events_v2::EventBus::new(Default::default()); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-error"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(bus)) + .build(); + let published_before = catalog.snapshot(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + let result = run_react_loop(context.clone(), 10).await; + + match result { + LoopResult::Error(crate::error::AgentError::MiddlewareError { middleware, reason }) => { + assert_eq!(middleware, "StartupGateProbe"); + assert_eq!(reason, "startup gate failure"); + } + other => panic!("闸门 Err 必须作为 loop fatal 返回, got {other:?}"), + } + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "闸门失败即终止,不得重试或重复准入" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 0, + "闸门失败后不得进入 Reason 调用模型" + ); + assert_eq!( + drain_loop_observe_events(&mut handles).stage_lifecycle, + expected_stage_lifecycle(&[Stage::Receive]), + "闸门失败只允许完成 Receive,不得进入 Compact / Reason / Act" + ); + assert!( + Arc::ptr_eq(&published_before, &catalog.snapshot()), + "闸门失败必须丢弃 state 内候选,不发布部分目录" + ); +} + +#[tokio::test] +async fn test_react_start_gate_interrupted_maps_to_interrupted_not_fatal() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Interrupt, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-interrupted"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + let published_before = catalog.snapshot(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + let result = run_react_loop(context.clone(), 10).await; + + assert!( + matches!(result, LoopResult::Interrupted), + "闸门 Interrupted 必须按中断分类,不得升级为 fatal, got {result:?}" + ); + assert_eq!(gate_calls.load(Ordering::SeqCst), 1); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 0, + "中断后不得进入 Reason 调用模型" + ); + assert!( + Arc::ptr_eq(&published_before, &catalog.snapshot()), + "中断时暂存候选必须随 state 丢弃" + ); +} + +#[tokio::test] +async fn test_before_agent_soft_failure_still_reaches_reason_after_startup_gate() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: true, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-soft-before-agent"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + context.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("startup gate prompt"), + )); + + assert!( + matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + ), + "既有 before_agent 的软失败必须保持 warn 降级、不阻止 loop" + ); + assert_eq!( + *order.lock().unwrap(), + vec!["before_agent", "before_react_start"], + "before_agent 软失败不影响闸门按序执行" + ); + assert_eq!( + llm_calls.load(Ordering::SeqCst), + 2, + "软失败后 loop 仍必须走到 Reason 并完成工具往返" + ); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 1, + "软失败不得让闸门重复执行" + ); +} + +#[tokio::test] +async fn test_react_start_gate_skipped_when_loop_exits_at_receive() { + let (working, catalog) = startup_gate_catalog(); + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let gate_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let llm_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_tools = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut chain = MiddlewareChain::new(); + chain.add(Box::new(StartupGateProbe { + outcome: StartupGateOutcome::Publish, + before_agent_fails: false, + order: Arc::clone(&order), + gate_calls: Arc::clone(&gate_calls), + })); + + let session = Session::new( + Arc::from("/tmp/react-start-gate-empty-queue"), + FrozenContext::builder().build(), + None, + ); + let turn = session.start_turn(); + let context = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(Arc::new(StartupGateLLM { + calls: Arc::clone(&llm_calls), + seen_tools: Arc::clone(&seen_tools), + })) + .with_tools(working) + .with_tool_catalog(Arc::clone(&catalog)) + .with_middleware_chain(Arc::new(chain)) + .build(); + + assert!(matches!( + run_react_loop(context.clone(), 10).await, + LoopResult::Completed + )); + assert_eq!( + gate_calls.load(Ordering::SeqCst), + 0, + "Receive 直接退出时不得调用启动闸门" + ); + assert!(order.lock().unwrap().is_empty()); + assert_eq!(llm_calls.load(Ordering::SeqCst), 0); +} diff --git a/peri-agent/src/agent/token.rs b/peri-agent/src/agent/token.rs index 62c14a776..5ad2a86c7 100644 --- a/peri-agent/src/agent/token.rs +++ b/peri-agent/src/agent/token.rs @@ -5,6 +5,7 @@ use peri_model::TokenUsage; pub(crate) struct PressureSampleKey { usage_generation: u64, tool_growth_generation: u64, + view_growth_generation: u64, } /// 会话级 token 用量追踪器 @@ -41,10 +42,21 @@ pub struct TokenTracker { /// 最近一次已消费的自动 Compact 压力样本。 #[serde(skip)] consumed_pressure_sample: Option, + /// 当前请求视图的近似大小;不伪装成 provider usage,也不跨恢复复用。 + #[serde(skip)] + current_input_estimate: Option, + /// 自最近有效 usage 后累计的正增长。Micro 缩减不抵扣后续新增工作。 + #[serde(skip)] + unconfirmed_input_growth: u64, + #[serde(skip)] + pending_input_estimate: Option, + #[serde(skip)] + view_growth_generation: u64, } impl TokenTracker { pub fn accumulate(&mut self, usage: &TokenUsage) { + let request_estimate = self.pending_input_estimate.take(); self.request_history.push(RequestRecord::from_usage(usage)); // 防止长时间会话中 request_history 无限增长 if self.request_history.len() > 1000 { @@ -64,6 +76,11 @@ impl TokenTracker { if usage.input_tokens > 0 { self.last_usage = Some(usage.clone()); self.usage_generation = self.usage_generation.saturating_add(1); + self.unconfirmed_input_growth = self + .current_input_estimate + .zip(request_estimate) + .map(|(current, sent)| current.saturating_sub(sent)) + .unwrap_or(0); // 只有新权威 input usage 已包含工具结果,才能清除本地预测。 self.estimated_tool_tokens_since_last_llm = 0; } @@ -75,7 +92,7 @@ impl TokenTracker { /// 在 `dispatch_tools` 写入 tool_result 后调用,用 `chars().count() / 4` 近似估算。 /// 不能与 LLM usage 混用——这是字符级估算,仅用于预算预警。 pub fn add_estimated_tool_tokens(&mut self, tool_output: &str) { - // 经验估算:英文 ~4 字符/token,CJK 略多但保守取 4 + // 字符启发式:不同语言和多模态成本可能偏差,不能当作真实 tokenizer。 let estimated = (tool_output.chars().count() / 4) as u64; if tool_output.is_empty() { return; @@ -87,14 +104,45 @@ impl TokenTracker { } pub fn estimated_context_tokens(&self) -> Option { - // input_tokens 已在 adapter 层规范化为总输入(含缓存 token), - // 即当前 prompt 的实际大小,直接反映上下文窗口占用。 - // 不加 output_tokens:output 会在下一轮 API 调用中包含进 input_tokens, - // 相加会导致双重计算,使显示用量约为实际的 2 倍。 - // 加上 estimated_tool_tokens_since_last_llm:本轮已写入但尚未被 LLM 感知的工具结果(P0-5) - self.last_usage.as_ref().map(|u| { - (u.input_tokens as u64).saturating_add(self.estimated_tool_tokens_since_last_llm) - }) + let Some(usage) = &self.last_usage else { + return self.current_input_estimate; + }; + // 权威 usage 只与产生它的请求视图比较:新增 assistant/user/reminder/ + // tool 内容都是增长;Micro 的估算减少不能证明真实预算已经恢复。 + // 工具提交后的即时预警和完整视图包含同一批工具,取 max 避免双计。 + Some( + u64::from(usage.input_tokens).saturating_add( + self.unconfirmed_input_growth + .max(self.estimated_tool_tokens_since_last_llm), + ), + ) + } + + /// 更新已提交模型视图;只有正增长重新激活压力样本,投影缩减不激活。 + pub(crate) fn refresh_input_estimate(&mut self, estimate: u64) -> bool { + let grew = self + .current_input_estimate + .is_none_or(|previous| estimate > previous); + if grew { + self.view_growth_generation = self.view_growth_generation.saturating_add(1); + } + if self.last_usage.is_some() { + // 有效 usage 只证明已发送快照的大小。投影缩减没有新 usage 确认, + // 故只移动比较位置;随后新增内容仍必须加到权威压力上。 + if let Some(previous) = self.current_input_estimate { + self.unconfirmed_input_growth = self + .unconfirmed_input_growth + .saturating_add(estimate.saturating_sub(previous)); + } + } + self.current_input_estimate = Some(estimate); + grew + } + + /// 在最终请求发出前绑定估算;零/缺失 usage 不会替换上一个有效基线。 + pub(crate) fn begin_request(&mut self, estimate: u64) { + self.refresh_input_estimate(estimate); + self.pending_input_estimate = Some(estimate); } pub fn context_usage_percent(&self, context_window: u32) -> Option { @@ -119,10 +167,13 @@ impl TokenTracker { } pub(crate) fn pressure_sample_key(&self) -> Option { - self.last_usage.as_ref()?; + if self.last_usage.is_none() && self.current_input_estimate.is_none() { + return None; + } Some(PressureSampleKey { usage_generation: self.usage_generation, tool_growth_generation: self.tool_growth_generation, + view_growth_generation: self.view_growth_generation, }) } @@ -139,15 +190,123 @@ impl TokenTracker { let usage_generation = self.usage_generation; let tool_growth_generation = self.tool_growth_generation; let consumed_pressure_sample = self.consumed_pressure_sample; + let view_growth_generation = self.view_growth_generation; *self = Self { usage_generation, tool_growth_generation, consumed_pressure_sample, + view_growth_generation, ..Self::default() }; } } +// 编码后的字节数不是媒体 token 成本;缺少尺寸/页数和 provider tokenizer 时, +// 每个二进制块只放一个固定预算占位。此值不是成本上限,实际 usage 仍为权威。 +const BINARY_MEDIA_ESTIMATED_TOKENS: u64 = 1_024; + +/// 无 tokenizer 时按文字和有界媒体占位估算请求内容。它用于提前压缩, +/// 不替代 provider usage;媒体尺寸/页数、协议开销和语言密度仍是近似边界。 +pub(crate) fn estimate_request_tokens( + messages: &[crate::messages::BaseMessage], + tools: &[&dyn crate::tools::BaseTool], +) -> u64 { + use crate::messages::{BaseMessage, ContentBlock, MessageContent}; + let mut chars = 0u64; + for message in messages { + let canonical_calls = match message { + BaseMessage::Ai { tool_calls, .. } => tool_calls.as_slice(), + _ => &[], + }; + chars = chars.saturating_add(match message.message_content() { + MessageContent::Text(text) => text.chars().count() as u64, + MessageContent::Blocks(blocks) => blocks + .iter() + .map(|block| estimate_block_chars(block, canonical_calls)) + .fold(0u64, u64::saturating_add), + MessageContent::Raw(values) => values + .iter() + .map( + |value| match serde_json::from_value::(value.clone()) { + Ok(block) => estimate_block_chars(&block, canonical_calls), + Err(_) => value.to_string().chars().count() as u64, + }, + ) + .fold(0u64, u64::saturating_add), + }); + if let BaseMessage::Ai { tool_calls, .. } = message { + for call in tool_calls { + chars = chars.saturating_add(call.name.chars().count() as u64); + chars = chars.saturating_add(call.arguments.to_string().chars().count() as u64); + } + } + } + for tool in tools { + chars = chars + .saturating_add(tool.name().chars().count() as u64) + .saturating_add(tool.description().chars().count() as u64) + .saturating_add(tool.parameters().to_string().chars().count() as u64); + } + chars.div_ceil(4) +} + +fn estimate_block_chars( + block: &crate::messages::ContentBlock, + canonical_calls: &[crate::messages::ToolCallRequest], +) -> u64 { + use crate::messages::{ContentBlock, DocumentSource, ImageSource}; + match block { + ContentBlock::Text { text } | ContentBlock::Reasoning { text, .. } => { + text.chars().count() as u64 + } + ContentBlock::Image { source } => match source { + ImageSource::Base64 { .. } => BINARY_MEDIA_ESTIMATED_TOKENS * 4, + ImageSource::Url { url } => estimate_media_url_chars(url), + }, + ContentBlock::Document { source, title } => { + let body = match source { + DocumentSource::Base64 { .. } => BINARY_MEDIA_ESTIMATED_TOKENS * 4, + DocumentSource::Text { text } => text.chars().count() as u64, + DocumentSource::Url { url } => estimate_media_url_chars(url), + }; + body.saturating_add( + title + .as_ref() + .map_or(0, |title| title.chars().count() as u64), + ) + } + // 只有确实对应canonical call的块才是镜像;AI/serde恢复并不保证存在镜像。 + ContentBlock::ToolUse { id, name, input } + if canonical_calls + .iter() + .any(|call| call.id == *id && call.name == *name && call.arguments == *input) => + { + 0 + } + ContentBlock::ToolUse { name, input, .. } => { + (name.chars().count() as u64).saturating_add(input.to_string().chars().count() as u64) + } + ContentBlock::ToolResult { content, .. } => content + .iter() + .map(|block| estimate_block_chars(block, &[])) + .fold(0u64, u64::saturating_add), + // 未知 JSON 没有可信媒体类型,保持文本预算,不能按 data 字段名删除成本。 + ContentBlock::Unknown(value) => value.to_string().chars().count() as u64, + } +} + +fn estimate_media_url_chars(url: &str) -> u64 { + if url + .get(..5) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:")) + { + // 内联媒体也可能经 URL 形式进入;不能让 data URI 重新绕回 base64 文本计量。 + BINARY_MEDIA_ESTIMATED_TOKENS * 4 + } else { + url.chars().count() as u64 + } +} + /// 单次 LLM 请求的 token 用量快照(仅内存,不持久化) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RequestRecord { diff --git a/peri-agent/src/agent/token_test.rs b/peri-agent/src/agent/token_test.rs index 18f58a7e4..f51a96788 100644 --- a/peri-agent/src/agent/token_test.rs +++ b/peri-agent/src/agent/token_test.rs @@ -14,6 +14,308 @@ fn make_usage( } } +/// [回归测试] 冷启动没有 usage 时,当前请求视图仍须提供压力事实。 +#[test] +fn test_cold_input_estimate_arms_compact_without_usage() { + let mut tracker = TokenTracker::default(); + assert!(tracker.refresh_input_estimate(110_000)); + assert_eq!(tracker.estimated_context_tokens(), Some(110_000)); + assert!( + tracker.last_usage.is_none(), + "估算不能伪装成 provider usage" + ); + assert!(tracker.pressure_sample_key().is_some()); +} + +/// [回归测试] assistant/user/reminder 与工具共用请求增长,不能将工具重复累加。 +#[test] +fn test_request_growth_uses_authoritative_baseline_without_double_counting_tools() { + let mut tracker = TokenTracker::default(); + tracker.begin_request(90_000); + tracker.accumulate(&make_usage(91_000, 8_000, None, None)); + tracker.add_estimated_tool_tokens(&"x".repeat(16_000)); + tracker.refresh_input_estimate(102_000); + assert_eq!(tracker.estimated_context_tokens(), Some(103_000)); + tracker.begin_request(102_000); + tracker.accumulate(&make_usage(103_000, 500, None, None)); + assert_eq!(tracker.estimated_context_tokens(), Some(103_000)); + assert_eq!(tracker.estimated_tool_tokens_since_last_llm, 0); +} + +/// [回归测试] 零 usage 不更新基线,后续非工具增长不能被旧样本去重抑制。 +#[test] +fn test_zero_usage_keeps_baseline_but_new_visible_growth_rearms_sample() { + let mut tracker = TokenTracker::default(); + tracker.begin_request(80_000); + tracker.accumulate(&make_usage(90_000, 0, None, None)); + tracker.refresh_input_estimate(84_000); + let first = tracker.pressure_sample_key().unwrap(); + tracker.consume_pressure_sample(first); + tracker.begin_request(84_000); + tracker.accumulate(&make_usage(0, 0, None, None)); + assert_eq!(tracker.pressure_sample_key(), Some(first)); + tracker.refresh_input_estimate(90_000); + assert_eq!(tracker.estimated_context_tokens(), Some(100_000)); + assert_ne!(tracker.pressure_sample_key(), Some(first)); +} + +/// [回归测试] Micro 投影估算减少不能扣除 provider usage 或重新激活同一样本。 +#[test] +fn test_projection_reduction_preserves_authoritative_pressure_and_consumed_sample() { + let mut tracker = TokenTracker::default(); + tracker.begin_request(100_000); + tracker.accumulate(&make_usage(196_000, 0, None, None)); + let sample = tracker.pressure_sample_key().unwrap(); + tracker.consume_pressure_sample(sample); + assert!(!tracker.refresh_input_estimate(50_000)); + assert_eq!(tracker.estimated_context_tokens(), Some(196_000)); + assert_eq!(tracker.pressure_sample_key(), Some(sample)); + assert!(tracker.is_pressure_sample_consumed(sample)); +} + +/// [回归测试] 未确认的 Micro 缩减不得抵销之后 before_model 新增的工作。 +#[test] +fn test_growth_after_projection_shrink_still_adds_to_authoritative_usage() { + let mut tracker = TokenTracker::default(); + tracker.begin_request(40_000); + tracker.accumulate(&make_usage(90_000, 0, None, None)); + let old = tracker.pressure_sample_key().unwrap(); + tracker.consume_pressure_sample(old); + tracker.refresh_input_estimate(2_000); + assert_eq!(tracker.estimated_context_tokens(), Some(90_000)); + tracker.refresh_input_estimate(14_000); + assert_eq!(tracker.estimated_context_tokens(), Some(102_000)); + assert_ne!(tracker.pressure_sample_key(), Some(old)); + tracker.begin_request(14_000); + tracker.accumulate(&make_usage(50_000, 0, None, None)); + assert_eq!(tracker.estimated_context_tokens(), Some(50_000)); +} + +#[test] +fn test_request_estimate_counts_tool_arguments_once() { + use crate::messages::{BaseMessage, ContentBlock}; + let message = BaseMessage::ai_from_blocks(vec![ContentBlock::ToolUse { + id: "call".into(), + name: "Write".into(), + input: serde_json::json!({"content": "abcd".repeat(10_000)}), + }]); + let estimate = estimate_request_tokens(&[message], &[]); + assert!( + (10_000..10_010).contains(&estimate), + "工具参数应参与估算,但不能同时计 block 与 canonical call:{estimate}" + ); +} + +/// [回归测试] 从serde恢复的AI工具块不一定有canonical镜像,参数仍会进入provider。 +#[test] +fn test_estimate_boundary_counts_unmirrored_ai_tool_blocks() { + use crate::messages::{BaseMessage, ContentBlock, MessageContent}; + let block = ContentBlock::ToolUse { + id: "call".into(), + name: "Write".into(), + input: serde_json::json!({"content":"text".repeat(12_000)}), + }; + for content in [ + MessageContent::blocks(vec![block.clone()]), + MessageContent::raw(vec![serde_json::to_value(&block).unwrap()]), + ] { + let original = BaseMessage::ai(content); + let restored: BaseMessage = + serde_json::from_value(serde_json::to_value(&original).unwrap()).unwrap(); + let estimate = estimate_request_tokens(&[restored], &[]); + assert!( + (12_000..12_010).contains(&estimate), + "无canonical对应的AI ToolUse不能漏计:{estimate}" + ); + } +} + +/// [回归测试] 只有id/name/arguments完全相同的工具块才是已计算镜像。 +#[test] +fn test_estimate_boundary_only_skips_exact_tool_call_mirrors() { + use crate::messages::{BaseMessage, ContentBlock, MessageContent, ToolCallRequest}; + let canonical = ToolCallRequest::new("call", "Write", serde_json::json!({"content":"small"})); + for (id, name, input) in [ + ("different", "Write", canonical.arguments.clone()), + ("call", "Different", canonical.arguments.clone()), + ( + "call", + "Write", + serde_json::json!({"content":"text".repeat(12_000)}), + ), + ] { + let extra = ContentBlock::ToolUse { + id: id.into(), + name: name.into(), + input: input.clone(), + }; + let mirror = ContentBlock::ToolUse { + id: canonical.id.clone(), + name: canonical.name.clone(), + input: canonical.arguments.clone(), + }; + let message = BaseMessage::ai_with_tool_calls( + MessageContent::blocks(vec![mirror, extra]), + vec![canonical.clone()], + ); + let expected_chars = canonical.name.chars().count() + + canonical.arguments.to_string().chars().count() + + name.chars().count() + + input.to_string().chars().count(); + assert_eq!( + estimate_request_tokens(&[message], &[]), + (expected_chars as u64).div_ceil(4), + "部分匹配不能让另一条有效工具块消失" + ); + } +} + +/// [回归测试] URI scheme大小写不敏感,DATA:/DaTa:仍然是二进制媒体。 +#[test] +fn test_estimate_boundary_data_uri_scheme_is_case_insensitive() { + use crate::messages::{BaseMessage, ContentBlock, DocumentSource, ImageSource, MessageContent}; + for scheme in ["data:", "DATA:", "DaTa:"] { + let url = format!( + "{scheme}application/octet-stream;base64,{}", + "AAAA".repeat(175_000) + ); + let blocks = vec![ + ContentBlock::Image { + source: ImageSource::Url { url: url.clone() }, + }, + ContentBlock::Document { + source: DocumentSource::Url { url }, + title: None, + }, + ]; + let estimate = + estimate_request_tokens(&[BaseMessage::human(MessageContent::blocks(blocks))], &[]); + assert!( + (2..=8_192).contains(&estimate), + "scheme={scheme}不能落回巨量文本估算:{estimate}" + ); + } +} + +/// [回归测试] 二进制媒体使用有界占位,不能把base64编码长度当作文字token。 +#[test] +fn test_binary_media_estimate_is_independent_of_encoded_size() { + use crate::messages::{BaseMessage, ContentBlock, DocumentSource, ImageSource, MessageContent}; + for document in [false, true] { + let estimate = |data: String| { + let block = if document { + ContentBlock::Document { + source: DocumentSource::Base64 { + media_type: "application/pdf".into(), + data, + }, + title: None, + } + } else { + ContentBlock::Image { + source: ImageSource::Base64 { + media_type: "image/png".into(), + data, + }, + } + }; + estimate_request_tokens( + &[BaseMessage::human(MessageContent::blocks(vec![block]))], + &[], + ) + }; + let small = estimate("AAAA".into()); + let large = estimate("AAAA".repeat(175_000)); + assert_eq!(large, small, "同类媒体的占位不能随传输编码长度增长"); + assert!( + (1..=4_096).contains(&large), + "没有图像尺寸/PDF页数时只能使用有界启发式:{large}" + ); + } +} + +#[test] +fn test_document_text_and_remote_urls_keep_text_estimates() { + use crate::messages::{BaseMessage, ContentBlock, DocumentSource, ImageSource, MessageContent}; + let text = "text".repeat(100_000); + let url = "https://example.invalid/file"; + let blocks = vec![ + ContentBlock::Document { + source: DocumentSource::Text { text: text.clone() }, + title: Some("title".into()), + }, + ContentBlock::Document { + source: DocumentSource::Url { url: url.into() }, + title: None, + }, + ContentBlock::Image { + source: ImageSource::Url { url: url.into() }, + }, + ]; + assert_eq!( + estimate_request_tokens(&[BaseMessage::human(MessageContent::blocks(blocks))], &[]), + ((text.chars().count() + 5 + 2 * url.chars().count()) as u64).div_ceil(4) + ); +} + +/// [回归测试] 媒体藏在ToolResult或Raw已知类型里仍须走同一预算规则。 +#[test] +fn test_nested_and_raw_media_use_bounded_estimates() { + use crate::messages::{BaseMessage, ContentBlock, ImageSource, MessageContent}; + let image = ContentBlock::Image { + source: ImageSource::Base64 { + media_type: "image/png".into(), + data: "AAAA".repeat(175_000), + }, + }; + let nested = BaseMessage::human(MessageContent::blocks(vec![ContentBlock::ToolResult { + id: None, + tool_use_id: "tool".into(), + content: vec![image.clone(), ContentBlock::text("caption")], + is_error: false, + }])); + let raw = BaseMessage::human(MessageContent::raw(vec![ + serde_json::to_value(image).unwrap() + ])); + let raw_estimate = estimate_request_tokens(&[raw], &[]); + assert!((1..=4_096).contains(&raw_estimate)); + assert_eq!(estimate_request_tokens(&[nested], &[]), raw_estimate + 2); +} + +#[test] +fn test_unknown_json_text_payload_is_not_treated_as_binary_media() { + use crate::messages::{BaseMessage, ContentBlock, MessageContent}; + let unknown = serde_json::json!({"type":"custom", "data":"text".repeat(10_000)}); + let message = BaseMessage::human(MessageContent::blocks(vec![ContentBlock::Unknown(unknown)])); + assert!( + estimate_request_tokens(&[message], &[]) >= 10_000, + "未知JSON不能因字段名data就丢失真实文字预算" + ); +} + +/// 内联媒体URL只是另一种编码载体,不能重新按大段base64文本计量。 +#[test] +fn test_inline_media_data_urls_use_bounded_estimates() { + use crate::messages::{BaseMessage, ContentBlock, DocumentSource, ImageSource, MessageContent}; + let image_url = format!("data:image/png;base64,{}", "AAAA".repeat(175_000)); + let document_url = format!("data:application/pdf;base64,{}", "AAAA".repeat(175_000)); + let blocks = vec![ + ContentBlock::Image { + source: ImageSource::Url { url: image_url }, + }, + ContentBlock::Document { + source: DocumentSource::Url { url: document_url }, + title: None, + }, + ]; + let estimate = + estimate_request_tokens(&[BaseMessage::human(MessageContent::blocks(blocks))], &[]); + assert!( + (2..=8_192).contains(&estimate), + "data URI需要有界媒体占位:{estimate}" + ); +} + #[test] fn test_accumulate_sums_tokens() { let mut tracker = TokenTracker::default(); diff --git a/peri-agent/src/session/exec/compact_pipeline.rs b/peri-agent/src/session/exec/compact_pipeline.rs index 089411c79..fce5dc946 100644 --- a/peri-agent/src/session/exec/compact_pipeline.rs +++ b/peri-agent/src/session/exec/compact_pipeline.rs @@ -322,7 +322,24 @@ async fn run_v2_compact_with_cancel( ) => r, }; - // 检测失败:affected_count == 0 + summary 为 None 表示 compact 未成功 + // 模型完成与取消可在同一次 poll 内同时发生;返回反馈前仍以取消为先。 + if cancel_token.is_cancelled() + || matches!(result.failure, Some(crate::error::AgentError::Interrupted)) + { + return Err(CancelOrError::Cancelled); + } + if let Some(error) = &result.failure { + let message = match error { + // force 路径没有实际压力样本,不能把占位预算展示为用户用量。 + crate::error::AgentError::CompactRetriesExhausted { attempts, .. } => format!( + "Full Compact failed after {attempts} attempts. Retry or change the compact model." + ), + error => error.user_facing_message(), + }; + return Err(CancelOrError::Error(message)); + } + + // 无失败原因的 no-op 不伪装成成功压缩。 if result.affected_count == 0 && result.summary.is_none() { warn!(strategy = ?result.strategy, "compact: v2 run_compact 无效果"); return Err(CancelOrError::Error( diff --git a/peri-agent/tests/compact_failure_adversarial_test.rs b/peri-agent/tests/compact_failure_adversarial_test.rs new file mode 100644 index 000000000..ecd82baa2 --- /dev/null +++ b/peri-agent/tests/compact_failure_adversarial_test.rs @@ -0,0 +1,812 @@ +//! 独立对抗审查:Full 失败必须阻止未恢复预算的 Reason,并保全真实持久化历史。 + +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources, +}; +use peri_acp_types::workspace::{SessionBinding, SessionExecutionLease, SESSION_BINDING_VERSION}; +use peri_agent::agent::compact_v2::CompactConfig; +use peri_agent::agent::events_v2::{EventBus, EventHandles, ObserveEvent}; +use peri_agent::agent::react::{ReactLLM, Reasoning, StreamingContext}; +use peri_agent::agent::stages::{run_react_loop, LoopResult, StageContext}; +use peri_agent::agent::token::ContextBudget; +use peri_agent::error::{AgentError, AgentResult}; +use peri_agent::messages::{BaseMessage, ToolCallRequest}; +use peri_agent::session::{FrozenContext, MessageSource, QueuedMessage, Session}; +use peri_agent::tools::BaseTool; +use peri_model::{ + ModelCapabilities, ModelError, ModelMessage, ModelRequest, ModelResponse, ModelStream, + StopReason, TokenUsage, +}; +use peri_resources::sessions::SessionResourcesImpl; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tokio_util::sync::CancellationToken; + +struct BoundSession { + resources: Arc, + thread_id: String, + _lease: Arc, + db: tempfile::TempDir, + repo: tempfile::TempDir, +} + +impl BoundSession { + async fn open() -> Self { + let repo = tempfile::tempdir().unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ] { + let result = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", repo.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(repo.path()) + .args(args) + .output() + .unwrap(); + assert!(result.status.success(), "临时工作区创建失败"); + } + let db = tempfile::tempdir().unwrap(); + let resources: Arc = Arc::new( + SessionResourcesImpl::open(db.path().join("threads.db")) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(repo.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let lease = resources + .create_session(&NewSession { + thread_id: thread_id.clone(), + created_at: "2026-09-28T00:00:00Z".into(), + meta: NewSessionMeta { + title: Some("compact audit".into()), + cwd: workspace.cwd.to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: Default::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd, + }, + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"test\":true}"), + }) + .await + .unwrap(); + Self { + resources, + thread_id, + _lease: lease, + db, + repo, + } + } +} + +#[derive(Debug, Clone, Copy)] +enum Failure { + Http, + Transport, + ProviderRetryExhausted, + MaxTokens, + ToolUse, + AnalysisOnly, + Success, +} + +struct SummaryModel { + failure: Failure, + calls: AtomicUsize, + successful_after: usize, + cancel_after_call: Option<(usize, Arc)>, +} + +#[async_trait::async_trait] +impl peri_model::Model for SummaryModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + unreachable!("摘要使用 complete") + } + async fn complete( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if let Some((target, token)) = &self.cancel_after_call { + if call == *target { + token.cancel(); + } + } + let failure = if call > self.successful_after { + Failure::Success + } else { + self.failure + }; + let (text, reason) = match failure { + Failure::Http => return Err(ModelError::http_status(503, "fixture", None::<&str>)), + Failure::Transport => { + return Err(ModelError::transport( + peri_model::TransportErrorKind::Timeout, + Some("fixture"), + )) + } + Failure::ProviderRetryExhausted => { + return Err( + ModelError::retry_exhausted(3, peri_model::RetryErrorKind::HttpStatus).unwrap(), + ) + } + Failure::MaxTokens => ("incomplete task", StopReason::MaxTokens), + Failure::ToolUse => ( + "untrusted tool pause", + StopReason::ToolUse, + ), + Failure::AnalysisOnly => ( + "no usable summary", + StopReason::EndTurn, + ), + Failure::Success => ("RECOVERED task", StopReason::EndTurn), + }; + ModelResponse::new(ModelMessage::assistant_text(text), reason, None, None) + } +} + +#[derive(Default)] +struct ReasonModel { + requests: parking_lot::Mutex>>, +} + +struct SummaryText(&'static str); + +#[async_trait::async_trait] +impl peri_model::Model for SummaryText { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + unreachable!("摘要使用 complete") + } + async fn complete( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + ModelResponse::new( + ModelMessage::assistant_text(self.0), + StopReason::EndTurn, + None, + None, + ) + } +} + +#[async_trait::async_trait] +impl ReactLLM for ReasonModel { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + self.requests.lock().push(messages.to_vec()); + Ok(Reasoning::with_answer("", "done")) + } +} + +async fn make_case( + failure: Failure, + successful_after: usize, + micro: bool, + cancel_on: Option, +) -> ( + BoundSession, + StageContext, + Arc, + Arc, + EventHandles, +) { + let bound = BoundSession::open().await; + let session = Session::new( + Arc::from(bound.repo.path().to_string_lossy().as_ref()), + FrozenContext::builder().build(), + Some(bound.thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut guard = transcript.write(); + *guard = std::mem::take(&mut *guard) + .with_persistence(bound.resources.clone(), bound.thread_id.clone()); + guard.append(BaseMessage::human("original task")); + if micro { + for index in 0..8 { + let call = format!("call-{index}"); + guard.append(BaseMessage::ai_with_tool_calls( + "", + vec![ToolCallRequest::new( + &call, + "Bash", + serde_json::json!({"command":"fixed command"}), + )], + )); + guard.append(BaseMessage::tool_result(call, "output ".repeat(100))); + guard.append(BaseMessage::human(format!("continue {index}"))); + } + } + } + let turn = session.start_turn(); + let summary = Arc::new(SummaryModel { + failure, + calls: AtomicUsize::new(0), + successful_after, + cancel_after_call: cancel_on.map(|call| (call, turn.cancel_token.clone())), + }); + let reason = Arc::new(ReasonModel::default()); + let (bus, handles) = EventBus::new(Default::default()); + let ctx = StageContext::builder(turn, session.transcript(), session.queue().clone()) + .with_llm(reason.clone()) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .with_event_bus(Arc::new(bus)) + .build(); + ctx.compact.token_tracker.write().accumulate(&TokenUsage { + input_tokens: 109_000, + output_tokens: 0, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + ctx.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("continue now"), + )); + (bound, ctx, reason, summary, handles) +} + +async fn assert_unusable_summary_blocks_reason(failure: Failure) { + let (bound, ctx, reason, summary, mut handles) = + make_case(failure, usize::MAX, false, None).await; + let result = run_react_loop(ctx.clone(), 4).await; + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let reopened = SessionResourcesImpl::open(bound.db.path().join("threads.db")) + .await + .unwrap(); + let snapshot = reopened + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!( + snapshot.payloads.iter().any(|payload| payload + .as_message() + .is_some_and(|message| message.content() == "original task")), + "失败不得丢失 canonical 原文" + ); + assert!( + snapshot.flags.values().all(|flags| !flags.excluded), + "不完整摘要不得提交 excluded" + ); + assert_eq!( + std::iter::from_fn(|| handles.try_observe()) + .filter(|event| matches!(event, ObserveEvent::CompactEnded { .. })) + .count(), + 1, + "失败应关闭 compact 事件" + ); + assert!(reason.requests.lock().is_empty(), "{failure:?}: 109% 且摘要不可用时不得 Reason;实际 {result:?},summary calls={},reason calls={}", summary.calls.load(Ordering::SeqCst), reason.requests.lock().len()); + let LoopResult::Error(error) = result else { + panic!("{failure:?} 必须明确失败"); + }; + let public = peri_acp_types::session::ExecutionFailure::from_agent_error(&error); + match failure { + Failure::Http => { + assert!(matches!(error, AgentError::ModelError(_))); + assert_eq!(public.http_status, Some(503)); + assert_eq!(public.diagnostic.unwrap().status(), Some(503)); + } + Failure::Transport => { + assert!(matches!(error, AgentError::ModelError(_))); + assert_eq!( + public.diagnostic.unwrap().transport(), + Some(peri_model::TransportErrorKind::Timeout) + ); + } + Failure::ProviderRetryExhausted => { + assert!(matches!(error, AgentError::ModelError(_))); + assert_eq!(public.diagnostic.unwrap().retry_attempts(), Some(3)); + } + Failure::MaxTokens => assert!(matches!( + error, + AgentError::CompactIncompleteResponse { + stop_reason: StopReason::MaxTokens + } + )), + Failure::ToolUse => assert!(matches!( + error, + AgentError::CompactIncompleteResponse { + stop_reason: StopReason::ToolUse + } + )), + Failure::AnalysisOnly => assert!(matches!( + error, + AgentError::CompactRetriesExhausted { attempts: 3, .. } + )), + Failure::Success => unreachable!(), + } + assert_eq!( + summary.calls.load(Ordering::SeqCst), + if matches!(failure, Failure::AnalysisOnly) { + 3 + } else { + 1 + }, + "Provider 预算不能在 Compact 层重启" + ); +} + +/// [回归测试] Provider HTTP 失败不应丢掉原因并继续高压推理。 +#[tokio::test] +async fn test_http_failure_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::Http).await; +} + +/// [回归测试] Provider transport 失败不应丢掉原因并继续高压推理。 +#[tokio::test] +async fn test_transport_failure_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::Transport).await; +} + +/// [回归测试] Provider 已耗尽自身重试时,Compact 不应重新启动普通推理。 +#[tokio::test] +async fn test_provider_retry_exhaustion_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::ProviderRetryExhausted).await; +} + +/// [回归测试] MaxTokens 输出不是完整摘要,失败后不得拿原高压历史继续请求。 +#[tokio::test] +async fn test_max_tokens_summary_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::MaxTokens).await; +} + +/// [回归测试] 工具调用停止不能伪装成成功摘要。 +#[tokio::test] +async fn test_tool_use_summary_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::ToolUse).await; +} + +#[tokio::test] +async fn test_analysis_only_exhaustion_blocks_reason() { + assert_unusable_summary_blocks_reason(Failure::AnalysisOnly).await; +} + +#[tokio::test] +async fn test_analysis_only_recovers_at_last_attempt_and_preserves_canonical() { + let (bound, ctx, reason, summary, _) = make_case(Failure::AnalysisOnly, 2, false, None).await; + let result = run_react_loop(ctx.clone(), 4).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!(summary.calls.load(Ordering::SeqCst), 3); + assert_eq!(reason.requests.lock().len(), 1); + assert!(reason.requests.lock()[0] + .iter() + .any(|message| message.content().contains("RECOVERED"))); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let reopened = SessionResourcesImpl::open(bound.db.path().join("threads.db")) + .await + .unwrap(); + let snapshot = reopened + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + let original = snapshot + .payloads + .iter() + .find_map(|payload| { + payload + .as_message() + .filter(|message| message.content() == "original task") + }) + .unwrap(); + assert!( + snapshot.flags[&original.id()].excluded, + "成功摘要应隐藏原文但保留磁盘历史" + ); +} + +#[tokio::test] +async fn test_cancel_on_last_empty_attempt_wins_over_exhaustion() { + let (_, ctx, reason, summary, _) = + make_case(Failure::AnalysisOnly, usize::MAX, false, Some(3)).await; + let result = run_react_loop(ctx, 4).await; + assert!( + matches!(result, LoopResult::Interrupted), + "取消应优先于摘要耗尽:{result:?}" + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 3); + assert!(reason.requests.lock().is_empty()); +} + +#[tokio::test] +async fn test_micro_persists_when_full_empty_attempts_exhaust() { + let (bound, ctx, reason, summary, _) = + make_case(Failure::AnalysisOnly, usize::MAX, true, None).await; + let result = run_react_loop(ctx.clone(), 4).await; + assert!( + matches!( + result, + LoopResult::Error(AgentError::CompactRetriesExhausted { attempts: 3, .. }) + ), + "{result:?}" + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 3); + assert!(reason.requests.lock().is_empty()); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let reopened = SessionResourcesImpl::open(bound.db.path().join("threads.db")) + .await + .unwrap(); + let snapshot = reopened + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!( + snapshot + .flags + .values() + .any(|flags| flags.projection.is_some()), + "Full 失败不能丢掉 Micro projection" + ); + assert!( + snapshot.flags.values().all(|flags| !flags.excluded), + "Full 失败不得隐藏原文" + ); + assert_eq!( + snapshot.payloads.len(), + 26, + "原始 task + 8 轮 + 新输入必须完整保存" + ); +} + +#[tokio::test] +async fn test_cancel_after_micro_preserves_projection_without_reason() { + let (bound, ctx, reason, summary, mut handles) = + make_case(Failure::AnalysisOnly, usize::MAX, true, Some(1)).await; + let result = run_react_loop(ctx.clone(), 4).await; + assert!(matches!(result, LoopResult::Interrupted), "{result:?}"); + assert_eq!(summary.calls.load(Ordering::SeqCst), 1); + assert!(reason.requests.lock().is_empty()); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!( + snapshot + .flags + .values() + .any(|flags| flags.projection.is_some()), + "取消应保留 Micro projection" + ); + assert!( + snapshot.flags.values().all(|flags| !flags.excluded), + "空摘要不得 excluded" + ); + assert_eq!(snapshot.payloads.len(), 26); + assert_eq!( + std::iter::from_fn(|| handles.try_observe()) + .filter(|event| matches!( + event, + ObserveEvent::MessagesCompacted { + outcome: peri_agent::agent::compact_v2::CompactOutcome::InterruptedAfterCommit, + .. + } + )) + .count(), + 1 + ); +} + +#[tokio::test] +async fn test_missing_summary_model_blocks_high_pressure_reason() { + let (_, mut ctx, reason, summary, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact.compact_llm = None; + let result = run_react_loop(ctx, 4).await; + assert!( + matches!(result, LoopResult::Error(AgentError::CompactNoLlm)), + "{result:?}" + ); + assert!(reason.requests.lock().is_empty()); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn test_shadow_empty_micro_plan_never_calls_full_at_high_pressure() { + let (bound, mut ctx, reason, summary, mut handles) = + make_case(Failure::Success, 0, false, None).await; + ctx.compact + .compact_config + .as_mut() + .unwrap() + .shadow_mode_enabled = true; + let result = run_react_loop(ctx.clone(), 4).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!( + summary.calls.load(Ordering::SeqCst), + 0, + "shadow 空 plan 也不得升级真实 Full" + ); + assert_eq!(reason.requests.lock().len(), 1); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(snapshot.flags.values().all(|flags| !flags.excluded)); + assert_eq!( + std::iter::from_fn(|| handles.try_observe()) + .filter(|event| matches!(event, ObserveEvent::MessagesCompacted { .. })) + .count(), + 0 + ); +} + +#[tokio::test] +async fn test_manual_force_still_applies_full_when_shadow_enabled() { + let (bound, mut ctx, _, summary, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact + .compact_config + .as_mut() + .unwrap() + .shadow_mode_enabled = true; + let mut transcript = std::mem::take(&mut *ctx.session.transcript.write()); + let mut failures = 0; + let result = peri_agent::agent::compact_v2::run_compact( + &mut transcript, + Some(summary.as_ref()), + ctx.compact.compact_config.as_ref().unwrap(), + &peri_agent::agent::compact_v2::ContextPressure { + estimated_tokens: 109_000, + context_window: 100_000, + output_reserve: 0, + predicted_tool_growth: 0, + safety_buffer: 0, + cache_hit_rate: 0.0, + }, + true, + &mut failures, + bound.repo.path().to_str().unwrap(), + ) + .await; + assert!(result.outcome().is_full_applied()); + assert_eq!(summary.calls.load(Ordering::SeqCst), 1); + assert!(result.failure.is_none()); +} + +#[tokio::test] +async fn test_cancel_on_provider_failure_wins_after_micro_commit() { + let (bound, ctx, reason, summary, _) = + make_case(Failure::Http, usize::MAX, true, Some(1)).await; + let result = run_react_loop(ctx.clone(), 4).await; + assert!(matches!(result, LoopResult::Interrupted), "{result:?}"); + assert!(reason.requests.lock().is_empty()); + assert_eq!(summary.calls.load(Ordering::SeqCst), 1); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert_eq!(snapshot.payloads.len(), 26); + assert!(snapshot + .flags + .values() + .any(|flags| flags.projection.is_some())); + assert!(snapshot.flags.values().all(|flags| !flags.excluded)); +} + +#[tokio::test] +async fn test_force_with_zero_retry_budget_reports_exhaustion_without_model_call() { + let (bound, mut ctx, _, summary, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact + .compact_config + .as_mut() + .unwrap() + .max_consecutive_failures = 0; + let mut transcript = std::mem::take(&mut *ctx.session.transcript.write()); + let mut failures = 0; + let result = peri_agent::agent::compact_v2::run_compact( + &mut transcript, + Some(summary.as_ref()), + ctx.compact.compact_config.as_ref().unwrap(), + &peri_agent::agent::compact_v2::ContextPressure { + estimated_tokens: 0, + context_window: u32::MAX, + output_reserve: 0, + predicted_tool_growth: 0, + safety_buffer: 0, + cache_hit_rate: 0.0, + }, + true, + &mut failures, + bound.repo.path().to_str().unwrap(), + ) + .await; + assert!(matches!( + result.failure, + Some(AgentError::CompactRetriesExhausted { attempts: 0, .. }) + )); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); + assert_eq!(transcript.visible_messages().len(), 1); +} + +#[tokio::test] +async fn test_exhausted_full_budget_does_not_fail_low_pressure_disabled_or_shadow() { + let (bound, ctx, _, summary, _) = make_case(Failure::Success, 0, false, None).await; + let mut transcript = std::mem::take(&mut *ctx.session.transcript.write()); + for (tokens, disabled, shadow) in [ + (1_000, false, false), + (109_000, true, false), + (109_000, false, true), + ] { + let config = CompactConfig { + max_consecutive_failures: 0, + auto_compact_enabled: !disabled, + shadow_mode_enabled: shadow, + ..Default::default() + }; + let mut failures = 0; + let result = peri_agent::agent::compact_v2::run_compact( + &mut transcript, + Some(summary.as_ref()), + &config, + &peri_agent::agent::compact_v2::ContextPressure { + estimated_tokens: tokens, + context_window: 100_000, + output_reserve: 0, + predicted_tool_growth: 0, + safety_buffer: 0, + cache_hit_rate: 0.0, + }, + false, + &mut failures, + bound.repo.path().to_str().unwrap(), + ) + .await; + assert!( + result.failure.is_none(), + "无需 Full 的分支不应因预算耗尽失败" + ); + assert!(!result.outcome().has_applied_change()); + } + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); + assert_eq!(transcript.visible_messages().len(), 1); +} + +/// [回归测试] 讨论解析器时,闭合摘要中的思考标签字面量必须进入下一次 Reason。 +#[tokio::test] +async fn test_closed_summary_preserves_literal_reasoning_tags() { + let (bound, mut ctx, reason, _, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact.compact_llm = Some(Arc::new(SummaryText( + "parser strips and tags", + ))); + let result = run_react_loop(ctx.clone(), 4).await; + assert!( + matches!(result, LoopResult::Completed), + "有效正文不得被误判为空摘要:{result:?}" + ); + assert_eq!(reason.requests.lock().len(), 1); + let request_text = reason.requests.lock()[0] + .iter() + .map(|message| message.content()) + .collect::>() + .join("\n"); + // Reason 使用既有 system-reminder XML 转义;canonical 摘要仍保存原始字面量。 + assert!( + request_text.contains("parser strips <think> and </analysis> tags"), + "{request_text}" + ); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + let original = stored + .payloads + .iter() + .find_map(|payload| { + payload + .as_message() + .filter(|message| message.content() == "original task") + }) + .unwrap(); + assert!(stored.flags[&original.id()].excluded); + assert!(stored + .payloads + .iter() + .any(|payload| payload.as_message().is_some_and(|message| message + .content() + .contains("parser strips and tags")))); +} + +/// [回归测试] 思考块内的闭合 summary 是草稿,不能提交并替代历史。 +#[tokio::test] +async fn test_summary_inside_reasoning_cannot_replace_history() { + let (bound, mut ctx, reason, _, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact.compact_llm = Some(Arc::new(SummaryText( + "draft", + ))); + let result = run_react_loop(ctx.clone(), 4).await; + assert!( + matches!( + result, + LoopResult::Error(AgentError::CompactRetriesExhausted { attempts: 3, .. }) + ), + "思考草稿不得成为摘要:{result:?}" + ); + assert!(reason.requests.lock().is_empty()); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + let stored = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(stored.flags.values().all(|flags| !flags.excluded)); + assert_eq!(stored.payloads.len(), 2, "只保留原始任务与本次继续输入"); +} + +/// [回归测试] 外层闭合摘要正文是普通文本,内部标签和外围尾部均不能污染它。 +#[tokio::test] +async fn test_closed_summary_after_reasoning_preserves_body_and_ignores_draft() { + let (_bound, mut ctx, reason, _, _) = make_case(Failure::Success, 0, false, None).await; + ctx.compact.compact_llm = Some(Arc::new(SummaryText("draftliteral tags"))); + let result = run_react_loop(ctx, 4).await; + assert!( + matches!(result, LoopResult::Completed), + "闭合正文必须优先于尾部标签:{result:?}" + ); + let requests = reason.requests.lock(); + assert_eq!(requests.len(), 1); + assert!(requests[0].iter().any(|message| message + .content() + .contains("literal <analysis> </thinking> <think> </analysis> <thinking> </think> tags"))); + assert!(requests[0] + .iter() + .all(|message| !message.content().contains("draft"))); +} diff --git a/peri-agent/tests/compact_followup_behavior_test.rs b/peri-agent/tests/compact_followup_behavior_test.rs new file mode 100644 index 000000000..e89eb03f1 --- /dev/null +++ b/peri-agent/tests/compact_followup_behavior_test.rs @@ -0,0 +1,184 @@ +//! PR #175 追加审查:公开估算入口的媒体及工具调用表示边界。 +//! 固定 base64 字符串只测预算,不声称由这里验证媒体解码或 provider 成本。 +use async_trait::async_trait; +use peri_agent::{ + agent::react::{ReactLLM, Reasoning, StreamingContext}, + error::AgentResult, + messages::{BaseMessage, ContentBlock, DocumentSource, MessageContent, ToolCallRequest}, + tools::BaseTool, +}; + +struct BudgetProbe; + +#[async_trait] +impl ReactLLM for BudgetProbe { + async fn generate_reasoning( + &self, + _: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + unreachable!("预算估算不得调用模型") + } +} + +fn estimate(message: BaseMessage) -> u64 { + BudgetProbe.estimate_request_tokens(&[message], &[]) +} + +fn tool_block(id: &str, count: usize) -> ContentBlock { + ContentBlock::ToolUse { + id: id.into(), + name: "Write".into(), + input: serde_json::json!({"content": "x".repeat(count)}), + } +} + +fn nested_attachment(count: usize) -> ContentBlock { + ContentBlock::ToolResult { + id: None, + tool_use_id: "attachment".into(), + is_error: false, + content: vec![ + ContentBlock::image_base64("image/png", "A".repeat(count)), + ContentBlock::Document { + source: DocumentSource::Base64 { + media_type: "application/pdf".into(), + data: "B".repeat(count), + }, + title: None, + }, + ], + } +} + +/// [回归测试] 工具结果嵌套媒体与 Raw 的已知媒体块同样不能按 base64 长度计预算。 +#[test] +fn test_followup_nested_and_raw_media_cost_does_not_scale_with_base64() { + let small = nested_attachment(40); + let large = nested_attachment(800_000); + let typed_small = estimate(BaseMessage::human(MessageContent::Blocks(vec![small]))); + let typed_large = estimate(BaseMessage::human(MessageContent::Blocks(vec![ + large.clone() + ]))); + let raw_large = estimate(BaseMessage::human(MessageContent::Raw(vec![ + serde_json::to_value(large).unwrap(), + ]))); + assert_eq!(typed_small, typed_large); + assert_eq!(typed_large, raw_large); + assert!( + typed_large < 100_000, + "附件字节大小不能独自造成高压:{typed_large}" + ); +} + +/// [负对照] 文本型 Document 的真实文字无论 typed/raw/nested 都须计入预算。 +#[test] +fn test_followup_text_documents_remain_budgeted_in_all_supported_shapes() { + let block = ContentBlock::Document { + source: DocumentSource::Text { + text: "text".repeat(110_000), + }, + title: Some("report".into()), + }; + let typed = estimate(BaseMessage::human(MessageContent::Blocks(vec![ + block.clone() + ]))); + let raw = estimate(BaseMessage::human(MessageContent::Raw(vec![ + serde_json::to_value(&block).unwrap(), + ]))); + let nested = estimate(BaseMessage::tool_result( + "doc", + MessageContent::Blocks(vec![ContentBlock::ToolResult { + id: None, + tool_use_id: "doc".into(), + is_error: false, + content: vec![block], + }]), + )); + assert!(typed >= 110_000, "文本附件不能被媒体占位吞掉:{typed}"); + assert_eq!(raw, typed); + assert_eq!(nested, typed); +} + +/// [回归测试] URI scheme 不区分 ASCII 大小写;原样 URL 构造不能绕回 base64 字符计量。 +#[test] +fn test_followup_data_uri_scheme_case_preserves_bounded_media_budget() { + let body = "A".repeat(800_000); + let lowercase = estimate(BaseMessage::human(MessageContent::Blocks(vec![ + ContentBlock::image_url(format!("data:image/png;base64,{body}")), + ]))); + for scheme in ["DATA", "Data", "dAtA"] { + let url = format!("{scheme}:image/png;base64,{body}"); + assert_eq!(url::Url::parse(&url).unwrap().scheme(), "data"); + let image = estimate(BaseMessage::human(MessageContent::Blocks(vec![ + ContentBlock::image_url(&url), + ]))); + let document = estimate(BaseMessage::human(MessageContent::Blocks(vec![ + ContentBlock::Document { + source: DocumentSource::Url { url }, + title: None, + }, + ]))); + assert_eq!(image, lowercase, "大小写 scheme 改变了图片预算"); + assert_eq!(document, lowercase, "大小写 scheme 改变了文档预算"); + } +} + +/// [回归测试] ai(content) 不创建 canonical tool_calls,不能把其 block 当作已计量镜像。 +#[test] +fn test_followup_ai_tool_blocks_without_canonical_mirror_are_budgeted() { + let block = tool_block("write", 440_000); + let message = BaseMessage::ai(MessageContent::Blocks(vec![block.clone()])); + let restored: BaseMessage = + serde_json::from_value(serde_json::to_value(message).unwrap()).unwrap(); + assert!(matches!(&restored, BaseMessage::Ai { tool_calls, .. } if tool_calls.is_empty())); + assert!( + estimate(restored) >= 110_000, + "恢复不会自动补 canonical 镜像,巨型工具参数必须计量" + ); + // Raw 估算形状也需一致;当前生产 AgentModelBridge 明确拒绝 Raw,不将其当作 provider 支持证明。 + assert!( + estimate(BaseMessage::ai(MessageContent::Raw(vec![ + serde_json::to_value(block).unwrap() + ]))) >= 110_000 + ); +} + +/// [负对照] ai_from_blocks 已创建相同调用的镜像,输入参数应恰好计量一次。 +#[test] +fn test_followup_canonical_tool_mirror_is_counted_once() { + let block = tool_block("write", 440_000); + let without_mirror = estimate(BaseMessage::ai(MessageContent::Blocks(vec![block.clone()]))); + let with_mirror = estimate(BaseMessage::ai_from_blocks(vec![block])); + assert!(with_mirror >= 110_000); + assert_eq!(with_mirror, without_mirror); +} + +/// [回归测试] 只有部分调用存在 canonical 镜像时,不能漏掉其他 block 参数。 +#[test] +fn test_followup_partial_tool_mirror_does_not_hide_other_arguments() { + let small = tool_block("small", 4); + let large = tool_block("large", 440_000); + let message = BaseMessage::ai_with_tool_calls( + MessageContent::Blocks(vec![small, large]), + vec![ToolCallRequest::new( + "small", + "Write", + serde_json::json!({"content":"xxxx"}), + )], + ); + assert!( + estimate(message) >= 110_000, + "不能因首个 canonical 调用存在就跳过其它调用" + ); +} + +/// [负对照] 未知 JSON 的 data 字段未声明媒体契约,应继续作为真实文本计量。 +#[test] +fn test_followup_unknown_json_data_is_not_treated_as_binary_media() { + let message = BaseMessage::human(MessageContent::Blocks(vec![ContentBlock::Unknown( + serde_json::json!({"type":"custom_text", "data":"text".repeat(110_000)}), + )])); + assert!(estimate(message) >= 110_000); +} diff --git a/peri-agent/tests/compact_pressure_adversarial_test.rs b/peri-agent/tests/compact_pressure_adversarial_test.rs new file mode 100644 index 000000000..dfdeb5239 --- /dev/null +++ b/peri-agent/tests/compact_pressure_adversarial_test.rs @@ -0,0 +1,634 @@ +//! 对抗场景:已收到 usage 后,真实模型视图增长必须在下一次 Reason 前参与预算。 + +use peri_acp_types::session_resources::{ + FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources, +}; +use peri_acp_types::system_reminder::{ + ReminderAudience, ReminderAudiences, ReminderCategory, ReminderDelivery, ReminderSeverity, + ReminderSource, SystemReminder, TrustedSystemReminderFactory, SYSTEM_REMINDER_VERSION, +}; +use peri_acp_types::thread::CancelPolicy; +use peri_acp_types::workspace::{SessionBinding, SessionExecutionLease, SESSION_BINDING_VERSION}; +use peri_agent::agent::compact_v2::CompactConfig; +use peri_agent::agent::react::{ReactLLM, Reasoning, StreamingContext, ToolCall}; +use peri_agent::agent::stages::{run_react_loop, LoopResult, StageContext}; +use peri_agent::agent::token::ContextBudget; +use peri_agent::error::{AgentError, AgentResult}; +use peri_agent::messages::BaseMessage; +use peri_agent::session::{ + FrozenContext, MessageKind, MessageQueue, MessageSource, QueuedMessage, Session, +}; +use peri_agent::tools::{BaseTool, ToolContext}; +use peri_model::{ + ModelCapabilities, ModelMessage, ModelRequest, ModelResponse, ModelStream, TokenUsage, +}; +use peri_resources::sessions::SessionResourcesImpl; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Copy)] +enum Growth { + Assistant, + User, + Reminder, + ToolControl, + BeforeModel, +} + +struct LateInput(Arc); + +#[async_trait::async_trait] +impl peri_agent::middleware::Middleware for LateInput { + fn name(&self) -> &str { + "late_input" + } + async fn before_model( + &self, + state: &mut dyn peri_agent::middleware::capabilities::BeforeModelState, + ) -> AgentResult<()> { + if self.0.fetch_add(1, Ordering::SeqCst) == 1 { + state.add_message(BaseMessage::human("new ".repeat(12_000))); + } + Ok(()) + } +} + +struct ScriptedReasoner { + growth: Growth, + request_tokens: parking_lot::Mutex>, +} + +#[async_trait::async_trait] +impl ReactLLM for ScriptedReasoner { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + // 固定模型将四个可见字符计为一个 token;直接测量真实 Reason 投影, + // 不依赖 provider output usage 是否包含不可回灌的 thinking。 + let tokens = messages + .iter() + .map(|message| message.content().chars().count()) + .sum::() + / 4; + let mut requests = self.request_tokens.lock(); + requests.push(tokens); + if tokens > 100_000 { + return Err(AgentError::LlmError(format!( + "scripted context overflow: {tokens} > 100000" + ))); + } + let first = requests.len() == 1; + let mut result = if first { + Reasoning::with_tools( + if matches!(self.growth, Growth::Assistant) { + "new ".repeat(8_000) + } else { + String::new() + }, + vec![ToolCall::new("work", "Work", serde_json::json!({}))], + ) + } else { + Reasoning::with_answer("", "done") + }; + result.usage = Some(TokenUsage { + input_tokens: tokens as u32, + output_tokens: if first && matches!(self.growth, Growth::Assistant) { + 8_000 + } else { + 1 + }, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + Ok(result) + } +} + +struct GrowthTool { + growth: Growth, + queue: MessageQueue, +} + +#[async_trait::async_trait] +impl BaseTool for GrowthTool { + fn name(&self) -> &str { + "Work" + } + fn description(&self) -> &str { + "Complete one work item" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + fn is_direct(&self) -> bool { + true + } + async fn invoke( + &self, + _: serde_json::Value, + _: ToolContext<'_>, + ) -> Result> { + match self.growth { + Growth::User => self.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("new ".repeat(12_000)), + )), + Growth::Reminder => { + let reminder = TrustedSystemReminderFactory::for_producer() + .construct(SystemReminder { + version: SYSTEM_REMINDER_VERSION, + category: ReminderCategory::Guidance, + source: ReminderSource("background_work".into()), + kind: "completed".into(), + severity: ReminderSeverity::Info, + delivery: ReminderDelivery::Required, + audiences: ReminderAudiences(vec![ReminderAudience::Model]), + body: "new ".repeat(12_000), + summary: None, + metadata: serde_json::json!({}), + }) + .unwrap(); + self.queue.push(QueuedMessage::system_reminder( + MessageKind::Defer, + MessageSource::SystemInjected, + reminder, + )); + } + _ => {} + } + Ok(match self.growth { + Growth::Assistant => "out ".repeat(4_000), + Growth::ToolControl => "out ".repeat(12_000), + _ => "done".to_owned(), + }) + } +} + +struct SummaryModel(AtomicUsize); + +#[async_trait::async_trait] +impl peri_model::Model for SummaryModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + unreachable!("只使用 complete") + } + async fn complete( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + self.0.fetch_add(1, Ordering::SeqCst); + ModelResponse::new( + ModelMessage::assistant_text( + "Original task and current progress preserved; continue.", + ), + peri_model::StopReason::EndTurn, + None, + None, + ) + } +} + +struct BoundSession { + session: Arc, + resources: Arc, + thread_id: String, + _lease: Arc, + _repo: tempfile::TempDir, + _db: tempfile::TempDir, +} + +async fn make_bound_session() -> BoundSession { + let repo = tempfile::tempdir().unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ] { + let status = std::process::Command::new("git") + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", repo.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .arg("-C") + .arg(repo.path()) + .args(args) + .status() + .unwrap(); + assert!(status.success()); + } + let db = tempfile::tempdir().unwrap(); + let resources: Arc = Arc::new( + SessionResourcesImpl::open(db.path().join("threads.db")) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(repo.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let lease = resources + .create_session(&NewSession { + thread_id: thread_id.clone(), + created_at: "2026-09-28T00:00:00Z".into(), + meta: NewSessionMeta { + title: Some("pressure adversarial".into()), + cwd: repo.path().to_string_lossy().into_owned(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding { + schema_version: SESSION_BINDING_VERSION, + revision: 1, + project_id: workspace.project_id, + workspace_id: workspace.workspace_id, + cwd_relative_to_workspace: workspace.relative_cwd, + }, + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"test\":true}"), + }) + .await + .unwrap(); + let session = Session::new( + Arc::from(repo.path().to_string_lossy().as_ref()), + FrozenContext::builder().build(), + Some(thread_id.clone()), + ); + { + let transcript = session.transcript(); + let mut transcript = transcript.write(); + *transcript = + std::mem::take(&mut *transcript).with_persistence(resources.clone(), thread_id.clone()); + } + BoundSession { + session, + resources, + thread_id, + _lease: lease, + _repo: repo, + _db: db, + } +} + +async fn assert_growth_compacted_before_next_reason(growth: Growth) { + let bound = make_bound_session().await; + let model = Arc::new(ScriptedReasoner { + growth, + request_tokens: Default::default(), + }); + let summary = Arc::new(SummaryModel(AtomicUsize::new(0))); + let tool = Arc::new(GrowthTool { + growth, + queue: bound.session.queue().clone(), + }); + let mut chain = peri_agent::middleware::MiddlewareChain::new(); + let late_calls = Arc::new(AtomicUsize::new(0)); + if matches!(growth, Growth::BeforeModel) { + chain.add(Box::new(LateInput(late_calls.clone()))); + } + let ctx = StageContext::builder( + bound.session.start_turn(), + bound.session.transcript(), + bound.session.queue().clone(), + ) + .with_llm(model.clone()) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .with_middleware_chain(Arc::new(chain)) + .with_tools(Arc::new(parking_lot::RwLock::new( + std::collections::BTreeMap::from([("Work".into(), tool as Arc)]), + ))) + .build(); + ctx.session.queue.push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("log ".repeat(90_000)), + )); + let result = run_react_loop(ctx.clone(), 5).await; + assert!(matches!(result, LoopResult::Completed), "下一次 Reason 前应压缩;result={result:?}, real_request_tokens={:?}, tracker={:?}, summary_calls={}", *model.request_tokens.lock(), ctx.compact.token_tracker.read().estimated_context_tokens(), summary.0.load(Ordering::SeqCst)); + assert_eq!( + summary.0.load(Ordering::SeqCst), + 1, + "超过窗口时必须在同循环内 Full" + ); + assert_eq!(model.request_tokens.lock().len(), 2); + assert!(model.request_tokens.lock()[1] < 95_000); + if matches!(growth, Growth::BeforeModel) { + assert_eq!( + late_calls.load(Ordering::SeqCst), + 2, + "补检不能重新运行 before_model" + ); + } + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); +} + +/// [回归测试] 最近 assistant 的可见正文 8k + 工具 4k 将输入从 90k 推到 102k。 +#[tokio::test] +async fn test_compact_accounts_for_committed_assistant_growth() { + assert_growth_compacted_before_next_reason(Growth::Assistant).await; +} + +/// [回归测试] 用户在工具运行时追加 12k 输入,同次循环 Receive 必须更新预算。 +#[tokio::test] +async fn test_compact_accounts_for_received_user_growth() { + assert_growth_compacted_before_next_reason(Growth::User).await; +} + +/// [回归测试] 后台结果经 canonical reminder 注入,同次循环必须计入模型输入。 +#[tokio::test] +async fn test_compact_accounts_for_received_reminder_growth() { + assert_growth_compacted_before_next_reason(Growth::Reminder).await; +} + +/// 相同 fixture 的阳性对照:12k 工具结果已被 tracker 识别,应能成功 Full。 +#[tokio::test] +async fn test_compact_tool_growth_control() { + assert_growth_compacted_before_next_reason(Growth::ToolControl).await; +} + +/// [回归测试] before_model 发生在 Compact 后;新增输入必须在最终请求前补检。 +#[tokio::test] +async fn test_compact_accounts_for_before_model_growth() { + assert_growth_compacted_before_next_reason(Growth::BeforeModel).await; +} + +async fn assert_usable_summary_commits(raw: &'static str) { + let bound = make_bound_session().await; + let ctx = StageContext::builder( + bound.session.start_turn(), + bound.session.transcript(), + bound.session.queue().clone(), + ) + .with_compact_llm(Arc::new(ScriptedMarkupSummary(raw))) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build(); + let original = BaseMessage::human("original task requiring preservation"); + let id = original.id(); + ctx.session.transcript.write().append(original); + ctx.compact.token_tracker.write().accumulate(&TokenUsage { + input_tokens: 109_000, + output_tokens: 0, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + let result = + peri_agent::agent::stages::compact::run_compact(peri_agent::agent::stages::CompactInput { + context: ctx.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); + assert!(result.compacted); + assert!(transcript.flags(id).excluded, "有效摘要允许替换旧模型视图"); + assert!( + transcript.entries().iter().any(|entry| entry.id() == id), + "canonical 原文仍保留" + ); + let visible = transcript.visible_messages(); + assert_eq!(visible.len(), 1); + assert!(visible[0].content().contains("USABLE_PROGRESS")); + assert!(!visible[0].content().contains("internal only")); +} + +/// 有摘要正文的嵌套思考响应必须仍可提交,不能因严格判空拒绝全部响应。 +#[tokio::test] +async fn test_nested_thinking_with_usable_summary_commits() { + assert_usable_summary_commits( + "internal onlyUSABLE_PROGRESS", + ) + .await; +} + +/// 无标签自然语言摘要仍是支持的模型响应。 +#[tokio::test] +async fn test_plain_text_usable_summary_commits() { + assert_usable_summary_commits("USABLE_PROGRESS").await; +} + +struct ScriptedMarkupSummary(&'static str); + +#[derive(Default)] +struct AttachmentReasoner(parking_lot::Mutex>>); + +#[async_trait::async_trait] +impl ReactLLM for AttachmentReasoner { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + self.0.lock().push(messages.to_vec()); + Ok(Reasoning::with_answer("", "attachment received")) + } +} + +async fn assert_binary_attachment_reaches_first_reason(cold_document: bool) { + use peri_agent::messages::{ContentBlock, DocumentSource, ImageSource, MessageContent}; + let bound = make_bound_session().await; + // 固定编码载荷只测试传输和预算边界,脚本模型不解码图片或 PDF。 + let data = "AAAA".repeat(175_000); + let block = if cold_document { + ContentBlock::Document { + source: DocumentSource::Base64 { + media_type: "application/pdf".into(), + data, + }, + title: Some("attached report".into()), + } + } else { + ContentBlock::Image { + source: ImageSource::Base64 { + media_type: "image/png".into(), + data, + }, + } + }; + let attachment = BaseMessage::human(MessageContent::blocks(vec![ + ContentBlock::text("Inspect this attachment."), + block, + ])); + let session = if cold_document { + bound + .session + .transcript() + .write() + .append(attachment.clone()); + let old = std::mem::take(&mut *bound.session.transcript().write()); + old.flush_persistence().await.unwrap(); + let reopened = SessionResourcesImpl::open(bound._db.path().join("threads.db")) + .await + .unwrap(); + let snapshot = reopened + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + let restored = Session::new( + Arc::from(bound._repo.path().to_string_lossy().as_ref()), + FrozenContext::builder().build(), + Some(bound.thread_id.clone()), + ); + let transcript = restored.transcript(); + let mut guard = transcript.write(); + *guard = peri_agent::session::MessageTranscript::new() + .with_own_payloads(snapshot.payloads) + .with_persistence(bound.resources.clone(), bound.thread_id.clone()); + guard.set_flags_batch(snapshot.flags); + drop(guard); + restored.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("Continue inspecting the attachment."), + )); + restored + } else { + bound.session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + attachment.clone(), + )); + bound.session.clone() + }; + let model = Arc::new(AttachmentReasoner::default()); + let summary = Arc::new(SummaryModel(AtomicUsize::new(0))); + let ctx = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(model.clone()) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build(); + let result = run_react_loop(ctx.clone(), 3).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!( + summary.0.load(Ordering::SeqCst), + 0, + "二进制base64传输大小不能触发提前Full并排除附件" + ); + { + let requests = model.0.lock(); + assert_eq!(requests.len(), 1); + assert!( + requests[0] + .iter() + .any(|message| message.id() == attachment.id() + && message.message_content() == attachment.message_content()), + "首个Reason必须收到完整原始附件" + ); + } + assert!( + !ctx.session + .transcript + .read() + .flags(attachment.id()) + .excluded + ); + let transcript = std::mem::take(&mut *ctx.session.transcript.write()); + transcript.flush_persistence().await.unwrap(); +} + +/// [回归测试] 约500KB截图不能按base64字符估出175k输入而在首次Reason前被压缩。 +#[tokio::test] +async fn test_binary_attachment_fresh_image_reaches_first_reason() { + assert_binary_attachment_reaches_first_reason(false).await; +} + +/// [回归测试] SQLite冷恢复的大附件也不能因base64长度在首次Reason前被排除。 +#[tokio::test] +async fn test_binary_attachment_cold_document_reaches_first_reason() { + assert_binary_attachment_reaches_first_reason(true).await; +} + +#[async_trait::async_trait] +impl peri_model::Model for ScriptedMarkupSummary { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + unreachable!() + } + async fn complete( + &self, + _: ModelRequest, + _: CancellationToken, + ) -> peri_model::ModelResult { + ModelResponse::new( + ModelMessage::assistant_text(self.0), + peri_model::StopReason::EndTurn, + None, + None, + ) + } +} + +/// [回归测试] 嵌套 analysis 的闭合标签不是摘要,不能据此隐藏完整历史。 +#[tokio::test] +async fn test_nested_analysis_only_response_cannot_replace_history() { + let bound = make_bound_session().await; + let ctx = StageContext::builder( + bound.session.start_turn(), + bound.session.transcript(), + bound.session.queue().clone(), + ) + .with_compact_llm(Arc::new(ScriptedMarkupSummary( + "internal only", + ))) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build(); + let original = BaseMessage::human("original task requiring preservation"); + let id = original.id(); + ctx.session.transcript.write().append(original); + ctx.compact.token_tracker.write().accumulate(&TokenUsage { + input_tokens: 109_000, + output_tokens: 0, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + let result = + peri_agent::agent::stages::compact::run_compact(peri_agent::agent::stages::CompactInput { + context: ctx.clone(), + has_tool_calls: false, + }) + .await; + assert!( + !ctx.session.transcript.read().flags(id).excluded, + "嵌套analysis没有摘要正文,不能把闭合标签当作有效摘要并excluded原文;visible={:?}", + ctx.session.transcript.read().visible_messages() + ); + assert!(matches!( + result, + Err(AgentError::CompactRetriesExhausted { attempts: 3, .. }) + )); +} diff --git a/peri-agent/tests/compact_session_adversarial_test.rs b/peri-agent/tests/compact_session_adversarial_test.rs new file mode 100644 index 000000000..cafe5f788 --- /dev/null +++ b/peri-agent/tests/compact_session_adversarial_test.rs @@ -0,0 +1,996 @@ +//! Compact 会话边界对抗测试:公开 API、临时 SQLite、确定性模型。 +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use async_trait::async_trait; +use parking_lot::Mutex; +use peri_acp_types::{ + command::{CommandContext, DependencyBag, FeedbackLevel, PromptStopReason}, + event::{EventSink, ExecutorEvent}, + session_resources::{FrozenSnapshotBytes, NewSession, NewSessionMeta, SessionResources}, + store::PersistedPayload, + thread::CancelPolicy, + workspace::{SessionBinding, SessionExecutionLease}, +}; +use peri_agent::{ + agent::{ + compact_v2::CompactConfig, + react::{ReactLLM, Reasoning, StreamingContext}, + stages::{run_react_loop, LoopResult, StageContext}, + token::ContextBudget, + }, + error::{AgentError, AgentResult}, + messages::BaseMessage, + session::{ + exec::compact_pipeline::execute_compact, FrozenContext, MessageSource, MessageTranscript, + QueuedMessage, Session, + }, + tools::BaseTool, +}; +use peri_model::{ + Model, ModelCapabilities, ModelMessage, ModelRequest, ModelResponse, ModelResult, ModelStream, + StopReason, TokenUsage, +}; +use tokio_util::sync::CancellationToken; + +struct BoundSession { + resources: Arc, + thread_id: String, + cwd: String, + db_path: std::path::PathBuf, + _lease: Arc, + _directory: tempfile::TempDir, +} + +impl BoundSession { + async fn open() -> Self { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("compact-audit.db"); + let resources: Arc = Arc::new( + peri_resources::sessions::SessionResourcesImpl::open(&db_path) + .await + .unwrap(), + ); + let workspace = resources.resolve_workspace(directory.path()).await.unwrap(); + let thread_id = uuid::Uuid::now_v7().to_string(); + let cwd = workspace.cwd.to_string_lossy().into_owned(); + let lease = resources + .create_session(&NewSession { + thread_id: thread_id.clone(), + created_at: "2026-09-28T00:00:00Z".into(), + meta: NewSessionMeta { + title: Some("compact audit".into()), + cwd: cwd.clone(), + parent_thread_id: None, + hidden: false, + cancel_policy: CancelPolicy::default(), + snapshot_at_message_id: None, + }, + binding: SessionBinding::from_workspace(&workspace), + frozen: FrozenSnapshotBytes::new("{\"version\":1,\"test\":true}"), + }) + .await + .unwrap(); + Self { + resources, + thread_id, + cwd, + db_path, + _lease: lease, + _directory: directory, + } + } + + fn session(&self, payloads: Vec) -> Arc { + let session = Session::new( + Arc::from(self.cwd.as_str()), + FrozenContext::builder().build(), + Some(self.thread_id.clone()), + ); + *session.transcript().write() = MessageTranscript::new() + .with_own_payloads(payloads) + .with_persistence(self.resources.clone(), self.thread_id.clone()); + session + } +} + +struct PrimaryModel { + accept_first: bool, + calls: AtomicUsize, + order: Arc>>, + requests: Mutex>>, +} + +#[async_trait] +impl ReactLLM for PrimaryModel { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + self.order.lock().push("reason"); + self.requests.lock().push(messages.to_vec()); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let chars: usize = messages + .iter() + .map(|message| message.content().chars().count()) + .sum(); + if chars > 380_000 && !(self.accept_first && call == 0) { + return Err(AgentError::LlmHttpError { + status: 400, + message: "fixture: restored request exceeded the context limit".into(), + }); + } + let mut response = Reasoning::with_answer("", "done"); + response.usage = Some(TokenUsage { + input_tokens: if chars > 350_000 { 90_000 } else { 1_000 }, + output_tokens: 1, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + Ok(response) + } +} + +struct SummaryModel { + failures: usize, + calls: AtomicUsize, + order: Arc>>, + cancel: Option, +} + +#[async_trait] +impl Model for SummaryModel { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream(&self, _: ModelRequest, _: CancellationToken) -> ModelResult { + unreachable!("摘要必须走 complete") + } + async fn complete(&self, _: ModelRequest, _: CancellationToken) -> ModelResult { + self.order.lock().push("summary"); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(cancel) = &self.cancel { + cancel.cancel(); + std::future::pending::<()>().await; + } + ModelResponse::new( + ModelMessage::assistant_text(if call < self.failures { + "no summary" + } else { + "RECOVERED: continue the work." + }), + StopReason::EndTurn, + None, + None, + ) + } +} + +fn make_models(accept_first: bool) -> (Arc, Arc) { + let order = Arc::new(Mutex::new(Vec::new())); + ( + Arc::new(PrimaryModel { + accept_first, + calls: AtomicUsize::new(0), + order: order.clone(), + requests: Default::default(), + }), + Arc::new(SummaryModel { + failures: 0, + calls: AtomicUsize::new(0), + order, + cancel: None, + }), + ) +} + +fn make_context( + session: &Session, + model: Arc, + summary: Arc, +) -> StageContext { + StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(model) + .with_compact_llm(summary) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build() +} + +/// [回归测试] 同会话下一 prompt 不得遗失上一轮 90% 的压力而直接发送超限请求。 +#[tokio::test] +async fn test_compact_session_next_prompt_restores_pressure_before_reason() { + let bound = BoundSession::open().await; + let session = bound.session(Vec::new()); + let (model, summary) = make_models(true); + let first = make_context(&session, model.clone(), summary.clone()); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("x".repeat(360_000)), + )); + assert!(matches!( + run_react_loop(first.clone(), 4).await, + LoopResult::Completed + )); + assert_eq!( + first + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(90_000) + ); + let second = make_context(&session, model.clone(), summary.clone()); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("y".repeat(60_000)), + )); + let result = run_react_loop(second, 4).await; + assert!( + matches!(result, LoopResult::Completed), + "下一 prompt 必须先压缩;实际 {result:?},顺序 {:?},摘要调用 {}", + *model.order.lock(), + summary.calls.load(Ordering::SeqCst) + ); + assert_eq!(*model.order.lock(), ["reason", "summary", "reason"]); +} + +/// [回归测试] SQLite 新句柄重建的大历史必须在首次 Reason 前建立压力基线。 +#[tokio::test] +async fn test_compact_session_cold_snapshot_compacts_before_first_reason() { + let bound = BoundSession::open().await; + let payloads = vec![PersistedPayload::Message(BaseMessage::human( + "x".repeat(440_000), + ))]; + bound + .resources + .append_history(&bound.thread_id, &payloads) + .await + .unwrap(); + let reader = + peri_resources::sessions::SessionResourcesImpl::open_existing_read_only(&bound.db_path) + .await + .unwrap(); + let snapshot = reader + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert_eq!(snapshot.payloads.len(), 1); + let session = bound.session(snapshot.payloads); + let (model, summary) = make_models(false); + let context = make_context(&session, model.clone(), summary.clone()); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("continue"), + )); + let result = run_react_loop(context, 4).await; + assert!( + matches!(result, LoopResult::Completed), + "冷读历史必须先压缩;实际 {result:?},顺序 {:?},摘要调用 {}", + *model.order.lock(), + summary.calls.load(Ordering::SeqCst) + ); + assert_eq!(*model.order.lock(), ["summary", "reason"]); +} + +#[derive(Default)] +struct RecordingSink(Mutex>); + +#[async_trait] +impl EventSink for RecordingSink { + async fn push_event(&self, _: &str, event: &ExecutorEvent, _: u32) { + self.0.lock().push(event.clone()); + } + async fn push_done(&self, _: &str, _: &str, _: Option<&str>) {} +} + +async fn manual_case( + failures: usize, + cancel_during_summary: bool, +) -> ( + BoundSession, + peri_acp_types::command::CommandResult, + Arc, + Arc, + Vec, +) { + let token = CancellationToken::new(); + let model = Arc::new(SummaryModel { + failures, + calls: AtomicUsize::new(0), + order: Default::default(), + cancel: cancel_during_summary.then(|| token.clone()), + }); + let (bound, result, sink, history) = manual_with_model(model.clone(), token).await; + (bound, result, model, sink, history) +} + +async fn manual_with_model( + model: Arc, + token: CancellationToken, +) -> ( + BoundSession, + peri_acp_types::command::CommandResult, + Arc, + Vec, +) { + let bound = BoundSession::open().await; + let history = vec![ + BaseMessage::human("retain original task"), + BaseMessage::ai("retain original answer"), + ]; + let sink = Arc::new(RecordingSink::default()); + let mut command = CommandContext::new( + bound.thread_id.clone(), + history.clone(), + bound.cwd.clone(), + sink.clone(), + token, + DependencyBag::new(), + ); + command.auxiliary_model = Some(model.clone()); + command.session_resources = Some(bound.resources.clone()); + command.thread_id = Some(bound.thread_id.clone()); + let result = execute_compact(command).await; + (bound, result, sink, history) +} + +#[tokio::test] +async fn test_compact_session_manual_empty_summary_retries_without_next_prompt() { + let (bound, result, model, sink, history) = manual_case(2, false).await; + assert_eq!(model.calls.load(Ordering::SeqCst), 3); + assert!(matches!(result.stop_reason, PromptStopReason::EndTurn)); + assert!(matches!( + result.feedback.unwrap().level, + FeedbackLevel::Info + )); + assert!(result.messages[0].content().contains("RECOVERED")); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + for original in history { + assert!(snapshot.flags[&original.id()].excluded); + } + let events = sink.0.lock(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, ExecutorEvent::CompactStarted { .. })) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, ExecutorEvent::CompactCompleted { .. })) + .count(), + 1 + ); +} + +#[tokio::test] +async fn test_compact_session_manual_failure_preserves_history_and_reports_feedback() { + let (bound, result, model, sink, history) = manual_case(usize::MAX, false).await; + assert_eq!(model.calls.load(Ordering::SeqCst), 3); + assert_eq!( + serde_json::to_value(&result.messages).unwrap(), + serde_json::to_value(&history).unwrap() + ); + let feedback = result.feedback.unwrap(); + assert!(matches!(feedback.level, FeedbackLevel::Error)); + assert_eq!( + feedback.message, + "Full Compact failed after 3 attempts. Retry or change the compact model." + ); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(snapshot.flags.values().all(|flag| !flag.excluded)); + assert_eq!(snapshot.payloads.len(), history.len()); + assert_eq!( + sink.0.lock().len(), + 1, + "当前失败仅发送 Started,反馈由命令编排投影" + ); +} + +#[tokio::test] +async fn test_compact_session_manual_cancel_preserves_history() { + let (bound, result, model, sink, history) = manual_case(0, true).await; + assert_eq!(model.calls.load(Ordering::SeqCst), 1); + assert!(matches!(result.stop_reason, PromptStopReason::Cancelled)); + assert_eq!( + serde_json::to_value(&result.messages).unwrap(), + serde_json::to_value(&history).unwrap() + ); + let feedback = result.feedback.unwrap(); + assert!(matches!(feedback.level, FeedbackLevel::Warning)); + assert_eq!(feedback.message, "compact cancelled"); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(snapshot.flags.values().all(|flag| !flag.excluded)); + assert_eq!(snapshot.payloads.len(), history.len()); + assert_eq!( + sink.0.lock().len(), + 1, + "当前取消仅发送 Started,上游用 TurnDone 结束 loading" + ); +} + +struct RejectedSummary { + protocol: bool, + cancel: Option, +} + +#[async_trait] +impl Model for RejectedSummary { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities::default() + } + async fn stream(&self, _: ModelRequest, _: CancellationToken) -> ModelResult { + unreachable!("摘要必须走 complete") + } + async fn complete(&self, _: ModelRequest, _: CancellationToken) -> ModelResult { + if let Some(cancel) = &self.cancel { + cancel.cancel(); + } + Err(if self.protocol { + peri_model::ModelError::protocol_with_summary( + peri_model::ProtocolErrorKind::InvalidJsonObject, + "fixture-private-body: sk-not-a-real-key https://private.example/path", + ) + } else { + peri_model::ModelError::http_status(401, "fixture", Some("req-compact-401")) + }) + } +} + +/// [回归测试] 手动 compact 必须保留安全的 HTTP 诊断,并保全原历史。 +#[tokio::test] +async fn test_compact_session_manual_http_failure_reports_safe_cause() { + let (bound, result, _, history) = manual_with_model( + Arc::new(RejectedSummary { + protocol: false, + cancel: None, + }), + CancellationToken::new(), + ) + .await; + let feedback = result.feedback.unwrap(); + assert!(matches!(feedback.level, FeedbackLevel::Error)); + assert_eq!( + feedback.message, + "An LLM API error occurred (HTTP 401, request id: req-compact-401). Please try again." + ); + assert_eq!( + serde_json::to_value(result.messages).unwrap(), + serde_json::to_value(history).unwrap() + ); + let snapshot = bound + .resources + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(snapshot.flags.values().all(|flag| !flag.excluded)); +} + +/// [回归测试] 协议诊断只显示 allowlist 分类,不透出 provider 原文。 +#[tokio::test] +async fn test_compact_session_manual_protocol_failure_redacts_provider_body() { + let (_, result, _, _) = manual_with_model( + Arc::new(RejectedSummary { + protocol: true, + cancel: None, + }), + CancellationToken::new(), + ) + .await; + let feedback = result.feedback.unwrap(); + assert!(matches!(feedback.level, FeedbackLevel::Error)); + assert_eq!( + feedback.message, + "An LLM API error occurred (protocol failure: invalid JSON object). Please try again." + ); +} + +/// [回归测试] provider 同 poll 返回错误并触发取消时,终态仍必须为取消。 +#[tokio::test] +async fn test_compact_session_manual_cancel_wins_over_ready_provider_error() { + let token = CancellationToken::new(); + let (_, result, _, history) = manual_with_model( + Arc::new(RejectedSummary { + protocol: false, + cancel: Some(token.clone()), + }), + token, + ) + .await; + assert!(matches!(result.stop_reason, PromptStopReason::Cancelled)); + assert_eq!(result.feedback.unwrap().message, "compact cancelled"); + assert_eq!( + serde_json::to_value(result.messages).unwrap(), + serde_json::to_value(history).unwrap() + ); +} + +/// [负对照] 轻上下文跨 prompt 不应新增 Full 或摘要请求。 +#[tokio::test] +async fn test_compact_session_small_prompts_do_not_call_summary() { + let bound = BoundSession::open().await; + let session = bound.session(Vec::new()); + let (model, summary) = make_models(false); + for text in ["first small task", "second small task"] { + let context = make_context(&session, model.clone(), summary.clone()); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human(text), + )); + let result = run_react_loop(context, 4).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + } + assert_eq!(*model.order.lock(), ["reason", "reason"]); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); +} + +/// [负对照] 冷读已提交 Full 的 canonical 原文仍存在,但不能复活进请求或压力估算。 +#[tokio::test] +async fn test_compact_session_cold_compacted_history_keeps_exclusions() { + let bound = BoundSession::open().await; + let original = BaseMessage::human(format!("ORIGINAL_HIDDEN_SENTINEL{}", "x".repeat(440_000))); + let sink = Arc::new(RecordingSink::default()); + let (_, summary) = make_models(false); + let mut command = CommandContext::new( + bound.thread_id.clone(), + vec![original.clone()], + bound.cwd.clone(), + sink, + CancellationToken::new(), + DependencyBag::new(), + ); + command.auxiliary_model = Some(summary); + command.session_resources = Some(bound.resources.clone()); + command.thread_id = Some(bound.thread_id.clone()); + let compacted = execute_compact(command).await; + assert!(matches!( + compacted.feedback.unwrap().level, + FeedbackLevel::Info + )); + let reader = + peri_resources::sessions::SessionResourcesImpl::open_existing_read_only(&bound.db_path) + .await + .unwrap(); + let snapshot = reader + .load_session_snapshot(&bound.thread_id) + .await + .unwrap(); + assert!(snapshot.flags[&original.id()].excluded); + assert!(snapshot + .payloads + .iter() + .any(|payload| payload.id() == original.id())); + let session = bound.session(snapshot.payloads); + session.transcript().write().set_flags_batch(snapshot.flags); + let (model, summary) = make_models(false); + let context = make_context(&session, model.clone(), summary.clone()); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("continue"), + )); + let result = run_react_loop(context, 4).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); + assert!(model.requests.lock()[0] + .iter() + .all(|message| !message.content().contains("ORIGINAL_HIDDEN_SENTINEL"))); + assert!(model.requests.lock()[0] + .iter() + .any(|message| message.content().contains("RECOVERED"))); +} + +struct AppendBeforeModel(Arc); + +#[async_trait] +impl peri_agent::middleware::Middleware for AppendBeforeModel { + fn name(&self) -> &str { + "append-before-model" + } + async fn before_model( + &self, + state: &mut dyn peri_agent::middleware::capabilities::BeforeModelState, + ) -> AgentResult<()> { + self.0.fetch_add(1, Ordering::SeqCst); + state.add_message(BaseMessage::human("z".repeat(440_000))); + Ok(()) + } +} + +/// [回归测试] before_model 在常规 Compact 后新增内容仍须在真实请求前压缩,hook只跑一次。 +#[tokio::test] +async fn test_compact_session_before_model_growth_is_checked_once_before_request() { + let bound = BoundSession::open().await; + let session = bound.session(Vec::new()); + let (model, summary) = make_models(false); + let calls = Arc::new(AtomicUsize::new(0)); + let mut chain = peri_agent::middleware::MiddlewareChain::new(); + chain.add(Box::new(AppendBeforeModel(calls.clone()))); + let mut context = make_context(&session, model.clone(), summary.clone()); + context.runtime.middleware_chain = Arc::new(chain); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("small task"), + )); + let result = run_react_loop(context, 4).await; + assert!(matches!(result, LoopResult::Completed), "{result:?}"); + assert_eq!(*model.order.lock(), ["summary", "reason"]); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +struct MicroGrowthModel(AtomicUsize); + +#[async_trait] +impl ReactLLM for MicroGrowthModel { + async fn generate_reasoning( + &self, + messages: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + let call = self.0.fetch_add(1, Ordering::SeqCst); + let summarized = messages + .iter() + .any(|message| message.content().contains("RECOVERED")); + // 固定 provider 边界:旧 Read 的长重复正文实际成本很低,字符级 Micro + // 节省不是权威节省;90k 真实基线主要来自其他输入/编码成本。 + if !summarized + && messages + .iter() + .any(|message| message.content().contains("NEW_WORK_SENTINEL")) + { + return Err(AgentError::LlmHttpError { + status: 400, + message: "fixture: 90000 unchanged provider tokens + 12000 new work exceeds limit" + .into(), + }); + } + let mut result = if call == 0 { + Reasoning::with_tools( + "", + vec![peri_agent::agent::react::ToolCall::new( + "work", + "Work", + serde_json::json!({}), + )], + ) + } else { + Reasoning::with_answer("", "done") + }; + result.usage = Some(TokenUsage { + input_tokens: if summarized { 1_000 } else { 90_000 }, + output_tokens: 1, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + Ok(result) + } +} + +struct ShortWork; + +#[async_trait] +impl BaseTool for ShortWork { + fn name(&self) -> &str { + "Work" + } + fn description(&self) -> &str { + "return short result" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + fn is_direct(&self) -> bool { + true + } + async fn invoke( + &self, + _: serde_json::Value, + _: peri_agent::tools::ToolContext<'_>, + ) -> Result> { + Ok("done".into()) + } +} + +struct AppendAfterMicro(AtomicUsize); + +#[async_trait] +impl peri_agent::middleware::Middleware for AppendAfterMicro { + fn name(&self) -> &str { + "append-after-micro" + } + async fn before_model( + &self, + state: &mut dyn peri_agent::middleware::capabilities::BeforeModelState, + ) -> AgentResult<()> { + if self.0.fetch_add(1, Ordering::SeqCst) == 1 { + state.add_message(BaseMessage::human(format!( + "NEW_WORK_SENTINEL{}", + "new ".repeat(12_000) + ))); + } + Ok(()) + } +} + +/// [对抗回归] Micro 的近似缩减不能抵销后续新增工作,旧权威 usage 尚未验证压缩收益。 +#[tokio::test] +async fn test_compact_session_micro_shrink_does_not_hide_new_before_model_growth() { + let bound = BoundSession::open().await; + let history = vec![ + BaseMessage::human("old read"), + BaseMessage::ai_with_tool_calls( + "", + vec![peri_agent::messages::ToolCallRequest::new( + "old-read", + "Read", + serde_json::json!({"file_path":"/fixture"}), + )], + ), + BaseMessage::tool_result("old-read", "x".repeat(160_000)), + BaseMessage::human("new task"), + ]; + let payloads: Vec<_> = history.into_iter().map(PersistedPayload::Message).collect(); + bound + .resources + .append_history(&bound.thread_id, &payloads) + .await + .unwrap(); + let session = bound.session(payloads); + let model = Arc::new(MicroGrowthModel(AtomicUsize::new(0))); + let (_, summary) = make_models(false); + let mut chain = peri_agent::middleware::MiddlewareChain::new(); + chain.add(Box::new(AppendAfterMicro(AtomicUsize::new(0)))); + let (bus, mut events) = peri_agent::agent::events_v2::EventBus::new(Default::default()); + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(model.clone()) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig { + micro_compact_stale_steps: 0, + ..Default::default() + }) + .with_middleware_chain(Arc::new(chain)) + .with_event_bus(Arc::new(bus)) + .with_tools(Arc::new(parking_lot::RwLock::new( + std::collections::BTreeMap::from([( + "Work".into(), + Arc::new(ShortWork) as Arc, + )]), + ))) + .build(); + session.queue().push(QueuedMessage::prompt( + MessageSource::UserInput, + BaseMessage::human("continue"), + )); + let result = run_react_loop(context.clone(), 5).await; + assert!( + std::iter::from_fn(|| events.try_observe()).any(|event| matches!( + event, + peri_agent::agent::events_v2::ObserveEvent::MessagesCompacted { + outcome: peri_agent::agent::compact_v2::CompactOutcome::MicroApplied, + .. + } + )), + "必须真实应用 Micro 才能暴露抵销" + ); + assert!( + matches!(result, LoopResult::Completed), + "Micro 后新增 12k 不得被净缩减吞掉,结果 {result:?},tracker {:?},summary {}", + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + summary.calls.load(Ordering::SeqCst) + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 1); +} + +struct UsageSequence(AtomicUsize); + +#[async_trait] +impl ReactLLM for UsageSequence { + async fn generate_reasoning( + &self, + _: &[BaseMessage], + _: &[&dyn BaseTool], + _: Option, + ) -> AgentResult { + let call = self.0.fetch_add(1, Ordering::SeqCst); + let mut result = Reasoning::with_answer("", "done"); + result.usage = Some(TokenUsage { + input_tokens: match call { + 0 => 1_000, + 1 => 0, + _ => 700, + }, + output_tokens: 1, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + }); + Ok(result) + } +} + +/// [负对照] 零 usage 不能结清新增输入,多次相同视图检查不重复累加;有效 usage 才结清。 +#[tokio::test] +async fn test_compact_session_zero_usage_keeps_growth_and_valid_usage_settles_it() { + use peri_agent::agent::stages::{compact, reason, CompactInput, ReasonInput}; + let bound = BoundSession::open().await; + let session = bound.session(Vec::new()); + session + .transcript() + .write() + .append(BaseMessage::human("base")); + let (_, summary) = make_models(false); + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(Arc::new(UsageSequence(AtomicUsize::new(0)))) + .with_compact_llm(summary.clone()) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig::default()) + .build(); + reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + session + .transcript() + .write() + .append(BaseMessage::human("x".repeat(4_000))); + for _ in 0..3 { + compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(2_000) + ); + } + reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + for _ in 0..3 { + compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(2_000) + ); + } + reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(700) + ); + assert_eq!(summary.calls.load(Ordering::SeqCst), 0); +} + +/// [负对照] 同一 Micro 投影视图反复检查不增加压力,也不重复提交压缩。 +#[tokio::test] +async fn test_compact_session_repeated_micro_view_does_not_add_pressure() { + use peri_agent::agent::stages::{compact, reason, CompactInput, ReasonInput}; + let bound = BoundSession::open().await; + let session = bound.session(Vec::new()); + for message in [ + BaseMessage::human("old"), + BaseMessage::ai_with_tool_calls( + "", + vec![peri_agent::messages::ToolCallRequest::new( + "old", + "Read", + serde_json::json!({"file_path":"/fixture"}), + )], + ), + BaseMessage::tool_result("old", "x".repeat(160_000)), + BaseMessage::human("current"), + ] { + session.transcript().write().append(message); + } + let context = StageContext::builder( + session.start_turn(), + session.transcript(), + session.queue().clone(), + ) + .with_llm(Arc::new(MicroGrowthModel(AtomicUsize::new(0)))) + .with_context_budget(ContextBudget::new(100_000)) + .with_compact_config(CompactConfig { + micro_compact_stale_steps: 0, + ..Default::default() + }) + .build(); + reason::run_reason(ReasonInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap(); + assert!( + compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap() + .compacted + ); + for _ in 0..3 { + assert!( + !compact::run_compact(CompactInput { + context: context.clone(), + has_tool_calls: false, + }) + .await + .unwrap() + .compacted + ); + } + assert_eq!( + context + .compact + .token_tracker + .read() + .estimated_context_tokens(), + Some(90_000), + "投影缩减后重复评估不能扣权威 usage,也不能重计增长" + ); +}