@agentchatme/openclaw 0.7.8 → 0.7.82
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/CHANGELOG.md +232 -176
- package/LICENSE +21 -21
- package/README.md +308 -297
- package/RUNBOOK.md +134 -134
- package/SECURITY.md +104 -104
- package/dist/binding/agents-anchor.cjs.map +1 -1
- package/dist/binding/agents-anchor.d.cts +1 -1
- package/dist/binding/agents-anchor.d.ts +1 -1
- package/dist/binding/agents-anchor.js.map +1 -1
- package/dist/configured-state.cjs.map +1 -1
- package/dist/configured-state.js.map +1 -1
- package/dist/credentials/read-env.cjs.map +1 -1
- package/dist/credentials/read-env.js.map +1 -1
- package/dist/index.cjs +858 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +840 -134
- package/dist/index.js.map +1 -1
- package/dist/setup-entry.cjs +856 -131
- package/dist/setup-entry.cjs.map +1 -1
- package/dist/setup-entry.js +838 -132
- package/dist/setup-entry.js.map +1 -1
- package/icon.svg +5 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +20 -16
- package/skills/agentchat/SKILL.md +331 -331
package/dist/setup-entry.js
CHANGED
|
@@ -6,10 +6,16 @@ import { z } from 'zod';
|
|
|
6
6
|
import { waitUntilAbort } from 'openclaw/plugin-sdk/channel-lifecycle';
|
|
7
7
|
import pino from 'pino';
|
|
8
8
|
import { WebSocket } from 'ws';
|
|
9
|
-
import { dispatchInboundDirectDmWithRuntime } from 'openclaw/plugin-sdk/direct-dm';
|
|
10
9
|
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from 'openclaw/plugin-sdk/inbound-envelope';
|
|
11
|
-
import {
|
|
10
|
+
import { dispatchChannelInboundReply } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
|
11
|
+
import fs2 from 'fs';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import os__default from 'os';
|
|
14
|
+
import * as path2 from 'path';
|
|
15
|
+
import path2__default from 'path';
|
|
16
|
+
import { readOpenClawProfileFromEnv } from './credentials/read-env.js';
|
|
12
17
|
import { AgentChatClient } from 'agentchatme';
|
|
18
|
+
import { prepareSimpleCompletionModelForAgent, completeWithPreparedSimpleCompletionModel } from 'openclaw/plugin-sdk/simple-completion-runtime';
|
|
13
19
|
import { Type } from '@sinclair/typebox';
|
|
14
20
|
|
|
15
21
|
// src/channel.setup.ts
|
|
@@ -263,9 +269,9 @@ async function registerAgentVerify(input, opts = {}) {
|
|
|
263
269
|
}
|
|
264
270
|
return { ok: false, reason: "server-error", status: res.status, message };
|
|
265
271
|
}
|
|
266
|
-
async function post(
|
|
272
|
+
async function post(path3, body, opts) {
|
|
267
273
|
const base = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
268
|
-
const url = `${base}${
|
|
274
|
+
const url = `${base}${path3}`;
|
|
269
275
|
const controller = new AbortController();
|
|
270
276
|
const fetchImpl = opts.fetch ?? fetch;
|
|
271
277
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -900,9 +906,9 @@ function createLogger(options) {
|
|
|
900
906
|
function buildRedactPaths(keys) {
|
|
901
907
|
const prefixes = ["", "config.", "headers.", "context.", "req.headers.", "res.headers."];
|
|
902
908
|
const paths = [];
|
|
903
|
-
for (const
|
|
909
|
+
for (const key2 of keys) {
|
|
904
910
|
for (const prefix of prefixes) {
|
|
905
|
-
paths.push(`${prefix}${
|
|
911
|
+
paths.push(`${prefix}${key2}`);
|
|
906
912
|
}
|
|
907
913
|
}
|
|
908
914
|
paths.push(...keys.map((k) => `*.${k}`));
|
|
@@ -1509,6 +1515,19 @@ var messageContentSchema = z.object({
|
|
|
1509
1515
|
data: z.record(z.string(), z.unknown()).optional(),
|
|
1510
1516
|
attachment_id: z.string().optional()
|
|
1511
1517
|
}).passthrough();
|
|
1518
|
+
var messageContextSchema = z.object({
|
|
1519
|
+
sender: z.object({
|
|
1520
|
+
handle: z.string(),
|
|
1521
|
+
display_name: z.string().nullable().optional(),
|
|
1522
|
+
kind: z.enum(["agent", "system"]).optional()
|
|
1523
|
+
}).passthrough().optional(),
|
|
1524
|
+
conversation: z.object({
|
|
1525
|
+
type: z.enum(["direct", "group"]).optional(),
|
|
1526
|
+
group_name: z.string().nullable().optional(),
|
|
1527
|
+
member_count: z.number().int().nullable().optional()
|
|
1528
|
+
}).passthrough().optional(),
|
|
1529
|
+
mentions: z.array(z.string()).optional()
|
|
1530
|
+
}).passthrough();
|
|
1512
1531
|
var messageSchema = z.object({
|
|
1513
1532
|
id: z.string(),
|
|
1514
1533
|
conversation_id: z.string(),
|
|
@@ -1518,6 +1537,7 @@ var messageSchema = z.object({
|
|
|
1518
1537
|
type: z.enum(["text", "structured", "file", "system"]),
|
|
1519
1538
|
content: messageContentSchema,
|
|
1520
1539
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
1540
|
+
context: messageContextSchema.optional(),
|
|
1521
1541
|
// Per-recipient delivery state lives in `message_deliveries` since
|
|
1522
1542
|
// migration 011 — the `messages` row no longer carries `status`,
|
|
1523
1543
|
// `delivered_at`, or `read_at`. Fresh-send envelopes over WS omit
|
|
@@ -1639,7 +1659,12 @@ function normalizeMessageNew(frame) {
|
|
|
1639
1659
|
createdAt: msg.created_at,
|
|
1640
1660
|
deliveredAt: msg.delivered_at ?? null,
|
|
1641
1661
|
readAt: msg.read_at ?? null,
|
|
1642
|
-
receivedAt: frame.receivedAt
|
|
1662
|
+
receivedAt: frame.receivedAt,
|
|
1663
|
+
senderDisplayName: msg.context?.sender?.display_name ?? null,
|
|
1664
|
+
senderKind: msg.context?.sender?.kind === "system" ? "system" : "agent",
|
|
1665
|
+
groupName: msg.context?.conversation?.group_name ?? null,
|
|
1666
|
+
memberCount: msg.context?.conversation?.member_count ?? null,
|
|
1667
|
+
mentions: (msg.context?.mentions ?? []).map((m) => m.toLowerCase())
|
|
1643
1668
|
};
|
|
1644
1669
|
}
|
|
1645
1670
|
function normalizeMessageRead(frame) {
|
|
@@ -1718,7 +1743,7 @@ function normalizeGroupDeleted(frame) {
|
|
|
1718
1743
|
|
|
1719
1744
|
// src/retry.ts
|
|
1720
1745
|
function defaultSleep(ms) {
|
|
1721
|
-
return new Promise((
|
|
1746
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1722
1747
|
}
|
|
1723
1748
|
async function retryWithPolicy(fn, policy) {
|
|
1724
1749
|
const random = policy.random ?? Math.random;
|
|
@@ -1846,7 +1871,7 @@ var CircuitBreaker = class {
|
|
|
1846
1871
|
};
|
|
1847
1872
|
|
|
1848
1873
|
// src/version.ts
|
|
1849
|
-
var PACKAGE_VERSION = "0.7.
|
|
1874
|
+
var PACKAGE_VERSION = "0.7.82";
|
|
1850
1875
|
|
|
1851
1876
|
// src/outbound.ts
|
|
1852
1877
|
var DEFAULT_RETRY_POLICY = {
|
|
@@ -2062,11 +2087,11 @@ var OutboundAdapter = class {
|
|
|
2062
2087
|
`outbound queue full (${this.queue.length}) \u2014 shedding load`
|
|
2063
2088
|
);
|
|
2064
2089
|
}
|
|
2065
|
-
return new Promise((
|
|
2090
|
+
return new Promise((resolve2) => {
|
|
2066
2091
|
this.queue.push(() => {
|
|
2067
2092
|
this.inFlight++;
|
|
2068
2093
|
this.metrics.setInFlightDepth(this.inFlight);
|
|
2069
|
-
|
|
2094
|
+
resolve2();
|
|
2070
2095
|
});
|
|
2071
2096
|
});
|
|
2072
2097
|
}
|
|
@@ -2142,10 +2167,10 @@ var AgentchatChannelRuntime = class {
|
|
|
2142
2167
|
stop(deadlineMs) {
|
|
2143
2168
|
if (this.stopPromise) return this.stopPromise;
|
|
2144
2169
|
const deadline = deadlineMs ?? this.now() + 5e3;
|
|
2145
|
-
this.stopPromise = new Promise((
|
|
2170
|
+
this.stopPromise = new Promise((resolve2) => {
|
|
2146
2171
|
const off = this.ws.on("closed", () => {
|
|
2147
2172
|
off();
|
|
2148
|
-
|
|
2173
|
+
resolve2();
|
|
2149
2174
|
});
|
|
2150
2175
|
this.ws.stop(deadline);
|
|
2151
2176
|
});
|
|
@@ -2375,6 +2400,476 @@ function unregisterRuntime(accountId, deadlineMs = Date.now() + 5e3) {
|
|
|
2375
2400
|
function getRuntime(accountId) {
|
|
2376
2401
|
return registry.get(accountId)?.runtime;
|
|
2377
2402
|
}
|
|
2403
|
+
function resolveWorkspaceDir(cfg) {
|
|
2404
|
+
const configured = cfg?.agents?.defaults?.workspace;
|
|
2405
|
+
if (typeof configured === "string" && configured.trim().length > 0) {
|
|
2406
|
+
return path2.resolve(configured);
|
|
2407
|
+
}
|
|
2408
|
+
const profile = readOpenClawProfileFromEnv();
|
|
2409
|
+
if (profile) {
|
|
2410
|
+
return path2.join(os.homedir(), ".openclaw", `workspace-${profile}`);
|
|
2411
|
+
}
|
|
2412
|
+
return path2.join(os.homedir(), ".openclaw", "workspace");
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/binding/thread-closures.ts
|
|
2416
|
+
var instances = /* @__PURE__ */ new Map();
|
|
2417
|
+
function fallbackWorkspaceDir() {
|
|
2418
|
+
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
|
2419
|
+
if (profile && profile.toLowerCase() !== "default") {
|
|
2420
|
+
return path2__default.join(os__default.homedir(), ".openclaw", `workspace-${profile}`);
|
|
2421
|
+
}
|
|
2422
|
+
return path2__default.join(os__default.homedir(), ".openclaw", "workspace");
|
|
2423
|
+
}
|
|
2424
|
+
function resolveStatePath(cfg, accountId) {
|
|
2425
|
+
const workspaceDir = typeof cfg === "object" && cfg !== null ? resolveWorkspaceDir(cfg) : fallbackWorkspaceDir();
|
|
2426
|
+
return path2__default.join(workspaceDir, `.agentchat-closed-threads-${accountId}.json`);
|
|
2427
|
+
}
|
|
2428
|
+
var ThreadClosures = class {
|
|
2429
|
+
filePath;
|
|
2430
|
+
closed = /* @__PURE__ */ new Map();
|
|
2431
|
+
constructor(filePath) {
|
|
2432
|
+
this.filePath = filePath;
|
|
2433
|
+
this.load();
|
|
2434
|
+
}
|
|
2435
|
+
isClosed(conversationId) {
|
|
2436
|
+
return this.closed.has(conversationId);
|
|
2437
|
+
}
|
|
2438
|
+
close(conversationId, reason) {
|
|
2439
|
+
const record = {
|
|
2440
|
+
conversationId,
|
|
2441
|
+
closedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2442
|
+
reason: reason?.trim() ? reason.trim() : null
|
|
2443
|
+
};
|
|
2444
|
+
this.closed.set(conversationId, record);
|
|
2445
|
+
this.save();
|
|
2446
|
+
return record;
|
|
2447
|
+
}
|
|
2448
|
+
reopen(conversationId) {
|
|
2449
|
+
const existed = this.closed.delete(conversationId);
|
|
2450
|
+
if (existed) this.save();
|
|
2451
|
+
return existed;
|
|
2452
|
+
}
|
|
2453
|
+
list() {
|
|
2454
|
+
return [...this.closed.values()].sort((a, b) => b.closedAt.localeCompare(a.closedAt));
|
|
2455
|
+
}
|
|
2456
|
+
load() {
|
|
2457
|
+
if (!fs2.existsSync(this.filePath)) return;
|
|
2458
|
+
try {
|
|
2459
|
+
const raw = JSON.parse(fs2.readFileSync(this.filePath, "utf8"));
|
|
2460
|
+
if (!Array.isArray(raw)) return;
|
|
2461
|
+
for (const entry of raw) {
|
|
2462
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2463
|
+
const conversationId = entry.conversationId;
|
|
2464
|
+
const closedAt = entry.closedAt;
|
|
2465
|
+
const reason = entry.reason;
|
|
2466
|
+
if (typeof conversationId !== "string" || typeof closedAt !== "string") continue;
|
|
2467
|
+
this.closed.set(conversationId, {
|
|
2468
|
+
conversationId,
|
|
2469
|
+
closedAt,
|
|
2470
|
+
reason: typeof reason === "string" ? reason : null
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
} catch {
|
|
2474
|
+
this.closed = /* @__PURE__ */ new Map();
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
save() {
|
|
2478
|
+
fs2.mkdirSync(path2__default.dirname(this.filePath), { recursive: true });
|
|
2479
|
+
const payload = JSON.stringify(this.list(), null, 2);
|
|
2480
|
+
const tempPath = `${this.filePath}.tmp`;
|
|
2481
|
+
fs2.writeFileSync(tempPath, payload, "utf8");
|
|
2482
|
+
fs2.renameSync(tempPath, this.filePath);
|
|
2483
|
+
}
|
|
2484
|
+
};
|
|
2485
|
+
function getThreadClosures(cfg, accountId) {
|
|
2486
|
+
const filePath = resolveStatePath(cfg, accountId);
|
|
2487
|
+
const existing = instances.get(filePath);
|
|
2488
|
+
if (existing) return existing;
|
|
2489
|
+
const created = new ThreadClosures(filePath);
|
|
2490
|
+
instances.set(filePath, created);
|
|
2491
|
+
return created;
|
|
2492
|
+
}
|
|
2493
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2494
|
+
function getClient({ accountId, config, options }) {
|
|
2495
|
+
const existing = cache.get(accountId);
|
|
2496
|
+
if (existing && existing.apiKey === config.apiKey && existing.apiBase === config.apiBase) {
|
|
2497
|
+
return existing.client;
|
|
2498
|
+
}
|
|
2499
|
+
const client = new AgentChatClient({
|
|
2500
|
+
apiKey: config.apiKey,
|
|
2501
|
+
baseUrl: config.apiBase,
|
|
2502
|
+
...options
|
|
2503
|
+
});
|
|
2504
|
+
cache.set(accountId, {
|
|
2505
|
+
client,
|
|
2506
|
+
apiKey: config.apiKey,
|
|
2507
|
+
apiBase: config.apiBase
|
|
2508
|
+
});
|
|
2509
|
+
return client;
|
|
2510
|
+
}
|
|
2511
|
+
function disposeClient(accountId) {
|
|
2512
|
+
cache.delete(accountId);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// src/binding/send-tracker.ts
|
|
2516
|
+
var lastSendByKey = /* @__PURE__ */ new Map();
|
|
2517
|
+
var MAX_TRACKED = 512;
|
|
2518
|
+
function key(accountId, conversationId) {
|
|
2519
|
+
return `${accountId}\0${conversationId}`;
|
|
2520
|
+
}
|
|
2521
|
+
function recordAgentSend(accountId, conversationId, atMs = Date.now()) {
|
|
2522
|
+
if (!conversationId) return;
|
|
2523
|
+
const k = key(accountId, conversationId);
|
|
2524
|
+
lastSendByKey.delete(k);
|
|
2525
|
+
lastSendByKey.set(k, atMs);
|
|
2526
|
+
if (lastSendByKey.size > MAX_TRACKED) {
|
|
2527
|
+
const oldest = lastSendByKey.keys().next().value;
|
|
2528
|
+
if (oldest !== void 0) lastSendByKey.delete(oldest);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
function hasAgentSendSince(accountId, conversationId, sinceMs) {
|
|
2532
|
+
const at = lastSendByKey.get(key(accountId, conversationId));
|
|
2533
|
+
return at !== void 0 && at >= sinceMs;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// src/binding/reply-gate.ts
|
|
2537
|
+
var DEFAULT_GATE_MAX_TOKENS = 256;
|
|
2538
|
+
var MAX_HISTORY_TURNS = 12;
|
|
2539
|
+
var CADENCE_WINDOW_SECONDS = 60;
|
|
2540
|
+
var VALID_CATEGORIES = /* @__PURE__ */ new Set([
|
|
2541
|
+
"open_request",
|
|
2542
|
+
"new_info",
|
|
2543
|
+
"goal_followup",
|
|
2544
|
+
"closing",
|
|
2545
|
+
"acknowledgement",
|
|
2546
|
+
"not_addressed",
|
|
2547
|
+
"no_action_needed",
|
|
2548
|
+
"spam",
|
|
2549
|
+
"other"
|
|
2550
|
+
]);
|
|
2551
|
+
var REPLY_TOKENS = /* @__PURE__ */ new Set(["reply", "yes", "true", "respond"]);
|
|
2552
|
+
var NO_REPLY_TOKENS = /* @__PURE__ */ new Set([
|
|
2553
|
+
"no_reply",
|
|
2554
|
+
"no-reply",
|
|
2555
|
+
"noreply",
|
|
2556
|
+
"no",
|
|
2557
|
+
"false",
|
|
2558
|
+
"silent",
|
|
2559
|
+
"skip",
|
|
2560
|
+
"none"
|
|
2561
|
+
]);
|
|
2562
|
+
var FENCE_RE = /```(?:json)?\s*([\s\S]+?)```/i;
|
|
2563
|
+
function computeConversationSignals(messages, params) {
|
|
2564
|
+
const windowSeconds = params.windowSeconds ?? CADENCE_WINDOW_SECONDS;
|
|
2565
|
+
const own = normalizeHandle(params.ownHandle);
|
|
2566
|
+
const prior = messages.filter(
|
|
2567
|
+
(m) => isRecord(m) && m.id !== params.triggerMessageId
|
|
2568
|
+
);
|
|
2569
|
+
const firstContact = prior.length === 0;
|
|
2570
|
+
const youHaveSpoken = prior.some((m) => messageIsOwn(m, own));
|
|
2571
|
+
const timestamps = [];
|
|
2572
|
+
for (const m of prior) {
|
|
2573
|
+
const ts = parseTimestamp(m.created_at);
|
|
2574
|
+
if (ts !== null) timestamps.push(ts);
|
|
2575
|
+
}
|
|
2576
|
+
const cutoff = params.nowMs - windowSeconds * 1e3;
|
|
2577
|
+
const recentInWindow = timestamps.filter((ts) => ts >= cutoff).length;
|
|
2578
|
+
let secondsSincePrevious = null;
|
|
2579
|
+
if (timestamps.length > 0) {
|
|
2580
|
+
const delta = (params.nowMs - Math.max(...timestamps)) / 1e3;
|
|
2581
|
+
secondsSincePrevious = delta > 0 ? delta : 0;
|
|
2582
|
+
}
|
|
2583
|
+
return {
|
|
2584
|
+
firstContact,
|
|
2585
|
+
youHaveSpoken,
|
|
2586
|
+
messagesLastWindow: recentInWindow + 1,
|
|
2587
|
+
// +1 for the new message
|
|
2588
|
+
secondsSincePrevious
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
function messageIsOwn(msg, ownHandleNorm) {
|
|
2592
|
+
if (typeof msg.is_own === "boolean") return msg.is_own;
|
|
2593
|
+
const sender = msg.sender ?? msg.from ?? msg.sender_handle ?? "";
|
|
2594
|
+
return normalizeHandle(String(sender)) === ownHandleNorm;
|
|
2595
|
+
}
|
|
2596
|
+
function parseTimestamp(raw) {
|
|
2597
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
2598
|
+
const t = Date.parse(raw);
|
|
2599
|
+
return Number.isNaN(t) ? null : t;
|
|
2600
|
+
}
|
|
2601
|
+
function normalizeHandle(handle) {
|
|
2602
|
+
return handle.replace(/^@/, "").toLowerCase();
|
|
2603
|
+
}
|
|
2604
|
+
function isRecord(value) {
|
|
2605
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2606
|
+
}
|
|
2607
|
+
function systemTemplate(handle) {
|
|
2608
|
+
const h = `@${handle}`;
|
|
2609
|
+
return `You are the reply gate for ${h}, an autonomous agent on AgentChat (a peer-to-peer messaging network for AI agents). A message just arrived. Your only job is to decide whether ${h} should reply to it now. You do NOT write the reply \u2014 you output one decision.
|
|
2610
|
+
|
|
2611
|
+
"no_reply" is a SUCCESS, not a failure. Most healthy conversations are SUPPOSED to end; going quiet is the normal, correct outcome and is never rude here \u2014 on this network silence IS the acknowledgement.
|
|
2612
|
+
|
|
2613
|
+
Judge DONE-NESS, not how interesting another message could be:
|
|
2614
|
+
|
|
2615
|
+
Choose "reply" only if a further message accomplishes a concrete, still-OPEN purpose:
|
|
2616
|
+
- answer a substantive pending question or supply specifically requested information
|
|
2617
|
+
- make or respond to a decision, or unblock the peer on a real task
|
|
2618
|
+
- ${h} started this thread toward a goal and the peer's reply needs a substantive follow-up to reach it
|
|
2619
|
+
- new information genuinely requires ${h}'s input
|
|
2620
|
+
|
|
2621
|
+
Choose "no_reply" when the exchange has reached its natural end \u2014 even if another clever or friendly message is easily possible:
|
|
2622
|
+
- it is trading pleasantries, mutual appreciation, agreement, or open-ended tangents / "riffing" with no open objective
|
|
2623
|
+
- a courtesy question that merely mirrors the exchange back ("and you?", "what are you working on?", "what tools are you using?") after the substantive part has run its course is part of the pleasantry, not an open task \u2014 it does not oblige a reply
|
|
2624
|
+
- the other side is acknowledging or closing out (thanks / ok / got it / sounds good / \u{1F44D} / bye) and replying would only prolong it
|
|
2625
|
+
- ${h} already answered what was asked and nothing new is on the table
|
|
2626
|
+
- in a group, the message is not addressed to ${h} and does not need it. (Groups only \u2014 in a direct conversation every message is addressed to ${h} by definition, so "not_addressed" never applies there.)
|
|
2627
|
+
|
|
2628
|
+
"I could add something" is NOT a reason to reply. "Something concrete is unresolved and my reply resolves it" IS. Two agents keeping a chat alive by each politely asking the next question is the exact failure you exist to prevent \u2014 the thread being pleasant does not make it open. If the Pace line shows messages flying back and forth with each turn only restating, appreciating, or re-asking a mirrored question, that IS the loop \u2014 choose "no_reply". When unsure, prefer "no_reply".
|
|
2629
|
+
|
|
2630
|
+
Respond with ONLY a JSON object \u2014 no prose, no markdown fences:
|
|
2631
|
+
{"decision": "reply" or "no_reply", "reason": "<one short sentence>", "category": "<one of: open_request, new_info, goal_followup, closing, acknowledgement, not_addressed, no_action_needed, spam, other>"}`;
|
|
2632
|
+
}
|
|
2633
|
+
function buildDecisionMessages(params) {
|
|
2634
|
+
return [
|
|
2635
|
+
{ role: "system", content: systemTemplate(params.handle) },
|
|
2636
|
+
{
|
|
2637
|
+
role: "user",
|
|
2638
|
+
content: buildUserContent({
|
|
2639
|
+
handle: params.handle,
|
|
2640
|
+
event: params.event,
|
|
2641
|
+
history: params.history,
|
|
2642
|
+
signals: params.signals ?? null,
|
|
2643
|
+
maxHistory: params.maxHistory ?? MAX_HISTORY_TURNS
|
|
2644
|
+
})
|
|
2645
|
+
}
|
|
2646
|
+
];
|
|
2647
|
+
}
|
|
2648
|
+
function formatReceivedAt(ms) {
|
|
2649
|
+
if (!Number.isFinite(ms)) return "an unknown time";
|
|
2650
|
+
const iso = new Date(ms).toISOString();
|
|
2651
|
+
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
2652
|
+
}
|
|
2653
|
+
function formatConversationContext(params) {
|
|
2654
|
+
const { handle, event, signals, priorCount } = params;
|
|
2655
|
+
const lines = [`Conversation type: ${formatConversationLabel(event)}`];
|
|
2656
|
+
if (signals) {
|
|
2657
|
+
lines.push(`Relationship: ${relationshipPhrase(signals)}`);
|
|
2658
|
+
if (signals.secondsSincePrevious !== null) {
|
|
2659
|
+
lines.push(
|
|
2660
|
+
`Pace: ${signals.messagesLastWindow} message(s) in the last ${CADENCE_WINDOW_SECONDS}s; ${formatGap(signals.secondsSincePrevious)} since the previous message`
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
lines.push(`Prior messages in this thread: ${priorCount}`);
|
|
2665
|
+
if (event.conversationKind === "group" && handle && (event.mentions ?? []).includes(handle.toLowerCase())) {
|
|
2666
|
+
lines.push("You were @-mentioned in this message.");
|
|
2667
|
+
}
|
|
2668
|
+
return lines;
|
|
2669
|
+
}
|
|
2670
|
+
function formatConversationLabel(event) {
|
|
2671
|
+
if (event.conversationKind !== "group") return "direct";
|
|
2672
|
+
let label = event.groupName ? `group "${event.groupName}"` : "group";
|
|
2673
|
+
if (event.memberCount != null) {
|
|
2674
|
+
label += ` (${event.memberCount} member${event.memberCount === 1 ? "" : "s"})`;
|
|
2675
|
+
}
|
|
2676
|
+
return label;
|
|
2677
|
+
}
|
|
2678
|
+
function buildUserContent(params) {
|
|
2679
|
+
const { handle, event, history, signals, maxHistory } = params;
|
|
2680
|
+
const lines = formatConversationContext({
|
|
2681
|
+
handle,
|
|
2682
|
+
event,
|
|
2683
|
+
signals,
|
|
2684
|
+
priorCount: history.length
|
|
2685
|
+
});
|
|
2686
|
+
lines.push("");
|
|
2687
|
+
const rendered = renderHistory(history, maxHistory);
|
|
2688
|
+
if (rendered.length > 0) {
|
|
2689
|
+
lines.push("Recent conversation (oldest first):");
|
|
2690
|
+
lines.push(...rendered);
|
|
2691
|
+
} else {
|
|
2692
|
+
lines.push("Recent conversation: (none \u2014 this is first contact)");
|
|
2693
|
+
}
|
|
2694
|
+
let newText = collapseWhitespace(event.contentText || "");
|
|
2695
|
+
if (newText.length > 2e3) newText = `${newText.slice(0, 2e3)}\u2026`;
|
|
2696
|
+
lines.push("");
|
|
2697
|
+
lines.push(`New message from @${event.senderHandle}: ${newText}`);
|
|
2698
|
+
lines.push("");
|
|
2699
|
+
lines.push("Decide now: reply or no_reply?");
|
|
2700
|
+
return lines.join("\n");
|
|
2701
|
+
}
|
|
2702
|
+
function renderHistory(history, maxHistory) {
|
|
2703
|
+
const recent = history.length > maxHistory ? history.slice(history.length - maxHistory) : history;
|
|
2704
|
+
const out = [];
|
|
2705
|
+
for (const turn of recent) {
|
|
2706
|
+
if (typeof turn.content !== "string" || !turn.content.trim()) continue;
|
|
2707
|
+
const speaker = turn.role === "assistant" ? "you" : "peer";
|
|
2708
|
+
let text = collapseWhitespace(turn.content);
|
|
2709
|
+
if (text.length > 400) text = `${text.slice(0, 400)}\u2026`;
|
|
2710
|
+
out.push(`${speaker}: ${text}`);
|
|
2711
|
+
}
|
|
2712
|
+
return out;
|
|
2713
|
+
}
|
|
2714
|
+
function relationshipPhrase(signals) {
|
|
2715
|
+
if (signals.firstContact) return "first contact \u2014 no prior messages in this thread";
|
|
2716
|
+
if (signals.youHaveSpoken) return "established \u2014 you have already replied in this thread";
|
|
2717
|
+
return "this peer is messaging you, but you have not replied yet";
|
|
2718
|
+
}
|
|
2719
|
+
function formatGap(seconds) {
|
|
2720
|
+
if (seconds < 90) return `${Math.round(seconds)}s`;
|
|
2721
|
+
if (seconds < 5400) return `${Math.round(seconds / 60)}m`;
|
|
2722
|
+
return `${Math.round(seconds / 3600)}h`;
|
|
2723
|
+
}
|
|
2724
|
+
function collapseWhitespace(text) {
|
|
2725
|
+
return text.split(/\s+/).filter(Boolean).join(" ");
|
|
2726
|
+
}
|
|
2727
|
+
function parseDecision(text, opts = {}) {
|
|
2728
|
+
const raw = extractJson(text);
|
|
2729
|
+
if (raw === null) return null;
|
|
2730
|
+
let obj;
|
|
2731
|
+
try {
|
|
2732
|
+
obj = JSON.parse(raw);
|
|
2733
|
+
} catch {
|
|
2734
|
+
return null;
|
|
2735
|
+
}
|
|
2736
|
+
if (!isRecord(obj)) return null;
|
|
2737
|
+
const decisionRaw = obj.decision;
|
|
2738
|
+
if (typeof decisionRaw !== "string") return null;
|
|
2739
|
+
const token = decisionRaw.trim().toLowerCase();
|
|
2740
|
+
let reply;
|
|
2741
|
+
if (REPLY_TOKENS.has(token)) reply = true;
|
|
2742
|
+
else if (NO_REPLY_TOKENS.has(token)) reply = false;
|
|
2743
|
+
else return null;
|
|
2744
|
+
const reasonRaw = obj.reason;
|
|
2745
|
+
let reason = typeof reasonRaw === "string" ? reasonRaw.trim() : "";
|
|
2746
|
+
if (reason.length > 280) reason = reason.slice(0, 280);
|
|
2747
|
+
const categoryRaw = obj.category;
|
|
2748
|
+
let category = typeof categoryRaw === "string" ? categoryRaw.trim().toLowerCase() : "other";
|
|
2749
|
+
if (!VALID_CATEGORIES.has(category)) category = "other";
|
|
2750
|
+
return {
|
|
2751
|
+
reply,
|
|
2752
|
+
reason,
|
|
2753
|
+
category,
|
|
2754
|
+
source: opts.source ?? "llm",
|
|
2755
|
+
latencyMs: opts.latencyMs ?? 0
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
function extractJson(text) {
|
|
2759
|
+
if (!text || !text.trim()) return null;
|
|
2760
|
+
let s = text.trim();
|
|
2761
|
+
const fence = FENCE_RE.exec(s);
|
|
2762
|
+
if (fence?.[1]) s = fence[1].trim();
|
|
2763
|
+
const start = s.indexOf("{");
|
|
2764
|
+
const end = s.lastIndexOf("}");
|
|
2765
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
2766
|
+
return s.slice(start, end + 1);
|
|
2767
|
+
}
|
|
2768
|
+
function gateFallback(failOpen, reason, latencyMs) {
|
|
2769
|
+
return {
|
|
2770
|
+
reply: failOpen,
|
|
2771
|
+
reason,
|
|
2772
|
+
category: "fallback",
|
|
2773
|
+
source: failOpen ? "fail_open" : "fail_closed",
|
|
2774
|
+
latencyMs
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// src/binding/gate.ts
|
|
2779
|
+
var DEFAULT_GATE_TIMEOUT_MS = 2e4;
|
|
2780
|
+
var GATE_REASONING_LEVEL = "off";
|
|
2781
|
+
var GateTimeoutError = class extends Error {
|
|
2782
|
+
};
|
|
2783
|
+
function withTimeout(promise, ms) {
|
|
2784
|
+
if (!Number.isFinite(ms) || ms <= 0) return promise;
|
|
2785
|
+
return new Promise((resolve2, reject) => {
|
|
2786
|
+
const timer = setTimeout(() => reject(new GateTimeoutError("gate decision timed out")), ms);
|
|
2787
|
+
promise.then(
|
|
2788
|
+
(value) => {
|
|
2789
|
+
clearTimeout(timer);
|
|
2790
|
+
resolve2(value);
|
|
2791
|
+
},
|
|
2792
|
+
(err3) => {
|
|
2793
|
+
clearTimeout(timer);
|
|
2794
|
+
reject(err3);
|
|
2795
|
+
}
|
|
2796
|
+
);
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
async function decideReply(params) {
|
|
2800
|
+
const signals = computeConversationSignals(params.rawMessages, {
|
|
2801
|
+
ownHandle: params.ownHandle,
|
|
2802
|
+
triggerMessageId: params.triggerMessageId,
|
|
2803
|
+
nowMs: params.nowMs
|
|
2804
|
+
});
|
|
2805
|
+
const messages = buildDecisionMessages({
|
|
2806
|
+
handle: params.handle,
|
|
2807
|
+
event: params.event,
|
|
2808
|
+
history: params.history,
|
|
2809
|
+
signals
|
|
2810
|
+
});
|
|
2811
|
+
const systemPrompt = messages.find((m) => m.role === "system")?.content ?? "";
|
|
2812
|
+
const userContent = messages.find((m) => m.role === "user")?.content ?? "";
|
|
2813
|
+
const caller = params.caller ?? createSimpleCompletionGateCaller(params.cfg, params.agentId);
|
|
2814
|
+
const maxTokens = params.maxTokens ?? DEFAULT_GATE_MAX_TOKENS;
|
|
2815
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
|
|
2816
|
+
const start = Date.now();
|
|
2817
|
+
let text;
|
|
2818
|
+
try {
|
|
2819
|
+
text = await withTimeout(caller({ systemPrompt, userContent, maxTokens }), timeoutMs);
|
|
2820
|
+
} catch (err3) {
|
|
2821
|
+
if (err3 instanceof GateTimeoutError) {
|
|
2822
|
+
return gateFallback(params.failOpen, "decision_timeout", Date.now() - start);
|
|
2823
|
+
}
|
|
2824
|
+
const detail = err3 instanceof Error ? err3.message : String(err3);
|
|
2825
|
+
return gateFallback(
|
|
2826
|
+
params.failOpen,
|
|
2827
|
+
`decision_call_error: ${detail}`.slice(0, 220),
|
|
2828
|
+
Date.now() - start
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
const latencyMs = Date.now() - start;
|
|
2832
|
+
const parsed = parseDecision(text, { source: "llm", latencyMs });
|
|
2833
|
+
return parsed ?? gateFallback(params.failOpen, "unparseable_decision", latencyMs);
|
|
2834
|
+
}
|
|
2835
|
+
function createSimpleCompletionGateCaller(cfg, agentId) {
|
|
2836
|
+
return async ({ systemPrompt, userContent, maxTokens, signal }) => {
|
|
2837
|
+
const prepared = await prepareSimpleCompletionModelForAgent({
|
|
2838
|
+
cfg,
|
|
2839
|
+
// plugin-local OpenClawConfig alias → sdk's internal type
|
|
2840
|
+
agentId,
|
|
2841
|
+
allowMissingApiKeyModes: ["aws-sdk"],
|
|
2842
|
+
// Do NOT skip discovery: it loads provider model catalogs. Skipping it
|
|
2843
|
+
// works for providers with pure dynamic resolution (e.g. Fireworks) but
|
|
2844
|
+
// makes catalog-based providers (Google/Gemini, Anthropic, OpenAI, …)
|
|
2845
|
+
// fail with "Unknown model: <ref>" — the gate must resolve the agent's
|
|
2846
|
+
// own model regardless of which provider it runs on.
|
|
2847
|
+
skipAgentDiscovery: false
|
|
2848
|
+
});
|
|
2849
|
+
if ("error" in prepared) throw new Error(prepared.error);
|
|
2850
|
+
const result = await completeWithPreparedSimpleCompletionModel({
|
|
2851
|
+
model: prepared.model,
|
|
2852
|
+
auth: prepared.auth,
|
|
2853
|
+
cfg,
|
|
2854
|
+
context: {
|
|
2855
|
+
systemPrompt,
|
|
2856
|
+
messages: [{ role: "user", content: userContent }]
|
|
2857
|
+
},
|
|
2858
|
+
// temperature 0 mirrors the Hermes gate: a binary policy decision must
|
|
2859
|
+
// be as deterministic as the provider allows, not sampled creatively.
|
|
2860
|
+
options: {
|
|
2861
|
+
maxTokens,
|
|
2862
|
+
temperature: 0,
|
|
2863
|
+
reasoning: GATE_REASONING_LEVEL,
|
|
2864
|
+
...signal ? { signal } : {}
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
2867
|
+
return result.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("");
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/binding/inbound-bridge.ts
|
|
2872
|
+
var GATE_HISTORY_LIMIT = 30;
|
|
2378
2873
|
function createInboundBridge(deps) {
|
|
2379
2874
|
return async function onInbound(event) {
|
|
2380
2875
|
switch (event.kind) {
|
|
@@ -2411,6 +2906,17 @@ async function handleMessage(deps, event) {
|
|
|
2411
2906
|
if (!body && !event.content.attachmentId && !event.content.data) {
|
|
2412
2907
|
return;
|
|
2413
2908
|
}
|
|
2909
|
+
const threadClosures = getThreadClosures(
|
|
2910
|
+
deps.gatewayCfg,
|
|
2911
|
+
deps.accountId
|
|
2912
|
+
);
|
|
2913
|
+
if (threadClosures.isClosed(event.conversationId)) {
|
|
2914
|
+
deps.logger.info(
|
|
2915
|
+
{ conversationId: event.conversationId, messageId: event.messageId, sender: event.sender },
|
|
2916
|
+
"inbound skipped for locally closed thread"
|
|
2917
|
+
);
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2414
2920
|
const channelRuntime = deps.channelRuntime;
|
|
2415
2921
|
if (!channelRuntime || typeof channelRuntime.reply?.dispatchReplyWithBufferedBlockDispatcher !== "function") {
|
|
2416
2922
|
deps.logger.error(
|
|
@@ -2425,6 +2931,7 @@ async function handleMessage(deps, event) {
|
|
|
2425
2931
|
);
|
|
2426
2932
|
return;
|
|
2427
2933
|
}
|
|
2934
|
+
const ts = typeof event.createdAt === "number" ? event.createdAt : Date.parse(event.createdAt);
|
|
2428
2935
|
const recipientHandle = selfHandle ?? "me";
|
|
2429
2936
|
const conversationLabel = event.conversationKind === "group" ? `group ${event.conversationId}` : `dm with @${senderHandle}`;
|
|
2430
2937
|
const sendReply = async (replyText) => {
|
|
@@ -2437,36 +2944,131 @@ async function handleMessage(deps, event) {
|
|
|
2437
2944
|
metadata: { reply_to: event.messageId }
|
|
2438
2945
|
});
|
|
2439
2946
|
};
|
|
2947
|
+
const turnStartMs = Date.now();
|
|
2440
2948
|
const deliver2 = async (payload) => {
|
|
2441
|
-
|
|
2442
|
-
|
|
2949
|
+
if (threadClosures.isClosed(event.conversationId)) {
|
|
2950
|
+
deps.logger.info(
|
|
2951
|
+
{ conversationId: event.conversationId, messageId: event.messageId },
|
|
2952
|
+
"reply suppressed for locally closed thread"
|
|
2953
|
+
);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
if (hasAgentSendSince(deps.accountId, event.conversationId, turnStartMs)) {
|
|
2957
|
+
deps.logger.info(
|
|
2958
|
+
{ conversationId: event.conversationId, messageId: event.messageId },
|
|
2959
|
+
"final turn text suppressed \u2014 agent already sent its reply via a message tool this turn"
|
|
2960
|
+
);
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
await sendReply(payload.text ?? extractText(payload.blocks));
|
|
2443
2964
|
};
|
|
2444
2965
|
try {
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2966
|
+
const runtime = channelRuntime;
|
|
2967
|
+
const peer = event.conversationKind === "group" ? { kind: "group", id: event.conversationId } : { kind: "direct", id: senderHandle };
|
|
2968
|
+
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
2969
|
+
cfg: deps.gatewayCfg,
|
|
2970
|
+
channel: "agentchat",
|
|
2971
|
+
accountId: deps.accountId,
|
|
2972
|
+
peer,
|
|
2973
|
+
runtime
|
|
2974
|
+
});
|
|
2975
|
+
let gateContext = null;
|
|
2976
|
+
if (gateEnabled()) {
|
|
2977
|
+
const { decision, context } = await runReplyGate({
|
|
2978
|
+
deps,
|
|
2979
|
+
event,
|
|
2980
|
+
body,
|
|
2981
|
+
agentId: route.agentId,
|
|
2982
|
+
selfHandle,
|
|
2983
|
+
senderHandle,
|
|
2984
|
+
nowMs: Number.isFinite(ts) ? ts : Date.now()
|
|
2985
|
+
});
|
|
2986
|
+
deps.logger.info(
|
|
2987
|
+
{
|
|
2988
|
+
conversationId: event.conversationId,
|
|
2989
|
+
messageId: event.messageId,
|
|
2990
|
+
reply: decision.reply,
|
|
2991
|
+
source: decision.source,
|
|
2992
|
+
category: decision.category,
|
|
2993
|
+
latencyMs: decision.latencyMs,
|
|
2994
|
+
reason: decision.reason
|
|
2468
2995
|
},
|
|
2469
|
-
|
|
2996
|
+
"reply gate decision"
|
|
2997
|
+
);
|
|
2998
|
+
if (!decision.reply) return;
|
|
2999
|
+
gateContext = context;
|
|
3000
|
+
}
|
|
3001
|
+
const contextHeader = [formatSenderLine(event), `Received: ${formatReceivedAt(ts)}`];
|
|
3002
|
+
if (gateContext) {
|
|
3003
|
+
contextHeader.push(...gateContext);
|
|
3004
|
+
} else {
|
|
3005
|
+
contextHeader.push(
|
|
3006
|
+
`Conversation type: ${formatConversationLabel({
|
|
3007
|
+
conversationKind: event.conversationKind,
|
|
3008
|
+
senderHandle,
|
|
3009
|
+
contentText: body,
|
|
3010
|
+
groupName: event.groupName,
|
|
3011
|
+
memberCount: event.memberCount,
|
|
3012
|
+
mentions: event.mentions
|
|
3013
|
+
})}`
|
|
3014
|
+
);
|
|
3015
|
+
const self = (selfHandle ?? "").replace(/^@/, "").toLowerCase();
|
|
3016
|
+
if (event.conversationKind === "group" && self && event.mentions.includes(self)) {
|
|
3017
|
+
contextHeader.push("You were @-mentioned in this message.");
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
const agentBody = `${contextHeader.join("\n")}
|
|
3021
|
+
|
|
3022
|
+
${body}`;
|
|
3023
|
+
const { storePath, body: envelopeBody } = buildEnvelope({
|
|
3024
|
+
channel: "AgentChat",
|
|
3025
|
+
from: conversationLabel,
|
|
3026
|
+
body: agentBody,
|
|
3027
|
+
timestamp: ts
|
|
3028
|
+
});
|
|
3029
|
+
const finalize = channelRuntime.reply.finalizeInboundContext;
|
|
3030
|
+
const ctxPayload = finalize({
|
|
3031
|
+
Body: envelopeBody,
|
|
3032
|
+
BodyForAgent: agentBody,
|
|
3033
|
+
RawBody: body,
|
|
3034
|
+
CommandBody: body,
|
|
3035
|
+
From: `@${senderHandle}`,
|
|
3036
|
+
To: `@${recipientHandle}`,
|
|
3037
|
+
SessionKey: route.sessionKey,
|
|
3038
|
+
AccountId: deps.accountId,
|
|
3039
|
+
ChatType: event.conversationKind === "group" ? "group" : "direct",
|
|
3040
|
+
ConversationLabel: conversationLabel,
|
|
3041
|
+
SenderId: senderHandle,
|
|
3042
|
+
Provider: "agentchat",
|
|
3043
|
+
Surface: "agentchat",
|
|
3044
|
+
MessageSid: event.messageId,
|
|
3045
|
+
MessageSidFull: event.messageId,
|
|
3046
|
+
Timestamp: ts,
|
|
3047
|
+
OriginatingChannel: "agentchat",
|
|
3048
|
+
OriginatingTo: `@${recipientHandle}`
|
|
3049
|
+
});
|
|
3050
|
+
const session = channelRuntime.session;
|
|
3051
|
+
await dispatchChannelInboundReply({
|
|
3052
|
+
cfg: deps.gatewayCfg,
|
|
3053
|
+
channel: "agentchat",
|
|
3054
|
+
accountId: deps.accountId,
|
|
3055
|
+
agentId: route.agentId,
|
|
3056
|
+
routeSessionKey: route.sessionKey,
|
|
3057
|
+
storePath,
|
|
3058
|
+
ctxPayload,
|
|
3059
|
+
recordInboundSession: session.recordInboundSession,
|
|
3060
|
+
dispatchReplyWithBufferedBlockDispatcher: channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
3061
|
+
// Under `automatic` (the default) the framework hands the agent's final
|
|
3062
|
+
// turn text to this `deliver`, which sends it to the source. Under the
|
|
3063
|
+
// opt-in `message_tool_only` mode the turn text is suppressed and the
|
|
3064
|
+
// agent sends via the message tool instead, so `deliver` only fires on a
|
|
3065
|
+
// framework fallback. Either way a gated `no_reply` turn never runs, so
|
|
3066
|
+
// nothing is sent.
|
|
3067
|
+
delivery: {
|
|
3068
|
+
deliver: async (payload) => {
|
|
3069
|
+
await deliver2(payload);
|
|
3070
|
+
},
|
|
3071
|
+
onError: (err3, info) => {
|
|
2470
3072
|
deps.logger.error(
|
|
2471
3073
|
{
|
|
2472
3074
|
err: err3 instanceof Error ? err3.message : String(err3),
|
|
@@ -2476,84 +3078,124 @@ async function handleMessage(deps, event) {
|
|
|
2476
3078
|
"inbound dispatch failed"
|
|
2477
3079
|
);
|
|
2478
3080
|
}
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
const runtime = channelRuntime;
|
|
2483
|
-
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
2484
|
-
cfg: deps.gatewayCfg,
|
|
2485
|
-
channel: "agentchat",
|
|
2486
|
-
accountId: deps.accountId,
|
|
2487
|
-
peer: { kind: "group", id: event.conversationId },
|
|
2488
|
-
runtime
|
|
2489
|
-
});
|
|
2490
|
-
const { storePath, body: envelopeBody } = buildEnvelope({
|
|
2491
|
-
channel: "AgentChat",
|
|
2492
|
-
from: conversationLabel,
|
|
2493
|
-
body,
|
|
2494
|
-
timestamp: ts
|
|
2495
|
-
});
|
|
2496
|
-
const finalize = channelRuntime.reply.finalizeInboundContext;
|
|
2497
|
-
const ctxPayload = finalize({
|
|
2498
|
-
Body: envelopeBody,
|
|
2499
|
-
BodyForAgent: body,
|
|
2500
|
-
RawBody: body,
|
|
2501
|
-
CommandBody: body,
|
|
2502
|
-
From: `@${senderHandle}`,
|
|
2503
|
-
To: `@${recipientHandle}`,
|
|
2504
|
-
SessionKey: route.sessionKey,
|
|
2505
|
-
AccountId: deps.accountId,
|
|
2506
|
-
ChatType: "group",
|
|
2507
|
-
ConversationLabel: conversationLabel,
|
|
2508
|
-
SenderId: senderHandle,
|
|
2509
|
-
Provider: "agentchat",
|
|
2510
|
-
Surface: "agentchat",
|
|
2511
|
-
MessageSid: event.messageId,
|
|
2512
|
-
MessageSidFull: event.messageId,
|
|
2513
|
-
Timestamp: ts,
|
|
2514
|
-
OriginatingChannel: "agentchat",
|
|
2515
|
-
OriginatingTo: `@${recipientHandle}`
|
|
2516
|
-
});
|
|
2517
|
-
const session = channelRuntime.session;
|
|
2518
|
-
await recordInboundSessionAndDispatchReply({
|
|
2519
|
-
cfg: deps.gatewayCfg,
|
|
2520
|
-
channel: "agentchat",
|
|
2521
|
-
accountId: deps.accountId,
|
|
2522
|
-
agentId: route.agentId,
|
|
2523
|
-
routeSessionKey: route.sessionKey,
|
|
2524
|
-
storePath,
|
|
2525
|
-
ctxPayload,
|
|
2526
|
-
recordInboundSession: session.recordInboundSession,
|
|
2527
|
-
dispatchReplyWithBufferedBlockDispatcher: channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
2528
|
-
deliver: deliver2,
|
|
3081
|
+
},
|
|
3082
|
+
replyOptions: { sourceReplyDeliveryMode: resolveSourceReplyMode() },
|
|
3083
|
+
record: {
|
|
2529
3084
|
onRecordError: (err3) => {
|
|
2530
3085
|
deps.logger.error(
|
|
2531
3086
|
{ err: err3 instanceof Error ? err3.message : String(err3), messageId: event.messageId },
|
|
2532
|
-
"recordInboundSession failed
|
|
2533
|
-
);
|
|
2534
|
-
},
|
|
2535
|
-
onDispatchError: (err3, info) => {
|
|
2536
|
-
deps.logger.error(
|
|
2537
|
-
{
|
|
2538
|
-
err: err3 instanceof Error ? err3.message : String(err3),
|
|
2539
|
-
messageId: event.messageId,
|
|
2540
|
-
kind: info.kind
|
|
2541
|
-
},
|
|
2542
|
-
"inbound dispatch failed (group)"
|
|
3087
|
+
"recordInboundSession failed"
|
|
2543
3088
|
);
|
|
2544
3089
|
}
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
2547
3092
|
} catch (err3) {
|
|
2548
3093
|
deps.logger.error(
|
|
2549
|
-
{
|
|
2550
|
-
err: err3 instanceof Error ? err3.message : String(err3),
|
|
2551
|
-
messageId: event.messageId
|
|
2552
|
-
},
|
|
3094
|
+
{ err: err3 instanceof Error ? err3.message : String(err3), messageId: event.messageId },
|
|
2553
3095
|
"inbound dispatch failed"
|
|
2554
3096
|
);
|
|
2555
3097
|
}
|
|
2556
3098
|
}
|
|
3099
|
+
function formatSenderLine(event) {
|
|
3100
|
+
const who = event.senderDisplayName ? `${event.senderDisplayName} (@${event.sender})` : `@${event.sender}`;
|
|
3101
|
+
return `From: ${event.senderKind === "system" ? `${who}, a system agent` : who}`;
|
|
3102
|
+
}
|
|
3103
|
+
async function runReplyGate(params) {
|
|
3104
|
+
const { deps, event, body, agentId, selfHandle, senderHandle, nowMs } = params;
|
|
3105
|
+
const ownHandle = selfHandle ?? "";
|
|
3106
|
+
let rawMessages = [];
|
|
3107
|
+
try {
|
|
3108
|
+
const client = getClient({ accountId: deps.accountId, config: deps.config });
|
|
3109
|
+
const fetched = await client.getMessages(event.conversationId, { limit: GATE_HISTORY_LIMIT });
|
|
3110
|
+
if (Array.isArray(fetched)) rawMessages = fetched;
|
|
3111
|
+
} catch (err3) {
|
|
3112
|
+
deps.logger.warn(
|
|
3113
|
+
{ err: err3 instanceof Error ? err3.message : String(err3), conversationId: event.conversationId },
|
|
3114
|
+
"reply gate: history fetch failed \u2014 deciding on the new message alone"
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
const bareHandle = ownHandle.replace(/^@/, "");
|
|
3118
|
+
const gateEvent = {
|
|
3119
|
+
conversationKind: event.conversationKind,
|
|
3120
|
+
senderHandle,
|
|
3121
|
+
contentText: body,
|
|
3122
|
+
groupName: event.groupName,
|
|
3123
|
+
memberCount: event.memberCount,
|
|
3124
|
+
mentions: event.mentions
|
|
3125
|
+
};
|
|
3126
|
+
const history = translateHistory(rawMessages, ownHandle, event.conversationKind, event.messageId);
|
|
3127
|
+
const decision = await decideReply({
|
|
3128
|
+
cfg: deps.gatewayCfg,
|
|
3129
|
+
agentId,
|
|
3130
|
+
handle: bareHandle,
|
|
3131
|
+
event: gateEvent,
|
|
3132
|
+
history,
|
|
3133
|
+
rawMessages,
|
|
3134
|
+
triggerMessageId: event.messageId,
|
|
3135
|
+
ownHandle,
|
|
3136
|
+
nowMs,
|
|
3137
|
+
failOpen: gateFailOpen(),
|
|
3138
|
+
timeoutMs: gateTimeoutMs(),
|
|
3139
|
+
caller: deps.gateCaller
|
|
3140
|
+
});
|
|
3141
|
+
const signals = computeConversationSignals(rawMessages, {
|
|
3142
|
+
ownHandle,
|
|
3143
|
+
triggerMessageId: event.messageId,
|
|
3144
|
+
nowMs
|
|
3145
|
+
});
|
|
3146
|
+
const context = formatConversationContext({
|
|
3147
|
+
handle: bareHandle,
|
|
3148
|
+
event: gateEvent,
|
|
3149
|
+
signals,
|
|
3150
|
+
priorCount: history.length
|
|
3151
|
+
});
|
|
3152
|
+
return { decision, context };
|
|
3153
|
+
}
|
|
3154
|
+
function translateHistory(messages, ownHandle, conversationKind, triggerMessageId) {
|
|
3155
|
+
const own = ownHandle.replace(/^@/, "").toLowerCase();
|
|
3156
|
+
const isGroup = conversationKind === "group";
|
|
3157
|
+
const sorted = [...messages].filter((m) => Boolean(m) && typeof m === "object").sort((a, b) => readSeq(a) - readSeq(b));
|
|
3158
|
+
const out = [];
|
|
3159
|
+
for (const m of sorted) {
|
|
3160
|
+
if (m.id === triggerMessageId) continue;
|
|
3161
|
+
const type = typeof m.type === "string" ? m.type : "text";
|
|
3162
|
+
if (type !== "text") continue;
|
|
3163
|
+
const content = m.content;
|
|
3164
|
+
const text = content && typeof content === "object" ? content.text : void 0;
|
|
3165
|
+
if (typeof text !== "string" || !text) continue;
|
|
3166
|
+
const senderRaw = String(m.sender ?? m.from ?? m.sender_handle ?? "");
|
|
3167
|
+
const isOwn = typeof m.is_own === "boolean" ? m.is_own : senderRaw.replace(/^@/, "").toLowerCase() === own;
|
|
3168
|
+
if (isOwn) {
|
|
3169
|
+
out.push({ role: "assistant", content: text });
|
|
3170
|
+
} else if (isGroup) {
|
|
3171
|
+
out.push({ role: "user", content: `[@${senderRaw.replace(/^@/, "") || "?"}] ${text}` });
|
|
3172
|
+
} else {
|
|
3173
|
+
out.push({ role: "user", content: text });
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
return out;
|
|
3177
|
+
}
|
|
3178
|
+
function readSeq(m) {
|
|
3179
|
+
const seq = m.seq;
|
|
3180
|
+
return typeof seq === "number" ? seq : 0;
|
|
3181
|
+
}
|
|
3182
|
+
var OFF_TOKENS = /* @__PURE__ */ new Set(["0", "false", "off", "no"]);
|
|
3183
|
+
var ON_TOKENS = /* @__PURE__ */ new Set(["1", "true", "on", "yes"]);
|
|
3184
|
+
function gateEnabled() {
|
|
3185
|
+
return !OFF_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_ENABLED ?? "").trim().toLowerCase());
|
|
3186
|
+
}
|
|
3187
|
+
function gateFailOpen() {
|
|
3188
|
+
return ON_TOKENS.has((process.env.AGENTCHAT_REPLY_GATE_FAIL_OPEN ?? "").trim().toLowerCase());
|
|
3189
|
+
}
|
|
3190
|
+
function gateTimeoutMs() {
|
|
3191
|
+
const raw = process.env.AGENTCHAT_REPLY_GATE_TIMEOUT_MS;
|
|
3192
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
3193
|
+
const n = Number(raw);
|
|
3194
|
+
return Number.isFinite(n) && n > 0 ? n : void 0;
|
|
3195
|
+
}
|
|
3196
|
+
function resolveSourceReplyMode() {
|
|
3197
|
+
return (process.env.AGENTCHAT_SOURCE_REPLY_MODE ?? "").trim().toLowerCase() === "message_tool_only" ? "message_tool_only" : "automatic";
|
|
3198
|
+
}
|
|
2557
3199
|
function handleGroupInvite(deps, event) {
|
|
2558
3200
|
deps.logger.info(
|
|
2559
3201
|
{
|
|
@@ -2701,27 +3343,6 @@ var agentchatGatewayAdapter = {
|
|
|
2701
3343
|
});
|
|
2702
3344
|
}
|
|
2703
3345
|
};
|
|
2704
|
-
var cache = /* @__PURE__ */ new Map();
|
|
2705
|
-
function getClient({ accountId, config, options }) {
|
|
2706
|
-
const existing = cache.get(accountId);
|
|
2707
|
-
if (existing && existing.apiKey === config.apiKey && existing.apiBase === config.apiBase) {
|
|
2708
|
-
return existing.client;
|
|
2709
|
-
}
|
|
2710
|
-
const client = new AgentChatClient({
|
|
2711
|
-
apiKey: config.apiKey,
|
|
2712
|
-
baseUrl: config.apiBase,
|
|
2713
|
-
...options
|
|
2714
|
-
});
|
|
2715
|
-
cache.set(accountId, {
|
|
2716
|
-
client,
|
|
2717
|
-
apiKey: config.apiKey,
|
|
2718
|
-
apiBase: config.apiBase
|
|
2719
|
-
});
|
|
2720
|
-
return client;
|
|
2721
|
-
}
|
|
2722
|
-
function disposeClient(accountId) {
|
|
2723
|
-
cache.delete(accountId);
|
|
2724
|
-
}
|
|
2725
3346
|
|
|
2726
3347
|
// src/binding/outbound.ts
|
|
2727
3348
|
function resolveConfig(cfg, accountId) {
|
|
@@ -2783,6 +3404,7 @@ async function deliver(ctx, attachmentId) {
|
|
|
2783
3404
|
attachmentId
|
|
2784
3405
|
);
|
|
2785
3406
|
const result = await runtime.sendMessage(input);
|
|
3407
|
+
recordAgentSend(accountId, result.message.conversation_id);
|
|
2786
3408
|
return {
|
|
2787
3409
|
channel: AGENTCHAT_CHANNEL_ID,
|
|
2788
3410
|
messageId: result.message.id,
|
|
@@ -2852,8 +3474,8 @@ async function uploadMediaFromUrl(ctx, mediaUrl) {
|
|
|
2852
3474
|
let contentType;
|
|
2853
3475
|
let filename = "attachment";
|
|
2854
3476
|
if (mediaUrl.startsWith("file://") && ctx.mediaReadFile) {
|
|
2855
|
-
const
|
|
2856
|
-
const buf = await ctx.mediaReadFile(
|
|
3477
|
+
const path3 = decodeURIComponent(mediaUrl.replace(/^file:\/\//, ""));
|
|
3478
|
+
const buf = await ctx.mediaReadFile(path3);
|
|
2857
3479
|
if (buf.byteLength > MAX_MEDIA_BYTES) {
|
|
2858
3480
|
throw new AgentChatChannelError(
|
|
2859
3481
|
"terminal-user",
|
|
@@ -2863,7 +3485,7 @@ async function uploadMediaFromUrl(ctx, mediaUrl) {
|
|
|
2863
3485
|
const copy = new Uint8Array(buf.byteLength);
|
|
2864
3486
|
copy.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
2865
3487
|
bytes = copy.buffer;
|
|
2866
|
-
filename =
|
|
3488
|
+
filename = path3.split(/[\\/]/).pop() ?? filename;
|
|
2867
3489
|
} else {
|
|
2868
3490
|
assertMediaUrlSafe(mediaUrl);
|
|
2869
3491
|
const controller = new AbortController();
|
|
@@ -3018,8 +3640,8 @@ function err(message) {
|
|
|
3018
3640
|
details: { error: message }
|
|
3019
3641
|
};
|
|
3020
3642
|
}
|
|
3021
|
-
function str(params,
|
|
3022
|
-
const v = params[
|
|
3643
|
+
function str(params, key2) {
|
|
3644
|
+
const v = params[key2];
|
|
3023
3645
|
return typeof v === "string" ? v : void 0;
|
|
3024
3646
|
}
|
|
3025
3647
|
function resolveConfig2(ctx) {
|
|
@@ -3276,6 +3898,7 @@ var agentchatAgentToolsFactory = ({ cfg }) => {
|
|
|
3276
3898
|
content: { text: p.message },
|
|
3277
3899
|
...p.replyToMessageId ? { metadata: { reply_to: p.replyToMessageId } } : {}
|
|
3278
3900
|
});
|
|
3901
|
+
recordAgentSend(r.accountId, result.message.conversation_id);
|
|
3279
3902
|
const summaryParts = [
|
|
3280
3903
|
`sent to @${handle} \u2014 message ${result.message.id} in ${result.message.conversation_id}`
|
|
3281
3904
|
];
|
|
@@ -3798,6 +4421,88 @@ ${lines.join("\n")}`);
|
|
|
3798
4421
|
}
|
|
3799
4422
|
}
|
|
3800
4423
|
}),
|
|
4424
|
+
tool({
|
|
4425
|
+
name: "agentchat_close_local_thread",
|
|
4426
|
+
description: "Locally close a conversation thread for this OpenClaw AgentChat account. Future inbound on this exact conversation id will not enter the reply pipeline here, but this does not block the peer and either side can still start a brand-new thread later.",
|
|
4427
|
+
parameters: Type.Object({
|
|
4428
|
+
conversationId: Type.String(),
|
|
4429
|
+
reason: Type.Optional(Type.String({ maxLength: 200 })),
|
|
4430
|
+
account: ACCOUNT_PARAM
|
|
4431
|
+
}),
|
|
4432
|
+
execute: async (_id, p) => {
|
|
4433
|
+
const r = clientFor(cfg, p.account);
|
|
4434
|
+
if ("error" in r) return err2(r.error);
|
|
4435
|
+
const closures = getThreadClosures(cfg, r.accountId);
|
|
4436
|
+
const record = closures.close(p.conversationId, p.reason);
|
|
4437
|
+
return {
|
|
4438
|
+
content: [
|
|
4439
|
+
{
|
|
4440
|
+
type: "text",
|
|
4441
|
+
text: `closed ${record.conversationId} locally`
|
|
4442
|
+
}
|
|
4443
|
+
],
|
|
4444
|
+
details: {
|
|
4445
|
+
conversationId: record.conversationId,
|
|
4446
|
+
closedAt: record.closedAt,
|
|
4447
|
+
reason: record.reason,
|
|
4448
|
+
localOnly: true
|
|
4449
|
+
}
|
|
4450
|
+
};
|
|
4451
|
+
}
|
|
4452
|
+
}),
|
|
4453
|
+
tool({
|
|
4454
|
+
name: "agentchat_reopen_local_thread",
|
|
4455
|
+
description: "Re-open a previously locally closed conversation thread so new inbound on that conversation id can reach the reply pipeline again.",
|
|
4456
|
+
parameters: Type.Object({
|
|
4457
|
+
conversationId: Type.String(),
|
|
4458
|
+
account: ACCOUNT_PARAM
|
|
4459
|
+
}),
|
|
4460
|
+
execute: async (_id, p) => {
|
|
4461
|
+
const r = clientFor(cfg, p.account);
|
|
4462
|
+
if ("error" in r) return err2(r.error);
|
|
4463
|
+
const closures = getThreadClosures(cfg, r.accountId);
|
|
4464
|
+
const reopened = closures.reopen(p.conversationId);
|
|
4465
|
+
return {
|
|
4466
|
+
content: [
|
|
4467
|
+
{
|
|
4468
|
+
type: "text",
|
|
4469
|
+
text: reopened ? `re-opened ${p.conversationId} locally` : `${p.conversationId} was not locally closed`
|
|
4470
|
+
}
|
|
4471
|
+
],
|
|
4472
|
+
details: {
|
|
4473
|
+
conversationId: p.conversationId,
|
|
4474
|
+
reopened,
|
|
4475
|
+
localOnly: true
|
|
4476
|
+
}
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
}),
|
|
4480
|
+
tool({
|
|
4481
|
+
name: "agentchat_list_local_closed_threads",
|
|
4482
|
+
description: "List conversation threads currently closed locally for this OpenClaw AgentChat account. These are client-side only, not server-side blocks or mutes.",
|
|
4483
|
+
parameters: Type.Object({
|
|
4484
|
+
account: ACCOUNT_PARAM
|
|
4485
|
+
}),
|
|
4486
|
+
execute: async (_id, p) => {
|
|
4487
|
+
const r = clientFor(cfg, p.account);
|
|
4488
|
+
if ("error" in r) return err2(r.error);
|
|
4489
|
+
const closures = getThreadClosures(cfg, r.accountId);
|
|
4490
|
+
const closed = closures.list();
|
|
4491
|
+
if (closed.length === 0) {
|
|
4492
|
+
return {
|
|
4493
|
+
content: [{ type: "text", text: "no locally closed threads" }],
|
|
4494
|
+
details: { closedThreads: [], localOnly: true }
|
|
4495
|
+
};
|
|
4496
|
+
}
|
|
4497
|
+
const lines = closed.map(
|
|
4498
|
+
(record) => `${record.conversationId} \u2014 ${record.closedAt}${record.reason ? ` \u2014 ${record.reason}` : ""}`
|
|
4499
|
+
);
|
|
4500
|
+
return {
|
|
4501
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
4502
|
+
details: { closedThreads: closed, localOnly: true }
|
|
4503
|
+
};
|
|
4504
|
+
}
|
|
4505
|
+
}),
|
|
3801
4506
|
tool({
|
|
3802
4507
|
name: "agentchat_get_conversation_history",
|
|
3803
4508
|
description: "Fetch recent messages from a specific conversation. Use this to: catch up on a thread you've been away from, load context before replying to an old message, or read back what you and a contact discussed last time. Returns messages oldest-first so the tail of your output is the most recent. Pass `beforeSeq` to paginate further back.",
|
|
@@ -4096,6 +4801,7 @@ ${lines.join("\n")}`);
|
|
|
4096
4801
|
to: "chatfather",
|
|
4097
4802
|
content: { text: p.message }
|
|
4098
4803
|
});
|
|
4804
|
+
recordAgentSend(r.accountId, result.message.conversation_id);
|
|
4099
4805
|
return ok2(
|
|
4100
4806
|
`message sent to @chatfather (id: ${result.message.id}). Watch your inbox for the reply.`
|
|
4101
4807
|
);
|