opencode-plugboard 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +228 -2
- package/index.ts +99 -0
- package/package.json +32 -4
- package/src/config.ts +150 -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 +214 -0
- package/src/runtime.ts +463 -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 +175 -0
- package/src/session/media.ts +89 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +134 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded attachment preparation: request images become prompt files.
|
|
3
|
+
*
|
|
4
|
+
* Prompt files must be inline `data:` URLs. http(s) images are fetched once, only when the
|
|
5
|
+
* `remoteImages` option is on, with no redirects, a 20 s timeout and a 20 MB cap. All images of
|
|
6
|
+
* one request together may hold at most 50 MB (`images_too_large`), data URLs and fetched ones alike.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PlugRequest } from "../contract.js"
|
|
10
|
+
|
|
11
|
+
const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024
|
|
12
|
+
/** Aggregate size of the prepared data URLs of one request. */
|
|
13
|
+
const MAX_TOTAL_IMAGE_BYTES = 50 * 1024 * 1024
|
|
14
|
+
const TOO_MANY = () => new ImageError("images_too_large", "The request's images are larger than 50 MB in total")
|
|
15
|
+
|
|
16
|
+
/** What one request's images may still use, in data URL characters (== bytes held). */
|
|
17
|
+
interface Budget {
|
|
18
|
+
remaining: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A caller image that cannot be used; `code` becomes the error code of the 400 response. */
|
|
22
|
+
export class ImageError extends Error {
|
|
23
|
+
constructor(
|
|
24
|
+
readonly code: string,
|
|
25
|
+
message: string,
|
|
26
|
+
) {
|
|
27
|
+
super(message)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean, budget: Budget): Promise<string> {
|
|
32
|
+
if (url.startsWith("data:")) {
|
|
33
|
+
if (url.length > budget.remaining) throw TOO_MANY()
|
|
34
|
+
return url
|
|
35
|
+
}
|
|
36
|
+
if (!/^https?:\/\//i.test(url)) throw new ImageError("unsupported_image_url", "Unsupported image URL scheme; use a data: URL or an http(s) URL")
|
|
37
|
+
if (!remoteImages)
|
|
38
|
+
throw new ImageError("remote_images_disabled", "Remote image URLs are disabled; send a data: URL or set the plugboard `remoteImages` option")
|
|
39
|
+
// Messages never carry the URL or the fetch error text: both can hold credentials in the query string.
|
|
40
|
+
const response = await fetch(url, { redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]) }).catch((error: unknown) => {
|
|
41
|
+
const reason = error instanceof Error && error.name === "TimeoutError" ? "timed out" : "network error"
|
|
42
|
+
throw new ImageError("image_fetch_failed", `Could not fetch image (${reason})`)
|
|
43
|
+
})
|
|
44
|
+
if (!response.ok) throw new ImageError("image_fetch_failed", `Could not fetch image (HTTP ${response.status})`)
|
|
45
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? ""
|
|
46
|
+
if (!mediaType.startsWith("image/")) throw new ImageError("invalid_image", `URL did not return an image (content-type: ${mediaType || "none"})`)
|
|
47
|
+
const declared = Number(response.headers.get("content-length"))
|
|
48
|
+
// The data URL is 4/3 of the raw size plus a short prefix: stop reading once it cannot fit the budget.
|
|
49
|
+
const budgetRaw = Math.floor((budget.remaining * 3) / 4)
|
|
50
|
+
const limit = Math.min(MAX_REMOTE_IMAGE_BYTES, budgetRaw)
|
|
51
|
+
const tooLarge = () => (limit < MAX_REMOTE_IMAGE_BYTES ? TOO_MANY() : new ImageError("image_too_large", "Image is larger than 20 MB"))
|
|
52
|
+
if (Number.isFinite(declared) && declared > limit) throw tooLarge()
|
|
53
|
+
const chunks: Uint8Array[] = []
|
|
54
|
+
let size = 0
|
|
55
|
+
const reader = response.body?.getReader()
|
|
56
|
+
if (!reader) throw new ImageError("invalid_image", "Image response had no body")
|
|
57
|
+
while (true) {
|
|
58
|
+
const { done, value } = await reader.read()
|
|
59
|
+
if (done) break
|
|
60
|
+
size += value.byteLength
|
|
61
|
+
if (size > limit) {
|
|
62
|
+
void reader.cancel().catch(() => {})
|
|
63
|
+
throw tooLarge()
|
|
64
|
+
}
|
|
65
|
+
chunks.push(value)
|
|
66
|
+
}
|
|
67
|
+
const dataUrl = `data:${mediaType};base64,${Buffer.concat(chunks).toString("base64")}`
|
|
68
|
+
if (dataUrl.length > budget.remaining) throw TOO_MANY()
|
|
69
|
+
return dataUrl
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Prompt files for every image in the request, in message order, named `image-1`, `image-2`, ... Throws `ImageError`. */
|
|
73
|
+
export async function prepareImages(
|
|
74
|
+
request: PlugRequest,
|
|
75
|
+
signal: AbortSignal,
|
|
76
|
+
remoteImages: boolean,
|
|
77
|
+
): Promise<Array<{ uri: string; name: string }>> {
|
|
78
|
+
const images: Array<{ uri: string; name: string }> = []
|
|
79
|
+
const budget: Budget = { remaining: MAX_TOTAL_IMAGE_BYTES }
|
|
80
|
+
for (const message of request.messages)
|
|
81
|
+
for (const part of message.content)
|
|
82
|
+
if (part.type === "media") {
|
|
83
|
+
signal.throwIfAborted()
|
|
84
|
+
const uri = await toDataUrl(part.url, signal, remoteImages, budget)
|
|
85
|
+
budget.remaining -= uri.length
|
|
86
|
+
images.push({ uri, name: `image-${images.length + 1}` })
|
|
87
|
+
}
|
|
88
|
+
return images
|
|
89
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed tools.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode's `session.hook("context")` may only advertise tool definitions that
|
|
5
|
+
* correspond to a *registered* Tool.Info (unmatched entries are dropped), so plugboard owns a
|
|
6
|
+
* pool of registered seeds (`maxTools` of them) and re-keys them in the hook:
|
|
7
|
+
* the caller's name becomes the map key (what the provider sees), while the seed keeps its
|
|
8
|
+
* own `name` (what the executor dispatches on).
|
|
9
|
+
*
|
|
10
|
+
* A seed's `execute` returns at once with `SEED_RESULT`, so history shows the call as
|
|
11
|
+
* completed. A tool result always makes the runner start a second model step
|
|
12
|
+
* (`record.needsContinuation`, runner/step.ts), and no tool result, hook or agent setting
|
|
13
|
+
* ends the loop (failing the seed, even with a typed non-`Tool.Error`, is converted to a
|
|
14
|
+
* normal tool failure, and `Permission.DeclinedError` is only reachable from host tools).
|
|
15
|
+
* So plugboard stops the turn itself: once the step has settled with a normal `tool-calls`
|
|
16
|
+
* finish (`session.step.ended`) it interrupts the session. Nothing orders that interrupt
|
|
17
|
+
* before the runner's next step, so a second step can occasionally start and be wasted (extra
|
|
18
|
+
* tokens, a stray assistant message in history, never a wrong response; tracked in issue #14).
|
|
19
|
+
* The captured `session.tool.called` events are what a `tool_calls` response needs.
|
|
20
|
+
* Seeds are registered only inside the plugboard data root, denied to every agent there,
|
|
21
|
+
* and allowed again by the permissions of plugboard's own request sessions. Those permissions
|
|
22
|
+
* deny every other tool (defense in depth: if a second step ever runs outside the context
|
|
23
|
+
* hook's replacement, it cannot run real OpenCode tools).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { PlugRequest, PlugTool } from "../contract.js"
|
|
27
|
+
|
|
28
|
+
export const SEED_PREFIX = "plugboard_seed_"
|
|
29
|
+
|
|
30
|
+
/** Output of every seed tool call: the caller, not OpenCode, runs the tool. */
|
|
31
|
+
export const SEED_RESULT = "Returned to caller (opencode-plugboard)"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Session permissions of a request session. The host takes the last matching rule (session rules
|
|
35
|
+
* come after the agent's): everything is denied, then the seeds are allowed again.
|
|
36
|
+
*/
|
|
37
|
+
export const REQUEST_PERMISSIONS: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }> = [
|
|
38
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
39
|
+
{ action: `${SEED_PREFIX}*`, resource: "*", effect: "allow" },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** The caller tools that get a seed in the model request (`tool_choice` none offers none, a named tool only that one). */
|
|
43
|
+
export function seedToolsFor(request: PlugRequest): ReadonlyArray<PlugTool> {
|
|
44
|
+
const choice = request.toolChoice
|
|
45
|
+
if (choice === "none") return []
|
|
46
|
+
return typeof choice === "object" ? request.tools.filter((tool) => tool.name === choice.name) : request.tools
|
|
47
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ownership store shared across plugin generations.
|
|
3
|
+
*
|
|
4
|
+
* The generation that serves an HTTP request and the generation inside the app directory that
|
|
5
|
+
* runs its session are different plugin instances (one per Location), and either can be replaced
|
|
6
|
+
* at any time. So per-request state is kept by session ID in one store owned by the process
|
|
7
|
+
* runtime (src/runtime.ts): the serving side registers it, the app-directory hook looks it up,
|
|
8
|
+
* and the process-wide event pump dispatches bus events to the request's event sink. Events of
|
|
9
|
+
* sessions that are not registered are dropped before anything is buffered.
|
|
10
|
+
*
|
|
11
|
+
* An entry is the request guard: while it exists, the context hook replaces the model request
|
|
12
|
+
* and tools. It outlives the HTTP response. It is released when the host reports the end of the
|
|
13
|
+
* session's execution (`session.execution.*`), or, after the response finished, when the bounded
|
|
14
|
+
* cleanup of `retire` runs out. Timers here are unref'd and owned by the process runtime's store,
|
|
15
|
+
* never by a plugin finalizer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PlugError, PlugRequest } from "../contract.js"
|
|
19
|
+
import { errorMessage, logError } from "../log.js"
|
|
20
|
+
|
|
21
|
+
/** Receives the flattened bus events of one session. The collector implements it. */
|
|
22
|
+
export interface EventSink {
|
|
23
|
+
push(event: Record<string, unknown>): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Synthetic event (`{ type, error: PlugError }`) that fails a request from inside the process: lost event stream, preparation mismatch. */
|
|
27
|
+
export const FAILURE_EVENT = "plugboard.failure"
|
|
28
|
+
|
|
29
|
+
export interface RequestState {
|
|
30
|
+
readonly sessionID: string
|
|
31
|
+
readonly app: string
|
|
32
|
+
readonly requestID: string
|
|
33
|
+
readonly request: PlugRequest
|
|
34
|
+
readonly collector: EventSink
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Retirement {
|
|
38
|
+
/** How long the guard may outlive the response while the host execution has not ended. */
|
|
39
|
+
readonly timeoutMs: number
|
|
40
|
+
/** Called once if the timeout runs out (the host never confirmed the end of the execution). Must not block. */
|
|
41
|
+
onTimeout(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SessionStore {
|
|
45
|
+
/** Own a request session; replaces any state registered under the same session ID. */
|
|
46
|
+
register(state: RequestState): void
|
|
47
|
+
lookup(sessionID: string): RequestState | undefined
|
|
48
|
+
/** Route one bus envelope (`{ type, data: { sessionID, ... } }`) to its request's sink, if any. Releases the session when its execution ended. */
|
|
49
|
+
dispatch(event: unknown): void
|
|
50
|
+
/** Stop owning a session; false when it was not registered. */
|
|
51
|
+
release(sessionID: string): boolean
|
|
52
|
+
/**
|
|
53
|
+
* The response is over: keep the guard until the host reports the end of the execution, or
|
|
54
|
+
* until `timeoutMs` passes, which is logged and released after `onTimeout`. No-op when the
|
|
55
|
+
* session is already released.
|
|
56
|
+
*/
|
|
57
|
+
retire(sessionID: string, retirement: Retirement): void
|
|
58
|
+
/** Fail every registered request with `error`; returns how many were registered. */
|
|
59
|
+
failAll(error: PlugError): number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EXECUTION_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
|
|
63
|
+
|
|
64
|
+
interface Entry {
|
|
65
|
+
readonly state: RequestState
|
|
66
|
+
readonly startedAt: number
|
|
67
|
+
timer?: ReturnType<typeof setTimeout>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createSessionStore(): SessionStore {
|
|
71
|
+
const entries = new Map<string, Entry>()
|
|
72
|
+
|
|
73
|
+
const release = (sessionID: string): boolean => {
|
|
74
|
+
const entry = entries.get(sessionID)
|
|
75
|
+
if (!entry) return false
|
|
76
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
77
|
+
return entries.delete(sessionID)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
register(state) {
|
|
82
|
+
const previous = entries.get(state.sessionID)
|
|
83
|
+
if (previous?.timer) clearTimeout(previous.timer)
|
|
84
|
+
entries.set(state.sessionID, { state, startedAt: Date.now() })
|
|
85
|
+
},
|
|
86
|
+
lookup: (sessionID) => entries.get(sessionID)?.state,
|
|
87
|
+
release,
|
|
88
|
+
dispatch(event) {
|
|
89
|
+
const envelope = event as { type?: unknown; data?: Record<string, unknown> } | null
|
|
90
|
+
const sessionID = envelope?.data?.sessionID
|
|
91
|
+
if (typeof sessionID !== "string") return
|
|
92
|
+
const entry = entries.get(sessionID)
|
|
93
|
+
if (!entry) return
|
|
94
|
+
try {
|
|
95
|
+
entry.state.collector.push({ type: envelope?.type, ...envelope?.data })
|
|
96
|
+
} catch (error) {
|
|
97
|
+
logError("session.event_failed", { error: errorMessage(error) })
|
|
98
|
+
}
|
|
99
|
+
if (typeof envelope?.type === "string" && EXECUTION_END.has(envelope.type)) release(sessionID)
|
|
100
|
+
},
|
|
101
|
+
retire(sessionID, retirement) {
|
|
102
|
+
const entry = entries.get(sessionID)
|
|
103
|
+
if (!entry || entry.timer) return
|
|
104
|
+
entry.timer = setTimeout(() => {
|
|
105
|
+
if (entries.get(sessionID) !== entry) return
|
|
106
|
+
logError("session.cleanup_timeout", {
|
|
107
|
+
sessionID,
|
|
108
|
+
requestID: entry.state.requestID,
|
|
109
|
+
app: entry.state.app,
|
|
110
|
+
model: entry.state.request.model,
|
|
111
|
+
durationMs: Date.now() - entry.startedAt,
|
|
112
|
+
outcome: "execution_end_not_confirmed",
|
|
113
|
+
})
|
|
114
|
+
try {
|
|
115
|
+
retirement.onTimeout()
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logError("session.cleanup_failed", { requestID: entry.state.requestID, error: errorMessage(error) })
|
|
118
|
+
}
|
|
119
|
+
entries.delete(sessionID)
|
|
120
|
+
}, retirement.timeoutMs)
|
|
121
|
+
entry.timer.unref?.()
|
|
122
|
+
},
|
|
123
|
+
failAll(error) {
|
|
124
|
+
for (const entry of entries.values()) {
|
|
125
|
+
try {
|
|
126
|
+
entry.state.collector.push({ type: FAILURE_EVENT, error })
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
logError("session.event_failed", { error: errorMessage(cause) })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return entries.size
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|