diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index b14e24595..3651d7b04 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -207,6 +207,11 @@ export type CreateAgentDefinitionInput = { readonly systemPrompt: string; readonly model?: string; readonly skills?: readonly string[]; + /** Tool packages to pin by name (no version — the create route + * resolves each to `*`). Used by a template-driven create + * (`instantiateWorkbenchTemplate`'s Scout/Jimmy requests), never by + * the hand-authored create form, which has no field for it. */ + readonly toolPackagePins?: readonly string[]; }; const CreatedAgentDefinition = WorkflowDefinitionResponse.and({ diff --git a/bun.lock b/bun.lock index 4d6090197..8b0c64c73 100644 --- a/bun.lock +++ b/bun.lock @@ -470,6 +470,7 @@ "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", + "@corbits/workflow-catalog": "workspace:*", "@tanstack/react-query": "catalog:", "@workbench/connections": "workspace:*", "arktype": "catalog:", @@ -477,7 +478,6 @@ "react-dom": "^19.2.0", }, "devDependencies": { - "@corbits/workflow-catalog": "workspace:*", "@happy-dom/global-registrator": "^20.11.2", "@intx/inference": "0.3.0", "@types/bun": "catalog:", @@ -1510,6 +1510,8 @@ "@corbits/code-review": "workspace:*", "@corbits/code-review-workflow": "workspace:*", "@corbits/github-tools": "workspace:*", + "@corbits/jimmy-agent": "workspace:*", + "@corbits/scout-agent": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", @@ -3489,6 +3491,18 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/tasks-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3511,6 +3525,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index 2203693a6..72ccc5d78 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -201,6 +201,9 @@ export function createAgentDefinitionRoutes({ if (body.description !== undefined) coreInput.description = body.description; if (body.model !== undefined) coreInput.model = body.model; + if (body.toolPackagePins !== undefined && body.toolPackagePins.length > 0) { + coreInput.toolPackagePins = body.toolPackagePins; + } let row: Awaited>["row"]; try { diff --git a/packages/agent-directory/src/validation.ts b/packages/agent-directory/src/validation.ts index e55a7d638..d4c71c507 100644 --- a/packages/agent-directory/src/validation.ts +++ b/packages/agent-directory/src/validation.ts @@ -40,6 +40,21 @@ const SkillNameArray = skillNameSchema.array().narrow((skills, ctx) => { return true; }); +// A pinned tool package names a `@corbits/*` workspace package, the only +// namespace this catalog ever resolves a pin against. +const ToolPackageNamePattern = type(/^@corbits\/[a-z0-9-]+$/); +const ToolPackagePinArray = ToolPackageNamePattern.array().narrow( + (pins, ctx) => { + const seen = new Set(); + for (const name of pins) { + if (seen.has(name)) + return ctx.mustBe(`a list without duplicate tool package "${name}"`); + seen.add(name); + } + return true; + }, +); + export const CreateAgentDefinitionInput = type({ name: boundedNonBlankString(100), handle: HANDLE_PATTERN.describe( @@ -49,14 +64,15 @@ export const CreateAgentDefinitionInput = type({ systemPrompt: boundedNonBlankString(8000), "model?": boundedNonBlankString(200), "skills?": SkillNameArray, - // No `toolPackagePins` field, deliberately: this is the HTTP route - // for a person hand-authoring an agent through a form, which has no - // affordance for typing an arbitrary tool-package pin. The one - // caller that needs `buildAgentDefinitionWorkflow`'s optional - // `toolPackagePins` (CL-6051's `{create}` planner branch, see - // `@corbits/task-planner`) calls that builder directly, in-process, - // never through this REST boundary — so parity here isn't needed - // unless a future UI grows a "pin a tool package" field of its own. + // `toolPackagePins` names tool packages by name only (no version — the + // core resolves each to `*`, matching `./workflow-create-routes.ts`'s + // own handling of the same field). Absent for a person hand-authoring + // an agent through a form, which has no affordance for typing one; the + // one caller that supplies it is `@corbits/workflow-catalog`'s + // `instantiateWorkbenchTemplate`, installing a template participant + // (Scout, Jimmy) whose tools ship as pinned packages rather than + // inline capabilities. + "toolPackagePins?": ToolPackagePinArray, }); export type CreateAgentDefinitionInput = typeof CreateAgentDefinitionInput.infer; diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 92429bca7..33b311980 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -555,6 +555,53 @@ test("a create request without skills records an empty skills list", async () => expect(await skillsStore.getSkills("ast_1")).toEqual([]); }); +test("a create request with toolPackagePins pins each named package at version *", async () => { + let writtenFiles: Record | undefined; + const app = buildApp( + fakeAssetService({ + createAsset: () => + Promise.resolve({ + id: "ast_1", + tenantId: TENANT.id, + kind: "workflow" as const, + name: "scout", + displayName: "Scout", + creatorPrincipalId: PRINCIPAL.id, + createdAt: new Date(), + updatedAt: new Date(), + }), + populateAsset: (params) => { + writtenFiles = params.tree.files; + return Promise.resolve({ commitSha: "deadbeef" }); + }, + }), + fakeCreateDb(), + ); + const response = await post(app, { + name: "Scout", + handle: "scout", + systemPrompt: "You are Scout.", + toolPackagePins: ["@corbits/memory-tools", "@corbits/web-search-tools"], + }); + expect(response.status).toBe(201); + const workflowJson = definitionFrom(writtenFiles); + expect(pinsFrom(workflowJson)).toEqual([ + { name: "@corbits/memory-tools", version: "*" }, + { name: "@corbits/web-search-tools", version: "*" }, + ]); +}); + +test("a create request rejects a toolPackagePins entry outside the @corbits scope", async () => { + const app = buildApp(fakeAssetService()); + const response = await post(app, { + name: "Scout", + handle: "scout", + systemPrompt: "You are Scout.", + toolPackagePins: ["not-a-corbits-package"], + }); + expect(response.status).toBe(400); +}); + function fakeSkillsDb( row: { id: string; assetId: string | null } | undefined, ): DB["db"] { diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 4da61054d..43a209e78 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -24,13 +24,13 @@ "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@tanstack/react-query": "catalog:", "@workbench/connections": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "arktype": "catalog:", "@corbits/icons": "workspace:*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "devDependencies": { - "@corbits/workflow-catalog": "workspace:*", "@happy-dom/global-registrator": "^20.11.2", "@intx/inference": "0.3.0", "@types/bun": "catalog:", diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index a782ddc0d..a037bf9ab 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -14,6 +14,7 @@ import { Part } from "@corbits/chat/parts"; import { parseParticipants } from "@corbits/chat/participants"; import type { ParticipantRecord } from "@corbits/chat/participants"; import { UnauthenticatedError } from "@corbits/api-query"; +import { jimmyAgentRequest } from "@corbits/workflow-catalog"; import { CHAT_STRINGS } from "./strings"; export { @@ -697,6 +698,32 @@ export function inviteAgent( ); } +// Jimmy's own request shape, the same `@corbits/workflow-catalog` object a +// workbench template's participant create used to resolve — CL-6499 removed +// Jimmy's template (he is not a "kind of workbench"), so this dialog's own +// "Add Jimmy" quick-create row (see `invite-agent-dialog.tsx`) is his only +// create path left. `jimmyAgentRequest()` is pure data (no tool bodies, no +// server-only imports), safe to call from browser code. +export const JIMMY_QUICK_CREATE = jimmyAgentRequest(); + +const CreatedAgentDefinition = type({ id: "string" }); + +/** + * Creates Jimmy's agent-directory definition in one call — the same + * one-shot `POST /agent-definitions` a template-driven participant create + * goes through. Idempotency is the caller's job: only offer this when + * `JIMMY_QUICK_CREATE.handle` is absent from the tenant's invitable list. + */ +export function quickCreateJimmy( + tenantId: string, +): Promise<{ readonly id: string }> { + return request( + `/api/tenants/${tenantId}/agent-definitions`, + CreatedAgentDefinition, + { method: "POST", body: JSON.stringify(JIMMY_QUICK_CREATE) }, + ); +} + // `DELETE /workbenches/:id/participants/:address` (see // `packages/chat/src/routes.ts`): the removal counterpart to // `inviteAgent`/workbench creation's own join — drops the participant and, diff --git a/packages/chat-ui/src/invite-agent-dialog.tsx b/packages/chat-ui/src/invite-agent-dialog.tsx index 231bdaffd..cc55eacbb 100644 --- a/packages/chat-ui/src/invite-agent-dialog.tsx +++ b/packages/chat-ui/src/invite-agent-dialog.tsx @@ -3,7 +3,9 @@ // host — the server-side list already excludes it), each with an // "Invite" action that launches it into the current workbench. The list // itself carries its own loading/empty/error states since it is fetched -// fresh every time the dialog opens. +// fresh every time the dialog opens. When Jimmy has never been created +// in this tenant, an extra "Add Jimmy" row offers to create and invite +// him in one click — see `quickCreateJimmy` in `./api`. import { Button, @@ -22,11 +24,18 @@ import { useEffect, useState } from "react"; import { ChatApiError, describeChatError, + JIMMY_QUICK_CREATE, listInvitableDefinitions, + quickCreateJimmy, } from "./api"; import type { InvitableDefinition } from "./api"; import { CHAT_STRINGS } from "./strings"; +// A sentinel `invitingId` distinct from any real definition id — lets the +// "Add Jimmy" row show its own "Adding…" state while `quickCreateJimmy` +// runs, before a real definition id exists to key off of. +const JIMMY_QUICK_CREATE_MARKER = "jimmy-quick-create"; + type ListState = | { readonly kind: "loading" } | { readonly kind: "error"; readonly message: string } @@ -88,6 +97,29 @@ export function InviteAgentDialog({ } } + /** + * Jimmy is no longer seeded by a workbench template (CL-6499: he is not + * a "kind of workbench") — this row is his one remaining create path. + * Creating him mints a real, tenant-wide agent-directory definition, + * exactly like a template's participant create did; inviting him into + * this workbench reuses `handleInvite`'s own state and error handling. + */ + async function handleQuickCreateJimmy() { + setInvitingId(JIMMY_QUICK_CREATE_MARKER); + setInviteError(null); + try { + const created = await quickCreateJimmy(tenantId); + await handleInvite(created.id); + } catch { + setInviteError(CHAT_STRINGS.inviteAgentQuickCreateError); + setInvitingId(null); + } + } + + const jimmyMissing = + state.kind === "ready" && + !state.items.some((item) => item.name === JIMMY_QUICK_CREATE.handle); + return ( @@ -111,7 +143,7 @@ export function InviteAgentDialog({ title={CHAT_STRINGS.inviteAgentLoadError} description={state.message} /> - ) : state.items.length === 0 ? ( + ) : state.items.length === 0 && !jimmyMissing ? ( } title={CHAT_STRINGS.inviteAgentEmptyTitle} @@ -138,6 +170,24 @@ export function InviteAgentDialog({ ))} + {jimmyMissing && ( +
  • + {JIMMY_QUICK_CREATE.description} + +
  • + )} )} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index c17e1bc25..2d173a195 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -105,6 +105,9 @@ export const CHAT_STRINGS = { inviteAgentInviting: "Inviting…", inviteAgentInviteError: "Couldn't invite that agent — try again.", inviteAgentConflictError: "This workbench already has its agent.", + inviteAgentQuickCreateAction: "Add", + inviteAgentQuickCreating: "Adding…", + inviteAgentQuickCreateError: "Couldn't add Jimmy — try again.", forkThreadAction: "Fork", forkThreadError: "Couldn't fork that message into a thread — try again.", replyInThreadAction: "Reply in thread", diff --git a/packages/chat-ui/test/api.test.ts b/packages/chat-ui/test/api.test.ts index 7515b311f..e8dd7408b 100644 --- a/packages/chat-ui/test/api.test.ts +++ b/packages/chat-ui/test/api.test.ts @@ -10,6 +10,7 @@ import { createWorkbench, runDisplayName, inviteAgent, + JIMMY_QUICK_CREATE, listWorkbenches, listAllWorkbenches, listRuns, @@ -19,6 +20,7 @@ import { openAgentDm, listMessages, listPinnedMessages, + quickCreateJimmy, sendMessage, fetchWorkbenchBlob, getWorkbenchSettings, @@ -504,6 +506,19 @@ describe("inviteAgent", () => { }); }); +describe("quickCreateJimmy", () => { + test("posts Jimmy's own request shape to the agent-definitions create route", async () => { + const calls = stubFetch(() => json({ id: "wfd_jimmy" }, 201)); + const created = await quickCreateJimmy("tenant_1"); + expect(calls[0]?.path).toBe("/api/tenants/tenant_1/agent-definitions"); + expect(calls[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual( + JIMMY_QUICK_CREATE, + ); + expect(created).toEqual({ id: "wfd_jimmy" }); + }); +}); + describe("getWorkbenchSettings", () => { test("fetches a workbench's settings by tenant and workbench id", async () => { const calls = stubFetch(() => diff --git a/packages/chat-ui/test/invite-agent-dialog.test.tsx b/packages/chat-ui/test/invite-agent-dialog.test.tsx new file mode 100644 index 000000000..dc3b4cb39 --- /dev/null +++ b/packages/chat-ui/test/invite-agent-dialog.test.tsx @@ -0,0 +1,130 @@ +// CL-6499: Jimmy is no longer seeded through a workbench template — his +// only create path left is this dialog's own "Add Jimmy" quick-create +// row (see `quickCreateJimmy` in `../src/api`). This proves the row +// appears only when Jimmy is genuinely absent from the tenant's +// invitable list, and that clicking it creates him and then invites him +// into the current workbench through the same `onInvite` seam every +// other row uses. +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { InviteAgentDialog } from "../src/invite-agent-dialog"; +import { JIMMY_QUICK_CREATE } from "../src/api"; + +const realFetch = globalThis.fetch; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + globalThis.fetch = realFetch; + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function stubFetch(routes: { + invitable: () => readonly { id: string; name: string }[]; + createJimmy?: () => { id: string }; +}) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + if (path.endsWith("/invitable")) { + return Promise.resolve(jsonResponse({ items: routes.invitable() })); + } + if (init?.method === "POST" && path.endsWith("/agent-definitions")) { + const created = routes.createJimmy?.() ?? { id: "wfd_jimmy" }; + return Promise.resolve(jsonResponse(created, 201)); + } + throw new Error(`unstubbed fetch: ${String(init?.method)} ${path}`); + }) as typeof fetch; +} + +async function mount(props: { + readonly invitable: () => readonly { id: string; name: string }[]; + readonly onInvite: (definitionId: string) => Promise; + readonly onOpenChange: (open: boolean) => void; +}) { + stubFetch({ invitable: props.invitable }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + // Flush the effect's `listInvitableDefinitions` promise. + await act(async () => { + await Promise.resolve(); + }); + // `Dialog` portals its content to `document.body`, not into `container`. + return document.body; +} + +describe("InviteAgentDialog's Jimmy quick-create row", () => { + test("appears when the tenant's invitable list has no Jimmy yet", async () => { + const el = await mount({ + invitable: () => [{ id: "wfd_echo", name: "echo" }], + onInvite: async () => undefined, + onOpenChange: () => undefined, + }); + const row = el.querySelector('[data-testid="quick-create-jimmy"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).toContain(JIMMY_QUICK_CREATE.description); + }); + + test("is absent once the tenant's invitable list already includes Jimmy", async () => { + const el = await mount({ + invitable: () => [{ id: "wfd_jimmy", name: JIMMY_QUICK_CREATE.handle }], + onInvite: async () => undefined, + onOpenChange: () => undefined, + }); + expect(el.querySelector('[data-testid="quick-create-jimmy"]')).toBeNull(); + }); + + test("clicking Add creates Jimmy and invites the created definition", async () => { + const invited: string[] = []; + let closed = false; + const el = await mount({ + invitable: () => [], + onInvite: async (definitionId) => { + invited.push(definitionId); + }, + onOpenChange: (open) => { + if (!open) closed = true; + }, + }); + + const button = el.querySelector( + '[data-testid="quick-create-jimmy"] button', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(invited).toEqual(["wfd_jimmy"]); + expect(closed).toBe(true); + }); +}); diff --git a/packages/jimmy-agent/README.md b/packages/jimmy-agent/README.md index bddd05c67..f96cd3e86 100644 --- a/packages/jimmy-agent/README.md +++ b/packages/jimmy-agent/README.md @@ -25,20 +25,27 @@ convention (`defineAgent`'s "portable half," `corbitsdev/examples`). `CredentialCapability.resolve("giphy")` seam every other tool package in this repo uses (see `@corbits/web-search-tools`'s `tool.ts`), so once a `giphy` connector exists it works with zero code changes here. As of - this package's introduction, that connector does not yet exist: + CL-6499, the "not connected" result also carries + `@corbits/connections`' `missing-credential-detail` wire shape + (`{kind: "missing-credential", connectorId: "giphy"}`), so the chat + orchestrator renders a real "Connect Giphy" card instead of a plain + error string — but clicking it still can't complete a connection: `packages/connections/src/registry.ts`'s `CONNECTOR_REGISTRY` has no `giphy` entry, and none of its three credential-provider plugins (`http`, `http-raw-authorization`, `http-x-api-key`) put the secret on the query string the way Giphy's `/v1/gifs/search` endpoint requires — a fourth plugin (mirroring `http-x-api-key-provider.ts`, injecting into - the URL's search params instead of a header) is the missing piece. - Until both land, `gif_search` always returns the "connect Giphy" - message, by design — never a silent failure. -- **Installing Jimmy as a mentionable chat agent.** This package exposes - `buildJimmyAgent`, an `AgentDefinition` ready to seed through - `packages/agent-directory`'s create path (the same path - `@corbits/code-review`'s reviewer agents install through) — that - seeding call is not wired in this change. + the URL's search params instead of a header) is the missing piece, + along with the registry entry itself. Until both land, `gif_search` + always returns the "connect Giphy" message, by design — never a silent + failure, but not yet a completable connect flow either. +- **Installing Jimmy as a mentionable chat agent.** CL-6499 wired this: + Jimmy is not seeded through a workbench template (he is not a "kind of + workbench"), but `@corbits/chat-ui`'s `InviteAgentDialog` offers an + "Add Jimmy" quick-create row whenever the tenant hasn't created him + yet — one click mints his real agent-directory definition and invites + him into the open workbench. See `packages/chat-ui/src/api.ts`'s + `quickCreateJimmy` and `invite-agent-dialog.tsx`. ## Test plan run diff --git a/packages/jimmy-agent/package.json b/packages/jimmy-agent/package.json index ee53327e3..b589c085e 100644 --- a/packages/jimmy-agent/package.json +++ b/packages/jimmy-agent/package.json @@ -13,7 +13,8 @@ ] }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./metadata": "./src/metadata.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/jimmy-agent/src/agent.ts b/packages/jimmy-agent/src/agent.ts index cec5db040..1d44cdf16 100644 --- a/packages/jimmy-agent/src/agent.ts +++ b/packages/jimmy-agent/src/agent.ts @@ -9,35 +9,12 @@ // and the shuffle/cancel signal machine (`scout/workflows/jimmy`). See // this package's README for what that leaves deferred. import type { AgentDefinition, InferencePreference } from "@intx/agent"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; -import { GIF_SEARCH_TOOL } from "./gif-search-tool"; - -export const JIMMY_AGENT_ID = "jimmy"; - -/** This definition pins itself: the package that carries `gif_search` is this one. */ -export const JIMMY_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/jimmy-agent", version: "0.0.1" }, -]; - -export const JIMMY_SYSTEM_PROMPT = - "You are Jimmy. Someone mentions you in chat with a request for a GIF " + - `— call \`${GIF_SEARCH_TOOL}\` with their words as the search query and ` + - "reply with the GIF it finds.\n" + - "\n" + - "Call the tool exactly once per request, with a short, literal query " + - "drawn from what they asked for — do not embellish or add unrelated " + - "terms. Reply with the CDN URL the tool returns so the chat renders " + - "the GIF; do not describe the GIF instead of showing it, and never " + - "download, re-host, or link anywhere other than the returned URL.\n" + - "\n" + - "If the tool comes back telling you Giphy is not connected, say that " + - "plainly in one sentence and stop — never invent a GIF, a URL, or a " + - "description in its place. If the search finds nothing, say so and " + - "suggest the requester try different words.\n" + - "\n" + - "You are a one-shot responder, not a conversation: one request, one " + - "reply, no follow-up picker."; +import { + JIMMY_AGENT_ID, + JIMMY_SYSTEM_PROMPT, + JIMMY_TOOL_PACKAGE_PINS, +} from "./metadata"; export interface BuildJimmyAgentInput { /** Provider/model preferences, in order; resolved at deploy time. */ diff --git a/packages/jimmy-agent/src/gif-search-tool.test.ts b/packages/jimmy-agent/src/gif-search-tool.test.ts index ae86c4996..2a47a33ef 100644 --- a/packages/jimmy-agent/src/gif-search-tool.test.ts +++ b/packages/jimmy-agent/src/gif-search-tool.test.ts @@ -2,7 +2,8 @@ import { expect, test } from "bun:test"; import type { ToolCall } from "@intx/types/runtime"; import type { CredentialCapability, MediatedCredential } from "@intx/types"; -import { GIF_SEARCH_TOOL, gifSearchTool } from "./gif-search-tool"; +import { gifSearchTool } from "./gif-search-tool"; +import { GIF_SEARCH_TOOL } from "./metadata"; import type { GifSearchEnv } from "./gif-search-tool"; const CALL: ToolCall = { @@ -79,6 +80,13 @@ test("surfaces a connect prompt, never a silent no-op, when Giphy is not connect const result = await bundle.run(CALL, new AbortController().signal); expect(result.isError).toBe(true); expect(result.content).toMatch(/connect giphy/i); + // The `missing-credential-detail` contract (`@corbits/connections`): + // this is what lets the chat orchestrator render a real "Connect + // Giphy" button instead of just a plain error string. + expect(result.detail).toEqual({ + kind: "missing-credential", + connectorId: "giphy", + }); }); test("surfaces the same connect prompt when the step carries no credentials capability at all", async () => { @@ -86,6 +94,10 @@ test("surfaces the same connect prompt when the step carries no credentials capa const result = await bundle.run(CALL, new AbortController().signal); expect(result.isError).toBe(true); expect(result.content).toMatch(/connect giphy/i); + expect(result.detail).toEqual({ + kind: "missing-credential", + connectorId: "giphy", + }); }); test("rejects a missing query without calling the network", async () => { diff --git a/packages/jimmy-agent/src/gif-search-tool.ts b/packages/jimmy-agent/src/gif-search-tool.ts index 6d5cb5d32..fcd2d81a4 100644 --- a/packages/jimmy-agent/src/gif-search-tool.ts +++ b/packages/jimmy-agent/src/gif-search-tool.ts @@ -12,7 +12,7 @@ import type { CredentialCapability } from "@intx/types"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { type } from "arktype"; -export const GIF_SEARCH_TOOL = "gif_search"; +import { GIF_SEARCH_TOOL } from "./metadata"; /** `defineTool` requires a namespaced id; the agent-facing call name stays `gif_search`. */ const GIF_SEARCH_TOOL_ID = "@corbits/jimmy-agent/gif-search"; @@ -38,12 +38,25 @@ export interface GifSearchEnv extends BaseEnv { readonly credentials?: CredentialCapability; } +/** The wire shape `@corbits/connections`' `missingCredentialDetail` defines + * (`{kind: "missing-credential", connectorId}`) — reproduced here rather + * than imported, per that module's own doc comment: any tool package can + * write this shape onto a `ToolResult` with no dependency on the + * `connections` package, since only the chat orchestrator's reader needs + * to parse it. `@corbits/github-tools`' `pull-request-tools.ts` follows + * the same convention. This is what turns the plain error message below + * into a real "Connect Giphy" button in chat instead of a dead end. */ +function missingCredentialDetail(connectorId: string) { + return { kind: "missing-credential", connectorId } as const; +} + function notConnectedResult(callId: string): ToolResult { return { callId, content: "Connect Giphy to let Jimmy search for GIFs — this workspace has no Giphy credential yet.", isError: true, + detail: missingCredentialDetail(GIPHY_CREDENTIAL_HANDLE), }; } diff --git a/packages/jimmy-agent/src/index.ts b/packages/jimmy-agent/src/index.ts index 1f3c54233..509893861 100644 --- a/packages/jimmy-agent/src/index.ts +++ b/packages/jimmy-agent/src/index.ts @@ -1,14 +1,16 @@ export { - GIF_SEARCH_TOOL, gifSearchTool, type GifResult, type GifSearchEnv, } from "./gif-search-tool"; +export { buildJimmyAgent, type BuildJimmyAgentInput } from "./agent"; + export { - buildJimmyAgent, + GIF_SEARCH_TOOL, JIMMY_AGENT_ID, + JIMMY_DESCRIPTION, + JIMMY_DISPLAY_NAME, JIMMY_SYSTEM_PROMPT, JIMMY_TOOL_PACKAGE_PINS, - type BuildJimmyAgentInput, -} from "./agent"; +} from "./metadata"; diff --git a/packages/jimmy-agent/src/metadata.ts b/packages/jimmy-agent/src/metadata.ts new file mode 100644 index 000000000..0cbbc0394 --- /dev/null +++ b/packages/jimmy-agent/src/metadata.ts @@ -0,0 +1,39 @@ +// Jimmy's plain-data identity: everything a caller needs to describe him +// without pulling his tool bodies in. The tool modules import `defineTool` +// from `@intx/agent`, whose module graph reaches `node:path`, so anything +// browser-reachable — the workbench template catalog among them — imports +// from here instead of from this package's index. +import type { ToolPackagePin } from "@intx/types/tool-packages"; + +export const JIMMY_AGENT_ID = "jimmy"; + +export const JIMMY_DISPLAY_NAME = "Jimmy"; + +export const JIMMY_DESCRIPTION = "Searches Giphy and replies with a GIF"; + +/** The agent-facing call name. `gif-search-tool.ts` gives it its namespaced id. */ +export const GIF_SEARCH_TOOL = "gif_search"; + +/** This definition pins itself: the package that carries `gif_search` is this one. */ +export const JIMMY_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ + { name: "@corbits/jimmy-agent", version: "0.0.1" }, +]; + +export const JIMMY_SYSTEM_PROMPT = + "You are Jimmy. Someone mentions you in chat with a request for a GIF " + + `— call \`${GIF_SEARCH_TOOL}\` with their words as the search query and ` + + "reply with the GIF it finds.\n" + + "\n" + + "Call the tool exactly once per request, with a short, literal query " + + "drawn from what they asked for — do not embellish or add unrelated " + + "terms. Reply with the CDN URL the tool returns so the chat renders " + + "the GIF; do not describe the GIF instead of showing it, and never " + + "download, re-host, or link anywhere other than the returned URL.\n" + + "\n" + + "If the tool comes back telling you Giphy is not connected, say that " + + "plainly in one sentence and stop — never invent a GIF, a URL, or a " + + "description in its place. If the search finds nothing, say so and " + + "suggest the requester try different words.\n" + + "\n" + + "You are a one-shot responder, not a conversation: one request, one " + + "reply, no follow-up picker."; diff --git a/packages/scout-agent/package.json b/packages/scout-agent/package.json index 4dcd2ca80..59f8a4e57 100644 --- a/packages/scout-agent/package.json +++ b/packages/scout-agent/package.json @@ -6,7 +6,8 @@ "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./definition": "./src/definition.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/scout-agent/src/artifact-tool.test.ts b/packages/scout-agent/src/artifact-tool.test.ts index 8d0470023..357527b80 100644 --- a/packages/scout-agent/src/artifact-tool.test.ts +++ b/packages/scout-agent/src/artifact-tool.test.ts @@ -83,7 +83,7 @@ describe("scoutArtifactTools", () => { expect(String(result.content)).toContain("Failed to persist"); }); - test("list_recent_artifacts returns the recent items", async () => { + test("list_artifacts returns the recent items", async () => { const result = await withMockFetch( () => new Response( diff --git a/packages/scout-agent/src/artifact-tool.ts b/packages/scout-agent/src/artifact-tool.ts index 10350bafc..cb3035cb8 100644 --- a/packages/scout-agent/src/artifact-tool.ts +++ b/packages/scout-agent/src/artifact-tool.ts @@ -19,7 +19,7 @@ import { } from "./artifact-client"; export const SCOUT_ARTIFACT_SAVE_TOOL = "save_artifact"; -export const SCOUT_ARTIFACT_LIST_TOOL = "list_recent_artifacts"; +export const SCOUT_ARTIFACT_LIST_TOOL = "list_artifacts"; const SaveArgs = type({ title: "string > 0", diff --git a/packages/scout-agent/src/definition.test.ts b/packages/scout-agent/src/definition.test.ts index 82ce202d9..e8828e909 100644 --- a/packages/scout-agent/src/definition.test.ts +++ b/packages/scout-agent/src/definition.test.ts @@ -33,7 +33,7 @@ describe("SCOUT_AGENT_DEFINITION", () => { "memory_list", "web_search", "save_artifact", - "list_recent_artifacts", + "list_artifacts", ]) { expect(prompt).toContain(toolName); } diff --git a/packages/scout-agent/src/system-prompt.ts b/packages/scout-agent/src/system-prompt.ts index 995c4d612..b60ac93c3 100644 --- a/packages/scout-agent/src/system-prompt.ts +++ b/packages/scout-agent/src/system-prompt.ts @@ -24,7 +24,7 @@ export const SCOUT_SYSTEM_PROMPT = [ "- When the user asks to save a note, leave a reminder, or remember something for later (for a person, deal, or topic), call memory_add with a short title and the full text. Do not refuse — you have this tool. Confirm what was saved in one line.", "- Use memory_list when the user asks what notes were recently saved, or to skim recent firm memory without a specific search query.", "- When the user asks for a write-up, brief, or summary worth keeping, call save_artifact with a short title and the full content — this persists it to the Library, pending human approval. Say plainly that it's pending approval; never claim it's saved before that approval completes.", - "- Use list_recent_artifacts when the user asks what's recently been saved to the Library, or wants to pick up a prior write-up instead of starting fresh.", + "- Use list_artifacts when the user asks what's recently been saved to the Library, or wants to pick up a prior write-up instead of starting fresh.", "- Use tools ONLY via tool calls — never write tool names or call syntax in a reply.", "- Keep answers direct and narrow. When you suggest a next step, suggest exactly ONE concrete action you can actually perform with your tools right now (search memory, search the web, save a note, save or recall an artifact) — never a menu of options, and never offer something you can't fulfill.", "- You cannot launch a multi-step diligence brief or fact-check pipeline. If asked for one, say so plainly and offer to research the question directly with the tools you have instead of pretending to start a report that will never arrive.", diff --git a/packages/tool-registry-publish/src/registry.ts b/packages/tool-registry-publish/src/registry.ts index 44e87fb4d..76c274dd6 100644 --- a/packages/tool-registry-publish/src/registry.ts +++ b/packages/tool-registry-publish/src/registry.ts @@ -38,4 +38,10 @@ export const CORBITS_TOOL_PACKAGE_DIRS: readonly string[] = [ new URL("../../web-search-tools", import.meta.url).pathname, new URL("../../granola-tools", import.meta.url).pathname, new URL("../../linear-tools", import.meta.url).pathname, + // Scout's own artifact-save/list tool bundle (`scoutArtifactTools`) and + // Jimmy's `gif_search` bundle: each package pins itself in its own + // `toolPackagePins` (`SCOUT_TOOL_PACKAGE_PINS`, `JIMMY_TOOL_PACKAGE_PINS`), + // so each must publish here too or that self-pin never resolves. + new URL("../../scout-agent", import.meta.url).pathname, + new URL("../../jimmy-agent", import.meta.url).pathname, ]; diff --git a/packages/workflow-catalog/package.json b/packages/workflow-catalog/package.json index ec4b8ffb9..5a5b36974 100644 --- a/packages/workflow-catalog/package.json +++ b/packages/workflow-catalog/package.json @@ -18,6 +18,8 @@ "@corbits/code-review": "workspace:*", "@corbits/code-review-workflow": "workspace:*", "@corbits/github-tools": "workspace:*", + "@corbits/jimmy-agent": "workspace:*", + "@corbits/scout-agent": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/hub-api": "workspace:*", "@workbench/hub-client": "workspace:*", diff --git a/packages/workflow-catalog/src/index.ts b/packages/workflow-catalog/src/index.ts index d039a9370..3bb50abc2 100644 --- a/packages/workflow-catalog/src/index.ts +++ b/packages/workflow-catalog/src/index.ts @@ -10,6 +10,7 @@ import { type } from "arktype"; export { CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, WorkbenchTemplateBlock, @@ -27,9 +28,14 @@ export { export type { WorkbenchTemplateManifest } from "./templates"; export { instantiateWorkbenchTemplate, + type ParticipantAgentRequest, type WorkbenchTemplateInstantiationPorts, type WorkbenchTemplateInstantiationResult, } from "./instantiate"; +export { + jimmyAgentRequest, + scoutAgentRequest, +} from "./participant-agent-requests"; export { TemplateReposSettingsPatch, TemplateSettingsPatch, diff --git a/packages/workflow-catalog/src/instantiate.ts b/packages/workflow-catalog/src/instantiate.ts index 9f4a1e177..600ef475d 100644 --- a/packages/workflow-catalog/src/instantiate.ts +++ b/packages/workflow-catalog/src/instantiate.ts @@ -6,26 +6,44 @@ // (today, `apps/web`'s `instant-agent-create.ts`) can bind the ports to // its own REST clients and this stays testable with plain fakes. // -// Today this only resolves a manifest whose non-Myra participants are -// backed by `@corbits/code-review`'s reviewer roster (CL-6344's -// `CODE_REVIEW_TEMPLATE`). A template like `GTM_TEMPLATE`, whose -// participants are backed by their own deployed workflow definitions -// rather than an agent-directory create request, needs its own -// resolution path — a later ticket, not this one; calling this -// function against such a manifest throws rather than silently doing -// nothing. -// `./agent-requests`, not the package root — see `./templates.ts`'s own -// comment on its `CODE_REVIEW_REVIEWERS` import for why. +// This resolves a manifest whose non-Myra participants are backed by +// either `@corbits/code-review`'s reviewer roster (CL-6344's +// `CODE_REVIEW_TEMPLATE`) or a standalone chat agent this catalog installs +// the same way — Scout, for `DUE_DILIGENCE_TEMPLATE` (see +// `./participant-agent-requests.ts`). Jimmy resolves through the identical +// `ParticipantAgentRequest` shape (`jimmyAgentRequest()`), but CL-6499 +// dropped his template — he is not a "kind of workbench" — so no shipped +// manifest names his handle today; `@corbits/chat-ui`'s "Add Jimmy" +// quick-create row calls `jimmyAgentRequest()` directly instead. Kept +// registered here too so a future template naming his handle resolves +// without new plumbing. +// A template like `GTM_TEMPLATE`, whose participants are backed by their +// own deployed workflow definitions rather than an agent-directory +// create request, needs its own resolution path — a later ticket, not +// this one; calling this function against such a manifest throws rather +// than silently doing nothing. import { codeReviewAgentRequests, type CodeReviewAgentRequest, } from "@corbits/code-review/agent-requests"; +import { + jimmyAgentRequest, + scoutAgentRequest, +} from "./participant-agent-requests"; import type { WorkbenchTemplateBlock, WorkbenchTemplateManifest, } from "./templates"; +/** The agent-directory create-request shape every participant resolves + * to: `CodeReviewAgentRequest`'s own fields, plus the tool-package pins + * a tool-calling participant (Scout, Jimmy) needs and a pure-text + * reviewer does not. */ +export type ParticipantAgentRequest = CodeReviewAgentRequest & { + readonly toolPackagePins?: readonly string[]; +}; + export interface WorkbenchTemplateInstantiationPorts { /** Every agent definition handle already deployed in the bench — * the idempotency check so re-running instantiation (a retried @@ -35,7 +53,7 @@ export interface WorkbenchTemplateInstantiationPorts { /** The agent-directory create path (`POST /agent-definitions`), or a * fake of it in tests. */ createParticipantAgent( - request: CodeReviewAgentRequest, + request: ParticipantAgentRequest, ): Promise<{ readonly id: string }>; /** Deploys one of the manifest's referenced block workflows through * the same source-form deploy the participant agents use @@ -102,8 +120,12 @@ export async function instantiateWorkbenchTemplate( ports: WorkbenchTemplateInstantiationPorts, ): Promise { const existingHandles = new Set(await ports.listAgentHandles()); - const requestsByHandle = new Map( - codeReviewAgentRequests().map((request) => [request.handle, request]), + const requestsByHandle = new Map( + [ + ...codeReviewAgentRequests(), + scoutAgentRequest(), + jimmyAgentRequest(), + ].map((request) => [request.handle, request]), ); // The manifest's referenced block workflows deploy first: a diff --git a/packages/workflow-catalog/src/participant-agent-requests.ts b/packages/workflow-catalog/src/participant-agent-requests.ts new file mode 100644 index 000000000..367f54bb6 --- /dev/null +++ b/packages/workflow-catalog/src/participant-agent-requests.ts @@ -0,0 +1,49 @@ +// Request-shaped views of this catalog's two standalone chat-agent +// participants (Scout, Jimmy), in the shape `./instantiate.ts`'s +// `createParticipantAgent` port takes — the same shape +// `@corbits/code-review/agent-requests`' `codeReviewAgentRequests` +// establishes for the reviewer roster, extended with `toolPackagePins` +// since neither Scout nor Jimmy is a pure-text agent. Built here, from +// each package's own exported definition, rather than inside +// `packages/scout-agent`/`packages/jimmy-agent` themselves: this +// package already owns "what a template participant needs to become an +// agent-directory create request" (it owns `codeReviewAgentRequests`' +// caller), so the mapping belongs beside it, not duplicated into every +// agent package that wants to be installable this way. +import { SCOUT_AGENT_DEFINITION } from "@corbits/scout-agent/definition"; +import { + JIMMY_AGENT_ID, + JIMMY_DESCRIPTION, + JIMMY_DISPLAY_NAME, + JIMMY_SYSTEM_PROMPT, + JIMMY_TOOL_PACKAGE_PINS, +} from "@corbits/jimmy-agent/metadata"; + +import type { ParticipantAgentRequest } from "./instantiate"; + +export function scoutAgentRequest(): ParticipantAgentRequest { + return { + name: SCOUT_AGENT_DEFINITION.displayName, + handle: SCOUT_AGENT_DEFINITION.handle, + description: SCOUT_AGENT_DEFINITION.description, + systemPrompt: SCOUT_AGENT_DEFINITION.systemPrompt, + toolPackagePins: SCOUT_AGENT_DEFINITION.toolPackagePins.map( + (pin) => pin.name, + ), + }; +} + +/** Jimmy's own package exports an `@intx/agent`-native `AgentDefinition` + * builder (`buildJimmyAgent`), not the `{handle, displayName, ...}` + * plain-data shape the agent-directory create path takes — it has no + * handle or display name of its own at all. `JIMMY_AGENT_ID` ("jimmy") + * is both his id and the handle a person types to reach him. */ +export function jimmyAgentRequest(): ParticipantAgentRequest { + return { + name: JIMMY_DISPLAY_NAME, + handle: JIMMY_AGENT_ID, + description: JIMMY_DESCRIPTION, + systemPrompt: JIMMY_SYSTEM_PROMPT, + toolPackagePins: JIMMY_TOOL_PACKAGE_PINS.map((pin) => pin.name), + }; +} diff --git a/packages/workflow-catalog/src/templates.ts b/packages/workflow-catalog/src/templates.ts index 67d8c5347..d406f4949 100644 --- a/packages/workflow-catalog/src/templates.ts +++ b/packages/workflow-catalog/src/templates.ts @@ -25,7 +25,11 @@ import { type } from "arktype"; // imports at all, so this subpath keeps every consumer of this // manifest (this package's whole point) off that much heavier graph. import { CODE_REVIEW_REVIEWERS } from "@corbits/code-review/reviewers"; - +import { + SCOUT_AGENT_HANDLE, + SCOUT_AGENT_DISPLAY_NAME, + SCOUT_AGENT_DESCRIPTION, +} from "@corbits/scout-agent/definition"; /** One workflow a template installs, pinned to the version it was * designed against. `assetName` matches a `WORKFLOW_CATALOG` entry. */ export const WorkbenchTemplateBlock = type({ @@ -55,12 +59,17 @@ export type WorkbenchTemplateRoutine = typeof WorkbenchTemplateRoutine.infer; /** * One agent a person can address in the created workbench. `handle` is - * what they type to reach it; `blockAssetName` is the workflow behind it. + * what they type to reach it. `blockAssetName` names the workflow behind + * it when the participant is a lens over one of the template's own + * blocks (the code-review reviewers); it is absent for a participant + * that is a standalone chat agent installed straight through the + * agent-directory create path (Scout, Jimmy) with no block of its own to + * reference. */ export const WorkbenchTemplateParticipant = type({ handle: "/^[a-z][a-z0-9-]*$/", displayName: "string > 0", - blockAssetName: "string > 0", + "blockAssetName?": "string > 0", /** One honest line: what this agent is for. */ role: "string > 0", }); @@ -283,9 +292,44 @@ export const CODE_REVIEW_TEMPLATE: WorkbenchTemplateManifest = { ], }; +/** + * The due-diligence template (CL-6499): Scout for the web/firm-memory + * research and Myra to talk through what it found. Scout is a + * standalone chat agent, not a lens over a block workflow — it has no + * cron, no webhook, nothing to schedule — so it carries no + * `blockAssetName` and this template's `blocks` list stays empty. + * Exa (Scout's web-research tool) resolves through the keyless MCP + * preset, so nothing here blocks the create on a connection. + */ +export const DUE_DILIGENCE_TEMPLATE: WorkbenchTemplateManifest = { + id: "due-diligence", + title: "Research & due diligence", + promise: + "Scout researches the web and what your team already knows, and saves what it finds so you can pick it up later.", + blocks: [], + requiredConnections: [], + optionalConnections: ["exa"], + routines: [], + webhookTriggers: [], + participants: [ + { + handle: "myra", + displayName: "Myra", + role: "Talks through what Scout found and helps you decide what to do with it.", + }, + { + handle: SCOUT_AGENT_HANDLE, + displayName: SCOUT_AGENT_DISPLAY_NAME, + role: SCOUT_AGENT_DESCRIPTION, + }, + ], + openInputs: [], +}; + export const WORKBENCH_TEMPLATES: readonly WorkbenchTemplateManifest[] = [ GTM_TEMPLATE, CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, ]; const templateById = new Map( @@ -404,7 +448,10 @@ function assertValid(template: WorkbenchTemplateManifest): void { } } for (const participant of template.participants) { - if (!blockNames.has(participant.blockAssetName)) { + if ( + participant.blockAssetName !== undefined && + !blockNames.has(participant.blockAssetName) + ) { throw new Error( `workbench template "${template.id}" participant "${participant.handle}" is backed by "${participant.blockAssetName}", which the template does not install`, ); diff --git a/packages/workflow-catalog/test/instantiate.test.ts b/packages/workflow-catalog/test/instantiate.test.ts index 5dcfe9f1c..c4e4c3f73 100644 --- a/packages/workflow-catalog/test/instantiate.test.ts +++ b/packages/workflow-catalog/test/instantiate.test.ts @@ -1,7 +1,11 @@ import { expect, test } from "bun:test"; import { CODE_REVIEW_REVIEWERS } from "@corbits/code-review/reviewers"; -import { CODE_REVIEW_TEMPLATE, GTM_TEMPLATE } from "../src/index"; +import { + CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, + GTM_TEMPLATE, +} from "../src/index"; import { instantiateWorkbenchTemplate, type WorkbenchTemplateInstantiationPorts, @@ -116,3 +120,80 @@ test("instantiating a manifest with a participant outside the reviewer roster th instantiateWorkbenchTemplate(GTM_TEMPLATE, fakePorts()), ).rejects.toThrow(/has no known create-agent request/); }); + +test("instantiating the due-diligence template creates Scout, never Myra, with no credential connected", async () => { + const ports = fakePorts(); + const result = await instantiateWorkbenchTemplate( + DUE_DILIGENCE_TEMPLATE, + ports, + ); + expect(result.createdHandles).toEqual(["scout"]); + expect(ports.created).toEqual(["scout"]); + expect(ports.created).not.toContain("myra"); + expect(result.skippedHandles).toEqual([]); + // No block to deploy and nothing required up front: seeding never + // fails for want of a connected credential (Exa's MCP preset is + // keyless, and this template requires nothing at all). + expect(ports.deployedBlocks).toEqual([]); + expect(result.pendingConnections).toEqual([]); +}); + +test("instantiating the due-diligence template twice never creates Scout a second time", async () => { + const first = fakePorts(); + await instantiateWorkbenchTemplate(DUE_DILIGENCE_TEMPLATE, first); + const second = fakePorts(["scout"]); + const result = await instantiateWorkbenchTemplate( + DUE_DILIGENCE_TEMPLATE, + second, + ); + expect(result.createdHandles).toEqual([]); + expect(result.skippedHandles).toEqual(["scout"]); + expect(second.created).toEqual([]); +}); + +test("Scout's create request carries its tool package pins", async () => { + const requests: { handle: string; toolPackagePins?: readonly string[] }[] = + []; + const ports: WorkbenchTemplateInstantiationPorts = { + async listAgentHandles() { + return []; + }, + async createParticipantAgent(request) { + requests.push(request); + return { id: `def-${request.handle}` }; + }, + async deployBlockWorkflow() { + return { created: true }; + }, + async recordPendingConnections() { + /* noop */ + }, + }; + await instantiateWorkbenchTemplate(DUE_DILIGENCE_TEMPLATE, ports); + const scout = requests.find((request) => request.handle === "scout"); + expect(scout?.toolPackagePins).toEqual( + expect.arrayContaining([ + "@corbits/memory-tools", + "@corbits/web-search-tools", + "@corbits/scout-agent", + ]), + ); +}); + +// CL-6499 dropped Jimmy's own template (he is not a "kind of workbench"); +// `@corbits/chat-ui`'s "Add Jimmy" quick-create row calls +// `jimmyAgentRequest()` directly instead of going through a manifest. This +// proves `instantiateWorkbenchTemplate`'s request map still resolves his +// handle, so a future template naming him works with no new plumbing. +test("a manifest naming Jimmy's handle still resolves and creates him", async () => { + const manifestNamingJimmy = { + ...DUE_DILIGENCE_TEMPLATE, + participants: [ + { handle: "jimmy", displayName: "Jimmy", role: "Replies with a GIF." }, + ], + }; + const ports = fakePorts(); + const result = await instantiateWorkbenchTemplate(manifestNamingJimmy, ports); + expect(result.createdHandles).toEqual(["jimmy"]); + expect(ports.created).toEqual(["jimmy"]); +}); diff --git a/packages/workflow-catalog/test/templates.test.ts b/packages/workflow-catalog/test/templates.test.ts index 7fbba30aa..d89f68144 100644 --- a/packages/workflow-catalog/test/templates.test.ts +++ b/packages/workflow-catalog/test/templates.test.ts @@ -4,6 +4,7 @@ import { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, WORKFLOW_CATALOG, @@ -167,6 +168,35 @@ test("participants are addressable by a distinct handle", () => { } }); +test("the due-diligence template's participants are Myra and Scout, neither backed by a block", () => { + expect(workbenchTemplate("due-diligence")).toBe(DUE_DILIGENCE_TEMPLATE); + expect( + DUE_DILIGENCE_TEMPLATE.participants.map( + (participant) => participant.handle, + ), + ).toEqual(["myra", "scout"]); + expect(templateBlockAssetNames(DUE_DILIGENCE_TEMPLATE)).toEqual([]); + for (const participant of DUE_DILIGENCE_TEMPLATE.participants) { + expect(participant.blockAssetName).toBeUndefined(); + } +}); + +test("the due-diligence template blocks the create on nothing — Exa is offered, never required", () => { + expect(DUE_DILIGENCE_TEMPLATE.requiredConnections).toEqual([]); + expect(DUE_DILIGENCE_TEMPLATE.optionalConnections).toEqual(["exa"]); +}); + +test("Jimmy is not a workbench template — the picker offers no such kind of workbench", () => { + expect(workbenchTemplate("default-teammates")).toBeUndefined(); + for (const template of WORKBENCH_TEMPLATES) { + expect( + template.participants.some( + (participant) => participant.handle === "jimmy", + ), + ).toBe(false); + } +}); + test("every shipped template survives the seed round trip verbatim", () => { for (const template of WORKBENCH_TEMPLATES) { const parsed = parseWorkbenchTemplateManifest(