refactor(ts-migration): Phase 11 batch 6 — agent and skill components
The fifteen files under `src/components/agents/` (including `skills/`)
and `src/components/skills/`. 15/15 erase byte-identically; bundle hash
unchanged.
Renaming raised 43 errors and 29 of them came from one thing: these
files carry 24 `/** @param {any} props */` annotations, one per
component, and JSDoc stops applying at the extension boundary. Without
them TypeScript infers every destructured prop as required, so a call
site passing seven of nine props is an error — which is how a file that
declared its props `any` ended up with fourteen complaints about missing
`className`. Restoring the author's own declaration as `: any` is not
blanket typing; it is the annotation that was already there, in the only
form that still works.
`Workspace` in `AgentCanvas` was the one component in that file its
author left without the hatch. It now matches its siblings.
The rest were five separate things:
- `React.isValidElement(children)` no longer narrows enough to read
`children.props.id`: React 19 types `ReactElement`'s props as
`unknown`. `isValidElement<any>` says what the `cloneElement` call
beneath it has always assumed. The migration plan predicted this
site by name.
- `useSkillSections(page, placement)` is called with one argument by
`UiEditingProvider`, which its doc comment explicitly permits —
"called with no placement it returns every section on the page". The
parameter simply lacked its optional marker. The marker erases, so
the emitted signature is unchanged.
- `new Date(b.at) - new Date(a.at)` is valueOf coercion, which
JavaScript performs and TypeScript refuses to describe. Cast rather
than rewritten to `.getTime()`: that would change the emitted code,
and this comparison orders the list.
- `Object.values<any>` on a tally, the same inference gap as earlier
batches, which also fixed a `ReactNode` complaint downstream of it.
All fifteen are linted under their new extensions, checked by name.
Measured against `446df7b`:
typecheck 6 errors, down from 9; no new error anywhere
lint exit 0, 0 errors, 289 warnings, 15/15 linted by name
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 72/72 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
864
src/components/agents/AgentConfigure.tsx
Normal file
864
src/components/agents/AgentConfigure.tsx
Normal file
@@ -0,0 +1,864 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
||||
SlidersHorizontal, Trash2, Wrench, X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Switch, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { getSkillsForPage } from '@/lib/skills/registry';
|
||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
import {
|
||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||
SettingRow, Workspace,
|
||||
} from './AgentCanvas';
|
||||
|
||||
/**
|
||||
* The agent configuration workspace.
|
||||
*
|
||||
* Four sections of one document — Identity, Instructions, Capabilities,
|
||||
* Behavior — with a rail beside them that lists the same four and what is
|
||||
* inside each. The rail is the page's table of contents and its scroll
|
||||
* position; it is never a second source of state, which is why every value it
|
||||
* shows is read from `fields` at render rather than tracked.
|
||||
*
|
||||
* Every control writes a *field*. The caller composes frontmatter from those
|
||||
* fields at save time, which is what keeps Markdown the storage format while a
|
||||
* recruiter never meets it.
|
||||
*/
|
||||
|
||||
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
|
||||
|
||||
/**
|
||||
* Which section the reader is in.
|
||||
*
|
||||
* Observed rather than set on click, so scrolling the document moves the rail
|
||||
* too — a table of contents that only updates when you use it is a menu, not a
|
||||
* position.
|
||||
*
|
||||
* Read from the sections' own top edges against a marker just below the sticky
|
||||
* header, rather than from intersection ratios. A ratio-based spy cannot
|
||||
* resolve the case this document actually has: Capabilities is long enough that
|
||||
* it is still crossing the reading band when Behavior arrives, so "the first
|
||||
* one intersecting" stays on Capabilities for the rest of the page. The last
|
||||
* section gets the same exemption for the same reason in reverse — the document
|
||||
* runs out of scroll before its top reaches the marker, so at the end of the
|
||||
* page it is simply what is being read.
|
||||
*/
|
||||
function useActiveSection(ids) {
|
||||
const [active, setActive] = React.useState(ids[0]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
const marker = 140;
|
||||
|
||||
let current = ids[0];
|
||||
for (const id of ids) {
|
||||
const top = document.getElementById(id)?.getBoundingClientRect().top;
|
||||
if (top !== undefined && top <= marker) current = id;
|
||||
}
|
||||
|
||||
/* Only when the document genuinely scrolls. With every section closed it
|
||||
fits on one screen, and "the end of the page" is then also its start —
|
||||
which would light the last section while the reader is looking at the
|
||||
first. */
|
||||
const doc = document.documentElement;
|
||||
const scrolls = doc.scrollHeight > window.innerHeight + 8;
|
||||
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
|
||||
if (scrolls && atBottom) current = ids[ids.length - 1];
|
||||
|
||||
setActive(current);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
measure();
|
||||
window.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
window.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
};
|
||||
}, [ids]);
|
||||
|
||||
return [active, setActive];
|
||||
}
|
||||
|
||||
export function AgentConfigure({
|
||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||
/* Attaching and detaching a skill goes through the caller's existing write
|
||||
path, NOT through `set`. Every other control here writes a draft that Save
|
||||
persists; a skill persists immediately, on purpose — the whole point of the
|
||||
catalog is that the agent gains the capability there and then. Two write
|
||||
paths for one field would mean the Configure screen and the catalog
|
||||
disagreed about when a skill takes effect. */
|
||||
onToggleSkill = null, pendingSkill = null,
|
||||
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
||||
rather than fetched here so this component stays a form over `fields`. */
|
||||
toolCatalogue = [],
|
||||
}: any) {
|
||||
/* Sections are open by default: this is a document, and one that greets its
|
||||
author with four closed headers hides the thing they came to write. Closing
|
||||
is for focus, not for the initial reading. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
|
||||
);
|
||||
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
|
||||
|
||||
const [active, setActive] = useActiveSection(SECTION_IDS);
|
||||
|
||||
/**
|
||||
* The skills these pages offer, and the ones not yet attached.
|
||||
*
|
||||
* Derived at render from `fields.pages` rather than held in state, for the
|
||||
* same reason the rail is: a second copy of a list that is already on screen
|
||||
* is a second thing to keep correct, and the one that drifts is always the
|
||||
* copy.
|
||||
*
|
||||
* Deduplicated by id because an agent covering several pages will be offered
|
||||
* the same skill by each of them.
|
||||
*/
|
||||
const availableSkills = React.useMemo(() => {
|
||||
const seen = new Map();
|
||||
for (const page of fields.pages) {
|
||||
for (const skill of getSkillsForPage(page, { customSources: customSkills })) {
|
||||
if (!seen.has(skill.id)) seen.set(skill.id, skill);
|
||||
}
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [fields.pages, customSkills]);
|
||||
|
||||
const attachableSkills = React.useMemo(
|
||||
() => availableSkills.filter((sk) => !fields.skills.includes(sk.id)),
|
||||
[availableSkills, fields.skills]
|
||||
);
|
||||
|
||||
const set = (patch) => onChange({ ...fields, ...patch });
|
||||
|
||||
const instructionWords = React.useMemo(
|
||||
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
|
||||
[fields.instructions]
|
||||
);
|
||||
|
||||
const availableSubagents = React.useMemo(
|
||||
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
|
||||
[agents, fields.id]
|
||||
);
|
||||
|
||||
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
|
||||
const pageLabel = (p) => surfaceFor(p)?.label || p;
|
||||
|
||||
const coveredLabels = fields.pages.map(pageLabel);
|
||||
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
|
||||
|
||||
/**
|
||||
* Going somewhere.
|
||||
*
|
||||
* A closed section is still addressable — the rail lists what is inside it,
|
||||
* and following one of those leaves has to arrive at the field rather than at
|
||||
* a header that happens to be shut. Opening first, scrolling on the next
|
||||
* frame, so the target has a height by the time it is scrolled to.
|
||||
*/
|
||||
const goTo = React.useCallback((sectionId, itemId) => {
|
||||
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
|
||||
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
|
||||
setActive(sectionId);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(itemId || sectionId)
|
||||
|| document.getElementById(sectionId);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
});
|
||||
}, [setActive]);
|
||||
|
||||
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
|
||||
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
|
||||
|
||||
/* The rail's model. Derived from the same fields the document renders, so a
|
||||
count on the rail cannot disagree with the list under it. */
|
||||
const sections = [
|
||||
{
|
||||
id: 'identity',
|
||||
icon: IdCard,
|
||||
label: 'Identity',
|
||||
meta: '',
|
||||
items: [
|
||||
{ id: 'identity-name', label: 'Name' },
|
||||
{ id: 'identity-description', label: 'Description' },
|
||||
{ id: 'identity-icon', label: 'Icon' },
|
||||
{ id: 'identity-trigger', label: 'When to use' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
icon: FileText,
|
||||
label: 'Instructions',
|
||||
meta: instructionWords ? `${instructionWords}w` : '',
|
||||
items: [{ id: 'instructions-editor', label: 'Editor' }],
|
||||
},
|
||||
{
|
||||
id: 'capabilities',
|
||||
icon: Boxes,
|
||||
label: 'Capabilities',
|
||||
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
|
||||
items: [
|
||||
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
||||
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
|
||||
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
icon: SlidersHorizontal,
|
||||
label: 'Behavior',
|
||||
meta: reasoning?.label || '',
|
||||
items: [
|
||||
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
|
||||
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
|
||||
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
|
||||
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
|
||||
{/* ── Identity ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="identity"
|
||||
icon={IdCard}
|
||||
title="Identity"
|
||||
description="How this agent is presented to people using Owliver."
|
||||
open={open.identity}
|
||||
onToggle={() => toggleSection('identity')}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
||||
<DocField id="identity-name" label="Name">
|
||||
<Input
|
||||
value={fields.name}
|
||||
placeholder="e.g. Positions Agent"
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-description"
|
||||
label="Description"
|
||||
hint="One line, shown under the name when choosing an agent."
|
||||
>
|
||||
<Input
|
||||
value={fields.description}
|
||||
placeholder="e.g. Open roles — what they need and which are at risk."
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-trigger"
|
||||
label="When to use it"
|
||||
hint="Helps a reader pick the right agent. Never matched against questions."
|
||||
>
|
||||
<Textarea
|
||||
value={fields.trigger}
|
||||
rows={3}
|
||||
placeholder="e.g. Use on Positions, for open roles and applicant flow."
|
||||
onChange={(e) => set({ trigger: e.target.value })}
|
||||
className="w-full resize-y"
|
||||
/>
|
||||
</DocField>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
|
||||
<AgentPreview
|
||||
name={fields.name}
|
||||
description={fields.description}
|
||||
icon={fields.icon}
|
||||
/>
|
||||
|
||||
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
|
||||
<IconPicker
|
||||
icons={AGENT_ICONS}
|
||||
value={fields.icon}
|
||||
onChange={(icon) => set({ icon })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Instructions ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="instructions"
|
||||
icon={FileText}
|
||||
title="Instructions"
|
||||
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
|
||||
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
||||
open={open.instructions}
|
||||
onToggle={() => toggleSection('instructions')}
|
||||
emphasis
|
||||
>
|
||||
<div
|
||||
id="instructions-editor"
|
||||
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
|
||||
bg-surface transition-all duration-base ease-out
|
||||
hover:border-border focus-within:border-krow-blue/50
|
||||
focus-within:ring-2 focus-within:ring-krow-blue/15`}
|
||||
>
|
||||
<Textarea
|
||||
value={fields.instructions}
|
||||
rows={8}
|
||||
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
|
||||
onChange={(e) => set({ instructions: e.target.value })}
|
||||
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
|
||||
text-body-md leading-relaxed focus-visible:ring-0`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="text-caption text-ink-4">Saved with the agent definition</span>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Capabilities ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="capabilities"
|
||||
icon={Boxes}
|
||||
title="Capabilities"
|
||||
description="What this agent can reach and use when it answers."
|
||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||
open={open.capabilities}
|
||||
onToggle={() => toggleSection('capabilities')}
|
||||
>
|
||||
<div className="divide-y divide-border/60">
|
||||
{/* Pages ── the boundary. */}
|
||||
<div className="pb-4">
|
||||
<GroupHead
|
||||
id="capabilities-pages"
|
||||
icon={LayoutGrid}
|
||||
title="Pages it covers"
|
||||
count={fields.pages.length}
|
||||
/>
|
||||
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
||||
{coveredLabels.length
|
||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||
</p>
|
||||
{/**
|
||||
* Scope, not a page picker.
|
||||
*
|
||||
* This was every id in the vocabulary — eighteen chips including
|
||||
* Settings, Workspace, Skills and Skill Configure — so the
|
||||
* Activity Agent could be pointed at the workspace configuration
|
||||
* screen. That is not a choice anyone should be offered: those
|
||||
* surfaces hold no workforce records (they declare
|
||||
* `placements: []`, which is what `DOMAIN_SURFACES` reads), and an
|
||||
* agent aimed at one has nothing to answer from.
|
||||
*
|
||||
* A shipped agent is a *registered domain agent* — its scope is
|
||||
* part of the definition Krow ships, and the skills it may carry
|
||||
* are derived from it. So it is stated rather than edited. An
|
||||
* agent this workspace authored still chooses, from the pages
|
||||
* that are actually product domains.
|
||||
*/}
|
||||
{scopeLocked ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fields.pages.map((page) => (
|
||||
<span
|
||||
key={page}
|
||||
className="rounded-full border border-krow-blue/40 bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue"
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</span>
|
||||
))}
|
||||
{!fields.pages.length && (
|
||||
<span className="text-body-sm text-ink-3">No surface registered.</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Domain surfaces, plus anything this definition already
|
||||
declares — so an agent written by hand against a
|
||||
configuration surface keeps it rather than losing it
|
||||
silently on the next save. */}
|
||||
{[...new Set([...DOMAIN_SURFACES, ...fields.pages])].map((page) => (
|
||||
<ScopeChip
|
||||
key={page}
|
||||
active={fields.pages.includes(page)}
|
||||
onClick={() => set({
|
||||
pages: fields.pages.includes(page)
|
||||
? fields.pages.filter((p) => p !== page)
|
||||
: [...fields.pages, page],
|
||||
})}
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</ScopeChip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-4">
|
||||
{scopeLocked
|
||||
? 'This agent ships with Krow, and its surface is part of that definition. Its scope decides which capabilities it can carry.'
|
||||
: 'A page decides which skills exist there. An agent chooses among them — it can narrow that list, never widen it.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills ── what it can actually do.
|
||||
|
||||
The section this screen was missing. Skills were attachable from
|
||||
the catalog and invisible here, on the one page that lists
|
||||
everything else an agent carries — so the reading was that this
|
||||
agent had none.
|
||||
|
||||
Availability is bounded by the pages above, which is the rule the
|
||||
copy under Pages already states: a page decides which skills
|
||||
exist there, and an agent chooses among them. It can narrow that
|
||||
list, never widen it. */}
|
||||
<div className="pt-4">
|
||||
<GroupHead
|
||||
id="capabilities-skills"
|
||||
icon={Boxes}
|
||||
title="Skills"
|
||||
count={fields.skills.length}
|
||||
action={onOpenSkills && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
|
||||
Browse catalog
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!fields.pages.length ? (
|
||||
<p className="mt-2 text-body-sm text-ink-3">
|
||||
Choose a page first. Skills belong to pages, so there are none to offer until
|
||||
this agent has somewhere to answer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-3">
|
||||
{fields.skills.length > 0 && (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.skills.map((id) => {
|
||||
const skill = availableSkills.find((sk) => sk.id === id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Boxes}
|
||||
title={skill?.name || id}
|
||||
detail={skill?.description}
|
||||
/* A skill the pages no longer offer is still
|
||||
attached and still listed — silently dropping it
|
||||
would edit the definition behind the author's
|
||||
back. Flagged instead, so removing it is their
|
||||
decision. */
|
||||
warning={skill ? null : 'Not available on the pages above.'}
|
||||
removeLabel={`Remove ${skill?.name || id}`}
|
||||
onRemove={onToggleSkill ? () => onToggleSkill(id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!fields.skills.length && (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No skills attached. This agent answers from the page's own reader only.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value=""
|
||||
disabled={!onToggleSkill || Boolean(pendingSkill)}
|
||||
onValueChange={(id) => !fields.skills.includes(id) && onToggleSkill?.(id)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder={
|
||||
pendingSkill ? 'Saving...' : 'Add a skill...'
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{attachableSkills.map((sk) => (
|
||||
<SelectItem key={sk.id} value={sk.id}>{sk.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
{attachableSkills.length
|
||||
? 'Attaching a skill takes effect immediately — it does not wait for Save.'
|
||||
: 'Every skill these pages offer is already attached.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools ── what it can actually do.
|
||||
|
||||
Skills are guidance the model reads; tools are the calls it may
|
||||
make. An agent with skills and no tools discusses the work and
|
||||
looks nothing up, which is what every agent authored here was
|
||||
before this section existed — the editor had no field for it and
|
||||
the parser dropped `tools:` on the way in.
|
||||
|
||||
The catalogue is served by the backend rather than listed here,
|
||||
so a tool renamed or withdrawn cannot leave a stale option in
|
||||
this form and an agent built from one that no longer exists. */}
|
||||
<div className="pt-4">
|
||||
<GroupHead
|
||||
id="capabilities-tools"
|
||||
icon={Wrench}
|
||||
title="Tools"
|
||||
count={fields.tools.length}
|
||||
/>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
{fields.tools.length > 0 && (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.tools.map((name) => {
|
||||
const tool = toolCatalogue.find((t) => t.name === name);
|
||||
return (
|
||||
<ItemRow
|
||||
key={name}
|
||||
icon={Wrench}
|
||||
title={tool?.name || name}
|
||||
detail={tool?.description}
|
||||
/* A tool this deployment no longer registers is kept
|
||||
and flagged rather than dropped: the backend now
|
||||
refuses to save a definition naming one, so the
|
||||
author needs to see which. */
|
||||
warning={tool
|
||||
? (tool.effect === 'write'
|
||||
? 'Proposes changes — a person is asked to approve each one.'
|
||||
: null)
|
||||
: 'Not available on this deployment.'}
|
||||
removeLabel={`Remove ${name}`}
|
||||
onRemove={() => set({ tools: fields.tools.filter((t) => t !== name) })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!fields.tools.length && (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No tools attached. This agent can discuss its subject but cannot look
|
||||
anything up.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(name) =>
|
||||
!fields.tools.includes(name) && set({ tools: [...fields.tools, name] })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder={
|
||||
toolCatalogue.length ? 'Add a tool...' : 'Loading tools...'
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toolCatalogue
|
||||
.filter((t) => !fields.tools.includes(t.name))
|
||||
.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.name}{t.effect === 'write' ? ' — writes' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A tool marked <em>writes</em> can propose a change. Nothing is written until
|
||||
somebody approves it, and the agent can only reach what you could reach
|
||||
yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Knowledge ── what it has been told. */}
|
||||
<div className="pt-4">
|
||||
<GroupHead
|
||||
id="capabilities-knowledge"
|
||||
icon={BookOpen}
|
||||
title="Knowledge"
|
||||
count={fields.knowledge.length}
|
||||
action={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: [
|
||||
...fields.knowledge,
|
||||
{ id: '', label: '', kind: 'note', body: '', url: '' },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add knowledge
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.knowledge.length ? (
|
||||
<div className="space-y-2.5">
|
||||
{fields.knowledge.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id || i}
|
||||
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Field label="Title">
|
||||
<Input
|
||||
value={entry.label}
|
||||
placeholder="e.g. Overtime policy"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, label: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Select
|
||||
value={entry.kind}
|
||||
onValueChange={(kind) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, kind };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWLEDGE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k} className="capitalize">
|
||||
{k.replace('-', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{entry.kind === 'link' ? (
|
||||
<Field label="Link">
|
||||
<Input
|
||||
value={entry.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, url: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field
|
||||
label="What it says"
|
||||
hint="Quoted back with its source named. Nothing is invented from it."
|
||||
>
|
||||
<Textarea
|
||||
value={entry.body}
|
||||
rows={3}
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, body: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No knowledge attached. Nothing is invented — this agent answers from skills only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Behavior ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="behavior"
|
||||
icon={SlidersHorizontal}
|
||||
title="Behavior"
|
||||
description="How this agent works when it answers."
|
||||
meta={reasoning?.label}
|
||||
open={open.behavior}
|
||||
onToggle={() => toggleSection('behavior')}
|
||||
last
|
||||
>
|
||||
<div>
|
||||
<SettingRow
|
||||
id="behavior-reasoning"
|
||||
icon={Brain}
|
||||
title="Reasoning"
|
||||
description={reasoning?.summary || 'How much work an answer is worth.'}
|
||||
control={(
|
||||
<SegmentedToggle
|
||||
size="sm"
|
||||
ariaLabel="Reasoning depth"
|
||||
value={fields.reasoning}
|
||||
onChange={(id) => set({ reasoning: id })}
|
||||
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-starters"
|
||||
icon={MessageSquare}
|
||||
title="Conversation starters"
|
||||
value={starterCount ? `${starterCount} configured` : 'None'}
|
||||
description="Questions offered as chips when this agent opens."
|
||||
open={setting === 'behavior-starters'}
|
||||
onToggle={() => toggleSetting('behavior-starters')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{fields.starters.map((starter, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={starter.label}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
className="max-w-[30rem]"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.starters];
|
||||
next[i] = { label: e.target.value, prompt: e.target.value };
|
||||
set({ starters: next });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove starter"
|
||||
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add starter
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<div id="behavior-web" className="scroll-mt-28">
|
||||
<SettingRow
|
||||
icon={Globe}
|
||||
title="Web search"
|
||||
value={fields.webSearch ? 'On' : 'Off'}
|
||||
description="Whether this agent may look outside the workspace."
|
||||
control={(
|
||||
<Switch
|
||||
checked={fields.webSearch}
|
||||
onCheckedChange={(webSearch) => set({ webSearch })}
|
||||
aria-label="Allow web search"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Collapse open={fields.webSearch}>
|
||||
<div className="pb-4">
|
||||
<Alert tone="warning" title="No search provider is configured">
|
||||
This deployment has no web search connected, so answers say plainly that
|
||||
nothing outside the workspace was consulted.
|
||||
</Alert>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-subagents"
|
||||
icon={Layers}
|
||||
title="Subagents"
|
||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : '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
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{fields.subagents.length > 0 && (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.subagents.map((id) => {
|
||||
const sub = agents.find((a) => a.id === id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Layers}
|
||||
title={sub?.name || id}
|
||||
detail={sub?.description}
|
||||
warning={sub ? null : 'No agent with this id is registered.'}
|
||||
removeLabel={`Remove ${sub?.name || id}`}
|
||||
onRemove={() => set({
|
||||
subagents: fields.subagents.filter((x) => x !== id),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(id) => !fields.subagents.includes(id)
|
||||
&& set({ subagents: [...fields.subagents, id] })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder="Add a subagent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSubagents
|
||||
.filter((a) => !fields.subagents.includes(a.id))
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
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>
|
||||
</div>
|
||||
</DocSection>
|
||||
</Workspace>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentConfigure;
|
||||
Reference in New Issue
Block a user