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:
1 parent
b70a56c325
commit
50b8feba07
10 files changed
+187
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user