A turn cut off by a crash leaves a 「繼續」 card, and nothing it may have done runs twice
Each bot keeps a work log (oplog/bot-<id>.jsonl, after pi's durable-harness design, cut down): before something happens a line says it is about to — the turn, each model call, each step once its checks passed (with the arguments it runs with and whether it may run again), each message typed mid-turn — synced to disk first. When the turn ends the file shrinks to the messages still waiting, usually nothing. On the next start after AnyBot was killed mid-turn, the log puts the chat right before anything runs: the message the turn answered comes back if the chat lost it; a step that was running and reaches outside (a text, a click, a file, a published site) shows as 「可能已經執行了」, both to the person and in the result the bot reads — no longer as "not allowed"; a step that only looked shows as cut off; messages typed mid-turn that never reached the bot go back into the chat. The chat is saved, then the log closes the turn. A turn the person didn't stop ends with a 「繼續」 card (what it was doing, what may have run, the messages put back); it goes on only when they press it — in the window, on a screen of the brain, or on the phone — from where its steps stand, never running a step that may have run again. Pressing stop is on disk at once, and a stopped turn stays stopped. Also in the turn loop: - 自動審核 that doesn't answer (or answers something that isn't a verdict) asks the person; in 直接操作 mode it used to let the step through unchecked. - "Too long for this model" folds older messages into a summary and asks the same step again, once per thing the person said, instead of ending the turn. - Folding keeps the person's chat whole: the summary goes in at the fold and the model reads from the newest summary on (it used to delete what it folded). - A step moved to the chosen computer runs there, but the chat keeps the call as the model wrote it; the result says where it ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
This commit is contained in:
parent
cc41ca8ccb
commit
2f603c2362
|
|
@ -66,6 +66,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
|
||||||
| `brain-copy.json` | 螢幕電腦保留的大腦副本(離線時還能看) |
|
| `brain-copy.json` | 螢幕電腦保留的大腦副本(離線時還能看) |
|
||||||
| `brain-files/` | 螢幕電腦從大腦下載的附件 |
|
| `brain-files/` | 螢幕電腦從大腦下載的附件 |
|
||||||
| `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) |
|
| `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) |
|
||||||
|
| `oplog/bot-<id>.jsonl` | 每個 Bot 的工作紀錄:回合開始、每次問模型、每個步驟開始和結束、做事時傳的訊息。每行先寫進磁碟才做那件事;AnyBot 在做事時被關掉,下次打開靠它把聊天修好,並留一張「繼續」卡片(`src/oplog.rs`)。回合結束就清空 |
|
||||||
|
|
||||||
寫入時先寫暫存檔再改名,不會寫一半。
|
寫入時先寫暫存檔再改名,不會寫一半。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1019,7 +1019,7 @@ function renderMsgs() {
|
||||||
// is done; while it works, the newest three stay in view.
|
// is done; while it works, the newest three stay in view.
|
||||||
const turns = []; let curT = null;
|
const turns = []; let curT = null;
|
||||||
S.msgs.forEach((m, i) => {
|
S.msgs.forEach((m, i) => {
|
||||||
if (m.role === "user" || m.summary) { curT = null; return; }
|
if (m.role === "user" || m.summary || m.interrupted) { curT = null; return; }
|
||||||
if (!curT) { curT = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(curT); }
|
if (!curT) { curT = {first: i, last: i, key: String(m.at || i), steps: 0}; turns.push(curT); }
|
||||||
curT.last = i; curT.steps += (m.tools || []).filter((t) => !t.page && !t.card).length;
|
curT.last = i; curT.steps += (m.tools || []).filter((t) => !t.page && !t.card).length;
|
||||||
});
|
});
|
||||||
|
|
@ -1036,6 +1036,13 @@ function renderMsgs() {
|
||||||
h += `<div class="summary" data-sum="1"><b>${ic("history", 15)} 較早的對話已整理成摘要 ${ic("chevron-down", 14)}</b><div class="tx">${esc(m.text)}</div></div>`;
|
h += `<div class="summary" data-sum="1"><b>${ic("history", 15)} 較早的對話已整理成摘要 ${ic("chevron-down", 14)}</b><div class="tx">${esc(m.text)}</div></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Where AnyBot stopping cut a turn off: what it was doing, what may have run, and 「繼續」
|
||||||
|
// while it is the newest thing in the chat.
|
||||||
|
if (m.interrupted) {
|
||||||
|
const go = m === lastMsg && !busy && owner() ? `<button class="btn main sm" style="margin-top:8px" data-resume="1">繼續</button>` : "";
|
||||||
|
h += `<div class="summary open"><div class="tx">${esc(m.text)}</div>${go}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m.role === "user") {
|
if (m.role === "user") {
|
||||||
const tag = m.wake ? `<div class="tag">${ic("alarm-clock", 12)} ${esc(m.via || "例行任務")}</div>` : "";
|
const tag = m.wake ? `<div class="tag">${ic("alarm-clock", 12)} ${esc(m.via || "例行任務")}</div>` : "";
|
||||||
const q = String(m.text || "").match(/^((?:>.*\n?)+)\n*([\s\S]*)$/);
|
const q = String(m.text || "").match(/^((?:>.*\n?)+)\n*([\s\S]*)$/);
|
||||||
|
|
@ -1084,6 +1091,7 @@ $("msgs").addEventListener("click", async (e) => {
|
||||||
const c = e.target.closest("[data-copy]"); if (c) { copyText(S.msgs[+c.dataset.copy].text); return; }
|
const c = e.target.closest("[data-copy]"); if (c) { copyText(S.msgs[+c.dataset.copy].text); return; }
|
||||||
if (e.target.closest("[data-regen]")) { regen(); return; }
|
if (e.target.closest("[data-regen]")) { regen(); return; }
|
||||||
if (e.target.closest("[data-resend]")) { act("reply", {bot: S.open}); return; }
|
if (e.target.closest("[data-resend]")) { act("reply", {bot: S.open}); return; }
|
||||||
|
if (e.target.closest("[data-resume]")) { act("resume", {bot: S.open}, "繼續中…"); return; }
|
||||||
const mo = e.target.closest("[data-more]"); if (mo) { msgMenu(+mo.dataset.more); return; }
|
const mo = e.target.closest("[data-more]"); if (mo) { msgMenu(+mo.dataset.more); return; }
|
||||||
const r = e.target.closest("[data-react]"); if (r) { act("react", {bot: S.open, index: +r.dataset.i, emoji: r.dataset.react}, false).then(() => loadChat()); return; }
|
const r = e.target.closest("[data-react]"); if (r) { act("react", {bot: S.open, index: +r.dataset.i, emoji: r.dataset.react}, false).then(() => loadChat()); return; }
|
||||||
const sg = e.target.closest("[data-sugg]");
|
const sg = e.target.closest("[data-sugg]");
|
||||||
|
|
|
||||||
434
src/agent.rs
434
src/agent.rs
|
|
@ -10,7 +10,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use crate::appctl;
|
use crate::appctl;
|
||||||
use crate::computer;
|
use crate::computer;
|
||||||
use crate::model::{Message, Role, ToolStatus};
|
use crate::model::{Message, Role, ToolCall, ToolStatus};
|
||||||
use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, StreamEvent};
|
use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, StreamEvent};
|
||||||
use crate::tools::{self, Decision};
|
use crate::tools::{self, Decision};
|
||||||
use crate::web;
|
use crate::web;
|
||||||
|
|
@ -56,13 +56,69 @@ pub struct Job {
|
||||||
/// The bot's name, for the pop-up text.
|
/// The bot's name, for the pop-up text.
|
||||||
pub via_name: String,
|
pub via_name: String,
|
||||||
/// Messages the person sends while the turn runs (they steer it; no new turn).
|
/// Messages the person sends while the turn runs (they steer it; no new turn).
|
||||||
pub steer: Arc<Mutex<Vec<String>>>,
|
pub steer: Arc<Mutex<Vec<Queued>>>,
|
||||||
/// 自動審核 rules, when on.
|
/// 自動審核 rules, when on.
|
||||||
pub review: Option<Review>,
|
pub review: Option<Review>,
|
||||||
/// 示範教學 this bot has learned (for view_demo_step).
|
/// 示範教學 this bot has learned (for view_demo_step).
|
||||||
pub demos: Vec<crate::teach::Demo>,
|
pub demos: Vec<crate::teach::Demo>,
|
||||||
/// The skills there are (a longer job is looked back on against them; see learn).
|
/// The skills there are (a longer job is looked back on against them; see learn).
|
||||||
pub skills: Vec<crate::memory::Skill>,
|
pub skills: Vec<crate::memory::Skill>,
|
||||||
|
/// This turn's id: its lines in the bot's work log carry it.
|
||||||
|
pub op: String,
|
||||||
|
/// The bot's work log (see oplog.rs); None where nothing is kept (tests, the demo).
|
||||||
|
pub log: Option<crate::oplog::OpLog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
/// Write a line to the work log (on disk before this returns).
|
||||||
|
fn note(&self, r: crate::oplog::Record) {
|
||||||
|
if let Some(l) = &self.log {
|
||||||
|
l.append(&r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message the person typed while the bot works: its words, when it was typed, and the id its
|
||||||
|
/// work-log lines carry (so one typed just before a crash isn't lost).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Queued {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Queued {
|
||||||
|
pub fn new(text: impl Into<String>) -> Self {
|
||||||
|
Queued { id: crate::oplog::new_id(), text: text.into(), at: crate::model::now_ms() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turn's last line in the work log, written once: explicitly (before Done / Error reach the
|
||||||
|
/// window), else when the turn's thread ends any other way — stopped (aborted), or a panic
|
||||||
|
/// (failed).
|
||||||
|
struct Closing {
|
||||||
|
log: Option<crate::oplog::OpLog>,
|
||||||
|
op: String,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
written: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Closing {
|
||||||
|
fn close(&mut self, outcome: crate::oplog::Outcome) {
|
||||||
|
if !self.written {
|
||||||
|
self.written = true;
|
||||||
|
if let Some(l) = &self.log {
|
||||||
|
l.finish(&self.op, outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Closing {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let o = if self.cancel.load(Ordering::Relaxed) { crate::oplog::Outcome::Aborted } else { crate::oplog::Outcome::Failed };
|
||||||
|
self.close(o);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
|
|
@ -77,8 +133,14 @@ pub enum ReviewVerdict {
|
||||||
Allow(String),
|
Allow(String),
|
||||||
Ask(String),
|
Ask(String),
|
||||||
NoMatch,
|
NoMatch,
|
||||||
|
/// The reviewer didn't answer, or answered something that isn't a verdict: nobody checked the
|
||||||
|
/// rules, so this never counts as "no rule matched" (a check that breaks, blocks).
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Said in the question when the reviewer couldn't check a step.
|
||||||
|
pub const REVIEW_FAILED_NOTE: &str = "(自動審核沒有回應,所以先問你)";
|
||||||
|
|
||||||
/// Ask the model whether an action matches the person's rules. "Ask first" wins on conflict
|
/// Ask the model whether an action matches the person's rules. "Ask first" wins on conflict
|
||||||
/// (NFRGrQ: 規則衝突時,「先詢問」優先); built-in safety checks have already run before this.
|
/// (NFRGrQ: 規則衝突時,「先詢問」優先); built-in safety checks have already run before this.
|
||||||
fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) -> ReviewVerdict {
|
fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) -> ReviewVerdict {
|
||||||
|
|
@ -92,8 +154,13 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool)
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
..job.req.clone()
|
..job.req.clone()
|
||||||
};
|
};
|
||||||
let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::NoMatch };
|
let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::Failed };
|
||||||
let text = out.text.trim();
|
parse_verdict(&out.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reviewer's one line of JSON. Anything but a clear ask / allow / none is a failed review.
|
||||||
|
fn parse_verdict(text: &str) -> ReviewVerdict {
|
||||||
|
let text = text.trim();
|
||||||
// A truncated answer can put the last "}" before the first "{" — slicing then panics.
|
// A truncated answer can put the last "}" before the first "{" — slicing then panics.
|
||||||
let json_part = text.find('{').and_then(|a| text.rfind('}').filter(|b| *b >= a).map(|b| &text[a..=b])).unwrap_or(text);
|
let json_part = text.find('{').and_then(|a| text.rfind('}').filter(|b| *b >= a).map(|b| &text[a..=b])).unwrap_or(text);
|
||||||
let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default();
|
let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default();
|
||||||
|
|
@ -101,7 +168,8 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool)
|
||||||
match v["verdict"].as_str() {
|
match v["verdict"].as_str() {
|
||||||
Some("ask") => ReviewVerdict::Ask(rule),
|
Some("ask") => ReviewVerdict::Ask(rule),
|
||||||
Some("allow") => ReviewVerdict::Allow(rule),
|
Some("allow") => ReviewVerdict::Allow(rule),
|
||||||
_ => ReviewVerdict::NoMatch,
|
Some("none") => ReviewVerdict::NoMatch,
|
||||||
|
_ => ReviewVerdict::Failed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,12 +204,14 @@ fn learn(job: &Job, history: &[Message], cancel: &AtomicBool, send: &dyn Fn(Stre
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take what the person typed mid-turn and add it to the chat as steering.
|
/// Take what the person typed mid-turn and add it to the chat as steering. (The window marks it
|
||||||
|
/// delivered in the work log once the chat holding it is saved; the message keeps the time it was
|
||||||
|
/// typed, which is how a restart tells it is already there.)
|
||||||
fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)) -> bool {
|
fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)) -> bool {
|
||||||
let queued: Vec<String> = std::mem::take(&mut *job.steer.lock().unwrap());
|
let queued: Vec<Queued> = std::mem::take(&mut *job.steer.lock().unwrap());
|
||||||
for t in &queued {
|
for q in &queued {
|
||||||
let m = Message { steer: true, ..Message::user(t) };
|
let m = Message { steer: true, at: q.at, ..Message::user(&q.text) };
|
||||||
send(StreamEvent::Steered(m.clone()));
|
send(StreamEvent::Steered(m.clone(), q.id.clone()));
|
||||||
history.push(m);
|
history.push(m);
|
||||||
}
|
}
|
||||||
!queued.is_empty()
|
!queued.is_empty()
|
||||||
|
|
@ -603,6 +673,7 @@ fn maybe_compact(job: &Job, history: &mut Vec<Message>, percent: u64, cancel: &A
|
||||||
send(StreamEvent::Alive);
|
send(StreamEvent::Alive);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
job.note(crate::oplog::Record::StepAttempt { op: job.op.clone(), step: "compaction".into(), attempt: 1 });
|
||||||
let out = providers::run_step(&req, cancel, &quiet).ok()?;
|
let out = providers::run_step(&req, cancel, &quiet).ok()?;
|
||||||
if out.text.trim().is_empty() || cancel.load(Ordering::Relaxed) {
|
if out.text.trim().is_empty() || cancel.load(Ordering::Relaxed) {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -645,11 +716,24 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let mut tried: Vec<String> = Vec::new();
|
let mut tried: Vec<String> = Vec::new();
|
||||||
let mut local_on = job.local_execution;
|
let mut local_on = job.local_execution;
|
||||||
let mut browsers = UsedBrowsers::new(&job.via_name, bot_id);
|
let mut browsers = UsedBrowsers::new(&job.via_name, bot_id);
|
||||||
|
// The turn's first line in the work log, and (whatever happens) its last.
|
||||||
|
job.note(crate::oplog::Record::OperationStarted {
|
||||||
|
op: job.op.clone(),
|
||||||
|
at: crate::model::now_ms(),
|
||||||
|
source_leaf: history.last().map(|m| m.at).unwrap_or(0),
|
||||||
|
prompt: history.last().filter(|m| m.role == Role::User).map(|m| crate::oplog::Prompt { at: m.at, text: m.text.clone() }),
|
||||||
|
});
|
||||||
|
let mut closing = Closing { log: job.log.clone(), op: job.op.clone(), cancel: cancel.clone(), written: false };
|
||||||
|
// Folding the chat because the model said it was too long: once per thing the person
|
||||||
|
// said (a second time means folding didn't help).
|
||||||
|
let mut overflow_folded = false;
|
||||||
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
||||||
for step in 0..MAX_STEPS {
|
for step in 0..MAX_STEPS {
|
||||||
if step > 0 {
|
if step > 0 {
|
||||||
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send);
|
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send);
|
||||||
drain_steer(&job, &mut history, &send);
|
if drain_steer(&job, &mut history, &send) {
|
||||||
|
overflow_folded = false;
|
||||||
|
}
|
||||||
check_in(&mut history, step);
|
check_in(&mut history, step);
|
||||||
if let Some(note) = refresh_own(&mut job, bot_id) {
|
if let Some(note) = refresh_own(&mut job, bot_id) {
|
||||||
if let Some(m) = history.last_mut().filter(|m| m.role == Role::Tool) {
|
if let Some(m) = history.last_mut().filter(|m| m.role == Role::Tool) {
|
||||||
|
|
@ -658,25 +742,44 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
send(StreamEvent::NextStep(job.via.clone()));
|
send(StreamEvent::NextStep(job.via.clone()));
|
||||||
}
|
}
|
||||||
|
let mut attempt = 0;
|
||||||
|
let out = loop {
|
||||||
|
attempt += 1;
|
||||||
|
job.note(crate::oplog::Record::StepAttempt { op: job.op.clone(), step: "assistant".into(), attempt });
|
||||||
let req = ChatRequest { history: history.clone(), ..job.req.clone() };
|
let req = ChatRequest { history: history.clone(), ..job.req.clone() };
|
||||||
let out = match providers::run_step(&req, &cancel, &send) {
|
match providers::run_step(&req, &cancel, &send) {
|
||||||
Ok(o) => o,
|
Ok(o) => break o,
|
||||||
Err(e) => {
|
// Too long for the model: that answer is thrown away, older messages are
|
||||||
|
// folded into a summary (however full the chat looks by our estimate), and
|
||||||
|
// the same step is asked again.
|
||||||
|
Err(e) if providers::is_context_overflow(&e) && !overflow_folded && !cancel.load(Ordering::Relaxed) => {
|
||||||
|
overflow_folded = true;
|
||||||
|
if maybe_compact(&job, &mut history, 0, &cancel, &send).is_none() {
|
||||||
|
closing.close(crate::oplog::Outcome::Failed);
|
||||||
send(StreamEvent::Error(e));
|
send(StreamEvent::Error(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let e = if providers::is_context_overflow(&e) && overflow_folded { format!("{e}\n(已經把較早的對話整理成摘要再試過一次,還是太長。)") } else { e };
|
||||||
|
closing.close(crate::oplog::Outcome::Failed);
|
||||||
|
send(StreamEvent::Error(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if cancel.load(Ordering::Relaxed) {
|
if cancel.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut out = out;
|
// Steps moved back to the chosen computer: what the bot is told with each result, and
|
||||||
// Steps moved back to the chosen computer: what the bot is told with each result.
|
// the arguments they run with. The chat keeps the call as the model wrote it.
|
||||||
let mut moved: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
let mut moved: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
for c in out.tool_calls.iter_mut() {
|
let mut runs_with: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
|
for c in out.tool_calls.iter() {
|
||||||
let (args, note) = keep_to_chosen(&job, &history, &c.name, &c.args);
|
let (args, note) = keep_to_chosen(&job, &history, &c.name, &c.args);
|
||||||
c.args = args;
|
|
||||||
if let Some(n) = note {
|
if let Some(n) = note {
|
||||||
moved.insert(c.id.clone(), n);
|
moved.insert(c.id.clone(), n);
|
||||||
|
runs_with.insert(c.id.clone(), args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = Message {
|
let msg = Message {
|
||||||
|
|
@ -695,6 +798,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
if calls.is_empty() {
|
if calls.is_empty() {
|
||||||
// Something typed while it answered keeps the same turn going.
|
// Something typed while it answered keeps the same turn going.
|
||||||
if job.steer.lock().unwrap().is_empty() {
|
if job.steer.lock().unwrap().is_empty() {
|
||||||
|
closing.close(crate::oplog::Outcome::Completed);
|
||||||
send(StreamEvent::Done);
|
send(StreamEvent::Done);
|
||||||
browsers.close_tabs();
|
browsers.close_tabs();
|
||||||
learn(&job, &history, &cancel, &send);
|
learn(&job, &history, &cancel, &send);
|
||||||
|
|
@ -702,13 +806,15 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// (Each call's computer was already kept to the chosen one, see keep_to_chosen.)
|
for asked in calls {
|
||||||
for call in calls {
|
// What runs: the call as asked, on the chosen computer (see keep_to_chosen).
|
||||||
|
let call = ToolCall { args: runs_with.get(&asked.id).cloned().unwrap_or_else(|| asked.args.clone()), ..asked.clone() };
|
||||||
// 鬼打牆: the same screen or browser step again and again (a click on the same
|
// 鬼打牆: the same screen or browser step again and again (a click on the same
|
||||||
// empty spot ten times). The third time it runs with a warning; after that it isn't
|
// empty spot ten times). The third time it runs with a warning; after that it isn't
|
||||||
// run at all, and the bot is told to change course.
|
// run at all, and the bot is told to change course. (Counted as the model asked,
|
||||||
|
// which is how the chat keeps them.)
|
||||||
let mut repeat_note = String::new();
|
let mut repeat_note = String::new();
|
||||||
if let Some(key) = step_key(&call.name, &call.args) {
|
if let Some(key) = step_key(&asked.name, &asked.args) {
|
||||||
let times = tried.iter().rev().take(10).filter(|k| **k == key).count() + 1;
|
let times = tried.iter().rev().take(10).filter(|k| **k == key).count() + 1;
|
||||||
tried.push(key);
|
tried.push(key);
|
||||||
if times >= 4 {
|
if times >= 4 {
|
||||||
|
|
@ -726,7 +832,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// The same look again and again with the same answer (a blank tab read eleven
|
// The same look again and again with the same answer (a blank tab read eleven
|
||||||
// times in a row): the third time with a warning, after that not at all.
|
// times in a row): the third time with a warning, after that not at all.
|
||||||
if repeat_note.is_empty() {
|
if repeat_note.is_empty() {
|
||||||
let same = same_answers(&history, &call.name, &call.args);
|
let same = same_answers(&history, &asked.name, &asked.args);
|
||||||
if same >= 3 {
|
if same >= 3 {
|
||||||
let text = format!("沒有執行:同一個工具、同樣的參數你已經做了 {same} 次,每次結果都一模一樣,再做也不會不同。換一個做法(例如用 browser_open 打開要看的網址),或直接告訴使用者你看到了什麼。");
|
let text = format!("沒有執行:同一個工具、同樣的參數你已經做了 {same} 次,每次結果都一模一樣,再做也不會不同。換一個做法(例如用 browser_open 打開要看的網址),或直接告訴使用者你看到了什麼。");
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Failed));
|
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Failed));
|
||||||
|
|
@ -742,15 +848,22 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// Every step ends the same way: its status, then its result (with the notes) and
|
// Every step ends the same way: its status, then its result (with the notes) and
|
||||||
// any picture, shown and kept.
|
// any picture, shown and kept.
|
||||||
let finish = |history: &mut Vec<Message>, output: &str, status: ToolStatus, image: Option<String>| {
|
let finish = |history: &mut Vec<Message>, output: &str, status: ToolStatus, image: Option<String>| {
|
||||||
|
job.note(crate::oplog::Record::ToolFinished { op: job.op.clone(), call_id: call.id.clone(), status: format!("{status:?}").to_lowercase() });
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), status));
|
send(StreamEvent::ToolStatus(call.id.clone(), status));
|
||||||
let mut result = Message::tool_result(&call.id, &call.name, &format!("{repeat_note}{}", noted(&moved, &call.id, output)));
|
let mut result = Message::tool_result(&call.id, &call.name, &format!("{repeat_note}{}", noted(&moved, &call.id, output)));
|
||||||
result.images.extend(image);
|
result.images.extend(image);
|
||||||
send(StreamEvent::ToolResult(result.clone()));
|
send(StreamEvent::ToolResult(result.clone()));
|
||||||
history.push(result);
|
history.push(result);
|
||||||
};
|
};
|
||||||
|
// About to run, every check passed: the work log hears it first, with the
|
||||||
|
// arguments it runs with and whether it may run again after a crash.
|
||||||
|
let started = || {
|
||||||
|
job.note(crate::oplog::Record::ToolStarted { op: job.op.clone(), call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), replay: crate::oplog::replay_of(&call.name) });
|
||||||
|
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
||||||
|
};
|
||||||
if appctl::is_app_tool(&call.name) {
|
if appctl::is_app_tool(&call.name) {
|
||||||
// App actions run on the window's side, which also asks for deletions.
|
// App actions run on the window's side, which also asks for deletions.
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let (rtx, rrx) = channel();
|
let (rtx, rrx) = channel();
|
||||||
send(StreamEvent::AppAction(AppCall { call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), reply: rtx }));
|
send(StreamEvent::AppAction(AppCall { call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), reply: rtx }));
|
||||||
let (output, ok) = loop {
|
let (output, ok) = loop {
|
||||||
|
|
@ -768,7 +881,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
// The person's own recording, shown back to the bot: nothing to ask.
|
// The person's own recording, shown back to the bot: nothing to ask.
|
||||||
if call.name == "view_demo_step" {
|
if call.name == "view_demo_step" {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
|
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
|
||||||
finish(&mut history, &output, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img);
|
finish(&mut history, &output, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -777,7 +890,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// …and neither does AnyBot's own browser (like Grok Bot's own browser).
|
// …and neither does AnyBot's own browser (like Grok Bot's own browser).
|
||||||
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
|
||||||
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
if call.name != "web_fetch" {
|
if call.name != "web_fetch" {
|
||||||
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
||||||
browsers.add(match &place {
|
browsers.add(match &place {
|
||||||
|
|
@ -874,7 +987,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
// 傳檔案: put a file the bot made into the chat as a card the person can open.
|
// 傳檔案: put a file the bot made into the chat as a card the person can open.
|
||||||
if call.name == "send_file" {
|
if call.name == "send_file" {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
|
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
|
||||||
let local = match target_for(&job, &call.args) {
|
let local = match target_for(&job, &call.args) {
|
||||||
// Files ride to the chat only from this bot's own folder on the box.
|
// Files ride to the chat only from this bot's own folder on the box.
|
||||||
|
|
@ -918,7 +1031,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
// 檔案預覽 / Bot 做的頁面: open what the bot made on the person's screen.
|
// 檔案預覽 / Bot 做的頁面: open what the bot made on the person's screen.
|
||||||
if call.name == "open_for_person" {
|
if call.name == "open_for_person" {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let v = tools::args_of(&call.args);
|
let v = tools::args_of(&call.args);
|
||||||
let path = v["path"].as_str().unwrap_or("").to_string();
|
let path = v["path"].as_str().unwrap_or("").to_string();
|
||||||
let on_box = match target_for(&job, &call.args) {
|
let on_box = match target_for(&job, &call.args) {
|
||||||
|
|
@ -979,7 +1092,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let (output, status) = if answer == ApprovalAnswer::Reject {
|
let (output, status) = if answer == ApprovalAnswer::Reject {
|
||||||
("使用者沒有允許。請不要再試,改問使用者。".to_string(), ToolStatus::Denied)
|
("使用者沒有允許。請不要再試,改問使用者。".to_string(), ToolStatus::Denied)
|
||||||
} else {
|
} else {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
match crate::phone::run(&call.name, &call.args) {
|
match crate::phone::run(&call.name, &call.args) {
|
||||||
(t, true) => (t, ToolStatus::Done),
|
(t, true) => (t, ToolStatus::Done),
|
||||||
(t, false) => (t, ToolStatus::Failed),
|
(t, false) => (t, ToolStatus::Failed),
|
||||||
|
|
@ -1011,7 +1124,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let (output, status) = if !allowed {
|
let (output, status) = if !allowed {
|
||||||
("使用者沒有允許發布。".to_string(), ToolStatus::Denied)
|
("使用者沒有允許發布。".to_string(), ToolStatus::Denied)
|
||||||
} else {
|
} else {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let local = match target_for(&job, &call.args) {
|
let local = match target_for(&job, &call.args) {
|
||||||
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
|
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
|
||||||
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
|
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
|
||||||
|
|
@ -1072,7 +1185,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
if !allowed {
|
if !allowed {
|
||||||
("使用者沒有允許使用保險庫的這個項目。請改問使用者。".to_string(), ToolStatus::Denied)
|
("使用者沒有允許使用保險庫的這個項目。請改問使用者。".to_string(), ToolStatus::Denied)
|
||||||
} else {
|
} else {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let r = match plan {
|
let r = match plan {
|
||||||
Plan::Login(pl) => crate::vault::fill_login(&pl, tools::args_of(&call.args)["submit"].as_bool().unwrap_or(false)),
|
Plan::Login(pl) => crate::vault::fill_login(&pl, tools::args_of(&call.args)["submit"].as_bool().unwrap_or(false)),
|
||||||
Plan::Card => crate::vault::fill_card(&call.args),
|
Plan::Card => crate::vault::fill_card(&call.args),
|
||||||
|
|
@ -1109,7 +1222,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (output, status, img) = if allowed {
|
let (output, status, img) = if allowed {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let (o, ok, img) = crate::mcp::call(&call.name, &call.args);
|
let (o, ok, img) = crate::mcp::call(&call.name, &call.args);
|
||||||
(o, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img)
|
(o, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1268,6 +1381,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
};
|
};
|
||||||
match verdict {
|
match verdict {
|
||||||
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
|
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
|
||||||
|
// Nobody could check the person's "always ask" rules: ask, never go ahead.
|
||||||
|
ReviewVerdict::Failed => Decision::Ask { reason: format!("{reason}{REVIEW_FAILED_NOTE}"), grant: String::new() },
|
||||||
// (No note on the result: the model doesn't need it, and it was on every step.)
|
// (No note on the result: the model doesn't need it, and it was on every step.)
|
||||||
_ => {
|
_ => {
|
||||||
let _ = grant;
|
let _ = grant;
|
||||||
|
|
@ -1282,6 +1397,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
|
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
|
||||||
ReviewVerdict::NoMatch => Decision::Ask { reason, grant },
|
ReviewVerdict::NoMatch => Decision::Ask { reason, grant },
|
||||||
|
ReviewVerdict::Failed => Decision::Ask { reason: format!("{reason}{REVIEW_FAILED_NOTE}"), grant },
|
||||||
},
|
},
|
||||||
(d, _) => d,
|
(d, _) => d,
|
||||||
};
|
};
|
||||||
|
|
@ -1318,7 +1434,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let mut image = None;
|
let mut image = None;
|
||||||
let (output, status) = match (approved, &target) {
|
let (output, status) = match (approved, &target) {
|
||||||
(Ok(()), Ok(t)) => {
|
(Ok(()), Ok(t)) => {
|
||||||
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
|
started();
|
||||||
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
|
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
|
||||||
let (out, ok) = if is_mac && on_device.is_none() {
|
let (out, ok) = if is_mac && on_device.is_none() {
|
||||||
crate::macapps::run(&call.name, &call.args)
|
crate::macapps::run(&call.name, &call.args)
|
||||||
|
|
@ -1370,6 +1486,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
}
|
}
|
||||||
send(StreamEvent::NextStep(job.via.clone()));
|
send(StreamEvent::NextStep(job.via.clone()));
|
||||||
send(StreamEvent::StepDone(Message::assistant(&format!("(這次已經用了 {MAX_STEPS} 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)"), &job.via)));
|
send(StreamEvent::StepDone(Message::assistant(&format!("(這次已經用了 {MAX_STEPS} 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)"), &job.via)));
|
||||||
|
closing.close(crate::oplog::Outcome::Completed);
|
||||||
send(StreamEvent::Done);
|
send(StreamEvent::Done);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1517,8 @@ mod tests {
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
|
op: String::new(),
|
||||||
|
log: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1982,6 +2101,8 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
|
op: String::new(),
|
||||||
|
log: None,
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
|
@ -2037,7 +2158,7 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
let _ = s.write_all(body.as_bytes());
|
let _ = s.write_all(body.as_bytes());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let steer = Arc::new(Mutex::new(vec!["順便用英文".to_string()]));
|
let steer = Arc::new(Mutex::new(vec![Queued::new("順便用英文")]));
|
||||||
let job = Job {
|
let job = Job {
|
||||||
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
|
||||||
computers: vec![],
|
computers: vec![],
|
||||||
|
|
@ -2055,13 +2176,15 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
|
op: String::new(),
|
||||||
|
log: None,
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
let (mut steered, mut done, mut texts) = (0, 0, vec![]);
|
let (mut steered, mut done, mut texts) = (0, 0, vec![]);
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
|
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
|
||||||
(_, StreamEvent::Steered(m)) => {
|
(_, StreamEvent::Steered(m, _)) => {
|
||||||
assert!(m.steer);
|
assert!(m.steer);
|
||||||
steered += 1;
|
steered += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -2134,6 +2257,8 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
|
op: String::new(),
|
||||||
|
log: None,
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
|
@ -2274,6 +2399,8 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
review: None,
|
review: None,
|
||||||
demos: vec![],
|
demos: vec![],
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
|
op: String::new(),
|
||||||
|
log: None,
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
|
@ -2295,4 +2422,241 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
assert!(w.join("made.txt").exists());
|
assert!(w.join("made.txt").exists());
|
||||||
assert!(results[1].contains("一律不允許"), "{results:?}");
|
assert!(results[1].contains("一律不允許"), "{results:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A made-up model that answers each request with the next (HTTP status, body) and keeps
|
||||||
|
/// what each request said.
|
||||||
|
fn fake_http(replies: Vec<(u16, String)>) -> (u16, Arc<Mutex<Vec<String>>>) {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
let seen = Arc::new(Mutex::new(vec![]));
|
||||||
|
let seen2 = seen.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for (status, body) in replies {
|
||||||
|
let Ok((mut s, _)) = l.accept() else { return };
|
||||||
|
let mut req = Vec::new();
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = s.read(&mut buf).unwrap_or(0);
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
req.extend_from_slice(&buf[..n]);
|
||||||
|
let t = String::from_utf8_lossy(&req).to_string();
|
||||||
|
if let Some(end) = t.find("\r\n\r\n") {
|
||||||
|
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
|
||||||
|
if req.len() >= end + 4 + len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen2.lock().unwrap().push(String::from_utf8_lossy(&req).to_string());
|
||||||
|
let head = if status == 200 { "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n".to_string() } else { format!("HTTP/1.1 {status} Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()) };
|
||||||
|
let _ = s.write_all(head.as_bytes());
|
||||||
|
let _ = s.write_all(body.as_bytes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(port, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_text(t: &str) -> (u16, String) {
|
||||||
|
(200, format!("data: {}\n\ndata: [DONE]\n\n", serde_json::json!({"choices": [{"delta": {"content": t}}]})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_call(id: &str, name: &str, args: serde_json::Value) -> (u16, String) {
|
||||||
|
(200, format!("data: {}\n\ndata: [DONE]\n\n", serde_json::json!({"choices": [{"delta": {"tool_calls": [{"index": 0, "id": id, "function": {"name": name, "arguments": args.to_string()}}]}}]})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERFLOW_BODY: &str = r#"{"error":{"message":"This model's maximum context length is 8192 tokens. However, your messages resulted in 9000 tokens."}}"#;
|
||||||
|
|
||||||
|
/// A long chat: enough older messages for a fold to have something to fold.
|
||||||
|
fn long_chat() -> Vec<Message> {
|
||||||
|
let mut h = vec![];
|
||||||
|
for i in 0..6 {
|
||||||
|
h.push(Message { at: i * 2 + 1, ..Message::user(&format!("第 {i} 個問題")) });
|
||||||
|
h.push(Message { at: i * 2 + 2, ..Message::assistant(&format!("第 {i} 個回答"), "test") });
|
||||||
|
}
|
||||||
|
h.push(Message { at: 100, ..Message::user("現在呢?") });
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a job to its end, collecting what came back. Approvals get `answer`.
|
||||||
|
fn drive(job: Job, answer: ApprovalAnswer) -> (Vec<StreamEvent>, Vec<String>) {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
|
||||||
|
let mut evs = vec![];
|
||||||
|
let mut asked = vec![];
|
||||||
|
loop {
|
||||||
|
let (_, ev) = rx.recv_timeout(Duration::from_secs(15)).expect("loop stalled");
|
||||||
|
match ev {
|
||||||
|
StreamEvent::NeedApproval(a) => {
|
||||||
|
asked.push(a.reason.clone());
|
||||||
|
let _ = a.reply.send(answer.clone());
|
||||||
|
}
|
||||||
|
StreamEvent::Done | StreamEvent::Error(_) => {
|
||||||
|
evs.push(ev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ev => evs.push(ev),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(evs, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_verdict_is_clear_or_the_review_failed() {
|
||||||
|
assert!(matches!(parse_verdict(r#"{"verdict":"ask","rule":"刪除前先問"}"#), ReviewVerdict::Ask(r) if r == "刪除前先問"));
|
||||||
|
assert!(matches!(parse_verdict(r#"好的 {"verdict":"allow","rule":"讀檔"}"#), ReviewVerdict::Allow(_)));
|
||||||
|
assert!(matches!(parse_verdict(r#"{"verdict":"none","rule":""}"#), ReviewVerdict::NoMatch));
|
||||||
|
for broken in ["", "嗯…讓我想想", r#"{"verdict":"maybe"}"#, r#"} {"#] {
|
||||||
|
assert!(matches!(parse_verdict(broken), ReviewVerdict::Failed), "{broken}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 直接操作 with an 「一律詢問」 rule: when the reviewer doesn't give a verdict, the step is
|
||||||
|
/// asked about, never run unchecked.
|
||||||
|
#[test]
|
||||||
|
fn a_review_that_fails_asks_instead_of_allowing() {
|
||||||
|
let (port, _) = fake_http(vec![
|
||||||
|
sse_call("c1", "shell", serde_json::json!({"command": "echo hi > made.txt", "computer": crate::model::THIS_COMPUTER_NAME})),
|
||||||
|
sse_text("嗯…讓我想想"),
|
||||||
|
sse_text("好,我不做了"),
|
||||||
|
]);
|
||||||
|
let w = std::env::temp_dir().join(format!("anybot-review-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
std::fs::create_dir_all(&w).unwrap();
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
|
||||||
|
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
|
||||||
|
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
|
||||||
|
direct: Arc::new(AtomicBool::new(true)),
|
||||||
|
review: Some(Review { allow: vec![], ask: vec!["刪除或修改任何檔案前先問我".into()] }),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (evs, asked) = drive(job, ApprovalAnswer::Reject);
|
||||||
|
assert_eq!(asked.len(), 1, "{asked:?}");
|
||||||
|
assert!(asked[0].contains(REVIEW_FAILED_NOTE), "{}", asked[0]);
|
||||||
|
assert!(!w.join("made.txt").exists());
|
||||||
|
assert!(matches!(evs.last(), Some(StreamEvent::Done)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The model says the chat is too long: older messages are folded into a summary and the same
|
||||||
|
/// step is asked again (with the summary), instead of the turn failing.
|
||||||
|
#[test]
|
||||||
|
fn an_overflow_folds_the_chat_and_asks_again() {
|
||||||
|
let (port, seen) = fake_http(vec![(400, OVERFLOW_BODY.into()), sse_text("摘要:問過六個問題"), sse_text("好了")]);
|
||||||
|
let job = Job { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: long_chat(), tools: vec![], session: String::new() }, ..test_job() };
|
||||||
|
let (evs, _) = drive(job, ApprovalAnswer::Reject);
|
||||||
|
assert!(matches!(evs.last(), Some(StreamEvent::Done)), "{:?}", evs.iter().filter_map(|e| if let StreamEvent::Error(e) = e { Some(e.clone()) } else { None }).collect::<Vec<_>>());
|
||||||
|
assert!(evs.iter().any(|e| matches!(e, StreamEvent::Compacted(..))));
|
||||||
|
let texts: Vec<String> = evs.iter().filter_map(|e| if let StreamEvent::StepDone(m) = e { Some(m.text.clone()) } else { None }).collect();
|
||||||
|
assert_eq!(texts, vec!["好了"]);
|
||||||
|
let seen = seen.lock().unwrap();
|
||||||
|
assert_eq!(seen.len(), 3);
|
||||||
|
assert!(seen[2].contains("摘要:問過六個問題") && !seen[2].contains("第 0 個問題"), "{}", seen[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Still too long after folding once: the turn ends with a clear error (no loop of folds).
|
||||||
|
#[test]
|
||||||
|
fn a_second_overflow_ends_the_turn() {
|
||||||
|
let (port, seen) = fake_http(vec![(400, OVERFLOW_BODY.into()), sse_text("摘要"), (400, OVERFLOW_BODY.into()), sse_text("不該問到這裡")]);
|
||||||
|
let job = Job { req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: long_chat(), tools: vec![], session: String::new() }, ..test_job() };
|
||||||
|
let (evs, _) = drive(job, ApprovalAnswer::Reject);
|
||||||
|
match evs.last() {
|
||||||
|
Some(StreamEvent::Error(e)) => assert!(providers::is_context_overflow(e) && e.contains("還是太長"), "{e}"),
|
||||||
|
_ => panic!("expected an error"),
|
||||||
|
}
|
||||||
|
assert_eq!(seen.lock().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The work log while a turn runs: the turn and its model call are written first; a step
|
||||||
|
/// waiting for the person isn't "started"; once allowed it is (with the arguments it runs
|
||||||
|
/// with), then finished; a finished turn leaves no file. A stopped turn ends as stopped.
|
||||||
|
#[test]
|
||||||
|
fn the_work_log_follows_the_turn() {
|
||||||
|
use crate::oplog::{reduce, OpLog};
|
||||||
|
let w = std::env::temp_dir().join(format!("anybot-oplog-turn-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&w);
|
||||||
|
std::fs::create_dir_all(&w).unwrap();
|
||||||
|
std::fs::write(w.join("a.txt"), "hi").unwrap();
|
||||||
|
let path = w.join("log.jsonl");
|
||||||
|
for stop in [false, true] {
|
||||||
|
let (port, _) = fake_http(vec![sse_call("c1", "shell", serde_json::json!({"command": "echo hi > b.txt"})), sse_text("做好了")]);
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message { at: 42, ..Message::user("做 b.txt") }], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
|
||||||
|
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
|
||||||
|
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
|
||||||
|
local_execution: true,
|
||||||
|
op: "turn-test".into(),
|
||||||
|
log: Some(OpLog::at(path.clone())),
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let read = || reduce(&OpLog::at(path.clone()).read());
|
||||||
|
let raw = || std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
spawn(job, 1, tx, cancel.clone(), eframe::egui::Context::default());
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(15)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::NeedApproval(a)) => {
|
||||||
|
let open = read().open.expect("the turn is open");
|
||||||
|
assert_eq!((open.op.as_str(), open.prompt.map(|p| p.at)), ("turn-test", Some(42)));
|
||||||
|
assert!(open.started.is_empty(), "waiting for the person is not started");
|
||||||
|
assert!(raw().contains("\"step_attempt\""));
|
||||||
|
if stop {
|
||||||
|
cancel.store(true, Ordering::Relaxed);
|
||||||
|
drop(a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = a.reply.send(ApprovalAnswer::Once);
|
||||||
|
}
|
||||||
|
(_, StreamEvent::ToolResult(_)) => {
|
||||||
|
let open = read().open.expect("the turn is still open");
|
||||||
|
assert_eq!(open.started.len(), 1);
|
||||||
|
assert!(open.started[0].finished && open.started[0].replay == crate::oplog::Replay::Never);
|
||||||
|
assert!(raw().contains("echo hi > b.txt"));
|
||||||
|
}
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop {
|
||||||
|
// The thread winds down on its own; its last line says stopped.
|
||||||
|
let until = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while path.exists() && std::time::Instant::now() < until {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!path.exists(), "a closed turn leaves nothing behind: {}", raw());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A step moved to the chosen computer runs there, but the chat keeps the call as the model
|
||||||
|
/// wrote it; the result says where it ran.
|
||||||
|
#[test]
|
||||||
|
fn a_moved_step_is_kept_as_the_model_asked() {
|
||||||
|
let (port, _) = fake_http(vec![sse_call("c1", "list_dir", serde_json::json!({"computer": "王小明的Mac mini"})), sse_text("好")]);
|
||||||
|
let w = std::env::temp_dir().join(format!("anybot-moved-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&w).unwrap();
|
||||||
|
std::fs::write(w.join("here.txt"), "hi").unwrap();
|
||||||
|
let job = Job {
|
||||||
|
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() },
|
||||||
|
computers: vec![
|
||||||
|
tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } },
|
||||||
|
tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } },
|
||||||
|
],
|
||||||
|
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
|
||||||
|
roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false },
|
||||||
|
local_execution: true,
|
||||||
|
..test_job()
|
||||||
|
};
|
||||||
|
let (evs, _) = drive(job, ApprovalAnswer::Once);
|
||||||
|
let asked = evs.iter().find_map(|e| if let StreamEvent::StepDone(m) = e { m.tool_calls.first().cloned() } else { None }).unwrap();
|
||||||
|
assert!(asked.args.contains("王小明的Mac mini"), "{}", asked.args);
|
||||||
|
let result = evs.iter().find_map(|e| if let StreamEvent::ToolResult(m) = e { Some(m.text.clone()) } else { None }).unwrap();
|
||||||
|
assert!(result.contains("這一步你指定了「王小明的Mac mini」") && result.contains("here.txt"), "{result}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
src/app.rs
154
src/app.rs
|
|
@ -114,7 +114,14 @@ pub struct App {
|
||||||
/// App actions (e.g. deleting a routine) waiting for that yes/no.
|
/// App actions (e.g. deleting a routine) waiting for that yes/no.
|
||||||
app_pending: HashMap<u64, AppCall>,
|
app_pending: HashMap<u64, AppCall>,
|
||||||
/// Messages that arrived as a turn ended: they start the next turn.
|
/// Messages that arrived as a turn ended: they start the next turn.
|
||||||
pending_turns: Vec<(u64, Vec<String>)>,
|
pending_turns: Vec<(u64, Vec<agent::Queued>)>,
|
||||||
|
/// Typed messages now in a chat (bot, id): the work log hears they were delivered once the
|
||||||
|
/// chat holding them is saved.
|
||||||
|
delivered: Vec<(u64, String)>,
|
||||||
|
/// Save at the next frame (a typed message just entered a chat), not up to 2 s later.
|
||||||
|
save_now: bool,
|
||||||
|
/// Turns that AnyBot stopping cut off, found at start (see resume_interrupted).
|
||||||
|
interrupted: Vec<crate::oplog::Interrupted>,
|
||||||
last_tick: Instant,
|
last_tick: Instant,
|
||||||
/// The routines list for this bot is open.
|
/// The routines list for this bot is open.
|
||||||
show_routines: Option<u64>,
|
show_routines: Option<u64>,
|
||||||
|
|
@ -218,7 +225,19 @@ impl App {
|
||||||
pub fn new(ctx: &egui::Context, screenshot: Option<PathBuf>) -> Self {
|
pub fn new(ctx: &egui::Context, screenshot: Option<PathBuf>) -> Self {
|
||||||
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
||||||
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
||||||
let state = if demo { State::demo() } else { State::load() };
|
let mut state = if demo { State::demo() } else { State::load() };
|
||||||
|
// After a crash: the work log puts the chats right (what was running, what was typed),
|
||||||
|
// the chats are saved, then the log closes the cut-off turns. Not for a screenshot run of
|
||||||
|
// the real data: that is a second copy next to the one that owns the log.
|
||||||
|
let interrupted = if demo || screenshot.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
let found = crate::oplog::recover(&mut state);
|
||||||
|
if found.changed {
|
||||||
|
state.save();
|
||||||
|
}
|
||||||
|
found.close()
|
||||||
|
};
|
||||||
crate::routines::set_person_timezone(&state.timezone);
|
crate::routines::set_person_timezone(&state.timezone);
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
|
|
@ -235,6 +254,9 @@ impl App {
|
||||||
approvals: HashMap::new(),
|
approvals: HashMap::new(),
|
||||||
app_pending: HashMap::new(),
|
app_pending: HashMap::new(),
|
||||||
pending_turns: vec![],
|
pending_turns: vec![],
|
||||||
|
delivered: vec![],
|
||||||
|
save_now: false,
|
||||||
|
interrupted,
|
||||||
last_tick: Instant::now(),
|
last_tick: Instant::now(),
|
||||||
show_routines: None,
|
show_routines: None,
|
||||||
new_remote: Default::default(),
|
new_remote: Default::default(),
|
||||||
|
|
@ -330,7 +352,7 @@ impl App {
|
||||||
produced: true,
|
produced: true,
|
||||||
status: None,
|
status: None,
|
||||||
paused: true,
|
paused: true,
|
||||||
steer: Arc::new(Mutex::new(vec!["2026-06 的照片先不要動".into()])),
|
steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])),
|
||||||
direct: Arc::new(AtomicBool::new(false)),
|
direct: Arc::new(AtomicBool::new(false)),
|
||||||
turn_id: "demo".into(),
|
turn_id: "demo".into(),
|
||||||
});
|
});
|
||||||
|
|
@ -671,9 +693,9 @@ impl App {
|
||||||
self.typed_on = Some(String::new());
|
self.typed_on = Some(String::new());
|
||||||
// While it works, a new message steers the running turn (the original's `turnSteer`:
|
// While it works, a new message steers the running turn (the original's `turnSteer`:
|
||||||
// it doesn't count as a new turn) and reaches the model at its next step.
|
// it doesn't count as a new turn) and reaches the model at its next step.
|
||||||
if let Some(l) = self.streams.get(&bot_id) {
|
if self.streams.contains_key(&bot_id) {
|
||||||
let (extra, _) = crate::attach::for_model(&attachments);
|
let (extra, _) = crate::attach::for_model(&attachments);
|
||||||
l.steer.lock().unwrap().push(format!("{}{extra}", text.trim()));
|
self.queue_steer(bot_id, format!("{}{extra}", text.trim()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(b) = self.state.bot_mut(bot_id) {
|
if let Some(b) = self.state.bot_mut(bot_id) {
|
||||||
|
|
@ -682,6 +704,17 @@ impl App {
|
||||||
self.start_reply(ctx, bot_id);
|
self.start_reply(ctx, bot_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A message for a bot that is working: it waits for the bot's next step. The work log has it
|
||||||
|
/// (on disk) before it is queued, so a crash before the bot reads it doesn't lose it.
|
||||||
|
fn queue_steer(&self, bot_id: u64, text: String) {
|
||||||
|
let Some(l) = self.streams.get(&bot_id) else { return };
|
||||||
|
let q = agent::Queued::new(text);
|
||||||
|
if !self.demo {
|
||||||
|
crate::oplog::OpLog::for_bot(bot_id).append(&crate::oplog::Record::QueueEnqueued { op: Some(l.turn_id.clone()), id: q.id.clone(), text: q.text.clone(), at: q.at });
|
||||||
|
}
|
||||||
|
l.steer.lock().unwrap().push(q);
|
||||||
|
}
|
||||||
|
|
||||||
fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) {
|
fn start_reply(&mut self, ctx: &egui::Context, bot_id: u64) {
|
||||||
if self.mir.on {
|
if self.mir.on {
|
||||||
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "reply"}));
|
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "reply"}));
|
||||||
|
|
@ -816,7 +849,7 @@ impl App {
|
||||||
let steer = Arc::new(Mutex::new(Vec::new()));
|
let steer = Arc::new(Mutex::new(Vec::new()));
|
||||||
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
|
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
|
||||||
let direct = Arc::new(AtomicBool::new(self.direct_on()));
|
let direct = Arc::new(AtomicBool::new(self.direct_on()));
|
||||||
self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id, direct: direct.clone() });
|
self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id: turn_id.clone(), direct: direct.clone() });
|
||||||
self.stick_bottom = true;
|
self.stick_bottom = true;
|
||||||
let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() });
|
let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() });
|
||||||
let job = agent::Job {
|
let job = agent::Job {
|
||||||
|
|
@ -842,6 +875,8 @@ impl App {
|
||||||
review,
|
review,
|
||||||
demos: b.demos.clone(),
|
demos: b.demos.clone(),
|
||||||
skills: self.state.skills.clone(),
|
skills: self.state.skills.clone(),
|
||||||
|
op: turn_id,
|
||||||
|
log: (!self.demo).then(|| crate::oplog::OpLog::for_bot(bot_id)),
|
||||||
};
|
};
|
||||||
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
|
agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone());
|
||||||
// (Its eyes start reading while it works; see face_pose.)
|
// (Its eyes start reading while it works; see face_pose.)
|
||||||
|
|
@ -1095,7 +1130,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Messages that arrived too late to steer start the next turn.
|
// Messages that arrived too late to steer start the next turn.
|
||||||
let leftover: Vec<String> = std::mem::take(&mut *l.steer.lock().unwrap());
|
let leftover: Vec<agent::Queued> = std::mem::take(&mut *l.steer.lock().unwrap());
|
||||||
if !leftover.is_empty() {
|
if !leftover.is_empty() {
|
||||||
self.pending_turns.push((bot_id, leftover));
|
self.pending_turns.push((bot_id, leftover));
|
||||||
}
|
}
|
||||||
|
|
@ -1106,8 +1141,8 @@ impl App {
|
||||||
|
|
||||||
/// Wake a bot with a message: a new turn when idle, steering when it is already working.
|
/// Wake a bot with a message: a new turn when idle, steering when it is already working.
|
||||||
fn wake(&mut self, ctx: &egui::Context, bot_id: u64, msg: Message) {
|
fn wake(&mut self, ctx: &egui::Context, bot_id: u64, msg: Message) {
|
||||||
if let Some(l) = self.streams.get(&bot_id) {
|
if self.streams.contains_key(&bot_id) {
|
||||||
l.steer.lock().unwrap().push(msg.text);
|
self.queue_steer(bot_id, msg.text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected = self.state.selected;
|
let selected = self.state.selected;
|
||||||
|
|
@ -1120,12 +1155,48 @@ impl App {
|
||||||
self.start_reply(ctx, bot_id);
|
self.start_reply(ctx, bot_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turns cut off by AnyBot stopping (found at start, chats already put right by
|
||||||
|
/// oplog::recover): what happens to them next. The policy: nothing goes on by itself, not
|
||||||
|
/// even a turn that was only looking. Each chat got a 「繼續」 card, and the turn goes on only
|
||||||
|
/// when the person presses it (resume_turn). This only notes them in the log.
|
||||||
|
fn resume_interrupted(&mut self, _ctx: &egui::Context) {
|
||||||
|
for t in std::mem::take(&mut self.interrupted) {
|
||||||
|
eprintln!(
|
||||||
|
"AnyBot:Bot {} 的回合 {} 在 AnyBot 關掉時被中斷了({};可能已執行的步驟 {} 個;放回聊天的訊息 {} 則)",
|
||||||
|
t.bot_id,
|
||||||
|
t.op,
|
||||||
|
if t.aborted { "已按停止" } else { "沒有按停止" },
|
||||||
|
t.maybe_ran.len(),
|
||||||
|
t.restored
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「繼續」 on the card a crash left (here, on a screen of this brain, or on the phone): the
|
||||||
|
/// cut-off turn goes on from where its steps stand. A step that may have run is closed as
|
||||||
|
/// such (its result says so) and never runs again by itself; messages typed while it worked
|
||||||
|
/// were put back in the chat and are read now. Only the newest card, while nothing came
|
||||||
|
/// after it (a new message already carries the chat on).
|
||||||
|
pub(super) fn resume_turn(&mut self, ctx: &egui::Context, bot_id: u64) {
|
||||||
|
if self.mir.on {
|
||||||
|
self.mirror_act(serde_json::json!({"bot": bot_id, "action": "resume"}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.streams.contains_key(&bot_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.state.bot(bot_id).and_then(|b| b.messages.last()).map(|m| m.interrupted).unwrap_or(false) {
|
||||||
|
self.start_reply(ctx, bot_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run due routines (a routine firing is a "wake" turn).
|
/// Run due routines (a routine firing is a "wake" turn).
|
||||||
fn tick_routines(&mut self, ctx: &egui::Context) {
|
fn tick_routines(&mut self, ctx: &egui::Context) {
|
||||||
if self.demo || self.last_tick.elapsed() < std::time::Duration::from_secs(1) {
|
if self.demo || self.last_tick.elapsed() < std::time::Duration::from_secs(1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.last_tick = Instant::now();
|
self.last_tick = Instant::now();
|
||||||
|
self.resume_interrupted(ctx);
|
||||||
let now = crate::routines::now();
|
let now = crate::routines::now();
|
||||||
let mut fire = vec![];
|
let mut fire = vec![];
|
||||||
for b in self.state.bots.iter_mut() {
|
for b in self.state.bots.iter_mut() {
|
||||||
|
|
@ -1155,8 +1226,11 @@ impl App {
|
||||||
}
|
}
|
||||||
for (id, texts) in std::mem::take(&mut self.pending_turns) {
|
for (id, texts) in std::mem::take(&mut self.pending_turns) {
|
||||||
if let Some(b) = self.state.bot_mut(id) {
|
if let Some(b) = self.state.bot_mut(id) {
|
||||||
for t in texts {
|
for q in texts {
|
||||||
b.messages.push(Message::user(&t));
|
// (At the time it was typed: how a restart tells it is already in the chat.)
|
||||||
|
b.messages.push(Message { at: q.at, ..Message::user(&q.text) });
|
||||||
|
self.delivered.push((id, q.id));
|
||||||
|
self.save_now = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.streams.contains_key(&id) {
|
if self.streams.contains_key(&id) {
|
||||||
|
|
@ -1214,7 +1288,16 @@ impl App {
|
||||||
if let Some(live) = self.streams.get(&bot_id) {
|
if let Some(live) = self.streams.get(&bot_id) {
|
||||||
live.cancel.store(true, Ordering::Relaxed);
|
live.cancel.store(true, Ordering::Relaxed);
|
||||||
// Stopping keeps what was typed but not yet read: it becomes the next turn only if sent again.
|
// Stopping keeps what was typed but not yet read: it becomes the next turn only if sent again.
|
||||||
live.steer.lock().unwrap().clear();
|
let dropped: Vec<agent::Queued> = std::mem::take(&mut *live.steer.lock().unwrap());
|
||||||
|
// The stop is on disk at once (a crash before the turn's thread winds down still ends
|
||||||
|
// it as stopped), and the dropped messages won't come back after a restart.
|
||||||
|
if !self.demo {
|
||||||
|
let log = crate::oplog::OpLog::for_bot(bot_id);
|
||||||
|
log.append(&crate::oplog::Record::AbortRequested { op: live.turn_id.clone() });
|
||||||
|
for q in dropped {
|
||||||
|
log.append(&crate::oplog::Record::QueueCancelled { id: q.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
self.settle(bot_id, TurnOutcome::Cancelled);
|
self.settle(bot_id, TurnOutcome::Cancelled);
|
||||||
if let Some(b) = self.state.bot_mut(bot_id) {
|
if let Some(b) = self.state.bot_mut(bot_id) {
|
||||||
for c in b.messages.iter_mut().rev().take(6).flat_map(|m| m.tool_calls.iter_mut()) {
|
for c in b.messages.iter_mut().rev().take(6).flat_map(|m| m.tool_calls.iter_mut()) {
|
||||||
|
|
@ -1463,15 +1546,15 @@ impl App {
|
||||||
let Some(b) = self.state.bot_mut(id) else { continue };
|
let Some(b) = self.state.bot_mut(id) else { continue };
|
||||||
match ev {
|
match ev {
|
||||||
StreamEvent::Alive | StreamEvent::Retrying(_) | StreamEvent::NeedApproval(_) | StreamEvent::AppAction(_) | StreamEvent::React(_) | StreamEvent::Attach(_) => continue,
|
StreamEvent::Alive | StreamEvent::Retrying(_) | StreamEvent::NeedApproval(_) | StreamEvent::AppAction(_) | StreamEvent::React(_) | StreamEvent::Attach(_) => continue,
|
||||||
StreamEvent::Steered(m) => b.messages.push(m),
|
StreamEvent::Steered(m, qid) => {
|
||||||
|
b.messages.push(m);
|
||||||
|
// Delivered, once this chat is on disk (saved at the next frame).
|
||||||
|
self.delivered.push((id, qid));
|
||||||
|
self.save_now = true;
|
||||||
|
}
|
||||||
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
|
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
|
||||||
StreamEvent::ToolResult(m) => b.messages.push(m),
|
StreamEvent::ToolResult(m) => b.messages.push(m),
|
||||||
StreamEvent::Compacted(n, summary, boundary) => {
|
StreamEvent::Compacted(n, summary, boundary) => crate::model::fold_in(&mut b.messages, n, summary, boundary),
|
||||||
// The model's history can be shorter than the window's chat (group chats
|
|
||||||
// filter); land the cut at the real first-kept message, not the raw index.
|
|
||||||
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
|
|
||||||
b.messages.splice(..cut, [summary]);
|
|
||||||
}
|
|
||||||
StreamEvent::ToolStatus(call_id, st) => {
|
StreamEvent::ToolStatus(call_id, st) => {
|
||||||
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
|
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
|
||||||
c.status = st;
|
c.status = st;
|
||||||
|
|
@ -2549,7 +2632,7 @@ impl App {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
let queued: Vec<String> = self.streams.get(&bot.id).map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default();
|
let queued: Vec<String> = self.streams.get(&bot.id).map(|l| l.steer.lock().unwrap().iter().map(|q| q.text.clone()).collect()).unwrap_or_default();
|
||||||
let notice = self.notice.clone();
|
let notice = self.notice.clone();
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
if let Some(n) = notice.filter(|_| queued.is_empty()) {
|
if let Some(n) = notice.filter(|_| queued.is_empty()) {
|
||||||
|
|
@ -3502,13 +3585,17 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_if_dirty(&mut self) {
|
fn save_if_dirty(&mut self) {
|
||||||
if self.dirty && !self.demo && (self.streams.is_empty() || self.last_save.elapsed().as_secs() >= 2) {
|
if self.dirty && !self.demo && (self.streams.is_empty() || self.save_now || self.last_save.elapsed().as_secs() >= 2) {
|
||||||
if self.mir.on {
|
if self.mir.on {
|
||||||
self.mirror_save();
|
self.mirror_save();
|
||||||
} else {
|
} else if self.state.save() {
|
||||||
self.state.save();
|
// Typed messages now on disk in their chats: the work log can let them go.
|
||||||
|
for (bot, id) in std::mem::take(&mut self.delivered) {
|
||||||
|
crate::oplog::OpLog::for_bot(bot).append(&crate::oplog::Record::QueueConsumed { id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
|
self.save_now = false;
|
||||||
self.last_save = Instant::now();
|
self.last_save = Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3888,7 +3975,7 @@ struct Live {
|
||||||
/// Waiting for an approval or a running tool: the stall watchdog stays quiet.
|
/// Waiting for an approval or a running tool: the stall watchdog stays quiet.
|
||||||
paused: bool,
|
paused: bool,
|
||||||
/// What the person typed during this turn, waiting for the next step.
|
/// What the person typed during this turn, waiting for the next step.
|
||||||
steer: Arc<Mutex<Vec<String>>>,
|
steer: Arc<Mutex<Vec<agent::Queued>>>,
|
||||||
turn_id: String,
|
turn_id: String,
|
||||||
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
|
/// 直接操作 for this turn (switched on mid-turn by "全部允許").
|
||||||
direct: Arc<AtomicBool>,
|
direct: Arc<AtomicBool>,
|
||||||
|
|
@ -3929,7 +4016,9 @@ pub fn message_max_width(container: f32) -> f32 {
|
||||||
fn group_history(messages: &[Message], speaker: Option<u64>, st: &State) -> Vec<Message> {
|
fn group_history(messages: &[Message], speaker: Option<u64>, st: &State) -> Vec<Message> {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
let mut skip_tool_ids: std::collections::HashSet<String> = Default::default();
|
let mut skip_tool_ids: std::collections::HashSet<String> = Default::default();
|
||||||
for m in messages {
|
// The model reads from the newest summary on (the chat itself keeps what was folded), and
|
||||||
|
// never the 「繼續」 cards a crash left (they are for the person).
|
||||||
|
for m in messages[crate::model::model_view_start(messages)..].iter().filter(|m| !m.interrupted) {
|
||||||
let mut m = m.clone();
|
let mut m = m.clone();
|
||||||
if !m.attachments.is_empty() {
|
if !m.attachments.is_empty() {
|
||||||
let (extra, images) = crate::attach::for_model(&m.attachments);
|
let (extra, images) = crate::attach::for_model(&m.attachments);
|
||||||
|
|
@ -4408,6 +4497,21 @@ fn dialog_footer(ui: &mut egui::Ui, p: Palette, body: impl FnOnce(&mut egui::Ui)
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// The model reads a chat from its newest summary on, and never the 「繼續」 card.
|
||||||
|
#[test]
|
||||||
|
fn the_model_reads_from_the_fold_and_skips_the_card() {
|
||||||
|
let st = State::demo();
|
||||||
|
let msgs = vec![
|
||||||
|
Message::user("很久以前的問題"),
|
||||||
|
Message::assistant("很久以前的回答", "x"),
|
||||||
|
Message { summary: true, ..Message::assistant("摘要:問過以前的事", "x") },
|
||||||
|
Message::user("最近的問題"),
|
||||||
|
Message { interrupted: true, error: true, ..Message::assistant("AnyBot 在工作時被關掉了…", "") },
|
||||||
|
];
|
||||||
|
let h = group_history(&msgs, None, &st);
|
||||||
|
assert_eq!(h.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), vec!["摘要:問過以前的事", "最近的問題"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn group_members_see_each_other_as_labelled_words() {
|
fn group_members_see_each_other_as_labelled_words() {
|
||||||
let st = State::demo();
|
let st = State::demo();
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,23 @@ impl App {
|
||||||
});
|
});
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
}
|
}
|
||||||
|
// Where AnyBot stopping cut a turn off: what it was doing and what may have run,
|
||||||
|
// with 「繼續」 while it is the newest thing in the chat.
|
||||||
|
_ if m.interrupted => {
|
||||||
|
let can_go_on = last && !self.streams.contains_key(&bot.id);
|
||||||
|
let mut go_on = false;
|
||||||
|
Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(14).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| {
|
||||||
|
ui.label(RichText::new(&m.text).size(13.0).color(p.text2));
|
||||||
|
if can_go_on {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
go_on = ui.button(RichText::new(t("繼續")).size(13.0)).clicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(10.0);
|
||||||
|
if go_on {
|
||||||
|
self.resume_turn(ctx, bot.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => self.bot_message(ui, ctx, bot, m, last, idx),
|
_ => self.bot_message(ui, ctx, bot, m, last, idx),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,7 @@ impl App {
|
||||||
"threads": b.threads.iter().enumerate().rev().take(20).map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
|
"threads": b.threads.iter().enumerate().rev().take(20).map(|(i, (title, msgs))| json!({"index": i, "title": title, "count": msgs.len(), "at": msgs.last().map(|m| m.at).unwrap_or(0)})).collect::<Vec<_>>(),
|
||||||
// The sidebar's sections, the colour by name (the editor), what is queued while it works.
|
// The sidebar's sections, the colour by name (the editor), what is queued while it works.
|
||||||
"folder": b.folder, "color_key": b.color,
|
"folder": b.folder, "color_key": b.color,
|
||||||
"queued": live.map(|l| l.steer.lock().unwrap().clone()).unwrap_or_default(),
|
"queued": live.map(|l| l.steer.lock().unwrap().iter().map(|q| q.text.clone()).collect::<Vec<_>>()).unwrap_or_default(),
|
||||||
}));
|
}));
|
||||||
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL);
|
let start = b.messages.len().saturating_sub(remote::PHONE_TAIL);
|
||||||
// Each message with its place in the chat (表情回應), its thinking and, on each step,
|
// Each message with its place in the chat (表情回應), its thinking and, on each step,
|
||||||
|
|
@ -375,9 +375,9 @@ impl App {
|
||||||
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box })
|
Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box })
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(l) = self.streams.get(&bot) {
|
if self.streams.contains_key(&bot) {
|
||||||
let (extra, _) = crate::attach::for_model(&atts);
|
let (extra, _) = crate::attach::for_model(&atts);
|
||||||
l.steer.lock().unwrap().push(format!("{text}{extra}"));
|
self.queue_steer(bot, format!("{text}{extra}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(b) = self.state.bot_mut(bot) {
|
if let Some(b) = self.state.bot_mut(bot) {
|
||||||
|
|
@ -414,6 +414,7 @@ impl App {
|
||||||
self.start_reply(ctx, bot);
|
self.start_reply(ctx, bot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Cmd::Resume { bot } => self.resume_turn(ctx, bot),
|
||||||
Cmd::RestoreThread { bot, index } => {
|
Cmd::RestoreThread { bot, index } => {
|
||||||
self.stop(bot);
|
self.stop(bot);
|
||||||
if let Some(b) = self.state.bot_mut(bot) {
|
if let Some(b) = self.state.bot_mut(bot) {
|
||||||
|
|
@ -949,7 +950,7 @@ impl App {
|
||||||
// model and computer, a new chat, regenerate) plus looking and small chat things; making,
|
// model and computer, a new chat, regenerate) plus looking and small chat things; making,
|
||||||
// changing or deleting bots, routines, memories, pages and settings stays the owner's.
|
// changing or deleting bots, routines, memories, pages and settings stays the owner's.
|
||||||
let admin_ok = match action {
|
let admin_ok = match action {
|
||||||
"detail" | "settings" | "pages" | "restart_bot" | "react" | "reply" => true,
|
"detail" | "settings" | "pages" | "restart_bot" | "react" | "reply" | "resume" => true,
|
||||||
"bot" => b["key"].as_str() == Some("unread"),
|
"bot" => b["key"].as_str() == Some("unread"),
|
||||||
"box" => matches!(b["op"].as_str(), Some("wake" | "disk")),
|
"box" => matches!(b["op"].as_str(), Some("wake" | "disk")),
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|
@ -1108,6 +1109,10 @@ impl App {
|
||||||
self.remote_cmd(ctx, Cmd::Reply { bot });
|
self.remote_cmd(ctx, Cmd::Reply { bot });
|
||||||
done("重新傳送中…")
|
done("重新傳送中…")
|
||||||
}
|
}
|
||||||
|
"resume" => {
|
||||||
|
self.remote_cmd(ctx, Cmd::Resume { bot });
|
||||||
|
done("繼續中…")
|
||||||
|
}
|
||||||
"clear" => {
|
"clear" => {
|
||||||
self.remote_cmd(ctx, Cmd::Clear { bot });
|
self.remote_cmd(ctx, Cmd::Clear { bot });
|
||||||
done("已清除聊天記錄")
|
done("已清除聊天記錄")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ mod mcp;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod model;
|
mod model;
|
||||||
mod notify;
|
mod notify;
|
||||||
|
mod oplog;
|
||||||
mod phone;
|
mod phone;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod preview;
|
mod preview;
|
||||||
|
|
|
||||||
55
src/model.rs
55
src/model.rs
|
|
@ -80,12 +80,37 @@ pub struct Message {
|
||||||
/// When it was written (ms since 1970; 0 for messages from before this was kept).
|
/// When it was written (ms since 1970; 0 for messages from before this was kept).
|
||||||
#[serde(default, skip_serializing_if = "is_zero_i64")]
|
#[serde(default, skip_serializing_if = "is_zero_i64")]
|
||||||
pub at: i64,
|
pub at: i64,
|
||||||
|
/// A card where AnyBot stopping cut a turn off (put there at the next start, see
|
||||||
|
/// oplog::recover), with 「繼續」 to go on from there. Never sent to the model.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub interrupted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_zero_i64(n: &i64) -> bool {
|
fn is_zero_i64(n: &i64) -> bool {
|
||||||
*n == 0
|
*n == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the model's view of a chat starts: the newest summary (folding keeps the person's chat
|
||||||
|
/// whole; the model reads from the fold on).
|
||||||
|
pub fn model_view_start(msgs: &[Message]) -> usize {
|
||||||
|
msgs.iter().rposition(|m| m.summary).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A summary the turn made (it folded the first `n` messages of its view; `boundary` is the time
|
||||||
|
/// of the first one it kept) goes into the chat at the fold. Nothing is removed: the person's
|
||||||
|
/// chat stays whole and the model reads from the newest summary on. The model's view can be
|
||||||
|
/// shorter than the chat (group chats filter), so the fold lands at the real first-kept message,
|
||||||
|
/// not the raw index.
|
||||||
|
pub fn fold_in(msgs: &mut Vec<Message>, n: usize, summary: Message, boundary: i64) {
|
||||||
|
let from = model_view_start(msgs);
|
||||||
|
let cut = msgs.iter().position(|m| m.at == boundary && m.role == Role::User).filter(|&i| i > from).unwrap_or((from + n).min(msgs.len()));
|
||||||
|
// Pictures in what was folded never go to the model again (and aren't saved anyway).
|
||||||
|
for m in &mut msgs[..cut] {
|
||||||
|
m.images.clear();
|
||||||
|
}
|
||||||
|
msgs.insert(cut, summary);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn now_ms() -> i64 {
|
pub fn now_ms() -> i64 {
|
||||||
chrono::Utc::now().timestamp_millis()
|
chrono::Utc::now().timestamp_millis()
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +480,8 @@ impl State {
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) {
|
/// Write everything to disk; true once it is there.
|
||||||
|
pub fn save(&self) -> bool {
|
||||||
let p = Self::path();
|
let p = Self::path();
|
||||||
if let Some(dir) = p.parent() {
|
if let Some(dir) = p.parent() {
|
||||||
let _ = std::fs::create_dir_all(dir);
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
|
@ -467,10 +493,11 @@ impl State {
|
||||||
if let Ok(mut f) = std::fs::File::create(&tmp) {
|
if let Ok(mut f) = std::fs::File::create(&tmp) {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() {
|
if f.write_all(s.as_bytes()).and_then(|_| f.sync_all()).is_ok() {
|
||||||
let _ = std::fs::rename(&tmp, &p);
|
return std::fs::rename(&tmp, &p).is_ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn provider(&self, kind: ProviderKind) -> ProviderSettings {
|
pub fn provider(&self, kind: ProviderKind) -> ProviderSettings {
|
||||||
|
|
@ -765,6 +792,30 @@ pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
|
||||||
mod start_tests {
|
mod start_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's
|
||||||
|
/// view starts at the newest one.
|
||||||
|
#[test]
|
||||||
|
fn folding_keeps_every_message() {
|
||||||
|
let mut chat: Vec<Message> = (0..10).map(|i| Message { at: i + 1, ..if i % 2 == 0 { Message::user(&format!("問 {i}")) } else { Message::assistant(&format!("答 {i}"), "x") } }).collect();
|
||||||
|
let sum = |t: &str, at| Message { summary: true, at, ..Message::assistant(t, "x") };
|
||||||
|
// The turn folded the first 4 messages; the first it kept is 「問 4」 (at 5).
|
||||||
|
fold_in(&mut chat, 4, sum("摘要一", 100), 5);
|
||||||
|
assert_eq!(chat.len(), 11);
|
||||||
|
assert!(chat[4].summary && chat[5].text == "問 4");
|
||||||
|
assert_eq!(model_view_start(&chat), 4);
|
||||||
|
// Again later: the model's view started at 「摘要一」; it folded 3 of it (the summary,
|
||||||
|
// 問 4, 答 5) and kept from 「問 6」 (at 7).
|
||||||
|
fold_in(&mut chat, 3, sum("摘要二", 200), 7);
|
||||||
|
assert_eq!(chat.len(), 12);
|
||||||
|
assert_eq!(chat.iter().filter(|m| m.summary).count(), 2);
|
||||||
|
assert_eq!(model_view_start(&chat), 7);
|
||||||
|
assert_eq!(chat[8].text, "問 6");
|
||||||
|
assert_eq!(chat.iter().filter(|m| !m.summary).count(), 10, "nothing the person saw was removed");
|
||||||
|
// A boundary the chat can't find: the fold lands by count within the model's view.
|
||||||
|
fold_in(&mut chat, 2, sum("摘要三", 300), 9999);
|
||||||
|
assert!(chat[9].summary && chat[9].text == "摘要三");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_new_install_has_no_bots() {
|
fn a_new_install_has_no_bots() {
|
||||||
let mut s = State::first_run();
|
let mut s = State::first_run();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,661 @@
|
||||||
|
// Each bot's work log, after pi's durable-harness design (harness-v2, Part II), cut down: before
|
||||||
|
// something happens a line says it is about to (a turn starting, a model call, a step about to
|
||||||
|
// run, a message typed mid-turn), and after, a line says how it ended. Every line is on disk
|
||||||
|
// (synced) before the thing it announces starts.
|
||||||
|
//
|
||||||
|
// Nothing reads it while AnyBot runs. It is for the next start after AnyBot was killed mid-turn:
|
||||||
|
// which turn was cut off, which steps were running then (and whether one may have done its thing
|
||||||
|
// anyway — a text sent, a site published), and which messages the person typed that never
|
||||||
|
// reached the bot. The chat in state.json is saved every couple of seconds; this is the part
|
||||||
|
// that can't wait.
|
||||||
|
//
|
||||||
|
// One file per bot: `<data folder>/oplog/bot-<id>.jsonl`. When a turn ends, the file shrinks to
|
||||||
|
// the typed messages still waiting (usually nothing).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::model::{Message, Role, State, ToolStatus};
|
||||||
|
|
||||||
|
/// May this step run again after AnyBot was killed while it ran? Looking can; anything that
|
||||||
|
/// reaches outside (a text, a click, a file written, a site published) never runs twice on
|
||||||
|
/// its own.
|
||||||
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Replay {
|
||||||
|
Safe,
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Outcome {
|
||||||
|
Completed,
|
||||||
|
Aborted,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person's message a turn answers (kept so a message typed just before a crash, before the
|
||||||
|
/// chat was saved, isn't lost).
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub struct Prompt {
|
||||||
|
pub at: i64,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Record {
|
||||||
|
/// A turn starts. `source_leaf`: the time of the newest message it starts from.
|
||||||
|
OperationStarted {
|
||||||
|
op: String,
|
||||||
|
at: i64,
|
||||||
|
source_leaf: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt: Option<Prompt>,
|
||||||
|
},
|
||||||
|
/// A model call is about to be made (`step`: assistant | compaction), the n-th for this step.
|
||||||
|
StepAttempt { op: String, step: String, attempt: u32 },
|
||||||
|
/// A step passed its checks and is about to run, with the arguments it runs with.
|
||||||
|
ToolStarted { op: String, call_id: String, name: String, args: String, replay: Replay },
|
||||||
|
ToolFinished { op: String, call_id: String, status: String },
|
||||||
|
/// The person typed this while the bot worked; it waits for the bot's next step.
|
||||||
|
QueueEnqueued {
|
||||||
|
#[serde(default)]
|
||||||
|
op: Option<String>,
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
at: i64,
|
||||||
|
},
|
||||||
|
/// …and reached the bot (or became the next turn's message).
|
||||||
|
QueueConsumed { id: String },
|
||||||
|
/// …or was thrown away (the person stopped the turn).
|
||||||
|
QueueCancelled { id: String },
|
||||||
|
/// The person pressed stop.
|
||||||
|
AbortRequested { op: String },
|
||||||
|
OperationFinished { op: String, outcome: Outcome },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One writer at a time, in this process (the window and every turn's thread write here).
|
||||||
|
static WRITE: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
// Tests never touch the person's data folder.
|
||||||
|
if cfg!(test) {
|
||||||
|
return std::env::temp_dir().join(format!("anybot-oplog-test-{}", std::process::id()));
|
||||||
|
}
|
||||||
|
State::path().parent().map(|p| p.join("oplog")).unwrap_or_else(|| std::env::temp_dir().join("anybot-oplog"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bot's log.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OpLog {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpLog {
|
||||||
|
pub fn for_bot(bot_id: u64) -> Self {
|
||||||
|
Self::in_dir(&dir(), bot_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_dir(d: &std::path::Path, bot_id: u64) -> Self {
|
||||||
|
OpLog { path: d.join(format!("bot-{bot_id}.jsonl")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn at(path: PathBuf) -> Self {
|
||||||
|
OpLog { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add one line and wait until it is on disk.
|
||||||
|
pub fn append(&self, r: &Record) {
|
||||||
|
let _g = WRITE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
self.append_locked(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_locked(&self, r: &Record) {
|
||||||
|
let Ok(line) = serde_json::to_string(r) else { return };
|
||||||
|
if let Some(d) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(d);
|
||||||
|
}
|
||||||
|
let written = std::fs::OpenOptions::new().create(true).append(true).open(&self.path).and_then(|mut f| {
|
||||||
|
f.write_all(format!("{line}\n").as_bytes())?;
|
||||||
|
f.sync_data()
|
||||||
|
});
|
||||||
|
if let Err(e) = written {
|
||||||
|
eprintln!("AnyBot:寫不進工作紀錄 {}:{e}", self.path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every line this version can read. A line cut short (power lost while writing it) is the
|
||||||
|
/// last one and is skipped.
|
||||||
|
pub fn read(&self) -> Vec<Record> {
|
||||||
|
std::fs::read_to_string(&self.path).map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turn ended: its last line, then the file keeps only the typed messages still waiting.
|
||||||
|
pub fn finish(&self, op: &str, outcome: Outcome) {
|
||||||
|
let _g = WRITE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
self.append_locked(&Record::OperationFinished { op: op.to_string(), outcome });
|
||||||
|
self.shrink_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines saying these typed messages reached a chat, then shrink (used after a restart).
|
||||||
|
fn consumed_and_shrink(&self, ids: &[String]) {
|
||||||
|
let _g = WRITE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
for id in ids {
|
||||||
|
self.append_locked(&Record::QueueConsumed { id: id.clone() });
|
||||||
|
}
|
||||||
|
self.shrink_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no turn open, everything but the messages still waiting is history: drop it.
|
||||||
|
fn shrink_locked(&self) {
|
||||||
|
let r = reduce(&self.read());
|
||||||
|
if r.open.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if r.pending.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body: String = r
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| serde_json::to_string(&Record::QueueEnqueued { op: p.op.clone(), id: p.id.clone(), text: p.text.clone(), at: p.at }).ok())
|
||||||
|
.map(|l| format!("{l}\n"))
|
||||||
|
.collect();
|
||||||
|
let tmp = self.path.with_extension("jsonl.tmp");
|
||||||
|
let ok = std::fs::File::create(&tmp).and_then(|mut f| {
|
||||||
|
f.write_all(body.as_bytes())?;
|
||||||
|
f.sync_all()
|
||||||
|
});
|
||||||
|
if ok.is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, &self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A step the open turn started.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Started {
|
||||||
|
pub call_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub args: String,
|
||||||
|
pub replay: Replay,
|
||||||
|
/// Its end was written down (its result may still not be in the saved chat).
|
||||||
|
pub finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The turn that was open when AnyBot stopped.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct OpenOp {
|
||||||
|
pub op: String,
|
||||||
|
pub prompt: Option<Prompt>,
|
||||||
|
pub aborted: bool,
|
||||||
|
pub started: Vec<Started>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed message that never reached the bot.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Pending {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub at: i64,
|
||||||
|
pub op: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a log says, read from start to end.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Reduced {
|
||||||
|
pub open: Option<OpenOp>,
|
||||||
|
pub pending: Vec<Pending>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the lines into where things stand. A turn started while another was still open means
|
||||||
|
/// the earlier one died without its last line; the newer one is what counts.
|
||||||
|
pub fn reduce(recs: &[Record]) -> Reduced {
|
||||||
|
let mut open: Option<OpenOp> = None;
|
||||||
|
let mut pending: Vec<Pending> = vec![];
|
||||||
|
for r in recs {
|
||||||
|
let mine = |o: &Option<OpenOp>, op: &str| o.as_ref().map(|o| o.op == op).unwrap_or(false);
|
||||||
|
match r {
|
||||||
|
Record::OperationStarted { op, prompt, .. } => open = Some(OpenOp { op: op.clone(), prompt: prompt.clone(), aborted: false, started: vec![] }),
|
||||||
|
Record::ToolStarted { op, call_id, name, args, replay } if mine(&open, op) => {
|
||||||
|
if let Some(o) = open.as_mut() {
|
||||||
|
o.started.retain(|s| &s.call_id != call_id);
|
||||||
|
o.started.push(Started { call_id: call_id.clone(), name: name.clone(), args: args.clone(), replay: *replay, finished: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Record::ToolFinished { op, call_id, .. } if mine(&open, op) => {
|
||||||
|
if let Some(o) = open.as_mut() {
|
||||||
|
for s in o.started.iter_mut().filter(|s| &s.call_id == call_id) {
|
||||||
|
s.finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Record::AbortRequested { op } if mine(&open, op) => {
|
||||||
|
if let Some(o) = open.as_mut() {
|
||||||
|
o.aborted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Record::OperationFinished { op, .. } if mine(&open, op) => open = None,
|
||||||
|
Record::QueueEnqueued { op, id, text, at } => {
|
||||||
|
if !pending.iter().any(|p| &p.id == id) {
|
||||||
|
pending.push(Pending { id: id.clone(), text: text.clone(), at: *at, op: op.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Record::QueueConsumed { id } | Record::QueueCancelled { id } => pending.retain(|p| &p.id != id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reduced { open, pending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps that only look: running one again changes nothing. Everything else (and every tool not
|
||||||
|
/// named here: MCP connectors, new tools) is `Never`.
|
||||||
|
pub fn replay_of(name: &str) -> Replay {
|
||||||
|
const LOOKS: &[&str] = &[
|
||||||
|
"computer_use_screenshot",
|
||||||
|
"computer_use_show",
|
||||||
|
"computer_use_check_permissions",
|
||||||
|
"browser_read",
|
||||||
|
"browser_screenshot",
|
||||||
|
"browser_profiles",
|
||||||
|
"browser_wait",
|
||||||
|
"browser_tabs",
|
||||||
|
"web_fetch",
|
||||||
|
"read_file",
|
||||||
|
"list_dir",
|
||||||
|
"search",
|
||||||
|
"read_terminal",
|
||||||
|
"vault_list",
|
||||||
|
"view_demo_step",
|
||||||
|
"list_bots",
|
||||||
|
"list_computers",
|
||||||
|
"list_routines",
|
||||||
|
"list_pages",
|
||||||
|
"read_page",
|
||||||
|
"mac_read_mail",
|
||||||
|
"mac_read_notes",
|
||||||
|
"mac_mail_accounts",
|
||||||
|
"ios_read_reminders",
|
||||||
|
"mobile_read_calendar",
|
||||||
|
"mobile_read_contacts",
|
||||||
|
];
|
||||||
|
if LOOKS.contains(&name) {
|
||||||
|
Replay::Safe
|
||||||
|
} else {
|
||||||
|
Replay::Never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a tool result says when AnyBot was killed while a step that reaches outside ran.
|
||||||
|
pub const MAYBE_RAN: &str = "(AnyBot 在執行這個步驟時被關掉了,它可能已經執行了。重做之前先確認結果(例如看畫面、讀檔案或問使用者),不要重複做一次。)";
|
||||||
|
/// …and when the step only looked.
|
||||||
|
pub const CUT_OFF: &str = "(這個步驟被中止了,沒有執行完。)";
|
||||||
|
|
||||||
|
/// A turn cut off by AnyBot stopping, as found on the next start.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Interrupted {
|
||||||
|
pub bot_id: u64,
|
||||||
|
pub op: String,
|
||||||
|
/// The person had pressed stop.
|
||||||
|
pub aborted: bool,
|
||||||
|
/// Steps that may have done their thing (how the chat names them).
|
||||||
|
pub maybe_ran: Vec<String>,
|
||||||
|
/// Typed messages put back into the chat.
|
||||||
|
pub restored: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one bot's log gets once the chats are saved: the cut-off turn's last line, and the typed
|
||||||
|
/// messages now in the chat.
|
||||||
|
struct Closing {
|
||||||
|
log: OpLog,
|
||||||
|
finish: Option<(String, Outcome)>,
|
||||||
|
consumed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `recover` found; `close` writes it down once the chat is saved.
|
||||||
|
pub struct Recovery {
|
||||||
|
pub interrupted: Vec<Interrupted>,
|
||||||
|
/// Did the chats change (save them before `close`).
|
||||||
|
pub changed: bool,
|
||||||
|
closing: Vec<Closing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recovery {
|
||||||
|
/// After the chats are saved: each cut-off turn gets its last line, and the typed messages
|
||||||
|
/// put back are marked as delivered. In this order a crash in between only repeats the
|
||||||
|
/// recovery (which finds everything already in the chat and changes nothing).
|
||||||
|
pub fn close(self) -> Vec<Interrupted> {
|
||||||
|
for c in &self.closing {
|
||||||
|
if let Some((op, outcome)) = &c.finish {
|
||||||
|
c.log.finish(op, *outcome);
|
||||||
|
}
|
||||||
|
c.log.consumed_and_shrink(&c.consumed);
|
||||||
|
}
|
||||||
|
self.interrupted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new message at its time in the chat (after everything older).
|
||||||
|
fn insert_by_time(msgs: &mut Vec<Message>, m: Message) {
|
||||||
|
let i = msgs.iter().rposition(|x| x.at <= m.at).map(|i| i + 1).unwrap_or(0);
|
||||||
|
msgs.insert(i, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On start, before anything runs: read every bot's log and put the chats right.
|
||||||
|
/// - The message a cut-off turn answered is put back if the chat lost it.
|
||||||
|
/// - Steps that were running: one that only looked shows as cut off; one that reaches outside
|
||||||
|
/// shows as "may have run" (a tool result the bot reads, and a line the person sees) — never
|
||||||
|
/// as "not allowed", which is what a stop looked like before.
|
||||||
|
/// - Messages typed mid-turn that never reached the bot go back into the chat, so the next turn
|
||||||
|
/// reads them.
|
||||||
|
///
|
||||||
|
/// - A turn that wasn't stopped by the person ends with a 「繼續」 card: what it was doing, the
|
||||||
|
/// steps that may have run, the messages put back. Nothing starts again until the person
|
||||||
|
/// presses it (App::resume_turn); the steps that may have run stay closed as such.
|
||||||
|
pub fn recover(state: &mut State) -> Recovery {
|
||||||
|
recover_in(&dir(), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_in(d: &std::path::Path, state: &mut State) -> Recovery {
|
||||||
|
let mut out = Recovery { interrupted: vec![], changed: false, closing: vec![] };
|
||||||
|
let ids: Vec<u64> = state.bots.iter().map(|b| b.id).collect();
|
||||||
|
// Logs of bots that no longer exist: nothing to put back.
|
||||||
|
if let Ok(rd) = std::fs::read_dir(d) {
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
let id = name.strip_prefix("bot-").and_then(|r| r.strip_suffix(".jsonl")).and_then(|n| n.parse::<u64>().ok());
|
||||||
|
if id.map(|id| !ids.contains(&id)).unwrap_or(false) {
|
||||||
|
let _ = std::fs::remove_file(e.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in ids {
|
||||||
|
let log = OpLog::in_dir(d, id);
|
||||||
|
let r = reduce(&log.read());
|
||||||
|
if r.open.is_none() && r.pending.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(b) = state.bot_mut(id) else { continue };
|
||||||
|
let mut finish = None;
|
||||||
|
let mut maybe_ran = vec![];
|
||||||
|
if let Some(o) = &r.open {
|
||||||
|
if let Some(p) = &o.prompt {
|
||||||
|
if !b.messages.iter().any(|m| m.role == Role::User && m.at == p.at) {
|
||||||
|
insert_by_time(&mut b.messages, Message { at: p.at, ..Message::user(&p.text) });
|
||||||
|
out.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every step that started but whose result never reached the saved chat (a result in
|
||||||
|
// the chat is the step's whole outcome; without one, it was cut off — or it finished
|
||||||
|
// and the chat wasn't saved after).
|
||||||
|
let result_of = |msgs: &[Message], id: &str| msgs.iter().find(|m| m.role == Role::Tool && m.tool_call_id == id).map(|m| m.text.clone());
|
||||||
|
// (A result already saying "may have run" is one an earlier pass of this put there.)
|
||||||
|
for s in o.started.iter().filter(|s| s.replay == Replay::Never) {
|
||||||
|
if result_of(&b.messages, &s.call_id).map(|t| t == MAYBE_RAN).unwrap_or(true) {
|
||||||
|
maybe_ran.push(format!("{} {}", crate::remote::tool_label(&s.name), crate::remote::tool_detail(&s.name, &s.args)).trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let missing: Vec<Started> = o.started.iter().filter(|s| result_of(&b.messages, &s.call_id).is_none()).cloned().collect();
|
||||||
|
for s in &missing {
|
||||||
|
let Some(mi) = b.messages.iter().rposition(|m| m.role == Role::Assistant && m.tool_calls.iter().any(|c| c.id == s.call_id)) else { continue };
|
||||||
|
for c in b.messages[mi].tool_calls.iter_mut().filter(|c| c.id == s.call_id) {
|
||||||
|
if c.status != ToolStatus::Failed {
|
||||||
|
c.status = ToolStatus::Failed;
|
||||||
|
out.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !b.messages.iter().any(|m| m.role == Role::Tool && m.tool_call_id == s.call_id) {
|
||||||
|
let mut at = mi + 1;
|
||||||
|
while at < b.messages.len() && b.messages[at].role == Role::Tool {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
let text = if s.replay == Replay::Never { MAYBE_RAN } else { CUT_OFF };
|
||||||
|
b.messages.insert(at, Message { at: b.messages[mi].at, ..Message::tool_result(&s.call_id, &s.name, text) });
|
||||||
|
out.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish = Some((o.op.clone(), if o.aborted { Outcome::Aborted } else { Outcome::Failed }));
|
||||||
|
}
|
||||||
|
// Typed messages that never reached the bot go back where they were typed: the next
|
||||||
|
// turn (「繼續」, or whatever the person says next) reads them.
|
||||||
|
let mut consumed = vec![];
|
||||||
|
for p in &r.pending {
|
||||||
|
if !b.messages.iter().any(|m| m.role == Role::User && m.at == p.at && m.text == p.text) {
|
||||||
|
insert_by_time(&mut b.messages, Message { at: p.at, ..Message::user(&p.text) });
|
||||||
|
out.changed = true;
|
||||||
|
}
|
||||||
|
consumed.push(p.id.clone());
|
||||||
|
}
|
||||||
|
if let Some(o) = &r.open {
|
||||||
|
// A turn the person stopped stays stopped; any other gets the 「繼續」 card, in place
|
||||||
|
// of the "interrupted, press 重新產生" line the load left on an empty reply.
|
||||||
|
if !o.aborted {
|
||||||
|
let from = o.prompt.as_ref().and_then(|p| b.messages.iter().position(|m| m.role == Role::User && m.at == p.at)).unwrap_or(0);
|
||||||
|
let before = b.messages.len();
|
||||||
|
let mut i = 0;
|
||||||
|
b.messages.retain(|m| {
|
||||||
|
i += 1;
|
||||||
|
!(i - 1 > from && m.role == Role::Assistant && m.tool_calls.is_empty() && m.text == crate::model::INTERRUPTED_NOTE)
|
||||||
|
});
|
||||||
|
out.changed |= b.messages.len() != before;
|
||||||
|
let card = card_text(o.prompt.as_ref(), &maybe_ran, r.pending.len());
|
||||||
|
if !b.messages.iter().any(|m| m.interrupted && m.text == card) {
|
||||||
|
b.messages.push(Message { error: true, interrupted: true, ..Message::assistant(&card, "") });
|
||||||
|
out.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.interrupted.push(Interrupted { bot_id: id, op: o.op.clone(), aborted: o.aborted, maybe_ran, restored: r.pending.len() });
|
||||||
|
}
|
||||||
|
out.closing.push(Closing { log, finish, consumed });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the 「繼續」 card says: what was being done, which steps may have run anyway (they are
|
||||||
|
/// closed as such and never run again by themselves), and the typed messages put back.
|
||||||
|
fn card_text(prompt: Option<&Prompt>, maybe_ran: &[String], restored: usize) -> String {
|
||||||
|
let mut s = String::from("AnyBot 在工作時被關掉了,這個回合沒有做完。");
|
||||||
|
if let Some(p) = prompt {
|
||||||
|
let line: String = p.text.lines().next().unwrap_or("").chars().take(40).collect();
|
||||||
|
let more = if p.text.chars().count() > line.chars().count() { "…" } else { "" };
|
||||||
|
s.push_str(&format!("\n正在做:「{line}{more}」"));
|
||||||
|
}
|
||||||
|
if !maybe_ran.is_empty() {
|
||||||
|
s.push_str("\n那時正在執行的這些步驟可能已經完成了(繼續時不會再做一次):\n");
|
||||||
|
s.push_str(&maybe_ran.iter().map(|d| format!("・{d}")).collect::<Vec<_>>().join("\n"));
|
||||||
|
}
|
||||||
|
if restored > 0 {
|
||||||
|
s.push_str(&format!("\n它工作時你傳的 {restored} 則訊息已經放回聊天,繼續時它會讀到。"));
|
||||||
|
}
|
||||||
|
s.push_str("\n按「繼續」從中斷的地方接著做。");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id for a typed message (unique in this process and across restarts).
|
||||||
|
pub fn new_id() -> String {
|
||||||
|
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
format!("q-{}-{}", crate::model::now_ms(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> OpLog {
|
||||||
|
let p = std::env::temp_dir().join(format!("anybot-oplog-{name}-{}.jsonl", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
OpLog::at(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_turn_leaves_only_waiting_messages() {
|
||||||
|
let log = scratch("finish");
|
||||||
|
log.append(&Record::OperationStarted { op: "t1".into(), at: 1, source_leaf: 1, prompt: None });
|
||||||
|
log.append(&Record::StepAttempt { op: "t1".into(), step: "assistant".into(), attempt: 1 });
|
||||||
|
log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "順便".into(), at: 2 });
|
||||||
|
log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q2".into(), text: "還有".into(), at: 3 });
|
||||||
|
log.append(&Record::QueueConsumed { id: "q1".into() });
|
||||||
|
log.finish("t1", Outcome::Completed);
|
||||||
|
assert_eq!(log.read(), vec![Record::QueueEnqueued { op: Some("t1".into()), id: "q2".into(), text: "還有".into(), at: 3 }]);
|
||||||
|
log.append(&Record::QueueConsumed { id: "q2".into() });
|
||||||
|
log.finish("t2", Outcome::Completed);
|
||||||
|
assert!(!log.path.exists(), "nothing waiting: no file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_torn_last_line_is_skipped() {
|
||||||
|
let log = scratch("torn");
|
||||||
|
log.append(&Record::OperationStarted { op: "t1".into(), at: 1, source_leaf: 1, prompt: None });
|
||||||
|
let mut f = std::fs::OpenOptions::new().append(true).open(&log.path).unwrap();
|
||||||
|
f.write_all(b"{\"type\":\"tool_star").unwrap();
|
||||||
|
let r = reduce(&log.read());
|
||||||
|
assert_eq!(r.open.map(|o| o.op), Some("t1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reduction_finds_what_was_running_and_what_was_waiting() {
|
||||||
|
let recs = vec![
|
||||||
|
Record::OperationStarted { op: "t0".into(), at: 1, source_leaf: 1, prompt: None },
|
||||||
|
Record::OperationStarted { op: "t1".into(), at: 2, source_leaf: 2, prompt: Some(Prompt { at: 2, text: "傳簡訊".into() }) },
|
||||||
|
Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe },
|
||||||
|
Record::ToolFinished { op: "t1".into(), call_id: "c1".into(), status: "done".into() },
|
||||||
|
Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: "{}".into(), replay: Replay::Never },
|
||||||
|
Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "等等".into(), at: 5 },
|
||||||
|
Record::QueueEnqueued { op: Some("t1".into()), id: "q2".into(), text: "算了".into(), at: 6 },
|
||||||
|
Record::QueueCancelled { id: "q2".into() },
|
||||||
|
// A stale line for a turn that isn't the open one changes nothing.
|
||||||
|
Record::ToolFinished { op: "t0".into(), call_id: "c2".into(), status: "done".into() },
|
||||||
|
];
|
||||||
|
let r = reduce(&recs);
|
||||||
|
let o = r.open.unwrap();
|
||||||
|
assert_eq!(o.op, "t1");
|
||||||
|
assert_eq!(o.started.iter().filter(|s| !s.finished).map(|s| (s.call_id.as_str(), s.replay)).collect::<Vec<_>>(), vec![("c2", Replay::Never)]);
|
||||||
|
assert_eq!(o.started.len(), 2);
|
||||||
|
assert_eq!(r.pending.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), vec!["q1"]);
|
||||||
|
let mut done = recs.clone();
|
||||||
|
done.push(Record::AbortRequested { op: "t1".into() });
|
||||||
|
assert!(reduce(&done).open.unwrap().aborted);
|
||||||
|
done.push(Record::OperationFinished { op: "t1".into(), outcome: Outcome::Aborted });
|
||||||
|
assert!(reduce(&done).open.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looking_may_run_again_and_reaching_out_never() {
|
||||||
|
assert_eq!(replay_of("computer_use_screenshot"), Replay::Safe);
|
||||||
|
assert_eq!(replay_of("read_file"), Replay::Safe);
|
||||||
|
for t in ["send_sms", "post_to_x", "publish_site", "browser_fill_card", "mac_send_mail", "shell", "edit_file", "computer_use_click", "computer_use_typing", "mcp__github__create_issue"] {
|
||||||
|
assert_eq!(replay_of(t), Replay::Never, "{t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a crash: the lost message comes back, a text that may have gone out says so (not
|
||||||
|
/// "not allowed"), a look shows as cut off, a typed message is put back, and the log closes.
|
||||||
|
/// A second pass (a crash before the log closed) changes nothing.
|
||||||
|
/// A state with one empty bot (id `id`), and a folder of logs of its own.
|
||||||
|
fn one_bot(id: u64, name: &str) -> (State, PathBuf) {
|
||||||
|
let mut st = State::samples();
|
||||||
|
st.bots.truncate(1);
|
||||||
|
st.bots[0].id = id;
|
||||||
|
st.bots[0].messages.clear();
|
||||||
|
let d = std::env::temp_dir().join(format!("anybot-oplog-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
(st, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_restart_puts_the_chat_right() {
|
||||||
|
let (mut st, d) = one_bot(77, "restart");
|
||||||
|
let b = &mut st.bots[0];
|
||||||
|
b.messages.push(Message { at: 10, ..Message::user("先看資料夾再傳簡訊給媽媽") });
|
||||||
|
let mut a = Message { at: 11, ..Message::assistant("", "test") };
|
||||||
|
a.tool_calls = vec![
|
||||||
|
crate::model::ToolCall { id: "c1".into(), name: "list_dir".into(), args: "{}".into(), status: ToolStatus::Running },
|
||||||
|
crate::model::ToolCall { id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), status: ToolStatus::Running },
|
||||||
|
];
|
||||||
|
b.messages.push(a);
|
||||||
|
// The next step's empty reply bubble (load turns it into "press 重新產生").
|
||||||
|
b.messages.push(Message { at: 13, ..Message::assistant("", "test") });
|
||||||
|
// What load() does first: running steps look denied.
|
||||||
|
b.repair_interrupted();
|
||||||
|
let log = OpLog::in_dir(&d, 77);
|
||||||
|
log.append(&Record::OperationStarted { op: "t1".into(), at: 10, source_leaf: 10, prompt: Some(Prompt { at: 10, text: "先看資料夾再傳簡訊給媽媽".into() }) });
|
||||||
|
log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe });
|
||||||
|
log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never });
|
||||||
|
log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "記得說謝謝".into(), at: 12 });
|
||||||
|
// And a log for a bot that was deleted.
|
||||||
|
OpLog::in_dir(&d, 999).append(&Record::OperationStarted { op: "x".into(), at: 1, source_leaf: 1, prompt: None });
|
||||||
|
|
||||||
|
let rec = recover_in(&d, &mut st);
|
||||||
|
assert!(rec.changed);
|
||||||
|
let found = rec.close();
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!((found[0].bot_id, found[0].restored, found[0].maybe_ran.len()), (77, 1, 1));
|
||||||
|
let m = &st.bots[0].messages;
|
||||||
|
let calls = &m[1].tool_calls;
|
||||||
|
assert!(calls.iter().all(|c| c.status == ToolStatus::Failed), "{calls:?}");
|
||||||
|
let result = |id: &str| m.iter().find(|x| x.role == Role::Tool && x.tool_call_id == id).map(|x| x.text.clone()).unwrap_or_default();
|
||||||
|
assert_eq!(result("c1"), CUT_OFF);
|
||||||
|
assert_eq!(result("c2"), MAYBE_RAN);
|
||||||
|
assert!(m.iter().any(|x| x.role == Role::User && x.text == "記得說謝謝"));
|
||||||
|
// The 「繼續」 card comes last: what was being done, the text that may have gone out, the
|
||||||
|
// message put back; the "press 重新產生" line it replaces is gone.
|
||||||
|
let card = m.last().unwrap();
|
||||||
|
assert!(card.interrupted && card.error, "{card:?}");
|
||||||
|
assert!(card.text.contains("先看資料夾再傳簡訊給媽媽") && card.text.contains("可能已經完成") && card.text.contains("1 則訊息"), "{}", card.text);
|
||||||
|
assert!(!m.iter().any(|x| x.text == crate::model::INTERRUPTED_NOTE));
|
||||||
|
// The model is told the same (a result per call, nothing "not allowed").
|
||||||
|
let sent = crate::providers::repair_history(m);
|
||||||
|
assert!(!sent.iter().any(|x| x.text.contains("沒有允許")));
|
||||||
|
assert!(!OpLog::in_dir(&d, 77).path.exists() && !OpLog::in_dir(&d, 999).path.exists());
|
||||||
|
|
||||||
|
// A crash after the chat was saved but before the log closed: the same lines again.
|
||||||
|
let before = st.bots[0].messages.len();
|
||||||
|
let log = OpLog::in_dir(&d, 77);
|
||||||
|
log.append(&Record::OperationStarted { op: "t1".into(), at: 10, source_leaf: 10, prompt: Some(Prompt { at: 10, text: "先看資料夾再傳簡訊給媽媽".into() }) });
|
||||||
|
log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never });
|
||||||
|
log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "記得說謝謝".into(), at: 12 });
|
||||||
|
let again = recover_in(&d, &mut st);
|
||||||
|
assert!(!again.changed);
|
||||||
|
again.close();
|
||||||
|
assert_eq!(st.bots[0].messages.len(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message a turn answered survives a crash that came before the chat was saved.
|
||||||
|
#[test]
|
||||||
|
fn a_message_the_chat_lost_comes_back() {
|
||||||
|
let (mut st, d) = one_bot(78, "lost");
|
||||||
|
st.bots[0].messages.push(Message { at: 5, ..Message::user("早") });
|
||||||
|
let log = OpLog::in_dir(&d, 78);
|
||||||
|
log.append(&Record::OperationStarted { op: "t9".into(), at: 20, source_leaf: 20, prompt: Some(Prompt { at: 20, text: "幫我訂餐廳".into() }) });
|
||||||
|
let rec = recover_in(&d, &mut st);
|
||||||
|
assert!(rec.changed);
|
||||||
|
let found = rec.close();
|
||||||
|
assert!(found[0].maybe_ran.is_empty() && !found[0].aborted);
|
||||||
|
let m = &st.bots[0].messages;
|
||||||
|
assert_eq!(m.iter().filter(|x| x.role == Role::User).map(|x| x.text.as_str()).collect::<Vec<_>>(), vec!["早", "幫我訂餐廳"]);
|
||||||
|
assert!(m.last().unwrap().interrupted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turn the person had stopped (the stop is on disk, the thread never wound down) ends as
|
||||||
|
/// stopped: no 「繼續」 card, and the messages they typed before stopping stay dropped.
|
||||||
|
#[test]
|
||||||
|
fn a_stopped_turn_stays_stopped() {
|
||||||
|
let (mut st, d) = one_bot(79, "stopped");
|
||||||
|
st.bots[0].messages.push(Message { at: 5, ..Message::user("整理照片") });
|
||||||
|
let log = OpLog::in_dir(&d, 79);
|
||||||
|
log.append(&Record::OperationStarted { op: "t1".into(), at: 5, source_leaf: 5, prompt: Some(Prompt { at: 5, text: "整理照片".into() }) });
|
||||||
|
log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "先不要刪".into(), at: 6 });
|
||||||
|
log.append(&Record::AbortRequested { op: "t1".into() });
|
||||||
|
log.append(&Record::QueueCancelled { id: "q1".into() });
|
||||||
|
let found = recover_in(&d, &mut st).close();
|
||||||
|
assert!(found[0].aborted);
|
||||||
|
assert!(!st.bots[0].messages.iter().any(|m| m.interrupted || m.text == "先不要刪"));
|
||||||
|
assert!(!log.path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -192,7 +192,8 @@ pub enum StreamEvent {
|
||||||
/// `boundary` the timestamp of the first kept message (the window's own copy may be longer).
|
/// `boundary` the timestamp of the first kept message (the window's own copy may be longer).
|
||||||
Compacted(usize, Message, i64),
|
Compacted(usize, Message, i64),
|
||||||
/// A message the person sent mid-turn was handed to the model (add it to the chat).
|
/// A message the person sent mid-turn was handed to the model (add it to the chat).
|
||||||
Steered(Message),
|
/// (With the id of the typed message, for the work log.)
|
||||||
|
Steered(Message, String),
|
||||||
/// Do something in the app (routines, bots, settings) and answer on the sender.
|
/// Do something in the app (routines, bots, settings) and answer on the sender.
|
||||||
AppAction(AppCall),
|
AppAction(AppCall),
|
||||||
/// 表情回應: the bot put an emoji on the person's latest message.
|
/// 表情回應: the bot put an emoji on the person's latest message.
|
||||||
|
|
@ -343,6 +344,10 @@ fn page_words(body: &str) -> String {
|
||||||
/// waited out and tried again (it came through raw English, as the reply, and was never retried).
|
/// waited out and tried again (it came through raw English, as the reply, and was never retried).
|
||||||
fn stream_failure(msg: &str) -> Failure {
|
fn stream_failure(msg: &str) -> Failure {
|
||||||
let low = msg.to_lowercase();
|
let low = msg.to_lowercase();
|
||||||
|
// The same overflow, reported inside the stream instead of as an HTTP 400.
|
||||||
|
if ["prompt is too long", "maximum context", "context length", "context window", "too many tokens"].iter().any(|k| low.contains(k)) {
|
||||||
|
return Failure::fatal(format!("{CONTEXT_OVERFLOW}請清除聊天記錄或換一個模型。\n({msg})"));
|
||||||
|
}
|
||||||
if ["capacity", "overloaded", "rate limit", "too many requests", "try again", "temporarily", "unavailable", "timeout", "timed out"].iter().any(|k| low.contains(k)) {
|
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) }
|
Failure { msg: format!("模型供應商現在太忙,正在等一下再試。\n({msg})"), retryable: true, retry_after_ms: Some(BUSY_RETRY_MS) }
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -353,6 +358,16 @@ fn stream_failure(msg: &str) -> Failure {
|
||||||
/// Wait before asking a busy model again (estimated; it says "a few minutes", 1–4 s was too soon).
|
/// 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;
|
const BUSY_RETRY_MS: u64 = 8_000;
|
||||||
|
|
||||||
|
/// How a "the conversation no longer fits this model" failure starts (SAND-E0404
|
||||||
|
/// contextWindowOverflow). The turn loop recognizes it by this and folds older messages into a
|
||||||
|
/// summary before asking once more, instead of giving up.
|
||||||
|
pub const CONTEXT_OVERFLOW: &str = "對話太長了,超過這個模型能讀的長度。";
|
||||||
|
|
||||||
|
/// The request didn't fit the model's context (a retry as-is would fail the same way).
|
||||||
|
pub fn is_context_overflow(msg: &str) -> bool {
|
||||||
|
msg.starts_with(CONTEXT_OVERFLOW)
|
||||||
|
}
|
||||||
|
|
||||||
fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
|
fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
|
||||||
let detail = serde_json::from_str::<Value>(body)
|
let detail = serde_json::from_str::<Value>(body)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -373,7 +388,7 @@ fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
|
||||||
let lower = detail.to_lowercase();
|
let lower = detail.to_lowercase();
|
||||||
// SAND-E0404 contextWindowOverflow: a retry would repeat it.
|
// SAND-E0404 contextWindowOverflow: a retry would repeat it.
|
||||||
if (status == 400 || status == 413) && ["context", "too long", "too many tokens", "maximum context", "prompt is too long"].iter().any(|k| lower.contains(k)) {
|
if (status == 400 || status == 413) && ["context", "too long", "too many tokens", "maximum context", "prompt is too long"].iter().any(|k| lower.contains(k)) {
|
||||||
return Failure::fatal(format!("對話太長了,超過這個模型能讀的長度。請清除聊天記錄或換一個模型。\n({detail})"));
|
return Failure::fatal(format!("{CONTEXT_OVERFLOW}請清除聊天記錄或換一個模型。\n({detail})"));
|
||||||
}
|
}
|
||||||
if status == 400 && lower.contains("tool") && (lower.contains("support") || lower.contains("not available")) {
|
if status == 400 && lower.contains("tool") && (lower.contains("support") || lower.contains("not available")) {
|
||||||
return Failure::fatal(format!("這個模型不支援工具。請換一個支援工具的模型,或在 Bot 設定裡關掉「電腦操作」。\n({detail})"));
|
return Failure::fatal(format!("這個模型不支援工具。請換一個支援工具的模型,或在 Bot 設定裡關掉「電腦操作」。\n({detail})"));
|
||||||
|
|
@ -1302,6 +1317,18 @@ mod tests {
|
||||||
assert!(!http_error(400, "{\"error\":{\"message\":\"prompt is too long\"}}", None).retryable);
|
assert!(!http_error(400, "{\"error\":{\"message\":\"prompt is too long\"}}", None).retryable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A conversation too long for the model is told apart from other failures (the turn folds
|
||||||
|
/// it into a summary and asks again), whether it comes as an HTTP 400 or inside the stream.
|
||||||
|
#[test]
|
||||||
|
fn an_overflow_is_recognized_as_one() {
|
||||||
|
assert!(is_context_overflow(&http_error(400, "{\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\"}}", None).msg));
|
||||||
|
assert!(is_context_overflow(&http_error(413, "{\"error\":{\"message\":\"prompt is too long: 210000 tokens > 200000 maximum\"}}", None).msg));
|
||||||
|
assert!(is_context_overflow(&stream_failure("prompt is too long: 210000 tokens > 200000 maximum").msg));
|
||||||
|
assert!(!is_context_overflow(&http_error(401, "{}", None).msg));
|
||||||
|
assert!(!is_context_overflow(&http_error(400, "{\"error\":{\"message\":\"tools are not supported\"}}", None).msg));
|
||||||
|
assert!(!is_context_overflow(&stream_failure("overloaded").msg));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_style_stream() {
|
fn openai_style_stream() {
|
||||||
let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"hm\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"!\"}}]}\n\ndata: [DONE]\n\n";
|
let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"hm\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"!\"}}]}\n\ndata: [DONE]\n\n";
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ pub enum Cmd {
|
||||||
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
|
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
|
||||||
Regenerate { bot: u64 },
|
Regenerate { bot: u64 },
|
||||||
Reply { bot: u64 },
|
Reply { bot: u64 },
|
||||||
|
/// 「繼續」 on the card a crash left: the cut-off turn goes on.
|
||||||
|
Resume { bot: u64 },
|
||||||
/// Bring back an earlier conversation (過去的對話).
|
/// Bring back an earlier conversation (過去的對話).
|
||||||
RestoreThread { bot: u64, index: usize },
|
RestoreThread { bot: u64, index: usize },
|
||||||
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
|
/// 清除聊天記錄 from a screen of this brain (the chat lives here).
|
||||||
|
|
@ -260,7 +262,7 @@ pub fn tool_label(name: &str) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_detail(name: &str, args: &str) -> String {
|
pub(crate) fn tool_detail(name: &str, args: &str) -> String {
|
||||||
let d = if crate::appctl::is_app_tool(name) {
|
let d = if crate::appctl::is_app_tool(name) {
|
||||||
crate::appctl::describe(name, args)
|
crate::appctl::describe(name, args)
|
||||||
} else if crate::computer::is_computer_tool(name) {
|
} else if crate::computer::is_computer_tool(name) {
|
||||||
|
|
@ -306,6 +308,7 @@ pub fn message_json(m: &crate::model::Message, names: &HashMap<u64, String>, pag
|
||||||
"via": m.via,
|
"via": m.via,
|
||||||
"error": m.error,
|
"error": m.error,
|
||||||
"summary": m.summary,
|
"summary": m.summary,
|
||||||
|
"interrupted": m.interrupted,
|
||||||
"wake": m.wake,
|
"wake": m.wake,
|
||||||
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
|
"author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() },
|
||||||
"reactions": m.reactions,
|
"reactions": m.reactions,
|
||||||
|
|
@ -548,6 +551,7 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
||||||
"new" => Cmd::NewThread { bot },
|
"new" => Cmd::NewThread { bot },
|
||||||
"regenerate" => Cmd::Regenerate { bot },
|
"regenerate" => Cmd::Regenerate { bot },
|
||||||
"reply" => Cmd::Reply { bot },
|
"reply" => Cmd::Reply { bot },
|
||||||
|
"resume" => Cmd::Resume { bot },
|
||||||
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
|
"restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize },
|
||||||
"clear" => Cmd::Clear { bot },
|
"clear" => Cmd::Clear { bot },
|
||||||
_ => return Resp::error(404, "沒有這個動作"),
|
_ => return Resp::error(404, "沒有這個動作"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue