diff --git a/crates/rhema-claw/Cargo.toml b/crates/rhema-claw/Cargo.toml index f9bc695..d3f7034 100644 --- a/crates/rhema-claw/Cargo.toml +++ b/crates/rhema-claw/Cargo.toml @@ -32,6 +32,8 @@ rhema-autonomous-agent = { path = "../rhema-autonomous-agent" } rhema-coding-agent = { path = "../rhema-coding-agent" } rhema-config = { path = "../rhema-config" } rhema-core = { path = "../rhema-core" } +# The bounded request view over the claw sessions, on by default here +rhema-transcript = { path = "../rhema-transcript" } rhema-calendar-integration = { path = "../../integrations/rhema-calendar-integration" } rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" } # The accounts layer: identities hold the forge tokens the watches poll diff --git a/crates/rhema-claw/README.md b/crates/rhema-claw/README.md index 44e0cf2..2d3debd 100644 --- a/crates/rhema-claw/README.md +++ b/crates/rhema-claw/README.md @@ -44,6 +44,16 @@ budget — in a named workspace, with one tool and an `action` argument: two agents editing the same files. Across instances, jobs run in parallel. +### The bounded request view + +A claw session never ends: the interactive chat is one conversation, +forge threads live as long as their pull requests, and a coding job's +session carries to its next run. From the start, the claw therefore +runs the transcript's bounded request view (`rhema-transcript`): +past a byte budget, older messages summarize into `id+n|text` lines +the agent's `zoom` tool opens to read any original back. The config +turns it off with `transcript: false`. + For the debug loop, daemon plus UI against throwaway state and zero credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock` diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index 328e90f..97844ad 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -278,6 +278,13 @@ pub struct ClawConfig { pub model: Option, /// The HTTP surface's `host:port`. [`DEFAULT_LISTEN`] when unset. pub listen: Option, + /// The bounded request view over the claw's sessions — past a byte + /// budget, older messages summarize into lines the agent's `zoom` + /// tool can open. On when unset; `transcript: false` sends the + /// whole history every step, as the coding agent does by default. + /// The claw turns it on because its chats and coding jobs never end + /// by design: a stashed session only ever grows. + pub transcript: Option, /// Bearer token for the `/v1` routes. Unset means the surface is /// open on its interface; set it when the port is reachable from /// anywhere but loopback. `/healthz` stays open either way. @@ -292,6 +299,14 @@ pub struct ClawConfig { pub calendars: Vec, } +impl ClawConfig { + /// Whether the claw's sessions run the bounded request view: on + /// unless the config says `transcript: false`. + pub fn transcript_enabled(&self) -> bool { + self.transcript != Some(false) + } +} + impl Default for ClawConfig { fn default() -> Self { Self { @@ -305,6 +320,7 @@ impl Default for ClawConfig { model: None, listen: None, token: None, + transcript: None, watches: Vec::new(), calendars: Vec::new(), } @@ -568,6 +584,21 @@ impl ClawConfig { #[cfg(test)] mod tests { + #[test] + fn the_transcript_is_on_unless_the_config_turns_it_off() { + assert!(ClawConfig::default().transcript_enabled()); + let off = ClawConfig { + transcript: Some(false), + ..ClawConfig::default() + }; + assert!(!off.transcript_enabled()); + let explicit_on = ClawConfig { + transcript: Some(true), + ..ClawConfig::default() + }; + assert!(explicit_on.transcript_enabled()); + } + use super::*; #[test] diff --git a/crates/rhema-claw/src/daemon.rs b/crates/rhema-claw/src/daemon.rs index 5c6ad81..d893b03 100644 --- a/crates/rhema-claw/src/daemon.rs +++ b/crates/rhema-claw/src/daemon.rs @@ -233,6 +233,7 @@ impl Daemon { workspace_root, watches.clone(), accounts.clone(), + config.transcript_enabled(), ); // Three drivers over one store admit from the same table, so // each owns its slice of the key space: a forge pass must never @@ -326,6 +327,7 @@ impl Daemon { notifications.clone(), config.max_turn_steps, extensions.clone(), + config.transcript_enabled(), ); let interactive_driver = Arc::new( Driver::new( @@ -345,6 +347,7 @@ impl Daemon { jobs.clone(), notifications.clone(), config.max_turn_steps, + config.transcript_enabled(), ); let jobs_driver = Arc::new( Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler)) diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs index 7af17a7..8962ed2 100644 --- a/crates/rhema-claw/src/handler.rs +++ b/crates/rhema-claw/src/handler.rs @@ -57,6 +57,9 @@ fn allowlist() -> rhema_autonomous_agent::Allowlist { pub struct ClawHandler { /// The deployment's providers and the active pair, read per dispatch. backends: Arc, + /// Whether the sessions run the bounded request view (the config's + /// `transcript`, on unless set false). + transcript: bool, workspace_root: PathBuf, watches: Arc, /// The account store a blank watch credential resolves through: the @@ -74,9 +77,11 @@ impl ClawHandler { workspace_root: impl Into, watches: Arc, accounts: Arc, + transcript: bool, ) -> Self { Self { backends, + transcript, workspace_root: workspace_root.into(), watches, accounts, @@ -210,7 +215,7 @@ impl ClawHandler { key: &str, ) -> Result { let (provider, model) = self.backends.current(); - let config = CodingAgentConfig::new(model, workspace); + let config = self.agent_config(&model, workspace); let agent = match &ctx.session.core.driver_state { // A never-dispatched key carries no stash: start clean. serde_json::Value::Null => CodingAgent::new(provider, config), @@ -223,6 +228,18 @@ impl ClawHandler { agent.map_err(|err| format!("cannot start the coding agent for {key}: {err}")) } + /// The config one dispatch's agent runs on: the default coding + /// agent's, with the bounded request view when the config has it on. + /// A forge thread answers for as long as the thread lives; without + /// the view its history would ride whole on every step. + fn agent_config(&self, model: &str, workspace: &Path) -> CodingAgentConfig { + let mut config = CodingAgentConfig::new(model, workspace); + if self.transcript { + config.transcript = Some(rhema_transcript::TranscriptSettings::default()); + } + config + } + /// The per-thread workspace: one directory under the root, created on /// demand. The name mapping is not injective (separators collapse); /// thread identity lives in the dispatch key — the directory is only @@ -759,6 +776,7 @@ mod tests { "/tmp/w", watches, Arc::new(rhema_identity::VolatileIdentityStore::new()), + true, ); // A watch added to the table AFTER the handler was built resolves: @@ -780,6 +798,40 @@ mod tests { ); } + #[test] + fn the_transcript_setting_reaches_the_dispatch_config() { + let watches = Arc::new(ClawStore::in_memory().expect("store opens")); + let registry = Arc::new( + crate::backends::BackendRegistry::new( + vec![crate::backends::Backend::headless( + "mock", + Arc::new(NoopProvider), + )], + None, + ) + .expect("constructs"), + ); + let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new()); + let on = ClawHandler::new( + registry.clone(), + "/tmp/w", + watches.clone(), + accounts.clone(), + true, + ); + let off = ClawHandler::new(registry, "/tmp/w", watches, accounts, false); + assert!( + on.agent_config("m", Path::new("/tmp/w")) + .transcript + .is_some() + ); + assert!( + off.agent_config("m", Path::new("/tmp/w")) + .transcript + .is_none() + ); + } + #[test] fn the_workspace_name_flattens_to_filesystem_safe_characters() { assert_eq!( diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs index fd0a688..daae65a 100644 --- a/crates/rhema-claw/src/interactive.rs +++ b/crates/rhema-claw/src/interactive.rs @@ -182,6 +182,10 @@ pub struct InteractiveHandler { /// dispatch, in the standard set's order — the same set whose /// sources the daemon's tick drives and whose boot pass runs first. extensions: Arc, + /// The transcript's mirror, when the config has the view on: the + /// decorator refreshes it per request, `zoom` reads it, and it + /// lives here — the agent rebuilds per dispatch, the log must not. + transcript_mirror: Option>>, } impl InteractiveHandler { @@ -193,7 +197,7 @@ impl InteractiveHandler { /// shell (approval v1-permissive — the shell is the point of a /// resident coding agent — installed once, since resolution is /// synchronous within a turn), memory, and `bootstrap_save`. - #[allow(clippy::too_many_arguments)] // eight parts, each load-bearing + #[allow(clippy::too_many_arguments)] // nine parts, each load-bearing pub fn new( backends: Arc, workspace: impl Into, @@ -203,6 +207,7 @@ impl InteractiveHandler { notifications: Arc, max_turn_steps: u32, extensions: Arc, + transcript: bool, ) -> Self { let workspace = workspace.into(); let tools = Arc::new(ShellTools::new(&workspace)); @@ -220,6 +225,11 @@ impl InteractiveHandler { notifications, max_turn_steps, extensions, + transcript_mirror: transcript.then(|| { + Arc::new(std::sync::Mutex::new(rhema_transcript::Transcript::new( + rhema_transcript::TranscriptSettings::default(), + ))) + }), } } @@ -238,6 +248,15 @@ impl InteractiveHandler { // the same expansion the coding driver wraps its own with: the // model sees plain text, the transcript keeps the chips. let provider: Arc = Arc::new(ExpandPastes::new(provider)); + // The view rides outside the expansion: stubs, whole messages, + // and zoom output all show the text the model would have seen. + let provider: Arc = match &self.transcript_mirror { + Some(mirror) => Arc::new(rhema_transcript::TranscriptView::new( + provider, + mirror.clone(), + )), + None => provider, + }; let resolver = self.resolver(key); let system = render_system( DEFAULT_SYSTEM_MD, @@ -273,6 +292,11 @@ impl InteractiveHandler { self.bootstrap_resolver.clone(), ]; resolvers.extend(self.extensions.tools(key)); + if let Some(mirror) = &self.transcript_mirror { + // Unique name, read-only: it shadows nothing and a replay + // may re-run it after a crash resume. + resolvers.push(Arc::new(rhema_transcript::Zoom::new(mirror.clone()))); + } Arc::new(ResolverChain::new(resolvers)) } @@ -730,6 +754,47 @@ mod tests { } } + /// The transcript wiring: on, the prompt advertises zoom and the + /// mirror exists; off, neither appears and the request rides whole. + #[test] + fn the_transcript_joins_zoom_when_on_and_nothing_when_off() { + for on in [true, false] { + let workspace = tempfile::tempdir().expect("tempdir"); + let hub = Arc::new(EventHub::default()); + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let notifications = notifications(); + let extensions = test_extensions( + hub.clone(), + store, + notifications.clone(), + tasks(), + jobs(), + accounts(), + consents(), + bridge(), + attachments(), + phone(), + identity(), + &workspace.path().join("interactive"), + ); + let handler = InteractiveHandler::new( + test_registry(RecordingProvider::new()), + workspace.path().join("interactive"), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub, + notifications, + crate::config::DEFAULT_MAX_TURN_STEPS, + extensions, + on, + ); + let key = Key::from("interactive:web"); + let agent = handler.agent(&key).expect("the agent builds"); + assert_eq!(agent.system().contains("zoom"), on, "on={on}"); + assert_eq!(handler.transcript_mirror.is_some(), on, "on={on}"); + } + } + /// The paste contract across the provider seam: a chat input that /// carries an embedded paste (the compact form the UIs mint) is /// expanded before the request leaves, while the transcript the hub @@ -764,6 +829,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let dispatcher = Arc::new(Dispatcher::new( DispatcherConfig { @@ -851,6 +917,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let specs = handler.resolver(&Key::from("interactive:web")).specs(); assert!( @@ -1121,6 +1188,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1216,6 +1284,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1320,6 +1389,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1400,6 +1470,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let backing = Arc::new(VolatileStore::new()); let dispatcher = Arc::new(Dispatcher::new( @@ -1481,6 +1552,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, )); let dispatcher = Arc::new(Dispatcher::new( @@ -1604,6 +1676,7 @@ mod tests { notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, extensions, + true, ); let backing = Arc::new(VolatileStore::new()); let dispatcher = Arc::new(Dispatcher::new( diff --git a/crates/rhema-claw/src/jobs.rs b/crates/rhema-claw/src/jobs.rs index 1c28092..f351d86 100644 --- a/crates/rhema-claw/src/jobs.rs +++ b/crates/rhema-claw/src/jobs.rs @@ -138,6 +138,9 @@ pub struct CodingJobHandler { store: Arc, notifications: Arc, max_turn_steps: u32, + /// Whether the jobs run the bounded request view (the config's + /// `transcript`, on unless set false). + transcript: bool, } impl CodingJobHandler { @@ -149,12 +152,14 @@ impl CodingJobHandler { store: Arc, notifications: Arc, max_turn_steps: u32, + transcript: bool, ) -> Self { Self { backends, store, notifications, max_turn_steps, + transcript, } } @@ -195,6 +200,11 @@ impl CodingJobHandler { let (provider, model) = self.backends.current(); let mut config = CodingAgentConfig::new(model, &workspace); config.max_turn_steps = self.max_turn_steps; + if self.transcript { + // A job's stashed session carries across runs and only ever + // grows; the view is what keeps a repeat run's steps cheap. + config.transcript = Some(rhema_transcript::TranscriptSettings::default()); + } let stashed = self .store .session(&instance_name) @@ -332,7 +342,7 @@ mod tests { BackendRegistry::new(vec![Backend::headless("mock", provider)], None) .expect("a single mock backend always constructs"), ); - CodingJobHandler::new(backends, store, notifications, 16) + CodingJobHandler::new(backends, store, notifications, 16, true) } fn reply(text: &str) -> ModelOutput { diff --git a/crates/rhema-claw/tests/continuity.rs b/crates/rhema-claw/tests/continuity.rs index cdf8b70..1c2d10c 100644 --- a/crates/rhema-claw/tests/continuity.rs +++ b/crates/rhema-claw/tests/continuity.rs @@ -67,6 +67,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { model: Some("test-model".to_owned()), listen: None, token: None, + transcript: None, watches: vec![Watch { forge: ForgeKind::GitHub, repo: "acme/widgets".to_owned(), diff --git a/crates/rhema-claw/tests/e2e.rs b/crates/rhema-claw/tests/e2e.rs index 62d4ee6..2e7125f 100644 --- a/crates/rhema-claw/tests/e2e.rs +++ b/crates/rhema-claw/tests/e2e.rs @@ -55,6 +55,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig { model: Some("test-model".to_owned()), listen: None, token: None, + transcript: None, watches: vec![Watch { forge: ForgeKind::GitHub, repo: "acme/widgets".to_owned(), diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs index d7d9056..baa8e2a 100644 --- a/crates/rhema-claw/tests/http.rs +++ b/crates/rhema-claw/tests/http.rs @@ -61,6 +61,7 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig { model: Some("test-model".to_owned()), listen: None, token: token.map(str::to_owned), + transcript: None, watches: Vec::new(), calendars: Vec::new(), }