@pfoundation/ocadvisor 26.9.0 → 26.9.2

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.
Files changed (52) hide show
  1. package/README.md +420 -43
  2. package/dist/artificialAnalysis.d.ts +4 -0
  3. package/dist/artificialAnalysis.d.ts.map +1 -0
  4. package/dist/artificialAnalysis.js +77 -0
  5. package/dist/artificialAnalysis.js.map +1 -0
  6. package/dist/benchmarkConfig.d.ts +19 -0
  7. package/dist/benchmarkConfig.d.ts.map +1 -0
  8. package/dist/benchmarkConfig.js +58 -0
  9. package/dist/benchmarkConfig.js.map +1 -0
  10. package/dist/benchmarkEvidence.d.ts +37 -0
  11. package/dist/benchmarkEvidence.d.ts.map +1 -0
  12. package/dist/benchmarkEvidence.js +81 -0
  13. package/dist/benchmarkEvidence.js.map +1 -0
  14. package/dist/benchmarkMatch.d.ts +57 -0
  15. package/dist/benchmarkMatch.d.ts.map +1 -0
  16. package/dist/benchmarkMatch.js +236 -0
  17. package/dist/benchmarkMatch.js.map +1 -0
  18. package/dist/benchmarkStore.d.ts +44 -0
  19. package/dist/benchmarkStore.d.ts.map +1 -0
  20. package/dist/benchmarkStore.js +308 -0
  21. package/dist/benchmarkStore.js.map +1 -0
  22. package/dist/benchmarkTypes.d.ts +48 -0
  23. package/dist/benchmarkTypes.d.ts.map +1 -0
  24. package/dist/benchmarkTypes.js +298 -0
  25. package/dist/benchmarkTypes.js.map +1 -0
  26. package/dist/benchmarkUpdate.d.ts +33 -0
  27. package/dist/benchmarkUpdate.d.ts.map +1 -0
  28. package/dist/benchmarkUpdate.js +327 -0
  29. package/dist/benchmarkUpdate.js.map +1 -0
  30. package/dist/cli.d.ts +13 -0
  31. package/dist/cli.d.ts.map +1 -0
  32. package/dist/cli.js +377 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/data/artificialAnalysis.mappings.json +197 -0
  35. package/dist/data/artificialAnalysis.snapshot.json +17684 -0
  36. package/dist/modelProfiles.d.ts +45 -0
  37. package/dist/modelProfiles.d.ts.map +1 -0
  38. package/dist/modelProfiles.js +141 -0
  39. package/dist/modelProfiles.js.map +1 -0
  40. package/dist/ocAdvisor.d.ts +152 -7
  41. package/dist/ocAdvisor.d.ts.map +1 -1
  42. package/dist/ocAdvisor.js +681 -54
  43. package/dist/ocAdvisor.js.map +1 -1
  44. package/dist/typesafeGate.d.ts +89 -0
  45. package/dist/typesafeGate.d.ts.map +1 -0
  46. package/dist/typesafeGate.js +463 -0
  47. package/dist/typesafeGate.js.map +1 -0
  48. package/dist/typesafeState.d.ts +94 -0
  49. package/dist/typesafeState.d.ts.map +1 -0
  50. package/dist/typesafeState.js +301 -0
  51. package/dist/typesafeState.js.map +1 -0
  52. package/package.json +9 -3
package/dist/ocAdvisor.js CHANGED
@@ -1,18 +1,27 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { appendFile } from "fs/promises";
3
3
  import { homedir } from "os";
4
- import { join } from "path";
4
+ import { isAbsolute, join } from "path";
5
+ import { resolveBenchmarkPaths } from "./benchmarkConfig.js";
6
+ import { buildBenchmarkEvidence, } from "./benchmarkEvidence.js";
7
+ import { createBenchmarkStore, } from "./benchmarkStore.js";
8
+ import { resolveAdvisorProfile, resolveRequesterProfile, } from "./modelProfiles.js";
9
+ import { runTypeSafeGate, } from "./typesafeGate.js";
10
+ import { TYPESAFE_DEFAULTS, buildDecisionState, normalizeTypeSafeOptions, resolveTypeSafeConfig, } from "./typesafeState.js";
5
11
  const DB_PATH = join(homedir(), ".local/share/opencode/opencode.db");
6
12
  const METRICS_PATH = join(homedir(), ".local/share/opencode/ocAdvisor-metrics.jsonl");
7
13
  const ADVISOR_PROVIDER = "anthropic";
8
14
  const ADVISOR_MODEL = "claude-fable-5-1";
9
- const ADVISOR_VARIANT = "max";
15
+ const ADVISOR_VARIANT = "xhigh";
10
16
  const ADVISOR_SESSION_TITLE = "advisor";
11
17
  // Sessions created before the ocAdvisor → advisor rename keep working: title
12
18
  // discovery accepts both, and the storage key below is unchanged.
13
19
  const LEGACY_ADVISOR_SESSION_TITLE = "ocAdvisor";
14
20
  const ADVISOR_STORAGE_KEY = "advisorSessionID";
15
21
  const ADVISOR_TIMEOUT_MS = 300_000;
22
+ // Effort levels the agent may request per call when `agentEffort: true`
23
+ // (subset of the model's catalog variants; validated per consultation).
24
+ const AGENT_EFFORT_DEFAULTS = ["high", "xhigh", "max"];
16
25
  const FABLE_DISABLED = "advisor is disabled for anthropic/claude-fable-* sessions — the current model is already Fable.";
17
26
  const DEFAULT_ADVISOR_CONFIG = {
18
27
  provider: ADVISOR_PROVIDER,
@@ -20,6 +29,12 @@ const DEFAULT_ADVISOR_CONFIG = {
20
29
  variant: ADVISOR_VARIANT,
21
30
  timeoutMs: ADVISOR_TIMEOUT_MS,
22
31
  maxTranscriptChars: 0,
32
+ agentEffort: null,
33
+ disabledForModels: [],
34
+ benchmarks: {},
35
+ typesafeSource: { disabled: false, overrides: {} },
36
+ typesafe: { enabled: false, settings: null, keyPresent: false },
37
+ typesafeSettings: null,
23
38
  };
24
39
  function normalizeVariant(value) {
25
40
  if (value === null || value === undefined)
@@ -29,6 +44,98 @@ function normalizeVariant(value) {
29
44
  return undefined;
30
45
  return text;
31
46
  }
47
+ function dedupeEfforts(items) {
48
+ const seen = new Set();
49
+ for (const item of items) {
50
+ const text = item.trim();
51
+ if (text)
52
+ seen.add(text);
53
+ }
54
+ return seen.size > 0 ? [...seen] : null;
55
+ }
56
+ // `true` enables the default levels, `false`/`null` disables the feature,
57
+ // and an array or comma-separated string sets an explicit allow-list.
58
+ // Returns undefined for unrecognized types so the caller keeps the base.
59
+ function normalizeAgentEffort(value) {
60
+ if (value === undefined)
61
+ return undefined;
62
+ if (value === null)
63
+ return null;
64
+ if (value === true)
65
+ return [...AGENT_EFFORT_DEFAULTS];
66
+ if (value === false)
67
+ return null;
68
+ if (typeof value === "string") {
69
+ const text = value.trim();
70
+ if (!text)
71
+ return null;
72
+ const lowered = text.toLowerCase();
73
+ if (lowered === "true")
74
+ return [...AGENT_EFFORT_DEFAULTS];
75
+ if (lowered === "false" || lowered === "none")
76
+ return null;
77
+ return dedupeEfforts(text.split(","));
78
+ }
79
+ if (Array.isArray(value)) {
80
+ return dedupeEfforts(value
81
+ .filter((entry) => typeof entry === "string")
82
+ .flatMap((entry) => entry.split(",")));
83
+ }
84
+ return undefined;
85
+ }
86
+ // Exact `provider/model` IDs. A string is split on commas; blanks are
87
+ // dropped. `undefined` means "not set" so a later source can keep the
88
+ // previous list. An empty array or whitespace-only string clears it.
89
+ function normalizeDisabledForModels(value) {
90
+ if (value === undefined)
91
+ return undefined;
92
+ const entries = typeof value === "string" ? value.split(",") : value;
93
+ if (!Array.isArray(entries) ||
94
+ entries.some((entry) => typeof entry !== "string")) {
95
+ throw new Error("disabledForModels must be a string array or comma-separated string.");
96
+ }
97
+ const refs = entries.map((entry) => entry.trim()).filter(Boolean);
98
+ for (const ref of refs) {
99
+ const slash = ref.indexOf("/");
100
+ if (slash <= 0 ||
101
+ slash === ref.length - 1 ||
102
+ ref.endsWith("/") ||
103
+ ref.includes("//") ||
104
+ /[\s#*?]/u.test(ref)) {
105
+ throw new Error("disabledForModels requires exact provider/model IDs without variants or wildcards.");
106
+ }
107
+ }
108
+ return [...new Set(refs)];
109
+ }
110
+ // `{ path, mappingsPath }` with absolute locations. `undefined`/`null`
111
+ // means "not set"; blank entries are dropped; unknown keys are ignored.
112
+ // Invalid explicit values throw so misconfiguration fails fast at setup
113
+ // instead of silently degrading to default benchmark data.
114
+ function normalizeBenchmarks(value) {
115
+ if (value === undefined || value === null)
116
+ return undefined;
117
+ if (typeof value !== "object" || Array.isArray(value)) {
118
+ throw new Error("benchmarks must be an object with path options.");
119
+ }
120
+ const raw = value;
121
+ const out = {};
122
+ for (const key of ["path", "mappingsPath"]) {
123
+ const entry = raw[key];
124
+ if (entry === undefined || entry === null)
125
+ continue;
126
+ if (typeof entry !== "string") {
127
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
128
+ }
129
+ const trimmed = entry.trim();
130
+ if (!trimmed)
131
+ continue;
132
+ if (!isAbsolute(trimmed)) {
133
+ throw new Error(`benchmarks.${key} must be an absolute path.`);
134
+ }
135
+ out[key] = trimmed;
136
+ }
137
+ return out;
138
+ }
32
139
  function toBoundedInt(value, min) {
33
140
  const raw = typeof value === "number"
34
141
  ? value
@@ -65,7 +172,7 @@ function parseModelRef(ref) {
65
172
  }
66
173
  return out;
67
174
  }
68
- function applyAdvisorConfigSource(base, src) {
175
+ function applyAdvisorConfigSource(base, src, env = process.env) {
69
176
  if (!src || typeof src !== "object")
70
177
  return base;
71
178
  const next = { ...base };
@@ -86,12 +193,37 @@ function applyAdvisorConfigSource(base, src) {
86
193
  if (src.variant !== undefined) {
87
194
  next.variant = normalizeVariant(src.variant);
88
195
  }
196
+ const agentEffort = src.agentEffort ?? src.agent_effort;
197
+ if (agentEffort !== undefined) {
198
+ const normalized = normalizeAgentEffort(agentEffort);
199
+ if (normalized !== undefined)
200
+ next.agentEffort = normalized;
201
+ }
202
+ const disabledForModels = normalizeDisabledForModels(src.disabledForModels);
203
+ if (disabledForModels !== undefined) {
204
+ next.disabledForModels = disabledForModels;
205
+ }
206
+ // Benchmark paths merge per field so a plugin option can override one
207
+ // location while the other still falls back to the environment default.
208
+ const benchmarks = normalizeBenchmarks(src.benchmarks);
209
+ if (benchmarks !== undefined) {
210
+ next.benchmarks = { ...next.benchmarks, ...benchmarks };
211
+ }
89
212
  const timeout = toBoundedInt(src.timeoutMs ?? src.timeout_ms, 1);
90
213
  if (timeout !== undefined)
91
214
  next.timeoutMs = timeout;
92
215
  const cap = toBoundedInt(src.maxTranscriptChars ?? src.max_transcript_chars, 0);
93
216
  if (cap !== undefined)
94
217
  next.maxTranscriptChars = cap;
218
+ if (src.typesafe !== undefined) {
219
+ const normalized = normalizeTypeSafeOptions(src.typesafe);
220
+ if ("error" in normalized) {
221
+ throw new Error(normalized.error);
222
+ }
223
+ next.typesafeSource = normalized;
224
+ }
225
+ next.typesafe = resolveTypeSafeConfig(next.typesafeSource, env);
226
+ next.typesafeSettings = next.typesafe.settings;
95
227
  return next;
96
228
  }
97
229
  function envAdvisorConfigSource(env = process.env) {
@@ -101,13 +233,22 @@ function envAdvisorConfigSource(env = process.env) {
101
233
  variant: env.OCADVISOR_VARIANT,
102
234
  timeoutMs: env.OCADVISOR_TIMEOUT_MS,
103
235
  maxTranscriptChars: env.OCADVISOR_MAX_TRANSCRIPT_CHARS,
236
+ agentEffort: env.OCADVISOR_AGENT_EFFORT,
237
+ disabledForModels: env.OCADVISOR_DISABLED_FOR_MODELS,
238
+ benchmarks: env.OCADVISOR_BENCHMARKS_PATH === undefined &&
239
+ env.OCADVISOR_BENCHMARK_MAPPINGS_PATH === undefined
240
+ ? undefined
241
+ : {
242
+ path: env.OCADVISOR_BENCHMARKS_PATH,
243
+ mappingsPath: env.OCADVISOR_BENCHMARK_MAPPINGS_PATH,
244
+ },
104
245
  };
105
246
  }
106
247
  // Precedence (low to high): built-in defaults, environment variables,
107
248
  // plugin options from opencode.json.
108
249
  function resolveAdvisorConfig(options, env = process.env) {
109
- let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env));
110
- config = applyAdvisorConfigSource(config, options);
250
+ let config = applyAdvisorConfigSource(DEFAULT_ADVISOR_CONFIG, envAdvisorConfigSource(env), env);
251
+ config = applyAdvisorConfigSource(config, options, env);
111
252
  return config;
112
253
  }
113
254
  const SYSTEM_BASE = `You are a senior advisor reviewing a coding agent's work. You have the full session transcript.
@@ -124,19 +265,30 @@ You are a debugger. Analyze error patterns, stack traces, and failed attempts in
124
265
  };
125
266
  const TOOL_DESCRIPTION = `Consult a senior advisor model with your full session transcript — including parent sessions for subagents — for high-quality analysis.
126
267
 
127
- Use advisor selectively on substantial, non-trivial work. Straightforward tasks normally need no consultation.
268
+ Use advisor when an independent perspective could improve the approach, help resolve a problem, or strengthen an implementation review.
269
+
270
+ On substantial work, consider consulting before committing to an approach, when progress stalls, or before completing meaningful changes. Additional consultations are welcome as the work evolves — particularly when new evidence appears, the approach changes, or another concern needs review.
128
271
 
129
- - Normally make AT MOST ONE consultation per task, at the point where a second opinion has the most value: a consequential unresolved design decision (mode "plan"), a blocker after two substantially different attempts (mode "debug"), or a high-risk change with a specific unresolved correctness concern (mode "review"). Pick one stage, not all three.
130
- - mode "general": a second opinion that does not fit the above.
272
+ Ask a concrete, focused question. Avoid repeating settled questions without new context. Straightforward tasks usually need no consultation.
131
273
 
132
274
  Rules:
133
275
  - Always pass a concrete "question" naming the decision or artifact under review.
134
- - A second consultation requires material new evidence, a distinct unresolved issue, or an explicit user request. Reconcile an advisor conflict with primary-source evidence via one "followup" call stating both sides.
276
+ - Reconcile an advisor conflict with primary-source evidence via one "followup" call stating both sides.
135
277
  - Give the advice serious weight. A passing self-test alone is not counter-evidence; primary-source evidence (the file says X) is. Clear factual corrections do not need another confirmation call.
278
+ - When TypeSafe screening is enabled, a clearly unnecessary consultation returns a skip notice instead of advice, and an omitted effort may be chosen for you.
136
279
 
137
280
  Args: "mode" (general, review, plan, debug), "trigger" (before_approach, stuck, pre_complete, followup, other), "question" (concrete question focusing the advisor).
138
281
  `;
139
- const CHECKPOINT_INSTRUCTION = `[advisor] Use advisor selectively on substantial work: normally 0-1 consultations per task, at most one unless material new evidence, a distinct unresolved issue, or an explicit user request. Consult for a consequential undecided design (mode "plan"), a blocker after 2+ different attempts (mode "debug"), or a high-risk change with a specific correctness concern (mode "review"). Always pass a concrete question.`;
282
+ // When `agentEffort` is enabled the tool advertises an optional `effort`
283
+ // argument; otherwise the description is exactly TOOL_DESCRIPTION.
284
+ function buildToolDescription(config = DEFAULT_ADVISOR_CONFIG) {
285
+ if (!config.agentEffort || config.agentEffort.length === 0) {
286
+ return TOOL_DESCRIPTION;
287
+ }
288
+ return (TOOL_DESCRIPTION +
289
+ `Optional "effort" (one of: ${config.agentEffort.join(", ")}): reasoning effort for this consultation; omit to use the configured variant.\n`);
290
+ }
291
+ const CHECKPOINT_INSTRUCTION = `[advisor] Consult advisor when an independent perspective would improve the approach, help resolve a problem, or strengthen review. Use useful checkpoints during substantial work; additional consultations are welcome when evidence, approach, or concerns change. Ask a concrete question and avoid repeating settled questions without new context.`;
140
292
  const ADVISOR_TRIGGERS = [
141
293
  "before_approach",
142
294
  "stuck",
@@ -163,8 +315,28 @@ const ADVISOR_INPUT_SCHEMA = {
163
315
  },
164
316
  },
165
317
  };
166
- function openDb() {
167
- return new Database(DB_PATH, { readonly: true });
318
+ // When `agentEffort` is enabled the schema gains an optional `effort`
319
+ // argument restricted to the allowed levels.
320
+ function buildAdvisorInputSchema(config = DEFAULT_ADVISOR_CONFIG) {
321
+ if (!config.agentEffort || config.agentEffort.length === 0) {
322
+ return ADVISOR_INPUT_SCHEMA;
323
+ }
324
+ return {
325
+ ...ADVISOR_INPUT_SCHEMA,
326
+ properties: {
327
+ ...ADVISOR_INPUT_SCHEMA.properties,
328
+ effort: {
329
+ type: "string",
330
+ enum: [...config.agentEffort],
331
+ description: "Reasoning effort for this consultation (omit to use the configured variant)",
332
+ },
333
+ },
334
+ };
335
+ }
336
+ function openDb(runtime) {
337
+ return new Database(runtime?.__advisorTest?.dbPath ?? DB_PATH, {
338
+ readonly: true,
339
+ });
168
340
  }
169
341
  function tableExists(db, name) {
170
342
  const row = db
@@ -190,6 +362,22 @@ function isFableModel(model) {
190
362
  const id = String(model.id || model.modelID || "").toLowerCase();
191
363
  return provider.includes("anthropic") && id.includes("fable");
192
364
  }
365
+ function advisorDisabledReason(model, config) {
366
+ if (isFableModel(model)) {
367
+ return { outcome: "skipped_fable", message: FABLE_DISABLED };
368
+ }
369
+ const provider = model?.providerID || model?.provider;
370
+ const id = model?.id || model?.modelID;
371
+ if (!provider || !id)
372
+ return null;
373
+ const ref = `${provider}/${id}`;
374
+ if (!config.disabledForModels.includes(ref))
375
+ return null;
376
+ return {
377
+ outcome: "skipped_model",
378
+ message: `advisor is disabled (model opt-out): ${ref} is listed in disabledForModels.`,
379
+ };
380
+ }
193
381
  function inferTrigger(mode, trigger) {
194
382
  if (trigger && ADVISOR_TRIGGERS.includes(trigger)) {
195
383
  return trigger;
@@ -205,6 +393,23 @@ function inferTrigger(mode, trigger) {
205
393
  return "other";
206
394
  }
207
395
  }
396
+ // Validates a per-call effort request against the plugin's allow-list.
397
+ // Returns undefined when the agent omitted it or the feature is disabled
398
+ // (a stray value is ignored then, since the schema never advertised it).
399
+ function resolveRequestedEffort(config, value) {
400
+ if (value === undefined || value === null)
401
+ return undefined;
402
+ const text = String(value).trim();
403
+ if (!text)
404
+ return undefined;
405
+ const allowed = config.agentEffort;
406
+ if (!allowed || allowed.length === 0)
407
+ return undefined;
408
+ if (!allowed.includes(text)) {
409
+ throw new Error(`Effort "${text}" is not allowed (allowed: ${allowed.join(", ")}).`);
410
+ }
411
+ return text;
412
+ }
208
413
  function classifyAdvisorError(message) {
209
414
  const text = message.toLowerCase();
210
415
  if (text.includes("credit balance is too low") ||
@@ -214,6 +419,9 @@ function classifyAdvisorError(message) {
214
419
  if (text.includes("rate_limit") || text.includes(" 429")) {
215
420
  return "rate_limit";
216
421
  }
422
+ if (text.includes("not a variant of") || text.includes("is not allowed")) {
423
+ return "invalid_effort";
424
+ }
217
425
  if (text.includes("failed to parse json") ||
218
426
  text.includes("unexpected token")) {
219
427
  return "json_parse";
@@ -308,6 +516,13 @@ function isAdvisorToolName(name) {
308
516
  function countPriorAdvisorCalls(db, sessionId) {
309
517
  const callIds = new Set();
310
518
  const modes = [];
519
+ const questions = [];
520
+ const record = (input) => {
521
+ modes.push(String(input?.mode || "general"));
522
+ if (typeof input?.question === "string" && input.question.trim()) {
523
+ questions.push(input.question);
524
+ }
525
+ };
311
526
  try {
312
527
  for (const sid of collectSessionChain(db, sessionId)) {
313
528
  if (tableExists(db, "session_message")) {
@@ -336,7 +551,7 @@ function countPriorAdvisorCalls(db, sessionId) {
336
551
  const cid = String(block.id || block.callID || row.id);
337
552
  if (!callIds.has(cid)) {
338
553
  callIds.add(cid);
339
- modes.push(String(state.input?.mode || "general"));
554
+ record(state.input);
340
555
  }
341
556
  }
342
557
  nested.forEach((call, index) => {
@@ -344,7 +559,7 @@ function countPriorAdvisorCalls(db, sessionId) {
344
559
  const cid = `${block.id || row.id}#${index}`;
345
560
  if (!callIds.has(cid)) {
346
561
  callIds.add(cid);
347
- modes.push(String(call.input?.mode || "general"));
562
+ record(call.input);
348
563
  }
349
564
  }
350
565
  });
@@ -372,22 +587,88 @@ function countPriorAdvisorCalls(db, sessionId) {
372
587
  if (!cid || callIds.has(cid))
373
588
  continue;
374
589
  callIds.add(cid);
375
- modes.push(String(block.state?.input?.mode || "general"));
590
+ record(block.state?.input);
376
591
  }
377
592
  }
378
593
  }
379
594
  }
380
595
  catch { }
381
- return { count: callIds.size, modes };
596
+ return { count: callIds.size, modes, questions };
382
597
  }
383
- async function logAdvisorMetrics(metrics) {
598
+ async function logAdvisorMetrics(runtime, metrics) {
384
599
  try {
385
- await appendFile(METRICS_PATH, JSON.stringify(metrics) + "\n", "utf-8");
600
+ const path = runtime?.__advisorTest?.metricsPath ?? METRICS_PATH;
601
+ await appendFile(path, JSON.stringify(metrics) + "\n", "utf-8");
386
602
  }
387
603
  catch {
388
604
  // Metrics must never break the advisor call.
389
605
  }
390
606
  }
607
+ // Flattens a gate decision into the additive metrics record. A bypassed gate
608
+ // reports `bypass` so reports can separate bypass, skip, fallback, and
609
+ // allowed proceed without inferring from other fields.
610
+ function gateRecordFrom(decision) {
611
+ if (decision.status === "disabled") {
612
+ return {
613
+ status: "bypass",
614
+ reason: "disabled",
615
+ model: null,
616
+ neededProbability: null,
617
+ suggestedEffort: null,
618
+ effortConfidence: null,
619
+ effectiveEffort: null,
620
+ effortSource: null,
621
+ latencyMs: 0,
622
+ stateBytes: 0,
623
+ truncated: false,
624
+ inputTokens: null,
625
+ outputTokens: null,
626
+ };
627
+ }
628
+ const metrics = decision.metrics;
629
+ return {
630
+ status: decision.status,
631
+ reason: decision.reason,
632
+ model: metrics.model,
633
+ neededProbability: metrics.neededProbability,
634
+ suggestedEffort: metrics.suggestedEffort,
635
+ effortConfidence: metrics.effortConfidence,
636
+ effectiveEffort: decision.status === "proceed" ? decision.effectiveEffort : null,
637
+ effortSource: decision.status === "proceed" ? decision.effortSource : null,
638
+ latencyMs: metrics.latencyMs,
639
+ stateBytes: metrics.stateBytes,
640
+ truncated: metrics.truncated,
641
+ inputTokens: metrics.inputTokens,
642
+ outputTokens: metrics.outputTokens,
643
+ };
644
+ }
645
+ // Human-readable gate summary for the tool output. A bypassed gate contributes
646
+ // nothing, so keyless or disabled installs keep the plain footer.
647
+ export function gateSummary(gate) {
648
+ if (!gate || gate.status === "bypass")
649
+ return null;
650
+ const parts = [];
651
+ if (gate.neededProbability !== null) {
652
+ parts.push(`need=${gate.neededProbability.toFixed(2)}`);
653
+ }
654
+ if (gate.effectiveEffort) {
655
+ parts.push(gate.effortSource === "typesafe"
656
+ ? `effort=${gate.effectiveEffort} (gate)`
657
+ : `effort=${gate.effectiveEffort}`);
658
+ }
659
+ switch (gate.status) {
660
+ case "skip":
661
+ parts.push("decision=skip", gate.reason);
662
+ break;
663
+ case "fallback":
664
+ parts.push("decision=fallback", gate.reason);
665
+ break;
666
+ default:
667
+ parts.push("decision=proceed");
668
+ break;
669
+ }
670
+ return `gate: ${parts.join(", ")}`;
671
+ }
391
672
  function getSession(db, sessionId) {
392
673
  return db
393
674
  .query("SELECT id, parent_id, title, directory FROM session WHERE id = ?")
@@ -642,7 +923,19 @@ function findAdvisorModel(models, config = DEFAULT_ADVISOR_CONFIG) {
642
923
  }
643
924
  return null;
644
925
  }
645
- function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG) {
926
+ function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG, requestedVariant) {
927
+ // An agent-requested effort must be a real variant of the model; unlike
928
+ // the configured default it never silently falls back.
929
+ if (requestedVariant !== undefined) {
930
+ if (!model || !Array.isArray(model.variants))
931
+ return requestedVariant;
932
+ const ids = model.variants.map((variant) => variant?.id);
933
+ if (!ids.includes(requestedVariant)) {
934
+ const available = ids.filter((id) => !!id).join(", ") || "none";
935
+ throw new Error(`Effort "${requestedVariant}" is not a variant of ${config.provider}/${config.model} (available: ${available}).`);
936
+ }
937
+ return requestedVariant;
938
+ }
646
939
  if (config.variant === undefined)
647
940
  return undefined;
648
941
  if (!model || !Array.isArray(model.variants))
@@ -650,6 +943,20 @@ function resolveAdvisorVariant(model, config = DEFAULT_ADVISOR_CONFIG) {
650
943
  const ids = model.variants.map((variant) => variant?.id);
651
944
  return ids.includes(config.variant) ? config.variant : undefined;
652
945
  }
946
+ // Effort levels the gate may choose: plugin-allowed candidates intersected
947
+ // with the model's live variants. Unknown discovery yields no candidates,
948
+ // which keeps the configured effort and omits automatic effort selection.
949
+ function resolveSupportedEfforts(model, config) {
950
+ const allowed = config.agentEffort && config.agentEffort.length > 0
951
+ ? config.agentEffort
952
+ : null;
953
+ if (!allowed || !model || !Array.isArray(model.variants))
954
+ return [];
955
+ const variants = new Set(model.variants
956
+ .map((variant) => variant?.id)
957
+ .filter((id) => !!id));
958
+ return allowed.filter((effort) => variants.has(effort));
959
+ }
653
960
  function hasAdvisorConnection(connection) {
654
961
  if (connection === null || connection === undefined)
655
962
  return false;
@@ -663,11 +970,50 @@ function hasAdvisorConnection(connection) {
663
970
  }
664
971
  return true;
665
972
  }
666
- async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG) {
667
- if (typeof runtime.catalog?.provider?.get === "function") {
973
+ // Discovery namespaces: the documented V2 `model`/`provider` domains win,
974
+ // the legacy `catalog` namespace stays for older servers and fixtures.
975
+ // Calls stay in method form so any receiver-bound implementation keeps
976
+ // working.
977
+ function discoveryModelApi(runtime) {
978
+ if (runtime.model && typeof runtime.model.list === "function") {
979
+ return runtime.model;
980
+ }
981
+ if (runtime.catalog?.model &&
982
+ typeof runtime.catalog.model.list === "function") {
983
+ return runtime.catalog.model;
984
+ }
985
+ return null;
986
+ }
987
+ function discoveryProviderApi(runtime) {
988
+ if (runtime.provider && typeof runtime.provider.get === "function") {
989
+ return runtime.provider;
990
+ }
991
+ if (runtime.catalog?.provider &&
992
+ typeof runtime.catalog.provider.get === "function") {
993
+ return runtime.catalog.provider;
994
+ }
995
+ return null;
996
+ }
997
+ // Shared by support checks and the effort gate; null means discovery is
998
+ // unavailable or the configured model was not found.
999
+ async function findAdvisorCatalogModel(runtime, config) {
1000
+ const api = discoveryModelApi(runtime);
1001
+ if (!api)
1002
+ return null;
1003
+ try {
1004
+ const models = unwrapData((await api.list()));
1005
+ return findAdvisorModel(models, config);
1006
+ }
1007
+ catch {
1008
+ return null;
1009
+ }
1010
+ }
1011
+ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG, requestedVariant) {
1012
+ const providerApi = discoveryProviderApi(runtime);
1013
+ if (providerApi) {
668
1014
  let provider = null;
669
1015
  try {
670
- provider = unwrapData((await runtime.catalog.provider.get({
1016
+ provider = unwrapData((await providerApi.get({
671
1017
  providerID: config.provider,
672
1018
  })));
673
1019
  }
@@ -684,11 +1030,12 @@ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG) {
684
1030
  };
685
1031
  }
686
1032
  }
687
- let variant = config.variant;
688
- if (typeof runtime.catalog?.model?.list === "function") {
1033
+ let variant = requestedVariant ?? config.variant;
1034
+ const modelApi = discoveryModelApi(runtime);
1035
+ if (modelApi) {
689
1036
  let models = null;
690
1037
  try {
691
- models = unwrapData((await runtime.catalog.model.list()));
1038
+ models = unwrapData((await modelApi.list()));
692
1039
  }
693
1040
  catch (err) {
694
1041
  return {
@@ -703,7 +1050,15 @@ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG) {
703
1050
  reason: `Model unavailable: ${config.provider}/${config.model}`,
704
1051
  };
705
1052
  }
706
- variant = resolveAdvisorVariant(model, config);
1053
+ try {
1054
+ variant = resolveAdvisorVariant(model, config, requestedVariant);
1055
+ }
1056
+ catch (err) {
1057
+ return {
1058
+ supported: false,
1059
+ reason: err instanceof Error ? err.message : String(err),
1060
+ };
1061
+ }
707
1062
  }
708
1063
  if (typeof runtime.integration?.connection?.active === "function") {
709
1064
  let connection = null;
@@ -726,9 +1081,15 @@ async function checkAdvisorSupport(runtime, config = DEFAULT_ADVISOR_CONFIG) {
726
1081
  return { supported: true, variant };
727
1082
  }
728
1083
  let cachedAdvisorSessionId = null;
1084
+ // Last variant pinned by this process (tri-state: unknown until a session
1085
+ // is pinned or reports its variant).
1086
+ let cachedAdvisorVariant;
1087
+ let cachedAdvisorVariantKnown = false;
729
1088
  let advisorQueue = Promise.resolve();
730
1089
  function resetAdvisorSessionCache() {
731
1090
  cachedAdvisorSessionId = null;
1091
+ cachedAdvisorVariant = undefined;
1092
+ cachedAdvisorVariantKnown = false;
732
1093
  }
733
1094
  function enqueueAdvisor(task) {
734
1095
  const next = advisorQueue.then(task, task);
@@ -777,7 +1138,7 @@ async function getAdvisorSession(runtime, sessionId) {
777
1138
  return null;
778
1139
  }
779
1140
  }
780
- function advisorSessionNeedsModel(session, config = DEFAULT_ADVISOR_CONFIG) {
1141
+ function advisorSessionNeedsModel(session, variant, allowCacheFallback, config = DEFAULT_ADVISOR_CONFIG) {
781
1142
  if (!session)
782
1143
  return true;
783
1144
  const model = session.model;
@@ -785,8 +1146,32 @@ function advisorSessionNeedsModel(session, config = DEFAULT_ADVISOR_CONFIG) {
785
1146
  return true;
786
1147
  const provider = String(model.providerID || "").toLowerCase();
787
1148
  const id = String(model.id || model.modelID || "").toLowerCase();
788
- return (provider !== config.provider.toLowerCase() ||
789
- id !== config.model.toLowerCase());
1149
+ if (provider !== config.provider.toLowerCase() ||
1150
+ id !== config.model.toLowerCase()) {
1151
+ return true;
1152
+ }
1153
+ // Re-pin when the requested effort differs from the session's variant. The
1154
+ // session payload may not report a variant; then fall back to the last
1155
+ // pinned value — but only for the session it was pinned on.
1156
+ if (typeof model.variant === "string" && model.variant) {
1157
+ return model.variant !== variant;
1158
+ }
1159
+ if (allowCacheFallback && cachedAdvisorVariantKnown) {
1160
+ return cachedAdvisorVariant !== variant;
1161
+ }
1162
+ return false;
1163
+ }
1164
+ // Records the variant a reused session reports so later requests for a
1165
+ // different effort re-pin even when a future payload omits it.
1166
+ function syncCachedAdvisorVariant(session) {
1167
+ const model = session?.model;
1168
+ if (model &&
1169
+ typeof model === "object" &&
1170
+ typeof model.variant === "string" &&
1171
+ model.variant) {
1172
+ cachedAdvisorVariant = model.variant;
1173
+ cachedAdvisorVariantKnown = true;
1174
+ }
790
1175
  }
791
1176
  async function switchAdvisorSessionModel(runtime, sessionId, variant, config = DEFAULT_ADVISOR_CONFIG) {
792
1177
  if (typeof runtime.session?.switchModel !== "function") {
@@ -811,8 +1196,13 @@ async function ensureAdvisorSession(runtime, variant, config = DEFAULT_ADVISOR_C
811
1196
  const session = await getAdvisorSession(runtime, candidate);
812
1197
  if (!session)
813
1198
  continue;
814
- if (advisorSessionNeedsModel(session, config)) {
1199
+ if (advisorSessionNeedsModel(session, variant, candidate === cachedAdvisorSessionId, config)) {
815
1200
  await switchAdvisorSessionModel(runtime, candidate, variant, config);
1201
+ cachedAdvisorVariant = variant;
1202
+ cachedAdvisorVariantKnown = true;
1203
+ }
1204
+ else {
1205
+ syncCachedAdvisorVariant(session);
816
1206
  }
817
1207
  cachedAdvisorSessionId = candidate;
818
1208
  return candidate;
@@ -824,8 +1214,13 @@ async function ensureAdvisorSession(runtime, variant, config = DEFAULT_ADVISOR_C
824
1214
  session.title === LEGACY_ADVISOR_SESSION_TITLE);
825
1215
  const existingId = typeof existing?.id === "string" ? existing.id : null;
826
1216
  if (existing && existingId) {
827
- if (advisorSessionNeedsModel(existing, config)) {
1217
+ if (advisorSessionNeedsModel(existing, variant, existingId === cachedAdvisorSessionId, config)) {
828
1218
  await switchAdvisorSessionModel(runtime, existingId, variant, config);
1219
+ cachedAdvisorVariant = variant;
1220
+ cachedAdvisorVariantKnown = true;
1221
+ }
1222
+ else {
1223
+ syncCachedAdvisorVariant(existing);
829
1224
  }
830
1225
  cachedAdvisorSessionId = existingId;
831
1226
  await storeAdvisorSessionId(runtime, existingId);
@@ -846,6 +1241,8 @@ async function ensureAdvisorSession(runtime, variant, config = DEFAULT_ADVISOR_C
846
1241
  }
847
1242
  await switchAdvisorSessionModel(runtime, sessionId, variant, config);
848
1243
  cachedAdvisorSessionId = sessionId;
1244
+ cachedAdvisorVariant = variant;
1245
+ cachedAdvisorVariantKnown = true;
849
1246
  await storeAdvisorSessionId(runtime, sessionId);
850
1247
  return sessionId;
851
1248
  }
@@ -855,7 +1252,7 @@ async function callAdvisor(opts) {
855
1252
  if (typeof runtime?.session?.generate !== "function") {
856
1253
  throw new Error("advisor requires the OpenCode V2 plugin runtime (session.generate unavailable).");
857
1254
  }
858
- const support = await checkAdvisorSupport(runtime, config);
1255
+ const support = await checkAdvisorSupport(runtime, config, opts.effort);
859
1256
  if (!support.supported) {
860
1257
  throw new Error(support.reason);
861
1258
  }
@@ -866,10 +1263,25 @@ async function callAdvisor(opts) {
866
1263
  const text = await enqueueAdvisor(() => withTimeout((async () => {
867
1264
  const sessionId = await ensureAdvisorSession(runtime, support.variant, config);
868
1265
  const request = { sessionID: sessionId, prompt };
869
- const result = opts.signal
870
- ? await generate(request, { signal: opts.signal })
871
- : await generate(request);
872
- const output = extractGeneratedText(result);
1266
+ const startedAt = Date.now();
1267
+ const run = async () => {
1268
+ const result = opts.signal
1269
+ ? await generate(request, { signal: opts.signal })
1270
+ : await generate(request);
1271
+ return extractGeneratedText(result);
1272
+ };
1273
+ // A generation can come back without text (transient provider
1274
+ // behavior, e.g. a reasoning-only response with adaptive thinking).
1275
+ // Retry once while most of the timeout budget remains; never retry
1276
+ // after a caller cancellation.
1277
+ let output = await run();
1278
+ if (!output?.trim() && !opts.signal?.aborted) {
1279
+ const elapsed = Date.now() - startedAt;
1280
+ if (elapsed < config.timeoutMs / 2) {
1281
+ console.log(`[advisor] empty generation; retrying once (session=${sessionId} elapsedMs=${elapsed})`);
1282
+ output = await run();
1283
+ }
1284
+ }
873
1285
  if (!output?.trim()) {
874
1286
  throw new Error("Advisor returned an empty response.");
875
1287
  }
@@ -882,6 +1294,86 @@ async function callAdvisor(opts) {
882
1294
  text: `${text}\n\n---\n_advisor via OpenCode: ${modelLabel} (token usage unavailable via session generation)_`,
883
1295
  inputTokens: null,
884
1296
  outputTokens: null,
1297
+ variant: support.variant,
1298
+ };
1299
+ }
1300
+ // Benchmark stores live for the process, keyed by resolved user paths (and
1301
+ // test seed overrides), so every consultation observes CLI refreshes
1302
+ // without re-reading unchanged files. Tests reset between cases.
1303
+ const benchmarkStores = new Map();
1304
+ function resetBenchmarkStores() {
1305
+ benchmarkStores.clear();
1306
+ }
1307
+ function benchmarkStoreKey(snapshotPath, mappingsPath, seedSnapshotPath, seedMappingsPath) {
1308
+ return [
1309
+ snapshotPath,
1310
+ mappingsPath,
1311
+ seedSnapshotPath ?? "",
1312
+ seedMappingsPath ?? "",
1313
+ ].join("\n");
1314
+ }
1315
+ // Loads one consistent benchmark view and assembles gate evidence from it.
1316
+ // Any failure (bad paths, unreadable files, unexpected errors) yields null
1317
+ // and the consultation proceeds without benchmark enrichment.
1318
+ async function loadBenchmarkEvidence(runtime, config, requester, advisor) {
1319
+ try {
1320
+ const testOpts = runtime?.__advisorTest?.benchmarkStoreOptions;
1321
+ const paths = resolveBenchmarkPaths(config.benchmarks, testOpts?.env ?? process.env);
1322
+ if ("error" in paths)
1323
+ return null;
1324
+ const key = benchmarkStoreKey(paths.snapshotPath, paths.mappingsPath, testOpts?.seedSnapshotPath, testOpts?.seedMappingsPath);
1325
+ let store = benchmarkStores.get(key);
1326
+ if (!store) {
1327
+ store = await createBenchmarkStore({
1328
+ snapshotPath: paths.snapshotPath,
1329
+ mappingsPath: paths.mappingsPath,
1330
+ seedSnapshotPath: testOpts?.seedSnapshotPath,
1331
+ seedMappingsPath: testOpts?.seedMappingsPath,
1332
+ fs: testOpts?.fs,
1333
+ maxBytes: testOpts?.maxBytes,
1334
+ });
1335
+ benchmarkStores.set(key, store);
1336
+ }
1337
+ const view = await store.view();
1338
+ return {
1339
+ view,
1340
+ evidence: buildBenchmarkEvidence({ requester, advisor, view }),
1341
+ };
1342
+ }
1343
+ catch {
1344
+ return null;
1345
+ }
1346
+ }
1347
+ function summarizeBenchmarkEvidence(loaded, finalEffort) {
1348
+ if (!loaded)
1349
+ return undefined;
1350
+ const { view, evidence } = loaded;
1351
+ const { requester, advisor } = evidence.models;
1352
+ const policy = advisor.policy;
1353
+ const finalMatch = finalEffort === null
1354
+ ? null
1355
+ : view.matcher.match({
1356
+ providerID: advisor.providerID,
1357
+ modelID: advisor.modelID,
1358
+ variant: finalEffort,
1359
+ }).status;
1360
+ return {
1361
+ source: evidence.benchmarks.source,
1362
+ contentHash: evidence.benchmarks.contentHash,
1363
+ fetchedAt: evidence.benchmarks.fetchedAt,
1364
+ hashVerified: evidence.benchmarks.hashVerified,
1365
+ requester: requester.providerID && requester.modelID
1366
+ ? `${requester.providerID}/${requester.modelID}${requester.variant ? `#${requester.variant}` : ""} (${requester.provenance})`
1367
+ : null,
1368
+ requesterMatch: evidence.benchmarks.requesterMatch.status,
1369
+ advisorPolicy: policy.kind === "pinned"
1370
+ ? `pinned:${policy.effort}`
1371
+ : policy.kind === "candidates"
1372
+ ? `candidates:${policy.candidates.join(",")}>${policy.fallback ?? "none"}`
1373
+ : `fixed:${policy.effort ?? "none"}`,
1374
+ advisorMatch: evidence.benchmarks.advisorDefaultMatch.status,
1375
+ finalEffort,
1376
+ finalMatch,
885
1377
  };
886
1378
  }
887
1379
  async function runAdvisor(opts) {
@@ -892,7 +1384,7 @@ async function runAdvisor(opts) {
892
1384
  const sessionId = opts.sessionId;
893
1385
  const questionChars = opts.question?.length ?? 0;
894
1386
  if (!sessionId) {
895
- await logAdvisorMetrics({
1387
+ await logAdvisorMetrics(opts.runtime, {
896
1388
  ts: new Date().toISOString(),
897
1389
  sessionId: null,
898
1390
  callerModel: null,
@@ -901,6 +1393,7 @@ async function runAdvisor(opts) {
901
1393
  mode,
902
1394
  trigger,
903
1395
  questionChars,
1396
+ effort: null,
904
1397
  outcome: "no_session",
905
1398
  errorType: "no_session",
906
1399
  latencyMs: Date.now() - started,
@@ -914,13 +1407,14 @@ async function runAdvisor(opts) {
914
1407
  }
915
1408
  let db = null;
916
1409
  try {
917
- db = openDb();
1410
+ db = openDb(opts.runtime);
918
1411
  const info = getSessionInfo(db, sessionId);
919
1412
  const callerModel = callerLabel(info?.model ?? null);
920
1413
  const callerAgent = opts.callerAgent || info?.agent || null;
921
1414
  const directory = opts.callerDirectory || info?.directory || null;
922
- if (isFableModel(info?.model)) {
923
- await logAdvisorMetrics({
1415
+ const disabled = advisorDisabledReason(info?.model, config);
1416
+ if (disabled) {
1417
+ await logAdvisorMetrics(opts.runtime, {
924
1418
  ts: new Date().toISOString(),
925
1419
  sessionId,
926
1420
  callerModel,
@@ -929,7 +1423,8 @@ async function runAdvisor(opts) {
929
1423
  mode,
930
1424
  trigger,
931
1425
  questionChars,
932
- outcome: "skipped_fable",
1426
+ effort: null,
1427
+ outcome: disabled.outcome,
933
1428
  errorType: null,
934
1429
  latencyMs: Date.now() - started,
935
1430
  inputTokens: null,
@@ -938,12 +1433,12 @@ async function runAdvisor(opts) {
938
1433
  priorConsultations: 0,
939
1434
  via: "opencode-session",
940
1435
  });
941
- console.log(`[advisor] session=${sessionId} mode=${mode} outcome=skipped_fable (already Fable)`);
942
- return FABLE_DISABLED;
1436
+ console.log(`[advisor] session=${sessionId} mode=${mode} outcome=${disabled.outcome}`);
1437
+ return disabled.message;
943
1438
  }
944
1439
  let transcript = buildTranscript(db, sessionId);
945
1440
  if (!transcript?.trim()) {
946
- await logAdvisorMetrics({
1441
+ await logAdvisorMetrics(opts.runtime, {
947
1442
  ts: new Date().toISOString(),
948
1443
  sessionId,
949
1444
  callerModel,
@@ -952,6 +1447,7 @@ async function runAdvisor(opts) {
952
1447
  mode,
953
1448
  trigger,
954
1449
  questionChars,
1450
+ effort: null,
955
1451
  outcome: "no_transcript",
956
1452
  errorType: "no_transcript",
957
1453
  latencyMs: Date.now() - started,
@@ -971,23 +1467,141 @@ async function runAdvisor(opts) {
971
1467
  "... (older transcript trimmed to fit maxTranscriptChars) ...\n\n" +
972
1468
  transcript.slice(-config.maxTranscriptChars);
973
1469
  }
1470
+ // Invocation-correct requester identity for benchmark matching: the
1471
+ // model that produced this tool call, resolved after the early guards
1472
+ // so skipped calls pay for no extra reads.
1473
+ const requesterProfile = resolveRequesterProfile(db, {
1474
+ sessionId,
1475
+ messageID: opts.callerMessageID,
1476
+ callID: opts.callerCallID,
1477
+ sessionModel: info?.model ?? null,
1478
+ });
974
1479
  const prior = countPriorAdvisorCalls(db, sessionId);
975
1480
  const priorNote = prior.count > 0
976
1481
  ? `Note: this session chain already has ${prior.count} recorded advisor consultation(s) (modes: ${prior.modes.join(", ") || "unknown"}). Focus on what is new since then; do not repeat settled advice unless new evidence changes it.`
977
1482
  : null;
978
1483
  const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.general;
1484
+ let requestedEffort;
1485
+ let effectiveEffort;
1486
+ let gateRecord;
1487
+ let benchmarkLoaded = null;
979
1488
  try {
1489
+ requestedEffort = resolveRequestedEffort(config, opts.effort);
1490
+ // Optional TypeSafe preflight: one bounded request decides whether this
1491
+ // consultation is worth the expensive generation and, when the caller
1492
+ // did not pin an effort, which supported effort fits. Gate failures
1493
+ // fall back to the ordinary behavior and never fail the call.
1494
+ let gateDecision = null;
1495
+ let advisorProfile = null;
1496
+ if (config.typesafe.keyPresent && !config.typesafeSource.disabled) {
1497
+ const catalogModel = await findAdvisorCatalogModel(opts.runtime, config);
1498
+ const supportedEfforts = resolveSupportedEfforts(catalogModel, config);
1499
+ advisorProfile = resolveAdvisorProfile({
1500
+ providerID: config.provider,
1501
+ modelID: config.model,
1502
+ requestedEffort,
1503
+ supportedEfforts,
1504
+ defaultEffort: requestedEffort ??
1505
+ resolveAdvisorVariant(catalogModel, config) ??
1506
+ null,
1507
+ });
1508
+ const gateSettings = config.typesafe.settings ?? TYPESAFE_DEFAULTS;
1509
+ benchmarkLoaded = await loadBenchmarkEvidence(opts.runtime, config, requesterProfile, advisorProfile);
1510
+ gateDecision = await runTypeSafeGate({
1511
+ state: buildDecisionState(db, sessionId, {
1512
+ question: opts.question ?? null,
1513
+ mode,
1514
+ trigger,
1515
+ explicitEffort: requestedEffort ?? null,
1516
+ caller: callerModel,
1517
+ directory,
1518
+ priorConsultations: prior.count,
1519
+ priorModes: prior.modes,
1520
+ priorQuestions: prior.questions,
1521
+ supportedEfforts,
1522
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1523
+ maxStateBytes: gateSettings.maxStateBytes,
1524
+ models: benchmarkLoaded?.evidence.models ?? null,
1525
+ benchmarks: benchmarkLoaded?.evidence.benchmarks ?? null,
1526
+ formatMessage: formatV2Message,
1527
+ }),
1528
+ input: {
1529
+ mode,
1530
+ trigger,
1531
+ question: opts.question ?? null,
1532
+ explicitEffort: requestedEffort ?? null,
1533
+ supportedEfforts,
1534
+ defaultEffort: requestedEffort ?? config.variant ?? null,
1535
+ },
1536
+ settings: gateSettings,
1537
+ keyPresent: config.typesafe.keyPresent,
1538
+ client: opts.runtime?.__advisorTest?.gateClient,
1539
+ env: opts.runtime?.__advisorTest?.gateEnv,
1540
+ fetchImpl: opts.runtime?.__advisorTest?.gateFetch,
1541
+ signal: opts.signal,
1542
+ });
1543
+ gateRecord = gateRecordFrom(gateDecision);
1544
+ }
1545
+ // Profiles resolve for every consultation: with discovery the advisor
1546
+ // policy reflects live variants, otherwise it fixes to the configured
1547
+ // effort (or the caller's pin).
1548
+ advisorProfile ??= resolveAdvisorProfile({
1549
+ providerID: config.provider,
1550
+ modelID: config.model,
1551
+ requestedEffort,
1552
+ supportedEfforts: [],
1553
+ defaultEffort: config.variant ?? null,
1554
+ });
1555
+ opts.runtime?.__advisorTest?.profileSink?.({
1556
+ requester: requesterProfile,
1557
+ advisor: advisorProfile,
1558
+ });
1559
+ if (gateDecision?.status === "skip") {
1560
+ const latencyMs = Date.now() - started;
1561
+ await logAdvisorMetrics(opts.runtime, {
1562
+ ts: new Date().toISOString(),
1563
+ sessionId,
1564
+ callerModel,
1565
+ callerAgent,
1566
+ directory,
1567
+ mode,
1568
+ trigger,
1569
+ questionChars,
1570
+ effort: null,
1571
+ outcome: "skipped_typesafe",
1572
+ errorType: null,
1573
+ latencyMs,
1574
+ inputTokens: null,
1575
+ outputTokens: null,
1576
+ transcriptChars: transcript.length,
1577
+ priorConsultations: prior.count,
1578
+ via: "opencode-session",
1579
+ gate: gateRecord,
1580
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, null),
1581
+ });
1582
+ console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=skipped_typesafe needed=${gateDecision.metrics.neededProbability ?? "n/a"} latencyMs=${latencyMs}`);
1583
+ const skipNote = gateSummary(gateRecord);
1584
+ return (`advisor consultation skipped (typesafe): the request did not need an advisor at this point` +
1585
+ `${gateDecision.metrics.neededProbability !== null ? ` (need probability ${gateDecision.metrics.neededProbability.toFixed(2)})` : ""}. ` +
1586
+ `Ask again with a concrete unresolved question if the situation changes.` +
1587
+ (skipNote ? `\n_${skipNote}_` : ""));
1588
+ }
1589
+ effectiveEffort =
1590
+ gateDecision?.status === "proceed" && gateDecision.effectiveEffort
1591
+ ? gateDecision.effectiveEffort
1592
+ : requestedEffort;
980
1593
  const result = await callAdvisor({
981
1594
  runtime: opts.runtime,
982
1595
  systemPrompt,
983
1596
  transcript,
984
1597
  question: opts.question,
985
1598
  priorNote,
1599
+ effort: effectiveEffort,
986
1600
  signal: opts.signal,
987
1601
  config,
988
1602
  });
989
1603
  const latencyMs = Date.now() - started;
990
- await logAdvisorMetrics({
1604
+ await logAdvisorMetrics(opts.runtime, {
991
1605
  ts: new Date().toISOString(),
992
1606
  sessionId,
993
1607
  callerModel,
@@ -996,6 +1610,7 @@ async function runAdvisor(opts) {
996
1610
  mode,
997
1611
  trigger,
998
1612
  questionChars,
1613
+ effort: result.variant ?? null,
999
1614
  outcome: "advisor_response",
1000
1615
  errorType: null,
1001
1616
  latencyMs,
@@ -1004,16 +1619,20 @@ async function runAdvisor(opts) {
1004
1619
  transcriptChars: transcript.length,
1005
1620
  priorConsultations: prior.count,
1006
1621
  via: "opencode-session",
1622
+ gate: gateRecord,
1623
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, result.variant ?? effectiveEffort ?? requestedEffort ?? null),
1007
1624
  });
1008
1625
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=advisor_response latencyMs=${latencyMs}`);
1626
+ const gateNote = gateSummary(gateRecord);
1009
1627
  return (result.text +
1010
- `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_`);
1628
+ `\n\n_advisor consultation #${prior.count + 1} in this session chain (trigger=${trigger})_` +
1629
+ (gateNote ? `\n_${gateNote}_` : ""));
1011
1630
  }
1012
1631
  catch (err) {
1013
1632
  const message = err instanceof Error ? err.message : String(err);
1014
1633
  const errorType = classifyAdvisorError(message);
1015
1634
  const latencyMs = Date.now() - started;
1016
- await logAdvisorMetrics({
1635
+ await logAdvisorMetrics(opts.runtime, {
1017
1636
  ts: new Date().toISOString(),
1018
1637
  sessionId,
1019
1638
  callerModel,
@@ -1022,6 +1641,7 @@ async function runAdvisor(opts) {
1022
1641
  mode,
1023
1642
  trigger,
1024
1643
  questionChars,
1644
+ effort: effectiveEffort ?? requestedEffort ?? null,
1025
1645
  outcome: "error",
1026
1646
  errorType,
1027
1647
  latencyMs,
@@ -1030,6 +1650,8 @@ async function runAdvisor(opts) {
1030
1650
  transcriptChars: transcript.length,
1031
1651
  priorConsultations: prior.count,
1032
1652
  via: "opencode-session",
1653
+ gate: gateRecord,
1654
+ benchmarks: summarizeBenchmarkEvidence(benchmarkLoaded, effectiveEffort ?? requestedEffort ?? null),
1033
1655
  });
1034
1656
  console.log(`[advisor] session=${sessionId} mode=${mode} trigger=${trigger} outcome=error errorType=${errorType} latencyMs=${latencyMs}`);
1035
1657
  throw new Error(`advisor failed (${errorType}): ${message}`);
@@ -1046,8 +1668,8 @@ export async function setupOcAdvisorV2(ctx) {
1046
1668
  const reg = await ctx.tool.transform((draft) => {
1047
1669
  draft.add({
1048
1670
  name: "advisor",
1049
- description: TOOL_DESCRIPTION,
1050
- input: ADVISOR_INPUT_SCHEMA,
1671
+ description: buildToolDescription(advisorConfig),
1672
+ input: buildAdvisorInputSchema(advisorConfig),
1051
1673
  // Register as a direct tool, not a Code Mode tool. OpenCode 2 only
1052
1674
  // exposes tools with `codemode: false` to the model directly; every
1053
1675
  // other tool is reachable solely through `execute`, whose tool log
@@ -1064,9 +1686,12 @@ export async function setupOcAdvisorV2(ctx) {
1064
1686
  mode: input?.mode,
1065
1687
  trigger: input?.trigger,
1066
1688
  question: input?.question,
1689
+ effort: input?.effort,
1067
1690
  signal: context.abort,
1068
1691
  callerAgent: context.agent,
1069
1692
  callerDirectory: context.directory,
1693
+ callerMessageID: context.messageID,
1694
+ callerCallID: context.id,
1070
1695
  config: advisorConfig,
1071
1696
  });
1072
1697
  return { content: text };
@@ -1082,15 +1707,17 @@ export async function setupOcAdvisorV2(ctx) {
1082
1707
  return;
1083
1708
  // `event.tools` lists the direct tools available to this request.
1084
1709
  // OpenCode drops entries a hook adds for tools it did not register,
1085
- // so the hook can only hide the tool (Fable sessions), never add it.
1086
- // The checkpoint instruction is injected only when the tool is
1087
- // actually available, e.g. not when a permission rule removed it.
1710
+ // so the hook can only hide the tool (Fable or opted-out sessions),
1711
+ // never add it. The checkpoint instruction is injected only when
1712
+ // the tool is actually available, e.g. not when a permission rule
1713
+ // removed it.
1714
+ const disabled = advisorDisabledReason(event.model, advisorConfig);
1088
1715
  let available = false;
1089
1716
  for (const key of Object.keys(event.tools)) {
1090
1717
  if (!isAdvisorToolName(key)) {
1091
1718
  continue;
1092
1719
  }
1093
- if (isFableModel(event.model)) {
1720
+ if (disabled) {
1094
1721
  delete event.tools[key];
1095
1722
  }
1096
1723
  else {
@@ -1123,5 +1750,5 @@ export const OcAdvisorPluginV2 = plugin;
1123
1750
  export default plugin;
1124
1751
  // Named exports for unit tests (bun test). The plugin entrypoint is the
1125
1752
  // default export above.
1126
- export { ADVISOR_TRIGGERS, CHECKPOINT_INSTRUCTION, DEFAULT_ADVISOR_CONFIG, TOOL_DESCRIPTION, buildAdvisorPrompt, checkAdvisorSupport, classifyAdvisorError, ensureAdvisorSession, extractGeneratedText, findAdvisorModel, hasAdvisorConnection, inferTrigger, isAdvisorToolName, isFableModel, isProviderUsable, parseModelRef, resolveAdvisorConfig, resolveAdvisorVariant, resetAdvisorSessionCache, unwrapData, withTimeout, };
1753
+ export { ADVISOR_TRIGGERS, CHECKPOINT_INSTRUCTION, DEFAULT_ADVISOR_CONFIG, TOOL_DESCRIPTION, buildAdvisorInputSchema, buildAdvisorPrompt, buildToolDescription, checkAdvisorSupport, classifyAdvisorError, ensureAdvisorSession, extractGeneratedText, findAdvisorModel, hasAdvisorConnection, advisorDisabledReason, inferTrigger, isAdvisorToolName, isFableModel, isProviderUsable, parseModelRef, resolveAdvisorConfig, resolveAdvisorVariant, resolveRequestedEffort, resolveSupportedEfforts, resetAdvisorSessionCache, resetBenchmarkStores, unwrapData, withTimeout, formatV2Message, runAdvisor, };
1127
1754
  //# sourceMappingURL=ocAdvisor.js.map