feat(sessions): add agent-to-agent ping-pong

This commit is contained in:
Peter Steinberger
2026-01-04 03:37:44 +01:00
parent add1301a51
commit cd3c42d0c0
8 changed files with 289 additions and 110 deletions

View File

@@ -9,6 +9,7 @@
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored. - Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features ### Features
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 05).
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling. - Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
@@ -20,7 +21,6 @@
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - 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. - 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. - 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 ### Fixes
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
@@ -72,7 +72,8 @@
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. - 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. - Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events. - macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`. - Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
- Skills: clarify wacli third-party messaging scope and JID format examples.
## 2.0.0-beta5 — 2026-01-03 ## 2.0.0-beta5 — 2026-01-03

View File

@@ -621,6 +621,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json", store: "~/.clawdis/sessions/sessions.json",
// mainKey is ignored; primary key is fixed to "main" // mainKey is ignored; primary key is fixed to "main"
agentToAgent: {
// Max ping-pong reply turns between requester/target (05).
maxPingPongTurns: 5
},
sendPolicy: { sendPolicy: {
rules: [ rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } } { action: "deny", match: { surface: "discord", chatType: "group" } }
@@ -632,6 +636,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
``` ```
Fields: Fields:
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.

View File

@@ -76,11 +76,14 @@ Behavior:
- If the run fails: `{ runId, status: "error", error }`. - If the run fails: `{ runId, status: "error", error }`.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait. - 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. - Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdis starts an **agent-to-agent post step**: - After the primary run completes, Clawdis runs a **reply-back loop**:
- The agent can reply with the announcement to post to the target session. - Round 2+ alternates between requester and target agents.
- To stay silent, reply exactly `ANNOUNCE_SKIP`. - Reply exactly `REPLY_SKIP` to stop the pingpong.
- Max turns is `session.agentToAgent.maxPingPongTurns` (05, default 5).
- Once the loop ends, Clawdis runs the **agenttoagent announce step** (target agent only):
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
- Any other reply is sent to the target channel. - Any other reply is sent to the target channel.
- The post step includes the original request and round1 reply in context. - Announce step includes the original request + round1 reply + latest pingpong reply.
## Provider Field ## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry. - For groups, `provider` is the `surface` recorded on the session entry.

View File

@@ -119,7 +119,8 @@ Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden. - `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered). - `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`. - `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. - `sessions_send` runs a replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `discord` ### `discord`
Send Discord reactions, stickers, or polls. Send Discord reactions, stickers, or polls.

View File

@@ -7,7 +7,11 @@ vi.mock("../gateway/call.js", () => ({
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: () => ({ loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" }, session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}), }),
resolveGatewayPort: () => 18789, resolveGatewayPort: () => 18789,
})); }));
@@ -127,18 +131,28 @@ describe("sessions tools", () => {
let agentCallCount = 0; let agentCallCount = 0;
let historyCallCount = 0; let historyCallCount = 0;
let sendCallCount = 0; let sendCallCount = 0;
let waitRunId: string | undefined; let lastWaitedRunId: string | undefined;
let nextHistoryIsWaitReply = false; const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
callGatewayMock.mockImplementation(async (opts: unknown) => { callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown }; const request = opts as { method?: string; params?: unknown };
calls.push(request); calls.push(request);
if (request.method === "agent") { if (request.method === "agent") {
agentCallCount += 1; agentCallCount += 1;
const runId = `run-${agentCallCount}`; const runId = `run-${agentCallCount}`;
const params = request.params as { message?: string } | undefined; const params = request.params as
if (params?.message === "wait") { | { message?: string; sessionKey?: string }
waitRunId = runId; | undefined;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
reply = "done";
} else if (message === "Agent-to-agent announce step.") {
reply = "ANNOUNCE_SKIP";
} else if (params?.sessionKey === requesterKey) {
reply = "pong";
} }
replyByRunId.set(runId, reply);
return { return {
runId, runId,
status: "accepted", status: "accepted",
@@ -147,15 +161,13 @@ describe("sessions tools", () => {
} }
if (request.method === "agent.wait") { if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined; const params = request.params as { runId?: string } | undefined;
if (params?.runId && params.runId === waitRunId) { lastWaitedRunId = params?.runId;
nextHistoryIsWaitReply = true;
}
return { runId: params?.runId ?? "run-1", status: "ok" }; return { runId: params?.runId ?? "run-1", status: "ok" };
} }
if (request.method === "chat.history") { if (request.method === "chat.history") {
historyCallCount += 1; historyCallCount += 1;
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP"; const text =
nextHistoryIsWaitReply = false; (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return { return {
messages: [ messages: [
{ {
@@ -178,7 +190,10 @@ describe("sessions tools", () => {
return {}; return {};
}); });
const tool = createClawdisTools().find( const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find(
(candidate) => candidate.name === "sessions_send", (candidate) => candidate.name === "sessions_send",
); );
expect(tool).toBeDefined(); expect(tool).toBeDefined();
@@ -191,6 +206,7 @@ describe("sessions tools", () => {
}); });
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" }); expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", { const waitPromise = tool.execute("call6", {
sessionKey: "main", sessionKey: "main",
@@ -204,13 +220,14 @@ describe("sessions tools", () => {
}); });
expect(typeof (waited.details as { runId?: string }).runId).toBe("string"); expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent"); const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait"); const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter( const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history", (call) => call.method === "chat.history",
); );
expect(agentCalls).toHaveLength(4); expect(agentCalls).toHaveLength(8);
for (const call of agentCalls) { for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" }); expect(call.params).toMatchObject({ lane: "nested" });
} }
@@ -229,11 +246,20 @@ describe("sessions tools", () => {
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt?.includes("Agent-to-agent post step"), ?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
), ),
).toBe(true); ).toBe(true);
expect(waitCalls).toHaveLength(3); expect(
expect(historyOnlyCalls).toHaveLength(3); agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect( expect(
waitCalls.some( waitCalls.some(
(call) => (call) =>

View File

@@ -2713,6 +2713,9 @@ function createSessionsHistoryTool(): AnyAgentTool {
} }
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5;
type AnnounceTarget = { type AnnounceTarget = {
channel: string; channel: string;
@@ -2747,38 +2750,72 @@ function buildAgentToAgentMessageContext(params: {
const lines = [ const lines = [
"Agent-to-agent message context:", "Agent-to-agent message context:",
params.requesterSessionKey params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.` ? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterSurface
? `Requester surface: ${params.requesterSurface}.` ? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined, : undefined,
`Target session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
].filter(Boolean); ].filter(Boolean);
return lines.join("\n"); return lines.join("\n");
} }
function buildAgentToAgentPostContext(params: { function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
currentRole: "requester" | "target";
turn: number;
maxTurns: number;
}) {
const currentLabel =
params.currentRole === "requester"
? "Agent 1 (requester)"
: "Agent 2 (target)";
const lines = [
"Agent-to-agent reply step:",
`Current agent: ${currentLabel}.`,
`Turn ${params.turn} of ${params.maxTurns}.`,
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel ? `Agent 2 (target) surface: ${params.targetChannel}.` : undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean);
return lines.join("\n");
}
function buildAgentToAgentAnnounceContext(params: {
requesterSessionKey?: string; requesterSessionKey?: string;
requesterSurface?: string; requesterSurface?: string;
targetSessionKey: string; targetSessionKey: string;
targetChannel?: string; targetChannel?: string;
originalMessage: string; originalMessage: string;
roundOneReply?: string; roundOneReply?: string;
latestReply?: string;
}) { }) {
const lines = [ const lines = [
"Agent-to-agent post step:", "Agent-to-agent announce step:",
params.requesterSessionKey params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.` ? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined, : undefined,
params.requesterSurface params.requesterSurface
? `Requester surface: ${params.requesterSurface}.` ? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined, : undefined,
`Target session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel ? `Target surface: ${params.targetChannel}.` : undefined, params.targetChannel ? `Agent 2 (target) surface: ${params.targetChannel}.` : undefined,
`Original request: ${params.originalMessage}`, `Original request: ${params.originalMessage}`,
params.roundOneReply params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}` ? `Round 1 reply: ${params.roundOneReply}`
: "Round 1 reply: (not available).", : "Round 1 reply: (not available).",
params.latestReply
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`, `If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.", "Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.", "After this reply, the agent-to-agent conversation is over.",
@@ -2790,6 +2827,18 @@ function isAnnounceSkip(text?: string) {
return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN; return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN;
} }
function isReplySkip(text?: string) {
return (text ?? "").trim() === REPLY_SKIP_TOKEN;
}
function resolvePingPongTurns(cfg?: ClawdisConfig) {
const raw = cfg?.session?.agentToAgent?.maxPingPongTurns;
const fallback = DEFAULT_PING_PONG_TURNS;
if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
const rounded = Math.floor(raw);
return Math.max(0, Math.min(MAX_PING_PONG_TURNS, rounded));
}
function createSessionsSendTool(opts?: { function createSessionsSendTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;
agentSurface?: string; agentSurface?: string;
@@ -2839,6 +2888,9 @@ function createSessionsSendTool(opts?: {
lane: "nested", lane: "nested",
extraSystemPrompt: agentMessageContext, extraSystemPrompt: agentMessageContext,
}; };
const requesterSessionKey = opts?.agentSessionKey;
const requesterSurface = opts?.agentSurface;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => { const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => {
const parsed = resolveAnnounceTargetFromKey(resolvedKey); const parsed = resolveAnnounceTargetFromKey(resolvedKey);
@@ -2869,85 +2921,160 @@ function createSessionsSendTool(opts?: {
return null; return null;
}; };
const runAgentToAgentPost = async (roundOneReply?: string) => { const readLatestAssistantReply = async (
const announceTarget = await resolveAnnounceTarget(); sessionKeyToRead: string,
): Promise<string | undefined> => {
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: sessionKeyToRead, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
return last ? extractAssistantText(last) : undefined;
};
const runAgentStep = async (params: {
sessionKey: string;
message: string;
extraSystemPrompt: string;
timeoutMs: number;
}): Promise<string | undefined> => {
const stepIdem = crypto.randomUUID();
const response = (await callGateway({
method: "agent",
params: {
message: params.message,
sessionKey: params.sessionKey,
idempotencyKey: stepIdem,
deliver: false,
lane: "nested",
extraSystemPrompt: params.extraSystemPrompt,
},
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const stepRunId =
typeof response?.runId === "string" && response.runId
? response.runId
: stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(params.timeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs,
},
timeoutMs: stepWaitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return undefined;
return readLatestAssistantReply(params.sessionKey);
};
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number },
) => {
try { try {
const postPrompt = buildAgentToAgentPostContext({ let primaryReply = roundOneReply;
requesterSessionKey: opts?.agentSessionKey, let latestReply = roundOneReply;
requesterSurface: opts?.agentSurface, if (!primaryReply && runInfo?.runId) {
targetSessionKey: displayKey, const waitMs = Math.min(announceTimeoutMs, 60_000);
targetChannel: announceTarget?.channel ?? "unknown", const wait = (await callGateway({
originalMessage: message, method: "agent.wait",
roundOneReply, params: {
}); runId: runInfo.runId,
const postIdem = crypto.randomUUID(); afterMs: runInfo.acceptedAt,
const postResponse = (await callGateway({ timeoutMs: waitMs,
method: "agent", },
params: { timeoutMs: waitMs + 2000,
message: "Agent-to-agent post step.", })) as { status?: string };
sessionKey: resolvedKey, if (wait?.status === "ok") {
idempotencyKey: postIdem, primaryReply = await readLatestAssistantReply(resolvedKey);
deliver: false, latestReply = primaryReply;
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,
});
} }
} }
if (!latestReply) return;
const announceTarget = await resolveAnnounceTarget();
const targetChannel = announceTarget?.channel ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
requesterSessionKey !== resolvedKey
) {
let currentSessionKey = requesterSessionKey;
let nextSessionKey = resolvedKey;
let incomingMessage = latestReply;
for (let turn = 1; turn <= maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === requesterSessionKey
? "requester"
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterSurface,
targetSessionKey: displayKey,
targetChannel,
currentRole,
turn,
maxTurns: maxPingPongTurns,
});
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
timeoutMs: announceTimeoutMs,
});
if (!replyText || isReplySkip(replyText)) {
break;
}
latestReply = replyText;
incomingMessage = replyText;
const swap = currentSessionKey;
currentSessionKey = nextSessionKey;
nextSessionKey = swap;
}
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterSurface,
targetSessionKey: displayKey,
targetChannel,
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
});
const announceReply = await runAgentStep({
sessionKey: resolvedKey,
message: "Agent-to-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: announceTimeoutMs,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.channel,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
}
} catch { } catch {
// Best-effort announce; ignore failures to avoid breaking the caller response. // Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} }
}; };
@@ -2957,11 +3084,15 @@ function createSessionsSendTool(opts?: {
method: "agent", method: "agent",
params: sendParams, params: sendParams,
timeoutMs: 10_000, timeoutMs: 10_000,
})) as { runId?: string }; })) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
if (typeof response?.runId === "string" && response.runId) { if (typeof response?.runId === "string" && response.runId) {
runId = response.runId; runId = response.runId;
} }
void runAgentToAgentPost(); void runAgentToAgentFlow(undefined, { runId, acceptedAt });
return jsonResult({ return jsonResult({
runId, runId,
status: "accepted", status: "accepted",
@@ -3067,7 +3198,7 @@ function createSessionsSendTool(opts?: {
const last = const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined; filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined; const reply = last ? extractAssistantText(last) : undefined;
void runAgentToAgentPost(reply ?? undefined); void runAgentToAgentFlow(reply ?? undefined);
return jsonResult({ return jsonResult({
runId, runId,

View File

@@ -44,6 +44,10 @@ export type SessionConfig = {
typingIntervalSeconds?: number; typingIntervalSeconds?: number;
mainKey?: string; mainKey?: string;
sendPolicy?: SessionSendPolicyConfig; sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: {
/** Max ping-pong turns between requester/target (05). Default: 5. */
maxPingPongTurns?: number;
};
}; };
export type LoggingConfig = { export type LoggingConfig = {
@@ -894,6 +898,11 @@ const SessionSchema = z
.optional(), .optional(),
}) })
.optional(), .optional(),
agentToAgent: z
.object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
})
.optional(),
}) })
.optional(); .optional();

View File

@@ -88,6 +88,7 @@ const FIELD_LABELS: Record<string, string> = {
"agent.model": "Default Model", "agent.model": "Default Model",
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL", "browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"talk.apiKey": "Talk API Key", "talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token", "telegram.botToken": "Telegram Bot Token",
"discord.token": "Discord Bot Token", "discord.token": "Discord Bot Token",
@@ -106,6 +107,8 @@ const FIELD_HELP: Record<string, string> = {
'Hot reload strategy for config changes ("hybrid" recommended).', 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.", "Debounce window (ms) before applying config changes.",
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).",
}; };
const FIELD_PLACEHOLDERS: Record<string, string> = { const FIELD_PLACEHOLDERS: Record<string, string> = {