diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index fd124cc..71da7d7 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -111,6 +111,11 @@ pub enum ForgeKind { } impl ForgeKind { + /// Every kind the claw ships a source for, in registration order. + /// A kind missing here has no watch poller, whatever the table + /// holds. + pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]; + /// The source name this kind registers and dispatches under. pub fn name(self) -> &'static str { match self { diff --git a/crates/rhema-claw/src/daemon.rs b/crates/rhema-claw/src/daemon.rs index 002dfc4..5c6ad81 100644 --- a/crates/rhema-claw/src/daemon.rs +++ b/crates/rhema-claw/src/daemon.rs @@ -30,13 +30,11 @@ use rhema_rsd::{ SystemClock, }; -use crate::calendar::CalendarSource; -use crate::config::{ClawConfig, ConfigError, ForgeKind}; -use crate::forge::ForgeSource; +use crate::config::{ClawConfig, ConfigError}; +use crate::extension::{ExtensionDeps, Extensions, Lanes}; use crate::handler::ClawHandler; use crate::http::ClawState; use crate::notifications::NotificationStore; -use crate::task_source::TaskScheduler; use crate::task_store::TaskStore; use rhema_memory::FileMemoryStore; @@ -125,15 +123,16 @@ fn interactive_dispatch() -> DispatcherConfig { } } -/// The resident daemon: one source per forge kind, the calendar -/// reminders, the task scheduler, the coding-job source, the three -/// drivers, and the HTTP surface. +/// The resident daemon: the extension set's sources (the forge watches, +/// the task scheduler, the coding jobs, the calendar reminders), the +/// three drivers, and the HTTP surface. pub struct Daemon { - sources: Vec, - calendar_source: CalendarSource, - scheduler: TaskScheduler, - job_source: crate::jobs::JobsSource, - jobs: Arc, + /// The extension set: the boot pass runs its hooks, and the tick + /// detects through the sources collected from it at assembly. + extensions: Arc, + /// The set's sources, in set order, each registered on the lane it + /// feeds; built once, at assembly. + sources: Vec>, forge_dispatcher: Arc, interactive_dispatcher: Arc, jobs_dispatcher: Arc, @@ -183,6 +182,11 @@ impl Daemon { let notifications = Arc::new(NotificationStore::open(&db)?); let tasks = Arc::new(TaskStore::open(&db)?); let turns = Arc::new(crate::turn_store::TurnStore::open(&db)?); + // The hub replays what past turns streamed: every event appends + // to the turn-history table as it streams, so a restart's replay + // reads the table back — a restart continues the transcript, + // whole, with no boot-time seed pass. + let hub = Arc::new(EventHub::with_record_store(turns.clone())); // The coding-job tables: instances the `coding_agent` tool // drives, jobs the jobs dispatcher answers. let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?); @@ -221,23 +225,6 @@ impl Daemon { .map_err(|err| ClawError::Backend(err.to_string()))?, ); - // One source per known forge kind, whatever the table currently - // holds: a watch added later (chat tool, HTTP route) of a kind - // nobody watched at startup still has its key function - // registered. A source over zero watches detects nothing. - let sources = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab] - .into_iter() - .map(|kind| { - ForgeSource::new( - &forge_dispatcher, - kind.name(), - watches.clone(), - accounts.clone(), - notifications.clone(), - ) - }) - .collect(); - // The forge handler resolves its watches through the table at // dispatch time, so a watch added mid-run is answerable without a // restart. @@ -260,11 +247,6 @@ impl Daemon { })), ); - // The hub replays what past turns streamed: every event appends - // to the turn-history table as it streams, so a restart's replay - // reads the table back — a restart continues the transcript, - // whole, with no boot-time seed pass. - let hub = Arc::new(EventHub::with_record_store(turns.clone())); // The consent table and its wake bridge: the `ask_secret` tool // asks through them, the consent routes answer through them. let consents = Arc::new(crate::consents::ConsentStore::open(&db)?); @@ -306,23 +288,44 @@ impl Daemon { // at every boot by deterministic discovery, never by the model, // and regenerated over whatever the last boot wrote. crate::environment::write_report(config.bootstrap_dir()?.join("ENVIRONMENT.md"))?; + // The extension set: every resident feature beyond the core, one + // struct per integration. The handler's tool chain, the tick's + // detect passes, and the boot pass below all iterate it — the + // daemon names no feature twice. + let extensions = Arc::new(Extensions::standard(ExtensionDeps { + watches: watches.clone(), + accounts: accounts.clone(), + notifications: notifications.clone(), + tasks: tasks.clone(), + jobs: jobs.clone(), + consents: consents.clone(), + bridge: bridge.clone(), + attachments: attachments.clone(), + phone: phone.clone(), + identity: identity.clone(), + hub: hub.clone(), + workspace: interactive_workspace.clone(), + calendars: config.calendars.clone(), + })); + eprintln!("[claw] extensions: {}", extensions.names().join(", ")); + // The sources register on their lanes as they build: a watch of + // a kind nobody watched at startup still has its key function + // registered, and a source over zero watches detects nothing. + let lanes = Lanes { + forge: forge_dispatcher.clone(), + interactive: interactive_dispatcher.clone(), + jobs: jobs_dispatcher.clone(), + }; + let sources = extensions.sources(&lanes)?; let interactive_handler = InteractiveHandler::new( backends.clone(), interactive_workspace, - watches.clone(), memory, bootstrap.clone(), hub.clone(), notifications.clone(), - tasks.clone(), - jobs.clone(), - accounts.clone(), config.max_turn_steps, - consents.clone(), - bridge.clone(), - attachments.clone(), - phone.clone(), - identity.clone(), + extensions.clone(), ); let interactive_driver = Arc::new( Driver::new( @@ -350,24 +353,7 @@ impl Daemon { crate::jobs::is_job_key(key.as_str()) })), ); - let job_source = crate::jobs::JobsSource::new(&jobs_dispatcher, jobs.clone()); crate::interactive::register_user(&interactive_dispatcher); - // The scheduler is the resident dispatcher's second own source: - // its detect pass (the daemon's tick drives it) is what turns a - // due task row into an event, never a timer near the model. - let scheduler = TaskScheduler::new( - &interactive_dispatcher, - Arc::new(SystemClock), - tasks.clone(), - ); - // The calendar reminders join them: a due reminder is an - // event, never a timer near the model. - let calendar_source = CalendarSource::new( - &interactive_dispatcher, - Arc::new(SystemClock), - &config.calendars, - ) - .map_err(ClawError::Backend)?; let http = ClawState::new( interactive_dispatcher.clone(), @@ -389,11 +375,8 @@ impl Daemon { ); Ok(Self { + extensions, sources, - calendar_source, - scheduler, - job_source, - jobs, forge_dispatcher, interactive_dispatcher, jobs_dispatcher, @@ -435,13 +418,10 @@ impl Daemon { /// top of the next dispatch (safe tools re-run, the rest report /// interrupted). Idempotent; called before the first pass. pub async fn bootstrap(&self) -> Result<(), ClawError> { - // The job rows first: a turn the restart cut is failed - // interrupted before anything re-runs, and a still-queued row - // is re-armed with a fresh dedup id. - let touched = self.jobs.recover()?; - if touched > 0 { - eprintln!("[claw] jobs: {touched} row(s) recovered at boot"); - } + // The extension boot hooks first: a coding job the restart cut + // is failed interrupted before anything re-runs, and a + // still-queued row is re-armed with a fresh dedup id. + self.extensions.boot()?; self.forge_dispatcher.recover_all()?; self.jobs_dispatcher.recover_all()?; self.interactive_dispatcher.recover_all()?; @@ -454,9 +434,10 @@ impl Daemon { /// backstop — a message delivered live into a running turn waits at /// the key for a pass like this one. pub async fn tick(&self) -> Result { - let mut detected = self.scheduler.detect().await?; - detected += self.calendar_source.detect().await?; - detected += self.job_source.detect().await?; + // The tick only detects — every timer here drives + // deterministic detection, and the set's order is the contract: + // due tasks before the calendar reminders that fire for them. + let mut detected = 0usize; for source in &self.sources { detected += source.detect().await?; } diff --git a/crates/rhema-claw/src/extension.rs b/crates/rhema-claw/src/extension.rs new file mode 100644 index 0000000..ca0695c --- /dev/null +++ b/crates/rhema-claw/src/extension.rs @@ -0,0 +1,494 @@ +//! The extension seam: one trait for everything the resident runs +//! beyond its core, and the standard set the daemon assembles from it. +//! +//! The claw's core is small: the shell, memory, the bootstrap prompt, +//! the chat surface, and the dispatch lanes. Everything else — the +//! watch table, the task scheduler, the coding jobs, the notification +//! queue, the consent bridge, attachments, the phone, the accounts +//! layer, the presentation identity, the calendar reminders — joins +//! the daemon through [`ClawExtension`]: tool resolvers for the +//! resident's chain, sources for the tick's detect passes, and a boot +//! hook where a previous process's wreckage gets made honest. The +//! daemon's assembly loops over the set; it does not name features. +//! +//! The seam is compile-time, not dynamic. An extension is a struct in +//! this crate holding its own store handles; installing one is a +//! constructor line in [`Extensions::standard`], and each struct is +//! the boundary a future extraction would lift wholesale — the glue +//! next to its logic in an integration crate, or, if a second +//! deployment shape ever appears, an out-of-process plugin speaking +//! for the struct. The HTTP routes are deliberately not part of the +//! trait: the surface is one API contract the web UI and the phone +//! client both compile against, and it stays whole in [`crate::http`] +//! until a second consumer exists. +//! +//! Order is contract. The resolver chain is the extensions' +//! [`ClawExtension::tools`] in [`Extensions::standard`]'s order, and +//! the tick detects through [`ClawExtension::sources`] in the same +//! order — due tasks before calendar reminders, the toolsets the model +//! addresses most first. Reordering the set changes what the model +//! sees and what fires first. + +use std::path::PathBuf; +use std::sync::Arc; + +use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry}; +use rhema_core::ToolResolver; +use rhema_rsd::{Dispatcher, ResourceKey, SystemClock}; + +use crate::calendar::CalendarSource; +use crate::config::{CalendarWatch, ForgeKind}; +use crate::consents::WaitBridge; +use crate::daemon::ClawError; +use crate::forge::ForgeSource; +use crate::hub::EventHub; + +/// The store handles the standard set is assembled over. One struct, +/// so a new extension is a field plus a constructor line, not another +/// positional argument down a sixteen-deep call chain. +pub struct ExtensionDeps { + /// The watch table: the forge extension's tools manage it, its + /// sources poll it, and the accounts tools hang account watches + /// off it. + pub watches: Arc, + /// The accounts layer the accounts tools manage and the forge + /// sources poll through. + pub accounts: Arc, + /// The notification queue: the `notify` tool writes it, devices + /// poll it, and non-chat replies auto-notify through it. + pub notifications: Arc, + /// The task table the task tools manage and the scheduler fires. + pub tasks: Arc, + /// The coding-job tables the `coding_agent` tool enqueues into. + pub jobs: Arc, + /// The consent table the `ask_secret` tool asks through. + pub consents: Arc, + /// The wake bridge between the consent tools and the routes that + /// answer them. + pub bridge: Arc, + /// The attachment store the `attach_file` tool copies into. + pub attachments: Arc, + /// The phone tables the `phone` tool reads and queues through. + pub phone: Arc, + /// The presentation-identity store the `identity_set` tool writes. + pub identity: Arc, + /// The event hub: the key-scoped tools (consents, attachments, + /// phone) emit their cards and asks onto the turn it streams. + pub hub: Arc, + /// The interactive workspace the account exec and the identity + /// registry resolve against. + pub workspace: PathBuf, + /// The calendar feeds the reminders source polls. + pub calendars: Vec, +} + +/// The dispatch lanes a source can feed, handed to +/// [`ClawExtension::sources`] once the daemon has built its +/// dispatchers. Which lane an extension's sources feed is fixed by +/// what the extension reads here — the forge sources feed the forge +/// lane, the task scheduler and the calendar reminders the resident +/// lane, the coding jobs their own. +pub struct Lanes { + /// The forge lane: watch polls and account notifications. + pub forge: Arc, + /// The resident lane: chat, tasks, reminders, ingest. + pub interactive: Arc, + /// The coding-job lane: one leased turn per job. + pub jobs: Arc, +} + +/// One resident feature beyond the core: the tools it lends the +/// resolver chain, the sources it feeds the tick, and the boot hook +/// that recovers what a crash left behind. +/// +/// Every method has a default, so an extension contributes only what +/// it has; `tools` is called per dispatch and `sources` once at +/// assembly. +pub trait ClawExtension: Send + Sync { + /// The extension's name, as logs and the boot line spell it. + fn name(&self) -> &'static str; + + /// The tool resolvers this extension lends the resident's chain, + /// in the order they join it. Each is cut permissive — the surface + /// is the trust boundary — and rebuilt per dispatch, because the + /// key-scoped toolsets (consents, attachments, phone) must bind + /// their asks and cards to the session that asked. + fn tools(&self, _key: &ResourceKey) -> Vec> { + Vec::new() + } + + /// The sources this extension feeds the tick with, each already + /// registered on the lane it feeds. Built once, at assembly; a + /// source over an empty table simply detects nothing. + fn sources(&self, _lanes: &Lanes) -> Result>, ClawError> { + Ok(Vec::new()) + } + + /// The boot pass: whatever a previous process's crash left + /// inconsistent in this extension's tables, made honest before the + /// first tick. Runs before the dispatchers recover their leases. + fn boot(&self) -> Result<(), ClawError> { + Ok(()) + } +} + +/// The assembled set: the extension list the daemon iterates. +pub struct Extensions { + items: Vec>, +} + +impl Extensions { + /// The standard set, in the order the resolver chain and the tick + /// inherit: notifications, tasks, jobs, forge watches, accounts, + /// identity, consents, attachments, phone, calendar reminders. + /// Tasks precede the calendar on purpose — a due task is the + /// user-facing clock, and the reminder that fires for it should + /// find the work already detected. + pub fn standard(deps: ExtensionDeps) -> Self { + Self { + items: vec![ + Box::new(NotifyExtension { + notifications: deps.notifications.clone(), + }), + Box::new(TasksExtension { + tasks: deps.tasks.clone(), + }), + Box::new(JobsExtension { + jobs: deps.jobs.clone(), + }), + Box::new(ForgeExtension { + watches: deps.watches.clone(), + accounts: deps.accounts.clone(), + notifications: deps.notifications.clone(), + }), + Box::new(AccountsExtension { + accounts: deps.accounts.clone(), + watches: deps.watches.clone(), + workspace: deps.workspace.clone(), + }), + Box::new(IdentityExtension { + identity: deps.identity.clone(), + workspace: deps.workspace.clone(), + }), + Box::new(ConsentsExtension { + consents: deps.consents.clone(), + bridge: deps.bridge.clone(), + hub: deps.hub.clone(), + }), + Box::new(AttachmentsExtension { + store: deps.attachments.clone(), + workspace: deps.workspace.clone(), + hub: deps.hub.clone(), + }), + Box::new(PhoneExtension { + store: deps.phone.clone(), + consents: deps.consents.clone(), + bridge: deps.bridge.clone(), + hub: deps.hub.clone(), + }), + Box::new(CalendarExtension { + calendars: deps.calendars, + }), + ], + } + } + + /// The full resolver chain one dispatch runs under: every + /// extension's tools, in set order, key-scoped toolsets bound to + /// `key`. + pub fn tools(&self, key: &ResourceKey) -> Vec> { + self.items + .iter() + .flat_map(|extension| extension.tools(key)) + .collect() + } + + /// Every extension's sources, in set order, each registered on the + /// lane it feeds. Built once; the daemon holds the result for the + /// tick's detect passes. + pub fn sources(&self, lanes: &Lanes) -> Result>, ClawError> { + let mut sources = Vec::new(); + for extension in &self.items { + sources.extend(extension.sources(lanes)?); + } + Ok(sources) + } + + /// Every extension's boot pass, in set order. + pub fn boot(&self) -> Result<(), ClawError> { + for extension in &self.items { + extension.boot()?; + } + Ok(()) + } + + /// The set's names, the order the chain and the tick run in — the + /// boot line's inventory. + pub fn names(&self) -> Vec<&'static str> { + self.items + .iter() + .map(|extension| extension.name()) + .collect() + } +} + +/// Wraps a registry in the permissive allowlist — the resident tier's +/// standing policy: the surface is the trust boundary, and the shell +/// already runs auto-approved. +fn permissive(registry: ToolRegistry) -> Arc { + registry.resolver_for(&Allowlist::permissive()) +} + +/// The notification queue: the `notify` tool the resident writes +/// through and the rows a device polls and acks. Nothing detects — +/// outbound mail needs no polling. +struct NotifyExtension { + notifications: Arc, +} + +impl ClawExtension for NotifyExtension { + fn name(&self) -> &'static str { + "notify" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![permissive(crate::notify_tools::registry( + self.notifications.clone(), + ))] + } +} + +/// The task scheduler: the `schedule_task`/`list_tasks`/`cancel_task` +/// tools, and the source whose detect pass turns a due row into an +/// event on the resident lane — never a timer near the model. +struct TasksExtension { + tasks: Arc, +} + +impl ClawExtension for TasksExtension { + fn name(&self) -> &'static str { + "tasks" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![permissive(crate::task_tools::registry(self.tasks.clone()))] + } + + fn sources(&self, lanes: &Lanes) -> Result>, ClawError> { + Ok(vec![Box::new(crate::task_source::TaskScheduler::new( + &lanes.interactive, + Arc::new(SystemClock), + self.tasks.clone(), + ))]) + } +} + +/// The coding jobs: the `coding_agent` tool, the source that ingests +/// queued rows onto the jobs lane, and the boot pass that fails the +/// turns a crash interrupted. +struct JobsExtension { + jobs: Arc, +} + +impl ClawExtension for JobsExtension { + fn name(&self) -> &'static str { + "jobs" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![permissive(crate::job_tools::registry(self.jobs.clone()))] + } + + fn sources(&self, lanes: &Lanes) -> Result>, ClawError> { + Ok(vec![Box::new(crate::jobs::JobsSource::new( + &lanes.jobs, + self.jobs.clone(), + ))]) + } + + fn boot(&self) -> Result<(), ClawError> { + let touched = self.jobs.recover().map_err(ClawError::Jobs)?; + if touched > 0 { + eprintln!("[claw] jobs: {touched} row(s) recovered at boot"); + } + Ok(()) + } +} + +/// The forge watches: the watch-management tools the resident configures +/// on request, and one source per forge kind — repo watches polled +/// through the watch table, account notifications triaged through the +/// accounts layer. +struct ForgeExtension { + watches: Arc, + accounts: Arc, + notifications: Arc, +} + +impl ClawExtension for ForgeExtension { + fn name(&self) -> &'static str { + "forge" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![permissive(crate::watch_tools::registry( + self.watches.clone(), + ))] + } + + fn sources(&self, lanes: &Lanes) -> Result>, ClawError> { + Ok(ForgeKind::ALL + .into_iter() + .map(|kind| { + Box::new(ForgeSource::new( + &lanes.forge, + kind.name(), + self.watches.clone(), + self.accounts.clone(), + self.notifications.clone(), + )) as Box + }) + .collect()) + } +} + +/// The accounts layer: the account tools that manage identities over +/// chat, and the one tool that acts with a stored credential — the +/// secret reaches a command's environment and never returns. +struct AccountsExtension { + accounts: Arc, + watches: Arc, + workspace: PathBuf, +} + +impl ClawExtension for AccountsExtension { + fn name(&self) -> &'static str { + "accounts" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![ + permissive(crate::accounts_tools::registry( + self.accounts.clone(), + self.watches.clone(), + )), + Arc::new(crate::accounts_tools::IdentityExec::new( + self.accounts.clone(), + self.workspace.clone(), + )), + ] + } +} + +/// The presentation identity: the `identity_set` tool, so the model +/// writes its own name, mark, and images the surface serves. +struct IdentityExtension { + identity: Arc, + workspace: PathBuf, +} + +impl ClawExtension for IdentityExtension { + fn name(&self) -> &'static str { + "identity" + } + + fn tools(&self, _key: &ResourceKey) -> Vec> { + vec![permissive(crate::identity::registry( + self.identity.clone(), + self.workspace.clone(), + ))] + } +} + +/// The consent bridge: the `ask_secret`/`request_permission` tools +/// that park a running turn on a human answer, and the routes that +/// deliver it. Key-scoped: an ask lands on the session that asked. +struct ConsentsExtension { + consents: Arc, + bridge: Arc, + hub: Arc, +} + +impl ClawExtension for ConsentsExtension { + fn name(&self) -> &'static str { + "consents" + } + + fn tools(&self, key: &ResourceKey) -> Vec> { + vec![permissive(crate::consents::registry( + self.consents.clone(), + self.bridge.clone(), + self.hub.clone(), + key.clone(), + ))] + } +} + +/// The attachments: the `attach_file` tool that copies a workspace +/// file into a fetchable card. Key-scoped: a card lands on the session +/// that attached it. +struct AttachmentsExtension { + store: Arc, + workspace: PathBuf, + hub: Arc, +} + +impl ClawExtension for AttachmentsExtension { + fn name(&self) -> &'static str { + "attachments" + } + + fn tools(&self, key: &ResourceKey) -> Vec> { + vec![permissive(crate::attachments::registry( + self.store.clone(), + self.workspace.clone(), + self.hub.clone(), + key.clone(), + ))] + } +} + +/// The phone: the `phone` tool's consent-gated control actions and the +/// status it reads. Key-scoped: a command queued from `phone:desk` +/// belongs to that device's session. +struct PhoneExtension { + store: Arc, + consents: Arc, + bridge: Arc, + hub: Arc, +} + +impl ClawExtension for PhoneExtension { + fn name(&self) -> &'static str { + "phone" + } + + fn tools(&self, key: &ResourceKey) -> Vec> { + vec![permissive(crate::phone::registry( + self.store.clone(), + self.consents.clone(), + self.bridge.clone(), + self.hub.clone(), + key.clone(), + ))] + } +} + +/// The calendar reminders: a due occurrence is an event on the resident +/// lane, never a timer near the model. +struct CalendarExtension { + calendars: Vec, +} + +impl ClawExtension for CalendarExtension { + fn name(&self) -> &'static str { + "calendar" + } + + fn sources(&self, lanes: &Lanes) -> Result>, ClawError> { + let source = + CalendarSource::new(&lanes.interactive, Arc::new(SystemClock), &self.calendars) + .map_err(ClawError::Backend)?; + Ok(vec![Box::new(source)]) + } +} diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs index dd37208..b77a842 100644 --- a/crates/rhema-claw/src/interactive.rs +++ b/crates/rhema-claw/src/interactive.rs @@ -40,11 +40,12 @@ //! the rest report interrupted. //! //! Deterministic setup first (spec §1, §8): the allowlist — permissive, -//! v1's starting point — and the resolver chain (the shell toolset over -//! the interactive workspace, the memory tools, `bootstrap_save`, the -//! notify tool, the task tools, and the watch tools) are assembled -//! before the first model call; the model never decides its own -//! permissions. The system prompt is rendered per dispatch from the +//! v1's starting point — and the resolver chain are assembled before +//! the first model call; the model never decides its own permissions. +//! The chain is the core toolsets (the shell over the interactive +//! workspace, memory, `bootstrap_save`) plus every +//! [`crate::extension::ClawExtension`]'s tools in the standard set's +//! order. The system prompt is rendered per dispatch from the //! bootstrap template over the live SOUL/USER files. use std::collections::HashMap; @@ -67,7 +68,6 @@ use crate::backends::BackendRegistry; use crate::bootstrap::{Bootstrap, DEFAULT_SYSTEM_MD, render_system}; use crate::hub::{EventHub, TurnEvent}; use crate::notifications::NotificationStore; -use crate::watch_store::ClawStore; /// The key prefixes of the resident context (see the module docs): /// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys — @@ -158,16 +158,6 @@ pub struct InteractiveHandler { backends: Arc, workspace: PathBuf, tools: Arc, - watch_resolver: Arc, - /// The identity tools: interactive-only by construction — the forge - /// handler never resolves them, so a forge comment cannot reach the - /// accounts. - accounts_resolver: Arc, - /// The one tool that acts with a stored credential: the secret is - /// injected into a command's environment and never returns. - identity_exec: Arc, - notify_resolver: Arc, - task_resolver: Arc, /// The durable cross-session store the memory tools act on; the /// resolver is built per dispatch so its scope tracks the workspace. memory: Arc, @@ -179,98 +169,52 @@ pub struct InteractiveHandler { /// The notification table: the `notify` tool writes it, and /// non-interactive replies auto-notify through it. notifications: Arc, - /// The consent table the `ask_secret` tool asks through and the - /// consent routes answer into. - consents: Arc, - /// The wake bridge between those routes and the tools waiting on - /// their rows. - bridge: Arc, - /// The presentation-identity tool: the same store the settings - /// pages write, offered to the model as `identity_set`. - identity_resolver: Arc, - /// The attachment store the `attach_file` tool copies into and the - /// fetch route serves from. - attachments: Arc, - /// The phone state and command queue the `phone` tool reads and - /// queues through. - phone: Arc, - /// The coding-agent tool: instances and async jobs the resident - /// drives a full rhema-coding-agent session through. - job_resolver: Arc, /// How many tool rounds one turn may run before it is cut off — the /// config's runaway guard for an agent meant to work unattended; a /// follow-up message keeps going. max_turn_steps: u32, + /// The extension set the tool half of the chain is cut from, per + /// 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, } impl InteractiveHandler { /// A handler whose chat turns run in `workspace` (the interactive - /// scratch space under the daemon's workspace root), whose watch - /// tools mutate `watches`, whose notifications land in - /// `notifications`, whose task tools manage `tasks`, and whose - /// `coding_agent` tool enqueues jobs into `jobs`. Approval is - /// v1-permissive — the shell is the point of a resident coding - /// agent — installed once, since resolution is synchronous within a - /// turn. - #[allow(clippy::too_many_arguments)] // sixteen parts, each load-bearing + /// scratch space under the daemon's workspace root) and whose tool + /// half of the resolver chain comes from `extensions` — the same + /// set whose sources the daemon's tick drives and whose boot pass + /// runs at assembly. The core toolsets are the handler's own: the + /// 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 pub fn new( backends: Arc, workspace: impl Into, - watches: Arc, memory: Arc, bootstrap: Arc, hub: Arc, notifications: Arc, - tasks: Arc, - jobs: Arc, - accounts: Arc, max_turn_steps: u32, - consents: Arc, - bridge: Arc, - attachments: Arc, - phone: Arc, - identity: Arc, + extensions: Arc, ) -> Self { let workspace = workspace.into(); let tools = Arc::new(ShellTools::new(&workspace)); tools.install_approval(Arc::new(AllowAll)); - let watch_resolver = - crate::watch_tools::registry(watches.clone()).resolver_for(&Allowlist::permissive()); - let accounts_resolver = crate::accounts_tools::registry(accounts.clone(), watches) - .resolver_for(&Allowlist::permissive()); - let identity_exec: Arc = Arc::new( - crate::accounts_tools::IdentityExec::new(accounts, workspace.clone()), - ); - let notify_resolver = crate::notify_tools::registry(notifications.clone()) - .resolver_for(&Allowlist::permissive()); - let task_resolver = - crate::task_tools::registry(tasks).resolver_for(&Allowlist::permissive()); - let job_resolver = crate::job_tools::registry(jobs).resolver_for(&Allowlist::permissive()); let bootstrap_resolver = crate::bootstrap::registry(bootstrap.clone()).resolver_for(&Allowlist::permissive()); - let identity_resolver = crate::identity::registry(identity.clone(), workspace.clone()) - .resolver_for(&Allowlist::permissive()); Self { backends, workspace, tools, - job_resolver, - watch_resolver, - accounts_resolver, - identity_exec, - notify_resolver, - task_resolver, memory, bootstrap, bootstrap_resolver, - identity_resolver, hub, notifications, - consents, - bridge, - attachments, - phone, max_turn_steps, + extensions, } } @@ -307,49 +251,24 @@ impl InteractiveHandler { .map_err(|err| format!("cannot build the interactive agent: {err}")) } - /// The resolver chain one dispatch runs under: the shell toolset - /// over the interactive workspace, then the resident toolsets. The - /// consent and attachment tools build per dispatch — their asks and - /// cards must land on the session that asked, and the handler + /// The resolver chain one dispatch runs under: the core toolsets — + /// the shell over the interactive workspace, memory, + /// `bootstrap_save` — then every extension's tools in the standard + /// set's order. The whole chain is cut per dispatch: the + /// key-scoped toolsets (consents, attachments, phone) must bind + /// their asks and cards to the session that asked, and the handler /// serves every session. fn resolver(&self, key: &ResourceKey) -> Arc { - Arc::new(ResolverChain::new(vec![ + let mut resolvers: Vec> = vec![ self.tools.clone(), Arc::new(MemoryResolver::new( self.memory.clone(), Scope::Workspace(self.workspace.clone()), )), self.bootstrap_resolver.clone(), - self.notify_resolver.clone(), - self.task_resolver.clone(), - self.job_resolver.clone(), - self.watch_resolver.clone(), - self.accounts_resolver.clone(), - self.identity_exec.clone(), - self.identity_resolver.clone(), - crate::consents::registry( - self.consents.clone(), - self.bridge.clone(), - self.hub.clone(), - key.clone(), - ) - .resolver_for(&Allowlist::permissive()), - crate::attachments::registry( - self.attachments.clone(), - self.workspace.clone(), - self.hub.clone(), - key.clone(), - ) - .resolver_for(&Allowlist::permissive()), - crate::phone::registry( - self.phone.clone(), - self.consents.clone(), - self.bridge.clone(), - self.hub.clone(), - key.clone(), - ) - .resolver_for(&Allowlist::permissive()), - ])) + ]; + resolvers.extend(self.extensions.tools(key)); + Arc::new(ResolverChain::new(resolvers)) } /// One dispatch: drain, restore, turn, stash, reply. @@ -634,6 +553,7 @@ mod tests { use crate::notifications::NotificationStore; use crate::task_store::TaskStore; + use crate::watch_store::ClawStore; use crate::backends::{Backend, BackendRegistry}; use rhema_core::{ @@ -713,6 +633,43 @@ mod tests { Arc::new(crate::attachments::AttachmentStore::open(root).expect("store opens")) } + /// The standard extension set over the tests' throwaway stores — + /// the same chain the daemon assembles, built per test so each + /// assertion runs against its own tables. + #[allow(clippy::too_many_arguments)] + fn test_extensions( + hub: Arc, + watches: Arc, + notifications: Arc, + tasks: Arc, + jobs: Arc, + accounts: Arc, + consents: Arc, + bridge: Arc, + attachments: Arc, + phone: Arc, + identity: Arc, + workspace: &Path, + ) -> Arc { + Arc::new(crate::extension::Extensions::standard( + crate::extension::ExtensionDeps { + watches, + accounts, + notifications, + tasks, + jobs, + consents, + bridge, + attachments, + phone, + identity, + hub, + workspace: workspace.to_path_buf(), + calendars: Vec::new(), + }, + )) + } + fn test_registry(provider: Arc) -> Arc { Arc::new( BackendRegistry::new(vec![Backend::headless("mock", provider)], None) @@ -778,23 +735,30 @@ mod tests { let store = Arc::new(ClawStore::in_memory().expect("store opens")); let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); - let handler = InteractiveHandler::new( - test_registry(recorder.clone()), - workspace.path().join("interactive"), - store, - Arc::new(rhema_memory::VolatileStore::new()), - Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + let notifications = notifications(); + let extensions = test_extensions( hub.clone(), - notifications(), + store, + notifications.clone(), tasks(), jobs(), accounts(), - crate::config::DEFAULT_MAX_TURN_STEPS, consents(), bridge(), attachments(), phone(), identity(), + &workspace.path().join("interactive"), + ); + let handler = InteractiveHandler::new( + test_registry(recorder.clone()), + workspace.path().join("interactive"), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications.clone(), + crate::config::DEFAULT_MAX_TURN_STEPS, + extensions, ); let dispatcher = Arc::new(Dispatcher::new( DispatcherConfig { @@ -857,23 +821,31 @@ mod tests { // must name every tool the chain advertises, with its schema — // what the prompt lists is what the request carries. let workspace = tempfile::tempdir().expect("tempdir"); - let handler = InteractiveHandler::new( - test_registry(Arc::new(MockProvider::new([reply("hi back")]))), - workspace.path().join("interactive"), + let hub = Arc::new(EventHub::default()); + let notifications = notifications(); + let extensions = test_extensions( + hub.clone(), Arc::new(ClawStore::in_memory().expect("store opens")), - Arc::new(rhema_memory::VolatileStore::new()), - Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), - Arc::new(EventHub::default()), - notifications(), + notifications.clone(), tasks(), jobs(), accounts(), - crate::config::DEFAULT_MAX_TURN_STEPS, consents(), bridge(), attachments(), phone(), identity(), + &workspace.path().join("interactive"), + ); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications.clone(), + crate::config::DEFAULT_MAX_TURN_STEPS, + extensions, ); let specs = handler.resolver(&Key::from("interactive:web")).specs(); assert!( @@ -1120,23 +1092,30 @@ mod tests { let store = Arc::new(ClawStore::in_memory().expect("store opens")); let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); - let handler = InteractiveHandler::new( - test_registry(Arc::new(MockProvider::new([reply("hi back")]))), - workspace.path().join("interactive"), - store, - Arc::new(rhema_memory::VolatileStore::new()), - Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + let notifications = notifications(); + let extensions = test_extensions( hub.clone(), - notifications(), + store, + notifications.clone(), tasks(), jobs(), accounts(), - crate::config::DEFAULT_MAX_TURN_STEPS, consents(), bridge(), attachments(), phone(), identity(), + &workspace.path().join("interactive"), + ); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications.clone(), + crate::config::DEFAULT_MAX_TURN_STEPS, + extensions, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1199,6 +1178,21 @@ mod tests { let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); let workspace_path = workspace.path().join("interactive"); + let notifications = notifications(); + let extensions = test_extensions( + hub.clone(), + store, + notifications.clone(), + tasks(), + jobs(), + accounts(), + consents(), + bridge(), + attachments(), + phone(), + identity(), + &workspace_path, + ); let handler = InteractiveHandler::new( test_registry(Arc::new(MockProvider::new([ scripted_call( @@ -1211,20 +1205,12 @@ mod tests { reply("the release channel is #deploys."), ]))), workspace_path.clone(), - store, memory.clone(), Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), - hub, - notifications(), - tasks(), - jobs(), - accounts(), + hub.clone(), + notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, - consents(), - bridge(), - attachments(), - phone(), - identity(), + extensions, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1292,6 +1278,21 @@ mod tests { let store = Arc::new(ClawStore::in_memory().expect("store opens")); let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); + 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(Arc::new(MockProvider::new([ scripted_call( @@ -1308,20 +1309,12 @@ mod tests { reply("setup is done."), ]))), workspace.path().join("interactive"), - store, Arc::new(rhema_memory::VolatileStore::new()), bootstrap.clone(), - hub, - notifications(), - tasks(), - jobs(), - accounts(), + hub.clone(), + notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, - consents(), - bridge(), - attachments(), - phone(), - identity(), + extensions, ); let dispatcher = Arc::new(Dispatcher::new( @@ -1378,23 +1371,30 @@ mod tests { let store = Arc::new(ClawStore::in_memory().expect("store opens")); let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); - let handler = InteractiveHandler::new( - test_registry(Arc::new(MockProvider::new([reply("hi back")]))), - workspace.path().join("interactive"), + let notifications = notifications(); + let extensions = test_extensions( + hub.clone(), store, - Arc::new(rhema_memory::VolatileStore::new()), - Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), - hub, - notifications(), + notifications.clone(), tasks(), jobs(), accounts(), - crate::config::DEFAULT_MAX_TURN_STEPS, consents(), bridge(), attachments(), phone(), identity(), + &workspace.path().join("interactive"), + ); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications.clone(), + crate::config::DEFAULT_MAX_TURN_STEPS, + extensions, ); let backing = Arc::new(VolatileStore::new()); let dispatcher = Arc::new(Dispatcher::new( @@ -1450,26 +1450,32 @@ mod tests { let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); let backing = Arc::new(VolatileStore::new()); + let extensions = test_extensions( + hub.clone(), + store, + notifications.clone(), + tasks(), + jobs(), + accounts(), + consents(), + bridge(), + attachments(), + phone(), + identity(), + &workspace.path().join("interactive"), + ); let handler = Arc::new(InteractiveHandler::new( test_registry(Arc::new(MockProvider::new([ reply("the bread is done."), reply("hi back"), ]))), workspace.path().join("interactive"), - store, Arc::new(rhema_memory::VolatileStore::new()), Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), - hub, + hub.clone(), notifications.clone(), - tasks(), - jobs(), - accounts(), crate::config::DEFAULT_MAX_TURN_STEPS, - consents(), - bridge(), - attachments(), - phone(), - identity(), + extensions, )); let dispatcher = Arc::new(Dispatcher::new( @@ -1566,26 +1572,33 @@ mod tests { let store = Arc::new(ClawStore::in_memory().expect("store opens")); let hub = Arc::new(EventHub::default()); let workspace = tempfile::tempdir().expect("tempdir"); + 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(Arc::new(MockProvider::new([ reply("noted, leaving early."), reply("evening summary: shipped the claw."), ]))), workspace.path().join("interactive"), - store, Arc::new(rhema_memory::VolatileStore::new()), Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), - hub, - notifications(), - tasks(), - jobs(), - accounts(), + hub.clone(), + notifications.clone(), crate::config::DEFAULT_MAX_TURN_STEPS, - consents(), - bridge(), - attachments(), - phone(), - identity(), + extensions, ); let backing = Arc::new(VolatileStore::new()); let dispatcher = Arc::new(Dispatcher::new( diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs index 6eb0c81..81b5160 100644 --- a/crates/rhema-claw/src/lib.rs +++ b/crates/rhema-claw/src/lib.rs @@ -45,6 +45,9 @@ //! - [`watch_tools`] — the agent's watch-management tool set //! - [`notify_tools`] — the `notify` tool: the queue a device polls //! - [`task_tools`] — `schedule_task`, `list_tasks`, `cancel_task` +//! - [`extension`] — the extension seam: the `ClawExtension` trait and +//! the standard set — the tool chain, the detect passes, and the +//! boot pass all iterate it //! - [`forge`] — the sources: one detectable stream per forge kind //! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in, //! events out @@ -71,6 +74,7 @@ pub mod config; pub mod consents; pub mod daemon; pub mod environment; +pub mod extension; pub mod forge; pub mod handler; pub mod http; @@ -104,6 +108,7 @@ pub use calendar::CalendarSource; pub use config::{ClawConfig, ConfigError, ForgeKind, Watch}; pub use daemon::{ClawError, Daemon, TickReport, run_once}; pub use environment::write_report; +pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes}; pub use forge::ForgeSource; pub use handler::ClawHandler; pub use hub::{EventHub, TurnEvent};