@open-cr-agent/runtime-opencode 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/binary.d.ts +3 -2
- package/dist/binary.js +35 -5
- 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 +53 -4
- package/dist/runtime.js +91 -59
- package/dist/server-env.d.ts +7 -3
- package/dist/server-env.js +67 -21
- 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 +6 -3
- 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/dist/binary.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Env } from "@open-cr-agent/core";
|
|
2
2
|
export interface Host {
|
|
3
3
|
platform: string;
|
|
4
4
|
arch: string;
|
|
5
5
|
musl: boolean;
|
|
6
6
|
baseline: boolean;
|
|
7
7
|
}
|
|
8
|
-
export declare function resolveOpencodeBinary(env: Env): string;
|
|
8
|
+
export declare function resolveOpencodeBinary(env: Env, opencodePackage?: string): string;
|
|
9
|
+
export declare function isNativeExecutable(path: string): boolean;
|
|
9
10
|
export declare function binaryVariants(host: Host): string[];
|
|
10
11
|
//# sourceMappingURL=binary.d.ts.map
|
package/dist/binary.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
5
6
|
const PLATFORMS = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
6
7
|
// Resolved from opencode-ai's platform packages directly, because newer npm
|
|
7
|
-
// versions block the postinstall script that would otherwise place it.
|
|
8
|
-
|
|
8
|
+
// versions block the postinstall script that would otherwise place it. An
|
|
9
|
+
// install without optional dependencies has no platform package, but where
|
|
10
|
+
// that script ran, it downloaded the binary into opencode-ai's own bin/.
|
|
11
|
+
export function resolveOpencodeBinary(env, opencodePackage = createRequire(import.meta.url).resolve("opencode-ai/package.json")) {
|
|
9
12
|
const override = env.OCRA_OPENCODE_BIN;
|
|
10
13
|
// OpenCode runs in a directory of its own, where a relative path would
|
|
11
14
|
// not resolve; a bare name is still looked up in PATH.
|
|
@@ -13,7 +16,7 @@ export function resolveOpencodeBinary(env) {
|
|
|
13
16
|
return /[\\/]/.test(override) ? resolve(override) : override;
|
|
14
17
|
const platform = PLATFORMS[process.platform] ?? process.platform;
|
|
15
18
|
const executable = platform === "windows" ? "opencode.exe" : "opencode";
|
|
16
|
-
const fromOpencode = createRequire(
|
|
19
|
+
const fromOpencode = createRequire(opencodePackage);
|
|
17
20
|
for (const variant of binaryVariants(currentHost(platform))) {
|
|
18
21
|
try {
|
|
19
22
|
const pkg = fromOpencode.resolve(`opencode-${platform}-${process.arch}${variant}/package.json`);
|
|
@@ -23,7 +26,34 @@ export function resolveOpencodeBinary(env) {
|
|
|
23
26
|
}
|
|
24
27
|
catch { }
|
|
25
28
|
}
|
|
26
|
-
|
|
29
|
+
// The script names the binary opencode.exe on every platform.
|
|
30
|
+
const placed = join(dirname(opencodePackage), "bin", "opencode.exe");
|
|
31
|
+
if (isNativeExecutable(placed))
|
|
32
|
+
return placed;
|
|
33
|
+
throw new OcraError("RUNTIME_START_FAILED", `No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
|
|
34
|
+
}
|
|
35
|
+
// Until the install script replaces it, opencode-ai's bin/opencode.exe is a
|
|
36
|
+
// shell script that only prints an error, so the file must be a real
|
|
37
|
+
// executable: ELF, Mach-O (either byte order, or universal) or Windows PE.
|
|
38
|
+
export function isNativeExecutable(path) {
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(path, "r");
|
|
42
|
+
const head = Buffer.alloc(4);
|
|
43
|
+
if (readSync(fd, head, 0, 4, 0) < 4)
|
|
44
|
+
return false;
|
|
45
|
+
const magic = head.readUInt32BE(0);
|
|
46
|
+
return (magic === 0x7f454c46 ||
|
|
47
|
+
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(magic) ||
|
|
48
|
+
head.subarray(0, 2).toString("latin1") === "MZ");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (fd !== undefined)
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
27
57
|
}
|
|
28
58
|
// The builds to try, best first. npm installs every build whose os and cpu
|
|
29
59
|
// match, and the glibc builds declare no libc, so musl Linux has both kinds;
|
package/dist/index.d.ts
CHANGED
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 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,9 +27,30 @@ export declare class OpenCodeRuntime implements AgentRuntime {
|
|
|
27
27
|
private start;
|
|
28
28
|
private launch;
|
|
29
29
|
}
|
|
30
|
-
export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean
|
|
30
|
+
export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>, sampling?: Sampling): {
|
|
31
31
|
share: string;
|
|
32
32
|
autoupdate: boolean;
|
|
33
|
+
provider?: {
|
|
34
|
+
[k: string]: {
|
|
35
|
+
npm: string;
|
|
36
|
+
name: string;
|
|
37
|
+
options: {
|
|
38
|
+
baseURL: string;
|
|
39
|
+
apiKey?: string;
|
|
40
|
+
};
|
|
41
|
+
models: {
|
|
42
|
+
[k: string]: {
|
|
43
|
+
name: string;
|
|
44
|
+
temperature?: boolean;
|
|
45
|
+
cost: {
|
|
46
|
+
input: number;
|
|
47
|
+
output: number;
|
|
48
|
+
cache_read?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
};
|
|
33
54
|
mcp: {
|
|
34
55
|
ocra: {
|
|
35
56
|
type: string;
|
|
@@ -41,6 +62,21 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
|
|
|
41
62
|
};
|
|
42
63
|
agent: {
|
|
43
64
|
"ocra-reviewer": {
|
|
65
|
+
temperature?: never;
|
|
66
|
+
mode: string;
|
|
67
|
+
prompt: string;
|
|
68
|
+
steps: number;
|
|
69
|
+
tools: {
|
|
70
|
+
[k: string]: boolean;
|
|
71
|
+
};
|
|
72
|
+
permission: {
|
|
73
|
+
edit: string;
|
|
74
|
+
bash: string;
|
|
75
|
+
webfetch: string;
|
|
76
|
+
skill: string;
|
|
77
|
+
};
|
|
78
|
+
} | {
|
|
79
|
+
temperature: number;
|
|
44
80
|
mode: string;
|
|
45
81
|
prompt: string;
|
|
46
82
|
steps: number;
|
|
@@ -55,6 +91,19 @@ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers"
|
|
|
55
91
|
};
|
|
56
92
|
};
|
|
57
93
|
"ocra-helper": {
|
|
94
|
+
temperature?: never;
|
|
95
|
+
mode: string;
|
|
96
|
+
prompt: string;
|
|
97
|
+
steps: number;
|
|
98
|
+
tools: Record<string, boolean>;
|
|
99
|
+
permission: {
|
|
100
|
+
edit: string;
|
|
101
|
+
bash: string;
|
|
102
|
+
webfetch: string;
|
|
103
|
+
skill: string;
|
|
104
|
+
};
|
|
105
|
+
} | {
|
|
106
|
+
temperature: number;
|
|
58
107
|
mode: string;
|
|
59
108
|
prompt: string;
|
|
60
109
|
steps: number;
|
package/dist/runtime.js
CHANGED
|
@@ -2,15 +2,12 @@ import { rmSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
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,16 +128,25 @@ export class OpenCodeRuntime {
|
|
|
155
128
|
await rm(infra.root, { recursive: true, force: true });
|
|
156
129
|
}
|
|
157
130
|
prompt(infra, input, signal, onUsage) {
|
|
158
|
-
return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {})
|
|
131
|
+
return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {}).then((outcome) => outcome.error
|
|
132
|
+
? {
|
|
133
|
+
...outcome,
|
|
134
|
+
error: {
|
|
135
|
+
...outcome.error,
|
|
136
|
+
message: withoutSecrets(outcome.error.message, infra.secrets),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: outcome);
|
|
159
140
|
}
|
|
160
141
|
start() {
|
|
161
142
|
this.infra ??= this.launch();
|
|
162
143
|
return this.infra;
|
|
163
144
|
}
|
|
164
145
|
async launch() {
|
|
165
|
-
const
|
|
146
|
+
const custom = this.options.providers ?? {};
|
|
147
|
+
const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
|
|
166
148
|
if (missing.length > 0)
|
|
167
|
-
throw new
|
|
149
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", missing.join("; "));
|
|
168
150
|
const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
|
|
169
151
|
const dirs = {
|
|
170
152
|
config: join(root, "config"),
|
|
@@ -179,8 +161,8 @@ export class OpenCodeRuntime {
|
|
|
179
161
|
const server = await startOpencodeServer({
|
|
180
162
|
binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
|
|
181
163
|
cwd: workspace,
|
|
182
|
-
env: serverEnv(this.options.env, dirs, providersOf(this.options.models)),
|
|
183
|
-
config: openCodeConfig(tools, this.helperTools),
|
|
164
|
+
env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
|
|
165
|
+
config: openCodeConfig(tools, this.helperTools, custom, this.options.sampling),
|
|
184
166
|
});
|
|
185
167
|
started = server;
|
|
186
168
|
const dispatcher = createUntimedDispatcher();
|
|
@@ -197,7 +179,8 @@ export class OpenCodeRuntime {
|
|
|
197
179
|
rmSync(root, { recursive: true, force: true });
|
|
198
180
|
};
|
|
199
181
|
process.on("exit", onExit);
|
|
200
|
-
|
|
182
|
+
const secrets = credentialValues(this.options.env, providersOf(this.options.models), custom);
|
|
183
|
+
return { root, tools, server, client, dispatcher, onExit, secrets };
|
|
201
184
|
}
|
|
202
185
|
catch (error) {
|
|
203
186
|
await Promise.allSettled([tools.close(), started?.close()]);
|
|
@@ -206,11 +189,26 @@ export class OpenCodeRuntime {
|
|
|
206
189
|
}
|
|
207
190
|
}
|
|
208
191
|
}
|
|
209
|
-
|
|
192
|
+
// OpenCode 1.18.32 takes a temperature per agent and sends it when the
|
|
193
|
+
// model's catalog entry says the model accepts one; a model declared in
|
|
194
|
+
// configuration is marked so (providerConfig). It has no seed setting: the
|
|
195
|
+
// request it builds carries temperature, topP, topK and the output limit only.
|
|
196
|
+
function openCodeSampling(sampling = {}) {
|
|
197
|
+
return {
|
|
198
|
+
...(sampling.temperature === undefined ? {} : { temperature: sampling.temperature }),
|
|
199
|
+
...(sampling.seed === undefined ? {} : { notApplied: ["seed"] }),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export function openCodeConfig(tools, helperTools, custom = {}, sampling = {}) {
|
|
210
203
|
const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
|
|
204
|
+
const { temperature } = sampling;
|
|
205
|
+
const agentSampling = temperature === undefined ? {} : { temperature };
|
|
211
206
|
return {
|
|
212
207
|
share: "disabled",
|
|
213
208
|
autoupdate: false,
|
|
209
|
+
...(Object.keys(custom).length > 0
|
|
210
|
+
? { provider: providerConfig(custom, temperature !== undefined) }
|
|
211
|
+
: {}),
|
|
214
212
|
mcp: {
|
|
215
213
|
[MCP_SERVER]: {
|
|
216
214
|
type: "remote",
|
|
@@ -227,6 +225,7 @@ export function openCodeConfig(tools, helperTools) {
|
|
|
227
225
|
steps: MAX_AGENT_STEPS,
|
|
228
226
|
tools: DISABLED_BUILTINS,
|
|
229
227
|
permission,
|
|
228
|
+
...agentSampling,
|
|
230
229
|
},
|
|
231
230
|
[HELPER_AGENT]: {
|
|
232
231
|
mode: "primary",
|
|
@@ -234,10 +233,43 @@ export function openCodeConfig(tools, helperTools) {
|
|
|
234
233
|
steps: HELPER_AGENT_STEPS,
|
|
235
234
|
tools: helperTools,
|
|
236
235
|
permission,
|
|
236
|
+
...agentSampling,
|
|
237
237
|
},
|
|
238
238
|
},
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
|
+
// OpenCode's form of a provider declared in configuration. OpenCode bundles
|
|
242
|
+
// the OpenAI-compatible client, so nothing is installed to reach it
|
|
243
|
+
// (docs/spikes/0002). The key stays in the environment: OpenCode reads
|
|
244
|
+
// {env:NAME} itself, and the configuration, which OpenCode may log, never
|
|
245
|
+
// holds it. Prices are per million tokens, as OpenCode's catalog has them.
|
|
246
|
+
// OpenCode assumes a declared model takes no temperature and drops one;
|
|
247
|
+
// with a temperature configured, the models are marked as taking it.
|
|
248
|
+
function providerConfig(custom, temperature) {
|
|
249
|
+
return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
|
|
250
|
+
id,
|
|
251
|
+
{
|
|
252
|
+
npm: "@ai-sdk/openai-compatible",
|
|
253
|
+
name: id,
|
|
254
|
+
options: {
|
|
255
|
+
baseURL: provider.baseUrl,
|
|
256
|
+
...(provider.apiKeyEnv ? { apiKey: `{env:${provider.apiKeyEnv}}` } : {}),
|
|
257
|
+
},
|
|
258
|
+
models: Object.fromEntries(Object.entries(provider.models).map(([model, price]) => [
|
|
259
|
+
model,
|
|
260
|
+
{
|
|
261
|
+
name: model,
|
|
262
|
+
...(temperature ? { temperature: true } : {}),
|
|
263
|
+
cost: {
|
|
264
|
+
input: price.input,
|
|
265
|
+
output: price.output,
|
|
266
|
+
...(price.cachedInput === undefined ? {} : { cache_read: price.cachedInput }),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
])),
|
|
270
|
+
},
|
|
271
|
+
]));
|
|
272
|
+
}
|
|
241
273
|
function providersOf(models) {
|
|
242
274
|
return Object.values(models).flatMap((chain) => (chain ?? []).map((m) => parseModel(m).providerID));
|
|
243
275
|
}
|
package/dist/server-env.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type { Env } from "@open-cr-agent/core";
|
|
1
|
+
import type { CustomProvider, Env } from "@open-cr-agent/core";
|
|
2
2
|
export interface IsolatedDirs {
|
|
3
3
|
config: string;
|
|
4
4
|
data: string;
|
|
5
5
|
state: string;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
type CustomProviders = Readonly<Record<string, CustomProvider>>;
|
|
8
|
+
export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
|
|
8
9
|
export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
|
|
9
|
-
export declare function
|
|
10
|
+
export declare function credentialNames(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
|
|
11
|
+
export declare function credentialValues(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
|
|
12
|
+
export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
|
|
13
|
+
export {};
|
|
10
14
|
//# sourceMappingURL=server-env.d.ts.map
|
package/dist/server-env.js
CHANGED
|
@@ -73,8 +73,13 @@ const REQUIRED_KEYS = {
|
|
|
73
73
|
xai: ["XAI_API_KEY"],
|
|
74
74
|
azure: ["AZURE_API_KEY"],
|
|
75
75
|
};
|
|
76
|
-
export function missingCredentials(base, providers) {
|
|
76
|
+
export function missingCredentials(base, providers, custom = {}) {
|
|
77
77
|
return [...new Set(providers)].flatMap((provider) => {
|
|
78
|
+
const declared = custom[provider];
|
|
79
|
+
if (declared) {
|
|
80
|
+
const name = declared.apiKeyEnv;
|
|
81
|
+
return name && !base[name] ? [`No API key for provider "${provider}": set ${name}`] : [];
|
|
82
|
+
}
|
|
78
83
|
const names = REQUIRED_KEYS[provider];
|
|
79
84
|
if (!names || names.some((name) => base[name]))
|
|
80
85
|
return [];
|
|
@@ -84,24 +89,77 @@ export function missingCredentials(base, providers) {
|
|
|
84
89
|
const GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY";
|
|
85
90
|
const GOOGLE_KEY_ALIASES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
|
|
86
91
|
export const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
|
|
87
|
-
//
|
|
88
|
-
//
|
|
92
|
+
// OpenCode installs its plugin package from npm into every config directory
|
|
93
|
+
// when it starts, and the SDK of a provider it does not bundle when a model
|
|
94
|
+
// first uses it; no flag turns either off. ocra loads no OpenCode plugins and
|
|
95
|
+
// supports only bundled providers, so both installs get the discard port on
|
|
96
|
+
// this machine as their registry, and no retries: they fail at once, no code
|
|
97
|
+
// is fetched at review time and no .npmrc token is sent (docs/spikes/0002).
|
|
98
|
+
const NPM_SETTINGS = {
|
|
99
|
+
npm_config_registry: "http://127.0.0.1:9/",
|
|
100
|
+
NPM_CONFIG_REGISTRY: "http://127.0.0.1:9/",
|
|
101
|
+
npm_config_fetch_retries: "0",
|
|
102
|
+
};
|
|
103
|
+
// A model chain naming a provider such as "github", "gitlab" or "aws" must
|
|
104
|
+
// not carry the CI's or the cloud's credentials across by prefix (GitLab CI
|
|
105
|
+
// puts CI_JOB_TOKEN and CI_REGISTRY_PASSWORD in every job); name them in
|
|
89
106
|
// OCRA_RUNTIME_ENV when they are really meant for a provider.
|
|
90
107
|
const NEVER_BY_PREFIX = [
|
|
91
108
|
"GITHUB_",
|
|
92
109
|
"GH_",
|
|
93
110
|
"ACTIONS_",
|
|
94
111
|
"RUNNER_",
|
|
112
|
+
"GITLAB_",
|
|
113
|
+
"CI_",
|
|
95
114
|
"AWS_",
|
|
96
115
|
"AZURE_",
|
|
97
116
|
"NPM_",
|
|
98
117
|
"SSH_",
|
|
99
118
|
"OCRA_",
|
|
100
119
|
];
|
|
120
|
+
// The variables that carry the credentials of the configured providers: for
|
|
121
|
+
// a provider declared in configuration only the one it names, for a known
|
|
122
|
+
// catalog provider its variables, for any other every variable with its
|
|
123
|
+
// prefix that is not a platform token.
|
|
124
|
+
export function credentialNames(base, providers, custom = {}) {
|
|
125
|
+
const names = [];
|
|
126
|
+
for (const provider of new Set(providers)) {
|
|
127
|
+
const declared = custom[provider];
|
|
128
|
+
if (declared) {
|
|
129
|
+
if (declared.apiKeyEnv)
|
|
130
|
+
names.push(declared.apiKeyEnv);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const known = PROVIDER_VARIABLES[provider];
|
|
134
|
+
if (known) {
|
|
135
|
+
names.push(...known);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const prefix = `${provider.toUpperCase().replaceAll("-", "_")}_`;
|
|
139
|
+
for (const name of Object.keys(base)) {
|
|
140
|
+
if (name.startsWith(prefix) && !NEVER_BY_PREFIX.some((p) => name.startsWith(p))) {
|
|
141
|
+
names.push(name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return names;
|
|
147
|
+
}
|
|
148
|
+
// The credential values themselves, to redact from what a provider says.
|
|
149
|
+
// Values shorter than a key could be ordinary words and are left alone.
|
|
150
|
+
export function credentialValues(base, providers, custom = {}) {
|
|
151
|
+
const names = credentialNames(base, providers, custom);
|
|
152
|
+
if (providers.includes("google") && !custom.google)
|
|
153
|
+
names.push(...GOOGLE_KEY_ALIASES);
|
|
154
|
+
return names
|
|
155
|
+
.map((name) => base[name])
|
|
156
|
+
.filter((value) => value !== undefined && value.length >= 8);
|
|
157
|
+
}
|
|
101
158
|
// The child sees only what it needs: system basics, the credentials of the
|
|
102
|
-
// providers in the configured model chains
|
|
159
|
+
// providers in the configured model chains (for a provider declared in
|
|
160
|
+
// configuration, only the variable it names), and names listed in
|
|
103
161
|
// OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
|
|
104
|
-
export function serverEnv(base, dirs, providers) {
|
|
162
|
+
export function serverEnv(base, dirs, providers, custom = {}) {
|
|
105
163
|
const env = {};
|
|
106
164
|
const copy = (name) => {
|
|
107
165
|
const value = base[name];
|
|
@@ -113,32 +171,20 @@ export function serverEnv(base, dirs, providers) {
|
|
|
113
171
|
for (const name of Object.keys(base))
|
|
114
172
|
if (name.startsWith("LC_"))
|
|
115
173
|
copy(name);
|
|
116
|
-
for (const
|
|
117
|
-
|
|
118
|
-
if (known) {
|
|
119
|
-
for (const name of known)
|
|
120
|
-
copy(name);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
const prefix = `${provider.toUpperCase().replaceAll("-", "_")}_`;
|
|
124
|
-
for (const name of Object.keys(base)) {
|
|
125
|
-
if (name.startsWith(prefix) && !NEVER_BY_PREFIX.some((p) => name.startsWith(p))) {
|
|
126
|
-
copy(name);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
174
|
+
for (const name of credentialNames(base, providers, custom))
|
|
175
|
+
copy(name);
|
|
131
176
|
for (const name of (base[EXTRA_ENV_VARIABLE] ?? "").split(",")) {
|
|
132
177
|
if (name.trim() !== "")
|
|
133
178
|
copy(name.trim());
|
|
134
179
|
}
|
|
135
|
-
if (providers.includes("google") && !env[GOOGLE_KEY]) {
|
|
180
|
+
if (providers.includes("google") && !custom.google && !env[GOOGLE_KEY]) {
|
|
136
181
|
const alias = GOOGLE_KEY_ALIASES.map((name) => base[name]).find((value) => value);
|
|
137
182
|
if (alias)
|
|
138
183
|
env[GOOGLE_KEY] = alias;
|
|
139
184
|
}
|
|
140
185
|
for (const flag of ISOLATION_FLAGS)
|
|
141
186
|
env[flag] = "1";
|
|
187
|
+
Object.assign(env, NPM_SETTINGS);
|
|
142
188
|
env.OPENCODE_CONFIG_DIR = dirs.config;
|
|
143
189
|
env.XDG_CONFIG_HOME = dirs.config;
|
|
144
190
|
env.XDG_DATA_HOME = dirs.data;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Usage } from "@open-cr-agent/core";
|
|
2
|
-
import { type
|
|
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.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.
|
|
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",
|
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
|