akm-cli 0.10.261017-beta → 0.10.261019
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/CHANGELOG.md +51 -0
- package/dist/assets/opencode/akm-plugin.js +103 -1
- package/dist/core/config/convert-engine-references.js +23 -7
- package/dist/core/config/convert-engines.js +94 -28
- package/dist/core/config/convert-models-overlay.js +14 -2
- package/dist/integrations/agent/spawn.js +7 -2
- package/dist/integrations/harnesses/opencode/agent-builder.js +10 -1
- package/dist/integrations/harnesses/opencode/runtime/binary.js +6 -3
- package/dist/integrations/harnesses/opencode/runtime/index.js +18 -9
- package/dist/integrations/harnesses/opencode/session-log-v2.js +11 -1
- package/dist/scripts/akm-migrate-node.js +91 -33
- package/dist/scripts/akm-migrate.js +91 -33
- package/docs/architecture/architecture.md +9 -3
- package/docs/architecture/testing/testing-workflow.md +1 -1
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/{0.10.0.md → 0.10.261019.md} +35 -4
- package/docs/migration/release-notes/README.md +1 -1
- package/docs/migration/v0.9-to-v0.10.md +58 -7
- package/docs/reference/configuration.md +45 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261019] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
The first stable release of the 0.10 line, published to npm `latest`. Its
|
|
12
|
+
changes are in the 0.10 sections below; the upgrade from 0.9 is in
|
|
13
|
+
[the 0.9 to 0.10 migration guide](docs/migration/v0.9-to-v0.10.md) and
|
|
14
|
+
`akm help migrate 0.10.261019`.
|
|
15
|
+
|
|
16
|
+
## [0.10.261018] - 2026-10-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- A run on akm's own runtime whose model makes the same tool call and gets the
|
|
21
|
+
same result three turns in a row now tells the model so, in a message that
|
|
22
|
+
names the tool and asks it to use the result it has or take a different
|
|
23
|
+
approach, in a request that raises a connection's temperature below 0.7 to
|
|
24
|
+
0.7 (that one request only; every other keeps the connection's settings, and
|
|
25
|
+
a connection that sets no temperature sends none), and fails with a message
|
|
26
|
+
that names the tool only if the model makes the call again, instead of
|
|
27
|
+
running to its deadline or, with no `timeoutMs`, for ever. Each session of a
|
|
28
|
+
run, a subagent's too, is told for itself. OpenCode has no check of its own
|
|
29
|
+
for this. The runtime
|
|
30
|
+
moves from OpenCode 2.1.4 to 2.2.0 with it: the next agent run fetches the
|
|
31
|
+
binary again, into `<akm cache dir>/opencode/2.2.0/`.
|
|
32
|
+
- Agent work on akm's own runtime runs in the directory it is given: the one
|
|
33
|
+
`akm agent --cwd` names, and the bundle root for a workflow's agent units.
|
|
34
|
+
It ran in the directory akm was started from, because OpenCode takes its
|
|
35
|
+
directory from `PWD`, which akm now sets for the run.
|
|
36
|
+
- A harness run in a directory (`akm agent --cwd`, a harness's `workspace`, the
|
|
37
|
+
scratch directory of model work) now has `PWD` naming that directory, as it
|
|
38
|
+
does for a program started after `cd`. OpenCode takes its directory from
|
|
39
|
+
`PWD`, not the working directory, so an `opencode` harness ran its shell and
|
|
40
|
+
file tools in the directory akm was started in. The spawn every harness
|
|
41
|
+
shares sets it, so any program that reads `PWD` gets the directory it runs in.
|
|
42
|
+
- OpenCode model work leaves out OpenCode's built-in browser plugin. From
|
|
43
|
+
OpenCode 2.2.0 its rules kept the `skill` tool listed for the read-only
|
|
44
|
+
`akm-model-work` agent (a call to it was refused).
|
|
45
|
+
- `akm migrate apply` keeps agent engines. 0.10.261014 to 0.10.261017 dropped
|
|
46
|
+
every `opencode-sdk` engine, so agent work moved from the user's own OpenCode
|
|
47
|
+
and model to akm's own runtime, and `harness:` selectors naming them failed.
|
|
48
|
+
An `opencode-sdk` engine with no `llmEngine` now becomes an `opencode` harness
|
|
49
|
+
with its `model`, `bin`, `workspace`, `agent` and `timeoutMs`; one over an
|
|
50
|
+
`llmEngine` stays akm's own runtime. A harness engine's own `model` stands
|
|
51
|
+
over `models.json`, and the notes say where agent work ran with no
|
|
52
|
+
`defaults.engine`, where a gate's judge ran and runs now, and, with no
|
|
53
|
+
`defaults.llmEngine` and no connection, that model work (improve, index
|
|
54
|
+
passes, extraction, judges) now runs on the default harness where 0.9 ran
|
|
55
|
+
none, and how to turn that off with `modelWork: false`. An `opencode-sdk`
|
|
56
|
+
engine's `args`, which 0.9 did not pass, are dropped with a note. A
|
|
57
|
+
config a beta already migrated is repaired from its pre-migration copy in
|
|
58
|
+
`<cache dir>/config-backups/` (migration guide, step 5).
|
|
59
|
+
|
|
9
60
|
## [0.10.261017] - 2026-10-27
|
|
10
61
|
|
|
11
62
|
### Changed
|
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
// transport sends them. OpenCode sizes every request's output cap from the model's limit and sends it as
|
|
19
19
|
// `max_tokens` or `max_completion_tokens`; that is its own accounting, not a cap anyone asked for, so it is
|
|
20
20
|
// dropped, and the connection's fields carry a cap only when the connection writes one.
|
|
21
|
+
// - A run that has stopped getting anywhere: when the model has made the same tool calls three turns in a row and
|
|
22
|
+
// been given the same results each time, the next request carries a message from the user that says so and asks it to
|
|
23
|
+
// use the result it has or take another route, and a temperature it carries below 0.7 is raised to 0.7, because a
|
|
24
|
+
// model that is sampled greedily answers the same thing to the same context however it is asked; if the model then
|
|
25
|
+
// makes the same calls again, the run fails with a message that says so, before the next model request is sent.
|
|
26
|
+
// OpenCode has no such check of its own (its `doom_loop` setting is read and ignored), and a run that never ends has
|
|
27
|
+
// nothing else to stop it when the connection sets no `timeoutMs`.
|
|
21
28
|
|
|
22
29
|
import { execFile } from "node:child_process";
|
|
23
30
|
|
|
@@ -128,6 +135,69 @@ function withFields(base, over) {
|
|
|
128
135
|
return merged;
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
/** How many turns in a row may make the same tool calls and get the same results before the model is told so. */
|
|
139
|
+
const REPEATED_TURNS = 3;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The temperature the one request that tells the model it is repeating itself is raised to, when it is sent at a lower
|
|
143
|
+
* one. A Qwen 27B model sampled greedily (temperature 0) gave the same call again to every wording of the message in
|
|
144
|
+
* every try (0 of 16 replays of a captured request); sampled at 0.7 it left the repeat in about half of them (22 of 44).
|
|
145
|
+
* A request that carries no temperature is left to the endpoint's default: some endpoints refuse any temperature but
|
|
146
|
+
* their own (OpenAI's reasoning models answer anything but 1 with a 400).
|
|
147
|
+
*/
|
|
148
|
+
const RECOVERY_TEMPERATURE = 0.7;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A tool result as the check compares it. OpenCode saves an output too long to send whole to a file of its own for each
|
|
152
|
+
* call and names that file in the result (`[showing … of …; full output saved to <file>]`), so the name is left out:
|
|
153
|
+
* the same long output is the same result.
|
|
154
|
+
*/
|
|
155
|
+
const comparable = (content) =>
|
|
156
|
+
JSON.stringify(content).replace(/full output saved to [^\]]*\]/g, "full output saved to <file>]");
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What the last turns of a chat request repeat, when REPEATED_TURNS or more of them in a row made the same calls (the
|
|
160
|
+
* same name and arguments, ids ignored, parallel calls as a set) and were answered with the same results, else
|
|
161
|
+
* undefined: how many turns, the names of the tools called, the calls and results themselves (`signature`) and the id
|
|
162
|
+
* of the first call of the oldest turn (`since`), which tells one run of repeats from the next. A model that does this
|
|
163
|
+
* has settled on one answer to what it is told and does not leave it; a call whose result changes (a poll) is not
|
|
164
|
+
* repeated.
|
|
165
|
+
*/
|
|
166
|
+
function repeatedToolCalls(messages) {
|
|
167
|
+
if (!Array.isArray(messages)) return undefined;
|
|
168
|
+
const turns = [];
|
|
169
|
+
let names = [];
|
|
170
|
+
let since;
|
|
171
|
+
let results = new Map();
|
|
172
|
+
// From the newest message back: the tool results, then the assistant message that made the calls they answer.
|
|
173
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
174
|
+
const message = messages[i];
|
|
175
|
+
if (message?.role === "tool") {
|
|
176
|
+
results.set(message.tool_call_id, comparable(message.content));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (message?.role !== "assistant" || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) break;
|
|
180
|
+
const calls = message.tool_calls.map((call) => [
|
|
181
|
+
call?.function?.name,
|
|
182
|
+
call?.function?.arguments,
|
|
183
|
+
results.get(call?.id),
|
|
184
|
+
]);
|
|
185
|
+
if (calls.some(([name, , result]) => name === undefined || result === undefined)) break;
|
|
186
|
+
const turn = calls
|
|
187
|
+
.map((call) => JSON.stringify(call))
|
|
188
|
+
.sort()
|
|
189
|
+
.join("\n");
|
|
190
|
+
if (turns.length > 0 && turn !== turns[0]) break;
|
|
191
|
+
if (turns.length === 0) names = [...new Set(calls.map(([name]) => name))];
|
|
192
|
+
turns.push(turn);
|
|
193
|
+
since = message.tool_calls[0]?.id;
|
|
194
|
+
results = new Map();
|
|
195
|
+
}
|
|
196
|
+
return turns.length >= REPEATED_TURNS ? { turns: turns.length, names, signature: turns[0], since } : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const quoted = (names) => names.map((name) => `\`${name}\``).join(", ");
|
|
200
|
+
|
|
131
201
|
export default {
|
|
132
202
|
id: "akm",
|
|
133
203
|
setup: async (ctx) => {
|
|
@@ -161,6 +231,9 @@ export default {
|
|
|
161
231
|
}
|
|
162
232
|
|
|
163
233
|
const fields = ctx.options?.requestFields;
|
|
234
|
+
// The repeats the model has been told about, by session and what they repeat: where they began and how many turns
|
|
235
|
+
// there were. One process runs a run's every session (a subagent's too), and each session's model is told for itself.
|
|
236
|
+
const told = new Map();
|
|
164
237
|
await ctx.session.hook("http.request", async (event) => {
|
|
165
238
|
const request = event.request;
|
|
166
239
|
if (request.body === null) return;
|
|
@@ -172,12 +245,41 @@ export default {
|
|
|
172
245
|
}
|
|
173
246
|
if (!isObject(body)) return;
|
|
174
247
|
const { max_tokens: _own, max_completion_tokens: _ownCompletion, ...kept } = body;
|
|
248
|
+
const stuck = repeatedToolCalls(body.messages);
|
|
249
|
+
if (stuck) {
|
|
250
|
+
const key = `${event.sessionID}\n${stuck.signature}`;
|
|
251
|
+
const was = told.get(key);
|
|
252
|
+
// The model has been told about these calls and made them again, or has come back to them since.
|
|
253
|
+
if (was && (was.since !== stuck.since || stuck.turns > was.turns)) {
|
|
254
|
+
// Measured on OpenCode 2.2.0: an error thrown from this hook fails the run's turn before the request is sent,
|
|
255
|
+
// and `opencode run` exits 1 with the error's message (an unknown error in the session, which akm reports as
|
|
256
|
+
// a non-zero exit). Answering the request with a 4xx instead also ends the run, but only after the request
|
|
257
|
+
// has gone to the model.
|
|
258
|
+
throw new Error(
|
|
259
|
+
`akm stopped this run: the model called ${quoted(stuck.names)} with the same input ${stuck.turns} times in a row and got the same result each time, although it had been told it was repeating itself.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// A request that is sent again unchanged (a retry) is told again, the same way.
|
|
263
|
+
told.set(key, { since: stuck.since, turns: stuck.turns });
|
|
264
|
+
// OpenCode keeps no trace of this message: it does not store what a request's body is changed to.
|
|
265
|
+
kept.messages = [
|
|
266
|
+
...kept.messages,
|
|
267
|
+
{
|
|
268
|
+
role: "user",
|
|
269
|
+
content: `You have called ${quoted(stuck.names)} with the same input ${stuck.turns} times in a row and got the same result each time, so calling it again will not give you anything new. Use the result you already have, or take a different approach to finish the task. If you call it again with the same input, this run will be stopped.`,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
const forwarded = isObject(fields) ? withFields(kept, fields) : kept;
|
|
274
|
+
if (stuck && typeof forwarded.temperature === "number" && forwarded.temperature < RECOVERY_TEMPERATURE) {
|
|
275
|
+
forwarded.temperature = RECOVERY_TEMPERATURE;
|
|
276
|
+
}
|
|
175
277
|
const headers = new Headers(request.headers);
|
|
176
278
|
headers.delete("content-length");
|
|
177
279
|
event.request = new Request(request.url, {
|
|
178
280
|
method: request.method,
|
|
179
281
|
headers,
|
|
180
|
-
body: JSON.stringify(
|
|
282
|
+
body: JSON.stringify(forwarded),
|
|
181
283
|
signal: request.signal,
|
|
182
284
|
});
|
|
183
285
|
});
|
|
@@ -85,14 +85,30 @@ export function improveStrategyDefault(out, defaults, improve) {
|
|
|
85
85
|
out.improve = { ...(isRecord(improve) ? improve : {}) };
|
|
86
86
|
out.improve.strategy ??= defaults.improveStrategy;
|
|
87
87
|
}
|
|
88
|
-
/**
|
|
89
|
-
|
|
88
|
+
/**
|
|
89
|
+
* `workflow.judgeEngine`, else `defaults.engine`, is where 0.9 ran a gate's judge. It is model work now and runs where
|
|
90
|
+
* model work does, `judge`: the default connection, else the default harness. `workflow.judgeEngine` is dropped, and
|
|
91
|
+
* a note says whether the judge moves.
|
|
92
|
+
*/
|
|
93
|
+
export function dropJudgeEngine(out, conversion, target, defaultEngine, judge) {
|
|
90
94
|
const workflow = out.workflow;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
const named = isRecord(workflow) && "judgeEngine" in workflow;
|
|
96
|
+
const engine = named ? str(workflow.judgeEngine) : defaultEngine;
|
|
97
|
+
if (named) {
|
|
98
|
+
delete workflow.judgeEngine;
|
|
99
|
+
conversion.changed = true;
|
|
100
|
+
}
|
|
101
|
+
const became = engine === undefined ? undefined : target(engine);
|
|
102
|
+
const stays = became !== undefined && judge !== undefined && isDeepStrictEqual(became, judge);
|
|
103
|
+
const kind = judge === undefined ? undefined : "connection" in judge ? "connection" : "harness";
|
|
104
|
+
if (named) {
|
|
105
|
+
conversion.notes.push(stays
|
|
106
|
+
? `workflow.judgeEngine: "${engine}" is the default ${kind}, where a gate's judge runs now; the setting is dropped.`
|
|
107
|
+
: `workflow.judgeEngine${engine === undefined ? "" : ` "${engine}"`}: dropped. A gate's judge runs on the default connection, else the default harness, so a judge that ran on another engine has moved; to keep it, make that engine the default.`);
|
|
108
|
+
}
|
|
109
|
+
else if (became !== undefined && judge !== undefined && !stays) {
|
|
110
|
+
conversion.notes.push(`workflow: with no workflow.judgeEngine, a gate's judge ran on the default engine "${engine}"; it is model work now and runs on the default ${kind}, "${"connection" in judge ? judge.connection : judge.harness}".`);
|
|
111
|
+
}
|
|
96
112
|
}
|
|
97
113
|
/** `index.defaults.*` and `index.<pass>.engine`: `index.*` and `connection`; a harness cannot run an index pass. */
|
|
98
114
|
export function convertIndexEngines(out, conversion, retarget) {
|
|
@@ -55,17 +55,32 @@ function implicitKeyVariables(name, defaults) {
|
|
|
55
55
|
return name === str(defaults.llmEngine) ? [own, "AKM_LLM_API_KEY"] : [own];
|
|
56
56
|
}
|
|
57
57
|
// ── engines → connections / harnesses ────────────────────────────────────────
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `engines.<name>` with `kind: "agent"` and `platform: "opencode-sdk"` over an `llmEngine`: OpenCode ran over
|
|
60
|
+
* akm's own connection, which is what akm's own runtime is now, so it has no entry of its own.
|
|
61
|
+
*/
|
|
62
|
+
function runtimeEngine(name, engine, llmEngine, converted, conversion) {
|
|
63
|
+
converted.runtimes.set(name, llmEngine);
|
|
64
|
+
const dropped = ["model", "timeoutMs", "workspace", "bin", "agent"].filter((key) => engine[key] !== undefined);
|
|
65
|
+
const gone = dropped.length === 0 ? "" : `; its ${dropped.join(", ")} ${dropped.length === 1 ? "is" : "are"} dropped`;
|
|
66
|
+
conversion.notes.push(`engines.${name}: opencode-sdk over llmEngine "${llmEngine}" is akm's own runtime over connection "${llmEngine}" now, with no entry of its own${gone}.`);
|
|
62
67
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
/**
|
|
69
|
+
* `engines.<name>` with `kind: "agent"`: `harnesses.<name>`. An `opencode-sdk` engine with no `llmEngine` ran the
|
|
70
|
+
* user's own OpenCode (provider, model and login from its configuration), which the `opencode` harness runs now.
|
|
71
|
+
*/
|
|
72
|
+
function agentEngineToHarness(name, engine, converted, conversion) {
|
|
73
|
+
const sdk = engine.platform === "opencode-sdk";
|
|
74
|
+
const harness = { platform: sdk ? "opencode" : engine.platform };
|
|
75
|
+
if (sdk)
|
|
76
|
+
converted.sdkHarnesses.add(name);
|
|
77
|
+
// 0.9 started `opencode serve` without an engine's `args`; the harness would pass them in place of `run`.
|
|
66
78
|
for (const key of ["bin", "args", "workspace", "agent", "timeoutMs"])
|
|
67
|
-
if (engine[key] !== undefined)
|
|
79
|
+
if (engine[key] !== undefined && !(sdk && key === "args"))
|
|
68
80
|
harness[key] = engine[key];
|
|
81
|
+
if (sdk && engine.args !== undefined) {
|
|
82
|
+
conversion.notes.push(`engines.${name}: args is dropped. 0.9 did not pass it to OpenCode, and the opencode harness would pass it in place of \`run\`.`);
|
|
83
|
+
}
|
|
69
84
|
const model = str(engine.model);
|
|
70
85
|
if (model !== undefined)
|
|
71
86
|
converted.ownModelOf.push({ kind: "harnesses", engine: name, model });
|
|
@@ -123,6 +138,7 @@ function convertEngines(engines, defaults, env, conversion) {
|
|
|
123
138
|
connections: {},
|
|
124
139
|
harnesses: {},
|
|
125
140
|
runtimes: new Map(),
|
|
141
|
+
sdkHarnesses: new Set(),
|
|
126
142
|
keyHolders: [],
|
|
127
143
|
ownModelOf: [],
|
|
128
144
|
};
|
|
@@ -132,12 +148,13 @@ function convertEngines(engines, defaults, env, conversion) {
|
|
|
132
148
|
const unknown = Object.keys(engine).filter((k) => !(engine.kind === "agent" ? AGENT_ENGINE_KEYS : LLM_ENGINE_KEYS).has(k));
|
|
133
149
|
if (unknown.length > 0)
|
|
134
150
|
conversion.notes.push(`engines.${name}: dropped ${unknown.join(", ")}, which akm does not read.`);
|
|
151
|
+
const llmEngine = str(engine.llmEngine);
|
|
135
152
|
if (engine.kind !== "agent")
|
|
136
153
|
llmEngineToConnection(name, engine, defaults, env, converted, conversion);
|
|
137
|
-
else if (engine.platform === "opencode-sdk")
|
|
138
|
-
runtimeEngine(name, engine, converted, conversion);
|
|
154
|
+
else if (engine.platform === "opencode-sdk" && llmEngine !== undefined)
|
|
155
|
+
runtimeEngine(name, engine, llmEngine, converted, conversion);
|
|
139
156
|
else
|
|
140
|
-
agentEngineToHarness(name, engine, converted);
|
|
157
|
+
agentEngineToHarness(name, engine, converted, conversion);
|
|
141
158
|
}
|
|
142
159
|
return converted;
|
|
143
160
|
}
|
|
@@ -161,12 +178,37 @@ function renameMap(names, taken, from) {
|
|
|
161
178
|
}
|
|
162
179
|
return (name) => moved.get(name) ?? name;
|
|
163
180
|
}
|
|
181
|
+
/** The entry the loader reads as the default: the one called `default`, else the only one. */
|
|
182
|
+
const defaultName = (entries) => "default" in entries ? "default" : Object.keys(entries).length === 1 ? Object.keys(entries)[0] : undefined;
|
|
183
|
+
/**
|
|
184
|
+
* The engine 0.9 ran agent work on when `defaults.engine` named none and OpenCode was installed: the one called
|
|
185
|
+
* `opencode-sdk`, else a bare OpenCode of the user's own.
|
|
186
|
+
*/
|
|
187
|
+
const IMPLICIT_AGENT_ENGINE = "opencode-sdk";
|
|
188
|
+
/**
|
|
189
|
+
* Where agent work ran when `defaults.engine` named no harness, if that is not where it runs now. A runtime was akm's
|
|
190
|
+
* own; with no `defaults.engine` it was the user's OpenCode, whenever it was installed.
|
|
191
|
+
*/
|
|
192
|
+
function agentWorkMoved(defaultEngine, runtimes, harness) {
|
|
193
|
+
if (defaultEngine !== undefined) {
|
|
194
|
+
return runtimes.has(defaultEngine) && harness !== undefined
|
|
195
|
+
? `Agent work ran on akm's own runtime (engines.${defaultEngine}); with the harness "${harness}" configured it now runs there. Remove that harness to keep agent work on akm's runtime.`
|
|
196
|
+
: undefined;
|
|
197
|
+
}
|
|
198
|
+
const ran = "Without defaults.engine, agent work ran on your own OpenCode (the implicit fallback), when it was installed";
|
|
199
|
+
return harness !== undefined
|
|
200
|
+
? `${ran}; with the harness "${harness}" configured it now runs there.`
|
|
201
|
+
: `${ran}; it now runs on akm's own runtime. To keep it on your OpenCode, enable the harness: \`akm setup --harness opencode\`.`;
|
|
202
|
+
}
|
|
164
203
|
/** `defaults.llmEngine`, else `defaults.engine`: that connection is `default`; `defaults.engine` naming one of several harnesses: that harness is. */
|
|
165
204
|
function nameDefaults(defaults, converted, conversion, written) {
|
|
166
|
-
const { connections, harnesses, runtimes, keyHolders } = converted;
|
|
167
|
-
const
|
|
205
|
+
const { connections, harnesses, runtimes, sdkHarnesses, keyHolders } = converted;
|
|
206
|
+
const implicit = sdkHarnesses.has(IMPLICIT_AGENT_ENGINE) || runtimes.has(IMPLICIT_AGENT_ENGINE);
|
|
207
|
+
const defaultEngine = str(defaults.engine) ?? (implicit ? IMPLICIT_AGENT_ENGINE : undefined);
|
|
208
|
+
const modelWorkEngine = str(defaults.llmEngine);
|
|
168
209
|
const runtimeLlm = defaultEngine === undefined ? undefined : runtimes.get(defaultEngine);
|
|
169
|
-
const
|
|
210
|
+
const modelWorkRuntimeLlm = modelWorkEngine === undefined ? undefined : runtimes.get(modelWorkEngine);
|
|
211
|
+
const defaultConnection = [modelWorkEngine, modelWorkRuntimeLlm, defaultEngine, runtimeLlm].find((n) => n !== undefined && n in connections);
|
|
170
212
|
const defaultHarness = defaultEngine !== undefined && defaultEngine in harnesses && Object.keys(harnesses).length > 1
|
|
171
213
|
? defaultEngine
|
|
172
214
|
: undefined;
|
|
@@ -177,13 +219,18 @@ function nameDefaults(defaults, converted, conversion, written) {
|
|
|
177
219
|
connections: renamed(connections, connectionName),
|
|
178
220
|
harnesses: renamed(harnesses, harnessName),
|
|
179
221
|
};
|
|
180
|
-
const defaultHarnessName =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
const defaultHarnessName = defaultName(named.harnesses);
|
|
223
|
+
const hasEngines = Object.keys(connections).length + Object.keys(harnesses).length + runtimes.size > 0;
|
|
224
|
+
const moved = hasEngines ? agentWorkMoved(defaultEngine, runtimes, defaultHarnessName) : undefined;
|
|
225
|
+
if (moved !== undefined)
|
|
226
|
+
conversion.notes.push(moved);
|
|
227
|
+
// `defaults.llmEngine` could name an agent engine, which then ran akm's model work; model work runs on the default
|
|
228
|
+
// connection, else the default harness, whichever engine that was.
|
|
229
|
+
if (modelWorkEngine !== undefined && modelWorkEngine in harnesses) {
|
|
230
|
+
const now = harnessName(modelWorkEngine);
|
|
231
|
+
if (defaultName({ ...named.connections, ...written.connections }) !== undefined || defaultHarnessName !== now) {
|
|
232
|
+
conversion.notes.push(`defaults.llmEngine "${modelWorkEngine}" named an agent engine, which ran akm's model work. Model work now runs on the default connection, else the default harness; to keep it on the harness "${now}", set improve.harness to it.`);
|
|
233
|
+
}
|
|
187
234
|
}
|
|
188
235
|
for (const [kind, rename] of [
|
|
189
236
|
["connections", connectionName],
|
|
@@ -195,10 +242,9 @@ function nameDefaults(defaults, converted, conversion, written) {
|
|
|
195
242
|
conversion.notes.push(`engines.default is now ${kind}.${aside}, and "default" names another engine: an engine: default key is rewritten by \`akm migrate apply\` where it can be, but a script or scheduled command that passes --engine default now reaches the new "default"; change it to the name ${aside}.`);
|
|
196
243
|
}
|
|
197
244
|
const target = (engine) => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return runtime
|
|
201
|
-
}
|
|
245
|
+
const runtime = runtimes.get(engine);
|
|
246
|
+
if (runtime !== undefined)
|
|
247
|
+
return runtime in connections ? { connection: connectionName(runtime) } : undefined;
|
|
202
248
|
if (engine in harnesses)
|
|
203
249
|
return { harness: harnessName(engine) };
|
|
204
250
|
if (engine in connections)
|
|
@@ -210,7 +256,7 @@ function nameDefaults(defaults, converted, conversion, written) {
|
|
|
210
256
|
connection: connectionName(engine),
|
|
211
257
|
field,
|
|
212
258
|
}));
|
|
213
|
-
return { defaultConnection, defaultHarness, connectionName, harnessName, ...named, keys, target };
|
|
259
|
+
return { defaultEngine, defaultConnection, defaultHarness, connectionName, harnessName, ...named, keys, target };
|
|
214
260
|
}
|
|
215
261
|
/** What the file writes under `connections` / `harnesses` itself, read with the converted entries: the written entry wins field by field. */
|
|
216
262
|
function mergeWritten(out, naming) {
|
|
@@ -230,6 +276,24 @@ function ownModelsOf(converted, naming) {
|
|
|
230
276
|
}
|
|
231
277
|
return ownModels;
|
|
232
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Where a gate's judge and akm's other model work run now when nothing names a place: the default connection, else
|
|
281
|
+
* the default harness. With no `defaults.llmEngine`, 0.9 ran none of that work unless a setting named an engine, so
|
|
282
|
+
* a default harness that takes it now gets a note saying so and how to turn it off.
|
|
283
|
+
*/
|
|
284
|
+
function modelWorkTarget(defaults, naming, merged, inherited, conversion) {
|
|
285
|
+
const connection = defaultName({ ...inherited?.connections, ...merged.connections });
|
|
286
|
+
if (connection !== undefined)
|
|
287
|
+
return { connection };
|
|
288
|
+
const harness = defaultName({ ...inherited?.harnesses, ...merged.harnesses });
|
|
289
|
+
const entry = harness === undefined ? undefined : merged.harnesses[harness];
|
|
290
|
+
if (harness === undefined || !isRecord(entry))
|
|
291
|
+
return undefined;
|
|
292
|
+
if (str(defaults.llmEngine) === undefined && harness in naming.harnesses && entry.modelWork === undefined) {
|
|
293
|
+
conversion.notes.push(`harnesses.${harness}: akm's own model work (improve, index passes, extraction, judges) now runs on this harness, the default for it with no connection. Without defaults.llmEngine, 0.9 ran none of it unless a setting named an engine (a workflow gate's judge ran on defaults.engine). To keep the 0.9 behaviour, set \`modelWork: false\` on this harness: a call that names it still runs there, but a workflow gate's judge then needs a connection (\`akm setup --endpoint URL\`).`);
|
|
294
|
+
}
|
|
295
|
+
return entry.modelWork === false ? undefined : { harness };
|
|
296
|
+
}
|
|
233
297
|
// ── the whole conversion ─────────────────────────────────────────────────────
|
|
234
298
|
export function convertEngineConfig(raw, options = {}) {
|
|
235
299
|
const out = structuredClone(raw);
|
|
@@ -254,8 +318,9 @@ export function convertEngineConfig(raw, options = {}) {
|
|
|
254
318
|
inherited: { connections: options.inherited?.connections ?? {}, harnesses: options.inherited?.harnesses ?? {} },
|
|
255
319
|
renamed: {
|
|
256
320
|
runtime: [...converted.runtimes.values()]
|
|
257
|
-
.filter((name) => name
|
|
321
|
+
.filter((name) => name in converted.connections)
|
|
258
322
|
.map((name) => naming.connectionName(name)),
|
|
323
|
+
sdkHarness: [...converted.sdkHarnesses].map((name) => naming.harnessName(name)),
|
|
259
324
|
connection: (engine) => (engine in converted.connections ? naming.connectionName(engine) : undefined),
|
|
260
325
|
},
|
|
261
326
|
});
|
|
@@ -265,7 +330,8 @@ export function convertEngineConfig(raw, options = {}) {
|
|
|
265
330
|
if (isRecord(improve))
|
|
266
331
|
moveStrategyExecution(improve, conversion, retarget);
|
|
267
332
|
improveStrategyDefault(out, defaults, improve);
|
|
268
|
-
|
|
333
|
+
const judge = modelWorkTarget(defaults, naming, merged, options.inherited, conversion);
|
|
334
|
+
dropJudgeEngine(out, conversion, naming.target, naming.defaultEngine, judge);
|
|
269
335
|
convertIndexEngines(out, conversion, retarget);
|
|
270
336
|
// Assemble, keeping the file's key order.
|
|
271
337
|
const config = {};
|
|
@@ -44,6 +44,7 @@ function overlayRows(modelsOverlay) {
|
|
|
44
44
|
/**
|
|
45
45
|
* `models.json`: an alias is set by, nearest first, the config's own `models`, models.json's column for that
|
|
46
46
|
* connection or harness, models.json's `*` column (and the flat shorthand, which is that), the engine's own model.
|
|
47
|
+
* The exception is a harness engine's own model: it ran when a request named none, so it stands over every column.
|
|
47
48
|
*/
|
|
48
49
|
export function foldModelsOverlay(modelsOverlay, fold) {
|
|
49
50
|
const { conversion, merged, written, inherited, ownModels, renamed } = fold;
|
|
@@ -57,6 +58,10 @@ export function foldModelsOverlay(modelsOverlay, fold) {
|
|
|
57
58
|
/** Whether the config itself, the file or its base, already names a model for the alias. */
|
|
58
59
|
const configNames = (own, base, name, alias) => hasModel(own[name], alias) || hasModel(base[name], alias);
|
|
59
60
|
const platformOf = (name) => String((mergedHarnesses[name] ?? inheritedHarnesses[name])?.platform ?? name);
|
|
61
|
+
/** The column of models.json that named a harness: an `opencode-sdk` engine's was `opencode-sdk`, not its platform's. */
|
|
62
|
+
const columnOf = (name) => (renamed.sdkHarness.includes(name) ? "opencode-sdk" : platformOf(name));
|
|
63
|
+
/** Harnesses whose engine had a `model` of its own, which names every alias and so stands over the column (noted once each). */
|
|
64
|
+
const notedOwn = new Set();
|
|
60
65
|
let folded = false;
|
|
61
66
|
for (const { alias, column, profile } of overlayRows(modelsOverlay)) {
|
|
62
67
|
const p = typeof profile === "string" ? { model: profile } : isRecord(profile) ? profile : {};
|
|
@@ -76,7 +81,7 @@ export function foldModelsOverlay(modelsOverlay, fold) {
|
|
|
76
81
|
: column in writtenConnections || column in inheritedConnections
|
|
77
82
|
? [column]
|
|
78
83
|
: [];
|
|
79
|
-
const harnessNamed = [...new Set([...Object.keys(mergedHarnesses), ...Object.keys(inheritedHarnesses)])].filter((name) =>
|
|
84
|
+
const harnessNamed = [...new Set([...Object.keys(mergedHarnesses), ...Object.keys(inheritedHarnesses)])].filter((name) => columnOf(name) === column);
|
|
80
85
|
if (candidates.length === 0 && harnessNamed.length === 0) {
|
|
81
86
|
notes.push(`${where}: names no configured connection or harness, so it is not carried over.`);
|
|
82
87
|
continue;
|
|
@@ -84,7 +89,14 @@ export function foldModelsOverlay(modelsOverlay, fold) {
|
|
|
84
89
|
// The config's own choice stands, and `*` leaves what an explicit column already set.
|
|
85
90
|
const connectionNames = candidates.filter((name) => !configNames(writtenConnections, inheritedConnections, name, alias) &&
|
|
86
91
|
!(column === "*" && hasModel(mergedConnections[name], alias)));
|
|
87
|
-
const
|
|
92
|
+
const unnamed = harnessNamed.filter((name) => !configNames(writtenHarnesses, inheritedHarnesses, name, alias));
|
|
93
|
+
// The model a harness engine named is what ran when a request named none, and a harness runs its first model then,
|
|
94
|
+
// so it stands over the column, which would put the column's models first.
|
|
95
|
+
const harnessNames = unnamed.filter((name) => !ownModels.harnesses.has(name));
|
|
96
|
+
for (const name of unnamed.filter((n) => ownModels.harnesses.has(n) && !notedOwn.has(`${column}:${n}`))) {
|
|
97
|
+
notedOwn.add(`${column}:${name}`);
|
|
98
|
+
notes.push(`models.json (${column === "*" ? "all" : column}): harness "${name}" has a model of its own, which names every alias, so the column is not carried over.`);
|
|
99
|
+
}
|
|
88
100
|
if (connectionNames.length === 0 && harnessNames.length === 0) {
|
|
89
101
|
// Only the config's own choice is worth a note; an explicit column that came first is the overlay's own.
|
|
90
102
|
const keptByConfig = candidates.some((name) => configNames(writtenConnections, inheritedConnections, name, alias)) ||
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* The command comes from the harness (`harness.build`); this module never
|
|
15
15
|
* touches a model. Agents are reachable only via shell-out.
|
|
16
16
|
*/
|
|
17
|
+
import path from "node:path";
|
|
17
18
|
import { ConfigError } from "../../core/errors.js";
|
|
18
19
|
import { childProcessEnv } from "../../core/paths.js";
|
|
19
20
|
import { runManagedSubprocess, streamCaptureFailure } from "../../core/subprocess.js";
|
|
@@ -86,10 +87,14 @@ export async function runBuiltCommand(label, { argv, env, stdin }, options, stdi
|
|
|
86
87
|
// and the optional stdin payload. This keeps the agent-specific surface:
|
|
87
88
|
// the failure taxonomy.
|
|
88
89
|
const start = Date.now();
|
|
90
|
+
// A program started after `cd <dir>` has PWD equal to <dir>, and OpenCode takes its directory from PWD before its
|
|
91
|
+
// own working directory: the PWD akm inherits names where akm was started, so a run given a directory names it. One
|
|
92
|
+
// path is both, so they agree even where the kernel would resolve a `..` after a symbolic link elsewhere.
|
|
93
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : undefined;
|
|
89
94
|
const result = await runManagedSubprocess([...argv], {
|
|
90
95
|
capture: stdioMode === "captured",
|
|
91
|
-
env: { ...env },
|
|
92
|
-
...(
|
|
96
|
+
env: { ...env, ...(cwd ? { PWD: cwd } : {}) },
|
|
97
|
+
...(cwd ? { cwd } : {}),
|
|
93
98
|
...(stdin !== undefined ? { stdin } : {}),
|
|
94
99
|
timeoutMs,
|
|
95
100
|
...(options.signal ? { signal: options.signal } : {}),
|
|
@@ -60,9 +60,17 @@ const MODEL_WORK_AGENT_DEFINITION = {
|
|
|
60
60
|
],
|
|
61
61
|
};
|
|
62
62
|
const CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT";
|
|
63
|
+
/**
|
|
64
|
+
* OpenCode's built-in browser plugin, removed from model work (a `-` entry in `plugins` removes a plugin, OpenCode 2.0.0
|
|
65
|
+
* and later). From 2.2.0 it appends a deny of its own skill after every agent's rules, and a rule on one skill keeps
|
|
66
|
+
* the `skill` tool listed for an agent that denies every tool; without the plugin, the `*` deny removes the tool again.
|
|
67
|
+
* Model work denies the browser anyway.
|
|
68
|
+
*/
|
|
69
|
+
const WITHOUT_BROWSER_PLUGIN = "-opencode.browser";
|
|
63
70
|
/**
|
|
64
71
|
* The inline config model work runs with: the user's own `OPENCODE_CONFIG_CONTENT` as it is, with akm's agent set on
|
|
65
|
-
* `agents.akm-model-work`, the one key akm's definition wins
|
|
72
|
+
* `agents.akm-model-work`, the one key akm's definition wins, and {@link WITHOUT_BROWSER_PLUGIN} after the user's
|
|
73
|
+
* `plugins`. OpenCode 2 reads that native `agents` map over the
|
|
66
74
|
* legacy `agent` one, so a same-named entry the user keeps in `agent` is left out rather than left to conflict. The
|
|
67
75
|
* child's value is built from the parent's, so a provider, model or key the user keeps only there stays. It is read as
|
|
68
76
|
* OpenCode reads it, as JSONC (comments and trailing commas), and written back as JSON; content that reads only once
|
|
@@ -78,6 +86,7 @@ function modelWorkConfig() {
|
|
|
78
86
|
}
|
|
79
87
|
const config = {
|
|
80
88
|
...user,
|
|
89
|
+
plugins: [...(Array.isArray(user?.plugins) ? user.plugins : []), WITHOUT_BROWSER_PLUGIN],
|
|
81
90
|
agents: { ...asRecord(user?.agents), [MODEL_WORK_AGENT]: MODEL_WORK_AGENT_DEFINITION },
|
|
82
91
|
};
|
|
83
92
|
const legacy = asRecord(user?.agent);
|
|
@@ -25,10 +25,13 @@ import { info } from "../../../../core/warn.js";
|
|
|
25
25
|
* The OpenCode release akm's own runtime runs. It is pinned because the runtime depends on things no OpenCode release
|
|
26
26
|
* promises: the inline config's shape, the `--standalone` flag, the plugin API, the text `run` prints, and the
|
|
27
27
|
* `opencode.db` tables that `../session-log-v2.ts` reads. To move it, change this version and `PACKAGES` if the release
|
|
28
|
-
* adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts`
|
|
29
|
-
*
|
|
28
|
+
* adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts` and
|
|
29
|
+
* `tests/integration/opencode-runtime-loop-guard.test.ts` against the new binary (they fetch it; the plugin ends a
|
|
30
|
+
* looping run by throwing from a hook, which OpenCode might one day handle differently, and tells the model by changing
|
|
31
|
+
* the body of a request, which it might one day stop letting a plugin do) and read
|
|
32
|
+
* `docs/reference/configuration.md` ("Without a harness") for what the version changes.
|
|
30
33
|
*/
|
|
31
|
-
export const OPENCODE_RUNTIME_VERSION = "2.
|
|
34
|
+
export const OPENCODE_RUNTIME_VERSION = "2.2.0";
|
|
32
35
|
/** The platform packages of `@opencode/cli` at {@link OPENCODE_RUNTIME_VERSION}. */
|
|
33
36
|
const PACKAGES = new Set([
|
|
34
37
|
"@opencode/cli-linux-x64",
|
|
@@ -23,7 +23,7 @@ import { openDatabase } from "../../../../storage/database.js";
|
|
|
23
23
|
import { resolveAkmInvocation } from "../../../../tasks/resolve-akm-bin.js";
|
|
24
24
|
import { buildChildEnv, runBuiltCommand, } from "../../../agent/spawn.js";
|
|
25
25
|
import { akmOpencodeDatabasePath, akmRuntimeDir } from "../roots.js";
|
|
26
|
-
import { v2SessionTokens } from "../session-log-v2.js";
|
|
26
|
+
import { v2SessionFailure, v2SessionTokens } from "../session-log-v2.js";
|
|
27
27
|
import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION, runtimeBinaryPath } from "./binary.js";
|
|
28
28
|
import { runtimeArgv, runtimeConfig, runtimeEnv, runtimePrepareArgv } from "./command.js";
|
|
29
29
|
/**
|
|
@@ -61,10 +61,11 @@ function installPlugin(directory) {
|
|
|
61
61
|
/**
|
|
62
62
|
* OpenCode makes and migrates its database when a process starts, and two processes doing that to one database break
|
|
63
63
|
* each other (one fails with "Standalone server exited before reporting readiness"; on 2.1.4 two runs started together
|
|
64
|
-
* on a fresh directory lose one of them every time
|
|
65
|
-
* its own, under a lock file in the runtime directory that every
|
|
66
|
-
* leaves a marker naming the version; a run that finds the marker
|
|
67
|
-
* lock is reclaimed. The run itself reports anything the preparation
|
|
64
|
+
* on a fresh directory lose one of them every time; on 2.2.0, 4 of 24 starts in groups of eight failed). So the first
|
|
65
|
+
* start of this OpenCode version is a preparing run of its own, under a lock file in the runtime directory that every
|
|
66
|
+
* run, of this akm process or another, waits on, and it leaves a marker naming the version; a run that finds the marker
|
|
67
|
+
* and the database starts at once. A dead holder's lock is reclaimed. The run itself reports anything the preparation
|
|
68
|
+
* does not.
|
|
68
69
|
*/
|
|
69
70
|
async function prepareDatabase(directory, command, opts) {
|
|
70
71
|
const marker = path.join(directory, `prepared-${OPENCODE_RUNTIME_VERSION}`);
|
|
@@ -100,9 +101,9 @@ async function prepareDatabase(directory, command, opts) {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
/**
|
|
103
|
-
* The run's session as OpenCode recorded it, read once the process has ended: its id
|
|
104
|
-
*
|
|
105
|
-
*
|
|
104
|
+
* The run's session as OpenCode recorded it, read once the process has ended: its id, the tokens its turn used, which a
|
|
105
|
+
* workflow's `budget.max_tokens` and the unit journal count, and the message its turn failed with, if it did. Nothing
|
|
106
|
+
* when the run made no session. Best-effort: a database that cannot be read leaves the run's result as it is.
|
|
106
107
|
*/
|
|
107
108
|
function sessionRecord(sessionId) {
|
|
108
109
|
try {
|
|
@@ -111,9 +112,11 @@ function sessionRecord(sessionId) {
|
|
|
111
112
|
const tokens = v2SessionTokens(db, sessionId);
|
|
112
113
|
if (!tokens)
|
|
113
114
|
return {};
|
|
115
|
+
const failure = v2SessionFailure(db, sessionId);
|
|
114
116
|
return {
|
|
115
117
|
sessionId,
|
|
116
118
|
usage: { inputTokens: tokens.input, outputTokens: tokens.output, reasoningTokens: tokens.reasoning },
|
|
119
|
+
...(failure ? { failure } : {}),
|
|
117
120
|
};
|
|
118
121
|
}
|
|
119
122
|
finally {
|
|
@@ -160,7 +163,13 @@ export const opencodeRuntime = {
|
|
|
160
163
|
writeKeyFile(keyFile, connection.apiKey);
|
|
161
164
|
await prepareDatabase(directory, { argv: runtimePrepareArgv(binary), env }, opts);
|
|
162
165
|
const result = await runBuiltCommand(label, { argv: runtimeArgv(connection, dispatch, binary, session), env, stdin: dispatch.prompt }, opts, "captured");
|
|
163
|
-
|
|
166
|
+
const { failure, ...record } = sessionRecord(session);
|
|
167
|
+
return {
|
|
168
|
+
...result,
|
|
169
|
+
...record,
|
|
170
|
+
// OpenCode prints why the turn failed on stderr, which a workflow does not keep; the error carries it.
|
|
171
|
+
...(result.reason === "non_zero_exit" && failure ? { error: `${result.error}: ${failure}` } : {}),
|
|
172
|
+
};
|
|
164
173
|
}
|
|
165
174
|
finally {
|
|
166
175
|
fs.rmSync(keyFile, { force: true });
|