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-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
+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
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`
+31
View File
@@ -278,6 +278,13 @@ pub struct ClawConfig {
pub model: Option<String>,
/// The HTTP surface's `host:port`. [`DEFAULT_LISTEN`] when unset.
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
/// 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<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 {
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]
+3
View File
@@ -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))
+53 -1
View File
@@ -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<BackendRegistry>,
/// Whether the sessions run the bounded request view (the config's
/// `transcript`, on unless set false).
transcript: bool,
workspace_root: PathBuf,
watches: Arc<ClawStore>,
/// The account store a blank watch credential resolves through: the
@@ -74,9 +77,11 @@ impl ClawHandler {
workspace_root: impl Into<PathBuf>,
watches: Arc<ClawStore>,
accounts: Arc<dyn IdentityStore>,
transcript: bool,
) -> Self {
Self {
backends,
transcript,
workspace_root: workspace_root.into(),
watches,
accounts,
@@ -210,7 +215,7 @@ impl ClawHandler {
key: &str,
) -> Result<CodingAgent, String> {
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!(
+74 -1
View File
@@ -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<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 {
@@ -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<BackendRegistry>,
workspace: impl Into<PathBuf>,
@@ -203,6 +207,7 @@ impl InteractiveHandler {
notifications: Arc<NotificationStore>,
max_turn_steps: u32,
extensions: Arc<crate::extension::Extensions>,
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<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 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(
+11 -1
View File
@@ -138,6 +138,9 @@ pub struct CodingJobHandler {
store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>,
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<CodingJobStore>,
notifications: Arc<NotificationStore>,
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 {
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
}