Skip to content
Closed
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
42 changes: 42 additions & 0 deletions extensions/commonly/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,46 @@ describe("CommonlyClient", () => {
).rejects.toThrow(/400/);
});
});

describe("agent-dm", () => {
it("openAgentDm POSTs target shape with default instanceId omitted", async () => {
fetchMock.mockResolvedValue(createResponse({ room: { _id: "pod-dm-1", name: "Pixel ↔ Aria" }, autoJoined: false }));
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });

const r = await client.openAgentDm({ agentName: "openclaw", instanceId: "aria" }, "pod-team-1");

expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:5000/api/agents/runtime/agent-dm",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ Authorization: "Bearer rt" }),
}),
);
const body = JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body);
expect(body.target).toEqual({ agentName: "openclaw", instanceId: "aria" });
expect(body.originPodId).toBe("pod-team-1");
expect(r.room._id).toBe("pod-dm-1");
});

it("openAgentDm omits instanceId when not provided (defaults to 'default' server-side)", async () => {
fetchMock.mockResolvedValue(createResponse({ room: { _id: "pod-dm-2" }, autoJoined: false }));
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });

await client.openAgentDm({ agentName: "codex" });

const body = JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body);
expect(body.target).toEqual({ agentName: "codex" });
expect(body.originPodId).toBeUndefined();
});

it("openAgentDm surfaces 403 from co-pod-member rule", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
text: async () => '{"message":"No shared pod with target"}',
});
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
await expect(client.openAgentDm({ agentName: "stranger" })).rejects.toThrow(/403/);
});
});
});
40 changes: 40 additions & 0 deletions extensions/commonly/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,46 @@ export class CommonlyClient {
return res.json();
}

/**
* Open or fetch an `agent-dm` pod between this agent and a target agent.
* Idempotent: a second call for the same (caller, target) pair returns
* the existing pod. The §3.7 co-pod-member rule on the server side gates
* creation — this agent must already share at least one pod with the
* target before a private DM is allowed (otherwise 403).
*
* Target identity is `agentName` (the registry/preset id) plus an
* optional `instanceId` (defaults to 'default'). For OpenClaw-driven
* agents the agentName is 'openclaw' and the instanceId carries the
* actual identity ('pixel', 'aria', etc.).
*
* `originPodId` is optional context — when set, the server drops a
* "DM started" system message in that pod so humans can find the
* thread without it polluting the team chat.
*/
async openAgentDm(
target: { agentName: string; instanceId?: string },
originPodId?: string,
): Promise<{ room: { _id: string; name?: string; type?: string; members?: unknown[] }; autoJoined: boolean }> {
const body: Record<string, unknown> = {
target: {
agentName: target.agentName,
...(target.instanceId ? { instanceId: target.instanceId } : {}),
},
};
if (originPodId) body.originPodId = originPodId;

const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/agent-dm`, {
method: 'POST',
headers: this.runtimeHeaders,
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = await res.text().catch(() => '');
throw new Error(`Failed to open agent-dm: ${res.status} ${msg}`);
}
return res.json();
}

/**
* List tasks for a pod
*/
Expand Down
30 changes: 30 additions & 0 deletions extensions/commonly/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,36 @@ export class CommonlyTools {
return jsonResult({ ok: true, ...result });
},
},
{
name: "commonly_open_dm",
label: "Commonly Open Agent DM",
description:
"Open or fetch a private 1:1 DM pod with another agent. Idempotent — repeat calls for the same target return the same pod. Returns the pod's id; use commonly_post_message with that podId to actually send a message. " +
"Identity: pass `agentName` (the other agent's registry name, e.g. 'pixel', 'codex') and optionally `instanceId` (defaults to 'default'). " +
"For OpenClaw-driven agents, agentName is the runtime ('openclaw') and instanceId carries the identity ('aria', 'pixel', etc.). " +
"Authorization: server enforces the co-pod-member rule — you must already share at least one pod with the target before a DM is allowed. If the target isn't reachable you'll get a 403. " +
"Use this when you want a side-thread with a peer you've already worked with — discussing details before surfacing the result, asking a specialist a one-off question, or coordinating handoffs that don't belong in a team pod yet.",
parameters: Type.Object({
agentName: Type.String({ description: "Target agent's registry name (e.g. 'codex', 'pixel'). For an OpenClaw-driven peer, pass 'openclaw' and supply their identity in instanceId." }),
instanceId: Type.Optional(Type.String({ description: "Target's instanceId (defaults to 'default'). For an OpenClaw peer, this is their actual identity ('aria', 'pixel', ...)." })),
originPodId: Type.Optional(Type.String({ description: "Pod where the conversation context originated. When set, the server drops a 'DM started' system message in that pod so humans can find the link without it polluting team chat." })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentName = readStringParam(params, "agentName", { required: true });
const instanceId = readStringParam(params, "instanceId");
const originPodId = readStringParam(params, "originPodId");
const result = await client.openAgentDm(
{ agentName, ...(instanceId ? { instanceId } : {}) },
originPodId || undefined,
);
return jsonResult({
ok: true,
podId: result.room?._id,
podName: result.room?.name,
autoJoined: result.autoJoined,
});
},
},
{
name: "commonly_get_tasks",
label: "Commonly Get Tasks",
Expand Down
Loading