diff --git a/apps/tray/src/hosts.rs b/apps/tray/src/hosts.rs index 35d7e988..4c12280d 100644 --- a/apps/tray/src/hosts.rs +++ b/apps/tray/src/hosts.rs @@ -16,6 +16,7 @@ mod snapshot; mod split; #[cfg(test)] mod tests; +mod timeouts; mod transaction; use crate::detect::executable; diff --git a/apps/tray/src/hosts/actions.rs b/apps/tray/src/hosts/actions.rs index 35d44a02..ccbe786f 100644 --- a/apps/tray/src/hosts/actions.rs +++ b/apps/tray/src/hosts/actions.rs @@ -106,6 +106,8 @@ impl Hosts { *latest = Some(json!({})); } patch(latest.as_mut().unwrap(), before.entry.as_ref(), desired); + timeouts::ensure(latest.as_mut().unwrap(), host) + .map_err(|error| format!("write_failed:{error}"))?; } else { *latest = None; } @@ -405,11 +407,9 @@ impl Hosts { )); } desired = Some(if id == "deepseek" { - // dsh cuts a tool call off at 60 s; `wait` blocks for up to 300 s. - json!({"serverName":"agentnave","transport":"stdio","command":path,"env":host_env(host),"toolCallTimeoutMs":310_000}) + json!({"serverName":"agentnave","transport":"stdio","command":path,"env":host_env(host)}) } else if id == "zcode" { - // ZCode stops a tool call after 30 s by default; `wait` blocks up to 300 s. - json!({"type":"stdio","command":path,"args":[],"env":host_env(host),"timeoutMs":310000}) + json!({"type":"stdio","command":path,"args":[],"env":host_env(host)}) } else if id == "hermes" { json!({"command":path,"args":[],"env":host_env(host),"enabled":true}) } else { diff --git a/apps/tray/src/hosts/refresh.rs b/apps/tray/src/hosts/refresh.rs index 8f7b5cc2..c52eda86 100644 --- a/apps/tray/src/hosts/refresh.rs +++ b/apps/tray/src/hosts/refresh.rs @@ -119,6 +119,8 @@ impl Hosts { return Ok(()); }; set_host_env(desired, host, "takeover_failed")?; + timeouts::ensure(desired, host) + .map_err(|error| format!("takeover_failed:{error}"))?; // Repair missing commands, or migrate our existing launchers once. if let Some(path) = self.launcher() && let Some(command) = layer.command(desired) diff --git a/apps/tray/src/hosts/tests.rs b/apps/tray/src/hosts/tests.rs index fb1fc473..5642c115 100644 --- a/apps/tray/src/hosts/tests.rs +++ b/apps/tray/src/hosts/tests.rs @@ -3,6 +3,7 @@ use crate::data::Reader; use tempfile::TempDir; mod reload_guidance; +mod timeouts; mod versioned; fn fixture() -> (TempDir, Hosts) { @@ -504,7 +505,7 @@ fn all_host_formats_connect_refresh_and_remove_without_running_host_clis() { // dsh would otherwise cut `wait` off after 60 s. assert_eq!( h.read().unwrap().entry.unwrap()["toolCallTimeoutMs"], - 310_000 + 720_000 ); } assert_eq!( diff --git a/apps/tray/src/hosts/tests/timeouts.rs b/apps/tray/src/hosts/tests/timeouts.rs new file mode 100644 index 00000000..7dcea256 --- /dev/null +++ b/apps/tray/src/hosts/tests/timeouts.rs @@ -0,0 +1,87 @@ +use super::*; + +#[test] +fn refresh_raises_existing_tool_timeouts_and_keeps_other_configurations() { + for (id, key, minimum) in [ + ("codex", "tool_timeout_sec", 720), + ("claude", "timeout", 720_000), + ("antigravity", "timeoutSeconds", 720), + ("pi", "timeout", 720), + ("hermes", "timeout", 720), + ("deepseek", "toolCallTimeoutMs", 720_000), + ("zcode", "timeoutMs", 720_000), + ] { + let (_temp, mut hosts) = fixture(); + let h = host(&hosts, id); + executable_file( + &hosts.context.search[0].join(h.binary), + "#!/bin/sh\nexit 99\n", + ); + save(&h.config, &seed(&h)); + let before = h.read().unwrap(); + let mut entry = json!({"command":"agentnave-mcp", "env":{"KEEP":"yes"}, "opaque":true}); + entry[key] = json!(60); + save(&h.config, &before.render(Some(&entry)).unwrap()); + hosts.refresh(); + let updated = h.read().unwrap(); + let entry = updated.entry.as_ref().unwrap(); + assert_eq!(entry[key], minimum, "{id}: {entry}"); + assert_eq!(entry["env"]["KEEP"], "yes"); + assert_eq!(entry["opaque"], true); + assert_eq!(updated.rest, before.rest, "{id}"); + let bytes = fs::read(&h.config).unwrap(); + hosts.refresh(); + assert_eq!(fs::read(&h.config).unwrap(), bytes, "{id}"); + // Reconnecting must not shorten a value increased by the user. + let mut longer = entry.clone(); + longer[key] = json!(minimum * 2); + save(&h.config, &updated.render(Some(&longer)).unwrap()); + let result = hosts.action("connect", &json!({"id":id})).unwrap(); + assert_eq!(result["ok"], true, "{id}: {result}"); + assert_eq!(h.read().unwrap().entry.unwrap()[key], minimum * 2, "{id}"); + } +} + +#[test] +fn native_long_defaults_unlimited_and_grok_tool_overrides_are_preserved() { + let (_temp, hosts) = fixture(); + for id in ["grok", "codebuddy", "workbuddy"] { + let mut entry = json!({"command":"agentnave-mcp","env":{"KEEP":"yes"}}); + let before = entry.clone(); + super::super::timeouts::ensure(&mut entry, &host(&hosts, id)).unwrap(); + assert_eq!(entry, before, "{id}"); + } + let mut unlimited = json!({"timeoutSeconds":-1}); + super::super::timeouts::ensure(&mut unlimited, &host(&hosts, "antigravity")).unwrap(); + assert_eq!(unlimited["timeoutSeconds"], -1); + let mut grok = + json!({"tool_timeout_sec":1800,"tool_timeouts":{"wait":60,"start_agent":900,"other":30}}); + super::super::timeouts::ensure(&mut grok, &host(&hosts, "grok")).unwrap(); + assert_eq!( + grok, + json!({"tool_timeout_sec":1800,"tool_timeouts":{"wait":720,"start_agent":900,"other":30}}) + ); +} + +#[test] +fn invalid_timeout_is_reported_without_rewriting_the_file() { + let (_temp, mut hosts) = fixture(); + let h = host(&hosts, "pi"); + executable_file( + &hosts.context.search[0].join(h.binary), + "#!/bin/sh\nexit 99\n", + ); + let text = r#"{"mcpServers":{"agentnave":{"command":"agentnave-mcp","timeout":"custom"}}}"#; + save(&h.config, text); + hosts.refresh(); + assert_eq!(fs::read_to_string(&h.config).unwrap(), text); + let result = hosts.action("connect", &json!({"id":"pi"})).unwrap(); + assert_eq!(result["ok"], false); + assert!( + result["message"] + .as_str() + .unwrap() + .contains("timeout 必须为数字") + ); + assert_eq!(fs::read_to_string(&h.config).unwrap(), text); +} diff --git a/apps/tray/src/hosts/timeouts.rs b/apps/tray/src/hosts/timeouts.rs new file mode 100644 index 00000000..98e9b877 --- /dev/null +++ b/apps/tray/src/hosts/timeouts.rs @@ -0,0 +1,45 @@ +//! Tool-call limits for AgentNave's ten-minute wait, with two minutes for the reply. +use super::{Host, Value, json}; + +pub(super) fn ensure(entry: &mut Value, host: &Host) -> Result<(), String> { + let (key, minimum) = match host.boss { + "codex" => ("tool_timeout_sec", 720), + "claude" => ("timeout", 720_000), + "antigravity" => ("timeoutSeconds", 720), + "pi" | "hermes" => ("timeout", 720), + "deepseek" => ("toolCallTimeoutMs", 720_000), + "zcode" => ("timeoutMs", 720_000), + "grok" => ("tool_timeout_sec", 720), + // CodeBuddy reads a host-process variable (default one hour), not a server field. + // WorkBuddy's limit is unverified. Neither belongs in the child service's env. + _ => return Ok(()), + }; + // Grok's native default is 6000 s; do not shorten it by adding an override. + if host.boss != "grok" || entry.get(key).is_some() { + raise(entry, key, minimum, host.boss == "antigravity")?; + } + if host.boss == "grok" + && let Some(overrides) = entry.get_mut("tool_timeouts") + { + if !overrides.is_object() { + return Err("tool_timeouts 必须为对象".into()); + } + for tool in ["wait", "start_agent", "run_round"] { + if overrides.get(tool).is_some() { + raise(overrides, tool, 720, false)?; + } + } + } + Ok(()) +} + +fn raise(entry: &mut Value, key: &str, minimum: u64, unlimited: bool) -> Result<(), String> { + if let Some(current) = entry.get(key) { + let value = current.as_f64().ok_or(format!("{key} 必须为数字"))?; + if (unlimited && value == -1.0) || value >= minimum as f64 { + return Ok(()); + } + } + entry[key] = json!(minimum); + Ok(()) +} diff --git a/apps/tray/src/rules.rs b/apps/tray/src/rules.rs index 70b098b7..e5bc2350 100644 --- a/apps/tray/src/rules.rs +++ b/apps/tray/src/rules.rs @@ -20,7 +20,7 @@ const SHARED: &str = "## AgentNave 编排偏好 - 探索、规划派 Explorer,实现派 Worker。 - 同一组文件同一时间只有一个 Worker 在写;同一职责续接只传 `session_id`,其余由服务继承;换职责时传新的 `role` 或 `seat` 并给自足简报。 - 本偏好不决定何时评审,用户要求或其规则要求时才评审。评审时有 agentnave-review Skill 就按它定级、选工位,不另派 Explorer;没有时派 Explorer,不交给写这段代码的 Worker,尽量换模型。 -- 结果不会主动推送:派发后结束本轮前用 `wait` 收齐全部终态;任务在跑时要先问用户,也先 `wait` 或 `cancel`。 +- 结果不会主动推送:派发后结束本轮前用 `wait` 收齐全部终态;每次最多等十分钟,常规省略 `wait_seconds`,`seq` 从 1 开始逐次递增以区分连续等待;任务在跑时要先问用户,也先 `wait` 或 `cancel`。 - `succeeded` 不等于完成,boss 核对产物后验收;失败或取消时先核对现状,再决定续接、重派还是收尾。"; const IMAGE_GENERATION: &str = "\n- 生成图片派生图工位,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务。"; diff --git a/docs/adr/0007-wait-any-ready-subset.md b/docs/adr/0007-wait-any-ready-subset.md index 8954953c..779f56f3 100644 --- a/docs/adr/0007-wait-any-ready-subset.md +++ b/docs/adr/0007-wait-any-ready-subset.md @@ -9,6 +9,7 @@ - 服务主动终止情形与终态句柄保留七天的边界由 [ADR-0049](0049-service-initiated-termination.md) 补充 - 由 [ADR-0057](0057-boss-answers-native-prompts.md) 补充:待答弹框是新的就绪原因,收件箱同样返回。 - 由 [ADR-0064](0064-dispatch-receipts-steer-to-wait.md) 修订:派发与等待回执在仍有待收结果时带 `next` 引导,派发调用可选顺带等一次。 +- 由 [ADR-0066](0066-unified-wait-window-and-call-sequence.md) 修订:所有宿主统一等待十分钟,移除旧的 240 秒档位;连续等待可用 `seq` 区分。 ## 背景 diff --git a/docs/adr/0015-app-host-integration.md b/docs/adr/0015-app-host-integration.md index bbad55eb..89307ef0 100644 --- a/docs/adr/0015-app-host-integration.md +++ b/docs/adr/0015-app-host-integration.md @@ -5,6 +5,7 @@ - 外部 Skill 不覆盖与所有权校验由 [ADR-0036](0036-app-owns-service-skills.md) 修订 - 配置根目录手填入口由 [ADR-0029](0029-settings-without-free-text.md) 取代 - 宿主接入行按实际配置目录划分的规则由 [ADR-0040](0040-boss-app-from-session-records.md) 补充 +- 已有 AgentNave 条目的工具超时下限由 [ADR-0066](0066-unified-wait-window-and-call-sequence.md) 补充。 App 优先的接入需要同时维护 MCP 与配套 Skill。已有手写条目包含用户选择的环境、启停与工具过滤,整体重建会丢失这些选择;已有 Skill 常由跨宿主部署管理器维护。用户级接入由托盘后端管理,核心与工作室服务不增加宿主写入能力,手动接入方式继续可用。 diff --git a/docs/adr/0066-unified-wait-window-and-call-sequence.md b/docs/adr/0066-unified-wait-window-and-call-sequence.md new file mode 100644 index 00000000..b0ec2f09 --- /dev/null +++ b/docs/adr/0066-unified-wait-window-and-call-sequence.md @@ -0,0 +1,13 @@ +# ADR-0066:统一十分钟等待窗口,用序号区分连续等待 + +- 状态:已接受 +- 日期:2026-11-08 +- 修订 ADR-0007 的固定 240 秒等待窗口、ADR-0015 的宿主配置刷新范围。 + +固定四分钟窗口让长任务的 boss 反复醒来,而直接延长服务等待会超过某些宿主的工具超时。所有宿主(包括 WorkBuddy 和未知宿主)统一每次等待最多十分钟,不保留按宿主回退的旧窗口。任务结束、阻塞、待答弹框和审核仍提前唤醒,任务自身没有总时限。 + +App 在接入与刷新已有用户级 AgentNave 条目时,为支持条目级工具超时的宿主补足十二分钟,已有更长值及已核实的无上限值保留。只处理 AgentNave 这一项,不修改宿主的全局超时或其他服务。CodeBuddy 的工具超时是宿主进程的环境设置,不能通过子服务环境代设;保留已核实的一小时原生默认值,Grok 保留其更长的原生默认值。手动接入须按安装文档设置超时,升级后重连旧连接。 + +`wait_seconds=0` 继续即时收取;`600` 与省略参数均选择十分钟。移除 `240`,不保留旧窗口兼容;尚未核实工具超时配置的宿主也使用十分钟,发生问题再按该宿主处理。 + +连续调用 `wait` 是正常操作,但部分宿主会把相同工具入参视为无效重复。增加可选 `seq`,由 boss 逐次递增,回执原样带回;它只区分调用,不保存计数、不校验递增、不去重或重放结果,不改变收件箱的一次性交付语义,也不承诺绕过所有宿主的循环检测。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 56b2550a..21aad91c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ | 0004 | [Windows 使用 Job Object 监督 Provider 进程树](0004-windows-job-object-supervision.md) | 已接受 | — | | 0005 | [由主控 Agent 导演的公共发言区与独立工作台](0005-director-controlled-discussion-rooms.md) | 已被 ADR-0006 取代 | [ADR-0006](0006-core-rounds-and-studio-split.md)、[ADR-0008](0008-tray-app-reads-studio-snapshots.md) | | 0006 | [核心收敛为子代理与协作轮次,节目、历史与看板拆为工作室包](0006-core-rounds-and-studio-split.md) | 部分取代 | [ADR-0009](0009-single-package-two-entry-points.md)、[ADR-0013](0013-members-defaults-and-host-identity.md)、[ADR-0016](0016-single-core-and-mcp-entrypoint.md)、[ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0025](0025-session-desks-and-running-limit.md)、[ADR-0045](0045-remove-browser-dashboard.md);2026-10-20 代码修订(席位上限 7) | -| 0007 | [`wait_any` 返回就绪子集并只消费回复中的阻塞](0007-wait-any-ready-subset.md) | 部分取代 | [ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0039](0039-retry-lost-seat-within-round.md)、[ADR-0049](0049-service-initiated-termination.md)、[ADR-0057](0057-boss-answers-native-prompts.md) | +| 0007 | [`wait_any` 返回就绪子集并只消费回复中的阻塞](0007-wait-any-ready-subset.md) | 部分取代 | [ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0039](0039-retry-lost-seat-within-round.md)、[ADR-0049](0049-service-initiated-termination.md)、[ADR-0057](0057-boss-answers-native-prompts.md)、[ADR-0066](0066-unified-wait-window-and-call-sequence.md) | | 0008 | [托盘应用只读展示工作室数据,工作室写出视图快照与运行文件](0008-tray-app-reads-studio-snapshots.md) | 部分取代 | [ADR-0010](0010-launcher-relay-swaps-upgraded-server.md)、[ADR-0012](0012-tray-conversation-cleanup.md)、[ADR-0018](0018-office-workspace-desks-and-meeting-rooms.md)、[ADR-0026](0026-sqlite-conversation-store.md)、[ADR-0044](0044-meeting-voice-cloud-and-key-input.md)、[ADR-0045](0045-remove-browser-dashboard.md)、[ADR-0050](0050-v1-compatibility-baseline.md)、[ADR-0060](0060-tray-detects-orphaned-running-work.md) | | 0009 | [核心与工作室合为一个发行包,保留两个入口](0009-single-package-two-entry-points.md) | 已被 ADR-0016 取代 | [ADR-0016](0016-single-core-and-mcp-entrypoint.md) | | 0010 | [入口经中继启动,升级后在空闲时自动换用新服务](0010-launcher-relay-swaps-upgraded-server.md) | 部分取代 | [ADR-0014](0014-tray-members-and-one-click-update.md)、[ADR-0016](0016-single-core-and-mcp-entrypoint.md)、[ADR-0045](0045-remove-browser-dashboard.md)、[ADR-0047](0047-versioned-service-install.md) | @@ -18,7 +18,7 @@ | 0012 | [托盘在原生会话锁下清理记录](0012-tray-conversation-cleanup.md) | 部分取代 | [ADR-0013](0013-members-defaults-and-host-identity.md)、[ADR-0014](0014-tray-members-and-one-click-update.md)、[ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0026](0026-sqlite-conversation-store.md)、[ADR-0045](0045-remove-browser-dashboard.md)、[ADR-0046](0046-service-history-retention.md) | | 0013 | [App 组员默认值、模式强制与宿主归属](0013-members-defaults-and-host-identity.md) | 部分取代 | [ADR-0016](0016-single-core-and-mcp-entrypoint.md)、[ADR-0017](0017-manager-chat-session-identity.md)、[ADR-0018](0018-office-workspace-desks-and-meeting-rooms.md)、[ADR-0020](0020-prefill-main-model-defaults.md)、[ADR-0021](0021-keep-default-models-when-unrestricted.md)、[ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0028](0028-follow-cli-default-model.md)、[ADR-0032](0032-cli-permission-tiers.md)、[ADR-0038](0038-role-applies-app-seat.md) | | 0014 | [托盘维护组员配置、探测 CLI 模型,并一键全部更新](0014-tray-members-and-one-click-update.md) | 部分取代 | [ADR-0029](0029-settings-without-free-text.md)、[ADR-0034](0034-models-and-efforts-from-each-cli.md)、[ADR-0047](0047-versioned-service-install.md)、[ADR-0050](0050-v1-compatibility-baseline.md) | -| 0015 | [App 定点编辑宿主配置,保留外部 Skill 所有权](0015-app-host-integration.md) | 部分取代 | [ADR-0029](0029-settings-without-free-text.md)、[ADR-0036](0036-app-owns-service-skills.md)、[ADR-0040](0040-boss-app-from-session-records.md) | +| 0015 | [App 定点编辑宿主配置,保留外部 Skill 所有权](0015-app-host-integration.md) | 部分取代 | [ADR-0029](0029-settings-without-free-text.md)、[ADR-0036](0036-app-owns-service-skills.md)、[ADR-0040](0040-boss-app-from-session-records.md)、[ADR-0066](0066-unified-wait-window-and-call-sequence.md) | | 0016 | [统一核心与 MCP 入口](0016-single-core-and-mcp-entrypoint.md) | 部分取代 | [ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0031](0031-prompt-only-orchestration.md)、[ADR-0033](0033-boss-workspace-handover.md)、[ADR-0045](0045-remove-browser-dashboard.md) | | 0017 | [按主控聊天归集 App 群聊](0017-manager-chat-session-identity.md) | 部分取代 | [ADR-0018](0018-office-workspace-desks-and-meeting-rooms.md)、[ADR-0033](0033-boss-workspace-handover.md)、[ADR-0050](0050-v1-compatibility-baseline.md) | | 0018 | [主控工作区、工位与会议室](0018-office-workspace-desks-and-meeting-rooms.md) | 部分取代 | [ADR-0023](0023-explicit-desk-continuation.md)、[ADR-0024](0024-consolidated-tools-and-private-review.md)、[ADR-0025](0025-session-desks-and-running-limit.md)、[ADR-0038](0038-role-applies-app-seat.md)、[ADR-0050](0050-v1-compatibility-baseline.md) | @@ -68,3 +68,4 @@ | 0062 | [服务统一生成成员称呼](0062-service-owned-member-names.md) | 已接受 | — | | 0063 | [CLI 共用模型池,编排替代普通任务目录](0063-cli-model-pool-and-orchestration-precedence.md) | 已接受 | 修订 ADR-0038、ADR-0053 | | 0064 | [派发回执引导收取,可选派发后先等](0064-dispatch-receipts-steer-to-wait.md) | 已接受 | 修订 ADR-0007 | +| 0066 | [统一十分钟等待窗口,用序号区分连续等待](0066-unified-wait-window-and-call-sequence.md) | 已接受 | 修订 ADR-0007、ADR-0015 | diff --git a/docs/installation.md b/docs/installation.md index 1b824dcc..2034a0b8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -242,7 +242,24 @@ MCP 配置路径的全部祖先(家目录本身除外)及目标都拒绝符 layers 返回用户、覆盖和已知项目层。移除删除所有用户级定义,逐文件备份并在回执列出;不会在移除高层后悄然退回另一用户层。项目级只读检查限于 App 启动目录及其祖先,实际宿主的其他项目目录仍须手动核对。多文件不是原子整体,冲突时保留外部新内容,依据回执重试余下层。 -Pi 的 `agentDir/skills` 已从安装包代码核实;Hermes 新条目 `enabled: true` 且省略工具过滤表示启用全部工具,接管保留原 `include/exclude`,尤其 `include: []`。DeepSeek Harness 的家目录级补丁层对桌面 App 与 `dsh` 各 profile 都生效(桌面 App 自己的设置写在 `profiles/desktop/cordis.patch.yml`,App 不碰);新条目带 `toolCallTimeoutMs: 310000`,否则 dsh 默认 60 秒就截断 `wait`;接管已有条目不改这项。补丁文件必须是 YAML 列表,移除最后一项时留下 `[]`,空文件会让 dsh 启动失败;dsh 会热重载这一层,改动后自动换新 MCP 连接。其他列表项(含 `!!js` 表达式)不解析、原样保留;以下情况交回手动处理:`agentnave` 行与其他条目合在同一个 `insert` 里;`id: agentnave` 的 `name` 不是 `@deepseek-ai/dsh-mcp-client`;文件不是顶格书写的块式列表,比如整表缩进、带 BOM、以 `---` 开头,或写成 `[{...}]` 流式列表。要恢复自动接入,去掉 BOM 和 `---`,把每个列表项改成从行首 `- ` 开始的块式写法即可。Hermes 流式 MCP 映射、锚点/别名、文档指令等结构仍拒绝自动写入并提示手动处理。真正的 MCP/Skill 发现须按第 5 节验证。 +Pi 的 `agentDir/skills` 已从安装包代码核实;Hermes 新条目 `enabled: true` 且省略工具过滤表示启用全部工具,接管保留原 `include/exclude`,尤其 `include: []`。DeepSeek Harness 的家目录级补丁层对桌面 App 与 `dsh` 各 profile 都生效(桌面 App 自己的设置写在 `profiles/desktop/cordis.patch.yml`,App 不碰);新条目带 `toolCallTimeoutMs: 720000`,否则 dsh 默认 60 秒就截断 `wait`;接入与刷新已有 AgentNave 条目时补足十二分钟,已有更长值保留。补丁文件必须是 YAML 列表,移除最后一项时留下 `[]`,空文件会让 dsh 启动失败;dsh 会热重载这一层,改动后自动换新 MCP 连接。其他列表项(含 `!!js` 表达式)不解析、原样保留;以下情况交回手动处理:`agentnave` 行与其他条目合在同一个 `insert` 里;`id: agentnave` 的 `name` 不是 `@deepseek-ai/dsh-mcp-client`;文件不是顶格书写的块式列表,比如整表缩进、带 BOM、以 `---` 开头,或写成 `[{...}]` 流式列表。要恢复自动接入,去掉 BOM 和 `---`,把每个列表项改成从行首 `- ` 开始的块式写法即可。Hermes 流式 MCP 映射、锚点/别名、文档指令等结构仍拒绝自动写入并提示手动处理。真正的 MCP/Skill 发现须按第 5 节验证。 + +所有 boss 的 `wait` 默认最多等待 600 秒,任务结束、阻塞、弹框或待审时提前返回;这不是子任务的总运行时限。`wait_seconds` 只接受 `0`、`600`,升级后停止传旧值 `240`;连续等待使用递增的 `seq`(1、2、3……),它只区分调用,不保证所有宿主都停止重复调用警告。 + +App 在接入与刷新已有用户级 AgentNave 条目时,将下列工具超时补到至少 720 秒;已有更长值保留,Antigravity 的 `-1` 无上限也保留。只改 `agentnave` 服务,项目配置和其他服务不动。手动接入时设置相同字段,并在升级后重连宿主: + +| 宿主 | AgentNave 服务条目的工具超时 | +| --- | --- | +| Codex(包括桌面与其他配置目录) | `tool_timeout_sec = 720` | +| Claude Code/Claude 桌面 Code 页 | `timeout: 720000`(毫秒) | +| Antigravity | `timeoutSeconds: 720` | +| Pi、Hermes | `timeout: 720`(秒) | +| DeepSeek Harness | `toolCallTimeoutMs: 720000` | +| ZCode | `timeoutMs: 720000` | +| Grok | 原生默认 6000 秒保留;已有较短 `tool_timeout_sec` 和 `wait`/`start_agent`/`run_round` 的 `tool_timeouts` 补至 720 秒 | +| CodeBuddy | 保留已核实的一小时原生默认值;若用户设置过更短的 `MCP_TOOL_TIMEOUT`,需在宿主进程环境调整,不能写到子服务 `env` 代设 | +| WorkBuddy | 同样使用十分钟等待;工具超时配置尚未核实,App 不写猜测字段,遇到超时再单独处理 | + ### Codex @@ -337,11 +354,11 @@ selection. Restart the host and verify Skill discovery separately. command: "/absolute/path/to/agentnave-mcp" env: AGENTNAVE_HOST: "deepseek" - toolCallTimeoutMs: 310000 + toolCallTimeoutMs: 720000 ``` `toolCallTimeoutMs` matters: dsh ends each MCP tool call after 60 seconds by default, while `wait` -blocks for up to 300 seconds. This home-level patch applies to the desktop App and every `dsh` profile. dsh clears inherited +blocks for up to 600 seconds. This home-level patch applies to the desktop App and every `dsh` profile. dsh clears inherited `DSH_*` and secret-looking variables before starting stdio servers; the `env` block above is merged back on top. Restart DeepSeek Harness, then check that the tools appear as `mcp__agentnave__*`. @@ -359,7 +376,7 @@ Use `~/.zcode/cli/config.json`, keeping any other settings and servers: "command": "/absolute/path/to/agentnave-mcp", "args": [], "env": {"AGENTNAVE_HOST": "zcode"}, - "timeoutMs": 310000 + "timeoutMs": 720000 } } } @@ -367,8 +384,8 @@ Use `~/.zcode/cli/config.json`, keeping any other settings and servers: ``` Use the installed release launcher's absolute path. ZCode stops an MCP tool call after 30 -seconds unless the entry sets `timeoutMs`; 310000 covers `wait`'s 300-second limit. The App -writes it only when it creates the entry; takeover of an existing entry leaves it unchanged. +seconds unless the entry sets `timeoutMs`; 720000 covers `wait`'s 600-second window with two minutes for the reply. The App +raises it to this minimum on connection and refresh, including existing entries, and preserves longer limits. ZCode reads `~/.agents/mcp.json` only while `~/.zcode/cli/config.json` names no MCP server, so the App refuses a first connection while that fallback holds servers: move them under `mcp.servers` first. New sessions load this entry; in Desktop, diff --git a/docs/linux-after-install.md b/docs/linux-after-install.md index 190f4f1e..5cd20270 100644 --- a/docs/linux-after-install.md +++ b/docs/linux-after-install.md @@ -238,7 +238,7 @@ for s in agentnave-manager $EXTRA; do # 第 6 步选了装时 EXTRA=agentnave- start_agent(prompt="回复:好了", seat="explorer-easy", # 没开编排时改用 provider="<第 3 步选的第一个 CLI>" role="自检", cwd="/tmp", manager_session_id="<固定 UUID>") - wait(ids=[""], wait_seconds=240, include_output=true) + wait(ids=[""], wait_seconds=600, include_output=true) ``` 期望:`succeeded`,输出“好了”(本机实测由 Pi 执行,大约 5 秒)。返回 `Boss model unknown` 警告是正常的(§8.4),不要因此重复调用。 diff --git a/docs/linux-headless.md b/docs/linux-headless.md index 15471cdd..dedb6c4e 100644 --- a/docs/linux-headless.md +++ b/docs/linux-headless.md @@ -203,10 +203,10 @@ done ``` start_agent(prompt="回复:好了", seat="explorer-easy", role="自检", cwd="/workspace", manager_session_id="<固定 UUID>") -wait(ids=[""], wait_seconds=240, include_output=true) +wait(ids=[""], wait_seconds=600, include_output=true) ``` -`wait_seconds` 只能取 0 或 240。一直等到 `status` 是 `succeeded`、`failed` 或 `cancelled`,并且 `all_finished: true`。 +`wait_seconds` 只能取 0 或 600。一直等到 `status` 是 `succeeded`、`failed` 或 `cancelled`,并且 `all_finished: true`。 ### 5.3 连接器坏了时:独立 stdio 客户端 @@ -248,7 +248,7 @@ async def main(): "wait", { "ids": [st["invocation_id"]], - "wait_seconds": 240, + "wait_seconds": 600, "include_output": True, }, ) diff --git a/skills/agentnave-manager/SKILL.md b/skills/agentnave-manager/SKILL.md index 34ca8e9f..73b436c7 100644 --- a/skills/agentnave-manager/SKILL.md +++ b/skills/agentnave-manager/SKILL.md @@ -153,8 +153,8 @@ description: 用户要求用 Claude Code、CodeBuddy Code、Codex CLI、Grok CLI ## 等待、验收与取消 1. `start_agent` 保存 `invocation_id`,`run_round` 保存 `round_id`;持续等到终态或显式取消,不另加总时限。 - 终态和阻塞不会主动推送,只能由 `wait` 收取:手头没有不依赖结果的工作时,派发后立刻单独调用 `wait`;有就先完成当前步骤再调用,并在结束本轮前收齐全部终态。常规省略 `wait_seconds`,`wait_elapsed` 后直接再等。调用已被宿主转入后台的,结束时按宿主提醒或句柄收取。回执里的 `unreceived` 是还没取走的结果,见到就先用 `wait` 收取;回执带 `next` 时照做,任务在跑时要先问用户,也先 `wait` 或 `cancel`。只派一个任务且本轮就要结果时,可传 `wait=true`,派发后先等一次,结果在回执的 `wait` 里;并联派发不要逐个传,全部派出后再统一等。 -2. 常规用收件箱:省略 `ids`、带上派发时相同的 `manager_session_id`,每次重复同样的调用;无显式或宿主注入身份时会报错。定向等待用显式 `ids`,下次传返回的 `pending_ids`;保留已终态句柄会重读结果。任一就绪即返回,先处理阻塞和待审,再继续等到 `all_finished=true`;它只表示所选范围无待收终态,不代表全部成功。遇到 `review_needed` 先用 `decide_answers` 裁决,否则继续 `wait` 会立即返回。仅在用户询问进展或现有状态不足以判断是否介入时,对相关 `ids` 调用 `wait(wait_seconds=0)`;它仍会收取就绪答案或阻塞,不是只读查询。在 Codex 用 `functions.exec` 调 `wait` 时,首行写 `// @exec: {"yield_time_ms": 250000}`;返回 `Script running with cell ID ...` 后,只用 `functions.wait(cell_id, yield_time_ms=250000)` 等该脚本完成。让出间隔要长过 `wait` 的 240 秒,否则同一结果会被反复轮询,可能被宿主或模型代理当成死循环而中断本轮;外层让出控制权不代表超时。 + 终态和阻塞不会主动推送,只能由 `wait` 收取:手头没有不依赖结果的工作时,派发后立刻单独调用 `wait`;有就先完成当前步骤再调用,并在结束本轮前收齐全部终态。常规省略 `wait_seconds`,最多等 600 秒;任务完成或需要处理时提前返回,`wait_elapsed` 后直接再等。`wait_seconds` 只接受 `0`、`600`,旧的 `240` 不再支持。调用已被宿主转入后台的,结束时按宿主提醒或句柄收取。回执里的 `unreceived` 是还没取走的结果,见到就先用 `wait` 收取;回执带 `next` 时照做,任务在跑时要先问用户,也先 `wait` 或 `cancel`。只派一个任务且本轮就要结果时,可传 `wait=true`,派发后先等一次,结果在回执的 `wait` 里;并联派发不要逐个传,全部派出后再统一等。 +2. 常规用收件箱:省略 `ids`、带上派发时相同的 `manager_session_id`,每次保持相同归属,`seq` 从 1 开始逐次递增,以区分正常的连续等待,减少宿主的重复入参警告;回执原样带回 `seq`,它不去重、不重放,也不要求服务保存计数。无显式或宿主注入身份时会报错。定向等待用显式 `ids`,下次传返回的 `pending_ids`;保留已终态句柄会重读结果。任一就绪即返回,先处理阻塞和待审,再继续等到 `all_finished=true`;它只表示所选范围无待收终态,不代表全部成功。遇到 `review_needed` 先用 `decide_answers` 裁决,否则继续 `wait` 会立即返回。仅在用户询问进展或现有状态不足以判断是否介入时,对相关 `ids` 调用 `wait(wait_seconds=0)`;它仍会收取就绪答案或阻塞,不是只读查询。在 Codex 用 `functions.exec` 调 `wait` 时,首行写 `// @exec: {"yield_time_ms": 300000}`;返回 `Script running with cell ID ...` 后,只用 `functions.wait(cell_id, yield_time_ms=300000)` 收取同一脚本,不再创建第二个 AgentNave `wait`。两处均显式设置已验证的五分钟让出窗口,十分钟的服务等待可能需要再收取一次;不要使用默认十秒间隔反复轮询。外层让出控制权不代表超时或取消;`seq` 只在新发起一次 AgentNave `wait` 时递增。 3. `status=running` 尚未结束。`execution_blocked` 按 `error.code` 决定继续等或取消:阻塞不停止 CLI,同类阻塞每个任务只提醒一次;非交互模式下单个工具调用被拒绝不算阻塞,CLI 常会换工具继续,终态再按结果核对;普通工具失败、重试、沉默不证明任务失败。`seat_lost` 表示某席已失败或受阻而其他席仍在跑,每席提醒一次,只给状态和错误,不交付答案;按 `error` 决定补跑或继续等。 4. `wait(0)` 返回的运行中 `output` 只是最新公开正文尾部,不含思考或工具参数;材料不足就报告未知,不凭此判定卡死。普通会议中,单席成功不单独唤醒等待,未就绪快照也不交付该席完整答案。 5. 终态按 `status`、完整 `output`、`error` 和原生 `session_id` 验收;会议每席全文每轮只给一次,再看用 `read_conversation`。`succeeded` 不证明目标完成,须核对命令、产物或检查。 diff --git a/src/agentnave/mcp_payloads.py b/src/agentnave/mcp_payloads.py index d619df65..257ed300 100644 --- a/src/agentnave/mcp_payloads.py +++ b/src/agentnave/mcp_payloads.py @@ -157,6 +157,7 @@ class WaitPayload(TypedDict): updates: list[InvocationPayload] rounds: list[RoundPayload] next: NotRequired[str] + seq: NotRequired[int] class CancelPayload(TypedDict): diff --git a/src/agentnave/mcp_runtime.py b/src/agentnave/mcp_runtime.py index dd7c25ac..1d5ece56 100644 --- a/src/agentnave/mcp_runtime.py +++ b/src/agentnave/mcp_runtime.py @@ -115,7 +115,7 @@ def _read_excluded_providers() -> frozenset[str]: return excluded if host_provider is None else excluded | {host_provider} -WAIT_SECONDS = 240 +WAIT_SECONDS = 600 # Ends are never pushed (ADR-0007), so receipts say what to call before the turn ends (ADR-0064). WAIT_NEXT = ( @@ -124,7 +124,7 @@ def _read_excluded_providers() -> frozenset[str]: ) WAIT_AGAIN = ( "Still pending: handle any prompt_pending or review_needed first, then call wait again " - "before ending your turn." + "before ending your turn; increment seq on each wait to distinguish repeated calls." ) EXCLUDED_PROVIDERS = _read_excluded_providers() @@ -142,7 +142,7 @@ def _read_excluded_providers() -> frozenset[str]: ) ACCESS = "Declared read/write access; see MCP instructions." DISPATCH_WAIT = ( - "true waits up to 240s like wait(ids=[this handle]) and returns it in wait; only for a single " + f"true waits up to {WAIT_SECONDS}s like wait(ids=[this handle]) and returns it in wait; only for a single " "dispatch whose result this turn needs, not parallel dispatches." ) PROVIDER_OPTIONS: dict[ProviderName, str] = { diff --git a/src/agentnave/mcp_tasks.py b/src/agentnave/mcp_tasks.py index a5961714..6bf314ad 100644 --- a/src/agentnave/mcp_tasks.py +++ b/src/agentnave/mcp_tasks.py @@ -340,10 +340,11 @@ async def wait( ), ] = True, wait_seconds: Annotated[ - Literal[0, 240] | None, + Literal[0, 600] | None, Field( description=( - "Omit to wait up to 240s; 0 checks progress immediately and includes running tails; ready results are still received; neither value stops work." + "Omit to wait up to 600s; 600 explicitly selects the same window. " + "0 checks progress immediately and includes running tails; ready results are still received; no value stops work." ), ), ] = None, @@ -355,12 +356,22 @@ async def wait( ), ), ] = None, + seq: Annotated[ + int | None, + Field( + ge=0, + description=( + "Optional call sequence: start at 1 and increment on each wait so legitimate repeated waits have distinct arguments. " + "Echoed in the reply; no deduplication, replay or ordering enforcement." + ), + ), + ] = None, *, ctx: Context[Runtime], ) -> WaitPayload: """Receive all handles ready now in updates (tasks) and rounds; return when any is ready. - Inbox: omit ids, pass this chat's manager_session_id, repeat the same call. + Inbox: omit ids, pass this chat's manager_session_id; increment seq for each call. Selected ids: continue with pending_ids; retaining finished ids rereads their results. all_finished means no unreceived ends remain in that scope, not that all succeeded. @@ -376,6 +387,21 @@ async def wait( Unknown/expired/duplicate ids reject the whole call. Ends are never pushed: wait after dispatch or independent work; receive every terminal result before ending your turn. """ + payload = await _receive(ids, include_output, wait_seconds, manager_session_id, ctx) + if seq is not None: + payload["seq"] = seq + if next_step := payload.get("next"): + payload["next"] = next_step + f" Next wait: seq={seq + 1}." + return payload + + +async def _receive( + ids: list[str] | None, + include_output: bool, + wait_seconds: Literal[0, 600] | None, + manager_session_id: str | None, + ctx: Context[Runtime], +) -> WaitPayload: manager = _manager(ctx) conversations = _conversations(ctx) invocation_ids: list[str] = [] @@ -407,7 +433,8 @@ async def wait( except KeyError as exc: raise _unknown_handle(handle) from exc round_ids.append(handle) - deadline = time.monotonic() + (runtime.WAIT_SECONDS if wait_seconds is None else wait_seconds) + window = runtime.WAIT_SECONDS if wait_seconds is None else wait_seconds + deadline = time.monotonic() + window while True: ready_invocations, ready_rounds = await _ready_handles( manager, @@ -469,7 +496,7 @@ def _wait_payload( results: _WaitResults, ids: list[str] | None, include_output: bool, - wait_seconds: Literal[0, 240] | None, + wait_seconds: Literal[0, 600] | None, manager_session_id: str | None, ) -> WaitPayload: invocation_ids, round_ids = results.invocation_ids, results.round_ids diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 4d21c5fb..d60ff03e 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -522,10 +522,16 @@ async def test_mcp_lists_lifecycle_and_discovery_tools_with_structured_contracts assert "timeout_seconds" not in start_properties assert tools["start_agent"].input_schema["required"] == ["prompt"] wait_properties = _payload(tools["wait"].input_schema["properties"]) - assert set(wait_properties) == {"ids", "include_output", "wait_seconds", "manager_session_id"} - assert mcp_server.WAIT_SECONDS == 240 + assert set(wait_properties) == { + "ids", + "include_output", + "wait_seconds", + "manager_session_id", + "seq", + } + assert mcp_server.WAIT_SECONDS == 600 wait_modes = cast(list[dict[str, object]], _payload(wait_properties["wait_seconds"])["anyOf"]) - assert wait_modes[0]["enum"] == [0, 240] + assert wait_modes[0]["enum"] == [0, 600] assert tools["answer_prompt"].input_schema["required"] == [ "invocation_id", "prompt_id", @@ -760,7 +766,7 @@ async def test_mcp_group_round_runs_seats_in_parallel_and_returns_final_answers( async with asyncio.timeout(10): while True: waited = await client.call_tool( - "wait", {"ids": [started_payload["round_id"]], "wait_seconds": 240} + "wait", {"ids": [started_payload["round_id"]], "wait_seconds": 600} ) payload = _first(waited, "rounds") if payload["status"] != "running": @@ -885,13 +891,65 @@ async def test_mcp_blocked_round_keeps_finished_answers_and_hides_running_tails( @pytest.mark.asyncio async def test_mcp_rejects_arbitrary_wait_windows() -> None: async with Client(mcp) as client: - for seconds in (1, 45, 300): + for seconds in (1, 45, 240, 300): result = await client.call_tool("wait", {"ids": ["missing"], "wait_seconds": seconds}) assert result.is_error is True - assert "0" in str(result.content) and "240" in str(result.content) + assert "0" in str(result.content) and "600" in str(result.content) assert "Unknown or expired id" not in str(result.content) +@pytest.mark.asyncio +async def test_wait_window_and_sequences_preserve_inbox_delivery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_claude(tmp_path, monkeypatch) + windows: list[float] = [] + + async def elapsed(*args: object) -> tuple[dict[str, object], dict[str, object]]: + windows.append(cast(float, args[-1])) + return {}, {} + + async with Client(mcp) as client: + started = _payload( + ( + await client.call_tool( + "start_agent", {"provider": "claude", "prompt": "sleep", "cwd": str(tmp_path)} + ) + ).structured_content + ) + with monkeypatch.context() as patch: + patch.setattr("agentnave.mcp_tasks._ready_handles", elapsed) + for arguments in ({"seq": 1}, {"seq": 2, "wait_seconds": 600}, {"seq": 2}): + reply = _payload( + ( + await client.call_tool( + "wait", {"ids": [started["invocation_id"]], **arguments} + ) + ).structured_content + ) + assert reply["reason"] == "wait_elapsed" and reply["seq"] == arguments["seq"] + assert reply["pending_ids"] == [started["invocation_id"]] + assert f"seq={int(arguments['seq']) + 1}" in str(reply["next"]) + assert len(windows) == 3 and all(599 < window <= 600 for window in windows) + # Reusing seq never replays a response: a later terminal result is still received once. + await client.call_tool("cancel", {"id": started["invocation_id"]}) + ended = _payload( + ( + await client.call_tool( + "start_agent", {"provider": "claude", "prompt": "ok", "cwd": str(tmp_path)} + ) + ).structured_content + ) + reply = _payload((await client.call_tool("wait", {"seq": 2})).structured_content) + assert reply["reason"] == "ready" and reply["seq"] == 2 + assert ( + _payload(cast(list[object], reply["updates"])[0])["invocation_id"] + == ended["invocation_id"] + ) + empty = _payload((await client.call_tool("wait", {"seq": 3})).structured_content) + assert empty["reason"] == "nothing_pending" and empty["seq"] == 3 + + @pytest.mark.asyncio async def test_mcp_wait_any_and_list_invocations_expose_handles_without_task_text( tmp_path: Path, monkeypatch: pytest.MonkeyPatch