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

identity_set 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-write the identity.
This commit is contained in:
vhaudiquet committed 2026-10-20 21:44:04 +02:00
1 parent 2747018b8f
commit 162cb21d7c
2 files changed
+90 -54

No files matched your search

+43 -54
View File
@@ -10,11 +10,11 @@
//! talks, what it is like — is deliberately not here: that is SOUL.md, //! talks, what it is like — is deliberately not here: that is SOUL.md,
//! the bootstrap store's domain, edited with the agent over onboarding. //! the bootstrap store's domain, edited with the agent over onboarding.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
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, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -239,9 +239,6 @@ impl IdentityStore {
} }
} }
/// The tool's name on the wire.
pub const IDENTITY_SET: &str = "identity_set";
/// `identity_set`: the model's surface over its own presentation — /// `identity_set`: the model's surface over its own presentation —
/// the same store the settings pages write, so what it sets here is /// the same store the settings pages write, so what it sets here is
/// what every client renders. Personality stays out of reach: that is /// what every client renders. Personality stays out of reach: that is
@@ -252,50 +249,7 @@ struct IdentitySet {
} }
#[async_trait] #[async_trait]
impl Tool for IdentitySet { impl ToolOp for IdentitySet {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
IDENTITY_SET,
"Set your own presentation identity: the name, mark, \
initials, tagline, and signoff the interfaces render, and \
your avatar or logo images. This is how you look, not who \
you are — personality lives in SOUL.md. Leave a field out \
to keep what is set; an empty string clears it. The avatar \
and logo take a workspace-relative path to an image file \
(png, jpeg, gif, or webp, under 2 MiB — svg is refused).",
json!({
"type": "object",
"properties": {
"name": { "type": "string",
"description": "Display name: message author, \
window title, notifications. Up to 64 chars." },
"mark": { "type": "string",
"description": "Glyph or emoji for the rail and \
favicon. Up to 8 chars." },
"initials": { "type": "string",
"description": "Shown when there is no mark and \
no avatar. Up to 4 chars." },
"tagline": { "type": "string",
"description": "One line under the name. Up to \
120 chars." },
"signoff": { "type": "string",
"description": "Appended to messages sent \
outside the interface. Up to 120 chars." },
"avatar": { "type": "string",
"description": "Workspace-relative path to the \
avatar image." },
"logo": { "type": "string",
"description": "Workspace-relative path to the \
logo image." },
"clear": { "type": "array",
"items": { "type": "string",
"enum": ["avatar", "logo"] },
"description": "Images to remove." }
},
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> { async fn invoke(&self, args: Value) -> Result<String, String> {
if !args.is_object() { if !args.is_object() {
return Err("bad arguments: expected an object".to_owned()); return Err("bad arguments: expected an object".to_owned());
@@ -373,19 +327,54 @@ fn workspace_image(workspace: &Path, path: &str) -> Result<PathBuf, String> {
Ok(workspace.join(relative)) Ok(workspace.join(relative))
} }
/// The identity tool set over `store`: `identity_set`. /// The compiled operation behind the manifest, by `run.builtin` id.
fn builtins(store: Arc<IdentityStore>, workspace: PathBuf) -> BTreeMap<String, Arc<dyn ToolOp>> {
BTreeMap::from([(
"identity.set".to_owned(),
Arc::new(IdentitySet { store, workspace }) as Arc<dyn ToolOp>,
)])
}
/// The identity tool's `TOOL.yaml`, embedded so the daemon ships it
/// with no filesystem layout.
pub const MANIFESTS: &[(&str, &str)] = &[(
"identity_set",
include_str!("../tools/identity_set/TOOL.yaml"),
)];
/// The identity tool set over `store`: `identity_set` — its spec from
/// the embedded manifest, the operation compiled here.
pub fn registry(store: Arc<IdentityStore>, workspace: impl Into<PathBuf>) -> ToolRegistry { pub fn registry(store: Arc<IdentityStore>, workspace: impl Into<PathBuf>) -> ToolRegistry {
let (tools, skipped) = rhema_autonomous_agent::manifest::builtin_tools(
MANIFESTS,
&builtins(store, workspace.into()),
);
assert!(
skipped.is_empty(),
"the identity_set tool manifest must load: {skipped:?}"
);
let mut registry = ToolRegistry::new(); let mut registry = ToolRegistry::new();
registry.register(Arc::new(IdentitySet { for tool in tools {
store, registry.register(tool);
workspace: workspace.into(), }
}));
registry registry
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rhema_autonomous_agent::Allowlist;
#[test]
fn the_manifest_loads_against_its_builtin_and_serves_the_registry() {
let (store, _dir) = store();
let resolver = registry(store.into(), "/ws").resolver_for(&Allowlist::permissive());
assert_eq!(
resolver.replay_safety("identity_set"),
rhema_core::ReplaySafety::Unsafe,
"re-running a set on resume would re-write the identity"
);
}
/// The store over a tempdir, with the guard returned so the /// The store over a tempdir, with the guard returned so the
/// directory outlives the calls under test. /// directory outlives the calls under test.
@@ -0,0 +1,47 @@
# The model-facing spec of the identity_set tool; the operation is the
# compiled builtin named under `run`.
name: identity_set
description: >-
Set your own presentation identity: the name, mark, initials,
tagline, and signoff the interfaces render, and your avatar or logo
images. This is how you look, not who you are — personality lives in
SOUL.md. Leave a field out to keep what is set; an empty string
clears it. The avatar and logo take a workspace-relative path to an
image file (png, jpeg, gif, or webp, under 2 MiB — svg is refused).
parameters:
type: object
properties:
name:
type: string
description: >-
Display name: message author, window title, notifications. Up
to 64 chars.
mark:
type: string
description: Glyph or emoji for the rail and favicon. Up to 8 chars.
initials:
type: string
description: >-
Shown when there is no mark and no avatar. Up to 4 chars.
tagline:
type: string
description: One line under the name. Up to 120 chars.
signoff:
type: string
description: >-
Appended to messages sent outside the interface. Up to 120
chars.
avatar:
type: string
description: Workspace-relative path to the avatar image.
logo:
type: string
description: Workspace-relative path to the logo image.
clear:
type: array
items:
type: string
enum: [avatar, logo]
description: Images to remove.
run:
builtin: identity.set