LazyBoy2/crates/grokboy-core/src/runtime.rs

502 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Turn state, one stdin owner, typed events, and durable checkpoints.
use crate::{ChatMessage, Session};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
collections::VecDeque,
future::Future,
io::BufRead,
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::sync::{oneshot, watch, Notify};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlanStep {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
Status {
message: String,
},
Progress {
message: String,
},
PlanUpdated {
plan: Vec<PlanStep>,
explanation: Option<String>,
},
ToolStarted {
id: String,
name: String,
},
ToolFinished {
id: String,
name: String,
success: bool,
},
Waiting {
stage: String,
elapsed_secs: u64,
},
Question {
question: Value,
},
Steering {
message: String,
},
TurnEnded {
verdict: String,
message: String,
},
}
#[derive(Default)]
struct InputState {
lines: VecDeque<String>,
steering: VecDeque<String>,
question: Option<oneshot::Sender<Option<String>>>,
eof: bool,
running: bool,
}
pub struct InputBroker {
state: Mutex<InputState>,
notify: Notify,
cancel: watch::Sender<bool>,
persistent: bool,
}
impl std::fmt::Debug for InputBroker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InputBroker")
}
}
impl InputBroker {
pub fn new() -> Arc<Self> {
Self::with_persistence(false)
}
pub(crate) fn persistent() -> Arc<Self> {
Self::with_persistence(true)
}
fn with_persistence(persistent: bool) -> Arc<Self> {
Arc::new(Self {
persistent,
state: Mutex::new(InputState::default()),
notify: Notify::new(),
cancel: watch::channel(false).0,
})
}
pub fn stdin() -> Arc<Self> {
let broker = Self::new();
let reader = broker.clone();
// This is the only stdin reader for run/agent, including human tools.
std::thread::spawn(move || {
for line in std::io::stdin().lock().lines() {
match line {
Ok(line) => reader.feed(line),
Err(_) => break,
}
}
reader.close();
});
broker
}
pub(crate) fn can_ask(&self) -> bool {
!self.state.lock().unwrap().eof
}
pub(crate) fn has_question(&self) -> bool {
self.state.lock().unwrap().question.is_some()
}
pub fn feed(&self, line: String) {
let mut state = self.state.lock().unwrap();
if state.running && matches!(line.trim(), "/stop" | "/exit" | "/quit") {
self.cancel.send_replace(true);
return;
}
if let Some(answer) = state.question.take() {
let _ = answer.send(Some(line));
} else if state.running {
state.steering.push_back(line);
} else {
state.lines.push_back(line);
}
drop(state);
self.notify.notify_one();
}
pub fn close(&self) {
let mut state = self.state.lock().unwrap();
state.eof = true;
if let Some(answer) = state.question.take() {
let _ = answer.send(None);
}
drop(state);
self.notify.notify_one();
}
pub fn begin(&self) {
self.cancel.send_replace(false);
self.state.lock().unwrap().running = true;
}
pub fn end(&self) {
let mut s = self.state.lock().unwrap();
s.running = false;
s.question.take();
let rest = s.steering.drain(..).collect::<Vec<_>>();
s.lines.extend(rest);
}
pub fn interrupt(&self) {
if self.state.lock().unwrap().running {
self.cancel.send_replace(true);
} else {
self.feed("/exit".into());
}
}
pub fn cancelled(&self) -> bool {
*self.cancel.borrow()
}
pub async fn cancellation(&self) {
let mut rx = self.cancel.subscribe();
loop {
if *rx.borrow_and_update() {
return;
}
if rx.changed().await.is_err() {
return;
}
}
}
pub fn drain(&self) -> Vec<String> {
self.state
.lock()
.unwrap()
.steering
.drain(..)
.filter(|s| !s.trim().is_empty())
.collect()
}
pub async fn next(&self) -> Option<String> {
loop {
let notified = self.notify.notified();
{
let mut s = self.state.lock().unwrap();
if let Some(line) = s.lines.pop_front() {
return Some(line);
}
if s.eof {
return None;
}
}
notified.await;
}
}
pub async fn ask(&self, seconds: u64, on_ready: impl FnOnce()) -> Result<String> {
let (tx, rx) = oneshot::channel();
{
let mut s = self.state.lock().unwrap();
if s.eof {
drop(s);
on_ready();
return Err(anyhow!("stdin closed; answer in a resumed session"));
}
s.question = Some(tx);
}
on_ready();
let result = tokio::select! {
biased;
_ = self.cancellation() => Err(anyhow!("cancelled")),
answer = async { if self.persistent { Ok(rx.await) } else { tokio::time::timeout(Duration::from_secs(seconds.clamp(1, 3600)), rx).await } } => match answer {
Ok(Ok(Some(line))) => Ok(line),
Ok(_) => Err(anyhow!("stdin closed")),
Err(_) => Err(anyhow!("user input timed out")),
}
};
self.state.lock().unwrap().question.take();
result
}
}
type CheckpointSink = Arc<dyn Fn(&Session) -> Result<()> + Send + Sync>;
type EventSink = Arc<dyn Fn(&AgentEvent) + Send + Sync>;
#[derive(Default)]
pub struct Runtime {
pub input: Option<Arc<InputBroker>>,
pub plan: Mutex<Vec<PlanStep>>,
pub pending_question: Mutex<Option<Value>>,
pub active_command: Mutex<Option<Value>>,
pub browser_url: Mutex<Option<String>>,
pub(crate) browser_profile: Mutex<Option<PathBuf>>,
checkpoint: Mutex<Option<Session>>,
pub events: Mutex<Vec<AgentEvent>>,
event_sink: Mutex<Option<EventSink>>,
checkpoint_sink: Mutex<Option<CheckpointSink>>,
}
impl std::fmt::Debug for Runtime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Runtime")
}
}
impl Runtime {
pub fn with_input(input: Arc<InputBroker>) -> Arc<Self> {
Arc::new(Self {
input: Some(input),
..Default::default()
})
}
pub fn for_session(session: &Session, input: Arc<InputBroker>) -> Arc<Self> {
Arc::new(Self {
input: Some(input),
plan: Mutex::new(session.plan.clone()),
pending_question: Mutex::new(session.pending_question.clone()),
active_command: Mutex::new(session.active_command.clone()),
browser_url: Mutex::new(session.last_browser_url.clone()),
browser_profile: Mutex::new(None),
checkpoint: Mutex::new(Some(session.clone())),
events: Mutex::new(vec![]),
event_sink: Mutex::new(None),
checkpoint_sink: Mutex::new(None),
})
}
pub(crate) fn set_checkpoint_handler(
&self,
handler: impl Fn(&Session) -> Result<()> + Send + Sync + 'static,
) {
*self.checkpoint_sink.lock().unwrap() = Some(Arc::new(handler));
}
fn save_checkpoint(&self, session: &Session) -> Result<()> {
if let Some(sink) = self.checkpoint_sink.lock().unwrap().clone() {
sink(session)
} else {
crate::save_session(session).map(|_| ())
}
}
pub fn set_event_handler(&self, handler: impl Fn(&AgentEvent) + Send + Sync + 'static) {
*self.event_sink.lock().unwrap() = Some(Arc::new(handler));
}
pub fn emit(&self, event: AgentEvent) {
let sink = self.event_sink.lock().unwrap().clone();
if let Some(sink) = sink {
sink(&event);
}
self.events.lock().unwrap().push(event);
}
pub async fn wait<T>(&self, stage: &str, future: impl Future<Output = Result<T>>) -> Result<T> {
tokio::pin!(future);
let start = std::time::Instant::now();
let mut tick = tokio::time::interval(Duration::from_secs(20));
tick.tick().await;
loop {
tokio::select! {
biased;
_ = async { match &self.input { Some(i) => i.cancellation().await, None => std::future::pending().await } } => return Err(anyhow!("cancelled")),
result = &mut future => return result,
_ = tick.tick() => self.emit(AgentEvent::Waiting { stage: stage.into(), elapsed_secs: start.elapsed().as_secs() }),
}
}
}
pub fn cancelled(&self) -> bool {
self.input.as_ref().is_some_and(|i| i.cancelled())
}
pub fn steering(&self) -> Vec<String> {
self.input.as_ref().map(|i| i.drain()).unwrap_or_default()
}
pub fn update_plan(&self, args: &Value) -> Result<Value> {
let next: Vec<PlanStep> = serde_json::from_value(args["plan"].clone())?;
if next.is_empty()
|| next.len() > 12
|| next.iter().any(|s| s.step.trim().is_empty())
|| next
.iter()
.filter(|s| s.status == StepStatus::InProgress)
.count()
> 1
{
return Err(anyhow!(
"plan requires 112 nonempty steps, at most one in_progress"
));
}
let mut current = self.plan.lock().unwrap();
let explanation = args["explanation"]
.as_str()
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
if !current.is_empty()
&& current.iter().map(|s| &s.step).collect::<Vec<_>>()
!= next.iter().map(|s| &s.step).collect::<Vec<_>>()
&& explanation.is_none()
{
return Err(anyhow!("explain why the plan steps changed"));
}
*current = next.clone();
drop(current);
self.emit(AgentEvent::PlanUpdated {
plan: next,
explanation,
});
Ok(json!({"updated":true}))
}
pub fn unfinished(&self) -> bool {
self.plan
.lock()
.unwrap()
.iter()
.any(|s| s.status != StepStatus::Completed)
}
pub fn checkpoint(&self, messages: &[ChatMessage], pending_tool: Option<&str>) -> Result<()> {
let mut guard = self.checkpoint.lock().unwrap();
if let Some(s) = guard.as_mut() {
s.messages = messages.to_vec();
s.plan = self.plan.lock().unwrap().clone();
s.pending_question = self.pending_question.lock().unwrap().clone();
s.pending_tool = pending_tool.map(str::to_string);
s.active_command = self.active_command.lock().unwrap().clone();
s.last_browser_url = self
.browser_url
.lock()
.unwrap()
.clone()
.or(s.last_browser_url.clone());
s.touch();
self.save_checkpoint(s)?;
}
Ok(())
}
pub fn sync_session(&self, session: &mut Session) {
session.plan = self.plan.lock().unwrap().clone();
session.pending_question = self.pending_question.lock().unwrap().clone();
session.pending_tool = None;
session.active_command = self.active_command.lock().unwrap().clone();
}
pub fn profile_dir(&self) -> Option<PathBuf> {
if let Some(path) = self.browser_profile.lock().unwrap().clone() {
return Some(path);
}
self.checkpoint.lock().unwrap().as_ref().and_then(|s| {
(if self.checkpoint_sink.lock().unwrap().is_some() {
Ok(crate::team::data_dir().join("profiles"))
} else {
crate::sessions_dir()
})
.ok()
.map(|d| d.join(format!("{}.browser", s.id)))
})
}
pub fn save_output(&self, output: &str) -> Result<Option<String>> {
if output.len() <= 16000 {
return Ok(None);
}
let guard = self.checkpoint.lock().unwrap();
let Some(s) = guard.as_ref() else {
return Ok(None);
};
// Artifacts belong to the workspace so read_file can access them.
let dir = s.cwd.join(".grokboy-output").join(&s.id);
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&path, output)?;
Ok(Some(json!({"preview":output.chars().take(4000).collect::<String>(),"output_file":path,"bytes":output.len(),"truncated":true,"hint":"read_file with offset/limit to inspect full output"}).to_string()))
}
pub async fn question(&self, args: &Value) -> Result<Value> {
let input = self
.input
.as_ref()
.ok_or_else(|| anyhow!("no interactive input channel"))?;
*self.pending_question.lock().unwrap() = Some(args.clone());
// Persist pending question against latest pre-tool checkpoint.
{
let mut guard = self.checkpoint.lock().unwrap();
if let Some(s) = guard.as_mut() {
s.pending_question = Some(args.clone());
s.last_browser_url = self
.browser_url
.lock()
.unwrap()
.clone()
.or(s.last_browser_url.clone());
self.save_checkpoint(s)?;
}
}
let line = input
.ask(args["timeout_secs"].as_u64().unwrap_or(300), || {
self.emit(AgentEvent::Question {
question: args.clone(),
})
})
.await?;
*self.pending_question.lock().unwrap() = None;
let answer = line
.trim()
.parse::<usize>()
.ok()
.and_then(|n| n.checked_sub(1))
.or_else(|| {
let text = line.trim().to_ascii_lowercase();
if text.len() == 1 {
text.as_bytes()[0]
.checked_sub(b'a')
.filter(|n| *n < 26)
.map(usize::from)
} else {
None
}
})
.and_then(|n| args["options"].get(n))
.and_then(Value::as_str)
.unwrap_or(line.trim())
.to_string();
Ok(json!({"answer":answer}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn queued_tasks_steering_and_answers_do_not_steal_each_other() {
let input = InputBroker::new();
input.feed("task one".into());
input.feed("task two".into());
assert_eq!(input.next().await.as_deref(), Some("task one"));
input.begin();
input.feed("keep original goal, add constraint".into());
assert_eq!(input.drain(), vec!["keep original goal, add constraint"]);
let answer = input.ask(2, || input.feed("answer".into())).await.unwrap();
assert_eq!(answer, "answer");
assert!(input.drain().is_empty());
input.end();
assert_eq!(input.next().await.as_deref(), Some("task two"));
}
#[tokio::test]
async fn cancellation_interrupts_a_pending_question_without_losing_next_input() {
let input = InputBroker::new();
input.begin();
assert!(input.ask(30, || input.interrupt()).await.is_err());
input.end();
input.feed("resume".into());
assert_eq!(input.next().await.as_deref(), Some("resume"));
}
#[test]
fn plan_changes_require_explanation_and_one_active_step() {
let runtime = Runtime::default();
runtime
.update_plan(&json!({"plan":[{"step":"inspect","status":"in_progress"}]}))
.unwrap();
assert!(runtime
.update_plan(&json!({"plan":[{"step":"other","status":"in_progress"}]}))
.is_err());
assert!(runtime.update_plan(&json!({"explanation":"new discovery","plan":[{"step":"other","status":"in_progress"},{"step":"third","status":"in_progress"}]})).is_err());
runtime.update_plan(&json!({"explanation":"new discovery","plan":[{"step":"other","status":"completed"}]})).unwrap();
assert!(!runtime.unfinished());
}
}