@clawroom/openclaw 0.3.0 → 0.5.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/README.md +4 -3
- package/package.json +2 -2
- package/src/channel.ts +19 -46
- package/src/chat-executor.ts +37 -42
- package/src/client.ts +29 -36
- package/src/task-executor.ts +58 -193
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ OpenClaw channel plugin for the ClawRoom task marketplace. Connects your OpenCla
|
|
|
7
7
|
```sh
|
|
8
8
|
openclaw plugins install @clawroom/openclaw
|
|
9
9
|
openclaw config set channels.clawroom.token "YOUR_TOKEN"
|
|
10
|
+
openclaw config set channels.clawroom.endpoint "http://localhost:3000/api/agents"
|
|
10
11
|
openclaw gateway restart
|
|
11
12
|
```
|
|
12
13
|
|
|
@@ -28,11 +29,11 @@ openclaw gateway restart
|
|
|
28
29
|
|
|
29
30
|
## Release
|
|
30
31
|
|
|
31
|
-
The repository includes a GitHub Actions workflow that publishes `@clawroom/protocol`, `@clawroom/sdk`, and `@clawroom/openclaw` to npm when a release tag is pushed.
|
|
32
|
+
The repository includes a GitHub Actions workflow that publishes `@clawroom/protocol`, `@clawroom/sdk`, `@clawroom/bridge`, and `@clawroom/openclaw` to npm when a release tag is pushed.
|
|
32
33
|
|
|
33
34
|
To publish a new version:
|
|
34
35
|
|
|
35
|
-
1. Update the package versions in `protocol/package.json`, `sdk/package.json`, and `plugin/package.json`.
|
|
36
|
+
1. Update the package versions in `protocol/package.json`, `sdk/package.json`, `bridge/package.json`, and `plugin/package.json`.
|
|
36
37
|
2. Commit and push the release commit to GitHub.
|
|
37
38
|
3. Push a release tag, for example `git tag plugin-0.2.3 && git push origin plugin-0.2.3`.
|
|
38
|
-
4. Watch `.github/workflows/release-plugin.yml` until all
|
|
39
|
+
4. Watch `.github/workflows/release-plugin.yml` until all publish jobs succeed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawroom/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for ClawRoom",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@clawroom/sdk"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@clawroom/sdk": "^0.
|
|
25
|
+
"@clawroom/sdk": "^0.5.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"openclaw": "*"
|
package/src/channel.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import { collectSkills } from "./skill-reporter.js";
|
|
3
|
+
// import { collectSkills } from "./skill-reporter.js";
|
|
4
4
|
import { getClawroomRuntime } from "./runtime.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ClawroomPluginClient } from "./client.js";
|
|
6
6
|
import { setupTaskExecutor } from "./task-executor.js";
|
|
7
7
|
import { setupChatExecutor } from "./chat-executor.js";
|
|
8
8
|
|
|
9
9
|
// ── Config resolution ────────────────────────────────────────────────
|
|
10
10
|
|
|
11
|
-
const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/
|
|
11
|
+
const DEFAULT_ENDPOINT = "https://clawroom.site9.ai/api/machines";
|
|
12
12
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
13
13
|
|
|
14
14
|
interface ClawroomAccountConfig {
|
|
15
|
-
|
|
15
|
+
api_key?: string;
|
|
16
|
+
token?: string; // deprecated, fallback
|
|
16
17
|
endpoint?: string;
|
|
17
18
|
skills?: string[];
|
|
18
19
|
enabled?: boolean;
|
|
@@ -23,7 +24,7 @@ interface ResolvedClawroomAccount {
|
|
|
23
24
|
name: string;
|
|
24
25
|
enabled: boolean;
|
|
25
26
|
configured: boolean;
|
|
26
|
-
|
|
27
|
+
apiKey: string;
|
|
27
28
|
endpoint: string;
|
|
28
29
|
skills: string[];
|
|
29
30
|
}
|
|
@@ -39,7 +40,7 @@ function resolveClawroomAccount(opts: {
|
|
|
39
40
|
accountId?: string | null;
|
|
40
41
|
}): ResolvedClawroomAccount {
|
|
41
42
|
const section = readClawroomSection(opts.cfg);
|
|
42
|
-
const
|
|
43
|
+
const apiKey = section.api_key ?? section.token ?? "";
|
|
43
44
|
const endpoint = section.endpoint || DEFAULT_ENDPOINT;
|
|
44
45
|
const skills = Array.isArray(section.skills) ? section.skills : [];
|
|
45
46
|
const enabled = section.enabled !== false;
|
|
@@ -48,8 +49,8 @@ function resolveClawroomAccount(opts: {
|
|
|
48
49
|
accountId: opts.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
49
50
|
name: "ClawRoom",
|
|
50
51
|
enabled,
|
|
51
|
-
configured:
|
|
52
|
-
|
|
52
|
+
configured: apiKey.length > 0,
|
|
53
|
+
apiKey,
|
|
53
54
|
endpoint,
|
|
54
55
|
skills,
|
|
55
56
|
};
|
|
@@ -57,7 +58,7 @@ function resolveClawroomAccount(opts: {
|
|
|
57
58
|
|
|
58
59
|
// ── Persistent client per gateway lifecycle ───────────────────────
|
|
59
60
|
|
|
60
|
-
let activeClient:
|
|
61
|
+
let activeClient: ClawroomPluginClient | null = null;
|
|
61
62
|
|
|
62
63
|
// ── Channel plugin definition ────────────────────────────────────────
|
|
63
64
|
|
|
@@ -104,17 +105,9 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
104
105
|
deliveryMode: "direct",
|
|
105
106
|
|
|
106
107
|
sendText: async ({ to, text }) => {
|
|
107
|
-
// Outbound
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// to deliver a reply through the channel outbound path.
|
|
111
|
-
if (activeClient) {
|
|
112
|
-
activeClient.send({
|
|
113
|
-
type: "agent.complete",
|
|
114
|
-
taskId: to,
|
|
115
|
-
output: text,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
108
|
+
// Outbound path — task results are sent directly by executors,
|
|
109
|
+
// this is a fallback if openclaw routing triggers outbound delivery.
|
|
110
|
+
// No-op for machine client (executors handle it).
|
|
118
111
|
return { channel: "clawroom", messageId: to, to };
|
|
119
112
|
},
|
|
120
113
|
},
|
|
@@ -125,24 +118,16 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
125
118
|
|
|
126
119
|
if (!account.configured) {
|
|
127
120
|
throw new Error(
|
|
128
|
-
"ClawRoom is not configured: set channels.clawroom.
|
|
121
|
+
"ClawRoom is not configured: set channels.clawroom.api_key in your OpenClaw config.",
|
|
129
122
|
);
|
|
130
123
|
}
|
|
131
124
|
|
|
132
125
|
const runtime = getClawroomRuntime();
|
|
133
|
-
const skills = collectSkills({
|
|
134
|
-
runtime,
|
|
135
|
-
configuredSkills: account.skills,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const deviceId = resolveDeviceId(ctx);
|
|
139
126
|
const log = ctx.log ?? undefined;
|
|
140
127
|
|
|
141
|
-
const client = new
|
|
128
|
+
const client = new ClawroomPluginClient({
|
|
142
129
|
endpoint: account.endpoint,
|
|
143
|
-
|
|
144
|
-
deviceId,
|
|
145
|
-
skills,
|
|
130
|
+
apiKey: account.apiKey,
|
|
146
131
|
log,
|
|
147
132
|
});
|
|
148
133
|
|
|
@@ -170,23 +155,11 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
170
155
|
});
|
|
171
156
|
};
|
|
172
157
|
|
|
173
|
-
// Wire up
|
|
158
|
+
// Wire up polling events to OpenClaw health status
|
|
174
159
|
client.onWelcome(() => publishConnected());
|
|
175
|
-
client.onTask(() => {
|
|
176
|
-
// Any server task = update lastEventAt so gateway knows we're alive
|
|
177
|
-
ctx.setStatus({
|
|
178
|
-
accountId: account.accountId,
|
|
179
|
-
running: true,
|
|
180
|
-
connected: true,
|
|
181
|
-
lastEventAt: Date.now(),
|
|
182
|
-
lastStartAt: Date.now(),
|
|
183
|
-
lastStopAt: null,
|
|
184
|
-
lastError: null,
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
160
|
client.onDisconnect(() => publishDisconnected());
|
|
188
|
-
client.onFatal((reason
|
|
189
|
-
log?.error?.(`[clawroom] fatal error
|
|
161
|
+
client.onFatal((reason) => {
|
|
162
|
+
log?.error?.(`[clawroom] fatal error: ${reason}`);
|
|
190
163
|
ctx.setStatus({
|
|
191
164
|
accountId: account.accountId,
|
|
192
165
|
running: false,
|
package/src/chat-executor.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { ServerChatMessage } from "@clawroom/sdk";
|
|
2
1
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import type {
|
|
2
|
+
import type { ClawroomPluginClient } from "./client.js";
|
|
4
3
|
|
|
5
4
|
/** Default timeout for chat reply subagent (2 minutes). */
|
|
6
5
|
const CHAT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
@@ -12,7 +11,7 @@ const CHAT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
|
12
11
|
* 3. Send reply back to channel
|
|
13
12
|
*/
|
|
14
13
|
export function setupChatExecutor(opts: {
|
|
15
|
-
client:
|
|
14
|
+
client: ClawroomPluginClient;
|
|
16
15
|
runtime: PluginRuntime;
|
|
17
16
|
log?: {
|
|
18
17
|
info?: (message: string, ...args: unknown[]) => void;
|
|
@@ -22,61 +21,70 @@ export function setupChatExecutor(opts: {
|
|
|
22
21
|
}): void {
|
|
23
22
|
const { client, runtime, log } = opts;
|
|
24
23
|
|
|
25
|
-
client.
|
|
24
|
+
client.onAgentChat((agentId: string, messages: any[]) => {
|
|
26
25
|
for (const msg of messages) {
|
|
27
|
-
void handleChatMention({ client, runtime, msg, log });
|
|
26
|
+
void handleChatMention({ client, runtime, msg, agentId, log });
|
|
28
27
|
}
|
|
29
28
|
});
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
async function handleChatMention(opts: {
|
|
33
|
-
client:
|
|
32
|
+
client: ClawroomPluginClient;
|
|
34
33
|
runtime: PluginRuntime;
|
|
35
|
-
msg:
|
|
34
|
+
msg: any;
|
|
35
|
+
agentId: string;
|
|
36
36
|
log?: {
|
|
37
37
|
info?: (message: string, ...args: unknown[]) => void;
|
|
38
38
|
warn?: (message: string, ...args: unknown[]) => void;
|
|
39
39
|
error?: (message: string, ...args: unknown[]) => void;
|
|
40
40
|
};
|
|
41
41
|
}): Promise<void> {
|
|
42
|
-
const { client, runtime, msg, log } = opts;
|
|
43
|
-
const sessionKey = `clawroom:chat:${msg.channelId}:${msg.messageId}`;
|
|
42
|
+
const { client, runtime, msg, agentId, log } = opts;
|
|
43
|
+
const sessionKey = `clawroom:chat:${agentId}:${msg.channelId}:${msg.messageId}`;
|
|
44
44
|
|
|
45
45
|
log?.info?.(`[clawroom:chat] processing mention in channel ${msg.channelId}: "${msg.content.slice(0, 80)}"`);
|
|
46
46
|
|
|
47
47
|
// Send typing indicator
|
|
48
|
-
client.
|
|
48
|
+
client.sendTyping(agentId, msg.channelId).catch(() => {});
|
|
49
49
|
|
|
50
50
|
// Build context from recent messages
|
|
51
51
|
const contextLines = msg.context
|
|
52
52
|
.map((c) => `[${c.senderName}]: ${c.content}`)
|
|
53
53
|
.join("\n");
|
|
54
54
|
|
|
55
|
+
const isMention = (msg as any).isMention ?? false;
|
|
55
56
|
const agentMessage = [
|
|
56
|
-
|
|
57
|
+
isMention
|
|
58
|
+
? "You were @mentioned in a chat channel. Reply directly and helpfully."
|
|
59
|
+
: "A new message appeared in a channel you're in. You are a participant in this channel — reply naturally as a teammate would. If the message is a greeting or directed at the group, respond. Only stay silent if the message is clearly a private conversation between other people that has nothing to do with you.",
|
|
57
60
|
"",
|
|
58
61
|
"## Recent messages",
|
|
59
62
|
contextLines,
|
|
60
63
|
"",
|
|
61
|
-
"## Message that mentioned you",
|
|
64
|
+
isMention ? "## Message that mentioned you" : "## Latest message",
|
|
62
65
|
msg.content,
|
|
63
66
|
].join("\n");
|
|
64
67
|
|
|
65
68
|
try {
|
|
66
69
|
// Keep typing indicator alive during execution
|
|
67
70
|
const typingInterval = setInterval(() => {
|
|
68
|
-
client.
|
|
71
|
+
client.sendTyping(agentId, msg.channelId).catch(() => {});
|
|
69
72
|
}, 3000);
|
|
70
73
|
|
|
71
74
|
const { runId } = await runtime.subagent.run({
|
|
72
75
|
sessionKey,
|
|
73
76
|
idempotencyKey: `clawroom:chat:${msg.messageId}`,
|
|
74
77
|
message: agentMessage,
|
|
75
|
-
extraSystemPrompt:
|
|
76
|
-
"You are responding to a
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
extraSystemPrompt: isMention
|
|
79
|
+
? "You are responding to a direct @mention in ClawRoom. " +
|
|
80
|
+
"You MUST reply with a helpful response. " +
|
|
81
|
+
"Keep your reply SHORT and conversational (1-3 sentences). " +
|
|
82
|
+
"Do NOT include file paths, system info, or markdown headers."
|
|
83
|
+
: "You are a member of a chat channel in ClawRoom. " +
|
|
84
|
+
"Respond naturally as a teammate. If someone greets the channel, greet back. " +
|
|
85
|
+
"If someone asks a question, help if you can. " +
|
|
86
|
+
"Only respond with exactly SKIP if the message is clearly a private exchange between others that doesn't involve you at all. " +
|
|
87
|
+
"Keep replies SHORT (1-3 sentences).",
|
|
80
88
|
lane: "clawroom",
|
|
81
89
|
});
|
|
82
90
|
|
|
@@ -89,23 +97,13 @@ async function handleChatMention(opts: {
|
|
|
89
97
|
|
|
90
98
|
if (waitResult.status === "error") {
|
|
91
99
|
log?.error?.(`[clawroom:chat] subagent error: ${waitResult.error}`);
|
|
92
|
-
client.
|
|
93
|
-
type: "agent.chat.reply",
|
|
94
|
-
channelId: msg.channelId,
|
|
95
|
-
content: "Sorry, I encountered an error processing your message.",
|
|
96
|
-
replyTo: msg.messageId,
|
|
97
|
-
});
|
|
100
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, I encountered an error processing your message.");
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
if (waitResult.status === "timeout") {
|
|
102
105
|
log?.error?.(`[clawroom:chat] subagent timeout for message ${msg.messageId}`);
|
|
103
|
-
client.
|
|
104
|
-
type: "agent.chat.reply",
|
|
105
|
-
channelId: msg.channelId,
|
|
106
|
-
content: "Sorry, I took too long to respond. Please try again.",
|
|
107
|
-
replyTo: msg.messageId,
|
|
108
|
-
});
|
|
106
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, I took too long to respond. Please try again.");
|
|
109
107
|
return;
|
|
110
108
|
}
|
|
111
109
|
|
|
@@ -116,13 +114,15 @@ async function handleChatMention(opts: {
|
|
|
116
114
|
|
|
117
115
|
const reply = extractLastAssistantMessage(messages);
|
|
118
116
|
|
|
117
|
+
// If agent chose to skip (awareness message, not relevant), don't reply
|
|
118
|
+
if (reply.trim() === "SKIP" || reply.trim() === "skip") {
|
|
119
|
+
log?.info?.(`[clawroom:chat] skipping ${msg.messageId} (not relevant)`);
|
|
120
|
+
await runtime.subagent.deleteSession({ sessionKey, deleteTranscript: true });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
119
124
|
log?.info?.(`[clawroom:chat] replying to ${msg.messageId}: "${reply.slice(0, 80)}"`);
|
|
120
|
-
client.
|
|
121
|
-
type: "agent.chat.reply",
|
|
122
|
-
channelId: msg.channelId,
|
|
123
|
-
content: reply,
|
|
124
|
-
replyTo: msg.messageId,
|
|
125
|
-
});
|
|
125
|
+
await client.sendChatReply(agentId, msg.channelId, reply);
|
|
126
126
|
|
|
127
127
|
await runtime.subagent.deleteSession({
|
|
128
128
|
sessionKey,
|
|
@@ -131,12 +131,7 @@ async function handleChatMention(opts: {
|
|
|
131
131
|
} catch (err) {
|
|
132
132
|
const reason = err instanceof Error ? err.message : String(err);
|
|
133
133
|
log?.error?.(`[clawroom:chat] unexpected error: ${reason}`);
|
|
134
|
-
client.
|
|
135
|
-
type: "agent.chat.reply",
|
|
136
|
-
channelId: msg.channelId,
|
|
137
|
-
content: "Sorry, something went wrong. Please try again.",
|
|
138
|
-
replyTo: msg.messageId,
|
|
139
|
-
});
|
|
134
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, something went wrong. Please try again.");
|
|
140
135
|
}
|
|
141
136
|
}
|
|
142
137
|
|
package/src/client.ts
CHANGED
|
@@ -1,63 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
1
|
+
import { ClawroomMachineClient } from "@clawroom/sdk";
|
|
2
|
+
import type { ClawroomMachineClientOptions } from "@clawroom/sdk";
|
|
3
3
|
|
|
4
4
|
type DisconnectCallback = () => void;
|
|
5
|
-
type WelcomeCallback = (
|
|
6
|
-
type FatalCallback = (reason: string
|
|
5
|
+
type WelcomeCallback = (machineId: string) => void;
|
|
6
|
+
type FatalCallback = (reason: string) => void;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Plugin-specific ClawRoom
|
|
10
|
-
* with OpenClaw lifecycle hooks
|
|
9
|
+
* Plugin-specific ClawRoom machine client.
|
|
10
|
+
* Wraps ClawroomMachineClient with OpenClaw lifecycle hooks.
|
|
11
11
|
*/
|
|
12
|
-
export class
|
|
13
|
-
private
|
|
12
|
+
export class ClawroomPluginClient {
|
|
13
|
+
private inner: ClawroomMachineClient;
|
|
14
14
|
private disconnectCallbacks: DisconnectCallback[] = [];
|
|
15
15
|
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
16
16
|
private fatalCallbacks: FatalCallback[] = [];
|
|
17
17
|
|
|
18
|
-
constructor(options:
|
|
19
|
-
|
|
18
|
+
constructor(options: ClawroomMachineClientOptions) {
|
|
19
|
+
this.inner = new ClawroomMachineClient(options);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
get isAlive(): boolean { return !this.stopped; }
|
|
23
|
-
get isConnected(): boolean { return this.connected; }
|
|
22
|
+
get isAlive(): boolean { return !this.inner.stopped; }
|
|
23
|
+
get isConnected(): boolean { return this.inner.connected; }
|
|
24
24
|
|
|
25
25
|
onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
|
|
26
26
|
onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
|
|
27
27
|
onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
onAgentTask(handler: (agentId: string, task: any) => void) { this.inner.onAgentTask(handler); }
|
|
30
|
+
onAgentChat(handler: (agentId: string, messages: any[]) => void) { this.inner.onAgentChat(handler); }
|
|
31
|
+
|
|
32
|
+
async sendComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>) {
|
|
33
|
+
await this.inner.sendAgentComplete(agentId, taskId, output);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async sendFail(agentId: string, taskId: string, reason: string) {
|
|
37
|
+
await this.inner.sendAgentFail(agentId, taskId, reason);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.connected = true;
|
|
37
|
-
this.options.log?.info?.("[clawroom] polling connected");
|
|
38
|
-
if (agentId) {
|
|
39
|
-
for (const cb of this.welcomeCallbacks) cb(agentId);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
40
|
+
async sendChatReply(agentId: string, channelId: string, content: string) {
|
|
41
|
+
await this.inner.sendAgentChatReply(agentId, channelId, content);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
this.
|
|
44
|
+
async sendTyping(agentId: string, channelId: string) {
|
|
45
|
+
await this.inner.sendAgentTyping(agentId, channelId);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
this.stopped = true;
|
|
51
|
-
this.markDisconnected();
|
|
52
|
-
for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`, 401);
|
|
53
|
-
}
|
|
48
|
+
connect(): void {
|
|
49
|
+
this.inner.connect();
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.connected = false;
|
|
52
|
+
disconnect(): void {
|
|
53
|
+
this.inner.disconnect();
|
|
59
54
|
for (const cb of this.disconnectCallbacks) cb();
|
|
60
55
|
}
|
|
61
56
|
}
|
|
62
|
-
|
|
63
|
-
export type { ClawroomClientOptions };
|
package/src/task-executor.ts
CHANGED
|
@@ -1,208 +1,99 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AgentResultFile } from "@clawroom/sdk";
|
|
2
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ClawroomPluginClient } from "./client.js";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
|
-
/** Default timeout for waiting on a subagent run (5 minutes). */
|
|
8
7
|
const SUBAGENT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
-
|
|
10
|
-
/** Max file size to upload (10 MB). */
|
|
11
8
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
12
9
|
|
|
13
|
-
/**
|
|
14
|
-
* Wire up the task execution pipeline.
|
|
15
|
-
*
|
|
16
|
-
* Tasks are assigned by humans and delivered via /poll.
|
|
17
|
-
* On receiving an assigned task, execute it immediately via subagent.
|
|
18
|
-
*/
|
|
19
10
|
export function setupTaskExecutor(opts: {
|
|
20
|
-
client:
|
|
11
|
+
client: ClawroomPluginClient;
|
|
21
12
|
runtime: PluginRuntime;
|
|
22
|
-
log?: {
|
|
23
|
-
info?: (message: string, ...args: unknown[]) => void;
|
|
24
|
-
warn?: (message: string, ...args: unknown[]) => void;
|
|
25
|
-
error?: (message: string, ...args: unknown[]) => void;
|
|
26
|
-
};
|
|
13
|
+
log?: { info?: (m: string, ...a: unknown[]) => void; warn?: (m: string, ...a: unknown[]) => void; error?: (m: string, ...a: unknown[]) => void };
|
|
27
14
|
}): void {
|
|
28
15
|
const { client, runtime, log } = opts;
|
|
29
16
|
const activeTasks = new Set<string>();
|
|
30
17
|
|
|
31
|
-
client.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
log?.info?.(`[clawroom:executor] executing assigned task ${task.taskId}: ${task.title}`);
|
|
38
|
-
activeTasks.add(task.taskId);
|
|
39
|
-
void executeTask({ client, runtime, task, log }).finally(() => {
|
|
40
|
-
activeTasks.delete(task.taskId);
|
|
41
|
-
});
|
|
18
|
+
client.onAgentTask((agentId: string, task: any) => {
|
|
19
|
+
const key = `${agentId}:${task.taskId}`;
|
|
20
|
+
if (activeTasks.has(key)) return;
|
|
21
|
+
activeTasks.add(key);
|
|
22
|
+
log?.info?.(`[clawroom:executor] [${agentId}] executing task ${task.taskId}: ${task.title}`);
|
|
23
|
+
void executeTask({ client, runtime, task, agentId, log }).finally(() => activeTasks.delete(key));
|
|
42
24
|
});
|
|
43
25
|
}
|
|
44
26
|
|
|
45
|
-
/**
|
|
46
|
-
* Execute a claimed task by running a subagent session and reporting the
|
|
47
|
-
* result back to the ClawRoom server.
|
|
48
|
-
*/
|
|
49
27
|
async function executeTask(opts: {
|
|
50
|
-
client:
|
|
28
|
+
client: ClawroomPluginClient;
|
|
51
29
|
runtime: PluginRuntime;
|
|
52
|
-
task:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
error?: (message: string, ...args: unknown[]) => void;
|
|
56
|
-
};
|
|
30
|
+
task: any;
|
|
31
|
+
agentId: string;
|
|
32
|
+
log?: { info?: (m: string, ...a: unknown[]) => void; error?: (m: string, ...a: unknown[]) => void };
|
|
57
33
|
}): Promise<void> {
|
|
58
|
-
const { client, runtime, task, log } = opts;
|
|
59
|
-
const sessionKey = `clawroom:task:${task.taskId}`;
|
|
34
|
+
const { client, runtime, task, agentId, log } = opts;
|
|
35
|
+
const sessionKey = `clawroom:task:${agentId}:${task.taskId}`;
|
|
60
36
|
|
|
61
|
-
const
|
|
37
|
+
const parts: string[] = [`# Task: ${task.title}`];
|
|
38
|
+
if (task.description) parts.push("", task.description);
|
|
39
|
+
if (task.input) parts.push("", "## Input", "", task.input);
|
|
40
|
+
if (task.skillTags?.length) parts.push("", `Skills: ${task.skillTags.join(", ")}`);
|
|
41
|
+
const agentMessage = parts.join("\n");
|
|
62
42
|
|
|
63
43
|
try {
|
|
64
|
-
log?.info?.(`[clawroom:executor] running subagent for task ${task.taskId}`);
|
|
65
|
-
|
|
66
44
|
const { runId } = await runtime.subagent.run({
|
|
67
45
|
sessionKey,
|
|
68
|
-
idempotencyKey: `clawroom:${task.taskId}`,
|
|
46
|
+
idempotencyKey: `clawroom:${agentId}:${task.taskId}`,
|
|
69
47
|
message: agentMessage,
|
|
70
48
|
extraSystemPrompt:
|
|
71
|
-
"You are executing a task from
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"If you create output files, list their absolute paths at the very end, " +
|
|
75
|
-
"one per line, prefixed with 'OUTPUT_FILE: '. These markers will be processed " +
|
|
76
|
-
"automatically and stripped before showing to the user. " +
|
|
77
|
-
"Example: OUTPUT_FILE: /path/to/report.pdf",
|
|
49
|
+
"You are executing a task from ClawRoom. Complete it and provide a SHORT summary (2-3 sentences). " +
|
|
50
|
+
"Do NOT include local file paths or internal details. " +
|
|
51
|
+
"If you create output files, list them at the end prefixed with 'OUTPUT_FILE: /path'.",
|
|
78
52
|
lane: "clawroom",
|
|
79
53
|
});
|
|
80
54
|
|
|
81
|
-
const waitResult = await runtime.subagent.waitForRun({
|
|
82
|
-
runId,
|
|
83
|
-
timeoutMs: SUBAGENT_TIMEOUT_MS,
|
|
84
|
-
});
|
|
55
|
+
const waitResult = await runtime.subagent.waitForRun({ runId, timeoutMs: SUBAGENT_TIMEOUT_MS });
|
|
85
56
|
|
|
86
57
|
if (waitResult.status === "error") {
|
|
87
|
-
log?.error?.(
|
|
88
|
-
|
|
89
|
-
);
|
|
90
|
-
client.send({
|
|
91
|
-
type: "agent.fail",
|
|
92
|
-
taskId: task.taskId,
|
|
93
|
-
reason: waitResult.error ?? "Agent execution failed",
|
|
94
|
-
});
|
|
58
|
+
log?.error?.(`[clawroom:executor] [${agentId}] subagent error: ${waitResult.error}`);
|
|
59
|
+
await client.sendFail(agentId, task.taskId, waitResult.error ?? "Agent execution failed");
|
|
95
60
|
return;
|
|
96
61
|
}
|
|
97
|
-
|
|
98
62
|
if (waitResult.status === "timeout") {
|
|
99
|
-
log?.error?.(`[clawroom:executor] subagent timeout
|
|
100
|
-
client.
|
|
101
|
-
type: "agent.fail",
|
|
102
|
-
taskId: task.taskId,
|
|
103
|
-
reason: "Agent execution timed out",
|
|
104
|
-
});
|
|
63
|
+
log?.error?.(`[clawroom:executor] [${agentId}] subagent timeout`);
|
|
64
|
+
await client.sendFail(agentId, task.taskId, "Agent execution timed out");
|
|
105
65
|
return;
|
|
106
66
|
}
|
|
107
67
|
|
|
108
|
-
const { messages } = await runtime.subagent.getSessionMessages({
|
|
109
|
-
sessionKey,
|
|
110
|
-
limit: 100,
|
|
111
|
-
});
|
|
112
|
-
|
|
68
|
+
const { messages } = await runtime.subagent.getSessionMessages({ sessionKey, limit: 100 });
|
|
113
69
|
const rawOutput = extractAgentOutput(messages);
|
|
114
|
-
const
|
|
115
|
-
const filesFromOutput = extractOutputFileMarkers(rawOutput);
|
|
116
|
-
const allFiles = [...new Set([...filesFromTools, ...filesFromOutput])];
|
|
70
|
+
const allFiles = [...new Set([...extractWrittenFiles(messages), ...extractOutputFileMarkers(rawOutput)])];
|
|
117
71
|
const files = readAllFiles(allFiles, log);
|
|
72
|
+
const output = rawOutput.split("\n").filter((l) => !l.match(/OUTPUT_FILE:\s*/)).join("\n").replace(/`?\/\S+\/[^\s`]+`?/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
118
73
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
.filter((line) => !line.match(/OUTPUT_FILE:\s*/))
|
|
123
|
-
.join("\n")
|
|
124
|
-
.replace(/`?\/\S+\/[^\s`]+`?/g, "") // strip absolute paths
|
|
125
|
-
.replace(/\n{3,}/g, "\n\n") // collapse extra newlines
|
|
126
|
-
.trim();
|
|
127
|
-
|
|
128
|
-
log?.info?.(`[clawroom:executor] task ${task.taskId} completed${files.length > 0 ? ` (with ${files.length} file(s))` : ""}`);
|
|
129
|
-
client.send({
|
|
130
|
-
type: "agent.complete",
|
|
131
|
-
taskId: task.taskId,
|
|
132
|
-
output,
|
|
133
|
-
attachments: files.length > 0 ? files : undefined,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
await runtime.subagent.deleteSession({
|
|
137
|
-
sessionKey,
|
|
138
|
-
deleteTranscript: true,
|
|
139
|
-
});
|
|
74
|
+
log?.info?.(`[clawroom:executor] [${agentId}] task completed${files.length > 0 ? ` (${files.length} files)` : ""}`);
|
|
75
|
+
await client.sendComplete(agentId, task.taskId, output, files.length > 0 ? files : undefined);
|
|
76
|
+
await runtime.subagent.deleteSession({ sessionKey, deleteTranscript: true });
|
|
140
77
|
} catch (err) {
|
|
141
78
|
const reason = err instanceof Error ? err.message : String(err);
|
|
142
|
-
log?.error?.(`[clawroom:executor]
|
|
143
|
-
client.
|
|
144
|
-
type: "agent.fail",
|
|
145
|
-
taskId: task.taskId,
|
|
146
|
-
reason,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function buildAgentMessage(task: ServerTask): string {
|
|
152
|
-
const parts: string[] = [];
|
|
153
|
-
parts.push(`# Task: ${task.title}`);
|
|
154
|
-
if (task.description) {
|
|
155
|
-
parts.push("", task.description);
|
|
156
|
-
}
|
|
157
|
-
if (task.input) {
|
|
158
|
-
parts.push("", "## Input", "", task.input);
|
|
159
|
-
}
|
|
160
|
-
if (task.skillTags.length > 0) {
|
|
161
|
-
parts.push("", `Skills: ${task.skillTags.join(", ")}`);
|
|
79
|
+
log?.error?.(`[clawroom:executor] [${agentId}] error: ${reason}`);
|
|
80
|
+
await client.sendFail(agentId, task.taskId, reason);
|
|
162
81
|
}
|
|
163
|
-
return parts.join("\n");
|
|
164
82
|
}
|
|
165
83
|
|
|
166
84
|
function extractAgentOutput(messages: unknown[]): string {
|
|
167
85
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
168
86
|
const msg = messages[i] as Record<string, unknown> | undefined;
|
|
169
|
-
if (!msg) continue;
|
|
170
|
-
|
|
171
|
-
if (msg.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
if (Array.isArray(msg.content)) {
|
|
176
|
-
const textParts: string[] = [];
|
|
177
|
-
for (const block of msg.content) {
|
|
178
|
-
const b = block as Record<string, unknown>;
|
|
179
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
180
|
-
textParts.push(b.text);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (textParts.length > 0) {
|
|
184
|
-
return textParts.join("\n");
|
|
185
|
-
}
|
|
186
|
-
}
|
|
87
|
+
if (!msg || msg.role !== "assistant") continue;
|
|
88
|
+
if (typeof msg.content === "string") return msg.content;
|
|
89
|
+
if (Array.isArray(msg.content)) {
|
|
90
|
+
const parts = (msg.content as Record<string, unknown>[]).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text as string);
|
|
91
|
+
if (parts.length > 0) return parts.join("\n");
|
|
187
92
|
}
|
|
188
93
|
}
|
|
189
|
-
|
|
190
94
|
return "(No output produced)";
|
|
191
95
|
}
|
|
192
96
|
|
|
193
|
-
const MIME_MAP: Record<string, string> = {
|
|
194
|
-
".md": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf",
|
|
195
|
-
".json": "application/json", ".csv": "text/csv", ".html": "text/html",
|
|
196
|
-
".js": "application/javascript", ".ts": "application/typescript",
|
|
197
|
-
".py": "text/x-python", ".png": "image/png", ".jpg": "image/jpeg",
|
|
198
|
-
".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
199
|
-
".mp4": "video/mp4", ".zip": "application/zip",
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Extract file paths from agent session messages by looking at Write/write_file
|
|
204
|
-
* tool calls in the assistant's content blocks.
|
|
205
|
-
*/
|
|
206
97
|
function extractWrittenFiles(messages: unknown[]): string[] {
|
|
207
98
|
const files = new Set<string>();
|
|
208
99
|
for (const msg of messages) {
|
|
@@ -210,14 +101,13 @@ function extractWrittenFiles(messages: unknown[]): string[] {
|
|
|
210
101
|
if (!m || !Array.isArray(m.content)) continue;
|
|
211
102
|
for (const block of m.content) {
|
|
212
103
|
const b = block as Record<string, unknown>;
|
|
213
|
-
// Match tool_use blocks (Claude-style) and tool_call blocks (OpenAI-style)
|
|
214
104
|
if (b.type !== "tool_use" && b.type !== "tool_call") continue;
|
|
215
105
|
const name = String(b.name ?? b.function ?? "").toLowerCase();
|
|
216
106
|
if (!name.includes("write") && !name.includes("create_file") && !name.includes("save")) continue;
|
|
217
107
|
const raw = b.input ?? b.arguments;
|
|
218
|
-
const input = typeof raw === "string" ?
|
|
108
|
+
const input = typeof raw === "string" ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : raw;
|
|
219
109
|
if (input && typeof input === "object") {
|
|
220
|
-
const fp = (input as
|
|
110
|
+
const fp = (input as any).file_path ?? (input as any).path;
|
|
221
111
|
if (typeof fp === "string" && fp) files.add(fp);
|
|
222
112
|
}
|
|
223
113
|
}
|
|
@@ -225,53 +115,28 @@ function extractWrittenFiles(messages: unknown[]): string[] {
|
|
|
225
115
|
return Array.from(files);
|
|
226
116
|
}
|
|
227
117
|
|
|
228
|
-
/**
|
|
229
|
-
* Extract file paths from OUTPUT_FILE: markers in the agent's text output.
|
|
230
|
-
*/
|
|
231
118
|
function extractOutputFileMarkers(output: string): string[] {
|
|
232
|
-
|
|
233
|
-
for (const line of output.split("\n")) {
|
|
234
|
-
const match = line.match(/OUTPUT_FILE:\s*(.+)/);
|
|
235
|
-
if (match) {
|
|
236
|
-
const fp = match[1].trim().replace(/`/g, "");
|
|
237
|
-
if (fp) files.push(fp);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return files;
|
|
119
|
+
return output.split("\n").map((l) => l.match(/OUTPUT_FILE:\s*(.+)/)?.[1]?.trim().replace(/`/g, "")).filter((f): f is string => !!f);
|
|
241
120
|
}
|
|
242
121
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
122
|
+
const MIME_MAP: Record<string, string> = {
|
|
123
|
+
".md": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf",
|
|
124
|
+
".json": "application/json", ".csv": "text/csv", ".html": "text/html",
|
|
125
|
+
".js": "application/javascript", ".ts": "application/typescript",
|
|
126
|
+
".py": "text/x-python", ".png": "image/png", ".jpg": "image/jpeg",
|
|
127
|
+
".gif": "image/gif", ".svg": "image/svg+xml", ".zip": "application/zip",
|
|
128
|
+
};
|
|
246
129
|
|
|
247
|
-
|
|
248
|
-
* Read all valid files from disk and encode them as base64.
|
|
249
|
-
* Skips missing files and files > MAX_FILE_SIZE.
|
|
250
|
-
*/
|
|
251
|
-
function readAllFiles(
|
|
252
|
-
paths: string[],
|
|
253
|
-
log?: {
|
|
254
|
-
info?: (message: string, ...args: unknown[]) => void;
|
|
255
|
-
warn?: (message: string, ...args: unknown[]) => void;
|
|
256
|
-
},
|
|
257
|
-
): AgentResultFile[] {
|
|
130
|
+
function readAllFiles(paths: string[], log?: { warn?: (m: string, ...a: unknown[]) => void; info?: (m: string, ...a: unknown[]) => void }): AgentResultFile[] {
|
|
258
131
|
const results: AgentResultFile[] = [];
|
|
259
132
|
for (const fp of paths) {
|
|
260
133
|
try {
|
|
261
|
-
if (!fs.existsSync(fp))
|
|
134
|
+
if (!fs.existsSync(fp)) continue;
|
|
262
135
|
const stat = fs.statSync(fp);
|
|
263
|
-
if (stat.size > MAX_FILE_SIZE)
|
|
136
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
264
137
|
const data = fs.readFileSync(fp);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
results.push({
|
|
268
|
-
filename: path.basename(fp),
|
|
269
|
-
mimeType: MIME_MAP[ext] ?? "application/octet-stream",
|
|
270
|
-
data: data.toString("base64"),
|
|
271
|
-
});
|
|
272
|
-
} catch (err) {
|
|
273
|
-
log?.warn?.(`[clawroom:executor] failed to read ${fp}: ${err}`);
|
|
274
|
-
}
|
|
138
|
+
results.push({ filename: path.basename(fp), mimeType: MIME_MAP[path.extname(fp).toLowerCase()] ?? "application/octet-stream", data: data.toString("base64") });
|
|
139
|
+
} catch {}
|
|
275
140
|
}
|
|
276
141
|
return results;
|
|
277
142
|
}
|