From e416ee1cf6cc648fc813e659af95d9f5f5083ea1 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 23 May 2026 01:59:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20timeouts=20=E2=80=94=20pe?= =?UTF-8?q?r-bucket/tier=20timeout=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port ORCHESTRATOR_TIMEOUT_PROFILES, SUB_AGENT_TIMEOUT_PROFILES, and builtin-planning timeouts from the TS ai-runtime-config / prompt-optimizer / model-profiles trio into crates/op-orchestrator/src/timeouts.rs. - Timeouts { hard, no_text, first_text } + PromptBucket enum - bucket_for(len): Short(<2200) / Medium(<4200) / Long - orchestrator_timeouts, sub_agent_timeouts (Basic-tier clamps), builtin_planning_timeouts - apply_profile_to_timeouts (multiplier scaling, Math.round parity) - CallRequest gains no_text_timeout/first_text_timeout: Option (all existing literals updated to None) --- crates/op-orchestrator/src/lib.rs | 1 + crates/op-orchestrator/src/prompt.rs | 6 + crates/op-orchestrator/src/timeouts.rs | 374 +++++++++++++++++++++++++ crates/op-orchestrator/src/types.rs | 6 + 4 files changed, 387 insertions(+) create mode 100644 crates/op-orchestrator/src/timeouts.rs diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index efa27dc5d..e6791c9ba 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -17,6 +17,7 @@ pub mod plan; pub mod plan_normalize; pub mod retry; pub mod style_guide_context; +pub mod timeouts; pub mod types; pub mod variables; diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 60ef1673e..ac06ec986 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -85,6 +85,8 @@ pub fn build_orchestrator_prompt( provider: req.provider.clone(), timeout: PLANNING_TIMEOUT, abort, + no_text_timeout: None, + first_text_timeout: None, }, forced_style_guide_name: Some(cp.selected_style_guide_name), mode, @@ -121,6 +123,8 @@ pub fn build_orchestrator_prompt( provider: req.provider.clone(), timeout: PLANNING_TIMEOUT, abort, + no_text_timeout: None, + first_text_timeout: None, }, forced_style_guide_name: None, mode, @@ -164,6 +168,8 @@ pub fn build_subagent_prompt( provider: req.provider.clone(), timeout: SUBAGENT_TIMEOUT, abort, + no_text_timeout: None, + first_text_timeout: None, } } diff --git a/crates/op-orchestrator/src/timeouts.rs b/crates/op-orchestrator/src/timeouts.rs new file mode 100644 index 000000000..46737dbfb --- /dev/null +++ b/crates/op-orchestrator/src/timeouts.rs @@ -0,0 +1,374 @@ +//! 超时档位表 —— S3b-1b Task A2。 +//! +//! Port of: +//! - `ai-runtime-config.ts` — `PROMPT_TIMEOUT_BUCKETS`, +//! `ORCHESTRATOR_TIMEOUT_PROFILES`, `SUB_AGENT_TIMEOUT_PROFILES` +//! - `orchestrator-prompt-optimizer.ts` — `getOrchestratorTimeouts`, +//! `getBuiltinPlanningTimeouts`, `getSubAgentTimeouts` +//! - `model-profiles.ts` — `applyProfileToTimeouts` + +#![allow(dead_code)] + +use crate::model_profile::ModelTier; +use std::time::Duration; + +// ── 从 TS `ai-runtime-config.ts` 逐字搬来的毫秒常量 ──────────────────────── + +/// 规划 prompt 桶阈值 1 —— `PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold`。 +/// `< SHORT_THRESH` → Short。 +const SHORT_THRESH: usize = 2200; + +/// 规划 prompt 桶阈值 2 —— `PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars`。 +/// `SHORT_THRESH <= len < MEDIUM_THRESH` → Medium; `>= MEDIUM_THRESH` → Long。 +const MEDIUM_THRESH: usize = 4200; + +// Orchestrator timeout profiles (ms) — verbatim from ORCHESTRATOR_TIMEOUT_PROFILES +const ORCH_SHORT_HARD_MS: u64 = 300_000; +const ORCH_SHORT_NO_TEXT_MS: u64 = 150_000; +const ORCH_SHORT_FIRST_TEXT_MS: u64 = 300_000; + +const ORCH_MEDIUM_HARD_MS: u64 = 420_000; +const ORCH_MEDIUM_NO_TEXT_MS: u64 = 210_000; +const ORCH_MEDIUM_FIRST_TEXT_MS: u64 = 420_000; + +const ORCH_LONG_HARD_MS: u64 = 600_000; +const ORCH_LONG_NO_TEXT_MS: u64 = 300_000; +const ORCH_LONG_FIRST_TEXT_MS: u64 = 600_000; + +// Sub-agent timeout profiles (ms) — verbatim from SUB_AGENT_TIMEOUT_PROFILES +// Base: hardTimeoutMs=420_000, noTextTimeoutMs=210_000 (SUB_AGENT_TIMEOUT_BASE) +const SA_SHORT_HARD_MS: u64 = 420_000; +const SA_SHORT_NO_TEXT_MS: u64 = 210_000; +const SA_SHORT_FIRST_TEXT_MS: u64 = 420_000; + +const SA_MEDIUM_HARD_MS: u64 = 600_000; +const SA_MEDIUM_NO_TEXT_MS: u64 = 300_000; +const SA_MEDIUM_FIRST_TEXT_MS: u64 = 600_000; + +const SA_LONG_HARD_MS: u64 = 900_000; +const SA_LONG_NO_TEXT_MS: u64 = 480_000; +const SA_LONG_FIRST_TEXT_MS: u64 = 900_000; + +// Basic-tier sub-agent clamps — from getSubAgentTimeouts: +// noTextTimeoutMs = Math.min(noTextTimeoutMs, 45_000) +// firstTextTimeoutMs = Math.min(firstTextTimeoutMs, 75_000) +const SA_BASIC_NO_TEXT_MAX_MS: u64 = 45_000; +const SA_BASIC_FIRST_TEXT_MAX_MS: u64 = 75_000; + +// Builtin planning base timeouts — from getBuiltinPlanningTimeouts +const BUILTIN_HARD_MS: u64 = 60_000; +const BUILTIN_NO_TEXT_MS: u64 = 30_000; +const BUILTIN_FIRST_TEXT_MS: u64 = 30_000; + +// Basic-tier builtin planning floors (Math.max raises): +// hardTimeoutMs = Math.max(hard, 150_000) +// noTextTimeoutMs = Math.max(no_text, 75_000) +// firstTextTimeoutMs = Math.max(first_text, 75_000) +const BUILTIN_BASIC_HARD_MIN_MS: u64 = 150_000; +const BUILTIN_BASIC_NO_TEXT_MIN_MS: u64 = 75_000; +const BUILTIN_BASIC_FIRST_TEXT_MIN_MS: u64 = 75_000; + +// ── 公开类型 ───────────────────────────────────────────────────────────────── + +/// 一次 LLM 调用的三路超时。 +/// +/// - `hard` — 整体硬截止 +/// - `no_text` — 从连接建立到第一个 text chunk 的超时(非 abort 中途超时) +/// - `first_text` — 从第一个 text chunk 到真正"文字内容"出现的超时 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Timeouts { + pub hard: Duration, + pub no_text: Duration, + pub first_text: Duration, +} + +impl Timeouts { + fn from_ms(hard_ms: u64, no_text_ms: u64, first_text_ms: u64) -> Self { + Self { + hard: Duration::from_millis(hard_ms), + no_text: Duration::from_millis(no_text_ms), + first_text: Duration::from_millis(first_text_ms), + } + } +} + +/// prompt 长度对应的桶 —— 决定采用哪组超时档。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptBucket { + /// `< 2200` 字符 + Short, + /// `2200 <= len < 4200` 字符 + Medium, + /// `>= 4200` 字符 + Long, +} + +/// 根据 prompt 字节长度确定桶。 +/// +/// Port of the bucket selection in `getOrchestratorTimeouts` / +/// `getSubAgentTimeouts`: +/// ```ts +/// if (len < PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) Short +/// else if (len < PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars) Medium +/// else Long +/// ``` +pub fn bucket_for(prompt_len: usize) -> PromptBucket { + if prompt_len < SHORT_THRESH { + PromptBucket::Short + } else if prompt_len < MEDIUM_THRESH { + PromptBucket::Medium + } else { + PromptBucket::Long + } +} + +// ── 公开函数 ───────────────────────────────────────────────────────────────── + +/// 规划阶段超时 —— port of `getOrchestratorTimeouts(promptLength, model)`. +/// +/// `tier` 只影响通过 `apply_profile_to_timeouts` 注入的 `timeout_multiplier`。 +/// 本函数不接 model_id,调用方提前 resolve 好 multiplier 传入即可。 +/// 注:TS 原函数最后调用 `applyProfileToTimeouts` —— 此处分两步 +/// (`orchestrator_timeouts` 取基值,再由调用方做 `apply_profile_to_timeouts`) +/// 保持函数接口纯净。 +pub fn orchestrator_timeouts(prompt_len: usize) -> Timeouts { + match bucket_for(prompt_len) { + PromptBucket::Short => Timeouts::from_ms( + ORCH_SHORT_HARD_MS, + ORCH_SHORT_NO_TEXT_MS, + ORCH_SHORT_FIRST_TEXT_MS, + ), + PromptBucket::Medium => Timeouts::from_ms( + ORCH_MEDIUM_HARD_MS, + ORCH_MEDIUM_NO_TEXT_MS, + ORCH_MEDIUM_FIRST_TEXT_MS, + ), + PromptBucket::Long => Timeouts::from_ms( + ORCH_LONG_HARD_MS, + ORCH_LONG_NO_TEXT_MS, + ORCH_LONG_FIRST_TEXT_MS, + ), + } +} + +/// sub-agent 超时 —— port of `getSubAgentTimeouts(promptLength, model)`. +/// +/// 基础档位由 prompt 长度决定;Basic tier 对 `no_text` / `first_text` +/// 施加上限(TS: `Math.min(..., 45_000)` / `Math.min(..., 75_000)`)。 +pub fn sub_agent_timeouts(prompt_len: usize, tier: ModelTier) -> Timeouts { + let mut t = match bucket_for(prompt_len) { + PromptBucket::Short => Timeouts::from_ms( + SA_SHORT_HARD_MS, + SA_SHORT_NO_TEXT_MS, + SA_SHORT_FIRST_TEXT_MS, + ), + PromptBucket::Medium => Timeouts::from_ms( + SA_MEDIUM_HARD_MS, + SA_MEDIUM_NO_TEXT_MS, + SA_MEDIUM_FIRST_TEXT_MS, + ), + PromptBucket::Long => { + Timeouts::from_ms(SA_LONG_HARD_MS, SA_LONG_NO_TEXT_MS, SA_LONG_FIRST_TEXT_MS) + } + }; + if tier == ModelTier::Basic { + t.no_text = t + .no_text + .min(Duration::from_millis(SA_BASIC_NO_TEXT_MAX_MS)); + t.first_text = t + .first_text + .min(Duration::from_millis(SA_BASIC_FIRST_TEXT_MAX_MS)); + } + t +} + +/// 内建规划超时(Compact/Minimal 模式的轻量规划调用)—— +/// port of `getBuiltinPlanningTimeouts(model)`. +/// +/// 基础值固定;Basic tier 用 `Math.max` 提高下限 +/// (Basic 模型较慢,需要更多时间思考但要禁 thinking)。 +pub fn builtin_planning_timeouts(tier: ModelTier) -> Timeouts { + let mut t = Timeouts::from_ms(BUILTIN_HARD_MS, BUILTIN_NO_TEXT_MS, BUILTIN_FIRST_TEXT_MS); + if tier == ModelTier::Basic { + t.hard = t.hard.max(Duration::from_millis(BUILTIN_BASIC_HARD_MIN_MS)); + t.no_text = t + .no_text + .max(Duration::from_millis(BUILTIN_BASIC_NO_TEXT_MIN_MS)); + t.first_text = t + .first_text + .max(Duration::from_millis(BUILTIN_BASIC_FIRST_TEXT_MIN_MS)); + } + t +} + +/// 按模型的 `timeout_multiplier` 缩放三路超时 —— port of +/// `applyProfileToTimeouts` (the timeout-scaling part only). +/// +/// `multiplier == 1.0` 为 no-op。四舍五入到整毫秒(`Math.round` 对齐)。 +pub fn apply_profile_to_timeouts(t: Timeouts, multiplier: f64) -> Timeouts { + if (multiplier - 1.0).abs() < f64::EPSILON { + return t; + } + Timeouts { + hard: mul_ms(t.hard, multiplier), + no_text: mul_ms(t.no_text, multiplier), + first_text: mul_ms(t.first_text, multiplier), + } +} + +fn mul_ms(d: Duration, m: f64) -> Duration { + let ms = (d.as_millis() as f64 * m).round() as u64; + Duration::from_millis(ms) +} + +// ── 测试 ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── bucket_for ──────────────────────────────────────────────────────────── + + #[test] + fn bucket_short_below_2200() { + assert_eq!(bucket_for(0), PromptBucket::Short); + assert_eq!(bucket_for(2199), PromptBucket::Short); + } + + #[test] + fn bucket_medium_at_2200_below_4200() { + assert_eq!(bucket_for(2200), PromptBucket::Medium); + assert_eq!(bucket_for(4199), PromptBucket::Medium); + } + + #[test] + fn bucket_long_at_and_above_4200() { + assert_eq!(bucket_for(4200), PromptBucket::Long); + assert_eq!(bucket_for(9999), PromptBucket::Long); + } + + // ── orchestrator_timeouts ───────────────────────────────────────────────── + + #[test] + fn orchestrator_timeouts_short_bucket() { + let t = orchestrator_timeouts(100); + assert_eq!(t.hard, Duration::from_millis(300_000)); + assert_eq!(t.no_text, Duration::from_millis(150_000)); + assert_eq!(t.first_text, Duration::from_millis(300_000)); + } + + #[test] + fn orchestrator_timeouts_medium_bucket() { + let t = orchestrator_timeouts(3000); + assert_eq!(t.hard, Duration::from_millis(420_000)); + assert_eq!(t.no_text, Duration::from_millis(210_000)); + assert_eq!(t.first_text, Duration::from_millis(420_000)); + } + + #[test] + fn orchestrator_timeouts_long_bucket() { + let t = orchestrator_timeouts(5000); + assert_eq!(t.hard, Duration::from_millis(600_000)); + assert_eq!(t.no_text, Duration::from_millis(300_000)); + assert_eq!(t.first_text, Duration::from_millis(600_000)); + } + + // ── sub_agent_timeouts ──────────────────────────────────────────────────── + + #[test] + fn sub_agent_timeouts_short_full_tier() { + let t = sub_agent_timeouts(100, ModelTier::Full); + assert_eq!(t.hard, Duration::from_millis(420_000)); + assert_eq!(t.no_text, Duration::from_millis(210_000)); + assert_eq!(t.first_text, Duration::from_millis(420_000)); + } + + #[test] + fn sub_agent_timeouts_medium_standard_tier() { + let t = sub_agent_timeouts(3000, ModelTier::Standard); + assert_eq!(t.hard, Duration::from_millis(600_000)); + assert_eq!(t.no_text, Duration::from_millis(300_000)); + assert_eq!(t.first_text, Duration::from_millis(600_000)); + } + + #[test] + fn sub_agent_timeouts_long_full_tier() { + let t = sub_agent_timeouts(5000, ModelTier::Full); + assert_eq!(t.hard, Duration::from_millis(900_000)); + assert_eq!(t.no_text, Duration::from_millis(480_000)); + assert_eq!(t.first_text, Duration::from_millis(900_000)); + } + + #[test] + fn sub_agent_timeouts_basic_tier_clamps_no_text_and_first_text() { + // Short bucket base: no_text=210_000, first_text=420_000 + // Basic clamp: no_text ≤ 45_000, first_text ≤ 75_000 + let t = sub_agent_timeouts(100, ModelTier::Basic); + assert_eq!(t.hard, Duration::from_millis(420_000)); // hard unclamped + assert_eq!(t.no_text, Duration::from_millis(45_000)); + assert_eq!(t.first_text, Duration::from_millis(75_000)); + } + + #[test] + fn sub_agent_timeouts_basic_tier_long_bucket_still_clamped() { + // Long bucket base: no_text=480_000, first_text=900_000 + let t = sub_agent_timeouts(5000, ModelTier::Basic); + assert_eq!(t.hard, Duration::from_millis(900_000)); // hard unclamped + assert_eq!(t.no_text, Duration::from_millis(45_000)); + assert_eq!(t.first_text, Duration::from_millis(75_000)); + } + + // ── builtin_planning_timeouts ───────────────────────────────────────────── + + #[test] + fn builtin_planning_full_tier_base_values() { + let t = builtin_planning_timeouts(ModelTier::Full); + assert_eq!(t.hard, Duration::from_millis(60_000)); + assert_eq!(t.no_text, Duration::from_millis(30_000)); + assert_eq!(t.first_text, Duration::from_millis(30_000)); + } + + #[test] + fn builtin_planning_standard_tier_base_values() { + let t = builtin_planning_timeouts(ModelTier::Standard); + assert_eq!(t.hard, Duration::from_millis(60_000)); + assert_eq!(t.no_text, Duration::from_millis(30_000)); + assert_eq!(t.first_text, Duration::from_millis(30_000)); + } + + #[test] + fn builtin_planning_basic_tier_floors_raised() { + // Basic: Math.max(hard, 150_000), Math.max(no_text, 75_000), + // Math.max(first_text, 75_000) + let t = builtin_planning_timeouts(ModelTier::Basic); + assert_eq!(t.hard, Duration::from_millis(150_000)); + assert_eq!(t.no_text, Duration::from_millis(75_000)); + assert_eq!(t.first_text, Duration::from_millis(75_000)); + } + + // ── apply_profile_to_timeouts ───────────────────────────────────────────── + + #[test] + fn apply_multiplier_scales_all_three() { + let base = Timeouts::from_ms(300_000, 150_000, 300_000); + let scaled = apply_profile_to_timeouts(base, 2.0); + assert_eq!(scaled.hard, Duration::from_millis(600_000)); + assert_eq!(scaled.no_text, Duration::from_millis(300_000)); + assert_eq!(scaled.first_text, Duration::from_millis(600_000)); + } + + #[test] + fn apply_multiplier_one_is_noop() { + let base = Timeouts::from_ms(300_000, 150_000, 300_000); + let scaled = apply_profile_to_timeouts(base.clone(), 1.0); + assert_eq!(scaled, base); + } + + #[test] + fn apply_multiplier_rounds_to_ms() { + // 210_000 * 1.5 = 315_000 (exact) + let base = Timeouts::from_ms(210_000, 210_000, 210_000); + let scaled = apply_profile_to_timeouts(base, 1.5); + assert_eq!(scaled.hard, Duration::from_millis(315_000)); + } +} diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index 7a0087841..0216c373e 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -41,6 +41,12 @@ pub struct CallRequest { pub provider: Option, pub timeout: Duration, pub abort: AbortFlag, + /// 从请求开始到收到第一个文本 chunk 的超时;`None` 表示不设。 + /// Port of `noTextTimeoutMs` in the TS timeout profiles. + pub no_text_timeout: Option, + /// 从第一个文本 chunk 到"真正内容"出现的超时;`None` 表示不设。 + /// Port of `firstTextTimeoutMs` in the TS timeout profiles. + pub first_text_timeout: Option, } /// 流元素 —— 区分文本与思考,与 TS 的 text/thinking/error 三分