Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/chat-and-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ TTFT 认首个任意增量,含 thinking 和工具增量;工具条取本轮

`tool:start` 提供工具名、args preview、phase;审批/范围门前 `waiting`,通过后 `running`,拒绝直接 error。只读调用只发 running;批层排除、未知工具等硬拒绝不需要先造 start 卡。`tool_progress` 只携带真实输出分片,不能再承担空分片建卡。

主会话的 start、result 和 progress 三条路径都先跨 assistant 项查已有 callKey,找不到才建;notice 插队不能制造第二张卡。已完成卡不能被迟到 start 翻回运行中;run 结束后的 start 不建卡。run 终态把遗留 waiting/running 置 `E_INTERRUPTED`,子代理终态只收其子流。**主会话历史无结果或 repair 的 `[interrupted]` 与子流均显示中性「已中断」**;不能写成「已使用」。`E_ASK_CANCELLED`、`E_ASK_NOT_ANSWERED` 同属中性用户中止;真实失败保留错误信息。
主会话的 start、result 和 progress 三条路径都先跨 assistant 项查已有 callKey,找不到才建;notice 插队不能制造第二张卡。已完成卡不能被迟到 start 翻回运行中;run 结束后的 start 不建卡。run 终态把遗留 waiting/running 置 `E_INTERRUPTED`,子代理终态只收其子流。**主会话历史无结果或 repair 的 `[interrupted]` 与子流均显示中性「已中断」**;不能写成「已使用」。`E_ASK_CANCELLED`、`E_ASK_NOT_ANSWERED` 同属中性用户中止;`E_ASK_SKIPPED` 显示中性「已跳过」并停止当前任务;真实失败保留错误信息。

waiting 仅表示审批/范围确认,工具信号量和文件写锁排队仍包含在 running 内。当前 start 事件没有 `run_id`,改事件身份时应整体审查生命周期,不能只在渲染侧补一个未可靠传递的字段。

Expand Down Expand Up @@ -295,7 +295,7 @@ iframe 使用 `sandbox="allow-scripts"`、不授予 `allow-same-origin`;资源

ask 是活跃会话唯一底部输入区域;主 Composer 和队列暂时不显示。选项标题和推荐 chip 同行,说明独占下一行、与标题左对齐、完整折行;整行包括说明都可点击,键盘/ARIA 状态不能因排版容器改变而丢失。长说明不限高,会占更多垂直空间。

补充说明内 `Enter` 走下一题/提交,IME 不提交;`Shift+Enter` 不提交,但当前补充控件是单行 Input,不能承诺它提供可见多行。批准、预览、多选与忽略的决定逻辑以[计划与批准](./plan-and-approval.md)为准;计划卡已展示全文时,单题批准形的题干可精简为确认问句,多题/普通询问不能丢原题信息。
补充说明内 `Enter` 走下一题/提交,IME 不提交;`Shift+Enter` 不提交,但当前补充控件是单行 Input,不能承诺它提供可见多行。批准、预览、多选与跳过的决定逻辑以[计划与批准](./plan-and-approval.md)为准;「跳过」在任意页明确停止当前任务,不提交答案或自动批准。计划卡已展示全文时,单题批准形的题干可精简为确认问句,多题/普通询问不能丢原题信息。

### 7.2 自绘标题栏

Expand Down
10 changes: 7 additions & 3 deletions docs/plan-and-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ git 仓库方案须提出 `<type>/<slug>`(type 使用 Conventional Commits,s

[Option2](../src-tauri/src/tools/ask/tool.rs) 的 `mode: Option<ApprovalMode>` 用 serde default 与 `skip_serializing_if`:缺省时 wire 不出现该键,不能输出 `mode:null`。结构化批准类判定为 `mode.is_some()`;目标取用户选中项的 mode,旧批准形态才回落 AutoEdit。旧 `id="approve"`、`switchToAutoEdit` 与宽松 id/label 匹配保留兼容,不要随意删掉。

例外:ask 缺失关联 id 的确定性补齐见[工具参数容错](./tools-and-safety.md#1-工具接线与结果通道)。宿主对补齐过 id 的询问标记 `explicit_mode_only=true`(前端 `explicitModeOnly`),该询问只接受选中项明确声明的 mode;关闭旧 id/label 批准匹配和有效应答轻量切档,不因补齐 id 推断审批意图,也不生成 `approve` 锚点。普通回答、修订与预览保持原档;原本完整的旧载荷兼容语义不变。

必须区分:

- `approval_shape` 是宽松的单题批准形**渲染标记,无安全语义**。
Expand Down Expand Up @@ -129,9 +131,11 @@ git 仓库方案须提出 `<type>/<slug>`(type 使用 Conventional Commits,s

## 5. 普通询问、计划卡与预览

ask schema 为 1–5 问,每题最多 6 选项,支持开放题。ask/wait 必须独占批次。全空 selections 且 note trim 后为空时,在 `ask:closed` 后返回 `E_ASK_NOT_ANSWERED`,明确要求停下等待,不把忽略当成功/默许,也不原样重问;run 取消/通道关闭为 `E_ASK_CANCELLED`。部分回答保留未回答题尾注,不把整组当忽略。
ask schema 为 1–5 问,每题最多 6 选项,支持开放题。ask/wait 必须独占批次。询问卡的「跳过」发送独立 `{action:"skip"}`,表示用户明确停止当前任务:关闭整组询问、返回中性 `E_ASK_SKIPPED`;主循环先保存工具结果配对,再直接走取消终态,不再请求模型或自动运行下一条队列项。无论单题、多题哪一页,都不提交已选项、推荐默认值或补充说明;不批准、不切档、不冻结基线。在子代理询问中跳过还会停止其所属根 run,按出生时的 root run 身份校验,迟到子代理不得取消新 run。已有进度与历史保留,等待用户新指示。

跳过动作与 answers/权限字段混合时失败关闭,不回落批准通道。旧全空 selections 且 note trim 后为空的载荷仍返回 `E_ASK_NOT_ANSWERED`(未作答,不是明确跳过);run 取消/通道关闭为 `E_ASK_CANCELLED`。部分回答保留未回答题尾注。

- AskPanel 覆盖输入区;回答或忽略后恢复草稿。多题按页回答,非末页忽略只清当前题并翻页,末页清当前题后提交整组。
- AskPanel 覆盖输入区;回答或跳过后恢复草稿。多题按页回答;「跳过」在任意页都停止整轮,不再清当前题后翻页。
- 批准形渲染优先;其余由[同一 `inferSingle`](../ui/src/features/tools/askShape.ts) 控制单选展示与 toggle,不能“显示 radio、实际多选”。显式 true/false 优先于启发式。当前 Rust `Question.single` 是 default bool,标准事件会把缺省序列化为 false,因此不要承诺模型省略该字段就一定触发前端启发式;强互斥题应显式 true。
- 键盘环包含选项与补充说明位;输入位 Enter/Space 只聚焦,不提交。完全访问风险提示由界面追加,不得被模型 description 抵消。

Expand All @@ -153,7 +157,7 @@ preview 技能只用于已有成型对象,界面示意须标“示意稿 ·

后端重点入口:[ask/tests.rs](../src-tauri/src/tools/ask/tests.rs)、[batch.rs 测试](../src-tauri/src/tools/batch.rs)、[plan.rs](../src-tauri/src/tools/plan.rs)、[approval.rs](../src-tauri/src/safety/approval.rs)。前端入口:[askpanel.test.tsx](../ui/src/__tests__/askpanel.test.tsx)、[runWidgetAutoOpen.test.ts](../ui/src/__tests__/runWidgetAutoOpen.test.ts)、[events.contract.test.ts](../ui/src/__tests__/events.contract.test.ts)。载荷只增可选字段时不改事件键名。

必须覆盖:无模型预览选项仍有固定入口、旧选项去重、计划保存失败仍可预览、重复/键盘点击与跨会话竞态、第四次手动预览;四档起点按所选 mode 同步;preview/preview+备注/自拟 approve 子串 id 均不切;revise 不冻结基线;G2 豁免仍拒空 todos;同批 plan 失败仍豁免的既有取舍;并发范围门只弹一次且拒绝持续拦;忽略不继续;自动预览只开一次且恢复不重开。
必须覆盖:无模型预览选项仍有固定入口、旧选项去重、计划保存失败仍可预览、重复/键盘点击与跨会话竞态、第四次手动预览;四档起点按所选 mode 同步;preview/preview+备注/自拟 approve 子串 id 均不切;revise 不冻结基线;G2 豁免仍拒空 todos;同批 plan 失败仍豁免的既有取舍;并发范围门只弹一次且拒绝持续拦;任意页跳过直接停止且最后一步也不报成功;自动预览只开一次且恢复不重开。

还需补齐文本 ask 的双向契约测试:`text_ask.rs` 的 `MAX_QUESTIONS/MAX_OPTIONS` 与 AskTool schema 的 `maxItems` 目前双写 5/6;当前四档 `ApprovalMode` 的 serde 名与 schema enum 也须成套比对。现有单一 `full_access` 解析用例不能替代这两类等价断言。

Expand Down
6 changes: 6 additions & 0 deletions docs/providers-and-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ KeyPool 以供应商 id 分组,同供应商模型共享健康状态:优先

Auth、Billing、Cancelled 不进入通用重试;网络、限流、服务端和协议错误最多重试 6 次,退避封顶 10 秒。BadRequest 另有每 run 一次 sanitize/repair 修复重试,不占通用重试计数。流内错误的分类由各适配器解析器决定,不应把 HTTP 状态分类套到 HTTP 200 的 error 帧上。

`run:retry` 增加可选 `reason`(事件键不变):`empty_response` / `stream_stalled` / provider 错误分类 / `request_repaired`。界面按原因提示;缺失或未知原因显示通用重试提示,不再一律声称“请求失败、丢弃半截回复”。清理旧代际候选帧的行为不变。

三协议每次**空响应**在常开会话日志写入“空响应诊断” JSON,不要求打开 verbose:包含 run/step/尝试序号(首次为 1)、模型、协议、当次耗时与 usage、HTTP 状态、有限长度 request ID、收到的 chunk/字节数、SSE 事件和内容块类型计数、内容字节数、工具块数、结束原因、正常完成标记及装配结果。摘要仅保留固定类型词表和计数,不保留正文、思考、参数原文或任意未知字段值;未知类型归为 `other`。诊断元数据不进入模型历史、界面帧、首 token 计时或流活跃度。

取证标签:`no_content_observed` 表示未观察到已知内容;`content_not_assembled` 表示原始 JSON 中有内容或工具块但最终装配为空;`unrecognized_shape` 表示存在未知结构/被忽略的非 JSON 事件;`diagnostics_missing` 表示摘要缺失。这些是排查线索,不把未知格式断言为上游空回复。内容字节数及工具结构出现次数包含事件中的重复快照,不能当作去重输出长度、独立调用数或计费依据。响应收集任务异常单独报告协议错误,不再静默降成空响应。

源码:[dto.rs](../src-tauri/src/provider/dto.rs)、[retry.rs](../src-tauri/src/provider/retry.rs)、[drive.rs](../src-tauri/src/core/agent/drive.rs)(`run_llm_turn`)、[runHandlers.ts](../ui/src/stores/runHandlers.ts)、[ChatMessages.tsx](../ui/src/features/chat/ChatMessages.tsx)。

### 输出上限与截断
Expand Down
2 changes: 2 additions & 0 deletions docs/runtime-and-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

前端 [runHandlers.ts](../ui/src/stores/runHandlers.ts) 的共享 `settleRun` 清理运行标志、流式正文、运行/等待工具卡、残留子代理及 AskPanel。只有清工具卡而不清 ask,会留下输入区被询问永久覆盖的假卡死。

明确跳过后仍可在同一会话重新发消息:历史、已写文件与 todos 保留,新 run 重新建立取消令牌和运行态;旧询问 responder 不复用。带 `run_id` 的实时 `ask:opened` 必须排除已收尾轮次及与当前轮不符的事件,避免跳过后的迟到询问重新覆盖输入区。start_chat 回执在途时允许新身份的询问先到达;缺 run_id 的旧载荷保持兼容。取消事件与询问关闭事件丢失时仍走下述快照对账收敛,取消本身不自动续队列。

### 丢事件后的自愈

[run.ts](../ui/src/stores/run.ts) 的 `reconcileRun` 通过 `session_run_snapshot` 双向对账:
Expand Down
4 changes: 4 additions & 0 deletions docs/tools-and-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

`ToolOutcome = ok + data + error + warnings + extra_model_content` 有两条消费通道:前端收完整结构化结果,模型经 [compact_for_model](../src-tauri/src/tools/compact.rs) 收文本。

参数只对确定的结构做容错,模型侧 schema 仍要求标准形态:read / batch_read 缺失 `files`、顶层只有非空字符串 `path` 与可选 `startLine/endLine` 时自动包装单元素 files,行区间与路径原样保留;主循环在保存 ToolUse 与执行前共用同一归一化,工具直调也复用它。已有 files(包括空、null、类型错误)、混合/未知字段或无有效 path 时不猜测、不合并,按原错误路径处理,空 files 的错误带正确示例。归一化后仍走读取根、二进制/文档、图片与行数预算等检查。

ask 仅为**缺失**的问题/选项 `id` 按位置生成确定的中性关联 id,避开已有 id 与选项 label,保留已有 id、原始选项和 mode;显式 null、空白、类型错误、同级重复 id 不覆盖。问题最多 5 题、每题最多 6 项。发生补齐时宿主下发 `explicit_mode_only=true`:前后端均关闭 id/label 批准兼容匹配及“有效回答→自动编辑”轻量通道,只有用户选中原输入明确带 mode 的选项才可切到该档,预览仍不批准、不切档。此标记不能由模型覆写;关联 id 补齐不生成 approve/revise/preview 语义,不推断、补写或默认批准选项与权限档位。完整合法输入保留既有兼容路径;计划门与编辑匹配检查继续生效。

- command 在工具内部裁剪,因此不再二次头尾截断;非零退出仍把 error 与 data.output/exit_code 一起交给模型。
- read/batch_read 保持文本行号与完整所选窗口;图片条目剥去 data_url,禁止把 base64 作为普通文本送模型烧 token。图片经出网副本的 `route_tool_images` 放到紧随的 User 消息,受模型 vision 设置控制,历史不改写。
- 其他成功工具只序列化 data,保留头 4 KiB + 尾 8 KiB。截断后不再是可靠完整 JSON;失败通常只剩错误行。
Expand Down
42 changes: 37 additions & 5 deletions src-tauri/src/core/agent/drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,7 @@ pub async fn drive_agent(
rt.id
);
if params.emit_events {
emit_retry(&sink, rt, run_id, attempt);
emit_retry(&sink, rt, run_id, attempt, "empty_response");
}
if !sleep_backoff(attempt, &run_token).await {
outcome = Err(ProviderError::Cancelled);
Expand Down Expand Up @@ -1424,6 +1424,21 @@ pub async fn drive_agent(
claims.clone(),
)
.await;
// A user skip is terminal. The batch result is already in history, preserving tool pairing.
if call_sigs
.iter()
.any(|sig| sig.name == "ask" && sig.error.as_deref() == Some("E_ASK_SKIPPED"))
{
session_log::info(rt, "用户跳过询问,停止当前运行并等待新指示");
run_token.cancel();
if let (Some(root_id), Some(root_run_id)) = (&rt.root_session_id, &rt.root_run_id)
&& let Some(root) = core.session(root_id)
{
root.cancel_run_if_matches(root_run_id);
}
outcome = Err(ProviderError::Cancelled);
break 'steps;
}
if let Some(items) = batch_suggest {
suggest_out = Some(items);
}
Expand Down Expand Up @@ -1664,7 +1679,7 @@ async fn run_llm_turn(
),
);
if params.emit_events {
emit_retry(sink, rt, run_id, *attempt);
emit_retry(sink, rt, run_id, *attempt, "stream_stalled");
}
if !sleep_backoff(*attempt, run_token).await {
return Err(ProviderError::Cancelled);
Expand All @@ -1682,7 +1697,10 @@ async fn run_llm_turn(
.store(usage.prompt_total(&model.api_format), Ordering::SeqCst);
// 收干流(= 本次生成结束)后才计耗时:duration 是「请求发出 → 流失结束」的
// **成功尝试**窗口(退避与失败尝试不进此窗口)
let (asm, ttft_ms) = collector.await.unwrap_or_default();
let (asm, ttft_ms) = collector.await.map_err(|error| {
session_log::error(rt, &format!("step {step} 响应收集任务异常:{error}"));
ProviderError::Protocol(format!("响应收集任务异常:{error}"))
})?;
let duration_ms = attempt_started.elapsed().as_millis() as u64;
rt.run_timing
.lock()
Expand All @@ -1702,6 +1720,17 @@ async fn run_llm_turn(
);
}
// 空响应按可重试处理
if asm.is_empty() {
let diagnostic = serde_json::json!({
"run_id": run_id, "step": step, "attempt": *attempt + 1,
"model": model.name, "api_format": model.api_format,
"duration_ms": duration_ms, "usage": usage,
"evidence": asm.diagnostics.as_ref().map(|d| d.empty_evidence()).unwrap_or("diagnostics_missing"),
"stream": asm.diagnostics,
"assembled": {"blocks": asm.blocks.len(), "tool_calls": asm.tool_calls.len()},
});
session_log::warn(rt, &format!("空响应诊断 {diagnostic}"));
}
if asm.is_empty()
&& retry::should_retry(&ProviderError::Server("空响应".into()), *attempt)
{
Expand All @@ -1710,7 +1739,7 @@ async fn run_llm_turn(
session_log::warn(rt, &format!("step {step} 空响应,第 {} 次重试", *attempt));
tracing::warn!("session {} step {step} 空响应,重试 #{}", rt.id, *attempt);
if params.emit_events {
emit_retry(sink, rt, run_id, *attempt);
emit_retry(sink, rt, run_id, *attempt, "empty_response");
}
if !sleep_backoff(*attempt, run_token).await {
return Err(ProviderError::Cancelled);
Expand Down Expand Up @@ -1813,6 +1842,7 @@ async fn run_llm_turn(
serde_json::json!({
"session": rt.id, "run_id": run_id,
"attempt": attempt.saturating_add(1),
"reason": "request_repaired",
"gen": rt.stream.generation(), "delay_ms": 0,
}),
);
Expand Down Expand Up @@ -1849,7 +1879,7 @@ async fn run_llm_turn(
*attempt
);
if params.emit_events {
emit_retry(sink, rt, run_id, *attempt);
emit_retry(sink, rt, run_id, *attempt, e.kind_tag());
}
if !sleep_backoff(*attempt, run_token).await {
return Err(ProviderError::Cancelled);
Expand Down Expand Up @@ -2822,12 +2852,14 @@ pub(super) fn emit_retry(
rt: &SessionRuntime,
run_id: &str,
attempt: u32,
reason: &str,
) {
sink.emit(
&rt.id,
"run:retry",
serde_json::json!({
"session": rt.id, "run_id": run_id, "attempt": attempt,
"reason": reason,
"gen": rt.stream.generation(),
"delay_ms": retry::delay_for_attempt(attempt.saturating_sub(1)).as_millis() as u64,
}),
Expand Down
Loading
Loading