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:
Omar Sobh
2026-07-30 10:47:27 -07:00
co-authored by Claude Opus 5
parent 285d0c82f2
commit 81b93a5c25
4 changed files with 121 additions and 16 deletions
+23 -10
View File
@@ -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() {
+3 -2
View File
@@ -427,11 +427,12 @@ 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)> =
cm_db::repo::skills::installed(&inner.pool, agent.id)
.await .await
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|s| (s.title, s.body)) .map(|s| (s.title, s.description, s.body))
.collect(); .collect();
let system_prompt = crate::brain::compose_system( let system_prompt = crate::brain::compose_system(
&agent.id.to_string(), &agent.id.to_string(),
+3
View File
@@ -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));
+88
View File
@@ -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(", ")
}
))
}
}
}
}