@stagewhisper/stagewhisper 0.40.0 → 0.43.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.
@@ -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
- task: TaskPayload,
181
- client: StageWhisperClient,
182
- ): Promise<void> {
183
- try {
184
- await updateStatus(client, task, "delivered");
185
- } catch (err) {
186
- api.logger.warn(`Failed to mark task as delivered, skipping to prevent duplicates: ${err}`);
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: randomUUID(),
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 task = JSON.parse(jsonStr) as TaskPayload;
349
- await handleTask(task, client);
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 task from stream: ${err}`);
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> {