@open-cr-agent/runtime-opencode 0.2.0 → 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,4 +1,4 @@
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;
package/dist/binary.js CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
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
8
  // versions block the postinstall script that would otherwise place it. An
@@ -29,7 +30,7 @@ export function resolveOpencodeBinary(env, opencodePackage = createRequire(impor
29
30
  const placed = join(dirname(opencodePackage), "bin", "opencode.exe");
30
31
  if (isNativeExecutable(placed))
31
32
  return placed;
32
- throw new Error(`No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
33
+ throw new OcraError("RUNTIME_START_FAILED", `No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
33
34
  }
34
35
  // Until the install script replaces it, opencode-ai's bin/opencode.exe is a
35
36
  // shell script that only prints an error, so the file must be a real
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 CustomProvider, 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,7 +27,7 @@ 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>, custom?: Readonly<Record<string, CustomProvider>>): {
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
33
  provider?: {
@@ -41,6 +41,7 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
41
41
  models: {
42
42
  [k: string]: {
43
43
  name: string;
44
+ temperature?: boolean;
44
45
  cost: {
45
46
  input: number;
46
47
  output: number;
@@ -61,6 +62,21 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
61
62
  };
62
63
  agent: {
63
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;
64
80
  mode: string;
65
81
  prompt: string;
66
82
  steps: number;
@@ -75,6 +91,19 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
75
91
  };
76
92
  };
77
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;
78
107
  mode: string;
79
108
  prompt: string;
80
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,7 +128,15 @@ 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();
@@ -165,7 +146,7 @@ export class OpenCodeRuntime {
165
146
  const custom = this.options.providers ?? {};
166
147
  const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
167
148
  if (missing.length > 0)
168
- throw new Error(missing.join("; "));
149
+ throw new OcraError("CONFIG_CREDENTIALS_MISSING", missing.join("; "));
169
150
  const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
170
151
  const dirs = {
171
152
  config: join(root, "config"),
@@ -181,7 +162,7 @@ export class OpenCodeRuntime {
181
162
  binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
182
163
  cwd: workspace,
183
164
  env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
184
- config: openCodeConfig(tools, this.helperTools, custom),
165
+ config: openCodeConfig(tools, this.helperTools, custom, this.options.sampling),
185
166
  });
186
167
  started = server;
187
168
  const dispatcher = createUntimedDispatcher();
@@ -198,7 +179,8 @@ export class OpenCodeRuntime {
198
179
  rmSync(root, { recursive: true, force: true });
199
180
  };
200
181
  process.on("exit", onExit);
201
- 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 };
202
184
  }
203
185
  catch (error) {
204
186
  await Promise.allSettled([tools.close(), started?.close()]);
@@ -207,12 +189,26 @@ export class OpenCodeRuntime {
207
189
  }
208
190
  }
209
191
  }
210
- export function openCodeConfig(tools, helperTools, custom = {}) {
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 = {}) {
211
203
  const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
204
+ const { temperature } = sampling;
205
+ const agentSampling = temperature === undefined ? {} : { temperature };
212
206
  return {
213
207
  share: "disabled",
214
208
  autoupdate: false,
215
- ...(Object.keys(custom).length > 0 ? { provider: providerConfig(custom) } : {}),
209
+ ...(Object.keys(custom).length > 0
210
+ ? { provider: providerConfig(custom, temperature !== undefined) }
211
+ : {}),
216
212
  mcp: {
217
213
  [MCP_SERVER]: {
218
214
  type: "remote",
@@ -229,6 +225,7 @@ export function openCodeConfig(tools, helperTools, custom = {}) {
229
225
  steps: MAX_AGENT_STEPS,
230
226
  tools: DISABLED_BUILTINS,
231
227
  permission,
228
+ ...agentSampling,
232
229
  },
233
230
  [HELPER_AGENT]: {
234
231
  mode: "primary",
@@ -236,6 +233,7 @@ export function openCodeConfig(tools, helperTools, custom = {}) {
236
233
  steps: HELPER_AGENT_STEPS,
237
234
  tools: helperTools,
238
235
  permission,
236
+ ...agentSampling,
239
237
  },
240
238
  },
241
239
  };
@@ -245,7 +243,9 @@ export function openCodeConfig(tools, helperTools, custom = {}) {
245
243
  // (docs/spikes/0002). The key stays in the environment: OpenCode reads
246
244
  // {env:NAME} itself, and the configuration, which OpenCode may log, never
247
245
  // holds it. Prices are per million tokens, as OpenCode's catalog has them.
248
- function providerConfig(custom) {
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
249
  return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
250
250
  id,
251
251
  {
@@ -259,6 +259,7 @@ function providerConfig(custom) {
259
259
  model,
260
260
  {
261
261
  name: model,
262
+ ...(temperature ? { temperature: true } : {}),
262
263
  cost: {
263
264
  input: price.input,
264
265
  output: price.output,
@@ -7,6 +7,8 @@ export interface IsolatedDirs {
7
7
  type CustomProviders = Readonly<Record<string, CustomProvider>>;
8
8
  export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
9
9
  export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
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[];
10
12
  export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
11
13
  export {};
12
14
  //# sourceMappingURL=server-env.d.ts.map
@@ -117,43 +117,62 @@ const NEVER_BY_PREFIX = [
117
117
  "SSH_",
118
118
  "OCRA_",
119
119
  ];
120
- // The child sees only what it needs: system basics, the credentials of the
121
- // providers in the configured model chains (for a provider declared in
122
- // configuration, only the variable it names), and names listed in
123
- // OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
124
- export function serverEnv(base, dirs, providers, custom = {}) {
125
- const env = {};
126
- const copy = (name) => {
127
- const value = base[name];
128
- if (value !== undefined)
129
- env[name] = value;
130
- };
131
- for (const name of SYSTEM_VARIABLES)
132
- copy(name);
133
- for (const name of Object.keys(base))
134
- if (name.startsWith("LC_"))
135
- copy(name);
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 = [];
136
126
  for (const provider of new Set(providers)) {
137
127
  const declared = custom[provider];
138
128
  if (declared) {
139
129
  if (declared.apiKeyEnv)
140
- copy(declared.apiKeyEnv);
130
+ names.push(declared.apiKeyEnv);
141
131
  continue;
142
132
  }
143
133
  const known = PROVIDER_VARIABLES[provider];
144
134
  if (known) {
145
- for (const name of known)
146
- copy(name);
135
+ names.push(...known);
147
136
  }
148
137
  else {
149
138
  const prefix = `${provider.toUpperCase().replaceAll("-", "_")}_`;
150
139
  for (const name of Object.keys(base)) {
151
140
  if (name.startsWith(prefix) && !NEVER_BY_PREFIX.some((p) => name.startsWith(p))) {
152
- copy(name);
141
+ names.push(name);
153
142
  }
154
143
  }
155
144
  }
156
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
+ }
158
+ // The child sees only what it needs: system basics, the credentials of the
159
+ // providers in the configured model chains (for a provider declared in
160
+ // configuration, only the variable it names), and names listed in
161
+ // OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
162
+ export function serverEnv(base, dirs, providers, custom = {}) {
163
+ const env = {};
164
+ const copy = (name) => {
165
+ const value = base[name];
166
+ if (value !== undefined)
167
+ env[name] = value;
168
+ };
169
+ for (const name of SYSTEM_VARIABLES)
170
+ copy(name);
171
+ for (const name of Object.keys(base))
172
+ if (name.startsWith("LC_"))
173
+ copy(name);
174
+ for (const name of credentialNames(base, providers, custom))
175
+ copy(name);
157
176
  for (const name of (base[EXTRA_ENV_VARIABLE] ?? "").split(",")) {
158
177
  if (name.trim() !== "")
159
178
  copy(name.trim());
@@ -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.2.0",
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.2.0",
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