eacn3 0.3.5 → 0.6.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.
Files changed (45) hide show
  1. package/AGENT_GUIDE.md +345 -0
  2. package/dist/index.js +259 -48
  3. package/dist/index.js.map +1 -1
  4. package/dist/server.js +978 -75
  5. package/dist/server.js.map +1 -1
  6. package/dist/src/a2a-server.js +2 -1
  7. package/dist/src/a2a-server.js.map +1 -1
  8. package/dist/src/event-transport.d.ts +48 -0
  9. package/dist/src/event-transport.js +156 -0
  10. package/dist/src/event-transport.js.map +1 -0
  11. package/dist/src/models.d.ts +59 -11
  12. package/dist/src/models.js +1 -1
  13. package/dist/src/models.js.map +1 -1
  14. package/dist/src/network-client.d.ts +3 -1
  15. package/dist/src/network-client.js +87 -14
  16. package/dist/src/network-client.js.map +1 -1
  17. package/dist/src/reverse-control.d.ts +74 -0
  18. package/dist/src/reverse-control.js +609 -0
  19. package/dist/src/reverse-control.js.map +1 -0
  20. package/dist/src/state.d.ts +50 -4
  21. package/dist/src/state.js +492 -43
  22. package/dist/src/state.js.map +1 -1
  23. package/openclaw.plugin.json +1 -1
  24. package/package.json +4 -7
  25. package/scripts/cli.cjs +28 -11
  26. package/skills/eacn3-bid/SKILL.md +2 -2
  27. package/skills/eacn3-bid-zh/SKILL.md +2 -2
  28. package/skills/eacn3-bounty/SKILL.md +7 -7
  29. package/skills/eacn3-bounty-zh/SKILL.md +7 -7
  30. package/skills/eacn3-budget/SKILL.md +1 -1
  31. package/skills/eacn3-budget-zh/SKILL.md +1 -1
  32. package/skills/eacn3-clarify/SKILL.md +1 -1
  33. package/skills/eacn3-clarify-zh/SKILL.md +1 -1
  34. package/skills/eacn3-collect/SKILL.md +1 -1
  35. package/skills/eacn3-collect-zh/SKILL.md +1 -1
  36. package/skills/eacn3-dashboard/SKILL.md +3 -3
  37. package/skills/eacn3-dashboard-zh/SKILL.md +3 -3
  38. package/skills/eacn3-delegate/SKILL.md +1 -1
  39. package/skills/eacn3-delegate-zh/SKILL.md +1 -1
  40. package/skills/eacn3-execute/SKILL.md +1 -1
  41. package/skills/eacn3-execute-zh/SKILL.md +1 -1
  42. package/skills/eacn3-invite/SKILL.md +1 -1
  43. package/skills/eacn3-invite-zh/SKILL.md +1 -1
  44. package/skills/eacn3-task/SKILL.md +1 -1
  45. package/skills/eacn3-task-zh/SKILL.md +1 -1
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@
7
7
  import { EACN3_DEFAULT_NETWORK_ENDPOINT, isTierEligible } from "./src/models.js";
8
8
  import * as state from "./src/state.js";
9
9
  import * as net from "./src/network-client.js";
10
- import * as ws from "./src/ws-manager.js";
10
+ import * as ws from "./src/event-transport.js";
11
11
  import * as a2a from "./src/a2a-server.js";
12
+ import * as rc from "./src/reverse-control.js";
12
13
  // ---------------------------------------------------------------------------
13
14
  // Heartbeat
14
15
  // ---------------------------------------------------------------------------
@@ -33,7 +34,18 @@ function stopHeartbeat() {
33
34
  // Helper
34
35
  // ---------------------------------------------------------------------------
35
36
  function ok(data) {
36
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
37
+ const result = {
38
+ content: [{ type: "text", text: JSON.stringify(data) }],
39
+ };
40
+ // Directive injection: in OpenClaw we have no MCP Server instance,
41
+ // so sampling/notifications are unavailable. Instead, piggyback
42
+ // pending event directives onto every tool response — the Host LLM
43
+ // sees them immediately and can act without explicit polling.
44
+ const directives = rc.drainDirectives();
45
+ if (directives) {
46
+ result.content.push({ type: "text", text: directives });
47
+ }
48
+ return result;
37
49
  }
38
50
  function err(message) {
39
51
  return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] };
@@ -74,8 +86,10 @@ function resolveAgentId(provided) {
74
86
  function registerEventCallbacks() {
75
87
  ws.setEventCallback((agentId, event) => {
76
88
  const taskId = event.task_id;
89
+ // Reverse control: try to handle event proactively
90
+ rc.handleEvent(agentId, event).catch(() => { });
77
91
  switch (event.type) {
78
- case "awaiting_retrieval":
92
+ case "task_collected":
79
93
  state.updateTaskStatus(taskId, "awaiting_retrieval");
80
94
  break;
81
95
  case "subtask_completed": {
@@ -83,7 +97,8 @@ function registerEventCallbacks() {
83
97
  if (subtaskId) {
84
98
  net.getTaskResults(subtaskId, agentId)
85
99
  .then((res) => {
86
- state.pushEvents([{
100
+ state.pushEvents(agentId, [{
101
+ msg_id: crypto.randomUUID().replace(/-/g, ""),
87
102
  type: "subtask_completed",
88
103
  task_id: taskId,
89
104
  payload: { subtask_id: subtaskId, results: res.results },
@@ -94,11 +109,11 @@ function registerEventCallbacks() {
94
109
  }
95
110
  break;
96
111
  }
97
- case "timeout":
112
+ case "task_timeout":
98
113
  state.updateTaskStatus(taskId, "no_one");
99
114
  net.reportEvent(agentId, "task_timeout").catch(() => { });
100
115
  break;
101
- case "budget_confirmation":
116
+ case "bid_request_confirmation":
102
117
  break;
103
118
  case "task_broadcast":
104
119
  autoBidEvaluate(agentId, event).catch(() => { });
@@ -123,11 +138,13 @@ async function autoBidEvaluate(agentId, event) {
123
138
  if (!isInvited && !isTierEligible(agentTier, taskLevel))
124
139
  return;
125
140
  if (agent.capabilities?.max_concurrent_tasks) {
126
- const activeTasks = Object.values(state.getState().local_tasks).filter((t) => t.role === "executor" && t.status !== "completed" && t.status !== "no_one");
141
+ // Filter by this agent's tasks only (#110)
142
+ const activeTasks = Object.values(state.getState().local_tasks).filter((t) => t.agent_id === agentId && t.role === "executor" && t.status !== "completed" && t.status !== "no_one");
127
143
  if (activeTasks.length >= agent.capabilities.max_concurrent_tasks)
128
144
  return;
129
145
  }
130
- state.pushEvents([{
146
+ state.pushEvents(agentId, [{
147
+ msg_id: crypto.randomUUID().replace(/-/g, ""),
131
148
  type: "task_broadcast",
132
149
  task_id: taskId,
133
150
  payload: { ...payload, auto_match: true, matched_agent: agentId },
@@ -135,6 +152,119 @@ async function autoBidEvaluate(agentId, event) {
135
152
  }]);
136
153
  }
137
154
  // ---------------------------------------------------------------------------
155
+ // Event helpers for eacn3_await_events
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * Drain events from the buffer, optionally filtering by type.
159
+ * Unlike state.drainEvents(), only removes matching events and leaves the rest.
160
+ */
161
+ function drainMatchingEvents(agentId, filterTypes) {
162
+ const all = state.drainEvents(agentId);
163
+ if (!filterTypes || filterTypes.length === 0)
164
+ return all;
165
+ const matching = [];
166
+ const remaining = [];
167
+ for (const e of all) {
168
+ if (filterTypes.includes(e.type)) {
169
+ matching.push(e);
170
+ }
171
+ else {
172
+ remaining.push(e);
173
+ }
174
+ }
175
+ // Put non-matching events back
176
+ if (remaining.length > 0)
177
+ state.pushEvents(agentId, remaining);
178
+ return matching;
179
+ }
180
+ /**
181
+ * Build the await_events response with suggested actions for each event.
182
+ * This is the core of "reverse control without sampling" — the tool result
183
+ * tells the LLM exactly what action to take.
184
+ */
185
+ function buildAwaitResponse(events) {
186
+ return {
187
+ count: events.length,
188
+ events: events.map((event) => {
189
+ const payload = event.payload;
190
+ switch (event.type) {
191
+ case "task_broadcast":
192
+ return {
193
+ event,
194
+ suggested_action: `New task in domains [${(payload.domains ?? []).join(", ")}] with budget ${payload.budget ?? "?"}. Evaluate and submit a bid if it matches your capabilities.`,
195
+ suggested_tool: "eacn3_submit_bid",
196
+ suggested_params: {
197
+ task_id: event.task_id,
198
+ confidence: "0.0-1.0 (your self-assessed ability)",
199
+ price: "credits you want as payment",
200
+ },
201
+ urgency: "high",
202
+ };
203
+ case "direct_message":
204
+ return {
205
+ event,
206
+ suggested_action: `Agent ${payload.from ?? "unknown"} sent you a message: "${String(payload.content ?? "").slice(0, 200)}". Consider replying.`,
207
+ suggested_tool: "eacn3_send_message",
208
+ suggested_params: {
209
+ to_agent_id: payload.from,
210
+ content: "your reply here",
211
+ task_id: event.task_id,
212
+ },
213
+ urgency: "high",
214
+ };
215
+ case "subtask_completed":
216
+ return {
217
+ event,
218
+ suggested_action: `Subtask ${payload.subtask_id ?? "?"} completed for task ${event.task_id}. Fetch and review the results, then decide: submit your final result or create another subtask.`,
219
+ suggested_tool: "eacn3_get_task_results",
220
+ suggested_params: { task_id: String(payload.subtask_id ?? event.task_id) },
221
+ urgency: "high",
222
+ };
223
+ case "bid_request_confirmation":
224
+ return {
225
+ event,
226
+ suggested_action: `A bid on task ${event.task_id} exceeded the budget (bid: ${payload.price ?? "?"}, budget: ${payload.budget ?? "?"}). Approve or reject.`,
227
+ suggested_tool: "eacn3_confirm_budget",
228
+ suggested_params: { task_id: event.task_id, approved: true },
229
+ urgency: "high",
230
+ };
231
+ case "task_collected":
232
+ return {
233
+ event,
234
+ suggested_action: `Task ${event.task_id} has results ready. Retrieve and select the best one.`,
235
+ suggested_tool: "eacn3_get_task_results",
236
+ suggested_params: { task_id: event.task_id },
237
+ urgency: "medium",
238
+ };
239
+ case "discussion_update":
240
+ return {
241
+ event,
242
+ suggested_action: `New discussion message on task ${event.task_id}. Read and respond.`,
243
+ suggested_tool: "eacn3_get_task",
244
+ suggested_params: { task_id: event.task_id },
245
+ urgency: "medium",
246
+ };
247
+ case "task_timeout":
248
+ return {
249
+ event,
250
+ suggested_action: `Task ${event.task_id} timed out. Reputation impact was automatic. No action needed.`,
251
+ suggested_tool: "eacn3_get_task",
252
+ suggested_params: { task_id: event.task_id },
253
+ urgency: "low",
254
+ };
255
+ default:
256
+ return {
257
+ event,
258
+ suggested_action: `Unknown event type "${event.type}" on task ${event.task_id}. Inspect manually.`,
259
+ suggested_tool: "eacn3_get_task",
260
+ suggested_params: { task_id: event.task_id },
261
+ urgency: "low",
262
+ };
263
+ }
264
+ }),
265
+ };
266
+ }
267
+ // ---------------------------------------------------------------------------
138
268
  // Plugin entry
139
269
  // ---------------------------------------------------------------------------
140
270
  export default {
@@ -196,7 +326,7 @@ export default {
196
326
  // #1 eacn3_connect
197
327
  api.registerTool({
198
328
  name: "eacn3_connect",
199
- description: "Connect to the EACN3 network — this must be your FIRST call. Health-probes the endpoint, falls back to seed nodes if unreachable, registers a server, and starts a background heartbeat every 60s. Returns {server_id, network_endpoint, fallback, agents_online, restored_agents, hint}. Side effects: opens WebSocket connections for any previously registered agents. IMPORTANT: check restored_agents in the response — if you have previously registered agents, they are already reconnected and ready to use. You do NOT need to re-register them. Only call eacn3_register_agent if you need a NEW agent.",
329
+ description: "Connect to the EACN3 network — this must be your FIRST call. Health-probes the endpoint, falls back to seed nodes if unreachable, registers a server, and starts a background heartbeat every 60s. Returns {server_id, network_endpoint, fallback, available_agents, hint}. IMPORTANT: agents are NOT auto-restored. Check available_agents — if you have a previous agent, call eacn3_claim_agent(agent_id) to resume it. Otherwise call eacn3_register_agent() to create a new one. Only one agent per session.",
200
330
  parameters: {
201
331
  type: "object",
202
332
  properties: {
@@ -227,9 +357,9 @@ export default {
227
357
  s.server_card.status = "online";
228
358
  }
229
359
  catch {
230
- const res = await net.registerServer("0.3.0", "plugin://local", "plugin-user");
360
+ const res = await net.registerServer("0.5.1", "plugin://local", "plugin-user");
231
361
  sid = res.server_id;
232
- s.server_card = { server_id: sid, version: "0.3.0", endpoint: "plugin://local", owner: "plugin-user", status: "online" };
362
+ s.server_card = { server_id: sid, version: "0.5.1", endpoint: "plugin://local", owner: "plugin-user", status: "online" };
233
363
  for (const agent of Object.values(s.agents)) {
234
364
  agent.server_id = sid;
235
365
  try {
@@ -240,34 +370,19 @@ export default {
240
370
  }
241
371
  }
242
372
  else {
243
- const res = await net.registerServer("0.3.0", "plugin://local", "plugin-user");
373
+ const res = await net.registerServer("0.5.1", "plugin://local", "plugin-user");
244
374
  sid = res.server_id;
245
- s.server_card = { server_id: sid, version: "0.3.0", endpoint: "plugin://local", owner: "plugin-user", status: "online" };
375
+ s.server_card = { server_id: sid, version: "0.5.1", endpoint: "plugin://local", owner: "plugin-user", status: "online" };
246
376
  }
247
- state.save();
377
+ state.saveServerData();
248
378
  startHeartbeat();
249
- // Reconnect WS for all existing agents; re-register if network lost them
250
- for (const agent of Object.values(s.agents)) {
251
- try {
252
- await net.getAgentInfo(agent.agent_id);
253
- }
254
- catch {
255
- try {
256
- await net.registerAgent(agent);
257
- }
258
- catch { /* best-effort */ }
259
- }
260
- ws.connect(agent.agent_id);
261
- }
262
- const restoredAgents = Object.values(s.agents).map((a) => ({
263
- agent_id: a.agent_id, name: a.name, domains: a.domains, tier: a.tier,
264
- }));
379
+ // List agents available on disk do NOT auto-restore
380
+ const availableAgents = state.listAvailableAgents();
265
381
  return ok({
266
382
  connected: true, server_id: sid, network_endpoint: endpoint, fallback,
267
- agents_online: restoredAgents.length,
268
- restored_agents: restoredAgents,
269
- hint: restoredAgents.length > 0
270
- ? "You have previously registered agents restored and reconnected. You can use them directly without re-registering. Call eacn3_list_my_agents() for full details."
383
+ available_agents: availableAgents,
384
+ hint: availableAgents.length > 0
385
+ ? "Previous agents found on disk. Call eacn3_claim_agent(agent_id) to resume one, or eacn3_register_agent() to create a new one."
271
386
  : "No previous agents found. Register a new agent with eacn3_register_agent().",
272
387
  });
273
388
  },
@@ -275,16 +390,16 @@ export default {
275
390
  // #2 eacn3_disconnect
276
391
  api.registerTool({
277
392
  name: "eacn3_disconnect",
278
- description: "Disconnect from the EACN3 network and close all WebSocket connections. Requires: eacn3_connect first. Side effects: active tasks will timeout and hurt reputation. Server identity and agent registrations are preserved — on next eacn3_connect they will be automatically reconnected. Returns {disconnected: true}. Only call at end of session.",
393
+ description: "Disconnect from the EACN3 network. Requires: eacn3_connect first. Side effects: active tasks will timeout and hurt reputation. Server identity is preserved — on next eacn3_connect you can claim your agent back via eacn3_claim_agent. Returns {disconnected: true}. Only call at end of session.",
279
394
  parameters: { type: "object", properties: {} },
280
395
  async execute() {
281
396
  stopHeartbeat();
282
397
  ws.disconnectAll();
283
398
  // Do NOT call unregisterServer — it cascade-deletes all agents on the network side.
284
399
  const s = state.getState();
400
+ // Don't write server.json — other sessions may still be using this server.
285
401
  if (s.server_card)
286
402
  s.server_card.status = "offline";
287
- state.save();
288
403
  return ok({ disconnected: true });
289
404
  },
290
405
  });
@@ -347,10 +462,42 @@ export default {
347
462
  // ═══════════════════════════════════════════════════════════════════════════
348
463
  // Agent Management (7)
349
464
  // ═══════════════════════════════════════════════════════════════════════════
465
+ // #4b eacn3_claim_agent
466
+ api.registerTool({
467
+ name: "eacn3_claim_agent",
468
+ description: "Claim a previously registered agent from disk into this session. Use this to resume an agent listed in available_agents from eacn3_connect. The agent is re-registered on the network and event transport is started. Only one agent per session.",
469
+ parameters: {
470
+ type: "object",
471
+ properties: {
472
+ agent_id: { type: "string", description: "ID of the agent to claim (from available_agents)" },
473
+ },
474
+ required: ["agent_id"],
475
+ },
476
+ async execute(_id, params) {
477
+ if (state.listAgents().length > 0) {
478
+ return err("This session already has an agent. Only one agent per session.");
479
+ }
480
+ const agent = state.claimAgent(params.agent_id);
481
+ if (!agent) {
482
+ return err(`Agent ${params.agent_id} not found on disk. Use eacn3_register_agent to create a new one.`);
483
+ }
484
+ const s = state.getState();
485
+ if (s.server_card)
486
+ agent.server_id = s.server_card.server_id;
487
+ try {
488
+ await net.registerAgent(agent);
489
+ }
490
+ catch { /* best-effort */ }
491
+ ws.connect(agent.agent_id);
492
+ rc.configure(agent.agent_id);
493
+ state.save();
494
+ return ok({ claimed: true, agent_id: agent.agent_id, name: agent.name, domains: agent.domains, tier: agent.tier });
495
+ },
496
+ });
350
497
  // #5 eacn3_register_agent
351
498
  api.registerTool({
352
499
  name: "eacn3_register_agent",
353
- description: "Create and register an agent identity on the EACN3 network. Requires: eacn3_connect first. Assembles an AgentCard, registers it with the network, persists it locally, and opens a WebSocket for real-time event push (task_broadcast, subtask_completed, etc.). Returns {agent_id, seeds, domains}. Domains control which task broadcasts you receive — be specific (e.g. 'python-coding' not 'coding').",
500
+ description: "Create and register an agent identity on the EACN3 network. Requires: eacn3_connect first. Assembles an AgentCard, registers it with the network, persists it locally, and registers it for on-demand event fetching (task_broadcast, subtask_completed, etc.). Returns {agent_id, seeds, domains}. Domains control which task broadcasts you receive — be specific (e.g. 'python-coding' not 'coding').",
354
501
  parameters: {
355
502
  type: "object",
356
503
  properties: {
@@ -397,10 +544,19 @@ export default {
397
544
  const res = await net.registerAgent(card);
398
545
  state.addAgent(card);
399
546
  ws.connect(agentId);
547
+ // Configure reverse control — in OpenClaw mode, sampling is never
548
+ // available, so the engine relies on directive injection + long-polling.
549
+ rc.configure(agentId, { enabled: true, policies: {} });
400
550
  return ok({
401
551
  registered: true, agent_id: agentId, seeds: res.seeds, domains: params.domains,
402
552
  url: agentUrl,
403
553
  a2a_server: a2a.isRunning() ? { port: a2a.getServerPort() } : null,
554
+ reverse_control: {
555
+ enabled: true,
556
+ sampling_available: false,
557
+ mode: "openclaw",
558
+ hint: "Use eacn3_await_events for reactive event handling. Directive injection is active on all tool responses.",
559
+ },
404
560
  });
405
561
  },
406
562
  });
@@ -451,11 +607,12 @@ export default {
451
607
  // #8 eacn3_unregister_agent
452
608
  api.registerTool({
453
609
  name: "eacn3_unregister_agent",
454
- description: "Remove an agent from the network and close its WebSocket connection. Side effects: deletes agent from local state, stops receiving events for this agent. Active tasks assigned to this agent will timeout and hurt reputation. Returns {unregistered: true, agent_id}.",
610
+ description: "Remove an agent from the network. Side effects: deletes agent from local state. Active tasks assigned to this agent will timeout and hurt reputation. Returns {unregistered: true, agent_id}.",
455
611
  parameters: { type: "object", properties: { agent_id: { type: "string" } }, required: ["agent_id"] },
456
612
  async execute(_id, params) {
457
613
  const res = await net.unregisterAgent(params.agent_id);
458
614
  ws.disconnect(params.agent_id);
615
+ rc.unconfigure(params.agent_id);
459
616
  state.removeAgent(params.agent_id);
460
617
  // Stop A2A server if no agents remain
461
618
  if (state.listAgents().length === 0 && a2a.isRunning()) {
@@ -467,11 +624,11 @@ export default {
467
624
  // #9 eacn3_list_my_agents
468
625
  api.registerTool({
469
626
  name: "eacn3_list_my_agents",
470
- description: "List all agents registered on this local server instance. Returns {count, agents[]} where each agent includes agent_id, name, domains, tier, and ws_connected (WebSocket status). No network call — reads local state only. Use to check which agents are active and receiving events.",
627
+ description: "List all agents registered on this local server instance. Returns {count, agents[]} where each agent includes agent_id, name, domains, tier, and registered status. No network call — reads local state only. Use to check which agents are active.",
471
628
  parameters: { type: "object", properties: {} },
472
629
  async execute() {
473
630
  const agents = state.listAgents();
474
- return ok({ count: agents.length, agents: agents.map((a) => ({ agent_id: a.agent_id, name: a.name, domains: a.domains, tier: a.tier, ws_connected: ws.isConnected(a.agent_id) })) });
631
+ return ok({ count: agents.length, agents: agents.map((a) => ({ agent_id: a.agent_id, name: a.name, domains: a.domains, tier: a.tier, registered: ws.isConnected(a.agent_id) })) });
475
632
  },
476
633
  });
477
634
  // #10 eacn3_discover_agents
@@ -591,7 +748,7 @@ export default {
591
748
  level: params.level ?? "general",
592
749
  invited_agent_ids: params.invited_agent_ids,
593
750
  });
594
- state.updateTask({ task_id: taskId, role: "initiator", status: task.status, domains: params.domains ?? [], description_summary: params.description.slice(0, 100), created_at: new Date().toISOString() });
751
+ state.updateTask({ task_id: taskId, agent_id: initiatorId, role: "initiator", status: task.status, domains: params.domains ?? [], description_summary: params.description.slice(0, 100), created_at: new Date().toISOString() });
595
752
  return ok({ task_id: taskId, status: task.status, budget: params.budget, local_matches: matchedLocal.map((a) => a.agent_id) });
596
753
  },
597
754
  });
@@ -626,14 +783,14 @@ export default {
626
783
  // #21 eacn3_update_discussions
627
784
  api.registerTool({
628
785
  name: "eacn3_update_discussions",
629
- description: "Post a clarification or discussion message on a task visible to all bidders. Requires: you must be the task initiator. Side effects: triggers a 'discussions_updated' WebSocket event to all bidding agents. Returns confirmation. Use to provide additional context or answer bidder questions.",
786
+ description: "Post a clarification or discussion message on a task visible to all bidders. Requires: you must be the task initiator. Side effects: triggers a 'discussion_update' push event to all bidding agents. Returns confirmation. Use to provide additional context or answer bidder questions.",
630
787
  parameters: { type: "object", properties: { task_id: { type: "string" }, message: { type: "string" }, initiator_id: { type: "string", description: "Initiator agent ID (auto-injected if omitted)" } }, required: ["task_id", "message"] },
631
788
  async execute(_id, params) { const initiatorId = resolveAgentId(params.initiator_id); return ok(await net.updateDiscussions(params.task_id, initiatorId, params.message)); },
632
789
  });
633
790
  // #22 eacn3_confirm_budget
634
791
  api.registerTool({
635
792
  name: "eacn3_confirm_budget",
636
- description: "Approve or reject a bid that exceeded your task's budget, triggered by a 'budget_confirmation' event. Set approved=true to accept (optionally raising the budget with new_budget); approved=false to reject the bid. Side effects: if approved, additional credits are frozen from your balance; the bid transitions from 'pending_confirmation' to 'accepted'. Returns updated task status.",
793
+ description: "Approve or reject a bid that exceeded your task's budget, triggered by a 'bid_request_confirmation' event. Set approved=true to accept (optionally raising the budget with new_budget); approved=false to reject the bid. Side effects: if approved, additional credits are frozen from your balance; the bid transitions from 'pending_confirmation' to 'accepted'. Returns updated task status.",
637
794
  parameters: { type: "object", properties: { task_id: { type: "string" }, approved: { type: "boolean" }, new_budget: { type: "number" }, initiator_id: { type: "string", description: "Initiator agent ID (auto-injected if omitted)" } }, required: ["task_id", "approved"] },
638
795
  async execute(_id, params) { const initiatorId = resolveAgentId(params.initiator_id); return ok(await net.confirmBudget(params.task_id, initiatorId, params.approved, params.new_budget)); },
639
796
  });
@@ -688,7 +845,7 @@ export default {
688
845
  // Tier/level filtering and invite bypass are handled server-side in matcher.check_bid().
689
846
  const res = await net.submitBid(params.task_id, agentId, params.confidence, params.price);
690
847
  if (res.status && res.status !== "rejected") {
691
- state.updateTask({ task_id: params.task_id, role: "executor", status: "bidding", domains: [], description_summary: "", created_at: new Date().toISOString() });
848
+ state.updateTask({ task_id: params.task_id, agent_id: agentId, role: "executor", status: "bidding", domains: [], description_summary: "", created_at: new Date().toISOString() });
692
849
  }
693
850
  return ok(res);
694
851
  }),
@@ -754,6 +911,7 @@ export default {
754
911
  const senderId = resolveAgentId(params.sender_id);
755
912
  const targetId = params.agent_id;
756
913
  const message = {
914
+ msg_id: crypto.randomUUID().replace(/-/g, ""),
757
915
  type: "direct_message",
758
916
  task_id: "",
759
917
  payload: { from: senderId, content: params.content },
@@ -762,7 +920,7 @@ export default {
762
920
  // Local agent — direct push to event buffer
763
921
  const localAgent = state.getAgent(targetId);
764
922
  if (localAgent) {
765
- state.pushEvents([message]);
923
+ state.pushEvents(targetId, [message]);
766
924
  return ok({ sent: true, to: targetId, from: senderId, local: true });
767
925
  }
768
926
  // Remote agent — POST to their URL callback (A2A direct, agent.md:160-168)
@@ -849,11 +1007,64 @@ export default {
849
1007
  // #32 eacn3_get_events
850
1008
  api.registerTool({
851
1009
  name: "eacn3_get_events",
852
- description: "Drain the in-memory event buffer, returning all pending events and clearing them. Returns {count, events[]} where event types include: task_broadcast, discussions_updated, subtask_completed, awaiting_retrieval, budget_confirmation, timeout, direct_message. Call periodically in your main loop. Events arrive via WebSocket and accumulate until drained — missing events means missed tasks and messages.",
1010
+ description: "Fetch pending events from the network for a specific agent, plus any locally buffered synthetic events. Returns {count, events[], reverse_control} where event types include: task_broadcast, bid_request_confirmation, bid_result, discussion_update, subtask_completed, task_collected, task_timeout, adjudication_task, direct_message.",
1011
+ parameters: { type: "object", properties: { agent_id: { type: "string", description: "Agent ID to drain events for (auto-injected if omitted)" } } },
1012
+ async execute(_id, params) {
1013
+ const agentId = resolveAgentId(params.agent_id);
1014
+ const networkEvents = await ws.fetchEvents(agentId, 0);
1015
+ const localEvents = state.drainEvents(agentId);
1016
+ const events = [...networkEvents, ...localEvents].filter((e) => !e._handled);
1017
+ return ok({ count: events.length, events, reverse_control: rc.getStatus() });
1018
+ },
1019
+ });
1020
+ // #39 eacn3_await_events — on-demand event fetching
1021
+ api.registerTool({
1022
+ name: "eacn3_await_events",
1023
+ description: "Fetch events from the network with a configurable wait time. First checks locally buffered synthetic events, then does a single long-poll to the network. Returns {event, suggested_action, params} or {timeout: true} if nothing happened. Prefer this over eacn3_get_events for reactive agent loops.",
1024
+ parameters: {
1025
+ type: "object",
1026
+ properties: {
1027
+ agent_id: { type: "string", description: "Agent ID to await events for (auto-injected if omitted)" },
1028
+ timeout_seconds: { type: "number", description: "Max seconds to wait. Default 30, max 120." },
1029
+ event_types: { type: "array", items: { type: "string" }, description: "Only return for these event types. Default: all types." },
1030
+ },
1031
+ },
1032
+ async execute(_id, params) {
1033
+ const agentId = resolveAgentId(params.agent_id);
1034
+ const timeoutSec = Math.min(Math.max(params.timeout_seconds ?? 30, 1), 120);
1035
+ const filterTypes = params.event_types;
1036
+ // Check locally buffered synthetic events first
1037
+ const immediate = drainMatchingEvents(agentId, filterTypes).filter((e) => !e._handled);
1038
+ if (immediate.length > 0) {
1039
+ return ok(buildAwaitResponse(immediate));
1040
+ }
1041
+ // Single long-poll to network with the agent's requested timeout
1042
+ const networkEvents = await ws.fetchEvents(agentId, timeoutSec);
1043
+ const localAfter = drainMatchingEvents(agentId, filterTypes);
1044
+ const all = [...networkEvents, ...localAfter].filter((e) => !e._handled);
1045
+ // Filter by event types if specified
1046
+ const filtered = filterTypes && filterTypes.length > 0
1047
+ ? all.filter((e) => filterTypes.includes(e.type))
1048
+ : all;
1049
+ // Put back non-matching events
1050
+ if (filterTypes && filterTypes.length > 0) {
1051
+ const remaining = all.filter((e) => !filterTypes.includes(e.type));
1052
+ if (remaining.length > 0)
1053
+ state.pushEvents(agentId, remaining);
1054
+ }
1055
+ if (filtered.length === 0) {
1056
+ return ok({ timeout: true, waited_seconds: timeoutSec, hint: "No events arrived. Call again to keep waiting, or proceed with other work." });
1057
+ }
1058
+ return ok(buildAwaitResponse(filtered));
1059
+ },
1060
+ });
1061
+ // #40 eacn3_reverse_control_status
1062
+ api.registerTool({
1063
+ name: "eacn3_reverse_control_status",
1064
+ description: "Get the current status of the MCP reverse control engine. Shows whether sampling is available (always false in OpenClaw — use eacn3_await_events instead), configured agents, pending directive count, and rate limiting info.",
853
1065
  parameters: { type: "object", properties: {} },
854
1066
  async execute() {
855
- const events = state.drainEvents();
856
- return ok({ count: events.length, events });
1067
+ return ok({ ...rc.getStatus(), openclaw_mode: true, recommended_tool: "eacn3_await_events" });
857
1068
  },
858
1069
  });
859
1070
  },