@open-cr-agent/runtime-opencode 0.4.0 → 0.6.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.
@@ -0,0 +1,14 @@
1
+ import type { CustomProvider } from "@open-cr-agent/core";
2
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
3
+ import { EffortRoutes } from "./effort.js";
4
+ type ConfigApi = Pick<OpencodeClient["config"], "providers">;
5
+ export interface EffortSetup {
6
+ models: readonly string[];
7
+ custom?: Readonly<Record<string, CustomProvider>>;
8
+ probe: ConfigApi;
9
+ workspace: ConfigApi;
10
+ file: string;
11
+ }
12
+ export declare function setUpEfforts(setup: EffortSetup): Promise<EffortRoutes>;
13
+ export {};
14
+ //# sourceMappingURL=effort-setup.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { EffortRoutes } from "./effort.js";
3
+ const CATALOG_TIMEOUT_MS = 30_000;
4
+ // The variants derive thinking budgets from the models' output limits,
5
+ // which only OpenCode's catalog knows, and the catalog is loaded only once
6
+ // OpenCode runs: the variants are written after it started and before the
7
+ // workspace is first used. Any failure leaves effort unsent, with a warning
8
+ // per agent, and never fails the review.
9
+ export async function setUpEfforts(setup) {
10
+ const { models, custom = {} } = setup;
11
+ const everyModel = new Map(models.map((m) => [m, 0]));
12
+ if (new EffortRoutes(models, custom, everyModel).empty)
13
+ return new EffortRoutes([]);
14
+ try {
15
+ const catalog = await catalogModels(setup.probe);
16
+ const routes = new EffortRoutes(models, custom, new Map([...catalog].map(([model, entry]) => [model, entry.outputLimit])));
17
+ if (routes.empty)
18
+ return routes;
19
+ await writeFile(setup.file, JSON.stringify({ provider: routes.providerConfig() }), {
20
+ mode: 0o600,
21
+ });
22
+ const loaded = await catalogModels(setup.workspace);
23
+ routes.keepLoaded(new Map([...loaded].map(([model, entry]) => [model, entry.variants])));
24
+ return routes;
25
+ }
26
+ catch {
27
+ return new EffortRoutes([]);
28
+ }
29
+ }
30
+ async function catalogModels(config) {
31
+ const answer = await config.providers({}, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
32
+ const models = new Map();
33
+ for (const provider of answer.data?.providers ?? []) {
34
+ for (const [id, model] of Object.entries(provider.models)) {
35
+ models.set(`${provider.id}/${id}`, {
36
+ outputLimit: model.limit?.output ?? 0,
37
+ variants: new Set(Object.keys(model.variants ?? {})),
38
+ });
39
+ }
40
+ }
41
+ return models;
42
+ }
43
+ //# sourceMappingURL=effort-setup.js.map
@@ -0,0 +1,27 @@
1
+ import { type AppliedSettings, type CustomProvider, type Effort, type Sampling } from "@open-cr-agent/core";
2
+ type Options = Record<string, unknown>;
3
+ export declare function effortOptions(model: string, level: Effort, custom?: Readonly<Record<string, CustomProvider>>, outputLimit?: number): Options | undefined;
4
+ export declare class EffortRoutes {
5
+ private readonly routes;
6
+ constructor(models: readonly string[], custom?: Readonly<Record<string, CustomProvider>>, known?: ReadonlyMap<string, number>);
7
+ get empty(): boolean;
8
+ providerConfig(): Record<string, {
9
+ models: Record<string, {
10
+ variants: Options;
11
+ }>;
12
+ }>;
13
+ keepLoaded(loaded: ReadonlyMap<string, ReadonlySet<string>>): void;
14
+ route(model: string, level: Effort): {
15
+ variant?: string;
16
+ } | undefined;
17
+ }
18
+ export declare class AppliedEfforts {
19
+ private readonly applied;
20
+ private readonly sampling;
21
+ constructor(sampling?: Sampling);
22
+ record(agent: string, effort: Effort, model: string, sent: boolean): boolean;
23
+ appliedTo(agent: string): AppliedSettings | undefined;
24
+ private requested;
25
+ }
26
+ export {};
27
+ //# sourceMappingURL=effort.d.ts.map
package/dist/effort.js ADDED
@@ -0,0 +1,149 @@
1
+ import { EFFORT_LEVELS, effortCapability, parseModel, thinkingBudget, } from "@open-cr-agent/core";
2
+ const GOOGLE_PROVIDERS = new Set(["google", "google-vertex"]);
3
+ // OpenCode merges a configured variant over a built-in one of the same name
4
+ // (1.18.32 has "high" and "max" for most models), so ocra's carry a prefix.
5
+ function effortVariant(level) {
6
+ return `ocra-${level}`;
7
+ }
8
+ // The provider options that carry a level to one model through OpenCode, in
9
+ // the option names of the provider's AI SDK package; {} when the level means
10
+ // sending nothing (no thinking for Anthropic), undefined when ocra knows no
11
+ // way to send it. A provider declared in configuration takes a level the
12
+ // way it says (`effort`), for any model the table does not rule out, as on
13
+ // the direct runtime.
14
+ export function effortOptions(model, level, custom = {}, outputLimit) {
15
+ const { providerID } = parseModel(model);
16
+ const capability = effortCapability(model);
17
+ if (capability && !capability.levels.includes(level))
18
+ return undefined;
19
+ const declared = custom[providerID];
20
+ if (declared) {
21
+ return declared.effort === "openrouter"
22
+ ? { reasoning: { effort: level } }
23
+ : { reasoningEffort: level };
24
+ }
25
+ if (!capability)
26
+ return undefined;
27
+ if (providerID === "openrouter")
28
+ return { reasoning: { effort: level } };
29
+ const google = GOOGLE_PROVIDERS.has(providerID);
30
+ switch (capability.parameter) {
31
+ case "reasoningEffort":
32
+ return providerID === "openai" ? { reasoningEffort: level } : undefined;
33
+ case "thinkingLevel":
34
+ return google ? { thinkingConfig: { thinkingLevel: level } } : undefined;
35
+ case "thinkingBudget": {
36
+ const budget = thinkingBudget(capability, level, outputLimit);
37
+ if (budget === undefined)
38
+ return undefined;
39
+ if (providerID === "anthropic") {
40
+ return budget === 0 ? {} : { thinking: { type: "enabled", budgetTokens: budget } };
41
+ }
42
+ // OpenCode asks Gemini for thoughts, which Gemini refuses with
43
+ // thinking off.
44
+ if (google) {
45
+ return {
46
+ thinkingConfig: budget === 0
47
+ ? { thinkingBudget: 0, includeThoughts: false }
48
+ : { thinkingBudget: budget },
49
+ };
50
+ }
51
+ return undefined;
52
+ }
53
+ }
54
+ }
55
+ // What each model can take, as OpenCode variants: one per (model, level)
56
+ // that sends something, so two agents on one model at different levels get
57
+ // different options. Only models OpenCode's catalog (or the configuration)
58
+ // knows get any, since declaring an unknown model would make OpenCode
59
+ // accept it at a price of 0.
60
+ export class EffortRoutes {
61
+ routes = new Map();
62
+ constructor(models, custom = {},
63
+ // Output limits by model, from OpenCode's catalog; 0 when it has none.
64
+ known = new Map()) {
65
+ for (const model of new Set(models)) {
66
+ if (!known.has(model))
67
+ continue;
68
+ const levels = new Map();
69
+ for (const level of EFFORT_LEVELS) {
70
+ const options = effortOptions(model, level, custom, known.get(model));
71
+ if (options)
72
+ levels.set(level, options);
73
+ }
74
+ if (levels.size > 0)
75
+ this.routes.set(model, levels);
76
+ }
77
+ }
78
+ get empty() {
79
+ return this.routes.size === 0;
80
+ }
81
+ // The `provider` section that declares the variants.
82
+ providerConfig() {
83
+ const config = {};
84
+ for (const [model, levels] of this.routes) {
85
+ const { providerID, modelID } = parseModel(model);
86
+ const variants = Object.fromEntries([...levels]
87
+ .filter(([, options]) => Object.keys(options).length > 0)
88
+ .map(([level, options]) => [effortVariant(level), options]));
89
+ if (Object.keys(variants).length === 0)
90
+ continue;
91
+ config[providerID] ??= { models: {} };
92
+ config[providerID].models[modelID] = { variants };
93
+ }
94
+ return config;
95
+ }
96
+ // Keeps the levels whose variant OpenCode loaded, so a level is never
97
+ // recorded as sent when OpenCode would have ignored its variant.
98
+ keepLoaded(loaded) {
99
+ for (const [model, levels] of this.routes) {
100
+ for (const [level, options] of levels) {
101
+ const needsVariant = Object.keys(options).length > 0;
102
+ if (needsVariant && !loaded.get(model)?.has(effortVariant(level)))
103
+ levels.delete(level);
104
+ }
105
+ }
106
+ }
107
+ // Undefined: the level cannot be sent to the model. No variant: the level
108
+ // is sent by sending nothing.
109
+ route(model, level) {
110
+ const options = this.routes.get(model)?.get(level);
111
+ if (!options)
112
+ return undefined;
113
+ return Object.keys(options).length > 0 ? { variant: effortVariant(level) } : {};
114
+ }
115
+ }
116
+ // What the run applied per agent (ADR-0025), as the direct runtime records
117
+ // it: a call that asks for an effort other than "none" goes without the
118
+ // configured sampling, even when its level could not be sent, so an
119
+ // agent's calls stay alike within a run.
120
+ export class AppliedEfforts {
121
+ applied = new Map();
122
+ sampling;
123
+ constructor(sampling = {}) {
124
+ this.sampling = sampling;
125
+ }
126
+ // Whether the call keeps the configured sampling.
127
+ record(agent, effort, model, sent) {
128
+ const keepsSampling = effort === "none";
129
+ const before = this.applied.get(agent);
130
+ const notApplied = [
131
+ ...new Set([...(before?.notApplied ?? []), ...(keepsSampling ? [] : this.requested())]),
132
+ ];
133
+ const unsupported = [...new Set([...(before?.unsupported ?? []), ...(sent ? [] : [model])])];
134
+ this.applied.set(agent, {
135
+ effort: (before?.effort ?? true) && sent,
136
+ ...(unsupported.length > 0 ? { unsupported } : {}),
137
+ ...(notApplied.length > 0 ? { notApplied } : {}),
138
+ });
139
+ return keepsSampling;
140
+ }
141
+ appliedTo(agent) {
142
+ const applied = this.applied.get(agent);
143
+ return applied && structuredClone(applied);
144
+ }
145
+ requested() {
146
+ return ["temperature", "seed"].filter((key) => this.sampling[key] !== undefined);
147
+ }
148
+ }
149
+ //# sourceMappingURL=effort.js.map
@@ -0,0 +1,101 @@
1
+ import { type AppliedSampling, type CustomProvider, type Sampling } from "@open-cr-agent/core";
2
+ import type { ToolServer } from "./tool-server.js";
3
+ export declare const MCP_SERVER = "ocra";
4
+ export declare const REVIEW_AGENT = "ocra-reviewer";
5
+ export declare const HELPER_AGENT = "ocra-helper";
6
+ export declare const WITHOUT_SAMPLING = "-effort";
7
+ export declare const HELPER_AGENT_STEPS = 2;
8
+ export declare const OPENCODE_BUILTIN_TOOLS: readonly ["invalid", "question", "bash", "read", "glob", "grep", "edit", "write", "task", "webfetch", "todowrite", "websearch", "skill", "apply_patch"];
9
+ export declare const DISABLED_BUILTINS: {
10
+ [k: string]: boolean;
11
+ };
12
+ export declare function openCodeSampling(sampling?: Sampling): AppliedSampling;
13
+ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>, sampling?: Sampling): {
14
+ share: string;
15
+ autoupdate: boolean;
16
+ provider?: {
17
+ [k: string]: {
18
+ npm: string;
19
+ name: string;
20
+ options: {
21
+ baseURL: string;
22
+ apiKey?: string;
23
+ };
24
+ models: {
25
+ [k: string]: {
26
+ name: string;
27
+ temperature?: boolean;
28
+ cost: {
29
+ input: number;
30
+ output: number;
31
+ cache_read?: number;
32
+ };
33
+ };
34
+ };
35
+ };
36
+ };
37
+ mcp: {
38
+ ocra: {
39
+ type: string;
40
+ url: string;
41
+ headers: Record<string, string>;
42
+ oauth: boolean;
43
+ enabled: boolean;
44
+ };
45
+ };
46
+ agent: {
47
+ [k: string]: {
48
+ mode: string;
49
+ prompt: string;
50
+ steps: number;
51
+ tools: {
52
+ [k: string]: boolean;
53
+ };
54
+ permission: {
55
+ edit: string;
56
+ bash: string;
57
+ webfetch: string;
58
+ skill: string;
59
+ };
60
+ } | {
61
+ mode: string;
62
+ prompt: string;
63
+ steps: number;
64
+ tools: Record<string, boolean>;
65
+ permission: {
66
+ edit: string;
67
+ bash: string;
68
+ webfetch: string;
69
+ skill: string;
70
+ };
71
+ };
72
+ } | {
73
+ "ocra-reviewer": {
74
+ mode: string;
75
+ prompt: string;
76
+ steps: number;
77
+ tools: {
78
+ [k: string]: boolean;
79
+ };
80
+ permission: {
81
+ edit: string;
82
+ bash: string;
83
+ webfetch: string;
84
+ skill: string;
85
+ };
86
+ };
87
+ "ocra-helper": {
88
+ mode: string;
89
+ prompt: string;
90
+ steps: number;
91
+ tools: Record<string, boolean>;
92
+ permission: {
93
+ edit: string;
94
+ bash: string;
95
+ webfetch: string;
96
+ skill: string;
97
+ };
98
+ };
99
+ };
100
+ };
101
+ //# sourceMappingURL=opencode-config.d.ts.map
@@ -0,0 +1,137 @@
1
+ import { MAX_AGENT_STEPS, OcraError, } from "@open-cr-agent/core";
2
+ export const MCP_SERVER = "ocra";
3
+ export const REVIEW_AGENT = "ocra-reviewer";
4
+ export const HELPER_AGENT = "ocra-helper";
5
+ // The same agents without the configured temperature, for calls that send
6
+ // an effort (ADR-0025): OpenCode sets sampling per agent.
7
+ export const WITHOUT_SAMPLING = "-effort";
8
+ // Must not be empty: OpenCode falls back to its full coding prompt otherwise.
9
+ const REVIEW_AGENT_PROMPT = "You are a code review agent run by ocra. Follow the review instructions below.";
10
+ const HELPER_AGENT_PROMPT = "You answer exactly as the instructions below ask, with no tools.";
11
+ // The helper answers in one step and has no tools. It still needs two:
12
+ // OpenCode appends an assistant message on an agent's last allowed step, and
13
+ // Gemini rejects a request that ends with a model turn (#66).
14
+ export const HELPER_AGENT_STEPS = 2;
15
+ // Every OpenCode built-in tool of the pinned version; a test fails when an
16
+ // upgrade adds one, so a new write-capable tool can never be enabled silently.
17
+ export const OPENCODE_BUILTIN_TOOLS = [
18
+ "invalid",
19
+ "question",
20
+ "bash",
21
+ "read",
22
+ "glob",
23
+ "grep",
24
+ "edit",
25
+ "write",
26
+ "task",
27
+ "webfetch",
28
+ "todowrite",
29
+ "websearch",
30
+ "skill",
31
+ "apply_patch",
32
+ ];
33
+ export const DISABLED_BUILTINS = Object.fromEntries(OPENCODE_BUILTIN_TOOLS.map((t) => [t, false]));
34
+ // OpenCode 1.18.32 takes a temperature per agent and sends it when the
35
+ // model's catalog entry says the model accepts one; a model declared in
36
+ // configuration is marked so (providerConfig). It has no seed setting: the
37
+ // request it builds carries temperature, topP, topK and the output limit only.
38
+ export function openCodeSampling(sampling = {}) {
39
+ return {
40
+ ...(sampling.temperature === undefined ? {} : { temperature: sampling.temperature }),
41
+ ...(sampling.seed === undefined ? {} : { notApplied: ["seed"] }),
42
+ };
43
+ }
44
+ export function openCodeConfig(tools, helperTools, custom = {}, sampling = {}) {
45
+ const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
46
+ const { temperature } = sampling;
47
+ const agents = {
48
+ [REVIEW_AGENT]: {
49
+ mode: "primary",
50
+ prompt: REVIEW_AGENT_PROMPT,
51
+ steps: MAX_AGENT_STEPS,
52
+ tools: DISABLED_BUILTINS,
53
+ permission,
54
+ },
55
+ [HELPER_AGENT]: {
56
+ mode: "primary",
57
+ prompt: HELPER_AGENT_PROMPT,
58
+ steps: HELPER_AGENT_STEPS,
59
+ tools: helperTools,
60
+ permission,
61
+ },
62
+ };
63
+ return {
64
+ share: "disabled",
65
+ autoupdate: false,
66
+ ...(Object.keys(custom).length > 0
67
+ ? { provider: providerConfig(custom, temperature !== undefined) }
68
+ : {}),
69
+ mcp: {
70
+ [MCP_SERVER]: {
71
+ type: "remote",
72
+ url: tools.url,
73
+ headers: tools.headers,
74
+ oauth: false,
75
+ enabled: true,
76
+ },
77
+ },
78
+ agent: temperature === undefined
79
+ ? agents
80
+ : Object.fromEntries(Object.entries(agents).flatMap(([name, agent]) => [
81
+ [name, { ...agent, temperature }],
82
+ [`${name}${WITHOUT_SAMPLING}`, agent],
83
+ ])),
84
+ };
85
+ }
86
+ // OpenCode's form of a provider declared in configuration. OpenCode bundles
87
+ // the OpenAI-compatible client, so nothing is installed to reach it
88
+ // (docs/spikes/0002). The key stays in the environment: OpenCode reads
89
+ // {env:NAME} itself, and the configuration, which OpenCode may log, never
90
+ // holds it. Prices are per million tokens, as OpenCode's catalog has them.
91
+ // OpenCode assumes a declared model takes no temperature and drops one;
92
+ // with a temperature configured, the models are marked as taking it.
93
+ function providerConfig(custom, temperature) {
94
+ for (const [id, provider] of Object.entries(custom))
95
+ checkProvider(id, provider);
96
+ return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
97
+ id,
98
+ {
99
+ npm: "@ai-sdk/openai-compatible",
100
+ name: id,
101
+ options: {
102
+ baseURL: provider.baseUrl,
103
+ ...(provider.apiKeyEnv ? { apiKey: `{env:${provider.apiKeyEnv}}` } : {}),
104
+ },
105
+ models: Object.fromEntries(Object.entries(provider.models).map(([model, price]) => [
106
+ model,
107
+ {
108
+ name: model,
109
+ ...(temperature ? { temperature: true } : {}),
110
+ cost: {
111
+ input: price.input,
112
+ output: price.output,
113
+ ...(price.cachedInput === undefined ? {} : { cache_read: price.cachedInput }),
114
+ },
115
+ },
116
+ ])),
117
+ },
118
+ ]));
119
+ }
120
+ // Checked here once, whatever declared the provider: a configuration file,
121
+ // ocra Cloud or another caller of this package.
122
+ function checkProvider(id, provider) {
123
+ const fields = [
124
+ ["id", id],
125
+ ["baseUrl", provider.baseUrl],
126
+ ...Object.keys(provider.models).map((model) => ["model", model]),
127
+ ];
128
+ for (const [field, value] of fields) {
129
+ if (/[{}]/.test(value)) {
130
+ throw new OcraError("CONFIG_INVALID", `Provider "${id}": ${field} must not contain { or }`);
131
+ }
132
+ }
133
+ if (provider.apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(provider.apiKeyEnv)) {
134
+ throw new OcraError("CONFIG_INVALID", `Provider "${id}": apiKeyEnv must be an environment variable name`);
135
+ }
136
+ }
137
+ //# sourceMappingURL=opencode-config.js.map
package/dist/runtime.d.ts CHANGED
@@ -1,120 +1,28 @@
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
- import { type ToolServer } from "./tool-server.js";
3
- export declare const MCP_SERVER = "ocra";
4
- export declare const HELPER_AGENT_STEPS = 2;
5
- export declare const REVIEW_RESUME: {
6
- doneTool: "task_done";
7
- maxSteps: number;
8
- message: string;
9
- };
10
- export declare const OPENCODE_BUILTIN_TOOLS: readonly ["invalid", "question", "bash", "read", "glob", "grep", "edit", "write", "task", "webfetch", "todowrite", "websearch", "skill", "apply_patch"];
1
+ import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type AppliedSampling, type AppliedSettings, type CompletionRequest, type CompletionResult, type RuntimeOptions } from "@open-cr-agent/core";
11
2
  export interface OpenCodeRuntimeOptions extends RuntimeOptions {
12
3
  binary?: string;
13
4
  }
14
5
  export declare class OpenCodeRuntime implements AgentRuntime {
15
- private readonly options;
16
6
  readonly name = "opencode";
17
7
  readonly sampling: AppliedSampling;
18
8
  private infra;
19
9
  private context;
20
- private readonly health;
10
+ private readonly chains;
21
11
  private readonly helperTools;
12
+ private readonly applied;
13
+ private readonly options;
22
14
  constructor(options: OpenCodeRuntimeOptions);
15
+ appliedTo(agent: string): AppliedSettings | undefined;
23
16
  runTask(spec: AgentTaskSpec, signal: AbortSignal): AsyncIterable<AgentEvent>;
24
17
  complete(request: CompletionRequest, signal: AbortSignal): Promise<CompletionResult>;
25
18
  dispose(): Promise<void>;
19
+ private attemptTask;
20
+ private attemptCompletion;
21
+ private effortCall;
26
22
  private prompt;
23
+ private unprepared;
27
24
  private start;
28
25
  private launch;
29
26
  }
30
- export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>, sampling?: Sampling): {
31
- share: string;
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
- };
54
- mcp: {
55
- ocra: {
56
- type: string;
57
- url: string;
58
- headers: Record<string, string>;
59
- oauth: boolean;
60
- enabled: boolean;
61
- };
62
- };
63
- agent: {
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;
80
- mode: string;
81
- prompt: string;
82
- steps: number;
83
- tools: {
84
- [k: string]: boolean;
85
- };
86
- permission: {
87
- edit: string;
88
- bash: string;
89
- webfetch: string;
90
- skill: string;
91
- };
92
- };
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;
107
- mode: string;
108
- prompt: string;
109
- steps: number;
110
- tools: Record<string, boolean>;
111
- permission: {
112
- edit: string;
113
- bash: string;
114
- webfetch: string;
115
- skill: string;
116
- };
117
- };
118
- };
119
- };
27
+ export declare function providersOf(options: Pick<RuntimeOptions, "models" | "agentModels">): string[];
120
28
  //# sourceMappingURL=runtime.d.ts.map
package/dist/runtime.js CHANGED
@@ -2,58 +2,32 @@ 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 { 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";
5
+ import { ChainRunner, MAX_AGENT_STEPS, OcraError, parseModel, RESUME_MESSAGE, REVIEW_TOOLS, reviewTools, withoutSecrets, } from "@open-cr-agent/core";
7
6
  import { createOpencodeClient } from "@opencode-ai/sdk/v2";
8
7
  import { resolveOpencodeBinary } from "./binary.js";
8
+ import { AppliedEfforts } from "./effort.js";
9
+ import { setUpEfforts } from "./effort-setup.js";
10
+ import { DISABLED_BUILTINS, HELPER_AGENT, MCP_SERVER, openCodeConfig, openCodeSampling, REVIEW_AGENT, WITHOUT_SAMPLING, } from "./opencode-config.js";
9
11
  import { startOpencodeServer } from "./opencode-server.js";
10
12
  import { credentialValues, missingCredentials, serverEnv } from "./server-env.js";
11
13
  import { promptSession } from "./session-prompt.js";
12
14
  import { startToolServer } from "./tool-server.js";
13
15
  import { createUntimedDispatcher, untimedFetch } from "./transport.js";
14
- export const MCP_SERVER = "ocra";
15
- const REVIEW_AGENT = "ocra-reviewer";
16
- const HELPER_AGENT = "ocra-helper";
17
- // Must not be empty: OpenCode falls back to its full coding prompt otherwise.
18
- const REVIEW_AGENT_PROMPT = "You are a code review agent run by ocra. Follow the review instructions below.";
19
- const HELPER_AGENT_PROMPT = "You answer exactly as the instructions below ask, with no tools.";
20
- // The helper answers in one step and has no tools. It still needs two:
21
- // OpenCode appends an assistant message on an agent's last allowed step, and
22
- // Gemini rejects a request that ends with a model turn (#66).
23
- export const HELPER_AGENT_STEPS = 2;
24
16
  // Sent once to a review agent that stopped before finishing (session-prompt.ts).
25
- export const REVIEW_RESUME = {
17
+ const REVIEW_RESUME = {
26
18
  doneTool: REVIEW_TOOLS.taskDone,
27
19
  maxSteps: MAX_AGENT_STEPS,
28
20
  message: RESUME_MESSAGE,
29
21
  };
30
- // Every OpenCode built-in tool of the pinned version; a test fails when an
31
- // upgrade adds one, so a new write-capable tool can never be enabled silently.
32
- export const OPENCODE_BUILTIN_TOOLS = [
33
- "invalid",
34
- "question",
35
- "bash",
36
- "read",
37
- "glob",
38
- "grep",
39
- "edit",
40
- "write",
41
- "task",
42
- "webfetch",
43
- "todowrite",
44
- "websearch",
45
- "skill",
46
- "apply_patch",
47
- ];
48
- const DISABLED_BUILTINS = Object.fromEntries(OPENCODE_BUILTIN_TOOLS.map((t) => [t, false]));
49
22
  export class OpenCodeRuntime {
50
- options;
51
23
  name = "opencode";
52
24
  sampling;
53
25
  infra;
54
26
  context;
55
- health = new ModelHealth();
27
+ chains;
56
28
  helperTools;
29
+ applied;
30
+ options;
57
31
  constructor(options) {
58
32
  this.options = options;
59
33
  const mcpTools = [...reviewTools, ...options.tools].map((t) => [
@@ -62,61 +36,28 @@ export class OpenCodeRuntime {
62
36
  ]);
63
37
  this.helperTools = { ...DISABLED_BUILTINS, ...Object.fromEntries(mcpTools) };
64
38
  this.sampling = openCodeSampling(options.sampling);
39
+ this.applied = new AppliedEfforts(options.sampling);
40
+ this.chains = new ChainRunner(options.models, {
41
+ refuse: (chain, own) => this.unprepared(chain, own),
42
+ ready: async () => {
43
+ await this.start();
44
+ },
45
+ task: (model, spec, signal, onUsage) => this.attemptTask(model, spec, signal, onUsage),
46
+ complete: (model, request, signal) => this.attemptCompletion(model, request, signal),
47
+ });
48
+ }
49
+ appliedTo(agent) {
50
+ return this.applied.appliedTo(agent);
65
51
  }
66
52
  async *runTask(spec, signal) {
67
53
  if (this.context && this.context !== spec.context) {
68
54
  throw new OcraError("INTERNAL", "An OpenCodeRuntime instance serves a single review run");
69
55
  }
70
56
  this.context = spec.context;
71
- const chain = this.options.models[spec.modelTier] ?? [];
72
- if (chain.length === 0) {
73
- yield {
74
- type: "error",
75
- taskId: spec.taskId,
76
- retryable: false,
77
- error: noModel(spec.modelTier),
78
- };
79
- return;
80
- }
81
- const infra = await this.start();
82
- yield* withFailback({
83
- taskId: spec.taskId,
84
- tier: spec.modelTier,
85
- chain,
86
- health: this.health,
87
- signal,
88
- attempt: (model, onUsage) => this.prompt(infra, {
89
- title: `ocra ${spec.taskId}`,
90
- agent: REVIEW_AGENT,
91
- model,
92
- system: spec.systemPrompt,
93
- user: spec.userPrompt,
94
- tools: DISABLED_BUILTINS,
95
- toolPrefix: `${MCP_SERVER}_`,
96
- resume: REVIEW_RESUME,
97
- }, signal, onUsage),
98
- });
57
+ yield* this.chains.runTask(spec, signal);
99
58
  }
100
- async complete(request, signal) {
101
- const chain = this.options.models[request.tier] ?? [];
102
- if (chain.length === 0)
103
- throw new OcraError("CONFIG_INVALID", noModel(request.tier));
104
- const infra = await this.start();
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
- });
59
+ complete(request, signal) {
60
+ return this.chains.complete(request, signal);
120
61
  }
121
62
  async dispose() {
122
63
  const infra = await this.infra?.catch(() => undefined);
@@ -127,6 +68,45 @@ export class OpenCodeRuntime {
127
68
  await Promise.allSettled([infra.server.close(), infra.tools.close(), infra.dispatcher.close()]);
128
69
  await rm(infra.root, { recursive: true, force: true });
129
70
  }
71
+ async attemptTask(model, spec, signal, onUsage) {
72
+ const infra = await this.start();
73
+ return this.prompt(infra, {
74
+ title: `ocra ${spec.taskId}`,
75
+ ...this.effortCall(infra, REVIEW_AGENT, spec.reviewer, spec.effort, model),
76
+ model,
77
+ system: spec.systemPrompt,
78
+ user: spec.userPrompt,
79
+ tools: DISABLED_BUILTINS,
80
+ toolPrefix: `${MCP_SERVER}_`,
81
+ resume: REVIEW_RESUME,
82
+ }, signal, onUsage);
83
+ }
84
+ async attemptCompletion(model, request, signal) {
85
+ const infra = await this.start();
86
+ return this.prompt(infra, {
87
+ title: "ocra helper",
88
+ ...this.effortCall(infra, HELPER_AGENT, request.agent ?? request.tier, request.effort, model),
89
+ model,
90
+ system: request.system,
91
+ user: request.user,
92
+ tools: this.helperTools,
93
+ toolPrefix: `${MCP_SERVER}_`,
94
+ }, signal);
95
+ }
96
+ // The OpenCode agent and variant one attempt runs with, recorded for the
97
+ // agent's provenance.
98
+ effortCall(infra, base, agent, effort, model) {
99
+ if (effort === undefined)
100
+ return { agent: base };
101
+ const route = infra.efforts.route(model, effort);
102
+ const keepsSampling = this.applied.record(agent, effort, model, route !== undefined);
103
+ return {
104
+ agent: keepsSampling || this.options.sampling?.temperature === undefined
105
+ ? base
106
+ : `${base}${WITHOUT_SAMPLING}`,
107
+ ...(route?.variant ? { variant: route.variant } : {}),
108
+ };
109
+ }
130
110
  prompt(infra, input, signal, onUsage) {
131
111
  return promptSession(infra.client.session, input, `${MCP_SERVER}_${REVIEW_TOOLS.reportFinding}`, signal, onUsage ? { onUsage } : {}).then((outcome) => outcome.error
132
112
  ? {
@@ -138,13 +118,25 @@ export class OpenCodeRuntime {
138
118
  }
139
119
  : outcome);
140
120
  }
121
+ // The server holds the credentials of the providers it started with; an
122
+ // agent's own chain naming another would fail at OpenCode as "model not
123
+ // found". The tier chains are always among them.
124
+ unprepared(chain, own) {
125
+ if (!own)
126
+ return undefined;
127
+ const prepared = new Set(providersOf(this.options));
128
+ const model = chain.find((m) => !prepared.has(parseModel(m).providerID));
129
+ return model === undefined
130
+ ? undefined
131
+ : new OcraError("CONFIG_INVALID", `"${model}" is not among the models the runtime was started with (RuntimeOptions.models or agentModels)`);
132
+ }
141
133
  start() {
142
134
  this.infra ??= this.launch();
143
135
  return this.infra;
144
136
  }
145
137
  async launch() {
146
138
  const custom = this.options.providers ?? {};
147
- const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
139
+ const missing = missingCredentials(this.options.env, providersOf(this.options), custom);
148
140
  if (missing.length > 0)
149
141
  throw new OcraError("CONFIG_CREDENTIALS_MISSING", missing.join("; "));
150
142
  const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
@@ -154,24 +146,26 @@ export class OpenCodeRuntime {
154
146
  state: join(root, "state"),
155
147
  };
156
148
  const workspace = join(root, "workspace");
157
- await Promise.all([...Object.values(dirs), workspace].map((d) => mkdir(d, { recursive: true })));
149
+ const probe = join(root, "probe");
150
+ await Promise.all([...Object.values(dirs), workspace, probe].map((d) => mkdir(d, { recursive: true })));
158
151
  const tools = await startToolServer([...reviewTools, ...this.options.tools], () => this.context);
159
152
  let started;
160
153
  try {
161
154
  const server = await startOpencodeServer({
162
155
  binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
163
156
  cwd: workspace,
164
- env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
157
+ env: serverEnv(this.options.env, dirs, providersOf(this.options), custom),
165
158
  config: openCodeConfig(tools, this.helperTools, custom, this.options.sampling),
166
159
  });
167
160
  started = server;
168
161
  const dispatcher = createUntimedDispatcher();
169
- const client = createOpencodeClient({
162
+ const clientFor = (directory) => createOpencodeClient({
170
163
  baseUrl: server.url,
171
- directory: workspace,
164
+ directory,
172
165
  headers: { Authorization: server.authorization },
173
166
  fetch: untimedFetch(dispatcher),
174
167
  });
168
+ const client = clientFor(workspace);
175
169
  // A second Ctrl-C exits at once, before dispose() can run: stop
176
170
  // OpenCode and remove its directory synchronously on the way out.
177
171
  const onExit = () => {
@@ -179,8 +173,15 @@ export class OpenCodeRuntime {
179
173
  rmSync(root, { recursive: true, force: true });
180
174
  };
181
175
  process.on("exit", onExit);
182
- const secrets = credentialValues(this.options.env, providersOf(this.options.models), custom);
183
- return { root, tools, server, client, dispatcher, onExit, secrets };
176
+ const efforts = await setUpEfforts({
177
+ models: chainModels(this.options),
178
+ custom,
179
+ probe: clientFor(probe).config,
180
+ workspace: client.config,
181
+ file: join(dirs.config, "opencode.json"),
182
+ });
183
+ const secrets = credentialValues(this.options.env, providersOf(this.options), custom);
184
+ return { root, tools, server, client, efforts, dispatcher, onExit, secrets };
184
185
  }
185
186
  catch (error) {
186
187
  await Promise.allSettled([tools.close(), started?.close()]);
@@ -189,91 +190,13 @@ export class OpenCodeRuntime {
189
190
  }
190
191
  }
191
192
  }
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 = {}) {
203
- const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
204
- const { temperature } = sampling;
205
- const agentSampling = temperature === undefined ? {} : { temperature };
206
- return {
207
- share: "disabled",
208
- autoupdate: false,
209
- ...(Object.keys(custom).length > 0
210
- ? { provider: providerConfig(custom, temperature !== undefined) }
211
- : {}),
212
- mcp: {
213
- [MCP_SERVER]: {
214
- type: "remote",
215
- url: tools.url,
216
- headers: tools.headers,
217
- oauth: false,
218
- enabled: true,
219
- },
220
- },
221
- agent: {
222
- [REVIEW_AGENT]: {
223
- mode: "primary",
224
- prompt: REVIEW_AGENT_PROMPT,
225
- steps: MAX_AGENT_STEPS,
226
- tools: DISABLED_BUILTINS,
227
- permission,
228
- ...agentSampling,
229
- },
230
- [HELPER_AGENT]: {
231
- mode: "primary",
232
- prompt: HELPER_AGENT_PROMPT,
233
- steps: HELPER_AGENT_STEPS,
234
- tools: helperTools,
235
- permission,
236
- ...agentSampling,
237
- },
238
- },
239
- };
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
- }
273
- function providersOf(models) {
274
- return Object.values(models).flatMap((chain) => (chain ?? []).map((m) => parseModel(m).providerID));
193
+ // Every model a call may run on: the tiers' chains and the agents' own
194
+ // (ADR-0025).
195
+ function chainModels(options) {
196
+ return [...Object.values(options.models), ...Object.values(options.agentModels ?? {})].flatMap((chain) => [...(chain ?? [])]);
275
197
  }
276
- function noModel(tier) {
277
- return `No model configured for the "${tier}" tier; set models.${tier} in .ocra/config.json or OCRA_MODEL_${tier.toUpperCase()}`;
198
+ // The providers of those models, so each gets its credentials like a tier's.
199
+ export function providersOf(options) {
200
+ return chainModels(options).map((m) => parseModel(m).providerID);
278
201
  }
279
202
  //# sourceMappingURL=runtime.js.map
@@ -6,8 +6,6 @@ export interface IsolatedDirs {
6
6
  }
7
7
  type CustomProviders = Readonly<Record<string, CustomProvider>>;
8
8
  export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
9
- export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
10
- export declare function credentialNames(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
11
9
  export declare function credentialValues(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
12
10
  export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
13
11
  export {};
@@ -88,7 +88,7 @@ export function missingCredentials(base, providers, custom = {}) {
88
88
  }
89
89
  const GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY";
90
90
  const GOOGLE_KEY_ALIASES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
91
- export const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
91
+ const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
92
92
  // OpenCode installs its plugin package from npm into every config directory
93
93
  // when it starts, and the SDK of a provider it does not bundle when a model
94
94
  // first uses it; no flag turns either off. ocra loads no OpenCode plugins and
@@ -121,7 +121,7 @@ const NEVER_BY_PREFIX = [
121
121
  // a provider declared in configuration only the one it names, for a known
122
122
  // catalog provider its variables, for any other every variable with its
123
123
  // prefix that is not a platform token.
124
- export function credentialNames(base, providers, custom = {}) {
124
+ function credentialNames(base, providers, custom = {}) {
125
125
  const names = [];
126
126
  for (const provider of new Set(providers)) {
127
127
  const declared = custom[provider];
@@ -1,36 +1,39 @@
1
- import type { Usage } from "@open-cr-agent/core";
2
- import { type AttemptOutcome } from "@open-cr-agent/core/internal";
3
- export interface SessionMessage {
4
- info: {
5
- role: string;
6
- cost?: number;
7
- tokens?: {
8
- input?: number;
9
- output?: number;
10
- reasoning?: number;
11
- cache?: {
12
- read?: number;
13
- };
14
- };
15
- error?: {
16
- name?: string;
17
- data?: {
18
- message?: string;
19
- statusCode?: number;
20
- isRetryable?: boolean;
21
- };
22
- };
23
- };
24
- parts: {
25
- type: string;
26
- text?: string;
27
- tool?: string;
28
- state?: {
29
- status?: string;
30
- input?: unknown;
31
- };
32
- }[];
33
- }
1
+ import { type AttemptOutcome, type Usage } from "@open-cr-agent/core";
2
+ import { z } from "zod";
3
+ declare const sessionMessageSchema: z.ZodObject<{
4
+ info: z.ZodObject<{
5
+ role: z.ZodString;
6
+ cost: z.ZodOptional<z.ZodNumber>;
7
+ tokens: z.ZodOptional<z.ZodObject<{
8
+ input: z.ZodOptional<z.ZodNumber>;
9
+ output: z.ZodOptional<z.ZodNumber>;
10
+ reasoning: z.ZodOptional<z.ZodNumber>;
11
+ cache: z.ZodOptional<z.ZodObject<{
12
+ read: z.ZodOptional<z.ZodNumber>;
13
+ }, z.core.$strip>>;
14
+ }, z.core.$strip>>;
15
+ error: z.ZodOptional<z.ZodObject<{
16
+ name: z.ZodOptional<z.ZodString>;
17
+ data: z.ZodOptional<z.ZodObject<{
18
+ message: z.ZodOptional<z.ZodString>;
19
+ statusCode: z.ZodOptional<z.ZodNumber>;
20
+ isRetryable: z.ZodOptional<z.ZodBoolean>;
21
+ }, z.core.$strip>>;
22
+ }, z.core.$strip>>;
23
+ }, z.core.$strip>;
24
+ parts: z.ZodArray<z.ZodObject<{
25
+ type: z.ZodString;
26
+ text: z.ZodOptional<z.ZodString>;
27
+ tool: z.ZodOptional<z.ZodString>;
28
+ state: z.ZodOptional<z.ZodObject<{
29
+ status: z.ZodOptional<z.ZodString>;
30
+ input: z.ZodOptional<z.ZodUnknown>;
31
+ }, z.core.$strip>>;
32
+ }, z.core.$strip>>;
33
+ }, z.core.$strip>;
34
+ export type SessionMessage = z.output<typeof sessionMessageSchema>;
35
+ export declare function parseSessionMessages(data: unknown): SessionMessage[];
34
36
  export declare function summarizeSession(messages: readonly SessionMessage[], reportTool: string, toolPrefix?: string): AttemptOutcome;
35
37
  export declare function sessionUsage(messages: readonly SessionMessage[]): Usage;
38
+ export {};
36
39
  //# sourceMappingURL=session-outcome.d.ts.map
@@ -1,4 +1,44 @@
1
- import { parseQuotaError } from "@open-cr-agent/core/internal";
1
+ import { parseQuotaError } from "@open-cr-agent/core";
2
+ import { z } from "zod";
3
+ // The part of OpenCode's session messages ocra reads, validated rather than
4
+ // cast: the SDK's types are not checked at runtime, and a server of another
5
+ // version could answer in another shape. Unknown fields are dropped.
6
+ const sessionMessageSchema = z.object({
7
+ info: z.object({
8
+ role: z.string(),
9
+ cost: z.number().optional(),
10
+ tokens: z
11
+ .object({
12
+ input: z.number().optional(),
13
+ output: z.number().optional(),
14
+ reasoning: z.number().optional(),
15
+ cache: z.object({ read: z.number().optional() }).optional(),
16
+ })
17
+ .optional(),
18
+ error: z
19
+ .object({
20
+ name: z.string().optional(),
21
+ data: z
22
+ .object({
23
+ message: z.string().optional(),
24
+ statusCode: z.number().optional(),
25
+ isRetryable: z.boolean().optional(),
26
+ })
27
+ .optional(),
28
+ })
29
+ .optional(),
30
+ }),
31
+ parts: z.array(z.object({
32
+ type: z.string(),
33
+ text: z.string().optional(),
34
+ tool: z.string().optional(),
35
+ state: z.object({ status: z.string().optional(), input: z.unknown().optional() }).optional(),
36
+ })),
37
+ });
38
+ // Throws when the answer is not a list of messages ocra can read.
39
+ export function parseSessionMessages(data) {
40
+ return z.array(sessionMessageSchema).parse(data ?? []);
41
+ }
2
42
  const AUTH_STATUS = new Set([401, 403]);
3
43
  const AUTH_MESSAGE = /api key|unauthori[sz]ed|permission denied|forbidden/i;
4
44
  // Tool names come back with the MCP server's prefix (MCP_SERVER in
@@ -1,10 +1,5 @@
1
- import { type Usage } from "@open-cr-agent/core";
2
- import { type AttemptOutcome } from "@open-cr-agent/core/internal";
1
+ import { type AttemptOutcome, type Usage } from "@open-cr-agent/core";
3
2
  import type { OpencodeClient } from "@opencode-ai/sdk/v2";
4
- import { type SessionMessage } from "./session-outcome.js";
5
- export declare const HARVEST_TIMEOUT_MS = 5000;
6
- export declare const INACTIVITY_MS: number;
7
- export declare const ACTIVITY_POLL_MS = 10000;
8
3
  export interface ActivityOptions {
9
4
  inactivityMs?: number;
10
5
  pollMs?: number;
@@ -14,21 +9,19 @@ export interface PromptInput {
14
9
  title: string;
15
10
  agent: string;
16
11
  model: string;
12
+ variant?: string;
17
13
  system: string;
18
14
  user: string;
19
15
  tools: Record<string, boolean>;
20
16
  toolPrefix?: string;
21
17
  resume?: ResumeOptions;
22
18
  }
23
- export interface ResumeOptions {
19
+ interface ResumeOptions {
24
20
  doneTool: string;
25
21
  maxSteps: number;
26
22
  message: string;
27
23
  }
28
- export declare function stoppedEarly(outcome: AttemptOutcome, resume: ResumeOptions): boolean;
29
24
  type SessionApi = Pick<OpencodeClient["session"], "create" | "prompt" | "messages" | "abort">;
30
25
  export declare function promptSession(session: SessionApi, input: PromptInput, reportTool: string, signal: AbortSignal, activity?: ActivityOptions): Promise<AttemptOutcome>;
31
- export declare function activitySignature(messages: readonly SessionMessage[]): string;
32
- export declare function emptyOutcome(): AttemptOutcome;
33
26
  export {};
34
27
  //# sourceMappingURL=session-prompt.d.ts.map
@@ -1,24 +1,23 @@
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";
1
+ import { emptyUsage, errorMessage, OcraError, parseModel, } from "@open-cr-agent/core";
2
+ import { parseSessionMessages, sessionUsage, summarizeSession, } from "./session-outcome.js";
4
3
  // A session that was cut off has still spent tokens and may have reported
5
4
  // findings; this bounds the one extra request that collects them.
6
- export const HARVEST_TIMEOUT_MS = 5_000;
5
+ const HARVEST_TIMEOUT_MS = 5_000;
7
6
  // A prompt returns only when the agent is done, so silence on the request is
8
7
  // normal; silence in the session is not. A session whose messages have not
9
8
  // changed for this long (no new step, no streamed text, no tool progress) is
10
9
  // stopped and the task moves to the next model instead of waiting for its
11
10
  // full timeout. Generous, so a slow step that is still writing survives.
12
- export const INACTIVITY_MS = 5 * 60_000;
11
+ const INACTIVITY_MS = 5 * 60_000;
13
12
  // Each poll also reports what the session has spent, and a run's spend limit
14
13
  // stops running tasks on those reports: this bounds how far past the limit a
15
14
  // task can get before it is stopped.
16
- export const ACTIVITY_POLL_MS = 10_000;
15
+ const ACTIVITY_POLL_MS = 10_000;
17
16
  // About one review attempt in twelve on Gemini ended after a step or two
18
17
  // with no text, no done tool and steps to spare (2026-09-28), and the task
19
18
  // counted as completed with its files unread. Continuing the same session
20
19
  // keeps what it read and is cheaper than starting over.
21
- export function stoppedEarly(outcome, resume) {
20
+ function stoppedEarly(outcome, resume) {
22
21
  return (!outcome.toolCalls.includes(resume.doneTool) &&
23
22
  outcome.steps < resume.maxSteps &&
24
23
  outcome.text.trim() === "");
@@ -38,6 +37,7 @@ export async function promptSession(session, input, reportTool, signal, activity
38
37
  sessionID,
39
38
  agent: input.agent,
40
39
  model: parseModel(input.model),
40
+ ...(input.variant ? { variant: input.variant } : {}),
41
41
  system: input.system,
42
42
  tools: input.tools,
43
43
  parts: [{ type: "text", text: input.user }],
@@ -53,7 +53,7 @@ export async function promptSession(session, input, reportTool, signal, activity
53
53
  let outcome;
54
54
  try {
55
55
  const messages = await session.messages({ sessionID }, { signal: attempt });
56
- outcome = summarizeSession((messages.data ?? []), reportTool, input.toolPrefix);
56
+ outcome = summarizeSession(parseSessionMessages(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
@@ -72,6 +72,7 @@ export async function promptSession(session, input, reportTool, signal, activity
72
72
  sessionID,
73
73
  agent: input.agent,
74
74
  model: parseModel(input.model),
75
+ ...(input.variant ? { variant: input.variant } : {}),
75
76
  system: input.system,
76
77
  tools: input.tools,
77
78
  parts: [{ type: "text", text: input.resume.message }],
@@ -120,7 +121,7 @@ function watchActivity(session, sessionID, options) {
120
121
  polling = true;
121
122
  try {
122
123
  const messages = await session.messages({ sessionID }, { signal: AbortSignal.timeout(HARVEST_TIMEOUT_MS) });
123
- const list = (messages.data ?? []);
124
+ const list = parseSessionMessages(messages.data);
124
125
  if (options.onUsage) {
125
126
  const spent = sessionUsage(list);
126
127
  if (spent.costUsd > reported.costUsd || spent.inputTokens > reported.inputTokens) {
@@ -148,7 +149,7 @@ function watchActivity(session, sessionID, options) {
148
149
  return { signal: controller.signal, stop: () => clearInterval(timer) };
149
150
  }
150
151
  // What changes while an agent works: steps, parts, streamed text, tool states.
151
- export function activitySignature(messages) {
152
+ function activitySignature(messages) {
152
153
  return messages
153
154
  .map((m) => m.parts.map((p) => `${p.type}:${p.state?.status ?? ""}:${p.text?.length ?? 0}`).join(","))
154
155
  .join("|");
@@ -156,13 +157,13 @@ export function activitySignature(messages) {
156
157
  async function harvest(session, sessionID, reportTool, toolPrefix) {
157
158
  try {
158
159
  const messages = await session.messages({ sessionID }, { signal: AbortSignal.timeout(HARVEST_TIMEOUT_MS) });
159
- return summarizeSession((messages.data ?? []), reportTool, toolPrefix);
160
+ return summarizeSession(parseSessionMessages(messages.data), reportTool, toolPrefix);
160
161
  }
161
162
  catch {
162
163
  return emptyOutcome();
163
164
  }
164
165
  }
165
- export function emptyOutcome() {
166
+ function emptyOutcome() {
166
167
  return { findings: [], steps: 0, toolCalls: [], text: "", usage: emptyUsage() };
167
168
  }
168
169
  //# sourceMappingURL=session-prompt.js.map
@@ -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/internal";
6
+ import { errorMessage } from "@open-cr-agent/core";
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/dist/transport.js CHANGED
@@ -8,6 +8,10 @@ import { Agent, fetch as undiciFetch } from "undici";
8
8
  export function createUntimedDispatcher() {
9
9
  return new Agent({ headersTimeout: 0, bodyTimeout: 0 });
10
10
  }
11
+ // The adapter from undici's fetch to Node's is a deliberate copy of the one in
12
+ // core's proxiedFetch: a runtime builds on core's public API only (ADR-0006),
13
+ // and sharing it there would put undici's Dispatcher type in core's contract
14
+ // and pass a dispatcher from this package's undici to core's copy.
11
15
  export function untimedFetch(dispatcher) {
12
16
  return async (input, init) => {
13
17
  const request = new Request(input, init);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/runtime-opencode",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "OpenCode-based agent runtime for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.30.1",
37
- "@open-cr-agent/core": "0.4.0",
37
+ "@open-cr-agent/core": "0.6.0",
38
38
  "@opencode-ai/sdk": "1.18.32",
39
39
  "opencode-ai": "1.18.32",
40
40
  "undici": "^8.11.2",