opencode-swarm-plugin 0.12.25 → 0.12.26
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/.beads/issues.jsonl +17 -0
- package/README.md +105 -0
- package/global-skills/agent-patterns/SKILL.md +682 -0
- package/global-skills/learning-systems/SKILL.md +644 -0
- package/global-skills/mcp-tool-authoring/SKILL.md +695 -0
- package/global-skills/resilience-patterns/SKILL.md +648 -0
- package/global-skills/tacit-knowledge-extraction/SKILL.md +387 -0
- package/global-skills/testing-strategies/SKILL.md +558 -0
- package/global-skills/zod-validation/SKILL.md +763 -0
- package/package.json +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mcp-tool-authoring
|
|
3
|
+
description: Building MCP (Model Context Protocol) tools for OpenCode plugins. Use when creating new tools, defining tool schemas, handling tool arguments, or extending the swarm plugin. Covers schema definition, context passing, error handling, and tool registration.
|
|
4
|
+
tags: [mcp, opencode, plugins, api-design, tool-development]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# MCP Tool Authoring
|
|
8
|
+
|
|
9
|
+
Build type-safe MCP tools for OpenCode plugins using the `@opencode-ai/plugin` SDK.
|
|
10
|
+
|
|
11
|
+
## Tool Definition Pattern
|
|
12
|
+
|
|
13
|
+
Define tools with `tool()` helper from `@opencode-ai/plugin`:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { tool } from "@opencode-ai/plugin";
|
|
17
|
+
|
|
18
|
+
export const my_tool = tool({
|
|
19
|
+
description: "Clear, concise description (one sentence, action-focused)",
|
|
20
|
+
args: {
|
|
21
|
+
required_arg: tool.schema.string().describe("What this arg does"),
|
|
22
|
+
optional_arg: tool.schema.number().optional().describe("Optional arg"),
|
|
23
|
+
},
|
|
24
|
+
async execute(args, ctx) {
|
|
25
|
+
// Implementation
|
|
26
|
+
return "Success message or JSON output";
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Key rules:**
|
|
32
|
+
|
|
33
|
+
- Description: Imperative form, under 120 chars. Start with verb.
|
|
34
|
+
- Args: Use `tool.schema` for validation (Zod-like API)
|
|
35
|
+
- Execute: Return string or JSON-serializable value
|
|
36
|
+
- Context (`ctx`): Contains `sessionID`, `messageID`, `agent` for state tracking
|
|
37
|
+
|
|
38
|
+
## Schema Definition
|
|
39
|
+
|
|
40
|
+
Use `tool.schema` for type-safe argument validation:
|
|
41
|
+
|
|
42
|
+
### Primitives
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
tool.schema.string(); // string
|
|
46
|
+
tool.schema.number(); // number
|
|
47
|
+
tool.schema.boolean(); // boolean
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Constraints
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
tool.schema.string().min(1); // non-empty string
|
|
54
|
+
tool.schema.number().min(0).max(10); // range validation
|
|
55
|
+
tool.schema.number().int(); // integer only
|
|
56
|
+
tool.schema.enum(["a", "b", "c"]); // enum values
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Complex Types
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Array
|
|
63
|
+
tool.schema.array(tool.schema.string());
|
|
64
|
+
|
|
65
|
+
// Object
|
|
66
|
+
tool.schema.object({
|
|
67
|
+
name: tool.schema.string(),
|
|
68
|
+
age: tool.schema.number().optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Nested
|
|
72
|
+
tool.schema.array(
|
|
73
|
+
tool.schema.object({
|
|
74
|
+
title: tool.schema.string(),
|
|
75
|
+
priority: tool.schema.number().min(0).max(3),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Optional Arguments
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
args: {
|
|
84
|
+
required: tool.schema.string().describe("Must provide"),
|
|
85
|
+
optional: tool.schema.string().optional().describe("Can omit"),
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Context Passing
|
|
90
|
+
|
|
91
|
+
Every tool receives `ctx` with session metadata:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
interface ToolContext {
|
|
95
|
+
sessionID: string; // Unique session identifier
|
|
96
|
+
messageID: string; // Current message ID
|
|
97
|
+
agent: string; // Agent name (e.g., "Claude Code")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async execute(args, ctx) {
|
|
101
|
+
const { sessionID, messageID, agent } = ctx;
|
|
102
|
+
|
|
103
|
+
// Use sessionID for state persistence across tool calls
|
|
104
|
+
// Use messageID for tracing/logging
|
|
105
|
+
// Use agent for multi-agent coordination
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Session State Pattern
|
|
110
|
+
|
|
111
|
+
Store state keyed by `sessionID` for multi-call workflows:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const sessionStates = new Map<string, SessionState>();
|
|
115
|
+
|
|
116
|
+
function requireState(sessionID: string): SessionState {
|
|
117
|
+
const state = sessionStates.get(sessionID);
|
|
118
|
+
if (!state) {
|
|
119
|
+
throw new Error("Not initialized - call init first");
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const init_tool = tool({
|
|
125
|
+
args: {
|
|
126
|
+
/* ... */
|
|
127
|
+
},
|
|
128
|
+
async execute(args, ctx) {
|
|
129
|
+
const state = {
|
|
130
|
+
/* ... */
|
|
131
|
+
};
|
|
132
|
+
sessionStates.set(ctx.sessionID, state);
|
|
133
|
+
return "Initialized";
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const action_tool = tool({
|
|
138
|
+
args: {
|
|
139
|
+
/* ... */
|
|
140
|
+
},
|
|
141
|
+
async execute(args, ctx) {
|
|
142
|
+
const state = requireState(ctx.sessionID);
|
|
143
|
+
// Use state
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Persistent State (CLI Bridge)
|
|
149
|
+
|
|
150
|
+
For CLI-based tools, persist state to disk:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
154
|
+
import { join } from "path";
|
|
155
|
+
import { tmpdir } from "os";
|
|
156
|
+
|
|
157
|
+
const STATE_DIR = join(tmpdir(), "my-plugin-sessions");
|
|
158
|
+
|
|
159
|
+
function loadState(sessionID: string): State | null {
|
|
160
|
+
const path = join(STATE_DIR, `${sessionID}.json`);
|
|
161
|
+
if (existsSync(path)) {
|
|
162
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function saveState(sessionID: string, state: State): void {
|
|
168
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
169
|
+
const path = join(STATE_DIR, `${sessionID}.json`);
|
|
170
|
+
writeFileSync(path, JSON.stringify(state, null, 2));
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Error Handling
|
|
175
|
+
|
|
176
|
+
Return errors as strings vs throwing for different behaviors:
|
|
177
|
+
|
|
178
|
+
### Throw for Hard Failures
|
|
179
|
+
|
|
180
|
+
Agent sees error, cannot continue with invalid result:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
async execute(args, ctx) {
|
|
184
|
+
if (!args.required_field) {
|
|
185
|
+
throw new Error("Missing required_field");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const result = await riskyOperation();
|
|
189
|
+
if (!result) {
|
|
190
|
+
throw new Error("Operation failed - cannot proceed");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Return for Graceful Degradation
|
|
198
|
+
|
|
199
|
+
Agent gets error message but can decide how to handle:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
async execute(args, ctx) {
|
|
203
|
+
try {
|
|
204
|
+
return await preferredMethod();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Return fallback instead of throwing
|
|
207
|
+
return JSON.stringify({
|
|
208
|
+
available: false,
|
|
209
|
+
error: error.message,
|
|
210
|
+
fallback: "Use alternative approach",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Custom Error Classes
|
|
217
|
+
|
|
218
|
+
Type-safe errors with metadata:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
export class ToolError extends Error {
|
|
222
|
+
constructor(
|
|
223
|
+
message: string,
|
|
224
|
+
public readonly code?: number,
|
|
225
|
+
public readonly data?: unknown,
|
|
226
|
+
) {
|
|
227
|
+
super(message);
|
|
228
|
+
this.name = "ToolError";
|
|
229
|
+
Object.setPrototypeOf(this, ToolError.prototype);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async execute(args, ctx) {
|
|
234
|
+
if (invalid) {
|
|
235
|
+
throw new ToolError("Invalid input", 400, { field: "name" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Tool Registration
|
|
241
|
+
|
|
242
|
+
Export tools in plugin hooks:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin";
|
|
246
|
+
import { my_tool, another_tool } from "./tools";
|
|
247
|
+
|
|
248
|
+
export const MyPlugin: Plugin = async (input: PluginInput): Promise<Hooks> => {
|
|
249
|
+
return {
|
|
250
|
+
tool: {
|
|
251
|
+
my_tool,
|
|
252
|
+
another_tool,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export default MyPlugin;
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Namespace Pattern
|
|
261
|
+
|
|
262
|
+
Group related tools:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { beadsTools } from "./beads";
|
|
266
|
+
import { swarmTools } from "./swarm";
|
|
267
|
+
|
|
268
|
+
export const SwarmPlugin: Plugin = async (input) => {
|
|
269
|
+
return {
|
|
270
|
+
tool: {
|
|
271
|
+
...beadsTools, // beads_create, beads_query, etc.
|
|
272
|
+
...swarmTools, // swarm_decompose, swarm_status, etc.
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Tool Lifecycle Hooks
|
|
279
|
+
|
|
280
|
+
Execute code before/after tool calls:
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
export const MyPlugin: Plugin = async (input) => {
|
|
284
|
+
return {
|
|
285
|
+
tool: {
|
|
286
|
+
/* tools */
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// After tool execution
|
|
290
|
+
"tool.execute.after": async (input, output) => {
|
|
291
|
+
const toolName = input.tool;
|
|
292
|
+
|
|
293
|
+
if (toolName === "close_task") {
|
|
294
|
+
// Auto-cleanup
|
|
295
|
+
await runCleanup();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (toolName === "init") {
|
|
299
|
+
// Track state
|
|
300
|
+
trackInitialization(output.output);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// Session lifecycle
|
|
305
|
+
event: async ({ event }) => {
|
|
306
|
+
if (event.type === "session.idle") {
|
|
307
|
+
// Release resources
|
|
308
|
+
await cleanup();
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## CLI Bridge Pattern
|
|
316
|
+
|
|
317
|
+
Delegate execution to external CLI (common for complex tools):
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { spawn } from "child_process";
|
|
321
|
+
|
|
322
|
+
async function execCLI(
|
|
323
|
+
command: string,
|
|
324
|
+
args: string[],
|
|
325
|
+
ctx: ToolContext,
|
|
326
|
+
): Promise<string> {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const proc = spawn(command, args, {
|
|
329
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
330
|
+
env: {
|
|
331
|
+
...process.env,
|
|
332
|
+
TOOL_SESSION_ID: ctx.sessionID,
|
|
333
|
+
TOOL_MESSAGE_ID: ctx.messageID,
|
|
334
|
+
TOOL_AGENT: ctx.agent,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
let stdout = "";
|
|
339
|
+
let stderr = "";
|
|
340
|
+
|
|
341
|
+
proc.stdout.on("data", (data) => {
|
|
342
|
+
stdout += data;
|
|
343
|
+
});
|
|
344
|
+
proc.stderr.on("data", (data) => {
|
|
345
|
+
stderr += data;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
proc.on("close", (code) => {
|
|
349
|
+
if (code === 0) {
|
|
350
|
+
resolve(stdout);
|
|
351
|
+
} else {
|
|
352
|
+
reject(new Error(stderr || `Exit ${code}`));
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
proc.on("error", (err) => {
|
|
357
|
+
reject(err);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const cli_tool = tool({
|
|
363
|
+
description: "Execute via CLI",
|
|
364
|
+
args: {
|
|
365
|
+
arg: tool.schema.string(),
|
|
366
|
+
},
|
|
367
|
+
async execute(args, ctx) {
|
|
368
|
+
const output = await execCLI("my-cli", ["--arg", args.arg], ctx);
|
|
369
|
+
return output;
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### JSON Communication
|
|
375
|
+
|
|
376
|
+
For structured CLI responses:
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
async execute(args, ctx) {
|
|
380
|
+
const cliArgs = ["tool", "name", "--json", JSON.stringify(args)];
|
|
381
|
+
const output = await execCLI("my-cli", cliArgs, ctx);
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const result = JSON.parse(output);
|
|
385
|
+
if (result.success) {
|
|
386
|
+
return JSON.stringify(result.data, null, 2);
|
|
387
|
+
} else {
|
|
388
|
+
throw new Error(result.error || "CLI failed");
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// Not JSON - return raw
|
|
392
|
+
return output;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Testing Tools
|
|
398
|
+
|
|
399
|
+
Test tools outside OpenCode runtime:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import { describe, it, expect } from "vitest";
|
|
403
|
+
import { my_tool } from "./my-tool";
|
|
404
|
+
|
|
405
|
+
describe("my_tool", () => {
|
|
406
|
+
it("validates required args", async () => {
|
|
407
|
+
const ctx = {
|
|
408
|
+
sessionID: "test-session",
|
|
409
|
+
messageID: "test-msg",
|
|
410
|
+
agent: "test-agent",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
await expect(
|
|
414
|
+
my_tool.execute(
|
|
415
|
+
{
|
|
416
|
+
/* missing required */
|
|
417
|
+
},
|
|
418
|
+
ctx,
|
|
419
|
+
),
|
|
420
|
+
).rejects.toThrow();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns success for valid input", async () => {
|
|
424
|
+
const ctx = { sessionID: "test", messageID: "msg", agent: "agent" };
|
|
425
|
+
const result = await my_tool.execute({ arg: "value" }, ctx);
|
|
426
|
+
|
|
427
|
+
expect(result).toContain("Success");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Mock Context
|
|
433
|
+
|
|
434
|
+
Reusable test context:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
function mockContext(overrides?: Partial<ToolContext>): ToolContext {
|
|
438
|
+
return {
|
|
439
|
+
sessionID: "test-session",
|
|
440
|
+
messageID: "test-message",
|
|
441
|
+
agent: "test-agent",
|
|
442
|
+
...overrides,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
it("uses session state", async () => {
|
|
447
|
+
const ctx = mockContext({ sessionID: "unique-session" });
|
|
448
|
+
await init_tool.execute(
|
|
449
|
+
{
|
|
450
|
+
/* ... */
|
|
451
|
+
},
|
|
452
|
+
ctx,
|
|
453
|
+
);
|
|
454
|
+
const result = await action_tool.execute(
|
|
455
|
+
{
|
|
456
|
+
/* ... */
|
|
457
|
+
},
|
|
458
|
+
ctx,
|
|
459
|
+
);
|
|
460
|
+
expect(result).toBeDefined();
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## Best Practices
|
|
465
|
+
|
|
466
|
+
### Descriptions
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
// ✅ Good: Action-focused, under 120 chars
|
|
470
|
+
"Create a new bead with type-safe validation";
|
|
471
|
+
"Query beads with filters (replaces bd list, bd ready, bd wip)";
|
|
472
|
+
|
|
473
|
+
// ❌ Bad: Vague, too long
|
|
474
|
+
"This tool helps you to create beads in the system";
|
|
475
|
+
"Query the beads database using various filtering mechanisms...";
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Arguments
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
// ✅ Good: Descriptive, type-constrained
|
|
482
|
+
{
|
|
483
|
+
title: tool.schema.string().min(1).describe("Bead title"),
|
|
484
|
+
priority: tool.schema.number().min(0).max(3).optional().describe("Priority 0-3"),
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ❌ Bad: No validation, unclear
|
|
488
|
+
{
|
|
489
|
+
title: tool.schema.string(),
|
|
490
|
+
priority: tool.schema.number(),
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### Return Values
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
// ✅ Good: Consistent JSON for structured data
|
|
498
|
+
return JSON.stringify({ id: "bd-123", status: "open" }, null, 2);
|
|
499
|
+
|
|
500
|
+
// ✅ Good: Human-readable for simple operations
|
|
501
|
+
return "Created bead bd-123";
|
|
502
|
+
|
|
503
|
+
// ❌ Bad: Inconsistent format
|
|
504
|
+
return Math.random() > 0.5 ? { id: "bd-123" } : "Created";
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Error Messages
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
// ✅ Good: Actionable, specific
|
|
511
|
+
throw new Error("Agent Mail not initialized. Call agentmail_init first.");
|
|
512
|
+
throw new ToolError("Invalid priority: must be 0-3", 400, {
|
|
513
|
+
value: args.priority,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ❌ Bad: Vague, no context
|
|
517
|
+
throw new Error("Failed");
|
|
518
|
+
throw new Error("Error");
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
## Common Patterns
|
|
522
|
+
|
|
523
|
+
### Validation Before Execution
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
import { z } from "zod";
|
|
527
|
+
|
|
528
|
+
const ArgsSchema = z.object({
|
|
529
|
+
id: z.string().min(1),
|
|
530
|
+
priority: z.number().min(0).max(3).optional(),
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
async execute(args, ctx) {
|
|
534
|
+
const validated = ArgsSchema.parse(args);
|
|
535
|
+
// Type-safe: validated.id is string, validated.priority is number | undefined
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Rate Limiting
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
const rateLimiter = new Map<string, { count: number; resetAt: number }>();
|
|
543
|
+
|
|
544
|
+
async execute(args, ctx) {
|
|
545
|
+
const limit = rateLimiter.get(ctx.sessionID);
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
|
|
548
|
+
if (limit && limit.resetAt > now) {
|
|
549
|
+
if (limit.count >= 100) {
|
|
550
|
+
throw new Error(`Rate limit exceeded. Retry after ${new Date(limit.resetAt)}`);
|
|
551
|
+
}
|
|
552
|
+
limit.count++;
|
|
553
|
+
} else {
|
|
554
|
+
rateLimiter.set(ctx.sessionID, {
|
|
555
|
+
count: 1,
|
|
556
|
+
resetAt: now + 60_000, // 1 minute
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Execute
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Retry Logic
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
async function withRetry<T>(
|
|
568
|
+
fn: () => Promise<T>,
|
|
569
|
+
maxRetries = 3,
|
|
570
|
+
): Promise<T> {
|
|
571
|
+
let lastError: Error | null = null;
|
|
572
|
+
|
|
573
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
574
|
+
try {
|
|
575
|
+
return await fn();
|
|
576
|
+
} catch (error) {
|
|
577
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
578
|
+
|
|
579
|
+
if (attempt < maxRetries) {
|
|
580
|
+
const delay = Math.pow(2, attempt) * 100;
|
|
581
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
throw lastError;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async execute(args, ctx) {
|
|
590
|
+
return await withRetry(() => riskyOperation(args));
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Graceful Degradation
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
async execute(args, ctx) {
|
|
598
|
+
// Try preferred method
|
|
599
|
+
const available = await checkDependency();
|
|
600
|
+
|
|
601
|
+
if (!available) {
|
|
602
|
+
// Return fallback info instead of failing
|
|
603
|
+
return JSON.stringify({
|
|
604
|
+
available: false,
|
|
605
|
+
error: "Dependency not running",
|
|
606
|
+
fallback: "Install with: npm install -g dependency",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Normal execution
|
|
611
|
+
const result = await callDependency(args);
|
|
612
|
+
return JSON.stringify({ available: true, result });
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
## Anti-Patterns
|
|
617
|
+
|
|
618
|
+
### ❌ Overloaded Tools
|
|
619
|
+
|
|
620
|
+
Don't combine unrelated actions in one tool:
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
// BAD: Does too much
|
|
624
|
+
const manage_item = tool({
|
|
625
|
+
args: {
|
|
626
|
+
action: tool.schema.enum(["create", "update", "delete", "list"]),
|
|
627
|
+
// ...
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// GOOD: Separate tools
|
|
632
|
+
const create_item = tool({
|
|
633
|
+
/* ... */
|
|
634
|
+
});
|
|
635
|
+
const update_item = tool({
|
|
636
|
+
/* ... */
|
|
637
|
+
});
|
|
638
|
+
const delete_item = tool({
|
|
639
|
+
/* ... */
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### ❌ Hidden State
|
|
644
|
+
|
|
645
|
+
Don't rely on module-level state without sessionID:
|
|
646
|
+
|
|
647
|
+
```typescript
|
|
648
|
+
// BAD: Shared across sessions
|
|
649
|
+
let currentUser: string;
|
|
650
|
+
|
|
651
|
+
export const set_user = tool({
|
|
652
|
+
async execute(args) {
|
|
653
|
+
currentUser = args.name; // Leaks between sessions!
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// GOOD: Session-keyed state
|
|
658
|
+
const sessions = new Map<string, { user: string }>();
|
|
659
|
+
|
|
660
|
+
export const set_user = tool({
|
|
661
|
+
async execute(args, ctx) {
|
|
662
|
+
sessions.set(ctx.sessionID, { user: args.name });
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### ❌ Swallowing Errors
|
|
668
|
+
|
|
669
|
+
Don't hide errors from the agent:
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
// BAD: Silent failure
|
|
673
|
+
async execute(args, ctx) {
|
|
674
|
+
try {
|
|
675
|
+
return await criticalOperation();
|
|
676
|
+
} catch {
|
|
677
|
+
return "Done"; // Lies!
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// GOOD: Explicit error or fallback
|
|
682
|
+
async execute(args, ctx) {
|
|
683
|
+
try {
|
|
684
|
+
return await criticalOperation();
|
|
685
|
+
} catch (error) {
|
|
686
|
+
throw new Error(`Critical operation failed: ${error.message}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
## Related
|
|
692
|
+
|
|
693
|
+
- OpenCode Plugin SDK: `@opencode-ai/plugin`
|
|
694
|
+
- Zod validation: [github.com/colinhacks/zod](https://github.com/colinhacks/zod)
|
|
695
|
+
- MCP spec: [modelcontextprotocol.io](https://modelcontextprotocol.io)
|