bereach-openclaw 1.5.6 → 1.5.8
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/node_modules/@bereach/tools/src/cache-types.ts +2 -0
- package/node_modules/@bereach/tools/src/definitions.ts +10 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/connector.ts +275 -29
- package/src/connector/manager.ts +168 -15
- package/src/hooks/cache.ts +1 -0
- package/src/hooks/context.ts +15 -0
- package/src/hooks/enforcement.ts +8 -2
- package/src/hooks/lifecycle.ts +79 -0
- package/src/index.ts +6 -6
|
@@ -102,4 +102,6 @@ export interface CacheStore {
|
|
|
102
102
|
sessionMeta: SessionMeta | null;
|
|
103
103
|
onboardingState: OnboardingState | null;
|
|
104
104
|
recentEvents: RecentEvent[];
|
|
105
|
+
/** LLM circuit breaker status: "auth" | "billing" | "error" when tripped, null when OK */
|
|
106
|
+
llmStatus?: string | null;
|
|
105
107
|
}
|
|
@@ -1289,6 +1289,11 @@ export const definitions: ToolDefinition[] = [
|
|
|
1289
1289
|
dailyActionLimit: { type: "integer", minimum: 1, description: "Max actions per day." },
|
|
1290
1290
|
dailyTarget: { type: "integer", minimum: 1, description: "Daily target goal (e.g. 50 leads/day). Set null to remove." },
|
|
1291
1291
|
totalTarget: { type: "integer", minimum: 1, description: "Total campaign goal (e.g. 500 leads total). Auto-completes when reached. Set null to remove." },
|
|
1292
|
+
handoverMode: {
|
|
1293
|
+
type: "string",
|
|
1294
|
+
enum: ["on_reply", "on_goal", "manual"],
|
|
1295
|
+
description: "When agent hands over conversation to human. on_reply (default): stop autonomous outreach when contact replies. on_goal: keep replying until meeting booked or converted. manual: never auto-stop.",
|
|
1296
|
+
},
|
|
1292
1297
|
taskOverrides: {
|
|
1293
1298
|
type: "object",
|
|
1294
1299
|
description: "Per-task-type config overrides. Keys are task types (e.g. 'lead-gen-visit', 'lead-gen-qualify'). Values are config objects with: maxRunsPerDay, minIntervalMinutes, defaultBatchSize, maxCreditsPerRun.",
|
|
@@ -1302,6 +1307,11 @@ export const definitions: ToolDefinition[] = [
|
|
|
1302
1307
|
},
|
|
1303
1308
|
},
|
|
1304
1309
|
},
|
|
1310
|
+
disabledTaskTypes: {
|
|
1311
|
+
type: "array",
|
|
1312
|
+
items: { type: "string" },
|
|
1313
|
+
description: "Task types to stop dispatching for this campaign (e.g. ['lead-gen-discovery']). Use when strategically pausing a task type (low conversion, enough leads). Set to [] to re-enable all.",
|
|
1314
|
+
},
|
|
1305
1315
|
},
|
|
1306
1316
|
},
|
|
1307
1317
|
},
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -59,10 +59,27 @@ type TaskResult = {
|
|
|
59
59
|
invitationsAccepted?: number;
|
|
60
60
|
error?: string;
|
|
61
61
|
reason?: string;
|
|
62
|
+
// Token usage tracking
|
|
63
|
+
tokenUsage?: {
|
|
64
|
+
inputTokens: number;
|
|
65
|
+
outputTokens: number;
|
|
66
|
+
cacheReadTokens?: number;
|
|
67
|
+
cacheWriteTokens?: number;
|
|
68
|
+
};
|
|
69
|
+
estimatedCostUsd?: number;
|
|
70
|
+
model?: string;
|
|
62
71
|
};
|
|
63
72
|
|
|
64
73
|
const MIN_POLL_MS = 5000;
|
|
65
74
|
|
|
75
|
+
/** Thrown when API returns 401/403 - credentials are invalid, retrying won't help */
|
|
76
|
+
export class AuthError extends Error {
|
|
77
|
+
constructor(public readonly statusCode: number, body: string) {
|
|
78
|
+
super(`Auth failed (HTTP ${statusCode}): ${body.slice(0, 200)}`);
|
|
79
|
+
this.name = "AuthError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
// ---------------------------------------------------------------------------
|
|
67
84
|
// TaskResult extraction from OpenClaw agent output
|
|
68
85
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +140,98 @@ function extractTaskResultFromWrapper(output: Record<string, unknown>): TaskResu
|
|
|
123
140
|
return null;
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Extract token usage from the OpenClaw --json wrapper's meta field.
|
|
145
|
+
* Returns null if no usage data is available.
|
|
146
|
+
*/
|
|
147
|
+
function extractTokenUsageFromWrapper(
|
|
148
|
+
output: Record<string, unknown>,
|
|
149
|
+
): { usage: TaskResult["tokenUsage"]; model?: string } | null {
|
|
150
|
+
const result = output?.result as Record<string, unknown> | undefined;
|
|
151
|
+
const meta = (result?.meta ?? output?.meta ?? {}) as Record<string, unknown>;
|
|
152
|
+
// OpenClaw 2026.4+ puts token data in meta.agentMeta.lastCallUsage
|
|
153
|
+
const agentMeta = (meta.agentMeta ?? {}) as Record<string, unknown>;
|
|
154
|
+
const lastCall = (agentMeta.lastCallUsage ?? {}) as Record<string, number>;
|
|
155
|
+
const costData = (meta.cost ?? {}) as Record<string, number>;
|
|
156
|
+
const usageData = (meta.usage ?? {}) as Record<string, number>;
|
|
157
|
+
|
|
158
|
+
const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
|
|
159
|
+
const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
|
|
160
|
+
|
|
161
|
+
if (inputTokens === 0 && outputTokens === 0) return null;
|
|
162
|
+
|
|
163
|
+
const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
|
|
164
|
+
const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
usage: {
|
|
168
|
+
inputTokens,
|
|
169
|
+
outputTokens,
|
|
170
|
+
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
171
|
+
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
172
|
+
},
|
|
173
|
+
model: agentMeta.model as string | undefined,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Model pricing per 1M tokens */
|
|
178
|
+
const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
|
|
179
|
+
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
|
|
180
|
+
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
181
|
+
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
|
|
182
|
+
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function estimateTaskCost(
|
|
186
|
+
usage: NonNullable<TaskResult["tokenUsage"]>,
|
|
187
|
+
modelSlug?: string | null,
|
|
188
|
+
): number {
|
|
189
|
+
const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
|
|
190
|
+
const p = MODEL_PRICING[key];
|
|
191
|
+
const cached = usage.cacheReadTokens ?? 0;
|
|
192
|
+
const uncached = Math.max(0, usage.inputTokens - cached);
|
|
193
|
+
return (uncached * p.input + cached * p.cacheRead + usage.outputTokens * p.output) / 1_000_000;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Enrich a TaskResult with token usage from the OpenClaw wrapper output */
|
|
197
|
+
function enrichResultWithTokens(
|
|
198
|
+
result: TaskResult,
|
|
199
|
+
output: Record<string, unknown>,
|
|
200
|
+
modelSlug?: string | null,
|
|
201
|
+
): void {
|
|
202
|
+
if (result.tokenUsage) return; // already enriched (e.g. by lifecycle hook)
|
|
203
|
+
const extracted = extractTokenUsageFromWrapper(output);
|
|
204
|
+
if (!extracted?.usage) return;
|
|
205
|
+
const effectiveModel = extracted.model ?? modelSlug ?? undefined;
|
|
206
|
+
result.tokenUsage = extracted.usage;
|
|
207
|
+
result.model = effectiveModel;
|
|
208
|
+
result.estimatedCostUsd = estimateTaskCost(extracted.usage, effectiveModel);
|
|
209
|
+
console.log(
|
|
210
|
+
`[connector] Token usage: input=${extracted.usage.inputTokens} output=${extracted.usage.outputTokens}` +
|
|
211
|
+
` cacheRead=${extracted.usage.cacheReadTokens ?? 0} cost=$${result.estimatedCostUsd.toFixed(4)}` +
|
|
212
|
+
` model=${effectiveModel ?? "unknown"}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// LLM provider error detection — mirrors apps/web/src/lib/errors/llm-errors.ts
|
|
217
|
+
// but self-contained since plugin can't import from apps/web.
|
|
218
|
+
const LLM_ERROR_PATTERNS = [
|
|
219
|
+
/authentication_error/i, /invalid.*api.?key/i, /permission_error/i,
|
|
220
|
+
/401.*unauthorized/i, /access_denied.*api/i, /unauthorized.*invalid/i,
|
|
221
|
+
/out of (extra )?usage/i, /insufficient.quota/i, /exceeded.*current quota/i,
|
|
222
|
+
/RESOURCE_EXHAUSTED/i, /credit balance.*too low/i, /exceeded.*spending.*limit/i,
|
|
223
|
+
/billing.*hard.*limit/i, /payment.*required/i,
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
/** Scan text for LLM provider errors (auth, billing). Returns the matched text or null. */
|
|
227
|
+
function detectLlmError(text: string): string | null {
|
|
228
|
+
for (const pattern of LLM_ERROR_PATTERNS) {
|
|
229
|
+
const match = text.match(pattern);
|
|
230
|
+
if (match) return match[0];
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
126
235
|
/** Stderr patterns that are noise (module warnings, not actual errors) */
|
|
127
236
|
const HARMLESS_STDERR_RE = [
|
|
128
237
|
/Cannot find module '@aws-sdk\/client-bedrock'/,
|
|
@@ -167,6 +276,9 @@ async function httpPost(url: string, body: unknown, headers: Record<string, stri
|
|
|
167
276
|
});
|
|
168
277
|
if (!res.ok) {
|
|
169
278
|
const text = await res.text().catch(() => "");
|
|
279
|
+
if (res.status === 401 || res.status === 403) {
|
|
280
|
+
throw new AuthError(res.status, text);
|
|
281
|
+
}
|
|
170
282
|
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
171
283
|
}
|
|
172
284
|
return res.json();
|
|
@@ -337,8 +449,11 @@ async function executeViaWebhook(
|
|
|
337
449
|
|
|
338
450
|
// Webhook completed - lifecycle hook has already posted the result.
|
|
339
451
|
// Return a success indicator so the connector doesn't double-report.
|
|
452
|
+
// Also extract token usage for the safety-net submission.
|
|
453
|
+
const webhookResult = (output.result as TaskResult) ?? { success: true };
|
|
454
|
+
enrichResultWithTokens(webhookResult, output, task.model);
|
|
340
455
|
return {
|
|
341
|
-
result:
|
|
456
|
+
result: webhookResult,
|
|
342
457
|
error: null,
|
|
343
458
|
};
|
|
344
459
|
} catch (err) {
|
|
@@ -419,20 +534,25 @@ async function executeViaExecFile(
|
|
|
419
534
|
// B17 fix: extract it instead of returning the raw wrapper.
|
|
420
535
|
const extracted = extractTaskResultFromWrapper(output);
|
|
421
536
|
if (extracted) {
|
|
537
|
+
enrichResultWithTokens(extracted, output, task.model);
|
|
422
538
|
return { result: extracted, error: null };
|
|
423
539
|
}
|
|
424
540
|
|
|
425
541
|
// Fallback: if output itself looks like a flat TaskResult
|
|
426
542
|
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
427
|
-
|
|
543
|
+
const flat = output as unknown as TaskResult;
|
|
544
|
+
enrichResultWithTokens(flat, output, task.model);
|
|
545
|
+
return { result: flat, error: null };
|
|
428
546
|
}
|
|
429
547
|
|
|
430
|
-
// Last resort: lifecycle hook may have already posted the real result
|
|
431
|
-
|
|
548
|
+
// Last resort: lifecycle hook may have already posted the real result.
|
|
549
|
+
// Don't blindly claim success — if we can't parse a result, something went wrong.
|
|
550
|
+
// The lifecycle hook will report the real result if it ran successfully.
|
|
551
|
+
return { result: { success: false, reason: "no_parseable_result" } as unknown as TaskResult, error: null };
|
|
432
552
|
} catch {
|
|
433
|
-
// Non-JSON output -
|
|
553
|
+
// Non-JSON output - can't determine success without structured data.
|
|
434
554
|
return {
|
|
435
|
-
result: { success:
|
|
555
|
+
result: { success: false, reason: "non_json_output" } as unknown as TaskResult,
|
|
436
556
|
error: null,
|
|
437
557
|
};
|
|
438
558
|
}
|
|
@@ -455,11 +575,14 @@ async function executeViaExecFile(
|
|
|
455
575
|
// B17 fix: extract TaskResult from OpenClaw wrapper (same as happy path)
|
|
456
576
|
const extracted = extractTaskResultFromWrapper(output);
|
|
457
577
|
if (extracted) {
|
|
578
|
+
enrichResultWithTokens(extracted, output, task.model);
|
|
458
579
|
return { result: extracted, error: null };
|
|
459
580
|
}
|
|
460
581
|
// Accept flat { success: true } format
|
|
461
582
|
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
462
|
-
|
|
583
|
+
const flat = output as unknown as TaskResult;
|
|
584
|
+
enrichResultWithTokens(flat, output, task.model);
|
|
585
|
+
return { result: flat, error: null };
|
|
463
586
|
}
|
|
464
587
|
} catch {
|
|
465
588
|
// Not JSON
|
|
@@ -468,8 +591,20 @@ async function executeViaExecFile(
|
|
|
468
591
|
|
|
469
592
|
// Filter known-harmless stderr noise (e.g. @aws-sdk/client-bedrock module warnings)
|
|
470
593
|
const filtered = filterStderr(stderr);
|
|
471
|
-
|
|
472
|
-
|
|
594
|
+
// When execFile fails, err.message is "Command failed: openclaw agent ..." which is useless.
|
|
595
|
+
// Prefer stderr content, then stdout snippet, then exit code - anything more useful than the command string.
|
|
596
|
+
let errorMsg: string;
|
|
597
|
+
if (filtered) {
|
|
598
|
+
errorMsg = filtered;
|
|
599
|
+
} else if (execErr.message && !execErr.message.startsWith("Command failed:")) {
|
|
600
|
+
errorMsg = execErr.message;
|
|
601
|
+
} else {
|
|
602
|
+
// No stderr, message is just the command - build a useful error from what we have
|
|
603
|
+
const exitCode = execErr.code ?? "unknown";
|
|
604
|
+
const stdoutSnippet = stdout ? ` stdout=${stdout.slice(0, 200)}` : "";
|
|
605
|
+
errorMsg = `Agent process exited with code ${exitCode}${stdoutSnippet}`;
|
|
606
|
+
}
|
|
607
|
+
return { result: null, error: errorMsg.slice(0, 500) };
|
|
473
608
|
}
|
|
474
609
|
}
|
|
475
610
|
|
|
@@ -513,7 +648,7 @@ async function updateTaskStatus(
|
|
|
513
648
|
}
|
|
514
649
|
|
|
515
650
|
export type ConnectorStatus = {
|
|
516
|
-
state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled";
|
|
651
|
+
state: "stopped" | "starting" | "running" | "stopping" | "error" | "disabled" | "dormant";
|
|
517
652
|
uptime: number;
|
|
518
653
|
lastHeartbeat: number;
|
|
519
654
|
currentTaskId: string | undefined;
|
|
@@ -524,16 +659,26 @@ export type ConnectorStatus = {
|
|
|
524
659
|
|
|
525
660
|
export async function runConnectorLoop(
|
|
526
661
|
config: ConnectorConfig,
|
|
527
|
-
options?: { signal?: AbortSignal },
|
|
662
|
+
options?: { signal?: AbortSignal; onHeartbeat?: () => void },
|
|
528
663
|
): Promise<void> {
|
|
529
664
|
let pollInterval = config.pollIntervalMs;
|
|
530
665
|
let currentTaskId: string | undefined;
|
|
531
666
|
let consecutiveErrors = 0;
|
|
667
|
+
let consecutiveAuthErrors = 0;
|
|
532
668
|
let totalTasksExecuted = 0;
|
|
533
669
|
let webhookAvailable = false;
|
|
534
670
|
|
|
535
671
|
const signal = options?.signal;
|
|
536
672
|
|
|
673
|
+
// Internal AbortController: links heartbeat + poll loops so either can abort both.
|
|
674
|
+
// When heartbeat detects auth failure → aborts internal → poll loop exits too.
|
|
675
|
+
// External signal (from ConnectorManager) is forwarded to internal.
|
|
676
|
+
const internalAbort = new AbortController();
|
|
677
|
+
const internalSignal = internalAbort.signal;
|
|
678
|
+
if (signal) {
|
|
679
|
+
signal.addEventListener("abort", () => internalAbort.abort(signal.reason), { once: true });
|
|
680
|
+
}
|
|
681
|
+
|
|
537
682
|
console.log(`[connector] Starting connector loop`);
|
|
538
683
|
console.log(`[connector] API: ${config.apiUrl}`);
|
|
539
684
|
console.log(`[connector] Auth: ${config.apiKey ? "API key (Bearer)" : "connector token (legacy)"}`);
|
|
@@ -548,34 +693,105 @@ export async function runConnectorLoop(
|
|
|
548
693
|
console.log(`[connector] OpenClaw: ${config.openclawUrl}`);
|
|
549
694
|
}
|
|
550
695
|
|
|
696
|
+
// Proactive LLM key validation on startup: detect bad API keys before wasting tasks.
|
|
697
|
+
// Makes a lightweight check against the LLM provider. Non-fatal — just warns.
|
|
698
|
+
const llmModel = readEnv("OPENCLAW_DEFAULT_MODEL") || readEnv("BEREACH_TASK_MODEL");
|
|
699
|
+
if (llmModel) {
|
|
700
|
+
const isAnthropic = llmModel.includes("anthropic") || llmModel.includes("claude");
|
|
701
|
+
const isGemini = llmModel.includes("gemini") || llmModel.includes("google");
|
|
702
|
+
const anthropicKey = readEnv("ANTHROPIC_API_KEY");
|
|
703
|
+
const geminiKey = readEnv("GEMINI_API_KEY") || readEnv("GOOGLE_API_KEY");
|
|
704
|
+
|
|
705
|
+
if (isAnthropic && anthropicKey) {
|
|
706
|
+
try {
|
|
707
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers: {
|
|
710
|
+
"x-api-key": anthropicKey,
|
|
711
|
+
"anthropic-version": "2023-06-01",
|
|
712
|
+
"content-type": "application/json",
|
|
713
|
+
},
|
|
714
|
+
body: JSON.stringify({ model: "claude-haiku-4-5-20241022", max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
|
|
715
|
+
signal: AbortSignal.timeout(10000),
|
|
716
|
+
});
|
|
717
|
+
if (res.status === 401 || res.status === 403) {
|
|
718
|
+
const body = await res.text().catch(() => "");
|
|
719
|
+
console.error(`[connector] ANTHROPIC_API_KEY is INVALID (HTTP ${res.status}): ${body.slice(0, 200)}`);
|
|
720
|
+
console.error("[connector] Tasks will fail until the API key is updated. Reporting to BeReach API...");
|
|
721
|
+
// Report the error to BeReach API so the circuit breaker can trip
|
|
722
|
+
try {
|
|
723
|
+
await fetch(`${config.apiUrl}/connectors/heartbeat`, {
|
|
724
|
+
method: "POST",
|
|
725
|
+
headers: buildHeaders(config),
|
|
726
|
+
body: JSON.stringify({ llmKeyError: { provider: "anthropic", status: res.status, error: body.slice(0, 200) } }),
|
|
727
|
+
signal: AbortSignal.timeout(5000),
|
|
728
|
+
});
|
|
729
|
+
} catch { /* best effort */ }
|
|
730
|
+
} else {
|
|
731
|
+
console.log(`[connector] Anthropic API key validated (HTTP ${res.status})`);
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
console.warn(`[connector] Anthropic key validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
735
|
+
}
|
|
736
|
+
} else if (isGemini && geminiKey) {
|
|
737
|
+
try {
|
|
738
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${geminiKey}`, {
|
|
739
|
+
signal: AbortSignal.timeout(10000),
|
|
740
|
+
});
|
|
741
|
+
if (res.status === 401 || res.status === 403) {
|
|
742
|
+
console.error(`[connector] GEMINI_API_KEY is INVALID (HTTP ${res.status})`);
|
|
743
|
+
} else {
|
|
744
|
+
console.log(`[connector] Gemini API key validated (HTTP ${res.status})`);
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
console.warn(`[connector] Gemini key validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
551
752
|
// Heartbeat runs independently - does NOT touch pollInterval or consecutiveErrors.
|
|
552
753
|
// Otherwise it would neutralize exponential backoff during error recovery.
|
|
754
|
+
// Uses internalAbort so auth failures kill both heartbeat AND poll loops.
|
|
553
755
|
let consecutiveHeartbeatFailures = 0;
|
|
756
|
+
let consecutiveHeartbeatAuthErrors = 0;
|
|
554
757
|
const heartbeatLoop = async () => {
|
|
555
|
-
|
|
758
|
+
let heartbeatIntervalMs = 30_000;
|
|
759
|
+
while (!internalSignal.aborted) {
|
|
556
760
|
try {
|
|
557
761
|
await heartbeat(config, currentTaskId);
|
|
558
762
|
consecutiveHeartbeatFailures = 0;
|
|
763
|
+
consecutiveHeartbeatAuthErrors = 0;
|
|
764
|
+
heartbeatIntervalMs = 30_000;
|
|
765
|
+
options?.onHeartbeat?.();
|
|
559
766
|
} catch (err) {
|
|
560
767
|
consecutiveHeartbeatFailures++;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
console.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
768
|
+
if (err instanceof AuthError) {
|
|
769
|
+
consecutiveHeartbeatAuthErrors++;
|
|
770
|
+
console.error(`[connector] Heartbeat auth failed (${consecutiveHeartbeatAuthErrors}/3): ${err.message}`);
|
|
771
|
+
if (consecutiveHeartbeatAuthErrors >= 3) {
|
|
772
|
+
console.error("[connector] FATAL: 3 consecutive auth failures in heartbeat, API key is invalid");
|
|
773
|
+
internalAbort.abort(err);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
} else {
|
|
777
|
+
consecutiveHeartbeatAuthErrors = 0;
|
|
778
|
+
console.error(`[connector] Heartbeat failed (${consecutiveHeartbeatFailures}):`, err instanceof Error ? err.message : String(err));
|
|
779
|
+
if (consecutiveHeartbeatFailures === 5) {
|
|
780
|
+
console.warn("[connector] WARNING: 5 consecutive heartbeat failures, API may be unreachable");
|
|
781
|
+
}
|
|
782
|
+
// Exponential backoff: 30s → 60s → 120s → max 5min (never die for transient errors)
|
|
783
|
+
heartbeatIntervalMs = Math.min(heartbeatIntervalMs * 2, 5 * 60 * 1000);
|
|
569
784
|
}
|
|
570
785
|
}
|
|
571
786
|
await new Promise((r) => {
|
|
572
|
-
const timer = setTimeout(r,
|
|
573
|
-
|
|
787
|
+
const timer = setTimeout(r, heartbeatIntervalMs);
|
|
788
|
+
internalSignal.addEventListener("abort", () => { clearTimeout(timer); r(undefined); }, { once: true });
|
|
574
789
|
});
|
|
575
790
|
}
|
|
576
791
|
};
|
|
577
792
|
heartbeatLoop().catch((err) => {
|
|
578
793
|
console.error("[connector] Heartbeat loop crashed:", err instanceof Error ? err.message : String(err));
|
|
794
|
+
if (!internalSignal.aborted) internalAbort.abort(err);
|
|
579
795
|
});
|
|
580
796
|
|
|
581
797
|
// Task execution lock - prevents concurrent task execution.
|
|
@@ -583,11 +799,11 @@ export async function runConnectorLoop(
|
|
|
583
799
|
let isExecuting = false;
|
|
584
800
|
let idlePollCount = 0;
|
|
585
801
|
|
|
586
|
-
while (!
|
|
802
|
+
while (!internalSignal.aborted) {
|
|
587
803
|
try {
|
|
588
804
|
// Skip polling while a task is running - prevents claiming concurrent tasks
|
|
589
805
|
if (isExecuting) {
|
|
590
|
-
await sleep(pollInterval,
|
|
806
|
+
await sleep(pollInterval, internalSignal);
|
|
591
807
|
continue;
|
|
592
808
|
}
|
|
593
809
|
|
|
@@ -664,6 +880,17 @@ export async function runConnectorLoop(
|
|
|
664
880
|
console.warn(`[connector] DIAGNOSTIC: Task ${task.id} failed in <10s with 0 tool calls — likely context injection failure or model error`);
|
|
665
881
|
}
|
|
666
882
|
|
|
883
|
+
// Scan output for LLM provider errors (auth/billing) that the gateway
|
|
884
|
+
// may not have flagged via meta.stopReason. If found, force-fail the task
|
|
885
|
+
// so the circuit breaker can detect it on the API side.
|
|
886
|
+
const allText = [error, result?.error, (result as any)?.reason].filter(Boolean).join(" ");
|
|
887
|
+
const llmError = detectLlmError(allText);
|
|
888
|
+
if (llmError && result && result.success !== false) {
|
|
889
|
+
console.warn(`[connector] LLM provider error detected in task ${task.id}: ${llmError}`);
|
|
890
|
+
result.success = false;
|
|
891
|
+
if (!result.error) result.error = `LLM provider error: ${llmError}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
667
894
|
// Always submit from connector as safety net. In webhook mode the lifecycle
|
|
668
895
|
// hook usually reports first, but if it fails silently (network, crash) the
|
|
669
896
|
// result is lost and the task stays "running" forever. The result endpoint
|
|
@@ -691,10 +918,22 @@ export async function runConnectorLoop(
|
|
|
691
918
|
isExecuting = false;
|
|
692
919
|
currentTaskId = undefined;
|
|
693
920
|
(globalThis as any).__bereachCurrentTaskId = undefined;
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
921
|
+
|
|
922
|
+
if (err instanceof AuthError) {
|
|
923
|
+
consecutiveAuthErrors++;
|
|
924
|
+
console.error(`[connector] Poll auth failed (${consecutiveAuthErrors}/3): ${err.message}`);
|
|
925
|
+
if (consecutiveAuthErrors >= 3) {
|
|
926
|
+
console.error("[connector] FATAL: 3 consecutive auth failures in poll loop, API key is invalid");
|
|
927
|
+
internalAbort.abort(err);
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
consecutiveAuthErrors = 0;
|
|
932
|
+
console.error(
|
|
933
|
+
`[connector] Poll error (${consecutiveErrors}):`,
|
|
934
|
+
err instanceof Error ? err.message : String(err),
|
|
935
|
+
);
|
|
936
|
+
}
|
|
698
937
|
|
|
699
938
|
pollInterval = Math.min(
|
|
700
939
|
config.pollIntervalMs * Math.pow(2, consecutiveErrors),
|
|
@@ -711,10 +950,17 @@ export async function runConnectorLoop(
|
|
|
711
950
|
}
|
|
712
951
|
}
|
|
713
952
|
|
|
714
|
-
await sleep(pollInterval,
|
|
953
|
+
await sleep(pollInterval, internalSignal);
|
|
715
954
|
}
|
|
716
955
|
|
|
717
956
|
console.log("[connector] Loop stopped (abort signal received)");
|
|
957
|
+
|
|
958
|
+
// If internal abort fired (auth failure) but external didn't (not a graceful shutdown),
|
|
959
|
+
// throw so ConnectorManager.handleCrash() triggers with the auth error.
|
|
960
|
+
if (internalSignal.aborted && !signal?.aborted) {
|
|
961
|
+
const reason = internalSignal.reason;
|
|
962
|
+
throw reason instanceof Error ? reason : new Error("Connector loop aborted internally");
|
|
963
|
+
}
|
|
718
964
|
}
|
|
719
965
|
|
|
720
966
|
/** Re-probe webhook availability. Called by ConnectorManager on retry. */
|
package/src/connector/manager.ts
CHANGED
|
@@ -4,39 +4,75 @@
|
|
|
4
4
|
* Auto-starts the polling loop from register() when an API key is present.
|
|
5
5
|
* Handles crash recovery with exponential backoff, graceful shutdown on
|
|
6
6
|
* SIGTERM/SIGINT, and prevents duplicate instances.
|
|
7
|
+
*
|
|
8
|
+
* Key resilience features:
|
|
9
|
+
* - AuthError detection: distinguishes auth failures from transient errors
|
|
10
|
+
* - Dormant mode: after persistent auth failures, enters low-power probe mode
|
|
11
|
+
* instead of burning restarts. Probes every 15 min and auto-recovers.
|
|
12
|
+
* - Config resolver: re-resolves API key on each restart/probe
|
|
13
|
+
* - Health check: detects zombie state (running but heartbeat dead)
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
|
-
import { runConnectorLoop, isWebhookAvailable, type ConnectorConfig, type ConnectorStatus } from "../commands/connector.js";
|
|
16
|
+
import { runConnectorLoop, isWebhookAvailable, AuthError, type ConnectorConfig, type ConnectorStatus } from "../commands/connector.js";
|
|
10
17
|
|
|
11
18
|
const MAX_RESTARTS = 10;
|
|
12
19
|
const INITIAL_RESTART_DELAY_MS = 1_000;
|
|
13
20
|
const MAX_RESTART_DELAY_MS = 30_000;
|
|
21
|
+
const DORMANT_PROBE_INTERVAL_MS = 15 * 60 * 1000; // 15 min
|
|
14
22
|
|
|
15
|
-
export type ConnectorManagerState = "stopped" | "starting" | "running" | "stopping" | "error" | "disabled";
|
|
23
|
+
export type ConnectorManagerState = "stopped" | "starting" | "running" | "stopping" | "error" | "disabled" | "dormant";
|
|
16
24
|
|
|
17
25
|
class ConnectorManager {
|
|
18
26
|
private state: ConnectorManagerState = "stopped";
|
|
19
27
|
private abortController: AbortController | null = null;
|
|
20
28
|
private config: ConnectorConfig | null = null;
|
|
29
|
+
private resolveConfig: (() => ConnectorConfig) | null = null;
|
|
21
30
|
private restartCount = 0;
|
|
22
31
|
private startedAt = 0;
|
|
23
32
|
private lastHeartbeatAt = 0;
|
|
24
33
|
private totalTasksExecuted = 0;
|
|
25
34
|
private consecutiveErrors = 0;
|
|
35
|
+
private consecutiveAuthCrashes = 0;
|
|
26
36
|
private executionMode: "webhook" | "execFile" = "execFile";
|
|
27
37
|
private loopPromise: Promise<void> | null = null;
|
|
28
38
|
private signalHandlersInstalled = false;
|
|
39
|
+
private stabilityTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
private dormantTimer: ReturnType<typeof setTimeout> | null = null;
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
|
-
* Start the connector.
|
|
43
|
+
* Start the connector. Accepts either a static config or a resolver function
|
|
44
|
+
* that re-resolves config (including API key) on each restart.
|
|
45
|
+
*
|
|
46
|
+
* Idempotent with smart recovery:
|
|
47
|
+
* - If dormant: wakes up and attempts restart (user may have changed key)
|
|
48
|
+
* - If running but unhealthy: forces restart
|
|
49
|
+
* - If running and healthy: skips
|
|
32
50
|
*/
|
|
33
|
-
async start(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
async start(configOrResolver: ConnectorConfig | (() => ConnectorConfig)): Promise<void> {
|
|
52
|
+
// Store resolver for future re-resolution
|
|
53
|
+
if (typeof configOrResolver === "function") {
|
|
54
|
+
this.resolveConfig = configOrResolver;
|
|
55
|
+
this.config = configOrResolver();
|
|
56
|
+
} else {
|
|
57
|
+
this.resolveConfig = null;
|
|
58
|
+
this.config = configOrResolver;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this.state === "dormant") {
|
|
62
|
+
// User may have changed their key - wake up and try
|
|
63
|
+
console.log("[bereach:connector] waking from dormant mode (register() called)");
|
|
64
|
+
if (this.dormantTimer) { clearTimeout(this.dormantTimer); this.dormantTimer = null; }
|
|
65
|
+
this.consecutiveAuthCrashes = 0;
|
|
66
|
+
// Fall through to normal start
|
|
67
|
+
} else if (this.state === "running" || this.state === "starting") {
|
|
68
|
+
if (this.isHealthy()) {
|
|
69
|
+
console.log("[bereach:connector] skip duplicate start (already running and healthy)");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.warn("[bereach:connector] connector unhealthy, forcing restart");
|
|
73
|
+
await this.stop();
|
|
37
74
|
}
|
|
38
75
|
|
|
39
|
-
this.config = config;
|
|
40
76
|
this.state = "starting";
|
|
41
77
|
this.restartCount = 0;
|
|
42
78
|
|
|
@@ -73,11 +109,11 @@ class ConnectorManager {
|
|
|
73
109
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
74
110
|
}
|
|
75
111
|
|
|
76
|
-
// Probe webhook availability with retry
|
|
112
|
+
// Probe webhook availability with retry - the gateway HTTP server may not be
|
|
77
113
|
// listening yet when register() fires during plugin load.
|
|
78
114
|
let webhookOk = false;
|
|
79
115
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
80
|
-
webhookOk = await isWebhookAvailable(config);
|
|
116
|
+
webhookOk = await isWebhookAvailable(this.config);
|
|
81
117
|
if (webhookOk) break;
|
|
82
118
|
if (attempt < 5) {
|
|
83
119
|
console.log(`[bereach:connector] webhook probe attempt ${attempt}/5 failed, retrying in 3s...`);
|
|
@@ -95,6 +131,9 @@ class ConnectorManager {
|
|
|
95
131
|
async stop(): Promise<void> {
|
|
96
132
|
if (this.state === "stopped" || this.state === "disabled") return;
|
|
97
133
|
|
|
134
|
+
// Clean up dormant timer if active
|
|
135
|
+
if (this.dormantTimer) { clearTimeout(this.dormantTimer); this.dormantTimer = null; }
|
|
136
|
+
|
|
98
137
|
this.state = "stopping";
|
|
99
138
|
console.log("[bereach:connector] stopping...");
|
|
100
139
|
|
|
@@ -128,10 +167,43 @@ class ConnectorManager {
|
|
|
128
167
|
};
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Check if the connector is actually healthy (not a zombie).
|
|
172
|
+
* Unhealthy = running state but no heartbeat in 5+ minutes,
|
|
173
|
+
* or running for 2+ minutes without ever getting a heartbeat.
|
|
174
|
+
*/
|
|
175
|
+
private isHealthy(): boolean {
|
|
176
|
+
if (this.state !== "running") return false;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const uptime = now - this.startedAt;
|
|
179
|
+
// Just started (< 2 min), give it time
|
|
180
|
+
if (uptime < 2 * 60 * 1000) return true;
|
|
181
|
+
// No heartbeat ever received after 2 min = zombie
|
|
182
|
+
if (!this.lastHeartbeatAt) return false;
|
|
183
|
+
// Last heartbeat > 5 min ago = zombie
|
|
184
|
+
return (now - this.lastHeartbeatAt) < 5 * 60 * 1000;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Re-resolve config from the resolver (picks up API key changes).
|
|
189
|
+
* Falls back to stored config if no resolver.
|
|
190
|
+
*/
|
|
191
|
+
private freshConfig(): ConnectorConfig | null {
|
|
192
|
+
if (this.resolveConfig) {
|
|
193
|
+
try {
|
|
194
|
+
this.config = this.resolveConfig();
|
|
195
|
+
const masked = this.config.apiKey ? `...${this.config.apiKey.slice(-6)}` : "NOT SET";
|
|
196
|
+
console.log(`[bereach:connector] re-resolved config (API key: ${masked})`);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error("[bereach:connector] config resolver failed:", err instanceof Error ? err.message : String(err));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return this.config;
|
|
202
|
+
}
|
|
132
203
|
|
|
133
204
|
private startLoop(): void {
|
|
134
|
-
|
|
205
|
+
const config = this.freshConfig();
|
|
206
|
+
if (!config) return;
|
|
135
207
|
|
|
136
208
|
this.abortController = new AbortController();
|
|
137
209
|
this.startedAt = Date.now();
|
|
@@ -151,7 +223,10 @@ class ConnectorManager {
|
|
|
151
223
|
}, 5 * 60 * 1000);
|
|
152
224
|
this.stabilityTimer.unref();
|
|
153
225
|
|
|
154
|
-
this.loopPromise = runConnectorLoop(
|
|
226
|
+
this.loopPromise = runConnectorLoop(config, {
|
|
227
|
+
signal: this.abortController.signal,
|
|
228
|
+
onHeartbeat: () => { this.lastHeartbeatAt = Date.now(); },
|
|
229
|
+
})
|
|
155
230
|
.then(() => {
|
|
156
231
|
// Normal exit (abort signal)
|
|
157
232
|
if (this.state !== "stopping") {
|
|
@@ -160,14 +235,29 @@ class ConnectorManager {
|
|
|
160
235
|
})
|
|
161
236
|
.catch((err) => {
|
|
162
237
|
console.error(`[bereach:connector] loop crashed: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
-
this.handleCrash();
|
|
238
|
+
this.handleCrash(err);
|
|
164
239
|
});
|
|
165
240
|
}
|
|
166
241
|
|
|
167
|
-
private handleCrash(): void {
|
|
242
|
+
private handleCrash(err?: unknown): void {
|
|
168
243
|
this.restartCount++;
|
|
169
244
|
this.consecutiveErrors++;
|
|
170
245
|
|
|
246
|
+
// Track consecutive auth-related crashes for dormant mode.
|
|
247
|
+
// 2 auth crashes = 2 full start→3-failures→crash cycles = ~6 total 401s.
|
|
248
|
+
const isAuthCrash = err instanceof AuthError ||
|
|
249
|
+
(err instanceof Error && err.message.includes("Auth failed"));
|
|
250
|
+
if (isAuthCrash) {
|
|
251
|
+
this.consecutiveAuthCrashes++;
|
|
252
|
+
console.error(`[bereach:connector] auth crash ${this.consecutiveAuthCrashes}/2`);
|
|
253
|
+
if (this.consecutiveAuthCrashes >= 2) {
|
|
254
|
+
this.enterDormant();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
this.consecutiveAuthCrashes = 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
171
261
|
if (this.restartCount > MAX_RESTARTS) {
|
|
172
262
|
// Don't give up permanently - enter cooldown then restart.
|
|
173
263
|
// The user's VPS has no supervisor to restart us, so we must self-recover.
|
|
@@ -199,6 +289,69 @@ class ConnectorManager {
|
|
|
199
289
|
this.startLoop();
|
|
200
290
|
}, delay);
|
|
201
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Enter dormant mode - lightweight 15-min probe instead of full restart loop.
|
|
295
|
+
* Used when API key is confirmed invalid (persistent auth failures).
|
|
296
|
+
* Auto-recovers when the key becomes valid again.
|
|
297
|
+
*/
|
|
298
|
+
private enterDormant(): void {
|
|
299
|
+
this.state = "dormant";
|
|
300
|
+
this.abortController = null;
|
|
301
|
+
this.loopPromise = null;
|
|
302
|
+
console.error(
|
|
303
|
+
"[bereach:connector] API key invalid - entering dormant mode. " +
|
|
304
|
+
"Will probe every 15 min and auto-recover when key is fixed.",
|
|
305
|
+
);
|
|
306
|
+
this.scheduleDormantProbe();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private scheduleDormantProbe(): void {
|
|
310
|
+
if (this.dormantTimer) clearTimeout(this.dormantTimer);
|
|
311
|
+
this.dormantTimer = setTimeout(() => this.runDormantProbe(), DORMANT_PROBE_INTERVAL_MS);
|
|
312
|
+
this.dormantTimer.unref();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async runDormantProbe(): Promise<void> {
|
|
316
|
+
if (this.state !== "dormant") return;
|
|
317
|
+
|
|
318
|
+
// Re-resolve config to pick up any key changes
|
|
319
|
+
const config = this.freshConfig();
|
|
320
|
+
if (!config?.apiKey) {
|
|
321
|
+
console.log("[bereach:connector] dormant probe: no API key configured, will retry in 15 min");
|
|
322
|
+
this.scheduleDormantProbe();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Single lightweight heartbeat probe
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(`${config.apiUrl}/connectors/heartbeat`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: {
|
|
331
|
+
"Content-Type": "application/json",
|
|
332
|
+
"Authorization": `Bearer ${config.apiKey}`,
|
|
333
|
+
},
|
|
334
|
+
body: JSON.stringify({}),
|
|
335
|
+
signal: AbortSignal.timeout(10_000),
|
|
336
|
+
});
|
|
337
|
+
if (res.ok) {
|
|
338
|
+
console.log("[bereach:connector] dormant probe succeeded! Restarting connector.");
|
|
339
|
+
this.consecutiveAuthCrashes = 0;
|
|
340
|
+
this.restartCount = 0;
|
|
341
|
+
this.consecutiveErrors = 0;
|
|
342
|
+
this.startLoop();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (res.status === 401 || res.status === 403) {
|
|
346
|
+
console.log("[bereach:connector] dormant probe: still 401, will retry in 15 min");
|
|
347
|
+
} else {
|
|
348
|
+
console.log(`[bereach:connector] dormant probe: HTTP ${res.status}, will retry in 15 min`);
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.log(`[bereach:connector] dormant probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
352
|
+
}
|
|
353
|
+
this.scheduleDormantProbe();
|
|
354
|
+
}
|
|
202
355
|
}
|
|
203
356
|
|
|
204
357
|
// Module-level singleton
|
package/src/hooks/cache.ts
CHANGED
|
@@ -176,6 +176,7 @@ export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
|
|
|
176
176
|
sessionMeta: snapshot.sessionMeta ?? null,
|
|
177
177
|
onboardingState: snapshot.onboardingState ?? null,
|
|
178
178
|
recentEvents: snapshot.recentEvents ?? [],
|
|
179
|
+
llmStatus: snapshot.llmStatus ?? null,
|
|
179
180
|
};
|
|
180
181
|
|
|
181
182
|
// Fallback: if snapshot didn't include limits, fetch from dedicated endpoint
|
package/src/hooks/context.ts
CHANGED
|
@@ -584,6 +584,21 @@ function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?: string
|
|
|
584
584
|
|
|
585
585
|
// ── ACTIVE MODE: data-only context for onboarded users ──
|
|
586
586
|
|
|
587
|
+
// LLM provider error warning — show prominently so the agent can inform the user
|
|
588
|
+
if (data.llmStatus) {
|
|
589
|
+
lines.push("### AI Provider Error");
|
|
590
|
+
if (data.llmStatus === "auth") {
|
|
591
|
+
lines.push("Your AI provider credentials are INVALID. Automated campaigns are paused.");
|
|
592
|
+
lines.push("Tell the user to update their API key in their provider's dashboard.");
|
|
593
|
+
} else if (data.llmStatus === "billing") {
|
|
594
|
+
lines.push("Your AI provider credits are EXHAUSTED. Automated campaigns are paused.");
|
|
595
|
+
lines.push("Tell the user to add credits or upgrade their plan with their AI provider.");
|
|
596
|
+
} else {
|
|
597
|
+
lines.push("There is an issue with the AI provider. Automated campaigns may be paused.");
|
|
598
|
+
}
|
|
599
|
+
lines.push("");
|
|
600
|
+
}
|
|
601
|
+
|
|
587
602
|
// Account
|
|
588
603
|
if (data.activeAccount) {
|
|
589
604
|
const a = data.activeAccount;
|
package/src/hooks/enforcement.ts
CHANGED
|
@@ -318,9 +318,15 @@ function createDmGuard() {
|
|
|
318
318
|
async function guardScheduledDm(toolName: string, params: Record<string, unknown>, key: string, state: SessionState) {
|
|
319
319
|
if (toolName !== "bereach_scheduled_message_create") return null;
|
|
320
320
|
|
|
321
|
-
// DM history check
|
|
321
|
+
// DM history check — skip in task mode when prefetch already loaded conversation
|
|
322
|
+
// data server-side. The agent is instructed to skip tool calls when prefetch is
|
|
323
|
+
// present, so dmHistoryChecked will be empty. This is safe because:
|
|
324
|
+
// 1. Prefetch runs the same dedup check server-side before injecting data
|
|
325
|
+
// 2. Drafts (default status) are user-reviewed before sending
|
|
326
|
+
const isDraft = !params.status || params.status === "draft";
|
|
327
|
+
const isTaskMode = !!state.currentTaskMode;
|
|
322
328
|
const dmId = extractDmIdentifier(params);
|
|
323
|
-
if (dmId && !state.dmHistoryChecked.has(dmId)) {
|
|
329
|
+
if (dmId && !state.dmHistoryChecked.has(dmId) && !(isTaskMode && isDraft)) {
|
|
324
330
|
log(`scheduled DM dedup BLOCKED: history not checked for ${dmId}`);
|
|
325
331
|
return {
|
|
326
332
|
blocked: true,
|
package/src/hooks/lifecycle.ts
CHANGED
|
@@ -65,6 +65,82 @@ function parseStructuredResult(text: string): Record<string, unknown> | null {
|
|
|
65
65
|
/** Exported for tests. */
|
|
66
66
|
export { parseStructuredResult as _parseStructuredResult };
|
|
67
67
|
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Token usage extraction & cost estimation
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** Model pricing per 1M tokens (hardcoded — plugin can't import from apps/web) */
|
|
73
|
+
const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
|
|
74
|
+
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
|
|
75
|
+
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
76
|
+
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
|
|
77
|
+
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function estimateTaskCost(
|
|
81
|
+
inputTokens: number,
|
|
82
|
+
outputTokens: number,
|
|
83
|
+
cacheReadTokens: number,
|
|
84
|
+
modelSlug?: string,
|
|
85
|
+
): number {
|
|
86
|
+
const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
|
|
87
|
+
const p = MODEL_PRICING[key];
|
|
88
|
+
const uncached = Math.max(0, inputTokens - cacheReadTokens);
|
|
89
|
+
return (uncached * p.input + cacheReadTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enrich a task result with token usage and cost estimation from OpenClaw's
|
|
94
|
+
* endCtx.meta. The meta object may contain:
|
|
95
|
+
* - meta.cost: { inputTokens, outputTokens } (preferred)
|
|
96
|
+
* - meta.usage: { input, output, cacheRead, cacheWrite } (alternative)
|
|
97
|
+
*/
|
|
98
|
+
function enrichWithTokenUsage(
|
|
99
|
+
taskResult: Record<string, unknown>,
|
|
100
|
+
endCtx: unknown,
|
|
101
|
+
taskMode: TaskModeInfo,
|
|
102
|
+
): void {
|
|
103
|
+
try {
|
|
104
|
+
const meta = (endCtx as any)?.meta ?? (endCtx as any)?.result?.meta ?? {};
|
|
105
|
+
// OpenClaw provides token data in multiple possible locations:
|
|
106
|
+
// - meta.agentMeta.lastCallUsage (OpenClaw 2026.4+)
|
|
107
|
+
// - meta.cost (older versions / test runner)
|
|
108
|
+
// - meta.usage (alternative format)
|
|
109
|
+
const agentMeta = meta.agentMeta ?? {};
|
|
110
|
+
const lastCall = agentMeta.lastCallUsage ?? {};
|
|
111
|
+
const costData = meta.cost ?? {};
|
|
112
|
+
const usageData = meta.usage ?? {};
|
|
113
|
+
|
|
114
|
+
const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
|
|
115
|
+
const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
|
|
116
|
+
|
|
117
|
+
// No token data available — skip enrichment
|
|
118
|
+
if (inputTokens === 0 && outputTokens === 0) {
|
|
119
|
+
log(`token usage: no data in endCtx.meta for ${taskMode.taskId} (keys: ${Object.keys(meta).join(",")})`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
|
|
124
|
+
const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
|
|
125
|
+
|
|
126
|
+
taskResult.tokenUsage = {
|
|
127
|
+
inputTokens,
|
|
128
|
+
outputTokens,
|
|
129
|
+
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
130
|
+
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Model slug from agentMeta (most reliable), env, or task mode
|
|
134
|
+
const modelSlug = agentMeta.model ?? readEnv("BEREACH_TASK_MODEL") ?? readEnv("OPENCLAW_MODEL") ?? undefined;
|
|
135
|
+
taskResult.model = modelSlug;
|
|
136
|
+
taskResult.estimatedCostUsd = estimateTaskCost(inputTokens, outputTokens, cacheRead, modelSlug);
|
|
137
|
+
|
|
138
|
+
log(`token usage for ${taskMode.taskId}: input=${inputTokens} output=${outputTokens} cacheRead=${cacheRead} cacheWrite=${cacheWrite} cost=$${(taskResult.estimatedCostUsd as number).toFixed(4)} model=${modelSlug ?? "unknown"}`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log(`token usage extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
68
144
|
// ---------------------------------------------------------------------------
|
|
69
145
|
// Hook registration
|
|
70
146
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +232,9 @@ export function registerLifecycleHook(
|
|
|
156
232
|
const shouldSubmit = result || !isExecFileMode;
|
|
157
233
|
const taskResult = result ?? fallbackResult;
|
|
158
234
|
|
|
235
|
+
// Enrich with token usage from OpenClaw meta (if available)
|
|
236
|
+
enrichWithTokenUsage(taskResult, endCtx, taskMode);
|
|
237
|
+
|
|
159
238
|
// Retry with backoff — task results are critical, losing them means
|
|
160
239
|
// the task stays "running" forever and the workflow chain stalls.
|
|
161
240
|
let posted = false;
|
package/src/index.ts
CHANGED
|
@@ -87,10 +87,10 @@ async function autoDetectModels(apiKey: string): Promise<void> {
|
|
|
87
87
|
const currentCreative = data.aiModels?.creative;
|
|
88
88
|
|
|
89
89
|
// Only auto-switch if still on Anthropic defaults (respect manual choices)
|
|
90
|
-
|
|
90
|
+
const isAnthropicDefault =
|
|
91
91
|
currentFast === "anthropic/claude-haiku-4-5" &&
|
|
92
|
-
currentCreative === "anthropic/claude-sonnet-4-6"
|
|
93
|
-
) {
|
|
92
|
+
(currentCreative === "anthropic/claude-haiku-4-5" || currentCreative === "anthropic/claude-sonnet-4-6");
|
|
93
|
+
if (isAnthropicDefault) {
|
|
94
94
|
const patchRes = await fetch(`${API_BASE}/me/settings`, {
|
|
95
95
|
method: "PATCH",
|
|
96
96
|
headers: {
|
|
@@ -232,14 +232,14 @@ export default function register(api: any) {
|
|
|
232
232
|
const gatewayUrl = config.gatewayUrl || readEnv("OPENCLAW_GATEWAY_URL") || "http://localhost:18789";
|
|
233
233
|
const pollMs = config.connectorPollIntervalMs ?? 15000;
|
|
234
234
|
|
|
235
|
-
connectorManager.start({
|
|
236
|
-
apiKey,
|
|
235
|
+
connectorManager.start(() => ({
|
|
236
|
+
apiKey: resolveApiKey(api),
|
|
237
237
|
apiUrl: API_BASE,
|
|
238
238
|
openclawUrl: readEnv("OPENCLAW_URL") || "http://localhost:3579",
|
|
239
239
|
pollIntervalMs: Math.max(5000, pollMs),
|
|
240
240
|
gatewayUrl,
|
|
241
241
|
hooksToken,
|
|
242
|
-
}).catch((err) => {
|
|
242
|
+
})).catch((err) => {
|
|
243
243
|
log(`connector auto-start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
244
244
|
});
|
|
245
245
|
} else if (!connectorEnabled) {
|