opencode-plugboard 0.20.0 → 0.21.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.
@@ -7,6 +7,7 @@
7
7
  * call passes a no-op `emit`. Reasoning deltas are dropped (no reasoning items are produced).
8
8
  */
9
9
  import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
+ import { structuredMissing, textFallback } from "../openai/response.js"
10
11
 
11
12
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
12
13
 
@@ -25,6 +26,8 @@ export interface ResponsesExchange {
25
26
  * answer is the message text only when the step called no caller tool.
26
27
  */
27
28
  readonly callerTools?: boolean
29
+ /** The caller's `text.format` schema: a text answer is taken when the model skipped the respond call (`textFallback`, #173). */
30
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
28
31
  }
29
32
 
30
33
  /** A stream event as emitted: `type` is also the SSE event name. */
@@ -37,6 +40,8 @@ export interface ResponseRun {
37
40
  readonly started: boolean
38
41
  /** The final response object (completed, incomplete or failed once started). */
39
42
  readonly response?: object
43
+ /** The structured answer came from the final text, not the respond call (`textFallback`). */
44
+ readonly structuredFallback?: boolean
40
45
  }
41
46
 
42
47
  type Item = Record<string, unknown>
@@ -63,21 +68,6 @@ const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Mod
63
68
  /** SSE frame of one stream event: `event: <type>` then `data: <json>`. */
64
69
  export const sseFrame = (event: ResponsesStreamEvent): string => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
65
70
 
66
- /**
67
- * Structured-output projection, same rules as the Chat Completions front-end: in structured mode
68
- * the message text is exactly the arguments of the first call to the `respond` tool; prose and
69
- * other calls are suppressed. Without an answer the exchange fails, except when the model
70
- * stopped for `length` or `content-filter`, which keep their status with empty output.
71
- */
72
- const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
73
- exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
74
- ? undefined
75
- : {
76
- status: 502,
77
- kind: "server",
78
- code: "structured_output_missing",
79
- message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
80
- }
81
71
 
82
72
  /**
83
73
  * `committed`: whether the transport already committed a 200 stream before any event was emitted
@@ -114,6 +104,8 @@ export async function runResponse(
114
104
  let started = false
115
105
  let calls = 0
116
106
  let answered = false
107
+ /** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
108
+ let collected = ""
117
109
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
118
110
  let held: ToolCall | undefined
119
111
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -199,6 +191,7 @@ export async function runResponse(
199
191
  }
200
192
  continue
201
193
  }
194
+ if (event.type === "text-delta") collected += event.text
202
195
  if (!(exchange.callerTools && event.type === "tool-call")) continue
203
196
  }
204
197
  if (event.type === "text-delta") text(event.text)
@@ -207,7 +200,9 @@ export async function runResponse(
207
200
 
208
201
  const passthrough = exchange.callerTools === true && calls > 0
209
202
  const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
210
- failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
203
+ const fallback = structuredAnswer && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
204
+ const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
205
+ failure ??= finish ? missing : noFinish()
211
206
  if (failure) {
212
207
  if (!started && !committed()) return { failure, started }
213
208
  start()
@@ -221,6 +216,7 @@ export async function runResponse(
221
216
  }
222
217
  start()
223
218
  if (held && structuredAnswer) text(args(held.input))
219
+ if (fallback !== undefined) text(fallback)
224
220
  closeMessage()
225
221
  const incomplete = finish === "length" ? "max_output_tokens" : finish === "content-filter" ? "content_filter" : undefined
226
222
  const response = envelope(incomplete ? "incomplete" : "completed", items, {
@@ -228,5 +224,5 @@ export async function runResponse(
228
224
  ...(usage ? { usage: encodeUsage(usage) } : {}),
229
225
  })
230
226
  send(incomplete ? "response.incomplete" : "response.completed", { response })
231
- return { started, response }
227
+ return { started, response, ...(fallback !== undefined ? { structuredFallback: true } : {}) }
232
228
  }
@@ -14,10 +14,10 @@ import { reportServed, type ServedInfo } from "../fallback.js"
14
14
  import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
- import { reportPolicy } from "../policy.js"
17
+ import { reportPolicy, reportServerTools } from "../policy.js"
18
18
  import { reportUsage } from "../usage-header.js"
19
19
  import { setSessionLink } from "../session/link.js"
20
- import { encodeError } from "../openai/response.js"
20
+ import { STRUCTURED_FALLBACK_HEADER, encodeError } from "../openai/response.js"
21
21
  import type { RouteHost } from "../openai/route.js"
22
22
  import { parseResponsesRequest } from "./request.js"
23
23
  import { runResponse, sseFrame } from "./response.js"
@@ -60,6 +60,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
60
60
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
61
61
  onPolicy: reportPolicy(response),
62
62
  onUsage: reportUsage(response, exchange.stream),
63
+ onServerTools: reportServerTools(response, exchange.stream),
63
64
  onCache: reportCache(response),
64
65
  }),
65
66
  scope,
@@ -81,5 +82,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
81
82
  const run = await runResponse(events, exchange, () => {})
82
83
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
83
84
  rememberAnswer(app, run.response)
85
+ if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
84
86
  sendJson(response, 200, run.response)
85
87
  }
package/src/runtime.ts CHANGED
@@ -118,8 +118,8 @@ const BACKEND_POLL_MS = 25
118
118
  const BIND_RETRY_MS = 15_000
119
119
  const BIND_REMINDER_MS = 5 * 60_000
120
120
 
121
- /** 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
122
- const VERSION = 14
121
+ /** 15: the last options error (`configError`, #159) and `catalogLoaded` for `/readyz` (#160). 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
122
+ const VERSION = 15
123
123
  const KEY = Symbol.for("opencode-plugboard.runtime")
124
124
 
125
125
  interface Listener {
@@ -156,6 +156,12 @@ interface Pump {
156
156
  stop(): void
157
157
  }
158
158
 
159
+ /** A plugin options error and when it was recorded (epoch ms). */
160
+ export interface ConfigError {
161
+ readonly message: string
162
+ readonly at: number
163
+ }
164
+
159
165
  interface Runtime {
160
166
  readonly version: number
161
167
  readonly sessions: SessionStore
@@ -187,6 +193,10 @@ interface Runtime {
187
193
  readonly budgets: BudgetState
188
194
  /** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
189
195
  readonly runtimeKeys: KeyState
196
+ /** The last plugin options error (`index.ts`), cleared by the next Location whose options parse (#159). */
197
+ configError?: ConfigError
198
+ /** Set once the serving catalog has loaded successfully (`loadServingCatalog`); `/readyz` reads it (#160). */
199
+ catalogLoaded?: boolean
190
200
  listener?: Listener
191
201
  /** Present while a fiber is subscribed to the server event bus. */
192
202
  pump?: Pump
@@ -345,7 +355,9 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
345
355
  if (rt) rt.cache = held
346
356
  }
347
357
  const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
348
- return cachedCaller(caller, config, held.store, count, (model) => personaOf(options, model) !== undefined)
358
+ // One bypass predicate: a persona model, or an app whose answers depend on live OpenCode tool output (#82).
359
+ const serverToolApps = new Set(Object.entries(options.apps).filter(([, policy]) => policy.serverTools !== undefined).map(([app]) => app))
360
+ return cachedCaller(caller, config, held.store, count, (model, app) => personaOf(options, model) !== undefined || serverToolApps.has(app))
349
361
  }
350
362
  return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
351
363
  }
@@ -357,12 +369,16 @@ export interface RuntimeStatus extends RuntimeSnapshot {
357
369
  readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
358
370
  /** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
359
371
  readonly servingConfirmed: boolean
372
+ /** The last plugin options error, when its Location was the latest to load the plugin (#159). */
373
+ readonly configError?: ConfigError
374
+ /** The serving catalog has loaded at least once in this process (`/readyz`, #160). */
375
+ readonly catalogLoaded: boolean
360
376
  }
361
377
 
362
378
  /** A synchronous snapshot of the runtime: no host call, no filesystem. */
363
379
  export function processStatus(): RuntimeStatus {
364
380
  const rt = runtime()
365
- if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
381
+ if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false, catalogLoaded: false }
366
382
  const serving = preferred(rt)
367
383
  return {
368
384
  runtimeVersion: rt.version,
@@ -371,10 +387,23 @@ export function processStatus(): RuntimeStatus {
371
387
  guards: rt.sessions.size(),
372
388
  startedAt: rt.startedAt,
373
389
  servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
390
+ catalogLoaded: rt.catalogLoaded === true,
374
391
  ...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
392
+ ...(rt.configError ? { configError: rt.configError } : {}),
375
393
  }
376
394
  }
377
395
 
396
+ /**
397
+ * Records the plugin options error of the Location that just failed to parse its options, or clears
398
+ * it (`undefined`) once options parse. Synchronous, so plugin setup may call it. The status and
399
+ * `/readyz` show it while a listener from an earlier good generation still serves (#159).
400
+ */
401
+ export function setConfigError(message: string | undefined): void {
402
+ const rt = runtime()
403
+ if (!rt) return
404
+ rt.configError = message === undefined ? undefined : { message, at: Date.now() }
405
+ }
406
+
378
407
  /** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
379
408
  function preferred(rt: Runtime): Generation | undefined {
380
409
  for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
@@ -385,9 +414,11 @@ function preferred(rt: Runtime): Generation | undefined {
385
414
  * The catalog of the preferred accepted generation. `fallback` (the caller's own loader) is only
386
415
  * used when no generation is registered, which a live request does not normally see.
387
416
  */
388
- export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
417
+ export async function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
389
418
  const rt = runtime()
390
- return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
419
+ const models = await (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
420
+ if (rt) rt.catalogLoaded = true
421
+ return models
391
422
  }
392
423
 
393
424
  /** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
@@ -521,7 +552,13 @@ export function registerGeneration(input: GenerationInput): Registration {
521
552
  } else if (canonicalJson(keys) !== canonicalJson(rt.keys ?? {})) {
522
553
  const live = rt.keys ?? {}
523
554
  if (rt.generations.length === 0) rt.keys = keys
524
- else if (revokesOrRotatesOnly(live, keys)) {
555
+ else if (Object.keys(keys).length === 0) {
556
+ // #152: removing the last key fails closed. The old key must not keep working, and the set must not go keyless.
557
+ if (canonicalJson(live) !== canonicalJson(REVOKED_KEYS)) {
558
+ log("listener.keys_updated", { removed: Object.keys(live).length, rotated: 0 })
559
+ rt.keys = REVOKED_KEYS
560
+ }
561
+ } else if (revokesOrRotatesOnly(live, keys)) {
525
562
  log("listener.keys_updated", {
526
563
  removed: Object.keys(live).filter((app) => !Object.hasOwn(keys, app)).length,
527
564
  rotated: Object.keys(keys).filter((app) => Object.hasOwn(live, app) && live[app] !== keys[app]).length,
@@ -571,6 +608,13 @@ export function registerGeneration(input: GenerationInput): Registration {
571
608
  }
572
609
  }
573
610
 
611
+ /**
612
+ * #152: the live key set once the last key is revoked while a generation serves. An empty key set means
613
+ * keyless, so this one keeps an entry no request can match: `*` is not a valid app name, and its key is
614
+ * empty, which `Bearer` (it needs a non-empty token) never presents. Every request gets 401 until restart.
615
+ */
616
+ const REVOKED_KEYS: Readonly<Record<string, string>> = { "*": "" }
617
+
574
618
  /** #90: `next` names no app that `live` lacks, and names at least one app: it only removes or changes keys. */
575
619
  function revokesOrRotatesOnly(live: Readonly<Record<string, string>>, next: Readonly<Record<string, string>>): boolean {
576
620
  const apps = Object.keys(next)
@@ -18,15 +18,15 @@
18
18
 
19
19
  import { randomUUID } from "node:crypto"
20
20
  import { raceAbort } from "../abort.js"
21
- import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
21
+ import { APP_NAME_PATTERN, DEFAULT_APP, DEFAULT_SERVER_TOOL_STEPS, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
22
22
  import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
23
- import { log, logError } from "../log.js"
23
+ import { log, logError, logWarn } from "../log.js"
24
24
  import { checkAppModel, type Persona } from "../models.js"
25
25
  import { RequestCollector } from "./events.js"
26
26
  import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
27
27
  import { describeError, type SessionPort } from "./host.js"
28
28
  import { ImageError, prepareImages, type RemoteImages } from "./media.js"
29
- import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
29
+ import { requestPermissions, seedToolsFor } from "./seeds.js"
30
30
  import type { SessionStore } from "./state.js"
31
31
 
32
32
  export interface SessionCallerDeps {
@@ -106,6 +106,33 @@ function checkAppTools(request: PlugRequest, app: string, policy: AppPolicy | un
106
106
  message: `Request has ${request.tools.length} tools; app ${app} allows ${max} (option \`apps.${app}.maxTools\`)`,
107
107
  }
108
108
  }
109
+ /** A caller tool may not share a name with one of the app's server tools: the model would see two tools under one name. */
110
+ function checkServerToolNames(request: PlugRequest, policy: AppPolicy | undefined): PlugError | undefined {
111
+ const clash = request.tools.find((tool) => policy?.serverTools?.includes(tool.name))
112
+ if (!clash) return undefined
113
+ return {
114
+ status: 400,
115
+ kind: "invalid_request",
116
+ code: "server_tool_collision",
117
+ param: "tools",
118
+ message: `Tool \`${clash.name}\` is one of this app's OpenCode server tools (option \`apps.<name>.serverTools\`); rename the caller's tool`,
119
+ }
120
+ }
121
+
122
+ /** Every server tool must be a direct tool of this location right now (fail closed; checked per request, never at setup). */
123
+ async function checkServerToolsAvailable(port: SessionPort, policy: AppPolicy | undefined, signal: AbortSignal): Promise<PlugError | undefined> {
124
+ if (!policy?.serverTools) return undefined
125
+ const offered = new Set(await raceAbort(port.directTools(), signal))
126
+ const missing = policy.serverTools.filter((tool) => !offered.has(tool))
127
+ if (missing.length === 0) return undefined
128
+ return {
129
+ status: 400,
130
+ kind: "invalid_request",
131
+ code: "server_tool_unavailable",
132
+ message: `OpenCode tool(s) not available in this location: ${missing.join(", ")} (option \`apps.<name>.serverTools\`). CodeMode tools, including MCP tools by default, cannot be listed; set codemode: false on the tool`,
133
+ }
134
+ }
135
+
109
136
  export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
110
137
  const { port, sessions, maxTools, ensureBackend } = deps
111
138
  const apps = deps.apps ?? {}
@@ -120,9 +147,12 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
120
147
  const requestID = randomUUID()
121
148
  const startedAt = Date.now()
122
149
  let outcome = "cancelled"
150
+ // Whether the model produced anything at all (text, reasoning or a tool call), for `answer.empty` (#172).
151
+ let produced = false
123
152
  const note = (event: PlugEvent): PlugEvent => {
124
153
  if (event.type === "finish") outcome = `finish:${event.reason}`
125
154
  else if (event.type === "error") outcome = `error:${event.error.status}${event.error.code ? `:${event.error.code}` : ""}`
155
+ else produced = true
126
156
  return event
127
157
  }
128
158
 
@@ -133,6 +163,8 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
133
163
  let { request, stripped } = applyToolPolicy(called, policy)
134
164
  const tooMany = checkAppTools(request, app, policy)
135
165
  if (tooMany) return yield note({ type: "error", error: tooMany })
166
+ const clash = checkServerToolNames(request, policy)
167
+ if (clash) return yield note({ type: "error", error: clash })
136
168
  if (stripped) {
137
169
  try {
138
170
  context.onPolicy?.(["tools-stripped"])
@@ -172,6 +204,9 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
172
204
  const unavailable = await ensureBackend?.(app, context.signal)
173
205
  if (unavailable) return yield note({ type: "error", error: unavailable })
174
206
  if (context.signal.aborted) return
207
+ const missingServerTools = await checkServerToolsAvailable(port, policy, context.signal)
208
+ if (missingServerTools) return yield note({ type: "error", error: missingServerTools })
209
+ if (context.signal.aborted) return
175
210
 
176
211
  // 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
177
212
  // caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
@@ -188,15 +223,32 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
188
223
  }
189
224
  if (context.signal.aborted) return
190
225
 
191
- collector = new RequestCollector({ stopTurn: interrupt, requestID })
192
- const created = await raceAbort(port.create({
226
+ collector = new RequestCollector({
227
+ stopTurn: interrupt,
228
+ requestID,
229
+ app,
230
+ ...(policy?.serverTools
231
+ ? { serverTools: new Set(policy.serverTools), maxServerToolSteps: policy.maxServerToolSteps ?? DEFAULT_SERVER_TOOL_STEPS }
232
+ : {}),
233
+ })
234
+
235
+ const creating = port.create({
193
236
  directory,
194
237
  model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
195
238
  ...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
196
239
  title: makeTitle(request),
197
240
  metadata: { plugboard: { app, model: request.model, requestID } },
198
- permissions: REQUEST_PERMISSIONS,
199
- }), context.signal)
241
+ permissions: requestPermissions(policy?.serverTools ?? []),
242
+ })
243
+ const created = await raceAbort(creating, context.signal).catch((error) => {
244
+ // The host keeps creating after the abort. A session that lands now is in no index, so it is removed.
245
+ if (context.signal.aborted)
246
+ void creating.then(
247
+ (late) => port.remove(late.id).catch((removeError) => logError("session.late_remove_failed", { requestID, app, sessionID: late.id, error: describeError(removeError) })),
248
+ () => {},
249
+ )
250
+ throw error
251
+ })
200
252
  sessionID = String(created.id)
201
253
  sessions.register({ sessionID, app, requestID, request, collector, prelude })
202
254
 
@@ -240,6 +292,16 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
240
292
  if (promptStarted) sessions.retire(sessionID, { timeoutMs: CLEANUP_TIMEOUT_MS, onTimeout: interrupt })
241
293
  else sessions.release(sessionID)
242
294
  }
295
+ const serverToolCalls = collector?.serverCallCount ?? 0
296
+ if (serverToolCalls > 0 && context.onServerTools) {
297
+ try {
298
+ context.onServerTools(serverToolCalls)
299
+ } catch (error) {
300
+ logError("request.on_server_tools_failed", { requestID, app, error: describeError(error) })
301
+ }
302
+ }
303
+ // A turn that stopped with nothing in it is kept as a session for inspection: the session id finds it.
304
+ if (outcome === "finish:stop" && !produced) logWarn("answer.empty", { requestID, app, model: request.model, sessionID, tools: request.tools.length })
243
305
  log("request.finished", { requestID, app, model: request.model, durationMs: Date.now() - startedAt, outcome })
244
306
  }
245
307
  },
@@ -59,10 +59,13 @@ function generationOptions(request: PlugRequest): Record<string, unknown> {
59
59
  * Returns how many caller tools got no seed (zero unless the seed pool is smaller than the request
60
60
  * needs, or two caller tools share a name and would collapse into one key).
61
61
  *
62
+ * The app's `serverTools` (option `apps`) are kept as the host offered them: their descriptors were
63
+ * never rewritten, so the host runs the real tool. Not offered when the caller set `tool_choice` none.
64
+ *
62
65
  * The map is built with `Object.fromEntries`: a tool named `__proto__` becomes an own key like any
63
66
  * other instead of assigning the prototype and vanishing.
64
67
  */
65
- function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number {
68
+ function remapSeedTools(session: ContextHookEvent, request: PlugRequest, serverTools: ReadonlyArray<string>): number {
66
69
  const seeds = Object.entries(session.tools ?? {}).filter(([name]) => name.startsWith(SEED_PREFIX))
67
70
  const tools = seedToolsFor(request)
68
71
  const next = new Map<string, HostToolDescriptor>()
@@ -77,8 +80,16 @@ function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number
77
80
  descriptor.input = tool.parameters
78
81
  next.set(tool.name, descriptor)
79
82
  }
83
+ const missing = tools.length - next.size
84
+ if (request.toolChoice !== "none") {
85
+ const offered = session.tools ?? {}
86
+ for (const name of serverTools) {
87
+ if (!Object.hasOwn(offered, name) || next.has(name)) continue
88
+ next.set(name, offered[name] as HostToolDescriptor)
89
+ }
90
+ }
80
91
  session.tools = Object.fromEntries(next)
81
- return tools.length - next.size
92
+ return missing
82
93
  }
83
94
 
84
95
  /**
@@ -109,6 +120,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
109
120
  const state = sessions.lookup(String(session.sessionID))
110
121
  if (!state) return
111
122
  const { request } = state
123
+ const policy = appPolicy({ apps }, state.app)
112
124
 
113
125
  // Swap media placeholders for the Asset-carrying parts the host built from the prompt files.
114
126
  const hostMedia = (session.messages ?? [])
@@ -125,7 +137,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
125
137
 
126
138
  // Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
127
139
  // then the tool-choice steering below.
128
- const system = [...(state.prelude ?? []), ...systemFor(request.system, appPolicy({ apps }, state.app))].map(text)
140
+ const system = [...(state.prelude ?? []), ...systemFor(request.system, policy)].map(text)
129
141
  // The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
130
142
  const choice = request.toolChoice
131
143
  if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
@@ -134,7 +146,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
134
146
  session.messages = toHostMessages(request.messages).map((message) => ({ ...message, content: message.content.map(swap) }))
135
147
  Object.assign(session.options ?? (session.options = {}), generationOptions(request))
136
148
 
137
- const missingTools = remapSeedTools(session, request)
149
+ const missingTools = remapSeedTools(session, request, policy?.serverTools ?? [])
138
150
 
139
151
  // The caller validates both before dispatch; this only fires if the host disagrees. The request is
140
152
  // already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.