mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 17:01:17 +00:00
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
/**
|
|
* Publishes agent activity (streamed commentary + tool progress) into
|
|
* ClickClack as durable `agent_commentary` / `agent_tool` message rows,
|
|
* coalesced so one logical step becomes one row instead of a row per frame.
|
|
*
|
|
* Ported from the clickglass agent-bridge sidecar, adapted from gateway
|
|
* websocket frames to the in-process `replyOptions.onItemEvent` seam:
|
|
*
|
|
* - Commentary/reasoning arrives as cumulative text snapshots per item id.
|
|
* Each segment becomes one durable row: POSTed when the segment starts
|
|
* streaming and PATCHed (debounced) as the snapshot grows, so prose
|
|
* interleaves chronologically with tool rows.
|
|
* - Tool/step items can emit several frames (start/update/complete) for the
|
|
* same call, sometimes with lane-prefixed ids (`tool:X`, `command:X`).
|
|
* Normalize the prefix away to one key per call, POST one row on the first
|
|
* frame, and PATCH it when a later frame carries a strictly longer body.
|
|
*/
|
|
import { buildChannelProgressDraftLine } from "openclaw/plugin-sdk/channel-outbound";
|
|
import type { ClickClackMessage, ClickClackMessageProvenance } from "./types.js";
|
|
|
|
/** Debounce window for PATCHing streaming commentary snapshots. */
|
|
const CLICKCLACK_COMMENTARY_FLUSH_MS = 700;
|
|
|
|
/** Item event payload shape delivered by `replyOptions.onItemEvent`. */
|
|
type ClickClackItemEventPayload = {
|
|
itemId?: string;
|
|
toolCallId?: string;
|
|
kind?: string;
|
|
title?: string;
|
|
name?: string;
|
|
phase?: string;
|
|
status?: string;
|
|
summary?: string;
|
|
progressText?: string;
|
|
meta?: string;
|
|
};
|
|
|
|
/** Destination for durable activity rows (channel or DM conversation). */
|
|
type ClickClackActivityTarget = {
|
|
channelId?: string;
|
|
conversationId?: string;
|
|
};
|
|
|
|
/** Client subset needed by the publisher (satisfied by `createClickClackClient`). */
|
|
type ClickClackActivityClient = {
|
|
createActivityMessage(params: {
|
|
channelId?: string;
|
|
conversationId?: string;
|
|
body: string;
|
|
kind: "agent_commentary" | "agent_tool";
|
|
turnId?: string;
|
|
provenance?: ClickClackMessageProvenance;
|
|
}): Promise<ClickClackMessage>;
|
|
updateMessageBody(messageId: string, body: string): Promise<ClickClackMessage>;
|
|
};
|
|
|
|
/** Item kinds rendered as agent_tool rows; everything else is commentary. */
|
|
const TOOL_ITEM_KINDS = new Set(["tool", "command", "command_output", "patch", "search", "api"]);
|
|
|
|
/** Item kinds that never become durable rows (ephemeral or plumbing lanes). */
|
|
const SKIPPED_ITEM_KINDS = new Set(["lifecycle"]);
|
|
|
|
/** Item kinds emitted as cumulative commentary snapshots. */
|
|
const STREAMING_COMMENTARY_ITEM_KINDS = new Set([
|
|
"preamble",
|
|
"commentary",
|
|
"analysis",
|
|
"thinking",
|
|
"reasoning",
|
|
]);
|
|
|
|
/** Provider-specific reasoning lanes normalized into one ClickClack label. */
|
|
const THINKING_ITEM_KINDS = new Set(["analysis", "thinking", "reasoning"]);
|
|
|
|
function normalizedItemKind(payload: ClickClackItemEventPayload): string {
|
|
return payload.kind?.trim().toLowerCase() ?? "";
|
|
}
|
|
|
|
function commentaryBody(payload: ClickClackItemEventPayload): string {
|
|
const text =
|
|
payload.progressText?.trim() || payload.summary?.trim() || payload.meta?.trim() || "";
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
const kind = normalizedItemKind(payload);
|
|
if (THINKING_ITEM_KINDS.has(kind)) {
|
|
return `**Thinking**\n\n${text}`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function activityBody(payload: ClickClackItemEventPayload): string {
|
|
// Reuse the shared channel progress-line renderer so ClickClack rows show
|
|
// the same tool name + command/argument detail as Discord/Slack/Telegram
|
|
// progress lines instead of a bespoke format.
|
|
const line = buildChannelProgressDraftLine({
|
|
event: "item",
|
|
itemId: payload.itemId,
|
|
toolCallId: payload.toolCallId,
|
|
itemKind: payload.kind,
|
|
title: payload.title,
|
|
name: payload.name,
|
|
phase: payload.phase,
|
|
status: payload.status,
|
|
summary: payload.summary,
|
|
progressText: payload.progressText,
|
|
meta: payload.meta,
|
|
})?.text?.trim();
|
|
if (line) {
|
|
return line;
|
|
}
|
|
const head = payload.name?.trim() || payload.title?.trim();
|
|
const text = payload.progressText?.trim() || payload.summary?.trim();
|
|
if (head && text) {
|
|
return `**${head}**\n\n${text}`;
|
|
}
|
|
if (text) {
|
|
return text;
|
|
}
|
|
if (head) {
|
|
return head;
|
|
}
|
|
return payload.status?.trim() || payload.kind?.trim() || "";
|
|
}
|
|
|
|
type CommentarySegment = {
|
|
messageId?: string;
|
|
body: string;
|
|
dirty: boolean;
|
|
timer?: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
type ToolRow = {
|
|
messageId?: string;
|
|
body: string;
|
|
/** Body last sent to ClickClack; skips redundant PATCHes after late reads. */
|
|
sentBody?: string;
|
|
};
|
|
|
|
/** Publisher wired into one agent turn via `replyOptions.onItemEvent`. */
|
|
export type ClickClackActivityPublisher = {
|
|
onItemEvent: (payload: ClickClackItemEventPayload) => void;
|
|
/**
|
|
* Records the resolved model/thinking for this turn (from
|
|
* `replyOptions.onModelSelected`); stamped onto subsequent activity rows.
|
|
*/
|
|
setProvenance: (provenance: ClickClackMessageProvenance) => void;
|
|
/** Flushes pending commentary and awaits all outstanding POST/PATCH work. */
|
|
finalize: () => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Creates a per-turn activity publisher. Publishing is best-effort: transport
|
|
* failures are reported through `onError` and never interrupt the reply turn.
|
|
*/
|
|
export function createClickClackActivityPublisher(params: {
|
|
client: ClickClackActivityClient;
|
|
target: ClickClackActivityTarget;
|
|
turnId: string;
|
|
flushMs?: number;
|
|
onError?: (error: unknown) => void;
|
|
}): ClickClackActivityPublisher {
|
|
const flushMs = params.flushMs ?? CLICKCLACK_COMMENTARY_FLUSH_MS;
|
|
const commentaryByItem = new Map<string, CommentarySegment>();
|
|
const toolRows = new Map<string, ToolRow>();
|
|
let provenance: ClickClackMessageProvenance | undefined;
|
|
// Single promise chain so POST/PATCH ordering matches frame arrival order.
|
|
let chain: Promise<void> = Promise.resolve();
|
|
|
|
const enqueue = (work: () => Promise<void>): Promise<void> => {
|
|
chain = chain.then(work).catch((error: unknown) => {
|
|
params.onError?.(error);
|
|
});
|
|
return chain;
|
|
};
|
|
|
|
const postRow = (kind: "agent_commentary" | "agent_tool", body: string) =>
|
|
params.client.createActivityMessage({
|
|
channelId: params.target.channelId,
|
|
conversationId: params.target.conversationId,
|
|
body,
|
|
kind,
|
|
turnId: params.turnId,
|
|
provenance,
|
|
});
|
|
|
|
const flushCommentary = (segmentKey: string): Promise<void> => {
|
|
const segment = commentaryByItem.get(segmentKey);
|
|
if (!segment) {
|
|
return Promise.resolve();
|
|
}
|
|
if (segment.timer) {
|
|
clearTimeout(segment.timer);
|
|
segment.timer = undefined;
|
|
}
|
|
if (!segment.dirty || !segment.body.trim()) {
|
|
return Promise.resolve();
|
|
}
|
|
segment.dirty = false;
|
|
const body = segment.body;
|
|
return enqueue(async () => {
|
|
if (segment.messageId) {
|
|
await params.client.updateMessageBody(segment.messageId, body);
|
|
return;
|
|
}
|
|
const posted = await postRow("agent_commentary", body);
|
|
segment.messageId = posted.id;
|
|
});
|
|
};
|
|
|
|
const flushAllCommentary = (): Promise<void> => {
|
|
const flushes = [...commentaryByItem.keys()].map((key) => flushCommentary(key));
|
|
return Promise.all(flushes).then(() => undefined);
|
|
};
|
|
|
|
const handleCommentary = (payload: ClickClackItemEventPayload): void => {
|
|
const body = commentaryBody(payload);
|
|
if (!body.trim()) {
|
|
return;
|
|
}
|
|
const key = payload.itemId?.trim() || "turn";
|
|
let segment = commentaryByItem.get(key);
|
|
if (!segment) {
|
|
segment = { body: "", dirty: false };
|
|
commentaryByItem.set(key, segment);
|
|
}
|
|
// Snapshots are cumulative per item; never shrink the row body on a
|
|
// shorter (stale or whitespace-normalized) frame, and skip identical
|
|
// snapshots entirely so out-of-order frames cannot queue redundant
|
|
// PATCHes.
|
|
if (body.length < segment.body.length || body === segment.body) {
|
|
return;
|
|
}
|
|
segment.body = body;
|
|
segment.dirty = true;
|
|
if (!segment.timer) {
|
|
segment.timer = setTimeout(() => {
|
|
segment.timer = undefined;
|
|
void flushCommentary(key);
|
|
}, flushMs);
|
|
}
|
|
};
|
|
|
|
const toolRowKey = (payload: ClickClackItemEventPayload): string => {
|
|
// toolCallId is an opaque identifier: use it untouched when present.
|
|
// itemId carries a lane/lifecycle prefix (`tool:X`, `command:X`) on some
|
|
// frames and appears bare on others, so normalize only itemId to give
|
|
// all frames of one call a shared key.
|
|
const toolCallId = payload.toolCallId?.trim();
|
|
if (toolCallId) {
|
|
return toolCallId;
|
|
}
|
|
const itemId = payload.itemId?.trim() ?? "";
|
|
return itemId.replace(/^(tool|command):/, "");
|
|
};
|
|
|
|
const handleDiscreteItem = (payload: ClickClackItemEventPayload): void => {
|
|
const body = activityBody(payload);
|
|
if (!body) {
|
|
return;
|
|
}
|
|
const kind = TOOL_ITEM_KINDS.has(payload.kind?.trim().toLowerCase() ?? "")
|
|
? ("agent_tool" as const)
|
|
: ("agent_commentary" as const);
|
|
// Flush streaming prose first so the commentary row lands before the
|
|
// step row it precedes chronologically.
|
|
void flushAllCommentary();
|
|
const key = `${kind}:${toolRowKey(payload)}`;
|
|
const existing = toolRows.get(key);
|
|
if (!existing || !toolRowKey(payload)) {
|
|
const row: ToolRow = { body };
|
|
if (toolRowKey(payload)) {
|
|
toolRows.set(key, row);
|
|
}
|
|
void enqueue(async () => {
|
|
// Late read is intentional: if the body was upgraded before this POST
|
|
// ran, post the richer body directly and skip the follow-up PATCH.
|
|
const posted = await postRow(kind, row.body);
|
|
row.messageId = posted.id;
|
|
row.sentBody = row.body;
|
|
});
|
|
return;
|
|
}
|
|
// Only upgrade the row when the new frame says strictly more (longer
|
|
// body), so a bare lane echo like "read" never clobbers a summary.
|
|
if (body.length <= existing.body.length) {
|
|
return;
|
|
}
|
|
existing.body = body;
|
|
void enqueue(async () => {
|
|
if (existing.messageId && existing.body !== existing.sentBody) {
|
|
await params.client.updateMessageBody(existing.messageId, existing.body);
|
|
existing.sentBody = existing.body;
|
|
}
|
|
});
|
|
};
|
|
|
|
return {
|
|
onItemEvent: (payload) => {
|
|
const kind = normalizedItemKind(payload);
|
|
if (STREAMING_COMMENTARY_ITEM_KINDS.has(kind)) {
|
|
handleCommentary(payload);
|
|
return;
|
|
}
|
|
if (SKIPPED_ITEM_KINDS.has(kind)) {
|
|
return;
|
|
}
|
|
handleDiscreteItem(payload);
|
|
},
|
|
setProvenance: (next) => {
|
|
provenance = next;
|
|
},
|
|
finalize: async () => {
|
|
await flushAllCommentary();
|
|
await chain;
|
|
},
|
|
};
|
|
}
|