feat(claw): move the task tool specs into TOOL.yaml manifests
schedule_task, list_tasks, and cancel_task load their specs from embedded manifests, one folder per tool under crates/rhema-claw/tools; the Rust side keeps only the invoke half. list_tasks declares replay_safety: safe, so a resumed run may re-execute it. Drift tests pin the shipped set: every manifest loads against its builtin, every builtin is named by a manifest, and the registry serves the manifest specs verbatim.
This commit is contained in:
1 parent
60f3b5eb9b
commit
4692c8cddc
4 files changed
+186
-71
No files matched your search
@@ -5,15 +5,17 @@
|
||||
//! restart, no second mechanism, and no timer anywhere: a due task is
|
||||
//! an event (spec §1), never a heartbeat.
|
||||
//!
|
||||
//! 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). Answers are
|
||||
//! short JSON; failures are in-band strings the model can react to.
|
||||
//! The spec the model sees lives in a `TOOL.yaml` per tool (one
|
||||
//! folder under `tools/`, embedded at compile time); 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). Answers are short
|
||||
//! JSON; failures are in-band strings the model can react to.
|
||||
|
||||
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 time::OffsetDateTime;
|
||||
@@ -22,17 +24,57 @@ use time::format_description::well_known::Rfc3339;
|
||||
use crate::interactive::is_resident_key;
|
||||
use crate::task_store::TaskStore;
|
||||
|
||||
/// The compiled operations behind the manifests, by `run.builtin` id:
|
||||
/// the id is a manifest's only link to this code.
|
||||
fn builtins(store: Arc<TaskStore>) -> BTreeMap<String, Arc<dyn ToolOp>> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"task.schedule".to_owned(),
|
||||
Arc::new(ScheduleTask {
|
||||
store: store.clone(),
|
||||
}) as Arc<dyn ToolOp>,
|
||||
),
|
||||
(
|
||||
"task.list".to_owned(),
|
||||
Arc::new(ListTasks {
|
||||
store: store.clone(),
|
||||
}) as Arc<dyn ToolOp>,
|
||||
),
|
||||
(
|
||||
"task.cancel".to_owned(),
|
||||
Arc::new(CancelTask { store }) as Arc<dyn ToolOp>,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/// Every task tool's `TOOL.yaml`, embedded so the daemon ships them
|
||||
/// with no filesystem layout: one folder per tool, named by tool.
|
||||
pub const MANIFESTS: &[(&str, &str)] = &[
|
||||
(
|
||||
"schedule_task",
|
||||
include_str!("../tools/schedule_task/TOOL.yaml"),
|
||||
),
|
||||
("list_tasks", include_str!("../tools/list_tasks/TOOL.yaml")),
|
||||
(
|
||||
"cancel_task",
|
||||
include_str!("../tools/cancel_task/TOOL.yaml"),
|
||||
),
|
||||
];
|
||||
|
||||
/// The task tool set over `store`: `schedule_task`, `list_tasks`,
|
||||
/// `cancel_task`.
|
||||
/// `cancel_task` — specs from the embedded manifests, operations
|
||||
/// compiled here.
|
||||
pub fn registry(store: Arc<TaskStore>) -> ToolRegistry {
|
||||
let (tools, skipped) =
|
||||
rhema_autonomous_agent::manifest::builtin_tools(MANIFESTS, &builtins(store));
|
||||
assert!(
|
||||
skipped.is_empty(),
|
||||
"the task tool manifests must all load: {skipped:?}"
|
||||
);
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(ScheduleTask {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(ListTasks {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(CancelTask { store }));
|
||||
for tool in tools {
|
||||
registry.register(tool);
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -91,33 +133,7 @@ struct ScheduleTask {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ScheduleTask {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"schedule_task",
|
||||
"Schedule a future turn of yours. `prompt` runs as this \
|
||||
agent's own turn — once at `at`, or repeatedly every \
|
||||
`every_secs` seconds; set exactly one of the two. The \
|
||||
scheduled turn has no chat surface: its reply reaches the \
|
||||
user as a notification, so write prompts that produce one. \
|
||||
Use list_tasks and cancel_task to manage what is scheduled.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string",
|
||||
"description": "What the scheduled turn should do." },
|
||||
"at": { "type": ["string", "number"],
|
||||
"description": "When to fire once: an RFC3339 \
|
||||
timestamp (\"2026-10-10T15:00:00Z\") or epoch \
|
||||
milliseconds." },
|
||||
"every_secs": { "type": "integer",
|
||||
"description": "Repeat every this many seconds." }
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
impl ToolOp for ScheduleTask {
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: ScheduleArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
@@ -183,19 +199,7 @@ struct ListTasks {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListTasks {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"list_tasks",
|
||||
"List the scheduled tasks: id, prompt, kind (at or every), \
|
||||
and next_due_millis — when each one fires next.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
impl ToolOp for ListTasks {
|
||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||
let tasks = self.store.list().map_err(|err| err.to_string())?;
|
||||
Ok(json!({ "tasks": tasks }).to_string())
|
||||
@@ -208,23 +212,7 @@ struct CancelTask {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CancelTask {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"cancel_task",
|
||||
"Cancel a scheduled task by id (as list_tasks reports it). \
|
||||
Already-fired one-shots report cancelled: false.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer",
|
||||
"description": "The task's id." }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
impl ToolOp for CancelTask {
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: CancelArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
@@ -259,6 +247,79 @@ mod tests {
|
||||
.await
|
||||
}
|
||||
|
||||
#[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,
|
||||
vec![
|
||||
"schedule_task".to_owned(),
|
||||
"list_tasks".to_owned(),
|
||||
"cancel_task".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 the_registry_serves_the_manifest_specs() {
|
||||
let resolver =
|
||||
registry(store()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
let specs = resolver.specs();
|
||||
for (name, raw) in MANIFESTS {
|
||||
let manifest = rhema_autonomous_agent::manifest::parse(raw)
|
||||
.unwrap_or_else(|err| panic!("{name}: {err}"));
|
||||
let spec = specs
|
||||
.iter()
|
||||
.find(|spec| spec.name == *name)
|
||||
.unwrap_or_else(|| panic!("{name} is not offered"));
|
||||
assert_eq!(
|
||||
spec,
|
||||
&rhema_core::ToolSpec::new(
|
||||
manifest.name,
|
||||
manifest.description,
|
||||
manifest.parameters,
|
||||
),
|
||||
"{name} serves its manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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_tasks"),
|
||||
rhema_core::ReplaySafety::Safe
|
||||
);
|
||||
assert_eq!(
|
||||
resolver.replay_safety("schedule_task"),
|
||||
rhema_core::ReplaySafety::Unsafe,
|
||||
"re-running a schedule call on resume could double-schedule"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_at_lands_a_one_shot_row() {
|
||||
let store = store();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# The model-facing spec of the cancel_task tool; the operation is the
|
||||
# compiled builtin named under `run`.
|
||||
name: cancel_task
|
||||
description: >-
|
||||
Cancel a scheduled task by id (as list_tasks reports it).
|
||||
Already-fired one-shots report cancelled: false.
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The task's id.
|
||||
required: [id]
|
||||
run:
|
||||
builtin: task.cancel
|
||||
@@ -0,0 +1,12 @@
|
||||
# The model-facing spec of the list_tasks tool; the operation is the
|
||||
# compiled builtin named under `run`.
|
||||
name: list_tasks
|
||||
description: >-
|
||||
List the scheduled tasks: id, prompt, kind (at or every), and
|
||||
next_due_millis — when each one fires next.
|
||||
replay_safety: safe
|
||||
parameters:
|
||||
type: object
|
||||
properties: {}
|
||||
run:
|
||||
builtin: task.list
|
||||
@@ -0,0 +1,27 @@
|
||||
# The model-facing spec of the schedule_task tool; the operation is the
|
||||
# compiled builtin named under `run`.
|
||||
name: schedule_task
|
||||
description: >-
|
||||
Schedule a future turn of yours. `prompt` runs as this agent's own
|
||||
turn, once at `at`, or repeatedly every `every_secs` seconds; set
|
||||
exactly one of the two. The scheduled turn has no chat surface: its
|
||||
reply reaches the user as a notification, so write prompts that
|
||||
produce one. Use list_tasks and cancel_task to manage what is
|
||||
scheduled.
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
prompt:
|
||||
type: string
|
||||
description: What the scheduled turn should do.
|
||||
at:
|
||||
type: [string, number]
|
||||
description: >-
|
||||
When to fire once: an RFC3339 timestamp
|
||||
("2026-10-10T15:00:00Z") or epoch milliseconds.
|
||||
every_secs:
|
||||
type: integer
|
||||
description: Repeat every this many seconds.
|
||||
required: [prompt]
|
||||
run:
|
||||
builtin: task.schedule
|
||||
Reference in new issue
Block a user