fix(core): omit unavailable host tools

This commit is contained in:
Kit Langton 2026-06-05 19:37:29 -04:00
commit 7d79daa2ca
13 changed files with 179 additions and 86 deletions

View file

@ -18,6 +18,13 @@ const registry = ToolRegistry.layer.pipe(
Layer.provide(ToolOutputStore.defaultLayer),
)
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 contextual = (contexts: Tool.Context[]) =>
@ -186,4 +193,20 @@ describe("ApplicationTools", () => {
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(
applicationTools,
LocationServiceMap.layer.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
Project.defaultLayer,

View file

@ -1867,6 +1867,53 @@ describe("SessionRunnerLLM", () => {
}),
)
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", () =>
Effect.gen(function* () {
yield* setup