opencode-harness-kit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GLOBAL.md +110 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +1025 -0
- package/dist/index.js +1038 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +146 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +81 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +78 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +161 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +358 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +484 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +481 -0
- package/src/telemetry.ts +98 -0
package/src/prune.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Deterministic context pruning.
|
|
2
|
+
//
|
|
3
|
+
// Measured on one install: 77.9% of stored message bytes were tool output and
|
|
4
|
+
// 15.6% reasoning, against 2.1% of actual text. Cache reads ran 20.6x fresh
|
|
5
|
+
// input, and p50 context per request was ~99k tokens with only ~534 fresh
|
|
6
|
+
// tokens. The session was re-sending old command output on every turn.
|
|
7
|
+
//
|
|
8
|
+
// Why not let the model decide when to compact: the model-driven approach needs
|
|
9
|
+
// the model to remember to call a tool, and this kit targets models with weak
|
|
10
|
+
// instruction following. This hook runs unconditionally and keeps pointers
|
|
11
|
+
// instead of bodies, so nothing is lost, only re-read on demand.
|
|
12
|
+
//
|
|
13
|
+
// Invariants, because this rewrites history on every turn:
|
|
14
|
+
// - never touch the last message
|
|
15
|
+
// - never touch a tool call from the turn in flight
|
|
16
|
+
// - never touch patch or file parts, which carry diffs the model needs
|
|
17
|
+
// - never stub a user message unless asked to
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
20
|
+
import { loadProfile, pruneThreshold, ROOT } from "./profile.js"
|
|
21
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
22
|
+
import { join } from "node:path"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which profile is active. In order: an explicit environment variable, the
|
|
26
|
+
* marker the plugin writes into the project, then a marker inside the package.
|
|
27
|
+
* Returns null when nothing is set, and pruning then stays off rather than
|
|
28
|
+
* guessing at thresholds.
|
|
29
|
+
*/
|
|
30
|
+
function activeProfileId(worktree: string): string | null {
|
|
31
|
+
const candidates = [
|
|
32
|
+
process.env.OPENCODE_HARNESS_PROFILE,
|
|
33
|
+
join(worktree, ".space", "active"),
|
|
34
|
+
join(ROOT, "profiles", ".active"),
|
|
35
|
+
]
|
|
36
|
+
for (const f of candidates) {
|
|
37
|
+
if (!f) continue
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(f)) {
|
|
40
|
+
const v = readFileSync(f, "utf8").trim()
|
|
41
|
+
if (v) return v
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Try the next location.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StubStats {
|
|
51
|
+
stubbed: number
|
|
52
|
+
kept: number
|
|
53
|
+
bytesBefore: number
|
|
54
|
+
bytesAfter: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Rough size of a part, used only for the stats line. */
|
|
58
|
+
function sizeOf(part: any): number {
|
|
59
|
+
if (typeof part?.text === "string") return part.text.length
|
|
60
|
+
if (typeof part?.state?.output === "string") return part.state.output.length
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One line standing in for a tool result. The agent keeps the shape of what
|
|
66
|
+
* happened and a way to get the rest, which is what it actually needs to decide
|
|
67
|
+
* what to do next.
|
|
68
|
+
*/
|
|
69
|
+
function stub(part: any): string {
|
|
70
|
+
const tool = part?.tool ?? "tool"
|
|
71
|
+
const state = part?.state ?? {}
|
|
72
|
+
const input = state.input ?? {}
|
|
73
|
+
|
|
74
|
+
if (tool === "bash") {
|
|
75
|
+
const cmd = String(input.command ?? "").replace(/\s+/g, " ").slice(0, 80)
|
|
76
|
+
const status = state.status ?? "done"
|
|
77
|
+
let tail = ""
|
|
78
|
+
const out = String(state.output ?? "")
|
|
79
|
+
const m = out.match(/(passed|failed|error|Error|ok|OK|\d+ (?:passed|failed))/g)
|
|
80
|
+
if (m && m.length) tail = `, ${m[m.length - 1]}`
|
|
81
|
+
return `[${tool}] ${cmd} -> ${status}${tail} | full output was pruned from context; re-run the command if you need it`
|
|
82
|
+
}
|
|
83
|
+
if (tool === "read" || tool === "grep" || tool === "glob" || tool === "list") {
|
|
84
|
+
const target = String(input.filePath ?? input.path ?? input.pattern ?? "")
|
|
85
|
+
return `[${tool}] ${target.slice(0, 100)} | result pruned from context; re-read it if you need it`
|
|
86
|
+
}
|
|
87
|
+
return `[${tool}] result pruned from context (${state.status ?? "done"}); re-run if you need it`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const PrunePlugin: Plugin = async ({ worktree, $ }) => {
|
|
91
|
+
let profile = null as ReturnType<typeof loadProfile> | null
|
|
92
|
+
try {
|
|
93
|
+
const id = activeProfileId(worktree)
|
|
94
|
+
if (id) profile = loadProfile(id)
|
|
95
|
+
} catch {
|
|
96
|
+
profile = null
|
|
97
|
+
}
|
|
98
|
+
if (!profile?.prune?.enabled) {
|
|
99
|
+
// Pruning is opt-in per profile. With no active profile we do nothing
|
|
100
|
+
// rather than guess at thresholds.
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const keepCalls = profile.prune.keepRecentToolCalls ?? 6
|
|
105
|
+
const keepTurns = profile.prune.keepRecentTurns ?? 3
|
|
106
|
+
const keepUsers = profile.prune.keepUserMessages ?? true
|
|
107
|
+
const threshold = pruneThreshold(profile)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
111
|
+
try {
|
|
112
|
+
const messages = output.messages
|
|
113
|
+
if (!Array.isArray(messages) || messages.length === 0) return
|
|
114
|
+
|
|
115
|
+
// The trailing keepTurns are off limits, and the last message always is.
|
|
116
|
+
const floor = Math.max(0, messages.length - 1 - keepTurns)
|
|
117
|
+
|
|
118
|
+
// Walk backwards collecting tool parts to preserve verbatim.
|
|
119
|
+
// Walk backwards from the end until keepCalls tool parts are collected.
|
|
120
|
+
// This is deliberately not bounded by `floor`: how many recent calls
|
|
121
|
+
// stay verbatim and which turns are prunable are independent limits,
|
|
122
|
+
// and a call can be both recent and inside the prunable span.
|
|
123
|
+
const keepToolMessageIds = new Set<string>()
|
|
124
|
+
let seen = 0
|
|
125
|
+
for (let i = messages.length - 1; i >= 0 && seen < keepCalls; i--) {
|
|
126
|
+
const msg = messages[i]
|
|
127
|
+
if (!msg?.parts) continue
|
|
128
|
+
for (const part of msg.parts) {
|
|
129
|
+
if (part?.type !== "tool") continue
|
|
130
|
+
if (seen >= keepCalls) break
|
|
131
|
+
keepToolMessageIds.add(msg.info?.id ?? String(i))
|
|
132
|
+
seen++
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stats: StubStats = {
|
|
137
|
+
stubbed: 0,
|
|
138
|
+
kept: 0,
|
|
139
|
+
bytesBefore: 0,
|
|
140
|
+
bytesAfter: 0,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < floor; i++) {
|
|
144
|
+
const msg = messages[i]
|
|
145
|
+
if (!msg?.parts) continue
|
|
146
|
+
const isUser = msg.info?.role === "user"
|
|
147
|
+
for (const part of msg.parts) {
|
|
148
|
+
const size = sizeOf(part)
|
|
149
|
+
stats.bytesBefore += size
|
|
150
|
+
|
|
151
|
+
if (isUser && keepUsers) {
|
|
152
|
+
stats.bytesAfter += size
|
|
153
|
+
stats.kept++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (keepToolMessageIds.has(msg.info?.id ?? String(i))) {
|
|
157
|
+
stats.bytesAfter += size
|
|
158
|
+
stats.kept++
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
switch (part?.type) {
|
|
162
|
+
case "patch":
|
|
163
|
+
case "file":
|
|
164
|
+
// Diffs and file bodies are load-bearing; never stub them.
|
|
165
|
+
stats.bytesAfter += size
|
|
166
|
+
stats.kept++
|
|
167
|
+
break
|
|
168
|
+
case "reasoning":
|
|
169
|
+
// Old reasoning is stale by definition and safe to drop.
|
|
170
|
+
stats.bytesAfter += Math.min(size, 200)
|
|
171
|
+
stats.stubbed++
|
|
172
|
+
break
|
|
173
|
+
case "tool": {
|
|
174
|
+
// Only completed calls carry an output worth pruning. Pending
|
|
175
|
+
// and running states are small, and error states are usually
|
|
176
|
+
// the most useful thing in the transcript.
|
|
177
|
+
const state = part.state
|
|
178
|
+
if (state?.status !== "completed") {
|
|
179
|
+
stats.bytesAfter += size
|
|
180
|
+
stats.kept++
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
part.state = {
|
|
184
|
+
status: "completed",
|
|
185
|
+
input: state.input,
|
|
186
|
+
output: stub(part),
|
|
187
|
+
title: state.title,
|
|
188
|
+
time: state.time,
|
|
189
|
+
metadata: { ...(state.metadata ?? {}), pruned: true, prunedAt: new Date().toISOString() },
|
|
190
|
+
}
|
|
191
|
+
stats.bytesAfter += sizeOf(part)
|
|
192
|
+
stats.stubbed++
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
case "text":
|
|
196
|
+
// Assistant prose carries the reasoning chain a human reads and
|
|
197
|
+
// the model later relies on. Keep it.
|
|
198
|
+
stats.bytesAfter += size
|
|
199
|
+
stats.kept++
|
|
200
|
+
break
|
|
201
|
+
default:
|
|
202
|
+
stats.bytesAfter += size
|
|
203
|
+
stats.kept++
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (stats.stubbed > 0) {
|
|
209
|
+
const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter)
|
|
210
|
+
await logPrune(profile.id, {
|
|
211
|
+
...stats,
|
|
212
|
+
saved,
|
|
213
|
+
threshold,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// A failed prune must never break the turn. Skipping is recoverable;
|
|
218
|
+
// corrupting history is not.
|
|
219
|
+
console.error(
|
|
220
|
+
`[opencode-harness-kit] prune skipped: ${
|
|
221
|
+
err instanceof Error ? err.message : String(err)
|
|
222
|
+
}`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function logPrune(
|
|
230
|
+
profileId: string,
|
|
231
|
+
stats: StubStats & {
|
|
232
|
+
saved: number
|
|
233
|
+
threshold: number
|
|
234
|
+
},
|
|
235
|
+
) {
|
|
236
|
+
const dir = join(process.cwd(), ".space")
|
|
237
|
+
try {
|
|
238
|
+
const { mkdir, appendFile } = await import("node:fs/promises")
|
|
239
|
+
await mkdir(dir, { recursive: true })
|
|
240
|
+
await appendFile(
|
|
241
|
+
join(dir, "prune.jsonl"),
|
|
242
|
+
JSON.stringify({
|
|
243
|
+
t: new Date().toISOString(),
|
|
244
|
+
profile: profileId,
|
|
245
|
+
stubbed: stats.stubbed,
|
|
246
|
+
kept: stats.kept,
|
|
247
|
+
savedBytes: stats.saved,
|
|
248
|
+
threshold: stats.threshold,
|
|
249
|
+
}) + "\n",
|
|
250
|
+
)
|
|
251
|
+
} catch {
|
|
252
|
+
// Telemetry must never be load-bearing.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const pruneMeta = { note: "opt-in via profile prune.enabled" }
|
|
257
|
+
|
|
258
|
+
export default PrunePlugin
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Model fallback across a profile's queue.
|
|
2
|
+
//
|
|
3
|
+
// The honest version of this feature: opencode has no hook that can change a
|
|
4
|
+
// live session's model. `chat.params` only sees temperature and sampling, the
|
|
5
|
+
// model on `chat.message` is read-only, and `config` runs once at startup. So
|
|
6
|
+
// failover here means two things, and only two:
|
|
7
|
+
//
|
|
8
|
+
// 1. At startup, if a health file marks the preferred model unhealthy, pick
|
|
9
|
+
// the next healthy profile before the session begins.
|
|
10
|
+
// 2. When a request fails, tell the agent plainly that the route is spent and
|
|
11
|
+
// a new session is needed, instead of letting it retry into a wall.
|
|
12
|
+
//
|
|
13
|
+
// Retry-on-429 is deliberately not attempted. Provider issue #42977 documents
|
|
14
|
+
// free models returning FreeUsageLimitError for ten days with no recovery, and
|
|
15
|
+
// the maintainers' own guidance is to mark the route unhealthy rather than
|
|
16
|
+
// retry it. The daily window resets at 00:00 UTC, so waiting inside a session
|
|
17
|
+
// is the wrong shape anyway.
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
import {
|
|
23
|
+
loadProfile,
|
|
24
|
+
pruneThreshold,
|
|
25
|
+
profileIdFromConfig,
|
|
26
|
+
reservedTokens,
|
|
27
|
+
resolveEffort,
|
|
28
|
+
} from "./profile.js"
|
|
29
|
+
|
|
30
|
+
interface Health {
|
|
31
|
+
unhealthy: Record<string, { at: string; reason: string }>
|
|
32
|
+
active?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function healthPath(worktree: string): string {
|
|
36
|
+
return join(worktree, ".space", "health.json")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readHealth(worktree: string): Health {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(healthPath(worktree), "utf8"))
|
|
42
|
+
} catch {
|
|
43
|
+
return { unhealthy: {} }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeHealth(worktree: string, h: Health) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(join(worktree, ".space"), { recursive: true })
|
|
50
|
+
writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2))
|
|
51
|
+
} catch {
|
|
52
|
+
// Best effort.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Patterns that mean "this route is spent", not "this request was bad". */
|
|
57
|
+
const SPENT = [
|
|
58
|
+
/FreeUsageLimitError/i,
|
|
59
|
+
/rate[_ -]?limit/i,
|
|
60
|
+
/\b429\b/,
|
|
61
|
+
/quota (?:exceeded|exhausted)/i,
|
|
62
|
+
/overloaded_error/i,
|
|
63
|
+
/model .*not found/i,
|
|
64
|
+
/no available provider/i,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function classify(text: string): string | null {
|
|
68
|
+
for (const p of SPENT) if (p.test(text)) return p.source
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const WARN_TEXT =
|
|
73
|
+
"[opencode-harness-kit] This session's model route is rate limited or withdrawn. " +
|
|
74
|
+
"No plugin can swap a live session's model, so stop retrying and ask the human to start a " +
|
|
75
|
+
"new session. Run `oc-harness list` to see the fallback queue, or `oc-harness doctor` to " +
|
|
76
|
+
"check whether the profile still matches live metadata."
|
|
77
|
+
|
|
78
|
+
export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
79
|
+
let failedSessions = 0
|
|
80
|
+
let warned = false
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
// Runs once, before the session does any work.
|
|
84
|
+
config: async (config) => {
|
|
85
|
+
try {
|
|
86
|
+
const cfg = config as Record<string, any>
|
|
87
|
+
const health = readHealth(worktree)
|
|
88
|
+
|
|
89
|
+
// config.harness is stripped by schema validation, so the active
|
|
90
|
+
// profile comes from the config file on disk.
|
|
91
|
+
const activeId = cfg.harness?.profile ?? profileIdFromConfig()
|
|
92
|
+
if (activeId) {
|
|
93
|
+
const id: string = activeId
|
|
94
|
+
const mark = health.unhealthy[`opencode/${id}`]
|
|
95
|
+
if (mark) {
|
|
96
|
+
// Walk the queue for the first profile that is not marked down.
|
|
97
|
+
let chosen: string | null = id
|
|
98
|
+
try {
|
|
99
|
+
const queue = [id, ...(loadProfile(id).fallback ?? [])]
|
|
100
|
+
for (const candidate of queue) {
|
|
101
|
+
if (!health.unhealthy[`opencode/${candidate}`]) {
|
|
102
|
+
chosen = candidate
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// A broken profile must not stop opencode from starting.
|
|
108
|
+
}
|
|
109
|
+
if (chosen !== id) {
|
|
110
|
+
applyProfileToConfig(cfg, chosen)
|
|
111
|
+
health.active = chosen
|
|
112
|
+
writeHealth(worktree, health)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Startup must not fail because of the router.
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
event: async ({ event }) => {
|
|
122
|
+
const e = event as any
|
|
123
|
+
const type = e?.type
|
|
124
|
+
if (!type) return
|
|
125
|
+
|
|
126
|
+
const text = JSON.stringify(e)
|
|
127
|
+
const reason = classify(text)
|
|
128
|
+
if (!reason) return
|
|
129
|
+
|
|
130
|
+
// Only mark the model this session is actually using.
|
|
131
|
+
const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID
|
|
132
|
+
const key = modelID ? `opencode/${modelID}` : undefined
|
|
133
|
+
if (!key) return
|
|
134
|
+
|
|
135
|
+
const health = readHealth(worktree)
|
|
136
|
+
if (health.unhealthy[key]) return // already recorded, do not spam
|
|
137
|
+
health.unhealthy[key] = { at: new Date().toISOString(), reason }
|
|
138
|
+
writeHealth(worktree, health)
|
|
139
|
+
failedSessions++
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
"chat.message": async (input, output) => {
|
|
143
|
+
// If this session has already hit a spent route, say so once, early,
|
|
144
|
+
// instead of letting the agent burn turns discovering it.
|
|
145
|
+
if (failedSessions === 0) return
|
|
146
|
+
if (warned) return
|
|
147
|
+
warned = true
|
|
148
|
+
|
|
149
|
+
// Appending to an existing text part avoids fabricating a Part, which
|
|
150
|
+
// needs id/sessionID/messageID this hook has no good source for.
|
|
151
|
+
const parts = output.parts ?? []
|
|
152
|
+
const last = parts[parts.length - 1]
|
|
153
|
+
if (last?.type === "text") {
|
|
154
|
+
last.text = `${last.text}\n\n${WARN_TEXT}`
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Nothing to attach to: the health file and telemetry still record it.
|
|
158
|
+
writeHealth(worktree, readHealth(worktree))
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Point the config at another profile, including its derived numbers. */
|
|
164
|
+
function applyProfileToConfig(cfg: Record<string, any>, profileId: string) {
|
|
165
|
+
try {
|
|
166
|
+
const p = loadProfile(profileId)
|
|
167
|
+
const model = `${p.provider}/${p.model}`
|
|
168
|
+
cfg.model = model
|
|
169
|
+
cfg.small_model = model
|
|
170
|
+
const variant = p.effort?.default
|
|
171
|
+
if (p.effort?.levels?.length && variant) cfg.variant = variant
|
|
172
|
+
else delete cfg.variant
|
|
173
|
+
|
|
174
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
175
|
+
const agent = cfg.agent ?? (cfg.agent = {})
|
|
176
|
+
const existing = agent[role] ?? (agent[role] = {})
|
|
177
|
+
existing.model = model
|
|
178
|
+
const level = resolveEffort(p.effort?.levels ?? [], want)
|
|
179
|
+
if (level) existing.variant = level
|
|
180
|
+
else delete existing.variant
|
|
181
|
+
}
|
|
182
|
+
if (cfg.compaction) {
|
|
183
|
+
cfg.compaction.prune = true
|
|
184
|
+
cfg.compaction.reserved = reservedTokens(p)
|
|
185
|
+
}
|
|
186
|
+
cfg.harness = { ...(cfg.harness ?? {}), profile: profileId, threshold: pruneThreshold(p) }
|
|
187
|
+
} catch {
|
|
188
|
+
// Leave the config alone if the fallback profile cannot be read.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default RouterPlugin
|