Compare commits
2 Commits
chore/untr
...
fix/owlive
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0dc7e5f1 | |||
| 1d35358dd6 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -452,6 +452,7 @@ export default function AdminAgentDetail() {
|
||||
)}
|
||||
{view === 'test' && (
|
||||
<AgentTestPanel
|
||||
onRunLive={onTestCapability}
|
||||
fields={fields}
|
||||
dirty={dirty}
|
||||
customSkills={customSkills}
|
||||
|
||||
Reference in New Issue
Block a user