Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

229 changes: 1 addition & 228 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ _Avoid_: Inferred observation, promoted score
A metric an Agent inspector includes in that agent's system prompt as evaluation guidance; it never exposes a future observation.
_Avoid_: Prompt metric

### Protocols

**Model**:
The configured language model an Agent or Critic Gate uses to produce or review output.
_Avoid_: AI, LLM (when naming the protocol role)

**Model connection**:
The required relationship assigning exactly one Model to an Agent or Critic Gate.
_Avoid_: AI connection, LLM connection

### Runs

**Protocol canvas**:
Expand Down
2 changes: 1 addition & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
React 19 + TypeScript + Vite, styled with Tailwind v4 and a small set of local
shadcn-style primitives over [Base UI](https://base-ui.com/) (`src/components/ui`).
The visual language is deliberate and documented in the repo root's
[`CLAUDE.md`](../CLAUDE.md) — read that before adding a page or component.
[`AGENTS.md`](../AGENTS.md) — read that before adding a page or component.

## How it runs: dev server only, no build step

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/CreateCredentialDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/ui/password-input'
import { cn, HUD_ACCENT_RING_CLASSNAME } from '@/lib/utils'
import { LLM_PROVIDER_CATALOG, type LLMProvider } from '@/types/llmSettings'
import { PROVIDER_META } from '@/components/protocol/nodes/LlmNode'
import { PROVIDER_META } from '@/components/protocol/nodes/ModelNode'

const PROVIDER_CATALOG = LLM_PROVIDER_CATALOG

Expand All @@ -21,7 +21,7 @@ export function CreateCredentialDialog({
}: {
open: boolean
onOpenChange: (open: boolean) => void
// Opening this from a specific LLM node's inspector should land straight
// Opening this from a specific Model node's inspector should land straight
// on that provider's fields, not the search screen -- only meaningful with
// more than one catalog entry to search through.
defaultProvider?: LLMProvider | null
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/LlmConnectionCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type { LLMConnectionStatus, LLMProvider } from '@/types/llmSettings'

// Shared by all three places a credential's health is shown -- the Profile
// page's credentials table, the save step in CreateCredentialDialog, and the
// LLM node inspector's Credential field. One definition on purpose: the same
// Model node inspector's Credential field. One definition on purpose: the same
// status must always resolve to the same color and the same word everywhere
// (the root CLAUDE.md's rule for status-driven tint), and "Key valid" in one
// (the root AGENTS.md's rule for status-driven tint), and "Key valid" in one
// place with "Connected" in another would read as two different claims.
//
// Colors follow the app's status language: emerald for done/good, amber for
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/protocol/AddNodePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AddNodePanel } from './AddNodePanel'

describe('AddNodePanel', () => {
it('groups node types by protocol role', () => {
render(<AddNodePanel onAdd={vi.fn()} onClose={vi.fn()} />)

expect(screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)).toEqual([
'Agents',
'Models',
'Execution patterns',
'Knowledge & data',
'Tools & output',
])
})

it('only shows categories containing search matches', () => {
render(<AddNodePanel onAdd={vi.fn()} onClose={vi.fn()} />)

fireEvent.change(screen.getByPlaceholderText('Search node types…'), { target: { value: 'OpenAI' } })

expect(screen.getByRole('heading', { name: 'Models' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Agents' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument()
})

it('shows where Sub-Agents are added and enables them in that connector picker', () => {
const { rerender } = render(<AddNodePanel onAdd={vi.fn()} onClose={vi.fn()} />)

expect(screen.getByRole('button', { name: /Sub-Agent/ })).toBeDisabled()
expect(screen.getByText("Add from an Agent's Sub-Agents connector")).toBeInTheDocument()

rerender(<AddNodePanel onAdd={vi.fn()} onClose={vi.fn()} allowedTypes={['sub_agent']} title="Add Sub-Agent" />)

expect(screen.getByRole('button', { name: /Sub-Agent/ })).toBeEnabled()
})
})
105 changes: 79 additions & 26 deletions frontend/src/components/protocol/AddNodePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,33 @@ import { MCP_SERVER_BROWSE } from './mcpServerCatalog'
import { OKF_BUNDLE_BROWSE, OKF_DOCUMENT_BROWSE } from './okfCatalog'
import { SKILL_BROWSE } from './skillCatalog'

const NODE_CATEGORIES = [
{ id: 'agents', label: 'Agents' },
{ id: 'models', label: 'Models' },
{ id: 'patterns', label: 'Execution patterns' },
{ id: 'knowledge', label: 'Knowledge & data' },
{ id: 'tools', label: 'Tools & output' },
] as const

type NodeCategory = (typeof NODE_CATEGORIES)[number]['id']

// Every entry here earns its place: GET /api/mcp-servers already backs the
// tool picker, GET /datasets already backs the dataset picker,
// services.protocol_execution._run_gated_worker already implements the
// critic gate's revision loop, and _resolve_llm_config/_resolve_tool_config/
// critic gate's revision loop, and _resolve_model_config/_resolve_tool_config/
// _resolve_dataset_configs/_resolve_script_configs already resolve an agent's
// respective connectors. "memory" and the two pattern entries are the
// exceptions -- each is real in the graph/validation sense (wiring one up is
// accepted and does something visually) but has NO runtime effect yet,
// documented on the node/inspector itself, not hidden from the catalog.
// LLM/Architectural Pattern are each a family of node types (one per
// provider/pattern -- see LlmNodeData/ReasonActPatternNodeData in
// provider/pattern -- see ModelNodeData/ReasonActPatternNodeData in
// types/protocols.ts for why), not one generic entry with an internal
// picker -- this catalog, filtered to a connector's own family via
// allowedTypes, IS that picker.
const NODE_CATALOG = [
{ type: 'agent', label: 'Agent', description: 'An LLM agent stage in the pipeline', icon: Bot },
{ type: 'agent', category: 'agents', label: 'Agent', description: 'An LLM agent stage in the pipeline', icon: Bot },
{ type: 'sub_agent', category: 'agents', label: 'Sub-Agent', description: 'A delegated worker callable by one parent Agent', icon: Bot },
// Not a node type -- picking this opens the server browser
// (McpServerBrowserPanel), and the node gets created from whichever
// server is chosen there. It replaced a plain "MCP Tool" entry that made
Expand All @@ -33,50 +44,58 @@ const NODE_CATALOG = [
// nothing already on a canvas changes.
{
type: MCP_SERVER_BROWSE,
category: 'tools',
label: 'MCP Servers',
description: "Browse available MCP servers and allow-list their tools for an Agent",
icon: Server,
},
{
type: 'critic_gate',
category: 'agents',
label: 'Critic Gate',
description: "Reviews an upstream Agent's output, requests revisions",
icon: ShieldCheck,
},
{ type: 'llm_anthropic', label: 'Anthropic', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Sparkles },
{ type: 'llm_openai', label: 'OpenAI', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Atom },
{ type: 'model_anthropic', category: 'models', label: 'Anthropic', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Sparkles },
{ type: 'model_openai', category: 'models', label: 'OpenAI', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Atom },
{
type: 'llm_azure_foundry',
type: 'model_azure_foundry',
category: 'models',
label: 'Azure AI Foundry',
description: "An Agent or Critic Gate's model, temperature, and parameters -- routed through your own Azure resource",
icon: Cloud,
},
{
type: 'llm_openrouter',
type: 'model_openrouter',
category: 'models',
label: 'OpenRouter',
description: "An Agent or Critic Gate's model, temperature, and parameters -- routed through your own OpenRouter account",
icon: Route,
},
{
type: 'llm_local',
type: 'model_local',
category: 'models',
label: 'Local',
description: "An Agent or Critic Gate's model, temperature, and parameters -- routed to a self-hosted OpenAI-compatible server",
icon: HardDrive,
},
{
type: 'pattern_reason_act',
category: 'patterns',
label: 'Reason + Act',
description: 'Alternates between reasoning and tool calls each iteration until it reaches a final answer',
icon: Repeat2,
},
{
type: 'pattern_single_agent_baseline',
category: 'patterns',
label: 'Single-Agent Baseline',
description: 'One reasoning pass per iteration, no tool-call loop -- the cheap default when there’s nothing to call',
icon: ArrowRight,
},
{
type: 'memory',
category: 'knowledge',
label: 'Memory',
description: 'Not yet functional -- declares intent for a future phase',
icon: BrainCircuit,
Expand All @@ -89,6 +108,7 @@ const NODE_CATALOG = [
// there's no picker in the node's inspector -- the dataset IS the node.
{
type: DATASET_BROWSE,
category: 'knowledge',
label: 'Datasets',
description: "Browse your registered datasets -- the data an Agent's workspace tools operate on",
icon: Database,
Expand All @@ -100,6 +120,7 @@ const NODE_CATALOG = [
// whole skill library, so it's where registering and deleting live.
{
type: SKILL_BROWSE,
category: 'knowledge',
label: 'Skills',
description: 'Browse your Agent Skills -- instructions an Agent opens when their description matches the task',
icon: ScrollText,
Expand All @@ -109,6 +130,7 @@ const NODE_CATALOG = [
// That browser is also the only place bundles are uploaded.
{
type: OKF_BUNDLE_BROWSE,
category: 'knowledge',
label: 'OKF Bundles',
description: 'Upload a folder of Markdown concepts an Agent reads and writes as it works',
icon: BookMarked,
Expand All @@ -119,23 +141,32 @@ const NODE_CATALOG = [
// concepts and a document is a single one -- exactly like Skills.
{
type: OKF_DOCUMENT_BROWSE,
category: 'knowledge',
label: 'OKF Documents',
description: 'Upload a single Markdown concept an Agent reads and rewrites as it works',
icon: FileText,
},
{
type: 'output_parser',
category: 'tools',
label: 'Output Parser',
description: "Defines the format an Agent's answer must take, and reads its named, typed fields back out",
icon: Braces,
},
{
type: 'script',
category: 'tools',
label: 'Script',
description: 'A fixed piece of Python code an Agent passes verbatim into some tool',
icon: Code2,
},
]
] satisfies Array<{
type: string
category: NodeCategory
label: string
description: string
icon: typeof Bot
}>

export function AddNodePanel({
onAdd,
Expand All @@ -152,34 +183,56 @@ export function AddNodePanel({
title?: string
}) {
const [query, setQuery] = useState('')
const catalog = allowedTypes ? NODE_CATALOG.filter((item) => allowedTypes.includes(item.type)) : NODE_CATALOG
const catalog = allowedTypes
? NODE_CATALOG.filter((item) => allowedTypes.includes(item.type))
: NODE_CATALOG
const filtered = catalog.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()))
const groups = NODE_CATEGORIES.flatMap((category) => {
const items = filtered.filter((item) => item.category === category.id)
return items.length > 0 ? [{ ...category, items }] : []
})

return (
<div className="flex w-80 shrink-0 flex-col gap-3 border-l p-4">
<div className="flex min-h-0 w-80 shrink-0 flex-col gap-3 overflow-hidden border-l p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">{title}</p>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<Input autoFocus placeholder="Search node types…" value={query} onChange={(e) => setQuery(e.target.value)} />
<div className="flex flex-col gap-1.5">
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{filtered.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">No matching node types.</p>}
{filtered.map((item) => (
<button
key={item.type}
type="button"
onClick={() => onAdd(item.type)}
className="flex cursor-pointer items-center gap-2.5 rounded-lg border bg-background px-3 py-2.5 text-left text-sm shadow-[0_0_16px_-6px_var(--primary)] ring-1 ring-primary/20 transition-colors hover:bg-muted"
>
<item.icon className="size-4 shrink-0 text-primary" />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">{item.description}</p>
</div>
</button>
))}
<div className="flex flex-col gap-4">
{groups.map((group) => (
<section key={group.id} aria-labelledby={`add-node-${group.id}`}>
<h3 id={`add-node-${group.id}`} className="mb-1.5 font-mono text-[10px] font-medium tracking-[0.16em] text-muted-foreground uppercase">
{group.label}
</h3>
<div className="flex flex-col gap-1.5">
{group.items.map((item) => (
<button
key={item.type}
type="button"
disabled={!allowedTypes && item.type === 'sub_agent'}
onClick={() => onAdd(item.type)}
className="flex cursor-pointer items-center gap-2.5 rounded-lg border bg-background px-3 py-2.5 text-left text-sm shadow-[0_0_16px_-6px_var(--primary)] ring-1 ring-primary/20 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-background"
>
<item.icon className="size-4 shrink-0 text-primary" />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">
{!allowedTypes && item.type === 'sub_agent'
? "Add from an Agent's Sub-Agents connector"
: item.description}
</p>
</div>
</button>
))}
</div>
</section>
))}
</div>
</div>
</div>
)
Expand Down
Loading
Loading