From 77192bdeb9c876c13d4a4ee5a95463edcaf39db6 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Mon, 8 Jun 2026 06:58:40 +0200 Subject: [PATCH 1/5] feat(agent-builder): editable visual canvas for channels/sub-agents/skills/tools/MCP/schedules Adds an editable node-graph Agent Builder at /admin/builder backed by the existing live, hot-reloading config graph. Drawing a connection writes the wiring; deleting an edge removes it; edits apply without a restart via pg_notify('agents_changed') -> registry.reload(). Backend (middleware): - migration 0003: skills, mcp_servers, agent_subagents, agent_tool_grants, agent_schedules + agents.model_routing/canvas_position + notify triggers - AgentGraphStore (CRUD) + ConfigStore snapshot/diff awareness: graph changes rebuild the owning agent; schedules are orthogonal (cron worker consumes them) - per-agent model routing: persisted JSON -> runtime triage config - MCP client (@modelcontextprotocol/sdk) + adapters to native + sub-agent tools - DB sub-agents materialised as LocalSubAgent DomainTools, attached in onAgentBuilt - REST router at /api/v1/operator: graph read, edge create/delete dispatcher, sub-agent/skill/mcp/schedule CRUD, model-routing, positions, mcp discover - cron schedule worker firing headless agent turns Frontend (web-ui): - @xyflow/react canvas: custom nodes, palette, toolbox, inspector, optimistic edges with rollback, debounced position save; admin dashboard card; en/de i18n Tests: cron matcher, model-routing mapping, sub-agent builder, MCP adapters, graph diff. Typecheck + lint clean (middleware + web-ui), 27/27 unit tests pass. See docs/plans/agent-builder-TESTING.md for the local end-to-end test flow. --- .../migrations/0003_agent_builder_graph.sql | 165 +++++ middleware/package-lock.json | 248 +++++++ .../harness-orchestrator/package.json | 3 + .../harness-orchestrator/src/index.ts | 41 ++ .../harness-orchestrator/src/mcp/mcpClient.ts | 297 ++++++++ .../src/registry/agentGraphStore.ts | 587 +++++++++++++++ .../src/registry/agentRuntime.ts | 59 ++ .../src/registry/applyDiff.ts | 97 ++- .../src/registry/configStore.ts | 107 ++- .../src/registry/index.ts | 76 +- .../src/registry/subAgentTools.ts | 158 +++++ .../packages/plugin-api/src/agentGraph.ts | 168 +++++ middleware/packages/plugin-api/src/index.ts | 6 + .../src/agents/subAgentToolHydration.ts | 123 ++++ middleware/src/index.ts | 75 +- middleware/src/routes/agentBuilder.ts | 669 ++++++++++++++++++ middleware/src/scheduler/cron.ts | 91 +++ middleware/src/scheduler/scheduleWorker.ts | 143 ++++ middleware/test/agentBuilderCron.test.ts | 44 ++ middleware/test/agentBuilderDiff.test.ts | 184 +++++ .../test/agentBuilderModelRouting.test.ts | 44 ++ .../test/agentBuilderSubAgentTools.test.ts | 137 ++++ web-ui/app/_lib/agentBuilder.ts | 499 +++++++++++++ web-ui/app/admin/builder/BuilderCanvas.tsx | 391 ++++++++++ web-ui/app/admin/builder/graphMapping.ts | 126 ++++ web-ui/app/admin/builder/nodes/AgentNode.tsx | 38 + .../app/admin/builder/nodes/ChannelNode.tsx | 22 + .../app/admin/builder/nodes/McpServerNode.tsx | 27 + web-ui/app/admin/builder/nodes/NodeShell.tsx | 91 +++ .../app/admin/builder/nodes/ScheduleNode.tsx | 22 + web-ui/app/admin/builder/nodes/SkillNode.tsx | 22 + .../app/admin/builder/nodes/SubAgentNode.tsx | 23 + web-ui/app/admin/builder/nodes/ToolNode.tsx | 21 + web-ui/app/admin/builder/nodes/types.ts | 63 ++ web-ui/app/admin/builder/page.tsx | 107 +++ .../builder/panels/InspectorControls.tsx | 42 ++ .../admin/builder/panels/InspectorPanel.tsx | 334 +++++++++ .../app/admin/builder/panels/PalettePanel.tsx | 43 ++ .../app/admin/builder/panels/ToolboxPanel.tsx | 90 +++ web-ui/app/admin/builder/useAgentGraph.ts | 96 +++ web-ui/app/admin/page.tsx | 5 + web-ui/messages/de.json | 56 ++ web-ui/messages/en.json | 56 ++ web-ui/package-lock.json | 242 ++++++- web-ui/package.json | 1 + 45 files changed, 5932 insertions(+), 7 deletions(-) create mode 100644 middleware/migrations/0003_agent_builder_graph.sql create mode 100644 middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts create mode 100644 middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts create mode 100644 middleware/packages/harness-orchestrator/src/registry/agentRuntime.ts create mode 100644 middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts create mode 100644 middleware/packages/plugin-api/src/agentGraph.ts create mode 100644 middleware/src/agents/subAgentToolHydration.ts create mode 100644 middleware/src/routes/agentBuilder.ts create mode 100644 middleware/src/scheduler/cron.ts create mode 100644 middleware/src/scheduler/scheduleWorker.ts create mode 100644 middleware/test/agentBuilderCron.test.ts create mode 100644 middleware/test/agentBuilderDiff.test.ts create mode 100644 middleware/test/agentBuilderModelRouting.test.ts create mode 100644 middleware/test/agentBuilderSubAgentTools.test.ts create mode 100644 web-ui/app/_lib/agentBuilder.ts create mode 100644 web-ui/app/admin/builder/BuilderCanvas.tsx create mode 100644 web-ui/app/admin/builder/graphMapping.ts create mode 100644 web-ui/app/admin/builder/nodes/AgentNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/ChannelNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/McpServerNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/NodeShell.tsx create mode 100644 web-ui/app/admin/builder/nodes/ScheduleNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/SkillNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/SubAgentNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/ToolNode.tsx create mode 100644 web-ui/app/admin/builder/nodes/types.ts create mode 100644 web-ui/app/admin/builder/page.tsx create mode 100644 web-ui/app/admin/builder/panels/InspectorControls.tsx create mode 100644 web-ui/app/admin/builder/panels/InspectorPanel.tsx create mode 100644 web-ui/app/admin/builder/panels/PalettePanel.tsx create mode 100644 web-ui/app/admin/builder/panels/ToolboxPanel.tsx create mode 100644 web-ui/app/admin/builder/useAgentGraph.ts diff --git a/middleware/migrations/0003_agent_builder_graph.sql b/middleware/migrations/0003_agent_builder_graph.sql new file mode 100644 index 00000000..38a01c32 --- /dev/null +++ b/middleware/migrations/0003_agent_builder_graph.sql @@ -0,0 +1,165 @@ +-- 0003_agent_builder_graph.sql +-- +-- Agent Builder canvas (P0). Adds the editable graph primitives that the +-- visual builder wires together: skills, MCP servers, sub-agents, tool grants +-- and schedule triggers — plus per-agent model-routing config and cosmetic +-- canvas coordinates. +-- +-- Source of truth is Postgres (copy-on-write from file-defined plugin +-- defaults): the first canvas edit forks an agent's wiring into these tables +-- and the DB rows become authoritative from then on. +-- +-- Every mutation re-uses the existing notify_agents_changed bus so the +-- OrchestratorRegistry hot-reloads without a restart (the payload is only a +-- wake-up signal — the registry always recomputes a full snapshot diff). +-- +-- Idempotent (IF NOT EXISTS / CREATE OR REPLACE) so re-application is a no-op. + +-- ── skills ──────────────────────────────────────────────────────────────── +-- DB-backed playbooks. `source='file'` rows are read-only mirrors imported +-- from SKILL.md; `source='db'` rows are operator-authored / forked. +CREATE TABLE IF NOT EXISTS skills ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + body TEXT NOT NULL DEFAULT '', + frontmatter JSONB NOT NULL DEFAULT '{}', + source TEXT NOT NULL DEFAULT 'db' CHECK (source IN ('db', 'file')), + source_path TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── mcp_servers ───────────────────────────────────────────────────────────── +-- External MCP endpoints registered for tool discovery. Secrets are never +-- stored raw — `secret_ref` keys into the existing secrets store; `headers` +-- holds only non-sensitive metadata. +CREATE TABLE IF NOT EXISTS mcp_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + transport TEXT NOT NULL CHECK (transport IN ('stdio', 'http', 'sse')), + endpoint TEXT, + headers JSONB NOT NULL DEFAULT '{}', + secret_ref TEXT, + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + last_discovered_at TIMESTAMPTZ, + discovered_tools JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── agent_subagents ─────────────────────────────────────────────────────── +-- A capability-scoped in-process LocalSubAgent owned by a parent agent. +CREATE TABLE IF NOT EXISTS agent_subagents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + name TEXT NOT NULL, + skill_id UUID REFERENCES skills(id) ON DELETE SET NULL, + model TEXT, -- null = inherit parent + max_tokens INT, + max_iterations INT, + system_prompt_override TEXT, -- null = use skill body + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + position JSONB, -- canvas {x,y} + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (parent_agent_id, name) +); +CREATE INDEX IF NOT EXISTS agent_subagents_parent_idx + ON agent_subagents(parent_agent_id); +CREATE INDEX IF NOT EXISTS agent_subagents_skill_idx + ON agent_subagents(skill_id); + +-- ── agent_tool_grants ─────────────────────────────────────────────────────── +-- Which native / MCP tool an agent (or one of its sub-agents) may use. +-- Exactly one of agent_id / subagent_id is set. +CREATE TABLE IF NOT EXISTS agent_tool_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + subagent_id UUID REFERENCES agent_subagents(id) ON DELETE CASCADE, + tool_kind TEXT NOT NULL CHECK (tool_kind IN ('native', 'mcp')), + tool_ref TEXT NOT NULL, -- native name, or ":" + mcp_server_id UUID REFERENCES mcp_servers(id) ON DELETE CASCADE, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (agent_id IS NOT NULL OR subagent_id IS NOT NULL) +); +CREATE INDEX IF NOT EXISTS agent_tool_grants_agent_idx + ON agent_tool_grants(agent_id); +CREATE INDEX IF NOT EXISTS agent_tool_grants_subagent_idx + ON agent_tool_grants(subagent_id); + +-- ── agent_schedules ───────────────────────────────────────────────────────── +-- Cron trigger → synthetic agent turn. +CREATE TABLE IF NOT EXISTS agent_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + cron TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + timezone TEXT NOT NULL DEFAULT 'UTC', + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + last_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS agent_schedules_agent_idx + ON agent_schedules(agent_id); + +-- ── agents / channel_bindings extensions ──────────────────────────────────── +-- model_routing: { mode:'single'|'triage', main, triage?, escalate_on?[] } +ALTER TABLE agents ADD COLUMN IF NOT EXISTS model_routing JSONB; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS canvas_position JSONB; +ALTER TABLE channel_bindings ADD COLUMN IF NOT EXISTS canvas_position JSONB; + +-- ── notify trigger: teach it the new row shapes ────────────────────────────── +-- The payload is only a wake-up hint; correctness comes from the registry's +-- full snapshot diff. Branch by TG_TABLE_NAME so each row touches only the +-- columns it actually has (plpgsql binds field refs at execution time). +CREATE OR REPLACE FUNCTION notify_agents_changed() RETURNS trigger AS $$ +DECLARE + payload TEXT; +BEGIN + IF TG_TABLE_NAME = 'multi_orchestrator_settings' THEN + payload := 'platform'; + ELSIF TG_TABLE_NAME = 'agents' THEN + payload := COALESCE((CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END)::text, 'platform'); + ELSIF TG_TABLE_NAME IN ('agent_plugins', 'channel_bindings', 'agent_schedules') THEN + payload := COALESCE((CASE WHEN TG_OP = 'DELETE' THEN OLD.agent_id ELSE NEW.agent_id END)::text, 'platform'); + ELSIF TG_TABLE_NAME = 'agent_subagents' THEN + payload := COALESCE((CASE WHEN TG_OP = 'DELETE' THEN OLD.parent_agent_id ELSE NEW.parent_agent_id END)::text, 'platform'); + ELSE + -- skills, mcp_servers, agent_tool_grants — fan-out potential, full reload. + payload := 'platform'; + END IF; + PERFORM pg_notify('agents_changed', payload); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS skills_notify ON skills; +CREATE TRIGGER skills_notify + AFTER INSERT OR UPDATE OR DELETE ON skills + FOR EACH ROW EXECUTE FUNCTION notify_agents_changed(); + +DROP TRIGGER IF EXISTS mcp_servers_notify ON mcp_servers; +CREATE TRIGGER mcp_servers_notify + AFTER INSERT OR UPDATE OR DELETE ON mcp_servers + FOR EACH ROW EXECUTE FUNCTION notify_agents_changed(); + +DROP TRIGGER IF EXISTS agent_subagents_notify ON agent_subagents; +CREATE TRIGGER agent_subagents_notify + AFTER INSERT OR UPDATE OR DELETE ON agent_subagents + FOR EACH ROW EXECUTE FUNCTION notify_agents_changed(); + +DROP TRIGGER IF EXISTS agent_tool_grants_notify ON agent_tool_grants; +CREATE TRIGGER agent_tool_grants_notify + AFTER INSERT OR UPDATE OR DELETE ON agent_tool_grants + FOR EACH ROW EXECUTE FUNCTION notify_agents_changed(); + +DROP TRIGGER IF EXISTS agent_schedules_notify ON agent_schedules; +CREATE TRIGGER agent_schedules_notify + AFTER INSERT OR UPDATE OR DELETE ON agent_schedules + FOR EACH ROW EXECUTE FUNCTION notify_agents_changed(); diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 31c8e5bf..79e23f68 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -1352,6 +1352,18 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "license": "Apache-2.0", @@ -1796,6 +1808,68 @@ } } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodable/entities": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", @@ -2521,6 +2595,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "dev": true, @@ -3326,6 +3439,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -3900,6 +4030,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -3978,6 +4129,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -4013,6 +4182,22 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -4419,6 +4604,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/htmlparser2": { "version": "9.1.0", "funding": [ @@ -4554,6 +4748,15 @@ "version": "1.3.8", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -4693,6 +4896,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "license": "MIT" @@ -5271,6 +5480,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "license": "MIT", @@ -5516,6 +5734,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "license": "MIT", @@ -5775,6 +6002,15 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -6758,6 +6994,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "packages/agent-reference-maximum": { "name": "@omadia/agent-reference-maximum", "version": "0.3.2", @@ -6941,6 +7186,9 @@ "name": "@omadia/orchestrator", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, "engines": { "node": ">=20" }, diff --git a/middleware/packages/harness-orchestrator/package.json b/middleware/packages/harness-orchestrator/package.json index 218c43c6..a58859ed 100644 --- a/middleware/packages/harness-orchestrator/package.json +++ b/middleware/packages/harness-orchestrator/package.json @@ -25,5 +25,8 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" } } diff --git a/middleware/packages/harness-orchestrator/src/index.ts b/middleware/packages/harness-orchestrator/src/index.ts index 73ef2099..76e47442 100644 --- a/middleware/packages/harness-orchestrator/src/index.ts +++ b/middleware/packages/harness-orchestrator/src/index.ts @@ -85,6 +85,47 @@ export type { } from './registry/configStore.js'; export { runMultiOrchestratorMigrations } from './registry/migrator.js'; +// Agent Builder — editable graph store, MCP client, sub-agent materialisation, +// and the persisted-routing → runtime mapping. +export { AgentGraphStore } from './registry/agentGraphStore.js'; +export type { + CanvasPos, + McpServerInput, + McpServerRow, + ScheduleInput, + ScheduleRow, + SkillInput, + SkillPatch, + SkillRow, + SubAgentInput, + SubAgentPatch, + SubAgentRow, + ToolGrantInput, + ToolGrantRow, +} from './registry/agentGraphStore.js'; +export { + McpManager, + mcpNativeHandler, + mcpNativeToolName, + mcpToolToLocalSubAgentTool, + mcpToolToNativeSpec, +} from './mcp/mcpClient.js'; +export type { + McpServerConfig, + McpToolDescriptor, + McpTransportKind, +} from './mcp/mcpClient.js'; +export { + buildSubAgentDomainTools, + subAgentToolName, +} from './registry/subAgentTools.js'; +export type { + SubAgentGraph, + SubAgentToolDeps, +} from './registry/subAgentTools.js'; +export { resolveAgentModelRouting } from './registry/agentRuntime.js'; +export type { ResolvedAgentRuntime } from './registry/agentRuntime.js'; + // Per-Agent Orchestrator factory (US3) — re-exported so US4-style external // callers (CLI, tests) can build Orchestrators without going through the // plugin's activate path. diff --git a/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts b/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts new file mode 100644 index 00000000..d5c12215 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/mcp/mcpClient.ts @@ -0,0 +1,297 @@ +/** + * MCP client manager (Agent Builder P4). + * + * Connects to operator-registered MCP servers (stdio / streamable-HTTP / SSE) + * via the official `@modelcontextprotocol/sdk`, discovers their tools, and + * adapts each discovered tool into the two shapes the orchestrator already + * understands: + * + * - `NativeToolSpec` + `NativeToolHandler` — for tools granted to a + * top-level agent (registered on its `NativeToolRegistry`). + * - `LocalSubAgentTool` — for tools granted to a sub-agent + * (passed into its `LocalSubAgent` tool list). + * + * Connections are pooled per server id and lazily (re)established. A failed + * connection is dropped so the next call retries — callers layer their own + * backoff. The manager never throws on `callTool`; it returns an `Error: …` + * string so a tool failure degrades the turn instead of killing it. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { + LocalSubAgentTool, + NativeToolHandler, + NativeToolSpec, +} from '@omadia/plugin-api'; + +export type McpTransportKind = 'stdio' | 'http' | 'sse'; + +export interface McpServerConfig { + readonly id: string; + readonly name: string; + readonly transport: McpTransportKind; + /** URL for http/sse, or a shell command line for stdio. */ + readonly endpoint: string | null; + /** Non-sensitive headers for http/sse. Secrets resolve via `secretRef`. */ + readonly headers?: Record; +} + +export interface McpToolDescriptor { + readonly name: string; + readonly description?: string; + readonly inputSchema?: Record; +} + +interface Pooled { + readonly client: Client; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly transport: any; +} + +const CLIENT_INFO = { name: 'omadia-agent-builder', version: '0.1.0' } as const; + +export class McpManager { + private readonly pool = new Map(); + private readonly connecting = new Map>(); + + /** Discover the tool list a server exposes. Throws on connection failure so + * the operator-facing `/discover` endpoint can report it. */ + async listTools(cfg: McpServerConfig): Promise { + const { client } = await this.getOrConnect(cfg); + const res = await client.listTools(); + const tools = Array.isArray(res?.tools) ? res.tools : []; + return tools.map((t) => ({ + name: String(t.name), + ...(t.description ? { description: String(t.description) } : {}), + ...(t.inputSchema + ? { inputSchema: t.inputSchema as Record } + : {}), + })); + } + + /** Invoke a tool. Never throws — returns an `Error: …` string on failure so + * the orchestrator turn keeps going. */ + async callTool( + cfg: McpServerConfig, + toolName: string, + args: Record, + ): Promise { + let pooled: Pooled; + try { + pooled = await this.getOrConnect(cfg); + } catch (err) { + return `Error: could not connect to MCP server "${cfg.name}": ${msg(err)}`; + } + try { + const res = await pooled.client.callTool({ + name: toolName, + arguments: args, + }); + return renderToolResult(res); + } catch (err) { + // Drop the connection so the next call reconnects (server may have died). + await this.close(cfg.id); + return `Error: MCP tool "${toolName}" on "${cfg.name}" failed: ${msg(err)}`; + } + } + + async close(id: string): Promise { + const pooled = this.pool.get(id); + this.pool.delete(id); + this.connecting.delete(id); + if (!pooled) return; + try { + await pooled.client.close(); + } catch { + /* best-effort */ + } + } + + async closeAll(): Promise { + await Promise.all([...this.pool.keys()].map((id) => this.close(id))); + } + + private getOrConnect(cfg: McpServerConfig): Promise { + const existing = this.pool.get(cfg.id); + if (existing) return Promise.resolve(existing); + const inflight = this.connecting.get(cfg.id); + if (inflight) return inflight; + + const p = this.connect(cfg) + .then((pooled) => { + this.pool.set(cfg.id, pooled); + this.connecting.delete(cfg.id); + return pooled; + }) + .catch((err) => { + this.connecting.delete(cfg.id); + throw err; + }); + this.connecting.set(cfg.id, p); + return p; + } + + private async connect(cfg: McpServerConfig): Promise { + const transport = this.makeTransport(cfg); + const client = new Client(CLIENT_INFO); + await client.connect(transport); + return { client, transport }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private makeTransport(cfg: McpServerConfig): any { + if (!cfg.endpoint) { + throw new Error(`MCP server "${cfg.name}" has no endpoint configured`); + } + if (cfg.transport === 'stdio') { + const [command, ...args] = splitCommand(cfg.endpoint); + if (!command) { + throw new Error(`MCP server "${cfg.name}" stdio command is empty`); + } + return new StdioClientTransport({ command, args }); + } + const url = new URL(cfg.endpoint); + const requestInit = cfg.headers ? { headers: cfg.headers } : undefined; + if (cfg.transport === 'sse') { + return new SSEClientTransport(url, requestInit ? { requestInit } : {}); + } + return new StreamableHTTPClientTransport( + url, + requestInit ? { requestInit } : {}, + ); + } +} + +// ── adapters ──────────────────────────────────────────────────────────────── + +/** Anthropic tool names must match `^[a-zA-Z0-9_-]{1,64}$`. Build a stable, + * collision-resistant native name for an MCP tool. */ +export function mcpNativeToolName( + serverName: string, + toolName: string, +): string { + const raw = `mcp__${serverName}__${toolName}`; + const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_'); + return safe.length <= 64 ? safe : safe.slice(0, 64); +} + +function inputSchemaOrEmpty(tool: McpToolDescriptor): { + type: 'object'; + properties: Record; + required: string[]; +} { + const schema = tool.inputSchema; + if (schema && schema['type'] === 'object') { + return { + type: 'object', + properties: (schema['properties'] as Record) ?? {}, + required: Array.isArray(schema['required']) + ? (schema['required'] as string[]) + : [], + }; + } + return { type: 'object', properties: {}, required: [] }; +} + +/** Adapt an MCP tool into a top-level orchestrator NativeToolSpec. */ +export function mcpToolToNativeSpec( + serverName: string, + tool: McpToolDescriptor, +): NativeToolSpec { + return { + name: mcpNativeToolName(serverName, tool.name), + description: + tool.description ?? `MCP tool "${tool.name}" from server "${serverName}".`, + input_schema: inputSchemaOrEmpty(tool), + domain: `mcp.${slugifyDomain(serverName)}`, + }; +} + +/** Native handler that routes a tool call to the MCP server. */ +export function mcpNativeHandler( + manager: McpManager, + cfg: McpServerConfig, + toolName: string, +): NativeToolHandler { + return async (input: unknown): Promise => { + const args = + input && typeof input === 'object' + ? (input as Record) + : {}; + return manager.callTool(cfg, toolName, args); + }; +} + +/** Adapt an MCP tool into a sub-agent tool (for `LocalSubAgent`). */ +export function mcpToolToLocalSubAgentTool( + manager: McpManager, + cfg: McpServerConfig, + tool: McpToolDescriptor, +): LocalSubAgentTool { + return { + spec: { + name: mcpNativeToolName(cfg.name, tool.name), + description: + tool.description ?? `MCP tool "${tool.name}" from "${cfg.name}".`, + input_schema: inputSchemaOrEmpty(tool), + }, + async handle(input: unknown): Promise { + const args = + input && typeof input === 'object' + ? (input as Record) + : {}; + return manager.callTool(cfg, tool.name, args); + }, + }; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** Flatten an MCP `CallToolResult` into a plain string for the LLM. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function renderToolResult(res: any): string { + const content = res?.content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + parts.push(block.text); + } else if (block?.type === 'resource' && block.resource?.text) { + parts.push(String(block.resource.text)); + } else { + parts.push(JSON.stringify(block)); + } + } + const joined = parts.join('\n').trim(); + const out = joined.length > 0 ? joined : JSON.stringify(content); + return res?.isError ? `Error: ${out}` : out; + } + if (typeof res?.structuredContent !== 'undefined') { + return JSON.stringify(res.structuredContent); + } + return JSON.stringify(res ?? {}); +} + +/** Split a shell command line into argv. Honours simple double/single quotes; + * not a full shell parser, but enough for `npx -y @scope/pkg --flag "v"`. */ +export function splitCommand(line: string): string[] { + const out: string[] = []; + const re = /"([^"]*)"|'([^']*)'|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + out.push(m[1] ?? m[2] ?? m[3] ?? ''); + } + return out; +} + +function slugifyDomain(name: string): string { + const s = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return s.length > 0 ? s : 'server'; +} + +function msg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts b/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts new file mode 100644 index 00000000..0abb23af --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts @@ -0,0 +1,587 @@ +import type { Pool } from 'pg'; + +import { ConfigValidationError } from './configStore.js'; + +/** + * Agent Builder graph store (P0). + * + * Pure CRUD against the editable graph tables introduced by + * `0003_agent_builder_graph.sql` (skills, mcp_servers, agent_subagents, + * agent_tool_grants, agent_schedules). Kept separate from `ConfigStore` so the + * latter stays under the 500-line cap; `ConfigStore.loadSnapshot` composes the + * `list*` methods here into the registry snapshot. + * + * Like `ConfigStore`, this enforces only what the DB enforces (PK uniqueness, + * FK cascades, CHECK constraints) — graph-level validation (cycles, privacy + * conflicts) lives in the REST edge-create validator (P2). + */ + +// ── row shapes (camelCase, mapped from snake_case DB rows) ────────────────── + +export interface CanvasPos { + readonly x: number; + readonly y: number; +} + +export interface SubAgentRow { + readonly id: string; + readonly parentAgentId: string; + readonly name: string; + readonly skillId: string | null; + readonly model: string | null; + readonly maxTokens: number | null; + readonly maxIterations: number | null; + readonly systemPromptOverride: string | null; + readonly status: 'enabled' | 'disabled'; + readonly position: CanvasPos | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface SkillRow { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly description: string | null; + readonly body: string; + readonly frontmatter: Record; + readonly source: 'db' | 'file'; + readonly sourcePath: string | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface McpServerRow { + readonly id: string; + readonly name: string; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly endpoint: string | null; + readonly headers: Record; + readonly secretRef: string | null; + readonly status: 'enabled' | 'disabled'; + readonly lastDiscoveredAt: Date | null; + readonly discoveredTools: readonly unknown[]; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface ToolGrantRow { + readonly id: string; + readonly agentId: string | null; + readonly subAgentId: string | null; + readonly toolKind: 'native' | 'mcp'; + readonly toolRef: string; + readonly mcpServerId: string | null; + readonly config: Record; + readonly createdAt: Date; +} + +export interface ScheduleRow { + readonly id: string; + readonly agentId: string; + readonly cron: string; + readonly payload: Record; + readonly timezone: string; + readonly status: 'enabled' | 'disabled'; + readonly lastRunAt: Date | null; + readonly createdAt: Date; +} + +// ── inputs ────────────────────────────────────────────────────────────────── + +export interface SubAgentInput { + readonly parentAgentId: string; + readonly name: string; + readonly skillId?: string | null; + readonly model?: string | null; + readonly maxTokens?: number | null; + readonly maxIterations?: number | null; + readonly systemPromptOverride?: string | null; + readonly status?: 'enabled' | 'disabled'; + readonly position?: CanvasPos | null; +} + +export interface SubAgentPatch { + readonly name?: string; + readonly skillId?: string | null; + readonly model?: string | null; + readonly maxTokens?: number | null; + readonly maxIterations?: number | null; + readonly systemPromptOverride?: string | null; + readonly status?: 'enabled' | 'disabled'; + readonly position?: CanvasPos | null; +} + +export interface SkillInput { + readonly slug: string; + readonly name: string; + readonly description?: string | null; + readonly body?: string; + readonly frontmatter?: Record; + readonly source?: 'db' | 'file'; + readonly sourcePath?: string | null; +} + +export interface SkillPatch { + readonly name?: string; + readonly description?: string | null; + readonly body?: string; + readonly frontmatter?: Record; +} + +export interface McpServerInput { + readonly name: string; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly endpoint?: string | null; + readonly headers?: Record; + readonly secretRef?: string | null; + readonly status?: 'enabled' | 'disabled'; +} + +export interface ToolGrantInput { + readonly agentId?: string | null; + readonly subAgentId?: string | null; + readonly toolKind: 'native' | 'mcp'; + readonly toolRef: string; + readonly mcpServerId?: string | null; + readonly config?: Record; +} + +export interface ScheduleInput { + readonly agentId: string; + readonly cron: string; + readonly payload?: Record; + readonly timezone?: string; + readonly status?: 'enabled' | 'disabled'; +} + +// ── DB row shapes ───────────────────────────────────────────────────────── + +interface SubAgentDbRow { + id: string; + parent_agent_id: string; + name: string; + skill_id: string | null; + model: string | null; + max_tokens: number | null; + max_iterations: number | null; + system_prompt_override: string | null; + status: 'enabled' | 'disabled'; + position: CanvasPos | null; + created_at: Date; + updated_at: Date; +} + +interface SkillDbRow { + id: string; + slug: string; + name: string; + description: string | null; + body: string; + frontmatter: Record; + source: 'db' | 'file'; + source_path: string | null; + created_at: Date; + updated_at: Date; +} + +interface McpServerDbRow { + id: string; + name: string; + transport: 'stdio' | 'http' | 'sse'; + endpoint: string | null; + headers: Record; + secret_ref: string | null; + status: 'enabled' | 'disabled'; + last_discovered_at: Date | null; + discovered_tools: unknown[]; + created_at: Date; + updated_at: Date; +} + +interface ToolGrantDbRow { + id: string; + agent_id: string | null; + subagent_id: string | null; + tool_kind: 'native' | 'mcp'; + tool_ref: string; + mcp_server_id: string | null; + config: Record; + created_at: Date; +} + +interface ScheduleDbRow { + id: string; + agent_id: string; + cron: string; + payload: Record; + timezone: string; + status: 'enabled' | 'disabled'; + last_run_at: Date | null; + created_at: Date; +} + +// ── mappers ───────────────────────────────────────────────────────────────── + +function mapSubAgent(r: SubAgentDbRow): SubAgentRow { + return { + id: r.id, + parentAgentId: r.parent_agent_id, + name: r.name, + skillId: r.skill_id, + model: r.model, + maxTokens: r.max_tokens, + maxIterations: r.max_iterations, + systemPromptOverride: r.system_prompt_override, + status: r.status, + position: r.position, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function mapSkill(r: SkillDbRow): SkillRow { + return { + id: r.id, + slug: r.slug, + name: r.name, + description: r.description, + body: r.body, + frontmatter: r.frontmatter, + source: r.source, + sourcePath: r.source_path, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function mapMcpServer(r: McpServerDbRow): McpServerRow { + return { + id: r.id, + name: r.name, + transport: r.transport, + endpoint: r.endpoint, + headers: r.headers, + secretRef: r.secret_ref, + status: r.status, + lastDiscoveredAt: r.last_discovered_at, + discoveredTools: r.discovered_tools, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function mapToolGrant(r: ToolGrantDbRow): ToolGrantRow { + return { + id: r.id, + agentId: r.agent_id, + subAgentId: r.subagent_id, + toolKind: r.tool_kind, + toolRef: r.tool_ref, + mcpServerId: r.mcp_server_id, + config: r.config, + createdAt: r.created_at, + }; +} + +function mapSchedule(r: ScheduleDbRow): ScheduleRow { + return { + id: r.id, + agentId: r.agent_id, + cron: r.cron, + payload: r.payload, + timezone: r.timezone, + status: r.status, + lastRunAt: r.last_run_at, + createdAt: r.created_at, + }; +} + +function isUniqueViolation(err: unknown, constraint?: string): boolean { + if (!err || typeof err !== 'object') return false; + if ((err as { code?: string }).code !== '23505') return false; + if (!constraint) return true; + return (err as { constraint?: string }).constraint === constraint; +} + +export class AgentGraphStore { + constructor(private readonly pool: Pool) {} + + // ── sub-agents ─────────────────────────────────────────────────────────── + async listAllSubAgents(): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM agent_subagents ORDER BY parent_agent_id, name', + ); + return rows.map(mapSubAgent); + } + + async createSubAgent(input: SubAgentInput): Promise { + try { + const { rows } = await this.pool.query( + `INSERT INTO agent_subagents + (parent_agent_id, name, skill_id, model, max_tokens, max_iterations, + system_prompt_override, status, position) + VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8,'enabled'),$9::jsonb) + RETURNING *`, + [ + input.parentAgentId, + input.name, + input.skillId ?? null, + input.model ?? null, + input.maxTokens ?? null, + input.maxIterations ?? null, + input.systemPromptOverride ?? null, + input.status ?? null, + input.position ? JSON.stringify(input.position) : null, + ], + ); + return mapSubAgent(rows[0]!); + } catch (err) { + if (isUniqueViolation(err, 'agent_subagents_parent_agent_id_name_key')) { + throw new ConfigValidationError( + `sub-agent "${input.name}" already exists for this agent`, + ); + } + throw err; + } + } + + async updateSubAgent(id: string, patch: SubAgentPatch): Promise { + const { rows } = await this.pool.query( + `UPDATE agent_subagents SET + name = COALESCE($2, name), + skill_id = COALESCE($3, skill_id), + model = COALESCE($4, model), + max_tokens = COALESCE($5, max_tokens), + max_iterations = COALESCE($6, max_iterations), + system_prompt_override = COALESCE($7, system_prompt_override), + status = COALESCE($8, status), + position = COALESCE($9::jsonb, position), + updated_at = now() + WHERE id = $1 + RETURNING *`, + [ + id, + patch.name ?? null, + patch.skillId ?? null, + patch.model ?? null, + patch.maxTokens ?? null, + patch.maxIterations ?? null, + patch.systemPromptOverride ?? null, + patch.status ?? null, + patch.position ? JSON.stringify(patch.position) : null, + ], + ); + const row = rows[0]; + if (!row) throw new ConfigValidationError(`sub-agent ${id} not found`); + return mapSubAgent(row); + } + + /** Set or clear (null) a sub-agent's skill. Direct write so the canvas can + * detach a skill edge. */ + async setSubAgentSkill( + id: string, + skillId: string | null, + ): Promise { + const { rows } = await this.pool.query( + `UPDATE agent_subagents SET skill_id = $2, updated_at = now() + WHERE id = $1 RETURNING *`, + [id, skillId], + ); + const row = rows[0]; + if (!row) throw new ConfigValidationError(`sub-agent ${id} not found`); + return mapSubAgent(row); + } + + async deleteSubAgent(id: string): Promise { + await this.pool.query('DELETE FROM agent_subagents WHERE id = $1', [id]); + } + + async listSchedulesForAgent(agentId: string): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM agent_schedules WHERE agent_id = $1 ORDER BY created_at', + [agentId], + ); + return rows.map(mapSchedule); + } + + // ── skills ───────────────────────────────────────────────────────────────── + async listSkills(): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM skills ORDER BY slug', + ); + return rows.map(mapSkill); + } + + async upsertSkill(input: SkillInput): Promise { + const { rows } = await this.pool.query( + `INSERT INTO skills (slug, name, description, body, frontmatter, source, source_path) + VALUES ($1,$2,$3,COALESCE($4,''),COALESCE($5::jsonb,'{}'::jsonb),COALESCE($6,'db'),$7) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + body = EXCLUDED.body, + frontmatter = EXCLUDED.frontmatter, + updated_at = now() + RETURNING *`, + [ + input.slug, + input.name, + input.description ?? null, + input.body ?? null, + input.frontmatter ? JSON.stringify(input.frontmatter) : null, + input.source ?? null, + input.sourcePath ?? null, + ], + ); + return mapSkill(rows[0]!); + } + + async updateSkill(id: string, patch: SkillPatch): Promise { + const { rows } = await this.pool.query( + `UPDATE skills SET + name = COALESCE($2, name), + description = COALESCE($3, description), + body = COALESCE($4, body), + frontmatter = COALESCE($5::jsonb, frontmatter), + updated_at = now() + WHERE id = $1 + RETURNING *`, + [ + id, + patch.name ?? null, + patch.description ?? null, + patch.body ?? null, + patch.frontmatter ? JSON.stringify(patch.frontmatter) : null, + ], + ); + const row = rows[0]; + if (!row) throw new ConfigValidationError(`skill ${id} not found`); + return mapSkill(row); + } + + async deleteSkill(id: string): Promise { + await this.pool.query('DELETE FROM skills WHERE id = $1', [id]); + } + + // ── mcp_servers ───────────────────────────────────────────────────────────── + async listMcpServers(): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM mcp_servers ORDER BY name', + ); + return rows.map(mapMcpServer); + } + + async createMcpServer(input: McpServerInput): Promise { + try { + const { rows } = await this.pool.query( + `INSERT INTO mcp_servers (name, transport, endpoint, headers, secret_ref, status) + VALUES ($1,$2,$3,COALESCE($4::jsonb,'{}'::jsonb),$5,COALESCE($6,'enabled')) + RETURNING *`, + [ + input.name, + input.transport, + input.endpoint ?? null, + input.headers ? JSON.stringify(input.headers) : null, + input.secretRef ?? null, + input.status ?? null, + ], + ); + return mapMcpServer(rows[0]!); + } catch (err) { + if (isUniqueViolation(err, 'mcp_servers_name_key')) { + throw new ConfigValidationError( + `MCP server "${input.name}" already exists`, + ); + } + throw err; + } + } + + async setMcpDiscoveredTools( + id: string, + tools: readonly unknown[], + ): Promise { + await this.pool.query( + `UPDATE mcp_servers + SET discovered_tools = $2::jsonb, last_discovered_at = now(), updated_at = now() + WHERE id = $1`, + [id, JSON.stringify(tools)], + ); + } + + async deleteMcpServer(id: string): Promise { + await this.pool.query('DELETE FROM mcp_servers WHERE id = $1', [id]); + } + + // ── tool grants ─────────────────────────────────────────────────────────── + async listAllToolGrants(): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM agent_tool_grants ORDER BY created_at', + ); + return rows.map(mapToolGrant); + } + + async createToolGrant(input: ToolGrantInput): Promise { + if (!input.agentId && !input.subAgentId) { + throw new ConfigValidationError( + 'tool grant must target an agent or a sub-agent', + ); + } + const { rows } = await this.pool.query( + `INSERT INTO agent_tool_grants + (agent_id, subagent_id, tool_kind, tool_ref, mcp_server_id, config) + VALUES ($1,$2,$3,$4,$5,COALESCE($6::jsonb,'{}'::jsonb)) + RETURNING *`, + [ + input.agentId ?? null, + input.subAgentId ?? null, + input.toolKind, + input.toolRef, + input.mcpServerId ?? null, + input.config ? JSON.stringify(input.config) : null, + ], + ); + return mapToolGrant(rows[0]!); + } + + async deleteToolGrant(id: string): Promise { + await this.pool.query('DELETE FROM agent_tool_grants WHERE id = $1', [id]); + } + + // ── schedules ───────────────────────────────────────────────────────────── + async listAllSchedules(): Promise { + const { rows } = await this.pool.query( + 'SELECT * FROM agent_schedules ORDER BY agent_id, created_at', + ); + return rows.map(mapSchedule); + } + + async createSchedule(input: ScheduleInput): Promise { + const { rows } = await this.pool.query( + `INSERT INTO agent_schedules (agent_id, cron, payload, timezone, status) + VALUES ($1,$2,COALESCE($3::jsonb,'{}'::jsonb),COALESCE($4,'UTC'),COALESCE($5,'enabled')) + RETURNING *`, + [ + input.agentId, + input.cron, + input.payload ? JSON.stringify(input.payload) : null, + input.timezone ?? null, + input.status ?? null, + ], + ); + return mapSchedule(rows[0]!); + } + + async deleteSchedule(id: string): Promise { + await this.pool.query('DELETE FROM agent_schedules WHERE id = $1', [id]); + } + + /** Stamp a schedule's last fire time (scheduler worker). */ + async markScheduleRun(id: string): Promise { + await this.pool.query( + 'UPDATE agent_schedules SET last_run_at = now() WHERE id = $1', + [id], + ); + } +} diff --git a/middleware/packages/harness-orchestrator/src/registry/agentRuntime.ts b/middleware/packages/harness-orchestrator/src/registry/agentRuntime.ts new file mode 100644 index 00000000..bfdbfc37 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/registry/agentRuntime.ts @@ -0,0 +1,59 @@ +import type { ModelRoutingConfig as RuntimeModelRouting } from '../modelRouter.js'; + +/** + * Map an agent's persisted `model_routing` JSON (the Agent Builder / plugin-api + * shape `{ mode, main, triage?, simple?, escalateOn? }`) onto the orchestrator + * runtime knobs: + * + * - a `model` override (the agent's chosen primary model), and + * - an optional `modelRouting` ({classifierModel, simpleModel, complexModel}) + * when the operator picked per-turn `triage` routing. + * + * Pure + defensive: unknown / malformed JSON yields `{}` (the registry falls + * back to the platform default runtime config). Lives in the orchestrator + * package so the persisted-shape→runtime-shape bridge has one home and is + * unit-testable without a DB. + */ + +const DEFAULT_CLASSIFIER_MODEL = 'claude-haiku-4-5'; + +export interface ResolvedAgentRuntime { + /** Primary model override (the agent's `main`), if set. */ + readonly model?: string; + /** Per-turn routing config, only when mode is 'triage' with a usable `main`. */ + readonly modelRouting?: RuntimeModelRouting; +} + +export function resolveAgentModelRouting( + raw: Record | null | undefined, +): ResolvedAgentRuntime { + if (!raw || typeof raw !== 'object') return {}; + + const mode = raw['mode']; + const main = typeof raw['main'] === 'string' ? (raw['main'] as string) : undefined; + if (!main) return {}; + + if (mode === 'single') { + return { model: main }; + } + + if (mode === 'triage') { + const triage = + typeof raw['triage'] === 'string' + ? (raw['triage'] as string) + : DEFAULT_CLASSIFIER_MODEL; + const simple = + typeof raw['simple'] === 'string' ? (raw['simple'] as string) : main; + return { + model: main, + modelRouting: { + classifierModel: triage, + simpleModel: simple, + complexModel: main, + }, + }; + } + + // Unknown mode — still honour the chosen primary model. + return { model: main }; +} diff --git a/middleware/packages/harness-orchestrator/src/registry/applyDiff.ts b/middleware/packages/harness-orchestrator/src/registry/applyDiff.ts index a034381c..5779ac67 100644 --- a/middleware/packages/harness-orchestrator/src/registry/applyDiff.ts +++ b/middleware/packages/harness-orchestrator/src/registry/applyDiff.ts @@ -5,6 +5,12 @@ import { type OrchestratorDeps, } from '../buildOrchestrator.js'; +import type { + SkillRow, + SubAgentRow, + ToolGrantRow, +} from './agentGraphStore.js'; +import { resolveAgentModelRouting } from './agentRuntime.js'; import type { AgentPluginRow, AgentRow, @@ -99,7 +105,10 @@ export function diffSnapshots( if (!isEnabled) continue; // Both old and new are enabled. Decide rebuild vs metadata-only update. - const reasons = runtimeChangeReasons(oldAgent!, newAgent); + const reasons = [ + ...runtimeChangeReasons(oldAgent!, newAgent), + ...graphChangeReasons(oldAgent!.id, newAgent.id, oldSnap, newSnap), + ]; if (reasons.length > 0) { actions.push({ kind: 'rebuild', @@ -147,12 +156,17 @@ export function buildForAgent( deps: OrchestratorDeps, runtime: Omit, ): BuiltOrchestrator { + // Agent Builder P5 — overlay the agent's persisted model_routing onto the + // platform default: `main` overrides the model, `triage` mode adds per-turn + // Haiku→Sonnet/Opus routing. Falls back to the platform runtime when unset. + const routing = resolveAgentModelRouting(agent.modelRouting); return buildOrchestratorForAgent( { agentId: agent.slug, - model: runtime.model, + model: routing.model ?? runtime.model, maxTokens: runtime.maxTokens, maxToolIterations: runtime.maxToolIterations, + ...(routing.modelRouting ? { modelRouting: routing.modelRouting } : {}), ...(runtime.loopRepeatSoft !== undefined ? { loopRepeatSoft: runtime.loopRepeatSoft } : {}), @@ -174,11 +188,90 @@ function runtimeChangeReasons(oldAgent: AgentRow, newAgent: AgentRow): string[] `privacy_profile:${oldAgent.privacyProfile}->${newAgent.privacyProfile}`, ); } + // Per-agent model routing (Agent Builder P5) changes which model the turn + // loop selects — a runtime-relevant change warranting a rebuild. + if ( + JSON.stringify(oldAgent.modelRouting ?? null) !== + JSON.stringify(newAgent.modelRouting ?? null) + ) { + reasons.push('model_routing'); + } // `name` / `description` are display-only and never warrant a rebuild — // they would invalidate sessions for no semantic gain. return reasons; } +/** + * Agent Builder graph (P0): rebuild when an agent's sub-agents, tool grants, + * or any skill referenced by those sub-agents changed. These define what the + * orchestrator (and its LocalSubAgents) can do, so a change is runtime- + * relevant. Schedules are intentionally excluded — they are consumed by the + * cron worker, not baked into the orchestrator build. + */ +function graphChangeReasons( + oldAgentId: string, + newAgentId: string, + oldSnap: ConfigSnapshot | undefined, + newSnap: ConfigSnapshot, +): string[] { + const oldSig = graphSignature(oldAgentId, oldSnap); + const newSig = graphSignature(newAgentId, newSnap); + return oldSig === newSig ? [] : ['graph']; +} + +/** + * Deterministic fingerprint of an agent's graph wiring within one snapshot: + * its sub-agents, the tool grants targeting the agent or those sub-agents, + * and the bodies of any skills those sub-agents reference. Order-independent + * (everything is sorted) so it captures semantic, not row-order, change. + */ +function graphSignature( + agentId: string, + snap: ConfigSnapshot | undefined, +): string { + const subAgents: readonly SubAgentRow[] = (snap?.subAgents ?? []).filter( + (s) => s.parentAgentId === agentId, + ); + const subAgentIds = new Set(subAgents.map((s) => s.id)); + const skillIds = new Set( + subAgents.map((s) => s.skillId).filter((id): id is string => !!id), + ); + + const grants: readonly ToolGrantRow[] = (snap?.toolGrants ?? []).filter( + (g) => + (g.agentId !== null && g.agentId === agentId) || + (g.subAgentId !== null && subAgentIds.has(g.subAgentId)), + ); + + const skills: readonly SkillRow[] = (snap?.skills ?? []).filter((sk) => + skillIds.has(sk.id), + ); + + const subPart = subAgents + .map( + (s) => + `${s.id}|${s.name}|${s.skillId ?? ''}|${s.model ?? ''}|${ + s.maxTokens ?? '' + }|${s.maxIterations ?? ''}|${s.systemPromptOverride ?? ''}|${s.status}`, + ) + .sort(); + const grantPart = grants + .map( + (g) => + `${g.agentId ?? ''}|${g.subAgentId ?? ''}|${g.toolKind}|${g.toolRef}|${ + g.mcpServerId ?? '' + }|${JSON.stringify(g.config)}`, + ) + .sort(); + const skillPart = skills + .map( + (sk) => `${sk.id}|${sk.name}|${sk.body}|${JSON.stringify(sk.frontmatter)}`, + ) + .sort(); + + return JSON.stringify({ subPart, grantPart, skillPart }); +} + function equalPlugins( a: readonly AgentPluginRow[], b: readonly AgentPluginRow[], diff --git a/middleware/packages/harness-orchestrator/src/registry/configStore.ts b/middleware/packages/harness-orchestrator/src/registry/configStore.ts index 069ebf51..cb8063b7 100644 --- a/middleware/packages/harness-orchestrator/src/registry/configStore.ts +++ b/middleware/packages/harness-orchestrator/src/registry/configStore.ts @@ -1,5 +1,14 @@ import type { Pool } from 'pg'; +import { + AgentGraphStore, + type ScheduleRow, + type SkillRow, + type SubAgentRow, + type McpServerRow, + type ToolGrantRow, +} from './agentGraphStore.js'; + /** * Multi-orchestrator config store (US4 / T014). * @@ -17,6 +26,11 @@ import type { Pool } from 'pg'; export type PrivacyProfile = 'strict' | 'default'; export type AgentStatus = 'enabled' | 'disabled'; +export interface CanvasPosition { + readonly x: number; + readonly y: number; +} + export interface AgentRow { readonly id: string; readonly slug: string; @@ -24,6 +38,12 @@ export interface AgentRow { readonly description: string | null; readonly privacyProfile: PrivacyProfile; readonly status: AgentStatus; + /** Per-agent model routing (Agent Builder P0). Raw JSONB; shaped to + * `ModelRoutingConfig` at the API boundary. `null`/absent = inherit + * platform default. Optional so pre-existing AgentRow fixtures stay valid. */ + readonly modelRouting?: Record | null; + /** Cosmetic canvas coordinate; `null`/absent until first laid out. */ + readonly canvasPosition?: CanvasPosition | null; readonly createdAt: Date; readonly updatedAt: Date; } @@ -61,6 +81,8 @@ export interface AgentPatch { readonly description?: string | null; readonly privacyProfile?: PrivacyProfile; readonly status?: AgentStatus; + readonly modelRouting?: Record | null; + readonly canvasPosition?: CanvasPosition | null; } export interface AgentPluginInput { @@ -95,6 +117,8 @@ interface AgentDbRow { description: string | null; privacy_profile: PrivacyProfile; status: AgentStatus; + model_routing: Record | null; + canvas_position: CanvasPosition | null; created_at: Date; updated_at: Date; } @@ -127,6 +151,8 @@ function mapAgent(row: AgentDbRow): AgentRow { description: row.description, privacyProfile: row.privacy_profile, status: row.status, + modelRouting: row.model_routing ?? null, + canvasPosition: row.canvas_position ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -230,6 +256,8 @@ export class ConfigStore { description = COALESCE($3, description), privacy_profile = COALESCE($4, privacy_profile), status = COALESCE($5, status), + model_routing = COALESCE($6::jsonb, model_routing), + canvas_position = COALESCE($7::jsonb, canvas_position), updated_at = now() WHERE id = $1 RETURNING *`, @@ -239,6 +267,8 @@ export class ConfigStore { patch.description ?? null, patch.privacyProfile ?? null, patch.status ?? null, + patch.modelRouting ? JSON.stringify(patch.modelRouting) : null, + patch.canvasPosition ? JSON.stringify(patch.canvasPosition) : null, ], ); const row = rows[0]; @@ -252,6 +282,46 @@ export class ConfigStore { await this.pool.query('DELETE FROM agents WHERE id = $1', [id]); } + /** Agent Builder — set (or clear, with null) the per-agent model routing. + * Direct write (not COALESCE) so the operator can disable routing. */ + async setModelRouting( + id: string, + routing: Record | null, + ): Promise { + const { rows } = await this.pool.query( + `UPDATE agents SET model_routing = $2::jsonb, updated_at = now() + WHERE id = $1 RETURNING *`, + [id, routing ? JSON.stringify(routing) : null], + ); + const row = rows[0]; + if (!row) throw new ConfigValidationError(`agent ${id} not found`); + return mapAgent(row); + } + + /** Agent Builder — persist an agent's cosmetic canvas coordinate. */ + async setCanvasPosition( + id: string, + pos: CanvasPosition | null, + ): Promise { + await this.pool.query( + `UPDATE agents SET canvas_position = $2::jsonb WHERE id = $1`, + [id, pos ? JSON.stringify(pos) : null], + ); + } + + /** Agent Builder — persist a channel binding's cosmetic canvas coordinate. */ + async setChannelBindingPosition( + channelType: string, + channelKey: string, + pos: CanvasPosition | null, + ): Promise { + await this.pool.query( + `UPDATE channel_bindings SET canvas_position = $3::jsonb + WHERE channel_type = $1 AND channel_key = $2`, + [channelType, channelKey, pos ? JSON.stringify(pos) : null], + ); + } + // ── agent_plugins ───────────────────────────────────────────────────── async listAgentPlugins( agentId: string, @@ -412,13 +482,39 @@ export class ConfigStore { * mildly-stale snapshot. The US5 reload bus catches up on the next NOTIFY. */ async loadSnapshot(): Promise { - const [agents, plugins, bindings, settings] = await Promise.all([ + const graph = new AgentGraphStore(this.pool); + const [ + agents, + plugins, + bindings, + settings, + subAgents, + toolGrants, + schedules, + skills, + mcpServers, + ] = await Promise.all([ this.listAgents(), this.listAllAgentPlugins(), this.listChannelBindings(), this.getPlatformSettings(), + graph.listAllSubAgents(), + graph.listAllToolGrants(), + graph.listAllSchedules(), + graph.listSkills(), + graph.listMcpServers(), ]); - return { agents, agentPlugins: plugins, channelBindings: bindings, platformSettings: settings }; + return { + agents, + agentPlugins: plugins, + channelBindings: bindings, + platformSettings: settings, + subAgents, + toolGrants, + schedules, + skills, + mcpServers, + }; } } @@ -427,6 +523,13 @@ export interface ConfigSnapshot { readonly agentPlugins: readonly AgentPluginRow[]; readonly channelBindings: readonly ChannelBindingRow[]; readonly platformSettings: PlatformSettingsRow; + // Agent Builder graph (P0). Optional so pre-existing snapshot literals + // (tests, fixtures) stay valid; `loadSnapshot` always populates them. + readonly subAgents?: readonly SubAgentRow[]; + readonly toolGrants?: readonly ToolGrantRow[]; + readonly schedules?: readonly ScheduleRow[]; + readonly skills?: readonly SkillRow[]; + readonly mcpServers?: readonly McpServerRow[]; } function isUniqueViolation(err: unknown, constraint?: string): boolean { diff --git a/middleware/packages/harness-orchestrator/src/registry/index.ts b/middleware/packages/harness-orchestrator/src/registry/index.ts index ad6f52f0..c55e5cda 100644 --- a/middleware/packages/harness-orchestrator/src/registry/index.ts +++ b/middleware/packages/harness-orchestrator/src/registry/index.ts @@ -6,6 +6,11 @@ import type { import type { ChatSessionStore, SessionConfigSnapshot } from '../chatSessionStore.js'; +import type { + SkillRow, + SubAgentRow, + ToolGrantRow, +} from './agentGraphStore.js'; import { buildForAgent, diffSnapshots, @@ -136,6 +141,16 @@ export interface ActiveAgent { readonly plugins: readonly AgentPluginRow[]; readonly bindings: readonly ChannelBindingRow[]; readonly built: BuiltOrchestrator; + /** + * Agent Builder graph (P0/P2): the agent's DB-defined sub-agents, the tool + * grants targeting the agent or those sub-agents, and the skills those + * sub-agents reference. Populated from the snapshot on add/rebuild/update so + * the kernel's `onAgentBuilt` callback can materialise sub-agent domain + * tools without re-querying the store. + */ + readonly subAgents: readonly SubAgentRow[]; + readonly toolGrants: readonly ToolGrantRow[]; + readonly skills: readonly SkillRow[]; /** * Strict per-orchestrator memory scope: `['core', 'orchestrator::*']` * (see {@link computeMemoryScope}). The Agent may touch only its own @@ -261,10 +276,11 @@ export class OrchestratorRegistry { private applyDiffActions(plan: DiffPlan, snap: ConfigSnapshot): void { const pluginsByAgent = groupBy(snap.agentPlugins, (p) => p.agentId); const bindingsByAgent = groupBy(snap.channelBindings, (b) => b.agentId); + const graph = indexGraph(snap); for (const action of plan.actions) { try { - this.runAction(action, pluginsByAgent, bindingsByAgent); + this.runAction(action, pluginsByAgent, bindingsByAgent, graph); } catch (err) { // T022 — isolate per-action failures so a throw on one Agent never // aborts the rest of the diff. @@ -289,6 +305,7 @@ export class OrchestratorRegistry { action: DiffAction, pluginsByAgent: Map, bindingsByAgent: Map, + graph: GraphIndex, ): void { switch (action.kind) { case 'add': { @@ -305,6 +322,9 @@ export class OrchestratorRegistry { plugins, bindings, built, + subAgents: graph.subAgentsByAgent.get(action.agent.id) ?? [], + toolGrants: graph.grantsByAgent.get(action.agent.id) ?? [], + skills: graph.skillsByAgent.get(action.agent.id) ?? [], memoryScope, }); this.notifyBuilt(action.agent.slug, built, 'add'); @@ -345,6 +365,9 @@ export class OrchestratorRegistry { plugins, bindings, built, + subAgents: graph.subAgentsByAgent.get(action.agent.id) ?? [], + toolGrants: graph.grantsByAgent.get(action.agent.id) ?? [], + skills: graph.skillsByAgent.get(action.agent.id) ?? [], memoryScope, }); this.notifyBuilt(action.agent.slug, built, 'rebuild'); @@ -367,6 +390,9 @@ export class OrchestratorRegistry { agent: action.agent, plugins, bindings, + subAgents: graph.subAgentsByAgent.get(action.agent.id) ?? [], + toolGrants: graph.grantsByAgent.get(action.agent.id) ?? [], + skills: graph.skillsByAgent.get(action.agent.id) ?? [], memoryScope, }); this.log(`registry: agent metadata updated`, { @@ -652,6 +678,54 @@ export function computeMemoryScope(agentSlug: string): readonly string[] { return orchestratorMemoryScope(agentSlug); } +/** + * Per-agent index of the Agent Builder graph collections in a snapshot, so a + * diff action can attach an agent's sub-agents / tool grants / referenced + * skills in O(1). A grant is attributed to an agent when it targets the agent + * directly (`agentId`) or one of the agent's sub-agents (`subAgentId`). + */ +interface GraphIndex { + readonly subAgentsByAgent: Map; + readonly grantsByAgent: Map; + readonly skillsByAgent: Map; +} + +function indexGraph(snap: ConfigSnapshot): GraphIndex { + const subAgents = snap.subAgents ?? []; + const grants = snap.toolGrants ?? []; + const skills = snap.skills ?? []; + + const subAgentsByAgent = groupBy(subAgents, (s) => s.parentAgentId); + const subParent = new Map( + subAgents.map((s) => [s.id, s.parentAgentId]), + ); + const skillsById = new Map(skills.map((s) => [s.id, s])); + + const grantsByAgent = new Map(); + for (const g of grants) { + const owner = g.agentId ?? (g.subAgentId ? subParent.get(g.subAgentId) : undefined); + if (!owner) continue; + const list = grantsByAgent.get(owner); + if (list) list.push(g); + else grantsByAgent.set(owner, [g]); + } + + const skillsByAgent = new Map(); + for (const [agentId, subs] of subAgentsByAgent) { + const ids = new Set( + subs.map((s) => s.skillId).filter((id): id is string => !!id), + ); + const refSkills: SkillRow[] = []; + for (const id of ids) { + const sk = skillsById.get(id); + if (sk) refSkills.push(sk); + } + skillsByAgent.set(agentId, refSkills); + } + + return { subAgentsByAgent, grantsByAgent, skillsByAgent }; +} + function groupBy( items: readonly T[], keyFn: (item: T) => K, diff --git a/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts b/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts new file mode 100644 index 00000000..3160bcd8 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/registry/subAgentTools.ts @@ -0,0 +1,158 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import type { LocalSubAgentTool } from '@omadia/plugin-api'; + +import { LocalSubAgent } from '../localSubAgent.js'; +import type { + McpManager} from '../mcp/mcpClient.js'; +import { + mcpToolToLocalSubAgentTool, + type McpServerConfig, +} from '../mcp/mcpClient.js'; +import { createDomainTool, type DomainTool } from '../tools/domainQueryTool.js'; + +import type { + SkillRow, + SubAgentRow, + ToolGrantRow, +} from './agentGraphStore.js'; + +/** + * Agent Builder P2/P4 — materialise an agent's DB-defined sub-agents into + * orchestrator `DomainTool`s. + * + * Each enabled `agent_subagents` row becomes a `LocalSubAgent` whose system + * prompt is its skill body (or an inline override), running on its own model + * with the tools granted to it (native tools resolved via the orchestrator's + * registry, MCP tools via the `McpManager`). The sub-agent is wrapped in a + * `DomainTool` (`ask_`) so the parent orchestrator can delegate to it by + * tool name — the same mechanism plugin-provided domain agents use. + * + * Pure-ish factory: the only side effect is constructing `LocalSubAgent` / + * MCP-tool closures. Unit-testable by passing a fake client + resolvers. + */ + +export interface SubAgentGraph { + readonly subAgents: readonly SubAgentRow[]; + readonly toolGrants: readonly ToolGrantRow[]; + readonly skills: readonly SkillRow[]; +} + +export interface SubAgentToolDeps { + readonly client: Anthropic; + readonly defaultModel: string; + readonly defaultMaxTokens: number; + readonly defaultMaxIterations: number; + /** Resolves an MCP server id → connection config (for mcp tool grants). */ + readonly mcpServersById?: ReadonlyMap; + /** Shared MCP connection pool. Required to honour mcp tool grants. */ + readonly mcpManager?: McpManager; + /** Resolves a native tool name → a sub-agent-callable tool, if available. */ + readonly nativeTool?: (toolRef: string) => LocalSubAgentTool | undefined; + readonly log?: (msg: string) => void; +} + +export function buildSubAgentDomainTools( + graph: SubAgentGraph, + deps: SubAgentToolDeps, +): DomainTool[] { + const skillsById = new Map(graph.skills.map((s) => [s.id, s])); + const grantsBySubAgent = new Map(); + for (const g of graph.toolGrants) { + if (!g.subAgentId) continue; + const list = grantsBySubAgent.get(g.subAgentId); + if (list) list.push(g); + else grantsBySubAgent.set(g.subAgentId, [g]); + } + + const tools: DomainTool[] = []; + for (const sub of graph.subAgents) { + if (sub.status !== 'enabled') continue; + + const skillBody = sub.skillId + ? skillsById.get(sub.skillId)?.body + : undefined; + const systemPrompt = + sub.systemPromptOverride?.trim() || + skillBody?.trim() || + `You are the "${sub.name}" sub-agent. Answer the delegated question precisely using your available tools.`; + + const subTools = resolveSubAgentTools( + grantsBySubAgent.get(sub.id) ?? [], + deps, + ); + + const local = new LocalSubAgent({ + name: sub.name, + client: deps.client, + model: sub.model ?? deps.defaultModel, + maxTokens: sub.maxTokens ?? deps.defaultMaxTokens, + maxIterations: sub.maxIterations ?? deps.defaultMaxIterations, + systemPrompt, + tools: subTools, + }); + + tools.push( + createDomainTool({ + name: subAgentToolName(sub.name), + description: `Delegate a focused question to the "${sub.name}" sub-agent.`, + agent: local, + domain: `subagent.${slugifyDomain(sub.name)}`, + }), + ); + } + return tools; +} + +function resolveSubAgentTools( + grants: readonly ToolGrantRow[], + deps: SubAgentToolDeps, +): LocalSubAgentTool[] { + const out: LocalSubAgentTool[] = []; + for (const g of grants) { + if (g.toolKind === 'native') { + const t = deps.nativeTool?.(g.toolRef); + if (t) out.push(t); + else deps.log?.(`sub-agent tool: native tool "${g.toolRef}" not resolvable — skipped`); + continue; + } + // mcp + if (!g.mcpServerId || !deps.mcpManager || !deps.mcpServersById) { + deps.log?.(`sub-agent tool: mcp grant "${g.toolRef}" missing manager/server — skipped`); + continue; + } + const cfg = deps.mcpServersById.get(g.mcpServerId); + if (!cfg) { + deps.log?.(`sub-agent tool: mcp server ${g.mcpServerId} not found — skipped`); + continue; + } + const toolName = mcpToolNameFromRef(g.toolRef, cfg.name); + out.push( + mcpToolToLocalSubAgentTool(deps.mcpManager, cfg, { name: toolName }), + ); + } + return out; +} + +/** `toolRef` for an mcp grant is ":"; fall back to the + * whole ref when it isn't prefixed. */ +export function mcpToolNameFromRef(toolRef: string, serverName: string): string { + const prefix = `${serverName}:`; + if (toolRef.startsWith(prefix)) return toolRef.slice(prefix.length); + const idx = toolRef.indexOf(':'); + return idx >= 0 ? toolRef.slice(idx + 1) : toolRef; +} + +/** `ask_` constrained to Anthropic's tool-name charset (`[a-zA-Z0-9_-]{1,64}`). */ +export function subAgentToolName(name: string): string { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + const full = `ask_${slug || 'subagent'}`; + return full.length <= 64 ? full : full.slice(0, 64); +} + +/** Lowercase dotted-domain segment (matches PLUGIN_DOMAIN_REGEX once prefixed). */ +function slugifyDomain(name: string): string { + let s = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + if (s.length === 0) s = 'agent'; + if (!/^[a-z]/.test(s)) s = `a${s}`; + return s; +} diff --git a/middleware/packages/plugin-api/src/agentGraph.ts b/middleware/packages/plugin-api/src/agentGraph.ts new file mode 100644 index 00000000..08c1f4d9 --- /dev/null +++ b/middleware/packages/plugin-api/src/agentGraph.ts @@ -0,0 +1,168 @@ +/** + * Agent Builder canvas — shared graph contract (P0). + * + * The visual builder is a thin renderer over the config graph: every node is + * a DB row, every edge is a relationship row. This module is the wire shape + * that the backend (`GET /api/v1/operator/agents/:slug/graph`) serialises and + * the web-ui canvas (xyflow) renders/mutates. Pure types — no runtime deps — + * so both sides import the same contract from the plugin-api surface. + * + * Edge semantics (what drawing a connection does): + * channel_bind Channel → Agent create channel_bindings row + * subagent Agent → Sub-Agent attach agent_subagents row + * skill Sub-Agent→ Skill set agent_subagents.skill_id + * tool_grant Agent|Sub→ Tool|MCP insert agent_tool_grants row + * schedule Schedule → Agent insert agent_schedules row + */ + +/** Cosmetic canvas coordinate persisted alongside the node's row. */ +export interface CanvasPosition { + readonly x: number; + readonly y: number; +} + +// ── model routing (persisted on agents.model_routing) ────────────────────── + +export type ModelRoutingMode = 'single' | 'triage'; + +/** A condition that escalates a triage-routed turn from the cheap to the main model. */ +export type EscalationTrigger = 'tool_error' | 'long_context' | 'low_confidence'; + +export interface ModelRoutingConfig { + readonly mode: ModelRoutingMode; + /** Primary / complex-turn model id, e.g. 'claude-opus-4-8'. */ + readonly main: string; + /** Cheap classifier model that decides simple-vs-complex per turn, e.g. + * 'claude-haiku-4-5'. Used when mode='triage'. */ + readonly triage?: string; + /** Model for simple turns under triage, e.g. 'claude-sonnet-4-6'. + * Defaults to `main` when omitted (degenerate triage = no savings). */ + readonly simple?: string; + /** Conditions under which a triage turn escalates to `main`. */ + readonly escalateOn?: readonly EscalationTrigger[]; +} + +// ── node DTOs ─────────────────────────────────────────────────────────────── + +export interface AgentNode { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly description: string | null; + readonly privacyProfile: 'strict' | 'default'; + readonly status: 'enabled' | 'disabled'; + readonly modelRouting: ModelRoutingConfig | null; + readonly position: CanvasPosition | null; +} + +export interface ChannelNode { + readonly channelType: string; + readonly channelKey: string; + readonly position: CanvasPosition | null; +} + +export interface SubAgentNode { + readonly id: string; + readonly parentAgentId: string; + readonly name: string; + readonly skillId: string | null; + readonly model: string | null; + readonly maxTokens: number | null; + readonly maxIterations: number | null; + readonly systemPromptOverride: string | null; + readonly status: 'enabled' | 'disabled'; + readonly position: CanvasPosition | null; +} + +export type SkillSource = 'db' | 'file'; + +export interface SkillNode { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly description: string | null; + readonly body: string; + readonly source: SkillSource; +} + +export type ToolKind = 'native' | 'mcp'; + +/** A tool granted to an agent or sub-agent (one grant = one canvas edge). */ +export interface ToolGrantNode { + readonly id: string; + /** Set when the grant belongs to the top-level agent. */ + readonly agentId: string | null; + /** Set when the grant belongs to a sub-agent. */ + readonly subAgentId: string | null; + readonly toolKind: ToolKind; + /** Native tool name, or ":" for MCP tools. */ + readonly toolRef: string; + readonly mcpServerId: string | null; +} + +export type McpTransport = 'stdio' | 'http' | 'sse'; + +export interface McpDiscoveredTool { + readonly name: string; + readonly description?: string; + readonly inputSchema?: Record; +} + +export interface McpServerNode { + readonly id: string; + readonly name: string; + readonly transport: McpTransport; + readonly endpoint: string | null; + readonly status: 'enabled' | 'disabled'; + readonly lastDiscoveredAt: string | null; + readonly discoveredTools: readonly McpDiscoveredTool[]; +} + +export interface ScheduleNode { + readonly id: string; + readonly agentId: string; + readonly cron: string; + readonly timezone: string; + readonly payload: Record; + readonly status: 'enabled' | 'disabled'; + readonly lastRunAt: string | null; +} + +// ── edges ───────────────────────────────────────────────────────────────── + +export type EdgeKind = + | 'channel_bind' + | 'subagent' + | 'skill' + | 'tool_grant' + | 'schedule'; + +export interface CanvasEdge { + /** Stable id (the underlying row id, or a composite key for channel binds). */ + readonly id: string; + readonly kind: EdgeKind; + /** Canvas node id of the edge source. */ + readonly source: string; + /** Canvas node id of the edge target. */ + readonly target: string; +} + +/** Full canvas payload for a single agent. */ +export interface AgentGraph { + readonly agent: AgentNode; + readonly channels: readonly ChannelNode[]; + readonly subAgents: readonly SubAgentNode[]; + readonly skills: readonly SkillNode[]; + readonly tools: readonly ToolGrantNode[]; + readonly mcpServers: readonly McpServerNode[]; + readonly schedules: readonly ScheduleNode[]; + readonly edges: readonly CanvasEdge[]; +} + +/** Body of POST /api/v1/operator/agents/:slug/graph/edges. */ +export interface CreateEdgeRequest { + readonly kind: EdgeKind; + readonly source: string; + readonly target: string; + readonly config?: Record; +} diff --git a/middleware/packages/plugin-api/src/index.ts b/middleware/packages/plugin-api/src/index.ts index 07cef6cc..96937104 100644 --- a/middleware/packages/plugin-api/src/index.ts +++ b/middleware/packages/plugin-api/src/index.ts @@ -13,6 +13,12 @@ export * from './targetRef.js'; // mutability derivation. Consumed by the canvas orchestrator (PR-9). export * from './writeCapabilities.js'; +// Agent Builder canvas (P0): the shared graph contract — AgentGraph, node +// DTOs, GraphEdge/EdgeKind, ModelRoutingConfig. The editable visual builder +// is a thin renderer over the config graph; backend serialises this shape +// from the registry tables and the web-ui xyflow canvas renders/mutates it. +export * from './agentGraph.js'; + // S+11-1: Knowledge-graph capability contract (interface + DTOs + node-id // helpers) lives on the plugin-api surface. Both the in-memory and the Neon // `knowledgeGraph@1` provider plugins (S+11-2 split of @omadia/knowledge-graph) diff --git a/middleware/src/agents/subAgentToolHydration.ts b/middleware/src/agents/subAgentToolHydration.ts new file mode 100644 index 00000000..8f5510b8 --- /dev/null +++ b/middleware/src/agents/subAgentToolHydration.ts @@ -0,0 +1,123 @@ +/** + * Agent Builder P2/P4 — hydrate an agent's orchestrator with the DomainTools + * built from its DB-defined sub-agents (`agent_subagents` + `agent_tool_grants` + * + `skills`). + * + * Bridges the registry's `ActiveAgent` graph slices into + * `buildSubAgentDomainTools` and registers the result on the built + * orchestrator — the same `registerDomainTool` seam the kernel uses for + * plugin-provided domain agents. Adapters here turn: + * - a `mcp_servers` row → an `McpServerConfig` (header coercion), and + * - a native registry entry → a `LocalSubAgentTool` (so a sub-agent can call + * a top-level native tool that was granted to it). + */ + +import type Anthropic from '@anthropic-ai/sdk'; +import type { LocalSubAgentTool } from '@omadia/plugin-api'; +import { + buildSubAgentDomainTools, + type McpManager, + type McpServerConfig, + type McpServerRow, + type NativeToolRegistry, + type SkillRow, + type SubAgentRow, + type ToolGrantRow, +} from '@omadia/orchestrator'; + +/** Minimal structural view of a BuiltOrchestrator's domain-tool surface. */ +interface DomainToolHost { + readonly orchestrator: { + hasDomainTool(name: string): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerDomainTool(tool: any): void; + }; +} + +export interface SubAgentGraphSlice { + readonly subAgents: readonly SubAgentRow[]; + readonly toolGrants: readonly ToolGrantRow[]; + readonly skills: readonly SkillRow[]; +} + +export interface HydrateDeps { + readonly client: Anthropic; + readonly nativeToolRegistry: NativeToolRegistry; + readonly mcpManager: McpManager; + readonly mcpServers: readonly McpServerRow[]; + readonly defaultModel: string; + readonly defaultMaxTokens?: number; + readonly defaultMaxIterations?: number; + readonly log?: (msg: string) => void; +} + +/** Coerce a `mcp_servers` row into the client config the manager consumes. */ +export function mcpRowToConfig(row: McpServerRow): McpServerConfig { + const headers: Record = {}; + for (const [k, v] of Object.entries(row.headers ?? {})) { + if (typeof v === 'string') headers[k] = v; + } + return { + id: row.id, + name: row.name, + transport: row.transport, + endpoint: row.endpoint, + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; +} + +/** Adapt a top-level native tool (handler + spec) into a sub-agent tool. */ +export function adaptNativeToolForSubAgent( + registry: NativeToolRegistry, + toolRef: string, +): LocalSubAgentTool | undefined { + const reg = registry.get(toolRef); + if (!reg?.handler || !reg.spec) return undefined; + const handler = reg.handler; + return { + spec: { + name: reg.spec.name, + description: reg.spec.description, + input_schema: { + type: 'object', + properties: reg.spec.input_schema.properties, + required: [...(reg.spec.input_schema.required ?? [])], + }, + }, + handle: (input: unknown) => handler(input), + }; +} + +/** + * Build + register the sub-agent DomainTools for one agent. Returns the number + * of tools newly registered (skips names already present so it is safe to call + * on both initial hydrate and post-rebuild). + */ +export function registerDbSubAgentTools( + slice: SubAgentGraphSlice, + built: DomainToolHost, + deps: HydrateDeps, +): number { + if (slice.subAgents.length === 0) return 0; + const mcpServersById = new Map( + deps.mcpServers.map((r) => [r.id, mcpRowToConfig(r)]), + ); + const tools = buildSubAgentDomainTools(slice, { + client: deps.client, + defaultModel: deps.defaultModel, + defaultMaxTokens: deps.defaultMaxTokens ?? 4096, + defaultMaxIterations: deps.defaultMaxIterations ?? 8, + mcpManager: deps.mcpManager, + mcpServersById, + nativeTool: (ref) => adaptNativeToolForSubAgent(deps.nativeToolRegistry, ref), + ...(deps.log ? { log: deps.log } : {}), + }); + let n = 0; + for (const t of tools) { + if (!built.orchestrator.hasDomainTool(t.name)) { + built.orchestrator.registerDomainTool(t); + n += 1; + } + } + return n; +} diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 476dc018..3eb5fbe5 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -11,6 +11,8 @@ import { createAdminRouter } from './routes/admin.js'; import { createChatRouter } from './routes/chat.js'; import { createOperatorAgentsRouter } from './routes/operatorAgents.js'; import { createOperatorChannelsRouter } from './routes/operatorChannels.js'; +import { createAgentBuilderRouter } from './routes/agentBuilder.js'; +import { ScheduleWorker } from './scheduler/scheduleWorker.js'; import type { ConfigStore as MultiOrchestratorConfigStore, OrchestratorRegistry as MultiOrchestratorRegistry, @@ -177,6 +179,9 @@ import { UiRouteCatalog } from './platform/uiRouteCatalog.js'; import { ServiceRegistry } from './platform/serviceRegistry.js'; import { TurnHookRegistry } from './platform/turnHookRegistry.js'; import { NativeToolRegistry } from '@omadia/orchestrator'; +import { McpManager } from '@omadia/orchestrator'; +import { AgentGraphStore } from '@omadia/orchestrator'; +import { registerDbSubAgentTools } from './agents/subAgentToolHydration.js'; import { DATA_DIR, DEV_VAULT_KEY_PATH, @@ -1253,6 +1258,37 @@ async function main(): Promise { const currentDomainTools = (): DomainTool[] => mergeDomainTools(domainTools, dynamicAgentRuntime.activeDomainTools()); + // Agent Builder P2/P4 — shared MCP connection pool + a closure that + // materialises each agent's DB-defined sub-agents into DomainTools and + // registers them on its orchestrator. Called on initial hydrate AND + // from `onAgentBuilt` so a rebuilt agent re-acquires its sub-agents. + const mcpManager = new McpManager(); + const SUBAGENT_DEFAULT_MODEL = 'claude-sonnet-4-6'; + const hydrateSubAgentTools = ( + slug: string, + built: { orchestrator: { hasDomainTool(n: string): boolean; registerDomainTool(t: DomainTool): void } }, + ): number => { + const entry = registryForHydrate.get(slug); + if (!entry) return 0; + const mcpServers = registryForHydrate.currentSnapshot()?.mcpServers ?? []; + return registerDbSubAgentTools( + { + subAgents: entry.subAgents, + toolGrants: entry.toolGrants, + skills: entry.skills, + }, + built, + { + client, + nativeToolRegistry, + mcpManager, + mcpServers, + defaultModel: SUBAGENT_DEFAULT_MODEL, + log: (m: string) => console.log(`[middleware] ${m}`), + }, + ); + }; + let attached = 0; for (const entry of registryForHydrate.list()) { for (const t of scopeDomainToolsToPlugins( @@ -1264,6 +1300,7 @@ async function main(): Promise { attached += 1; } } + attached += hydrateSubAgentTools(entry.agent.slug, entry.built); } console.log( `[middleware] registry orchestrators: hydrated with ${String(attached)} domain-tool registrations across ${String(registryForHydrate.list().length)} agent(s) (per-Agent plugin-scoped)`, @@ -1287,8 +1324,9 @@ async function main(): Promise { built.orchestrator.registerDomainTool(t); } } + const subTools = hydrateSubAgentTools(slug, built); console.log( - `[middleware] registry: orchestrator for "${slug}" hydrated with ${String(tools.length)} domain-tool(s) (per-Agent plugin-scoped)`, + `[middleware] registry: orchestrator for "${slug}" hydrated with ${String(tools.length)} domain-tool(s) + ${String(subTools)} sub-agent tool(s) (per-Agent plugin-scoped)`, ); }); @@ -1627,6 +1665,41 @@ async function main(): Promise { '[middleware] operator-channels endpoints ready at /api/v1/operator/channels/* (auth-gated)', ); + // Agent Builder canvas backend (P1/P2). Mounted at the /api/v1/operator + // parent so the /agents/:slug/graph|subagents|… subpaths fall through here + // after the operator-agents router. 503s without a graphPool (in-memory KG + // backend). Writes route through ConfigStore/AgentGraphStore → notify → + // registry.reload(), and we reload inline so the response reflects the diff. + app.use( + '/api/v1/operator', + requireAuth, + createAgentBuilderRouter({ + getConfigStore: () => + serviceRegistry.get('configStore'), + getGraphStore: () => + graphPool ? new AgentGraphStore(graphPool) : undefined, + getRegistry: () => + serviceRegistry.get('orchestratorRegistry'), + }), + ); + console.log( + `[middleware] agent-builder endpoints ready at /api/v1/operator/{agents/:slug/graph,skills,mcp-servers,…} (auth-gated, graphPool=${graphPool ? 'on' : 'off'})`, + ); + + // Agent Builder schedule worker (P6) — fires cron-scheduled agent turns. + // Only with a Neon graphPool (the agent_schedules table lives there). + if (graphPool) { + const schedulePool = graphPool; + const scheduleWorker = new ScheduleWorker({ + getGraphStore: () => new AgentGraphStore(schedulePool), + getRegistry: () => + serviceRegistry.get('orchestratorRegistry'), + log: (m, f) => console.log(`[middleware] ${m}`, f ?? ''), + }); + scheduleWorker.start(); + console.log('[middleware] agent-builder schedule worker started (1-min poll)'); + } + // Slice 10 — near-duplicate MK workflow. Mirrors the Slice 9 // mounting pattern: detector + bulk are optional, route 503s when // missing. `requireAuth` gates the router, consistent with the diff --git a/middleware/src/routes/agentBuilder.ts b/middleware/src/routes/agentBuilder.ts new file mode 100644 index 00000000..8ce7aa53 --- /dev/null +++ b/middleware/src/routes/agentBuilder.ts @@ -0,0 +1,669 @@ +/** + * Agent Builder canvas — REST surface (P1/P2). + * + * Backs the editable `/admin/builder` canvas. Mounted at `/api/v1/operator` + * (after the operator-agents router, so the `/agents/:slug/graph|subagents|…` + * subpaths fall through to here). Every write routes through `ConfigStore` / + * `AgentGraphStore`, whose triggers fire the `agents_changed` notify → the + * registry hot-reloads; we also call `registry.reload()` inline so the + * response already reflects the applied diff. + * + * Node-id scheme (must match web-ui `graphMapping.nodeId`): + * channel:: · agent: · subagent: · skill: · + * tool: · mcp: · schedule: + */ + +import { + ConfigValidationError, + type AgentGraphStore, + type AgentRow, + type ConfigStore, + type McpServerConfig, + type McpServerRow, + type OrchestratorRegistry, + type ScheduleRow, + type SkillRow, + type SubAgentRow, + type ToolGrantRow, +} from '@omadia/orchestrator'; +import { McpManager } from '@omadia/orchestrator'; +import { Router, type Request, type Response } from 'express'; + +export interface AgentBuilderRouterOptions { + readonly getConfigStore: () => ConfigStore | undefined; + readonly getGraphStore: () => AgentGraphStore | undefined; + readonly getRegistry: () => OrchestratorRegistry | undefined; +} + +interface Live { + readonly config: ConfigStore; + readonly graph: AgentGraphStore; + readonly registry: OrchestratorRegistry | undefined; +} + +export function createAgentBuilderRouter( + options: AgentBuilderRouterOptions, +): Router { + const router = Router(); + const mcp = new McpManager(); + + function live(res: Response): Live | undefined { + const config = options.getConfigStore(); + const graph = options.getGraphStore(); + if (!config || !graph) { + res.status(503).json({ error: 'multi_orchestrator_unavailable' }); + return undefined; + } + return { config, graph, registry: options.getRegistry() }; + } + + async function agentOr404( + l: Live, + slug: string, + res: Response, + ): Promise { + const agent = await l.config.getAgentBySlug(slug); + if (!agent) { + res.status(404).json({ error: 'agent_not_found', slug }); + return undefined; + } + return agent; + } + + // ── GET graph ────────────────────────────────────────────────────────── + router.get('/agents/:slug/graph', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const [bindings, subAgents, skills, grants, servers, schedules] = + await Promise.all([ + l.config.listChannelBindingsForAgent(agent.id), + l.graph.listAllSubAgents(), + l.graph.listSkills(), + l.graph.listAllToolGrants(), + l.graph.listMcpServers(), + l.graph.listSchedulesForAgent(agent.id), + ]); + res.json( + assembleGraph(agent, bindings, subAgents, skills, grants, servers, schedules), + ); + } catch (err) { + fail(res, err); + } + }); + + // ── edges ────────────────────────────────────────────────────────────── + router.post('/agents/:slug/graph/edges', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const edge = await createEdge(l, agent, req.body ?? {}); + const diff = await reload(l); + res.json({ edge, diff }); + } catch (err) { + fail(res, err); + } + }); + + router.delete( + '/agents/:slug/graph/edges/:id', + async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const kind = String(req.query['kind'] ?? ''); + await deleteEdge(l, decodeURIComponent(req.params.id ?? ''), kind); + await reload(l); + res.status(204).end(); + } catch (err) { + fail(res, err); + } + }, + ); + + // ── sub-agents ─────────────────────────────────────────────────────────── + router.post('/agents/:slug/subagents', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const b = req.body ?? {}; + const row = await l.graph.createSubAgent({ + parentAgentId: agent.id, + name: String(b.name ?? '').trim(), + skillId: b.skillId ?? null, + model: b.model ?? null, + maxTokens: b.maxTokens ?? null, + maxIterations: b.maxIterations ?? null, + systemPromptOverride: b.systemPromptOverride ?? null, + status: b.status ?? 'enabled', + position: b.position ?? null, + }); + await reload(l); + res.json(subAgentNode(row)); + } catch (err) { + fail(res, err); + } + }); + + router.patch( + '/agents/:slug/subagents/:id', + async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const row = await l.graph.updateSubAgent(req.params.id ?? '', req.body ?? {}); + await reload(l); + res.json(subAgentNode(row)); + } catch (err) { + fail(res, err); + } + }, + ); + + router.delete( + '/agents/:slug/subagents/:id', + async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + await l.graph.deleteSubAgent(req.params.id ?? ''); + await reload(l); + res.status(204).end(); + } catch (err) { + fail(res, err); + } + }, + ); + + // ── model routing + positions ──────────────────────────────────────────── + router.patch( + '/agents/:slug/model-routing', + async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const routing = (req.body ?? {}).modelRouting ?? null; + const updated = await l.config.setModelRouting(agent.id, routing); + await reload(l); + res.json(agentNode(updated)); + } catch (err) { + fail(res, err); + } + }, + ); + + router.patch('/agents/:slug/positions', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const b = req.body ?? {}; + if (b.agent) await l.config.setCanvasPosition(agent.id, b.agent); + for (const s of b.subAgents ?? []) { + await l.graph.updateSubAgent(s.id, { position: s.position }); + } + for (const c of b.channels ?? []) { + await l.config.setChannelBindingPosition(c.channelType, c.channelKey, c.position); + } + res.status(204).end(); // positions are cosmetic — no reload needed + } catch (err) { + fail(res, err); + } + }); + + // ── skills (global) ──────────────────────────────────────────────────────── + router.get('/skills', async (_req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const skills = (await l.graph.listSkills()).map(skillNode); + res.json({ skills }); + } catch (err) { + fail(res, err); + } + }); + + router.post('/skills', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const b = req.body ?? {}; + const row = await l.graph.upsertSkill({ + slug: String(b.slug ?? '').trim(), + name: String(b.name ?? '').trim(), + description: b.description ?? null, + body: b.body ?? '', + }); + res.json(skillNode(row)); + } catch (err) { + fail(res, err); + } + }); + + router.patch('/skills/:id', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const row = await l.graph.updateSkill(req.params.id ?? '', req.body ?? {}); + await reload(l); + res.json(skillNode(row)); + } catch (err) { + fail(res, err); + } + }); + + router.delete('/skills/:id', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + await l.graph.deleteSkill(req.params.id ?? ''); + await reload(l); + res.status(204).end(); + } catch (err) { + fail(res, err); + } + }); + + // ── mcp servers ─────────────────────────────────────────────────────────── + router.get('/mcp-servers', async (_req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + res.json({ servers: (await l.graph.listMcpServers()).map(mcpNode) }); + } catch (err) { + fail(res, err); + } + }); + + router.post('/mcp-servers', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const b = req.body ?? {}; + const row = await l.graph.createMcpServer({ + name: String(b.name ?? '').trim(), + transport: b.transport, + endpoint: b.endpoint ?? null, + status: b.status ?? 'enabled', + }); + res.json(mcpNode(row)); + } catch (err) { + fail(res, err); + } + }); + + router.delete('/mcp-servers/:id', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + await l.graph.deleteMcpServer(req.params.id ?? ''); + await reload(l); + res.status(204).end(); + } catch (err) { + fail(res, err); + } + }); + + router.post('/mcp-servers/:id/discover', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const servers = await l.graph.listMcpServers(); + const row = servers.find((s) => s.id === req.params.id); + if (!row) { + res.status(404).json({ error: 'mcp_server_not_found' }); + return; + } + const tools = await mcp.listTools(toMcpConfig(row)); + await l.graph.setMcpDiscoveredTools(row.id, tools); + const updated = (await l.graph.listMcpServers()).find((s) => s.id === row.id); + res.json(updated ? mcpNode(updated) : mcpNode(row)); + } catch (err) { + // Discovery talks to an external process — report as a 502, not a 5xx crash. + res.status(502).json({ error: 'mcp_discover_failed', message: msg(err) }); + } + }); + + // ── schedules ───────────────────────────────────────────────────────────── + router.get('/agents/:slug/schedules', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const schedules = (await l.graph.listSchedulesForAgent(agent.id)).map( + scheduleNode, + ); + res.json({ schedules }); + } catch (err) { + fail(res, err); + } + }); + + router.post('/agents/:slug/schedules', async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + const agent = await agentOr404(l, req.params.slug ?? '', res); + if (!agent) return; + const b = req.body ?? {}; + const row = await l.graph.createSchedule({ + agentId: agent.id, + cron: String(b.cron ?? '').trim(), + timezone: b.timezone ?? 'UTC', + payload: b.payload ?? {}, + status: b.status ?? 'enabled', + }); + res.json(scheduleNode(row)); + } catch (err) { + fail(res, err); + } + }); + + router.delete( + '/agents/:slug/schedules/:id', + async (req: Request, res: Response) => { + const l = live(res); + if (!l) return; + try { + await l.graph.deleteSchedule(req.params.id ?? ''); + res.status(204).end(); + } catch (err) { + fail(res, err); + } + }, + ); + + return router; +} + +// ── edge dispatchers ───────────────────────────────────────────────────────── + +async function createEdge( + l: Live, + agent: AgentRow, + body: Record, +): Promise<{ id: string; kind: string; source: string; target: string }> { + const kind = String(body['kind'] ?? ''); + const source = String(body['source'] ?? ''); + const target = String(body['target'] ?? ''); + const config = (body['config'] as Record | undefined) ?? {}; + + switch (kind) { + case 'channel_bind': { + const { channelType, channelKey } = parseChannel(source); + await l.config.createChannelBinding(agent.id, { channelType, channelKey }); + return { id: `channel_bind:${channelType}:${channelKey}`, kind, source, target }; + } + case 'skill': { + const subId = idAfter(source, 'subagent'); + const skillId = idAfter(target, 'skill'); + await l.graph.setSubAgentSkill(subId, skillId); + return { id: `skill:${subId}`, kind, source: `subagent:${subId}`, target }; + } + case 'tool_grant': { + const onAgent = source.startsWith('agent:'); + const subAgentId = onAgent ? null : idAfter(source, 'subagent'); + const toolKind = (config['toolKind'] as 'native' | 'mcp') ?? 'native'; + const toolRef = String(config['toolRef'] ?? idAfter(target, 'tool')); + const mcpServerId = (config['mcpServerId'] as string | null) ?? null; + if (!toolRef) { + throw new ConfigValidationError('tool_grant requires a toolRef'); + } + const grant = await l.graph.createToolGrant({ + agentId: onAgent ? agent.id : null, + subAgentId, + toolKind, + toolRef, + mcpServerId, + }); + return { id: `tool_grant:${grant.id}`, kind, source, target }; + } + case 'subagent': + case 'schedule': + // Sub-agents and schedules are created via their own POST endpoints; the + // ownership edge is implicit. Return it idempotently for the canvas. + return { id: `${kind}:${idAfter(target, target.split(':', 1)[0] ?? '')}`, kind, source, target }; + default: + throw new ConfigValidationError(`unknown edge kind "${kind}"`); + } +} + +async function deleteEdge(l: Live, id: string, kind: string): Promise { + switch (kind) { + case 'channel_bind': { + const rest = id.slice('channel_bind:'.length); + const sep = rest.indexOf(':'); + const channelType = sep >= 0 ? rest.slice(0, sep) : rest; + const channelKey = sep >= 0 ? rest.slice(sep + 1) : ''; + await l.config.removeChannelBinding(channelType, channelKey); + return; + } + case 'subagent': + await l.graph.deleteSubAgent(id.slice('subagent:'.length)); + return; + case 'skill': + await l.graph.setSubAgentSkill(id.slice('skill:'.length), null); + return; + case 'tool_grant': + await l.graph.deleteToolGrant(id.slice('tool_grant:'.length)); + return; + case 'schedule': + await l.graph.deleteSchedule(id.slice('schedule:'.length)); + return; + default: + throw new ConfigValidationError(`unknown edge kind "${kind}"`); + } +} + +// ── graph assembly ───────────────────────────────────────────────────────── + +function assembleGraph( + agent: AgentRow, + bindings: readonly { channelType: string; channelKey: string }[], + subAgents: readonly SubAgentRow[], + skills: readonly SkillRow[], + grants: readonly ToolGrantRow[], + servers: readonly McpServerRow[], + schedules: readonly ScheduleRow[], +) { + const mySubs = subAgents.filter((s) => s.parentAgentId === agent.id); + const subIds = new Set(mySubs.map((s) => s.id)); + const myGrants = grants.filter( + (g) => + (g.agentId && g.agentId === agent.id) || + (g.subAgentId && subIds.has(g.subAgentId)), + ); + + const edges: { id: string; kind: string; source: string; target: string }[] = []; + for (const b of bindings) { + edges.push({ + id: `channel_bind:${b.channelType}:${b.channelKey}`, + kind: 'channel_bind', + source: `channel:${b.channelType}:${b.channelKey}`, + target: `agent:${agent.id}`, + }); + } + for (const s of mySubs) { + edges.push({ + id: `subagent:${s.id}`, + kind: 'subagent', + source: `agent:${agent.id}`, + target: `subagent:${s.id}`, + }); + if (s.skillId) { + edges.push({ + id: `skill:${s.id}`, + kind: 'skill', + source: `subagent:${s.id}`, + target: `skill:${s.skillId}`, + }); + } + } + for (const g of myGrants) { + edges.push({ + id: `tool_grant:${g.id}`, + kind: 'tool_grant', + source: g.agentId ? `agent:${agent.id}` : `subagent:${g.subAgentId}`, + target: `tool:${g.toolRef}`, + }); + } + for (const sc of schedules) { + edges.push({ + id: `schedule:${sc.id}`, + kind: 'schedule', + source: `schedule:${sc.id}`, + target: `agent:${agent.id}`, + }); + } + + return { + agent: agentNode(agent), + channels: bindings.map((b) => ({ + channelType: b.channelType, + channelKey: b.channelKey, + position: null, + })), + subAgents: mySubs.map(subAgentNode), + skills: skills.map(skillNode), + tools: myGrants.map(toolGrantNode), + mcpServers: servers.map(mcpNode), + schedules: schedules.map(scheduleNode), + edges, + }; +} + +// ── node mappers ───────────────────────────────────────────────────────────── + +function agentNode(a: AgentRow) { + return { + id: a.id, + slug: a.slug, + name: a.name, + description: a.description, + privacyProfile: a.privacyProfile, + status: a.status, + modelRouting: (a.modelRouting as Record | null) ?? null, + position: a.canvasPosition ?? null, + }; +} + +function subAgentNode(s: SubAgentRow) { + return { + id: s.id, + parentAgentId: s.parentAgentId, + name: s.name, + skillId: s.skillId, + model: s.model, + maxTokens: s.maxTokens, + maxIterations: s.maxIterations, + systemPromptOverride: s.systemPromptOverride, + status: s.status, + position: s.position, + }; +} + +function skillNode(s: SkillRow) { + return { + id: s.id, + slug: s.slug, + name: s.name, + description: s.description, + body: s.body, + source: s.source, + }; +} + +function toolGrantNode(g: ToolGrantRow) { + return { + id: g.id, + agentId: g.agentId, + subAgentId: g.subAgentId, + toolKind: g.toolKind, + toolRef: g.toolRef, + mcpServerId: g.mcpServerId, + }; +} + +function mcpNode(s: McpServerRow) { + return { + id: s.id, + name: s.name, + transport: s.transport, + endpoint: s.endpoint, + status: s.status, + lastDiscoveredAt: s.lastDiscoveredAt ? s.lastDiscoveredAt.toISOString() : null, + discoveredTools: s.discoveredTools, + }; +} + +function scheduleNode(s: ScheduleRow) { + return { + id: s.id, + agentId: s.agentId, + cron: s.cron, + timezone: s.timezone, + payload: s.payload, + status: s.status, + lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null, + }; +} + +function toMcpConfig(row: McpServerRow): McpServerConfig { + const headers: Record = {}; + for (const [k, v] of Object.entries(row.headers ?? {})) { + if (typeof v === 'string') headers[k] = v; + } + return { + id: row.id, + name: row.name, + transport: row.transport, + endpoint: row.endpoint, + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** `channel::` where key may itself contain ':'. */ +function parseChannel(source: string): { channelType: string; channelKey: string } { + const rest = source.startsWith('channel:') ? source.slice('channel:'.length) : source; + const sep = rest.indexOf(':'); + if (sep < 0) throw new ConfigValidationError(`malformed channel node id "${source}"`); + return { channelType: rest.slice(0, sep), channelKey: rest.slice(sep + 1) }; +} + +function idAfter(nodeIdStr: string, prefix: string): string { + const p = `${prefix}:`; + return nodeIdStr.startsWith(p) ? nodeIdStr.slice(p.length) : nodeIdStr; +} + +async function reload(l: Live): Promise { + if (!l.registry) return undefined; + try { + return await l.registry.reload(); + } catch { + return undefined; + } +} + +function fail(res: Response, err: unknown): void { + if (err instanceof ConfigValidationError) { + res.status(409).json({ error: 'config_validation', message: err.message }); + return; + } + res.status(500).json({ error: 'internal', message: msg(err) }); +} + +function msg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/middleware/src/scheduler/cron.ts b/middleware/src/scheduler/cron.ts new file mode 100644 index 00000000..820b3ab6 --- /dev/null +++ b/middleware/src/scheduler/cron.ts @@ -0,0 +1,91 @@ +/** + * Minimal standard 5-field cron matcher (Agent Builder P6). + * + * Fields: minute hour day-of-month month day-of-week. Each field supports + * a wildcard, a step ("wildcard/n"), an "a-b" range, "a-b/n", and comma lists + * of those. Day-of-week 0 and 7 both mean Sunday. Evaluated against the supplied + * `Date`'s UTC parts (timezone handling is a future enhancement — schedules + * default to UTC). + * + * Pure + dependency-free so it unit-tests without a clock or a DB. + */ + +const RANGES: ReadonlyArray = [ + [0, 59], // minute + [0, 23], // hour + [1, 31], // day of month + [1, 12], // month + [0, 6], // day of week (after 7→0 normalisation) +]; + +export function isValidCron(expr: string): boolean { + const fields = expr.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((f, i) => { + try { + return expandField(f, RANGES[i]![0], RANGES[i]![1]).size > 0; + } catch { + return false; + } + }); +} + +export function cronMatches(expr: string, date: Date): boolean { + const fields = expr.trim().split(/\s+/); + if (fields.length !== 5) return false; + + const minute = date.getUTCMinutes(); + const hour = date.getUTCHours(); + const dom = date.getUTCDate(); + const month = date.getUTCMonth() + 1; + const dow = date.getUTCDay(); // 0..6, Sunday = 0 + + const values = [minute, hour, dom, month, dow]; + for (let i = 0; i < 5; i++) { + const set = expandField(fields[i]!, RANGES[i]![0], RANGES[i]![1]); + if (!set.has(values[i]!)) return false; + } + return true; +} + +function expandField(field: string, min: number, max: number): Set { + const out = new Set(); + for (const part of field.split(',')) { + let step = 1; + let body = part; + const slash = part.indexOf('/'); + if (slash >= 0) { + step = parseInt(part.slice(slash + 1), 10); + body = part.slice(0, slash); + if (!Number.isFinite(step) || step <= 0) { + throw new Error(`bad step in "${part}"`); + } + } + + let lo = min; + let hi = max; + if (body !== '*') { + const dash = body.indexOf('-'); + if (dash >= 0) { + lo = parseInt(body.slice(0, dash), 10); + hi = parseInt(body.slice(dash + 1), 10); + } else { + lo = parseInt(body, 10); + hi = lo; + } + if (!Number.isFinite(lo) || !Number.isFinite(hi)) { + throw new Error(`bad range in "${part}"`); + } + } + + for (let v = lo; v <= hi; v += step) { + // day-of-week: 7 → 0 (Sunday) + const normalised = max === 6 && v === 7 ? 0 : v; + if (normalised < min || normalised > max) { + throw new Error(`value ${String(v)} out of [${String(min)},${String(max)}]`); + } + out.add(normalised); + } + } + return out; +} diff --git a/middleware/src/scheduler/scheduleWorker.ts b/middleware/src/scheduler/scheduleWorker.ts new file mode 100644 index 00000000..aac767cb --- /dev/null +++ b/middleware/src/scheduler/scheduleWorker.ts @@ -0,0 +1,143 @@ +/** + * Agent Builder schedule worker (P6). + * + * Polls `agent_schedules` once per minute and fires a synthetic chat turn + * against the bound agent's orchestrator for each enabled schedule whose cron + * matches the current minute. Per-minute, per-schedule de-duplication lives in + * memory so a within-minute restart can't double-fire; overlap across ticks is + * prevented by tracking in-flight schedule ids. + * + * The turn runs headlessly via the registry's `ChatAgent.chat(...)` — the same + * entrypoint the chat route uses — so scheduled runs exercise the full + * orchestrator (tools, sub-agents, memory) exactly like an interactive turn. + */ + +import type { AgentGraphStore, OrchestratorRegistry } from '@omadia/orchestrator'; + +import { cronMatches } from './cron.js'; + +export interface ScheduleWorkerDeps { + readonly getGraphStore: () => AgentGraphStore | undefined; + readonly getRegistry: () => OrchestratorRegistry | undefined; + readonly log?: (msg: string, fields?: Record) => void; + /** Poll cadence in ms. Defaults to 60_000 (one minute). */ + readonly intervalMs?: number; + /** Injectable clock for tests. Defaults to `() => new Date()`. */ + readonly now?: () => Date; +} + +export class ScheduleWorker { + private timer: ReturnType | undefined; + private readonly firedThisMinute = new Map(); + private readonly inFlight = new Set(); + + constructor(private readonly deps: ScheduleWorkerDeps) {} + + start(): void { + if (this.timer) return; + const interval = this.deps.intervalMs ?? 60_000; + // Run once promptly, then on the cadence. + void this.tick(); + this.timer = setInterval(() => void this.tick(), interval); + if (typeof this.timer.unref === 'function') this.timer.unref(); + this.log('schedule worker started', { intervalMs: interval }); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** One poll cycle. Exposed for tests (call directly, no timer needed). */ + async tick(): Promise { + const graph = this.deps.getGraphStore(); + const registry = this.deps.getRegistry(); + if (!graph || !registry) return; + + const now = (this.deps.now ?? (() => new Date()))(); + const minuteKey = isoMinute(now); + + let schedules; + try { + schedules = await graph.listAllSchedules(); + } catch (err) { + this.log('schedule worker: list failed', { error: errMsg(err) }); + return; + } + + // Map agentId → slug from the live registry (only active agents can run). + const slugByAgentId = new Map(); + for (const a of registry.list()) slugByAgentId.set(a.agent.id, a.agent.slug); + + for (const s of schedules) { + if (s.status !== 'enabled') continue; + if (this.inFlight.has(s.id)) continue; + if (this.firedThisMinute.get(s.id) === minuteKey) continue; + if (!cronMatches(s.cron, now)) continue; + + const slug = slugByAgentId.get(s.agentId); + if (!slug) { + this.log('schedule worker: agent not active — skipping', { + scheduleId: s.id, + agentId: s.agentId, + }); + continue; + } + + this.firedThisMinute.set(s.id, minuteKey); + this.inFlight.add(s.id); + void this.fire(s.id, slug, s.payload, registry, graph).finally(() => + this.inFlight.delete(s.id), + ); + } + + // Bound the dedup map: drop entries from previous minutes. + for (const [id, key] of this.firedThisMinute) { + if (key !== minuteKey) this.firedThisMinute.delete(id); + } + } + + private async fire( + scheduleId: string, + slug: string, + payload: Record, + registry: OrchestratorRegistry, + graph: AgentGraphStore, + ): Promise { + const entry = registry.get(slug); + if (!entry) return; + const userMessage = + typeof payload['prompt'] === 'string' && payload['prompt'].trim() + ? (payload['prompt'] as string) + : 'Scheduled run: perform your configured routine.'; + try { + this.log('schedule worker: firing', { scheduleId, slug }); + await entry.built.bundle.agent.chat({ + userMessage, + sessionScope: `schedule:${scheduleId}`, + }); + await graph.markScheduleRun(scheduleId); + this.log('schedule worker: completed', { scheduleId, slug }); + } catch (err) { + this.log('schedule worker: turn failed', { + scheduleId, + slug, + error: errMsg(err), + }); + } + } + + private log(msg: string, fields?: Record): void { + this.deps.log?.(msg, fields); + } +} + +function isoMinute(d: Date): string { + return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/middleware/test/agentBuilderCron.test.ts b/middleware/test/agentBuilderCron.test.ts new file mode 100644 index 00000000..b4c39137 --- /dev/null +++ b/middleware/test/agentBuilderCron.test.ts @@ -0,0 +1,44 @@ +/** + * Agent Builder P6 — cron matcher. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { cronMatches, isValidCron } from '../src/scheduler/cron.js'; + +// 2026-06-08T09:30:00Z is a Monday (getUTCDay === 1). +const MON_0930 = new Date('2026-06-08T09:30:00Z'); + +test('wildcard every minute always matches', () => { + assert.equal(cronMatches('* * * * *', MON_0930), true); +}); + +test('exact minute+hour matches and near-misses do not', () => { + assert.equal(cronMatches('30 9 * * *', MON_0930), true); + assert.equal(cronMatches('31 9 * * *', MON_0930), false); + assert.equal(cronMatches('30 10 * * *', MON_0930), false); +}); + +test('step and range fields', () => { + assert.equal(cronMatches('*/15 * * * *', MON_0930), true); // 30 % 15 === 0 + assert.equal(cronMatches('*/7 * * * *', MON_0930), false); // 30 % 7 !== 0 + assert.equal(cronMatches('0-45 9 * * *', MON_0930), true); + assert.equal(cronMatches('0,15,30,45 9 * * *', MON_0930), true); +}); + +test('day-of-week (Sunday is both 0 and 7)', () => { + assert.equal(cronMatches('30 9 * * 1', MON_0930), true); // Monday + assert.equal(cronMatches('30 9 * * 0', MON_0930), false); // Sunday + const sun = new Date('2026-06-07T09:30:00Z'); // Sunday + assert.equal(cronMatches('30 9 * * 7', sun), true); + assert.equal(cronMatches('30 9 * * 0', sun), true); +}); + +test('isValidCron rejects malformed expressions', () => { + assert.equal(isValidCron('* * * * *'), true); + assert.equal(isValidCron('*/15 9 * * 1-5'), true); + assert.equal(isValidCron('* * * *'), false); // 4 fields + assert.equal(isValidCron('99 * * * *'), false); // out of range + assert.equal(isValidCron('*/0 * * * *'), false); // bad step +}); diff --git a/middleware/test/agentBuilderDiff.test.ts b/middleware/test/agentBuilderDiff.test.ts new file mode 100644 index 00000000..8d853849 --- /dev/null +++ b/middleware/test/agentBuilderDiff.test.ts @@ -0,0 +1,184 @@ +/** + * Agent Builder P0 — diffSnapshots graph-awareness. + * + * Verifies that `diffSnapshots` treats the new editable-graph collections + * (sub-agents, tool grants, referenced-skill bodies, model routing) as + * runtime-relevant — a change rebuilds the owning agent — while schedules, + * consumed by the cron worker rather than the orchestrator build, produce no + * orchestrator action. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { + ScheduleRow, + SkillRow, + SubAgentRow, + ToolGrantRow, +} from '../packages/harness-orchestrator/src/registry/agentGraphStore.js'; +import { diffSnapshots } from '../packages/harness-orchestrator/src/registry/applyDiff.js'; +import type { + AgentRow, + ConfigSnapshot, +} from '../packages/harness-orchestrator/src/registry/configStore.js'; + +const AGENT_ID = '00000000-0000-0000-0000-000000000001'; +const SKILL_ID = '00000000-0000-0000-0000-0000000000a1'; +const SUB_ID = '00000000-0000-0000-0000-0000000000b1'; + +function agent(overrides: Partial = {}): AgentRow { + return { + id: AGENT_ID, + slug: 'public', + name: 'public', + description: null, + privacyProfile: 'default', + status: 'enabled', + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +function snap(overrides: Partial = {}): ConfigSnapshot { + return { + agents: [agent()], + agentPlugins: [], + channelBindings: [], + platformSettings: { fallbackAgentId: null, updatedAt: new Date(0) }, + subAgents: [], + toolGrants: [], + schedules: [], + skills: [], + mcpServers: [], + ...overrides, + }; +} + +function subAgent(overrides: Partial = {}): SubAgentRow { + return { + id: SUB_ID, + parentAgentId: AGENT_ID, + name: 'researcher', + skillId: null, + model: null, + maxTokens: null, + maxIterations: null, + systemPromptOverride: null, + status: 'enabled', + position: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +function skill(overrides: Partial = {}): SkillRow { + return { + id: SKILL_ID, + slug: 'research', + name: 'Research', + description: null, + body: 'You research things.', + frontmatter: {}, + source: 'db', + sourcePath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +function grant(overrides: Partial = {}): ToolGrantRow { + return { + id: '00000000-0000-0000-0000-0000000000c1', + agentId: AGENT_ID, + subAgentId: null, + toolKind: 'native', + toolRef: 'web_search', + mcpServerId: null, + config: {}, + createdAt: new Date(0), + ...overrides, + }; +} + +function onlyRebuild(plan: ReturnType) { + assert.equal(plan.actions.length, 1, 'exactly one action'); + const [action] = plan.actions; + assert.equal(action!.kind, 'rebuild'); + return action as { kind: 'rebuild'; reason: string }; +} + +test('adding a sub-agent rebuilds the owning agent (reason: graph)', () => { + const before = snap(); + const after = snap({ subAgents: [subAgent()] }); + const reason = onlyRebuild(diffSnapshots(before, after)).reason; + assert.match(reason, /graph/); +}); + +test('editing a referenced skill body rebuilds the agent', () => { + const base = { + subAgents: [subAgent({ skillId: SKILL_ID })], + skills: [skill()], + }; + const before = snap(base); + const after = snap({ + subAgents: [subAgent({ skillId: SKILL_ID })], + skills: [skill({ body: 'You research things, deeply.' })], + }); + assert.match(onlyRebuild(diffSnapshots(before, after)).reason, /graph/); +}); + +test('an unrelated skill edit does NOT rebuild the agent', () => { + const before = snap({ skills: [skill()] }); + const after = snap({ skills: [skill({ body: 'changed' })] }); + // No sub-agent references this skill → no graph change for the agent. + assert.equal(diffSnapshots(before, after).actions.length, 0); +}); + +test('granting a tool to a sub-agent rebuilds the agent', () => { + const before = snap({ subAgents: [subAgent()] }); + const after = snap({ + subAgents: [subAgent()], + toolGrants: [grant({ agentId: null, subAgentId: SUB_ID })], + }); + assert.match(onlyRebuild(diffSnapshots(before, after)).reason, /graph/); +}); + +test('changing model_routing rebuilds the agent (reason: model_routing)', () => { + const before = snap(); + const after = snap({ + agents: [agent({ modelRouting: { mode: 'triage', main: 'opus' } })], + }); + assert.match(onlyRebuild(diffSnapshots(before, after)).reason, /model_routing/); +}); + +test('adding a schedule produces NO orchestrator action', () => { + const before = snap(); + const after = snap({ + schedules: [ + { + id: '00000000-0000-0000-0000-0000000000d1', + agentId: AGENT_ID, + cron: '0 9 * * *', + payload: {}, + timezone: 'UTC', + status: 'enabled', + lastRunAt: null, + createdAt: new Date(0), + } satisfies ScheduleRow, + ], + }); + assert.equal(diffSnapshots(before, after).actions.length, 0); +}); + +test('idempotent: identical graph-populated snapshot yields zero actions', () => { + const populated = snap({ + subAgents: [subAgent({ skillId: SKILL_ID })], + skills: [skill()], + toolGrants: [grant()], + }); + assert.equal(diffSnapshots(populated, populated).actions.length, 0); +}); diff --git a/middleware/test/agentBuilderModelRouting.test.ts b/middleware/test/agentBuilderModelRouting.test.ts new file mode 100644 index 00000000..6d255e33 --- /dev/null +++ b/middleware/test/agentBuilderModelRouting.test.ts @@ -0,0 +1,44 @@ +/** + * Agent Builder P5 — persisted model_routing JSON → runtime mapping. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { resolveAgentModelRouting } from '../packages/harness-orchestrator/src/registry/agentRuntime.js'; + +test('null / malformed → empty (platform default)', () => { + assert.deepEqual(resolveAgentModelRouting(null), {}); + assert.deepEqual(resolveAgentModelRouting(undefined), {}); + assert.deepEqual(resolveAgentModelRouting({ mode: 'triage' }), {}); // no main +}); + +test('single mode → model override, no routing', () => { + const r = resolveAgentModelRouting({ mode: 'single', main: 'claude-opus-4-8' }); + assert.equal(r.model, 'claude-opus-4-8'); + assert.equal(r.modelRouting, undefined); +}); + +test('triage mode → runtime routing with classifier/simple/complex', () => { + const r = resolveAgentModelRouting({ + mode: 'triage', + main: 'claude-opus-4-8', + triage: 'claude-haiku-4-5', + simple: 'claude-sonnet-4-6', + }); + assert.equal(r.model, 'claude-opus-4-8'); + assert.deepEqual(r.modelRouting, { + classifierModel: 'claude-haiku-4-5', + simpleModel: 'claude-sonnet-4-6', + complexModel: 'claude-opus-4-8', + }); +}); + +test('triage without explicit simple defaults simple→main; missing triage→haiku', () => { + const r = resolveAgentModelRouting({ mode: 'triage', main: 'claude-opus-4-8' }); + assert.deepEqual(r.modelRouting, { + classifierModel: 'claude-haiku-4-5', + simpleModel: 'claude-opus-4-8', + complexModel: 'claude-opus-4-8', + }); +}); diff --git a/middleware/test/agentBuilderSubAgentTools.test.ts b/middleware/test/agentBuilderSubAgentTools.test.ts new file mode 100644 index 00000000..f29f1f6b --- /dev/null +++ b/middleware/test/agentBuilderSubAgentTools.test.ts @@ -0,0 +1,137 @@ +/** + * Agent Builder P2/P4 — sub-agent DomainTool builder + MCP adapters. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type Anthropic from '@anthropic-ai/sdk'; +import type { LocalSubAgentTool } from '@omadia/plugin-api'; + +import { + mcpNativeToolName, + mcpToolToNativeSpec, + renderToolResult, + splitCommand, +} from '../packages/harness-orchestrator/src/mcp/mcpClient.js'; +import type { + SkillRow, + SubAgentRow, + ToolGrantRow, +} from '../packages/harness-orchestrator/src/registry/agentGraphStore.js'; +import { + buildSubAgentDomainTools, + mcpToolNameFromRef, + subAgentToolName, +} from '../packages/harness-orchestrator/src/registry/subAgentTools.js'; + +const fakeClient = {} as unknown as Anthropic; + +function sub(overrides: Partial = {}): SubAgentRow { + return { + id: 'sub-1', + parentAgentId: 'agent-1', + name: 'Researcher Bot', + skillId: 'skill-1', + model: null, + maxTokens: null, + maxIterations: null, + systemPromptOverride: null, + status: 'enabled', + position: null, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; +} + +function skill(): SkillRow { + return { + id: 'skill-1', + slug: 'research', + name: 'Research', + description: null, + body: 'You are a researcher.', + frontmatter: {}, + source: 'db', + sourcePath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function nativeGrant(): ToolGrantRow { + return { + id: 'g-1', + agentId: null, + subAgentId: 'sub-1', + toolKind: 'native', + toolRef: 'web_search', + mcpServerId: null, + config: {}, + createdAt: new Date(0), + }; +} + +const stubNativeTool: LocalSubAgentTool = { + spec: { + name: 'web_search', + description: 'search', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + handle: async () => 'ok', +}; + +test('builds one DomainTool per enabled sub-agent with sanitised name+domain', () => { + const tools = buildSubAgentDomainTools( + { subAgents: [sub()], toolGrants: [nativeGrant()], skills: [skill()] }, + { + client: fakeClient, + defaultModel: 'claude-sonnet-4-6', + defaultMaxTokens: 2048, + defaultMaxIterations: 6, + nativeTool: (ref) => (ref === 'web_search' ? stubNativeTool : undefined), + }, + ); + assert.equal(tools.length, 1); + assert.equal(tools[0]!.name, 'ask_researcher_bot'); + assert.equal(tools[0]!.domain, 'subagent.researcher-bot'); +}); + +test('disabled sub-agents are skipped', () => { + const tools = buildSubAgentDomainTools( + { subAgents: [sub({ status: 'disabled' })], toolGrants: [], skills: [] }, + { client: fakeClient, defaultModel: 'm', defaultMaxTokens: 1, defaultMaxIterations: 1 }, + ); + assert.equal(tools.length, 0); +}); + +test('subAgentToolName + mcpToolNameFromRef', () => { + assert.equal(subAgentToolName('GTM Agent!!'), 'ask_gtm_agent'); + assert.equal(mcpToolNameFromRef('exa:web_search', 'exa'), 'web_search'); + assert.equal(mcpToolNameFromRef('web_search', 'exa'), 'web_search'); +}); + +test('mcp adapters: native tool name + spec + result rendering + command split', () => { + assert.equal(mcpNativeToolName('Exa Search', 'web.search'), 'mcp__Exa_Search__web_search'); + const spec = mcpToolToNativeSpec('exa', { name: 'search', description: 'd' }); + assert.equal(spec.name, 'mcp__exa__search'); + assert.equal(spec.input_schema.type, 'object'); + assert.equal(spec.domain, 'mcp.exa'); + + assert.equal( + renderToolResult({ content: [{ type: 'text', text: 'hello' }] }), + 'hello', + ); + assert.equal( + renderToolResult({ isError: true, content: [{ type: 'text', text: 'boom' }] }), + 'Error: boom', + ); + assert.deepEqual(splitCommand('npx -y @scope/mcp --flag "a b"'), [ + 'npx', + '-y', + '@scope/mcp', + '--flag', + 'a b', + ]); +}); diff --git a/web-ui/app/_lib/agentBuilder.ts b/web-ui/app/_lib/agentBuilder.ts new file mode 100644 index 00000000..188e9b1a --- /dev/null +++ b/web-ui/app/_lib/agentBuilder.ts @@ -0,0 +1,499 @@ +import { ApiError } from './api'; + +/** + * Typed client for the Agent-Builder visual-canvas REST surface + * (`/api/v1/operator/agents/:slug/graph/*` and the sibling sub-agent / + * skill / mcp-server / schedule routes). Mirrors the cookie-forwarding + + * URL conventions from `_lib/agents.ts` and `_lib/api.ts` verbatim so the + * canvas works identically from RSC fetches and client-side writes. + * + * The types here intentionally re-declare the backend contract locally + * (no cross-package import from `middleware/`) so the web-ui bundle stays + * self-contained. + */ + +function botApi(path: string): string { + if (typeof window !== 'undefined') { + return `/bot-api${path}`; + } + const base = process.env['MIDDLEWARE_URL'] ?? 'http://localhost:3979'; + return `${base}/api${path}`; +} + +async function forwardCookieHeader(): Promise> { + if (typeof window !== 'undefined') return {}; + try { + const mod = await import('next/headers'); + const jar = await mod.cookies(); + const cookieHeader = jar + .getAll() + .map((c) => `${c.name}=${c.value}`) + .join('; '); + return cookieHeader ? { cookie: cookieHeader } : {}; + } catch { + return {}; + } +} + +async function callJson( + path: string, + init?: RequestInit & { method?: string }, +): Promise { + const forwarded = await forwardCookieHeader(); + const res = await fetch(botApi(path), { + ...init, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...forwarded, + ...(init?.headers ?? {}), + }, + cache: 'no-store', + credentials: 'include', + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!res.ok) { + throw new ApiError( + res.status, + `${init?.method ?? 'GET'} ${path} failed: ${res.status}`, + text, + ); + } + try { + return JSON.parse(text) as T; + } catch { + return undefined as T; + } +} + +// ----------------------------------------------------------------------------- +// Backend contract types (mirrored locally — do NOT import from middleware) +// ----------------------------------------------------------------------------- + +export interface CanvasPosition { + x: number; + y: number; +} + +export type ModelRoutingMode = 'single' | 'triage'; +export type EscalationTrigger = 'tool_error' | 'long_context' | 'low_confidence'; + +export interface ModelRoutingConfig { + mode: ModelRoutingMode; + main: string; + triage?: string; + simple?: string; + escalateOn?: EscalationTrigger[]; +} + +export type PrivacyProfile = 'strict' | 'default'; +export type NodeStatus = 'enabled' | 'disabled'; + +export interface AgentNode { + id: string; + slug: string; + name: string; + description: string | null; + privacyProfile: PrivacyProfile; + status: NodeStatus; + modelRouting: ModelRoutingConfig | null; + position: CanvasPosition | null; +} + +export interface ChannelNode { + channelType: string; + channelKey: string; + position: CanvasPosition | null; +} + +export interface SubAgentNode { + id: string; + parentAgentId: string; + name: string; + skillId: string | null; + model: string | null; + maxTokens: number | null; + maxIterations: number | null; + systemPromptOverride: string | null; + status: NodeStatus; + position: CanvasPosition | null; +} + +export interface SkillNode { + id: string; + slug: string; + name: string; + description: string | null; + body: string; + source: 'db' | 'file'; +} + +export type ToolKind = 'native' | 'mcp'; + +export interface ToolGrantNode { + id: string; + agentId: string | null; + subAgentId: string | null; + toolKind: ToolKind; + toolRef: string; + mcpServerId: string | null; +} + +export interface McpDiscoveredTool { + name: string; + description?: string; + inputSchema?: Record; +} + +export type McpTransport = 'stdio' | 'http' | 'sse'; + +export interface McpServerNode { + id: string; + name: string; + transport: McpTransport; + endpoint: string | null; + status: NodeStatus; + lastDiscoveredAt: string | null; + discoveredTools: McpDiscoveredTool[]; +} + +export interface ScheduleNode { + id: string; + agentId: string; + cron: string; + timezone: string; + payload: Record; + status: NodeStatus; + lastRunAt: string | null; +} + +export type EdgeKind = + | 'channel_bind' + | 'subagent' + | 'skill' + | 'tool_grant' + | 'schedule'; + +export interface CanvasEdge { + id: string; + kind: EdgeKind; + source: string; + target: string; +} + +export interface AgentGraph { + agent: AgentNode; + channels: ChannelNode[]; + subAgents: SubAgentNode[]; + skills: SkillNode[]; + tools: ToolGrantNode[]; + mcpServers: McpServerNode[]; + schedules: ScheduleNode[]; + edges: CanvasEdge[]; +} + +// ----------------------------------------------------------------------------- +// Graph read + edge mutations +// ----------------------------------------------------------------------------- + +export async function getAgentGraph(slug: string): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/graph`, + ); +} + +export interface CreateEdgeInput { + kind: EdgeKind; + source: string; + target: string; + config?: Record; +} + +export interface CreateEdgeResponse { + edge: CanvasEdge; + diff?: unknown; +} + +export async function createGraphEdge( + slug: string, + input: CreateEdgeInput, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/graph/edges`, + { method: 'POST', body: JSON.stringify(input) }, + ); +} + +export async function deleteGraphEdge( + slug: string, + edgeId: string, + kind: EdgeKind, +): Promise { + const params = new URLSearchParams({ kind }); + await callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/graph/edges/${encodeURIComponent(edgeId)}?${params.toString()}`, + { method: 'DELETE' }, + ); +} + +// ----------------------------------------------------------------------------- +// Sub-agents +// ----------------------------------------------------------------------------- + +export interface CreateSubAgentInput { + name: string; + skillId?: string | null; + model?: string | null; + maxTokens?: number | null; + maxIterations?: number | null; + systemPromptOverride?: string | null; + status?: NodeStatus; + position?: CanvasPosition; +} + +export async function createSubAgent( + slug: string, + input: CreateSubAgentInput, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/subagents`, + { method: 'POST', body: JSON.stringify(input) }, + ); +} + +export type PatchSubAgentInput = Partial; + +export async function patchSubAgent( + slug: string, + id: string, + patch: PatchSubAgentInput, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/subagents/${encodeURIComponent(id)}`, + { method: 'PATCH', body: JSON.stringify(patch) }, + ); +} + +export async function deleteSubAgent(slug: string, id: string): Promise { + await callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/subagents/${encodeURIComponent(id)}`, + { method: 'DELETE' }, + ); +} + +// ----------------------------------------------------------------------------- +// Model routing + positions +// ----------------------------------------------------------------------------- + +export async function patchModelRouting( + slug: string, + modelRouting: ModelRoutingConfig | null, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/model-routing`, + { method: 'PATCH', body: JSON.stringify({ modelRouting }) }, + ); +} + +export interface PositionsPatchInput { + agent?: CanvasPosition; + subAgents?: Array<{ id: string; position: CanvasPosition }>; + channels?: Array<{ + channelType: string; + channelKey: string; + position: CanvasPosition; + }>; +} + +export async function patchPositions( + slug: string, + input: PositionsPatchInput, +): Promise { + await callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/positions`, + { method: 'PATCH', body: JSON.stringify(input) }, + ); +} + +// ----------------------------------------------------------------------------- +// Skills (global registry) +// ----------------------------------------------------------------------------- + +export interface SkillsListResponse { + skills: SkillNode[]; +} + +export async function listSkills(): Promise { + return callJson('/v1/operator/skills'); +} + +export interface CreateSkillInput { + slug: string; + name: string; + description?: string | null; + body: string; +} + +export async function createSkill(input: CreateSkillInput): Promise { + return callJson('/v1/operator/skills', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export type PatchSkillInput = Partial; + +export async function patchSkill( + id: string, + patch: PatchSkillInput, +): Promise { + return callJson(`/v1/operator/skills/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function deleteSkill(id: string): Promise { + await callJson(`/v1/operator/skills/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); +} + +// ----------------------------------------------------------------------------- +// MCP servers +// ----------------------------------------------------------------------------- + +export interface McpServersListResponse { + servers: McpServerNode[]; +} + +export async function listMcpServers(): Promise { + return callJson('/v1/operator/mcp-servers'); +} + +export interface CreateMcpServerInput { + name: string; + transport: McpTransport; + endpoint?: string | null; + status?: NodeStatus; +} + +export async function createMcpServer( + input: CreateMcpServerInput, +): Promise { + return callJson('/v1/operator/mcp-servers', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function deleteMcpServer(id: string): Promise { + await callJson(`/v1/operator/mcp-servers/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); +} + +export async function discoverMcpTools(id: string): Promise { + return callJson( + `/v1/operator/mcp-servers/${encodeURIComponent(id)}/discover`, + { method: 'POST' }, + ); +} + +// ----------------------------------------------------------------------------- +// Schedules +// ----------------------------------------------------------------------------- + +export interface SchedulesListResponse { + schedules: ScheduleNode[]; +} + +export async function listSchedules( + slug: string, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/schedules`, + ); +} + +export interface CreateScheduleInput { + cron: string; + timezone: string; + payload?: Record; + status?: NodeStatus; +} + +export async function createSchedule( + slug: string, + input: CreateScheduleInput, +): Promise { + return callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/schedules`, + { method: 'POST', body: JSON.stringify(input) }, + ); +} + +export async function deleteSchedule(slug: string, id: string): Promise { + await callJson( + `/v1/operator/agents/${encodeURIComponent(slug)}/schedules/${encodeURIComponent(id)}`, + { method: 'DELETE' }, + ); +} + +// ----------------------------------------------------------------------------- +// Edge semantics — single source of truth for legal connections. +// Encoded as source-node-kind → target-node-kind → edge-kind. The canvas +// stamps each ReactFlow node with a `kind` in its data; `isValidConnection` +// looks the pair up here. +// ----------------------------------------------------------------------------- + +export type CanvasNodeKind = + | 'channel' + | 'agent' + | 'subagent' + | 'skill' + | 'tool' + | 'mcp' + | 'schedule'; + +interface EdgeRule { + source: CanvasNodeKind; + target: CanvasNodeKind; + kind: EdgeKind; +} + +const EDGE_RULES: readonly EdgeRule[] = [ + { source: 'channel', target: 'agent', kind: 'channel_bind' }, + { source: 'agent', target: 'subagent', kind: 'subagent' }, + { source: 'subagent', target: 'skill', kind: 'skill' }, + { source: 'agent', target: 'tool', kind: 'tool_grant' }, + { source: 'agent', target: 'mcp', kind: 'tool_grant' }, + { source: 'subagent', target: 'tool', kind: 'tool_grant' }, + { source: 'subagent', target: 'mcp', kind: 'tool_grant' }, + { source: 'schedule', target: 'agent', kind: 'schedule' }, +]; + +/** + * Resolve the edge-kind for a directed source→target node-kind pair, or + * `null` when the connection is illegal. Used both by `isValidConnection` + * (reject illegal drags) and `onConnect` (derive the kind to POST). + */ +export function resolveEdgeKind( + source: CanvasNodeKind, + target: CanvasNodeKind, +): EdgeKind | null { + const rule = EDGE_RULES.find( + (r) => r.source === source && r.target === target, + ); + return rule ? rule.kind : null; +} + +/** Hardcoded native-tool catalog surfaced in the toolbox palette. */ +export const NATIVE_TOOLS: readonly string[] = [ + 'web_search', + 'query_knowledge_graph', + 'render_diagram', + 'book_meeting', + 'find_free_slots', +]; diff --git a/web-ui/app/admin/builder/BuilderCanvas.tsx b/web-ui/app/admin/builder/BuilderCanvas.tsx new file mode 100644 index 00000000..c30ad08f --- /dev/null +++ b/web-ui/app/admin/builder/BuilderCanvas.tsx @@ -0,0 +1,391 @@ +'use client'; + +import '@xyflow/react/dist/style.css'; + +import { + Background, + Controls, + type Connection, + type Edge, + type EdgeChange, + type NodeChange, + type NodeTypes, + ReactFlow, + ReactFlowProvider, + applyEdgeChanges, + applyNodeChanges, + useReactFlow, +} from '@xyflow/react'; +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + createGraphEdge, + createSubAgent, + createSchedule, + createSkill, + createMcpServer, + deleteGraphEdge, + patchPositions, + resolveEdgeKind, + type CanvasPosition, + type EdgeKind, +} from '../../_lib/agentBuilder'; +import { AgentNodeView } from './nodes/AgentNode'; +import { ChannelNodeView } from './nodes/ChannelNode'; +import { McpServerNodeView } from './nodes/McpServerNode'; +import { ScheduleNodeView } from './nodes/ScheduleNode'; +import { SkillNodeView } from './nodes/SkillNode'; +import { SubAgentNodeView } from './nodes/SubAgentNode'; +import { ToolNodeView } from './nodes/ToolNode'; +import type { BuilderNode, BuilderNodeData } from './nodes/types'; +import { graphToFlow, kindOfNodeId, nodeId } from './graphMapping'; +import { InspectorPanel } from './panels/InspectorPanel'; +import { DND_MIME, PalettePanel } from './panels/PalettePanel'; +import { TOOL_DND_MIME, ToolboxPanel, type ToolDragPayload } from './panels/ToolboxPanel'; +import { useAgentGraph } from './useAgentGraph'; + +const nodeTypes: NodeTypes = { + channel: ChannelNodeView as NodeTypes[string], + agent: AgentNodeView as NodeTypes[string], + subagent: SubAgentNodeView as NodeTypes[string], + skill: SkillNodeView as NodeTypes[string], + tool: ToolNodeView as NodeTypes[string], + mcp: McpServerNodeView as NodeTypes[string], + schedule: ScheduleNodeView as NodeTypes[string], +}; + +export interface BuilderCanvasProps { + slug: string; +} + +export default function BuilderCanvas(props: BuilderCanvasProps): React.ReactElement { + return ( + + + + ); +} + +function CanvasInner({ slug }: BuilderCanvasProps): React.ReactElement { + const t = useTranslations('admin.builder'); + const { state, actionError, clearActionError, reload, mutate } = useAgentGraph(slug); + const { screenToFlowPosition } = useReactFlow(); + + const labels = useMemo( + () => ({ + channel: t('nodes.channel'), + agent: t('nodes.agent'), + subAgent: t('nodes.subagent'), + skill: t('nodes.skill'), + tool: t('nodes.tool'), + mcp: t('nodes.mcp'), + schedule: t('nodes.schedule'), + tools: t('nodes.tools'), + }), + [t], + ); + + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [selected, setSelected] = useState(null); + const dragTimer = useRef | null>(null); + const wrapRef = useRef(null); + + // Re-derive the flow graph whenever the authoritative state changes. + useEffect(() => { + if (state.kind !== 'ready') return; + const flow = graphToFlow(state.graph, labels); + // eslint-disable-next-line react-hooks/set-state-in-effect + setNodes(flow.nodes); + setEdges(flow.edges); + }, [state, labels]); + + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((ns) => applyNodeChanges(changes, ns) as BuilderNode[]); + }, []); + + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + setEdges((es) => applyEdgeChanges(changes, es)); + }, []); + + const isValidConnection = useCallback((c: Connection | Edge): boolean => { + const s = kindOfNodeId(c.source ?? ''); + const tgt = kindOfNodeId(c.target ?? ''); + if (!s || !tgt) return false; + return resolveEdgeKind(s, tgt) !== null; + }, []); + + const onConnect = useCallback( + (c: Connection) => { + const s = kindOfNodeId(c.source); + const tgt = kindOfNodeId(c.target); + if (!s || !tgt) return; + const kind = resolveEdgeKind(s, tgt); + if (!kind || !c.source || !c.target) return; + const tempId = `tmp-${String(Date.now())}`; + const source = c.source; + const target = c.target; + setEdges((es) => [...es, { id: tempId, source, target, data: { kind } }]); + void mutate( + (g) => g, + async () => { + try { + const res = await createGraphEdge(slug, { kind, source, target }); + setEdges((es) => + es.map((e) => (e.id === tempId ? { ...e, id: res.edge.id } : e)), + ); + } catch (err) { + setEdges((es) => es.filter((e) => e.id !== tempId)); + throw err; + } + }, + ); + }, + [slug, mutate], + ); + + const onEdgesDelete = useCallback( + (deleted: Edge[]) => { + void mutate( + (g) => g, + async () => { + for (const e of deleted) { + const kind = (e.data as { kind?: EdgeKind } | undefined)?.kind; + if (!kind || e.id.startsWith('tmp-')) continue; + await deleteGraphEdge(slug, e.id, kind); + } + }, + ); + }, + [slug, mutate], + ); + + // Debounced persistence of node positions after a drag settles. + const onNodeDragStop = useCallback(() => { + if (dragTimer.current) clearTimeout(dragTimer.current); + dragTimer.current = setTimeout(() => { + void persistPositions(slug, nodes); + }, 600); + }, [slug, nodes]); + + useEffect( + () => () => { + if (dragTimer.current) clearTimeout(dragTimer.current); + }, + [], + ); + + const onSelectionChange = useCallback( + (params: { nodes: BuilderNode[] }) => { + const first = params.nodes.length > 0 ? params.nodes[0] : undefined; + setSelected(first ? first.data : null); + }, + [], + ); + + const handleToolDrop = useCallback( + (raw: string, pos: CanvasPosition): void => { + const payload = JSON.parse(raw) as ToolDragPayload; + // Find the agent/sub-agent node under the drop point to grant against. + const targetNode = nodeUnderPoint(nodes, pos); + if ( + !targetNode || + (targetNode.data.kind !== 'agent' && targetNode.data.kind !== 'subagent') + ) { + clearActionError(); + return; + } + const toolNodeId = nodeId.tool(payload.toolRef); + void mutate( + (g) => g, + async () => { + await createGraphEdge(slug, { + kind: 'tool_grant', + source: targetNode.id, + target: toolNodeId, + config: { + toolKind: payload.toolKind, + toolRef: payload.toolRef, + mcpServerId: payload.mcpServerId, + }, + }); + await reload(); + }, + ); + }, + [slug, nodes, mutate, reload, clearActionError], + ); + + const handlePaletteDrop = useCallback( + (kind: string, pos: CanvasPosition): void => { + void mutate( + (g) => g, + async () => { + if (kind === 'subagent') { + await createSubAgent(slug, { + name: t('defaults.subAgentName'), + position: pos, + }); + } else if (kind === 'skill') { + await createSkill({ + slug: `skill-${String(Date.now())}`, + name: t('defaults.skillName'), + body: '', + }); + } else if (kind === 'mcp') { + await createMcpServer({ name: t('defaults.mcpName'), transport: 'http' }); + } else if (kind === 'schedule') { + await createSchedule(slug, { cron: '0 9 * * *', timezone: 'UTC' }); + } else { + // channel — created via binding edge once wired; nothing to POST yet. + return; + } + await reload(); + }, + ); + }, + [slug, mutate, reload, t], + ); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); + const toolRaw = e.dataTransfer.getData(TOOL_DND_MIME); + if (toolRaw) { + handleToolDrop(toolRaw, pos); + return; + } + const nodeKind = e.dataTransfer.getData(DND_MIME); + if (nodeKind) handlePaletteDrop(nodeKind, pos); + }, + [screenToFlowPosition, handleToolDrop, handlePaletteDrop], + ); + + if (state.kind === 'loading') { + return ; + } + if (state.kind === 'error') { + return ; + } + + return ( +
+ +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }} + onDrop={onDrop} + > + + + + + {actionError && ( +
+ {actionError} +
+ )} +
+ {selected && ( + setSelected(null)} + onSaved={() => void reload()} + /> + )} + {state.kind === 'ready' && ( + + )} +
+ ); +} + +function nodeUnderPoint(nodes: BuilderNode[], pos: CanvasPosition): BuilderNode | null { + // Reverse so topmost (later-rendered) node wins on overlap. + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const n = nodes[i]; + if (!n) continue; + const w = n.measured?.width ?? 220; + const h = n.measured?.height ?? 80; + if ( + pos.x >= n.position.x && + pos.x <= n.position.x + w && + pos.y >= n.position.y && + pos.y <= n.position.y + h + ) { + return n; + } + } + return null; +} + +async function persistPositions(slug: string, nodes: BuilderNode[]): Promise { + const subAgents: Array<{ id: string; position: CanvasPosition }> = []; + const channels: Array<{ + channelType: string; + channelKey: string; + position: CanvasPosition; + }> = []; + let agent: CanvasPosition | undefined; + + for (const n of nodes) { + const data = n.data; + if (data.kind === 'agent') { + agent = n.position; + } else if (data.kind === 'subagent') { + subAgents.push({ id: data.subAgent.id, position: n.position }); + } else if (data.kind === 'channel') { + channels.push({ + channelType: data.channel.channelType, + channelKey: data.channel.channelKey, + position: n.position, + }); + } + } + try { + await patchPositions(slug, { + ...(agent ? { agent } : {}), + ...(subAgents.length ? { subAgents } : {}), + ...(channels.length ? { channels } : {}), + }); + } catch { + // Position drift is non-critical; a reload will reconcile. + } +} + +function Centered({ + text, + tone, +}: { + text: string; + tone?: 'error'; +}): React.ReactElement { + return ( +
+ {text} +
+ ); +} diff --git a/web-ui/app/admin/builder/graphMapping.ts b/web-ui/app/admin/builder/graphMapping.ts new file mode 100644 index 00000000..5deef670 --- /dev/null +++ b/web-ui/app/admin/builder/graphMapping.ts @@ -0,0 +1,126 @@ +import type { Edge } from '@xyflow/react'; +import type { AgentGraph, CanvasNodeKind } from '../../_lib/agentBuilder'; +import type { BuilderNode } from './nodes/types'; + +/** + * Deterministic node-id helpers. A canvas node id encodes its kind so the + * connection handler can recover the node-kind pair purely from the id when + * the node `data` is momentarily stale during an optimistic add. + */ +export const nodeId = { + channel: (c: { channelType: string; channelKey: string }): string => + `channel:${c.channelType}:${c.channelKey}`, + agent: (id: string): string => `agent:${id}`, + subagent: (id: string): string => `subagent:${id}`, + skill: (id: string): string => `skill:${id}`, + tool: (ref: string): string => `tool:${ref}`, + mcp: (id: string): string => `mcp:${id}`, + schedule: (id: string): string => `schedule:${id}`, +}; + +export function kindOfNodeId(id: string): CanvasNodeKind | null { + const prefix = id.split(':', 1)[0]; + switch (prefix) { + case 'channel': + return 'channel'; + case 'agent': + return 'agent'; + case 'subagent': + return 'subagent'; + case 'skill': + return 'skill'; + case 'tool': + return 'tool'; + case 'mcp': + return 'mcp'; + case 'schedule': + return 'schedule'; + default: + return null; + } +} + +/** Auto-layout fallback when a node carries no persisted position. */ +function gridPos(col: number, row: number): { x: number; y: number } { + return { x: col * 300 + 40, y: row * 130 + 40 }; +} + +export function graphToFlow( + graph: AgentGraph, + labels: Record, +): { nodes: BuilderNode[]; edges: Edge[] } { + const nodes: BuilderNode[] = []; + + graph.channels.forEach((channel, i) => { + nodes.push({ + id: nodeId.channel(channel), + type: 'channel', + position: channel.position ?? gridPos(0, i), + data: { kind: 'channel', labels, channel }, + }); + }); + + nodes.push({ + id: nodeId.agent(graph.agent.id), + type: 'agent', + position: graph.agent.position ?? gridPos(1, 0), + data: { kind: 'agent', labels, agent: graph.agent }, + }); + + graph.subAgents.forEach((subAgent, i) => { + nodes.push({ + id: nodeId.subagent(subAgent.id), + type: 'subagent', + position: subAgent.position ?? gridPos(2, i), + data: { kind: 'subagent', labels, subAgent }, + }); + }); + + graph.skills.forEach((skill, i) => { + nodes.push({ + id: nodeId.skill(skill.id), + type: 'skill', + position: gridPos(3, i), + data: { kind: 'skill', labels, skill }, + }); + }); + + const grantByTool = new Map(graph.tools.map((g) => [g.toolRef, g])); + const toolRefs = new Set(graph.tools.map((g) => g.toolRef)); + Array.from(toolRefs).forEach((ref, i) => { + nodes.push({ + id: nodeId.tool(ref), + type: 'tool', + position: gridPos(3, graph.skills.length + i), + data: { kind: 'tool', labels, toolRef: ref, grant: grantByTool.get(ref) ?? null }, + }); + }); + + graph.mcpServers.forEach((server, i) => { + nodes.push({ + id: nodeId.mcp(server.id), + type: 'mcp', + position: gridPos(4, i), + data: { kind: 'mcp', labels, server }, + }); + }); + + graph.schedules.forEach((schedule, i) => { + nodes.push({ + id: nodeId.schedule(schedule.id), + type: 'schedule', + position: gridPos(0, graph.channels.length + i), + data: { kind: 'schedule', labels, schedule }, + }); + }); + + const edges: Edge[] = graph.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + data: { kind: e.kind }, + animated: e.kind === 'schedule', + })); + + return { nodes, edges }; +} diff --git a/web-ui/app/admin/builder/nodes/AgentNode.tsx b/web-ui/app/admin/builder/nodes/AgentNode.tsx new file mode 100644 index 00000000..90056929 --- /dev/null +++ b/web-ui/app/admin/builder/nodes/AgentNode.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { AgentNodeData } from './types'; + +export function AgentNodeView({ + data, + selected, +}: NodeProps & { data: AgentNodeData }): React.ReactElement { + const { agent, labels } = data; + const routing = agent.modelRouting; + return ( + +
+ + + {routing ? : null} +
+
+ ); +} + +function Pill({ text }: { text: string }): React.ReactElement { + return ( + + {text} + + ); +} diff --git a/web-ui/app/admin/builder/nodes/ChannelNode.tsx b/web-ui/app/admin/builder/nodes/ChannelNode.tsx new file mode 100644 index 00000000..63686575 --- /dev/null +++ b/web-ui/app/admin/builder/nodes/ChannelNode.tsx @@ -0,0 +1,22 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { ChannelNodeData } from './types'; + +export function ChannelNodeView({ + data, + selected, +}: NodeProps & { data: ChannelNodeData }): React.ReactElement { + const { channel, labels } = data; + return ( + + ); +} diff --git a/web-ui/app/admin/builder/nodes/McpServerNode.tsx b/web-ui/app/admin/builder/nodes/McpServerNode.tsx new file mode 100644 index 00000000..7d817a34 --- /dev/null +++ b/web-ui/app/admin/builder/nodes/McpServerNode.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { McpNodeData } from './types'; + +export function McpServerNodeView({ + data, + selected, +}: NodeProps & { data: McpNodeData }): React.ReactElement { + const { server, labels } = data; + const toolCount = server.discoveredTools.length; + return ( + +
+ {toolCount} {labels['tools']} +
+
+ ); +} diff --git a/web-ui/app/admin/builder/nodes/NodeShell.tsx b/web-ui/app/admin/builder/nodes/NodeShell.tsx new file mode 100644 index 00000000..df4a2b0f --- /dev/null +++ b/web-ui/app/admin/builder/nodes/NodeShell.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { Handle, Position } from '@xyflow/react'; +import type { CanvasNodeKind } from '../../../_lib/agentBuilder'; + +export interface NodeShellProps { + kind: CanvasNodeKind; + title: string; + subtitle?: string | null; + badge?: string | null; + selected?: boolean; + /** Render a target handle (left) — node can be a connection target. */ + hasTarget?: boolean; + /** Render a source handle (right) — node can start a connection. */ + hasSource?: boolean; + children?: React.ReactNode; +} + +const ACCENTS: Record = { + channel: 'var(--accent)', + agent: '#7c5cff', + subagent: '#3aa0ff', + skill: '#34d399', + tool: '#f59e0b', + mcp: '#ec4899', + schedule: '#a3a3a3', +}; + +/** + * Shared visual shell for every canvas node. Keeps each node-type file tiny + * — they just pass a title/subtitle/badge and which handles to expose. The + * left/coloured rail encodes the node kind at a glance. + */ +export function NodeShell({ + kind, + title, + subtitle, + badge, + selected, + hasTarget, + hasSource, + children, +}: NodeShellProps): React.ReactElement { + const accent = ACCENTS[kind]; + return ( +
+
+ {hasTarget && ( + + )} + {hasSource && ( + + )} +
+
+ + {title} + + {badge ? ( + + {badge} + + ) : null} +
+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} + {children} +
+
+ ); +} diff --git a/web-ui/app/admin/builder/nodes/ScheduleNode.tsx b/web-ui/app/admin/builder/nodes/ScheduleNode.tsx new file mode 100644 index 00000000..0c57f2da --- /dev/null +++ b/web-ui/app/admin/builder/nodes/ScheduleNode.tsx @@ -0,0 +1,22 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { ScheduleNodeData } from './types'; + +export function ScheduleNodeView({ + data, + selected, +}: NodeProps & { data: ScheduleNodeData }): React.ReactElement { + const { schedule, labels } = data; + return ( + + ); +} diff --git a/web-ui/app/admin/builder/nodes/SkillNode.tsx b/web-ui/app/admin/builder/nodes/SkillNode.tsx new file mode 100644 index 00000000..d682d946 --- /dev/null +++ b/web-ui/app/admin/builder/nodes/SkillNode.tsx @@ -0,0 +1,22 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { SkillNodeData } from './types'; + +export function SkillNodeView({ + data, + selected, +}: NodeProps & { data: SkillNodeData }): React.ReactElement { + const { skill, labels } = data; + return ( + + ); +} diff --git a/web-ui/app/admin/builder/nodes/SubAgentNode.tsx b/web-ui/app/admin/builder/nodes/SubAgentNode.tsx new file mode 100644 index 00000000..49e010bc --- /dev/null +++ b/web-ui/app/admin/builder/nodes/SubAgentNode.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { SubAgentNodeData } from './types'; + +export function SubAgentNodeView({ + data, + selected, +}: NodeProps & { data: SubAgentNodeData }): React.ReactElement { + const { subAgent, labels } = data; + return ( + + ); +} diff --git a/web-ui/app/admin/builder/nodes/ToolNode.tsx b/web-ui/app/admin/builder/nodes/ToolNode.tsx new file mode 100644 index 00000000..cbeb180b --- /dev/null +++ b/web-ui/app/admin/builder/nodes/ToolNode.tsx @@ -0,0 +1,21 @@ +'use client'; + +import type { NodeProps } from '@xyflow/react'; +import { NodeShell } from './NodeShell'; +import type { ToolNodeData } from './types'; + +export function ToolNodeView({ + data, + selected, +}: NodeProps & { data: ToolNodeData }): React.ReactElement { + const { toolRef, labels } = data; + return ( + + ); +} diff --git a/web-ui/app/admin/builder/nodes/types.ts b/web-ui/app/admin/builder/nodes/types.ts new file mode 100644 index 00000000..9a23cc63 --- /dev/null +++ b/web-ui/app/admin/builder/nodes/types.ts @@ -0,0 +1,63 @@ +import type { Node } from '@xyflow/react'; +import type { + AgentNode, + CanvasNodeKind, + ChannelNode, + McpServerNode, + ScheduleNode, + SkillNode, + SubAgentNode, + ToolGrantNode, +} from '../../../_lib/agentBuilder'; + +/** + * Per-node data carried on the ReactFlow node. Every node stamps its + * `kind` so `isValidConnection` can look the source/target pair up against + * the edge-semantics table without re-deriving it from the DOM. + */ +export interface BaseNodeData extends Record { + kind: CanvasNodeKind; + /** i18n labels resolved once in the canvas and threaded into node UIs. */ + labels: Record; +} + +export interface ChannelNodeData extends BaseNodeData { + kind: 'channel'; + channel: ChannelNode; +} +export interface AgentNodeData extends BaseNodeData { + kind: 'agent'; + agent: AgentNode; +} +export interface SubAgentNodeData extends BaseNodeData { + kind: 'subagent'; + subAgent: SubAgentNode; +} +export interface SkillNodeData extends BaseNodeData { + kind: 'skill'; + skill: SkillNode; +} +export interface ToolNodeData extends BaseNodeData { + kind: 'tool'; + toolRef: string; + grant: ToolGrantNode | null; +} +export interface McpNodeData extends BaseNodeData { + kind: 'mcp'; + server: McpServerNode; +} +export interface ScheduleNodeData extends BaseNodeData { + kind: 'schedule'; + schedule: ScheduleNode; +} + +export type BuilderNodeData = + | ChannelNodeData + | AgentNodeData + | SubAgentNodeData + | SkillNodeData + | ToolNodeData + | McpNodeData + | ScheduleNodeData; + +export type BuilderNode = Node; diff --git a/web-ui/app/admin/builder/page.tsx b/web-ui/app/admin/builder/page.tsx new file mode 100644 index 00000000..81706881 --- /dev/null +++ b/web-ui/app/admin/builder/page.tsx @@ -0,0 +1,107 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +import { + listOperatorAgents, + type OperatorAgentDto, +} from '../../_lib/agents'; + +const BuilderCanvas = dynamic(() => import('./BuilderCanvas'), { + ssr: false, + loading: () => ( +
+ … +
+ ), +}); + +type State = + | { kind: 'loading' } + | { kind: 'ready'; agents: OperatorAgentDto[] } + | { kind: 'error'; message: string }; + +/** + * Agent-Builder visual canvas (`/admin/builder`). Pick an agent, then wire + * Channels → Agent → Sub-Agents → Skills → Tools/MCP plus a Schedule + * trigger on an editable node-graph. The canvas itself is lazy-loaded + * (ssr:false) to keep @xyflow out of the main bundle and avoid SSR issues. + */ +export default function BuilderPage(): React.ReactElement { + const t = useTranslations('admin.builder'); + const [state, setState] = useState({ kind: 'loading' }); + const [slug, setSlug] = useState(null); + + useEffect(() => { + let alive = true; + void (async () => { + try { + const res = await listOperatorAgents(); + if (!alive) return; + setState({ kind: 'ready', agents: res.agents }); + const first = res.agents.length > 0 ? res.agents[0] : undefined; + if (first) setSlug(first.slug); + } catch (err) { + if (!alive) return; + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + return () => { + alive = false; + }; + }, []); + + return ( +
+
+
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ {state.kind === 'ready' && ( + + )} +
+ +
+ {state.kind === 'loading' && ( +

{t('loading')}

+ )} + {state.kind === 'error' && ( +

+ {t('loadError')}: {state.message} +

+ )} + {state.kind === 'ready' && slug && } + {state.kind === 'ready' && !slug && ( +

+ {t('noAgents')} +

+ )} +
+
+ ); +} diff --git a/web-ui/app/admin/builder/panels/InspectorControls.tsx b/web-ui/app/admin/builder/panels/InspectorControls.tsx new file mode 100644 index 00000000..b2a4322e --- /dev/null +++ b/web-ui/app/admin/builder/panels/InspectorControls.tsx @@ -0,0 +1,42 @@ +'use client'; + +export const inputCls = + 'w-full rounded-md border border-[color:var(--border)] bg-transparent px-3 py-2 text-sm outline-none focus:border-[color:var(--accent)]'; + +export function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}): React.ReactElement { + return ( + + ); +} + +export function SaveButton({ + onClick, + pending, + label, +}: { + onClick: () => void; + pending: boolean; + label: string; +}): React.ReactElement { + return ( + + ); +} diff --git a/web-ui/app/admin/builder/panels/InspectorPanel.tsx b/web-ui/app/admin/builder/panels/InspectorPanel.tsx new file mode 100644 index 00000000..d82588f5 --- /dev/null +++ b/web-ui/app/admin/builder/panels/InspectorPanel.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import { + discoverMcpTools, + patchModelRouting, + patchSkill, + patchSubAgent, + type AgentNode, + type McpServerNode, + type ModelRoutingConfig, + type ModelRoutingMode, + type ScheduleNode, + type SkillNode, + type SubAgentNode, +} from '../../../_lib/agentBuilder'; +import type { BuilderNodeData } from '../nodes/types'; +import { Field, inputCls, SaveButton } from './InspectorControls'; + +export interface InspectorPanelProps { + slug: string; + data: BuilderNodeData; + onClose: () => void; + /** Re-fetch the authoritative graph after a successful save. */ + onSaved: () => void; +} + +/** + * Right-hand inspector for the selected node. Switches editor by node kind: + * agent identity + model-routing, sub-agent model/skill/prompt, skill + * markdown body, MCP connection + Discover, schedule cron/timezone. + */ +export function InspectorPanel(props: InspectorPanelProps): React.ReactElement { + const t = useTranslations('admin.builder'); + return ( + + ); +} + +function Editor(props: InspectorPanelProps): React.ReactElement { + const { data } = props; + switch (data.kind) { + case 'agent': + return ; + case 'subagent': + return ( + + ); + case 'skill': + return ; + case 'mcp': + return ; + case 'schedule': + return ; + default: + return ; + } +} + +function ReadOnly(): React.ReactElement { + const t = useTranslations('admin.builder'); + return

{t('inspector.readOnly')}

; +} + +function useSaver(): { + pending: boolean; + error: string | null; + run: (fn: () => Promise) => Promise; +} { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + async function run(fn: () => Promise): Promise { + setPending(true); + setError(null); + try { + await fn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } + } + return { pending, error, run }; +} + +function ErrLine({ error }: { error: string | null }): React.ReactElement | null { + if (!error) return null; + return

{error}

; +} + +// ── Agent ──────────────────────────────────────────────────────────────── +function AgentEditor({ + slug, + agent, + onSaved, +}: { + slug: string; + agent: AgentNode; + onSaved: () => void; +}): React.ReactElement { + const t = useTranslations('admin.builder'); + const r = agent.modelRouting; + const [mode, setMode] = useState(r?.mode ?? 'single'); + const [main, setMain] = useState(r?.main ?? ''); + const [triage, setTriage] = useState(r?.triage ?? ''); + const [simple, setSimple] = useState(r?.simple ?? ''); + const { pending, error, run } = useSaver(); + + async function save(): Promise { + await run(async () => { + const cfg: ModelRoutingConfig = { + mode, + main: main.trim(), + ...(triage.trim() ? { triage: triage.trim() } : {}), + ...(simple.trim() ? { simple: simple.trim() } : {}), + }; + await patchModelRouting(slug, main.trim() ? cfg : null); + onSaved(); + }); + } + + return ( +
+

{agent.name}

+ + + + + setMain(e.target.value)} className={inputCls} /> + + {mode === 'triage' && ( + <> + + setTriage(e.target.value)} className={inputCls} /> + + + setSimple(e.target.value)} className={inputCls} /> + + + )} + + void save()} pending={pending} label={t('inspector.save')} /> +
+ ); +} + +// ── Sub-agent ────────────────────────────────────────────────────────────── +function SubAgentEditor({ + slug, + subAgent, + onSaved, +}: { + slug: string; + subAgent: SubAgentNode; + onSaved: () => void; +}): React.ReactElement { + const t = useTranslations('admin.builder'); + const [name, setName] = useState(subAgent.name); + const [model, setModel] = useState(subAgent.model ?? ''); + const [prompt, setPrompt] = useState(subAgent.systemPromptOverride ?? ''); + const { pending, error, run } = useSaver(); + + async function save(): Promise { + await run(async () => { + await patchSubAgent(slug, subAgent.id, { + name: name.trim(), + model: model.trim() || null, + systemPromptOverride: prompt.trim() || null, + }); + onSaved(); + }); + } + + return ( +
+ + setName(e.target.value)} className={inputCls} /> + + + setModel(e.target.value)} className={inputCls} /> + + +