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:
1 parent
8c14ea7749
commit
c1779c2423
4 files changed
+162
-75
No files matched your search
@@ -3,34 +3,75 @@
|
|||||||
//! tool call, the row lands in the [`ClawStore`], and the next detect
|
//! tool call, the row lands in the [`ClawStore`], and the next detect
|
||||||
//! pass picks it up. No restart, no second mechanism.
|
//! pass picks it up. No restart, no second mechanism.
|
||||||
//!
|
//!
|
||||||
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
//! Each tool's spec lives in a `TOOL.yaml` (one folder under
|
||||||
//! interactive handler offers them through the same allowlist-filtered
|
//! `tools/`); the code here is the operation half only — [`ToolOp`]
|
||||||
//! seam as everything else the model may call (spec §8: the allowlist is
|
//! implementations behind a [`ToolRegistry`], offered through the same
|
||||||
//! fixed before the first step, never decided mid-run). Answers are
|
//! allowlist-filtered seam as everything else the model may call
|
||||||
//! short JSON; failures are in-band strings the model can react to.
|
//! (spec §8: the allowlist is fixed before the first step, never
|
||||||
//! Tokens never appear in an answer.
|
//! 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 std::sync::Arc;
|
||||||
|
|
||||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
use rhema_autonomous_agent::{ToolOp, ToolRegistry, async_trait};
|
||||||
use rhema_core::ToolSpec;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::config::ForgeKind;
|
use crate::config::ForgeKind;
|
||||||
use crate::watch_store::{ClawStore, validate_watch};
|
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`,
|
/// 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 {
|
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();
|
let mut registry = ToolRegistry::new();
|
||||||
registry.register(Arc::new(WatchRepo {
|
for tool in tools {
|
||||||
store: store.clone(),
|
registry.register(tool);
|
||||||
}));
|
}
|
||||||
registry.register(Arc::new(UnwatchRepo {
|
|
||||||
store: store.clone(),
|
|
||||||
}));
|
|
||||||
registry.register(Arc::new(ListWatches { store }));
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,33 +97,7 @@ struct WatchRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for WatchRepo {
|
impl ToolOp 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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
let args: WatchArgs =
|
let args: WatchArgs =
|
||||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
@@ -115,25 +130,7 @@ struct UnwatchRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for UnwatchRepo {
|
impl ToolOp 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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
let args: UnwatchArgs =
|
let args: UnwatchArgs =
|
||||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
@@ -159,19 +156,7 @@ struct ListWatches {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ListWatches {
|
impl ToolOp for ListWatches {
|
||||||
fn spec(&self) -> ToolSpec {
|
|
||||||
ToolSpec::new(
|
|
||||||
"list_watches",
|
|
||||||
"List the watched repositories: forge, repo, and host for \
|
|
||||||
each.",
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||||
let watches = self.store.list().map_err(|err| err.to_string())?;
|
let watches = self.store.list().map_err(|err| err.to_string())?;
|
||||||
let rows: Vec<Value> = watches
|
let rows: Vec<Value> = watches
|
||||||
@@ -205,6 +190,51 @@ mod tests {
|
|||||||
.collect()
|
.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]
|
#[tokio::test]
|
||||||
async fn the_registry_offers_the_three_tools() {
|
async fn the_registry_offers_the_three_tools() {
|
||||||
let tools = registry(store());
|
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
|
||||||
Reference in new issue
Block a user