@pnds/pond 1.0.0 → 1.1.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.
- package/package.json +2 -2
- package/src/gateway.ts +192 -28
- package/src/hooks.ts +52 -30
- package/src/outbound.ts +13 -2
- package/src/runtime.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"openclaw.plugin.json"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pnds/sdk": "1.
|
|
17
|
+
"@pnds/sdk": "1.1.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"typescript": "^5.7.0"
|
package/src/gateway.ts
CHANGED
|
@@ -38,6 +38,60 @@ async function fetchAllChats(
|
|
|
38
38
|
return total;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Fetch recent messages from a chat (or thread) and format them as a history context block.
|
|
43
|
+
* Pass threadRootId to scope to a thread; omit (or null) to fetch top-level messages only.
|
|
44
|
+
* Returns empty string on failure or timeout (non-fatal).
|
|
45
|
+
*/
|
|
46
|
+
async function buildChatHistoryContext(
|
|
47
|
+
client: PondClient,
|
|
48
|
+
orgId: string,
|
|
49
|
+
chatId: string,
|
|
50
|
+
beforeMessageId: string,
|
|
51
|
+
agentUserId: string,
|
|
52
|
+
limit: number = 30,
|
|
53
|
+
log?: { warn: (msg: string) => void },
|
|
54
|
+
threadRootId?: string | null,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
try {
|
|
57
|
+
const threadParams = threadRootId
|
|
58
|
+
? { thread_root_id: threadRootId }
|
|
59
|
+
: { top_level: true as const };
|
|
60
|
+
const res = await client.getMessages(orgId, chatId, { before: beforeMessageId, limit, ...threadParams });
|
|
61
|
+
if (!res.data.length) return "";
|
|
62
|
+
|
|
63
|
+
const escapeHistoryValue = (value: string): string =>
|
|
64
|
+
value
|
|
65
|
+
.replace(/\r?\n/g, "\\n")
|
|
66
|
+
.replace(/\[Recent chat history\]|\[End of chat history\]/g, (m) => `\\${m}`);
|
|
67
|
+
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
for (const msg of res.data) {
|
|
70
|
+
if (msg.message_type !== "text" && msg.message_type !== "file") continue;
|
|
71
|
+
const senderLabel = escapeHistoryValue(msg.sender?.display_name ?? msg.sender_id);
|
|
72
|
+
const role = msg.sender_id === agentUserId ? "You" : senderLabel;
|
|
73
|
+
|
|
74
|
+
if (msg.message_type === "text") {
|
|
75
|
+
const content = msg.content as TextContent;
|
|
76
|
+
const text = escapeHistoryValue(content.text?.trim() ?? "");
|
|
77
|
+
if (text) lines.push(`${role}: ${text}`);
|
|
78
|
+
} else {
|
|
79
|
+
const content = msg.content as MediaContent;
|
|
80
|
+
const caption = escapeHistoryValue(
|
|
81
|
+
content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`,
|
|
82
|
+
);
|
|
83
|
+
lines.push(`${role}: ${caption}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!lines.length) return "";
|
|
88
|
+
return `[Recent chat history]\n${lines.join("\n")}\n[End of chat history]\n\n`;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log?.warn(`pond: failed to fetch chat history for context: ${String(err)}`);
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
41
95
|
/**
|
|
42
96
|
* Dispatch an inbound message to the OpenClaw agent and handle the reply.
|
|
43
97
|
* Shared by both text and file message handlers to avoid duplication.
|
|
@@ -49,29 +103,65 @@ async function dispatchToAgent(opts: {
|
|
|
49
103
|
config: PondChannelConfig;
|
|
50
104
|
agentUserId: string;
|
|
51
105
|
accountId: string;
|
|
106
|
+
sessionKey: string;
|
|
52
107
|
chatId: string;
|
|
53
108
|
messageId: string;
|
|
54
109
|
inboundCtx: Record<string, unknown>;
|
|
110
|
+
noReply?: boolean;
|
|
55
111
|
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
56
112
|
}) {
|
|
57
|
-
const { core, cfg, client, config, agentUserId, accountId, chatId, messageId, inboundCtx, log } = opts;
|
|
113
|
+
const { core, cfg, client, config, agentUserId, accountId, sessionKey, chatId, messageId, inboundCtx, noReply, log } = opts;
|
|
114
|
+
const sessionKeyLower = sessionKey.toLowerCase();
|
|
58
115
|
let thinkingSent = false;
|
|
59
116
|
let runIdPromise: Promise<string | undefined> | undefined;
|
|
117
|
+
let reasoningBuffer = "";
|
|
60
118
|
try {
|
|
61
119
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
62
120
|
ctx: inboundCtx,
|
|
63
121
|
cfg,
|
|
64
122
|
dispatcherOptions: {
|
|
65
|
-
deliver: async (payload: { text?: string }) => {
|
|
123
|
+
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
66
124
|
const replyText = payload.text?.trim();
|
|
67
125
|
if (!replyText) return;
|
|
68
126
|
// Wait for run creation to complete before sending
|
|
69
127
|
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
128
|
+
|
|
129
|
+
if (info.kind === "final") {
|
|
130
|
+
if (noReply) {
|
|
131
|
+
// no_reply: unconditionally suppress chat output; runId only affects step recording
|
|
132
|
+
if (runId) {
|
|
133
|
+
try {
|
|
134
|
+
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
135
|
+
step_type: "text",
|
|
136
|
+
content: { text: replyText, suppressed: true },
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log?.warn(`pond[${accountId}]: failed to create suppressed text step: ${String(err)}`);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
log?.warn(`pond[${accountId}]: suppressing final reply but no runId available`);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Normal: final reply → chat message
|
|
147
|
+
await client.sendMessage(config.org_id, chatId, {
|
|
148
|
+
message_type: "text",
|
|
149
|
+
content: { text: replyText },
|
|
150
|
+
agent_run_id: runId,
|
|
151
|
+
});
|
|
152
|
+
} else if (info.kind === "block" && runId) {
|
|
153
|
+
// Intermediate text → step with chat_projection hint (server decides)
|
|
154
|
+
try {
|
|
155
|
+
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
156
|
+
step_type: "text",
|
|
157
|
+
content: { text: replyText },
|
|
158
|
+
chat_projection: true,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log?.warn(`pond[${accountId}]: failed to create text step: ${String(err)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// kind === "tool" → ignore (handled by hooks)
|
|
75
165
|
},
|
|
76
166
|
onReplyStart: () => {
|
|
77
167
|
if (thinkingSent) return;
|
|
@@ -83,18 +173,34 @@ async function dispatchToAgent(opts: {
|
|
|
83
173
|
trigger_ref: { message_id: messageId },
|
|
84
174
|
chat_id: chatId,
|
|
85
175
|
});
|
|
86
|
-
const state = getPondAccountState(accountId);
|
|
87
|
-
if (state) state.activeRunId = run.id;
|
|
88
|
-
await client.createAgentStep(config.org_id, agentUserId, run.id, {
|
|
89
|
-
step_type: "thinking",
|
|
90
|
-
content: { text: "" },
|
|
91
|
-
});
|
|
92
176
|
return run.id;
|
|
93
177
|
} catch (err) {
|
|
94
178
|
log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
|
|
95
179
|
return undefined;
|
|
96
180
|
}
|
|
97
181
|
})();
|
|
182
|
+
// Store promise immediately so hooks can await it before the run is created
|
|
183
|
+
const state = getPondAccountState(accountId);
|
|
184
|
+
if (state) state.activeRuns.set(sessionKeyLower, runIdPromise);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
replyOptions: {
|
|
188
|
+
onReasoningStream: (payload: { text?: string }) => {
|
|
189
|
+
reasoningBuffer += payload.text ?? "";
|
|
190
|
+
},
|
|
191
|
+
onReasoningEnd: async () => {
|
|
192
|
+
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
193
|
+
if (runId && reasoningBuffer) {
|
|
194
|
+
try {
|
|
195
|
+
await client.createAgentStep(config.org_id, agentUserId, runId, {
|
|
196
|
+
step_type: "thinking",
|
|
197
|
+
content: { text: reasoningBuffer },
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
log?.warn(`pond[${accountId}]: failed to create thinking step: ${String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
reasoningBuffer = "";
|
|
98
204
|
},
|
|
99
205
|
},
|
|
100
206
|
});
|
|
@@ -161,6 +267,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
161
267
|
let sessionId = "";
|
|
162
268
|
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
163
269
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
270
|
+
// Track active dispatches per chat for typing indicator management
|
|
271
|
+
const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
|
|
272
|
+
// Heartbeat watchdog: detect event loop blocking during long CC runs
|
|
273
|
+
let lastHeartbeatAt = Date.now();
|
|
164
274
|
|
|
165
275
|
// Log connection state changes (covers reconnection)
|
|
166
276
|
ws.onStateChange((state) => {
|
|
@@ -180,11 +290,20 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
180
290
|
client,
|
|
181
291
|
orgId: config.org_id,
|
|
182
292
|
agentUserId,
|
|
293
|
+
activeRuns: new Map(),
|
|
183
294
|
});
|
|
184
295
|
|
|
185
296
|
// (Re)start heartbeat with server-provided interval
|
|
186
297
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
298
|
+
lastHeartbeatAt = Date.now();
|
|
187
299
|
heartbeatTimer = setInterval(() => {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const expectedMs = intervalSec * 1000;
|
|
302
|
+
const drift = now - lastHeartbeatAt - expectedMs;
|
|
303
|
+
if (drift > 15000) {
|
|
304
|
+
log?.warn(`pond[${ctx.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
305
|
+
}
|
|
306
|
+
lastHeartbeatAt = now;
|
|
188
307
|
if (ws.state !== "connected") return;
|
|
189
308
|
ws.sendAgentHeartbeat(sessionId, {
|
|
190
309
|
hostname: os.hostname(),
|
|
@@ -201,8 +320,9 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
201
320
|
|
|
202
321
|
// Track chat type changes (new chats, renames, etc.)
|
|
203
322
|
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
204
|
-
|
|
205
|
-
|
|
323
|
+
const changes = data.changes as Record<string, unknown> | undefined;
|
|
324
|
+
if (changes && typeof changes.type === "string") {
|
|
325
|
+
chatTypeMap.set(data.chat_id, changes.type as Chat["type"]);
|
|
206
326
|
}
|
|
207
327
|
});
|
|
208
328
|
|
|
@@ -271,10 +391,32 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
271
391
|
const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
|
|
272
392
|
setSessionChatId(sessionKey, chatId);
|
|
273
393
|
|
|
394
|
+
// Start typing indicator immediately before any async work so the user
|
|
395
|
+
// sees feedback right away. Reference-counted for concurrent dispatches.
|
|
396
|
+
const existingDispatchEarly = activeDispatches.get(chatId);
|
|
397
|
+
if (existingDispatchEarly) {
|
|
398
|
+
existingDispatchEarly.count++;
|
|
399
|
+
} else {
|
|
400
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
401
|
+
// Frontend auto-clears typing after 3s, so refresh at 2s to avoid flicker
|
|
402
|
+
const typingRefresh = setInterval(() => {
|
|
403
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
404
|
+
}, 2000);
|
|
405
|
+
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Fetch history with a 2s timeout — degrade gracefully to empty on slow API
|
|
409
|
+
const historyTimeout = new Promise<string>((resolve) => setTimeout(() => resolve(""), 2000));
|
|
410
|
+
const historyFetch = buildChatHistoryContext(
|
|
411
|
+
client, config.org_id, chatId, data.id, agentUserId, 30, log,
|
|
412
|
+
data.thread_root_id,
|
|
413
|
+
);
|
|
414
|
+
const historyPrefix = await Promise.race([historyFetch, historyTimeout]);
|
|
415
|
+
|
|
274
416
|
// Build inbound context for OpenClaw agent
|
|
275
417
|
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
276
418
|
Body: body,
|
|
277
|
-
BodyForAgent: body,
|
|
419
|
+
BodyForAgent: historyPrefix ? `${historyPrefix}${body}` : body,
|
|
278
420
|
RawBody: body,
|
|
279
421
|
CommandBody: body,
|
|
280
422
|
...mediaFields,
|
|
@@ -294,18 +436,35 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
294
436
|
OriginatingTo: `pond:${chatId}`,
|
|
295
437
|
});
|
|
296
438
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
439
|
+
// Extract no_reply hint from message
|
|
440
|
+
const noReply = data.hints?.no_reply ?? false;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
await dispatchToAgent({
|
|
444
|
+
core,
|
|
445
|
+
cfg: ctx.cfg,
|
|
446
|
+
client,
|
|
447
|
+
config,
|
|
448
|
+
agentUserId,
|
|
449
|
+
accountId: ctx.accountId,
|
|
450
|
+
sessionKey,
|
|
451
|
+
chatId,
|
|
452
|
+
messageId: data.id,
|
|
453
|
+
inboundCtx,
|
|
454
|
+
noReply,
|
|
455
|
+
log,
|
|
456
|
+
});
|
|
457
|
+
} finally {
|
|
458
|
+
const dispatch = activeDispatches.get(chatId);
|
|
459
|
+
if (dispatch) {
|
|
460
|
+
dispatch.count--;
|
|
461
|
+
if (dispatch.count <= 0) {
|
|
462
|
+
clearInterval(dispatch.typingTimer);
|
|
463
|
+
activeDispatches.delete(chatId);
|
|
464
|
+
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
309
468
|
});
|
|
310
469
|
|
|
311
470
|
// Re-apply platform config when server pushes an update notification
|
|
@@ -324,6 +483,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
324
483
|
// Clean up on abort
|
|
325
484
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
326
485
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
486
|
+
// Clean up all active typing timers to prevent leaks
|
|
487
|
+
for (const [, dispatch] of activeDispatches) {
|
|
488
|
+
clearInterval(dispatch.typingTimer);
|
|
489
|
+
}
|
|
490
|
+
activeDispatches.clear();
|
|
327
491
|
ws.disconnect();
|
|
328
492
|
removePondAccountState(ctx.accountId);
|
|
329
493
|
// Clear injected env vars so stale credentials don't leak to future subprocesses
|
package/src/hooks.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
3
|
-
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
3
|
+
import { getActiveRunId, getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Resolve the PondClient + orgId + runId for a hook context.
|
|
7
|
-
*
|
|
7
|
+
* Awaits the run ID promise so hooks that fire before createAgentRun
|
|
8
|
+
* completes will block until the run ID is available (not silently drop).
|
|
8
9
|
*/
|
|
9
|
-
function resolveClientForHook(sessionKey: string | undefined) {
|
|
10
|
+
async function resolveClientForHook(sessionKey: string | undefined) {
|
|
10
11
|
if (!sessionKey) return undefined;
|
|
11
12
|
// Use stored mapping to recover original (case-sensitive) chat ID,
|
|
12
13
|
// because openclaw normalizes session keys to lowercase.
|
|
@@ -17,7 +18,10 @@ function resolveClientForHook(sessionKey: string | undefined) {
|
|
|
17
18
|
|
|
18
19
|
const state = getPondAccountState(accountId);
|
|
19
20
|
if (!state) return undefined;
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
// Await the run ID promise — may block briefly if the run is still being created
|
|
23
|
+
const activeRunId = await getActiveRunId(state, sessionKey);
|
|
24
|
+
return { ...state, chatId, activeRunId };
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
const POND_CLI_CONTEXT = `## Pond CLI
|
|
@@ -26,9 +30,13 @@ You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-conf
|
|
|
26
30
|
|
|
27
31
|
\`\`\`
|
|
28
32
|
npx @pnds/cli@latest whoami # Check your identity
|
|
33
|
+
npx @pnds/cli@latest agents list # List all agents (name, model, status)
|
|
34
|
+
npx @pnds/cli@latest dm <name-or-id> --text "..." # DM a user by name or ID (auto-creates chat)
|
|
35
|
+
npx @pnds/cli@latest dm <name-or-id> --text "..." --no-reply # DM, signal no reply expected
|
|
29
36
|
npx @pnds/cli@latest chats list # List chats
|
|
30
37
|
npx @pnds/cli@latest messages list <chatId> # Read chat history
|
|
31
38
|
npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
|
|
39
|
+
npx @pnds/cli@latest messages send <chatId> --text "..." --no-reply # Send, no reply expected
|
|
32
40
|
npx @pnds/cli@latest tasks list [--status ...] # List tasks
|
|
33
41
|
npx @pnds/cli@latest tasks create --title "..." # Create a task
|
|
34
42
|
npx @pnds/cli@latest tasks update <taskId> --status in_progress
|
|
@@ -37,7 +45,14 @@ npx @pnds/cli@latest users search <query> # Find users
|
|
|
37
45
|
npx @pnds/cli@latest members list # List org members
|
|
38
46
|
\`\`\`
|
|
39
47
|
|
|
40
|
-
Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON
|
|
48
|
+
Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.
|
|
49
|
+
|
|
50
|
+
## Agent-to-Agent Interaction
|
|
51
|
+
|
|
52
|
+
When you receive a message from another agent (not a human):
|
|
53
|
+
- If the message has a no_reply hint, you may still reason and use tools, but your response will not be sent to chat.
|
|
54
|
+
- If you have nothing meaningful to add to the conversation, produce an empty response to avoid unnecessary back-and-forth.
|
|
55
|
+
- Use --no-reply when sending messages that don't require a response (e.g., delivering results, FYI notifications).`;
|
|
41
56
|
|
|
42
57
|
export function registerPondHooks(api: OpenClawPluginApi) {
|
|
43
58
|
const log = api.logger;
|
|
@@ -48,33 +63,38 @@ export function registerPondHooks(api: OpenClawPluginApi) {
|
|
|
48
63
|
});
|
|
49
64
|
|
|
50
65
|
// before_tool_call -> send tool_call step to AgentRun
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Fire-and-forget: before_tool_call is an awaited hook in OpenClaw (can block/modify
|
|
67
|
+
// tool params), so we must not block tool execution waiting for run creation.
|
|
68
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
69
|
+
void (async () => {
|
|
70
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
71
|
+
if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
|
|
72
|
+
try {
|
|
73
|
+
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
74
|
+
step_type: "tool_call",
|
|
75
|
+
content: {
|
|
76
|
+
call_id: event.toolCallId,
|
|
77
|
+
tool_name: event.toolName,
|
|
78
|
+
tool_input: event.params ?? {},
|
|
79
|
+
status: "running",
|
|
80
|
+
started_at: new Date().toISOString(),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
67
87
|
});
|
|
68
88
|
|
|
69
89
|
// after_tool_call -> send tool_result step to AgentRun
|
|
70
90
|
api.on("after_tool_call", async (event, ctx) => {
|
|
71
|
-
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
72
|
-
if (!resolved || !resolved.activeRunId) return;
|
|
91
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
92
|
+
if (!resolved || !resolved.activeRunId || !event.toolCallId) return;
|
|
73
93
|
try {
|
|
74
94
|
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
75
95
|
step_type: "tool_result",
|
|
76
96
|
content: {
|
|
77
|
-
|
|
97
|
+
call_id: event.toolCallId,
|
|
78
98
|
tool_name: event.toolName,
|
|
79
99
|
status: event.error ? "error" : "success",
|
|
80
100
|
output: event.error ?? (typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "")),
|
|
@@ -89,23 +109,25 @@ export function registerPondHooks(api: OpenClawPluginApi) {
|
|
|
89
109
|
|
|
90
110
|
// agent_end -> complete the AgentRun
|
|
91
111
|
api.on("agent_end", async (_event, ctx) => {
|
|
92
|
-
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
112
|
+
const resolved = await resolveClientForHook(ctx.sessionKey);
|
|
93
113
|
if (!resolved) {
|
|
94
114
|
log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
|
|
95
115
|
return;
|
|
96
116
|
}
|
|
97
117
|
if (!resolved.activeRunId) return;
|
|
118
|
+
const accountId = extractAccountIdFromSessionKey(ctx.sessionKey ?? "");
|
|
119
|
+
const state = accountId ? getPondAccountState(accountId) : undefined;
|
|
98
120
|
try {
|
|
99
121
|
await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
100
122
|
status: "completed",
|
|
101
123
|
});
|
|
102
|
-
// Clear the active run
|
|
103
|
-
const state = getPondAccountState(
|
|
104
|
-
extractAccountIdFromSessionKey(ctx.sessionKey ?? "") ?? "",
|
|
105
|
-
);
|
|
106
|
-
if (state) state.activeRunId = undefined;
|
|
107
124
|
} catch (err) {
|
|
108
125
|
log?.warn(`pond hook agent_end failed: ${String(err)}`);
|
|
126
|
+
} finally {
|
|
127
|
+
// Always clear local state — even if the server call failed, the run is over locally
|
|
128
|
+
if (state && ctx.sessionKey) {
|
|
129
|
+
state.activeRuns.delete(ctx.sessionKey.toLowerCase());
|
|
130
|
+
}
|
|
109
131
|
}
|
|
110
132
|
});
|
|
111
133
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
2
2
|
import { resolvePondAccount } from "./accounts.js";
|
|
3
|
-
import { getPondAccountState } from "./runtime.js";
|
|
3
|
+
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
4
4
|
import { PondClient } from "@pnds/sdk";
|
|
5
5
|
import type { SendMessageRequest } from "@pnds/sdk";
|
|
6
6
|
|
|
@@ -22,10 +22,21 @@ export const pondOutbound: ChannelOutboundAdapter = {
|
|
|
22
22
|
const orgId = state?.orgId ?? account.config.org_id;
|
|
23
23
|
const chatId = to;
|
|
24
24
|
|
|
25
|
+
// Match active run by chatId (outbound path has no sessionKey, but has chatId via `to`)
|
|
26
|
+
let agentRunId: string | undefined;
|
|
27
|
+
if (state) {
|
|
28
|
+
for (const [sessionKey, runPromise] of state.activeRuns.entries()) {
|
|
29
|
+
if (getSessionChatId(sessionKey) === chatId) {
|
|
30
|
+
agentRunId = await runPromise;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
const req: SendMessageRequest = {
|
|
26
37
|
message_type: "text",
|
|
27
38
|
content: { text },
|
|
28
|
-
agent_run_id:
|
|
39
|
+
agent_run_id: agentRunId,
|
|
29
40
|
};
|
|
30
41
|
const msg = await client.sendMessage(orgId, chatId, req);
|
|
31
42
|
return { channel: "pond", messageId: msg.id, channelId: chatId };
|
package/src/runtime.ts
CHANGED
|
@@ -19,9 +19,18 @@ export type PondAccountState = {
|
|
|
19
19
|
client: PondClient;
|
|
20
20
|
orgId: string;
|
|
21
21
|
agentUserId: string;
|
|
22
|
-
|
|
22
|
+
activeRuns: Map<string, Promise<string | undefined>>; // sessionKey (lowercased) → runId promise
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/** Resolve run ID for a session, awaiting if the run is still being created. */
|
|
26
|
+
export async function getActiveRunId(
|
|
27
|
+
state: PondAccountState,
|
|
28
|
+
sessionKey: string,
|
|
29
|
+
): Promise<string | undefined> {
|
|
30
|
+
const promise = state.activeRuns.get(sessionKey.toLowerCase());
|
|
31
|
+
return promise ? await promise : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
const accountStates = new Map<string, PondAccountState>();
|
|
26
35
|
|
|
27
36
|
export function setPondAccountState(accountId: string, state: PondAccountState) {
|