Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Kit Langton
7d79daa2ca fix(core): omit unavailable host tools 2026-06-05 21:02:17 -04:00
Kit Langton
8bb211ebfd test(core): cover permission denial continuation 2026-06-05 21:02:17 -04:00
13 changed files with 240 additions and 89 deletions

View file

@ -111,6 +111,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer, FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer, ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
], ],
}) {} }) {}
export namespace LocationServiceMap {
export const defaultLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))
}

View file

@ -15,6 +15,11 @@ import { ApplicationTools } from "../tool/application-tools"
import { Session } from "./session" import { Session } from "./session"
import { Tool } from "./tool" import { Tool } from "./tool"
export interface HostConfig {
/** Tool names that this host cannot service. They are omitted from prompts and rejected at execution. */
readonly unavailableTools?: ReadonlyArray<string>
}
export interface Interface { export interface Interface {
readonly sessions: Session.Interface readonly sessions: Session.Interface
readonly tools: Tool.Service readonly tools: Tool.Service
@ -32,7 +37,6 @@ class SessionModelValidation extends Context.Service<
} }
>()("@opencode/public/OpenCode/SessionModelValidation") {} >()("@opencode/public/OpenCode/SessionModelValidation") {}
const LocationServicesLayer = LocationServiceMap.layer
const SessionModelValidationLayer = Layer.effect( const SessionModelValidationLayer = Layer.effect(
SessionModelValidation, SessionModelValidation,
Effect.gen(function* () { Effect.gen(function* () {
@ -77,54 +81,59 @@ const SessionsLayer = Layer.merge(
Layer.orDie, Layer.orDie,
), ),
SessionModelValidationLayer, SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer)) )
const ApplicationToolsLayer = ApplicationTools.layer
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect( export const layerWithHostConfig = (config: HostConfig) => {
Service, const applicationTools = ApplicationTools.layerWithUnavailable(new Set(config.unavailableTools ?? []))
Effect.gen(function* () { const locations = LocationServiceMap.layer.pipe(Layer.provide(applicationTools))
const sessions = yield* SessionV2.Service const sessions = SessionsLayer.pipe(Layer.provide(locations))
const tools = yield* ApplicationTools.Service return Layer.effect(
const validation = yield* SessionModelValidation Service,
return Service.of({ Effect.gen(function* () {
tools: { attach: tools.attach }, const sessions = yield* SessionV2.Service
sessions: { const tools = yield* ApplicationTools.Service
create: (input) => const validation = yield* SessionModelValidation
sessions.create({ return Service.of({
id: input.id, tools: { attach: tools.attach },
agent: input.agent, sessions: {
model: input.model, create: (input) =>
location: input.location, sessions.create({
id: input.id,
agent: input.agent,
model: input.model,
location: input.location,
}),
get: sessions.get,
list: sessions.list,
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
const session = yield* sessions.get(input.sessionID)
yield* validation.validate({ ...input, location: session.location })
yield* sessions.switchModel(input)
}), }),
get: sessions.get, interrupt: sessions.interrupt,
list: sessions.list, prompt: (input) =>
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { sessions.prompt({
const session = yield* sessions.get(input.sessionID) id: input.id,
yield* validation.validate({ ...input, location: session.location }) sessionID: input.sessionID,
yield* sessions.switchModel(input) prompt: input.prompt,
}), delivery: input.delivery,
interrupt: sessions.interrupt, }),
prompt: (input) => messages: (input) =>
sessions.prompt({ sessions.messages({
id: input.id, sessionID: input.sessionID,
sessionID: input.sessionID, limit: input.limit,
prompt: input.prompt, order: input.order,
delivery: input.delivery, cursor: input.cursor,
}), }),
messages: (input) => message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }),
sessions.messages({ context: sessions.context,
sessionID: input.sessionID, events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }),
limit: input.limit, },
order: input.order, })
cursor: input.cursor, }),
}), ).pipe(Layer.provide(Layer.merge(applicationTools, sessions)))
message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), }
context: sessions.context,
events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), export const layer = layerWithHostConfig({})
},
})
}),
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

View file

@ -16,36 +16,41 @@ type Editor = {
export interface Interface { export interface Interface {
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope> readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
readonly entries: () => ReadonlyMap<string, NativeTool.Any> readonly entries: () => ReadonlyMap<string, NativeTool.Any>
readonly isAvailable: (name: string) => boolean
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
enableMapSet() enableMapSet()
export const layer = Layer.effect( export const layerWithUnavailable = (unavailable: ReadonlySet<string>) =>
Service, Layer.effect(
Effect.gen(function* () { Service,
const state = State.create<Data, Editor>({ Effect.gen(function* () {
initial: () => ({ entries: new Map() }), const state = State.create<Data, Editor>({
editor: (draft) => ({ initial: () => ({ entries: new Map() }),
set: (name, tool) => { editor: (draft) => ({
draft.entries.set( set: (name, tool) => {
name, draft.entries.set(
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never, name,
) castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
}, )
}), },
}) }),
})
return Service.of({ return Service.of({
attach: Effect.fn("ApplicationTools.attach")(function* (tools) { attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
const entries = Object.entries(tools) const entries = Object.entries(tools)
const transform = yield* state.transform() const transform = yield* state.transform()
yield* transform((editor) => { yield* transform((editor) => {
for (const [name, tool] of entries) editor.set(name, tool) for (const [name, tool] of entries) editor.set(name, tool)
}) })
}), }),
entries: () => state.get().entries, entries: () => state.get().entries,
}) isAvailable: (name) => !unavailable.has(name),
}), })
) }),
)
export const layer = layerWithUnavailable(new Set())

View file

@ -115,15 +115,20 @@ export const layer = Layer.effect(
}) })
const definitions = Effect.fn("ToolRegistry.definitions")(function* () { const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const)) const tools = new Map(
Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const).filter(([name]) =>
applications.isAvailable(name),
),
)
// Location tools own their names. Application tools fill otherwise-unclaimed names. // Location tools own their names. Application tools fill otherwise-unclaimed names.
for (const [name, tool] of applications.entries()) { for (const [name, tool] of applications.entries()) {
if (!tools.has(name)) tools.set(name, tool.definition) if (applications.isAvailable(name) && !tools.has(name)) tools.set(name, tool.definition)
} }
return Tool.toDefinitions(Object.fromEntries(tools)) return Tool.toDefinitions(Object.fromEntries(tools))
}) })
const entry = (name: string): Entry | undefined => { const entry = (name: string): Entry | undefined => {
if (!applications.isAvailable(name)) return
const local = state.get().entries.get(name) const local = state.get().entries.get(name)
if (local !== undefined) return local if (local !== undefined) return local
const tool = applications.entries().get(name) const tool = applications.entries().get(name)

View file

@ -18,6 +18,13 @@ const registry = ToolRegistry.layer.pipe(
Layer.provide(ToolOutputStore.defaultLayer), Layer.provide(ToolOutputStore.defaultLayer),
) )
const it = testEffect(Layer.mergeAll(applications, registry)) const it = testEffect(Layer.mergeAll(applications, registry))
const unavailableApplications = ApplicationTools.layerWithUnavailable(new Set(["question"]))
const unavailableRegistry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(unavailableApplications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const unavailableIt = testEffect(Layer.mergeAll(unavailableApplications, unavailableRegistry))
const sessionID = SessionV2.ID.make("ses_application_tool") const sessionID = SessionV2.ID.make("ses_application_tool")
const contextual = (contexts: Tool.Context[]) => const contextual = (contexts: Tool.Context[]) =>
@ -186,4 +193,20 @@ describe("ApplicationTools", () => {
expect(applicationContexts).toEqual([]) expect(applicationContexts).toEqual([])
}), }),
) )
unavailableIt.effect("omits and rejects tools the host cannot service", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) => editor.set("question", { tool: contextual([]).definition }))
expect(yield* registry.definitions()).toEqual([])
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-stale-question", name: "question", input: { query: "Continue?" } },
}),
).toEqual({ type: "error", value: "Unknown tool: question" })
}),
)
}) })

View file

@ -27,6 +27,7 @@ const it = testEffect(
Layer.merge( Layer.merge(
applicationTools, applicationTools,
LocationServiceMap.layer.pipe( LocationServiceMap.layer.pipe(
Layer.provide(applicationTools),
Layer.provide( Layer.provide(
Layer.mergeAll( Layer.mergeAll(
Project.defaultLayer, Project.defaultLayer,

View file

@ -5,6 +5,7 @@ import {
LLMEvent, LLMEvent,
Model, Model,
Tool, Tool,
ToolFailure,
TransportReason, TransportReason,
InvalidRequestReason, InvalidRequestReason,
type LLMClientShape, type LLMClientShape,
@ -112,6 +113,7 @@ const recoveryModel = Model.make({
}) })
const authorizations: ToolRegistry.AuthorizeInput[] = [] const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = [] const executions: string[] = []
let denyEcho = false
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
PermissionV2.Service.of({ PermissionV2.Service.of({
@ -135,9 +137,18 @@ const echo = Layer.effectDiscard(
registry.contribute((editor) => { registry.contribute((editor) => {
;(editor.set("echo", { ;(editor.set("echo", {
authorize: (input) => authorize: (input) =>
Effect.sync(() => { denyEcho
authorizations.push(input) ? Effect.fail(
}), new ToolFailure({
message: "Permission denied",
error: new PermissionV2.DeniedError({
rules: [{ action: "echo", resource: "*", effect: "deny" }],
}),
}),
)
: Effect.sync(() => {
authorizations.push(input)
}),
tool: Tool.make({ tool: Tool.make({
description: "Echo text", description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }), parameters: Schema.Struct({ text: Schema.String }),
@ -1809,6 +1820,100 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("continues after a permission is denied without awaiting a responder", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Try the denied tool" }), resume: false })
requests.length = 0
executions.length = 0
denyEcho = true
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-denied", name: "echo", input: { text: "blocked" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-after-denial" }),
LLMEvent.textDelta({ id: "text-after-denial", text: "Permission denied" }),
LLMEvent.textEnd({ id: "text-after-denial" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID).pipe(Effect.ensuring(Effect.sync(() => (denyEcho = false))))
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(executions).toEqual([])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Try the denied tool" },
{
type: "assistant",
finish: "tool-calls",
content: [{ type: "tool", id: "call-denied", name: "echo", state: { status: "error" } }],
},
{
type: "assistant",
finish: "stop",
content: [{ type: "text", id: "text-after-denial", text: "Permission denied" }],
},
])
}),
)
it.effect("settles a stale unavailable question call and continues without a pending request", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const question = yield* QuestionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not wait for a question" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-stale-question", name: "question", input: { questions: [] } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-after-question" }),
LLMEvent.textDelta({ id: "text-after-question", text: "Continued" }),
LLMEvent.textEnd({ id: "text-after-question" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("question")
expect(yield* question.list()).toEqual([])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Do not wait for a question" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-stale-question",
state: { status: "error", error: { message: "Unknown tool: question" } },
},
],
},
{ type: "assistant", content: [{ type: "text", id: "text-after-question", text: "Continued" }] },
])
}),
)
it.effect("reloads a model switch before a tool-driven continuation turn", () => it.effect("reloads a model switch before a tool-driven continuation turn", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup

View file

@ -10,7 +10,7 @@ import { cmd } from "../cmd"
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) => const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe( effect.pipe(
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })), Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.layer), Effect.provide(LocationServiceMap.defaultLayer),
) )
const FileSearchCommand = effectCmd({ const FileSearchCommand = effectCmd({

View file

@ -41,6 +41,6 @@ export const V2Command = effectCmd({
directory: AbsolutePath.make(process.cwd()), directory: AbsolutePath.make(process.cwd()),
}), }),
), ),
Effect.provide(LocationServiceMap.layer), Effect.provide(LocationServiceMap.defaultLayer),
), ),
}) })

View file

@ -91,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
.handle("content", content) .handle("content", content)
.handle("status", status) .handle("status", status)
}), }),
).pipe(Layer.provide(LocationServiceMap.layer)) ).pipe(Layer.provide(LocationServiceMap.defaultLayer))

View file

@ -150,7 +150,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("remove", remove) .handle("remove", remove)
.handle("connectToken", connectToken) .handle("connectToken", connectToken)
}), }),
).pipe(Layer.provide(LocationServiceMap.layer)) ).pipe(Layer.provide(LocationServiceMap.defaultLayer))
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -255,4 +255,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
}), }),
) )
}), }),
).pipe(Layer.provide(LocationServiceMap.layer)) ).pipe(Layer.provide(LocationServiceMap.defaultLayer))

View file

@ -25,7 +25,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
const routedSessions = SessionV2.layer.pipe( const routedSessions = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer), Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer), Layer.provide(SessionExecutionLocal.layer),
Layer.provide(LocationServiceMap.layer), Layer.provide(LocationServiceMap.defaultLayer),
Layer.provide(SessionStore.layer), Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer), Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
@ -51,7 +51,7 @@ export const v2Handlers = Layer.mergeAll(
sessionQuestionHandlers, sessionQuestionHandlers,
).pipe( ).pipe(
Layer.provide(v2LocationLayer), Layer.provide(v2LocationLayer),
Layer.provide(LocationServiceMap.layer), Layer.provide(LocationServiceMap.defaultLayer),
Layer.provide(PermissionSaved.layer), Layer.provide(PermissionSaved.layer),
Layer.provide(routedSessions), Layer.provide(routedSessions),
) )

View file

@ -22,7 +22,7 @@ export function createRoutes(password?: string) {
? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.defaultLayer, : ServerAuth.Config.defaultLayer,
), ),
Layer.provide(LocationServiceMap.layer), Layer.provide(LocationServiceMap.defaultLayer),
Layer.provide(PermissionSaved.layer), Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),