2 Commits

Author SHA1 Message Date
1a0dc7e5f1 Settle subagents: it means delegate to, not borrow skills from
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
2026-08-29 15:29:09 +05:30
1d35358dd6 Carry a routed question across the move, and let the Test tab actually test
TWO fixes, both found by using the product rather than reading it.

A question asked on a page that cannot answer it was lost. resolveIntent routes
to the page that can, and routing.js answered "That is on Candidates. Taking
you there now. Ask again once the page loads." — but the panel is page-scoped,
so it re-mounts on the new route and the navigation destroys the very message
explaining why the reader moved. What the reader saw was a different page and a
fresh greeting, with no trace of what they asked.

The question now travels with the destination. AssistantPanelContext sits ABOVE
the router and already has `ask` for exactly this — a page handing a question to
the panel — so the panel that mounts on the other side asks it. That is what
the reader wanted, and what "ask again once the page loads" was apologising for.
It cannot loop: resolveIntent only routes when the destination differs from the
page you are on, and answerableHere short-circuits before that.

The Test tab did not test. "Simulation & Scope Diagnostics" reads where a
question WOULD route — which skills are reachable, which tools are in scope,
what the classifier makes of it — and never calls the model. That is genuinely
useful and it is not what a tab called Test leads anyone to expect. A real test
did exist, but under Skills, as "Test in Owliver" on a capability card.

So the Simulated User Query the tab has always shown is now runnable, through
the same path that card uses: the existing Owliver panel, scoped to the draft's
own agent and disabled skills, so the answer comes from the agent being edited
rather than the published one. The diagnostics stay — they answer a different
and still useful question. The button is disabled when the agent does not cover
the selected page, with the reason in its title, rather than offering a run that
would decline.

924/924 checks pass and the production build is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-29 15:13:53 +05:30
8 changed files with 98 additions and 46 deletions

View File

@@ -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 ───────────────────────────────────────────────────────────── */

View File

@@ -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&apos;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>

View File

@@ -47,7 +47,7 @@ const CLASSIFICATION_COPY = {
};
/** @param {any} props */
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId
@@ -79,6 +79,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
return {
covers,
disabled,
pageOnly,
scoped,
matched,
@@ -152,6 +153,33 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
className="pl-9 w-full bg-surface"
/>
</div>
{/* Everything else on this tab is a STATIC read of where a
question would route. Useful, and not the same as asking. The
tab is called Test, so a reader reasonably expects to be able
to run the thing — this is that, through the existing Owliver
panel and the draft's own scope, so the answer comes from the
agent being edited rather than the published one. */}
{onRunLive && (
<button
type="button"
onClick={() => onRunLive({
question,
scope: { contextId, disabledSkills: result.disabled, agent: draftAgent },
capability: null,
trace: { agentName: fields.name || 'This agent', surface: contextId },
})}
disabled={!question.trim() || !result.covers}
title={result.covers
? 'Ask this for real, in the Owliver panel'
: 'This agent does not cover the selected page'}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-border
bg-surface px-3 py-1.5 text-caption font-medium text-ink-2
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
Ask Owliver for real
</button>
)}
</div>
</div>

View File

@@ -281,13 +281,21 @@ export default function KrowAssistant({
request: panelRequest, consumeRequest,
notice: panelNotice, consumeNotice,
test: panelTest, reportTest, clearTest,
ask: askAssistant,
} = useAssistantPanel();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
const goToPage = React.useCallback(
(destination) => navigate(destination.route),
[navigate]
(destination, question) => {
navigate(destination.route);
/* Hand the question to the panel that mounts on the other side. The
request lives in AssistantPanelContext, which sits ABOVE the router, so
it survives the navigation that discards this panel's thread. Without
this the reader has to retype what they just typed. */
if (question) askAssistant({ question });
},
[navigate, askAssistant]
);
/* Skills available here, and the roles they can act on — both read from what

View File

@@ -159,7 +159,7 @@ export function answerableHere(context, question) {
export function navigationAnswer(destination) {
return doc(
text(`That is on **${destination.page}**. Taking you there now.`),
note('Ask again once the page loads and I will answer from it.')
note('Bringing your question with me — I will answer it from that page.')
);
}

View File

@@ -723,12 +723,20 @@ export function useConversation({
abortRef.current = null;
}
/* Navigate after the reply is on screen, so the user reads why they moved.
The panel re-resolves its context from the new route, which is what
makes the next question answer from the page they land on. */
/* Navigate after the reply is on screen — except the reply does not
survive the move. The panel is page-scoped, so it re-mounts on the new
route and the message explaining why the reader moved is destroyed by
the navigation that message was explaining. The reader landed somewhere
else with a fresh greeting and no trace of what they asked.
So the question travels with the destination. The panel on the other
side asks it, which is what the reader wanted in the first place and
what the old copy ("ask again once the page loads") was apologising
for. Re-asking cannot loop: resolveIntent only navigates when the
destination differs from the current page. */
if (controller.signal.aborted) return;
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
if (intent.kind === 'navigate') onNavigate?.(intent.destination, text);
/* A skill's action runs after its reply, for the same reason: the user
should read why the form opened before it opens. */
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);

View File

@@ -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 || [])];
}
/**

View File

@@ -452,6 +452,7 @@ export default function AdminAgentDetail() {
)}
{view === 'test' && (
<AgentTestPanel
onRunLive={onTestCapability}
fields={fields}
dirty={dirty}
customSkills={customSkills}