@smithers-orchestrator/agents 0.17.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/agents",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "AI SDK and CLI agent adapters for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -50,9 +50,9 @@
50
50
  "ai": "^6.0.168",
51
51
  "effect": "^3.21.1",
52
52
  "zod": "^4.3.6",
53
- "@smithers-orchestrator/driver": "0.17.0",
54
- "@smithers-orchestrator/errors": "0.17.0",
55
- "@smithers-orchestrator/observability": "0.17.0"
53
+ "@smithers-orchestrator/errors": "0.18.0",
54
+ "@smithers-orchestrator/observability": "0.18.0",
55
+ "@smithers-orchestrator/driver": "0.18.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/bun": "latest",
package/src/AgentLike.ts CHANGED
@@ -12,6 +12,8 @@ export type AgentLike = {
12
12
  tools?: Record<string, unknown>;
13
13
  /** Optional structured capability registry for cache and diagnostics */
14
14
  capabilities?: AgentCapabilityRegistry;
15
+ /** True when the agent consumes outputSchema through a native structured-output API. */
16
+ supportsNativeStructuredOutput?: boolean;
15
17
  /**
16
18
  * Generates a response or action based on the provided arguments.
17
19
  *
@@ -1,5 +1,5 @@
1
1
  import { anthropic } from "@ai-sdk/anthropic";
2
- import { ToolLoopAgent, } from "ai";
2
+ import { Output, ToolLoopAgent, } from "ai";
3
3
  import { resolveSdkModel } from "./resolveSdkModel.js";
4
4
  import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js";
5
5
  /** @typedef {import("ai").AgentCallParameters} AgentCallParameters */
@@ -17,6 +17,7 @@ import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js"
17
17
 
18
18
  export class AnthropicAgent extends ToolLoopAgent {
19
19
  hijackEngine = "anthropic-sdk";
20
+ supportsNativeStructuredOutput = true;
20
21
  /**
21
22
  * @param {AnthropicAgentOptions<CALL_OPTIONS, TOOLS>} opts
22
23
  */
@@ -35,11 +36,15 @@ export class AnthropicAgent extends ToolLoopAgent {
35
36
  const promptArgs = "messages" in args
36
37
  ? { messages: args.messages }
37
38
  : { prompt: args.prompt };
39
+ const outputArgs = args.outputSchema
40
+ ? { output: Output.object({ schema: args.outputSchema }) }
41
+ : {};
38
42
  if (!args.onStdout) {
39
43
  return super.generate({
40
44
  options: args.options,
41
45
  abortSignal: args.abortSignal,
42
46
  ...promptArgs,
47
+ ...outputArgs,
43
48
  timeout: args.timeout,
44
49
  onStepFinish: args.onStepFinish,
45
50
  });
@@ -48,6 +53,7 @@ export class AnthropicAgent extends ToolLoopAgent {
48
53
  options: args.options,
49
54
  abortSignal: args.abortSignal,
50
55
  ...promptArgs,
56
+ ...outputArgs,
51
57
  timeout: args.timeout,
52
58
  onStepFinish: args.onStepFinish,
53
59
  }).then((stream) => streamResultToGenerateResult(stream, args.onStdout));
@@ -281,15 +281,67 @@ function extractTextFromJsonPayload(raw) {
281
281
  return text;
282
282
  }
283
283
  }
284
+ // OpenCode-style CLIs emit a final "finish" or "done" event with the
285
+ // complete response text directly on the payload. Prefer this over
286
+ // concatenating all text_delta chunks which would duplicate content.
287
+ if (type === "finish" || type === "done") {
288
+ const text = typeof parsed?.text === "string" ? parsed.text : undefined;
289
+ if (text)
290
+ return text;
291
+ }
292
+ // OpenCode nd-JSON format: "text" events carry part.text with finalized
293
+ // text chunks. Accumulate these as a fallback when the interpreter's
294
+ // completed event isn't surfaced properly.
295
+ if (type === "text" && parsed?.part?.text) {
296
+ // Don't return early — accumulate via the chunks path below
297
+ }
284
298
  }
285
299
  const chunks = [];
286
300
  for (const parsed of parsedLines) {
287
- const text = extractTextFromJsonValue(parsed);
301
+ let text;
302
+ if (parsed?.type === "text" && typeof parsed?.part?.text === "string") {
303
+ text = parsed.part.text;
304
+ }
305
+ else {
306
+ text = extractTextFromJsonValue(parsed);
307
+ }
288
308
  if (text)
289
309
  chunks.push(text);
290
310
  }
291
311
  return chunks.length ? chunks.join("") : undefined;
292
312
  }
313
+ /**
314
+ * @param {string} raw
315
+ * @returns {string}
316
+ */
317
+ function stripOscSequences(raw) {
318
+ return raw.replace(/\x1b\]0;[^\x07]*\x07/g, "");
319
+ }
320
+ /**
321
+ * @param {string} raw
322
+ * @returns {string | undefined}
323
+ */
324
+ function extractErrorFromJsonPayload(raw) {
325
+ const trimmed = stripOscSequences(raw).trim();
326
+ if (!trimmed)
327
+ return undefined;
328
+ const lines = trimmed.split(/\r?\n/).filter(Boolean);
329
+ for (let i = lines.length - 1; i >= 0; i--) {
330
+ try {
331
+ const parsed = JSON.parse(lines[i]);
332
+ if (parsed?.type !== "error")
333
+ continue;
334
+ const message = parsed?.error?.data?.message ?? parsed?.error?.message ?? parsed?.error?.name;
335
+ if (typeof message === "string" && message.trim()) {
336
+ return message.trim();
337
+ }
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ }
343
+ return undefined;
344
+ }
293
345
  /**
294
346
  * @param {string[]} args
295
347
  * @returns {string | undefined}
@@ -403,7 +455,7 @@ function buildStreamResult(result) {
403
455
  * @returns {CliUsageInfo | undefined}
404
456
  */
405
457
  export function extractUsageFromOutput(raw) {
406
- const lines = raw.split(/\r?\n/).filter(Boolean);
458
+ const lines = stripOscSequences(raw).split(/\r?\n/).filter(Boolean);
407
459
  const usage = {};
408
460
  let found = false;
409
461
  for (const line of lines) {
@@ -453,6 +505,25 @@ export function extractUsageFromOutput(raw) {
453
505
  found = true;
454
506
  continue;
455
507
  }
508
+ if (parsed.type === "step_finish" && parsed.part?.tokens && typeof parsed.part.tokens === "object") {
509
+ const tokens = parsed.part.tokens;
510
+ const input = tokens.input ?? 0;
511
+ const output = tokens.output ?? 0;
512
+ const total = tokens.total ?? 0;
513
+ const reasoning = tokens.reasoning ?? 0;
514
+ const cacheRead = tokens.cache?.read ?? 0;
515
+ const cacheWrite = tokens.cache?.write ?? 0;
516
+ if (input > 0 || output > 0 || total > 0 || reasoning > 0 || cacheRead > 0 || cacheWrite > 0) {
517
+ usage.inputTokens = (usage.inputTokens ?? 0) + input;
518
+ usage.outputTokens = (usage.outputTokens ?? 0) + output;
519
+ usage.totalTokens = (usage.totalTokens ?? 0) + total;
520
+ usage.reasoningTokens = (usage.reasoningTokens ?? 0) + reasoning;
521
+ usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + cacheRead;
522
+ usage.cacheWriteTokens = (usage.cacheWriteTokens ?? 0) + cacheWrite;
523
+ found = true;
524
+ continue;
525
+ }
526
+ }
456
527
  if (parsed.usage && typeof parsed.usage === "object") {
457
528
  const u = parsed.usage;
458
529
  const inTok = u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? 0;
@@ -690,6 +761,7 @@ export class BaseCliAgent {
690
761
  const interpreter = this.createOutputInterpreter();
691
762
  let stdoutBuffer = "";
692
763
  let stderrBuffer = "";
764
+ let completedEvent = null;
693
765
  /**
694
766
  * @param {AgentCliEvent[] | AgentCliEvent | null | undefined} eventPayload
695
767
  */
@@ -698,6 +770,9 @@ export class BaseCliAgent {
698
770
  return;
699
771
  const events = Array.isArray(eventPayload) ? eventPayload : [eventPayload];
700
772
  for (const event of events) {
773
+ if (event?.type === "completed") {
774
+ completedEvent = event;
775
+ }
701
776
  logAgentCliEvent(event, commandLogAnnotations, span);
702
777
  if (!options?.onEvent)
703
778
  continue;
@@ -779,7 +854,11 @@ export class BaseCliAgent {
779
854
  if (result.exitCode && result.exitCode !== 0) {
780
855
  const filteredStderr = filterBenignStderr(result.stderr, commandSpec.benignStderrPatterns);
781
856
  if (!(commandSpec.command === "codex" && filteredStderr.length === 0)) {
782
- const errorText = filteredStderr ||
857
+ const structuredError = (commandSpec.outputFormat === "json" || commandSpec.outputFormat === "stream-json")
858
+ ? extractErrorFromJsonPayload(result.stdout)
859
+ : undefined;
860
+ const errorText = structuredError ||
861
+ filteredStderr ||
783
862
  result.stdout.trim() ||
784
863
  `CLI exited with code ${result.exitCode}`;
785
864
  const nonRetryable = classifyNonRetryableAgentError(errorText, commandSpec.command);
@@ -808,6 +887,9 @@ export class BaseCliAgent {
808
887
  return yield* Effect.fail(new SmithersError("AGENT_CLI_ERROR", errorText));
809
888
  }
810
889
  }
890
+ if (completedEvent?.ok === false) {
891
+ return yield* Effect.fail(new SmithersError("AGENT_CLI_ERROR", completedEvent.error || "CLI agent reported an error"));
892
+ }
811
893
  // Some CLIs may print extra banners to stdout. Allow individual agents
812
894
  // to provide patterns so this logic stays opt-in and agent-specific.
813
895
  const stdoutBannerPatterns = commandSpec.stdoutBannerPatterns ?? [];
@@ -853,7 +935,7 @@ export class BaseCliAgent {
853
935
  textTokens: undefined,
854
936
  reasoningTokens: cliUsage.reasoningTokens,
855
937
  },
856
- totalTokens: (cliUsage.inputTokens ?? 0) + (cliUsage.outputTokens ?? 0) || undefined,
938
+ totalTokens: cliUsage.totalTokens ?? ((cliUsage.inputTokens ?? 0) + (cliUsage.outputTokens ?? 0) || undefined),
857
939
  } : undefined;
858
940
  const tokenTotals = extractAgentTokenTotals(usage);
859
941
  stdoutEmitter?.flush(extractedText);
@@ -32,6 +32,8 @@ export function extractTextFromJsonValue(value) {
32
32
  if (parts.trim())
33
33
  return parts;
34
34
  }
35
+ if (record.type === "text" && record.part)
36
+ return extractTextFromJsonValue(record.part);
35
37
  if (record.response)
36
38
  return extractTextFromJsonValue(record.response);
37
39
  if (record.message)
@@ -1,5 +1,5 @@
1
1
  import { openai } from "@ai-sdk/openai";
2
- import { ToolLoopAgent, } from "ai";
2
+ import { Output, ToolLoopAgent, } from "ai";
3
3
  import { resolveSdkModel } from "./resolveSdkModel.js";
4
4
  import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js";
5
5
  /** @typedef {import("ai").AgentCallParameters} AgentCallParameters */
@@ -17,6 +17,7 @@ import { streamResultToGenerateResult } from "./streamResultToGenerateResult.js"
17
17
 
18
18
  export class OpenAIAgent extends ToolLoopAgent {
19
19
  hijackEngine = "openai-sdk";
20
+ supportsNativeStructuredOutput = true;
20
21
  /**
21
22
  * @param {OpenAIAgentOptions<CALL_OPTIONS, TOOLS>} opts
22
23
  */
@@ -35,11 +36,15 @@ export class OpenAIAgent extends ToolLoopAgent {
35
36
  const promptArgs = "messages" in args
36
37
  ? { messages: args.messages }
37
38
  : { prompt: args.prompt };
39
+ const outputArgs = args.outputSchema
40
+ ? { output: Output.object({ schema: args.outputSchema }) }
41
+ : {};
38
42
  if (!args.onStdout) {
39
43
  return super.generate({
40
44
  options: args.options,
41
45
  abortSignal: args.abortSignal,
42
46
  ...promptArgs,
47
+ ...outputArgs,
43
48
  timeout: args.timeout,
44
49
  onStepFinish: args.onStepFinish,
45
50
  });
@@ -48,6 +53,7 @@ export class OpenAIAgent extends ToolLoopAgent {
48
53
  options: args.options,
49
54
  abortSignal: args.abortSignal,
50
55
  ...promptArgs,
56
+ ...outputArgs,
51
57
  timeout: args.timeout,
52
58
  onStepFinish: args.onStepFinish,
53
59
  }).then((stream) => streamResultToGenerateResult(stream, args.onStdout));
@@ -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
+ }
@@ -13,6 +13,11 @@ import type {
13
13
 
14
14
  type AssertAssignable<T extends AgentLike> = T;
15
15
 
16
+ type _CustomNativeStructuredAgent = AssertAssignable<{
17
+ supportsNativeStructuredOutput: true;
18
+ generate: () => Promise<unknown>;
19
+ }>;
20
+
16
21
  type _ConcreteAgentsAreAgentLike = [
17
22
  AssertAssignable<AmpAgent>,
18
23
  AssertAssignable<AnthropicAgent>,
@@ -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",
package/src/index.d.ts CHANGED
@@ -112,7 +112,7 @@ type AgentToolDescriptor$1 = {
112
112
 
113
113
  type AgentCapabilityRegistry$3 = {
114
114
  version: 1;
115
- engine: "claude-code" | "codex" | "gemini" | "kimi" | "pi" | "amp" | "forge";
115
+ engine: "claude-code" | "codex" | "gemini" | "kimi" | "pi" | "amp" | "forge" | "opencode";
116
116
  runtimeTools: Record<string, AgentToolDescriptor$1>;
117
117
  mcp: {
118
118
  bootstrap: "inline-config" | "project-config" | "allow-list" | "unsupported";
@@ -151,6 +151,8 @@ type AgentLike$1 = {
151
151
  tools?: Record<string, unknown>;
152
152
  /** Optional structured capability registry for cache and diagnostics */
153
153
  capabilities?: AgentCapabilityRegistry$1;
154
+ /** True when the agent consumes outputSchema through a native structured-output API. */
155
+ supportsNativeStructuredOutput?: boolean;
154
156
  /**
155
157
  * Generates a response or action based on the provided arguments.
156
158
  *
@@ -762,6 +764,42 @@ declare class ForgeAgent extends BaseCliAgent {
762
764
  type CliOutputInterpreter = CliOutputInterpreter$8;
763
765
  type ForgeAgentOptions = ForgeAgentOptions$1;
764
766
 
767
+ type OpenCodeAgentOptions$1 = BaseCliAgentOptions$1 & {
768
+ /** Model identifier (e.g., "anthropic/claude-opus-4-20250514", "openai/gpt-5.4") */
769
+ model?: string;
770
+ /** OpenCode agent name (maps to --agent flag, selects predefined agent config) */
771
+ agentName?: string;
772
+ /** Files to attach to the prompt via -f flags */
773
+ attachFiles?: string[];
774
+ /** Continue a previous session */
775
+ continueSession?: boolean;
776
+ /** Resume a specific session by ID */
777
+ sessionId?: string;
778
+ /** Provider-specific model variant/reasoning effort level */
779
+ variant?: string;
780
+ };
781
+
782
+ declare class OpenCodeAgent extends BaseCliAgent {
783
+ constructor(opts?: OpenCodeAgentOptions);
784
+ opts: OpenCodeAgentOptions$1;
785
+ capabilities: AgentCapabilityRegistry$3;
786
+ cliEngine: "opencode";
787
+ createOutputInterpreter(): CliOutputInterpreter;
788
+ buildCommand(params: {
789
+ prompt: string;
790
+ systemPrompt?: string;
791
+ cwd: string;
792
+ options: any;
793
+ }): Promise<{
794
+ command: string;
795
+ args: string[];
796
+ outputFormat: "stream-json";
797
+ env?: Record<string, string>;
798
+ stdoutBannerPatterns: RegExp[];
799
+ stdoutErrorPatterns: RegExp[];
800
+ }>;
801
+ }
802
+
765
803
  /**
766
804
  * @param {CreateSmithersAgentContractOptions} options
767
805
  * @returns {SmithersAgentContract}
@@ -821,6 +859,7 @@ type AgentCapabilityRegistry = AgentCapabilityRegistry$3;
821
859
  type AgentLike = AgentLike$1;
822
860
  type AgentToolDescriptor = AgentToolDescriptor$1;
823
861
  type AnthropicAgentOptions<CALL_OPTIONS = never, TOOLS = ai.ToolSet> = AnthropicAgentOptions$2<CALL_OPTIONS, TOOLS>;
862
+ type OpenCodeAgentOptions = OpenCodeAgentOptions$1;
824
863
  type OpenAIAgentOptions<CALL_OPTIONS = never, TOOLS = ai.ToolSet> = OpenAIAgentOptions$2<CALL_OPTIONS, TOOLS>;
825
864
  type PiAgentOptions = PiAgentOptions$2;
826
865
  type PiExtensionUiRequest = PiExtensionUiRequest$1;
@@ -831,4 +870,4 @@ type SmithersAgentToolCategory = SmithersAgentToolCategory$1;
831
870
  type SmithersListedTool = SmithersListedTool$2;
832
871
  type SmithersToolSurface = SmithersToolSurface$2;
833
872
 
834
- export { type AgentCapabilityRegistry, type AgentGenerateOptions, type AgentLike, type AgentToolDescriptor, AmpAgent, AnthropicAgent, type AnthropicAgentOptions, BaseCliAgent, ClaudeCodeAgent, CodexAgent, ForgeAgent, GeminiAgent, KimiAgent, OpenAIAgent, type OpenAIAgentOptions, PiAgent, type PiAgentOptions, type PiExtensionUiRequest, type PiExtensionUiResponse, type SmithersAgentContract, type SmithersAgentContractTool, type SmithersAgentToolCategory, type SmithersListedTool, type SmithersToolSurface, createSmithersAgentContract, hashCapabilityRegistry, renderSmithersAgentPromptGuidance, sanitizeForOpenAI, zodToOpenAISchema };
873
+ export { type AgentCapabilityRegistry, type AgentGenerateOptions, type AgentLike, type AgentToolDescriptor, AmpAgent, AnthropicAgent, type AnthropicAgentOptions, BaseCliAgent, ClaudeCodeAgent, CodexAgent, ForgeAgent, GeminiAgent, KimiAgent, OpenAIAgent, type OpenAIAgentOptions, OpenCodeAgent, type OpenCodeAgentOptions, PiAgent, type PiAgentOptions, type PiExtensionUiRequest, type PiExtensionUiResponse, type SmithersAgentContract, type SmithersAgentContractTool, type SmithersAgentToolCategory, type SmithersListedTool, type SmithersToolSurface, createSmithersAgentContract, hashCapabilityRegistry, renderSmithersAgentPromptGuidance, sanitizeForOpenAI, zodToOpenAISchema };
package/src/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  /** @typedef {import("./PiAgentOptions.ts").PiAgentOptions} PiAgentOptions */
17
17
  /** @typedef {import("./BaseCliAgent/PiExtensionUiRequest.ts").PiExtensionUiRequest} PiExtensionUiRequest */
18
18
  /** @typedef {import("./BaseCliAgent/PiExtensionUiResponse.ts").PiExtensionUiResponse} PiExtensionUiResponse */
19
+ /** @typedef {import("./OpenCodeAgent.ts").OpenCodeAgentOptions} OpenCodeAgentOptions */
19
20
  /** @typedef {import("./agent-contract/SmithersAgentContract.ts").SmithersAgentContract} SmithersAgentContract */
20
21
  /** @typedef {import("./agent-contract/SmithersAgentContractTool.ts").SmithersAgentContractTool} SmithersAgentContractTool */
21
22
  /** @typedef {import("./agent-contract/SmithersAgentToolCategory.ts").SmithersAgentToolCategory} SmithersAgentToolCategory */
@@ -34,6 +35,7 @@ export { GeminiAgent } from "./GeminiAgent.js";
34
35
  export { PiAgent } from "./PiAgent.js";
35
36
  export { KimiAgent } from "./KimiAgent.js";
36
37
  export { ForgeAgent } from "./ForgeAgent.js";
38
+ export { OpenCodeAgent } from "./OpenCodeAgent.js";
37
39
  export { createSmithersAgentContract } from "./agent-contract/createSmithersAgentContract.js";
38
40
  export { renderSmithersAgentPromptGuidance } from "./agent-contract/renderSmithersAgentPromptGuidance.js";
39
41
  export { zodToOpenAISchema } from "./zodToOpenAISchema.js";