opencode-plugboard 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/index.ts +101 -0
- package/package.json +33 -4
- package/src/config.ts +164 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +216 -0
- package/src/responses/request.ts +377 -0
- package/src/responses/response.ts +225 -0
- package/src/responses/route.ts +72 -0
- package/src/runtime.ts +535 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +215 -0
- package/src/session/media.ts +89 -0
- package/src/session/prune.ts +175 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +144 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-backed `ModelCaller`: one model call = one kept OpenCode session in the caller
|
|
3
|
+
* app's directory under the data root, one model step.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle of `stream()`: validate the app name, prepare images, create the session, register
|
|
6
|
+
* its request state, start the prompt, then relay the collector's events until it is terminal.
|
|
7
|
+
* Abort is checked before every side effect (directory, backend, create, register, prompt), and
|
|
8
|
+
* the awaited host call (create) is raced against the request signal; teardown never is.
|
|
9
|
+
* Request sessions are registered in the process-wide session store (`state.ts`) so the
|
|
10
|
+
* app-directory hook (`context.ts`) and the event pump can find them.
|
|
11
|
+
*
|
|
12
|
+
* The response and the host execution have different lifetimes (docs/architecture.md). When the
|
|
13
|
+
* response is over, `finally` interrupts unless the host already reported the end of the
|
|
14
|
+
* execution or the turn settled with a plain stop (a step can fail while the runner goes on) and hands the store entry, the
|
|
15
|
+
* request guard, to `sessions.retire`: it stays until the host reports the end of the execution
|
|
16
|
+
* or a bounded cleanup runs out (logged). Nothing here waits for that.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { randomUUID } from "node:crypto"
|
|
20
|
+
import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
|
|
21
|
+
import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
|
|
22
|
+
import { log, logError } from "../log.js"
|
|
23
|
+
import { RequestCollector } from "./events.js"
|
|
24
|
+
import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
|
|
25
|
+
import { describeError, type SessionPort } from "./host.js"
|
|
26
|
+
import { ImageError, prepareImages } from "./media.js"
|
|
27
|
+
import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
|
|
28
|
+
import type { SessionStore } from "./state.js"
|
|
29
|
+
|
|
30
|
+
export interface SessionCallerDeps {
|
|
31
|
+
readonly port: SessionPort
|
|
32
|
+
/** The process-wide store request sessions are registered in. */
|
|
33
|
+
readonly sessions: SessionStore
|
|
34
|
+
/** The seed pool size of the app directories (option `maxTools`); a request needing more tools fails before dispatch. */
|
|
35
|
+
readonly maxTools: number
|
|
36
|
+
/** Allow fetching http(s) image URLs (option `remoteImages`). */
|
|
37
|
+
readonly remoteImages?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Make sure the backend of `app` can run request sessions (the app directory's plugin is loaded),
|
|
40
|
+
* called right before `session.create`. An error ends the request without creating anything.
|
|
41
|
+
*/
|
|
42
|
+
readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How long the request guard may outlive the response while the host has not reported the end of the execution. */
|
|
46
|
+
const CLEANUP_TIMEOUT_MS = 30_000
|
|
47
|
+
|
|
48
|
+
const failure = (status: number, message: string, code: string): PlugEvent => ({
|
|
49
|
+
type: "error",
|
|
50
|
+
error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const countMedia = (request: PlugRequest): number =>
|
|
54
|
+
request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
|
|
55
|
+
|
|
56
|
+
/** Everything the hook will need must exist before dispatch: one prompt file per image, one seed tool per offered tool. */
|
|
57
|
+
function checkPreparation(request: PlugRequest, images: number, maxTools: number): PlugError | undefined {
|
|
58
|
+
const wanted = countMedia(request)
|
|
59
|
+
if (images !== wanted) {
|
|
60
|
+
return { status: 500, kind: "server", code: "backend_preparation_failed", message: `Prepared ${images} attachment(s) for a request with ${wanted}` }
|
|
61
|
+
}
|
|
62
|
+
const tools = seedToolsFor(request).length
|
|
63
|
+
if (tools > maxTools) {
|
|
64
|
+
return {
|
|
65
|
+
status: 400,
|
|
66
|
+
kind: "invalid_request",
|
|
67
|
+
code: "backend_preparation_failed",
|
|
68
|
+
param: "tools",
|
|
69
|
+
message: `The request offers ${tools} tools but plugboard supports at most ${maxTools} (option \`maxTools\`)`,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Settles with `promise`, or rejects as soon as `signal` aborts. Request side only: the promise itself keeps running. */
|
|
76
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
77
|
+
promise.catch(() => {}) // a late rejection after the race was lost must not become unhandled
|
|
78
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
79
|
+
return new Promise<T>((resolve, reject) => {
|
|
80
|
+
const onAbort = () => reject(signal.reason)
|
|
81
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
82
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
87
|
+
const { port, sessions, maxTools, ensureBackend } = deps
|
|
88
|
+
const remoteImages = deps.remoteImages === true
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async *stream(request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
|
|
92
|
+
const app = APP_NAME_PATTERN.test(context.app) || context.app === DEFAULT_APP ? context.app : undefined
|
|
93
|
+
if (!app) return yield failure(400, `Invalid app name: ${context.app}`, "invalid_app")
|
|
94
|
+
if (context.signal.aborted) return
|
|
95
|
+
|
|
96
|
+
const requestID = randomUUID()
|
|
97
|
+
const startedAt = Date.now()
|
|
98
|
+
let outcome = "cancelled"
|
|
99
|
+
const note = (event: PlugEvent): PlugEvent => {
|
|
100
|
+
if (event.type === "finish") outcome = `finish:${event.reason}`
|
|
101
|
+
else if (event.type === "error") outcome = `error:${event.error.status}${event.error.code ? `:${event.error.code}` : ""}`
|
|
102
|
+
return event
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let sessionID: string | undefined
|
|
106
|
+
let collector: RequestCollector | undefined
|
|
107
|
+
let promptStarted = false
|
|
108
|
+
let interruptRequested = false
|
|
109
|
+
const interrupt = () => {
|
|
110
|
+
if (sessionID) {
|
|
111
|
+
interruptRequested = true
|
|
112
|
+
void port.interrupt(sessionID).catch((error) => logError("session.interrupt_failed", { requestID, app, error: describeError(error) }))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const onAbort = () => {
|
|
116
|
+
collector?.cancel()
|
|
117
|
+
interrupt()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// 1. Prompt files: images become data URLs (http(s) ones are fetched once).
|
|
122
|
+
const images = await prepareImages(request, context.signal, remoteImages)
|
|
123
|
+
const invalid = checkPreparation(request, images.length, maxTools)
|
|
124
|
+
if (invalid) return yield note({ type: "error", error: invalid })
|
|
125
|
+
|
|
126
|
+
// 2. The app directory under the data root; one top-level session per request.
|
|
127
|
+
if (context.signal.aborted) return
|
|
128
|
+
const directory = await ensureAppDir(app)
|
|
129
|
+
if (isPlugError(directory)) return yield note({ type: "error", error: directory })
|
|
130
|
+
|
|
131
|
+
// 3. The backend must be able to run the session before it is created.
|
|
132
|
+
if (context.signal.aborted) return
|
|
133
|
+
const unavailable = await ensureBackend?.(app, context.signal)
|
|
134
|
+
if (unavailable) return yield note({ type: "error", error: unavailable })
|
|
135
|
+
if (context.signal.aborted) return
|
|
136
|
+
|
|
137
|
+
collector = new RequestCollector({ stopTurn: interrupt })
|
|
138
|
+
const created = await raceAbort(port.create({
|
|
139
|
+
directory,
|
|
140
|
+
model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
|
|
141
|
+
title: makeTitle(request),
|
|
142
|
+
metadata: { plugboard: { app, model: request.model, requestID } },
|
|
143
|
+
permissions: REQUEST_PERMISSIONS,
|
|
144
|
+
}), context.signal)
|
|
145
|
+
sessionID = String(created.id)
|
|
146
|
+
sessions.register({ sessionID, app, requestID, request, collector })
|
|
147
|
+
|
|
148
|
+
context.signal.addEventListener("abort", onAbort, { once: true })
|
|
149
|
+
if (context.signal.aborted) return onAbort()
|
|
150
|
+
|
|
151
|
+
// The history id is known from here on: tell the front-end before the first event.
|
|
152
|
+
try {
|
|
153
|
+
context.onRecord?.(sessionID)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logError("request.on_record_failed", { requestID, app, error: describeError(error) })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Prompt concurrently; events stream through the collector while it runs.
|
|
159
|
+
if (context.signal.aborted) return onAbort()
|
|
160
|
+
const owned = collector
|
|
161
|
+
promptStarted = true
|
|
162
|
+
void port.prompt({ sessionID, text: renderTranscript(request), files: images }).catch((error) => {
|
|
163
|
+
logError("session.prompt_failed", { requestID, app, model: request.model, error: describeError(error) })
|
|
164
|
+
if (owned.terminal) return
|
|
165
|
+
owned.push({ type: "session.execution.failed", error: { type: "prompt_failed", message: "Could not start the model session" } })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
for await (const event of collector.events()) yield note(event)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof ImageError) yield note(failure(400, error.message, error.code))
|
|
171
|
+
else if (!context.signal.aborted) {
|
|
172
|
+
logError("request.backend_failed", { requestID, app, model: request.model, error: describeError(error) })
|
|
173
|
+
yield note(failure(502, "plugboard session backend failed", "backend_failed"))
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
context.signal.removeEventListener("abort", onAbort)
|
|
177
|
+
if (sessionID && collector) {
|
|
178
|
+
// The response may be over (consumer left early, error, timeout, a failed step) while the
|
|
179
|
+
// host keeps running the session: interrupt unless it reported the end of the execution
|
|
180
|
+
// or the step ended plainly (the runner stops by itself), and only if no interrupt went
|
|
181
|
+
// out yet (the tool-calls path asked at the step end already).
|
|
182
|
+
if (!collector.terminal) collector.cancel()
|
|
183
|
+
if (!collector.executionEnded && !collector.turnSettled && !interruptRequested) interrupt()
|
|
184
|
+
// The response ends here, the host execution may not have: the guard is kept until it does.
|
|
185
|
+
if (promptStarted) sessions.retire(sessionID, { timeoutMs: CLEANUP_TIMEOUT_MS, onTimeout: interrupt })
|
|
186
|
+
else sessions.release(sessionID)
|
|
187
|
+
}
|
|
188
|
+
log("request.finished", { requestID, app, model: request.model, durationMs: Date.now() - startedAt, outcome })
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context hook and seed tools: what plugboard installs inside the app directories, and what the
|
|
3
|
+
* `session.hook("context")` hook does for a plugboard request session.
|
|
4
|
+
*
|
|
5
|
+
* The stored prompt is only a readable transcript. The hook swaps in the caller's real
|
|
6
|
+
* structured system, messages, tool definitions and generation options. Sessions that are not
|
|
7
|
+
* plugboard requests (not in the process-wide session store) are untouched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Effect } from "effect"
|
|
11
|
+
import type { PlugError, PlugMessage, PlugRequest } from "../contract.js"
|
|
12
|
+
import type { ContextHookEvent, HostContext, HostPart, HostToolDescriptor } from "./host.js"
|
|
13
|
+
import { SEED_PREFIX, SEED_RESULT, seedToolsFor } from "./seeds.js"
|
|
14
|
+
import { FAILURE_EVENT, type SessionStore } from "./state.js"
|
|
15
|
+
|
|
16
|
+
const text = (value: string): HostPart => ({ type: "text", text: value })
|
|
17
|
+
|
|
18
|
+
/** Placeholder for a caller image; swapped for the host-built media part in `applyContext`. */
|
|
19
|
+
const mediaPlaceholder = (index: number): HostPart => ({ type: "media", __attachment: index })
|
|
20
|
+
|
|
21
|
+
/** Caller messages as host `Message` objects. Media placeholders are swapped in `applyContext`. */
|
|
22
|
+
function toHostMessages(messages: ReadonlyArray<PlugMessage>): Array<{ role: string; content: HostPart[] }> {
|
|
23
|
+
let image = 0
|
|
24
|
+
const part = (item: PlugMessage["content"][number]): HostPart => {
|
|
25
|
+
switch (item.type) {
|
|
26
|
+
case "text":
|
|
27
|
+
return text(item.text)
|
|
28
|
+
case "media":
|
|
29
|
+
return mediaPlaceholder(image++)
|
|
30
|
+
case "tool-call":
|
|
31
|
+
return { type: "tool-call", id: item.id, name: item.name, input: item.input }
|
|
32
|
+
case "tool-result":
|
|
33
|
+
return { type: "tool-result", id: item.id, name: item.name, result: { type: "text", value: item.result } }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return messages.map((message) => ({ role: message.role, content: message.content.map(part) }))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function generationOptions(request: PlugRequest): Record<string, unknown> {
|
|
40
|
+
const generation = request.generation
|
|
41
|
+
const options: Record<string, unknown> = {}
|
|
42
|
+
if (generation.maxTokens !== undefined) options.maxTokens = generation.maxTokens
|
|
43
|
+
if (generation.temperature !== undefined) options.temperature = generation.temperature
|
|
44
|
+
if (generation.topP !== undefined) options.topP = generation.topP
|
|
45
|
+
if (generation.stop !== undefined) options.stop = [...generation.stop]
|
|
46
|
+
if (generation.presencePenalty !== undefined) options.presencePenalty = generation.presencePenalty
|
|
47
|
+
if (generation.frequencyPenalty !== undefined) options.frequencyPenalty = generation.frequencyPenalty
|
|
48
|
+
return options
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Re-key the registered seed tools onto the caller's tool names.
|
|
53
|
+
*
|
|
54
|
+
* Descriptor object identity is load-bearing: the host maps a rewritten tool back to its
|
|
55
|
+
* registered tool by the descriptor object it handed to the hook (upstream
|
|
56
|
+
* `model-request.ts`, tool mapping after the `context` hook), so the descriptors are mutated in
|
|
57
|
+
* place and the very same objects are re-inserted under the caller's names. Never copy them.
|
|
58
|
+
* Returns how many caller tools got no seed (zero unless the seed pool is smaller than the request
|
|
59
|
+
* needs, or two caller tools share a name and would collapse into one key).
|
|
60
|
+
*
|
|
61
|
+
* The map is built with `Object.fromEntries`: a tool named `__proto__` becomes an own key like any
|
|
62
|
+
* other instead of assigning the prototype and vanishing.
|
|
63
|
+
*/
|
|
64
|
+
function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number {
|
|
65
|
+
const seeds = Object.entries(session.tools ?? {}).filter(([name]) => name.startsWith(SEED_PREFIX))
|
|
66
|
+
const tools = seedToolsFor(request)
|
|
67
|
+
const next = new Map<string, HostToolDescriptor>()
|
|
68
|
+
let index = 0
|
|
69
|
+
for (const tool of tools) {
|
|
70
|
+
const seed = seeds[index]
|
|
71
|
+
if (!seed) break
|
|
72
|
+
index++
|
|
73
|
+
if (next.has(tool.name)) continue
|
|
74
|
+
const descriptor = seed[1]
|
|
75
|
+
descriptor.description = tool.description
|
|
76
|
+
descriptor.input = tool.parameters
|
|
77
|
+
next.set(tool.name, descriptor)
|
|
78
|
+
}
|
|
79
|
+
session.tools = Object.fromEntries(next)
|
|
80
|
+
return tools.length - next.size
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const preparationFailed = (message: string): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message })
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `session.hook("context")` body: swap the request session's system, messages, tools and
|
|
87
|
+
* generation options for the caller's.
|
|
88
|
+
*/
|
|
89
|
+
function applyContext(event: unknown, sessions: SessionStore): void {
|
|
90
|
+
// Assumed invariant: the host passes its mutable `SessionContext` (checked structurally by `HostContextMatches`).
|
|
91
|
+
const session = event as ContextHookEvent
|
|
92
|
+
const state = sessions.lookup(String(session.sessionID))
|
|
93
|
+
if (!state) return
|
|
94
|
+
const { request } = state
|
|
95
|
+
|
|
96
|
+
// Swap media placeholders for the Asset-carrying parts the host built from the prompt files.
|
|
97
|
+
const hostMedia = (session.messages ?? [])
|
|
98
|
+
.flatMap((message) => (Array.isArray(message.content) ? (message.content as unknown[]) : []))
|
|
99
|
+
.filter((part) => (part as HostPart | undefined)?.type === "media")
|
|
100
|
+
let missingMedia = 0
|
|
101
|
+
const swap = (part: HostPart) => {
|
|
102
|
+
if (typeof part.__attachment !== "number") return part
|
|
103
|
+
const media = hostMedia[part.__attachment]
|
|
104
|
+
if (media) return media
|
|
105
|
+
missingMedia++
|
|
106
|
+
return text("[image unavailable]")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const system = request.system.map(text)
|
|
110
|
+
// The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
|
|
111
|
+
const choice = request.toolChoice
|
|
112
|
+
if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
|
|
113
|
+
if (typeof choice === "object") system.push(text(`You must respond by calling the \`${choice.name}\` tool.`))
|
|
114
|
+
session.system = system
|
|
115
|
+
session.messages = toHostMessages(request.messages).map((message) => ({ ...message, content: message.content.map(swap) }))
|
|
116
|
+
Object.assign(session.options ?? (session.options = {}), generationOptions(request))
|
|
117
|
+
|
|
118
|
+
const missingTools = remapSeedTools(session, request)
|
|
119
|
+
|
|
120
|
+
// The caller validates both before dispatch; this only fires if the host disagrees. The request is
|
|
121
|
+
// already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
|
|
122
|
+
if (missingMedia > 0 || missingTools > 0) {
|
|
123
|
+
const detail = [missingMedia > 0 ? `${missingMedia} attachment(s) not delivered by the host` : "", missingTools > 0 ? `${missingTools} tool(s) without a seed tool` : ""]
|
|
124
|
+
state.collector.push({
|
|
125
|
+
type: FAILURE_EVENT,
|
|
126
|
+
error: preparationFailed(`Could not prepare the model request: ${detail.filter(Boolean).join(", ")}`),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Registrations for a plugboard Location: the context hook, the agent deny for the seeds, and
|
|
133
|
+
* the seed tool pool.
|
|
134
|
+
*
|
|
135
|
+
* Whether the Location is inside the data root is only known after an asynchronous filesystem
|
|
136
|
+
* check (never during setup), so these are registered for every accepted generation and every
|
|
137
|
+
* one of them stays a no-op until `active()` turns true: the hook leaves sessions alone, the
|
|
138
|
+
* transforms add and change nothing. `active` is read synchronously on every run; the owner
|
|
139
|
+
* rebuilds the transforms (`ctx.agent.reload()`, `ctx.tool.reload()`) after it flips.
|
|
140
|
+
*
|
|
141
|
+
* Every agent denies the seeds; request sessions are created with a session permission that
|
|
142
|
+
* allows them again, so a person prompting inside an app directory is never offered them.
|
|
143
|
+
* Registration only: no host call that waits on readiness (see docs/architecture.md).
|
|
144
|
+
*/
|
|
145
|
+
export function installSessionHooks(
|
|
146
|
+
ctx: HostContext,
|
|
147
|
+
options: { readonly maxTools: number; readonly sessions: SessionStore; readonly active: () => boolean },
|
|
148
|
+
) {
|
|
149
|
+
return Effect.gen(function* () {
|
|
150
|
+
// Swap in the caller's system/messages/tools for plugboard request sessions only.
|
|
151
|
+
yield* ctx.session.hook("context", (event) => Effect.sync(() => (options.active() ? applyContext(event, options.sessions) : undefined)))
|
|
152
|
+
|
|
153
|
+
yield* ctx.agent.transform((editor) => {
|
|
154
|
+
if (!options.active()) return
|
|
155
|
+
for (const agent of editor.list()) {
|
|
156
|
+
editor.update(agent.id, (item) => {
|
|
157
|
+
item.permissions.push({ action: `${SEED_PREFIX}*`, resource: "*", effect: "deny" })
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
yield* ctx.tool.transform((editor) => {
|
|
162
|
+
if (!options.active()) return
|
|
163
|
+
for (let index = 0; index < options.maxTools; index++) {
|
|
164
|
+
editor.add({
|
|
165
|
+
name: `${SEED_PREFIX}${index}`,
|
|
166
|
+
description: "Plugboard placeholder tool.",
|
|
167
|
+
input: { type: "object", properties: {}, additionalProperties: true },
|
|
168
|
+
options: { codemode: false },
|
|
169
|
+
// Settles at once so history shows a completed call; plugboard ends the turn itself (see seeds.ts).
|
|
170
|
+
execute: () => Effect.succeed({ content: SEED_RESULT }),
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
}
|