diff --git a/src/agent.rs b/src/agent.rs index 7086500..dd3bef5 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1143,8 +1143,12 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let decision = match (decision, &job.review) { // Direct mode: go ahead, unless an "always ask" rule matches. (Decision::Ask { reason, grant }, review) if direct => { + // Only looking (a screenshot, a page or file read) changes nothing, so no + // "always ask" rule (deleting, paying, posting…) can be about it: no model + // call to check (it cost every look at the person's Mac 10–20 s). + let looks = matches!(call.name.as_str(), "computer_use_screenshot" | "computer_use_show" | "browser_read" | "read_file" | "list_dir" | "search"); let verdict = match review { - Some(rv) if !rv.ask.is_empty() => review_action(&job, &Review { allow: vec![], ask: rv.ask.clone() }, &reason, &cancel), + Some(rv) if !rv.ask.is_empty() && !looks => review_action(&job, &Review { allow: vec![], ask: rv.ask.clone() }, &reason, &cancel), _ => ReviewVerdict::NoMatch, }; match verdict { @@ -1236,7 +1240,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // 給使用者看畫面: the picture goes into the chat for the person (the bot still sees it). if call.name == "computer_use_show" { if let Some(bytes) = image.as_deref().and_then(|b| base64::engine::general_purpose::STANDARD.decode(b).ok()) { - let name = format!("{} 的畫面 {}.jpg", target.as_ref().map(|t| t.name.as_str()).unwrap_or("電腦"), chrono::Local::now().format("%H%M%S")); + // (No time in the name: a brain on a server keeps UTC, and the chat shows when.) + let name = format!("{} 的畫面.jpg", target.as_ref().map(|t| t.name.as_str()).unwrap_or("電腦")); if let Ok(a) = crate::attach::store_bytes(bot_id, &name, &bytes) { send(StreamEvent::Attach(a)); } diff --git a/src/app.rs b/src/app.rs index 7ee57fd..a3e7e6c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -754,7 +754,7 @@ impl App { "link": {"type": "string"}}, "required": ["kind", "title"]}})); tool_specs.push(serde_json::json!({"name": "react", "description": "Put an emoji reaction on the person's latest message, like a friend would (e.g. ❤️ for thanks or good news, 👍 for OK, 😂 for a joke, 🎉 for a win, 👀 when you'll look into it). Use it now and then where it fits; you can still answer in words.", "parameters": {"type": "object", "properties": {"emoji": {"type": "string", "description": "One emoji"}}, "required": ["emoji"]}})); - tool_specs.push(serde_json::json!({"name": "send_file", "description": "Send a file you made (spreadsheet, document, Markdown, web page, picture, PDF…) into the chat as a file card the person can preview and download. Use this to hand over results instead of only saying where the file is.", + tool_specs.push(serde_json::json!({"name": "send_file", "description": "Send a file (spreadsheet, document, Markdown, web page, picture, PDF…) into the chat as a file card the person can preview and download, straight from whichever computer it is on — your own or one of the person's (pass computer; up to 20 MB from theirs). Use this to hand over results instead of only saying where the file is; never copy a file to another computer first just to send it.", "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder)"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}})); tool_specs.push(serde_json::json!({"name": "open_for_person", "description": "Open a file you made (a web page, chart, report, picture, document) on the person's screen so they can see it, or a web address. From the bot's computer it is copied over first.", "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "File path (relative to your folder) or http(s) address"}, "computer": {"type": "string", "enum": names.clone()}}, "required": ["path"]}})); diff --git a/src/providers.rs b/src/providers.rs index c077135..8c96983 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -336,6 +336,20 @@ fn page_words(body: &str) -> String { out.split_whitespace().collect::>().join(" ") } +/// An error the model sent inside its answer stream, in words: "at capacity" and the like are +/// waited out and tried again (it came through raw English, as the reply, and was never retried). +fn stream_failure(msg: &str) -> Failure { + let low = msg.to_lowercase(); + if ["capacity", "overloaded", "rate limit", "too many requests", "try again", "temporarily", "unavailable", "timeout", "timed out"].iter().any(|k| low.contains(k)) { + Failure { msg: format!("模型供應商現在太忙,正在等一下再試。\n({msg})"), retryable: true, retry_after_ms: Some(BUSY_RETRY_MS) } + } else { + Failure::fatal(format!("模型回報錯誤,這一步沒有完成。\n({msg})")) + } +} + +/// Wait before asking a busy model again (estimated; it says "a few minutes", 1–4 s was too soon). +const BUSY_RETRY_MS: u64 = 8_000; + fn http_error(status: u16, body: &str, retry_after: Option) -> Failure { let detail = serde_json::from_str::(body) .ok() @@ -913,7 +927,7 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F true })?; if let Some(e) = error { - return Err(Failure::fatal(e)); + return Err(stream_failure(&e)); } for (i, (id, name, args)) in pending.into_iter().enumerate() { if name.is_empty() { @@ -1105,7 +1119,7 @@ fn stream_anthropic(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(Stream true })?; if let Some(e) = error { - return Err(Failure::fatal(e)); + return Err(stream_failure(&e)); } out.blocks = blocks.into_iter().filter(|b| !b.is_null()).collect(); Ok(out) @@ -1258,6 +1272,14 @@ mod tests { } } + #[test] + fn a_busy_model_mid_stream_is_waited_out() { + let f = stream_failure("The model is currently at capacity due to high demand. Please try again in a few minutes"); + assert!(f.retryable && f.retry_after_ms == Some(BUSY_RETRY_MS) && f.msg.starts_with("模型供應商現在太忙"), "{}", f.msg); + let f = stream_failure("Invalid tool schema"); + assert!(!f.retryable && f.msg.starts_with("模型回報錯誤"), "{}", f.msg); + } + #[test] fn a_gateways_error_page_reads_as_words() { let page = "\n\n\n\n\nError response\n\n\n

Error response

\n

Error code: 502

\n

Message: Bad Gateway.

\n\n\n";