feat(sessions): add agent-to-agent post step

This commit is contained in:
Peter Steinberger
2026-01-04 03:04:55 +01:00
parent 052cec70ae
commit add1301a51
11 changed files with 295 additions and 11 deletions

View File

@@ -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 agenttoagent 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

View File

@@ -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"
}
}

View File

@@ -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 round1 reply in context.
## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry.

View File

@@ -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 followup **agenttoagent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `discord`
Send Discord reactions, stickers, or polls.

View File

@@ -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);
});
});

View File

@@ -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,
}),
];
}

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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,