Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
108 changes: 71 additions & 37 deletions packages/dex-host-rs/src/host_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -70,7 +73,9 @@ pub struct HostTurnRun<M: Model + 'static> {
principal: PrincipalId,
local: LocalLog,
engine: Arc<Engine<ObservedLog<LocalLog>, M, HostTools, LocalEffects, Lexicon>>,
observed: mpsc::UnboundedReceiver<Observed>,
observed: mpsc::Receiver<()>,
observed_offset: u64,
caller_events: BTreeSet<Cursor>,
cancel: CancellationToken,
running: Option<JoinHandle<Finished>>,
buffered: VecDeque<Observed>,
Expand Down Expand Up @@ -124,6 +129,10 @@ impl<M: Model + 'static> HostTurnRun<M> {
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,
Expand All @@ -140,6 +149,8 @@ impl<M: Model + 'static> HostTurnRun<M> {
local,
engine: Arc::new(engine),
observed,
observed_offset,
caller_events: BTreeSet::new(),
cancel: CancellationToken::new(),
running: None,
buffered: VecDeque::new(),
Expand Down Expand Up @@ -201,38 +212,53 @@ impl<M: Model + 'static> HostTurnRun<M> {
/// The next accepted write, park, or the exit. After `Step::Exit` it
/// keeps returning that exit.
pub async fn next(&mut self) -> Result<Step, String> {
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<Step, String> {
Expand Down Expand Up @@ -282,7 +308,8 @@ impl<M: Model + 'static> HostTurnRun<M> {
binding: &ActionConfirmation,
approved: bool,
) -> Result<(), String> {
self.local
let cursors = self
.local
.append(&[Event::Answer {
call: question,
principal: self.principal.clone(),
Expand All @@ -295,8 +322,9 @@ impl<M: Model + 'static> HostTurnRun<M> {
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.
Expand All @@ -306,7 +334,8 @@ impl<M: Model + 'static> HostTurnRun<M> {
succeeded: bool,
output: String,
) -> Result<(), String> {
self.local
let cursors = self
.local
.append(&[Event::ClientToolResult {
call,
principal: self.principal.clone(),
Expand All @@ -318,8 +347,9 @@ impl<M: Model + 'static> HostTurnRun<M> {
output,
}])
.await
.map(|_| ())
.map_err(|fenced| fenced.to_string())
.map_err(|fenced| fenced.to_string())?;
self.caller_events.extend(cursors);
Ok(())
}
}

Expand All @@ -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;
Loading
Loading