feat(sessions): add agent-to-agent ping-pong
This commit is contained in:
@@ -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, 0–5).
|
||||||
- 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 agent‑to‑agent 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (0–5).
|
||||||
|
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 (0–5, 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ping‑pong.
|
||||||
|
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
|
||||||
|
- Once the loop ends, Clawdis runs the **agent‑to‑agent 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 round‑1 reply in context.
|
- Announce step includes the original request + round‑1 reply + latest ping‑pong 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.
|
||||||
|
|||||||
@@ -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 follow‑up **agent‑to‑agent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
||||||
|
- After the ping‑pong, 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.
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (0–5). 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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 (0–5).",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user