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
|
//! restart, no second mechanism, and no timer anywhere: a due task is
|
||||||
//! an event (spec §1), never a heartbeat.
|
//! an event (spec §1), never a heartbeat.
|
||||||
//!
|
//!
|
||||||
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
//! The spec the model sees lives in a `TOOL.yaml` per tool (one
|
||||||
//! interactive handler offers them through the same allowlist-filtered
|
//! folder under `tools/`, embedded at compile time); the code here is
|
||||||
//! seam as everything else the model may call (spec §8). Answers are
|
//! the operation half only — [`ToolOp`] implementations behind a
|
||||||
//! short JSON; failures are in-band strings the model can react to.
|
//! [`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 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 time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -22,17 +24,57 @@ use time::format_description::well_known::Rfc3339;
|
|||||||
use crate::interactive::is_resident_key;
|
use crate::interactive::is_resident_key;
|
||||||
use crate::task_store::TaskStore;
|
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`,
|
/// 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 {
|
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();
|
let mut registry = ToolRegistry::new();
|
||||||
registry.register(Arc::new(ScheduleTask {
|
for tool in tools {
|
||||||
store: store.clone(),
|
registry.register(tool);
|
||||||
}));
|
}
|
||||||
registry.register(Arc::new(ListTasks {
|
|
||||||
store: store.clone(),
|
|
||||||
}));
|
|
||||||
registry.register(Arc::new(CancelTask { store }));
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,33 +133,7 @@ struct ScheduleTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ScheduleTask {
|
impl ToolOp 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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
let args: ScheduleArgs =
|
let args: ScheduleArgs =
|
||||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
@@ -183,19 +199,7 @@ struct ListTasks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ListTasks {
|
impl ToolOp 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": {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||||
let tasks = self.store.list().map_err(|err| err.to_string())?;
|
let tasks = self.store.list().map_err(|err| err.to_string())?;
|
||||||
Ok(json!({ "tasks": tasks }).to_string())
|
Ok(json!({ "tasks": tasks }).to_string())
|
||||||
@@ -208,23 +212,7 @@ struct CancelTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for CancelTask {
|
impl ToolOp 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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
let args: CancelArgs =
|
let args: CancelArgs =
|
||||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
@@ -259,6 +247,79 @@ mod tests {
|
|||||||
.await
|
.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]
|
#[tokio::test]
|
||||||
async fn schedule_at_lands_a_one_shot_row() {
|
async fn schedule_at_lands_a_one_shot_row() {
|
||||||
let store = store();
|
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