refactor(effect): resolve built tools through the registry (#20787)
This commit is contained in:
@@ -28,13 +28,13 @@ import { BashTool } from "../../tool/bash"
|
|||||||
import { TodoWriteTool } from "../../tool/todo"
|
import { TodoWriteTool } from "../../tool/todo"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
|
|
||||||
type ToolProps<T extends Tool.Info> = {
|
type ToolProps<T> = {
|
||||||
input: Tool.InferParameters<T>
|
input: Tool.InferParameters<T>
|
||||||
metadata: Tool.InferMetadata<T>
|
metadata: Tool.InferMetadata<T>
|
||||||
part: ToolPart
|
part: ToolPart
|
||||||
}
|
}
|
||||||
|
|
||||||
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
|
function props<T>(part: ToolPart): ToolProps<T> {
|
||||||
const state = part.state
|
const state = part.state
|
||||||
return {
|
return {
|
||||||
input: state.input as Tool.InferParameters<T>,
|
input: state.input as Tool.InferParameters<T>,
|
||||||
|
|||||||
@@ -1572,7 +1572,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolProps<T extends Tool.Info> = {
|
type ToolProps<T> = {
|
||||||
input: Partial<Tool.InferParameters<T>>
|
input: Partial<Tool.InferParameters<T>>
|
||||||
metadata: Partial<Tool.InferMetadata<T>>
|
metadata: Partial<Tool.InferMetadata<T>>
|
||||||
permission: Record<string, any>
|
permission: Record<string, any>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export namespace Question {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}) {
|
}) {
|
||||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
const taskTool = yield* Effect.promise(() => registry.named.task.init())
|
||||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -583,7 +583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
sessionID: assistantMessage.sessionID,
|
sessionID: assistantMessage.sessionID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: ulid(),
|
callID: ulid(),
|
||||||
tool: TaskTool.id,
|
tool: registry.named.task.id,
|
||||||
state: {
|
state: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: {
|
input: {
|
||||||
@@ -1110,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||||
Effect.flatMap((t) =>
|
Effect.flatMap((t) =>
|
||||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||||
Effect.flatMap((mdl) =>
|
Effect.flatMap((mdl) =>
|
||||||
@@ -1174,7 +1174,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
if (part.mime === "application/x-directory") {
|
if (part.mime === "application/x-directory") {
|
||||||
const args = { filePath: filepath }
|
const args = { filePath: filepath }
|
||||||
const result = yield* Effect.promise(() => ReadTool.init()).pipe(
|
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||||
Effect.flatMap((t) =>
|
Effect.flatMap((t) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
t.execute(args, {
|
t.execute(args, {
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Question } from "../question"
|
import { Question } from "../question"
|
||||||
import DESCRIPTION from "./question.txt"
|
import DESCRIPTION from "./question.txt"
|
||||||
|
|
||||||
export const QuestionTool = Tool.define("question", {
|
const parameters = z.object({
|
||||||
description: DESCRIPTION,
|
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
|
||||||
parameters: z.object({
|
})
|
||||||
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
|
|
||||||
}),
|
|
||||||
async execute(params, ctx) {
|
|
||||||
const answers = await Question.ask({
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
questions: params.questions,
|
|
||||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
function format(answer: Question.Answer | undefined) {
|
type Metadata = {
|
||||||
if (!answer?.length) return "Unanswered"
|
answers: Question.Answer[]
|
||||||
return answer.join(", ")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
|
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
|
||||||
|
"question",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const question = yield* Question.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
description: DESCRIPTION,
|
||||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
parameters,
|
||||||
metadata: {
|
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||||
answers,
|
const answers = await question
|
||||||
|
.ask({
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
questions: params.questions,
|
||||||
|
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||||
|
})
|
||||||
|
.pipe(Effect.runPromise)
|
||||||
|
|
||||||
|
const formatted = params.questions
|
||||||
|
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||||
|
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||||
|
metadata: {
|
||||||
|
answers,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
|
import { Question } from "../question"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
@@ -42,8 +43,11 @@ export namespace ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly register: (tool: Tool.Info) => Effect.Effect<void>
|
|
||||||
readonly ids: () => Effect.Effect<string[]>
|
readonly ids: () => Effect.Effect<string[]>
|
||||||
|
readonly named: {
|
||||||
|
task: Tool.Info
|
||||||
|
read: Tool.Info
|
||||||
|
}
|
||||||
readonly tools: (
|
readonly tools: (
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
agent?: Agent.Info,
|
agent?: Agent.Info,
|
||||||
@@ -52,12 +56,15 @@ export namespace ToolRegistry {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
|
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
||||||
|
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||||
const custom: Tool.Info[] = []
|
const custom: Tool.Info[] = []
|
||||||
@@ -112,43 +119,52 @@ export namespace ToolRegistry {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const invalid = yield* build(InvalidTool)
|
||||||
|
const ask = yield* build(QuestionTool)
|
||||||
|
const bash = yield* build(BashTool)
|
||||||
|
const read = yield* build(ReadTool)
|
||||||
|
const glob = yield* build(GlobTool)
|
||||||
|
const grep = yield* build(GrepTool)
|
||||||
|
const edit = yield* build(EditTool)
|
||||||
|
const write = yield* build(WriteTool)
|
||||||
|
const task = yield* build(TaskTool)
|
||||||
|
const fetch = yield* build(WebFetchTool)
|
||||||
|
const todo = yield* build(TodoWriteTool)
|
||||||
|
const search = yield* build(WebSearchTool)
|
||||||
|
const code = yield* build(CodeSearchTool)
|
||||||
|
const skill = yield* build(SkillTool)
|
||||||
|
const patch = yield* build(ApplyPatchTool)
|
||||||
|
const lsp = yield* build(LspTool)
|
||||||
|
const batch = yield* build(BatchTool)
|
||||||
|
const plan = yield* build(PlanExitTool)
|
||||||
|
|
||||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
|
|
||||||
return [
|
return [
|
||||||
InvalidTool,
|
invalid,
|
||||||
...(question ? [QuestionTool] : []),
|
...(question ? [ask] : []),
|
||||||
BashTool,
|
bash,
|
||||||
ReadTool,
|
read,
|
||||||
GlobTool,
|
glob,
|
||||||
GrepTool,
|
grep,
|
||||||
EditTool,
|
edit,
|
||||||
WriteTool,
|
write,
|
||||||
TaskTool,
|
task,
|
||||||
WebFetchTool,
|
fetch,
|
||||||
TodoWriteTool,
|
todo,
|
||||||
WebSearchTool,
|
search,
|
||||||
CodeSearchTool,
|
code,
|
||||||
SkillTool,
|
skill,
|
||||||
ApplyPatchTool,
|
patch,
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
|
||||||
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
|
...(cfg.experimental?.batch_tool === true ? [batch] : []),
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
|
||||||
...custom,
|
...custom,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const idx = s.custom.findIndex((t) => t.id === tool.id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
s.custom.splice(idx, 1, tool)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.custom.push(tool)
|
|
||||||
})
|
|
||||||
|
|
||||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const tools = yield* all(s.custom)
|
const tools = yield* all(s.custom)
|
||||||
@@ -196,12 +212,18 @@ export namespace ToolRegistry {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ register, ids, tools })
|
return Service.of({ ids, named: { task, read }, tools })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.unwrap(
|
||||||
Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
|
Effect.sync(() =>
|
||||||
|
layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(Question.defaultLayer),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect } from "effect"
|
||||||
import type { MessageV2 } from "../session/message-v2"
|
import type { MessageV2 } from "../session/message-v2"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import type { Permission } from "../permission"
|
import type { Permission } from "../permission"
|
||||||
@@ -45,48 +46,67 @@ export namespace Tool {
|
|||||||
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
|
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InferParameters<T extends Info> = T extends Info<infer P> ? z.infer<P> : never
|
export type InferParameters<T> =
|
||||||
export type InferMetadata<T extends Info> = T extends Info<any, infer M> ? M : never
|
T extends Info<infer P, any>
|
||||||
|
? z.infer<P>
|
||||||
|
: T extends Effect.Effect<Info<infer P, any>, any, any>
|
||||||
|
? z.infer<P>
|
||||||
|
: never
|
||||||
|
export type InferMetadata<T> =
|
||||||
|
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
||||||
|
|
||||||
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
|
id: string,
|
||||||
|
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||||
|
) {
|
||||||
|
return async (initCtx?: InitContext) => {
|
||||||
|
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
||||||
|
const execute = toolInfo.execute
|
||||||
|
toolInfo.execute = async (args, ctx) => {
|
||||||
|
try {
|
||||||
|
toolInfo.parameters.parse(args)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||||
|
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||||
|
{ cause: error },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const result = await execute(args, ctx)
|
||||||
|
if (result.metadata.truncated !== undefined) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
output: truncated.content,
|
||||||
|
metadata: {
|
||||||
|
...result.metadata,
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toolInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
id: string,
|
id: string,
|
||||||
init: Info<Parameters, Result>["init"] | Def<Parameters, Result>,
|
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||||
): Info<Parameters, Result> {
|
): Info<Parameters, Result> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
init: async (initCtx) => {
|
init: wrap(id, init),
|
||||||
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
|
||||||
const execute = toolInfo.execute
|
|
||||||
toolInfo.execute = async (args, ctx) => {
|
|
||||||
try {
|
|
||||||
toolInfo.parameters.parse(args)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
|
||||||
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
|
||||||
{ cause: error },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const result = await execute(args, ctx)
|
|
||||||
// skip truncation for tools that handle it themselves
|
|
||||||
if (result.metadata.truncated !== undefined) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
output: truncated.content,
|
|
||||||
metadata: {
|
|
||||||
...result.metadata,
|
|
||||||
truncated: truncated.truncated,
|
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toolInfo
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
|
||||||
|
id: string,
|
||||||
|
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
|
||||||
|
): Effect.Effect<Info<Parameters, Result>, never, R> {
|
||||||
|
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Plugin } from "../../src/plugin"
|
|||||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
import type { Provider } from "../../src/provider/provider"
|
import type { Provider } from "../../src/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
|
import { Question } from "../../src/question"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
@@ -160,7 +161,8 @@ function makeHttp() {
|
|||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
status,
|
status,
|
||||||
).pipe(Layer.provideMerge(infra))
|
).pipe(Layer.provideMerge(infra))
|
||||||
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
|
||||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||||
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { MCP } from "../../src/mcp"
|
|||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
|
import { Question } from "../../src/question"
|
||||||
import { SessionCompaction } from "../../src/session/compaction"
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import { SessionProcessor } from "../../src/session/processor"
|
import { SessionProcessor } from "../../src/session/processor"
|
||||||
@@ -124,7 +125,8 @@ function makeHttp() {
|
|||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
status,
|
status,
|
||||||
).pipe(Layer.provideMerge(infra))
|
).pipe(Layer.provideMerge(infra))
|
||||||
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
|
||||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||||
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { z } from "zod"
|
import { Effect, Fiber, Layer } from "effect"
|
||||||
|
import { Tool } from "../../src/tool/tool"
|
||||||
import { QuestionTool } from "../../src/tool/question"
|
import { QuestionTool } from "../../src/tool/question"
|
||||||
import * as QuestionModule from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test-session"),
|
sessionID: SessionID.make("ses_test-session"),
|
||||||
@@ -15,55 +19,69 @@ const ctx = {
|
|||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||||
|
|
||||||
|
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||||
|
for (;;) {
|
||||||
|
const items = yield* question.list()
|
||||||
|
const item = items[0]
|
||||||
|
if (item) return item
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe("tool.question", () => {
|
describe("tool.question", () => {
|
||||||
let askSpy: any
|
it.live("should successfully execute with valid question parameters", () =>
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const question = yield* Question.Service
|
||||||
|
const toolInfo = yield* QuestionTool
|
||||||
|
const tool = yield* Effect.promise(() => toolInfo.init())
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
question: "What is your favorite color?",
|
||||||
|
header: "Color",
|
||||||
|
options: [
|
||||||
|
{ label: "Red", description: "The color of passion" },
|
||||||
|
{ label: "Blue", description: "The color of sky" },
|
||||||
|
],
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||||
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
|
const item = yield* pending(question)
|
||||||
return []
|
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
const result = yield* Fiber.join(fiber)
|
||||||
askSpy.mockRestore()
|
expect(result.title).toBe("Asked 1 question")
|
||||||
})
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
test("should successfully execute with valid question parameters", async () => {
|
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
|
||||||
const tool = await QuestionTool.init()
|
provideTmpdirInstance(() =>
|
||||||
const questions = [
|
Effect.gen(function* () {
|
||||||
{
|
const question = yield* Question.Service
|
||||||
question: "What is your favorite color?",
|
const toolInfo = yield* QuestionTool
|
||||||
header: "Color",
|
const tool = yield* Effect.promise(() => toolInfo.init())
|
||||||
options: [
|
const questions = [
|
||||||
{ label: "Red", description: "The color of passion" },
|
{
|
||||||
{ label: "Blue", description: "The color of sky" },
|
question: "What is your favorite animal?",
|
||||||
],
|
header: "This Header is Over 12",
|
||||||
multiple: false,
|
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
askSpy.mockResolvedValueOnce([["Red"]])
|
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||||
|
const item = yield* pending(question)
|
||||||
|
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||||
|
|
||||||
const result = await tool.execute({ questions }, ctx)
|
const result = yield* Fiber.join(fiber)
|
||||||
expect(askSpy).toHaveBeenCalledTimes(1)
|
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
||||||
expect(result.title).toBe("Asked 1 question")
|
}),
|
||||||
})
|
),
|
||||||
|
)
|
||||||
test("should now pass with a header longer than 12 but less than 30 chars", async () => {
|
|
||||||
const tool = await QuestionTool.init()
|
|
||||||
const questions = [
|
|
||||||
{
|
|
||||||
question: "What is your favorite animal?",
|
|
||||||
header: "This Header is Over 12",
|
|
||||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
askSpy.mockResolvedValueOnce([["Dog"]])
|
|
||||||
|
|
||||||
const result = await tool.execute({ questions }, ctx)
|
|
||||||
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
|
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
|
||||||
// test("should throw an Error for header exceeding 30 characters", async () => {
|
// test("should throw an Error for header exceeding 30 characters", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user