1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
//! `rsclaw debug …` subcommands — prompt-spec dump and other
//! introspection utilities.
//!
//! These run **without** spinning up the full gateway: they only load
//! config + skills and synthesize the bytes that would be sent to the
//! upstream LLM, so they're safe to invoke as part of a CI / release
//! pipeline (no port binding, no spawn, no side effects beyond
//! reading files / writing output).
use anyhow::{Context as _, Result};
use serde_json::json;
/// 37 built-in tool names that compile into the RsClaw binary. Used
/// by `dump-prompt-spec` to partition the merged tool list into the
/// cacheable half (these names) and the per-user half (everything
/// else: registered sub-agents, plugins, MCP, WASM).
///
/// Keep in sync with the list in `runtime.rs::dump_prompt_spec`. If a
/// new built-in tool is added, both lists must grow together.
// Single source of truth for the builtin/user split: reuse the runtime's
// classification list (prompt_builder::BUILTIN_TOOL_NAMES) instead of a
// local copy. A local duplicate had silently drifted (was missing
// task_finish/edit_file/ask_user), which would have produced a baseline
// export that didn't match what the gateway actually classifies as builtin.
use crate::agent::prompt_builder::BUILTIN_TOOL_NAMES as BUILTIN_TOOLS;
use crate::{
agent::{
prompt_builder::{build_shared_system_prefix, build_user_system},
tools_builder::build_tool_list,
workspace::WorkspaceContext,
},
cli::{DebugCommand, DumpPromptSpecArgs},
config,
skill::loader::load_skills,
};
pub async fn cmd_debug(sub: DebugCommand) -> Result<()> {
match sub {
DebugCommand::DumpPromptSpec(args) => dump_prompt_spec(args).await,
}
}
async fn dump_prompt_spec(args: DumpPromptSpecArgs) -> Result<()> {
// 1. Load config. `load_quiet` skips banner/log noise so this is safe to pipe
// through `jq`.
let config = config::load_quiet().context("failed to load rsclaw config")?;
// 2. Resolve the target agent id. Priority: explicit --agent -> first entry
// flagged default=true -> "main".
let agent_id = args
.agent
.or_else(|| {
config
.agents
.list
.iter()
.find(|e| e.default.unwrap_or(false))
.map(|e| e.id.clone())
})
.unwrap_or_else(|| "main".to_owned());
let agent_cfg = config
.agents
.list
.iter()
.find(|e| e.id == agent_id)
.with_context(|| format!("agent `{agent_id}` not found in config.agents.list"))?;
// 3. Workspace dir resolution mirrors what AgentRuntime does at boot: explicit
// `agent.workspace` -> `<base>/workspace-<id>`.
let ws_dir = agent_cfg
.workspace
.as_deref()
.map(config::loader::expand_tilde_path_pub)
.unwrap_or_else(|| config::loader::base_dir().join(format!("workspace-{agent_id}")));
// SessionType / max_chars are runtime-tuned; for a CLI dump we
// pick conservative defaults that won't blow up the JSON. The
// exact number of files in the workspace segment doesn't have to
// match what a live session would emit — the goal is to surface
// the *shape* of the per-user suffix to a cache integrator, not
// reproduce a specific session.
let ws_ctx = WorkspaceContext::load(
&ws_dir,
crate::agent::workspace::SessionType::Normal,
false,
4_000, // max_chars_per_file
20_000, // total_max_chars
);
// 4. Discover installed skills the same way the runtime does: global skills
// under `<base>/skills/`, plus the per-agent workspace's `skills/`
// subdirectory if present.
let skills_dir = config::loader::base_dir().join("skills");
let workspace_skills = ws_dir.join("skills");
let skills = load_skills(
&skills_dir,
if workspace_skills.is_dir() {
Some(&ws_dir)
} else {
None
},
config.raw.skills.as_ref(),
)
.unwrap_or_default();
// 5. Build the prompt halves. `rsclaw debug` runs offline without
// any live plugin runtime; pass empty plugin sources so the
// resulting user_system reflects "no plugins installed" rather
// than a divergent live state. Skills load from disk via the
// SkillRegistry above; that's enough for the prompt-spec dump.
let shared_prefix = build_shared_system_prefix();
let user_system = build_user_system(&ws_ctx, &skills, &[], None, &config.raw);
// 6. Build the merged tool list, then split by name into the cacheable
// built-ins vs the per-machine remainder. `build_tool_list` only knows about
// a live AgentRegistry; we don't have one here, so we let it generate the
// built-ins
// + remote-agent tools and tack the local sub-agent
// (`agent_<id>`) tools on ourselves to mirror what a running
// gateway would advertise.
let mut tool_defs = build_tool_list(&skills, None, &agent_id, &config.agents.a2a);
for entry in &config.agents.list {
if entry.id == agent_id {
continue;
}
tool_defs.push(crate::provider::ToolDef {
name: format!("agent_{}", entry.id),
description: format!(
"Send a task to agent '{}'. Returns the agent's reply.",
entry.id
),
parameters: json!({
"type": "object",
"properties": {
"text": {"type": "string", "description": "Task or message to send"}
},
"required": ["text"]
}),
});
}
let to_json = |t: &crate::provider::ToolDef| {
json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
};
let mut builtin_tools = Vec::new();
let mut user_tools = Vec::new();
for t in &tool_defs {
if BUILTIN_TOOLS.contains(&t.name.as_str()) {
builtin_tools.push(to_json(t));
} else {
user_tools.push(to_json(t));
}
}
// 7. Emit. `--shared-only` strips per-user fields entirely so the output is
// suitable for ingest into rsclaw-llm without any machine-specific state
// leaking through.
//
// `rsclaw_version` here is the BASELINE version (the `<ver>`
// component of `RSCLAW_DEFAULT_PREFIX_ID`), NOT the Cargo crate
// version. The two are deliberately decoupled — see the doc on
// `RSCLAW_DEFAULT_PREFIX_ID` for why. This dump documents the
// canonical wire bytes for a specific prefix_id, so its version
// label must track the prefix_id, not the gateway release.
let rsclaw_version = crate::provider::rsclaw::RSCLAW_DEFAULT_PREFIX_ID
.split('/')
.nth(1)
.unwrap_or(env!("CARGO_PKG_VERSION"));
let payload = if args.shared_only {
json!({
"rsclaw_version": rsclaw_version,
"shared_prefix": shared_prefix,
"builtin_tools": builtin_tools,
})
} else {
json!({
"rsclaw_version": rsclaw_version,
"agent_id": agent_id,
"shared_prefix": shared_prefix,
"user_system": user_system,
"builtin_tools": builtin_tools,
"user_tools": user_tools,
})
};
let s = serde_json::to_string_pretty(&payload).context("serialize prompt spec to JSON")?;
match args.output {
Some(path) => {
std::fs::write(&path, &s).with_context(|| format!("write {}", path.display()))?;
eprintln!("wrote {} ({} bytes)", path.display(), s.len());
}
None => println!("{s}"),
}
Ok(())
}