Skip to content

Commit a1c335b

Browse files
committed
feat(chat): pin agent DM warm-mailbox shape in one place (CL-7108)
Agent DMs run the interactive warm-mailbox shape: one standing per-agent run provisioned from the conversation's definition asset, every inbound mail a header-threaded turn, durable turn rows + turn-mail correlation as the per-run INBOX. The DM pin (kind:chat + definitionId) now lives in agent-dm-mode.ts with the why-DMs-differ note; mint, reopen, view, and launch gate all read through it. Deletes the dead AGENT_SECTION_MODE beside the new deploy path.
1 parent fcc6c3c commit a1c335b

8 files changed

Lines changed: 91 additions & 62 deletions

File tree

‎packages/chat/src/agent-dm-mode.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// CL-7108: the agent-DM pin — the ONE place that says what an agent DM
2+
// is. An agent DM is a `kind: "chat"` conversation carrying a
3+
// `chat/definitionId`: the one 1:1 (tenant, agent) conversation, minted by
4+
// `mintAgentDm` and found-or-reopened by `findExistingAgentChat` (see
5+
// `./workbench-service.ts`) rather than invited like a room agent.
6+
//
7+
// Why DMs differ from rooms is chat-layer only — the execution plane is
8+
// identical. A DM's agent runs the same interactive warm-mailbox shape a
9+
// room agent does: one standing per-agent run, provisioned once (at mint,
10+
// ahead of the member's first message) from the conversation's
11+
// single-definition asset, every inbound mail a header-threaded turn on
12+
// that run, the turn rows a durable per-run INBOX verifiable in the hub
13+
// replica, and the agent's `mail_wait` wired in the one place the
14+
// execution plane parks (the run child's watch registry — the chat layer
15+
// never parks awaiting agent mail; dispatch is fire-and-forget and the
16+
// agent-turns projection plus the turn-mail correlation carry the trail).
17+
// What makes a DM a DM lives entirely here at the chat layer: the 1:1
18+
// identity this pin names, find-or-reopen instead of invite, the sidebar
19+
// bucket, the greeting, mint-not-invite.
20+
//
21+
// Every settings-level reader and writer of DM-ness goes through this
22+
// module's keys and predicate — never a second inline `chat/definitionId`
23+
// literal beside it — so the pin cannot drift between mint, reopen, the
24+
// workbench view, and the launch gate.
25+
export const AGENT_DM_KIND = "chat";
26+
27+
export const AGENT_DM_DEFINITION_ID_KEY = "chat/definitionId";
28+
29+
/** The agent this DM was minted for, or `undefined` when the settings
30+
* carry no (string) definition id. Non-string values are not a DM's agent
31+
* — validation at the trust boundary, not a fallback path. */
32+
export function definitionIdOfSettings(
33+
settings: Record<string, unknown>,
34+
): string | undefined {
35+
const value = settings[AGENT_DM_DEFINITION_ID_KEY];
36+
return typeof value === "string" ? value : undefined;
37+
}
38+
39+
/** Whether these settings are an agent DM: kind `chat` (a missing kind
40+
* reads as `chat`, mirroring `kindOf` in `./workbench-settings.ts`) with
41+
* a definition id naming its agent. */
42+
export function isAgentDmSettings(settings: Record<string, unknown>): boolean {
43+
const kind = settings["chat/kind"];
44+
const effectiveKind = typeof kind === "string" ? kind : AGENT_DM_KIND;
45+
return (
46+
effectiveKind === AGENT_DM_KIND &&
47+
definitionIdOfSettings(settings) !== undefined
48+
);
49+
}

‎packages/chat/src/index.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ export {
129129
createInMemoryTurnClaimStore,
130130
} from "./turn-claims";
131131
export type { TurnClaim, TurnClaimStore, TurnClaimToken } from "./turn-claims";
132+
export { workbenchLaunchPersistExtra } from "./standalone-launch";
132133
export {
133-
AGENT_SECTION_MODE,
134-
workbenchLaunchPersistExtra,
135-
} from "./standalone-launch";
134+
AGENT_DM_DEFINITION_ID_KEY,
135+
AGENT_DM_KIND,
136+
definitionIdOfSettings,
137+
isAgentDmSettings,
138+
} from "./agent-dm-mode";
136139
export { recordSourcesDigest } from "./agent-binding";
137140
export { createWorkbenchTurnQueue, TurnQueuedEvent } from "./turn-queue";
138141
export type {

‎packages/chat/src/routes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
validateSettingsPatch,
6565
visibilityOf,
6666
} from "./workbench-settings";
67+
import { AGENT_DM_DEFINITION_ID_KEY } from "./agent-dm-mode";
6768
import { listWorkbenchLiveState } from "./workbench-reply-activity";
6869
import { postRoomMessage, type RoomMessageStore } from "./room-messages";
6970
import { WorkbenchOnboardingStep } from "./blocks";
@@ -1191,7 +1192,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
11911192
const withDefinitionId: Record<string, unknown> = isChatWithDefinition(
11921193
body,
11931194
)
1194-
? { ...baseSettings, "chat/definitionId": body.definitionId }
1195+
? { ...baseSettings, [AGENT_DM_DEFINITION_ID_KEY]: body.definitionId }
11951196
: baseSettings;
11961197
const settings: Record<string, unknown> =
11971198
chatTitle !== undefined

‎packages/chat/src/standalone-launch.test.ts‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@
44
import { describe, expect, test } from "bun:test";
55

66
import { workbenchLaunch } from "./schema";
7-
import {
8-
AGENT_SECTION_MODE,
9-
workbenchLaunchPersistExtra,
10-
} from "./standalone-launch";
11-
import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims";
7+
import { workbenchLaunchPersistExtra } from "./standalone-launch";
128

139
const FOLDED_BODY = {
1410
systemPrompt: "be helpful",
@@ -18,15 +14,6 @@ const FOLDED_BODY = {
1814
model: "claude-sonnet-5",
1915
};
2016

21-
describe("AGENT_SECTION_MODE", () => {
22-
test("is the onTrigger section shape with the chat turn timeout", () => {
23-
expect(AGENT_SECTION_MODE).toEqual({
24-
kind: "section",
25-
turnTimeoutMs: CHAT_TURN_TIMEOUT_MS,
26-
});
27-
});
28-
});
29-
3017
describe("workbenchLaunchPersistExtra", () => {
3118
test("writes the identity mapping into workbench_launch", async () => {
3219
const written: { table: unknown; values: unknown }[] = [];

‎packages/chat/src/standalone-launch.ts‎

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,11 @@
99
// it, the wake path cannot resolve it, and its next occurrence 409s
1010
// (`workflow_run_terminal`) forever. These exports are what a standalone
1111
// launcher passes so its run rides the exact same relaunch path a
12-
// room-invited agent does: the section mode the deploy pins, and the
13-
// `persistExtra` that writes the mapping row inside the launch
14-
// transaction itself.
12+
// room-invited agent does: the `persistExtra` that writes the mapping row
13+
// inside the launch transaction itself.
1514
import type { DBExecutor } from "@intx/db";
1615
import type { FoldedBody } from "@intx/workflow-deploy";
17-
import type { AgentRuntimeConfig } from "@corbits/agent-runtime";
1816
import { workbenchLaunch } from "./schema";
19-
import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims";
20-
21-
/**
22-
* The shape every launched agent run deploys as — chat's room invites
23-
* and standalone routine/webhook launches alike: an `onTrigger` section
24-
* (CL-6329), one warm run per agent, each inbound message an occurrence
25-
* running as its own child run (`turn__<n>`) with its own event log.
26-
* That child id is what a reply's `run_id` carries, which is the whole
27-
* reason a reply is traceable.
28-
*
29-
* `onBodyFailure: "tolerate"` — authored in the section shape itself
30-
* (`@corbits/agent-runtime`) — is the failure edge: a turn that throws
31-
* records a failed occurrence and leaves the section subscribed, so one
32-
* bad turn kills neither the agent nor the conversation.
33-
*/
34-
export const AGENT_SECTION_MODE: AgentRuntimeConfig["mode"] = {
35-
kind: "section",
36-
turnTimeoutMs: CHAT_TURN_TIMEOUT_MS,
37-
};
3817

3918
/**
4019
* The `persistExtra` a standalone launch hands its provisioned deploy:

‎packages/chat/src/workbench-service.ts‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import {
4343
participantsOf,
4444
resolveContextWindow,
4545
} from "./workbench-settings";
46+
import {
47+
AGENT_DM_DEFINITION_ID_KEY,
48+
AGENT_DM_KIND,
49+
definitionIdOfSettings,
50+
} from "./agent-dm-mode";
4651
import { presetForKind } from "./kinds";
4752
import type {
4853
WorkbenchLauncher,
@@ -232,9 +237,11 @@ export type FindExistingAgentChatDeps = {
232237
* tenant with that agent. Uniqueness is per (bench, definitionId).
233238
* Product reopens; it does not clone.
234239
*
235-
* Matches forward, by the `chat/definitionId` every agent chat has
236-
* carried in its settings since this landed, and falls back to
237-
* `matchesLegacyAgentChat` for a chat minted before that key existed.
240+
* Matches forward, by the DM pin (`definitionIdOfSettings` in
241+
* `./agent-dm-mode.ts` — the one place that says what an agent DM is)
242+
* every agent chat has carried in its settings since this landed, and
243+
* falls back to `matchesLegacyAgentChat` for a chat minted before that
244+
* key existed.
238245
* The comparison is on the definition's ASSET, not the row id: a
239246
* code-sourced deploy projects a new `workflow_definition` row per
240247
* frozen wire projection, so the id a chat recorded at creation and the
@@ -255,11 +262,10 @@ export async function findExistingAgentChat(
255262
const assetId = await deps.platform.resolveDefinitionAssetId(definitionId);
256263
const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = [];
257264
for (const row of chats) {
258-
const storedDefinitionId = row.settings["chat/definitionId"];
265+
const storedDefinitionId = definitionIdOfSettings(row.settings);
259266
const isMatch =
260267
storedDefinitionId !== undefined
261-
? typeof storedDefinitionId === "string" &&
262-
(await sameAgent(deps, storedDefinitionId, definitionId, assetId))
268+
? await sameAgent(deps, storedDefinitionId, definitionId, assetId)
263269
: await matchesLegacyAgentChat(deps, row, definitionId);
264270
if (!isMatch) continue;
265271
const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId);
@@ -384,10 +390,10 @@ export async function mintAgentDm(
384390

385391
const preset = presetForKind("chat");
386392
const baseSettings: Record<string, unknown> = {
387-
"chat/kind": "chat",
393+
"chat/kind": AGENT_DM_KIND,
388394
"chat/pinned": preset.pinned,
389395
"chat/participants": [],
390-
"chat/definitionId": input.definitionId,
396+
[AGENT_DM_DEFINITION_ID_KEY]: input.definitionId,
391397
};
392398
const settings: Record<string, unknown> =
393399
chatTitle !== undefined
@@ -680,8 +686,8 @@ export async function launchAndJoinAgent(
680686
};
681687
}
682688

683-
if (kindOf(input.existingSettings) === "chat") {
684-
const boundDefinitionId = input.existingSettings["chat/definitionId"];
689+
if (kindOf(input.existingSettings) === AGENT_DM_KIND) {
690+
const boundDefinitionId = definitionIdOfSettings(input.existingSettings);
685691
const alreadyHasAgent = participants.some((participant) =>
686692
isAgentAddress(participant.address),
687693
);
@@ -1980,9 +1986,9 @@ export type DispatchTurnInput = {
19801986
* Asks one agent for a turn — the seam between the room (rows on a
19811987
* timeline) and the execution plane.
19821988
*
1983-
* A room agent deploys as an `onTrigger` section (CL-6329, see
1984-
* `./standalone-launch.ts`'s `AGENT_SECTION_MODE`), so this fires that
1985-
* section's trigger: one occurrence, running as its own child run
1989+
* An agent runs as one warm folded run provisioned from the
1990+
* conversation's definition asset, so this fires that run's mail trigger:
1991+
* one occurrence, running as its own child run
19861992
* (`turn__<n>`) with its own event log, against the one warm run the
19871993
* (agent, workbench) pair already holds. The trigger is a mail trigger —
19881994
* that is the primitive the section subscribes on — so `sendMail`

‎packages/chat/src/workbench-settings.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ParticipantsSetting,
1414
type ParticipantRecord,
1515
} from "./participants";
16+
import { definitionIdOfSettings } from "./agent-dm-mode";
1617

1718
const PatchSettingsBody = type("Record<string, unknown>");
1819

@@ -245,7 +246,7 @@ export function workbenchView(row: {
245246
const kind = kindOf(row.settings);
246247
const name = row.settings["chat/name"];
247248
const pinned = row.settings["chat/pinned"];
248-
const definitionId = row.settings["chat/definitionId"];
249+
const definitionId = definitionIdOfSettings(row.settings);
249250
return {
250251
id: row.workbenchId,
251252
title: typeof name === "string" ? name : row.workbenchId,

‎packages/chat/test/agent-dm-warm-mailbox.test.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ describe("agent DM pin (CL-7108)", () => {
5555
});
5656

5757
test("a group conversation with a definition id is not a DM", () => {
58-
expect(
59-
isAgentDmSettings({ ...dmSettings(), "chat/kind": "group" }),
60-
).toBe(false);
58+
expect(isAgentDmSettings({ ...dmSettings(), "chat/kind": "group" })).toBe(
59+
false,
60+
);
6161
});
6262

6363
test("a chat without a definition id is not a DM", () => {
@@ -184,17 +184,20 @@ describe("agent DM conversation trail (CL-7108)", () => {
184184
]);
185185

186186
// Every inbound mail is a threaded turn: each dispatched frame carries
187-
// its own row-derived Message-ID (CL-7450), and each turn's
188-
// correlation back to its row is recorded for the reply path.
187+
// its own row-derived Message-ID (CL-7450), and each dispatch mail's
188+
// correlation back to the row it answers is recorded for the reply
189+
// path (CL-6314) — keyed by the answered row, whose derived
190+
// Message-ID the sidecar's bracket reports back.
189191
expect(sentMail).toHaveLength(2);
190192
expect(sentMail[0]?.messageId).toBe(`<${first.id}@${DOMAIN}>`);
191193
expect(sentMail[1]?.messageId).toBe(`<${second.id}@${DOMAIN}>`);
192194
for (const posted of [first, second]) {
193195
const source = await turnMailCorrelation.findTurnMailSource({
194196
tenantId: TENANT_ID,
195-
mailMessageId: `<${posted.id}@${DOMAIN}>`,
197+
mailId: posted.id,
196198
});
197-
expect(source?.tenantId).toBe(TENANT_ID);
199+
expect(source?.workbenchId).toBe(WORKBENCH_ID);
200+
expect(source?.sourceMessageId).toBe(posted.id);
198201
}
199202
});
200203
});

0 commit comments

Comments
 (0)