Unifies interaction handling around execute/decline/elicit decisions and lets hosts own polling or blocking behavior via callbacks instead of baking retry logic into executor.
Unified · Split
| @@ -2,6 +2,7 @@ export { · 1 unmodified line | |||
| 2 | CodeToolInputSchema, | 2 | CodeToolInputSchema, |
| 3 | createCodeTool, | 3 | createCodeTool, |
| 4 | createDynamicDiscovery, | 4 | createDynamicDiscovery, |
| 5 | allowAllToolInteractions, | ||
| 5 | createStaticDiscoveryFromTools, | 6 | createStaticDiscoveryFromTools, |
| 6 | createSystemToolMap, | 7 | createSystemToolMap, |
| 7 | createToolsFromAiSdkTools, | 8 | createToolsFromAiSdkTools, |
| @@ -10,6 +11,8 @@ export { · 2 unmodified lines | |||
| 10 | mergeToolMaps, | 11 | mergeToolMaps, |
| 11 | toExecutorTool, | 12 | toExecutorTool, |
| 12 | toTool, | 13 | toTool, |
| 14 | ToolInteractionDeniedError, | ||
| 15 | ToolInteractionPendingError, | ||
| 13 | wrapTool, | 16 | wrapTool, |
| 14 | type AiSdkToolMap, | 17 | type AiSdkToolMap, |
| 15 | type CatalogPrimitive, | 18 | type CatalogPrimitive, |
| @@ -30,6 +33,8 @@ export { · 14 unmodified lines | |||
| 30 | type ToolDescriptor, | 33 | type ToolDescriptor, |
| 31 | type ToolDirectory, | 34 | type ToolDirectory, |
| 32 | type ToolInput, | 35 | type ToolInput, |
| 36 | type OnElicitation, | ||
| 37 | type OnToolInteraction, | ||
| 33 | type ToolInvocationInput, | 38 | type ToolInvocationInput, |
| 34 | type ToolInvoker, | 39 | type ToolInvoker, |
| 35 | type ToolMap, | 40 | type ToolMap, |
| @@ -9,10 +9,13 @@ import { · 8 unmodified lines | |||
| 9 | type CodeExecutor, | 9 | type CodeExecutor, |
| 10 | type CodeToolOutput, | 10 | type CodeToolOutput, |
| 11 | type ExecutableTool, | 11 | type ExecutableTool, |
| 12 | type OnElicitation, | ||
| 13 | type OnToolInteraction, | ||
| 12 | type ToolMap, | 14 | type ToolMap, |
| 13 | } from "@executor-v3/codemode-core"; | 15 | } from "@executor-v3/codemode-core"; |
| 14 | 16 | ||
| 15 | export { | 17 | export { |
| 18 | allowAllToolInteractions, | ||
| 16 | buildExecuteDescription, | 19 | buildExecuteDescription, |
| 17 | createDiscoveryPrimitives, | 20 | createDiscoveryPrimitives, |
| 18 | createDynamicDiscovery, | 21 | createDynamicDiscovery, |
| @@ -23,6 +26,8 @@ export { · 4 unmodified lines | |||
| 23 | mergeToolMaps, | 26 | mergeToolMaps, |
| 24 | toExecutorTool, | 27 | toExecutorTool, |
| 25 | toTool, | 28 | toTool, |
| 29 | ToolInteractionDeniedError, | ||
| 30 | ToolInteractionPendingError, | ||
| 26 | toolDescriptorsFromTools, | 31 | toolDescriptorsFromTools, |
| 27 | wrapTool, | 32 | wrapTool, |
| 28 | type CatalogPrimitive, | 33 | type CatalogPrimitive, |
| @@ -42,6 +47,8 @@ export { · 13 unmodified lines | |||
| 42 | type ToolDescriptor, | 47 | type ToolDescriptor, |
| 43 | type ToolDirectory, | 48 | type ToolDirectory, |
| 44 | type ToolInput, | 49 | type ToolInput, |
| 50 | type OnElicitation, | ||
| 51 | type OnToolInteraction, | ||
| 45 | type ToolInvocationInput, | 52 | type ToolInvocationInput, |
| 46 | type ToolInvoker, | 53 | type ToolInvoker, |
| 47 | type ToolMap, | 54 | type ToolMap, |
| @@ -70,6 +77,8 @@ export function createToolsFromAiSdkTools(input: { · 22 unmodified lines | |||
| 70 | export function createCodeTool(input: { | 77 | export function createCodeTool(input: { |
| 71 | tools: ToolMap; | 78 | tools: ToolMap; |
| 72 | executor: CodeExecutor; | 79 | executor: CodeExecutor; |
| 80 | onToolInteraction?: OnToolInteraction; | ||
| 81 | onElicitation?: OnElicitation; | ||
| 73 | description?: string; | 82 | description?: string; |
| 74 | }) { | 83 | }) { |
| 75 | return tool({ | 84 | return tool({ |
| @@ -86,6 +95,8 @@ export function createCodeTool(input: { · 10 unmodified lines | |||
| 86 | code, | 95 | code, |
| 87 | tools: input.tools, | 96 | tools: input.tools, |
| 88 | executor: input.executor, | 97 | executor: input.executor, |
| 98 | onToolInteraction: input.onToolInteraction, | ||
| 99 | onElicitation: input.onElicitation, | ||
| 89 | }), | 100 | }), |
| 90 | ), | 101 | ), |
| 91 | }); | 102 | }); |
| @@ -4,6 +4,8 @@ import { makeToolInvokerFromTools } from "./tool-map"; · 3 unmodified lines | |||
| 4 | import type { | 4 | import type { |
| 5 | CodeExecutor, | 5 | CodeExecutor, |
| 6 | CodeToolOutput, | 6 | CodeToolOutput, |
| 7 | OnElicitation, | ||
| 8 | OnToolInteraction, | ||
| 7 | ToolInvoker, | 9 | ToolInvoker, |
| 8 | ToolMap, | 10 | ToolMap, |
| 9 | } from "./types"; | 11 | } from "./types"; |
| @@ -16,6 +18,8 @@ export const executeCodeWithTools = (input: { · 6 unmodified lines | |||
| 16 | executor: CodeExecutor; | 18 | executor: CodeExecutor; |
| 17 | tools?: ToolMap; | 19 | tools?: ToolMap; |
| 18 | sourceKey?: string; | 20 | sourceKey?: string; |
| 21 | onToolInteraction?: OnToolInteraction; | ||
| 22 | onElicitation?: OnElicitation; | ||
| 19 | toolInvoker?: ToolInvoker; | 23 | toolInvoker?: ToolInvoker; |
| 20 | }): Effect.Effect<CodeToolOutput, Error> => | 24 | }): Effect.Effect<CodeToolOutput, Error> => |
| 21 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| @@ -24,6 +28,8 @@ export const executeCodeWithTools = (input: { · 2 unmodified lines | |||
| 24 | ? makeToolInvokerFromTools({ | 28 | ? makeToolInvokerFromTools({ |
| 25 | tools: input.tools, | 29 | tools: input.tools, |
| 26 | sourceKey: input.sourceKey, | 30 | sourceKey: input.sourceKey, |
| 31 | onToolInteraction: input.onToolInteraction, | ||
| 32 | onElicitation: input.onElicitation, | ||
| 27 | }) | 33 | }) |
| 28 | : null); | 34 | : null); |
| 29 | 35 | ||
| @@ -1,8 +1,12 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 3 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as TestClock from "effect/TestClock"; | ||
| 4 | 7 | ||
| 5 | import { | 8 | import { |
| 9 | allowAllToolInteractions, | ||
| 6 | createDynamicDiscovery, | 10 | createDynamicDiscovery, |
| 7 | createStaticDiscoveryFromTools, | 11 | createStaticDiscoveryFromTools, |
| 8 | createSystemToolMap, | 12 | createSystemToolMap, |
| @@ -10,6 +14,8 @@ import { · 1 unmodified line | |||
| 10 | makeToolInvokerFromTools, | 14 | makeToolInvokerFromTools, |
| 11 | mergeToolMaps, | 15 | mergeToolMaps, |
| 12 | standardSchemaFromJsonSchema, | 16 | standardSchemaFromJsonSchema, |
| 17 | ToolInteractionDeniedError, | ||
| 18 | ToolInteractionPendingError, | ||
| 13 | toTool, | 19 | toTool, |
| 14 | type CodeExecutor, | 20 | type CodeExecutor, |
| 15 | type ToolDescriptor, | 21 | type ToolDescriptor, |
| @@ -81,6 +87,155 @@ describe("codemode-core", () => { · 65 unmodified lines | |||
| 81 | ); | 87 | ); |
| 82 | 88 | ||
| 83 | 89 | ||
| 90 | it.effect("returns pending interaction error for required tools", () => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const invoker = makeToolInvokerFromTools({ | ||
| 93 | tools: { | ||
| 94 | "issues.create": toTool({ | ||
| 95 | tool: { | ||
| 96 | inputSchema: titleInputSchema, | ||
| 97 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 98 | }, | ||
| 99 | metadata: { | ||
| 100 | interaction: "required", | ||
| 101 | }, | ||
| 102 | }), | ||
| 103 | }, | ||
| 104 | }); | ||
| 105 | |||
| 106 | const pendingError = yield* Effect.flip( | ||
| 107 | invoker.invoke({ | ||
| 108 | path: "issues.create", | ||
| 109 | args: { title: "hello" }, | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | |||
| 113 | expect(pendingError).toBeInstanceOf(ToolInteractionPendingError); | ||
| 114 | if (pendingError instanceof ToolInteractionPendingError) { | ||
| 115 | expect(pendingError.path).toBe("issues.create"); | ||
| 116 | expect(pendingError.elicitation.mode).toBe("form"); | ||
| 117 | } | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("waits for elicitation response and executes when accepted", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const invoker = makeToolInvokerFromTools({ | ||
| 124 | tools: { | ||
| 125 | "issues.create": toTool({ | ||
| 126 | tool: { | ||
| 127 | inputSchema: titleInputSchema, | ||
| 128 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 129 | }, | ||
| 130 | metadata: { | ||
| 131 | interaction: "required", | ||
| 132 | }, | ||
| 133 | }), | ||
| 134 | }, | ||
| 135 | onElicitation: () => | ||
| 136 | Effect.succeed({ | ||
| 137 | action: "accept" as const, | ||
| 138 | }), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const output = yield* invoker.invoke({ | ||
| 142 | path: "issues.create", | ||
| 143 | args: { title: "hello" }, | ||
| 144 | context: { runId: "run_1", callId: "call_1" }, | ||
| 145 | }); | ||
| 146 | |||
| 147 | expect(output).toEqual({ id: "issue_1", title: "hello" }); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | |||
| 151 | it.effect("waits through polling-style elicitation callback and then executes", () => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | let pollingSleeps = 0; | ||
| 154 | |||
| 155 | const invoker = makeToolInvokerFromTools({ | ||
| 156 | tools: { | ||
| 157 | "issues.create": toTool({ | ||
| 158 | tool: { | ||
| 159 | inputSchema: titleInputSchema, | ||
| 160 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 161 | }, | ||
| 162 | metadata: { | ||
| 163 | interaction: "required", | ||
| 164 | }, | ||
| 165 | }), | ||
| 166 | }, | ||
| 167 | onElicitation: () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | for (let attempt = 0; attempt < 3; attempt += 1) { | ||
| 170 | pollingSleeps += 1; | ||
| 171 | yield* Effect.sleep("200 seconds"); | ||
| 172 | } | ||
| 173 | |||
| 174 | return { | ||
| 175 | action: "accept" as const, | ||
| 176 | }; | ||
| 177 | }), | ||
| 178 | }); | ||
| 179 | |||
| 180 | const fiber = yield* invoker.invoke({ | ||
| 181 | path: "issues.create", | ||
| 182 | args: { title: "hello" }, | ||
| 183 | context: { runId: "run_poll", callId: "call_poll" }, | ||
| 184 | }).pipe(Effect.fork); | ||
| 185 | |||
| 186 | yield* TestClock.adjust("200 seconds"); | ||
| 187 | const firstPoll = yield* Fiber.poll(fiber); | ||
| 188 | expect(Option.isNone(firstPoll)).toBe(true); | ||
| 189 | |||
| 190 | yield* TestClock.adjust("200 seconds"); | ||
| 191 | const secondPoll = yield* Fiber.poll(fiber); | ||
| 192 | expect(Option.isNone(secondPoll)).toBe(true); | ||
| 193 | |||
| 194 | yield* TestClock.adjust("200 seconds"); | ||
| 195 | const output = yield* Fiber.join(fiber); | ||
| 196 | |||
| 197 | expect(output).toEqual({ id: "issue_1", title: "hello" }); | ||
| 198 | expect(pollingSleeps).toBe(3); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | |||
| 202 | it.effect("fails when elicitation is declined", () => | ||
| 203 | Effect.gen(function* () { | ||
| 204 | const invoker = makeToolInvokerFromTools({ | ||
| 205 | tools: { | ||
| 206 | "issues.create": toTool({ | ||
| 207 | tool: { | ||
| 208 | inputSchema: titleInputSchema, | ||
| 209 | execute: ({ title }: { title: string }) => ({ id: "issue_1", title }), | ||
| 210 | }, | ||
| 211 | metadata: { | ||
| 212 | interaction: "required", | ||
| 213 | }, | ||
| 214 | }), | ||
| 215 | }, | ||
| 216 | onElicitation: () => | ||
| 217 | Effect.succeed({ | ||
| 218 | action: "decline" as const, | ||
| 219 | content: { | ||
| 220 | reason: "User declined tool execution", | ||
| 221 | }, | ||
| 222 | }), | ||
| 223 | }); | ||
| 224 | |||
| 225 | const declinedError = yield* Effect.flip( | ||
| 226 | invoker.invoke({ | ||
| 227 | path: "issues.create", | ||
| 228 | args: { title: "hello" }, | ||
| 229 | }), | ||
| 230 | ); | ||
| 231 | |||
| 232 | expect(declinedError).toBeInstanceOf(ToolInteractionDeniedError); | ||
| 233 | if (declinedError instanceof ToolInteractionDeniedError) { | ||
| 234 | expect(declinedError.reason).toContain("User declined"); | ||
| 235 | } | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 84 | it.effect("builds Standard Schema validators from JSON Schema", () => | 239 | it.effect("builds Standard Schema validators from JSON Schema", () => |
| 85 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| 86 | const tools = { | 241 | const tools = { |
| @@ -278,6 +433,7 @@ describe("codemode-core", () => { · 191 unmodified lines | |||
| 278 | code: "return await tools.math.add({ a: 2, b: 3 });", | 433 | code: "return await tools.math.add({ a: 2, b: 3 });", |
| 279 | tools, | 434 | tools, |
| 280 | executor, | 435 | executor, |
| 436 | onToolInteraction: allowAllToolInteractions, | ||
| 281 | }); | 437 | }); |
| 282 | 438 | ||
| 283 | expect(output.code).toContain("tools.math.add"); | 439 | expect(output.code).toContain("tools.math.add"); |
| @@ -1,11 +1,20 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | ||
| 4 | import type { | 5 | import type { |
| 6 | ElicitationRequest, | ||
| 7 | ElicitationResponse, | ||
| 5 | ExecutableTool, | 8 | ExecutableTool, |
| 9 | OnElicitation, | ||
| 10 | OnToolInteraction, | ||
| 6 | ToolDefinition, | 11 | ToolDefinition, |
| 7 | ToolDescriptor, | 12 | ToolDescriptor, |
| 13 | ToolElicitationRequest, | ||
| 8 | ToolInput, | 14 | ToolInput, |
| 15 | ToolInteractionDecision, | ||
| 16 | ToolInteractionRequest, | ||
| 17 | ToolInvocationContext, | ||
| 9 | ToolInvoker, | 18 | ToolInvoker, |
| 10 | ToolMap, | 19 | ToolMap, |
| 11 | ToolMetadata, | 20 | ToolMetadata, |
| @@ -98,6 +107,216 @@ const parseInput = (input: { · 86 unmodified lines | |||
| 98 | ); | 107 | ); |
| 99 | }; | 108 | }; |
| 100 | 109 | ||
| 110 | const defaultRequiredElicitation = (path: string): ElicitationRequest => ({ | ||
| 111 | mode: "form", | ||
| 112 | message: `Approval required before invoking ${path}`, | ||
| 113 | requestedSchema: { | ||
| 114 | type: "object", | ||
| 115 | properties: {}, | ||
| 116 | additionalProperties: false, | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | |||
| 120 | const executeDecision: ToolInteractionDecision = { kind: "execute" }; | ||
| 121 | |||
| 122 | const toElicitationDecision = (input: { | ||
| 123 | metadata?: ToolMetadata; | ||
| 124 | path: string; | ||
| 125 | }): ToolInteractionDecision | null => { | ||
| 126 | if (input.metadata?.elicitation) { | ||
| 127 | return { | ||
| 128 | kind: "elicit", | ||
| 129 | elicitation: input.metadata.elicitation, | ||
| 130 | }; | ||
| 131 | } | ||
| 132 | |||
| 133 | if (input.metadata?.interaction === "required") { | ||
| 134 | return { | ||
| 135 | kind: "elicit", | ||
| 136 | elicitation: defaultRequiredElicitation(input.path), | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | return null; | ||
| 141 | }; | ||
| 142 | |||
| 143 | const defaultInteractionDecision = (input: { | ||
| 144 | metadata?: ToolMetadata; | ||
| 145 | path: string; | ||
| 146 | }): ToolInteractionDecision => | ||
| 147 | toElicitationDecision(input) ?? executeDecision; | ||
| 148 | |||
| 149 | export class ToolInteractionPendingError extends Data.TaggedError( | ||
| 150 | "ToolInteractionPendingError", | ||
| 151 | )<{ | ||
| 152 | readonly path: string; | ||
| 153 | readonly elicitation: ElicitationRequest; | ||
| 154 | readonly interactionId?: string; | ||
| 155 | }> {} | ||
| 156 | |||
| 157 | export class ToolInteractionDeniedError extends Data.TaggedError( | ||
| 158 | "ToolInteractionDeniedError", | ||
| 159 | )<{ | ||
| 160 | readonly path: string; | ||
| 161 | readonly reason: string; | ||
| 162 | }> {} | ||
| 163 | |||
| 164 | export const allowAllToolInteractions: OnToolInteraction = () => | ||
| 165 | Effect.succeed(executeDecision); | ||
| 166 | |||
| 167 | const evaluateInteractionDecision = (input: { | ||
| 168 | path: ToolPath; | ||
| 169 | args: unknown; | ||
| 170 | metadata?: ToolMetadata; | ||
| 171 | sourceKey: string; | ||
| 172 | context?: ToolInvocationContext; | ||
| 173 | onToolInteraction?: OnToolInteraction; | ||
| 174 | }): Effect.Effect<ToolInteractionDecision, unknown> => { | ||
| 175 | const defaultDecision = defaultInteractionDecision({ | ||
| 176 | metadata: input.metadata, | ||
| 177 | path: input.path, | ||
| 178 | }); | ||
| 179 | |||
| 180 | if (!input.onToolInteraction) { | ||
| 181 | return Effect.succeed(defaultDecision); | ||
| 182 | } | ||
| 183 | |||
| 184 | const request: ToolInteractionRequest = { | ||
| 185 | path: input.path, | ||
| 186 | sourceKey: input.sourceKey, | ||
| 187 | args: input.args, | ||
| 188 | metadata: input.metadata, | ||
| 189 | context: input.context, | ||
| 190 | defaultElicitation: | ||
| 191 | defaultDecision.kind === "elicit" | ||
| 192 | ? defaultDecision.elicitation | ||
| 193 | : null, | ||
| 194 | }; | ||
| 195 | |||
| 196 | return input.onToolInteraction(request); | ||
| 197 | }; | ||
| 198 | |||
| 199 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 200 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 201 | |||
| 202 | const mergeAcceptedElicitationContent = (input: { | ||
| 203 | path: ToolPath; | ||
| 204 | args: unknown; | ||
| 205 | response: ElicitationResponse; | ||
| 206 | inputSchema: unknown; | ||
| 207 | }): Effect.Effect<unknown, Error> => { | ||
| 208 | if (!input.response.content) { | ||
| 209 | return Effect.succeed(input.args); | ||
| 210 | } | ||
| 211 | |||
| 212 | if (!isRecord(input.args)) { | ||
| 213 | return Effect.fail( | ||
| 214 | new Error( | ||
| 215 | `Tool ${input.path} cannot merge elicitation content into non-object arguments`, | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | } | ||
| 219 | |||
| 220 | const merged = { | ||
| 221 | ...input.args, | ||
| 222 | ...input.response.content, | ||
| 223 | }; | ||
| 224 | |||
| 225 | return parseInput({ | ||
| 226 | schema: input.inputSchema, | ||
| 227 | value: merged, | ||
| 228 | path: input.path, | ||
| 229 | }); | ||
| 230 | }; | ||
| 231 | |||
| 232 | const declineReasonFromResponse = (input: { | ||
| 233 | path: ToolPath; | ||
| 234 | response: ElicitationResponse; | ||
| 235 | }): string => { | ||
| 236 | const reason = | ||
| 237 | input.response.content | ||
| 238 | && typeof input.response.content.reason === "string" | ||
| 239 | && input.response.content.reason.trim().length > 0 | ||
| 240 | ? input.response.content.reason.trim() | ||
| 241 | : null; | ||
| 242 | |||
| 243 | if (reason) { | ||
| 244 | return reason; | ||
| 245 | } | ||
| 246 | |||
| 247 | return input.response.action === "cancel" | ||
| 248 | ? `Interaction cancelled for ${input.path}` | ||
| 249 | : `Interaction declined for ${input.path}`; | ||
| 250 | }; | ||
| 251 | |||
| 252 | const resolveInteractionDecision = (input: { | ||
| 253 | path: ToolPath; | ||
| 254 | args: unknown; | ||
| 255 | inputSchema: unknown; | ||
| 256 | metadata?: ToolMetadata; | ||
| 257 | sourceKey: string; | ||
| 258 | context?: ToolInvocationContext; | ||
| 259 | decision: ToolInteractionDecision; | ||
| 260 | interactionId: string; | ||
| 261 | onElicitation?: OnElicitation; | ||
| 262 | }): Effect.Effect<unknown, ToolInteractionPendingError | ToolInteractionDeniedError | Error> => { | ||
| 263 | if (input.decision.kind === "execute") { | ||
| 264 | return Effect.succeed(input.args); | ||
| 265 | } | ||
| 266 | |||
| 267 | if (input.decision.kind === "decline") { | ||
| 268 | return Effect.fail( | ||
| 269 | new ToolInteractionDeniedError({ | ||
| 270 | path: input.path, | ||
| 271 | reason: input.decision.reason, | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | |||
| 276 | if (!input.onElicitation) { | ||
| 277 | return Effect.fail( | ||
| 278 | new ToolInteractionPendingError({ | ||
| 279 | path: input.path, | ||
| 280 | elicitation: input.decision.elicitation, | ||
| 281 | interactionId: input.interactionId, | ||
| 282 | }), | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | const elicitationRequest: ToolElicitationRequest = { | ||
| 287 | interactionId: input.interactionId, | ||
| 288 | path: input.path, | ||
| 289 | sourceKey: input.sourceKey, | ||
| 290 | args: input.args, | ||
| 291 | metadata: input.metadata, | ||
| 292 | context: input.context, | ||
| 293 | elicitation: input.decision.elicitation, | ||
| 294 | }; | ||
| 295 | |||
| 296 | return input.onElicitation(elicitationRequest).pipe( | ||
| 297 | Effect.mapError(toError), | ||
| 298 | Effect.flatMap((response) => { | ||
| 299 | if (response.action !== "accept") { | ||
| 300 | return Effect.fail( | ||
| 301 | new ToolInteractionDeniedError({ | ||
| 302 | path: input.path, | ||
| 303 | reason: declineReasonFromResponse({ | ||
| 304 | path: input.path, | ||
| 305 | response, | ||
| 306 | }), | ||
| 307 | }), | ||
| 308 | ); | ||
| 309 | } | ||
| 310 | |||
| 311 | return mergeAcceptedElicitationContent({ | ||
| 312 | path: input.path, | ||
| 313 | args: input.args, | ||
| 314 | response, | ||
| 315 | inputSchema: input.inputSchema, | ||
| 316 | }); | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | }; | ||
| 101 | 320 | ||
| 102 | export function wrapTool(input: { | 321 | export function wrapTool(input: { |
| 103 | tool: ExecutableTool; | 322 | tool: ExecutableTool; |
| @@ -241,6 +460,7 @@ export function toolDescriptorsFromTools(input: { · 137 unmodified lines | |||
| 241 | sourceKey: metadata?.sourceKey ?? "in_memory.tools", | 460 | sourceKey: metadata?.sourceKey ?? "in_memory.tools", |
| 242 | description: definition.description, | 461 | description: definition.description, |
| 243 | interaction: metadata?.interaction, | 462 | interaction: metadata?.interaction, |
| 463 | elicitation: metadata?.elicitation, | ||
| 244 | inputHint: | 464 | inputHint: |
| 245 | metadata?.inputHint ?? inferHintFromSchemaJson(inputSchemaJson, "input"), | 465 | metadata?.inputHint ?? inferHintFromSchemaJson(inputSchemaJson, "input"), |
| 246 | outputHint: | 466 | outputHint: |
| @@ -252,18 +472,43 @@ export function toolDescriptorsFromTools(input: { · 5 unmodified lines | |||
| 252 | }); | 472 | }); |
| 253 | } | 473 | } |
| 254 | 474 | ||
| 475 | const asInteractionIdPart = (value: string): string => | ||
| 476 | value.replace(/[^a-zA-Z0-9._-]/g, "_"); | ||
| 477 | |||
| 478 | const createInteractionIdFactory = () => { | ||
| 479 | let sequence = 0; | ||
| 480 | |||
| 481 | return (input: { path: ToolPath; context?: ToolInvocationContext }): string => { | ||
| 482 | sequence += 1; | ||
| 483 | |||
| 484 | const runId = | ||
| 485 | typeof input.context?.runId === "string" && input.context.runId.length > 0 | ||
| 486 | ? input.context.runId | ||
| 487 | : "run"; | ||
| 488 | const callId = | ||
| 489 | typeof input.context?.callId === "string" && input.context.callId.length > 0 | ||
| 490 | ? input.context.callId | ||
| 491 | : `call_${String(sequence)}`; | ||
| 492 | |||
| 493 | return `${asInteractionIdPart(runId)}:${asInteractionIdPart(callId)}:${asInteractionIdPart(String(input.path))}:${String(sequence)}`; | ||
| 494 | }; | ||
| 495 | }; | ||
| 496 | |||
| 255 | export const makeToolInvokerFromTools = (input: { | 497 | export const makeToolInvokerFromTools = (input: { |
| 256 | tools: ToolMap; | 498 | tools: ToolMap; |
| 257 | sourceKey?: string; | 499 | sourceKey?: string; |
| 500 | onToolInteraction?: OnToolInteraction; | ||
| 501 | onElicitation?: OnElicitation; | ||
| 258 | }): ToolInvoker => { | 502 | }): ToolInvoker => { |
| 259 | const resolvedTools = resolveToolsFromMap({ | 503 | const resolvedTools = resolveToolsFromMap({ |
| 260 | tools: input.tools, | 504 | tools: input.tools, |
| 261 | sourceKey: input.sourceKey, | 505 | sourceKey: input.sourceKey, |
| 262 | }); | 506 | }); |
| 263 | const byPath = new Map(resolvedTools.map((entry) => [entry.path as string, entry])); | 507 | const byPath = new Map(resolvedTools.map((entry) => [entry.path as string, entry])); |
| 508 | const nextInteractionId = createInteractionIdFactory(); | ||
| 264 | 509 | ||
| 265 | return { | 510 | return { |
| 266 | invoke: ({ path, args }) => | 511 | invoke: ({ path, args, context }) => |
| 267 | Effect.gen(function* () { | 512 | Effect.gen(function* () { |
| 268 | const entry = byPath.get(path); | 513 | const entry = byPath.get(path); |
| 269 | if (!entry) { | 514 | if (!entry) { |
| @@ -276,8 +521,34 @@ export const makeToolInvokerFromTools = (input: { · 6 unmodified lines | |||
| 276 | path, | 521 | path, |
| 277 | }); | 522 | }); |
| 278 | 523 | ||
| 524 | const decision = yield* evaluateInteractionDecision({ | ||
| 525 | path: entry.path, | ||
| 526 | args: parsedInput, | ||
| 527 | metadata: entry.metadata, | ||
| 528 | sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools", | ||
| 529 | context, | ||
| 530 | onToolInteraction: input.onToolInteraction, | ||
| 531 | }); | ||
| 532 | |||
| 533 | const interactionId = | ||
| 534 | decision.kind === "elicit" && decision.interactionId | ||
| 535 | ? decision.interactionId | ||
| 536 | : nextInteractionId({ path: entry.path, context }); | ||
| 537 | |||
| 538 | const executableInput = yield* resolveInteractionDecision({ | ||
| 539 | path: entry.path, | ||
| 540 | args: parsedInput, | ||
| 541 | inputSchema: entry.tool.inputSchema, | ||
| 542 | metadata: entry.metadata, | ||
| 543 | sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools", | ||
| 544 | context, | ||
| 545 | decision, | ||
| 546 | interactionId, | ||
| 547 | onElicitation: input.onElicitation, | ||
| 548 | }); | ||
| 549 | |||
| 279 | return yield* Effect.tryPromise({ | 550 | return yield* Effect.tryPromise({ |
| 280 | try: () => Promise.resolve(entry.tool.execute(parsedInput)), | 551 | try: () => Promise.resolve(entry.tool.execute(executableInput)), |
| 281 | catch: toError, | 552 | catch: toError, |
| 282 | }); | 553 | }); |
| 283 | }), | 554 | }), |
| @@ -6,8 +6,77 @@ export type ToolPath = string & { readonly __toolPath: unique symbol }; · 5 unmodified lines | |||
| 6 | export type StandardSchema<Input = unknown, Output = unknown> = | 6 | export type StandardSchema<Input = unknown, Output = unknown> = |
| 7 | StandardSchemaV1<Input, Output>; | 7 | StandardSchemaV1<Input, Output>; |
| 8 | 8 | ||
| 9 | export type ElicitationFormRequest = { | ||
| 10 | mode?: "form"; | ||
| 11 | message: string; | ||
| 12 | requestedSchema: Record<string, unknown>; | ||
| 13 | }; | ||
| 14 | |||
| 15 | export type ElicitationUrlRequest = { | ||
| 16 | mode: "url"; | ||
| 17 | message: string; | ||
| 18 | url: string; | ||
| 19 | elicitationId: string; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export type ElicitationRequest = | ||
| 23 | | ElicitationFormRequest | ||
| 24 | | ElicitationUrlRequest; | ||
| 25 | |||
| 26 | export type ElicitationAction = "accept" | "decline" | "cancel"; | ||
| 27 | |||
| 28 | export type ElicitationResponse = { | ||
| 29 | action: ElicitationAction; | ||
| 30 | content?: Record<string, unknown>; | ||
| 31 | }; | ||
| 32 | |||
| 33 | export type ToolInvocationContext = { | ||
| 34 | runId?: string; | ||
| 35 | callId?: string; | ||
| 36 | scope?: string; | ||
| 37 | actor?: string; | ||
| 38 | [key: string]: unknown; | ||
| 39 | }; | ||
| 40 | |||
| 41 | export type ToolInteractionRequest = { | ||
| 42 | path: ToolPath; | ||
| 43 | sourceKey: string; | ||
| 44 | args: unknown; | ||
| 45 | metadata?: ToolMetadata; | ||
| 46 | context?: ToolInvocationContext; | ||
| 47 | defaultElicitation: ElicitationRequest | null; | ||
| 48 | }; | ||
| 49 | |||
| 50 | export type ToolInteractionDecision = | ||
| 51 | | { kind: "execute" } | ||
| 52 | | { kind: "decline"; reason: string } | ||
| 53 | | { | ||
| 54 | kind: "elicit"; | ||
| 55 | elicitation: ElicitationRequest; | ||
| 56 | interactionId?: string; | ||
| 57 | }; | ||
| 58 | |||
| 59 | export type ToolElicitationRequest = { | ||
| 60 | interactionId: string; | ||
| 61 | path: ToolPath; | ||
| 62 | sourceKey: string; | ||
| 63 | args: unknown; | ||
| 64 | metadata?: ToolMetadata; | ||
| 65 | context?: ToolInvocationContext; | ||
| 66 | elicitation: ElicitationRequest; | ||
| 67 | }; | ||
| 68 | |||
| 69 | export type OnToolInteraction = ( | ||
| 70 | input: ToolInteractionRequest, | ||
| 71 | ) => Effect.Effect<ToolInteractionDecision, unknown>; | ||
| 72 | |||
| 73 | export type OnElicitation = ( | ||
| 74 | input: ToolElicitationRequest, | ||
| 75 | ) => Effect.Effect<ElicitationResponse, unknown>; | ||
| 76 | |||
| 9 | export type ToolMetadata = { | 77 | export type ToolMetadata = { |
| 10 | interaction?: "auto" | "required"; | 78 | interaction?: "auto" | "required"; |
| 79 | elicitation?: ElicitationRequest; | ||
| 11 | inputHint?: string; | 80 | inputHint?: string; |
| 12 | outputHint?: string; | 81 | outputHint?: string; |
| 13 | inputSchemaJson?: string; | 82 | inputSchemaJson?: string; |
| @@ -49,6 +118,7 @@ export type ToolDescriptor = { · 35 unmodified lines | |||
| 49 | sourceKey: string; | 118 | sourceKey: string; |
| 50 | description?: string; | 119 | description?: string; |
| 51 | interaction?: "auto" | "required"; | 120 | interaction?: "auto" | "required"; |
| 121 | elicitation?: ElicitationRequest; | ||
| 52 | inputHint?: string; | 122 | inputHint?: string; |
| 53 | outputHint?: string; | 123 | outputHint?: string; |
| 54 | inputSchemaJson?: string; | 124 | inputSchemaJson?: string; |
| @@ -144,6 +214,7 @@ export type ExecuteResult = { · 89 unmodified lines | |||
| 144 | export type ToolInvocationInput = { | 214 | export type ToolInvocationInput = { |
| 145 | path: string; | 215 | path: string; |
| 146 | args: unknown; | 216 | args: unknown; |
| 217 | context?: ToolInvocationContext; | ||
| 147 | }; | 218 | }; |
| 148 | 219 | ||
| 149 | export interface ToolInvoker { | 220 | export interface ToolInvoker { |
| @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | |||
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | allowAllToolInteractions, | ||
| 6 | createCodeTool, | 7 | createCodeTool, |
| 7 | executeCodeWithTools, | 8 | executeCodeWithTools, |
| 8 | makeToolInvokerFromTools, | 9 | makeToolInvokerFromTools, |
| @@ -55,6 +56,7 @@ describe("kitchen-sink", () => { · 46 unmodified lines | |||
| 55 | ].join("\n"), | 56 | ].join("\n"), |
| 56 | tools, | 57 | tools, |
| 57 | executor, | 58 | executor, |
| 59 | onToolInteraction: allowAllToolInteractions, | ||
| 58 | }); | 60 | }); |
| 59 | 61 | ||
| 60 | expect(output.result).toEqual({ sum: 42 }); | 62 | expect(output.result).toEqual({ sum: 42 }); |