feat(claw): move the watch tool specs into TOOL.yaml manifests

watch_repo, unwatch_repo, and list_watches load their specs from
embedded manifests under crates/rhema-claw/tools; the Rust side keeps
only the invoke half. The manifests stay name-sorted, which is the
order the registry serves and the roster shows. list_watches declares
replay_safety: safe - re-reading the table on a resumed run is free -
and the drift tests pin the shipped set: every manifest loads against
its builtin, every builtin is named by a manifest, and replay safety
reaches the resolver from the file.
This commit is contained in:
vhaudiquet committed 2026-10-20 19:06:33 +02:00
1 parent 8c14ea7749
commit c1779c2423
4 files changed
+162 -75

No files matched your search

+105 -75
View File
@@ -3,34 +3,75 @@
//! tool call, the row lands in the [`ClawStore`], and the next detect
//! pass picks it up. No restart, no second mechanism.
//!
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
//! interactive handler offers them through the same allowlist-filtered
//! seam as everything else the model may call (spec §8: the allowlist is
//! fixed before the first step, never decided mid-run). Answers are
//! short JSON; failures are in-band strings the model can react to.
//! Tokens never appear in an answer.
//! Each tool's spec lives in a `TOOL.yaml` (one folder under
//! `tools/`); the code here is the operation half only — [`ToolOp`]
//! implementations behind a [`ToolRegistry`], offered through the same
//! allowlist-filtered seam as everything else the model may call
//! (spec §8: the allowlist is fixed before the first step, never
//! decided mid-run). Answers are short JSON; failures are in-band
//! strings the model can react to. Tokens never appear in an answer.
use std::collections::BTreeMap;
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use rhema_autonomous_agent::{ToolOp, ToolRegistry, async_trait};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::config::ForgeKind;
use crate::watch_store::{ClawStore, validate_watch};
/// The compiled operations behind the manifests, by `run.builtin` id.
fn builtins(store: Arc<ClawStore>) -> BTreeMap<String, Arc<dyn ToolOp>> {
BTreeMap::from([
(
"watch.repo".to_owned(),
Arc::new(WatchRepo {
store: store.clone(),
}) as Arc<dyn ToolOp>,
),
(
"watch.unwatch".to_owned(),
Arc::new(UnwatchRepo {
store: store.clone(),
}) as Arc<dyn ToolOp>,
),
(
"watch.list".to_owned(),
Arc::new(ListWatches { store }) as Arc<dyn ToolOp>,
),
])
}
/// Every watch tool's `TOOL.yaml`, embedded so the daemon ships them
/// with no filesystem layout: one folder per tool, named by tool, in
/// the order the registry serves them.
pub const MANIFESTS: &[(&str, &str)] = &[
(
"list_watches",
include_str!("../tools/list_watches/TOOL.yaml"),
),
(
"unwatch_repo",
include_str!("../tools/unwatch_repo/TOOL.yaml"),
),
("watch_repo", include_str!("../tools/watch_repo/TOOL.yaml")),
];
/// The watch-management tool set over `store`: `watch_repo`,
/// `unwatch_repo`, `list_watches`.
/// `unwatch_repo`, `list_watches` — specs from the embedded manifests,
/// operations compiled here.
pub fn registry(store: Arc<ClawStore>) -> ToolRegistry {
let (tools, skipped) =
rhema_autonomous_agent::manifest::builtin_tools(MANIFESTS, &builtins(store));
assert!(
skipped.is_empty(),
"the watch tool manifests must all load: {skipped:?}"
);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(WatchRepo {
store: store.clone(),
}));
registry.register(Arc::new(UnwatchRepo {
store: store.clone(),
}));
registry.register(Arc::new(ListWatches { store }));
for tool in tools {
registry.register(tool);
}
registry
}
@@ -56,33 +97,7 @@ struct WatchRepo {
}
#[async_trait]
impl Tool for WatchRepo {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"watch_repo",
"Start watching a repository: new activity (issues, pull \
requests, comments, reviews) arrives as its own dispatch. \
`forge` is `github`, `gitea`, or `gitlab`; `repo` is \
`owner/name` (GitLab: `namespace/project`); gitea also needs \
`host`, its instance base URL. `token` is optional and falls \
back to the forge's environment variable.",
json!({
"type": "object",
"properties": {
"forge": { "type": "string",
"description": "github, gitea, or gitlab." },
"repo": { "type": "string",
"description": "The repository as owner/name." },
"host": { "type": "string",
"description": "Instance base URL; required for gitea." },
"token": { "type": "string",
"description": "Forge token; optional, env fallback." }
},
"required": ["forge", "repo"]
}),
)
}
impl ToolOp for WatchRepo {
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: WatchArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -115,25 +130,7 @@ struct UnwatchRepo {
}
#[async_trait]
impl Tool for UnwatchRepo {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"unwatch_repo",
"Stop watching a repository: no more activity arrives from \
it. Same `forge` and `repo` spelling as `watch_repo`.",
json!({
"type": "object",
"properties": {
"forge": { "type": "string",
"description": "github, gitea, or gitlab." },
"repo": { "type": "string",
"description": "The repository as owner/name." }
},
"required": ["forge", "repo"]
}),
)
}
impl ToolOp for UnwatchRepo {
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: UnwatchArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -159,19 +156,7 @@ struct ListWatches {
}
#[async_trait]
impl Tool for ListWatches {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"list_watches",
"List the watched repositories: forge, repo, and host for \
each.",
json!({
"type": "object",
"properties": {},
}),
)
}
impl ToolOp for ListWatches {
async fn invoke(&self, _args: Value) -> Result<String, String> {
let watches = self.store.list().map_err(|err| err.to_string())?;
let rows: Vec<Value> = watches
@@ -205,6 +190,51 @@ mod tests {
.collect()
}
#[test]
fn every_manifest_loads_against_its_builtin() {
let (tools, skipped) =
rhema_autonomous_agent::manifest::builtin_tools(MANIFESTS, &builtins(store()));
assert!(skipped.is_empty(), "{skipped:?}");
let names: Vec<String> = tools.iter().map(|tool| tool.spec().name).collect();
assert_eq!(
names,
["list_watches", "unwatch_repo", "watch_repo"].map(str::to_owned)
);
}
#[test]
fn every_builtin_is_named_by_a_manifest() {
let mut covered = Vec::new();
for (source, raw) in MANIFESTS {
covered.push(
rhema_autonomous_agent::manifest::parse(raw)
.unwrap_or_else(|err| panic!("{source}: {err}"))
.builtin,
);
}
for id in builtins(store()).keys() {
assert!(
covered.iter().any(|builtin| builtin == id),
"{id} has no TOOL.yaml"
);
}
}
#[test]
fn replay_safety_comes_from_the_manifest() {
let resolver =
registry(store()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
assert_eq!(
resolver.replay_safety("list_watches"),
rhema_core::ReplaySafety::Safe
);
assert_eq!(
resolver.replay_safety("watch_repo"),
rhema_core::ReplaySafety::Unsafe,
"re-running a watch call on resume could double-watch"
);
}
#[tokio::test]
async fn the_registry_offers_the_three_tools() {
let tools = registry(store());
@@ -0,0 +1,11 @@
# The model-facing spec of the list_watches tool; the operation is the
# compiled builtin named under `run`.
name: list_watches
description: >-
List the watched repositories: forge, repo, and host for each.
replay_safety: safe
parameters:
type: object
properties: {}
run:
builtin: watch.list
@@ -0,0 +1,18 @@
# The model-facing spec of the unwatch_repo tool; the operation is the
# compiled builtin named under `run`.
name: unwatch_repo
description: >-
Stop watching a repository: no more activity arrives from it. Same
`forge` and `repo` spelling as `watch_repo`.
parameters:
type: object
properties:
forge:
type: string
description: github, gitea, or gitlab.
repo:
type: string
description: The repository as owner/name.
required: [forge, repo]
run:
builtin: watch.unwatch
@@ -0,0 +1,28 @@
# The model-facing spec of the watch_repo tool; the operation is the
# compiled builtin named under `run`.
name: watch_repo
description: >-
Start watching a repository: new activity (issues, pull requests,
comments, reviews) arrives as its own dispatch. `forge` is `github`,
`gitea`, or `gitlab`; `repo` is `owner/name` (GitLab:
`namespace/project`); gitea also needs `host`, its instance base
URL. `token` is optional and falls back to the forge's environment
variable.
parameters:
type: object
properties:
forge:
type: string
description: github, gitea, or gitlab.
repo:
type: string
description: The repository as owner/name.
host:
type: string
description: Instance base URL; required for gitea.
token:
type: string
description: Forge token; optional, env fallback.
required: [forge, repo]
run:
builtin: watch.repo