@open-cr-agent/runtime-opencode 0.1.2 → 0.3.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/dist/binary.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { Env } from "@open-cr-agent/core";
1
+ import { type Env } from "@open-cr-agent/core";
2
2
  export interface Host {
3
3
  platform: string;
4
4
  arch: string;
5
5
  musl: boolean;
6
6
  baseline: boolean;
7
7
  }
8
- export declare function resolveOpencodeBinary(env: Env): string;
8
+ export declare function resolveOpencodeBinary(env: Env, opencodePackage?: string): string;
9
+ export declare function isNativeExecutable(path: string): boolean;
9
10
  export declare function binaryVariants(host: Host): string[];
10
11
  //# sourceMappingURL=binary.d.ts.map
package/dist/binary.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, resolve } from "node:path";
5
+ import { OcraError } from "@open-cr-agent/core";
5
6
  const PLATFORMS = { darwin: "darwin", linux: "linux", win32: "windows" };
6
7
  // Resolved from opencode-ai's platform packages directly, because newer npm
7
- // versions block the postinstall script that would otherwise place it.
8
- export function resolveOpencodeBinary(env) {
8
+ // versions block the postinstall script that would otherwise place it. An
9
+ // install without optional dependencies has no platform package, but where
10
+ // that script ran, it downloaded the binary into opencode-ai's own bin/.
11
+ export function resolveOpencodeBinary(env, opencodePackage = createRequire(import.meta.url).resolve("opencode-ai/package.json")) {
9
12
  const override = env.OCRA_OPENCODE_BIN;
10
13
  // OpenCode runs in a directory of its own, where a relative path would
11
14
  // not resolve; a bare name is still looked up in PATH.
@@ -13,7 +16,7 @@ export function resolveOpencodeBinary(env) {
13
16
  return /[\\/]/.test(override) ? resolve(override) : override;
14
17
  const platform = PLATFORMS[process.platform] ?? process.platform;
15
18
  const executable = platform === "windows" ? "opencode.exe" : "opencode";
16
- const fromOpencode = createRequire(createRequire(import.meta.url).resolve("opencode-ai/package.json"));
19
+ const fromOpencode = createRequire(opencodePackage);
17
20
  for (const variant of binaryVariants(currentHost(platform))) {
18
21
  try {
19
22
  const pkg = fromOpencode.resolve(`opencode-${platform}-${process.arch}${variant}/package.json`);
@@ -23,7 +26,34 @@ export function resolveOpencodeBinary(env) {
23
26
  }
24
27
  catch { }
25
28
  }
26
- throw new Error(`No OpenCode binary for ${platform}-${process.arch}; set OCRA_OPENCODE_BIN`);
29
+ // The script names the binary opencode.exe on every platform.
30
+ const placed = join(dirname(opencodePackage), "bin", "opencode.exe");
31
+ if (isNativeExecutable(placed))
32
+ return placed;
33
+ throw new OcraError("RUNTIME_START_FAILED", `No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
34
+ }
35
+ // Until the install script replaces it, opencode-ai's bin/opencode.exe is a
36
+ // shell script that only prints an error, so the file must be a real
37
+ // executable: ELF, Mach-O (either byte order, or universal) or Windows PE.
38
+ export function isNativeExecutable(path) {
39
+ let fd;
40
+ try {
41
+ fd = openSync(path, "r");
42
+ const head = Buffer.alloc(4);
43
+ if (readSync(fd, head, 0, 4, 0) < 4)
44
+ return false;
45
+ const magic = head.readUInt32BE(0);
46
+ return (magic === 0x7f454c46 ||
47
+ [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(magic) ||
48
+ head.subarray(0, 2).toString("latin1") === "MZ");
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ finally {
54
+ if (fd !== undefined)
55
+ closeSync(fd);
56
+ }
27
57
  }
28
58
  // The builds to try, best first. npm installs every build whose os and cpu
29
59
  // match, and the glibc builds declare no libc, so musl Linux has both kinds;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
- export * from "./models.js";
2
- export * from "./plugin.js";
3
- export * from "./runtime.js";
1
+ export { opencodeRuntimePlugin } from "./plugin.js";
4
2
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,2 @@
1
- export * from "./models.js";
2
- export * from "./plugin.js";
3
- export * from "./runtime.js";
1
+ export { opencodeRuntimePlugin } from "./plugin.js";
4
2
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:net";
4
+ import { OcraError } from "@open-cr-agent/core";
4
5
  const LISTENING = /opencode server listening on (https?:\/\/\S+)/;
5
6
  export async function startOpencodeServer(options) {
6
7
  const port = await freePort();
@@ -32,7 +33,7 @@ function waitForListening(child, timeoutMs) {
32
33
  const fail = (message) => {
33
34
  clearTimeout(timer);
34
35
  void stop(child);
35
- reject(new Error(`${message}${output.trim() ? `\n${output.trim()}` : ""}`));
36
+ reject(new OcraError("RUNTIME_START_FAILED", `${message}${output.trim() ? `\n${output.trim()}` : ""}`));
36
37
  };
37
38
  const timer = setTimeout(() => fail(`OpenCode did not start within ${timeoutMs}ms`), timeoutMs);
38
39
  // Only the tail matters for a startup error message.
package/dist/runtime.d.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type CompletionRequest, type CompletionResult, type RuntimeOptions } from "@open-cr-agent/core";
1
+ import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type AppliedSampling, type CompletionRequest, type CompletionResult, type CustomProvider, type RuntimeOptions, type Sampling } from "@open-cr-agent/core";
2
2
  import { type ToolServer } from "./tool-server.js";
3
3
  export declare const MCP_SERVER = "ocra";
4
- export declare const MAX_AGENT_STEPS = 30;
5
4
  export declare const HELPER_AGENT_STEPS = 2;
6
5
  export declare const REVIEW_RESUME: {
7
- doneTool: string;
6
+ doneTool: "task_done";
8
7
  maxSteps: number;
9
8
  message: string;
10
9
  };
@@ -15,6 +14,7 @@ export interface OpenCodeRuntimeOptions extends RuntimeOptions {
15
14
  export declare class OpenCodeRuntime implements AgentRuntime {
16
15
  private readonly options;
17
16
  readonly name = "opencode";
17
+ readonly sampling: AppliedSampling;
18
18
  private infra;
19
19
  private context;
20
20
  private readonly health;
@@ -27,9 +27,30 @@ export declare class OpenCodeRuntime implements AgentRuntime {
27
27
  private start;
28
28
  private launch;
29
29
  }
30
- export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>): {
30
+ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>, sampling?: Sampling): {
31
31
  share: string;
32
32
  autoupdate: boolean;
33
+ provider?: {
34
+ [k: string]: {
35
+ npm: string;
36
+ name: string;
37
+ options: {
38
+ baseURL: string;
39
+ apiKey?: string;
40
+ };
41
+ models: {
42
+ [k: string]: {
43
+ name: string;
44
+ temperature?: boolean;
45
+ cost: {
46
+ input: number;
47
+ output: number;
48
+ cache_read?: number;
49
+ };
50
+ };
51
+ };
52
+ };
53
+ };
33
54
  mcp: {
34
55
  ocra: {
35
56
  type: string;
@@ -41,6 +62,21 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
41
62
  };
42
63
  agent: {
43
64
  "ocra-reviewer": {
65
+ temperature?: never;
66
+ mode: string;
67
+ prompt: string;
68
+ steps: number;
69
+ tools: {
70
+ [k: string]: boolean;
71
+ };
72
+ permission: {
73
+ edit: string;
74
+ bash: string;
75
+ webfetch: string;
76
+ skill: string;
77
+ };
78
+ } | {
79
+ temperature: number;
44
80
  mode: string;
45
81
  prompt: string;
46
82
  steps: number;
@@ -55,6 +91,19 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
55
91
  };
56
92
  };
57
93
  "ocra-helper": {
94
+ temperature?: never;
95
+ mode: string;
96
+ prompt: string;
97
+ steps: number;
98
+ tools: Record<string, boolean>;
99
+ permission: {
100
+ edit: string;
101
+ bash: string;
102
+ webfetch: string;
103
+ skill: string;
104
+ };
105
+ } | {
106
+ temperature: number;
58
107
  mode: string;
59
108
  prompt: string;
60
109
  steps: number;
package/dist/runtime.js CHANGED
@@ -2,15 +2,12 @@ import { rmSync } from "node:fs";
2
2
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { addUsage, CompletionError, emptyUsage, REVIEW_TOOLS, } from "@open-cr-agent/core";
5
+ import { OcraError, } from "@open-cr-agent/core";
6
+ import { completeWithFailback, MAX_AGENT_STEPS, ModelHealth, parseModel, RESUME_MESSAGE, REVIEW_TOOLS, reviewTools, withFailback, withoutSecrets, } from "@open-cr-agent/core/internal";
6
7
  import { createOpencodeClient } from "@opencode-ai/sdk/v2";
7
8
  import { resolveOpencodeBinary } from "./binary.js";
8
- import { withFailback } from "./failback.js";
9
- import { ModelHealth, parseModel } from "./models.js";
10
9
  import { startOpencodeServer } from "./opencode-server.js";
11
- import { sleep } from "./quota.js";
12
- import { reviewTools } from "./review-tools.js";
13
- import { missingCredentials, serverEnv } from "./server-env.js";
10
+ import { credentialValues, missingCredentials, serverEnv } from "./server-env.js";
14
11
  import { promptSession } from "./session-prompt.js";
15
12
  import { startToolServer } from "./tool-server.js";
16
13
  import { createUntimedDispatcher, untimedFetch } from "./transport.js";
@@ -20,21 +17,15 @@ const HELPER_AGENT = "ocra-helper";
20
17
  // Must not be empty: OpenCode falls back to its full coding prompt otherwise.
21
18
  const REVIEW_AGENT_PROMPT = "You are a code review agent run by ocra. Follow the review instructions below.";
22
19
  const HELPER_AGENT_PROMPT = "You answer exactly as the instructions below ask, with no tools.";
23
- // Each step resends the whole conversation, so an unbounded loop is the
24
- // largest cost risk. At 20 steps a quarter of the review tasks on Vertex
25
- // ended at the cap and one golden bug was never found; at 30 it was found in
26
- // both runs, for about a third more cost on average (2026-09-28). Most tasks
27
- // finish in about 15 steps and never reach it.
28
- export const MAX_AGENT_STEPS = 30;
29
20
  // The helper answers in one step and has no tools. It still needs two:
30
21
  // OpenCode appends an assistant message on an agent's last allowed step, and
31
22
  // Gemini rejects a request that ends with a model turn (#66).
32
23
  export const HELPER_AGENT_STEPS = 2;
33
24
  // Sent once to a review agent that stopped before finishing (session-prompt.ts).
34
25
  export const REVIEW_RESUME = {
35
- doneTool: `${MCP_SERVER}_${REVIEW_TOOLS.taskDone}`,
26
+ doneTool: REVIEW_TOOLS.taskDone,
36
27
  maxSteps: MAX_AGENT_STEPS,
37
- message: `You stopped before finishing the review. Continue with the files in <ocra_review_files> you have not reviewed yet, report each confirmed issue with ${REVIEW_TOOLS.reportFinding}, and call ${REVIEW_TOOLS.taskDone} when every file is done.`,
28
+ message: RESUME_MESSAGE,
38
29
  };
39
30
  // Every OpenCode built-in tool of the pinned version; a test fails when an
40
31
  // upgrade adds one, so a new write-capable tool can never be enabled silently.
@@ -58,6 +49,7 @@ const DISABLED_BUILTINS = Object.fromEntries(OPENCODE_BUILTIN_TOOLS.map((t) => [
58
49
  export class OpenCodeRuntime {
59
50
  options;
60
51
  name = "opencode";
52
+ sampling;
61
53
  infra;
62
54
  context;
63
55
  health = new ModelHealth();
@@ -69,10 +61,11 @@ export class OpenCodeRuntime {
69
61
  false,
70
62
  ]);
71
63
  this.helperTools = { ...DISABLED_BUILTINS, ...Object.fromEntries(mcpTools) };
64
+ this.sampling = openCodeSampling(options.sampling);
72
65
  }
73
66
  async *runTask(spec, signal) {
74
67
  if (this.context && this.context !== spec.context) {
75
- throw new Error("An OpenCodeRuntime instance serves a single review run");
68
+ throw new OcraError("INTERNAL", "An OpenCodeRuntime instance serves a single review run");
76
69
  }
77
70
  this.context = spec.context;
78
71
  const chain = this.options.models[spec.modelTier] ?? [];
@@ -99,6 +92,7 @@ export class OpenCodeRuntime {
99
92
  system: spec.systemPrompt,
100
93
  user: spec.userPrompt,
101
94
  tools: DISABLED_BUILTINS,
95
+ toolPrefix: `${MCP_SERVER}_`,
102
96
  resume: REVIEW_RESUME,
103
97
  }, signal, onUsage),
104
98
  });
@@ -106,44 +100,23 @@ export class OpenCodeRuntime {
106
100
  async complete(request, signal) {
107
101
  const chain = this.options.models[request.tier] ?? [];
108
102
  if (chain.length === 0)
109
- throw new Error(noModel(request.tier));
103
+ throw new OcraError("CONFIG_INVALID", noModel(request.tier));
110
104
  const infra = await this.start();
111
- let usage = emptyUsage();
112
- let lastError = "";
113
- for (const model of this.health.order(chain)) {
114
- for (;;) {
115
- await sleep(this.health.pausedFor(model), signal);
116
- if (signal.aborted)
117
- throw new CompletionError("cancelled", usage);
118
- const outcome = await this.prompt(infra, {
119
- title: "ocra helper",
120
- agent: HELPER_AGENT,
121
- model,
122
- system: request.system,
123
- user: request.user,
124
- tools: this.helperTools,
125
- }, signal);
126
- usage = addUsage(usage, outcome.usage);
127
- if (!outcome.error) {
128
- this.health.recordSuccess(model);
129
- return { text: outcome.text, usage };
130
- }
131
- if (!outcome.error.retryable) {
132
- throw new CompletionError(`${model}: ${outcome.error.message}`, usage);
133
- }
134
- if (outcome.error.quota && this.health.recordQuota(model, outcome.error.quota) === "wait") {
135
- continue;
136
- }
137
- if (!outcome.error.quota)
138
- this.health.recordFailure(model);
139
- lastError = `${model}: ${outcome.error.message}`;
140
- break;
141
- }
142
- }
143
- if (!lastError) {
144
- throw new CompletionError(`every ${request.tier} model is out of quota for this run`, usage);
145
- }
146
- throw new CompletionError(`every ${request.tier} model failed (${lastError})`, usage);
105
+ return completeWithFailback({
106
+ tier: request.tier,
107
+ chain,
108
+ health: this.health,
109
+ signal,
110
+ attempt: (model) => this.prompt(infra, {
111
+ title: "ocra helper",
112
+ agent: HELPER_AGENT,
113
+ model,
114
+ system: request.system,
115
+ user: request.user,
116
+ tools: this.helperTools,
117
+ toolPrefix: `${MCP_SERVER}_`,
118
+ }, signal),
119
+ });
147
120
  }
148
121
  async dispose() {
149
122
  const infra = await this.infra?.catch(() => undefined);
@@ -155,16 +128,25 @@ export class OpenCodeRuntime {
155
128
  await rm(infra.root, { recursive: true, force: true });
156
129
  }
157
130
  prompt(infra, input, signal, onUsage) {
158
- return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {});
131
+ return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {}).then((outcome) => outcome.error
132
+ ? {
133
+ ...outcome,
134
+ error: {
135
+ ...outcome.error,
136
+ message: withoutSecrets(outcome.error.message, infra.secrets),
137
+ },
138
+ }
139
+ : outcome);
159
140
  }
160
141
  start() {
161
142
  this.infra ??= this.launch();
162
143
  return this.infra;
163
144
  }
164
145
  async launch() {
165
- const missing = missingCredentials(this.options.env, providersOf(this.options.models));
146
+ const custom = this.options.providers ?? {};
147
+ const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
166
148
  if (missing.length > 0)
167
- throw new Error(missing.join("; "));
149
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", missing.join("; "));
168
150
  const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
169
151
  const dirs = {
170
152
  config: join(root, "config"),
@@ -179,8 +161,8 @@ export class OpenCodeRuntime {
179
161
  const server = await startOpencodeServer({
180
162
  binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
181
163
  cwd: workspace,
182
- env: serverEnv(this.options.env, dirs, providersOf(this.options.models)),
183
- config: openCodeConfig(tools, this.helperTools),
164
+ env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
165
+ config: openCodeConfig(tools, this.helperTools, custom, this.options.sampling),
184
166
  });
185
167
  started = server;
186
168
  const dispatcher = createUntimedDispatcher();
@@ -197,7 +179,8 @@ export class OpenCodeRuntime {
197
179
  rmSync(root, { recursive: true, force: true });
198
180
  };
199
181
  process.on("exit", onExit);
200
- return { root, tools, server, client, dispatcher, onExit };
182
+ const secrets = credentialValues(this.options.env, providersOf(this.options.models), custom);
183
+ return { root, tools, server, client, dispatcher, onExit, secrets };
201
184
  }
202
185
  catch (error) {
203
186
  await Promise.allSettled([tools.close(), started?.close()]);
@@ -206,11 +189,26 @@ export class OpenCodeRuntime {
206
189
  }
207
190
  }
208
191
  }
209
- export function openCodeConfig(tools, helperTools) {
192
+ // OpenCode 1.18.32 takes a temperature per agent and sends it when the
193
+ // model's catalog entry says the model accepts one; a model declared in
194
+ // configuration is marked so (providerConfig). It has no seed setting: the
195
+ // request it builds carries temperature, topP, topK and the output limit only.
196
+ function openCodeSampling(sampling = {}) {
197
+ return {
198
+ ...(sampling.temperature === undefined ? {} : { temperature: sampling.temperature }),
199
+ ...(sampling.seed === undefined ? {} : { notApplied: ["seed"] }),
200
+ };
201
+ }
202
+ export function openCodeConfig(tools, helperTools, custom = {}, sampling = {}) {
210
203
  const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
204
+ const { temperature } = sampling;
205
+ const agentSampling = temperature === undefined ? {} : { temperature };
211
206
  return {
212
207
  share: "disabled",
213
208
  autoupdate: false,
209
+ ...(Object.keys(custom).length > 0
210
+ ? { provider: providerConfig(custom, temperature !== undefined) }
211
+ : {}),
214
212
  mcp: {
215
213
  [MCP_SERVER]: {
216
214
  type: "remote",
@@ -227,6 +225,7 @@ export function openCodeConfig(tools, helperTools) {
227
225
  steps: MAX_AGENT_STEPS,
228
226
  tools: DISABLED_BUILTINS,
229
227
  permission,
228
+ ...agentSampling,
230
229
  },
231
230
  [HELPER_AGENT]: {
232
231
  mode: "primary",
@@ -234,10 +233,43 @@ export function openCodeConfig(tools, helperTools) {
234
233
  steps: HELPER_AGENT_STEPS,
235
234
  tools: helperTools,
236
235
  permission,
236
+ ...agentSampling,
237
237
  },
238
238
  },
239
239
  };
240
240
  }
241
+ // OpenCode's form of a provider declared in configuration. OpenCode bundles
242
+ // the OpenAI-compatible client, so nothing is installed to reach it
243
+ // (docs/spikes/0002). The key stays in the environment: OpenCode reads
244
+ // {env:NAME} itself, and the configuration, which OpenCode may log, never
245
+ // holds it. Prices are per million tokens, as OpenCode's catalog has them.
246
+ // OpenCode assumes a declared model takes no temperature and drops one;
247
+ // with a temperature configured, the models are marked as taking it.
248
+ function providerConfig(custom, temperature) {
249
+ return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
250
+ id,
251
+ {
252
+ npm: "@ai-sdk/openai-compatible",
253
+ name: id,
254
+ options: {
255
+ baseURL: provider.baseUrl,
256
+ ...(provider.apiKeyEnv ? { apiKey: `{env:${provider.apiKeyEnv}}` } : {}),
257
+ },
258
+ models: Object.fromEntries(Object.entries(provider.models).map(([model, price]) => [
259
+ model,
260
+ {
261
+ name: model,
262
+ ...(temperature ? { temperature: true } : {}),
263
+ cost: {
264
+ input: price.input,
265
+ output: price.output,
266
+ ...(price.cachedInput === undefined ? {} : { cache_read: price.cachedInput }),
267
+ },
268
+ },
269
+ ])),
270
+ },
271
+ ]));
272
+ }
241
273
  function providersOf(models) {
242
274
  return Object.values(models).flatMap((chain) => (chain ?? []).map((m) => parseModel(m).providerID));
243
275
  }
@@ -1,10 +1,14 @@
1
- import type { Env } from "@open-cr-agent/core";
1
+ import type { CustomProvider, Env } from "@open-cr-agent/core";
2
2
  export interface IsolatedDirs {
3
3
  config: string;
4
4
  data: string;
5
5
  state: string;
6
6
  }
7
- export declare function missingCredentials(base: Env, providers: readonly string[]): string[];
7
+ type CustomProviders = Readonly<Record<string, CustomProvider>>;
8
+ export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
8
9
  export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
9
- export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[]): Record<string, string>;
10
+ export declare function credentialNames(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
11
+ export declare function credentialValues(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
12
+ export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
13
+ export {};
10
14
  //# sourceMappingURL=server-env.d.ts.map
@@ -73,8 +73,13 @@ const REQUIRED_KEYS = {
73
73
  xai: ["XAI_API_KEY"],
74
74
  azure: ["AZURE_API_KEY"],
75
75
  };
76
- export function missingCredentials(base, providers) {
76
+ export function missingCredentials(base, providers, custom = {}) {
77
77
  return [...new Set(providers)].flatMap((provider) => {
78
+ const declared = custom[provider];
79
+ if (declared) {
80
+ const name = declared.apiKeyEnv;
81
+ return name && !base[name] ? [`No API key for provider "${provider}": set ${name}`] : [];
82
+ }
78
83
  const names = REQUIRED_KEYS[provider];
79
84
  if (!names || names.some((name) => base[name]))
80
85
  return [];
@@ -84,24 +89,77 @@ export function missingCredentials(base, providers) {
84
89
  const GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY";
85
90
  const GOOGLE_KEY_ALIASES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
86
91
  export const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
87
- // A model chain naming a provider such as "github" or "aws" must not carry
88
- // the CI's or the cloud's credentials across by prefix; name them in
92
+ // OpenCode installs its plugin package from npm into every config directory
93
+ // when it starts, and the SDK of a provider it does not bundle when a model
94
+ // first uses it; no flag turns either off. ocra loads no OpenCode plugins and
95
+ // supports only bundled providers, so both installs get the discard port on
96
+ // this machine as their registry, and no retries: they fail at once, no code
97
+ // is fetched at review time and no .npmrc token is sent (docs/spikes/0002).
98
+ const NPM_SETTINGS = {
99
+ npm_config_registry: "http://127.0.0.1:9/",
100
+ NPM_CONFIG_REGISTRY: "http://127.0.0.1:9/",
101
+ npm_config_fetch_retries: "0",
102
+ };
103
+ // A model chain naming a provider such as "github", "gitlab" or "aws" must
104
+ // not carry the CI's or the cloud's credentials across by prefix (GitLab CI
105
+ // puts CI_JOB_TOKEN and CI_REGISTRY_PASSWORD in every job); name them in
89
106
  // OCRA_RUNTIME_ENV when they are really meant for a provider.
90
107
  const NEVER_BY_PREFIX = [
91
108
  "GITHUB_",
92
109
  "GH_",
93
110
  "ACTIONS_",
94
111
  "RUNNER_",
112
+ "GITLAB_",
113
+ "CI_",
95
114
  "AWS_",
96
115
  "AZURE_",
97
116
  "NPM_",
98
117
  "SSH_",
99
118
  "OCRA_",
100
119
  ];
120
+ // The variables that carry the credentials of the configured providers: for
121
+ // a provider declared in configuration only the one it names, for a known
122
+ // catalog provider its variables, for any other every variable with its
123
+ // prefix that is not a platform token.
124
+ export function credentialNames(base, providers, custom = {}) {
125
+ const names = [];
126
+ for (const provider of new Set(providers)) {
127
+ const declared = custom[provider];
128
+ if (declared) {
129
+ if (declared.apiKeyEnv)
130
+ names.push(declared.apiKeyEnv);
131
+ continue;
132
+ }
133
+ const known = PROVIDER_VARIABLES[provider];
134
+ if (known) {
135
+ names.push(...known);
136
+ }
137
+ else {
138
+ const prefix = `${provider.toUpperCase().replaceAll("-", "_")}_`;
139
+ for (const name of Object.keys(base)) {
140
+ if (name.startsWith(prefix) && !NEVER_BY_PREFIX.some((p) => name.startsWith(p))) {
141
+ names.push(name);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return names;
147
+ }
148
+ // The credential values themselves, to redact from what a provider says.
149
+ // Values shorter than a key could be ordinary words and are left alone.
150
+ export function credentialValues(base, providers, custom = {}) {
151
+ const names = credentialNames(base, providers, custom);
152
+ if (providers.includes("google") && !custom.google)
153
+ names.push(...GOOGLE_KEY_ALIASES);
154
+ return names
155
+ .map((name) => base[name])
156
+ .filter((value) => value !== undefined && value.length >= 8);
157
+ }
101
158
  // The child sees only what it needs: system basics, the credentials of the
102
- // providers in the configured model chains, and names listed in
159
+ // providers in the configured model chains (for a provider declared in
160
+ // configuration, only the variable it names), and names listed in
103
161
  // OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
104
- export function serverEnv(base, dirs, providers) {
162
+ export function serverEnv(base, dirs, providers, custom = {}) {
105
163
  const env = {};
106
164
  const copy = (name) => {
107
165
  const value = base[name];
@@ -113,32 +171,20 @@ export function serverEnv(base, dirs, providers) {
113
171
  for (const name of Object.keys(base))
114
172
  if (name.startsWith("LC_"))
115
173
  copy(name);
116
- for (const provider of new Set(providers)) {
117
- const known = PROVIDER_VARIABLES[provider];
118
- if (known) {
119
- for (const name of known)
120
- copy(name);
121
- }
122
- else {
123
- const prefix = `${provider.toUpperCase().replaceAll("-", "_")}_`;
124
- for (const name of Object.keys(base)) {
125
- if (name.startsWith(prefix) && !NEVER_BY_PREFIX.some((p) => name.startsWith(p))) {
126
- copy(name);
127
- }
128
- }
129
- }
130
- }
174
+ for (const name of credentialNames(base, providers, custom))
175
+ copy(name);
131
176
  for (const name of (base[EXTRA_ENV_VARIABLE] ?? "").split(",")) {
132
177
  if (name.trim() !== "")
133
178
  copy(name.trim());
134
179
  }
135
- if (providers.includes("google") && !env[GOOGLE_KEY]) {
180
+ if (providers.includes("google") && !custom.google && !env[GOOGLE_KEY]) {
136
181
  const alias = GOOGLE_KEY_ALIASES.map((name) => base[name]).find((value) => value);
137
182
  if (alias)
138
183
  env[GOOGLE_KEY] = alias;
139
184
  }
140
185
  for (const flag of ISOLATION_FLAGS)
141
186
  env[flag] = "1";
187
+ Object.assign(env, NPM_SETTINGS);
142
188
  env.OPENCODE_CONFIG_DIR = dirs.config;
143
189
  env.XDG_CONFIG_HOME = dirs.config;
144
190
  env.XDG_DATA_HOME = dirs.data;
@@ -1,5 +1,5 @@
1
1
  import type { Usage } from "@open-cr-agent/core";
2
- import { type QuotaError } from "./quota.js";
2
+ import { type AttemptOutcome } from "@open-cr-agent/core/internal";
3
3
  export interface SessionMessage {
4
4
  info: {
5
5
  role: string;
@@ -31,19 +31,6 @@ export interface SessionMessage {
31
31
  };
32
32
  }[];
33
33
  }
34
- export interface SessionOutcome {
35
- findings: unknown[];
36
- steps: number;
37
- toolCalls: string[];
38
- text: string;
39
- resumed?: true;
40
- usage: Usage;
41
- error?: {
42
- message: string;
43
- retryable: boolean;
44
- quota?: QuotaError;
45
- };
46
- }
47
- export declare function summarizeSession(messages: readonly SessionMessage[], reportTool: string): SessionOutcome;
34
+ export declare function summarizeSession(messages: readonly SessionMessage[], reportTool: string, toolPrefix?: string): AttemptOutcome;
48
35
  export declare function sessionUsage(messages: readonly SessionMessage[]): Usage;
49
36
  //# sourceMappingURL=session-outcome.d.ts.map
@@ -1,7 +1,9 @@
1
- import { parseQuotaError } from "./quota.js";
1
+ import { parseQuotaError } from "@open-cr-agent/core/internal";
2
2
  const AUTH_STATUS = new Set([401, 403]);
3
3
  const AUTH_MESSAGE = /api key|unauthori[sz]ed|permission denied|forbidden/i;
4
- export function summarizeSession(messages, reportTool) {
4
+ // Tool names come back with the MCP server's prefix (MCP_SERVER in
5
+ // runtime.ts), which the shared outcome does without.
6
+ export function summarizeSession(messages, reportTool, toolPrefix = "") {
5
7
  const assistant = messages.filter((m) => m.info.role === "assistant");
6
8
  const tools = assistant.flatMap((m) => m.parts.filter((p) => p.type === "tool"));
7
9
  const outcome = {
@@ -9,7 +11,7 @@ export function summarizeSession(messages, reportTool) {
9
11
  .filter((p) => p.tool === reportTool && p.state?.status === "completed")
10
12
  .map((p) => p.state?.input),
11
13
  steps: assistant.reduce((n, m) => n + m.parts.filter((p) => p.type === "step-start").length, 0),
12
- toolCalls: tools.map((p) => p.tool ?? "unknown"),
14
+ toolCalls: tools.map((p) => (p.tool ?? "unknown").replace(toolPrefix, "")),
13
15
  text: assistant
14
16
  .flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => p.text ?? ""))
15
17
  .join("\n")
@@ -1,6 +1,7 @@
1
1
  import { type Usage } from "@open-cr-agent/core";
2
+ import { type AttemptOutcome } from "@open-cr-agent/core/internal";
2
3
  import type { OpencodeClient } from "@opencode-ai/sdk/v2";
3
- import { type SessionMessage, type SessionOutcome } from "./session-outcome.js";
4
+ import { type SessionMessage } from "./session-outcome.js";
4
5
  export declare const HARVEST_TIMEOUT_MS = 5000;
5
6
  export declare const INACTIVITY_MS: number;
6
7
  export declare const ACTIVITY_POLL_MS = 10000;
@@ -16,6 +17,7 @@ export interface PromptInput {
16
17
  system: string;
17
18
  user: string;
18
19
  tools: Record<string, boolean>;
20
+ toolPrefix?: string;
19
21
  resume?: ResumeOptions;
20
22
  }
21
23
  export interface ResumeOptions {
@@ -23,10 +25,10 @@ export interface ResumeOptions {
23
25
  maxSteps: number;
24
26
  message: string;
25
27
  }
26
- export declare function stoppedEarly(outcome: SessionOutcome, resume: ResumeOptions): boolean;
28
+ export declare function stoppedEarly(outcome: AttemptOutcome, resume: ResumeOptions): boolean;
27
29
  type SessionApi = Pick<OpencodeClient["session"], "create" | "prompt" | "messages" | "abort">;
28
- export declare function promptSession(session: SessionApi, input: PromptInput, reportTool: string, signal: AbortSignal, activity?: ActivityOptions): Promise<SessionOutcome>;
30
+ export declare function promptSession(session: SessionApi, input: PromptInput, reportTool: string, signal: AbortSignal, activity?: ActivityOptions): Promise<AttemptOutcome>;
29
31
  export declare function activitySignature(messages: readonly SessionMessage[]): string;
30
- export declare function emptyOutcome(): SessionOutcome;
32
+ export declare function emptyOutcome(): AttemptOutcome;
31
33
  export {};
32
34
  //# sourceMappingURL=session-prompt.d.ts.map
@@ -1,6 +1,6 @@
1
- import { emptyUsage, errorMessage } from "@open-cr-agent/core";
2
- import { parseModel } from "./models.js";
3
- import { sessionUsage, summarizeSession, } from "./session-outcome.js";
1
+ import { OcraError } from "@open-cr-agent/core";
2
+ import { emptyUsage, errorMessage, parseModel, } from "@open-cr-agent/core/internal";
3
+ import { sessionUsage, summarizeSession } from "./session-outcome.js";
4
4
  // A session that was cut off has still spent tokens and may have reported
5
5
  // findings; this bounds the one extra request that collects them.
6
6
  export const HARVEST_TIMEOUT_MS = 5_000;
@@ -26,7 +26,7 @@ export function stoppedEarly(outcome, resume) {
26
26
  export async function promptSession(session, input, reportTool, signal, activity = {}) {
27
27
  const created = await session.create({ title: input.title }, { signal });
28
28
  if (!created.data) {
29
- throw new Error(`OpenCode could not create a session: ${JSON.stringify(created.error)}`);
29
+ throw new OcraError("RUNTIME_FAILED", `OpenCode could not create a session: ${JSON.stringify(created.error)}`);
30
30
  }
31
31
  const sessionID = created.data.id;
32
32
  const stop = () => void session.abort({ sessionID }).catch(() => { });
@@ -46,20 +46,20 @@ export async function promptSession(session, input, reportTool, signal, activity
46
46
  // spent tokens and reported findings: keep them.
47
47
  if (response.error) {
48
48
  return {
49
- ...(await harvest(session, sessionID, reportTool)),
49
+ ...(await harvest(session, sessionID, reportTool, input.toolPrefix)),
50
50
  error: { message: JSON.stringify(response.error), retryable: false },
51
51
  };
52
52
  }
53
53
  let outcome;
54
54
  try {
55
55
  const messages = await session.messages({ sessionID }, { signal: attempt });
56
- outcome = summarizeSession((messages.data ?? []), reportTool);
56
+ outcome = summarizeSession((messages.data ?? []), reportTool, input.toolPrefix);
57
57
  }
58
58
  catch (error) {
59
59
  // The session finished; running it again on the next model would pay
60
60
  // twice. Keep what one more read gets, and do not retry.
61
61
  return {
62
- ...(await harvest(session, sessionID, reportTool)),
62
+ ...(await harvest(session, sessionID, reportTool, input.toolPrefix)),
63
63
  error: {
64
64
  message: `could not read the finished session: ${errorMessage(error)}`,
65
65
  retryable: false,
@@ -78,7 +78,7 @@ export async function promptSession(session, input, reportTool, signal, activity
78
78
  }, { signal: attempt });
79
79
  // Both turns are in the session: their findings and their spend.
80
80
  const resumed = {
81
- ...(await harvest(session, sessionID, reportTool)),
81
+ ...(await harvest(session, sessionID, reportTool, input.toolPrefix)),
82
82
  resumed: true,
83
83
  };
84
84
  if (again.error)
@@ -89,7 +89,7 @@ export async function promptSession(session, input, reportTool, signal, activity
89
89
  // Aborted or cut off by the transport: OpenCode may still be running the
90
90
  // session, spending tokens, so stop it and keep what it already did.
91
91
  stop();
92
- const partial = await harvest(session, sessionID, reportTool);
92
+ const partial = await harvest(session, sessionID, reportTool, input.toolPrefix);
93
93
  const seconds = Math.round((activity.inactivityMs ?? INACTIVITY_MS) / 1000);
94
94
  return {
95
95
  ...partial,
@@ -153,10 +153,10 @@ export function activitySignature(messages) {
153
153
  .map((m) => m.parts.map((p) => `${p.type}:${p.state?.status ?? ""}:${p.text?.length ?? 0}`).join(","))
154
154
  .join("|");
155
155
  }
156
- async function harvest(session, sessionID, reportTool) {
156
+ async function harvest(session, sessionID, reportTool, toolPrefix) {
157
157
  try {
158
158
  const messages = await session.messages({ sessionID }, { signal: AbortSignal.timeout(HARVEST_TIMEOUT_MS) });
159
- return summarizeSession((messages.data ?? []), reportTool);
159
+ return summarizeSession((messages.data ?? []), reportTool, toolPrefix);
160
160
  }
161
161
  catch {
162
162
  return emptyOutcome();
@@ -1,4 +1,4 @@
1
- import { type ReviewContext, type ToolDefinition } from "@open-cr-agent/core";
1
+ import type { ReviewContext, ToolDefinition } from "@open-cr-agent/core";
2
2
  export interface ToolServer {
3
3
  url: string;
4
4
  headers: Record<string, string>;
@@ -3,7 +3,7 @@ import { createServer } from "node:http";
3
3
  import { createRequire } from "node:module";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
- import { errorMessage } from "@open-cr-agent/core";
6
+ import { errorMessage } from "@open-cr-agent/core/internal";
7
7
  // src/ and dist/ both sit one level below the package root.
8
8
  const VERSION = createRequire(import.meta.url)("../package.json").version;
9
9
  export async function startToolServer(tools, context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/runtime-opencode",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode-based agent runtime for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",
@@ -19,11 +19,14 @@
19
19
  "node": ">=22.19"
20
20
  },
21
21
  "type": "module",
22
+ "sideEffects": false,
22
23
  "exports": {
23
24
  ".": {
25
+ "@open-cr-agent/source": "./src/index.ts",
24
26
  "types": "./dist/index.d.ts",
25
27
  "default": "./dist/index.js"
26
- }
28
+ },
29
+ "./package.json": "./package.json"
27
30
  },
28
31
  "files": [
29
32
  "dist",
@@ -31,7 +34,7 @@
31
34
  ],
32
35
  "dependencies": {
33
36
  "@modelcontextprotocol/sdk": "^1.30.1",
34
- "@open-cr-agent/core": "0.1.2",
37
+ "@open-cr-agent/core": "0.3.0",
35
38
  "@opencode-ai/sdk": "1.18.32",
36
39
  "opencode-ai": "1.18.32",
37
40
  "undici": "^8.11.2",
@@ -1,23 +0,0 @@
1
- import { type AgentEvent, type ModelTier, type Usage } from "@open-cr-agent/core";
2
- import type { ModelHealth } from "./models.js";
3
- import type { SessionOutcome } from "./session-outcome.js";
4
- export interface FailbackOptions {
5
- taskId: string;
6
- tier: ModelTier;
7
- chain: readonly string[];
8
- health: ModelHealth;
9
- signal: AbortSignal;
10
- attempt(model: string, onUsage: (spent: Usage) => void): Promise<SessionOutcome>;
11
- }
12
- export declare function withFailback(options: FailbackOptions): AsyncGenerator<AgentEvent>;
13
- export declare class LiveUsage {
14
- private seen;
15
- private given;
16
- private wake;
17
- observe(spent: Usage): void;
18
- changed(): Promise<void>;
19
- take(): Usage;
20
- rest(total: Usage): Usage;
21
- }
22
- export declare function toolSummary(toolCalls: readonly string[]): string;
23
- //# sourceMappingURL=failback.d.ts.map
package/dist/failback.js DELETED
@@ -1,139 +0,0 @@
1
- import { emptyUsage, REVIEW_TOOLS, } from "@open-cr-agent/core";
2
- import { sleep } from "./quota.js";
3
- // Findings from a failed attempt are still emitted: the pipeline deduplicates
4
- // by fingerprint, so a retry on the next model cannot double-report them.
5
- export async function* withFailback(options) {
6
- const { taskId, health, signal } = options;
7
- let lastError = "";
8
- for (const model of health.order(options.chain)) {
9
- for (;;) {
10
- if (signal.aborted)
11
- return;
12
- const pause = health.pausedFor(model);
13
- if (pause > 0) {
14
- yield {
15
- type: "progress",
16
- taskId,
17
- message: `${model} is rate limited; waiting ${Math.ceil(pause / 1000)}s`,
18
- };
19
- await sleep(pause, signal);
20
- if (signal.aborted)
21
- return;
22
- }
23
- yield { type: "progress", taskId, message: `reviewing with ${model}` };
24
- // Spend is reported while the attempt runs, so a run's spend limit
25
- // can stop it; the finished attempt's total settles the rest.
26
- const live = new LiveUsage();
27
- const running = options.attempt(model, (spent) => live.observe(spent));
28
- const finished = running.then(() => false, () => false);
29
- while (await Promise.race([finished, live.changed().then(() => true)])) {
30
- yield { type: "usage", taskId, ...live.take() };
31
- }
32
- const outcome = await running;
33
- yield { type: "usage", taskId, ...live.rest(outcome.usage) };
34
- yield { type: "progress", taskId, message: attemptSummary(model, outcome) };
35
- for (const finding of outcome.findings)
36
- yield { type: "finding", taskId, finding };
37
- if (!outcome.error) {
38
- health.recordSuccess(model);
39
- yield { type: "done", taskId };
40
- return;
41
- }
42
- if (!outcome.error.retryable) {
43
- yield {
44
- type: "error",
45
- taskId,
46
- error: `${model}: ${outcome.error.message}`,
47
- retryable: false,
48
- };
49
- return;
50
- }
51
- if (outcome.error.quota && health.recordQuota(model, outcome.error.quota) === "wait") {
52
- continue;
53
- }
54
- if (!outcome.error.quota)
55
- health.recordFailure(model);
56
- lastError = `${model}: ${outcome.error.message}`;
57
- yield { type: "progress", taskId, message: `${lastError}; trying the next model` };
58
- break;
59
- }
60
- }
61
- yield {
62
- type: "error",
63
- taskId,
64
- error: lastError
65
- ? `every ${options.tier} model failed (${lastError})`
66
- : `every ${options.tier} model is out of quota for this run`,
67
- retryable: true,
68
- };
69
- }
70
- // Hands out what an attempt has spent in increments, each what grew since the
71
- // last one; `rest` settles the finished attempt's total, so the increments
72
- // add up to it and nothing is counted twice.
73
- export class LiveUsage {
74
- seen = emptyUsage();
75
- given = emptyUsage();
76
- wake;
77
- observe(spent) {
78
- this.seen = larger(this.seen, spent);
79
- if (ahead(this.seen, this.given)) {
80
- this.wake?.();
81
- this.wake = undefined;
82
- }
83
- }
84
- // Resolves once more has been seen than handed out.
85
- changed() {
86
- if (ahead(this.seen, this.given))
87
- return Promise.resolve();
88
- return new Promise((resolve) => {
89
- this.wake = resolve;
90
- });
91
- }
92
- take() {
93
- const increment = beyond(this.seen, this.given);
94
- this.given = this.seen;
95
- return increment;
96
- }
97
- rest(total) {
98
- const settled = larger(total, this.given);
99
- const increment = beyond(settled, this.given);
100
- this.given = settled;
101
- return increment;
102
- }
103
- }
104
- const FIELDS = [
105
- "inputTokens",
106
- "outputTokens",
107
- "reasoningTokens",
108
- "cachedTokens",
109
- "costUsd",
110
- ];
111
- function larger(a, b) {
112
- return Object.fromEntries(FIELDS.map((f) => [f, Math.max(a[f], b[f])]));
113
- }
114
- function beyond(a, b) {
115
- return Object.fromEntries(FIELDS.map((f) => [f, Math.max(0, a[f] - b[f])]));
116
- }
117
- function ahead(a, b) {
118
- return FIELDS.some((f) => a[f] > b[f]);
119
- }
120
- // Which tools an attempt spent its steps on, and whether it finished: a
121
- // review that never called task_done was cut off, usually by the step cap.
122
- function attemptSummary(model, outcome) {
123
- const { inputTokens, outputTokens, reasoningTokens, costUsd } = outcome.usage;
124
- const resumed = outcome.resumed ? ", resumed after stopping early" : "";
125
- return `${model}: ${outcome.steps} step(s), ${toolSummary(outcome.toolCalls)}${resumed}, ${inputTokens} in / ${outputTokens} out / ${reasoningTokens} reasoning tokens, $${costUsd.toFixed(4)}`;
126
- }
127
- export function toolSummary(toolCalls) {
128
- if (toolCalls.length === 0)
129
- return "no tool calls";
130
- // MCP tools carry the server's name as a prefix (MCP_SERVER in runtime.ts).
131
- const names = toolCalls.map((t) => t.replace(/^ocra_/, ""));
132
- const counts = new Map();
133
- for (const name of names)
134
- counts.set(name, (counts.get(name) ?? 0) + 1);
135
- const byUse = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
136
- const finished = names.includes(REVIEW_TOOLS.taskDone) ? "" : "; no task_done";
137
- return `${toolCalls.length} tool call(s) (${byUse.map(([n, c]) => `${n} ${c}`).join(", ")}${finished})`;
138
- }
139
- //# sourceMappingURL=failback.js.map
package/dist/models.d.ts DELETED
@@ -1,30 +0,0 @@
1
- import { type QuotaError } from "./quota.js";
2
- export interface ModelRef {
3
- providerID: string;
4
- modelID: string;
5
- }
6
- export declare function parseModel(model: string): ModelRef;
7
- export interface CircuitOptions {
8
- threshold?: number;
9
- cooldownMs?: number;
10
- maxCooldownMs?: number;
11
- now?: () => number;
12
- }
13
- export declare class ModelHealth {
14
- private readonly circuits;
15
- private readonly quotas;
16
- private readonly outOfQuota;
17
- private readonly threshold;
18
- private readonly cooldownMs;
19
- private readonly maxCooldownMs;
20
- private readonly now;
21
- constructor(options?: CircuitOptions);
22
- order(chain: readonly string[]): string[];
23
- state(model: string): "closed" | "open" | "half-open";
24
- recordFailure(model: string): void;
25
- recordSuccess(model: string): void;
26
- pausedFor(model: string): number;
27
- isOutOfQuota(model: string): boolean;
28
- recordQuota(model: string, quota: QuotaError): "wait" | "out_of_quota";
29
- }
30
- //# sourceMappingURL=models.d.ts.map
package/dist/models.js DELETED
@@ -1,88 +0,0 @@
1
- import { MAX_QUOTA_WAIT_MS, QUOTA_RETRIES } from "./quota.js";
2
- export function parseModel(model) {
3
- const slash = model.indexOf("/");
4
- if (slash <= 0 || slash === model.length - 1) {
5
- throw new Error(`Model "${model}" must be written as provider/model, for example google/gemini-flash-lite-latest`);
6
- }
7
- return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
8
- }
9
- // A circuit breaker per model: after `threshold` consecutive failures the
10
- // model is skipped (open) for a cooldown, then one attempt is let through
11
- // (half-open). Success closes the circuit; failure reopens it for twice as
12
- // long, up to a limit. Tasks go straight to the fallback instead of paying
13
- // for a model that is down.
14
- export class ModelHealth {
15
- circuits = new Map();
16
- quotas = new Map();
17
- outOfQuota = new Set();
18
- threshold;
19
- cooldownMs;
20
- maxCooldownMs;
21
- now;
22
- constructor(options = {}) {
23
- this.threshold = options.threshold ?? 2;
24
- this.cooldownMs = options.cooldownMs ?? 60_000;
25
- this.maxCooldownMs = options.maxCooldownMs ?? 10 * 60_000;
26
- this.now = options.now ?? Date.now;
27
- }
28
- // Models whose circuit is closed or half-open, in chain order. When every
29
- // circuit is open, all models in the order they reopen: a review should
30
- // still try rather than fail without a request.
31
- // Models out of quota are left out entirely: another request would only be
32
- // refused again.
33
- order(chain) {
34
- const now = this.now();
35
- const usable = chain.filter((m) => !this.outOfQuota.has(m));
36
- const available = usable.filter((m) => (this.circuits.get(m)?.openUntil ?? 0) <= now);
37
- if (available.length > 0)
38
- return available;
39
- return [...usable].sort((a, b) => (this.circuits.get(a)?.openUntil ?? 0) - (this.circuits.get(b)?.openUntil ?? 0));
40
- }
41
- state(model) {
42
- const circuit = this.circuits.get(model);
43
- if (circuit?.openUntil === undefined)
44
- return "closed";
45
- return circuit.openUntil > this.now() ? "open" : "half-open";
46
- }
47
- recordFailure(model) {
48
- const circuit = this.circuits.get(model) ?? { failures: 0, cooldownMs: this.cooldownMs };
49
- const probing = this.state(model) === "half-open";
50
- circuit.failures += 1;
51
- if (probing || circuit.failures >= this.threshold) {
52
- circuit.openUntil = this.now() + circuit.cooldownMs;
53
- circuit.cooldownMs = Math.min(circuit.cooldownMs * 2, this.maxCooldownMs);
54
- circuit.failures = 0;
55
- }
56
- this.circuits.set(model, circuit);
57
- }
58
- recordSuccess(model) {
59
- this.circuits.delete(model);
60
- this.quotas.delete(model);
61
- }
62
- // How long every task should hold off this model (a shared rate-limit pause).
63
- pausedFor(model) {
64
- return Math.max(0, (this.quotas.get(model)?.pausedUntil ?? 0) - this.now());
65
- }
66
- isOutOfQuota(model) {
67
- return this.outOfQuota.has(model);
68
- }
69
- // A rate limit with a short, stated wait pauses the model for every task;
70
- // a daily limit, no stated wait, a long one, or too many waits in a row
71
- // mean the model is out of quota for the rest of the run.
72
- recordQuota(model, quota) {
73
- const state = this.quotas.get(model) ?? { waits: 0, pausedUntil: 0 };
74
- const wait = quota.retryAfterMs;
75
- if (quota.daily ||
76
- wait === undefined ||
77
- wait > MAX_QUOTA_WAIT_MS ||
78
- state.waits >= QUOTA_RETRIES) {
79
- this.outOfQuota.add(model);
80
- return "out_of_quota";
81
- }
82
- state.waits += 1;
83
- state.pausedUntil = Math.max(state.pausedUntil, this.now() + wait);
84
- this.quotas.set(model, state);
85
- return "wait";
86
- }
87
- }
88
- //# sourceMappingURL=models.js.map
package/dist/quota.d.ts DELETED
@@ -1,9 +0,0 @@
1
- export interface QuotaError {
2
- retryAfterMs?: number;
3
- daily: boolean;
4
- }
5
- export declare const MAX_QUOTA_WAIT_MS = 90000;
6
- export declare const QUOTA_RETRIES = 3;
7
- export declare function parseQuotaError(message: string, statusCode?: number): QuotaError | undefined;
8
- export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
9
- //# sourceMappingURL=quota.d.ts.map
package/dist/quota.js DELETED
@@ -1,38 +0,0 @@
1
- // Longest wait for a rate limit inside a run; per-minute limits ask for less.
2
- export const MAX_QUOTA_WAIT_MS = 90_000;
3
- // Waits per model before it counts as out of quota for the rest of the run.
4
- export const QUOTA_RETRIES = 3;
5
- const QUOTA_MESSAGE = /exceeded your current quota|quota exceeded|resource[_ ]exhausted|rate limit/i;
6
- const RETRY_IN = /retry in ([\d.]+)\s*(ms|s)\b/i;
7
- const RETRY_DELAY = /"retryDelay"\s*:\s*"([\d.]+)s"/i;
8
- const DAILY = /per ?day|daily/i;
9
- export function parseQuotaError(message, statusCode) {
10
- if (statusCode !== 429 && !QUOTA_MESSAGE.test(message))
11
- return undefined;
12
- const quota = { daily: DAILY.test(message) };
13
- const retryIn = RETRY_IN.exec(message);
14
- const delay = RETRY_DELAY.exec(message);
15
- if (retryIn) {
16
- const value = Number(retryIn[1]);
17
- quota.retryAfterMs = Math.ceil(retryIn[2]?.toLowerCase() === "ms" ? value : value * 1000);
18
- }
19
- else if (delay) {
20
- quota.retryAfterMs = Math.ceil(Number(delay[1]) * 1000);
21
- }
22
- return quota;
23
- }
24
- // Resolves after `ms`, or at once when the signal aborts.
25
- export function sleep(ms, signal) {
26
- if (signal.aborted || ms <= 0)
27
- return Promise.resolve();
28
- return new Promise((resolve) => {
29
- const done = () => {
30
- clearTimeout(timer);
31
- signal.removeEventListener("abort", done);
32
- resolve();
33
- };
34
- const timer = setTimeout(done, ms);
35
- signal.addEventListener("abort", done, { once: true });
36
- });
37
- }
38
- //# sourceMappingURL=quota.js.map
@@ -1,21 +0,0 @@
1
- import { type ToolDefinition } from "@open-cr-agent/core";
2
- import { z } from "zod";
3
- export declare const MAX_READ_LINES = 400;
4
- export declare const MAX_SEARCH_RESULTS = 50;
5
- export declare const MAX_LINE_CHARS = 2000;
6
- export declare const MAX_RESULT_CHARS = 50000;
7
- export declare const reportFindingInput: z.ZodObject<{
8
- file: z.ZodString;
9
- existingCode: z.ZodString;
10
- severity: z.ZodEnum<{
11
- critical: "critical";
12
- suggestion: "suggestion";
13
- warning: "warning";
14
- }>;
15
- title: z.ZodString;
16
- body: z.ZodString;
17
- suggestion: z.ZodOptional<z.ZodString>;
18
- evidence: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
- }, z.core.$strip>;
20
- export declare const reviewTools: readonly ToolDefinition[];
21
- //# sourceMappingURL=review-tools.d.ts.map
@@ -1,111 +0,0 @@
1
- import { promptData, REVIEW_TOOLS, severitySchema } from "@open-cr-agent/core";
2
- import { z } from "zod";
3
- export const MAX_READ_LINES = 400;
4
- export const MAX_SEARCH_RESULTS = 50;
5
- // Lines and results are also capped in characters: one line of a minified
6
- // bundle or a JSON fixture can be megabytes, and every step resends it.
7
- export const MAX_LINE_CHARS = 2_000;
8
- export const MAX_RESULT_CHARS = 50_000;
9
- function clip(line, max = MAX_LINE_CHARS) {
10
- return line.length > max ? `${line.slice(0, max)}…[${line.length - max} more characters]` : line;
11
- }
12
- // The whole lines that fit in the result cap.
13
- function fitting(lines) {
14
- const kept = [];
15
- let size = 0;
16
- for (const line of lines) {
17
- size += line.length + 1;
18
- if (size > MAX_RESULT_CHARS)
19
- break;
20
- kept.push(line);
21
- }
22
- return kept;
23
- }
24
- export const reportFindingInput = z.object({
25
- file: z.string().min(1).describe("Path of a file in <ocra_review_files>"),
26
- existingCode: z
27
- .string()
28
- .min(1)
29
- .describe("1-5 lines copied verbatim from the new version of the file"),
30
- severity: severitySchema,
31
- title: z.string().min(1),
32
- body: z.string().min(1),
33
- suggestion: z.string().optional(),
34
- evidence: z.array(z.string()).optional(),
35
- });
36
- // Tool results carry repository text back to the model, so they are data
37
- // like every prompt section: they cannot form one of ocra's tags.
38
- const readFile = {
39
- name: REVIEW_TOOLS.readFile,
40
- description: `Read a file at the revision under review, with line numbers. Returns at most ${MAX_READ_LINES} lines; use startLine to page.`,
41
- inputSchema: z.object({
42
- path: z.string().min(1),
43
- startLine: z.number().int().positive().optional(),
44
- }),
45
- async execute(args, context) {
46
- const { path, startLine = 1 } = args;
47
- const content = await context.readFile(path);
48
- if (content === undefined)
49
- return promptData(`File not found: ${path}`);
50
- const lines = content.split("\n");
51
- const end = Math.min(lines.length, startLine + MAX_READ_LINES - 1);
52
- const body = fitting(lines.slice(startLine - 1, end).map((line, i) => `${startLine + i}: ${clip(line)}`));
53
- const next = startLine + body.length;
54
- if (next <= lines.length) {
55
- body.push(`[truncated: ${lines.length - next + 1} more lines; call again with startLine=${next}]`);
56
- }
57
- return promptData(body.join("\n"));
58
- },
59
- };
60
- const readDiff = {
61
- name: REVIEW_TOOLS.readDiff,
62
- description: "Read the diff of any changed file in this change, including files outside your bundle.",
63
- inputSchema: z.object({ path: z.string().min(1) }),
64
- async execute(args, context) {
65
- const { path } = args;
66
- const diff = context.readDiff(path);
67
- if (diff === undefined)
68
- return promptData(`No changes to ${path} in this change.`);
69
- const lines = diff.split("\n").map((line) => clip(line));
70
- const kept = fitting(lines);
71
- if (kept.length < lines.length)
72
- kept.push(`[diff truncated after ${kept.length} lines]`);
73
- return promptData(kept.join("\n"));
74
- },
75
- };
76
- const codeSearch = {
77
- name: REVIEW_TOOLS.codeSearch,
78
- description: `Search the revision under review for a literal string (not a regex). Returns up to ${MAX_SEARCH_RESULTS} matches as path:line: text.`,
79
- inputSchema: z.object({ literal: z.string().min(2) }),
80
- async execute(args, context) {
81
- const { literal } = args;
82
- const matches = await context.searchCode(literal);
83
- if (matches.length === 0)
84
- return "No matches.";
85
- const shown = fitting(matches.slice(0, MAX_SEARCH_RESULTS).map((m) => `${m.path}:${m.line}: ${clip(m.text, 300)}`));
86
- if (shown.length < matches.length) {
87
- shown.push(`[${matches.length - shown.length} more matches omitted]`);
88
- }
89
- return promptData(shown.join("\n"));
90
- },
91
- };
92
- const reportFinding = {
93
- name: REVIEW_TOOLS.reportFinding,
94
- description: "Report one confirmed defect. Call once per issue.",
95
- inputSchema: reportFindingInput,
96
- execute: async () => "Recorded.",
97
- };
98
- const taskDone = {
99
- name: REVIEW_TOOLS.taskDone,
100
- description: "Call once every file in <ocra_review_files> has been reviewed.",
101
- inputSchema: z.object({}),
102
- execute: async () => "Done.",
103
- };
104
- export const reviewTools = [
105
- readFile,
106
- readDiff,
107
- codeSearch,
108
- reportFinding,
109
- taskDone,
110
- ];
111
- //# sourceMappingURL=review-tools.js.map