diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 663e658..7c0d77c 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -3023,25 +3023,32 @@ record('a requested agent is never silently swapped for another', /* ── Subagents ──────────────────────────────────────────────────────────── */ -record('subagent skills are inherited one level deep', - runtime.agentSkillIds(rootAgent, ALL_AGENTS).length >= rootAgent.skills.length, - `${rootAgent.skills.length} own → ${runtime.agentSkillIds(rootAgent, ALL_AGENTS).length} with subagents`); +/* `subagents` means DELEGATE TO, not borrow from — CLAUDE.md §3 and §6. These + checks pin that, because the opposite behaviour lived here for a long time + and reads as reasonable: a parent silently carried one level of its + subagents' skills, which is a second and incompatible meaning for the key + the backend uses to spawn a delegated run. */ -record('an unpublished subagent contributes nothing', +record('an agent carries only its own skills, never a subagent\'s', (() => { - const draftSub = { ...agentReg.getAgent(ALL_AGENTS, 'analytics-agent'), status: 'draft' }; - const others = ALL_AGENTS.map((a) => (a.id === 'analytics-agent' ? draftSub : a)); - const withDraft = runtime.agentSkillIds(rootAgent, others); - const withPublished = runtime.agentSkillIds(rootAgent, ALL_AGENTS); - return withDraft.length <= withPublished.length; - })()); + const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' }; + const b = { id: 'b', skills: ['s2'], subagents: [], status: 'published' }; + const carried = runtime.agentSkillIds(a, [a, b]); + return carried.length === 1 && carried[0] === 's1'; + })(), + 'reaching another agent is delegation, which the runtime does with its own budget and trajectory'); -record('a subagent cycle terminates', +record('the shipped root agent carries exactly what it declares', + runtime.agentSkillIds(rootAgent, ALL_AGENTS).length === new Set(rootAgent.skills).size, + `${new Set(rootAgent.skills).size} declared`); + +record('a subagent cycle cannot be walked, because nothing walks subagents here', (() => { const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' }; const b = { id: 'b', skills: ['s2'], subagents: ['a'], status: 'published' }; - return runtime.agentSkillIds(a, [a, b]).length === 2; - })()); + return runtime.agentSkillIds(a, [a, b]).length === 1; + })(), + 'cycles are refused at publish (definition.FindSubagentCycle) and bounded at run time by the depth cap'); /* ── Starters ───────────────────────────────────────────────────────────── */ diff --git a/src/components/agents/AgentConfigure.jsx b/src/components/agents/AgentConfigure.jsx index 09440aa..8b39c04 100644 --- a/src/components/agents/AgentConfigure.jsx +++ b/src/components/agents/AgentConfigure.jsx @@ -804,7 +804,7 @@ export function AgentConfigure({ icon={Layers} title="Subagents" value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'} - description="Other agents whose skills this one may also use. Most need none." + description="Other agents this one may hand a question to and report back from. Most need none." open={setting === 'behavior-subagents'} onToggle={() => toggleSetting('behavior-subagents')} last @@ -849,8 +849,9 @@ export function AgentConfigure({
- A subagent's skills are still bounded by the current page — borrowing them - cannot reach data this page does not hold. + A subagent runs as you, with the same access you have and out of the same + budget as the question that reached it — so delegating cannot read anything + you could not read yourself, and cannot buy more time by asking again.
diff --git a/src/lib/agents/runtime.js b/src/lib/agents/runtime.js index 83f6612..7d4de3e 100644 --- a/src/lib/agents/runtime.js +++ b/src/lib/agents/runtime.js @@ -39,32 +39,31 @@ import { reasoningFor } from './vocabulary'; /* ── Scope ──────────────────────────────────────────────────────────────── */ /** - * The skill ids an agent carries, including one level of subagent. + * The skill ids an agent carries. * - * One level, with a visited set. A deeper walk would let a chain of agents - * assemble a skill list nobody wrote down, and the cycle guard is not - * optional — `readAgentRegistry` rejects self-reference, but A→B→A is only - * caught here. + * Its OWN skills, and only those. `subagents` used to be folded in here — one + * level deep, so a parent silently carried everything its subagents carried — + * and that was a second, incompatible meaning for the same key. * - * A subagent that is not published contributes nothing: an archived or draft - * agent has been taken out of service, and inheriting its skills through a - * parent would put it back. + * CLAUDE.md §3 defines `subagents` as "keys of other specs this may DELEGATE + * to", and §6 as a tool call from the parent's perspective: the subagent runs + * its own turn, as the same caller, sharing the parent's budget, and returns + * an answer. The backend implements that. Borrowing the skills instead meant + * the two halves of the product disagreed about what an agent was allowed to + * do, and krow-workforce-agent carried eight delegation tools AND the + * flattened skills of those same eight agents — asking twice for one answer. + * + * So this returns what the spec says it carries. Reaching another agent is + * delegation, which is the runtime's job and has its own budget and its own + * trajectory. + * + * `agents` is still accepted so every call site keeps working; it is no longer + * read. Removing the parameter would be a wider edit for no behavioural gain, + * and agentScopedDisabledWith exists precisely to pass it. */ -export function agentSkillIds(agent, agents = []) { +export function agentSkillIds(agent) { if (!agent) return []; - - const ids = new Set(agent.skills || []); - const seen = new Set([agent.id]); - - for (const subId of agent.subagents || []) { - if (seen.has(subId)) continue; - seen.add(subId); - const sub = getAgent(agents, subId); - if (!sub || sub.status !== 'published') continue; - for (const id of sub.skills || []) ids.add(id); - } - - return [...ids]; + return [...new Set(agent.skills || [])]; } /**