@efengx/openclaw-channel-dragon 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/bridge/BridgeComponent.d.ts +16 -0
- package/dist/components/bridge/BridgeComponent.js +44 -0
- package/dist/components/sync/PollingComponent.d.ts +19 -0
- package/dist/components/sync/PollingComponent.js +140 -0
- package/dist/components/telemetry/TelemetryComponent.d.ts +18 -0
- package/dist/components/telemetry/TelemetryComponent.js +57 -0
- package/dist/core/IComponent.d.ts +4 -0
- package/dist/core/IComponent.js +1 -0
- package/dist/core/ServiceContainer.d.ts +9 -0
- package/dist/core/ServiceContainer.js +30 -0
- package/dist/index.js +100 -554
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DragonBridgeClient } from "../../ws.js";
|
|
2
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
3
|
+
export declare class BridgeComponent implements IComponent {
|
|
4
|
+
private options;
|
|
5
|
+
client: DragonBridgeClient | undefined;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
port: number;
|
|
8
|
+
token: string;
|
|
9
|
+
agentId: string;
|
|
10
|
+
gatewayPort: number;
|
|
11
|
+
gatewayToken: string;
|
|
12
|
+
logger?: any;
|
|
13
|
+
});
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { connectToDragonBridge } from "../../ws.js";
|
|
2
|
+
export class BridgeComponent {
|
|
3
|
+
options;
|
|
4
|
+
client;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
}
|
|
8
|
+
async start() {
|
|
9
|
+
const { port, token, agentId, gatewayPort, gatewayToken, logger } = this.options;
|
|
10
|
+
this.client = connectToDragonBridge({
|
|
11
|
+
port,
|
|
12
|
+
token,
|
|
13
|
+
onConnected: () => {
|
|
14
|
+
logger?.info?.("dragon channel: connected to dragon bridge");
|
|
15
|
+
this.client?.sendJson({
|
|
16
|
+
type: "hello",
|
|
17
|
+
channel: "dragon",
|
|
18
|
+
agentId,
|
|
19
|
+
port: gatewayPort,
|
|
20
|
+
token: gatewayToken,
|
|
21
|
+
version: "0.1.27",
|
|
22
|
+
timestamp: Date.now()
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
onDisconnected: () => {
|
|
26
|
+
logger?.info?.("dragon channel: disconnected from dragon bridge");
|
|
27
|
+
},
|
|
28
|
+
onMessage: (msg) => {
|
|
29
|
+
const t = msg?.type;
|
|
30
|
+
if (t === "ping") {
|
|
31
|
+
this.client?.sendJson({ type: "pong", t: Date.now() });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (t === "hot_control") {
|
|
35
|
+
const { command } = msg;
|
|
36
|
+
logger?.info?.(`dragon channel: received hot control: ${command}`);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async stop() {
|
|
42
|
+
// WebSocket close logic if needed
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
2
|
+
export declare class PollingComponent implements IComponent {
|
|
3
|
+
private options;
|
|
4
|
+
private pollInterval;
|
|
5
|
+
constructor(options: {
|
|
6
|
+
agentId: string;
|
|
7
|
+
orchestratorUrl: string;
|
|
8
|
+
accountId: string;
|
|
9
|
+
abortSignal: AbortSignal;
|
|
10
|
+
logger?: any;
|
|
11
|
+
deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
|
|
12
|
+
});
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
private consumePendingMessages;
|
|
16
|
+
private startLoop;
|
|
17
|
+
private connectOnce;
|
|
18
|
+
private connectHttp2;
|
|
19
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as http2 from "node:http2";
|
|
2
|
+
export class PollingComponent {
|
|
3
|
+
options;
|
|
4
|
+
pollInterval = null;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
}
|
|
8
|
+
async start() {
|
|
9
|
+
this.startLoop();
|
|
10
|
+
// Safety: Periodic polling fallback every 60 seconds
|
|
11
|
+
this.pollInterval = setInterval(() => {
|
|
12
|
+
void this.consumePendingMessages();
|
|
13
|
+
}, 60_000);
|
|
14
|
+
}
|
|
15
|
+
async stop() {
|
|
16
|
+
if (this.pollInterval) {
|
|
17
|
+
clearInterval(this.pollInterval);
|
|
18
|
+
this.pollInterval = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async consumePendingMessages() {
|
|
22
|
+
const { agentId, orchestratorUrl, logger, deliverToOpenClaw } = this.options;
|
|
23
|
+
try {
|
|
24
|
+
const pollUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/poll`;
|
|
25
|
+
const res = await fetch(pollUrl);
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
const data = (await res.json());
|
|
28
|
+
const messages = data.messages || [];
|
|
29
|
+
if (messages.length > 0) {
|
|
30
|
+
logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
|
|
31
|
+
for (const m of messages) {
|
|
32
|
+
await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async startLoop() {
|
|
42
|
+
const { agentId, orchestratorUrl, logger, abortSignal } = this.options;
|
|
43
|
+
let attempt = 0;
|
|
44
|
+
while (!abortSignal.aborted) {
|
|
45
|
+
try {
|
|
46
|
+
attempt += 1;
|
|
47
|
+
logger?.debug?.(`dragon channel: SSE connecting (attempt ${attempt})`);
|
|
48
|
+
await this.connectOnce();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (e?.name === 'AbortError')
|
|
52
|
+
break;
|
|
53
|
+
logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`);
|
|
54
|
+
const delayMs = Math.min(10_000, 500 + attempt * 500);
|
|
55
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async connectOnce() {
|
|
60
|
+
const { agentId, orchestratorUrl, logger, abortSignal, deliverToOpenClaw } = this.options;
|
|
61
|
+
const sseUrl = `${orchestratorUrl}/api/agents/events`;
|
|
62
|
+
void this.consumePendingMessages();
|
|
63
|
+
const handleSseText = async (chunkText, bufRef) => {
|
|
64
|
+
bufRef.buf += chunkText;
|
|
65
|
+
let idx;
|
|
66
|
+
while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
|
|
67
|
+
const raw = bufRef.buf.slice(0, idx);
|
|
68
|
+
bufRef.buf = bufRef.buf.slice(idx + 2);
|
|
69
|
+
if (!raw.trim() || raw.trim().startsWith(':'))
|
|
70
|
+
continue;
|
|
71
|
+
const dataLines = raw
|
|
72
|
+
.split('\n')
|
|
73
|
+
.filter((l) => l.startsWith('data:'))
|
|
74
|
+
.map((l) => l.slice('data:'.length).trim());
|
|
75
|
+
if (!dataLines.length)
|
|
76
|
+
continue;
|
|
77
|
+
const dataStr = dataLines.join('\n');
|
|
78
|
+
try {
|
|
79
|
+
const evt = JSON.parse(dataStr);
|
|
80
|
+
if (evt?.type === 'WORKBENCH_MESSAGE' && evt.agentId === agentId) {
|
|
81
|
+
await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (e) { }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const res = await fetch(sseUrl, {
|
|
88
|
+
signal: abortSignal,
|
|
89
|
+
headers: { Accept: 'text/event-stream' },
|
|
90
|
+
});
|
|
91
|
+
if (res.status === 404) {
|
|
92
|
+
// HTTP/2 fallback logic (simplified for brevity here, original has full impl)
|
|
93
|
+
await this.connectHttp2(sseUrl, handleSseText);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`Orchestrator SSE failed: ${res.status}`);
|
|
98
|
+
const body = res.body;
|
|
99
|
+
const reader = body.getReader();
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
const bufRef = { buf: '' };
|
|
102
|
+
while (!abortSignal.aborted) {
|
|
103
|
+
const { value, done } = await reader.read();
|
|
104
|
+
if (done)
|
|
105
|
+
break;
|
|
106
|
+
await handleSseText(decoder.decode(value, { stream: true }), bufRef);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async connectHttp2(sseUrl, handleSseText) {
|
|
110
|
+
const { logger, abortSignal } = this.options;
|
|
111
|
+
const u = new URL(sseUrl);
|
|
112
|
+
const origin = `${u.protocol}//${u.host}`;
|
|
113
|
+
const pathWithQuery = `${u.pathname}${u.search || ''}`;
|
|
114
|
+
const bufRef = { buf: '' };
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const session = http2.connect(origin);
|
|
117
|
+
let done = false;
|
|
118
|
+
const finish = (err) => {
|
|
119
|
+
if (done)
|
|
120
|
+
return;
|
|
121
|
+
done = true;
|
|
122
|
+
try {
|
|
123
|
+
session.close();
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
if (err)
|
|
127
|
+
reject(err);
|
|
128
|
+
else
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
abortSignal?.addEventListener?.('abort', () => finish({ name: 'AbortError' }));
|
|
132
|
+
session.on('error', (err) => finish(err));
|
|
133
|
+
const req = session.request({ ':method': 'GET', ':path': pathWithQuery, accept: 'text/event-stream' });
|
|
134
|
+
req.setEncoding('utf8');
|
|
135
|
+
req.on('data', (chunk) => handleSseText(chunk, bufRef));
|
|
136
|
+
req.on('end', () => finish());
|
|
137
|
+
req.end();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
2
|
+
export declare class TelemetryComponent implements IComponent {
|
|
3
|
+
private options;
|
|
4
|
+
constructor(options: {
|
|
5
|
+
agentId: string;
|
|
6
|
+
orchestratorUrl: string;
|
|
7
|
+
logger?: any;
|
|
8
|
+
});
|
|
9
|
+
start(): Promise<void>;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
reportReply(payload: {
|
|
12
|
+
content: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
tool_calls?: any[];
|
|
15
|
+
reasoning_content?: string;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
reportEvent(stream: string, data: any, ts: number): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class TelemetryComponent {
|
|
2
|
+
options;
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.options = options;
|
|
5
|
+
}
|
|
6
|
+
async start() { }
|
|
7
|
+
async stop() { }
|
|
8
|
+
async reportReply(payload) {
|
|
9
|
+
const { agentId, orchestratorUrl, logger } = this.options;
|
|
10
|
+
try {
|
|
11
|
+
const replyUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
12
|
+
await fetch(replyUrl, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify(payload),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
logger?.error?.(`dragon channel: failed to post reply to orchestrator: ${e.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async reportEvent(stream, data, ts) {
|
|
23
|
+
const { agentId, orchestratorUrl, logger } = this.options;
|
|
24
|
+
try {
|
|
25
|
+
const telemetryUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
26
|
+
let telemetryPayload = null;
|
|
27
|
+
if (stream === "thinking") {
|
|
28
|
+
telemetryPayload = { content: "", ts, metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" } };
|
|
29
|
+
}
|
|
30
|
+
else if (stream === "tool" || stream === "item") {
|
|
31
|
+
const toolName = data?.name || data?.title || "unknown tool";
|
|
32
|
+
telemetryPayload = {
|
|
33
|
+
content: data?.output ? `[Tool Result] ${toolName}` : `[Tool Call] ${toolName}...`,
|
|
34
|
+
ts,
|
|
35
|
+
metadata: {
|
|
36
|
+
isTelemetry: true,
|
|
37
|
+
toolName,
|
|
38
|
+
toolInput: data?.input,
|
|
39
|
+
toolOutput: data?.output || data?.summary,
|
|
40
|
+
status: data?.output ? "completed" : "running",
|
|
41
|
+
stream: "tool"
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (telemetryPayload) {
|
|
46
|
+
void fetch(telemetryUrl, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify(telemetryPayload),
|
|
50
|
+
}).catch(e => logger?.error?.(`[Dragon] Failed to sync telemetry: ${e.message}`));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
logger?.error?.(`[Dragon] Telemetry resolution failed: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { IComponent } from './IComponent.js';
|
|
2
|
+
export declare class ServiceContainer {
|
|
3
|
+
private components;
|
|
4
|
+
private started;
|
|
5
|
+
register(name: string, component: IComponent): IComponent;
|
|
6
|
+
get<T extends IComponent>(name: string): T;
|
|
7
|
+
startAll(): Promise<void>;
|
|
8
|
+
stopAll(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class ServiceContainer {
|
|
2
|
+
components = new Map();
|
|
3
|
+
started = false;
|
|
4
|
+
register(name, component) {
|
|
5
|
+
this.components.set(name, component);
|
|
6
|
+
return component;
|
|
7
|
+
}
|
|
8
|
+
get(name) {
|
|
9
|
+
const component = this.components.get(name);
|
|
10
|
+
if (!component)
|
|
11
|
+
throw new Error(`Component ${name} not found`);
|
|
12
|
+
return component;
|
|
13
|
+
}
|
|
14
|
+
async startAll() {
|
|
15
|
+
if (this.started)
|
|
16
|
+
return;
|
|
17
|
+
for (const [name, component] of this.components) {
|
|
18
|
+
await component.start();
|
|
19
|
+
}
|
|
20
|
+
this.started = true;
|
|
21
|
+
}
|
|
22
|
+
async stopAll() {
|
|
23
|
+
if (!this.started)
|
|
24
|
+
return;
|
|
25
|
+
for (const [name, component] of this.components) {
|
|
26
|
+
await component.stop();
|
|
27
|
+
}
|
|
28
|
+
this.started = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,497 +1,153 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { defineChannelPluginEntry, createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
1
|
+
import { defineChannelPluginEntry, createChatChannelPlugin, createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
|
3
2
|
import { createRawChannelSendResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
|
4
|
-
// @ts-ignore
|
|
5
3
|
import * as InfraRuntime from "openclaw/plugin-sdk/infra-runtime";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
let cachedRuntime;
|
|
11
|
-
let globalDispatcher;
|
|
12
|
-
console.log("[Dragon] Plugin script is being evaluated by OpenClaw gateway...");
|
|
4
|
+
import { ServiceContainer } from "./core/ServiceContainer.js";
|
|
5
|
+
import { BridgeComponent } from "./components/bridge/BridgeComponent.js";
|
|
6
|
+
import { PollingComponent } from "./components/sync/PollingComponent.js";
|
|
7
|
+
import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js";
|
|
13
8
|
const channelId = "dragon";
|
|
9
|
+
let cachedRuntime;
|
|
10
|
+
const containers = new Map();
|
|
14
11
|
const processedMessageIds = new Set();
|
|
15
|
-
async function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const token = account.bridgeToken || "";
|
|
20
|
-
const client = connectToDragonBridge({
|
|
21
|
-
port,
|
|
22
|
-
token,
|
|
23
|
-
onConnected: () => {
|
|
24
|
-
runtimeLogger?.info?.("dragon channel: connected to dragon bridge");
|
|
25
|
-
client.sendJson({
|
|
26
|
-
type: "hello",
|
|
27
|
-
channel: channelId,
|
|
28
|
-
agentId: account.agentId,
|
|
29
|
-
port: parseInt(account.gatewayPort || "18789", 10),
|
|
30
|
-
token: account.gatewayToken || "",
|
|
31
|
-
version: "0.1.27",
|
|
32
|
-
timestamp: Date.now()
|
|
33
|
-
});
|
|
34
|
-
},
|
|
35
|
-
onDisconnected: () => {
|
|
36
|
-
runtimeLogger?.info?.("dragon channel: disconnected from dragon bridge");
|
|
37
|
-
},
|
|
38
|
-
onMessage: (msg) => {
|
|
39
|
-
const t = msg?.type;
|
|
40
|
-
if (t === "ping") {
|
|
41
|
-
client.sendJson({ type: "pong", t: Date.now() });
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
// Instructions from Dragon Client UI
|
|
45
|
-
if (t === "hot_control") {
|
|
46
|
-
const { command } = msg;
|
|
47
|
-
runtimeLogger?.info?.(`dragon channel: received hot control: ${command}`);
|
|
48
|
-
// Model switching is now handled directly via local openclaw.json / CLI by Dragon Client
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
bridgeClient = client;
|
|
53
|
-
runtimeLogger?.info?.(`dragon channel: bridge client initialized on port ${port}`);
|
|
54
|
-
}
|
|
55
|
-
async function startPolling(ctx) {
|
|
56
|
-
const account = ctx.account;
|
|
57
|
-
const agentId = account.agentId;
|
|
58
|
-
const orchestratorUrl = account.orchestratorUrl;
|
|
12
|
+
async function getOrCreateContainer(account, ctx) {
|
|
13
|
+
const key = `${account.agentId}:${account.orchestratorUrl}`;
|
|
14
|
+
if (containers.has(key))
|
|
15
|
+
return containers.get(key);
|
|
59
16
|
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
logger
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
17
|
+
const container = new ServiceContainer();
|
|
18
|
+
const bridge = container.register('bridge', new BridgeComponent({
|
|
19
|
+
port: parseInt(account.bridgePort || "18799", 10),
|
|
20
|
+
token: account.bridgeToken || "",
|
|
21
|
+
agentId: account.agentId,
|
|
22
|
+
gatewayPort: parseInt(account.gatewayPort || "18789", 10),
|
|
23
|
+
gatewayToken: account.gatewayToken || "",
|
|
24
|
+
logger
|
|
25
|
+
}));
|
|
26
|
+
const telemetry = container.register('telemetry', new TelemetryComponent({
|
|
27
|
+
agentId: account.agentId,
|
|
28
|
+
orchestratorUrl: account.orchestratorUrl,
|
|
29
|
+
logger
|
|
30
|
+
}));
|
|
73
31
|
const deliverToOpenClaw = async (content, sessionId = 'default', modelId, attachments, messageId) => {
|
|
74
|
-
|
|
75
|
-
if (!msg && (!attachments || attachments.length === 0))
|
|
76
|
-
return;
|
|
77
|
-
// Deduplication check
|
|
78
|
-
if (messageId && processedMessageIds.has(messageId)) {
|
|
32
|
+
if (messageId && processedMessageIds.has(messageId))
|
|
79
33
|
return;
|
|
80
|
-
}
|
|
81
34
|
if (messageId) {
|
|
82
35
|
processedMessageIds.add(messageId);
|
|
83
|
-
// Keep cache size manageable
|
|
84
36
|
if (processedMessageIds.size > 1000) {
|
|
85
37
|
const first = processedMessageIds.values().next().value;
|
|
86
38
|
if (first !== undefined)
|
|
87
39
|
processedMessageIds.delete(first);
|
|
88
40
|
}
|
|
89
41
|
}
|
|
90
|
-
|
|
91
|
-
|
|
42
|
+
const replyDispatcher = ctx.channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
|
|
43
|
+
if (typeof replyDispatcher !== 'function')
|
|
44
|
+
return;
|
|
92
45
|
const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
|
|
93
|
-
logger?.info?.(`dragon channel: [LATENCY_CHECK] Dispatching to OpenClaw core... Session=${sessionId}`);
|
|
94
46
|
await replyDispatcher({
|
|
95
47
|
ctx: {
|
|
96
|
-
Body:
|
|
97
|
-
BodyForAgent: msg,
|
|
98
|
-
BodyForCommands: msg,
|
|
48
|
+
Body: content,
|
|
99
49
|
From: sessionId === 'default' ? "workbench-user" : `workbench-user-${sessionId}`,
|
|
100
|
-
SenderName: "Workbench User",
|
|
101
|
-
SenderId: "workbench-user",
|
|
102
50
|
To: account.agentId,
|
|
103
51
|
ChatType: "direct",
|
|
104
52
|
Provider: channelId,
|
|
105
53
|
ChannelId: channelId,
|
|
106
|
-
Surface: "dragon-workbench",
|
|
107
54
|
AccountId: account.accountId,
|
|
108
55
|
SessionKey: sessionKey,
|
|
109
|
-
CommandSource: "native",
|
|
110
|
-
CommandAuthorized: true,
|
|
111
56
|
Timestamp: Date.now(),
|
|
112
57
|
Model: modelId,
|
|
113
|
-
Attachments: attachments,
|
|
58
|
+
Attachments: attachments,
|
|
114
59
|
},
|
|
115
|
-
// Building OpenClaw-native multi-modal blocks if images are present
|
|
116
|
-
customBlocks: (attachments || [])
|
|
117
|
-
.filter((a) => a.type === 'image')
|
|
118
|
-
.map((a) => ({
|
|
119
|
-
kind: 'image',
|
|
120
|
-
data: a.data, // Should be base64 or URL
|
|
121
|
-
mime: a.mime || 'image/png'
|
|
122
|
-
})),
|
|
123
60
|
cfg: ctx.cfg,
|
|
124
61
|
dispatcherOptions: {
|
|
125
62
|
deliver: async (payload) => {
|
|
126
63
|
const text = payload?.text || "";
|
|
127
|
-
|
|
128
|
-
const reasoning = payload?.reasoning_content || "";
|
|
129
|
-
if (!text && !toolCalls.length && !reasoning)
|
|
64
|
+
if (!text && !payload?.tool_calls?.length)
|
|
130
65
|
return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (bridgeClient) {
|
|
134
|
-
bridgeClient.sendJson({
|
|
66
|
+
if (bridge.client) {
|
|
67
|
+
bridge.client.sendJson({
|
|
135
68
|
type: "outbound_text",
|
|
136
69
|
channel: channelId,
|
|
137
|
-
messageId: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
138
70
|
text,
|
|
139
|
-
tool_calls:
|
|
140
|
-
reasoning_content:
|
|
141
|
-
route: {
|
|
142
|
-
agentId: account.agentId,
|
|
143
|
-
accountId: account.accountId,
|
|
144
|
-
peer: { kind: "direct", id: sessionId },
|
|
145
|
-
sessionKey: sessionKey,
|
|
146
|
-
},
|
|
71
|
+
tool_calls: payload?.tool_calls,
|
|
72
|
+
reasoning_content: payload?.reasoning_content,
|
|
73
|
+
route: { agentId: account.agentId, accountId: account.accountId, peer: { kind: "direct", id: sessionId }, sessionKey },
|
|
147
74
|
});
|
|
148
75
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
body: JSON.stringify({
|
|
156
|
-
content: text,
|
|
157
|
-
sessionId,
|
|
158
|
-
tool_calls: toolCalls,
|
|
159
|
-
reasoning_content: reasoning,
|
|
160
|
-
}),
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
catch (e) {
|
|
164
|
-
logger?.error?.(`dragon channel: failed to post deliver-reply to orchestrator: ${e.message}`);
|
|
165
|
-
}
|
|
76
|
+
await telemetry.reportReply({
|
|
77
|
+
content: text,
|
|
78
|
+
sessionId,
|
|
79
|
+
tool_calls: payload?.tool_calls,
|
|
80
|
+
reasoning_content: payload?.reasoning_content,
|
|
81
|
+
});
|
|
166
82
|
}
|
|
167
83
|
}
|
|
168
84
|
});
|
|
169
85
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
const connectOnce = async () => {
|
|
191
|
-
const sseUrl = `${orchestratorUrl}/api/agents/events`;
|
|
192
|
-
// Recovery: Catch up on any messages missed during downtime
|
|
193
|
-
void consumePendingMessages();
|
|
194
|
-
const handleSseText = async (chunkText, bufRef) => {
|
|
195
|
-
// Diagnostic: log first chunk of SSE if it's not a heartbeat comment
|
|
196
|
-
const isHeartbeat = chunkText.trim().startsWith(':');
|
|
197
|
-
if (!bufRef.buf && chunkText.trim() && !isHeartbeat) {
|
|
198
|
-
logger?.info?.(`dragon channel: SSE received first message chunk (len=${chunkText.length})`);
|
|
199
|
-
}
|
|
200
|
-
bufRef.buf += chunkText;
|
|
201
|
-
let idx;
|
|
202
|
-
while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
|
|
203
|
-
const raw = bufRef.buf.slice(0, idx);
|
|
204
|
-
bufRef.buf = bufRef.buf.slice(idx + 2);
|
|
205
|
-
if (!raw.trim() || raw.trim().startsWith(':'))
|
|
206
|
-
continue;
|
|
207
|
-
const dataLines = raw
|
|
208
|
-
.split('\n')
|
|
209
|
-
.filter((l) => typeof l === 'string' && l.startsWith('data:'))
|
|
210
|
-
.map((l) => l.slice('data:'.length).trim());
|
|
211
|
-
if (!dataLines.length)
|
|
212
|
-
continue;
|
|
213
|
-
const dataStr = dataLines.join('\n');
|
|
214
|
-
try {
|
|
215
|
-
const evt = JSON.parse(dataStr);
|
|
216
|
-
if (!evt)
|
|
217
|
-
continue;
|
|
218
|
-
if (evt.type === 'WORKBENCH_MESSAGE') {
|
|
219
|
-
const sendTs = evt.payload?.ts || 0;
|
|
220
|
-
const latency = sendTs ? (Date.now() - sendTs) : 'unknown';
|
|
221
|
-
// Critical Log: Confirm receipt of SSE message
|
|
222
|
-
logger?.info?.(`dragon channel: [SSE] RECEIVED WORKBENCH_MESSAGE. agentId=${evt.agentId} (Current Config: ${agentId}), sessionId=${evt.payload?.sessionId}, latency=${latency}ms`);
|
|
223
|
-
if (evt.agentId !== agentId) {
|
|
224
|
-
logger?.warn?.(`dragon channel: [SSE] ID Mismatch! Event ID "${evt.agentId}" !== Config ID "${agentId}". Ignoring message.`);
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
logger?.info?.(`dragon channel: [DEBUG] Received WORKBENCH_MESSAGE via SSE. AgentID=${evt.agentId}, ContentLen=${evt.payload?.content?.length}, Latency=${latency}ms`);
|
|
228
|
-
logger?.info?.(`dragon channel: [DEBUG] Payload Content: "${evt.payload?.content?.substring(0, 500)}"`);
|
|
229
|
-
logger?.info?.(`dragon channel: [DEBUG] Current Account Config: ${JSON.stringify(account)}`);
|
|
230
|
-
await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id // Pass DB ID for deduplication
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
else if (evt.type === 'FETCH_HISTORY') {
|
|
234
|
-
const { sessionId } = evt.payload;
|
|
235
|
-
logger?.info?.(`dragon channel: [DEBUG] Received FETCH_HISTORY for session ${sessionId}`);
|
|
236
|
-
try {
|
|
237
|
-
// Retrieve history from OpenClaw core via channelRuntime
|
|
238
|
-
// sessionKey format: dragon:agentId:direct:sessionId
|
|
239
|
-
const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
|
|
240
|
-
// Standard OpenClaw 2026.4.12+ history retrieval API
|
|
241
|
-
const history = await ctx.channelRuntime?.history?.getMessages?.({
|
|
242
|
-
sessionKey,
|
|
243
|
-
limit: 50
|
|
244
|
-
}) || [];
|
|
245
|
-
logger?.info?.(`dragon channel: [DEBUG] Retrieved ${history.length} messages from OpenClaw core for sync.`);
|
|
246
|
-
// Post back to orchestrator
|
|
247
|
-
const syncUrl = `${orchestratorUrl}/api/agents/${account.agentId}/messages/sync-context`;
|
|
248
|
-
await fetch(syncUrl, {
|
|
249
|
-
method: "POST",
|
|
250
|
-
headers: { "Content-Type": "application/json" },
|
|
251
|
-
body: JSON.stringify({
|
|
252
|
-
sessionId,
|
|
253
|
-
messages: history.map((m) => ({
|
|
254
|
-
role: m.role || (m.from === 'workbench-user' ? 'user' : 'assistant'),
|
|
255
|
-
content: m.body || m.text || '',
|
|
256
|
-
ts: m.timestamp || Date.now()
|
|
257
|
-
}))
|
|
258
|
-
}),
|
|
259
|
-
});
|
|
260
|
-
logger?.info?.(`dragon channel: [DEBUG] Successfully synced ${history.length} messages back to orchestrator.`);
|
|
261
|
-
}
|
|
262
|
-
catch (e) {
|
|
263
|
-
logger?.error?.(`dragon channel: [ERROR] FETCH_HISTORY failed: ${e.message}`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch (e) {
|
|
268
|
-
logger?.error?.(`dragon channel: failed to parse SSE data: ${e?.message || e}`, e?.stack);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
// Diagnostic: log connection attempt
|
|
273
|
-
logger?.info?.(`dragon channel: connecting to SSE (no-env-diag)...`);
|
|
274
|
-
// First try: standard fetch (HTTP/1.1 in Node).
|
|
275
|
-
const res = await fetch(sseUrl, {
|
|
276
|
-
signal: ctx.abortSignal,
|
|
277
|
-
headers: { Accept: 'text/event-stream' },
|
|
278
|
-
});
|
|
279
|
-
logger?.info?.(`dragon channel: fetch(${sseUrl}) status: ${res.status} ${res.statusText}`);
|
|
280
|
-
// Some reverse proxy stacks may only route this endpoint over HTTP/2.
|
|
281
|
-
// If we see a 404 from the edge, fall back to a native HTTP/2 client.
|
|
282
|
-
if (res.status === 404) {
|
|
283
|
-
const u = new URL(sseUrl);
|
|
284
|
-
const origin = `${u.protocol}//${u.host}`;
|
|
285
|
-
const pathWithQuery = `${u.pathname}${u.search || ''}`;
|
|
286
|
-
const bufRef = { buf: '' };
|
|
287
|
-
const decoder = new TextDecoder();
|
|
288
|
-
await new Promise((resolve, reject) => {
|
|
289
|
-
const session = http2.connect(origin);
|
|
290
|
-
let done = false;
|
|
291
|
-
const finish = (err) => {
|
|
292
|
-
if (done)
|
|
293
|
-
return;
|
|
294
|
-
done = true;
|
|
295
|
-
try {
|
|
296
|
-
session.close();
|
|
297
|
-
}
|
|
298
|
-
catch { }
|
|
299
|
-
if (err)
|
|
300
|
-
reject(err);
|
|
301
|
-
else
|
|
302
|
-
resolve();
|
|
303
|
-
};
|
|
304
|
-
const onAbort = () => finish({ name: 'AbortError' });
|
|
305
|
-
try {
|
|
306
|
-
ctx.abortSignal?.addEventListener?.('abort', onAbort);
|
|
307
|
-
}
|
|
308
|
-
catch { }
|
|
309
|
-
session.on('error', (err) => finish(err));
|
|
310
|
-
const req = session.request({
|
|
311
|
-
':method': 'GET',
|
|
312
|
-
':path': pathWithQuery,
|
|
313
|
-
accept: 'text/event-stream',
|
|
314
|
-
});
|
|
315
|
-
req.setEncoding('utf8');
|
|
316
|
-
req.on('response', (headers) => {
|
|
317
|
-
const status = Number(headers[':status'] || 0);
|
|
318
|
-
if (status !== 200) {
|
|
319
|
-
finish(new Error(`Orchestrator SSE failed: ${status}`));
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
req.on('data', async (chunk) => {
|
|
324
|
-
try {
|
|
325
|
-
await handleSseText(chunk, bufRef);
|
|
326
|
-
}
|
|
327
|
-
catch (e) {
|
|
328
|
-
finish(e);
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
req.on('end', () => finish());
|
|
332
|
-
req.on('close', () => finish());
|
|
333
|
-
req.end();
|
|
334
|
-
});
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (!res.ok)
|
|
338
|
-
throw new Error(`Orchestrator SSE failed: ${res.status}`);
|
|
339
|
-
const body = res.body;
|
|
340
|
-
if (!body || typeof body.getReader !== 'function') {
|
|
341
|
-
throw new Error('Orchestrator SSE: response body not readable');
|
|
342
|
-
}
|
|
343
|
-
const reader = body.getReader();
|
|
344
|
-
const decoder = new TextDecoder();
|
|
345
|
-
const bufRef = { buf: '' };
|
|
346
|
-
while (!ctx.abortSignal.aborted) {
|
|
347
|
-
const { value, done } = await reader.read();
|
|
348
|
-
if (done)
|
|
349
|
-
break;
|
|
350
|
-
await handleSseText(decoder.decode(value, { stream: true }), bufRef);
|
|
351
|
-
}
|
|
352
|
-
};
|
|
353
|
-
let attempt = 0;
|
|
354
|
-
// Safety: Periodic polling fallback every 60 seconds
|
|
355
|
-
const pollInterval = setInterval(() => {
|
|
356
|
-
void consumePendingMessages();
|
|
357
|
-
}, 60_000);
|
|
358
|
-
while (!ctx.abortSignal.aborted) {
|
|
359
|
-
try {
|
|
360
|
-
attempt += 1;
|
|
361
|
-
logger?.info?.(`dragon channel: SSE connecting (attempt ${attempt})`);
|
|
362
|
-
await connectOnce();
|
|
363
|
-
}
|
|
364
|
-
catch (e) {
|
|
365
|
-
if (e?.name === 'AbortError')
|
|
366
|
-
break;
|
|
367
|
-
logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`, e?.stack);
|
|
368
|
-
// reconnect delay (cap 10s)
|
|
369
|
-
const delayMs = Math.min(10_000, 500 + attempt * 500);
|
|
370
|
-
await new Promise((r) => setTimeout(r, delayMs));
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
clearInterval(pollInterval);
|
|
374
|
-
logger?.info?.({ agentId }, "dragon channel: SSE loop terminated");
|
|
86
|
+
container.register('polling', new PollingComponent({
|
|
87
|
+
agentId: account.agentId,
|
|
88
|
+
orchestratorUrl: account.orchestratorUrl,
|
|
89
|
+
accountId: account.accountId,
|
|
90
|
+
abortSignal: ctx.abortSignal,
|
|
91
|
+
logger,
|
|
92
|
+
deliverToOpenClaw
|
|
93
|
+
}));
|
|
94
|
+
await container.startAll();
|
|
95
|
+
containers.set(key, container);
|
|
96
|
+
return container;
|
|
375
97
|
}
|
|
376
98
|
const base = createChannelPluginBase({
|
|
377
99
|
id: channelId,
|
|
378
|
-
meta: {
|
|
379
|
-
|
|
380
|
-
selectionLabel: "Dragon Workbench",
|
|
381
|
-
docsPath: "https://github.com/efengx/dragon",
|
|
382
|
-
blurb: "Real-time communication bridge between OpenClaw and Dragon Workbench.",
|
|
383
|
-
},
|
|
384
|
-
capabilities: {
|
|
385
|
-
chatTypes: ["direct", "group"],
|
|
386
|
-
},
|
|
100
|
+
meta: { label: "Dragon Workbench", selectionLabel: "Dragon Workbench" },
|
|
101
|
+
capabilities: { chatTypes: ["direct", "group"] },
|
|
387
102
|
setup: {
|
|
388
|
-
async
|
|
389
|
-
|
|
390
|
-
},
|
|
391
|
-
async finalize(ctx) {
|
|
392
|
-
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
393
|
-
await ensureConnection(ctx.account, logger);
|
|
394
|
-
return { ok: true };
|
|
395
|
-
},
|
|
103
|
+
validate: async () => ({ ok: true }),
|
|
104
|
+
finalize: async () => ({ ok: true })
|
|
396
105
|
},
|
|
397
106
|
config: {
|
|
398
|
-
listAccountIds: (cfg) => {
|
|
399
|
-
const channelConfig = cfg.channels?.[channelId];
|
|
400
|
-
if (!channelConfig?.accounts)
|
|
401
|
-
return ["default"];
|
|
402
|
-
return Object.keys(channelConfig.accounts);
|
|
403
|
-
},
|
|
404
107
|
resolveAccount: (cfg, accountId = "default") => {
|
|
405
108
|
const accountConfig = cfg.channels?.[channelId]?.accounts?.[accountId] || {};
|
|
406
|
-
const agentId = accountConfig.agentId || accountId;
|
|
407
|
-
const orchestratorUrl = accountConfig.orchestratorUrl || "http://127.0.0.1:4000";
|
|
408
|
-
const bridgePort = accountConfig.bridgePort || "18799";
|
|
409
|
-
const bridgeToken = accountConfig.bridgeToken || "";
|
|
410
|
-
const gatewayPort = accountConfig.gatewayPort || "18789";
|
|
411
|
-
const gatewayToken = accountConfig.gatewayToken || "";
|
|
412
|
-
const now = Date.now();
|
|
413
|
-
const lastLog = global.lastDragonAccountLogTime?.[accountId] || 0;
|
|
414
|
-
if (now - lastLog > 10 * 60 * 1000) {
|
|
415
|
-
console.log(`[Dragon] Resolved account ${accountId}: agentId=${agentId}, url=${orchestratorUrl}`);
|
|
416
|
-
if (!global.lastDragonAccountLogTime)
|
|
417
|
-
global.lastDragonAccountLogTime = {};
|
|
418
|
-
global.lastDragonAccountLogTime[accountId] = now;
|
|
419
|
-
}
|
|
420
109
|
return {
|
|
421
110
|
accountId,
|
|
422
|
-
agentId,
|
|
423
|
-
orchestratorUrl,
|
|
424
|
-
bridgePort,
|
|
425
|
-
bridgeToken,
|
|
426
|
-
gatewayPort,
|
|
427
|
-
gatewayToken,
|
|
111
|
+
agentId: accountConfig.agentId || accountId,
|
|
112
|
+
orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
|
|
113
|
+
bridgePort: accountConfig.bridgePort,
|
|
114
|
+
bridgeToken: accountConfig.bridgeToken,
|
|
115
|
+
gatewayPort: accountConfig.gatewayPort,
|
|
116
|
+
gatewayToken: accountConfig.gatewayToken,
|
|
428
117
|
};
|
|
429
118
|
},
|
|
430
119
|
},
|
|
431
120
|
});
|
|
432
|
-
const gateway = {
|
|
433
|
-
startAccount: async (ctx) => {
|
|
434
|
-
const account = ctx.account;
|
|
435
|
-
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
436
|
-
logger?.info?.(`dragon channel: starting account gateway for agent ${account.agentId} at ${account.orchestratorUrl}`);
|
|
437
|
-
// Background poll loop - AWAIT it to keep the account active in the gateway
|
|
438
|
-
try {
|
|
439
|
-
await startPolling(ctx);
|
|
440
|
-
}
|
|
441
|
-
catch (err) {
|
|
442
|
-
logger?.error?.(`dragon channel: fatal poll error: ${err.message}`, err.stack);
|
|
443
|
-
}
|
|
444
|
-
},
|
|
445
|
-
};
|
|
446
121
|
const plugin = createChatChannelPlugin({
|
|
447
122
|
base: {
|
|
448
123
|
...base,
|
|
449
|
-
|
|
124
|
+
capabilities: { chatTypes: ["direct", "group"] },
|
|
125
|
+
gateway: {
|
|
126
|
+
startAccount: async (ctx) => {
|
|
127
|
+
const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
|
|
128
|
+
await getOrCreateContainer(account, ctx);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
450
131
|
},
|
|
451
132
|
outbound: {
|
|
452
133
|
attachedResults: createRawChannelSendResultAdapter({
|
|
453
134
|
channel: channelId,
|
|
454
135
|
async sendText(ctx) {
|
|
455
|
-
const text = ctx?.text
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
logger?.info?.(`dragon channel: sendText invoked. agentId=${agentId}, orchestratorUrl=${orchestratorUrl}, text="${text.substring(0, 30)}..."`);
|
|
463
|
-
// 1. Send to local bridge (WebSocket)
|
|
464
|
-
if (bridgeClient) {
|
|
465
|
-
bridgeClient.sendJson({
|
|
136
|
+
const text = ctx?.text || "";
|
|
137
|
+
const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
|
|
138
|
+
const container = await getOrCreateContainer(account, ctx);
|
|
139
|
+
const bridge = container.get('bridge');
|
|
140
|
+
const telemetry = container.get('telemetry');
|
|
141
|
+
if (bridge.client) {
|
|
142
|
+
bridge.client.sendJson({
|
|
466
143
|
type: "outbound_text",
|
|
467
144
|
channel: channelId,
|
|
468
|
-
messageId,
|
|
469
145
|
text,
|
|
470
|
-
route: {
|
|
471
|
-
agentId,
|
|
472
|
-
accountId: ctx?.accountId,
|
|
473
|
-
peer: ctx?.peer,
|
|
474
|
-
sessionKey,
|
|
475
|
-
},
|
|
146
|
+
route: { agentId: account.agentId, accountId: account.accountId, peer: ctx?.peer, sessionKey: ctx?.ctx?.SessionKey },
|
|
476
147
|
});
|
|
477
148
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
const replyUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
482
|
-
logger?.info?.(`dragon channel: posting reply to ${replyUrl}`);
|
|
483
|
-
const res = await fetch(replyUrl, {
|
|
484
|
-
method: "POST",
|
|
485
|
-
headers: { "Content-Type": "application/json" },
|
|
486
|
-
body: JSON.stringify({ content: text }),
|
|
487
|
-
});
|
|
488
|
-
logger?.info?.(`dragon channel: reply post status: ${res.status}`);
|
|
489
|
-
}
|
|
490
|
-
catch (e) {
|
|
491
|
-
logger?.error?.(`dragon channel: failed to post reply to orchestrator: ${e.message}`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
return { ok: true, messageId };
|
|
149
|
+
await telemetry.reportReply({ content: text, sessionId: ctx?.peer?.id || 'default' });
|
|
150
|
+
return { ok: true, messageId: Date.now().toString() };
|
|
495
151
|
},
|
|
496
152
|
}),
|
|
497
153
|
},
|
|
@@ -500,145 +156,35 @@ const entry = defineChannelPluginEntry({
|
|
|
500
156
|
id: channelId,
|
|
501
157
|
name: "Dragon Workbench Channel",
|
|
502
158
|
description: "Connect OpenClaw to the Dragon agent-client workbench chat.",
|
|
503
|
-
configSchema: {
|
|
504
|
-
type: "object",
|
|
505
|
-
properties: {
|
|
506
|
-
enabled: { type: "boolean" },
|
|
507
|
-
activated: { type: "boolean" }
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
159
|
plugin,
|
|
511
160
|
registerFull(api) {
|
|
512
|
-
console.log("[Dragon] registerFull() hook called by OpenClaw gateway");
|
|
513
161
|
cachedRuntime = api.runtime;
|
|
514
|
-
// Subscribe to real-time events (thinking, tool calls, etc.)
|
|
515
|
-
// v2026.4.12+ : runtime.events.onAgentEvent
|
|
516
162
|
const agentEventHandler = api.runtime?.events?.onAgentEvent || InfraRuntime.onAgentEvent;
|
|
517
163
|
if (typeof agentEventHandler === 'function') {
|
|
518
|
-
agentEventHandler((evt) => {
|
|
519
|
-
const logger = cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
520
|
-
// Filter by sessionKey to ensure we only send events belonging to this channel
|
|
164
|
+
agentEventHandler(async (evt) => {
|
|
521
165
|
const sessionKey = evt.sessionKey;
|
|
522
166
|
if (!sessionKey || !sessionKey.startsWith(`${channelId}:`))
|
|
523
167
|
return;
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const telemetryUrl = `${account.orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
541
|
-
// Decide what to send to orchestrator
|
|
542
|
-
let telemetryPayload = null;
|
|
543
|
-
if (stream === "thinking") {
|
|
544
|
-
telemetryPayload = {
|
|
545
|
-
content: "", // Content can be empty for pure reasoning update
|
|
546
|
-
ts: evt.ts,
|
|
547
|
-
metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" }
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
else if (stream === "tool" || (stream === "item" && data?.kind === "tool")) {
|
|
551
|
-
const toolName = data?.name || data?.title || "unknown tool";
|
|
552
|
-
if (data?.status === "completed" || data?.phase === "end" || data?.output) {
|
|
553
|
-
telemetryPayload = {
|
|
554
|
-
content: `[Tool Result] ${toolName}`,
|
|
555
|
-
ts: evt.ts,
|
|
556
|
-
metadata: {
|
|
557
|
-
isTelemetry: true,
|
|
558
|
-
toolName: toolName,
|
|
559
|
-
toolInput: data?.input,
|
|
560
|
-
toolOutput: data?.output || data?.summary,
|
|
561
|
-
toolCallId: data?.toolCallId || runId,
|
|
562
|
-
status: "completed",
|
|
563
|
-
stream: "tool"
|
|
564
|
-
}
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
// Initial tool call info
|
|
569
|
-
telemetryPayload = {
|
|
570
|
-
content: `[Tool Call] ${toolName}...`,
|
|
571
|
-
ts: evt.ts,
|
|
572
|
-
metadata: {
|
|
573
|
-
isTelemetry: true,
|
|
574
|
-
toolName: toolName,
|
|
575
|
-
toolInput: data?.input,
|
|
576
|
-
status: "running",
|
|
577
|
-
stream: "tool"
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
else if (stream === "plan") {
|
|
583
|
-
telemetryPayload = {
|
|
584
|
-
content: data?.explanation || `Plan Update: ${data?.title}`,
|
|
585
|
-
ts: evt.ts,
|
|
586
|
-
metadata: {
|
|
587
|
-
isTelemetry: true,
|
|
588
|
-
planTitle: data?.title,
|
|
589
|
-
planSteps: data?.steps,
|
|
590
|
-
stream: "plan"
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
if (telemetryPayload) {
|
|
595
|
-
void fetch(telemetryUrl, {
|
|
596
|
-
method: "POST",
|
|
597
|
-
headers: { "Content-Type": "application/json" },
|
|
598
|
-
body: JSON.stringify(telemetryPayload),
|
|
599
|
-
}).catch(e => logger?.error?.(`[Dragon] Failed to sync telemetry: ${e.message}`));
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
catch (e) {
|
|
605
|
-
logger?.error?.(`[Dragon] Telemetry resolution failed: ${e.message}`);
|
|
606
|
-
}
|
|
607
|
-
// 2. Also send to local bridge (WebSocket)
|
|
608
|
-
if (bridgeClient) {
|
|
609
|
-
bridgeClient.sendJson({
|
|
610
|
-
type: "agent_event",
|
|
611
|
-
channel: channelId,
|
|
612
|
-
agentId,
|
|
613
|
-
runId,
|
|
614
|
-
stream,
|
|
615
|
-
data,
|
|
616
|
-
sessionKey
|
|
617
|
-
});
|
|
618
|
-
}
|
|
168
|
+
const agentId = sessionKey.split(':')[1] || "";
|
|
169
|
+
const accountId = sessionKey.split(':')[2] || "default";
|
|
170
|
+
const account = base.config.resolveAccount(api.runtime.cfg, accountId);
|
|
171
|
+
const container = await getOrCreateContainer(account, { cfg: api.runtime.cfg, abortSignal: new AbortController().signal });
|
|
172
|
+
const bridge = container.get('bridge');
|
|
173
|
+
const telemetry = container.get('telemetry');
|
|
174
|
+
if (bridge.client) {
|
|
175
|
+
bridge.client.sendJson({
|
|
176
|
+
type: "agent_event",
|
|
177
|
+
channel: channelId,
|
|
178
|
+
agentId,
|
|
179
|
+
runId: evt.runId,
|
|
180
|
+
stream: evt.stream,
|
|
181
|
+
data: evt.data,
|
|
182
|
+
sessionKey
|
|
183
|
+
});
|
|
619
184
|
}
|
|
185
|
+
await telemetry.reportEvent(evt.stream, evt.data, evt.ts);
|
|
620
186
|
});
|
|
621
187
|
}
|
|
622
|
-
|
|
623
|
-
console.warn("[Dragon] onAgentEvent not found in runtime. Real-time status updates may be disabled.");
|
|
624
|
-
}
|
|
625
|
-
api.registerGatewayMethod("dragon.activate", async ({ respond, payload }) => {
|
|
626
|
-
const logger = cachedRuntime?.logger ?? cachedRuntime?.log ?? cachedRuntime;
|
|
627
|
-
// Resolve account for activation
|
|
628
|
-
const accountId = payload?.accountId || "default";
|
|
629
|
-
const account = entry.resolveAccount(api.runtime.cfg, accountId);
|
|
630
|
-
await ensureConnection(account, logger);
|
|
631
|
-
respond(true, { active: true });
|
|
632
|
-
});
|
|
633
|
-
api.registerGatewayMethod("dragon.status", async ({ respond }) => {
|
|
634
|
-
respond(true, {
|
|
635
|
-
active: !!bridgeClient,
|
|
636
|
-
connected: !!bridgeClient,
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
},
|
|
640
|
-
setRuntime: (runtime) => {
|
|
641
|
-
cachedRuntime = runtime;
|
|
642
|
-
},
|
|
188
|
+
}
|
|
643
189
|
});
|
|
644
190
|
export default entry;
|