From 5706daa627716bed0f4293abd8ac53cfaddf50c1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:46:26 -0700 Subject: [PATCH 01/17] Add tests for pinning tool packages through the agent-definition create route A template-driven create (Scout, Jimmy) needs to pin tool packages the same way a hand-authored definition never has to; these tests state the route's expected behavior ahead of adding the field. --- packages/agent-directory/test/routes.test.ts | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) 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"] { From 734e0a42eb48cac3264581d596d90779d01036f4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:46:33 -0700 Subject: [PATCH 02/17] agent-directory: accept toolPackagePins on the create-agent-definition route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person hand-authoring an agent through a form has no affordance for typing a tool-package pin, but a template-driven create does need one (Scout's memory/web-search/artifact tools, Jimmy's Giphy search) — this adds an optional, name-only field, scoped to @corbits packages, that a programmatic caller can set and the form never sees. --- packages/agent-directory/src/routes.ts | 3 ++ packages/agent-directory/src/validation.ts | 32 ++++++++++++++++------ 2 files changed, 27 insertions(+), 8 deletions(-) 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; From 821f9e04101eddbd95c96ad658a4ee0037a16c12 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:46:40 -0700 Subject: [PATCH 03/17] Add tests for seeding Scout and Jimmy through workbench templates States the expected shape ahead of adding the templates and their request wiring: a due-diligence bench creates Scout (never Myra a second time), a default-teammates bench creates Jimmy the same way, neither blocks on a connection, and re-instantiating either is a no-op skip rather than a duplicate. --- .../workflow-catalog/test/instantiate.test.ts | 89 ++++++++++++++++++- .../workflow-catalog/test/templates.test.ts | 37 ++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/workflow-catalog/test/instantiate.test.ts b/packages/workflow-catalog/test/instantiate.test.ts index 5dcfe9f1c..2b15121ca 100644 --- a/packages/workflow-catalog/test/instantiate.test.ts +++ b/packages/workflow-catalog/test/instantiate.test.ts @@ -1,7 +1,12 @@ 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, + DEFAULT_TEAMMATES_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, + GTM_TEMPLATE, +} from "../src/index"; import { instantiateWorkbenchTemplate, type WorkbenchTemplateInstantiationPorts, @@ -116,3 +121,85 @@ 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", + ]), + ); +}); + +test("instantiating the default-teammates template creates Jimmy, never Myra", async () => { + const ports = fakePorts(); + const result = await instantiateWorkbenchTemplate( + DEFAULT_TEAMMATES_TEMPLATE, + ports, + ); + expect(result.createdHandles).toEqual(["jimmy"]); + expect(ports.created).toEqual(["jimmy"]); + expect(ports.created).not.toContain("myra"); + expect(result.pendingConnections).toEqual([]); +}); + +test("instantiating the default-teammates template skips Jimmy once he already exists", async () => { + const ports = fakePorts(["jimmy"]); + const result = await instantiateWorkbenchTemplate( + DEFAULT_TEAMMATES_TEMPLATE, + ports, + ); + expect(result.skippedHandles).toEqual(["jimmy"]); + expect(result.createdHandles).toEqual([]); + expect(ports.created).toEqual([]); +}); diff --git a/packages/workflow-catalog/test/templates.test.ts b/packages/workflow-catalog/test/templates.test.ts index 7fbba30aa..01db0cc5a 100644 --- a/packages/workflow-catalog/test/templates.test.ts +++ b/packages/workflow-catalog/test/templates.test.ts @@ -4,6 +4,8 @@ import { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { CODE_REVIEW_TEMPLATE, + DEFAULT_TEAMMATES_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, WORKFLOW_CATALOG, @@ -167,6 +169,41 @@ 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("the default-teammates template's participants are Myra and Jimmy, neither backed by a block", () => { + expect(workbenchTemplate("default-teammates")).toBe( + DEFAULT_TEAMMATES_TEMPLATE, + ); + expect( + DEFAULT_TEAMMATES_TEMPLATE.participants.map( + (participant) => participant.handle, + ), + ).toEqual(["myra", "jimmy"]); + expect(templateBlockAssetNames(DEFAULT_TEAMMATES_TEMPLATE)).toEqual([]); +}); + +test("the default-teammates template blocks the create on nothing", () => { + expect(DEFAULT_TEAMMATES_TEMPLATE.requiredConnections).toEqual([]); + expect(DEFAULT_TEAMMATES_TEMPLATE.optionalConnections).toEqual([]); +}); + test("every shipped template survives the seed round trip verbatim", () => { for (const template of WORKBENCH_TEMPLATES) { const parsed = parseWorkbenchTemplateManifest( From f51ca423b3efdcfc5c9ec2dccd5ce13da2a0cc95 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:46:49 -0700 Subject: [PATCH 04/17] workflow-catalog: seed Scout and Jimmy as workbench-template participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two templates the "new workbench" picker can offer: due-diligence (Myra + Scout) and default-teammates (Myra + Jimmy). Both agents are standalone chat participants with no block workflow of their own, so `WorkbenchTemplateParticipant.blockAssetName` becomes optional rather than every participant needing one to reference. `instantiateWorkbenchTemplate`'s create-request lookup was hardcoded to the code-review reviewer roster; it now also resolves Scout's and Jimmy's own definitions into the same agent-directory create-request shape, tool-package pins included, so their creation goes through exactly the mechanism the code-review reviewers already use — same idempotency check (skip a handle that already exists), same "never recreate Myra" rule, and no required connection blocks either bench (Exa is a keyless MCP preset; Jimmy's own tool degrades to a "connect Giphy" reply rather than failing). Depends on #234 (@corbits/jimmy-agent) and #235 (@corbits/scout-agent) merging first — this package now depends on both by name. --- apps/web/src/agents-api.ts | 5 ++ packages/workflow-catalog/package.json | 2 + packages/workflow-catalog/src/index.ts | 7 ++ packages/workflow-catalog/src/instantiate.ts | 42 ++++++--- .../src/participant-agent-requests.ts | 47 ++++++++++ packages/workflow-catalog/src/templates.ts | 89 ++++++++++++++++++- 6 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 packages/workflow-catalog/src/participant-agent-requests.ts 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/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 6d4cb421e..9b83a5f8c 100644 --- a/packages/workflow-catalog/src/index.ts +++ b/packages/workflow-catalog/src/index.ts @@ -10,6 +10,8 @@ import { type } from "arktype"; export { CODE_REVIEW_TEMPLATE, + DEFAULT_TEAMMATES_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, WorkbenchTemplateBlock, @@ -27,9 +29,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..cfd209563 100644 --- a/packages/workflow-catalog/src/instantiate.ts +++ b/packages/workflow-catalog/src/instantiate.ts @@ -6,26 +6,38 @@ // (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 one of the standalone chat agents this +// catalog installs the same way — Scout (`DUE_DILIGENCE_TEMPLATE`) and +// Jimmy (`DEFAULT_TEAMMATES_TEMPLATE`), see `./participant-agent-requests.ts`. +// 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 +47,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 +114,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..efdf3c1f5 --- /dev/null +++ b/packages/workflow-catalog/src/participant-agent-requests.ts @@ -0,0 +1,47 @@ +// 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"; +import { + JIMMY_AGENT_ID, + JIMMY_SYSTEM_PROMPT, + JIMMY_TOOL_PACKAGE_PINS, +} from "@corbits/jimmy-agent"; + +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", + handle: JIMMY_AGENT_ID, + description: "Searches Giphy and replies with a GIF", + 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..33982e985 100644 --- a/packages/workflow-catalog/src/templates.ts +++ b/packages/workflow-catalog/src/templates.ts @@ -25,6 +25,12 @@ 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"; +import { JIMMY_AGENT_ID } from "@corbits/jimmy-agent"; /** One workflow a template installs, pinned to the version it was * designed against. `assetName` matches a `WORKFLOW_CATALOG` entry. */ @@ -55,12 +61,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 +294,78 @@ 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: [], +}; + +/** + * The default-teammates template (CL-6499): Myra plus Jimmy, the one + * other standalone chat agent this catalog has today — "not necessarily + * specialists, just a group of work" per the owner's own framing. Jimmy + * needs a Giphy credential to actually find a GIF; his own tool degrades + * to a plain "connect Giphy" reply rather than failing, so nothing here + * blocks the create on a connection either. + */ +export const DEFAULT_TEAMMATES_TEMPLATE: WorkbenchTemplateManifest = { + id: "default-teammates", + title: "Default teammates", + promise: + "Myra plus a couple of teammates to have around — start here and grow the room as you go.", + blocks: [], + requiredConnections: [], + optionalConnections: [], + routines: [], + webhookTriggers: [], + participants: [ + { + handle: "myra", + displayName: "Myra", + role: "The teammate who helps you set the rest of this room up.", + }, + { + handle: JIMMY_AGENT_ID, + displayName: "Jimmy", + role: "Mention him with a request and he replies with a GIF.", + }, + ], + openInputs: [], +}; + export const WORKBENCH_TEMPLATES: readonly WorkbenchTemplateManifest[] = [ GTM_TEMPLATE, CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, + DEFAULT_TEAMMATES_TEMPLATE, ]; const templateById = new Map( @@ -404,7 +484,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`, ); From 13ef5869281ac8bde87beba25db8c7abec103db2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 08:46:57 -0700 Subject: [PATCH 05/17] tool-registry-publish: publish Scout's and Jimmy's own tool bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both packages pin themselves in their own toolPackagePins (Scout's artifact save/list tools, Jimmy's gif_search) — without an entry here that self-pin can never resolve to a real tarball or a tool:* grant. Note for review: registering @corbits/scout-agent here surfaces a real defect in it, not introduced by this change — its artifact-tool bundle id ("@corbits/scout-agent/artifacts") combined with "scout_list_recent_artifacts" produces a 71-char qualified tool name, over this package's own 64-char OpenAI-compatible cap (tool-name-limits.test.ts). That needs a fix on the Scout side (a shorter bundle id or tool name) before this package's suite is green with Scout's directory present. --- packages/tool-registry-publish/src/registry.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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, ]; From 58b7876cc1b0044c69b0358c05d3f98ecf4542f8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 11:25:07 -0700 Subject: [PATCH 06/17] Sync lockfile with main --- bun.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bun.lock b/bun.lock index 261d34bf9..259251fab 100644 --- a/bun.lock +++ b/bun.lock @@ -1477,6 +1477,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:*", @@ -3436,6 +3438,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@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=="], + "@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=="], @@ -3458,6 +3462,8 @@ "@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=="], + "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=="], From 17ae9fdde1bf6d93609289b7b1a48b2b9e22a71f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 11:39:40 -0700 Subject: [PATCH 07/17] Split agent metadata out of the tool modules so the browser bundle stays clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template catalog is browser-reachable and imported each agent package's index, which re-exports tool bodies that import defineTool from @intx/agent — whose module graph reaches node:path. Jimmy's plain-data identity moves to its own module and Scout's already-clean definition gets a subpath, so the catalog imports identity without pulling tool bodies. --- packages/jimmy-agent/package.json | 3 +- packages/jimmy-agent/src/agent.ts | 34 +++------------- .../jimmy-agent/src/gif-search-tool.test.ts | 4 +- packages/jimmy-agent/src/gif-search-tool.ts | 2 +- packages/jimmy-agent/src/index.ts | 10 +++-- packages/jimmy-agent/src/metadata.ts | 39 +++++++++++++++++++ packages/scout-agent/package.json | 3 +- .../src/participant-agent-requests.ts | 10 +++-- packages/workflow-catalog/src/templates.ts | 4 +- 9 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 packages/jimmy-agent/src/metadata.ts 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..2d9133aa0 100644 --- a/packages/jimmy-agent/src/agent.ts +++ b/packages/jimmy-agent/src/agent.ts @@ -9,35 +9,13 @@ // 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 { + GIF_SEARCH_TOOL, + 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..b495773c0 100644 --- a/packages/jimmy-agent/src/gif-search-tool.test.ts +++ b/packages/jimmy-agent/src/gif-search-tool.test.ts @@ -2,8 +2,10 @@ 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"; +import { GIF_SEARCH_TOOL } from "./metadata"; const CALL: ToolCall = { id: "call_1", diff --git a/packages/jimmy-agent/src/gif-search-tool.ts b/packages/jimmy-agent/src/gif-search-tool.ts index 6d5cb5d32..d3e497952 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"; 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/workflow-catalog/src/participant-agent-requests.ts b/packages/workflow-catalog/src/participant-agent-requests.ts index efdf3c1f5..367f54bb6 100644 --- a/packages/workflow-catalog/src/participant-agent-requests.ts +++ b/packages/workflow-catalog/src/participant-agent-requests.ts @@ -10,12 +10,14 @@ // 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"; +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"; +} from "@corbits/jimmy-agent/metadata"; import type { ParticipantAgentRequest } from "./instantiate"; @@ -38,9 +40,9 @@ export function scoutAgentRequest(): ParticipantAgentRequest { * is both his id and the handle a person types to reach him. */ export function jimmyAgentRequest(): ParticipantAgentRequest { return { - name: "Jimmy", + name: JIMMY_DISPLAY_NAME, handle: JIMMY_AGENT_ID, - description: "Searches Giphy and replies with a GIF", + 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 33982e985..578f16a17 100644 --- a/packages/workflow-catalog/src/templates.ts +++ b/packages/workflow-catalog/src/templates.ts @@ -29,8 +29,8 @@ import { SCOUT_AGENT_HANDLE, SCOUT_AGENT_DISPLAY_NAME, SCOUT_AGENT_DESCRIPTION, -} from "@corbits/scout-agent"; -import { JIMMY_AGENT_ID } from "@corbits/jimmy-agent"; +} from "@corbits/scout-agent/definition"; +import { JIMMY_AGENT_ID } from "@corbits/jimmy-agent/metadata"; /** One workflow a template installs, pinned to the version it was * designed against. `assetName` matches a `WORKFLOW_CATALOG` entry. */ From 914420a24cae3aca8eaf81e7d510ae95a9f40af0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 11:48:31 -0700 Subject: [PATCH 08/17] Drop the now-unused tool-name import from Jimmy's agent module --- packages/jimmy-agent/src/agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jimmy-agent/src/agent.ts b/packages/jimmy-agent/src/agent.ts index 2d9133aa0..1d44cdf16 100644 --- a/packages/jimmy-agent/src/agent.ts +++ b/packages/jimmy-agent/src/agent.ts @@ -11,7 +11,6 @@ import type { AgentDefinition, InferencePreference } from "@intx/agent"; import { - GIF_SEARCH_TOOL, JIMMY_AGENT_ID, JIMMY_SYSTEM_PROMPT, JIMMY_TOOL_PACKAGE_PINS, From 1dab047a115029f12a4eb5be8f3b2b318d6e9222 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 13:34:55 -0700 Subject: [PATCH 09/17] Drop the duplicated metadata import in Jimmy's tool test --- packages/jimmy-agent/src/gif-search-tool.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jimmy-agent/src/gif-search-tool.test.ts b/packages/jimmy-agent/src/gif-search-tool.test.ts index b495773c0..1da91de9b 100644 --- a/packages/jimmy-agent/src/gif-search-tool.test.ts +++ b/packages/jimmy-agent/src/gif-search-tool.test.ts @@ -5,7 +5,6 @@ import type { CredentialCapability, MediatedCredential } from "@intx/types"; import { gifSearchTool } from "./gif-search-tool"; import { GIF_SEARCH_TOOL } from "./metadata"; import type { GifSearchEnv } from "./gif-search-tool"; -import { GIF_SEARCH_TOOL } from "./metadata"; const CALL: ToolCall = { id: "call_1", From 54a166bd8af83ae932e250f58c5d5cdca43dfdba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:14:31 -0700 Subject: [PATCH 10/17] Add tests expecting Scout's artifact-list tool renamed to fit the tool-name cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @corbits/scout-agent's list_recent_artifacts tool, qualified with its bundle id, encodes to 65 chars on the wire — one over the 64-char OpenAI-compatible cap tool-registry-publish's tool-name-limits suite enforces. These tests assert the shorter list_artifacts name instead. --- packages/scout-agent/src/artifact-tool.test.ts | 2 +- packages/scout-agent/src/definition.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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); } From c231cc23c485782b4c0cbff03c44c602db33180b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:14:37 -0700 Subject: [PATCH 11/17] Rename Scout's list_recent_artifacts tool to list_artifacts Fixes the structural and build-test CI failures: the qualified name "@corbits/scout-agent/artifacts:list_recent_artifacts" encodes to 65 chars on the wire, one over the 64-char OpenAI-compatible cap every corbits tool package is checked against. Shortening the tool name (rather than the bundle id) keeps the fix scoped to this one bundle. --- packages/scout-agent/src/artifact-tool.ts | 2 +- packages/scout-agent/src/system-prompt.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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.", From c8af0e34525c97893d93666a03e5898644f39372 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:14:42 -0700 Subject: [PATCH 12/17] Add tests for gif_search's missing-credential-detail contract Asserts the "connect Giphy" result carries {kind: "missing-credential", connectorId: "giphy"} in its detail field, not just a plain error string. --- packages/jimmy-agent/src/gif-search-tool.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/jimmy-agent/src/gif-search-tool.test.ts b/packages/jimmy-agent/src/gif-search-tool.test.ts index 1da91de9b..2a47a33ef 100644 --- a/packages/jimmy-agent/src/gif-search-tool.test.ts +++ b/packages/jimmy-agent/src/gif-search-tool.test.ts @@ -80,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 () => { @@ -87,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 () => { From 0abfa77fd8583fda329ba8d05985bd0366bd15f3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:14:48 -0700 Subject: [PATCH 13/17] Wire gif_search's "not connected" result through missing-credential-detail Jimmy's Giphy tool degraded to a plain error string when no Giphy credential was bound. It now also carries the missing-credential-detail wire shape (@corbits/connections' contract), so the chat orchestrator renders a real "Connect Giphy" card instead of dead-ending on text. The shape is reproduced locally rather than imported, matching @corbits/github-tools' own convention: no dependency on the connections package is needed to produce it, only the orchestrator needs to parse it. --- packages/jimmy-agent/src/gif-search-tool.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/jimmy-agent/src/gif-search-tool.ts b/packages/jimmy-agent/src/gif-search-tool.ts index d3e497952..fcd2d81a4 100644 --- a/packages/jimmy-agent/src/gif-search-tool.ts +++ b/packages/jimmy-agent/src/gif-search-tool.ts @@ -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), }; } From 28a98e826ff583617f5b398d8856de9cd5c8abb9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:14:56 -0700 Subject: [PATCH 14/17] Remove the default-teammates workbench template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jimmy is not a "kind of workbench" — he's a single teammate, not a research bench or a code-review bench. Per the owner's own framing, he should be an agent that's easy to add to any workbench, not a picker entry that mints a whole new room. The due-diligence template (Scout) stays: it's a genuine kind of workbench, modeled on code-review's own template shape. jimmyAgentRequest() and instantiateWorkbenchTemplate's participant lookup keep resolving his handle, so a future template naming him still works with no new plumbing — only the picker entry is gone. Jimmy's actual create path moves to chat-ui's invite dialog (next commits). --- packages/workflow-catalog/src/index.ts | 1 - packages/workflow-catalog/src/instantiate.ts | 12 +++++-- packages/workflow-catalog/src/templates.ts | 36 ------------------- .../workflow-catalog/test/instantiate.test.ts | 32 +++++++---------- .../workflow-catalog/test/templates.test.ts | 25 +++++-------- 5 files changed, 31 insertions(+), 75 deletions(-) diff --git a/packages/workflow-catalog/src/index.ts b/packages/workflow-catalog/src/index.ts index 8ed5ca41d..3bb50abc2 100644 --- a/packages/workflow-catalog/src/index.ts +++ b/packages/workflow-catalog/src/index.ts @@ -10,7 +10,6 @@ import { type } from "arktype"; export { CODE_REVIEW_TEMPLATE, - DEFAULT_TEAMMATES_TEMPLATE, DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, diff --git a/packages/workflow-catalog/src/instantiate.ts b/packages/workflow-catalog/src/instantiate.ts index cfd209563..600ef475d 100644 --- a/packages/workflow-catalog/src/instantiate.ts +++ b/packages/workflow-catalog/src/instantiate.ts @@ -8,9 +8,15 @@ // // 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 one of the standalone chat agents this -// catalog installs the same way — Scout (`DUE_DILIGENCE_TEMPLATE`) and -// Jimmy (`DEFAULT_TEAMMATES_TEMPLATE`), see `./participant-agent-requests.ts`. +// `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 diff --git a/packages/workflow-catalog/src/templates.ts b/packages/workflow-catalog/src/templates.ts index 578f16a17..d406f4949 100644 --- a/packages/workflow-catalog/src/templates.ts +++ b/packages/workflow-catalog/src/templates.ts @@ -30,8 +30,6 @@ import { SCOUT_AGENT_DISPLAY_NAME, SCOUT_AGENT_DESCRIPTION, } from "@corbits/scout-agent/definition"; -import { JIMMY_AGENT_ID } from "@corbits/jimmy-agent/metadata"; - /** One workflow a template installs, pinned to the version it was * designed against. `assetName` matches a `WORKFLOW_CATALOG` entry. */ export const WorkbenchTemplateBlock = type({ @@ -328,44 +326,10 @@ export const DUE_DILIGENCE_TEMPLATE: WorkbenchTemplateManifest = { openInputs: [], }; -/** - * The default-teammates template (CL-6499): Myra plus Jimmy, the one - * other standalone chat agent this catalog has today — "not necessarily - * specialists, just a group of work" per the owner's own framing. Jimmy - * needs a Giphy credential to actually find a GIF; his own tool degrades - * to a plain "connect Giphy" reply rather than failing, so nothing here - * blocks the create on a connection either. - */ -export const DEFAULT_TEAMMATES_TEMPLATE: WorkbenchTemplateManifest = { - id: "default-teammates", - title: "Default teammates", - promise: - "Myra plus a couple of teammates to have around — start here and grow the room as you go.", - blocks: [], - requiredConnections: [], - optionalConnections: [], - routines: [], - webhookTriggers: [], - participants: [ - { - handle: "myra", - displayName: "Myra", - role: "The teammate who helps you set the rest of this room up.", - }, - { - handle: JIMMY_AGENT_ID, - displayName: "Jimmy", - role: "Mention him with a request and he replies with a GIF.", - }, - ], - openInputs: [], -}; - export const WORKBENCH_TEMPLATES: readonly WorkbenchTemplateManifest[] = [ GTM_TEMPLATE, CODE_REVIEW_TEMPLATE, DUE_DILIGENCE_TEMPLATE, - DEFAULT_TEAMMATES_TEMPLATE, ]; const templateById = new Map( diff --git a/packages/workflow-catalog/test/instantiate.test.ts b/packages/workflow-catalog/test/instantiate.test.ts index 2b15121ca..c4e4c3f73 100644 --- a/packages/workflow-catalog/test/instantiate.test.ts +++ b/packages/workflow-catalog/test/instantiate.test.ts @@ -3,7 +3,6 @@ import { CODE_REVIEW_REVIEWERS } from "@corbits/code-review/reviewers"; import { CODE_REVIEW_TEMPLATE, - DEFAULT_TEAMMATES_TEMPLATE, DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, } from "../src/index"; @@ -181,25 +180,20 @@ test("Scout's create request carries its tool package pins", async () => { ); }); -test("instantiating the default-teammates template creates Jimmy, never Myra", async () => { +// 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( - DEFAULT_TEAMMATES_TEMPLATE, - ports, - ); + const result = await instantiateWorkbenchTemplate(manifestNamingJimmy, ports); expect(result.createdHandles).toEqual(["jimmy"]); expect(ports.created).toEqual(["jimmy"]); - expect(ports.created).not.toContain("myra"); - expect(result.pendingConnections).toEqual([]); -}); - -test("instantiating the default-teammates template skips Jimmy once he already exists", async () => { - const ports = fakePorts(["jimmy"]); - const result = await instantiateWorkbenchTemplate( - DEFAULT_TEAMMATES_TEMPLATE, - ports, - ); - expect(result.skippedHandles).toEqual(["jimmy"]); - expect(result.createdHandles).toEqual([]); - expect(ports.created).toEqual([]); }); diff --git a/packages/workflow-catalog/test/templates.test.ts b/packages/workflow-catalog/test/templates.test.ts index 01db0cc5a..d89f68144 100644 --- a/packages/workflow-catalog/test/templates.test.ts +++ b/packages/workflow-catalog/test/templates.test.ts @@ -4,7 +4,6 @@ import { MCP_PRESETS } from "@workbench/connections/mcp-presets"; import { CODE_REVIEW_TEMPLATE, - DEFAULT_TEAMMATES_TEMPLATE, DUE_DILIGENCE_TEMPLATE, GTM_TEMPLATE, WORKBENCH_TEMPLATES, @@ -187,21 +186,15 @@ test("the due-diligence template blocks the create on nothing — Exa is offered expect(DUE_DILIGENCE_TEMPLATE.optionalConnections).toEqual(["exa"]); }); -test("the default-teammates template's participants are Myra and Jimmy, neither backed by a block", () => { - expect(workbenchTemplate("default-teammates")).toBe( - DEFAULT_TEAMMATES_TEMPLATE, - ); - expect( - DEFAULT_TEAMMATES_TEMPLATE.participants.map( - (participant) => participant.handle, - ), - ).toEqual(["myra", "jimmy"]); - expect(templateBlockAssetNames(DEFAULT_TEAMMATES_TEMPLATE)).toEqual([]); -}); - -test("the default-teammates template blocks the create on nothing", () => { - expect(DEFAULT_TEAMMATES_TEMPLATE.requiredConnections).toEqual([]); - expect(DEFAULT_TEAMMATES_TEMPLATE.optionalConnections).toEqual([]); +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", () => { From ff514755143459cfcdbf41284e2656caa3698fa0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:15:02 -0700 Subject: [PATCH 15/17] Add tests for the invite dialog's Jimmy quick-create row Covers quickCreateJimmy's request shape and the InviteAgentDialog's "Add Jimmy" row: it appears only when Jimmy is absent from the tenant's invitable list, and clicking it creates and then invites him through the same onInvite seam every other row uses. --- packages/chat-ui/test/api.test.ts | 15 ++ .../chat-ui/test/invite-agent-dialog.test.tsx | 130 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 packages/chat-ui/test/invite-agent-dialog.test.tsx 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); + }); +}); From a078f88c17a1d6055ee10867859da35f82ee0f34 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:15:11 -0700 Subject: [PATCH 16/17] Add an "Add Jimmy" quick-create row to the invite-agent dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jimmy is no longer seeded through a workbench template (previous commit), so this is his one remaining create path: InviteAgentDialog now offers a quick-create row whenever the tenant's invitable list is missing his handle. Clicking it calls quickCreateJimmy (the same one-shot POST /agent-definitions a template's participant create used to go through, using jimmyAgentRequest()'s own request shape) and then invites the created definition through the dialog's existing onInvite seam — reusing its loading/error/close handling as-is. @corbits/workflow-catalog moves from a devDependency to a runtime one: jimmyAgentRequest() is now called from production code (api.ts), not just tests. --- bun.lock | 14 ++++- packages/chat-ui/package.json | 2 +- packages/chat-ui/src/api.ts | 27 ++++++++++ packages/chat-ui/src/invite-agent-dialog.tsx | 54 +++++++++++++++++++- packages/chat-ui/src/strings.ts | 3 ++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 806d8c9bf..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:", @@ -3491,8 +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=="], @@ -3517,6 +3527,8 @@ "@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/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", From 87acc135dd588114eec4dc1caee8fce0be7a2f50 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:15:17 -0700 Subject: [PATCH 17/17] Update docs: Jimmy's quick-create path and connect-Giphy detail contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records that Jimmy installs via chat-ui's "Add Jimmy" row rather than a template, and that his "not connected" result now carries the missing-credential-detail contract — while being explicit that a real "Connect Giphy" flow still needs a CONNECTOR_REGISTRY entry and a query-param credential-provider plugin, neither of which exist yet. --- packages/jimmy-agent/README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) 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