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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
//! Agent-bundled skill co-deployment and observability (DOC-42, issue #2889).
//!
//! Why: `docs/specs/agent-bundled-skills.md` §SPEC-AGENTSKILLS-02 requires
//! that when an agent declaring `skills:` is deployed, its declared skills
//! are also deployed to the same target — and §SPEC-AGENTSKILLS-05 requires
//! logging both the co-deploy set and any declared skill that fails to
//! resolve through the 3-tier precedence. This logic is identical whether the
//! caller is the ordinary session-launch path (`core::session_launch`) or the
//! standalone `tm install` deploy — both call
//! [`agent_deployer::deploy_agents_filtered`](crate::core::agent_deployer::deploy_agents_filtered) and then need to (a) fold every
//! deployed agent's declared skills into the skill-deploy `select` predicate
//! and (b) log the resolution outcome. Centralising it here keeps the two
//! call sites from re-implementing (and silently diverging on) the same
//! union + resolve + log sequence.
//! What: [`co_deploy_skill_set`] unions every agent's declared skills into
//! one stem set (for the skill deployer's `select` predicate);
//! [`log_declared_skills`] emits the per-agent `info` summary line and a
//! `warn`/`debug` line per declared skill depending on whether it resolves.
//! Test: `co_deploy_skill_set_unions_all_agents`,
//! `co_deploy_skill_set_empty_when_no_agent_declares_skills`, plus the
//! `log_declared_skills` tests in this file (log content is asserted via the
//! returned dangling-skill list, since `tracing` output itself is not
//! directly assertable in a unit test).
use std::collections::{BTreeSet, HashMap};
use crate::core::skill_tiers::resolve_skill_tier;
/// Union every declared-skills list into one stem set.
///
/// Why: the skill deployer's bundled-tier `select` predicate needs the FULL
/// set of skill names any deployed agent depends on, so a declared skill the
/// harness manifest would otherwise exclude still deploys (§SPEC-AGENTSKILLS-02
/// co-deploy guarantee).
/// What: returns the union of every `Vec<String>` in `declared`, de-duplicated
/// via a `BTreeSet` (also gives deterministic iteration order for logging).
/// Test: `co_deploy_skill_set_unions_all_agents`,
/// `co_deploy_skill_set_empty_when_no_agent_declares_skills`.
pub fn co_deploy_skill_set(declared: &HashMap<String, Vec<String>>) -> BTreeSet<String> {
declared.values().flatten().cloned().collect()
}
/// Log the co-deploy summary and per-skill resolution for every declared skill.
///
/// Why: an operator debugging a "skill not found" error needs to see, in the
/// logs, which skills each deployed agent declared and whether each one
/// resolved — this is the §SPEC-AGENTSKILLS-05 observability contract.
/// What: for each agent with a non-empty `skills` list, emits one `info` line
/// naming the agent and its declared skills, then for each declared skill
/// either a `debug` line naming the resolved tier or a `warn` line when the
/// skill is absent from all three tiers (`project`/`user`/`bundled`, per
/// [`resolve_skill_tier`]). Returns the list of dangling `(agent, skill)`
/// pairs so callers can additionally surface them beyond the log stream (e.g.
/// a deploy summary count) without re-deriving resolution.
/// Test: `log_declared_skills_resolves_all_tiers`,
/// `log_declared_skills_flags_dangling_reference`,
/// `log_declared_skills_skips_agents_with_no_skills`.
pub fn log_declared_skills(
declared: &HashMap<String, Vec<String>>,
project: &BTreeSet<String>,
user: &BTreeSet<String>,
bundled: &BTreeSet<String>,
) -> Vec<(String, String)> {
let mut dangling = Vec::new();
// Sort agent names for deterministic log ordering across runs.
let mut agents: Vec<&String> = declared.keys().collect();
agents.sort();
for agent in agents {
let skills = &declared[agent];
if skills.is_empty() {
continue;
}
tracing::info!(
agent = %agent,
skills = %skills.join(", "),
"deploying agent with declared skills"
);
for skill in skills {
match resolve_skill_tier(skill, project, user, bundled) {
Some(tier) => tracing::debug!(
agent = %agent,
skill = %skill,
tier = tier.label(),
"co-deployed skill resolved"
),
None => {
tracing::warn!(
agent = %agent,
skill = %skill,
"agent declares skill '{skill}', but it is not found in any tier \
(project/user/bundled)"
);
dangling.push((agent.clone(), skill.clone()));
}
}
}
}
dangling
}
#[cfg(test)]
mod tests {
use super::*;
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn declared(pairs: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
pairs
.iter()
.map(|(agent, skills)| {
(
agent.to_string(),
skills.iter().map(|s| s.to_string()).collect(),
)
})
.collect()
}
#[test]
fn co_deploy_skill_set_unions_all_agents() {
let map = declared(&[
(
"code-critic",
&["code-review-standards", "systematic-debugging"],
),
(
"rust-engineer",
&["toolchains-rust-core", "systematic-debugging"],
),
]);
let union = co_deploy_skill_set(&map);
assert_eq!(
union,
set(&[
"code-review-standards",
"systematic-debugging",
"toolchains-rust-core"
])
);
}
#[test]
fn co_deploy_skill_set_empty_when_no_agent_declares_skills() {
let map = declared(&[("plain", &[])]);
assert!(co_deploy_skill_set(&map).is_empty());
}
// ---- bundled-asset gates (#4642, #4643) --------------------------------
//
// The harness renders every skill named in an agent's `skills:` frontmatter
// as a FULL SKILL.md body into that agent's system prompt at dispatch,
// before the Skill tool is ever called. `skills:` is therefore a RESIDENT
// cost paid on every dispatch, not a capability list — a skill left out is
// still invokable on demand. These three gates pin the shipped assets:
// every declared skill resolves, the resident cost stays bounded, and no
// foundation layer silently adds to a leaf agent's list.
use std::path::PathBuf;
use trusty_agents_common::agents::builder::compose_agent;
use trusty_agents_common::agents::metadata::agent_metadata_from_str;
fn agents_dir() -> PathBuf {
crate::core::content_source::test_support::repo_agents_dir()
}
fn skills_dir() -> PathBuf {
// #9012: the skills are the checkout's content.
crate::core::content_source::test_support::repo_root().join("content/skills")
}
/// Every bundled non-foundation agent stem, sorted.
///
/// Shares the production predicate deliberately: a private copy of the rule
/// here would let these gates and the roster scanner drift, which is the
/// exact defect shape #4711 removed. The first cut of this helper filtered
/// on the bare `starts_with("base")` prefix — the very form #4711 replaced
/// in `is_foundation_file` — which would have made a future bundled
/// `baseline-*` agent invisible to all three gates below.
fn bundled_agent_stems() -> Vec<String> {
let mut stems: Vec<String> = std::fs::read_dir(agents_dir())
.expect("bundled agent assets dir")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
.filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
.filter(|stem| !crate::core::delegation_authority::is_foundation_file(stem))
.collect();
stems.sort();
stems
}
/// The COMPOSED (deployed) `skills:` list for a bundled agent.
fn composed_skills(stem: &str) -> Vec<String> {
let composed = compose_agent(stem, &agents_dir())
.unwrap_or_else(|e| panic!("compose bundled agent `{stem}`: {e}"));
agent_metadata_from_str(&composed).skills
}
#[test]
fn bundled_agent_declared_skills_all_resolve() {
// #4642/#4643 keep the existing `agent_skills` doctor check green: a
// trimmed list must never leave a dangling reference behind, and a
// typo'd rename must fail here rather than as a runtime WARN.
let mut dangling: Vec<String> = Vec::new();
for stem in bundled_agent_stems() {
for skill in composed_skills(&stem) {
if !skills_dir().join(format!("{skill}.md")).is_file() {
dangling.push(format!("{stem} declares `{skill}`"));
}
}
}
assert!(
dangling.is_empty(),
"bundled agents declare skills with no bundled asset: {dangling:?}"
);
}
#[test]
fn bundled_agent_resident_skill_cost_stays_within_budget() {
// #4642 REGRESSION GATE. Before the trim the worst agent (`qa`, 15
// skills) rendered 84,343 bytes (~21,085 tokens) of skill body into
// every single dispatch, and `research` 55,894 (~13,974). The budget is
// deliberately just above today's worst case so that adding a skill to
// a bundled agent is a decision someone has to make on purpose.
const BUDGET_BYTES: u64 = 24_000;
// The rust family carries `rust-build-performance` (10,546 bytes) on
// top of the shared core, per a standing 2026-07-17 operator directive
// recorded in `rust_build_performance_declared_by_rust_family_agents`.
// #4642 does not get to reverse that, so the exception is explicit and
// named here rather than hidden by a blanket budget raise.
// #8192 raised this from 34,000: `rust-engineer` additionally declares
// `rust-delivery-workflow` (~7,000 bytes), the delivery-process half of
// the split this issue made. Same rule as before — the budget sits just
// above today's worst case so the next addition is a deliberate act.
const RUST_FAMILY_BUDGET_BYTES: u64 = 44_000;
const RUST_FAMILY: &[&str] = &["rust-engineer", "tauri-engineer"];
let mut over: Vec<String> = Vec::new();
for stem in bundled_agent_stems() {
let budget = if RUST_FAMILY.contains(&stem.as_str()) {
RUST_FAMILY_BUDGET_BYTES
} else {
BUDGET_BYTES
};
let total: u64 = composed_skills(&stem)
.iter()
.filter_map(|s| std::fs::metadata(skills_dir().join(format!("{s}.md"))).ok())
.map(|m| m.len())
.sum();
if total > budget {
over.push(format!(
"{stem}: {total} bytes (~{} tokens, budget {budget})",
total / 4
));
}
}
assert!(
over.is_empty(),
"resident skill-body cost exceeds the per-dispatch budget (#4642). \
Drop the skill from `skills:` — it stays invokable via the Skill \
tool on demand: {over:?}"
);
}
#[test]
fn no_foundation_layer_silently_adds_skills_except_base_engineer() {
// #4643. `skills:` UNIONS across the `extends:` chain, so a declaration
// on a BASE-* template silently appears in every descendant's DEPLOYED
// frontmatter while its own source file shows a shorter list. That is
// exactly the source/deployed divergence #4643 reported: `research.md`
// declared 11, the deployed copy carried 12 (`tm-capabilities`, from
// BASE-AGENT). Only BASE-ENGINEER contributes a skill now, and it does
// so deliberately — `documentation-style` shapes every doc comment an
// engineer writes in this repo.
for base in ["BASE-AGENT", "BASE-QA", "BASE-OPS", "BASE-RESEARCH"] {
let raw = std::fs::read_to_string(agents_dir().join(format!("{base}.md")))
.unwrap_or_else(|e| panic!("read {base}.md: {e}"));
assert!(
agent_metadata_from_str(&raw).skills.is_empty(),
"{base} declares `skills:`, which every descendant then pays for \
on every dispatch while its own source file does not show it (#4643)"
);
}
assert_eq!(
composed_skills("research"),
Vec::<String>::new(),
"the deployed `research` skills list must equal its source declaration (#4643)"
);
assert_eq!(
composed_skills("engineer"),
vec![
"documentation-style".to_string(),
"systematic-debugging".to_string(),
"test-driven-development".to_string(),
],
"BASE-ENGINEER's `documentation-style` is the one deliberate inherited skill"
);
}
#[test]
fn log_declared_skills_resolves_all_tiers() {
let map = declared(&[(
"code-critic",
&["project-skill", "user-skill", "bundled-skill"],
)]);
let dangling = log_declared_skills(
&map,
&set(&["project-skill"]),
&set(&["user-skill"]),
&set(&["bundled-skill"]),
);
assert!(dangling.is_empty());
}
#[test]
fn log_declared_skills_flags_dangling_reference() {
let map = declared(&[("code-critic", &["missing-skill"])]);
let dangling =
log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &BTreeSet::new());
assert_eq!(
dangling,
vec![("code-critic".to_string(), "missing-skill".to_string())]
);
}
#[test]
fn log_declared_skills_skips_agents_with_no_skills() {
let map = declared(&[("plain", &[])]);
let dangling =
log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &BTreeSet::new());
assert!(dangling.is_empty());
}
#[test]
fn co_deploy_resolves_sibling_2890_code_critic_fixture() {
// Cross-PR fixture (issue #2890, PR #2900): `code-critic` declares
// `[code-review-standards, contract-driven-testing]`. Once both land
// on main these resolve from the Bundled tier; here we pin the
// resolution behavior with a synthetic bundled set standing in for
// the real asset (not yet merged) so #2889's co-deploy logic is
// proven compatible independent of #2890's landing order.
let map = declared(&[(
"code-critic",
&["code-review-standards", "contract-driven-testing"],
)]);
let bundled = set(&["code-review-standards", "contract-driven-testing"]);
let dangling = log_declared_skills(&map, &BTreeSet::new(), &BTreeSet::new(), &bundled);
assert!(
dangling.is_empty(),
"both skills must resolve from bundled: {dangling:?}"
);
assert_eq!(co_deploy_skill_set(&map), bundled);
}
#[test]
fn log_declared_skills_partial_resolution_flags_only_missing() {
let map = declared(&[("agent", &["ok-skill", "bad-skill"])]);
let dangling = log_declared_skills(
&map,
&BTreeSet::new(),
&BTreeSet::new(),
&set(&["ok-skill"]),
);
assert_eq!(
dangling,
vec![("agent".to_string(), "bad-skill".to_string())]
);
}
}