From 4f8e6a73f516fc63edaa27cb66072253ad4ee8fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:27:35 -0700 Subject: [PATCH 1/7] Add tests for create_agent minting the specialist's own DM --- .../agent-directory-tools/src/client.test.ts | 44 +++++ .../agent-directory-tools/src/tool.test.ts | 29 ++- .../test/workbenches-mutated-event.test.ts | 57 +++++- packages/chat/src/participants.test.ts | 30 +++ .../chat/test/start-workflow-command.test.ts | 40 +++- packages/chat/test/workbench-service.test.ts | 116 ++++++++++- packages/chat/test/workbench-settings.test.ts | 30 ++- .../test/workflow-participant-routes.test.ts | 181 ++++++++++++++++++ packages/evals/src/scorers/scorers.test.ts | 31 ++- workflows/assistant/test/definition.test.ts | 8 +- 10 files changed, 545 insertions(+), 21 deletions(-) create mode 100644 packages/chat/src/participants.test.ts diff --git a/packages/agent-directory-tools/src/client.test.ts b/packages/agent-directory-tools/src/client.test.ts index 279785f50..f23b2f91b 100644 --- a/packages/agent-directory-tools/src/client.test.ts +++ b/packages/agent-directory-tools/src/client.test.ts @@ -5,7 +5,9 @@ import { CreateAgentDefinitionError, inviteParticipant, listAgentDefinitions, + mintAgentDm, NoOwnChannelError, + NoOwnWorkbenchError, type AgentDirectoryToolClientConfig, } from "./client"; @@ -196,3 +198,45 @@ test("inviteParticipant throws NoOwnChannelError on a 404", async () => { inviteParticipant(testConfig(fetchImpl), "def_1"), ).rejects.toBeInstanceOf(NoOwnChannelError); }); + +test("mintAgentDm posts to the workflow-chat participants mint-dm endpoint", async () => { + let seenUrl: string | undefined; + let seenBody: unknown; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + seenUrl = String(url); + seenBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + workbenchId: "wb_1", + address: "ins_1@acme.example", + definitionId: "def_1", + handle: "research-buddy", + }), + { status: 201 }, + ); + }) as unknown as typeof fetch; + + const result = await mintAgentDm(testConfig(fetchImpl), "def_1"); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-chat/participants/mint-dm", + ); + expect(seenBody).toEqual({ definitionId: "def_1" }); + expect(result.workbenchId).toBe("wb_1"); + expect(result.address).toBe("ins_1@acme.example"); + expect(result.definitionId).toBe("def_1"); + expect(result.handle).toBe("research-buddy"); +}); + +test("mintAgentDm throws NoOwnWorkbenchError on a 404", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + error: { code: "not_found", message: "no workbench found" }, + }), + { status: 404 }, + )) as unknown as typeof fetch; + + await expect( + mintAgentDm(testConfig(fetchImpl), "def_1"), + ).rejects.toBeInstanceOf(NoOwnWorkbenchError); +}); diff --git a/packages/agent-directory-tools/src/tool.test.ts b/packages/agent-directory-tools/src/tool.test.ts index d4bd7f45c..c3f5e85f1 100644 --- a/packages/agent-directory-tools/src/tool.test.ts +++ b/packages/agent-directory-tools/src/tool.test.ts @@ -144,7 +144,7 @@ test("list_agents reports honestly when the workbench has no other agents", asyn } }); -test("create_agent creates then invites by default, in one call sequence", async () => { +test("create_agent creates then mints its own DM by default, in one call sequence", async () => { const originalFetch = globalThis.fetch; const seenUrls: string[] = []; globalThis.fetch = (async (url: string | URL) => { @@ -165,6 +165,7 @@ test("create_agent creates then invites by default, in one call sequence", async } return new Response( JSON.stringify({ + workbenchId: "wb_1", address: "ins_1@acme.example", definitionId: "def_1", handle: "research-buddy", @@ -183,17 +184,21 @@ test("create_agent creates then invites by default, in one call sequence", async ); expect(result.isError).toBeFalsy(); expect(result.content).toMatch(/Created "Research Buddy"/); - expect(result.content).toMatch(/invited/); + expect(result.content).toMatch(/opened its own chat/); + expect(result.content).toMatch(/workbenchId/); expect(seenUrls.some((url) => url.endsWith("/definitions"))).toBe(true); - expect(seenUrls.some((url) => url.endsWith("/participants/invite"))).toBe( + expect(seenUrls.some((url) => url.endsWith("/participants/mint-dm"))).toBe( true, ); + expect(seenUrls.some((url) => url.endsWith("/participants/invite"))).toBe( + false, + ); } finally { globalThis.fetch = originalFetch; } }); -test("create_agent with invite: false creates but never calls the invite route", async () => { +test("create_agent with invite: false creates but never calls mint-dm or invite", async () => { const originalFetch = globalThis.fetch; const seenUrls: string[] = []; globalThis.fetch = (async (url: string | URL) => { @@ -226,12 +231,15 @@ test("create_agent with invite: false creates but never calls the invite route", expect(seenUrls.some((url) => url.endsWith("/participants/invite"))).toBe( false, ); + expect(seenUrls.some((url) => url.endsWith("/participants/mint-dm"))).toBe( + false, + ); } finally { globalThis.fetch = originalFetch; } }); -test("create_agent reports a create-succeeded/invite-failed half-failure honestly, never as a plain error", async () => { +test("create_agent reports a create-succeeded/mint-failed half-failure honestly, never as a plain error", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (url: string | URL) => { if (String(url).endsWith("/definitions")) { @@ -249,7 +257,7 @@ test("create_agent reports a create-succeeded/invite-failed half-failure honestl ); } return new Response( - JSON.stringify({ error: { code: "not_found", message: "no channel" } }), + JSON.stringify({ error: { code: "not_found", message: "no workbench" } }), { status: 404 }, ); }) as unknown as typeof fetch; @@ -264,7 +272,7 @@ test("create_agent reports a create-succeeded/invite-failed half-failure honestl ); expect(result.isError).toBeFalsy(); expect(result.content).toMatch(/Created "Research Buddy"/); - expect(result.content).toMatch(/could not invite/); + expect(result.content).toMatch(/could not open its own chat/); } finally { globalThis.fetch = originalFetch; } @@ -291,6 +299,7 @@ test("create_agent maps modelPreference to the create route's model field", asyn } return new Response( JSON.stringify({ + workbenchId: "wb_1", address: "ins_1@acme.example", definitionId: "def_1", handle: "research-buddy", @@ -316,7 +325,7 @@ test("create_agent maps modelPreference to the create route's model field", asyn } }); -test("create_agent surfaces a model fallback note in its content, and still completes the invite, rather than producing a silently dead agent", async () => { +test("create_agent surfaces a model fallback note in its content, and still opens the specialist's own chat, rather than producing a silently dead agent", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async (url: string | URL) => { if (String(url).endsWith("/definitions")) { @@ -336,6 +345,7 @@ test("create_agent surfaces a model fallback note in its content, and still comp } return new Response( JSON.stringify({ + workbenchId: "wb_1", address: "ins_1@acme.example", definitionId: "def_1", handle: "research-buddy", @@ -355,7 +365,8 @@ test("create_agent surfaces a model fallback note in its content, and still comp ); expect(result.isError).toBeFalsy(); expect(result.content).toMatch(/Created "Research Buddy"/); - expect(result.content).toMatch(/invited/); + expect(result.content).toMatch(/opened its own chat/); + expect(result.content).toMatch(/workbenchId/); expect(result.content).toMatch(/gpt-4o/); expect(result.content).toMatch(/ollama\/llama3/); } finally { diff --git a/packages/chat-ui/test/workbenches-mutated-event.test.ts b/packages/chat-ui/test/workbenches-mutated-event.test.ts index 12bc44791..0b414b3f3 100644 --- a/packages/chat-ui/test/workbenches-mutated-event.test.ts +++ b/packages/chat-ui/test/workbenches-mutated-event.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { WORKBENCHES_MUTATED_EVENT, createWorkbench } from "../src/api"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + WORKBENCHES_MUTATED_EVENT, + applyStreamWorkbenchesMutated, + createWorkbench, +} from "../src/api"; + +GlobalRegistrator.register(); const realFetch = globalThis.fetch; afterEach(() => { @@ -60,3 +67,51 @@ describe("createWorkbench mutation event", () => { expect(fired).toBe(0); }); }); + +describe("applyStreamWorkbenchesMutated", () => { + test("valid { tenantId } dispatches WORKBENCHES_MUTATED_EVENT with that tenantId", () => { + const seen: string[] = []; + const listener = (event: Event) => { + seen.push((event as CustomEvent<{ tenantId: string }>).detail.tenantId); + }; + window.addEventListener(WORKBENCHES_MUTATED_EVENT, listener); + try { + applyStreamWorkbenchesMutated({ tenantId: "tnt_1" }); + } finally { + window.removeEventListener(WORKBENCHES_MUTATED_EVENT, listener); + } + expect(seen).toEqual(["tnt_1"]); + }); + + test("garbage / missing tenantId does not dispatch", () => { + let fired = 0; + const listener = () => { + fired += 1; + }; + window.addEventListener(WORKBENCHES_MUTATED_EVENT, listener); + try { + applyStreamWorkbenchesMutated(undefined); + applyStreamWorkbenchesMutated(null); + applyStreamWorkbenchesMutated({}); + applyStreamWorkbenchesMutated({ tenantId: 1 }); + applyStreamWorkbenchesMutated("tnt_1"); + } finally { + window.removeEventListener(WORKBENCHES_MUTATED_EVENT, listener); + } + expect(fired).toBe(0); + }); + + test("extra keys in data still dispatch (forward-compatible)", () => { + const seen: string[] = []; + const listener = (event: Event) => { + seen.push((event as CustomEvent<{ tenantId: string }>).detail.tenantId); + }; + window.addEventListener(WORKBENCHES_MUTATED_EVENT, listener); + try { + applyStreamWorkbenchesMutated({ tenantId: "tnt_1", extra: "ok" }); + } finally { + window.removeEventListener(WORKBENCHES_MUTATED_EVENT, listener); + } + expect(seen).toEqual(["tnt_1"]); + }); +}); diff --git a/packages/chat/src/participants.test.ts b/packages/chat/src/participants.test.ts new file mode 100644 index 000000000..805bd23a9 --- /dev/null +++ b/packages/chat/src/participants.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { addParticipant, dedupeHandle, handleFromName } from "./participants"; + +describe("addParticipant", () => { + test("appends a new address and de-duplicates the handle", () => { + const existing = [{ address: "a@x", handle: "echo" }]; + expect(addParticipant(existing, "b@x", "echo")).toEqual([ + { address: "a@x", handle: "echo" }, + { address: "b@x", handle: "echo-2" }, + ]); + }); + + test("same-address retries return the existing list by identity", () => { + const existing = [{ address: "a@x", handle: "echo" }]; + expect(addParticipant(existing, "a@x", "echo")).toBe(existing); + }); +}); + +describe("handleFromName / dedupeHandle", () => { + test("slugs a display name and falls back to the address local part", () => { + expect(handleFromName("Content Researcher", "run_1@x")).toBe( + "content-researcher", + ); + expect(handleFromName("!!!", "run_scout@x")).toBe("run_scout"); + }); + + test("suffixes the first free handle", () => { + expect(dedupeHandle("echo", new Set(["echo", "echo-2"]))).toBe("echo-3"); + }); +}); diff --git a/packages/chat/test/start-workflow-command.test.ts b/packages/chat/test/start-workflow-command.test.ts index c4135d1ef..19387ab13 100644 --- a/packages/chat/test/start-workflow-command.test.ts +++ b/packages/chat/test/start-workflow-command.test.ts @@ -3,7 +3,7 @@ // workflow-command registrar: invite-then-send, sharing the same // `launchAndJoinAgent` core as `POST .../invite`. import { describe, expect, test } from "bun:test"; -import { startWorkflowCommand } from "../src/workbench-service"; +import { KindIsChatError, startWorkflowCommand } from "../src/workbench-service"; import { createInMemoryChatStore } from "../src/store"; import { createInMemoryRoomMessageStore } from "../src/room-messages"; import { fakePlatform, TENANT } from "./test-support"; @@ -198,4 +198,42 @@ describe("startWorkflowCommand", () => { expect(platform.launchInviteCalls).toHaveLength(0); expect(result.address).toBe("ins_existing@acme.example"); }); + + test("throws KindIsChatError when a kind: chat already has a different agent", async () => { + const store = createInMemoryChatStore(); + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + }); + await store.createWorkbenchSettings({ + tenantId: TENANT.id, + workbenchId: "chan_dm", + settings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_assistant", + "chat/participants": [ + { address: "ins_myra@acme.example", handle: "myra" }, + ], + }, + updatedBy: "prn_alice", + }); + + await expect( + startWorkflowCommand( + { + store, + platform, + roomMessages: createInMemoryRoomMessageStore(), + publish: () => undefined, + }, + { + tenantId: TENANT.id, + principalId: "prn_alice", + workbenchId: "chan_dm", + definitionId: "wfd_echo", + args: "hi", + }, + ), + ).rejects.toBeInstanceOf(KindIsChatError); + expect(platform.launchInviteCalls).toHaveLength(0); + }); }); diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index 70fa21472..647c3dc37 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -9,7 +9,8 @@ import { decodeParts } from "../src/codec"; import type { Part } from "../src/parts"; import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; -import { cannedGreeting, postCannedGreeting } from "../src/workbench-service"; +import { cannedGreeting, KindIsChatError, launchAndJoinAgent, postCannedGreeting } from "../src/workbench-service"; +import { createInMemoryChatStore } from "../src/store"; import { buildDeps, createWorkbench, @@ -1267,10 +1268,21 @@ describe("POST /workbenches/:id/invite", () => { ).toHaveLength(0); }); - test("inviting a second agent into a chat grows it — no invite cap", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), + test("re-inviting the same definition into a chat reuses the resident — no extra launch", async () => { + let launches = 0; + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, + launchInvite: async () => { + launches += 1; + return { + instanceId: `ins_invited${launches}`, + address: `ins_invited${launches}@acme.example`, + }; + }, }); + const deps = buildDeps({ platform }); const app = mountAs(createChatRoutes(deps), "prn_alice"); const { body: workbench } = await createWorkbench(app, { kind: "chat", @@ -1286,6 +1298,102 @@ describe("POST /workbenches/:id/invite", () => { expect(response.status).toBe(201); const body = (await response.json()) as { address: string }; expect(body.address).toBe("ins_invited1@acme.example"); + expect(platform.launchInviteCalls).toHaveLength(1); + }); + + test("inviting a different agent into a chat is a 409 kind_is_chat", async () => { + const platform = fakePlatform({ + invitable: [ + { id: "wfd_echo", name: "Echo" }, + { id: "wfd_copywriter", name: "Copywriter" }, + ], + }); + const deps = buildDeps({ platform }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "chat", + definitionId: "wfd_echo", + }); + expect(platform.launchInviteCalls).toHaveLength(1); + + const response = await app.request(`/workbenches/${workbench.id}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_copywriter" }), + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("kind_is_chat"); + expect(platform.launchInviteCalls).toHaveLength(1); + }); +}); + +describe("launchAndJoinAgent 1:1 chats", () => { + const invitable = [ + { id: "wfd_echo", name: "Echo" }, + { id: "wfd_copywriter", name: "Copywriter" }, + ]; + + test("throws KindIsChatError when a chat already has a different agent", async () => { + const platform = fakePlatform({ invitable }); + await expect( + launchAndJoinAgent( + { + store: createInMemoryChatStore(), + platform, + roomMessages: createInMemoryRoomMessageStore(), + publish: () => undefined, + }, + { + tenantId: TENANT.id, + principalId: "prn_alice", + workbenchId: "chan_1", + definitionId: "wfd_copywriter", + existingSettings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_echo", + "chat/participants": [ + { address: "ins_echo@acme.example", handle: "echo" }, + ], + }, + invitable, + }, + ), + ).rejects.toBeInstanceOf(KindIsChatError); + expect(platform.launchInviteCalls).toHaveLength(0); + }); + + test("returns the resident when the chat already holds this definition", async () => { + const platform = fakePlatform({ + invitable, + resolveDefinitionIdByAddress: async (address) => + address === "ins_echo@acme.example" ? "wfd_echo" : undefined, + }); + const joined = await launchAndJoinAgent( + { + store: createInMemoryChatStore(), + platform, + roomMessages: createInMemoryRoomMessageStore(), + publish: () => undefined, + }, + { + tenantId: TENANT.id, + principalId: "prn_alice", + workbenchId: "chan_1", + definitionId: "wfd_echo", + existingSettings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_echo", + "chat/participants": [ + { address: "ins_echo@acme.example", handle: "echo" }, + ], + }, + invitable, + }, + ); + expect(joined.address).toBe("ins_echo@acme.example"); + expect(platform.launchInviteCalls).toHaveLength(0); }); }); diff --git a/packages/chat/test/workbench-settings.test.ts b/packages/chat/test/workbench-settings.test.ts index b7707f591..2f64a29a7 100644 --- a/packages/chat/test/workbench-settings.test.ts +++ b/packages/chat/test/workbench-settings.test.ts @@ -12,13 +12,13 @@ import { import { buildDeps, createWorkbench, + fakePlatform, mountAs, sendText, TENANT, timelineEvents, timelineOf, } from "./test-support"; -import type { fakePlatform } from "./test-support"; describe("chat/contextWindow", () => { test("a window of 2 keeps only the last 2 prior messages in the block", async () => { @@ -337,6 +337,34 @@ describe("PATCH /workbenches/:id/settings", () => { expect(response.status).toBe(400); }); + test("refuses to PATCH chat/participants on a kind: chat", async () => { + const deps = buildDeps({ + platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "chat", + definitionId: "wfd_echo", + }); + + const response = await app.request( + `/workbenches/${workbench.id}/settings`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + "chat/participants": [ + { address: "ins_extra@acme.example", handle: "extra" }, + ], + }), + }, + ); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("kind_is_chat"); + }); + test("chat/purpose round-trips through PATCH /workbenches/:id/settings", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index 56d38773f..242d0ae8d 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -18,6 +18,7 @@ import { } from "../src/workflow-participant-routes"; import { createInMemoryTurnClaimStore } from "../src/turn-claims"; import { createWorkbenchTurnQueue } from "../src/turn-queue"; +import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { fakePlatform, TENANT } from "./test-support"; const RUN_ID = "run_1"; @@ -54,6 +55,7 @@ function buildApp( publish, }), authenticator: overrides.authenticator ?? authenticateAsRun, + tenancy: overrides.tenancy ?? createInMemoryWorkbenchTenancyStore(), }) as unknown as Hono; } @@ -138,6 +140,35 @@ test("invites the named definition into the caller's own workbench, resolved fro ]); }); +test("inviting a different agent into the caller's kind: chat is a 409 kind_is_chat", async () => { + const store = createInMemoryChatStore(); + await store.createWorkbenchSettings({ + tenantId: TENANT.id, + workbenchId: "chan_dm", + settings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_assistant", + "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], + }, + updatedBy: "prn_1", + }); + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + }); + + const app = buildApp({ store, platform }); + const response = await app.request("/participants/invite", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("kind_is_chat"); + expect(platform.launchInviteCalls).toHaveLength(0); +}); + describe("POST /participants/messages", () => { test("is a 401 without a recognized run credential", async () => { const app = buildApp(); @@ -341,3 +372,153 @@ describe("POST /participants/messages", () => { expect(updated?.settings["connections/pending"]).toBeUndefined(); }); }); + +describe("POST /participants/mint-dm", () => { + test("is a 401 without a recognized run credential", async () => { + const app = buildApp(); + const response = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + expect(response.status).toBe(401); + }); + + test("a run whose address is not a participant of any workbench is a 404", async () => { + const app = buildApp(); + const response = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + expect(response.status).toBe(404); + }); + + test("an invalid body is a 400", async () => { + const app = buildApp(); + const response = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({}), + }); + expect(response.status).toBe(400); + }); + + test("is a 500 owner_unresolved when the parent tenant has no owner grant", async () => { + const store = createInMemoryChatStore(); + await store.createWorkbenchSettings({ + tenantId: TENANT.id, + workbenchId: "chan_dm", + settings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_assistant", + "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], + }, + updatedBy: "prn_1", + }); + const tenancy = createInMemoryWorkbenchTenancyStore(); + tenancy.registerExistingTenant(TENANT.id); + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + }); + + const app = buildApp({ store, platform, tenancy }); + const response = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + + expect(response.status).toBe(500); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("owner_unresolved"); + expect(platform.launchInviteCalls).toHaveLength(0); + }); + + test("mints a new kind:chat 1:1 and publishes chat.workbenches-mutated on the caller workbench", async () => { + const store = createInMemoryChatStore(); + const callerWorkbenchId = "chan_dm"; + await store.createWorkbenchSettings({ + tenantId: TENANT.id, + workbenchId: callerWorkbenchId, + settings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_assistant", + "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], + }, + updatedBy: "prn_1", + }); + const tenancy = createInMemoryWorkbenchTenancyStore(); + tenancy.registerExistingTenant(TENANT.id); + tenancy.grantManageInTenant("usr_alice", TENANT.id); + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + }); + const published: { workbenchId: string; event: unknown }[] = []; + const publish = (workbenchId: string, event: unknown) => { + published.push({ workbenchId, event }); + }; + + const app = buildApp({ store, platform, tenancy, publish }); + const response = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { + workbenchId: string; + address: string; + definitionId: string; + handle: string; + }; + expect(typeof body.workbenchId).toBe("string"); + expect(body.workbenchId).not.toBe(callerWorkbenchId); + expect(body.address).toBe("ins_invited1@acme.example"); + expect(body.definitionId).toBe("wfd_echo"); + expect(body.handle).toBe("echo"); + expect(platform.launchInviteCalls).toEqual([ + { + tenantId: TENANT.id, + creatorPrincipalId: "prn_1", + definitionId: "wfd_echo", + }, + ]); + + const minted = await store.getWorkbenchSettings( + TENANT.id, + body.workbenchId, + ); + expect(minted?.settings["chat/kind"]).toBe("chat"); + expect(minted?.settings["chat/definitionId"]).toBe("wfd_echo"); + expect(minted?.settings["chat/participants"]).toEqual([ + { address: "ins_invited1@acme.example", handle: "echo" }, + ]); + + // Caller DM is unchanged — mint never invites into Myra's chat. + const caller = await store.getWorkbenchSettings( + TENANT.id, + callerWorkbenchId, + ); + expect(caller?.settings["chat/participants"]).toEqual([ + { address: RUN_ADDRESS, handle: "myra" }, + ]); + + const mutated = published.filter( + (entry) => + (entry.event as { type?: string }).type === + "chat.workbenches-mutated" && + entry.workbenchId === callerWorkbenchId, + ); + expect(mutated).toHaveLength(1); + expect( + published.some( + (entry) => + (entry.event as { type?: string }).type === + "chat.workbenches-mutated" && + entry.workbenchId === body.workbenchId, + ), + ).toBe(false); + }); +}); diff --git a/packages/evals/src/scorers/scorers.test.ts b/packages/evals/src/scorers/scorers.test.ts index b81a499ef..cc9489af9 100644 --- a/packages/evals/src/scorers/scorers.test.ts +++ b/packages/evals/src/scorers/scorers.test.ts @@ -178,17 +178,42 @@ describe("memoryWritten", () => { }); describe("agentCreatedInWorkbench", () => { - test("passes when create_agent succeeded and its result shows an invite", () => { + test("passes when create_agent minted the specialist's own chat", () => { const transcript = [ turn("make a researcher", "Done.", [ - call("create_agent", {}, { result: "invited into this workbench" }), + call("create_agent", {}, { + result: + 'Created "Researcher" (use this id for routines/dispatch: def_1). workbenchId=wb_abc minted their own chat.', + }), + ]), + ]; + const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); + expect(r.pass).toBe(true); + }); + + test("passes when the result has a created/workbench id and no invite-into-current fields", () => { + const transcript = [ + turn("make a researcher", "Done.", [ + call("create_agent", {}, { + result: '{"id":"def_1","workbenchId":"wb_abc","created":true}', + }), ]), ]; const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); expect(r.pass).toBe(true); }); - test("fails when create_agent succeeded but its result shows no invite", () => { + test("fails when create_agent only invited into the current workbench", () => { + const transcript = [ + turn("make a researcher", "Done.", [ + call("create_agent", {}, { result: "invited into this workbench" }), + ]), + ]; + const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); + expect(r.pass).toBe(false); + }); + + test("fails when create_agent succeeded but its result shows no own chat", () => { const transcript = [ turn("make a researcher", "Done.", [ call("create_agent", {}, { result: "created" }), diff --git a/workflows/assistant/test/definition.test.ts b/workflows/assistant/test/definition.test.ts index ab6ddc5b8..a02fbb95d 100644 --- a/workflows/assistant/test/definition.test.ts +++ b/workflows/assistant/test/definition.test.ts @@ -222,9 +222,11 @@ test("the prompt asks only for facts Myra can't infer, never permission to use t test("the prompt builds the whole team on the person's OK: agents, routines, and memory in one go", () => { expect(ASSISTANT_SYSTEM_PROMPT).toContain( "On their OK, build the whole thing in one go: create the " + - "specialists and invite them in, create the routines, and save " + - "the facts they gave you to memory", + "specialists (each gets their own chat), create the routines, " + + "and save the facts they gave you to memory", ); + expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite them in"); + expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("create_channel"); }); test("the prompt has a delegated specialist finish its thread with a summary back to the host/main", () => { @@ -298,4 +300,6 @@ test("the prompt hands a built team off with the exact discovery closing line", "Their own chats for focused work. Here when you want me to run " + "the hunt and hand things off.", ); + expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite them into this"); + expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite it into this"); }); From 5e7756620ba8c0e30638344f6d2619d02a5cef42 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:27:39 -0700 Subject: [PATCH 2/7] create_agent mints the specialist's own DM instead of inviting into the caller's chat --- apps/hub/src/index.ts | 15 +- packages/agent-directory-tools/package.json | 2 +- packages/agent-directory-tools/src/client.ts | 59 +++++ packages/agent-directory-tools/src/index.ts | 3 + packages/agent-directory-tools/src/tool.ts | 54 ++--- packages/chat-ui/src/api.ts | 28 +++ packages/chat-ui/src/chat-workspace.tsx | 6 + packages/chat-ui/src/index.ts | 2 + packages/chat-ui/src/use-workbench-stream.ts | 4 + .../test/workbenches-mutated-event.test.ts | 4 +- packages/chat/src/index.ts | 5 + packages/chat/src/participants.ts | 5 + packages/chat/src/routes.ts | 47 +++- packages/chat/src/workbench-service.ts | 220 +++++++++++++++++- packages/chat/src/workbench-tenancy.ts | 42 ++++ .../chat/src/workflow-participant-routes.ts | 124 +++++++++- packages/chat/test/workbench-service.test.ts | 2 +- packages/evals/src/cases/ai-daily-research.ts | 4 +- packages/evals/src/scorers/scorers.ts | 40 ++-- workflows/assistant/src/index.ts | 8 +- 20 files changed, 595 insertions(+), 79 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 66db0cc6d..08251c316 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1558,13 +1558,13 @@ export async function createHub(config: HubConfig) { sidecarRouter.sendAgentUndeploy(address, reason), }; app.route(`${TENANT_PREFIX}/chat`, createChatRoutes(chatDeps)); - // Myra's own workbench-invite surface (`@corbits/agent-directory-tools`' - // `create_agent`'s `invite: true` default): the workflow-run- - // authenticated counterpart to `POST .../invite` above, self-WORKBENCH - // scoped — see `@corbits/chat`'s `workflow-participant-routes.ts` for - // the [Intx/repo gap] this resolves around (no direct run-address -> - // workbench index; resolved by scanning the tenant's workbench - // participant lists). + // Myra's workflow-run chat surfaces (`@corbits/agent-directory-tools`' + // `create_agent` default mint-dm + invite for non-chat kinds): the + // workflow-run-authenticated counterpart to browser chat routes, + // self-WORKBENCH scoped — see `@corbits/chat`'s + // `workflow-participant-routes.ts` for the [Intx/repo gap] this resolves + // around (no direct run-address -> workbench index; resolved by scanning + // the tenant's workbench participant lists). app.route( "/api/workflow-chat", createWorkflowParticipantRoutes({ @@ -1574,6 +1574,7 @@ export async function createHub(config: HubConfig) { publish: workbenchSubscribers.publish, turnQueue, authenticator: createWorkflowRunAuthenticator({ db }), + tenancy: chatTenancy, }), ); // Slack tag ingress (CL-5288 Phase 1): mounted OUTSIDE the tenant diff --git a/packages/agent-directory-tools/package.json b/packages/agent-directory-tools/package.json index 1fede8d7d..b1c1eeebf 100644 --- a/packages/agent-directory-tools/package.json +++ b/packages/agent-directory-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/agent-directory-tools", "private": true, "description": "Myra's manager tools: list_agents and create_agent as an @intx/agent tool bundle — creates a new specialist agent definition in the caller's own tenant and, by default, invites it into the caller's own channel. Creation is free; the reactor never parks the create/invite pair", - "version": "0.0.4", + "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/agent-directory-tools/src/client.ts b/packages/agent-directory-tools/src/client.ts index 0d3b29c59..dc3696da5 100644 --- a/packages/agent-directory-tools/src/client.ts +++ b/packages/agent-directory-tools/src/client.ts @@ -67,6 +67,15 @@ export interface InvitedParticipant { readonly handle: string; } +/** Response from `POST .../participants/mint-dm` — the specialist's own + * 1:1 chat workbench plus the launched participant identity. */ +export interface MintedAgentDm { + readonly workbenchId: string; + readonly address: string; + readonly definitionId: string; + readonly handle: string; +} + function authHeaders( config: AgentDirectoryToolClientConfig, ): Record { @@ -230,3 +239,53 @@ export async function inviteParticipant( } return parsed; } + +/** Thrown when the caller's run has no own workbench to mint a DM + * against — the workflow-participant mint-dm route's 404 — distinct + * from a bare transport/HTTP failure so `create_agent` can report the + * created-but-not-minted half-failure honestly rather than a generic + * error. */ +export class NoOwnWorkbenchError extends Error {} + +const MintedAgentDmResponse = type({ + workbenchId: "string", + address: "string", + definitionId: "string", + handle: "string", +}); + +export async function mintAgentDm( + config: AgentDirectoryToolClientConfig, + definitionId: string, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + `${config.hubChatUrl}/api/workflow-chat/participants/mint-dm`, + { + method: "POST", + headers: { ...authHeaders(config), "content-type": "application/json" }, + body: JSON.stringify({ definitionId }), + }, + ); + if (response.status === 404) { + throw new NoOwnWorkbenchError( + await readErrorMessage( + response, + "The caller has no own workbench to mint a DM against", + ), + ); + } + if (!response.ok) { + throw new Error( + `Minting the agent DM failed: ${response.status} ${response.statusText}`, + ); + } + const body: unknown = await response.json(); + const parsed = MintedAgentDmResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `Mint-DM response did not match the expected shape: ${parsed.summary}`, + ); + } + return parsed; +} diff --git a/packages/agent-directory-tools/src/index.ts b/packages/agent-directory-tools/src/index.ts index b177ff3f2..7f9c3f421 100644 --- a/packages/agent-directory-tools/src/index.ts +++ b/packages/agent-directory-tools/src/index.ts @@ -3,12 +3,15 @@ export { CreateAgentDefinitionError, inviteParticipant, listAgentDefinitions, + mintAgentDm, NoOwnChannelError, + NoOwnWorkbenchError, type AgentDirectoryToolClientConfig, type CreateAgentDefinitionRequest, type CreatedAgentDefinition, type InvitedParticipant, type ListedAgentDefinition, + type MintedAgentDm, } from "./client"; export { agentDirectoryTools, diff --git a/packages/agent-directory-tools/src/tool.ts b/packages/agent-directory-tools/src/tool.ts index 32ff86459..073f3ac6b 100644 --- a/packages/agent-directory-tools/src/tool.ts +++ b/packages/agent-directory-tools/src/tool.ts @@ -1,13 +1,13 @@ // The `@corbits/agent-directory-tools` bundle: Myra's manager tools — // `list_agents`, a plain read of the tenant's taskable agents, and // `create_agent`, which materializes a brand-new specialist agent -// definition and, by default, invites it straight into the channel -// Myra is talking in. Neither tool carries an `approval` key: creation -// is free and the reactor never parks the call. Creation plus the -// default invite is tenant-internal — a new definition in the caller's -// own tenant, then (unless opted out) an invite into the caller's own -// channel. `toolPackagePins` on that definition are pins the user -// asked Myra to set, not a capability grant that needs a +// definition and, by default, opens that specialist its own `kind: chat` +// 1:1 (mint-dm) — never inviting into Myra's current DM. Neither tool +// carries an `approval` key: creation is free and the reactor never parks +// the call. Creation plus the default mint is tenant-internal — a new +// definition in the caller's own tenant, then (unless opted out) a fresh +// chat for that definition. `toolPackagePins` on that definition are pins +// the user asked Myra to set, not a capability grant that needs a // per-invocation gate. State-changing MCP tools and run-now/execution // stay gated on their own declarations. // @@ -16,10 +16,10 @@ // ground `@corbits/memory-tools`'/`@corbits/capability-tools`' own env // keys are threaded from. // -// See `./client.ts` for the two workflow-run-authenticated routes this +// See `./client.ts` for the workflow-run-authenticated routes this // bundle's execution calls: `@corbits/agent-directory`'s // `createWorkflowAgentCreateRoutes` and `@corbits/chat`'s -// `createWorkflowParticipantRoutes`. +// `createWorkflowParticipantRoutes` (mint-dm + invite). import { defineTool } from "@intx/agent"; import type { BaseEnv } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; @@ -28,9 +28,9 @@ import { type } from "arktype"; import { createAgentDefinition, CreateAgentDefinitionError, - inviteParticipant, listAgentDefinitions, - NoOwnChannelError, + mintAgentDm, + NoOwnWorkbenchError, type AgentDirectoryToolClientConfig, type CreateAgentDefinitionRequest, } from "./client"; @@ -169,40 +169,40 @@ async function runCreateAgent( // model it originally asked for. const modelSuffix = created.modelNote !== null ? ` ${created.modelNote}` : ""; - // `invite` defaults to `true` — never require the model to pass it, - // only to opt out explicitly. - const shouldInvite = call.arguments["invite"] !== false; - if (!shouldInvite) { + // `invite` defaults to `true` — open the specialist's own 1:1 chat. + // Pass `false` to create the definition only. + const shouldMint = call.arguments["invite"] !== false; + if (!shouldMint) { return { callId: call.id, isError: false, - content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}). It is not in this channel — invite it explicitly if you want it here.${modelSuffix}`, + content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}). It has no chat of its own yet — call again without invite:false to open one.${modelSuffix}`, }; } try { - await inviteParticipant(clientConfig(env), created.id); + const minted = await mintAgentDm(clientConfig(env), created.id); return { callId: call.id, isError: false, - content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}) and invited it into this channel.${modelSuffix}`, + content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}) and opened its own chat (workbenchId: ${minted.workbenchId}).${modelSuffix}`, }; } catch (err) { // The agent was genuinely created — that half-success must never // be dropped or reported as a bare error. A completed (not error) - // result whose content names the create-succeeded/invite-failed + // result whose content names the create-succeeded/mint-failed // split, and why, so the model can relay it honestly rather than // claiming either full success or total failure. const reason = - err instanceof NoOwnChannelError - ? "this channel could not be identified as the caller's own" + err instanceof NoOwnWorkbenchError + ? "this workbench could not be identified as the caller's own" : err instanceof Error ? err.message : String(err); return { callId: call.id, isError: false, - content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}), but could not invite it into this channel: ${reason}.${modelSuffix}`, + content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}), but could not open its own chat: ${reason}.${modelSuffix}`, }; } } @@ -232,8 +232,9 @@ export const agentDirectoryTools = defineTool({ description: "Create a brand-new specialist agent in this workbench, with " + "its own name and system prompt, and — unless told not to — " + - "invite it straight into this channel. Use this only for a " + - "genuine, specific need; never speculatively.", + "open that specialist its own 1:1 chat (not an invite into " + + "the current DM). Use this only for a genuine, specific " + + "need; never speculatively.", inputSchema: { type: "object", properties: { @@ -274,9 +275,10 @@ export const agentDirectoryTools = defineTool({ invite: { type: "boolean", description: - "Whether to invite the new agent into this channel " + + "Whether to open the new agent's own 1:1 chat " + "immediately after creating it. Defaults to true — " + - "pass false to create the agent without inviting it.", + "pass false to create the agent without opening a chat. " + + "Does not invite into the current DM.", }, }, required: ["name", "systemPrompt"], diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 8e849c3d3..6a0f31415 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -369,6 +369,34 @@ export type CreateWorkbenchInput = * in the sidebar without waiting for an unrelated refetch. */ export const WORKBENCHES_MUTATED_EVENT = "workbench:chat:workbenches-mutated"; +/** SSE `event.type` the chat service publishes onto a workbench stream when + * the tenant's workbench list changed (a specialist minted in the + * background, a create from another tab). The host sidebar already + * invalidates on `WORKBENCHES_MUTATED_EVENT`; `applyStreamWorkbenchesMutated` + * is the bridge from this stream payload onto that same CustomEvent. */ +export const WORKBENCHES_MUTATED_STREAM_TYPE = "chat.workbenches-mutated"; + +const WorkbenchesMutatedStreamData = type({ + tenantId: "string", + "+": "ignore", +}); + +/** Parses a `chat.workbenches-mutated` SSE payload and, on success, fires + * `WORKBENCHES_MUTATED_EVENT` with `{tenantId}` so the shell sidebar + * refetches. Parse failure is a no-op — a malformed stream event must + * never throw into the EventSource handler. Extra keys are ignored so + * the server can grow the payload without breaking older clients. */ +export function applyStreamWorkbenchesMutated(data: unknown): void { + const parsed = WorkbenchesMutatedStreamData(data); + if (parsed instanceof type.errors) return; + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(WORKBENCHES_MUTATED_EVENT, { + detail: { tenantId: parsed.tenantId }, + }), + ); +} + export function createWorkbench( tenantId: string, input: CreateWorkbenchInput, diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index d714e45bf..afa54f7ec 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -39,6 +39,8 @@ import { unpinMessage, workbenchStreamUrl, isKnownWorkbenchKind, + WORKBENCHES_MUTATED_STREAM_TYPE, + applyStreamWorkbenchesMutated, } from "./api"; import type { Workbench, ParticipantRecord, Part } from "./api"; import { WorkbenchSettingsSurface } from "./workbench-settings"; @@ -956,6 +958,10 @@ function ChatWorkspaceInner({ } break; } + case WORKBENCHES_MUTATED_STREAM_TYPE: { + applyStreamWorkbenchesMutated(data); + break; + } } }, refreshFeed, diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 50e1aff40..564bed2b5 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -146,6 +146,8 @@ export { workbenchesQueryKey, workbenchesQueryKeyPrefix, WORKBENCHES_MUTATED_EVENT, + WORKBENCHES_MUTATED_STREAM_TYPE, + applyStreamWorkbenchesMutated, createWorkbench, listMessages, sendMessage, diff --git a/packages/chat-ui/src/use-workbench-stream.ts b/packages/chat-ui/src/use-workbench-stream.ts index 400857409..26bfd1013 100644 --- a/packages/chat-ui/src/use-workbench-stream.ts +++ b/packages/chat-ui/src/use-workbench-stream.ts @@ -135,6 +135,9 @@ export function useWorkbenchStream( // turn lifecycle) that sit between them. "chat.presence"/ // "chat.presence.snapshot" are the who's-here roster (CL-6328), piggybacked // on this same connection rather than a second stream. + // "chat.workbenches-mutated" tells the host sidebar the tenant's + // workbench list changed (a specialist minted while this stream is + // open) — still this workbench's SSE, not a tenant-wide stream. for (const eventType of [ "chat.message", "chat.agent", @@ -144,6 +147,7 @@ export function useWorkbenchStream( "chat.pin", "chat.presence", "chat.presence.snapshot", + "chat.workbenches-mutated", "message", ]) { source.addEventListener(eventType, forward(eventType)); diff --git a/packages/chat-ui/test/workbenches-mutated-event.test.ts b/packages/chat-ui/test/workbenches-mutated-event.test.ts index 0b414b3f3..6691c8b41 100644 --- a/packages/chat-ui/test/workbenches-mutated-event.test.ts +++ b/packages/chat-ui/test/workbenches-mutated-event.test.ts @@ -7,7 +7,9 @@ import { createWorkbench, } from "../src/api"; -GlobalRegistrator.register(); +if (typeof document === "undefined") { + GlobalRegistrator.register(); +} const realFetch = globalThis.fetch; afterEach(() => { diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index fd9692b23..c29dc2744 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -228,6 +228,8 @@ export { DEFAULT_TURN_DISPATCH_TIMEOUT_MS, turnDispatchTimeoutMessage, launchAndJoinAgent, + KindIsChatError, + mintAgentDm, postCannedGreeting, cannedGreeting, sendWorkbenchMessage, @@ -238,6 +240,9 @@ export type { LaunchAndJoinAgentDeps, LaunchAndJoinAgentInput, LaunchAndJoinAgentResult, + MintAgentDmDeps, + MintAgentDmInput, + MintAgentDmResult, PostCannedGreetingDeps, PostCannedGreetingInput, CannedGreetingInput, diff --git a/packages/chat/src/participants.ts b/packages/chat/src/participants.ts index 9a2446070..feaad7272 100644 --- a/packages/chat/src/participants.ts +++ b/packages/chat/src/participants.ts @@ -85,12 +85,17 @@ export function dedupeHandle( /** * Appends a new participant to an existing record list, de-duplicating * the desired handle against every handle already in the workbench. + * Same-address retries return the existing list by identity so a caller + * can tell "already present" apart from "appended a row". */ export function addParticipant( existing: readonly ParticipantRecord[], address: string, desiredHandle: string, ): ParticipantRecord[] { + if (existing.some((participant) => participant.address === address)) { + return existing as ParticipantRecord[]; + } const taken = new Set(existing.map((participant) => participant.handle)); const handle = dedupeHandle(desiredHandle, taken); return [...existing, { address, handle }]; diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 196da3ab5..8cfb30069 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -70,6 +70,7 @@ import { findResidentAgentForDefinition, postCannedGreeting, joinHumanParticipant, + KindIsChatError, launchAndJoinAgent, removeWorkbenchParticipant, sendWorkbenchMessage, @@ -2031,6 +2032,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 409, ); } + if (err instanceof KindIsChatError) { + return c.json(ErrorEnvelope(err.code, err.message), 409); + } throw err; } continue; @@ -2082,12 +2086,24 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // resolving it against the registry only runs once it is // confirmed not to name a known handle, so that mention keeps // its ordinary fan-out behavior exactly as before. - const commandDecision = await dispatchWorkbenchCommand(deps, { - tenantId: ownerTenantId, - principalId: principal.id, - workbenchId, - text: textOf(messageParts), - }); + const commandDecision = await (async () => { + try { + return await dispatchWorkbenchCommand(deps, { + tenantId: ownerTenantId, + principalId: principal.id, + workbenchId, + text: textOf(messageParts), + }); + } catch (err) { + if (err instanceof KindIsChatError) { + return c.json(ErrorEnvelope(err.code, err.message), 409); + } + throw err; + } + })(); + if (commandDecision instanceof Response) { + return commandDecision; + } if (commandDecision !== undefined && "command" in commandDecision) { const commandResult = commandDecision.command; const resultText = textForCommandResult(commandResult); @@ -2828,6 +2844,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { if (err instanceof DefinitionProjectionMissingError) { return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); } + if (err instanceof KindIsChatError) { + return c.json(ErrorEnvelope(err.code, err.message), 409); + } throw err; } }, @@ -2889,10 +2908,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // The removal counterpart to `POST .../invite` (and to the inline // join a chat's own creation runs): drops a participant record and, // for an invited agent, releases its launched instance — see - // `workbench-service.ts`'s `removeWorkbenchParticipant`. A chat's - // participants are fixed at creation exactly as `POST .../invite` - // already refuses to grow them, so removal from a `kind: "chat"` - // workbench is refused the same way, with the same 409 shape. + // `workbench-service.ts`'s `removeWorkbenchParticipant`. A chat is + // 1:1: `launchAndJoinAgent` reuses the same definition and refuses + // a different agent, so removal from a `kind: "chat"` workbench is + // refused the same way. app.delete( "/workbenches/:id/participants/:address", deps.requireGrant(idResource("workflow-run", "id"), "manage"), @@ -3400,6 +3419,14 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { throw err; } + if ( + kindOf(existing.settings) === "chat" && + patch["chat/participants"] !== undefined + ) { + const refusal = new KindIsChatError(); + return c.json(ErrorEnvelope(refusal.code, refusal.message), 409); + } + // `chat/participants` is normalized to records on write even when // a caller PATCHes it with bare addresses (as the settings-control // wire path does) — settings always hold records, never strings. diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index ee38afc31..34b0c6e89 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -31,6 +31,7 @@ import { } from "./participants"; import { benchContextWindowOf, + kindOf, participantsOf, resolveContextWindow, } from "./workbench-settings"; @@ -141,6 +142,189 @@ export async function provisionSpaceWorkbench( }; } +const mintAgentDmLog = getLogger(["chat", "mint-agent-dm"]); + +export type MintAgentDmDeps = { + readonly tenancy: Pick< + WorkbenchTenancyStore, + "createWorkbenchTenant" | "compensateWorkbenchTenant" + >; + readonly store: Pick< + ChatStore, + | "createWorkbenchSettings" + | "deleteWorkbenchSettings" + | "updateWorkbenchSettings" + >; + readonly platform: LaunchAndJoinAgentDeps["platform"]; + readonly roomMessages: LaunchAndJoinAgentDeps["roomMessages"]; + readonly publish: LaunchAndJoinAgentDeps["publish"]; +}; + +export type MintAgentDmInput = { + /** Parent bench tenant id (`scope.tenantId`) — settings and launches + * are scoped here, matching `POST /workbenches` agent-DM mint. */ + readonly tenantId: string; + /** Myra's (caller's) workbench id — receives `chat.workbenches-mutated` + * so the sidebar refreshes without joining the new agent into this DM. */ + readonly callerWorkbenchId: string; + /** Caller's principal id — settings `updatedBy` / launch creator. */ + readonly callerPrincipalId: string; + /** Human auth user `refId` — required by `createWorkbenchTenant`. */ + readonly creatorUserId: string; + readonly definitionId: string; + /** Optional title; else invitable description / definition name. */ + readonly name?: string; +}; + +export type MintAgentDmResult = { + readonly workbenchId: string; + readonly address: string; + readonly definitionId: string; + readonly handle: string; + readonly displayName: string; +}; + +/** + * Mints a brand-new `kind: "chat"` 1:1 for `definitionId` under the + * caller's bench, launches the agent into THAT workbench (never into + * `callerWorkbenchId`), and publishes `chat.workbenches-mutated` on the + * caller's workbench so the sidebar picks up the new chat. The + * workflow-run counterpart of `POST /workbenches` agent-DM mint — + * `create_agent`'s default path uses this instead of inviting into + * Myra's own DM (which is itself `kind: chat` and rejects additional + * agents via `KindIsChatError`). + */ +export async function mintAgentDm( + deps: MintAgentDmDeps, + input: MintAgentDmInput, +): Promise { + const invitable = await deps.platform.listInvitableDefinitions( + input.tenantId, + ); + const matched = invitable.find( + (definition) => definition.id === input.definitionId, + ); + const chatTitle = + input.name ?? matched?.description ?? matched?.name ?? undefined; + + const workbenchId = generateId("workflowRun"); + const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ + parentTenantId: input.tenantId, + workbenchId, + name: chatTitle ?? workbenchId, + creatorUserId: input.creatorUserId, + }); + + async function compensateMint(err: unknown, phase: string): Promise { + mintAgentDmLog.error( + "Agent DM {phase} failed for {workbenchId} after minting " + + "{tenantId}; compensating the orphaned tenant and settings: {cause}", + { + phase, + workbenchId, + tenantId: workbenchTenant.tenantId, + cause: err instanceof Error ? err.message : String(err), + err, + }, + ); + try { + await deps.store.deleteWorkbenchSettings(input.tenantId, workbenchId); + await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); + } catch (compensationErr) { + mintAgentDmLog.error( + "Compensation failed for orphaned tenant {tenantId} after " + + "workbench {workbenchId}'s {phase} failure; this tenant is now " + + "a privileged orphan and requires manual cleanup", + { + phase, + workbenchId, + tenantId: workbenchTenant.tenantId, + compensationErr, + }, + ); + } + } + + const preset = presetForKind("chat"); + const baseSettings: Record = { + "chat/kind": "chat", + "chat/pinned": preset.pinned, + "chat/participants": [], + "chat/definitionId": input.definitionId, + }; + const settings: Record = + chatTitle !== undefined + ? { ...baseSettings, "chat/name": chatTitle } + : baseSettings; + + let row; + try { + row = await deps.store.createWorkbenchSettings({ + tenantId: input.tenantId, + workbenchId, + settings, + updatedBy: input.callerPrincipalId, + }); + } catch (err) { + await compensateMint(err, "settings write"); + throw err; + } + + let joined: LaunchAndJoinAgentResult; + try { + joined = await launchAndJoinAgent( + { + store: deps.store, + platform: deps.platform, + roomMessages: deps.roomMessages, + publish: deps.publish, + }, + { + tenantId: input.tenantId, + principalId: input.callerPrincipalId, + workbenchId, + definitionId: input.definitionId, + existingSettings: row.settings, + invitable, + }, + ); + } catch (err) { + await compensateMint(err, "agent mint"); + throw err; + } + + if (chatTitle === undefined) { + await deps.store.updateWorkbenchSettings({ + tenantId: input.tenantId, + workbenchId, + settings: { ...joined.settings, "chat/name": joined.handle }, + updatedBy: input.callerPrincipalId, + }); + } + + deps.publish(input.callerWorkbenchId, { + type: "chat.workbenches-mutated", + data: { tenantId: input.tenantId }, + }); + + // Fire-and-forget pre-warm — never block mint+join+publish on deploy. + void deps.platform.ensureAwake(joined.address).catch((err: unknown) => { + mintAgentDmLog.error( + "Pre-warm deploy failed for minted agent DM {workbenchId}'s agent " + + "{address}; the next message to it retries the wake: {err}", + { workbenchId, address: joined.address, err }, + ); + }); + + return { + workbenchId, + address: joined.address, + definitionId: joined.definitionId, + handle: joined.handle, + displayName: joined.displayName, + }; +} + export type LaunchAndJoinAgentDeps = { readonly store: Pick; readonly platform: WorkbenchLauncher; @@ -255,6 +439,21 @@ export async function resolveInvitedDisplayName( return displayName; } +/** + * A `kind: chat` is 1:1. Same-definition invite reuses the resident + * (CL-6978) and never reaches this error; a different or additional + * agent belongs on a workbench, not a DM. + */ +export class KindIsChatError extends Error { + readonly code = "kind_is_chat" as const; + constructor() { + super( + "a chat is 1:1; adding another agent is only for workbenches", + ); + this.name = "KindIsChatError"; + } +} + /** * The invite core: launches the definition's own instance (or reuses * the tenant's standing run for that agent), derives its friendly @@ -266,10 +465,13 @@ export async function resolveInvitedDisplayName( * * Room-local first (CL-6978): if this room already holds a participant * launched from the definition, return that handle/address without - * launching and without appending a second row — `addParticipant` does - * not de-dupe addresses. Tenant-wide, `launchInvite` reuses the + * launching and without appending a second row — `addParticipant` + * de-dupes the same address by identity. Tenant-wide, `launchInvite` + * reuses the * standing `workbench_launch` so the same principal can sit in its DM * and many channels without a sibling instance. + * + * A `kind: chat` cannot gain a different agent after that first join. */ export async function launchAndJoinAgent( deps: LaunchAndJoinAgentDeps, @@ -297,6 +499,20 @@ export async function launchAndJoinAgent( }; } + if (kindOf(input.existingSettings) === "chat") { + const boundDefinitionId = input.existingSettings["chat/definitionId"]; + const alreadyHasAgent = participants.some((participant) => + isAgentAddress(participant.address), + ); + const isThisChatMint = + typeof boundDefinitionId === "string" && + boundDefinitionId === input.definitionId && + !alreadyHasAgent; + if (!isThisChatMint) { + throw new KindIsChatError(); + } + } + const launched = await deps.platform.launchInvite({ tenantId: input.tenantId, creatorPrincipalId: input.principalId, diff --git a/packages/chat/src/workbench-tenancy.ts b/packages/chat/src/workbench-tenancy.ts index d8b0467b5..626490438 100644 --- a/packages/chat/src/workbench-tenancy.ts +++ b/packages/chat/src/workbench-tenancy.ts @@ -294,6 +294,14 @@ export interface WorkbenchTenancyStore { }): Promise< { readonly tenantId: string; readonly principalId: string } | undefined >; + + /** + * The auth user id (`principal.refId`) of an active owner-role principal + * in `tenantId`, or `undefined` when none is present — used by mint-dm + * to seed a new agent chat's child tenant with the human bench owner as + * creator, without the workflow child inventing a user id. + */ + getWorkbenchOwnerUserId(tenantId: string): Promise; } export interface WorkbenchTenancyAuthzDeps { @@ -741,6 +749,25 @@ export function createDrizzleWorkbenchTenancyStore< return { tenantId: link.tenantId, principalId }; }); }, + + async getWorkbenchOwnerUserId(tenantId) { + const [row] = await db + .select({ refId: principal.refId }) + .from(principal) + .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) + .innerJoin(role, eq(role.id, principalRole.roleId)) + .where( + and( + eq(principal.tenantId, tenantId), + eq(principal.kind, "user"), + eq(principal.status, "active"), + eq(role.tenantId, tenantId), + eq(role.name, "owner"), + ), + ) + .limit(1); + return row?.refId; + }, }; } @@ -772,6 +799,10 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { const manageGrants = new Set(); const principalsByKey = new Map(); const principalsByRefKey = new Map(); + /** Owner `refId` per tenant — seeded by `createWorkbenchTenant`, and by + * `grantManageInTenant` when a test stands up a root bench owner without + * minting a child workbench. */ + const ownerRefByTenant = new Map(); const principalKey = (tenantId: string, principalId: string) => `${tenantId}::${principalId}`; const refKey = (tenantId: string, refId: string) => `${tenantId}::${refId}`; @@ -849,6 +880,7 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { refKey(tenantId, input.creatorUserId), ownerPrincipal, ); + ownerRefByTenant.set(tenantId, input.creatorUserId); return { tenantId, parentTenantId: input.parentTenantId, @@ -892,6 +924,12 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { grantManageInTenant(refId, tenantId) { manageGrants.add(manageGrantKey(refId, tenantId)); + // A test seeding a root bench owner (no createWorkbenchTenant) still + // needs getWorkbenchOwnerUserId to resolve — treat the manage grant + // as the owner marker when none is recorded yet. + if (!ownerRefByTenant.has(tenantId)) { + ownerRefByTenant.set(tenantId, refId); + } }, registerPrincipal(tenantId, principalRow) { @@ -913,6 +951,10 @@ export function createInMemoryWorkbenchTenancyStore(): WorkbenchTenancyStore & { return principalsByRefKey.get(refKey(tenantId, refId)); }, + async getWorkbenchOwnerUserId(tenantId) { + return ownerRefByTenant.get(tenantId); + }, + // Mirrors the drizzle store's fold of the destination check into // the move itself: there is no separate pre-check call to race // against, in-memory or not — every failure mode is an outcome of diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index 3e36bb133..60595bdc1 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -1,12 +1,16 @@ -// The sanctioned path for a workflow-process child to invite an -// already-created agent definition into the workbench it is itself -// messaging in — the execution half of `@corbits/agent-directory-tools`' -// `create_agent`'s `invite: true` default (Myra's manager tools): -// after creating a definition through `@corbits/agent-directory`'s -// `createWorkflowAgentCreateRoutes`, the tool calls this surface to -// drop the new agent into the caller's own workbench, reusing -// `./workbench-service.ts`'s `launchAndJoinAgent` directly rather than -// reimplementing invite. Mirrors `@corbits/agent-directory`'s +// Workflow-run-authenticated surfaces for a child process that is itself +// messaging in a workbench: +// +// - `POST /participants/mint-dm` — `create_agent`'s default path: mint a +// brand-new `kind: chat` 1:1 for a just-created definition and launch +// the agent into THAT workbench (never into Myra's own DM). +// - `POST /participants/invite` — invite an already-created definition +// into a non-chat workbench the caller already participates in. +// - `POST /participants/messages` — post a message (e.g. ask_user block) +// into the caller's own workbench. +// +// Invite reuses `./workbench-service.ts`'s `launchAndJoinAgent`; mint-dm +// reuses `mintAgentDm`. Mirrors `@corbits/agent-directory`'s // `workflow-capability-routes.ts`/`workflow-create-routes.ts`: a // workflow child has no browser session, only its sidecar bearer token // and its own run address, so it authenticates through a @@ -33,7 +37,7 @@ // The calling tool (`@corbits/agent-directory-tools`' `create_agent`) // declares `approval: "ask"` (`@intx/agent`'s native per-invocation // gate), so a human already had to approve the specific agent being -// created (and, by extension, invited) before this route ever runs. +// created (and, by extension, minted/invited) before this route ever runs. // This route still enforces, unconditionally: (1) the caller's run // must resolve to a live tenant/principal/run via the sidecar-token + // run-address check below, and (2) the resolved workbench must actually @@ -44,7 +48,9 @@ import { type } from "arktype"; import { DefinitionProjectionMissingError } from "@corbits/folded-runs"; import { + KindIsChatError, launchAndJoinAgent, + mintAgentDm, sendWorkbenchMessage, type LaunchAndJoinAgentDeps, type SendWorkbenchMessageDeps, @@ -56,6 +62,7 @@ import { connectServiceConnectorIds, pendingConnectionsOf, } from "./connect-pending"; +import type { WorkbenchTenancyStore } from "./workbench-tenancy"; function errorEnvelope(code: string, message: string) { return { error: { code, message } }; @@ -95,13 +102,17 @@ export type WorkflowParticipantEnv = { }; const InviteParticipantInput = type({ definitionId: "string > 0" }); +const MintDmInput = type({ definitionId: "string > 0" }); const PostMessageInput = type({ parts: Part.array() }); export type CreateWorkflowParticipantRoutesDeps = { readonly store: Pick< ChatStore, - "findWorkbenchByParticipantAddress" | "updateWorkbenchSettings" + | "findWorkbenchByParticipantAddress" + | "updateWorkbenchSettings" + | "createWorkbenchSettings" + | "deleteWorkbenchSettings" > & SendWorkbenchMessageDeps["store"]; readonly platform: LaunchAndJoinAgentDeps["platform"] & @@ -114,6 +125,13 @@ export type CreateWorkflowParticipantRoutesDeps = { * workbench serialize against each other too. */ readonly turnQueue: SendWorkbenchMessageDeps["turnQueue"]; readonly authenticator: WorkflowRunAuthenticator; + readonly tenancy: Pick< + WorkbenchTenancyStore, + | "createWorkbenchTenant" + | "compensateWorkbenchTenant" + | "getWorkbenchTenancy" + | "getWorkbenchOwnerUserId" + >; }; export function createWorkflowParticipantRoutes( @@ -194,6 +212,9 @@ export function createWorkflowParticipantRoutes( if (err instanceof DefinitionProjectionMissingError) { return c.json(errorEnvelope("not_launchable", err.guidance), 409); } + if (err instanceof KindIsChatError) { + return c.json(errorEnvelope(err.code, err.message), 409); + } throw err; } @@ -207,6 +228,87 @@ export function createWorkflowParticipantRoutes( ); }); + // create_agent's default path: mint the specialist its own kind:chat + // 1:1 under the caller's bench. Never invites into the caller's DM. + app.post("/participants/mint-dm", async (c) => { + const scope = c.get("workflowParticipantScope"); + const body = MintDmInput(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + errorEnvelope("bad_request", `invalid mint-dm body: ${body.summary}`), + 400, + ); + } + + const workbench = await deps.store.findWorkbenchByParticipantAddress( + scope.tenantId, + scope.address, + ); + if (workbench === undefined) { + return c.json( + errorEnvelope( + "not_found", + `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, + ), + 404, + ); + } + + // Prefer the human owner of the parent bench: Myra's DM is itself a + // child workbench whose parentTenantId is the bench. + const link = await deps.tenancy.getWorkbenchTenancy(workbench.workbenchId); + const ownerTenantId = link?.parentTenantId ?? scope.tenantId; + const creatorUserId = + await deps.tenancy.getWorkbenchOwnerUserId(ownerTenantId); + if (creatorUserId === undefined) { + return c.json( + errorEnvelope( + "owner_unresolved", + `No owner user id for tenant "${ownerTenantId}" — cannot mint an agent DM`, + ), + 500, + ); + } + + let minted: Awaited>; + try { + minted = await mintAgentDm( + { + tenancy: deps.tenancy, + store: deps.store, + platform: deps.platform, + roomMessages: deps.roomMessages, + publish: deps.publish, + }, + { + tenantId: scope.tenantId, + callerWorkbenchId: workbench.workbenchId, + callerPrincipalId: scope.principalId, + creatorUserId, + definitionId: body.definitionId, + }, + ); + } catch (err) { + if (err instanceof DefinitionProjectionMissingError) { + return c.json(errorEnvelope("not_launchable", err.guidance), 409); + } + if (err instanceof KindIsChatError) { + return c.json(errorEnvelope(err.code, err.message), 409); + } + throw err; + } + + return c.json( + { + workbenchId: minted.workbenchId, + address: minted.address, + definitionId: minted.definitionId, + handle: minted.handle, + }, + 201, + ); + }); + // The posting half of an in-workbench gen-UI block: a workflow child // (`@corbits/interaction-tools`'s `ask_user`) posts a message carrying a // `block` part into its own workbench — resolved the same way diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index 647c3dc37..5cb3cc410 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -990,7 +990,7 @@ describe("POST /workbenches/:id/invite", () => { // One room participant = one standing principal (CL-6978): an // explicit re-invite of a definition this room already holds returns // the resident handle rather than minting a sibling or appending a - // second participant row. `addParticipant` does not de-dupe addresses. + // second participant row. `addParticipant` de-dupes the same address. test("an explicit re-invite of the same definition does not mint a second instance", async () => { let launches = 0; const platform = fakePlatform({ diff --git a/packages/evals/src/cases/ai-daily-research.ts b/packages/evals/src/cases/ai-daily-research.ts index 25b8e1d29..9f60484c2 100644 --- a/packages/evals/src/cases/ai-daily-research.ts +++ b/packages/evals/src/cases/ai-daily-research.ts @@ -2,8 +2,8 @@ // canonical interview -> tool-map -> memory -> specialist -> routine // flow. Expects Myra to: (1) ask a short interview about // topics/sources/cadence/delivery rather than building anything first, -// (2) create a web-search-tools researcher agent invited into this -// workbench, (3) write the choices to firm memory, and (4) create the +// (2) create a web-search-tools researcher agent with its own chat, +// (3) write the choices to firm memory, and (4) create the // daily delivery routine only after an explicit go-ahead. import { defineEval } from "../define-eval.ts"; import { diff --git a/packages/evals/src/scorers/scorers.ts b/packages/evals/src/scorers/scorers.ts index 0bc8998aa..2bebc1626 100644 --- a/packages/evals/src/scorers/scorers.ts +++ b/packages/evals/src/scorers/scorers.ts @@ -132,13 +132,28 @@ export function memoryWritten(keys: readonly string[]) { } /** - * Passes if create_agent succeeded and the created agent was invited - * into the same workbench the conversation is running in — the tool's - * own contract is "invite into the caller's channel by default" (see - * `packages/agent-directory-tools/src/tool.ts`), so this checks the - * call succeeded and its result mentions an invite/participant rather - * than re-deriving invite plumbing here. + * Passes if create_agent succeeded and the result shows the specialist's + * own chat was minted or reopened. Creating an agent now opens that + * agent's 1:1 — invite-into-the-current-workbench wording is ignored, + * not required. A definition-created signal plus either an own-chat id + * (`workbenchId` / `chatId` / a `created` field) or explicit minted-chat + * wording is enough, so this still scores after invite-into-current + * fields disappear from the tool result. */ +const DEFINITION_CREATED_SIGNAL = + /Created\s+"|use this id for routines\/dispatch:|"id"\s*:/i; + +const MINTED_OWN_CHAT_SIGNAL = + /workbenchId|chatId|chat_id|"created"\s*:|own chats?|minted|reopened/i; + +function createAgentMintedOwnChat(call: ToolCall): boolean { + return ( + !call.isError && + DEFINITION_CREATED_SIGNAL.test(call.result) && + MINTED_OWN_CHAT_SIGNAL.test(call.result) + ); +} + export function agentCreatedInWorkbench() { return function agentCreatedInWorkbenchScorer( ctx: ScorerContext, @@ -146,18 +161,15 @@ export function agentCreatedInWorkbench() { const creates = allToolCalls( ctx.transcript.slice(0, ctx.turnIndex + 1), ).filter((call) => call.name === "create_agent"); - const succeeded = creates.filter((call) => !call.isError); - const invited = succeeded.filter((call) => - /invit|particip|address/i.test(call.result), - ); + const minted = creates.filter(createAgentMintedOwnChat); return result( "agentCreatedInWorkbench", - creates.length > 0 && invited.length === creates.length, + creates.length > 0 && minted.length === creates.length, creates.length === 0 ? "no create_agent call yet" - : invited.length === creates.length - ? `${String(creates.length)} agent(s) created, all invited` - : `${String(creates.length - invited.length)} of ${String(creates.length)} created agent(s) show no invite in their result`, + : minted.length === creates.length + ? `${String(creates.length)} agent(s) created, all with their own chat` + : `${String(creates.length - minted.length)} of ${String(creates.length)} created agent(s) show no minted own chat in their result`, ); }; } diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 1640466d8..4841f56d7 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -35,7 +35,7 @@ export const ASSISTANT_STEP_ID = "assistant"; * (CL-6084/CL-6086) lets Myra self-service a missing tool, skill, or * model; the remaining four are the manager-tools bundles giving Myra * real workbench-management capability — a specialist agent she can - * create and invite in, this workbench's routines, connection + * create (each gets their own chat), this workbench's routines, connection * visibility, and skill capture — each a thin wrapper over an existing * platform primitive (see each package's own file-header comment for * which one). @@ -44,7 +44,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, { name: "@corbits/capability-tools", version: "0.0.3" }, { name: "@corbits/routines-tools", version: "0.0.5" }, - { name: "@corbits/agent-directory-tools", version: "0.0.4" }, + { name: "@corbits/agent-directory-tools", version: "0.0.5" }, { name: "@corbits/connections-tools", version: "0.0.6" }, { name: "@corbits/catalog-tools", version: "0.0.1" }, { name: "@corbits/skills-tools", version: "0.0.6" }, @@ -133,8 +133,8 @@ const ASSISTANT_TEAMMATE_CLAUSE = "URL, a cadence, whichever specifics the plan actually turns on — " + "never 'should I create an agent for that?' or any other question " + "that just asks permission to use the mechanism. On their OK, build " + - "the whole thing in one go: create the specialists and invite them " + - "in, create the routines, and save the facts they gave you to " + + "the whole thing in one go: create the specialists (each gets their " + + "own chat), create the routines, and save the facts they gave you to " + "memory — every write already asks for its own approval, so build " + "once you have what you need rather than checking in again first."; From 55f88dca5c5ac1347e921fcfe454a17189a964d5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 10:56:27 -0700 Subject: [PATCH 3/7] Fix manager-tools scenario for create_agent mint-dm default --- .../test/manager-tools-scenario.test.ts | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/workflows/assistant/test/manager-tools-scenario.test.ts b/workflows/assistant/test/manager-tools-scenario.test.ts index a9915effa..527bc0079 100644 --- a/workflows/assistant/test/manager-tools-scenario.test.ts +++ b/workflows/assistant/test/manager-tools-scenario.test.ts @@ -28,8 +28,8 @@ // are connected, so she hands over connect links for each. // 3. Once the human connects them (simulated: the fake connections // state flips to connected), she creates two specialist agents — -// a call-notes extractor and a weekly-analytics agent — inviting -// each into the workbench's channel. +// a call-notes extractor and a weekly-analytics agent — minting +// each specialist its own 1:1 chat (never inviting into Myra's). // 4. She creates two routines: a daily one that extracts info from // calls and shares updates, targeting the call-notes agent; and a // weekly one that provides analytical updates, targeting the @@ -73,6 +73,7 @@ function call( function createFakeHub() { const connected = new Set(); const createdDefinitions: { id: string; name: string }[] = []; + const mintedDefinitionIds: string[] = []; const invitedDefinitionIds: string[] = []; const createdRoutines: { definitionId: string; @@ -177,6 +178,24 @@ function createFakeHub() { ); } + if ( + url.pathname === "/api/workflow-chat/participants/mint-dm" && + method === "POST" && + body + ) { + const definitionId = body["definitionId"] as string; + mintedDefinitionIds.push(definitionId); + return Response.json( + { + workbenchId: `wb_${definitionId}`, + address: `${definitionId}@workflow`, + definitionId, + handle: definitionId, + }, + { status: 201 }, + ); + } + if ( url.pathname === "/api/workflow-chat/participants/invite" && method === "POST" && @@ -231,6 +250,7 @@ function createFakeHub() { connected, createdDefinitions, invitedDefinitionIds, + mintedDefinitionIds, createdRoutines, postedCards, }; @@ -339,8 +359,8 @@ async function runScenario( "Connected: Granola, Exa, Linear.", ); - // Step 3: Myra creates the two specialist agents she needs, inviting - // each into the workbench (the tool's own default). + // Step 3: Myra creates the two specialist agents she needs, minting + // each its own 1:1 chat (the tool's default). const agentDirectoryBundle = agentDirectoryTools(agentDirectoryEnv); const callNotesAgent = await agentDirectoryBundle.run( call("a1", CREATE_AGENT_TOOL, { @@ -367,11 +387,11 @@ async function runScenario( "Call Notes Extractor", "Weekly Analytics", ]); - // Both were invited into the caller's channel (the default), never - // silently skipped. - expect(hub.invitedDefinitionIds).toEqual( + // Both got their own chat (the default), never invited into Myra's. + expect(hub.mintedDefinitionIds).toEqual( hub.createdDefinitions.map((d) => d.id), ); + expect(hub.invitedDefinitionIds).toEqual([]); const [callNotesDefinition, analyticsDefinition] = hub.createdDefinitions; if (callNotesDefinition === undefined || analyticsDefinition === undefined) { From cd9ceeec83806f9cb37f0d069d973077c7dff152 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 15:37:47 -0700 Subject: [PATCH 4/7] Format create_agent mint-dm files --- packages/chat/src/workbench-service.ts | 4 +--- .../chat/test/start-workflow-command.test.ts | 5 ++++- packages/chat/test/workbench-service.test.ts | 7 +++++- .../test/workflow-participant-routes.test.ts | 3 +-- packages/evals/src/scorers/scorers.test.ts | 22 +++++++++++++------ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 34b0c6e89..9cae9d120 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -447,9 +447,7 @@ export async function resolveInvitedDisplayName( export class KindIsChatError extends Error { readonly code = "kind_is_chat" as const; constructor() { - super( - "a chat is 1:1; adding another agent is only for workbenches", - ); + super("a chat is 1:1; adding another agent is only for workbenches"); this.name = "KindIsChatError"; } } diff --git a/packages/chat/test/start-workflow-command.test.ts b/packages/chat/test/start-workflow-command.test.ts index 19387ab13..5db03dad1 100644 --- a/packages/chat/test/start-workflow-command.test.ts +++ b/packages/chat/test/start-workflow-command.test.ts @@ -3,7 +3,10 @@ // workflow-command registrar: invite-then-send, sharing the same // `launchAndJoinAgent` core as `POST .../invite`. import { describe, expect, test } from "bun:test"; -import { KindIsChatError, startWorkflowCommand } from "../src/workbench-service"; +import { + KindIsChatError, + startWorkflowCommand, +} from "../src/workbench-service"; import { createInMemoryChatStore } from "../src/store"; import { createInMemoryRoomMessageStore } from "../src/room-messages"; import { fakePlatform, TENANT } from "./test-support"; diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index 5cb3cc410..0cd07522a 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -9,7 +9,12 @@ import { decodeParts } from "../src/codec"; import type { Part } from "../src/parts"; import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; -import { cannedGreeting, KindIsChatError, launchAndJoinAgent, postCannedGreeting } from "../src/workbench-service"; +import { + cannedGreeting, + KindIsChatError, + launchAndJoinAgent, + postCannedGreeting, +} from "../src/workbench-service"; import { createInMemoryChatStore } from "../src/store"; import { buildDeps, diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index 242d0ae8d..f68cd5c55 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -508,8 +508,7 @@ describe("POST /participants/mint-dm", () => { const mutated = published.filter( (entry) => (entry.event as { type?: string }).type === - "chat.workbenches-mutated" && - entry.workbenchId === callerWorkbenchId, + "chat.workbenches-mutated" && entry.workbenchId === callerWorkbenchId, ); expect(mutated).toHaveLength(1); expect( diff --git a/packages/evals/src/scorers/scorers.test.ts b/packages/evals/src/scorers/scorers.test.ts index cc9489af9..2f466f13e 100644 --- a/packages/evals/src/scorers/scorers.test.ts +++ b/packages/evals/src/scorers/scorers.test.ts @@ -181,10 +181,14 @@ describe("agentCreatedInWorkbench", () => { test("passes when create_agent minted the specialist's own chat", () => { const transcript = [ turn("make a researcher", "Done.", [ - call("create_agent", {}, { - result: - 'Created "Researcher" (use this id for routines/dispatch: def_1). workbenchId=wb_abc minted their own chat.', - }), + call( + "create_agent", + {}, + { + result: + 'Created "Researcher" (use this id for routines/dispatch: def_1). workbenchId=wb_abc minted their own chat.', + }, + ), ]), ]; const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); @@ -194,9 +198,13 @@ describe("agentCreatedInWorkbench", () => { test("passes when the result has a created/workbench id and no invite-into-current fields", () => { const transcript = [ turn("make a researcher", "Done.", [ - call("create_agent", {}, { - result: '{"id":"def_1","workbenchId":"wb_abc","created":true}', - }), + call( + "create_agent", + {}, + { + result: '{"id":"def_1","workbenchId":"wb_abc","created":true}', + }, + ), ]), ]; const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); From c53569443392f30ae46645a7490429ea0f1705f2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 16:06:13 -0700 Subject: [PATCH 5/7] Reopen the existing agent DM instead of cloning a second mintAgentDm now find-or-reopens the kind:chat for (bench, definition), matching POST /workbenches. The eval scorer no longer passes create_agent copy that says the own chat could not be opened. --- packages/agent-directory-tools/package.json | 2 +- packages/chat/src/routes.ts | 87 +-------- packages/chat/src/workbench-service.ts | 173 +++++++++++++++++- .../chat/src/workflow-participant-routes.ts | 10 +- .../test/workflow-participant-routes.test.ts | 65 +++++++ packages/evals/src/scorers/scorers.test.ts | 17 ++ packages/evals/src/scorers/scorers.ts | 7 +- 7 files changed, 261 insertions(+), 100 deletions(-) diff --git a/packages/agent-directory-tools/package.json b/packages/agent-directory-tools/package.json index b1c1eeebf..b9f302b50 100644 --- a/packages/agent-directory-tools/package.json +++ b/packages/agent-directory-tools/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/agent-directory-tools", "private": true, - "description": "Myra's manager tools: list_agents and create_agent as an @intx/agent tool bundle — creates a new specialist agent definition in the caller's own tenant and, by default, invites it into the caller's own channel. Creation is free; the reactor never parks the create/invite pair", + "description": "Myra's manager tools: list_agents and create_agent as an @intx/agent tool bundle — creates a new specialist agent definition in the caller's own tenant and, by default, mints or reopens that specialist's own 1:1 chat. Creation is free; the reactor never parks the create/mint pair", "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 8cfb30069..f5aea2731 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -72,6 +72,7 @@ import { joinHumanParticipant, KindIsChatError, launchAndJoinAgent, + findExistingAgentChat, removeWorkbenchParticipant, sendWorkbenchMessage, } from "./workbench-service"; @@ -91,7 +92,7 @@ import { type WorkbenchTurnQueue, } from "./turn-queue"; import type { ChatPlatform } from "./platform-port"; -import type { WorkbenchSettingsRow, ChatStore } from "./store"; +import type { ChatStore } from "./store"; import { dispatchAtCommand, dispatchSlashCommand, @@ -864,89 +865,7 @@ const MoveWorkbenchBody = type({ newParentTenantId: "string", }); -/** - * Finds an existing chat with the given agent. `POST /workbenches` with - * `kind: "chat"` + `definitionId` always find-or-reopens this way - * (CL-6981): a DM is the one 1:1 tenant with that agent. Uniqueness is - * per (bench, definitionId). Product reopens; it does not clone. - * - * Matches forward, by the `chat/definitionId` every agent chat has - * carried in its settings since this landed, and falls back to - * `matchesLegacyAgentChat` for a chat minted before that key existed. - * The comparison is on the definition's ASSET, not the row id: a - * code-sourced deploy projects a new `workflow_definition` row per - * frozen wire projection, so the id a chat recorded at creation and the - * id the picker offers later are routinely different rows over the one - * asset that IS the agent. - * More than one match (duplicates this same gap already let through) - * resolves to the oldest by its workbench-tenancy `createdAt` — the - * original conversation, not whichever the caller happens to hit first — - * with a workbench that predates workbench tenancy entirely sorting oldest - * of all. - */ -export async function findExistingAgentChat( - deps: Pick, - tenantId: string, - definitionId: string, -): Promise { - const chats = await deps.store.listWorkbenchSettings(tenantId, "chat"); - const assetId = await deps.platform.resolveDefinitionAssetId(definitionId); - const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = []; - for (const row of chats) { - const storedDefinitionId = row.settings["chat/definitionId"]; - const isMatch = - storedDefinitionId !== undefined - ? typeof storedDefinitionId === "string" && - (await sameAgent(deps, storedDefinitionId, definitionId, assetId)) - : await matchesLegacyAgentChat(deps, row, definitionId); - if (!isMatch) continue; - const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId); - matches.push({ row, createdAt: link?.createdAt ?? new Date(0) }); - } - if (matches.length === 0) return undefined; - matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - return matches[0]?.row; -} - -/** - * Whether two definition ids name the same agent: the same row, or two - * rows projected over the same workflow asset. An unresolvable asset (a - * definition row that no longer exists) never matches by asset, so a - * stale recorded id falls back to plain id equality alone. - */ -async function sameAgent( - deps: Pick, - storedDefinitionId: string, - definitionId: string, - assetId: string | undefined, -): Promise { - if (storedDefinitionId === definitionId) return true; - if (assetId === undefined) return false; - const storedAssetId = - await deps.platform.resolveDefinitionAssetId(storedDefinitionId); - return storedAssetId === assetId; -} - -/** - * A chat minted before `chat/definitionId` was recorded at creation - * carries no forward marker naming its agent — the only way back to its - * definition is the platform's reverse address lookup, run once per - * agent participant the chat has (ordinarily exactly one). - */ -async function matchesLegacyAgentChat( - deps: Pick, - row: WorkbenchSettingsRow, - definitionId: string, -): Promise { - const agentAddresses = participantsOf(row.settings) - .map((participant) => participant.address) - .filter(isAgentAddress); - for (const address of agentAddresses) { - const resolved = await deps.platform.resolveDefinitionIdByAddress(address); - if (resolved === definitionId) return true; - } - return false; -} +export { findExistingAgentChat }; /** Annotates a workbench view with its native child-tenancy — the * `tenancy` field every workbench created after this rollout carries, diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 9cae9d120..13196167e 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -46,7 +46,7 @@ import { postRoomMessage, type RoomMessageStore } from "./room-messages"; import type { WorkbenchSubscriberRegistry } from "./workbench-events"; import type { QueuedTurn, WorkbenchTurnQueue } from "./turn-queue"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import type { ChatStore } from "./store"; +import type { ChatStore, WorkbenchSettingsRow } from "./store"; import { withTimeout } from "./with-timeout"; const provisionLog = getLogger(["chat", "provision-space"]); @@ -147,13 +147,16 @@ const mintAgentDmLog = getLogger(["chat", "mint-agent-dm"]); export type MintAgentDmDeps = { readonly tenancy: Pick< WorkbenchTenancyStore, - "createWorkbenchTenant" | "compensateWorkbenchTenant" + | "createWorkbenchTenant" + | "compensateWorkbenchTenant" + | "getWorkbenchTenancy" >; readonly store: Pick< ChatStore, | "createWorkbenchSettings" | "deleteWorkbenchSettings" | "updateWorkbenchSettings" + | "listWorkbenchSettings" >; readonly platform: LaunchAndJoinAgentDeps["platform"]; readonly roomMessages: LaunchAndJoinAgentDeps["roomMessages"]; @@ -184,20 +187,124 @@ export type MintAgentDmResult = { readonly displayName: string; }; +export type FindExistingAgentChatDeps = { + readonly store: Pick; + readonly platform: Pick< + WorkbenchLauncher, + "resolveDefinitionAssetId" | "resolveDefinitionIdByAddress" + >; + readonly tenancy: Pick; +}; + +/** + * Finds an existing chat with the given agent. `POST /workbenches` with + * `kind: "chat"` + `definitionId` always find-or-reopens this way + * (CL-6981), and `mintAgentDm` does the same: a DM is the one 1:1 + * tenant with that agent. Uniqueness is per (bench, definitionId). + * Product reopens; it does not clone. + * + * Matches forward, by the `chat/definitionId` every agent chat has + * carried in its settings since this landed, and falls back to + * `matchesLegacyAgentChat` for a chat minted before that key existed. + * The comparison is on the definition's ASSET, not the row id: a + * code-sourced deploy projects a new `workflow_definition` row per + * frozen wire projection, so the id a chat recorded at creation and the + * id the picker offers later are routinely different rows over the one + * asset that IS the agent. + * More than one match (duplicates this same gap already let through) + * resolves to the oldest by its workbench-tenancy `createdAt` — the + * original conversation, not whichever the caller happens to hit first — + * with a workbench that predates workbench tenancy entirely sorting oldest + * of all. + */ +export async function findExistingAgentChat( + deps: FindExistingAgentChatDeps, + tenantId: string, + definitionId: string, +): Promise { + const chats = await deps.store.listWorkbenchSettings(tenantId, "chat"); + const assetId = await deps.platform.resolveDefinitionAssetId(definitionId); + const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = []; + for (const row of chats) { + const storedDefinitionId = row.settings["chat/definitionId"]; + const isMatch = + storedDefinitionId !== undefined + ? typeof storedDefinitionId === "string" && + (await sameAgent(deps, storedDefinitionId, definitionId, assetId)) + : await matchesLegacyAgentChat(deps, row, definitionId); + if (!isMatch) continue; + const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId); + matches.push({ row, createdAt: link?.createdAt ?? new Date(0) }); + } + if (matches.length === 0) return undefined; + matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + return matches[0]?.row; +} + +/** + * Whether two definition ids name the same agent: the same row, or two + * rows projected over the same workflow asset. An unresolvable asset (a + * definition row that no longer exists) never matches by asset, so a + * stale recorded id falls back to plain id equality alone. + */ +async function sameAgent( + deps: Pick, + storedDefinitionId: string, + definitionId: string, + assetId: string | undefined, +): Promise { + if (storedDefinitionId === definitionId) return true; + if (assetId === undefined) return false; + const storedAssetId = + await deps.platform.resolveDefinitionAssetId(storedDefinitionId); + return storedAssetId === assetId; +} + +/** + * A chat minted before `chat/definitionId` was recorded at creation + * carries no forward marker naming its agent — the only way back to its + * definition is the platform's reverse address lookup, run once per + * agent participant the chat has (ordinarily exactly one). + */ +async function matchesLegacyAgentChat( + deps: Pick, + row: WorkbenchSettingsRow, + definitionId: string, +): Promise { + const agentAddresses = participantsOf(row.settings) + .map((participant) => participant.address) + .filter(isAgentAddress); + for (const address of agentAddresses) { + const resolved = await deps.platform.resolveDefinitionIdByAddress(address); + if (resolved === definitionId) return true; + } + return false; +} + /** - * Mints a brand-new `kind: "chat"` 1:1 for `definitionId` under the - * caller's bench, launches the agent into THAT workbench (never into - * `callerWorkbenchId`), and publishes `chat.workbenches-mutated` on the - * caller's workbench so the sidebar picks up the new chat. The - * workflow-run counterpart of `POST /workbenches` agent-DM mint — - * `create_agent`'s default path uses this instead of inviting into - * Myra's own DM (which is itself `kind: chat` and rejects additional - * agents via `KindIsChatError`). + * Mints a `kind: "chat"` 1:1 for `definitionId` under the caller's bench, + * or reopens the existing one for that (bench, definition) — never clones + * a second DM. Launches the agent into THAT workbench (never into + * `callerWorkbenchId`), and on a fresh mint publishes + * `chat.workbenches-mutated` on the caller's workbench so the sidebar + * picks up the new chat. The workflow-run counterpart of `POST + * /workbenches` agent-DM mint — `create_agent`'s default path uses this + * instead of inviting into Myra's own DM (which is itself `kind: chat` + * and rejects additional agents via `KindIsChatError`). */ export async function mintAgentDm( deps: MintAgentDmDeps, input: MintAgentDmInput, ): Promise { + const existing = await findExistingAgentChat( + deps, + input.tenantId, + input.definitionId, + ); + if (existing !== undefined) { + return reopenAgentDm(deps, input, existing); + } + const invitable = await deps.platform.listInvitableDefinitions( input.tenantId, ); @@ -325,6 +432,52 @@ export async function mintAgentDm( }; } +async function reopenAgentDm( + deps: MintAgentDmDeps, + input: MintAgentDmInput, + existing: WorkbenchSettingsRow, +): Promise { + const agent = participantsOf(existing.settings).find((participant) => + isAgentAddress(participant.address), + ); + if (agent !== undefined) { + return { + workbenchId: existing.workbenchId, + address: agent.address, + definitionId: input.definitionId, + handle: agent.handle, + displayName: agent.handle, + }; + } + + const invitable = await deps.platform.listInvitableDefinitions( + input.tenantId, + ); + const joined = await launchAndJoinAgent( + { + store: deps.store, + platform: deps.platform, + roomMessages: deps.roomMessages, + publish: deps.publish, + }, + { + tenantId: input.tenantId, + principalId: input.callerPrincipalId, + workbenchId: existing.workbenchId, + definitionId: input.definitionId, + existingSettings: existing.settings, + invitable, + }, + ); + return { + workbenchId: existing.workbenchId, + address: joined.address, + definitionId: joined.definitionId, + handle: joined.handle, + displayName: joined.displayName, + }; +} + export type LaunchAndJoinAgentDeps = { readonly store: Pick; readonly platform: WorkbenchLauncher; diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index 60595bdc1..ce08aca30 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -1,8 +1,8 @@ // Workflow-run-authenticated surfaces for a child process that is itself // messaging in a workbench: // -// - `POST /participants/mint-dm` — `create_agent`'s default path: mint a -// brand-new `kind: chat` 1:1 for a just-created definition and launch +// - `POST /participants/mint-dm` — `create_agent`'s default path: mint or +// reopen the specialist's `kind: chat` 1:1 for a definition and launch // the agent into THAT workbench (never into Myra's own DM). // - `POST /participants/invite` — invite an already-created definition // into a non-chat workbench the caller already participates in. @@ -113,6 +113,7 @@ export type CreateWorkflowParticipantRoutesDeps = { | "updateWorkbenchSettings" | "createWorkbenchSettings" | "deleteWorkbenchSettings" + | "listWorkbenchSettings" > & SendWorkbenchMessageDeps["store"]; readonly platform: LaunchAndJoinAgentDeps["platform"] & @@ -228,8 +229,9 @@ export function createWorkflowParticipantRoutes( ); }); - // create_agent's default path: mint the specialist its own kind:chat - // 1:1 under the caller's bench. Never invites into the caller's DM. + // create_agent's default path: mint or reopen the specialist's own + // kind:chat 1:1 under the caller's bench. Never invites into the + // caller's DM. app.post("/participants/mint-dm", async (c) => { const scope = c.get("workflowParticipantScope"); const body = MintDmInput(await c.req.json().catch(() => undefined)); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index f68cd5c55..557240fce 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -520,4 +520,69 @@ describe("POST /participants/mint-dm", () => { ), ).toBe(false); }); + + test("second mint-dm with the same definitionId reopens the existing chat instead of cloning", async () => { + const store = createInMemoryChatStore(); + const callerWorkbenchId = "chan_dm"; + await store.createWorkbenchSettings({ + tenantId: TENANT.id, + workbenchId: callerWorkbenchId, + settings: { + "chat/kind": "chat", + "chat/definitionId": "wfd_assistant", + "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], + }, + updatedBy: "prn_1", + }); + const tenancy = createInMemoryWorkbenchTenancyStore(); + tenancy.registerExistingTenant(TENANT.id); + tenancy.grantManageInTenant("usr_alice", TENANT.id); + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "Echo" }], + }); + + const app = buildApp({ store, platform, tenancy }); + const first = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + expect(first.status).toBe(201); + const firstBody = (await first.json()) as { + workbenchId: string; + address: string; + handle: string; + }; + + const second = await app.request("/participants/mint-dm", { + method: "POST", + headers: { "content-type": "application/json", ...AUTH_HEADERS }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + expect(second.ok).toBe(true); + const secondBody = (await second.json()) as { + workbenchId: string; + address: string; + handle: string; + }; + + expect(secondBody.workbenchId).toBe(firstBody.workbenchId); + expect(secondBody.address).toBe(firstBody.address); + expect(secondBody.handle).toBe(firstBody.handle); + expect(platform.launchInviteCalls).toHaveLength(1); + + const chats = await store.listWorkbenchSettings(TENANT.id, "chat"); + expect(chats).toHaveLength(2); + expect( + chats.filter((row) => row.settings["chat/definitionId"] === "wfd_echo"), + ).toHaveLength(1); + + const caller = await store.getWorkbenchSettings( + TENANT.id, + callerWorkbenchId, + ); + expect(caller?.settings["chat/participants"]).toEqual([ + { address: RUN_ADDRESS, handle: "myra" }, + ]); + }); }); diff --git a/packages/evals/src/scorers/scorers.test.ts b/packages/evals/src/scorers/scorers.test.ts index 2f466f13e..2e45c4a14 100644 --- a/packages/evals/src/scorers/scorers.test.ts +++ b/packages/evals/src/scorers/scorers.test.ts @@ -231,6 +231,23 @@ describe("agentCreatedInWorkbench", () => { expect(r.pass).toBe(false); }); + test("fails when create_agent created the definition but could not open its own chat", () => { + const transcript = [ + turn("make a researcher", "Done.", [ + call( + "create_agent", + {}, + { + result: + 'Created "Researcher" (use this id for routines/dispatch: def_1), but could not open its own chat: this workbench could not be identified as the caller\'s own.', + }, + ), + ]), + ]; + const r = agentCreatedInWorkbench()(ctxAt(transcript, 0)); + expect(r.pass).toBe(false); + }); + test("fails when no create_agent call happened yet", () => { const r = agentCreatedInWorkbench()(ctxAt([turn("hi", "hi")], 0)); expect(r.pass).toBe(false); diff --git a/packages/evals/src/scorers/scorers.ts b/packages/evals/src/scorers/scorers.ts index 2bebc1626..069fadba2 100644 --- a/packages/evals/src/scorers/scorers.ts +++ b/packages/evals/src/scorers/scorers.ts @@ -138,7 +138,9 @@ export function memoryWritten(keys: readonly string[]) { * not required. A definition-created signal plus either an own-chat id * (`workbenchId` / `chatId` / a `created` field) or explicit minted-chat * wording is enough, so this still scores after invite-into-current - * fields disappear from the tool result. + * fields disappear from the tool result. Copy that says the definition + * was created but its own chat could not be opened is a fail — the + * minted-chat wording would otherwise match `/own chats?/`. */ const DEFINITION_CREATED_SIGNAL = /Created\s+"|use this id for routines\/dispatch:|"id"\s*:/i; @@ -146,9 +148,12 @@ const DEFINITION_CREATED_SIGNAL = const MINTED_OWN_CHAT_SIGNAL = /workbenchId|chatId|chat_id|"created"\s*:|own chats?|minted|reopened/i; +const COULD_NOT_OPEN_OWN_CHAT_SIGNAL = /could not open its own chats?/i; + function createAgentMintedOwnChat(call: ToolCall): boolean { return ( !call.isError && + !COULD_NOT_OPEN_OWN_CHAT_SIGNAL.test(call.result) && DEFINITION_CREATED_SIGNAL.test(call.result) && MINTED_OWN_CHAT_SIGNAL.test(call.result) ); From 1d6d5a4037976b4aad3c5a0779af41e9d3fb19c8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 16:42:20 -0700 Subject: [PATCH 6/7] Update docs: create_agent mints or reopens the specialist DM --- ARCHITECTURE.md | 15 +++++++++++++++ IMPLEMENTATION.md | 22 ++++++++++++++++++++++ PRODUCT.md | 9 +++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a26139c7..0959ca92a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -100,6 +100,21 @@ signal. Consecutive agent-joined events collapse into one line so the scene and the reviewers' own introductions are what a person reads first. +**A `kind: chat` is 1:1.** It is the one DM with its agent. Inviting a +different or additional agent into that conversation is a conflict +(HTTP 409 `kind_is_chat`); extra agents belong on a `kind: workbench` +channel. A same-definition invite reuses the resident principal and +does not clone a sibling instance. + +**Default specialist creation mints or reopens that DM.** Myra's +`create_agent` path does not invite the new definition into the +caller's conversation — Myra's DM is itself `kind: chat` and would +reject the extra agent. It mints a `kind: chat` for the definition +under the bench, or reopens the existing one for that (bench, +definition) pair, matching the product-surface find-or-reopen rule. +The specialist launches into that chat, never into Myra's. + + **Streaming a reply.** An agent's live reply reaches the timeline through one path, deltas to pixels: diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 1d3abd7a8..6cd3ba480 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -160,6 +160,28 @@ Optional `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` exist for that future hosted path; leaving them unset is normal. See `docs/connect-cards.md` and PRODUCT.md's Code review first minute. +## create_agent and specialist DMs + +`@corbits/agent-directory-tools`' `create_agent` creates a specialist +definition in the caller's tenant and, by default, opens that +specialist's own 1:1 — never an invite into Myra's DM. + +- **Default** (`invite` omitted or true): POST + `/api/workflow-chat/participants/mint-dm` (`mintAgentDm` in + `@corbits/chat`). That find-or-reopens the `kind: chat` for + `(bench, definition)`, matching `POST /workbenches`. The agent + launches into that chat, not the caller's. +- **`invite: false`**: create the definition only — no mint-dm and no + invite. +- **Extra agent into `kind: chat`**: POST + `/api/workflow-chat/participants/invite` (and the session invite + path) returns **409** `kind_is_chat` when the target is a DM and the + definition is not that chat's first/same agent. Same-definition + retry reuses the resident. + +A create-succeeded / mint-failed split is a completed tool result that +names both halves, not a bare error. + ## Related docs - [README.md](README.md) — quickstart, local setup, repo layout, e2e detail diff --git a/PRODUCT.md b/PRODUCT.md index 5e33328ec..e4b3f304a 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -49,7 +49,10 @@ column at a time: and in many channels. Product reopens or invites; it does not clone the definition or mint a sibling instance per room. Myra is the first-run guide in her DM, not a special home slot and not a - parallel home route. + parallel home route. When she creates a specialist (`create_agent`), + the default is to mint or reopen that specialist's own DM + (`kind: chat`) — never to invite them into Myra's conversation. A + DM stays 1:1; another agent belongs in a channel. - The active workbench occupies the main column; a contextual panel beside it carries account-wide surfaces (approvals, recent activity) that stay visible regardless of which workbench is open. @@ -181,8 +184,10 @@ User-facing surfaces (UI, docs, support) use exactly these nouns: the sidebar list Workbenches. - **Agent** — a coworker principal. Opening the row reopens that agent's one DM. Never "template." Myra is the first-run guide in her DM. + Creating a specialist opens (or reopens) that specialist's own DM, + not a seat in Myra's. - **DM** — the one 1:1 conversation with an agent. Never cloned by a - second open. + second open, and never a room for a second agent. - **Channel** — a shared room between people and agents. Plus mints an empty one; nobody is auto-hosted. Named templates instantiate their Workbench Definition's own agents into that room (Myra joins only From b4ffd4529e671072050f4a8393a8581c9be80527 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 08:32:59 -0700 Subject: [PATCH 7/7] Format ARCHITECTURE.md --- ARCHITECTURE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0959ca92a..fd0c4444d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -114,7 +114,6 @@ under the bench, or reopens the existing one for that (bench, definition) pair, matching the product-surface find-or-reopen rule. The specialist launches into that chat, never into Myra's. - **Streaming a reply.** An agent's live reply reaches the timeline through one path, deltas to pixels: