feat(sessions): add agent-to-agent post step
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
||||
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
||||
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
||||
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
|
||||
|
||||
### Fixes
|
||||
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
|
||||
@@ -71,6 +72,7 @@
|
||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
||||
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
|
||||
- macOS: clarify menu bar uses sessionKey from agent events.
|
||||
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
|
||||
@@ -378,6 +378,8 @@ public struct AgentParams: Codable {
|
||||
public let deliver: Bool?
|
||||
public let channel: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -389,6 +391,8 @@ public struct AgentParams: Codable {
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.message = message
|
||||
@@ -399,6 +403,8 @@ public struct AgentParams: Codable {
|
||||
self.deliver = deliver
|
||||
self.channel = channel
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -410,6 +416,8 @@ public struct AgentParams: Codable {
|
||||
case deliver
|
||||
case channel
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ Behavior:
|
||||
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
|
||||
- If the run fails: `{ runId, status: "error", error }`.
|
||||
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
|
||||
- Agent-to-agent message context is injected for the primary run.
|
||||
- After the primary run completes, Clawdis starts an **agent-to-agent post step**:
|
||||
- The agent can reply with the announcement to post to the target session.
|
||||
- To stay silent, reply exactly `ANNOUNCE_SKIP`.
|
||||
- Any other reply is sent to the target channel.
|
||||
- The post step includes the original request and round‑1 reply in context.
|
||||
|
||||
## Provider Field
|
||||
- For groups, `provider` is the `surface` recorded on the session entry.
|
||||
|
||||
@@ -119,6 +119,7 @@ Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
- `sessions_send` always runs a follow‑up **agent‑to‑agent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||
|
||||
### `discord`
|
||||
Send Discord reactions, stickers, or polls.
|
||||
|
||||
@@ -124,16 +124,38 @@ describe("sessions tools", () => {
|
||||
it("sessions_send supports fire-and-forget and wait", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let historyCallCount = 0;
|
||||
let sendCallCount = 0;
|
||||
let waitRunId: string | undefined;
|
||||
let nextHistoryIsWaitReply = false;
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-1", status: "accepted", acceptedAt: 1234 };
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as { message?: string } | undefined;
|
||||
if (params?.message === "wait") {
|
||||
waitRunId = runId;
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 1234 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
return { runId: "run-1", status: "ok" };
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
if (params?.runId && params.runId === waitRunId) {
|
||||
nextHistoryIsWaitReply = true;
|
||||
}
|
||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
historyCallCount += 1;
|
||||
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
|
||||
nextHistoryIsWaitReply = false;
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
@@ -141,7 +163,7 @@ describe("sessions tools", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
text,
|
||||
},
|
||||
],
|
||||
timestamp: 20,
|
||||
@@ -149,6 +171,10 @@ describe("sessions tools", () => {
|
||||
],
|
||||
};
|
||||
}
|
||||
if (request.method === "send") {
|
||||
sendCallCount += 1;
|
||||
return { messageId: "m1" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -164,6 +190,7 @@ describe("sessions tools", () => {
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const waitPromise = tool.execute("call6", {
|
||||
sessionKey: "main",
|
||||
@@ -173,21 +200,46 @@ describe("sessions tools", () => {
|
||||
const waited = await waitPromise;
|
||||
expect(waited.details).toMatchObject({
|
||||
status: "ok",
|
||||
runId: "run-1",
|
||||
reply: "done",
|
||||
});
|
||||
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
const waitCalls = calls.filter((call) => call.method === "agent.wait");
|
||||
const historyOnlyCalls = calls.filter(
|
||||
(call) => call.method === "chat.history",
|
||||
);
|
||||
expect(agentCalls).toHaveLength(2);
|
||||
expect(agentCalls).toHaveLength(4);
|
||||
for (const call of agentCalls) {
|
||||
expect(call.params).toMatchObject({ lane: "nested" });
|
||||
}
|
||||
expect(waitCalls).toHaveLength(1);
|
||||
expect(historyOnlyCalls).toHaveLength(1);
|
||||
expect(waitCalls[0]?.params).toMatchObject({ afterMs: 1234 });
|
||||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt?.includes("Agent-to-agent message context"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt?.includes("Agent-to-agent post step"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(waitCalls).toHaveLength(3);
|
||||
expect(historyOnlyCalls).toHaveLength(3);
|
||||
expect(
|
||||
waitCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { afterMs?: number })?.afterMs === "number",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(sendCallCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2712,7 +2712,88 @@ function createSessionsHistoryTool(): AnyAgentTool {
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionsSendTool(): AnyAgentTool {
|
||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||
|
||||
type AnnounceTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
function resolveAnnounceTargetFromKey(
|
||||
sessionKey: string,
|
||||
): AnnounceTarget | null {
|
||||
const parts = sessionKey.split(":").filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const [surface, kind, ...rest] = parts;
|
||||
if (kind !== "group" && kind !== "channel") return null;
|
||||
const id = rest.join(":").trim();
|
||||
if (!id) return null;
|
||||
if (!surface) return null;
|
||||
const channel = surface.toLowerCase();
|
||||
if (channel === "discord") {
|
||||
return { channel, to: `channel:${id}` };
|
||||
}
|
||||
if (channel === "signal") {
|
||||
return { channel, to: `group:${id}` };
|
||||
}
|
||||
return { channel, to: id };
|
||||
}
|
||||
|
||||
function buildAgentToAgentMessageContext(params: {
|
||||
requesterSessionKey?: string;
|
||||
requesterSurface?: string;
|
||||
targetSessionKey: string;
|
||||
}) {
|
||||
const lines = [
|
||||
"Agent-to-agent message context:",
|
||||
params.requesterSessionKey
|
||||
? `Requester session: ${params.requesterSessionKey}.`
|
||||
: undefined,
|
||||
params.requesterSurface
|
||||
? `Requester surface: ${params.requesterSurface}.`
|
||||
: undefined,
|
||||
`Target session: ${params.targetSessionKey}.`,
|
||||
].filter(Boolean);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildAgentToAgentPostContext(params: {
|
||||
requesterSessionKey?: string;
|
||||
requesterSurface?: string;
|
||||
targetSessionKey: string;
|
||||
targetChannel?: string;
|
||||
originalMessage: string;
|
||||
roundOneReply?: string;
|
||||
}) {
|
||||
const lines = [
|
||||
"Agent-to-agent post step:",
|
||||
params.requesterSessionKey
|
||||
? `Requester session: ${params.requesterSessionKey}.`
|
||||
: undefined,
|
||||
params.requesterSurface
|
||||
? `Requester surface: ${params.requesterSurface}.`
|
||||
: undefined,
|
||||
`Target session: ${params.targetSessionKey}.`,
|
||||
params.targetChannel ? `Target surface: ${params.targetChannel}.` : undefined,
|
||||
`Original request: ${params.originalMessage}`,
|
||||
params.roundOneReply
|
||||
? `Round 1 reply: ${params.roundOneReply}`
|
||||
: "Round 1 reply: (not available).",
|
||||
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
|
||||
"Any other reply will be posted to the target channel.",
|
||||
"After this reply, the agent-to-agent conversation is over.",
|
||||
].filter(Boolean);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function isAnnounceSkip(text?: string) {
|
||||
return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN;
|
||||
}
|
||||
|
||||
function createSessionsSendTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
agentSurface?: string;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "Session Send",
|
||||
name: "sessions_send",
|
||||
@@ -2736,6 +2817,8 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
Number.isFinite(params.timeoutSeconds)
|
||||
? Math.max(0, Math.floor(params.timeoutSeconds))
|
||||
: 30;
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
let runId: string = idempotencyKey;
|
||||
const displayKey = resolveDisplaySessionKey({
|
||||
@@ -2743,12 +2826,129 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
const agentMessageContext = buildAgentToAgentMessageContext({
|
||||
requesterSessionKey: opts?.agentSessionKey,
|
||||
requesterSurface: opts?.agentSurface,
|
||||
targetSessionKey: displayKey,
|
||||
});
|
||||
const sendParams = {
|
||||
message,
|
||||
sessionKey: resolvedKey,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
lane: "nested",
|
||||
extraSystemPrompt: agentMessageContext,
|
||||
};
|
||||
|
||||
const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => {
|
||||
const parsed = resolveAnnounceTargetFromKey(resolvedKey);
|
||||
if (parsed) return parsed;
|
||||
try {
|
||||
const list = (await callGateway({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
limit: 200,
|
||||
},
|
||||
})) as { sessions?: Array<Record<string, unknown>> };
|
||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||
const match =
|
||||
sessions.find((entry) => entry?.key === resolvedKey) ??
|
||||
sessions.find((entry) => entry?.key === displayKey);
|
||||
const channel =
|
||||
typeof match?.lastChannel === "string"
|
||||
? match.lastChannel
|
||||
: undefined;
|
||||
const to =
|
||||
typeof match?.lastTo === "string" ? match.lastTo : undefined;
|
||||
if (channel && to) return { channel, to };
|
||||
} catch {
|
||||
// ignore; fall through to null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const runAgentToAgentPost = async (roundOneReply?: string) => {
|
||||
const announceTarget = await resolveAnnounceTarget();
|
||||
try {
|
||||
const postPrompt = buildAgentToAgentPostContext({
|
||||
requesterSessionKey: opts?.agentSessionKey,
|
||||
requesterSurface: opts?.agentSurface,
|
||||
targetSessionKey: displayKey,
|
||||
targetChannel: announceTarget?.channel ?? "unknown",
|
||||
originalMessage: message,
|
||||
roundOneReply,
|
||||
});
|
||||
const postIdem = crypto.randomUUID();
|
||||
const postResponse = (await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
message: "Agent-to-agent post step.",
|
||||
sessionKey: resolvedKey,
|
||||
idempotencyKey: postIdem,
|
||||
deliver: false,
|
||||
lane: "nested",
|
||||
extraSystemPrompt: postPrompt,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
})) as { runId?: string; acceptedAt?: number };
|
||||
const postRunId =
|
||||
typeof postResponse?.runId === "string" && postResponse.runId
|
||||
? postResponse.runId
|
||||
: postIdem;
|
||||
const postAcceptedAt =
|
||||
typeof postResponse?.acceptedAt === "number"
|
||||
? postResponse.acceptedAt
|
||||
: undefined;
|
||||
const postWaitMs = Math.min(announceTimeoutMs, 60_000);
|
||||
const postWait = (await callGateway({
|
||||
method: "agent.wait",
|
||||
params: {
|
||||
runId: postRunId,
|
||||
afterMs: postAcceptedAt,
|
||||
timeoutMs: postWaitMs,
|
||||
},
|
||||
timeoutMs: postWaitMs + 2000,
|
||||
})) as { status?: string };
|
||||
if (postWait?.status === "ok") {
|
||||
const postHistory = (await callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolvedKey, limit: 50 },
|
||||
})) as { messages?: unknown[] };
|
||||
const postFiltered = stripToolMessages(
|
||||
Array.isArray(postHistory?.messages)
|
||||
? postHistory.messages
|
||||
: [],
|
||||
);
|
||||
const postLast =
|
||||
postFiltered.length > 0
|
||||
? postFiltered[postFiltered.length - 1]
|
||||
: undefined;
|
||||
const postReply = postLast
|
||||
? extractAssistantText(postLast)
|
||||
: undefined;
|
||||
if (
|
||||
announceTarget &&
|
||||
postReply &&
|
||||
postReply.trim() &&
|
||||
!isAnnounceSkip(postReply)
|
||||
) {
|
||||
await callGateway({
|
||||
method: "send",
|
||||
params: {
|
||||
to: announceTarget.to,
|
||||
message: postReply.trim(),
|
||||
provider: announceTarget.channel,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort announce; ignore failures to avoid breaking the caller response.
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutSeconds === 0) {
|
||||
@@ -2761,6 +2961,7 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
if (typeof response?.runId === "string" && response.runId) {
|
||||
runId = response.runId;
|
||||
}
|
||||
void runAgentToAgentPost();
|
||||
return jsonResult({
|
||||
runId,
|
||||
status: "accepted",
|
||||
@@ -2810,7 +3011,6 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let waitStatus: string | undefined;
|
||||
let waitError: string | undefined;
|
||||
try {
|
||||
@@ -2867,6 +3067,7 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
const last =
|
||||
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
|
||||
const reply = last ? extractAssistantText(last) : undefined;
|
||||
void runAgentToAgentPost(reply ?? undefined);
|
||||
|
||||
return jsonResult({
|
||||
runId,
|
||||
@@ -2880,6 +3081,8 @@ function createSessionsSendTool(): AnyAgentTool {
|
||||
|
||||
export function createClawdisTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
agentSessionKey?: string;
|
||||
agentSurface?: string;
|
||||
}): AnyAgentTool[] {
|
||||
return [
|
||||
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
|
||||
@@ -2890,6 +3093,9 @@ export function createClawdisTools(options?: {
|
||||
createGatewayTool(),
|
||||
createSessionsListTool(),
|
||||
createSessionsHistoryTool(),
|
||||
createSessionsSendTool(),
|
||||
createSessionsSendTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentSurface: options?.agentSurface,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -498,6 +498,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
bash: params.config?.agent?.bash,
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
|
||||
@@ -445,6 +445,7 @@ export function createClawdisCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
surface?: string;
|
||||
sandbox?: SandboxContext | null;
|
||||
sessionKey?: string;
|
||||
}): AnyAgentTool[] {
|
||||
const bashToolName = "bash";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
@@ -488,6 +489,8 @@ export function createClawdisCodingTools(options?: {
|
||||
createWhatsAppLoginTool(),
|
||||
...createClawdisTools({
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentSurface: options?.surface,
|
||||
}),
|
||||
];
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
|
||||
|
||||
@@ -62,6 +62,7 @@ type AgentCommandOpts = {
|
||||
abortSignal?: AbortSignal;
|
||||
lane?: string;
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
};
|
||||
|
||||
type SessionResolution = {
|
||||
@@ -388,6 +389,7 @@ export async function agentCommand(
|
||||
runId,
|
||||
lane: opts.lane,
|
||||
abortSignal: opts.abortSignal,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
onAgentEvent: (evt) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
|
||||
@@ -209,6 +209,7 @@ export const AgentParamsSchema = Type.Object(
|
||||
channel: Type.Optional(Type.String()),
|
||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
||||
@@ -2931,6 +2931,7 @@ export async function handleGatewayRequest(
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
lane?: string;
|
||||
extraSystemPrompt?: string;
|
||||
idempotencyKey: string;
|
||||
timeout?: number;
|
||||
};
|
||||
@@ -3122,6 +3123,7 @@ export async function handleGatewayRequest(
|
||||
surface: "VoiceWake",
|
||||
runId,
|
||||
lane: params.lane,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
},
|
||||
defaultRuntime,
|
||||
deps,
|
||||
|
||||
Reference in New Issue
Block a user