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
865 lines
37 KiB
TypeScript
865 lines
37 KiB
TypeScript
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;
|