refactor(tools): unify tool APIs and result handling (#38367)
This commit is contained in:
parent
8cac010bac
commit
79c1544072
133 changed files with 3602 additions and 2770 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
Exit,
|
||||
Fiber,
|
||||
FiberSet,
|
||||
JsonSchema,
|
||||
Layer,
|
||||
PubSub,
|
||||
Queue,
|
||||
|
|
@ -451,7 +452,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
|||
name: string,
|
||||
input: unknown,
|
||||
context: Tool.Context,
|
||||
): Effect.Effect<Tool.DynamicOutput, Tool.Failure> =>
|
||||
): Effect.Effect<Tool.Response<JsonSchema.JsonSchema>, Tool.Failure> =>
|
||||
Effect.gen(function* () {
|
||||
const encoded = yield* Schema.decodeUnknownEffect(Schema.Json)(input).pipe(
|
||||
Effect.mapError((error) => new Tool.Failure({ message: `Simulated tool input is not JSON: ${error.message}` })),
|
||||
|
|
@ -518,7 +519,15 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
|||
),
|
||||
),
|
||||
)
|
||||
if (invocation.type === "success") return invocation.output
|
||||
// The simulation wire protocol keeps its historical field names; map to the
|
||||
// canonical output at this boundary.
|
||||
if (invocation.type === "success")
|
||||
return {
|
||||
output: invocation.output.structured,
|
||||
...(invocation.output.content.length === 0
|
||||
? {}
|
||||
: { content: invocation.output.content as [Tool.Content, ...Tool.Content[]] }),
|
||||
}
|
||||
return yield* Effect.fail(new Tool.Failure({ message: invocation.message }))
|
||||
})
|
||||
|
||||
|
|
@ -546,11 +555,8 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
|||
registration.name,
|
||||
Tool.make({
|
||||
description: registration.description,
|
||||
jsonSchema: registration.inputSchema,
|
||||
...(registration.outputSchema === undefined
|
||||
? {}
|
||||
: { outputSchema: registration.outputSchema }),
|
||||
...(registration.permission === undefined ? {} : { permission: registration.permission }),
|
||||
input: registration.inputSchema,
|
||||
output: registration.outputSchema ?? {},
|
||||
execute: (input, context) =>
|
||||
invoke(
|
||||
generation,
|
||||
|
|
@ -559,7 +565,9 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
|
|||
context,
|
||||
),
|
||||
}),
|
||||
registration.options,
|
||||
registration.permission === undefined
|
||||
? registration.options
|
||||
: { ...registration.options, permission: registration.permission },
|
||||
)
|
||||
})
|
||||
.pipe(Scope.provide(nextScope)),
|
||||
|
|
|
|||
|
|
@ -473,10 +473,7 @@ export namespace Backend {
|
|||
: `${registration.options.namespace.replaceAll(".", "_")}_${registration.name}`
|
||||
}
|
||||
|
||||
export const ToolProgress = Schema.Struct({
|
||||
structured: Schema.Record(Schema.String, Schema.Json),
|
||||
content: Schema.optionalKey(Schema.Array(ToolContent)),
|
||||
})
|
||||
export const ToolProgress = Schema.Record(Schema.String, Schema.Json)
|
||||
export interface ToolProgress extends Schema.Schema.Type<typeof ToolProgress> {}
|
||||
|
||||
export const ToolOutput = Schema.Struct({
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ test("decodes the simulated tool lifecycle", () => {
|
|||
params: {
|
||||
id: "tool_1",
|
||||
sequence: 0,
|
||||
update: { structured: { phase: "searching" }, content: [{ type: "text", text: "Searching" }] },
|
||||
update: { phase: "searching" },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ method: "tool.update" })
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: { type: "object" },
|
||||
permission: "simulate_lookup",
|
||||
options: { codemode: false },
|
||||
}
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
|
@ -264,19 +265,22 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const materialized = yield* registry.materialize()
|
||||
expect(materialized.definitions).toContainEqual(
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.definitions).toContainEqual(
|
||||
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
|
||||
)
|
||||
const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) =>
|
||||
secondaryRegistry.materialize(),
|
||||
expect(
|
||||
(yield* registry.snapshot([{ action: "simulate_lookup", resource: "*", effect: "deny" }])).definitions,
|
||||
).not.toContainEqual(expect.objectContaining({ name: "lookup" }))
|
||||
const secondaryToolSet = yield* ToolRegistry.Service.use((secondaryRegistry) =>
|
||||
secondaryRegistry.snapshot(),
|
||||
).pipe(Effect.provide(secondary))
|
||||
expect(secondaryMaterialized.definitions).toContainEqual(
|
||||
expect(secondaryToolSet.definitions).toContainEqual(
|
||||
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
|
||||
)
|
||||
const progress: ToolRegistry.Progress[] = []
|
||||
const settle = (callID: string, query: string) =>
|
||||
materialized.settle({
|
||||
const executeCall = (callID: string, query: string) =>
|
||||
toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_simulated_tools"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_simulated_tools"),
|
||||
|
|
@ -289,7 +293,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped)
|
||||
const successful = yield* executeCall("call_success", "answer").pipe(Effect.forkScoped)
|
||||
const successInvocation = yield* takeToolInvocation(messages)
|
||||
expect(successInvocation.params).toMatchObject({
|
||||
name: "lookup",
|
||||
|
|
@ -309,10 +313,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
params: {
|
||||
id: successID,
|
||||
sequence: 0,
|
||||
update: {
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
},
|
||||
update: { phase: "searching" },
|
||||
},
|
||||
})
|
||||
socket.send(update)
|
||||
|
|
@ -334,7 +335,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
params: {
|
||||
id: successID,
|
||||
sequence: 0,
|
||||
update: { structured: { phase: "different" } },
|
||||
update: { phase: "different" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
@ -350,7 +351,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
params: {
|
||||
id: successID,
|
||||
sequence: 2,
|
||||
update: { structured: { phase: "skipped" } },
|
||||
update: { phase: "skipped" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
@ -389,20 +390,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } })
|
||||
expect(yield* Fiber.join(successful)).toMatchObject({
|
||||
result: { type: "text", value: "42" },
|
||||
output: {
|
||||
structured: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
},
|
||||
status: "completed",
|
||||
output: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
expect(progress).toEqual([
|
||||
{
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
},
|
||||
])
|
||||
expect(progress).toEqual([{ phase: "searching" }])
|
||||
|
||||
const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped)
|
||||
const failed = yield* executeCall("call_failure", "missing").pipe(Effect.forkScoped)
|
||||
const failedInvocation = yield* takeToolInvocation(messages)
|
||||
const failedID = requireString(requireRecord(failedInvocation.params).id)
|
||||
socket.send(
|
||||
|
|
@ -415,12 +409,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } })
|
||||
expect(yield* Fiber.join(failed)).toMatchObject({
|
||||
result: { type: "error", value: "lookup failed" },
|
||||
status: "error",
|
||||
error: { message: "lookup failed" },
|
||||
})
|
||||
|
||||
const concurrent = [
|
||||
yield* settle("call_first", "first").pipe(Effect.forkScoped),
|
||||
yield* settle("call_second", "second").pipe(Effect.forkScoped),
|
||||
yield* executeCall("call_first", "first").pipe(Effect.forkScoped),
|
||||
yield* executeCall("call_second", "second").pipe(Effect.forkScoped),
|
||||
]
|
||||
const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)]
|
||||
const byCall = new Map(
|
||||
|
|
@ -447,10 +442,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
|
||||
}
|
||||
expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" })
|
||||
expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" })
|
||||
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
|
||||
status: "completed",
|
||||
output: "first result",
|
||||
content: [{ type: "text", text: "first result" }],
|
||||
})
|
||||
expect(yield* Fiber.join(concurrent[1])).toMatchObject({
|
||||
status: "completed",
|
||||
output: "second result",
|
||||
content: [{ type: "text", text: "second result" }],
|
||||
})
|
||||
|
||||
const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped)
|
||||
const cancelled = yield* executeCall("call_cancelled", "slow").pipe(Effect.forkScoped)
|
||||
const cancelledInvocation = yield* takeToolInvocation(messages)
|
||||
const cancelledID = requireString(requireRecord(cancelledInvocation.params).id)
|
||||
yield* Fiber.interrupt(cancelled)
|
||||
|
|
@ -474,13 +477,10 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
error: { message: expect.stringContaining("not found or already finished") },
|
||||
})
|
||||
|
||||
const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped)
|
||||
const replayed = yield* executeCall("call_replayed", "reconnect").pipe(Effect.forkScoped)
|
||||
const original = yield* takeToolInvocation(messages)
|
||||
const originalID = requireString(requireRecord(original.params).id)
|
||||
const replayedProgress = {
|
||||
structured: { phase: "before-reconnect" },
|
||||
content: [{ type: "text", text: "Still running" }],
|
||||
}
|
||||
const replayedProgress = { phase: "before-reconnect" }
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -505,7 +505,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
error: { message: expect.stringContaining("already attached") },
|
||||
})
|
||||
yield* closeSocket(socket)
|
||||
const disconnected = yield* registry.materialize()
|
||||
const disconnected = yield* registry.snapshot()
|
||||
expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" }))
|
||||
replacement.send(
|
||||
JSON.stringify({
|
||||
|
|
@ -547,7 +547,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
}),
|
||||
)
|
||||
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } })
|
||||
expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1)
|
||||
expect(progress.filter((update) => update.phase === "before-reconnect")).toHaveLength(1)
|
||||
replacement.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -563,12 +563,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
}),
|
||||
)
|
||||
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } })
|
||||
expect((yield* Fiber.join(replayed)).result).toEqual({
|
||||
type: "text",
|
||||
value: "replayed result",
|
||||
expect(yield* Fiber.join(replayed)).toMatchObject({
|
||||
status: "completed",
|
||||
output: "replayed result",
|
||||
content: [{ type: "text", text: "replayed result" }],
|
||||
})
|
||||
|
||||
const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped)
|
||||
const preserved = yield* executeCall("call_preserved", "same generation").pipe(Effect.forkScoped)
|
||||
const preservedInvocation = yield* takeToolInvocation(replacementMessages)
|
||||
const preservedID = requireString(requireRecord(preservedInvocation.params).id)
|
||||
replacement.send(
|
||||
|
|
@ -583,7 +584,11 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
}),
|
||||
)
|
||||
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } })
|
||||
expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" })
|
||||
expect(yield* Fiber.join(preserved)).toMatchObject({
|
||||
status: "completed",
|
||||
output: "preserved",
|
||||
content: [{ type: "text", text: "preserved" }],
|
||||
})
|
||||
|
||||
const namespaced = [
|
||||
{ ...registration, name: "search", options: { namespace: "github", codemode: false } },
|
||||
|
|
@ -598,18 +603,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
}),
|
||||
)
|
||||
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } })
|
||||
const replaced = yield* registry.materialize()
|
||||
const replaced = yield* registry.snapshot()
|
||||
const replacedNames = replaced.definitions.map((definition) => definition.name)
|
||||
expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
|
||||
expect(replacedNames).not.toContain("lookup")
|
||||
const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) =>
|
||||
secondaryRegistry.materialize(),
|
||||
secondaryRegistry.snapshot(),
|
||||
).pipe(Effect.provide(secondary))
|
||||
const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name)
|
||||
expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
|
||||
expect(secondaryNames).not.toContain("lookup")
|
||||
const routed = yield* replaced
|
||||
.settle({
|
||||
.execute({
|
||||
sessionID: SessionV2.ID.make("ses_simulated_tools"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_simulated_tools"),
|
||||
|
|
@ -636,9 +641,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
}),
|
||||
)
|
||||
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } })
|
||||
expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" })
|
||||
expect(yield* Fiber.join(routed)).toMatchObject({
|
||||
status: "completed",
|
||||
output: "routed",
|
||||
content: [{ type: "text", text: "routed" }],
|
||||
})
|
||||
expect(
|
||||
yield* materialized.settle({
|
||||
yield* toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_simulated_tools"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_simulated_tools"),
|
||||
|
|
@ -650,7 +659,8 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
|||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "error", value: expect.stringContaining("no longer active") },
|
||||
status: "error",
|
||||
error: { message: expect.stringContaining("no longer active") },
|
||||
})
|
||||
expect(activations).toBe(2)
|
||||
}).pipe(Effect.provide(primary))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue