@botcord/daemon 0.2.44 → 0.2.46
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.
|
@@ -280,6 +280,13 @@ export class OpenclawAcpAdapter {
|
|
|
280
280
|
if (capped) {
|
|
281
281
|
log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
|
|
282
282
|
}
|
|
283
|
+
if (!finalText) {
|
|
284
|
+
const stopReason = pickStopReason(promptResult);
|
|
285
|
+
const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
|
|
286
|
+
const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
|
|
287
|
+
const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
|
|
288
|
+
return failResult(acpSessionId, `openclaw-acp: ${reason}${detail}`);
|
|
289
|
+
}
|
|
283
290
|
return {
|
|
284
291
|
text: finalText,
|
|
285
292
|
newSessionId: acpSessionId,
|
|
@@ -359,6 +366,7 @@ export class OpenclawAcpAdapter {
|
|
|
359
366
|
subscribers: new Map(),
|
|
360
367
|
nextId: 1,
|
|
361
368
|
buffer: "",
|
|
369
|
+
nonJsonStdoutTail: [],
|
|
362
370
|
initialized: false,
|
|
363
371
|
inFlight: 0,
|
|
364
372
|
closed: false,
|
|
@@ -431,6 +439,10 @@ function onStdoutChunk(handle, chunk) {
|
|
|
431
439
|
msg = JSON.parse(line);
|
|
432
440
|
}
|
|
433
441
|
catch (err) {
|
|
442
|
+
handle.nonJsonStdoutTail.push(line.slice(0, 500));
|
|
443
|
+
if (handle.nonJsonStdoutTail.length > 20) {
|
|
444
|
+
handle.nonJsonStdoutTail.splice(0, handle.nonJsonStdoutTail.length - 20);
|
|
445
|
+
}
|
|
434
446
|
log.warn("openclaw-acp.parse-error", {
|
|
435
447
|
error: err instanceof Error ? err.message : String(err),
|
|
436
448
|
line: line.slice(0, 200),
|
|
@@ -744,6 +756,15 @@ function pickFinalText(result) {
|
|
|
744
756
|
return r.message;
|
|
745
757
|
return undefined;
|
|
746
758
|
}
|
|
759
|
+
function pickStopReason(result) {
|
|
760
|
+
if (!result || typeof result !== "object")
|
|
761
|
+
return undefined;
|
|
762
|
+
const v = result.stopReason;
|
|
763
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
764
|
+
}
|
|
765
|
+
function truncateDetail(text, max) {
|
|
766
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
767
|
+
}
|
|
747
768
|
function looksLikeReasoningLeak(text) {
|
|
748
769
|
const t = text.trim();
|
|
749
770
|
if (!t)
|
package/dist/turn-text.js
CHANGED
|
@@ -5,15 +5,18 @@ const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly ment
|
|
|
5
5
|
const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
|
|
6
6
|
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
7
7
|
/**
|
|
8
|
-
* Reminder appended to
|
|
9
|
-
* dispatcher discards `result.text` for
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Reminder appended to wrapped BotCord network rooms that are not owner-chat.
|
|
9
|
+
* The dispatcher discards `result.text` for those rooms, so the agent must
|
|
10
|
+
* call the `botcord_send` tool (or the `botcord send` CLI via Bash) to
|
|
11
|
+
* actually deliver a reply. Plain assistant text in those rooms is logged
|
|
12
|
+
* and dropped.
|
|
13
13
|
*/
|
|
14
14
|
const NON_OWNER_REPLY_HINT = "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
|
|
15
15
|
"To reply, call the `botcord_send` tool, or run " +
|
|
16
16
|
'`botcord send --room <room_id> --text "..."` via Bash.]';
|
|
17
|
+
const THIRD_PARTY_REPLY_HINT = "[This is a third-party gateway chat. Reply normally in your final assistant " +
|
|
18
|
+
"message; BotCord daemon will deliver that text through the same channel. " +
|
|
19
|
+
"No extra send tool is required for this chat.]";
|
|
17
20
|
/**
|
|
18
21
|
* Read the BotCord envelope type from a raw inbound message. Returns
|
|
19
22
|
* `undefined` when the message didn't come from the BotCord channel or the
|
|
@@ -28,6 +31,15 @@ function readEnvelopeType(raw) {
|
|
|
28
31
|
const t = env.type;
|
|
29
32
|
return typeof t === "string" ? t : undefined;
|
|
30
33
|
}
|
|
34
|
+
function isThirdPartyConversation(conversationId) {
|
|
35
|
+
return (conversationId.startsWith("telegram:") ||
|
|
36
|
+
conversationId.startsWith("wechat:"));
|
|
37
|
+
}
|
|
38
|
+
function replyDeliveryHint(msg) {
|
|
39
|
+
return isThirdPartyConversation(msg.conversation.id)
|
|
40
|
+
? THIRD_PARTY_REPLY_HINT
|
|
41
|
+
: NON_OWNER_REPLY_HINT;
|
|
42
|
+
}
|
|
31
43
|
/**
|
|
32
44
|
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
33
45
|
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
@@ -127,7 +139,7 @@ export function composeBotCordUserTurn(msg) {
|
|
|
127
139
|
"",
|
|
128
140
|
hint,
|
|
129
141
|
"",
|
|
130
|
-
|
|
142
|
+
replyDeliveryHint(msg),
|
|
131
143
|
];
|
|
132
144
|
if (contactRequestHint) {
|
|
133
145
|
lines.push("", contactRequestHint);
|
|
@@ -178,7 +190,7 @@ function composeBatchedTurn(msg, batch) {
|
|
|
178
190
|
"",
|
|
179
191
|
hint,
|
|
180
192
|
"",
|
|
181
|
-
|
|
193
|
+
replyDeliveryHint(msg),
|
|
182
194
|
];
|
|
183
195
|
if (contactRequestSenders.length > 0) {
|
|
184
196
|
// Dedup + list — multiple distinct senders show as "A, B".
|
package/package.json
CHANGED
|
@@ -162,6 +162,46 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
162
162
|
expect(spawnFn.mock.calls[0][1]).toEqual(["acp", "--url", "ws://127.0.0.1:1"]);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
it("returns an error instead of empty text when OpenClaw emits warnings and no assistant text", async () => {
|
|
166
|
+
const child = new FakeChild();
|
|
167
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
168
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
169
|
+
name: "local",
|
|
170
|
+
url: "ws://127.0.0.1:1",
|
|
171
|
+
openclawAgent: "main",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
175
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
176
|
+
const frame = JSON.parse(line);
|
|
177
|
+
if (frame.method === "initialize") {
|
|
178
|
+
child.stdout.write("◇ Config warnings ─────────────────────╮\n");
|
|
179
|
+
child.stdout.write("│ - models.providers.foo.apiKey: Missing env var FOO_API_KEY │\n");
|
|
180
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
181
|
+
} else if (frame.method === "session/new") {
|
|
182
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-warn" } }) + "\n");
|
|
183
|
+
} else if (frame.method === "session/prompt") {
|
|
184
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "error" } }) + "\n");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const res = await adapter.run({
|
|
190
|
+
text: "hi",
|
|
191
|
+
sessionId: null,
|
|
192
|
+
cwd: "/tmp",
|
|
193
|
+
accountId: "ag_alice",
|
|
194
|
+
signal: new AbortController().signal,
|
|
195
|
+
trustLevel: "owner",
|
|
196
|
+
gateway,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(res.text).toBe("");
|
|
200
|
+
expect(res.newSessionId).toBe("sid-warn");
|
|
201
|
+
expect(res.error).toContain("prompt stopped: error");
|
|
202
|
+
expect(res.error).toContain("Missing env var FOO_API_KEY");
|
|
203
|
+
});
|
|
204
|
+
|
|
165
205
|
it("streams only final text when OpenClaw sends reasoning before a final block", async () => {
|
|
166
206
|
const child = new FakeChild();
|
|
167
207
|
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
@@ -68,6 +68,43 @@ describe("composeBotCordUserTurn", () => {
|
|
|
68
68
|
expect(out).not.toContain("do NOT reply unless");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it("keeps the botcord_send delivery hint for non-owner BotCord rooms", () => {
|
|
72
|
+
const out = composeBotCordUserTurn(
|
|
73
|
+
makeMessage({
|
|
74
|
+
conversation: { id: "rm_dm_xxx", kind: "direct" },
|
|
75
|
+
sender: { id: "ag_peer", kind: "agent" },
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
expect(out).toContain("Plain text output WILL NOT be sent");
|
|
79
|
+
expect(out).toContain("botcord_send");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not tell Telegram chats to use botcord_send", () => {
|
|
83
|
+
const out = composeBotCordUserTurn(
|
|
84
|
+
makeMessage({
|
|
85
|
+
channel: "gw_telegram_123",
|
|
86
|
+
conversation: { id: "telegram:user:7904063707", kind: "direct" },
|
|
87
|
+
sender: { id: "telegram:user:7904063707", name: "danny_aaas", kind: "user" },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect(out).toContain("third-party gateway chat");
|
|
91
|
+
expect(out).toContain("Reply normally in your final assistant message");
|
|
92
|
+
expect(out).not.toContain("Plain text output WILL NOT be sent");
|
|
93
|
+
expect(out).not.toContain("botcord_send");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not tell WeChat chats to use botcord_send", () => {
|
|
97
|
+
const out = composeBotCordUserTurn(
|
|
98
|
+
makeMessage({
|
|
99
|
+
channel: "gw_wechat_123",
|
|
100
|
+
conversation: { id: "wechat:user:wxl_alice", kind: "direct" },
|
|
101
|
+
sender: { id: "wechat:user:wxl_alice", name: "Alice", kind: "user" },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
expect(out).toContain("third-party gateway chat");
|
|
105
|
+
expect(out).not.toContain("botcord_send");
|
|
106
|
+
});
|
|
107
|
+
|
|
71
108
|
it("passes owner-chat messages through verbatim (no wrapper, no hint)", () => {
|
|
72
109
|
const out = composeBotCordUserTurn(
|
|
73
110
|
makeMessage({
|
|
@@ -37,6 +37,7 @@ interface AcpProcessHandle {
|
|
|
37
37
|
subscribers: Map<string, (note: AcpNotification) => void>;
|
|
38
38
|
nextId: number;
|
|
39
39
|
buffer: string;
|
|
40
|
+
nonJsonStdoutTail: string[];
|
|
40
41
|
initialized: boolean;
|
|
41
42
|
initializePromise?: Promise<void>;
|
|
42
43
|
idleTimer?: NodeJS.Timeout;
|
|
@@ -355,6 +356,14 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
355
356
|
log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
if (!finalText) {
|
|
360
|
+
const stopReason = pickStopReason(promptResult);
|
|
361
|
+
const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
|
|
362
|
+
const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
|
|
363
|
+
const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
|
|
364
|
+
return failResult(acpSessionId, `openclaw-acp: ${reason}${detail}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
358
367
|
return {
|
|
359
368
|
text: finalText,
|
|
360
369
|
newSessionId: acpSessionId,
|
|
@@ -447,6 +456,7 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
447
456
|
subscribers: new Map(),
|
|
448
457
|
nextId: 1,
|
|
449
458
|
buffer: "",
|
|
459
|
+
nonJsonStdoutTail: [],
|
|
450
460
|
initialized: false,
|
|
451
461
|
inFlight: 0,
|
|
452
462
|
closed: false,
|
|
@@ -533,6 +543,10 @@ function onStdoutChunk(handle: AcpProcessHandle, chunk: string): void {
|
|
|
533
543
|
try {
|
|
534
544
|
msg = JSON.parse(line);
|
|
535
545
|
} catch (err) {
|
|
546
|
+
handle.nonJsonStdoutTail.push(line.slice(0, 500));
|
|
547
|
+
if (handle.nonJsonStdoutTail.length > 20) {
|
|
548
|
+
handle.nonJsonStdoutTail.splice(0, handle.nonJsonStdoutTail.length - 20);
|
|
549
|
+
}
|
|
536
550
|
log.warn("openclaw-acp.parse-error", {
|
|
537
551
|
error: err instanceof Error ? err.message : String(err),
|
|
538
552
|
line: line.slice(0, 200),
|
|
@@ -847,6 +861,16 @@ function pickFinalText(result: unknown): string | undefined {
|
|
|
847
861
|
return undefined;
|
|
848
862
|
}
|
|
849
863
|
|
|
864
|
+
function pickStopReason(result: unknown): string | undefined {
|
|
865
|
+
if (!result || typeof result !== "object") return undefined;
|
|
866
|
+
const v = (result as Record<string, unknown>).stopReason;
|
|
867
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function truncateDetail(text: string, max: number): string {
|
|
871
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
872
|
+
}
|
|
873
|
+
|
|
850
874
|
function looksLikeReasoningLeak(text: string): boolean {
|
|
851
875
|
const t = text.trim();
|
|
852
876
|
if (!t) return false;
|
package/src/turn-text.ts
CHANGED
|
@@ -35,16 +35,20 @@ const DIRECT_HINT =
|
|
|
35
35
|
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Reminder appended to
|
|
39
|
-
* dispatcher discards `result.text` for
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* Reminder appended to wrapped BotCord network rooms that are not owner-chat.
|
|
39
|
+
* The dispatcher discards `result.text` for those rooms, so the agent must
|
|
40
|
+
* call the `botcord_send` tool (or the `botcord send` CLI via Bash) to
|
|
41
|
+
* actually deliver a reply. Plain assistant text in those rooms is logged
|
|
42
|
+
* and dropped.
|
|
43
43
|
*/
|
|
44
44
|
const NON_OWNER_REPLY_HINT =
|
|
45
45
|
"[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
|
|
46
46
|
"To reply, call the `botcord_send` tool, or run " +
|
|
47
47
|
'`botcord send --room <room_id> --text "..."` via Bash.]';
|
|
48
|
+
const THIRD_PARTY_REPLY_HINT =
|
|
49
|
+
"[This is a third-party gateway chat. Reply normally in your final assistant " +
|
|
50
|
+
"message; BotCord daemon will deliver that text through the same channel. " +
|
|
51
|
+
"No extra send tool is required for this chat.]";
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* Read the BotCord envelope type from a raw inbound message. Returns
|
|
@@ -59,6 +63,19 @@ function readEnvelopeType(raw: unknown): string | undefined {
|
|
|
59
63
|
return typeof t === "string" ? t : undefined;
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
function isThirdPartyConversation(conversationId: string): boolean {
|
|
67
|
+
return (
|
|
68
|
+
conversationId.startsWith("telegram:") ||
|
|
69
|
+
conversationId.startsWith("wechat:")
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function replyDeliveryHint(msg: GatewayInboundMessage): string {
|
|
74
|
+
return isThirdPartyConversation(msg.conversation.id)
|
|
75
|
+
? THIRD_PARTY_REPLY_HINT
|
|
76
|
+
: NON_OWNER_REPLY_HINT;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
/** Minimal shape of one batched inbound entry. Matches the BotCord channel
|
|
63
80
|
* `BatchedInboxRaw.batch[]` elements but expressed structurally so the
|
|
64
81
|
* composer doesn't import channel internals. */
|
|
@@ -182,7 +199,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
|
182
199
|
"",
|
|
183
200
|
hint,
|
|
184
201
|
"",
|
|
185
|
-
|
|
202
|
+
replyDeliveryHint(msg),
|
|
186
203
|
];
|
|
187
204
|
if (contactRequestHint) {
|
|
188
205
|
lines.push("", contactRequestHint);
|
|
@@ -243,7 +260,7 @@ function composeBatchedTurn(
|
|
|
243
260
|
"",
|
|
244
261
|
hint,
|
|
245
262
|
"",
|
|
246
|
-
|
|
263
|
+
replyDeliveryHint(msg),
|
|
247
264
|
];
|
|
248
265
|
|
|
249
266
|
if (contactRequestSenders.length > 0) {
|