diff --git a/CHANGELOG.md b/CHANGELOG.md index cadf4315b..e288f110c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 2a13daffe..2a10b182a 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -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" } } diff --git a/docs/session-tool.md b/docs/session-tool.md index d7a795d78..ef12fa776 100644 --- a/docs/session-tool.md +++ b/docs/session-tool.md @@ -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. diff --git a/docs/tools.md b/docs/tools.md index b14fa8059..fc2a92996 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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. diff --git a/src/agents/clawdis-tools.sessions.test.ts b/src/agents/clawdis-tools.sessions.test.ts index c30d5a71b..e764e20f8 100644 --- a/src/agents/clawdis-tools.sessions.test.ts +++ b/src/agents/clawdis-tools.sessions.test.ts @@ -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); }); }); diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 7e2ca4d15..7f785baba 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -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 => { + 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> }; + 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, + }), ]; } diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 660d0cdbd..7babec643 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -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 = { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 732309fe5..d1e7aa7fa 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4e30b6cb6..a9490dd2c 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 796cbac98..a3badfb20 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index acbeb4e36..f9b2f8ee2 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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,