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
49 changes: 49 additions & 0 deletions packages/chat/src/agent-dm-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// CL-7108: the agent-DM pin — the ONE place that says what an agent DM
// is. An agent DM is a `kind: "chat"` conversation carrying a
// `chat/definitionId`: the one 1:1 (tenant, agent) conversation, minted by
// `mintAgentDm` and found-or-reopened by `findExistingAgentChat` (see
// `./workbench-service.ts`) rather than invited like a room agent.
//
// Why DMs differ from rooms is chat-layer only — the execution plane is
// identical. A DM's agent runs the same interactive warm-mailbox shape a
// room agent does: one standing per-agent run, provisioned once (at mint,
// ahead of the member's first message) from the conversation's
// single-definition asset, every inbound mail a header-threaded turn on
// that run, the turn rows a durable per-run INBOX verifiable in the hub
// replica, and the agent's `mail_wait` wired in the one place the
// execution plane parks (the run child's watch registry — the chat layer
// never parks awaiting agent mail; dispatch is fire-and-forget and the
// agent-turns projection plus the turn-mail correlation carry the trail).
// What makes a DM a DM lives entirely here at the chat layer: the 1:1
// identity this pin names, find-or-reopen instead of invite, the sidebar
// bucket, the greeting, mint-not-invite.
//
// Every settings-level reader and writer of DM-ness goes through this
// module's keys and predicate — never a second inline `chat/definitionId`
// literal beside it — so the pin cannot drift between mint, reopen, the
// workbench view, and the launch gate.
export const AGENT_DM_KIND = "chat";

export const AGENT_DM_DEFINITION_ID_KEY = "chat/definitionId";

/** The agent this DM was minted for, or `undefined` when the settings
* carry no (string) definition id. Non-string values are not a DM's agent
* — validation at the trust boundary, not a fallback path. */
export function definitionIdOfSettings(
settings: Record<string, unknown>,
): string | undefined {
const value = settings[AGENT_DM_DEFINITION_ID_KEY];
return typeof value === "string" ? value : undefined;
}

/** Whether these settings are an agent DM: kind `chat` (a missing kind
* reads as `chat`, mirroring `kindOf` in `./workbench-settings.ts`) with
* a definition id naming its agent. */
export function isAgentDmSettings(settings: Record<string, unknown>): boolean {
const kind = settings["chat/kind"];
const effectiveKind = typeof kind === "string" ? kind : AGENT_DM_KIND;
return (
effectiveKind === AGENT_DM_KIND &&
definitionIdOfSettings(settings) !== undefined
);
}
9 changes: 6 additions & 3 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,13 @@ export {
createInMemoryTurnClaimStore,
} from "./turn-claims";
export type { TurnClaim, TurnClaimStore, TurnClaimToken } from "./turn-claims";
export { workbenchLaunchPersistExtra } from "./standalone-launch";
export {
AGENT_SECTION_MODE,
workbenchLaunchPersistExtra,
} from "./standalone-launch";
AGENT_DM_DEFINITION_ID_KEY,
AGENT_DM_KIND,
definitionIdOfSettings,
isAgentDmSettings,
} from "./agent-dm-mode";
export { recordSourcesDigest } from "./agent-binding";
export { createWorkbenchTurnQueue, TurnQueuedEvent } from "./turn-queue";
export type {
Expand Down
3 changes: 2 additions & 1 deletion packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
validateSettingsPatch,
visibilityOf,
} from "./workbench-settings";
import { AGENT_DM_DEFINITION_ID_KEY } from "./agent-dm-mode";
import { listWorkbenchLiveState } from "./workbench-reply-activity";
import { postRoomMessage, type RoomMessageStore } from "./room-messages";
import { WorkbenchOnboardingStep } from "./blocks";
Expand Down Expand Up @@ -1191,7 +1192,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
const withDefinitionId: Record<string, unknown> = isChatWithDefinition(
body,
)
? { ...baseSettings, "chat/definitionId": body.definitionId }
? { ...baseSettings, [AGENT_DM_DEFINITION_ID_KEY]: body.definitionId }
: baseSettings;
const settings: Record<string, unknown> =
chatTitle !== undefined
Expand Down
15 changes: 1 addition & 14 deletions packages/chat/src/standalone-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
import { describe, expect, test } from "bun:test";

import { workbenchLaunch } from "./schema";
import {
AGENT_SECTION_MODE,
workbenchLaunchPersistExtra,
} from "./standalone-launch";
import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims";
import { workbenchLaunchPersistExtra } from "./standalone-launch";

const FOLDED_BODY = {
systemPrompt: "be helpful",
Expand All @@ -18,15 +14,6 @@ const FOLDED_BODY = {
model: "claude-sonnet-5",
};

describe("AGENT_SECTION_MODE", () => {
test("is the onTrigger section shape with the chat turn timeout", () => {
expect(AGENT_SECTION_MODE).toEqual({
kind: "section",
turnTimeoutMs: CHAT_TURN_TIMEOUT_MS,
});
});
});

describe("workbenchLaunchPersistExtra", () => {
test("writes the identity mapping into workbench_launch", async () => {
const written: { table: unknown; values: unknown }[] = [];
Expand Down
25 changes: 2 additions & 23 deletions packages/chat/src/standalone-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,11 @@
// it, the wake path cannot resolve it, and its next occurrence 409s
// (`workflow_run_terminal`) forever. These exports are what a standalone
// launcher passes so its run rides the exact same relaunch path a
// room-invited agent does: the section mode the deploy pins, and the
// `persistExtra` that writes the mapping row inside the launch
// transaction itself.
// room-invited agent does: the `persistExtra` that writes the mapping row
// inside the launch transaction itself.
import type { DBExecutor } from "@intx/db";
import type { FoldedBody } from "@intx/workflow-deploy";
import type { AgentRuntimeConfig } from "@corbits/agent-runtime";
import { workbenchLaunch } from "./schema";
import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims";

/**
* The shape every launched agent run deploys as — chat's room invites
* and standalone routine/webhook launches alike: an `onTrigger` section
* (CL-6329), one warm run per agent, each inbound message an occurrence
* running as its own child run (`turn__<n>`) with its own event log.
* That child id is what a reply's `run_id` carries, which is the whole
* reason a reply is traceable.
*
* `onBodyFailure: "tolerate"` — authored in the section shape itself
* (`@corbits/agent-runtime`) — is the failure edge: a turn that throws
* records a failed occurrence and leaves the section subscribed, so one
* bad turn kills neither the agent nor the conversation.
*/
export const AGENT_SECTION_MODE: AgentRuntimeConfig["mode"] = {
kind: "section",
turnTimeoutMs: CHAT_TURN_TIMEOUT_MS,
};

/**
* The `persistExtra` a standalone launch hands its provisioned deploy:
Expand Down
32 changes: 19 additions & 13 deletions packages/chat/src/workbench-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ import {
participantsOf,
resolveContextWindow,
} from "./workbench-settings";
import {
AGENT_DM_DEFINITION_ID_KEY,
AGENT_DM_KIND,
definitionIdOfSettings,
} from "./agent-dm-mode";
import { presetForKind } from "./kinds";
import type {
WorkbenchLauncher,
Expand Down Expand Up @@ -232,9 +237,11 @@ export type FindExistingAgentChatDeps = {
* 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.
* Matches forward, by the DM pin (`definitionIdOfSettings` in
* `./agent-dm-mode.ts` — the one place that says what an agent DM is)
* 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
Expand All @@ -255,11 +262,10 @@ export async function findExistingAgentChat(
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 storedDefinitionId = definitionIdOfSettings(row.settings);
const isMatch =
storedDefinitionId !== undefined
? typeof storedDefinitionId === "string" &&
(await sameAgent(deps, storedDefinitionId, definitionId, assetId))
? await sameAgent(deps, storedDefinitionId, definitionId, assetId)
: await matchesLegacyAgentChat(deps, row, definitionId);
if (!isMatch) continue;
const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId);
Expand Down Expand Up @@ -384,10 +390,10 @@ export async function mintAgentDm(

const preset = presetForKind("chat");
const baseSettings: Record<string, unknown> = {
"chat/kind": "chat",
"chat/kind": AGENT_DM_KIND,
"chat/pinned": preset.pinned,
"chat/participants": [],
"chat/definitionId": input.definitionId,
[AGENT_DM_DEFINITION_ID_KEY]: input.definitionId,
};
const settings: Record<string, unknown> =
chatTitle !== undefined
Expand Down Expand Up @@ -680,8 +686,8 @@ export async function launchAndJoinAgent(
};
}

if (kindOf(input.existingSettings) === "chat") {
const boundDefinitionId = input.existingSettings["chat/definitionId"];
if (kindOf(input.existingSettings) === AGENT_DM_KIND) {
const boundDefinitionId = definitionIdOfSettings(input.existingSettings);
const alreadyHasAgent = participants.some((participant) =>
isAgentAddress(participant.address),
);
Expand Down Expand Up @@ -1980,9 +1986,9 @@ export type DispatchTurnInput = {
* Asks one agent for a turn — the seam between the room (rows on a
* timeline) and the execution plane.
*
* A room agent deploys as an `onTrigger` section (CL-6329, see
* `./standalone-launch.ts`'s `AGENT_SECTION_MODE`), so this fires that
* section's trigger: one occurrence, running as its own child run
* An agent runs as one warm folded run provisioned from the
* conversation's definition asset, so this fires that run's mail trigger:
* one occurrence, running as its own child run
* (`turn__<n>`) with its own event log, against the one warm run the
* (agent, workbench) pair already holds. The trigger is a mail trigger —
* that is the primitive the section subscribes on — so `sendMail`
Expand Down
3 changes: 2 additions & 1 deletion packages/chat/src/workbench-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ParticipantsSetting,
type ParticipantRecord,
} from "./participants";
import { definitionIdOfSettings } from "./agent-dm-mode";

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

Expand Down Expand Up @@ -245,7 +246,7 @@ export function workbenchView(row: {
const kind = kindOf(row.settings);
const name = row.settings["chat/name"];
const pinned = row.settings["chat/pinned"];
const definitionId = row.settings["chat/definitionId"];
const definitionId = definitionIdOfSettings(row.settings);
return {
id: row.workbenchId,
title: typeof name === "string" ? name : row.workbenchId,
Expand Down
Loading
Loading