@open-cr-agent/runtime-opencode 0.2.0 → 0.4.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/README.md +1 -1
- package/dist/binary.d.ts +1 -1
- package/dist/binary.js +2 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -3
- package/dist/opencode-server.js +2 -1
- package/dist/runtime.d.ts +33 -4
- package/dist/runtime.js +60 -59
- package/dist/server-env.d.ts +2 -0
- package/dist/server-env.js +39 -20
- package/dist/session-outcome.d.ts +2 -15
- package/dist/session-outcome.js +5 -3
- package/dist/session-prompt.d.ts +6 -4
- package/dist/session-prompt.js +11 -11
- package/dist/tool-server.d.ts +1 -1
- package/dist/tool-server.js +1 -1
- package/package.json +7 -4
- package/dist/failback.d.ts +0 -23
- package/dist/failback.js +0 -139
- package/dist/models.d.ts +0 -30
- package/dist/models.js +0 -88
- package/dist/quota.d.ts +0 -9
- package/dist/quota.js +0 -38
- package/dist/review-tools.d.ts +0 -21
- package/dist/review-tools.js +0 -111
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# @open-cr-agent/runtime-opencode
|
|
2
2
|
|
|
3
|
-
The agent runtime backed by [OpenCode](https://opencode.ai) of [Open-CR-Agent](https://github.com/jma49/Open-CR-Agent), the multi-agent code reviewer. Most users want [`@open-cr-agent/cli`](https://www.npmjs.com/package/@open-cr-agent/cli), which provides the `ocra` command and installs this package; see the [manual](https://
|
|
3
|
+
The agent runtime backed by [OpenCode](https://opencode.ai) of [Open-CR-Agent](https://github.com/jma49/Open-CR-Agent), the multi-agent code reviewer. Most users want [`@open-cr-agent/cli`](https://www.npmjs.com/package/@open-cr-agent/cli), which provides the `ocra` command and installs this package; see the [manual](https://ocracloud.com). All `@open-cr-agent` packages are released together at one version. Apache-2.0.
|
package/dist/binary.d.ts
CHANGED
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
|
|
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
package/dist/index.js
CHANGED
package/dist/opencode-server.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 {
|
|
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 {
|
|
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:
|
|
26
|
+
doneTool: REVIEW_TOOLS.taskDone,
|
|
36
27
|
maxSteps: MAX_AGENT_STEPS,
|
|
37
|
-
message:
|
|
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
|
|
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
|
|
103
|
+
throw new OcraError("CONFIG_INVALID", noModel(request.tier));
|
|
110
104
|
const infra = await this.start();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/dist/server-env.d.ts
CHANGED
|
@@ -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
|
package/dist/server-env.js
CHANGED
|
@@ -117,43 +117,62 @@ const NEVER_BY_PREFIX = [
|
|
|
117
117
|
"SSH_",
|
|
118
118
|
"OCRA_",
|
|
119
119
|
];
|
|
120
|
-
// The
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
export function
|
|
125
|
-
const
|
|
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
|
-
|
|
130
|
+
names.push(declared.apiKeyEnv);
|
|
141
131
|
continue;
|
|
142
132
|
}
|
|
143
133
|
const known = PROVIDER_VARIABLES[provider];
|
|
144
134
|
if (known) {
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/session-outcome.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { parseQuotaError } from "
|
|
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
|
-
|
|
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")
|
package/dist/session-prompt.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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<
|
|
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():
|
|
32
|
+
export declare function emptyOutcome(): AttemptOutcome;
|
|
31
33
|
export {};
|
|
32
34
|
//# sourceMappingURL=session-prompt.d.ts.map
|
package/dist/session-prompt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parseModel } from "
|
|
3
|
-
import { sessionUsage, summarizeSession
|
|
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
|
|
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();
|
package/dist/tool-server.d.ts
CHANGED
package/dist/tool-server.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "OpenCode-based agent runtime for Open-CR-Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"code-review",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"agents"
|
|
9
9
|
],
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://ocracloud.com",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
14
|
"url": "git+https://github.com/jma49/Open-CR-Agent.git",
|
|
@@ -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.
|
|
37
|
+
"@open-cr-agent/core": "0.4.0",
|
|
35
38
|
"@opencode-ai/sdk": "1.18.32",
|
|
36
39
|
"opencode-ai": "1.18.32",
|
|
37
40
|
"undici": "^8.11.2",
|
package/dist/failback.d.ts
DELETED
|
@@ -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
|
package/dist/review-tools.d.ts
DELETED
|
@@ -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
|
package/dist/review-tools.js
DELETED
|
@@ -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
|