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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ 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:

Expand Down
22 changes: 22 additions & 0 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-directory-tools/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"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",
"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",
"exports": {
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-directory-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
CreateAgentDefinitionError,
inviteParticipant,
listAgentDefinitions,
mintAgentDm,
NoOwnChannelError,
NoOwnWorkbenchError,
type AgentDirectoryToolClientConfig,
} from "./client";

Expand Down Expand Up @@ -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);
});
59 changes: 59 additions & 0 deletions packages/agent-directory-tools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
Expand Down Expand Up @@ -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<MintedAgentDm> {
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;
}
3 changes: 3 additions & 0 deletions packages/agent-directory-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 20 additions & 9 deletions packages/agent-directory-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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",
Expand All @@ -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) => {
Expand Down Expand Up @@ -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")) {
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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",
Expand All @@ -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")) {
Expand All @@ -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",
Expand All @@ -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 {
Expand Down
Loading
Loading