perf(chat): index skills in the prompt instead of inlining every body
The chat path concatenated every installed skill's complete markdown into the system prompt on every turn. Bodies average ~3.5 KB (~900 tokens) and the count is unbounded, so this was by far the largest thing in the prompt and it scaled with how many skills a claw had installed -- a fixed toll paid whether or not any skill was relevant to the turn. The prompt now lists name + description, and a new `skills.read` tool fetches a body on demand. This is the contract the mission path already had: the `clawmates_skills` MCP server advertises description + when_to_use and lets the agent read what it needs. The two paths now agree. `compose_system` takes (title, description, body) rather than (title, body): the index needs the description, and first-touch brain seeding still needs the real body so the .brain stays a complete portable artifact. Not done here: filtering tool descriptors per agent, which the plan paired with this. The premise doesn't hold -- risk_profile governs the ZeroClaw tool namespace (file_edit, shell) on the mission path, while the chat path has its own registry (files.write, shell.exec) and no per-agent policy whatsoever; `risk_profile` appears nowhere in cm-runtime. Filtering there would invent a capability boundary rather than enforce one, silently revoking chat tools. Left for a deliberate decision. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
285d0c82f2
commit
81b93a5c25
@@ -1,10 +1,12 @@
|
|||||||
//! Best-effort brain augmentation for the chat path.
|
//! Best-effort brain augmentation for the chat path.
|
||||||
//!
|
//!
|
||||||
//! Each turn we open the claw's local working `.brain` (cm-brain / ClawhDF5),
|
//! Each turn we open the claw's local working `.brain` (cm-brain / ClawhDF5),
|
||||||
//! recall relevant memory, record the user's turn, and compose a system prompt
|
//! recall relevant memory, record the turn, and compose a system prompt on top
|
||||||
//! that injects the claw's **identity** (AGENTS.md "how I operate" + personality),
|
//! of the claw's Postgres-authoritative one. Any failure falls back to the plain
|
||||||
//! **skills**, and **recalled memory** on top of its Postgres-authoritative system
|
//! prompt — the brain must never break chat.
|
||||||
//! prompt. Any failure falls back to the plain prompt — the brain must never break chat.
|
//!
|
||||||
|
//! Skills are **indexed, not inlined**: the prompt lists what the claw has and
|
||||||
|
//! what each is for, and `skills.read` fetches a body on demand.
|
||||||
//!
|
//!
|
||||||
//! The local file is a working cache of the claw's brain (canonical home is
|
//! The local file is a working cache of the claw's brain (canonical home is
|
||||||
//! ClawBrainHub); memory accrues here and is pushed back on save/publish.
|
//! ClawBrainHub); memory accrues here and is pushed back on save/publish.
|
||||||
@@ -25,7 +27,7 @@ fn brain_dir() -> PathBuf {
|
|||||||
pub fn compose_system(
|
pub fn compose_system(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
base_prompt: &str,
|
base_prompt: &str,
|
||||||
skills: &[(String, String)], // (title, body)
|
skills: &[(String, String, String)], // (title, description, body)
|
||||||
user_text: &str,
|
user_text: &str,
|
||||||
session_label: &str,
|
session_label: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
@@ -41,7 +43,7 @@ pub fn compose_system(
|
|||||||
fn try_compose(
|
fn try_compose(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
base_prompt: &str,
|
base_prompt: &str,
|
||||||
skills: &[(String, String)],
|
skills: &[(String, String, String)],
|
||||||
user_text: &str,
|
user_text: &str,
|
||||||
session_label: &str,
|
session_label: &str,
|
||||||
) -> Result<String, cm_brain::BrainError> {
|
) -> Result<String, cm_brain::BrainError> {
|
||||||
@@ -55,7 +57,7 @@ fn try_compose(
|
|||||||
if !base_prompt.is_empty() {
|
if !base_prompt.is_empty() {
|
||||||
brain.set_system_prompt(base_prompt)?;
|
brain.set_system_prompt(base_prompt)?;
|
||||||
}
|
}
|
||||||
for (name, body) in skills {
|
for (name, _description, body) in skills {
|
||||||
brain.set_skill(name, body)?;
|
brain.set_skill(name, body)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,10 +88,21 @@ fn try_compose(
|
|||||||
out.push_str("\n\n## Personality\n");
|
out.push_str("\n\n## Personality\n");
|
||||||
out.push_str(&persona);
|
out.push_str(&persona);
|
||||||
}
|
}
|
||||||
|
// Skills are indexed, not inlined. Bodies average ~3.5 KB (~900 tokens)
|
||||||
|
// each and were previously concatenated in full on every turn, unbounded in
|
||||||
|
// the number installed — by far the largest thing in the prompt. The claw
|
||||||
|
// now sees what it has and what each is for, and calls `skills.read` for a
|
||||||
|
// body when one is actually relevant. Same summary-and-fetch contract the
|
||||||
|
// mission path already gets from the `clawmates_skills` MCP server.
|
||||||
if !skills.is_empty() {
|
if !skills.is_empty() {
|
||||||
out.push_str("\n\n## Your skills (apply them when relevant)\n");
|
out.push_str("\n\n## Your skills\n");
|
||||||
for (name, body) in skills {
|
out.push_str("Call `skills.read` with a skill's name to read it in full.\n");
|
||||||
out.push_str(&format!("\n### {name}\n{body}\n"));
|
for (name, description, _body) in skills {
|
||||||
|
if description.trim().is_empty() {
|
||||||
|
out.push_str(&format!("- {name}\n"));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("- {name} — {description}\n"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !recalled.is_empty() {
|
if !recalled.is_empty() {
|
||||||
|
|||||||
@@ -427,12 +427,13 @@ impl Runtime {
|
|||||||
// Brain-augmented system prompt: inject the claw's installed skills +
|
// Brain-augmented system prompt: inject the claw's installed skills +
|
||||||
// recall relevant memory from its .brain, and record the user turn.
|
// recall relevant memory from its .brain, and record the user turn.
|
||||||
// Best-effort — falls back to the plain system prompt on any error.
|
// Best-effort — falls back to the plain system prompt on any error.
|
||||||
let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&inner.pool, agent.id)
|
let skills: Vec<(String, String, String)> =
|
||||||
.await
|
cm_db::repo::skills::installed(&inner.pool, agent.id)
|
||||||
.unwrap_or_default()
|
.await
|
||||||
.into_iter()
|
.unwrap_or_default()
|
||||||
.map(|s| (s.title, s.body))
|
.into_iter()
|
||||||
.collect();
|
.map(|s| (s.title, s.description, s.body))
|
||||||
|
.collect();
|
||||||
let system_prompt = crate::brain::compose_system(
|
let system_prompt = crate::brain::compose_system(
|
||||||
&agent.id.to_string(),
|
&agent.id.to_string(),
|
||||||
&agent.system_prompt,
|
&agent.system_prompt,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod email;
|
|||||||
mod files;
|
mod files;
|
||||||
mod routine;
|
mod routine;
|
||||||
mod shell;
|
mod shell;
|
||||||
|
mod skills;
|
||||||
mod slack;
|
mod slack;
|
||||||
mod websearch;
|
mod websearch;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ pub use delegate::Delegate;
|
|||||||
pub use email::EmailSend;
|
pub use email::EmailSend;
|
||||||
pub use files::{FilesDelete, FilesList, FilesWrite};
|
pub use files::{FilesDelete, FilesList, FilesWrite};
|
||||||
pub use routine::RoutineSchedule;
|
pub use routine::RoutineSchedule;
|
||||||
|
pub use skills::SkillsRead;
|
||||||
pub use slack::SlackPost;
|
pub use slack::SlackPost;
|
||||||
|
|
||||||
/// Execution context handed to tools: who is acting, for which tenant.
|
/// Execution context handed to tools: who is acting, for which tenant.
|
||||||
@@ -85,6 +87,7 @@ impl Default for ToolRegistry {
|
|||||||
registry.register(Arc::new(FilesList));
|
registry.register(Arc::new(FilesList));
|
||||||
registry.register(Arc::new(FilesDelete));
|
registry.register(Arc::new(FilesDelete));
|
||||||
registry.register(Arc::new(RoutineSchedule));
|
registry.register(Arc::new(RoutineSchedule));
|
||||||
|
registry.register(Arc::new(SkillsRead));
|
||||||
registry.register(Arc::new(ChatSend));
|
registry.register(Arc::new(ChatSend));
|
||||||
registry.register(Arc::new(ChatInbox));
|
registry.register(Arc::new(ChatInbox));
|
||||||
registry.register(Arc::new(RoomCreate));
|
registry.register(Arc::new(RoomCreate));
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use cm_llm::ToolDescriptor;
|
||||||
|
use cm_tools::Effect;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::{Tool, ToolContext};
|
||||||
|
|
||||||
|
/// Read the full body of one of the claw's installed skills.
|
||||||
|
///
|
||||||
|
/// The chat path used to concatenate every installed skill's complete markdown
|
||||||
|
/// into the system prompt on every turn (~900 tokens each, unbounded in the
|
||||||
|
/// number installed). The system prompt now carries only a name + description
|
||||||
|
/// index, and this tool fetches a body when the claw decides it needs one —
|
||||||
|
/// the same summary-and-fetch contract the mission path already gets from the
|
||||||
|
/// `clawmates_skills` MCP server (`cm-api/src/mcp_skills.rs`).
|
||||||
|
///
|
||||||
|
/// Read-only over the claw's own installed skills, so it declares no effects
|
||||||
|
/// and is never gated. Skill bodies are curated in-workspace content, not
|
||||||
|
/// third-party input, so the output carries no taint.
|
||||||
|
pub struct SkillsRead;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for SkillsRead {
|
||||||
|
fn descriptor(&self) -> ToolDescriptor {
|
||||||
|
ToolDescriptor {
|
||||||
|
name: "skills.read".into(),
|
||||||
|
description: "Read the full text of one of your installed skills by \
|
||||||
|
name. Your system prompt lists the skills you have and \
|
||||||
|
what each is for; call this when one of them is relevant \
|
||||||
|
to the task at hand."
|
||||||
|
.into(),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The skill's title, as listed in your system prompt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> &'static [Effect] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String> {
|
||||||
|
let name = input
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("skills.read requires a non-empty `name`".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = cm_db::repo::skills::installed(&ctx.pool, ctx.agent_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("could not list installed skills: {e}"))?;
|
||||||
|
|
||||||
|
// Exact title match first, then case-insensitive, so a model that
|
||||||
|
// lowercases the name it read still resolves.
|
||||||
|
let found = installed
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.title == name)
|
||||||
|
.or_else(|| installed.iter().find(|s| s.title.eq_ignore_ascii_case(name)));
|
||||||
|
|
||||||
|
match found {
|
||||||
|
Some(s) => Ok(json!({
|
||||||
|
"name": s.title,
|
||||||
|
"description": s.description,
|
||||||
|
"body": s.body,
|
||||||
|
})),
|
||||||
|
None => {
|
||||||
|
let available: Vec<&str> = installed.iter().map(|s| s.title.as_str()).collect();
|
||||||
|
Err(format!(
|
||||||
|
"no installed skill named {name:?}. You have: {}",
|
||||||
|
if available.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
available.join(", ")
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user