feat(claw): move the attach_file tool spec into a TOOL.yaml manifest

attach_file loads its spec from an embedded manifest under
crates/rhema-claw/tools; the Rust side keeps only the invoke half. It
stays replay-unsafe: a resumed run must not re-attach a file the
transcript already carries a card for.
This commit is contained in:
vhaudiquet committed 2026-10-20 22:08:49 +02:00
1 parent 162cb21d7c
commit 1df2718032
2 files changed
+80 -36

No files matched your search

+59 -36
View File
@@ -11,13 +11,13 @@
//! from turning the store into a workspace mirror; a file past the cap
//! is an in-band error, and the model can cut it down first.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
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};
@@ -194,9 +194,6 @@ pub fn content_type(name: &str) -> &'static str {
}
}
/// The tool's name on the wire.
pub const ATTACH_FILE: &str = "attach_file";
/// `attach_file`: copy a workspace file into the attachment store and
/// put its card on the stream. Built per dispatch — the event must
/// land on the asking session.
@@ -216,31 +213,7 @@ struct AttachArgs {
}
#[async_trait]
impl Tool for AttachFile {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
ATTACH_FILE,
"Attach a file from this session's workspace to the chat: the \
human gets a card with the file's name and can view or \
download it. Use it to hand over artifacts a reply snippet \
cannot carry — a screenshot, a diff, a generated report, a \
log. The path is workspace-relative. Large files are \
refused; attach the relevant cut instead.",
json!({
"type": "object",
"properties": {
"path": { "type": "string",
"description": "The workspace-relative file path, \
e.g. 'screenshots/failure.png'." },
"caption": { "type": "string",
"description": "One line about what the file is \
and why it matters." }
},
"required": ["path"]
}),
)
}
impl ToolOp for AttachFile {
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: AttachArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -301,25 +274,75 @@ impl Tool for AttachFile {
}
/// The attachment tool set over `store`: `attach_file`.
/// The compiled operation behind the manifest, by `run.builtin` id.
fn builtins(
store: Arc<AttachmentStore>,
workspace: PathBuf,
hub: Arc<EventHub>,
key: ResourceKey,
) -> BTreeMap<String, Arc<dyn ToolOp>> {
BTreeMap::from([(
"attach.file".to_owned(),
Arc::new(AttachFile {
store,
workspace,
hub,
key,
}) as Arc<dyn ToolOp>,
)])
}
/// The attach tool's `TOOL.yaml`, embedded so the daemon ships it with
/// no filesystem layout.
pub const MANIFESTS: &[(&str, &str)] = &[(
"attach_file",
include_str!("../tools/attach_file/TOOL.yaml"),
)];
/// The attachment tool set over `store`: `attach_file` — its spec from
/// the embedded manifest, the operation compiled here.
pub fn registry(
store: Arc<AttachmentStore>,
workspace: impl Into<PathBuf>,
hub: Arc<EventHub>,
key: ResourceKey,
) -> ToolRegistry {
let (tools, skipped) = rhema_autonomous_agent::manifest::builtin_tools(
MANIFESTS,
&builtins(store, workspace.into(), hub, key),
);
assert!(
skipped.is_empty(),
"the attach_file tool manifest must load: {skipped:?}"
);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(AttachFile {
store,
workspace: workspace.into(),
hub,
key,
}));
for tool in tools {
registry.register(tool);
}
registry
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_autonomous_agent::Allowlist;
#[test]
fn the_manifest_loads_against_its_builtin_and_serves_the_registry() {
let dir = tempfile::tempdir().expect("tempdir");
let resolver = registry(
store(dir.path()),
dir.path().join("ws"),
Arc::new(EventHub::with_capacity(16)),
ResourceKey::new("r"),
)
.resolver_for(&Allowlist::permissive());
assert_eq!(
resolver.replay_safety("attach_file"),
rhema_core::ReplaySafety::Unsafe,
"re-running an attach on resume would duplicate the card"
);
}
fn store(dir: &Path) -> Arc<AttachmentStore> {
Arc::new(AttachmentStore::open(dir.join("attachments")).expect("store opens"))
@@ -0,0 +1,21 @@
# The model-facing spec of the attach_file tool; the operation is the
# compiled builtin named under `run`.
name: attach_file
description: >-
Attach a file from this session's workspace to the chat: the human
gets a card with the file's name and can view or download it. Use it
to hand over artifacts a reply snippet cannot carry — a screenshot,
a diff, a generated report, a log. The path is workspace-relative.
Large files are refused; attach the relevant cut instead.
parameters:
type: object
properties:
path:
type: string
description: "The workspace-relative file path, e.g. 'screenshots/failure.png'."
caption:
type: string
description: One line about what the file is and why it matters.
required: [path]
run:
builtin: attach.file