mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:18:25 +00:00
TUI/Gateway: fix pi streaming + tool routing + model display + msg updating (#8432)
* TUI/Gateway: fix pi streaming + tool routing * Tests: clarify verbose tool output expectation * fix: avoid seq gaps for targeted tool events (#8432) (thanks @gumadeiras)
This commit is contained in:
committed by
GitHub
parent
a42e3cb78a
commit
38e6da1fe0
@@ -42,6 +42,12 @@ export type GatewayClientInfo = {
|
||||
instanceId?: string;
|
||||
};
|
||||
|
||||
export const GATEWAY_CLIENT_CAPS = {
|
||||
TOOL_EVENTS: "tool-events",
|
||||
} as const;
|
||||
|
||||
export type GatewayClientCap = (typeof GATEWAY_CLIENT_CAPS)[keyof typeof GATEWAY_CLIENT_CAPS];
|
||||
|
||||
const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS));
|
||||
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES));
|
||||
|
||||
@@ -68,3 +74,13 @@ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMo
|
||||
? (normalized as GatewayClientMode)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function hasGatewayClientCap(
|
||||
caps: string[] | null | undefined,
|
||||
cap: GatewayClientCap,
|
||||
): boolean {
|
||||
if (!Array.isArray(caps)) {
|
||||
return false;
|
||||
}
|
||||
return caps.includes(cap);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AjvPkg, { type ErrorObject } from "ajv";
|
||||
import type { SessionsPatchResult } from "../session-utils.types.js";
|
||||
import {
|
||||
type AgentEvent,
|
||||
AgentEventSchema,
|
||||
@@ -536,6 +537,7 @@ export type {
|
||||
SessionsPreviewParams,
|
||||
SessionsResolveParams,
|
||||
SessionsPatchParams,
|
||||
SessionsPatchResult,
|
||||
SessionsResetParams,
|
||||
SessionsDeleteParams,
|
||||
SessionsCompactParams,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("gateway broadcaster", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
||||
|
||||
broadcast("exec.approval.requested", { id: "1" });
|
||||
broadcast("device.pair.requested", { requestId: "r1" });
|
||||
@@ -52,5 +52,10 @@ describe("gateway broadcaster", () => {
|
||||
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(readSocket.send).toHaveBeenCalledTimes(0);
|
||||
|
||||
broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"]));
|
||||
expect(readSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,15 +33,18 @@ function hasEventScope(client: GatewayWsClient, event: string): boolean {
|
||||
|
||||
export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
|
||||
let seq = 0;
|
||||
const broadcast = (
|
||||
|
||||
const broadcastInternal = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
targetConnIds?: ReadonlySet<string>,
|
||||
) => {
|
||||
const eventSeq = ++seq;
|
||||
const isTargeted = Boolean(targetConnIds);
|
||||
const eventSeq = isTargeted ? undefined : ++seq;
|
||||
const frame = JSON.stringify({
|
||||
type: "event",
|
||||
event,
|
||||
@@ -51,8 +54,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
|
||||
});
|
||||
const logMeta: Record<string, unknown> = {
|
||||
event,
|
||||
seq: eventSeq,
|
||||
seq: eventSeq ?? "targeted",
|
||||
clients: params.clients.size,
|
||||
targets: targetConnIds ? targetConnIds.size : undefined,
|
||||
dropIfSlow: opts?.dropIfSlow,
|
||||
presenceVersion: opts?.stateVersion?.presence,
|
||||
healthVersion: opts?.stateVersion?.health,
|
||||
@@ -62,6 +66,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
|
||||
}
|
||||
logWs("out", "event", logMeta);
|
||||
for (const c of params.clients) {
|
||||
if (targetConnIds && !targetConnIds.has(c.connId)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasEventScope(c, event)) {
|
||||
continue;
|
||||
}
|
||||
@@ -84,5 +91,30 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
|
||||
}
|
||||
}
|
||||
};
|
||||
return { broadcast };
|
||||
|
||||
const broadcast = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => broadcastInternal(event, payload, opts);
|
||||
|
||||
const broadcastToConnIds = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
connIds: ReadonlySet<string>,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => {
|
||||
if (connIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
broadcastInternal(event, payload, opts, connIds);
|
||||
};
|
||||
|
||||
return { broadcast, broadcastToConnIds };
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
|
||||
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import {
|
||||
createAgentEventHandler,
|
||||
createChatRunState,
|
||||
createToolEventRecipientRegistry,
|
||||
} from "./server-chat.js";
|
||||
|
||||
describe("agent event handler", () => {
|
||||
it("emits chat delta for assistant text-only events", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => undefined,
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
});
|
||||
|
||||
handler({
|
||||
@@ -39,4 +48,158 @@ describe("agent event handler", () => {
|
||||
expect(sessionChatCalls).toHaveLength(1);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("routes tool events only to registered recipients when verbose is enabled", () => {
|
||||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
registerAgentRunContext("run-tool", { sessionKey: "session-1", verboseLevel: "on" });
|
||||
toolEventRecipients.add("run-tool", "conn-1");
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => "session-1",
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-tool",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start", name: "read", toolCallId: "t1" },
|
||||
});
|
||||
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
it("suppresses tool events when verbose is off", () => {
|
||||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
registerAgentRunContext("run-tool-off", { sessionKey: "session-1", verboseLevel: "off" });
|
||||
toolEventRecipients.add("run-tool-off", "conn-1");
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => "session-1",
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-tool-off",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
data: { phase: "start", name: "read", toolCallId: "t2" },
|
||||
});
|
||||
|
||||
expect(broadcastToConnIds).not.toHaveBeenCalled();
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
it("strips tool output when verbose is on", () => {
|
||||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
registerAgentRunContext("run-tool-on", { sessionKey: "session-1", verboseLevel: "on" });
|
||||
toolEventRecipients.add("run-tool-on", "conn-1");
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => "session-1",
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-tool-on",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
phase: "result",
|
||||
name: "exec",
|
||||
toolCallId: "t3",
|
||||
result: { content: [{ type: "text", text: "secret" }] },
|
||||
partialResult: { content: [{ type: "text", text: "partial" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
||||
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
|
||||
expect(payload.data?.result).toBeUndefined();
|
||||
expect(payload.data?.partialResult).toBeUndefined();
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
it("keeps tool output when verbose is full", () => {
|
||||
const broadcast = vi.fn();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
registerAgentRunContext("run-tool-full", { sessionKey: "session-1", verboseLevel: "full" });
|
||||
toolEventRecipients.add("run-tool-full", "conn-1");
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => "session-1",
|
||||
clearAgentRunContext: vi.fn(),
|
||||
toolEventRecipients,
|
||||
});
|
||||
|
||||
const result = { content: [{ type: "text", text: "secret" }] };
|
||||
handler({
|
||||
runId: "run-tool-full",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
phase: "result",
|
||||
name: "exec",
|
||||
toolCallId: "t4",
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
||||
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
|
||||
expect(payload.data?.result).toEqual(result);
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,6 +120,79 @@ export function createChatRunState(): ChatRunState {
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolEventRecipientRegistry = {
|
||||
add: (runId: string, connId: string) => void;
|
||||
get: (runId: string) => ReadonlySet<string> | undefined;
|
||||
markFinal: (runId: string) => void;
|
||||
};
|
||||
|
||||
type ToolRecipientEntry = {
|
||||
connIds: Set<string>;
|
||||
updatedAt: number;
|
||||
finalizedAt?: number;
|
||||
};
|
||||
|
||||
const TOOL_EVENT_RECIPIENT_TTL_MS = 10 * 60 * 1000;
|
||||
const TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS = 30 * 1000;
|
||||
|
||||
export function createToolEventRecipientRegistry(): ToolEventRecipientRegistry {
|
||||
const recipients = new Map<string, ToolRecipientEntry>();
|
||||
|
||||
const prune = () => {
|
||||
if (recipients.size === 0) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [runId, entry] of recipients) {
|
||||
const cutoff = entry.finalizedAt
|
||||
? entry.finalizedAt + TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS
|
||||
: entry.updatedAt + TOOL_EVENT_RECIPIENT_TTL_MS;
|
||||
if (now >= cutoff) {
|
||||
recipients.delete(runId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const add = (runId: string, connId: string) => {
|
||||
if (!runId || !connId) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const existing = recipients.get(runId);
|
||||
if (existing) {
|
||||
existing.connIds.add(connId);
|
||||
existing.updatedAt = now;
|
||||
} else {
|
||||
recipients.set(runId, {
|
||||
connIds: new Set([connId]),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
prune();
|
||||
};
|
||||
|
||||
const get = (runId: string) => {
|
||||
const entry = recipients.get(runId);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
entry.updatedAt = Date.now();
|
||||
prune();
|
||||
return entry.connIds;
|
||||
};
|
||||
|
||||
const markFinal = (runId: string) => {
|
||||
const entry = recipients.get(runId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.finalizedAt = Date.now();
|
||||
prune();
|
||||
};
|
||||
|
||||
return { add, get, markFinal };
|
||||
}
|
||||
|
||||
export type ChatEventBroadcast = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
@@ -130,20 +203,29 @@ export type NodeSendToSession = (sessionKey: string, event: string, payload: unk
|
||||
|
||||
export type AgentEventHandlerOptions = {
|
||||
broadcast: ChatEventBroadcast;
|
||||
broadcastToConnIds: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
connIds: ReadonlySet<string>,
|
||||
opts?: { dropIfSlow?: boolean },
|
||||
) => void;
|
||||
nodeSendToSession: NodeSendToSession;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatRunState: ChatRunState;
|
||||
resolveSessionKeyForRun: (runId: string) => string | undefined;
|
||||
clearAgentRunContext: (runId: string) => void;
|
||||
toolEventRecipients: ToolEventRecipientRegistry;
|
||||
};
|
||||
|
||||
export function createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun,
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
}: AgentEventHandlerOptions) {
|
||||
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
|
||||
chatRunState.buffers.set(clientRunId, text);
|
||||
@@ -213,25 +295,25 @@ export function createAgentEventHandler({
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
||||
const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
|
||||
if (runVerbose) {
|
||||
return runVerbose === "on";
|
||||
return runVerbose;
|
||||
}
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
return "off";
|
||||
}
|
||||
try {
|
||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (sessionVerbose) {
|
||||
return sessionVerbose === "on";
|
||||
return sessionVerbose;
|
||||
}
|
||||
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
|
||||
return defaultVerbose === "on";
|
||||
return defaultVerbose ?? "off";
|
||||
} catch {
|
||||
return false;
|
||||
return "off";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,10 +326,21 @@ export function createAgentEventHandler({
|
||||
// Include sessionKey so Control UI can filter tool streams per session.
|
||||
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
|
||||
const last = agentRunSeq.get(evt.runId) ?? 0;
|
||||
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
|
||||
const isToolEvent = evt.stream === "tool";
|
||||
const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off";
|
||||
if (isToolEvent && toolVerbose === "off") {
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
return;
|
||||
}
|
||||
const toolPayload =
|
||||
isToolEvent && toolVerbose !== "full"
|
||||
? (() => {
|
||||
const data = evt.data ? { ...evt.data } : {};
|
||||
delete data.result;
|
||||
delete data.partialResult;
|
||||
return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data };
|
||||
})()
|
||||
: agentPayload;
|
||||
if (evt.seq !== last + 1) {
|
||||
broadcast("agent", {
|
||||
runId: evt.runId,
|
||||
@@ -262,13 +355,20 @@ export function createAgentEventHandler({
|
||||
});
|
||||
}
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
broadcast("agent", agentPayload);
|
||||
if (isToolEvent) {
|
||||
const recipients = toolEventRecipients.get(evt.runId);
|
||||
if (recipients && recipients.size > 0) {
|
||||
broadcastToConnIds("agent", toolPayload, recipients);
|
||||
}
|
||||
} else {
|
||||
broadcast("agent", agentPayload);
|
||||
}
|
||||
|
||||
const lifecyclePhase =
|
||||
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
|
||||
if (sessionKey) {
|
||||
nodeSendToSession(sessionKey, "agent", agentPayload);
|
||||
nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload);
|
||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
@@ -306,6 +406,7 @@ export function createAgentEventHandler({
|
||||
}
|
||||
|
||||
if (lifecyclePhase === "end" || lifecyclePhase === "error") {
|
||||
toolEventRecipients.markFinal(evt.runId);
|
||||
clearAgentRunContext(evt.runId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||
import { parseMessageWithAttachments } from "../chat-attachments.js";
|
||||
import { resolveAssistantAvatarUrl } from "../control-ui-shared.js";
|
||||
import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -42,7 +43,7 @@ import { waitForAgentJob } from "./agent-job.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
|
||||
export const agentHandlers: GatewayRequestHandlers = {
|
||||
agent: async ({ params, respond, context }) => {
|
||||
agent: async ({ params, respond, context, client }) => {
|
||||
const p = params;
|
||||
if (!validateAgentParams(p)) {
|
||||
respond(
|
||||
@@ -296,6 +297,14 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const runId = idem;
|
||||
const connId = typeof client?.connId === "string" ? client.connId : undefined;
|
||||
const wantsToolEvents = hasGatewayClientCap(
|
||||
client?.connect?.caps,
|
||||
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
|
||||
);
|
||||
if (connId && wantsToolEvents) {
|
||||
context.registerToolEventRecipient(runId, connId);
|
||||
}
|
||||
|
||||
const wantsDelivery = request.deliver === true;
|
||||
const explicitTo =
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../chat-abort.js";
|
||||
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
|
||||
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
||||
import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -216,7 +217,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
if (configured) {
|
||||
thinkingLevel = configured;
|
||||
} else {
|
||||
const { provider, model } = resolveSessionModelRef(cfg, entry);
|
||||
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
||||
const { provider, model } = resolveSessionModelRef(cfg, entry, sessionAgentId);
|
||||
const catalog = await context.loadGatewayModelCatalog();
|
||||
thinkingLevel = resolveThinkingDefault({
|
||||
cfg,
|
||||
@@ -226,11 +228,13 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
}
|
||||
}
|
||||
const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault;
|
||||
respond(true, {
|
||||
sessionKey,
|
||||
sessionId,
|
||||
messages: capped,
|
||||
thinkingLevel,
|
||||
verboseLevel,
|
||||
});
|
||||
},
|
||||
"chat.abort": ({ params, respond, context }) => {
|
||||
@@ -432,7 +436,6 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
startedAtMs: now,
|
||||
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||
});
|
||||
|
||||
const ackPayload = {
|
||||
runId: clientRunId,
|
||||
status: "started" as const,
|
||||
@@ -506,8 +509,16 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
abortSignal: abortController.signal,
|
||||
images: parsedImages.length > 0 ? parsedImages : undefined,
|
||||
disableBlockStreaming: true,
|
||||
onAgentRunStart: () => {
|
||||
onAgentRunStart: (runId) => {
|
||||
agentRunStarted = true;
|
||||
const connId = typeof client?.connId === "string" ? client.connId : undefined;
|
||||
const wantsToolEvents = hasGatewayClientCap(
|
||||
client?.connect?.caps,
|
||||
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
|
||||
);
|
||||
if (connId && wantsToolEvents) {
|
||||
context.registerToolEventRecipient(runId, connId);
|
||||
}
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
|
||||
import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
|
||||
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
loadSessionEntry,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionModelRef,
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
type SessionsPreviewEntry,
|
||||
@@ -194,11 +197,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, applied.error);
|
||||
return;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
|
||||
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);
|
||||
const result: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
key: target.canonicalKey,
|
||||
entry: applied.entry,
|
||||
resolved: {
|
||||
modelProvider: resolved.provider,
|
||||
model: resolved.model,
|
||||
},
|
||||
};
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export type GatewayClient = {
|
||||
connect: ConnectParams;
|
||||
connId?: string;
|
||||
};
|
||||
|
||||
export type RespondFn = (
|
||||
@@ -42,6 +43,15 @@ export type GatewayRequestContext = {
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
broadcastToConnIds: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
connIds: ReadonlySet<string>,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
@@ -60,6 +70,7 @@ export type GatewayRequestContext = {
|
||||
clientRunId: string,
|
||||
sessionKey?: string,
|
||||
) => { sessionKey: string; clientRunId: string } | undefined;
|
||||
registerToolEventRecipient: (runId: string, connId: string) => void;
|
||||
dedupe: Map<string, DedupeEntry>;
|
||||
wizardSessions: Map<string, WizardSession>;
|
||||
findRunningWizard: () => string | null;
|
||||
|
||||
@@ -15,7 +15,11 @@ import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
|
||||
import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
|
||||
import { resolveGatewayListenHosts } from "./net.js";
|
||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||
import { type ChatRunEntry, createChatRunState } from "./server-chat.js";
|
||||
import {
|
||||
type ChatRunEntry,
|
||||
createChatRunState,
|
||||
createToolEventRecipientRegistry,
|
||||
} from "./server-chat.js";
|
||||
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
|
||||
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||
@@ -59,6 +63,15 @@ export async function createGatewayRuntimeState(params: {
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
broadcastToConnIds: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
connIds: ReadonlySet<string>,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
agentRunSeq: Map<string, number>;
|
||||
dedupe: Map<string, DedupeEntry>;
|
||||
chatRunState: ReturnType<typeof createChatRunState>;
|
||||
@@ -71,6 +84,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
sessionKey?: string,
|
||||
) => ChatRunEntry | undefined;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
|
||||
}> {
|
||||
let canvasHost: CanvasHostHandler | null = null;
|
||||
if (params.canvasHostEnabled) {
|
||||
@@ -154,7 +168,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
}
|
||||
|
||||
const clients = new Set<GatewayWsClient>();
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const dedupe = new Map<string, DedupeEntry>();
|
||||
const chatRunState = createChatRunState();
|
||||
@@ -164,6 +178,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
const addChatRun = chatRunRegistry.add;
|
||||
const removeChatRun = chatRunRegistry.remove;
|
||||
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
|
||||
const toolEventRecipients = createToolEventRecipientRegistry();
|
||||
|
||||
return {
|
||||
canvasHost,
|
||||
@@ -173,6 +188,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
agentRunSeq,
|
||||
dedupe,
|
||||
chatRunState,
|
||||
@@ -181,5 +197,6 @@ export async function createGatewayRuntimeState(params: {
|
||||
addChatRun,
|
||||
removeChatRun,
|
||||
chatAbortControllers,
|
||||
toolEventRecipients,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,8 +380,8 @@ describe("gateway server chat", () => {
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-1",
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: "read", toolCallId: "tool-1" },
|
||||
stream: "assistant",
|
||||
data: { text: "hello" },
|
||||
});
|
||||
|
||||
const evt = await agentEvtP;
|
||||
@@ -390,31 +390,6 @@ describe("gateway server chat", () => {
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
}
|
||||
|
||||
{
|
||||
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-off",
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: "read", toolCallId: "tool-1" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-off",
|
||||
stream: "assistant",
|
||||
data: { text: "hello" },
|
||||
});
|
||||
|
||||
const evt = await onceMessage(
|
||||
webchatWs,
|
||||
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
|
||||
8000,
|
||||
);
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.stream).toBe("assistant");
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ export async function startGatewayServer(
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
agentRunSeq,
|
||||
dedupe,
|
||||
chatRunState,
|
||||
@@ -326,6 +327,7 @@ export async function startGatewayServer(
|
||||
addChatRun,
|
||||
removeChatRun,
|
||||
chatAbortControllers,
|
||||
toolEventRecipients,
|
||||
} = await createGatewayRuntimeState({
|
||||
cfg: cfgAtStart,
|
||||
bindHost,
|
||||
@@ -441,11 +443,13 @@ export async function startGatewayServer(
|
||||
const agentUnsub = onAgentEvent(
|
||||
createAgentEventHandler({
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun,
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -495,6 +499,7 @@ export async function startGatewayServer(
|
||||
incrementPresenceVersion,
|
||||
getHealthVersion,
|
||||
broadcast,
|
||||
broadcastToConnIds,
|
||||
nodeSendToSession,
|
||||
nodeSendToAllSubscribed,
|
||||
nodeSubscribe,
|
||||
@@ -509,6 +514,7 @@ export async function startGatewayServer(
|
||||
chatDeltaSentAt: chatRunState.deltaSentAt,
|
||||
addChatRun,
|
||||
removeChatRun,
|
||||
registerToolEventRecipient: toolEventRecipients.add,
|
||||
dedupe,
|
||||
wizardSessions,
|
||||
findRunningWizard,
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
resolveConfiguredModelRef,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "../agents/model-selection.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
@@ -522,12 +525,15 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults
|
||||
export function resolveSessionModelRef(
|
||||
cfg: OpenClawConfig,
|
||||
entry?: SessionEntry,
|
||||
agentId?: string,
|
||||
): { provider: string; model: string } {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const resolved = agentId
|
||||
? resolveDefaultModelForAgent({ cfg, agentId })
|
||||
: resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
let provider = resolved.provider;
|
||||
let model = resolved.model;
|
||||
const storedModelOverride = entry?.modelOverride?.trim();
|
||||
@@ -623,6 +629,11 @@ export function listSessionsFromStore(params: {
|
||||
entry?.label ??
|
||||
originLabel;
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
const parsedAgent = parseAgentSessionKey(key);
|
||||
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
|
||||
const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER;
|
||||
const model = resolvedModel.model ?? DEFAULT_MODEL;
|
||||
return {
|
||||
key,
|
||||
entry,
|
||||
@@ -648,8 +659,8 @@ export function listSessionsFromStore(params: {
|
||||
outputTokens: entry?.outputTokens,
|
||||
totalTokens: total,
|
||||
responseUsage: entry?.responseUsage,
|
||||
modelProvider: entry?.modelProvider,
|
||||
model: entry?.model,
|
||||
modelProvider,
|
||||
model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
deliveryContext: deliveryFields.deliveryContext,
|
||||
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
||||
|
||||
@@ -84,4 +84,8 @@ export type SessionsPatchResult = {
|
||||
path: string;
|
||||
key: string;
|
||||
entry: SessionEntry;
|
||||
resolved?: {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveAllowedModelRef, resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveAllowedModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
normalizeUsageDisplay,
|
||||
supportsXHighThinking,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
@@ -63,6 +67,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
|
||||
const { cfg, store, storeKey, patch } = params;
|
||||
const now = Date.now();
|
||||
const parsedAgent = parseAgentSessionKey(storeKey);
|
||||
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId });
|
||||
|
||||
const existing = store[storeKey];
|
||||
const next: SessionEntry = existing
|
||||
@@ -121,11 +128,6 @@ export async function applySessionsPatchToStore(params: {
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const hintProvider = existing?.providerOverride?.trim() || resolvedDefault.provider;
|
||||
const hintModel = existing?.modelOverride?.trim() || resolvedDefault.model;
|
||||
return invalid(
|
||||
@@ -251,11 +253,6 @@ export async function applySessionsPatchToStore(params: {
|
||||
|
||||
if ("model" in patch) {
|
||||
const raw = patch.model;
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
if (raw === null) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: next,
|
||||
@@ -302,11 +299,6 @@ export async function applySessionsPatchToStore(params: {
|
||||
}
|
||||
|
||||
if (next.thinkingLevel === "xhigh") {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
|
||||
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
|
||||
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
|
||||
|
||||
Reference in New Issue
Block a user