opencode-plugboard 0.20.0 → 0.21.1
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/README.md +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +30 -23
- package/package.json +10 -4
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +6 -4
- package/src/cache.ts +16 -5
- package/src/completions/request.ts +13 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +69 -3
- package/src/contract.ts +6 -0
- package/src/discovery.ts +27 -0
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +7 -3
- package/src/http.ts +65 -7
- package/src/openai/fanout.ts +19 -5
- package/src/openai/request.ts +4 -4
- package/src/openai/response.ts +108 -19
- package/src/openai/route.ts +21 -11
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +1 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +4 -2
- package/src/runtime.ts +51 -7
- package/src/session/caller.ts +69 -7
- package/src/session/context.ts +16 -4
- package/src/session/events.ts +116 -12
- package/src/session/host.ts +14 -0
- package/src/session/seeds.ts +10 -0
- package/tui.ts +5 -4
|
@@ -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
|
-
|
|
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
|
}
|
package/src/responses/route.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
package/src/session/caller.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
192
|
-
|
|
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:
|
|
199
|
-
})
|
|
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
|
},
|
package/src/session/context.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|