diff --git a/.repository-projection.json b/.repository-projection.json index b7032a333..790a8bdc5 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "880d24b8d9656db0b8d3c859644bb7fcee1d410c", + "sourceSha": "0c170bbd62d8b748893c14be7a0bbc39efb1bc41", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "4ee737816a77f0f27974d1aa3dea0465de0e299c", + "priorProjectedBase": "cc2b26fe4f4847aac0975d7034e81c93adf0fe29", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13", - "contentDigest": "9f55a740a2489f9028851b0a74b3042b3ce8d15f9486fd7db0cc10bd8d2a1285", + "contentDigest": "ba7079edf519d432198922f09bd444f8237a82c25af2dfc48f16989971795bc0", "publicationEligible": true } diff --git a/packages/dex-host-rs/src/host_turn.rs b/packages/dex-host-rs/src/host_turn.rs index 06915f4b8..f7cc034e7 100644 --- a/packages/dex-host-rs/src/host_turn.rs +++ b/packages/dex-host-rs/src/host_turn.rs @@ -9,14 +9,14 @@ //! This is the one place a Maestro surface drives the kernel; the gateway's //! chat endpoints, automations and A2A turns are thin projections of it. -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; use dex_loop::{ ActionConfirmation, ApprovalMode, ArtifactRef, Budget, CallId, CancellationToken, - ClientToolSpec, ConfirmationDecision, Engine, Event, Exit, Lexicon, Log as _, Model, Outcome, - PrincipalId, ThreadId, ToolName, TurnId, rehydrate, + ClientToolSpec, ConfirmationDecision, Cursor, Engine, Event, Exit, Lexicon, Log as _, Model, + Outcome, PrincipalId, ThreadId, ToolName, TurnId, rehydrate, }; use serde_json::Value; use tokio::sync::mpsc; @@ -61,6 +61,9 @@ pub enum Step { Exit(Exit), } +/// At most this many payloads are retained between pulls. +const OBSERVED_PAGE_SIZE: usize = 32; + type Finished = Result<(Exit, dex_loop::Context), String>; /// A turn in progress. See the module docs. @@ -70,7 +73,9 @@ pub struct HostTurnRun { principal: PrincipalId, local: LocalLog, engine: Arc, M, HostTools, LocalEffects, Lexicon>>, - observed: mpsc::UnboundedReceiver, + observed: mpsc::Receiver<()>, + observed_offset: u64, + caller_events: BTreeSet, cancel: CancellationToken, running: Option>, buffered: VecDeque, @@ -124,6 +129,10 @@ impl HostTurnRun { let effects = LocalEffects::open(dir.join("effects.json")) .await .map_err(|error| format!("open the effect ledger: {error}"))?; + let observed_offset = local + .observer_offset() + .await + .map_err(|error| format!("start observing the turn log: {error}"))?; let (log, observed) = ObservedLog::new(local.clone()); let engine = Engine::new( log, @@ -140,6 +149,8 @@ impl HostTurnRun { local, engine: Arc::new(engine), observed, + observed_offset, + caller_events: BTreeSet::new(), cancel: CancellationToken::new(), running: None, buffered: VecDeque::new(), @@ -201,38 +212,53 @@ impl HostTurnRun { /// The next accepted write, park, or the exit. After `Step::Exit` it /// keeps returning that exit. pub async fn next(&mut self) -> Result { - if let Some(observed) = self.buffered.pop_front() { - return Ok(Step::Observed(observed)); - } - if let Some(exit) = &self.exited { - return Ok(Step::Exit(exit.clone())); - } - if let Some(finished) = self.finished.take() { - return self.settle(finished); - } - if self.running.is_none() { - self.spawn(); - } - let running = self.running.as_mut().expect("spawned above"); - let finished = tokio::select! { - biased; - Some(observed) = self.observed.recv() => { + loop { + if let Some(observed) = self.buffered.pop_front() { self.note(&observed); return Ok(Step::Observed(observed)); } - finished = running => finished.map_err(|error| format!("the turn task failed: {error}"))?, - }; - self.running = None; - // Everything the run wrote reaches the caller before its outcome. - while let Ok(observed) = self.observed.try_recv() { - self.note(&observed); - self.buffered.push_back(observed); - } - if let Some(observed) = self.buffered.pop_front() { + if let Some(exit) = &self.exited { + return Ok(Step::Exit(exit.clone())); + } + // Catch up even without a wakeup: notifications may coalesce, + // and completion may win the select after the final write. + let (offset, entries) = self + .local + .read_observed(self.observed_offset, OBSERVED_PAGE_SIZE) + .await + .map_err(|error| format!("read observed turn writes: {error}"))?; + self.observed_offset = offset; + let had_entries = !entries.is_empty(); + for (cursor, event) in entries { + // The old observer wrapped only engine writes. Answers and + // caller tool results still reach rehydrate, not the surface. + if self.caller_events.remove(&cursor) { + continue; + } + self.buffered.push_back(match event { + Event::TextDelta { text } => Observed::Text(text), + event => Observed::Event(cursor, Box::new(event)), + }); + } + if had_entries { + continue; + } + // Everything the run wrote reaches the caller before its outcome. + if let Some(finished) = self.finished.take() { + return self.settle(finished); + } + if self.running.is_none() { + self.spawn(); + } + let running = self.running.as_mut().expect("spawned above"); + let finished = tokio::select! { + biased; + Some(()) = self.observed.recv() => continue, + finished = running => finished.map_err(|error| format!("the turn task failed: {error}"))?, + }; + self.running = None; self.finished = Some(finished); - return Ok(Step::Observed(observed)); } - self.settle(finished) } fn settle(&mut self, finished: Finished) -> Result { @@ -282,7 +308,8 @@ impl HostTurnRun { binding: &ActionConfirmation, approved: bool, ) -> Result<(), String> { - self.local + let cursors = self + .local .append(&[Event::Answer { call: question, principal: self.principal.clone(), @@ -295,8 +322,9 @@ impl HostTurnRun { args_digest: binding.args_digest.clone(), }]) .await - .map(|_| ()) - .map_err(|fenced| fenced.to_string()) + .map_err(|fenced| fenced.to_string())?; + self.caller_events.extend(cursors); + Ok(()) } /// Answers a `Park::ClientTool`; the next pull resumes the turn. @@ -306,7 +334,8 @@ impl HostTurnRun { succeeded: bool, output: String, ) -> Result<(), String> { - self.local + let cursors = self + .local .append(&[Event::ClientToolResult { call, principal: self.principal.clone(), @@ -318,8 +347,9 @@ impl HostTurnRun { output, }]) .await - .map(|_| ()) - .map_err(|fenced| fenced.to_string()) + .map_err(|fenced| fenced.to_string())?; + self.caller_events.extend(cursors); + Ok(()) } } @@ -331,3 +361,7 @@ pub fn turn_dir(prefix: &str) -> PathBuf { .unwrap_or_default(); std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) } + +#[cfg(test)] +#[path = "host_turn/tests.rs"] +mod tests; diff --git a/packages/dex-host-rs/src/host_turn/tests.rs b/packages/dex-host-rs/src/host_turn/tests.rs new file mode 100644 index 000000000..175332c27 --- /dev/null +++ b/packages/dex-host-rs/src/host_turn/tests.rs @@ -0,0 +1,263 @@ +use super::*; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use dex_loop::{Context, ExecutorKind, GovernanceClass, ModelChunk, ModelError, ToolSpec}; +use futures_util::{Stream, stream}; +use maestro_local_host::agent::{NativeAgentConfig, dex_loop_execution_host}; +use maestro_runtime::agent::CredentialVault; +use maestro_runtime::agent::native_host::ApprovalMode as NativeApprovalMode; +use serde_json::json; +use tempfile::TempDir; + +type Script = Box Vec> + Send + Sync>; + +struct ScriptedModel(Mutex>); + +impl Model for ScriptedModel { + fn stream<'a>( + &'a self, + ctx: &'a Context, + _tools: &'a [&'a ToolSpec], + ) -> impl Stream> + Send + 'a { + let script = self + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .pop_front() + .expect("scripted model response"); + stream::iter(script(ctx)) + } +} + +fn scripted(chunks: Vec) -> Script { + Box::new(move |_| chunks.iter().cloned().map(Ok).collect()) +} + +fn text_chunks() -> Vec { + (0..OBSERVED_PAGE_SIZE * 3) + .map(|index| ModelChunk::Text(format!("chunk-{index} "))) + .collect() +} + +fn tools(workspace: &Path, mode: NativeApprovalMode) -> HostTools { + let config = NativeAgentConfig { + cwd: workspace.to_string_lossy().into_owned(), + ..NativeAgentConfig::default() + }; + let host = + dex_loop_execution_host(&config, CredentialVault::new()).expect("compose native host"); + HostTools::new(host, mode) +} + +async fn start(dir: &Path, tools: HostTools, scripts: Vec