Settle subagents: it means delegate to, not borrow skills from
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
The key had two incompatible meanings running at once. 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, out of the parent's budget, and returns an answer. The backend implements exactly that. This side did something else: agentSkillIds folded one level of subagent skills into the parent's carried set, so a parent silently gained everything its subagents carried, and the UI described it that way — "other agents whose skills this one may also use", "borrowing them cannot reach data this page does not hold". Both are defensible readings. Only one is the specification, and running both meant krow-workforce-agent carried eight delegation tools AND the flattened skills of those same eight agents — able to answer a question directly or to ask an agent that had already lent it the means to answer. Two ways to do one thing, differing in cost and in what the trajectory records. So an agent carries what it declares. Reaching another agent is delegation, which the runtime does with its own budget and its own trajectory. The blast radius was one check, which is the useful part of the answer: only "a subagent cycle terminates" depended on the folding, because that traversal was the only thing that could loop. Nothing walks subagents here now, so the cycle question moved to where it belongs — refused at publish by definition.FindSubagentCycle, bounded at run time by the depth cap. The replacement checks assert the new meaning rather than deleting the old ones, because the previous behaviour reads as perfectly reasonable and will be reinvented otherwise. The `agents` parameter stays on agentSkillIds and is no longer read. Removing it is a wider edit for no behavioural gain, and agentScopedDisabledWith exists precisely to pass it. 924/924 checks pass; production build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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({
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@@ -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 || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user