@smithers-orchestrator/agents 0.16.9 → 0.18.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.
@@ -0,0 +1,495 @@
1
+ import {
2
+ BaseCliAgent,
3
+ pushFlag,
4
+ isRecord,
5
+ asString,
6
+ truncate,
7
+ toolKindFromName,
8
+ shouldSurfaceUnparsedStdout,
9
+ createSyntheticIdGenerator,
10
+ } from "./BaseCliAgent/index.js";
11
+ import { normalizeCapabilityStringList } from "./capability-registry/index.js";
12
+
13
+ /** @typedef {import("./BaseCliAgent/index.ts").BaseCliAgentOptions} BaseCliAgentOptions */
14
+ /** @typedef {import("./capability-registry/index.ts").AgentCapabilityRegistry} AgentCapabilityRegistry */
15
+
16
+ /**
17
+ * @typedef {BaseCliAgentOptions & {
18
+ * model?: string;
19
+ * agentName?: string;
20
+ * attachFiles?: string[];
21
+ * continueSession?: boolean;
22
+ * sessionId?: string;
23
+ * variant?: "high" | "medium" | "low";
24
+ * }} OpenCodeAgentOptions
25
+ */
26
+
27
+ /** @typedef {import("./BaseCliAgent/index.ts").CliOutputInterpreter} CliOutputInterpreter */
28
+
29
+ /**
30
+ * @param {OpenCodeAgentOptions} [opts] Currently unused — kept for API
31
+ * consistency with other agents (e.g. ClaudeCodeAgent uses opts to resolve
32
+ * builtIns based on tool allow/deny lists). OpenCode does not yet expose
33
+ * CLI flags for restricting built-in tools, so the set is static.
34
+ * @returns {AgentCapabilityRegistry}
35
+ */
36
+ export function createOpenCodeCapabilityRegistry(opts = {}) {
37
+ return {
38
+ version: 1,
39
+ engine: "opencode",
40
+ runtimeTools: {},
41
+ mcp: {
42
+ bootstrap: "project-config",
43
+ supportsProjectScope: true,
44
+ supportsUserScope: true,
45
+ },
46
+ skills: {
47
+ supportsSkills: true,
48
+ installMode: "plugin",
49
+ smithersSkillIds: [],
50
+ },
51
+ humanInteraction: {
52
+ supportsUiRequests: false,
53
+ methods: [],
54
+ },
55
+ builtIns: normalizeCapabilityStringList([
56
+ "read",
57
+ "write",
58
+ "edit",
59
+ "apply_patch",
60
+ "bash",
61
+ "glob",
62
+ "grep",
63
+ "list",
64
+ "webfetch",
65
+ "websearch",
66
+ "codesearch",
67
+ "question",
68
+ "task",
69
+ "todowrite",
70
+ "skill",
71
+ ]),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * CLI agent wrapper for OpenCode (https://opencode.ai).
77
+ *
78
+ * Shells out to `opencode run` in non-interactive mode with `--format json`
79
+ * for streaming nd-JSON output. Parses AgentCliEvents from the JSON stream.
80
+ *
81
+ * Usage:
82
+ * const agent = new OpenCodeAgent({
83
+ * model: "anthropic/claude-opus-4-20250514",
84
+ * yolo: true,
85
+ * });
86
+ * const result = await agent.generate({
87
+ * messages: [{ role: "user", content: "Fix the bug" }],
88
+ * });
89
+ */
90
+ export class OpenCodeAgent extends BaseCliAgent {
91
+ /** @type {OpenCodeAgentOptions} */
92
+ opts;
93
+ /** @type {AgentCapabilityRegistry} */
94
+ capabilities;
95
+ /** @type {"opencode"} */
96
+ cliEngine = "opencode";
97
+
98
+ /**
99
+ * @param {OpenCodeAgentOptions} [opts]
100
+ */
101
+ constructor(opts = {}) {
102
+ super(opts);
103
+ this.opts = opts;
104
+ this.capabilities = createOpenCodeCapabilityRegistry(opts);
105
+ }
106
+
107
+ /**
108
+ * Create an output interpreter that parses OpenCode's nd-JSON streaming format.
109
+ *
110
+ * OpenCode `--format json` emits one JSON object per line (verified from source:
111
+ * packages/opencode/src/cli/cmd/run.ts). The envelope is:
112
+ *
113
+ * { type, timestamp: number, sessionID: string, ...payload }
114
+ *
115
+ * Event types:
116
+ * step_start → { part: { type:"step-start", id, sessionID, messageID } }
117
+ * text → { part: { type:"text", text, time: { start, end } } }
118
+ * tool_use → { part: { type:"tool", tool, callID, state: { status, ... } } }
119
+ * step_finish → { part: { type:"step-finish", reason, tokens, cost } }
120
+ * reasoning → { part: { type:"reasoning", text } }
121
+ * error → { error: { name, data: { message } } }
122
+ *
123
+ * We map these to Smithers' AgentCliEvent union (started | action | completed).
124
+ *
125
+ * @returns {CliOutputInterpreter}
126
+ */
127
+ createOutputInterpreter() {
128
+ let fullText = "";
129
+ let sessionId = "";
130
+ let didEmitStarted = false;
131
+ let didEmitCompleted = false;
132
+ let terminalError = null;
133
+
134
+ // Accumulate tokens across multiple step_finish events
135
+ let totalInputTokens = 0;
136
+ let totalOutputTokens = 0;
137
+ let totalTokens = 0;
138
+
139
+ const nextSyntheticId = createSyntheticIdGenerator();
140
+
141
+ /**
142
+ * @param {string} title
143
+ * @param {string} message
144
+ * @param {"warning" | "error"} [level]
145
+ * @returns {import("./BaseCliAgent/index.ts").AgentCliEvent}
146
+ */
147
+ const warningAction = (title, message, level = "warning") => ({
148
+ type: "action",
149
+ engine: this.cliEngine,
150
+ phase: "completed",
151
+ entryType: "thought",
152
+ action: {
153
+ id: nextSyntheticId("opencode-warning"),
154
+ kind: "warning",
155
+ title,
156
+ detail: {},
157
+ },
158
+ message,
159
+ ok: level !== "error",
160
+ level,
161
+ });
162
+
163
+ /**
164
+ * @param {string} line
165
+ * @returns {import("./BaseCliAgent/index.ts").AgentCliEvent[]}
166
+ */
167
+ const parseLine = (line) => {
168
+ // Strip OSC terminal escape sequences (e.g. title-setting "\x1b]0;...\x07")
169
+ // that OpenCode emits inline with JSON events on stdout.
170
+ const cleaned = line.replace(/\x1b\]0;[^\x07]*\x07/g, "");
171
+ const trimmed = cleaned.trim();
172
+ if (!trimmed) return [];
173
+
174
+ /** @type {Record<string, unknown>} */
175
+ let payload;
176
+ try {
177
+ payload = JSON.parse(trimmed);
178
+ } catch {
179
+ if (!shouldSurfaceUnparsedStdout(trimmed)) return [];
180
+ return [warningAction("stdout", truncate(trimmed, 220))];
181
+ }
182
+
183
+ if (!isRecord(payload)) return [];
184
+
185
+ const eventType = asString(payload.type);
186
+ if (!eventType) return [];
187
+
188
+ // Capture sessionID from the envelope (present on every event)
189
+ const envelopeSessionId = asString(payload.sessionID);
190
+ if (envelopeSessionId) {
191
+ sessionId = envelopeSessionId;
192
+ }
193
+
194
+ const part = isRecord(payload.part) ? payload.part : null;
195
+
196
+ // --- step_start: session/step beginning ---
197
+ if (eventType === "step_start") {
198
+ if (!didEmitStarted) {
199
+ didEmitStarted = true;
200
+ return [
201
+ {
202
+ type: "started",
203
+ engine: this.cliEngine,
204
+ title: "OpenCode",
205
+ resume: sessionId || undefined,
206
+ detail: sessionId ? { sessionId } : undefined,
207
+ },
208
+ ];
209
+ }
210
+ return [];
211
+ }
212
+
213
+ // --- text: finalized text chunk from the model ---
214
+ if (eventType === "text") {
215
+ const text = part ? asString(part.text) : null;
216
+ if (text) {
217
+ fullText += text;
218
+ return [
219
+ {
220
+ type: "action",
221
+ engine: this.cliEngine,
222
+ phase: "updated",
223
+ entryType: "message",
224
+ action: {
225
+ id: nextSyntheticId("opencode-text"),
226
+ kind: "note",
227
+ title: "assistant",
228
+ detail: {},
229
+ },
230
+ message: text,
231
+ ok: true,
232
+ level: "info",
233
+ },
234
+ ];
235
+ }
236
+ return [];
237
+ }
238
+
239
+ // --- tool_use: tool completed or errored ---
240
+ if (eventType === "tool_use" && part) {
241
+ const toolName = asString(part.tool) ?? "tool";
242
+ const callID = asString(part.callID) ?? nextSyntheticId("opencode-tool");
243
+ const state = isRecord(part.state) ? part.state : null;
244
+ const status = state ? asString(state.status) : null;
245
+ const isError = status === "error";
246
+
247
+ const events = [];
248
+
249
+ // Emit a "started" action for the tool
250
+ events.push({
251
+ type: "action",
252
+ engine: this.cliEngine,
253
+ phase: "started",
254
+ entryType: "thought",
255
+ action: {
256
+ id: callID,
257
+ kind: toolKindFromName(toolName),
258
+ title: toolName,
259
+ detail: state && isRecord(state.input)
260
+ ? { input: state.input }
261
+ : {},
262
+ },
263
+ message: `Running ${toolName}`,
264
+ level: "info",
265
+ });
266
+
267
+ // Emit a "completed" action for the tool
268
+ const output = state
269
+ ? asString(state.output) ?? asString(state.error)
270
+ : undefined;
271
+ events.push({
272
+ type: "action",
273
+ engine: this.cliEngine,
274
+ phase: "completed",
275
+ entryType: "thought",
276
+ action: {
277
+ id: callID,
278
+ kind: toolKindFromName(toolName),
279
+ title: toolName,
280
+ detail: {},
281
+ },
282
+ message: output ? truncate(output, 300) : undefined,
283
+ ok: !isError,
284
+ level: isError ? "warning" : "info",
285
+ });
286
+
287
+ return events;
288
+ }
289
+
290
+ // --- step_finish: step completed with token usage ---
291
+ if (eventType === "step_finish" && part) {
292
+ const tokens = isRecord(part.tokens) ? part.tokens : null;
293
+ if (tokens) {
294
+ const input = typeof tokens.input === "number" ? tokens.input : 0;
295
+ const output = typeof tokens.output === "number" ? tokens.output : 0;
296
+ const total = typeof tokens.total === "number" ? tokens.total : 0;
297
+ totalInputTokens += input;
298
+ totalOutputTokens += output;
299
+ totalTokens += total;
300
+ }
301
+
302
+ const reason = asString(part.reason);
303
+ // Only emit "completed" on the final step (reason: "stop")
304
+ if (reason === "stop") {
305
+ if (didEmitCompleted) return [];
306
+ didEmitCompleted = true;
307
+
308
+ return [
309
+ {
310
+ type: "completed",
311
+ engine: this.cliEngine,
312
+ ok: true,
313
+ answer: fullText || undefined,
314
+ resume: sessionId || undefined,
315
+ usage: {
316
+ inputTokens: totalInputTokens,
317
+ outputTokens: totalOutputTokens,
318
+ totalTokens: totalTokens,
319
+ },
320
+ },
321
+ ];
322
+ }
323
+
324
+ return [];
325
+ }
326
+
327
+ // --- reasoning: model thinking (only with --thinking flag) ---
328
+ if (eventType === "reasoning") {
329
+ // Surface reasoning as a thought action, don't accumulate into fullText
330
+ const text = part ? asString(part.text) : null;
331
+ if (text) {
332
+ return [
333
+ {
334
+ type: "action",
335
+ engine: this.cliEngine,
336
+ phase: "updated",
337
+ entryType: "thought",
338
+ action: {
339
+ id: nextSyntheticId("opencode-reasoning"),
340
+ kind: "note",
341
+ title: "reasoning",
342
+ detail: {},
343
+ },
344
+ message: truncate(text, 500),
345
+ ok: true,
346
+ level: "info",
347
+ },
348
+ ];
349
+ }
350
+ return [];
351
+ }
352
+
353
+ // --- error: session error ---
354
+ if (eventType === "error") {
355
+ const errorObj = isRecord(payload.error) ? payload.error : null;
356
+ const errorData = errorObj && isRecord(errorObj.data) ? errorObj.data : null;
357
+ const errorName = errorObj ? asString(errorObj.name) : null;
358
+ const errorMessage = errorData
359
+ ? asString(errorData.message)
360
+ : errorName ?? "OpenCode reported an error";
361
+ terminalError = errorMessage ?? "OpenCode reported an error";
362
+
363
+ if (didEmitCompleted) {
364
+ return [warningAction("error", errorMessage ?? "OpenCode reported an error", "error")];
365
+ }
366
+
367
+ didEmitCompleted = true;
368
+ return [
369
+ {
370
+ type: "completed",
371
+ engine: this.cliEngine,
372
+ ok: false,
373
+ answer: fullText || undefined,
374
+ error: errorMessage ?? "OpenCode reported an error",
375
+ },
376
+ ];
377
+ }
378
+
379
+ return [];
380
+ };
381
+
382
+ return {
383
+ onStdoutLine: parseLine,
384
+
385
+ onStderrLine: (line) => {
386
+ const trimmed = line.trim();
387
+ if (!trimmed) return [];
388
+ return [warningAction("stderr", truncate(trimmed, 220))];
389
+ },
390
+
391
+ onExit: (result) => {
392
+ if (didEmitCompleted) return [];
393
+ const isSuccess = (result.exitCode ?? 0) === 0 && !terminalError;
394
+ didEmitCompleted = true;
395
+ return [
396
+ {
397
+ type: "completed",
398
+ engine: this.cliEngine,
399
+ ok: isSuccess,
400
+ answer: isSuccess ? fullText || undefined : undefined,
401
+ error: isSuccess
402
+ ? undefined
403
+ : terminalError ?? `OpenCode exited with code ${result.exitCode ?? -1}`,
404
+ },
405
+ ];
406
+ },
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Build the CLI command spec for `opencode run`.
412
+ *
413
+ * @param {{ prompt: string; systemPrompt?: string; cwd: string; options: any }} params
414
+ */
415
+ async buildCommand(params) {
416
+ const resumeSession = typeof params.options?.resumeSession === "string"
417
+ ? params.options.resumeSession
418
+ : undefined;
419
+ const args = ["run"];
420
+
421
+ // Model selection
422
+ pushFlag(args, "-m", this.opts.model ?? this.model);
423
+
424
+ // Working directory
425
+ pushFlag(args, "--dir", params.cwd);
426
+
427
+ // Streaming nd-JSON output
428
+ pushFlag(args, "--format", "json");
429
+
430
+ // Named agent config
431
+ pushFlag(args, "--agent", this.opts.agentName);
432
+
433
+ // File attachments: -f file1 -f file2 (repeated flag)
434
+ if (this.opts.attachFiles) {
435
+ for (const file of this.opts.attachFiles) {
436
+ args.push("-f", file);
437
+ }
438
+ }
439
+
440
+ // Session continuation
441
+ const explicitSession = resumeSession ?? this.opts.sessionId;
442
+ if (this.opts.continueSession && !explicitSession) {
443
+ args.push("--continue");
444
+ }
445
+ pushFlag(args, "--session", explicitSession);
446
+
447
+ // Variant / reasoning effort
448
+ pushFlag(args, "--variant", this.opts.variant);
449
+
450
+ // Yolo mode: auto-approve all tool calls.
451
+ // OpenCode parses OPENCODE_PERMISSION with JSON.parse() and expects a
452
+ // permission object. '{"*":"allow"}' grants blanket approval for every
453
+ // tool category. See: packages/opencode/src/config/config.ts
454
+ const yoloEnabled = this.opts.yolo ?? this.yolo;
455
+ const env = {};
456
+ if (yoloEnabled) {
457
+ env.OPENCODE_PERMISSION = '{"*":"allow"}';
458
+ }
459
+
460
+ // Extra args from constructor
461
+ if (this.extraArgs?.length) {
462
+ args.push(...this.extraArgs);
463
+ }
464
+
465
+ const systemPrefix = params.systemPrompt
466
+ ? `${params.systemPrompt}\n\n`
467
+ : "";
468
+ const fullPrompt = `${systemPrefix}${params.prompt ?? ""}`;
469
+
470
+ // When flags like -f (yargs [array] type) are present, subsequent
471
+ // positional arguments can be consumed as flag values. Insert '--'
472
+ // to tell yargs to stop parsing flags and treat the rest as positional.
473
+ if (fullPrompt) {
474
+ args.push("--", fullPrompt);
475
+ }
476
+
477
+ return {
478
+ command: "opencode",
479
+ args,
480
+ outputFormat: "stream-json",
481
+ env: Object.keys(env).length > 0 ? env : undefined,
482
+ stdoutBannerPatterns: [
483
+ // OpenCode may print a version banner
484
+ /^opencode\s+v[\d.]+/gim,
485
+ // Strip OSC terminal title-setting sequences (ESC ] 0 ; ... BEL)
486
+ // OpenCode emits these even with --format json
487
+ /\x1b\]0;[^\x07]*\x07/g,
488
+ ],
489
+ stdoutErrorPatterns: [
490
+ /^error:/im,
491
+ /^fatal:/im,
492
+ ],
493
+ };
494
+ }
495
+ }
@@ -0,0 +1,43 @@
1
+ import { type CliOutputInterpreter, BaseCliAgent } from "./BaseCliAgent";
2
+ import type { BaseCliAgentOptions } from "./BaseCliAgent";
3
+ import { type AgentCapabilityRegistry } from "./capability-registry";
4
+
5
+ export type OpenCodeAgentOptions = BaseCliAgentOptions & {
6
+ /** Model identifier (e.g., "anthropic/claude-opus-4-20250514", "openai/gpt-5.4") */
7
+ model?: string;
8
+ /** OpenCode agent name (maps to --agent flag, selects predefined agent config) */
9
+ agentName?: string;
10
+ /** Files to attach to the prompt via -f flags */
11
+ attachFiles?: string[];
12
+ /** Continue a previous session */
13
+ continueSession?: boolean;
14
+ /** Resume a specific session by ID */
15
+ sessionId?: string;
16
+ /** Provider-specific model variant/reasoning effort level */
17
+ variant?: string;
18
+ };
19
+
20
+ export declare function createOpenCodeCapabilityRegistry(
21
+ opts?: OpenCodeAgentOptions
22
+ ): AgentCapabilityRegistry;
23
+
24
+ export declare class OpenCodeAgent extends BaseCliAgent {
25
+ private readonly opts: OpenCodeAgentOptions;
26
+ readonly capabilities: AgentCapabilityRegistry;
27
+ readonly cliEngine: "opencode";
28
+ constructor(opts?: OpenCodeAgentOptions);
29
+ createOutputInterpreter(): CliOutputInterpreter;
30
+ buildCommand(params: {
31
+ prompt: string;
32
+ systemPrompt?: string;
33
+ cwd: string;
34
+ options: any;
35
+ }): Promise<{
36
+ command: string;
37
+ args: string[];
38
+ outputFormat: "stream-json";
39
+ env?: Record<string, string>;
40
+ stdoutBannerPatterns: RegExp[];
41
+ stdoutErrorPatterns: RegExp[];
42
+ }>;
43
+ }
package/src/PiAgent.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // @smithers-type-exports-end
5
5
 
6
6
  import { Effect } from "effect";
7
- import { BaseCliAgent, buildGenerateResult, combineNonEmpty, extractPrompt, extractTextFromJsonValue, pushFlag, resolveTimeouts, runAgentPromise, runRpcCommandEffect, tryParseJson, asString, truncate, toolKindFromName, } from "./BaseCliAgent/index.js";
7
+ import { BaseCliAgent, buildGenerateResult, combineNonEmpty, extractPrompt, extractTextFromJsonValue, pushFlag, resolveTimeouts, runAgentPromise, runRpcCommandEffect, asString, truncate, toolKindFromName, } from "./BaseCliAgent/index.js";
8
8
  import { normalizeCapabilityStringList, } from "./capability-registry/index.js";
9
9
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
10
10
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
@@ -0,0 +1,31 @@
1
+ import type {
2
+ AgentLike,
3
+ AmpAgent,
4
+ AnthropicAgent,
5
+ ClaudeCodeAgent,
6
+ CodexAgent,
7
+ ForgeAgent,
8
+ GeminiAgent,
9
+ KimiAgent,
10
+ OpenAIAgent,
11
+ PiAgent,
12
+ } from "../index.js";
13
+
14
+ type AssertAssignable<T extends AgentLike> = T;
15
+
16
+ type _CustomNativeStructuredAgent = AssertAssignable<{
17
+ supportsNativeStructuredOutput: true;
18
+ generate: () => Promise<unknown>;
19
+ }>;
20
+
21
+ type _ConcreteAgentsAreAgentLike = [
22
+ AssertAssignable<AmpAgent>,
23
+ AssertAssignable<AnthropicAgent>,
24
+ AssertAssignable<ClaudeCodeAgent>,
25
+ AssertAssignable<CodexAgent>,
26
+ AssertAssignable<ForgeAgent>,
27
+ AssertAssignable<GeminiAgent>,
28
+ AssertAssignable<KimiAgent>,
29
+ AssertAssignable<OpenAIAgent>,
30
+ AssertAssignable<PiAgent>,
31
+ ];
@@ -2,7 +2,7 @@ import type { AgentToolDescriptor } from "./AgentToolDescriptor";
2
2
 
3
3
  export type AgentCapabilityRegistry = {
4
4
  version: 1;
5
- engine: "claude-code" | "codex" | "gemini" | "kimi" | "pi" | "amp" | "forge";
5
+ engine: "claude-code" | "codex" | "gemini" | "kimi" | "pi" | "amp" | "forge" | "opencode";
6
6
  runtimeTools: Record<string, AgentToolDescriptor>;
7
7
  mcp: {
8
8
  bootstrap: "inline-config" | "project-config" | "allow-list" | "unsupported";
@@ -3,4 +3,5 @@ export type CliAgentCapabilityAdapterId =
3
3
  | "codex"
4
4
  | "gemini"
5
5
  | "kimi"
6
+ | "opencode"
6
7
  | "pi";
@@ -3,6 +3,7 @@ import { createClaudeCodeCapabilityRegistry } from "../ClaudeCodeAgent.js";
3
3
  import { createCodexCapabilityRegistry } from "../CodexAgent.js";
4
4
  import { createGeminiCapabilityRegistry } from "../GeminiAgent.js";
5
5
  import { createKimiCapabilityRegistry } from "../KimiAgent.js";
6
+ import { createOpenCodeCapabilityRegistry } from "../OpenCodeAgent.js";
6
7
  import { createPiCapabilityRegistry } from "../PiAgent.js";
7
8
  /** @typedef {import("./CliAgentCapabilityReportEntry.ts").CliAgentCapabilityReportEntry} CliAgentCapabilityReportEntry */
8
9
 
@@ -27,6 +28,11 @@ const CLI_AGENT_CAPABILITY_ADAPTERS = [
27
28
  binary: "kimi",
28
29
  buildRegistry: () => createKimiCapabilityRegistry(),
29
30
  },
31
+ {
32
+ id: "opencode",
33
+ binary: "opencode",
34
+ buildRegistry: () => createOpenCodeCapabilityRegistry(),
35
+ },
30
36
  {
31
37
  id: "pi",
32
38
  binary: "pi",