openclaw-lark-multi-agent 1.6.0 → 1.8.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,344 @@
1
+ // Never serialize unknown error objects. Only traverse error-envelope fields;
2
+ // request/config/headers/prompt/tool definitions are deliberately not visited.
3
+ const ENVELOPE_FIELDS = ["response", "data", "body", "error", "cause", "message", "msg", "errorMessage", "details"];
4
+ const MAX_TEXT = 16_384;
5
+ const CODES = new Set([
6
+ "model_not_supported", "invalid_model", "model_not_found", "unsupported_model", "incompatible_api", "model_disabled", "model_unavailable", "unknown_model",
7
+ "invalid_request_error", "invalid_request", "bad_request", "invalid_argument", "schema_rejected", "schema_validation_error", "validation_error", "malformed_request",
8
+ "context_length_exceeded", "context_window_exceeded", "prompt_too_long", "input_too_long",
9
+ "insufficient_quota", "quota_exceeded", "usage_limit_exceeded", "credits_exhausted", "rate_limit_exceeded", "too_many_requests", "rate_limit_error",
10
+ "authentication_error", "permission_denied", "unauthorized", "forbidden", "invalid_api_key",
11
+ "econnrefused", "econnreset", "enotfound", "eai_again", "etimedout", "und_err_connect_timeout", "cert_has_expired", "unable_to_verify_leaf_signature", "err_tls_cert_altname_invalid",
12
+ "ttft_timeout", "first_token_timeout", "response_header_timeout", "und_err_headers_timeout", "total_timeout", "execution_timeout", "request_deadline_exceeded",
13
+ "server_error", "internal_error", "internal_server_error", "api_error", "service_unavailable", "overloaded_error", "timeout_error", "abort_err",
14
+ ]);
15
+ const TYPES = new Set([...CODES, "error", "request_error", "invalid_argument_error", "permission_error", "not_found_error", "invalid_response_error", "gateway_error"]);
16
+ // A known model identifier is classification evidence, not public provider prose.
17
+ // Reject URLs and credential-shaped IDs; even a valid captured ID is never echoed.
18
+ const isSafeModelId = (value) => /^[a-z0-9_./:-]{1,128}$/i.test(value)
19
+ && !value.includes("://") && !/(?:^|[/:._-])(?:sk-|gh[pousr]_|eyJ)|secret|private_|password|credential|api[_-]?key|access[_-]?token/i.test(value)
20
+ && !value.split("/").some(part => /^[a-f0-9]{24,}$/i.test(part) || /^[a-z0-9_+]{48,}$/i.test(part));
21
+ const isDisabledModelBody = (value) => {
22
+ const id = value.match(/^model '([a-z0-9_./:-]{1,128})' is disabled$/)?.[1];
23
+ return id !== undefined && isSafeModelId(id);
24
+ };
25
+ const UNKNOWN_MODEL_NOTE = "The selected model is not registered in the current model catalog or is unavailable.";
26
+ const isUnknownModelBody = (value) => {
27
+ const id = value.match(/^Unknown model: ([a-z0-9][a-z0-9_.-]{0,63}\/([a-z0-9][a-z0-9_./-]{0,127}))$/i)?.[1];
28
+ return id !== undefined && isSafeModelId(id) && id.split("/").every(part => part.length > 0);
29
+ };
30
+ // Exact observed Core copy, not a substring/context heuristic. No arbitrary
31
+ // reason suffix, model ID or provider prose is accepted or shown to the chat.
32
+ const CORE_COMPACTION_FAILURE = "⚠️ Context is too large and auto-compaction could not recover this turn. Try again, use /compact, or use /new to start a fresh session.";
33
+ const diagnostic = (value, values) => typeof value === "string"
34
+ && values.has(value.toLowerCase()) ? value : undefined;
35
+ /** Classify by a validated root; expose only known structural path segments.
36
+ * Arbitrary property/tool/function names never become public metadata. */
37
+ const parameter = (value) => {
38
+ if (typeof value !== "string" || value.length > 256
39
+ || !/^(?:model|api|api_version|endpoint|messages|input|max_tokens|max_completion_tokens|context|tools|functions|tool_choice)(?:\[\d{1,4}\]|\.[a-z0-9_-]+)*$/i.test(value))
40
+ return;
41
+ const parts = value.match(/^[a-z_]+|\[\d+\]|\.[a-z0-9_-]+/gi);
42
+ let safe = parts[0];
43
+ for (const part of parts.slice(1)) {
44
+ if (!/^(?:\[\d{1,4}\]|\.(?:\d{1,4}|function|parameters|name|type|properties|required|items))$/i.test(part))
45
+ break;
46
+ safe += part;
47
+ }
48
+ return safe;
49
+ };
50
+ const requestId = (value) => typeof value === "string"
51
+ && /^(?:gw-[a-f0-9]{8,64}|req[_-][a-z0-9_-]{4,80}|[a-f0-9]{8}-[a-f0-9-]{27,40}|[a-f0-9]{16,64})$/i.test(value)
52
+ && !/private_|secret|token|password|credential|api[_-]?key/i.test(value) ? value : undefined;
53
+ const httpStatus = (value) => {
54
+ const n = Number(value);
55
+ return Number.isInteger(n) && n >= 400 && n <= 599 ? n : undefined;
56
+ };
57
+ // Exact, known diagnostic sentences only. Arbitrary provider prose can echo
58
+ // user input, credentials or tool schemas; metadata survives when prose cannot.
59
+ const SAFE_MESSAGES = [
60
+ "The requested model is not supported.", "The requested model is not supported",
61
+ "Invalid model", "Model not found", "Invalid argument", "invalid argument",
62
+ "model not allowed by agents.defaults.modelPolicy.allow",
63
+ "authentication rejected", "tool execution failed", "Request timed out", "Request timeout",
64
+ "Gateway disconnected", "Gateway disconnected during run", "Not connected and reconnect failed",
65
+ "execution budget exhausted", "Authentication failed", "Forbidden", "Unauthorized",
66
+ ];
67
+ /** Bounded parsing of SDK strings (e.g. Agent error: 400 {"error":...})
68
+ * and nested JSON errors. Deeper structured provider fields beat wrapper fields.
69
+ * Message text is used for classification, never returned verbatim. */
70
+ export function classifyProviderError(error) {
71
+ const candidates = [];
72
+ const texts = [];
73
+ const seen = new Set();
74
+ let nodes = 0;
75
+ let cancelled = false;
76
+ let abortSignal = false;
77
+ function visit(value, depth = 0, rank = 0, envelopeStatus, rootDiagnostic = true, compactionDiagnostic = true) {
78
+ if (depth > 8 || ++nodes > 64)
79
+ return;
80
+ if (typeof value === "string") {
81
+ const text = value.slice(0, MAX_TEXT).trim();
82
+ texts.push(text);
83
+ const unwrapped = text.replace(/^(?:(?:Agent error|RPC error|Error|HTTP(?: error)?)\s*:\s*)+/i, "");
84
+ const statusPrefix = unwrapped.match(/^(?:HTTP\s*)?([45]\d{2})\b\s*:?\s*/i);
85
+ const localStatus = statusPrefix ? httpStatus(statusPrefix[1]) : envelopeStatus;
86
+ if (statusPrefix)
87
+ candidates.push({ score: rank + depth, status: localStatus });
88
+ if (/<(?:!doctype|html|body)\b/i.test(text))
89
+ return;
90
+ const body = statusPrefix ? unwrapped.slice(statusPrefix[0].length) : unwrapped;
91
+ // Only a complete JSON value in an explicit SDK/HTTP envelope. Never
92
+ // search arbitrary prose (which may contain prompt echoes) for JSON or
93
+ // code/type/request_id assignments.
94
+ if (value.length <= MAX_TEXT && /^[{"]/.test(body)) {
95
+ try {
96
+ const parsed = JSON.parse(body);
97
+ if (parsed !== value)
98
+ visit(parsed, depth + 1, rank, localStatus, rootDiagnostic, compactionDiagnostic && typeof parsed === "string" && /^(?:Agent error|RPC error|Error)\s*:/i.test(text));
99
+ }
100
+ catch { /* not JSON */ }
101
+ }
102
+ const plain = body.trim();
103
+ if (rootDiagnostic && compactionDiagnostic && value.length <= MAX_TEXT && plain === CORE_COMPACTION_FAILURE) {
104
+ candidates.push({ score: rank + depth, compactionFailedBody: true });
105
+ }
106
+ // Mark only a complete known sentence, never a "disabled" substring.
107
+ // Classification below additionally requires the enclosing HTTP503 and
108
+ // compatible diagnostic provenance, including sibling response.status.
109
+ // Do not classify a truncated long body as a complete known sentence.
110
+ if (value.length <= MAX_TEXT && isDisabledModelBody(plain)) {
111
+ candidates.push({ score: rank + depth, disabledModelBody: true });
112
+ }
113
+ // A root Core/SDK diagnostic (including complete quoted envelopes), not
114
+ // an arbitrary response-data/details/tool tree or a substring search.
115
+ if (rootDiagnostic && value.length <= MAX_TEXT && isUnknownModelBody(plain)) {
116
+ candidates.push({ score: rank + depth, unknownModelBody: true });
117
+ }
118
+ if (SAFE_MESSAGES.some(message => message.toLowerCase() === plain.toLowerCase())) {
119
+ candidates.push({ score: rank + depth, safeMessage: plain });
120
+ }
121
+ return;
122
+ }
123
+ if (!value || typeof value !== "object" || seen.has(value))
124
+ return;
125
+ seen.add(value);
126
+ const obj = value;
127
+ // Control intent is supplied by the caller's typed error, never by JSON
128
+ // quoted inside provider prose. A message echo must not synthesize a stop.
129
+ if (depth === 0) {
130
+ if (obj.userStop === true || ["user", "user_stop", "user_cancel", "stop"].includes(String(obj.stopReason)))
131
+ cancelled = true;
132
+ if (obj.name === "AbortError" || obj.code === "ABORT_ERR")
133
+ abortSignal = true;
134
+ }
135
+ const candidate = { score: rank + depth,
136
+ code: diagnostic(obj.code, CODES), type: diagnostic(obj.type, TYPES), param: parameter(obj.param),
137
+ status: httpStatus(obj.status) ?? httpStatus(obj.statusCode) ?? httpStatus(obj.httpStatus)
138
+ // Go writeErr's closed envelope uses a numeric HTTP code, not a string
139
+ // provider code. Accept it only with its known gateway_error type.
140
+ ?? (obj.type === "gateway_error" ? httpStatus(obj.code) : undefined),
141
+ requestId: requestId(obj.request_id) ?? requestId(obj.req_id) ?? requestId(obj.requestId) };
142
+ candidates.push(candidate);
143
+ for (const field of ENVELOPE_FIELDS) {
144
+ // An authoritative response.data.error outranks a sibling wrapper message
145
+ // regardless of iteration order. Keep code/type/param as one envelope.
146
+ const weight = field === "error" ? 100 : field === "response" ? 80 : field === "data" ? 20
147
+ : field === "body" || field === "cause" || field === "details" ? 10 : 0;
148
+ visit(obj[field], depth + 1, rank + weight, candidate.status ?? envelopeStatus, rootDiagnostic && ["error", "message", "msg", "errorMessage"].includes(field), compactionDiagnostic);
149
+ }
150
+ }
151
+ visit(error);
152
+ candidates.sort((a, b) => b.score - a.score);
153
+ const chosen = candidates.find(c => c.code || c.type || c.param);
154
+ const result = { category: "unknown",
155
+ ...(chosen?.code ? { code: chosen.code } : {}), ...(chosen?.type ? { type: chosen.type } : {}),
156
+ ...(chosen?.param ? { param: chosen.param } : {}),
157
+ };
158
+ for (const field of ["status", "requestId", "safeMessage"]) {
159
+ const candidate = candidates.find(c => c[field] !== undefined);
160
+ if (candidate)
161
+ Object.assign(result, { [field]: candidate[field] });
162
+ }
163
+ // Exclude HTML and credential/payload text from heuristic classification.
164
+ const text = texts.filter(t => !/<(?:!doctype|html|body)\b/i.test(t))
165
+ .join(" ").replace(/https?:\/\/\S+/gi, "[url]").toLowerCase();
166
+ const code = (result.code || "").toLowerCase();
167
+ const type = (result.type || "").toLowerCase();
168
+ const param = (result.param || "").toLowerCase();
169
+ const tags = `${code} ${type}`;
170
+ const disabledBody = candidates.find(c => c.disabledModelBody);
171
+ const neutralCode = /^(?:server_error|internal_error|internal_server_error|api_error|service_unavailable|invalid_request_error|bad_request)$/.test(code);
172
+ const knownDisabledBody = result.status === 503 && disabledBody !== undefined
173
+ && disabledBody.score >= (chosen?.score ?? -Infinity) && (!param || param === "model")
174
+ && (!code || neutralCode)
175
+ && (!type || /^(?:error|request_error|gateway_error|api_error|server_error|invalid_request_error|service_unavailable)$/.test(type));
176
+ const unknownBody = candidates.find(c => c.unknownModelBody);
177
+ const knownUnknownBody = unknownBody !== undefined && unknownBody.score >= (chosen?.score ?? -Infinity)
178
+ && !abortSignal && ![401, 403, 429].includes(result.status ?? 0) && (!param || param === "model")
179
+ && (!code || neutralCode)
180
+ && (!type || /^(?:error|request_error|gateway_error|api_error|server_error|invalid_request_error|service_unavailable)$/.test(type));
181
+ if (cancelled)
182
+ result.category = "cancelled";
183
+ else if (code === "unknown_model" || ((!code || neutralCode) && type === "unknown_model") || knownUnknownBody) {
184
+ result.category = "model_unavailable";
185
+ // Fixed semantic explanation, not an invented provider code or captured ID.
186
+ result.safeMessage = UNKNOWN_MODEL_NOTE;
187
+ }
188
+ else if (/^(?:model_disabled|model_unavailable)$/.test(code)
189
+ || ((!code || neutralCode) && /^(?:model_disabled|model_unavailable)$/.test(type)) || knownDisabledBody)
190
+ result.category = "model_unavailable";
191
+ else if (/\b(?:model_not_supported|invalid_model|model_not_found|unsupported_model|incompatible_api)\b/.test(tags) || param === "model")
192
+ result.category = "model";
193
+ else if (/^(?:tools|functions|tool_choice)(?:\b|[.\[])/.test(param)
194
+ && (/^(?:invalid_argument|invalid_request_error|invalid_request|bad_request|schema_rejected|schema_validation_error|validation_error|malformed_request|invalid_argument_error)$/.test(code)
195
+ || (!code && (/^(?:invalid_argument|invalid_request_error|validation_error|schema_validation_error|invalid_argument_error)$/.test(type)
196
+ || /json schema|invalid (?:argument|schema)|tool payload/.test(text)))))
197
+ result.category = "tools";
198
+ else if (/\b(?:context_length_exceeded|context_window_exceeded|prompt_too_long|input_too_long)\b/.test(tags))
199
+ result.category = "context";
200
+ else if (/\b(?:insufficient_quota|quota_exceeded|usage_limit_exceeded|credits_exhausted)\b/.test(tags))
201
+ result.category = "quota";
202
+ else if (/\b(?:rate_limit_exceeded|too_many_requests|rate_limit_error)\b/.test(tags))
203
+ result.category = "rate_limit";
204
+ else if (result.status === 401 || result.status === 403 || /\b(?:authentication_error|permission_denied|unauthorized|forbidden|invalid_api_key)\b/.test(tags))
205
+ result.category = "auth";
206
+ else if (/\b(?:econnrefused|econnreset|enotfound|eai_again|etimedout|und_err_connect_timeout|cert_has_expired|unable_to_verify_leaf_signature|err_tls[a-z_]*)\b/.test(tags))
207
+ result.category = "network";
208
+ else if (/\b(?:ttft_timeout|first_token_timeout|response_header_timeout|und_err_headers_timeout)\b/.test(tags))
209
+ result.category = "ttft_timeout";
210
+ else if (/\b(?:total_timeout|execution_timeout|request_deadline_exceeded)\b/.test(tags))
211
+ result.category = "total_timeout";
212
+ else if (abortSignal || code === "abort_err")
213
+ result.category = "cancelled";
214
+ else if (candidates.some(c => c.compactionFailedBody && c.score >= (chosen?.score ?? -Infinity))
215
+ && ![401, 403, 429].includes(result.status ?? 0) && (!code || neutralCode) && !param
216
+ && (!type || /^(?:error|request_error|gateway_error|api_error|server_error|invalid_request_error|service_unavailable)$/.test(type)))
217
+ result.category = "compaction_failed";
218
+ // Fallback prose is classification evidence only. It cannot overwrite the
219
+ // concrete structural category above and is never serialized to audiences.
220
+ else if (/model not allowed by agents\.defaults\.modelpolicy\.allow/.test(text))
221
+ result.category = "model_policy";
222
+ else if (/\b(?:invalid model|model (?:is )?not (?:supported|found)|requested model is not supported|incompatible api)\b/.test(text))
223
+ result.category = "model";
224
+ else if (/\b(?:maximum context length|context window exceeded|prompt too (?:large|long))\b/.test(text))
225
+ result.category = "context";
226
+ else if (/\b(?:quota (?:exceeded|exhausted)|(?:exceeded|exhausted).{0,40}(?:usage quota|usage limit)|insufficient (?:quota|credits)|credits exhausted)\b/.test(text))
227
+ result.category = "quota";
228
+ else if (result.status === 429 || /\brate limit(?:ed| exceeded)?\b/.test(text))
229
+ result.category = "rate_limit";
230
+ else if (/\b(?:authentication (?:failed|rejected)|permission denied|unauthorized|forbidden)\b/.test(text))
231
+ result.category = "auth";
232
+ else if (/\b(?:dns (?:lookup|resolution) failed|tls (?:handshake|certificate)|ssl certificate|connection (?:refused|reset)|gateway disconnected|not connected and reconnect failed)\b/.test(text))
233
+ result.category = "network";
234
+ else if (/\b(?:ttft|first[- ]token|response[- ]header).{0,24}(?:timeout|timed out)\b/.test(text))
235
+ result.category = "ttft_timeout";
236
+ else if (/\b(?:total|execution|overall).{0,24}(?:timeout|timed out|deadline exceeded)\b/.test(text))
237
+ result.category = "total_timeout";
238
+ else if (/\b(?:rpc timeout|collector timeout|collectreply.*timeout|status lookup timeout)\b/.test(text)
239
+ || /等待 agent 运行结果超时/.test(text))
240
+ result.category = "collector_timeout";
241
+ else if (!result.status && !/invalid|validation|schema|malformed/.test(tags + " " + text) && /\b(?:timeout|timed out|etimedout)\b|超时/.test(text))
242
+ result.category = "timeout";
243
+ return result;
244
+ }
245
+ /** Transparency (user directive 2026-10-09): the visible notice must always
246
+ * carry the original diagnostic so "unclassified" is still actionable. The
247
+ * excerpt is extracted from the error's diagnostic envelope fields and
248
+ * sanitized (no HTML, no URLs, no keys/tokens, capped) — never verbatim
249
+ * arbitrary provider prose. Kept out of classifyProviderError so the
250
+ * classification shape stays stable. */
251
+ export function rawErrorExcerpt(error) {
252
+ const texts = [];
253
+ const seen = new Set();
254
+ const visit = (value, depth = 0) => {
255
+ if (depth > 3 || texts.length > 16)
256
+ return;
257
+ if (typeof value === "string") {
258
+ if (value.length <= MAX_TEXT)
259
+ texts.push(value);
260
+ return;
261
+ }
262
+ if (!value || typeof value !== "object" || seen.has(value))
263
+ return;
264
+ seen.add(value);
265
+ const obj = value;
266
+ for (const field of ENVELOPE_FIELDS) {
267
+ if (obj[field] !== undefined)
268
+ visit(obj[field], depth + 1);
269
+ }
270
+ };
271
+ visit(error);
272
+ // Safety screen (the curated anti-echo contract keeps applying): prompt
273
+ // echoes, tool outputs, JSON/HTML bodies, PRIVATE-like markers and known
274
+ // compaction-copy fragments are attacker- or prompt-controlled content and
275
+ // are never eligible for the transparency excerpt. Real server diagnostics
276
+ // (status lines, provider error prose) pass through sanitized below.
277
+ const isEcho = (t) => /\b(?:user|your)\s+prompt\b|\btool\s+outputs?\b|\btool\s+results?\b|\bdocs\s+quote\b/i.test(t);
278
+ const containsCoreCopyFragment = /context is too large|auto-compaction could not|use \/compact|start a fresh session/i;
279
+ // Credential-shaped or malformed-ID content is never eligible for echo.
280
+ // Applied AFTER URL pre-redaction so URL-bearing diagnostics still show as
281
+ // "[url]" instead of being dropped entirely.
282
+ const credentialish = (t) => /api[_-]?key|secret|credential|password|passcode|ghp_[a-z0-9]|eyJ[a-z0-9]|sk-[a-z0-9]|private|\/\/|[{"]/i.test(t);
283
+ const preRedact = (t) => t.replace(/\s+/g, " ").replace(/https?:\/\/\S+/gi, "[url]");
284
+ const isUnsafeForEcho = (t) => isEcho(t) || credentialish(t) || containsCoreCopyFragment.test(t)
285
+ || /^\s*[{\["]/.test(t) || /<(?:!doctype|html|body)\b/i.test(t);
286
+ const diagnosticTexts = texts.map(preRedact).filter(t => t && !isUnsafeForEcho(t));
287
+ const excerptSource = diagnosticTexts.filter(t => /(?:error|fail|exceed|disabled|timeout|filter|quota|unsupported|invalid|refused|terminated|abort|prohibited|denied|limit|not found|unknown)/i.test(t))
288
+ .sort((a, b) => b.length - a.length)[0]
289
+ || [...diagnosticTexts].sort((a, b) => b.length - a.length)[0]
290
+ || "";
291
+ if (!excerptSource)
292
+ return undefined;
293
+ const sanitized = excerptSource
294
+ .replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{8,}/g, "[key]")
295
+ .replace(/\b(?:bearer|authorization)\s+[A-Za-z0-9._-]{8,}/gi, "[auth]")
296
+ .replace(/[A-Za-z0-9+/]{32,}={0,2}/g, "[token]")
297
+ .trim()
298
+ .slice(0, 200);
299
+ return sanitized || undefined;
300
+ }
301
+ export function safeErrorSummary(error) {
302
+ const e = classifyProviderError(error);
303
+ return [e.category, e.safeMessage, e.status && `HTTP ${e.status}`, e.code && `code=${e.code}`,
304
+ e.type && `type=${e.type}`, e.param && `param=${e.param}`, e.requestId && `request_id=${e.requestId}`].filter(Boolean).join(" · ");
305
+ }
306
+ export function formatProviderError(error, botName, model, locale) {
307
+ const e = classifyProviderError(error);
308
+ // Transparency for the unclassified gap (user directive 2026-10-09): when
309
+ // no category explains the failure, carry the sanitized original text.
310
+ // Categorized errors keep their curated prose (anti-echo hardening) unless
311
+ // an exact safe message was allowlisted.
312
+ const rawExcerpt = e.category === "unknown" ? rawErrorExcerpt(error) : undefined;
313
+ const en = locale === "en";
314
+ const reasons = {
315
+ cancelled: ["本次运行收到停止或取消信号;不据此认定供应商故障。", "A stop/cancel signal was reported; provider failure is not established."],
316
+ model: ["当前模型不受供应商支持、不可用,或与所用 API 不兼容;供应商支持尚未恢复。", "The current model is unsupported, unavailable or incompatible with this API; provider support has not been restored."],
317
+ model_unavailable: ["所选模型已暂停或当前没有可用上游;这不是工具参数错误。", "The selected model is paused or currently has no usable upstream; this is not a tool-argument error."],
318
+ model_policy: ["当前模型被 OpenClaw 模型策略拒绝(modelPolicy.allow);没有自动切换模型。", "The current model was rejected by OpenClaw modelPolicy.allow; no automatic model switch occurred."],
319
+ tools: ["供应商明确拒绝了 tools/functions 的参数或 JSON Schema。", "The provider explicitly rejected tools/functions arguments or JSON Schema."],
320
+ context: ["请求超过模型上下文或输入长度限制;这不是供应商额度耗尽。", "The request exceeded the model context/input limit; this is not quota exhaustion."],
321
+ compaction_failed: ["整理/压缩未完成,本轮尚未生成回复;请检查摘要辅助模型或后台状态。", "Context preparation/compaction did not complete and this turn has not produced a reply; check the summarization helper model or background status."],
322
+ quota: ["模型/供应商额度已用尽。", "The model/provider quota is exhausted."],
323
+ rate_limit: ["供应商返回限流或额度限制(429);未能确认额度已耗尽。", "The provider reported a rate/quota limit (429); quota exhaustion is not confirmed."],
324
+ auth: ["供应商认证失败或当前账号无权访问。", "Provider authentication failed or the current account lacks access."],
325
+ network: ["连接、DNS 或 TLS 失败;不是已确认的模型响应超时。", "Connection, DNS or TLS failed; this is not a confirmed model response timeout."],
326
+ ttft_timeout: ["等待首个 token/响应头超时。", "Waiting for the first token/response headers timed out."],
327
+ total_timeout: ["请求或运行的总时限已到。", "The request/run total deadline was reached."],
328
+ collector_timeout: ["等待或 RPC 查询超时;不能据此认定供应商故障或底层运行已停止。", "A collector/RPC wait timed out; this does not establish provider failure or stopped execution."],
329
+ timeout: ["收到超时错误,但未能确认是网络、首 token、总时限还是等待层。", "A timeout was reported; its network, first-token, total or collector boundary is unconfirmed."],
330
+ unknown: ["请求未完成,原因尚未分类;不据此认定工具参数错误。", "The request did not complete and the cause is unclassified; a tool-argument failure is not established."],
331
+ };
332
+ const registration = e.category === "model_unavailable" && e.safeMessage === UNKNOWN_MODEL_NOTE;
333
+ const reason = registration
334
+ ? (en ? "The selected model is not registered in the current model catalog or is unavailable; this is not a tool-argument error."
335
+ : "所选模型在当前模型目录中未注册或不可用;这不是工具参数错误。")
336
+ : reasons[e.category][en ? 1 : 0];
337
+ const metadata = [e.status && `HTTP ${e.status}`, e.code && `code=${e.code}`, e.type && `type=${e.type}`,
338
+ e.param && `param=${e.param}`, e.requestId && `request_id=${e.requestId}`].filter(Boolean).join(" · ");
339
+ // Names come from bridge configuration, not provider prose. Validate them too.
340
+ const safeName = /^[\p{L}\p{N} _.-]{1,64}$/u.test(botName) ? botName : "Agent";
341
+ const safeModel = isSafeModelId(model) ? model : "unknown";
342
+ const title = e.category === "cancelled" ? (en ? "run cancelled" : "本次运行已取消") : (en ? "did not finish this reply" : "这次没有完成回复");
343
+ return `${e.category === "cancelled" ? "⏹️" : "⚠️"} ${safeName} ${title}${en ? "." : "。"}\n${en ? "Model: " : "模型:"}${safeModel}\n${en ? "Reason: " : "原因:"}${reason}${e.safeMessage && !registration ? `\n${e.safeMessage}` : ""}${rawExcerpt && rawExcerpt !== e.safeMessage ? `\n${en ? "Raw error (excerpt): " : "原始错误(节选):"}${rawExcerpt}` : ""}${metadata ? `\n${metadata}` : ""}`;
344
+ }
@@ -9,6 +9,9 @@ export interface ErrorProvenance {
9
9
  userStop?: boolean;
10
10
  }
11
11
  export interface ProactiveMessageMeta {
12
+ /** Bridge-local Go result owner; never derived from an untrusted model body. */
13
+ goJobId?: number;
14
+ sessionId?: string;
12
15
  sourceType?: string;
13
16
  runId?: string;
14
17
  sessionKey?: string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { safeErrorSummary } from "./provider-errors.js";
2
3
  export const errorIdentity = (sessionKey, runId) => createHash("sha256").update(JSON.stringify([sessionKey, runId])).digest("hex");
3
4
  export const genericFailure = (text) => text.trim() === "The agent run failed before producing a reply.";
4
5
  // These are candidate locators, NOT business identities. Verify every component
@@ -43,7 +44,7 @@ export async function inspectError(p, rpc) {
43
44
  if (p.source === "chat" && (p.state === "error" || (p.state === "final" && p.stopReason === "error")))
44
45
  return {
45
46
  state: "terminal", reason: "ordinary_gateway_error", terminalKey: `run-error:${errorIdentity(p.sessionKey, p.runId)}`,
46
- text: `⚠️ Agent run failed${p.detail ? `: ${p.detail}` : ""}`,
47
+ text: `⚠️ Agent run failed${p.detail ? `: ${safeErrorSummary(p.detail)}` : ""}`,
47
48
  };
48
49
  try {
49
50
  const wait = await rpc("agent.wait", { runId: p.runId, timeoutMs: 1 }, 2000);
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "build": "tsc && npm run build:steer-plugin",
7
+ "build": "tsc && npm run build:steer-plugin && npm run build:go-plugin && npm run build:core-plugin",
8
8
  "build:steer-plugin": "tsc -p plugins/lma-steer/tsconfig.json",
9
9
  "start": "node dist/index.js",
10
10
  "dev": "tsx src/index.ts",
11
11
  "test": "vitest run --config tests/normal-flow-offline.config.ts",
12
12
  "test:coverage": "vitest run --config tests/normal-flow-offline.config.ts --coverage",
13
13
  "prepack": "npm run build",
14
- "prepublishOnly": "npm test && npm pack --dry-run"
14
+ "prepublishOnly": "npm test && npm pack --dry-run",
15
+ "build:go-plugin": "tsc -p plugins/lma-go/tsconfig.json",
16
+ "build:core-plugin": "tsc -p plugins/lma-core/tsconfig.json"
15
17
  },
16
18
  "dependencies": {
17
19
  "@larksuiteoapi/node-sdk": "^1.73.3",
@@ -57,6 +59,10 @@
57
59
  "plugins/lma-steer/openclaw.plugin.json",
58
60
  "plugins/lma-steer/package.json",
59
61
  "plugins/lma-steer/README.md",
62
+ "plugins/lma-go/dist",
63
+ "plugins/lma-go/openclaw.plugin.json",
64
+ "plugins/lma-go/package.json",
65
+ "plugins/lma-go/README.md",
60
66
  "config.example.json",
61
67
  "README.md",
62
68
  "README.zh-CN.md",
@@ -69,4 +75,4 @@
69
75
  "publishConfig": {
70
76
  "access": "public"
71
77
  }
72
- }
78
+ }
@@ -0,0 +1,22 @@
1
+ # LMA Go independent runtime enforcement
2
+
3
+ Build with `npm run build:go-plugin`. Enable plugin id `lma-go` through the host's
4
+ supported plugin installation/configuration workflow (operator-owned).
5
+
6
+ The real before_tool_call hook denies **all** tools for explicitly bound dedicated
7
+ Checkers. Exact canonical keys/generations are persisted under the host state dir;
8
+ missing/replaced Checker generations remain denied on the bound key. Executor
9
+ native Goal tools and unrelated sessions are not restricted.
10
+
11
+ Public operator RPCs:
12
+ - `lma.go.capabilities` (operator.read): independent policy/task query capability;
13
+ explicitly no atomic snapshot and no native Goal authority.
14
+ - `lma.go.bind` (operator.admin): persistent idempotent dynamic Checker binding;
15
+ identity conflicts reject. Called before the first Checker admission.
16
+ - `lma.go.snapshot` (operator.read): canonical-key task views, explicit query
17
+ boundaries; the production client separately checks session generation, public
18
+ input/recovery/live-run state and LMA's durable queue/delivery ledger.
19
+
20
+ No static bindings or nativeGoalId are required. The bridge's independent SQLite
21
+ Go state owns objective, criteria, revision and completion. No Host changes needed.
22
+ See ../../docs/go-integration-boundaries.md for limits and activation procedure.
@@ -0,0 +1,35 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
3
+ import { join } from "node:path";
4
+ import { checkTool } from "./policy.js";
5
+ import { GoPolicyRegistry } from "./registry.js";
6
+ export default definePluginEntry({
7
+ id: "lma-go", name: "LMA Go", description: "Exact-identity persisted Go tool enforcement",
8
+ register(api) {
9
+ const registry = new GoPolicyRegistry(join(resolveStateDir(), "lma-go", "bindings.json"), api.pluginConfig?.bindings ?? []);
10
+ api.on("before_tool_call", (event, ctx) => checkTool(registry.list(), event, ctx), { priority: 10000 });
11
+ api.registerGatewayMethod("lma.go.bind", ({ params, respond }) => {
12
+ try {
13
+ registry.bind(params.binding);
14
+ respond(true, { persisted: true, binding: params.binding });
15
+ }
16
+ catch (e) {
17
+ respond(false, undefined, { code: "INVALID_REQUEST", message: e.message });
18
+ }
19
+ }, { scope: "operator.admin" });
20
+ api.registerGatewayMethod("lma.go.capabilities", ({ respond }) => {
21
+ respond(true, { mode: "independent", checkerToolsIsolated: true, taskQuery: true,
22
+ atomicSnapshot: false, nativeGoalAuthority: false });
23
+ }, { scope: "operator.read" });
24
+ api.registerGatewayMethod("lma.go.snapshot", ({ params, respond }) => {
25
+ if (typeof params.sessionKey !== "string" || typeof params.sessionId !== "string") {
26
+ respond(false, undefined, { code: "INVALID_REQUEST", message: "exact session identity required" });
27
+ return;
28
+ }
29
+ const tasks = api.runtime.tasks.runs.bindSession({ sessionKey: params.sessionKey }).list();
30
+ respond(true, { sessionKey: params.sessionKey, requestedSessionId: params.sessionId, tasks,
31
+ queryAvailable: true, atomicSnapshot: false,
32
+ boundaries: ["tasks scoped to canonical key; adapter pins generation and run ids", "LMA checks its own queue and delivery ledger"] });
33
+ }, { scope: "operator.read" });
34
+ },
35
+ });
@@ -0,0 +1,25 @@
1
+ export function checkTool(bindings, event, ctx) {
2
+ for (const b of bindings) {
3
+ // Deny also on changed/missing generation: an old checker must not become unrestricted.
4
+ if (ctx.sessionKey === b.checkerSessionKey || ctx.sessionId === b.checkerSessionId)
5
+ return { block: true, blockReason: "LMA Go message-review Checker cannot invoke tools" };
6
+ }
7
+ }
8
+ export function parseBindings(value) {
9
+ if (!Array.isArray(value))
10
+ throw new Error("bindings must be an array");
11
+ const seen = new Set();
12
+ return value.map((item) => {
13
+ if (!item || typeof item !== "object")
14
+ throw new Error("invalid binding");
15
+ for (const k of ["checkerSessionKey", "checkerSessionId", "executorSessionKey", "executorSessionId"])
16
+ if (typeof item[k] !== "string" || !item[k].trim())
17
+ throw new Error(`missing ${k}`);
18
+ if (!item.checkerSessionKey.startsWith("agent:") || !item.executorSessionKey.startsWith("agent:"))
19
+ throw new Error("canonical session keys required");
20
+ if (item.checkerSessionKey === item.executorSessionKey || item.checkerSessionId === item.executorSessionId || seen.has(item.checkerSessionKey))
21
+ throw new Error("ambiguous binding");
22
+ seen.add(item.checkerSessionKey);
23
+ return { ...item };
24
+ });
25
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { parseBindings } from "./policy.js";
4
+ /** Atomic persisted restriction registry. Never auto-unbind on restart/stop. */
5
+ export class GoPolicyRegistry {
6
+ path;
7
+ bindings;
8
+ constructor(path, initial = []) {
9
+ this.path = path;
10
+ let value;
11
+ try {
12
+ value = JSON.parse(readFileSync(path, "utf8"));
13
+ }
14
+ catch (e) {
15
+ if (e.code !== "ENOENT")
16
+ throw e;
17
+ value = initial;
18
+ }
19
+ this.bindings = parseBindings(value);
20
+ }
21
+ list() { return this.bindings.map(b => ({ ...b })); }
22
+ bind(value) {
23
+ const [binding] = parseBindings([value]);
24
+ const previous = this.bindings.find(b => b.checkerSessionKey === binding.checkerSessionKey);
25
+ if (previous) {
26
+ if (JSON.stringify(previous) !== JSON.stringify(binding))
27
+ throw new Error("Binding identity conflict");
28
+ return;
29
+ }
30
+ const next = parseBindings([...this.bindings, binding]);
31
+ mkdirSync(dirname(this.path), { recursive: true });
32
+ const temp = `${this.path}.tmp`;
33
+ writeFileSync(temp, JSON.stringify(next), { mode: 0o600 });
34
+ renameSync(temp, this.path);
35
+ this.bindings = next;
36
+ }
37
+ }
@@ -0,0 +1 @@
1
+ {"id": "lma-go", "name": "LMA Go", "description": "Independent LMA Go persisted Checker tool enforcement", "configSchema": {"type": "object", "additionalProperties": false, "properties": {"bindings": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["checkerSessionKey", "checkerSessionId", "executorSessionKey", "executorSessionId"], "properties": {"checkerSessionKey": {"type": "string"}, "checkerSessionId": {"type": "string"}, "executorSessionKey": {"type": "string"}, "executorSessionId": {"type": "string"}, "goalId": {"type": "string"}}}}}}}
@@ -0,0 +1 @@
1
+ {"name":"@hackerphysics/lma-go","version":"0.1.0","type":"module","openclaw":{"extensions":["./dist/index.js"]}}
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Update the consolidated lma-core gateway plugin from the LMA repo source.
3
+ #
4
+ # Usage: bash scripts/update-lma-core.sh (build + deploy + hot reload)
5
+ # bash scripts/update-lma-core.sh --check (verify only, no changes)
6
+ #
7
+ # Requirements: repo at ~/openclaw-lark-multi-agent with the plugin sources
8
+ # under plugins/lma-core/src. The gateway hot-reloads the plugin; no gateway
9
+ # restart is needed for code updates (method names are stable).
10
+ set -euo pipefail
11
+
12
+ REPO="${LMA_REPO:-$HOME/openclaw-lark-multi-agent}"
13
+ EXT_DIR="$HOME/.openclaw/extensions/lma-core"
14
+ PLUGIN_ID="lma-core"
15
+
16
+ say() { printf '[update-lma-core] %s\n' "$*"; }
17
+
18
+ if [[ "${1:-}" == "--check" ]]; then
19
+ say "verifying deployed plugin health..."
20
+ openclaw gateway call health 2>/dev/null | grep -o "\"$PLUGIN_ID\"" >/dev/null \
21
+ && say "OK: $PLUGIN_ID loaded in gateway" || { say "FAIL: $PLUGIN_ID not in loaded list"; exit 1; }
22
+ openclaw gateway call lma.core.capabilities 2>/dev/null | grep -q '"lma-core"' \
23
+ && say "OK: lma.core.capabilities responds" || { say "FAIL: capabilities RPC"; exit 1; }
24
+ say "all checks passed."
25
+ exit 0
26
+ fi
27
+
28
+ cd "$REPO"
29
+ say "building plugins/lma-core..."
30
+ npm run build:core-plugin
31
+
32
+ say "deploying to $EXT_DIR..."
33
+ mkdir -p "$EXT_DIR"
34
+ rm -rf "$EXT_DIR/dist"
35
+ cp -r plugins/lma-core/dist "$EXT_DIR/dist"
36
+ cp plugins/lma-core/openclaw.plugin.json plugins/lma-core/package.json "$EXT_DIR/"
37
+ if [[ -f plugins/lma-core/README.md ]]; then cp plugins/lma-core/README.md "$EXT_DIR/"; fi
38
+
39
+ say "hot-reloading $PLUGIN_ID..."
40
+ openclaw plugins reload "$PLUGIN_ID"
41
+
42
+ say "verifying..."
43
+ sleep 1
44
+ bash "$0" --check
45
+ say "done. (rollback: git -C $REPO checkout <prev> && re-run this script)"