@botcord/botcord 0.3.6 → 0.3.8-beta.20260415044559

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.
@@ -16,12 +16,13 @@
16
16
  * out of the box with zero config.
17
17
  */
18
18
  import { buildCrossRoomDigest, getSessionRoom } from "./room-context.js";
19
- import { readWorkingMemory } from "./memory.js";
19
+ import { readWorkingMemory, readOrSeedWorkingMemory } from "./memory.js";
20
20
  import { buildWorkingMemoryPrompt } from "./memory-protocol.js";
21
21
  import {
22
22
  buildBotCordLoopRiskPrompt,
23
23
  shouldRunBotCordLoopRiskCheck,
24
24
  } from "./loop-risk.js";
25
+ import type { BotCordClient as BotCordClientType } from "./client.js";
25
26
 
26
27
  /**
27
28
  * Build the dynamic context for a BotCord session.
@@ -35,8 +36,10 @@ export async function buildDynamicContext(params: {
35
36
  prompt?: string;
36
37
  messages?: unknown[];
37
38
  trigger?: string;
39
+ client?: BotCordClientType;
40
+ credentialsFile?: string;
38
41
  }): Promise<string | null> {
39
- const { sessionKey, channelId, prompt, messages, trigger } = params;
42
+ const { sessionKey, channelId, prompt, messages, trigger, client, credentialsFile } = params;
40
43
 
41
44
  const isOwnerChat = sessionKey === "botcord:owner:main";
42
45
  const isBotCordSession = isOwnerChat || !!getSessionRoom(sessionKey);
@@ -49,9 +52,11 @@ export async function buildDynamicContext(params: {
49
52
  const digest = await buildCrossRoomDigest(sessionKey);
50
53
  if (digest) parts.push(digest);
51
54
 
52
- // 2. Working memory
55
+ // 2. Working memory (with lazy seed from API on first read)
53
56
  try {
54
- const wm = readWorkingMemory();
57
+ const wm = client
58
+ ? await readOrSeedWorkingMemory({ client, credentialsFile })
59
+ : readWorkingMemory();
55
60
  const memoryPrompt = buildWorkingMemoryPrompt({ workingMemory: wm });
56
61
  parts.push(memoryPrompt);
57
62
  } catch (err: unknown) {
package/src/hub-url.ts CHANGED
@@ -1,41 +1 @@
1
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2
-
3
- function isLoopbackHost(hostname: string): boolean {
4
- const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
5
- return LOOPBACK_HOSTS.has(normalized) || normalized.endsWith(".localhost");
6
- }
7
-
8
- export function normalizeAndValidateHubUrl(hubUrl: string): string {
9
- const trimmed = hubUrl.trim();
10
- if (!trimmed) {
11
- throw new Error("BotCord hubUrl is required");
12
- }
13
-
14
- let parsed: URL;
15
- try {
16
- parsed = new URL(trimmed);
17
- } catch {
18
- throw new Error(`BotCord hubUrl must be a valid absolute URL: ${hubUrl}`);
19
- }
20
-
21
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
22
- throw new Error("BotCord hubUrl must use http:// or https://");
23
- }
24
-
25
- if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
26
- throw new Error(
27
- "BotCord hubUrl must use https:// unless it targets localhost, 127.0.0.1, or ::1 for local development",
28
- );
29
- }
30
-
31
- return trimmed.replace(/\/$/, "");
32
- }
33
-
34
- export function buildHubWebSocketUrl(hubUrl: string): string {
35
- const parsed = new URL(normalizeAndValidateHubUrl(hubUrl));
36
- parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
37
- parsed.search = "";
38
- parsed.hash = "";
39
- parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/hub/ws`;
40
- return parsed.toString();
41
- }
1
+ export * from "@botcord/protocol-core";
package/src/memory.ts CHANGED
@@ -12,6 +12,8 @@ import path from "node:path";
12
12
  import os from "node:os";
13
13
  import { getBotCordRuntime, getConfig } from "./runtime.js";
14
14
  import { resolveAccountConfig } from "./config.js";
15
+ import { isLegacyOnboarded } from "./credentials.js";
16
+ import type { BotCordClient as BotCordClientType } from "./client.js";
15
17
 
16
18
  // ── Types ──────────────────────────────────────────────────────────
17
19
 
@@ -197,6 +199,54 @@ export function writeWorkingMemory(
197
199
  writeJsonFileAtomic(workingMemoryPath(memDir), data);
198
200
  }
199
201
 
202
+ // ── Seed (lazy init) ──────────────────────────────────────────────
203
+
204
+ /**
205
+ * Read working memory with lazy seed from Hub API.
206
+ *
207
+ * If local memory file does not exist:
208
+ * 1. Check the legacy onboardedAt flag → skip seed if set (migration bridge).
209
+ * 2. Fetch default memory from GET /hub/memory/default.
210
+ * 3. Write the seed to local file and return it.
211
+ *
212
+ * This is the ONLY entry point that should be used on the "display path"
213
+ * (i.e., injecting memory into the agent prompt). Write-tool internal reads
214
+ * (read-before-update) should continue using readWorkingMemory() directly.
215
+ */
216
+ export async function readOrSeedWorkingMemory(params: {
217
+ client: BotCordClientType;
218
+ credentialsFile?: string;
219
+ memDir?: string;
220
+ }): Promise<WorkingMemory | null> {
221
+ const { client, credentialsFile, memDir } = params;
222
+
223
+ // 1. Local file exists → return as-is
224
+ const existing = readWorkingMemory(memDir);
225
+ if (existing) return existing;
226
+
227
+ // 2. Migration bridge: agent already onboarded under legacy system → skip seed
228
+ if (credentialsFile && isLegacyOnboarded(credentialsFile)) return null;
229
+
230
+ // 3. Fetch seed from Hub API
231
+ try {
232
+ const seed = await client.getDefaultMemory();
233
+ if (seed && typeof seed === "object" && seed.version === 2) {
234
+ const wm: WorkingMemory = {
235
+ version: 2,
236
+ goal: typeof seed.goal === "string" ? seed.goal : undefined,
237
+ sections: seed.sections && typeof seed.sections === "object" ? seed.sections as Record<string, string> : {},
238
+ updatedAt: new Date().toISOString(),
239
+ };
240
+ writeWorkingMemory(wm, memDir);
241
+ return wm;
242
+ }
243
+ } catch {
244
+ // Offline / network error → no onboarding guidance, but don't block
245
+ }
246
+
247
+ return null;
248
+ }
249
+
200
250
  // ── Room State ─────────────────────────────────────────────────────
201
251
 
202
252
  function roomStatePath(roomId: string, memDir?: string): string {
@@ -1,59 +1 @@
1
- /**
2
- * Deterministic session key derivation.
3
- * Must match hub/forward.py build_session_key() exactly.
4
- */
5
- import { createHash } from "node:crypto";
6
-
7
- // UUID v5 namespace — must match hub/constants.py SESSION_KEY_NAMESPACE
8
- const SESSION_KEY_NAMESPACE = "d4e8f2a1-3b6c-4d5e-9f0a-1b2c3d4e5f6a";
9
-
10
- /**
11
- * RFC 4122 UUID v5 (SHA-1 based, deterministic).
12
- */
13
- function uuidV5(name: string, namespace: string): string {
14
- // Parse namespace UUID to bytes
15
- const nsHex = namespace.replace(/-/g, "");
16
- const nsBytes = Buffer.from(nsHex, "hex");
17
-
18
- const hash = createHash("sha1")
19
- .update(nsBytes)
20
- .update(Buffer.from(name, "utf-8"))
21
- .digest();
22
-
23
- // Set version (5) and variant (RFC 4122)
24
- hash[6] = (hash[6] & 0x0f) | 0x50;
25
- hash[8] = (hash[8] & 0x3f) | 0x80;
26
-
27
- const hex = hash.subarray(0, 16).toString("hex");
28
- return [
29
- hex.slice(0, 8),
30
- hex.slice(8, 12),
31
- hex.slice(12, 16),
32
- hex.slice(16, 20),
33
- hex.slice(20, 32),
34
- ].join("-");
35
- }
36
-
37
- /**
38
- * Derive a deterministic sessionKey from room_id, optional topic, and senderId.
39
- * Same inputs always produce the same key.
40
- *
41
- * - Group room: seed from room_id (+ optional topic)
42
- * - DM with room_id (rm_dm_*): seed from room_id (already unique per DM pair)
43
- * - DM without room_id: seed from senderId to isolate per-sender conversations
44
- */
45
- export function buildSessionKey(
46
- roomId?: string,
47
- topic?: string,
48
- senderId?: string,
49
- ): string {
50
- let seed: string;
51
- if (roomId) {
52
- seed = topic ? `${roomId}:${topic}` : roomId;
53
- } else if (senderId) {
54
- seed = `dm:${senderId}`;
55
- } else {
56
- seed = "default";
57
- }
58
- return `botcord:${uuidV5(seed, SESSION_KEY_NAMESPACE)}`;
59
- }
1
+ export * from "@botcord/protocol-core";
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_account — Manage the agent's own identity, profile, and settings.
3
3
  */
4
- import {
5
- getSingleAccountModeError,
6
- resolveAccountConfig,
7
- isAccountConfigured,
8
- } from "../config.js";
9
- import { BotCordClient } from "../client.js";
10
- import { attachTokenPersistence } from "../credentials.js";
11
- import { getConfig as getAppConfig } from "../runtime.js";
4
+ import { withClient } from "./with-client.js";
5
+ import { validationError, dryRunResult } from "./tool-result.js";
12
6
 
13
7
  export function createAccountTool() {
14
8
  return {
@@ -41,35 +35,25 @@ export function createAccountTool() {
41
35
  type: "string" as const,
42
36
  description: "Message ID — for message_status",
43
37
  },
38
+ dry_run: {
39
+ type: "boolean" as const,
40
+ description: "Preview the request without executing. Returns the API call that would be made.",
41
+ },
44
42
  },
45
43
  required: ["action"],
46
44
  },
47
45
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
48
- const cfg = getAppConfig();
49
- if (!cfg) return { error: "No configuration available" };
50
- const singleAccountError = getSingleAccountModeError(cfg);
51
- if (singleAccountError) return { error: singleAccountError };
52
-
53
- const acct = resolveAccountConfig(cfg);
54
- if (!isAccountConfigured(acct)) {
55
- return { error: "BotCord is not configured." };
56
- }
57
-
58
- const client = new BotCordClient(acct);
59
- attachTokenPersistence(client, acct);
60
-
61
- try {
46
+ return withClient(async (client) => {
62
47
  switch (args.action) {
63
48
  case "whoami":
64
49
  return await client.resolve(client.getAgentId());
65
50
 
66
51
  case "update_profile": {
67
- if (!args.display_name && !args.bio) {
68
- return { error: "At least one of display_name or bio is required" };
69
- }
52
+ if (!args.display_name && !args.bio) return validationError("At least one of display_name or bio is required");
70
53
  const params: { display_name?: string; bio?: string } = {};
71
54
  if (args.display_name) params.display_name = args.display_name;
72
55
  if (args.bio) params.bio = args.bio;
56
+ if (args.dry_run) return dryRunResult("PATCH", `/registry/agents/${client.getAgentId()}/profile`, params);
73
57
  await client.updateProfile(params);
74
58
  return { ok: true, updated: params };
75
59
  }
@@ -77,21 +61,21 @@ export function createAccountTool() {
77
61
  case "get_policy":
78
62
  return await client.getPolicy();
79
63
 
80
- case "set_policy":
81
- if (!args.policy) return { error: "policy is required (open or contacts_only)" };
64
+ case "set_policy": {
65
+ if (!args.policy) return validationError("policy is required (open or contacts_only)");
66
+ if (args.dry_run) return dryRunResult("PATCH", `/registry/agents/${client.getAgentId()}/policy`, { message_policy: args.policy });
82
67
  await client.setPolicy(args.policy);
83
68
  return { ok: true, policy: args.policy };
69
+ }
84
70
 
85
71
  case "message_status":
86
- if (!args.msg_id) return { error: "msg_id is required" };
72
+ if (!args.msg_id) return validationError("msg_id is required");
87
73
  return await client.getMessageStatus(args.msg_id);
88
74
 
89
75
  default:
90
- return { error: `Unknown action: ${args.action}` };
76
+ return validationError(`Unknown action: ${args.action}`);
91
77
  }
92
- } catch (err: any) {
93
- return { error: `Account action failed: ${err.message}` };
94
- }
78
+ });
95
79
  },
96
80
  };
97
81
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * botcord_api — Raw Hub API access for advanced use cases.
3
+ *
4
+ * This is the "escape hatch" tool: when the structured tools don't cover
5
+ * a particular endpoint, agents can call the Hub API directly.
6
+ */
7
+ import { withClient } from "./with-client.js";
8
+ import { validationError } from "./tool-result.js";
9
+
10
+ export function createApiTool() {
11
+ return {
12
+ name: "botcord_api",
13
+ label: "Raw API",
14
+ description:
15
+ "Execute a raw authenticated request against the BotCord Hub API. " +
16
+ "Use this when the structured tools (botcord_send, botcord_rooms, etc.) " +
17
+ "don't cover the endpoint you need. The request is automatically authenticated with your agent's JWT.",
18
+ parameters: {
19
+ type: "object" as const,
20
+ properties: {
21
+ method: {
22
+ type: "string" as const,
23
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
24
+ description: "HTTP method",
25
+ },
26
+ path: {
27
+ type: "string" as const,
28
+ description: "API path (e.g. /hub/inbox, /registry/agents/ag_xxx)",
29
+ },
30
+ query: {
31
+ type: "object" as const,
32
+ description: "Query parameters as key-value pairs",
33
+ },
34
+ data: {
35
+ type: "object" as const,
36
+ description: "Request body (for POST/PUT/PATCH)",
37
+ },
38
+ confirm: {
39
+ type: "boolean" as const,
40
+ description: "Must be true for write operations (POST/PUT/PATCH/DELETE). Safety gate to prevent unintended mutations.",
41
+ },
42
+ },
43
+ required: ["method", "path"],
44
+ },
45
+ execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
46
+ if (!args.method) return validationError("method is required");
47
+ if (!args.path) return validationError("path is required");
48
+
49
+ const method = (args.method as string).toUpperCase();
50
+ const path = args.path as string;
51
+
52
+ // Validate path to prevent SSRF / path traversal.
53
+ const ALLOWED_PREFIXES = ["/hub/", "/registry/", "/wallet/", "/subscriptions/", "/app/"];
54
+
55
+ // Reject absolute URLs (scheme://...) — path must be relative to Hub
56
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path)) {
57
+ return validationError(
58
+ "Absolute URLs are not allowed — provide a path like /hub/inbox",
59
+ "Path traversal and arbitrary URLs are not allowed.",
60
+ );
61
+ }
62
+
63
+ // Reject query strings embedded in path — callers must use the query field.
64
+ // URL normalization strips ?… from path, so silently accepting them would
65
+ // drop parameters the caller intended to send.
66
+ if (path.includes("?")) {
67
+ return validationError(
68
+ "Query strings in path are not allowed — use the query parameter instead",
69
+ 'e.g. path: "/hub/search", query: { q: "deploy" }',
70
+ );
71
+ }
72
+
73
+ // Resolve against dummy base to normalize percent-encoded traversal
74
+ // (e.g. /%2e%2e/ → /../ → resolved away by URL constructor)
75
+ let resolvedPath: string;
76
+ try {
77
+ resolvedPath = new URL(path, "http://localhost").pathname;
78
+ } catch {
79
+ return validationError("Invalid path", "Could not parse the provided path as a URL.");
80
+ }
81
+ const normalized = resolvedPath.replace(/\/+/g, "/"); // collapse duplicate slashes
82
+ if (normalized.includes("..") || !ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) {
83
+ return validationError(
84
+ `path must start with one of: ${ALLOWED_PREFIXES.join(", ")}`,
85
+ "Path traversal and arbitrary URLs are not allowed.",
86
+ );
87
+ }
88
+
89
+ // Write operations require explicit confirmation via confirm param
90
+ if (method !== "GET" && !args.confirm) {
91
+ return {
92
+ ok: false,
93
+ error: {
94
+ type: "validation" as const,
95
+ code: "confirmation_required",
96
+ message: `${method} ${path} is a write operation — set confirm: true to proceed`,
97
+ hint: "Raw API write operations bypass structured tool safeguards. Review the request carefully before confirming.",
98
+ },
99
+ };
100
+ }
101
+
102
+ return withClient(async (client) => {
103
+ // Use the normalized path so the request matches what was validated
104
+ const result = await client.request(method, normalized, {
105
+ body: args.data,
106
+ query: args.query,
107
+ });
108
+ return { response: result };
109
+ });
110
+ },
111
+ };
112
+ }
package/src/tools/bind.ts CHANGED
@@ -4,44 +4,26 @@
4
4
  * [POS]: plugin dashboard 认领执行器,把命令行参数翻译成稳定的绑定请求
5
5
  * [PROTOCOL]: 变更时更新此头部,然后检查 README.md
6
6
  */
7
- import {
8
- getSingleAccountModeError,
9
- resolveAccountConfig,
10
- isAccountConfigured,
11
- } from "../config.js";
12
- import { BotCordClient } from "../client.js";
13
- import { attachTokenPersistence } from "../credentials.js";
14
- import { getConfig as getAppConfig } from "../runtime.js";
7
+ import { withClient } from "./with-client.js";
8
+ import { validationError } from "./tool-result.js";
9
+ import { HubApiError } from "../client.js";
15
10
 
16
11
  /**
17
12
  * Shared bind logic used by both the tool and the command.
18
13
  */
19
14
  export async function executeBind(
20
15
  bindCredential: string,
21
- ): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
22
- const cfg = getAppConfig();
23
- if (!cfg) return { error: "No configuration available" };
24
- const singleAccountError = getSingleAccountModeError(cfg);
25
- if (singleAccountError) return { error: singleAccountError };
26
-
27
- const acct = resolveAccountConfig(cfg);
28
- if (!isAccountConfigured(acct)) {
29
- return { error: "BotCord is not configured." };
30
- }
31
-
32
- const client = new BotCordClient(acct);
33
- attachTokenPersistence(client, acct);
34
-
35
- try {
16
+ ) {
17
+ return withClient(async (client) => {
36
18
  const agentToken = await client.ensureToken();
37
19
  const agentId = client.getAgentId();
38
20
 
39
21
  const resolved = (await client.resolve(agentId)) as Record<string, unknown>;
40
22
  const displayName = (resolved.display_name as string) || agentId;
41
23
 
42
- const hubUrl = client.getHubUrl();
24
+ const baseUrl = client.getHubUrl().replace(/\/+$/, "");
43
25
 
44
- const res = await fetch(`${hubUrl}/api/users/me/agents/bind`, {
26
+ const res = await fetch(`${baseUrl}/api/users/me/agents/bind`, {
45
27
  method: "POST",
46
28
  headers: { "Content-Type": "application/json" },
47
29
  body: JSON.stringify({
@@ -59,13 +41,11 @@ export async function executeBind(
59
41
 
60
42
  if (!res.ok) {
61
43
  const msg = body?.error || body?.detail || body?.message || res.statusText;
62
- return { error: `Bind failed (${res.status}): ${msg}` };
44
+ throw new HubApiError(res.status, JSON.stringify({ detail: msg }), "/api/users/me/agents/bind");
63
45
  }
64
46
 
65
47
  return { ok: true, ...body };
66
- } catch (err: any) {
67
- return { error: `Bind failed: ${err.message}` };
68
- }
48
+ });
69
49
  }
70
50
 
71
51
  export function createBindTool() {
@@ -86,7 +66,7 @@ export function createBindTool() {
86
66
  },
87
67
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
88
68
  if (!args.bind_ticket) {
89
- return { error: "bind_ticket is required" };
69
+ return validationError("bind_ticket is required");
90
70
  }
91
71
  return executeBind(args.bind_ticket);
92
72
  },
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_contacts — Manage social relationships: contacts, requests, blocks.
3
3
  */
4
- import {
5
- getSingleAccountModeError,
6
- resolveAccountConfig,
7
- isAccountConfigured,
8
- } from "../config.js";
9
- import { BotCordClient } from "../client.js";
10
- import { attachTokenPersistence } from "../credentials.js";
11
- import { getConfig as getAppConfig } from "../runtime.js";
4
+ import { withClient } from "./with-client.js";
5
+ import { validationError, dryRunResult } from "./tool-result.js";
12
6
 
13
7
  export function createContactsTool() {
14
8
  return {
@@ -56,69 +50,66 @@ export function createContactsTool() {
56
50
  enum: ["pending", "accepted", "rejected"],
57
51
  description: "Filter by state — for received_requests, sent_requests",
58
52
  },
53
+ dry_run: {
54
+ type: "boolean" as const,
55
+ description: "Preview the request without executing. Returns the API call that would be made.",
56
+ },
59
57
  },
60
58
  required: ["action"],
61
59
  },
62
60
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
63
- const cfg = getAppConfig();
64
- if (!cfg) return { error: "No configuration available" };
65
- const singleAccountError = getSingleAccountModeError(cfg);
66
- if (singleAccountError) return { error: singleAccountError };
67
-
68
- const acct = resolveAccountConfig(cfg);
69
- if (!isAccountConfigured(acct)) {
70
- return { error: "BotCord is not configured." };
71
- }
72
-
73
- const client = new BotCordClient(acct);
74
- attachTokenPersistence(client, acct);
75
-
76
- try {
61
+ return withClient(async (client) => {
77
62
  switch (args.action) {
78
63
  case "list":
79
- return await client.listContacts();
64
+ return { contacts: await client.listContacts() };
80
65
 
81
66
  case "remove":
82
- if (!args.agent_id) return { error: "agent_id is required" };
67
+ if (!args.agent_id) return validationError("agent_id is required");
68
+ if (args.dry_run) return dryRunResult("DELETE", `/registry/agents/{self}/contacts/${args.agent_id}`);
83
69
  await client.removeContact(args.agent_id);
84
70
  return { ok: true, removed: args.agent_id };
85
71
 
86
72
  case "send_request":
87
- if (!args.agent_id) return { error: "agent_id is required" };
73
+ if (!args.agent_id) return validationError("agent_id is required");
74
+ if (args.dry_run) return dryRunResult("POST", "/hub/send", { to: args.agent_id, type: "contact_request", payload: args.message ? { text: args.message } : {} }, { note: "The actual body is a signed envelope (JCS + Ed25519), not the raw fields shown here." });
88
75
  await client.sendContactRequest(args.agent_id, args.message);
89
76
  return { ok: true, sent_to: args.agent_id };
90
77
 
91
78
  case "received_requests":
92
- return await client.listReceivedRequests(args.state);
79
+ return { requests: await client.listReceivedRequests(args.state) };
93
80
 
94
81
  case "sent_requests":
95
- return await client.listSentRequests(args.state);
82
+ return { requests: await client.listSentRequests(args.state) };
96
83
 
97
84
  case "accept_request":
98
- if (!args.request_id) return { error: "request_id is required" };
85
+ if (!args.request_id) return validationError("request_id is required");
86
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/contact-requests/${args.request_id}/accept`);
99
87
  await client.acceptRequest(args.request_id);
100
88
  return { ok: true, accepted: args.request_id };
101
89
 
102
90
  case "reject_request":
103
- if (!args.request_id) return { error: "request_id is required" };
91
+ if (!args.request_id) return validationError("request_id is required");
92
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/contact-requests/${args.request_id}/reject`);
104
93
  await client.rejectRequest(args.request_id);
105
94
  return { ok: true, rejected: args.request_id };
106
95
 
107
96
  case "block":
108
- if (!args.agent_id) return { error: "agent_id is required" };
97
+ if (!args.agent_id) return validationError("agent_id is required");
98
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/blocks`, { blocked_agent_id: args.agent_id });
109
99
  await client.blockAgent(args.agent_id);
110
100
  return { ok: true, blocked: args.agent_id };
111
101
 
112
102
  case "unblock":
113
- if (!args.agent_id) return { error: "agent_id is required" };
103
+ if (!args.agent_id) return validationError("agent_id is required");
104
+ if (args.dry_run) return dryRunResult("DELETE", `/registry/agents/{self}/blocks/${args.agent_id}`);
114
105
  await client.unblockAgent(args.agent_id);
115
106
  return { ok: true, unblocked: args.agent_id };
116
107
 
117
108
  case "list_blocks":
118
- return await client.listBlocks();
109
+ return { blocks: await client.listBlocks() };
119
110
 
120
111
  case "redeem_invite": {
121
- if (!args.invite_code) return { error: "invite_code is required" };
112
+ if (!args.invite_code) return validationError("invite_code is required");
122
113
  // Extract code from full URL if needed (e.g. .../invites/iv_xxx/redeem or /i/iv_xxx)
123
114
  const raw = args.invite_code as string;
124
115
  const match = raw.match(/\b(iv_[a-zA-Z0-9]+)/);
@@ -127,11 +118,9 @@ export function createContactsTool() {
127
118
  }
128
119
 
129
120
  default:
130
- return { error: `Unknown action: ${args.action}` };
121
+ return validationError(`Unknown action: ${args.action}`);
131
122
  }
132
- } catch (err: any) {
133
- return { error: `Contact action failed: ${err.message}` };
134
- }
123
+ });
135
124
  },
136
125
  };
137
126
  }