Compare commits
2 Commits
chore/untr
...
1a0dc7e5f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0dc7e5f1 | |||
| 1d35358dd6 |
@@ -3023,25 +3023,32 @@ record('a requested agent is never silently swapped for another',
|
|||||||
|
|
||||||
/* ── Subagents ──────────────────────────────────────────────────────────── */
|
/* ── Subagents ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
record('subagent skills are inherited one level deep',
|
/* `subagents` means DELEGATE TO, not borrow from — CLAUDE.md §3 and §6. These
|
||||||
runtime.agentSkillIds(rootAgent, ALL_AGENTS).length >= rootAgent.skills.length,
|
checks pin that, because the opposite behaviour lived here for a long time
|
||||||
`${rootAgent.skills.length} own → ${runtime.agentSkillIds(rootAgent, ALL_AGENTS).length} with subagents`);
|
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 a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' };
|
||||||
const others = ALL_AGENTS.map((a) => (a.id === 'analytics-agent' ? draftSub : a));
|
const b = { id: 'b', skills: ['s2'], subagents: [], status: 'published' };
|
||||||
const withDraft = runtime.agentSkillIds(rootAgent, others);
|
const carried = runtime.agentSkillIds(a, [a, b]);
|
||||||
const withPublished = runtime.agentSkillIds(rootAgent, ALL_AGENTS);
|
return carried.length === 1 && carried[0] === 's1';
|
||||||
return withDraft.length <= withPublished.length;
|
})(),
|
||||||
})());
|
'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 a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' };
|
||||||
const b = { id: 'b', skills: ['s2'], subagents: ['a'], 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 ───────────────────────────────────────────────────────────── */
|
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -804,7 +804,7 @@ export function AgentConfigure({
|
|||||||
icon={Layers}
|
icon={Layers}
|
||||||
title="Subagents"
|
title="Subagents"
|
||||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
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'}
|
open={setting === 'behavior-subagents'}
|
||||||
onToggle={() => toggleSetting('behavior-subagents')}
|
onToggle={() => toggleSetting('behavior-subagents')}
|
||||||
last
|
last
|
||||||
@@ -849,8 +849,9 @@ export function AgentConfigure({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<p className="text-caption leading-relaxed text-ink-4">
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
A subagent's skills are still bounded by the current page — borrowing them
|
A subagent runs as you, with the same access you have and out of the same
|
||||||
cannot reach data this page does not hold.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const CLASSIFICATION_COPY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
|
||||||
const [contextId, setContextId] = React.useState(
|
const [contextId, setContextId] = React.useState(
|
||||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||||
|| CONTEXT_OPTIONS[0].contextId
|
|| CONTEXT_OPTIONS[0].contextId
|
||||||
@@ -79,6 +79,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
covers,
|
covers,
|
||||||
|
disabled,
|
||||||
pageOnly,
|
pageOnly,
|
||||||
scoped,
|
scoped,
|
||||||
matched,
|
matched,
|
||||||
@@ -152,6 +153,33 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
|||||||
className="pl-9 w-full bg-surface"
|
className="pl-9 w-full bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -281,13 +281,21 @@ export default function KrowAssistant({
|
|||||||
request: panelRequest, consumeRequest,
|
request: panelRequest, consumeRequest,
|
||||||
notice: panelNotice, consumeNotice,
|
notice: panelNotice, consumeNotice,
|
||||||
test: panelTest, reportTest, clearTest,
|
test: panelTest, reportTest, clearTest,
|
||||||
|
ask: askAssistant,
|
||||||
} = useAssistantPanel();
|
} = useAssistantPanel();
|
||||||
|
|
||||||
/* The app's own router, not a location assignment: a full page load would
|
/* The app's own router, not a location assignment: a full page load would
|
||||||
discard the thread and the panel state along with it. */
|
discard the thread and the panel state along with it. */
|
||||||
const goToPage = React.useCallback(
|
const goToPage = React.useCallback(
|
||||||
(destination) => navigate(destination.route),
|
(destination, question) => {
|
||||||
[navigate]
|
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
|
/* Skills available here, and the roles they can act on — both read from what
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function answerableHere(context, question) {
|
|||||||
export function navigationAnswer(destination) {
|
export function navigationAnswer(destination) {
|
||||||
return doc(
|
return doc(
|
||||||
text(`That is on **${destination.page}**. Taking you there now.`),
|
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.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -723,12 +723,20 @@ export function useConversation({
|
|||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
/* Navigate after the reply is on screen — except the reply does not
|
||||||
The panel re-resolves its context from the new route, which is what
|
survive the move. The panel is page-scoped, so it re-mounts on the new
|
||||||
makes the next question answer from the page they land on. */
|
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 (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
|
/* A skill's action runs after its reply, for the same reason: the user
|
||||||
should read why the form opened before it opens. */
|
should read why the form opened before it opens. */
|
||||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||||
|
|||||||
@@ -39,32 +39,31 @@ import { reasoningFor } from './vocabulary';
|
|||||||
/* ── Scope ──────────────────────────────────────────────────────────────── */
|
/* ── 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
|
* Its OWN skills, and only those. `subagents` used to be folded in here — one
|
||||||
* assemble a skill list nobody wrote down, and the cycle guard is not
|
* level deep, so a parent silently carried everything its subagents carried —
|
||||||
* optional — `readAgentRegistry` rejects self-reference, but A→B→A is only
|
* and that was a second, incompatible meaning for the same key.
|
||||||
* caught here.
|
|
||||||
*
|
*
|
||||||
* A subagent that is not published contributes nothing: an archived or draft
|
* CLAUDE.md §3 defines `subagents` as "keys of other specs this may DELEGATE
|
||||||
* agent has been taken out of service, and inheriting its skills through a
|
* to", and §6 as a tool call from the parent's perspective: the subagent runs
|
||||||
* parent would put it back.
|
* 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 [];
|
if (!agent) return [];
|
||||||
|
return [...new Set(agent.skills || [])];
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ export default function AdminAgentDetail() {
|
|||||||
)}
|
)}
|
||||||
{view === 'test' && (
|
{view === 'test' && (
|
||||||
<AgentTestPanel
|
<AgentTestPanel
|
||||||
|
onRunLive={onTestCapability}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
customSkills={customSkills}
|
customSkills={customSkills}
|
||||||
|
|||||||
Reference in New Issue
Block a user