@stagewhisper/stagewhisper 0.40.0 → 0.42.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/openclaw.plugin.json +1 -1
- package/openresponses.d.ts +1361 -0
- package/package.json +2 -1
- package/plugin-main.ts +85 -0
- package/src/client.ts +34 -26
- package/src/health.test.ts +101 -0
- package/src/health.ts +94 -0
- package/src/openresponses.ts +101 -0
- package/src/reasoning.test.ts +116 -0
- package/src/reasoning.ts +198 -0
- package/src/service.ts +141 -55
package/src/reasoning.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import {
|
|
3
|
+
callOpenResponses,
|
|
4
|
+
OpenResponsesError,
|
|
5
|
+
type OpenResponsesCreateResponseRequestBody,
|
|
6
|
+
type OpenResponsesResponseResource,
|
|
7
|
+
} from "./openresponses.js";
|
|
8
|
+
|
|
9
|
+
export type ProbeResult = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
model: string | null;
|
|
12
|
+
error: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function probeOpenResponses(
|
|
16
|
+
api: OpenClawPluginApi,
|
|
17
|
+
): Promise<ProbeResult> {
|
|
18
|
+
const body: OpenResponsesCreateResponseRequestBody = {
|
|
19
|
+
model: "default",
|
|
20
|
+
input: "Reply with exactly: OK",
|
|
21
|
+
max_output_tokens: 16,
|
|
22
|
+
temperature: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await callOpenResponses(api, body, controller.signal, undefined);
|
|
30
|
+
const model = (result as Record<string, unknown>).model as string ?? null;
|
|
31
|
+
return { ok: true, model, error: null };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
model: null,
|
|
36
|
+
error: err instanceof Error ? err.message : String(err),
|
|
37
|
+
};
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ReasoningJobEnvelope = {
|
|
44
|
+
event_type: "reasoning_job";
|
|
45
|
+
job_id: string;
|
|
46
|
+
purpose: string;
|
|
47
|
+
deadline_at: string;
|
|
48
|
+
idempotency_key: string;
|
|
49
|
+
schema_version: number;
|
|
50
|
+
response_schema: Record<string, unknown>;
|
|
51
|
+
payload: Record<string, unknown>;
|
|
52
|
+
model?: string;
|
|
53
|
+
correlation_id?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ReasoningJobResult = {
|
|
57
|
+
job_id: string;
|
|
58
|
+
status: "completed" | "failed" | "timed_out";
|
|
59
|
+
provider_run_id: string | null;
|
|
60
|
+
model_ref: string | null;
|
|
61
|
+
usage: { input_tokens: number; output_tokens: number } | null;
|
|
62
|
+
output: Record<string, unknown> | null;
|
|
63
|
+
error_code: string | null;
|
|
64
|
+
error_message: string | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function extractTextOutput(result: OpenResponsesResponseResource): string | null {
|
|
68
|
+
const output = result.output;
|
|
69
|
+
if (!Array.isArray(output)) return null;
|
|
70
|
+
for (const item of output) {
|
|
71
|
+
if (item.type !== "message") continue;
|
|
72
|
+
const content = (item as Record<string, unknown>).content;
|
|
73
|
+
if (!Array.isArray(content)) continue;
|
|
74
|
+
for (const part of content) {
|
|
75
|
+
const p = part as Record<string, unknown>;
|
|
76
|
+
if (p.type === "output_text" && typeof p.text === "string") {
|
|
77
|
+
return p.text;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function executeReasoningJob(
|
|
85
|
+
api: OpenClawPluginApi,
|
|
86
|
+
job: ReasoningJobEnvelope,
|
|
87
|
+
displayModel: string | null,
|
|
88
|
+
): Promise<ReasoningJobResult> {
|
|
89
|
+
const parsedDeadline = new Date(job.deadline_at).getTime();
|
|
90
|
+
const deadlineMs = Number.isFinite(parsedDeadline)
|
|
91
|
+
? parsedDeadline - Date.now()
|
|
92
|
+
: -1;
|
|
93
|
+
if (deadlineMs <= 0) {
|
|
94
|
+
return {
|
|
95
|
+
job_id: job.job_id,
|
|
96
|
+
status: "timed_out",
|
|
97
|
+
provider_run_id: null,
|
|
98
|
+
model_ref: displayModel,
|
|
99
|
+
usage: null,
|
|
100
|
+
output: null,
|
|
101
|
+
error_code: "deadline_expired_before_start",
|
|
102
|
+
error_message: "Job deadline had already passed when execution began",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const model = job.model ?? displayModel ?? "default";
|
|
107
|
+
const correlationId = job.correlation_id;
|
|
108
|
+
|
|
109
|
+
const requestBody: OpenResponsesCreateResponseRequestBody = {
|
|
110
|
+
model,
|
|
111
|
+
input: JSON.stringify(job.payload),
|
|
112
|
+
instructions: (job.payload.system_instruction as string) ?? undefined,
|
|
113
|
+
text: {
|
|
114
|
+
format: {
|
|
115
|
+
type: "json_schema",
|
|
116
|
+
name: `reasoning_${job.purpose}`,
|
|
117
|
+
schema: job.response_schema,
|
|
118
|
+
strict: true,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
max_output_tokens: 4096,
|
|
122
|
+
temperature: 0.2,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await callOpenResponses(api, requestBody, controller.signal, correlationId);
|
|
130
|
+
const textOutput = extractTextOutput(result);
|
|
131
|
+
|
|
132
|
+
let parsed: Record<string, unknown> | null = null;
|
|
133
|
+
if (textOutput) {
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(textOutput) as Record<string, unknown>;
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
job_id: job.job_id,
|
|
139
|
+
status: "failed",
|
|
140
|
+
provider_run_id: result.id,
|
|
141
|
+
model_ref: (result as Record<string, unknown>).model as string ?? model,
|
|
142
|
+
usage: result.usage
|
|
143
|
+
? {
|
|
144
|
+
input_tokens: result.usage.input_tokens ?? 0,
|
|
145
|
+
output_tokens: result.usage.output_tokens ?? 0,
|
|
146
|
+
}
|
|
147
|
+
: null,
|
|
148
|
+
output: null,
|
|
149
|
+
error_code: "response_parse_error",
|
|
150
|
+
error_message: "Response text is not valid JSON",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
job_id: job.job_id,
|
|
157
|
+
status: "completed",
|
|
158
|
+
provider_run_id: result.id,
|
|
159
|
+
model_ref: (result as Record<string, unknown>).model as string ?? model,
|
|
160
|
+
usage: result.usage
|
|
161
|
+
? {
|
|
162
|
+
input_tokens: result.usage.input_tokens ?? 0,
|
|
163
|
+
output_tokens: result.usage.output_tokens ?? 0,
|
|
164
|
+
}
|
|
165
|
+
: null,
|
|
166
|
+
output: parsed,
|
|
167
|
+
error_code: null,
|
|
168
|
+
error_message: null,
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (controller.signal.aborted) {
|
|
172
|
+
return {
|
|
173
|
+
job_id: job.job_id,
|
|
174
|
+
status: "timed_out",
|
|
175
|
+
provider_run_id: null,
|
|
176
|
+
model_ref: model,
|
|
177
|
+
usage: null,
|
|
178
|
+
output: null,
|
|
179
|
+
error_code: "deadline_exceeded",
|
|
180
|
+
error_message: `Reasoning execution exceeded deadline of ${deadlineMs}ms`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const isRetryable = err instanceof OpenResponsesError && err.retryable;
|
|
185
|
+
return {
|
|
186
|
+
job_id: job.job_id,
|
|
187
|
+
status: "failed",
|
|
188
|
+
provider_run_id: null,
|
|
189
|
+
model_ref: model,
|
|
190
|
+
usage: null,
|
|
191
|
+
output: null,
|
|
192
|
+
error_code: isRetryable ? "retryable_error" : "execution_error",
|
|
193
|
+
error_message: err instanceof Error ? err.message : String(err),
|
|
194
|
+
};
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
OpenClawPluginApi,
|
|
3
|
-
OpenClawPluginServiceContext,
|
|
4
|
-
} from "openclaw/plugin-sdk/core";
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core";
|
|
5
2
|
import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
|
|
6
3
|
import { randomUUID } from "node:crypto";
|
|
7
4
|
import { StageWhisperClient } from "./client.js";
|
|
8
5
|
import type { TaskPayload } from "./client.js";
|
|
9
6
|
import type { StageWhisperAccount } from "./channel.js";
|
|
10
7
|
import { resolveAccount } from "./channel.js";
|
|
8
|
+
import { createHealthTracker } from "./health.js";
|
|
9
|
+
import { executeReasoningJob, probeOpenResponses, type ReasoningJobEnvelope } from "./reasoning.js";
|
|
11
10
|
|
|
12
11
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
13
12
|
const RECONNECT_BASE_MS = 1_000;
|
|
@@ -30,6 +29,22 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
30
29
|
|
|
31
30
|
let abortController: AbortController | null = null;
|
|
32
31
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
32
|
+
const health = createHealthTracker(null);
|
|
33
|
+
|
|
34
|
+
const completedReasoningJobs = new Map<string, number>();
|
|
35
|
+
const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
36
|
+
const COMPLETED_JOB_MAX_SIZE = 5000;
|
|
37
|
+
const processingReasoningJobs = new Set<string>();
|
|
38
|
+
|
|
39
|
+
function evictStaleCompletedJobs(): void {
|
|
40
|
+
const cutoff = Date.now() - COMPLETED_JOB_TTL_MS;
|
|
41
|
+
for (const [jobId, completedAt] of completedReasoningJobs) {
|
|
42
|
+
if (completedAt < cutoff) completedReasoningJobs.delete(jobId);
|
|
43
|
+
}
|
|
44
|
+
while (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
|
|
45
|
+
completedReasoningJobs.delete(completedReasoningJobs.keys().next().value!);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
|
|
34
49
|
function resolveServiceAccount(): StageWhisperAccount {
|
|
35
50
|
try {
|
|
@@ -90,9 +105,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
90
105
|
await client.updateTaskStatus(task.id, status);
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
function extractContentFromMessage(
|
|
94
|
-
msg: Record<string, unknown>,
|
|
95
|
-
): string | null {
|
|
108
|
+
function extractContentFromMessage(msg: Record<string, unknown>): string | null {
|
|
96
109
|
const content = msg["content"];
|
|
97
110
|
if (typeof content === "string") return content;
|
|
98
111
|
|
|
@@ -111,9 +124,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
111
124
|
return null;
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
function extractAssistantReply(
|
|
115
|
-
messages: unknown[],
|
|
116
|
-
): string | null {
|
|
127
|
+
function extractAssistantReply(messages: unknown[]): string | null {
|
|
117
128
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
118
129
|
const msg = messages[i] as Record<string, unknown> | undefined;
|
|
119
130
|
if (!msg) continue;
|
|
@@ -176,14 +187,20 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
176
187
|
return null;
|
|
177
188
|
}
|
|
178
189
|
|
|
179
|
-
async function handleNormalTask(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
async function handleNormalTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
|
|
191
|
+
let deliveredMarked = false;
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
193
|
+
try {
|
|
194
|
+
await updateStatus(client, task, "delivered");
|
|
195
|
+
deliveredMarked = true;
|
|
196
|
+
break;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
api.logger.warn(`Failed to mark task as delivered (attempt ${attempt + 1}): ${err}`);
|
|
199
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 1000));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!deliveredMarked) {
|
|
203
|
+
api.logger.warn(`Skipping task ${task.id} — could not mark delivered after retries`);
|
|
187
204
|
return;
|
|
188
205
|
}
|
|
189
206
|
|
|
@@ -199,12 +216,10 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
199
216
|
sessionKey,
|
|
200
217
|
message: messageContent,
|
|
201
218
|
deliver: true,
|
|
202
|
-
idempotencyKey:
|
|
219
|
+
idempotencyKey: `sw-task-${task.id}`,
|
|
203
220
|
});
|
|
204
221
|
|
|
205
|
-
api.logger.info(
|
|
206
|
-
`Task ${task.id} dispatched to agent session (runId: ${result.runId})`,
|
|
207
|
-
);
|
|
222
|
+
api.logger.info(`Task ${task.id} dispatched to agent session (runId: ${result.runId})`);
|
|
208
223
|
|
|
209
224
|
try {
|
|
210
225
|
await updateStatus(client, task, "running");
|
|
@@ -225,9 +240,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
225
240
|
}
|
|
226
241
|
await updateStatus(client, task, "completed").catch(() => {});
|
|
227
242
|
} else {
|
|
228
|
-
api.logger.error(
|
|
229
|
-
`Agent run failed for task ${task.id}: ${waitResult.error}`,
|
|
230
|
-
);
|
|
243
|
+
api.logger.error(`Agent run failed for task ${task.id}: ${waitResult.error}`);
|
|
231
244
|
await updateStatus(client, task, "failed").catch(() => {});
|
|
232
245
|
}
|
|
233
246
|
})
|
|
@@ -237,10 +250,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
237
250
|
});
|
|
238
251
|
}
|
|
239
252
|
|
|
240
|
-
async function handleTestTask(
|
|
241
|
-
task: TaskPayload,
|
|
242
|
-
client: StageWhisperClient,
|
|
243
|
-
): Promise<void> {
|
|
253
|
+
async function handleTestTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
|
|
244
254
|
const messageContent = buildTaskMessage(task);
|
|
245
255
|
const sessionKey = buildAgentSessionKey({
|
|
246
256
|
agentId: "default",
|
|
@@ -255,9 +265,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
255
265
|
idempotencyKey: randomUUID(),
|
|
256
266
|
});
|
|
257
267
|
|
|
258
|
-
api.logger.info(
|
|
259
|
-
`Test task ${task.id} dispatched (runId: ${result.runId})`,
|
|
260
|
-
);
|
|
268
|
+
api.logger.info(`Test task ${task.id} dispatched (runId: ${result.runId})`);
|
|
261
269
|
|
|
262
270
|
const waitResult = await api.runtime.subagent.waitForRun({
|
|
263
271
|
runId: result.runId,
|
|
@@ -274,16 +282,11 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
274
282
|
await client.testReply(task.id, "(no reply extracted)");
|
|
275
283
|
}
|
|
276
284
|
} else {
|
|
277
|
-
api.logger.error(
|
|
278
|
-
`Agent run failed for test task ${task.id}: ${waitResult.error}`,
|
|
279
|
-
);
|
|
285
|
+
api.logger.error(`Agent run failed for test task ${task.id}: ${waitResult.error}`);
|
|
280
286
|
}
|
|
281
287
|
}
|
|
282
288
|
|
|
283
|
-
async function handleTask(
|
|
284
|
-
task: TaskPayload,
|
|
285
|
-
client: StageWhisperClient,
|
|
286
|
-
): Promise<void> {
|
|
289
|
+
async function handleTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
|
|
287
290
|
api.logger.info(`Received task: ${task.title} (${task.id})`);
|
|
288
291
|
|
|
289
292
|
try {
|
|
@@ -298,6 +301,76 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
298
301
|
}
|
|
299
302
|
}
|
|
300
303
|
|
|
304
|
+
async function handleReasoningJob(
|
|
305
|
+
job: ReasoningJobEnvelope,
|
|
306
|
+
client: StageWhisperClient,
|
|
307
|
+
): Promise<void> {
|
|
308
|
+
const correlationId = job.correlation_id;
|
|
309
|
+
api.logger.info(`Received reasoning job: ${job.job_id} (purpose: ${job.purpose}, correlation: ${correlationId ?? "none"})`);
|
|
310
|
+
|
|
311
|
+
if (completedReasoningJobs.has(job.job_id)) {
|
|
312
|
+
api.logger.info(`Skipping completed reasoning job: ${job.job_id}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (processingReasoningJobs.has(job.job_id)) {
|
|
316
|
+
api.logger.info(`Skipping in-flight reasoning job: ${job.job_id}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
processingReasoningJobs.add(job.job_id);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const displayModel = health.get().displayModel ?? null;
|
|
323
|
+
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
result = await executeReasoningJob(api, job, displayModel);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
329
|
+
health.recordFailure(errMsg);
|
|
330
|
+
api.logger.error(`Reasoning job ${job.job_id} failed: ${errMsg}`);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await client.postReasoningResult(job.job_id, {
|
|
334
|
+
job_id: job.job_id,
|
|
335
|
+
status: "failed",
|
|
336
|
+
provider_run_id: null,
|
|
337
|
+
model_ref: displayModel,
|
|
338
|
+
usage: null,
|
|
339
|
+
output: null,
|
|
340
|
+
error_code: "execution_error",
|
|
341
|
+
error_message: errMsg,
|
|
342
|
+
}, correlationId);
|
|
343
|
+
} catch (postErr) {
|
|
344
|
+
api.logger.error(`Failed to report reasoning failure: ${postErr}`);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (result.status === "completed") {
|
|
350
|
+
health.recordSuccess();
|
|
351
|
+
} else {
|
|
352
|
+
health.recordFailure(result.error_message ?? `reasoning ${result.status}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (result.model_ref && result.model_ref !== displayModel) {
|
|
356
|
+
health.setModel(result.model_ref);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await client.postReasoningResult(job.job_id, result as unknown as Record<string, unknown>, correlationId);
|
|
361
|
+
completedReasoningJobs.set(job.job_id, Date.now());
|
|
362
|
+
if (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
|
|
363
|
+
evictStaleCompletedJobs();
|
|
364
|
+
}
|
|
365
|
+
api.logger.info(`Reasoning job ${job.job_id} completed (status: ${result.status})`);
|
|
366
|
+
} catch (postErr) {
|
|
367
|
+
api.logger.error(`Failed to post reasoning result for ${job.job_id}: ${postErr}`);
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
processingReasoningJobs.delete(job.job_id);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
301
374
|
async function connectStream(account: StageWhisperAccount): Promise<void> {
|
|
302
375
|
const client = new StageWhisperClient(
|
|
303
376
|
account.apiBaseUrl,
|
|
@@ -324,6 +397,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
324
397
|
|
|
325
398
|
state.connected = true;
|
|
326
399
|
state.reconnectAttempts = 0;
|
|
400
|
+
health.setConnected();
|
|
327
401
|
api.logger.info("Connected to StageWhisper relay stream");
|
|
328
402
|
|
|
329
403
|
const reader = res.body.getReader();
|
|
@@ -345,24 +419,29 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
345
419
|
if (!jsonStr) continue;
|
|
346
420
|
|
|
347
421
|
try {
|
|
348
|
-
const
|
|
349
|
-
|
|
422
|
+
const envelope = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
423
|
+
if (envelope.event_type === "reasoning_job") {
|
|
424
|
+
handleReasoningJob(envelope as unknown as ReasoningJobEnvelope, client).catch((err) =>
|
|
425
|
+
api.logger.error(`Error handling reasoning job: ${err}`),
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
const task = envelope as unknown as TaskPayload;
|
|
429
|
+
await handleTask(task, client);
|
|
430
|
+
}
|
|
350
431
|
} catch (err) {
|
|
351
|
-
api.logger.error(`Error processing
|
|
432
|
+
api.logger.error(`Error processing event from stream: ${err}`);
|
|
352
433
|
}
|
|
353
434
|
}
|
|
354
435
|
}
|
|
355
436
|
} finally {
|
|
356
437
|
reader.releaseLock();
|
|
357
438
|
state.connected = false;
|
|
439
|
+
health.setDisconnected();
|
|
358
440
|
}
|
|
359
441
|
}
|
|
360
442
|
|
|
361
443
|
function backoffMs(): number {
|
|
362
|
-
const ms = Math.min(
|
|
363
|
-
RECONNECT_BASE_MS * Math.pow(2, state.reconnectAttempts),
|
|
364
|
-
RECONNECT_MAX_MS,
|
|
365
|
-
);
|
|
444
|
+
const ms = Math.min(RECONNECT_BASE_MS * Math.pow(2, state.reconnectAttempts), RECONNECT_MAX_MS);
|
|
366
445
|
return ms + Math.random() * 1000;
|
|
367
446
|
}
|
|
368
447
|
|
|
@@ -372,6 +451,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
372
451
|
await connectStream(account);
|
|
373
452
|
} catch (err) {
|
|
374
453
|
if (!state.running) break;
|
|
454
|
+
health.setDisconnected();
|
|
375
455
|
state.reconnectAttempts++;
|
|
376
456
|
const delay = backoffMs();
|
|
377
457
|
api.logger.warn(
|
|
@@ -391,7 +471,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
391
471
|
|
|
392
472
|
heartbeatTimer = setInterval(async () => {
|
|
393
473
|
try {
|
|
394
|
-
await client.heartbeat();
|
|
474
|
+
await client.heartbeat(health.toHeartbeatPayload());
|
|
395
475
|
state.lastHeartbeat = new Date();
|
|
396
476
|
} catch (err) {
|
|
397
477
|
api.logger.warn(`Heartbeat failed: ${err}`);
|
|
@@ -404,11 +484,7 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
404
484
|
|
|
405
485
|
async start(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
|
406
486
|
const account = resolveServiceAccount();
|
|
407
|
-
if (
|
|
408
|
-
!account.apiBaseUrl ||
|
|
409
|
-
!account.integrationId ||
|
|
410
|
-
!account.relayToken
|
|
411
|
-
) {
|
|
487
|
+
if (!account.apiBaseUrl || !account.integrationId || !account.relayToken) {
|
|
412
488
|
api.logger.info(
|
|
413
489
|
"StageWhisper not configured — skipping relay service. Pair first with: openclaw stagewhisper pair",
|
|
414
490
|
);
|
|
@@ -420,13 +496,23 @@ export function createRelayService(api: OpenClawPluginApi) {
|
|
|
420
496
|
);
|
|
421
497
|
|
|
422
498
|
state.running = true;
|
|
499
|
+
|
|
500
|
+
api.logger.info("Probing /v1/responses to verify local AI connectivity...");
|
|
501
|
+
const probe = await probeOpenResponses(api);
|
|
502
|
+
if (probe.ok) {
|
|
503
|
+
health.recordSuccess();
|
|
504
|
+
if (probe.model) health.setModel(probe.model);
|
|
505
|
+
api.logger.info(`Local AI verified — model: ${probe.model ?? "unknown"}`);
|
|
506
|
+
} else {
|
|
507
|
+
health.recordFailure(probe.error ?? "probe_failed");
|
|
508
|
+
api.logger.warn(`Local AI probe failed: ${probe.error} — relay starts as unverified`);
|
|
509
|
+
}
|
|
510
|
+
|
|
423
511
|
startHeartbeat(account);
|
|
424
512
|
runLoop(account).catch((err) => {
|
|
425
513
|
api.logger.error(`Relay service crashed: ${err}`);
|
|
426
514
|
});
|
|
427
|
-
api.logger.info(
|
|
428
|
-
`StageWhisper relay service started for ${account.label}`,
|
|
429
|
-
);
|
|
515
|
+
api.logger.info(`StageWhisper relay service started for ${account.label}`);
|
|
430
516
|
},
|
|
431
517
|
|
|
432
518
|
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|