feat(claw): run the bounded request view by default

The claw's sessions never end by design — one interactive chat, forge
threads alive as long as their pull requests, coding jobs stashed
across runs — so off-by-default would mean the view is never on where
it matters most. On: chat turns wrap the provider seam with the view
and join zoom to the resolver chain, the mirror living on the handler
so it survives the per-dispatch agent rebuild; forge threads and
coding jobs set the coding agent's transcript field. The config turns
it off with transcript: false.
This commit is contained in:
vhaudiquet committed 2026-10-20 06:01:24 +02:00
1 parent b70a56c325
commit 50b8feba07
10 files changed
+187 -3

No files matched your search

+2
View File
@@ -32,6 +32,8 @@ rhema-autonomous-agent = { path = "../rhema-autonomous-agent" }
rhema-coding-agent = { path = "../rhema-coding-agent" } rhema-coding-agent = { path = "../rhema-coding-agent" }
rhema-config = { path = "../rhema-config" } rhema-config = { path = "../rhema-config" }
rhema-core = { path = "../rhema-core" } 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-calendar-integration = { path = "../../integrations/rhema-calendar-integration" }
rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" } rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" }
# The accounts layer: identities hold the forge tokens the watches poll # The accounts layer: identities hold the forge tokens the watches poll
+10
View File
@@ -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 two agents editing the same files. Across instances, jobs run in
parallel. 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 For the debug loop, daemon plus UI against throwaway state and zero
credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI
is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock` is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock`
+31
View File
@@ -278,6 +278,13 @@ pub struct ClawConfig {
pub model: Option<String>, pub model: Option<String>,
/// The HTTP surface's `host:port`. [`DEFAULT_LISTEN`] when unset. /// The HTTP surface's `host:port`. [`DEFAULT_LISTEN`] when unset.
pub listen: Option<String>, pub listen: Option<String>,
/// 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<bool>,
/// Bearer token for the `/v1` routes. Unset means the surface is /// Bearer token for the `/v1` routes. Unset means the surface is
/// open on its interface; set it when the port is reachable from /// open on its interface; set it when the port is reachable from
/// anywhere but loopback. `/healthz` stays open either way. /// anywhere but loopback. `/healthz` stays open either way.
@@ -292,6 +299,14 @@ pub struct ClawConfig {
pub calendars: Vec<CalendarWatch>, pub calendars: Vec<CalendarWatch>,
} }
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 { impl Default for ClawConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -305,6 +320,7 @@ impl Default for ClawConfig {
model: None, model: None,
listen: None, listen: None,
token: None, token: None,
transcript: None,
watches: Vec::new(), watches: Vec::new(),
calendars: Vec::new(), calendars: Vec::new(),
} }
@@ -568,6 +584,21 @@ impl ClawConfig {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
+3
View File
@@ -233,6 +233,7 @@ impl Daemon {
workspace_root, workspace_root,
watches.clone(), watches.clone(),
accounts.clone(), accounts.clone(),
config.transcript_enabled(),
); );
// Three drivers over one store admit from the same table, so // Three drivers over one store admit from the same table, so
// each owns its slice of the key space: a forge pass must never // each owns its slice of the key space: a forge pass must never
@@ -326,6 +327,7 @@ impl Daemon {
notifications.clone(), notifications.clone(),
config.max_turn_steps, config.max_turn_steps,
extensions.clone(), extensions.clone(),
config.transcript_enabled(),
); );
let interactive_driver = Arc::new( let interactive_driver = Arc::new(
Driver::new( Driver::new(
@@ -345,6 +347,7 @@ impl Daemon {
jobs.clone(), jobs.clone(),
notifications.clone(), notifications.clone(),
config.max_turn_steps, config.max_turn_steps,
config.transcript_enabled(),
); );
let jobs_driver = Arc::new( let jobs_driver = Arc::new(
Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler)) Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler))
+53 -1
View File
@@ -57,6 +57,9 @@ fn allowlist() -> rhema_autonomous_agent::Allowlist {
pub struct ClawHandler { pub struct ClawHandler {
/// The deployment's providers and the active pair, read per dispatch. /// The deployment's providers and the active pair, read per dispatch.
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
/// Whether the sessions run the bounded request view (the config's
/// `transcript`, on unless set false).
transcript: bool,
workspace_root: PathBuf, workspace_root: PathBuf,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
/// The account store a blank watch credential resolves through: the /// The account store a blank watch credential resolves through: the
@@ -74,9 +77,11 @@ impl ClawHandler {
workspace_root: impl Into<PathBuf>, workspace_root: impl Into<PathBuf>,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
accounts: Arc<dyn IdentityStore>, accounts: Arc<dyn IdentityStore>,
transcript: bool,
) -> Self { ) -> Self {
Self { Self {
backends, backends,
transcript,
workspace_root: workspace_root.into(), workspace_root: workspace_root.into(),
watches, watches,
accounts, accounts,
@@ -210,7 +215,7 @@ impl ClawHandler {
key: &str, key: &str,
) -> Result<CodingAgent, String> { ) -> Result<CodingAgent, String> {
let (provider, model) = self.backends.current(); 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 { let agent = match &ctx.session.core.driver_state {
// A never-dispatched key carries no stash: start clean. // A never-dispatched key carries no stash: start clean.
serde_json::Value::Null => CodingAgent::new(provider, config), 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}")) 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 /// The per-thread workspace: one directory under the root, created on
/// demand. The name mapping is not injective (separators collapse); /// demand. The name mapping is not injective (separators collapse);
/// thread identity lives in the dispatch key — the directory is only /// thread identity lives in the dispatch key — the directory is only
@@ -759,6 +776,7 @@ mod tests {
"/tmp/w", "/tmp/w",
watches, watches,
Arc::new(rhema_identity::VolatileIdentityStore::new()), Arc::new(rhema_identity::VolatileIdentityStore::new()),
true,
); );
// A watch added to the table AFTER the handler was built resolves: // 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] #[test]
fn the_workspace_name_flattens_to_filesystem_safe_characters() { fn the_workspace_name_flattens_to_filesystem_safe_characters() {
assert_eq!( assert_eq!(
+74 -1
View File
@@ -182,6 +182,10 @@ pub struct InteractiveHandler {
/// dispatch, in the standard set's order — the same set whose /// dispatch, in the standard set's order — the same set whose
/// sources the daemon's tick drives and whose boot pass runs first. /// sources the daemon's tick drives and whose boot pass runs first.
extensions: Arc<crate::extension::Extensions>, extensions: Arc<crate::extension::Extensions>,
/// 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<Arc<std::sync::Mutex<rhema_transcript::Transcript>>>,
} }
impl InteractiveHandler { impl InteractiveHandler {
@@ -193,7 +197,7 @@ impl InteractiveHandler {
/// shell (approval v1-permissive — the shell is the point of a /// shell (approval v1-permissive — the shell is the point of a
/// resident coding agent — installed once, since resolution is /// resident coding agent — installed once, since resolution is
/// synchronous within a turn), memory, and `bootstrap_save`. /// 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( pub fn new(
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
workspace: impl Into<PathBuf>, workspace: impl Into<PathBuf>,
@@ -203,6 +207,7 @@ impl InteractiveHandler {
notifications: Arc<NotificationStore>, notifications: Arc<NotificationStore>,
max_turn_steps: u32, max_turn_steps: u32,
extensions: Arc<crate::extension::Extensions>, extensions: Arc<crate::extension::Extensions>,
transcript: bool,
) -> Self { ) -> Self {
let workspace = workspace.into(); let workspace = workspace.into();
let tools = Arc::new(ShellTools::new(&workspace)); let tools = Arc::new(ShellTools::new(&workspace));
@@ -220,6 +225,11 @@ impl InteractiveHandler {
notifications, notifications,
max_turn_steps, max_turn_steps,
extensions, 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 // the same expansion the coding driver wraps its own with: the
// model sees plain text, the transcript keeps the chips. // model sees plain text, the transcript keeps the chips.
let provider: Arc<dyn ModelProvider> = Arc::new(ExpandPastes::new(provider)); let provider: Arc<dyn ModelProvider> = 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<dyn ModelProvider> = match &self.transcript_mirror {
Some(mirror) => Arc::new(rhema_transcript::TranscriptView::new(
provider,
mirror.clone(),
)),
None => provider,
};
let resolver = self.resolver(key); let resolver = self.resolver(key);
let system = render_system( let system = render_system(
DEFAULT_SYSTEM_MD, DEFAULT_SYSTEM_MD,
@@ -273,6 +292,11 @@ impl InteractiveHandler {
self.bootstrap_resolver.clone(), self.bootstrap_resolver.clone(),
]; ];
resolvers.extend(self.extensions.tools(key)); 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)) 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 /// The paste contract across the provider seam: a chat input that
/// carries an embedded paste (the compact form the UIs mint) is /// carries an embedded paste (the compact form the UIs mint) is
/// expanded before the request leaves, while the transcript the hub /// expanded before the request leaves, while the transcript the hub
@@ -764,6 +829,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
DispatcherConfig { DispatcherConfig {
@@ -851,6 +917,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let specs = handler.resolver(&Key::from("interactive:web")).specs(); let specs = handler.resolver(&Key::from("interactive:web")).specs();
assert!( assert!(
@@ -1121,6 +1188,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
@@ -1216,6 +1284,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
@@ -1320,6 +1389,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
@@ -1400,6 +1470,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let backing = Arc::new(VolatileStore::new()); let backing = Arc::new(VolatileStore::new());
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
@@ -1481,6 +1552,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
)); ));
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
@@ -1604,6 +1676,7 @@ mod tests {
notifications.clone(), notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS, crate::config::DEFAULT_MAX_TURN_STEPS,
extensions, extensions,
true,
); );
let backing = Arc::new(VolatileStore::new()); let backing = Arc::new(VolatileStore::new());
let dispatcher = Arc::new(Dispatcher::new( let dispatcher = Arc::new(Dispatcher::new(
+11 -1
View File
@@ -138,6 +138,9 @@ pub struct CodingJobHandler {
store: Arc<CodingJobStore>, store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>, notifications: Arc<NotificationStore>,
max_turn_steps: u32, max_turn_steps: u32,
/// Whether the jobs run the bounded request view (the config's
/// `transcript`, on unless set false).
transcript: bool,
} }
impl CodingJobHandler { impl CodingJobHandler {
@@ -149,12 +152,14 @@ impl CodingJobHandler {
store: Arc<CodingJobStore>, store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>, notifications: Arc<NotificationStore>,
max_turn_steps: u32, max_turn_steps: u32,
transcript: bool,
) -> Self { ) -> Self {
Self { Self {
backends, backends,
store, store,
notifications, notifications,
max_turn_steps, max_turn_steps,
transcript,
} }
} }
@@ -195,6 +200,11 @@ impl CodingJobHandler {
let (provider, model) = self.backends.current(); let (provider, model) = self.backends.current();
let mut config = CodingAgentConfig::new(model, &workspace); let mut config = CodingAgentConfig::new(model, &workspace);
config.max_turn_steps = self.max_turn_steps; 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 let stashed = self
.store .store
.session(&instance_name) .session(&instance_name)
@@ -332,7 +342,7 @@ mod tests {
BackendRegistry::new(vec![Backend::headless("mock", provider)], None) BackendRegistry::new(vec![Backend::headless("mock", provider)], None)
.expect("a single mock backend always constructs"), .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 { fn reply(text: &str) -> ModelOutput {
+1
View File
@@ -67,6 +67,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: None, token: None,
transcript: None,
watches: vec![Watch { watches: vec![Watch {
forge: ForgeKind::GitHub, forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(), repo: "acme/widgets".to_owned(),
+1
View File
@@ -55,6 +55,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: None, token: None,
transcript: None,
watches: vec![Watch { watches: vec![Watch {
forge: ForgeKind::GitHub, forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(), repo: "acme/widgets".to_owned(),
+1
View File
@@ -61,6 +61,7 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: token.map(str::to_owned), token: token.map(str::to_owned),
transcript: None,
watches: Vec::new(), watches: Vec::new(),
calendars: Vec::new(), calendars: Vec::new(),
} }