refactor(plugin): return tool output directly
This commit is contained in:
parent
8f4b62eb49
commit
8f7c6c33d9
21 changed files with 235 additions and 172 deletions
|
|
@ -4,7 +4,7 @@ This folder owns Core's one local tool representation, process and Location regi
|
|||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type.
|
||||
- `tool.ts` re-exports the opaque canonical `Tool.make({ description, input, output, execute })` value. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ Do not add a second executable entry type, registry-owned executor, authorizatio
|
|||
|
||||
## Construction
|
||||
|
||||
Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
|
||||
Tool schemas use `input` and `output` terminology. Executors return `{ structured, content }` directly. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
|
||||
|
||||
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ Definition filtering is catalog visibility, not execution authorization. A call
|
|||
|
||||
## Output
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete structured output and model content together. `ToolRegistry.Materialization.settle` validates and encodes the structured value, then applies generic model-output bounding and owns managed retention paths.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ export const Plugin = {
|
|||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string) => {
|
||||
|
|
@ -184,7 +183,13 @@ export const Plugin = {
|
|||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
}).pipe(
|
||||
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured) }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
|
|
|
|||
|
|
@ -101,9 +101,6 @@ export const Plugin = {
|
|||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
|
|
@ -204,7 +201,12 @@ export const Plugin = {
|
|||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
}).pipe(
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured, input.oldString, input.newString) }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
|
|
|
|||
|
|
@ -49,14 +49,6 @@ export const Plugin = {
|
|||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
|
|
@ -102,6 +94,20 @@ export const Plugin = {
|
|||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
||||
),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
structured.map((entry) => ({
|
||||
...entry,
|
||||
path: path.resolve(location.directory, entry.path),
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,17 +63,6 @@ export const Plugin = {
|
|||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
|
|
@ -133,6 +122,20 @@ export const Plugin = {
|
|||
? error
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
||||
),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
structured.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -54,9 +54,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
|
|
@ -77,7 +74,10 @@ export const Plugin = {
|
|||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
Effect.map((answers) => ({
|
||||
structured: { answers },
|
||||
content: [{ type: "text", text: toModelOutput(input.questions, answers) }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,14 +48,6 @@ export const Plugin = {
|
|||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
||||
return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
]
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -106,7 +98,9 @@ export const Plugin = {
|
|||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root)
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
|
|
@ -132,6 +126,23 @@ export const Plugin = {
|
|||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content:
|
||||
"encoding" in structured &&
|
||||
structured.encoding === "base64" &&
|
||||
SUPPORTED_IMAGE_MIMES.has(structured.mime)
|
||||
? [
|
||||
{ type: "text" as const, text: "Image read successfully" },
|
||||
{
|
||||
type: "file" as const,
|
||||
data: structured.content,
|
||||
mime: structured.mime,
|
||||
name: input.path,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -45,21 +45,17 @@ const StructuredOutput = Schema.Struct({
|
|||
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
...StructuredOutput.fields,
|
||||
output: Schema.String,
|
||||
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
type Result = typeof StructuredOutput.Type & {
|
||||
readonly output: string
|
||||
readonly status?: "completed" | "running"
|
||||
readonly warnings?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
const modelOutput = (output: Result): string | undefined => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.status === "running")
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
|
@ -144,20 +140,7 @@ export const Plugin = {
|
|||
[name]: Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
truncated: output.truncated,
|
||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
|
||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||
}),
|
||||
toModelOutput: ({ output }) => {
|
||||
const parts: Content[] = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) parts.push({ type: "text", text: model })
|
||||
return parts
|
||||
},
|
||||
output: StructuredOutput,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -247,9 +230,9 @@ export const Plugin = {
|
|||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const result = yield* runtime.job
|
||||
.block({ id: job.id, sessionID: context.sessionID })
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
|
|
@ -260,14 +243,26 @@ export const Plugin = {
|
|||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return {
|
||||
...(yield* settleShell()),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
|
||||
Effect.map((result) => {
|
||||
const content: Content[] = [{ type: "text", text: result.output }]
|
||||
const model = modelOutput(result)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
structured: result,
|
||||
content,
|
||||
}
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
|
|
@ -87,11 +86,15 @@ export const Plugin = {
|
|||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
const structured = {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
return {
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: structured.output }],
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -92,13 +92,17 @@ export const Plugin = {
|
|||
)
|
||||
})
|
||||
|
||||
const output = (structured: typeof Output.Type) => ({
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: structured.output }],
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* runtime.session
|
||||
|
|
@ -146,7 +150,7 @@ export const Plugin = {
|
|||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
|
|
@ -158,12 +162,16 @@ export const Plugin = {
|
|||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
return output({
|
||||
sessionID: child.id,
|
||||
status: "completed",
|
||||
output: result?.info.output ?? NO_TEXT,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ export const Plugin = {
|
|||
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
|
|
@ -45,7 +44,11 @@ export const Plugin = {
|
|||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
const structured = { todos: input.todos }
|
||||
return {
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: toModelOutput(structured) }],
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
|
|
@ -170,7 +169,13 @@ export const Plugin = {
|
|||
format: input.format,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: structured.output }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -200,7 +200,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -243,7 +242,13 @@ export const Plugin = {
|
|||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: structured.text }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ export const Plugin = {
|
|||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -83,7 +82,13 @@ export const Plugin = {
|
|||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured) }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ describe("PluginV2", () => {
|
|||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
|
|
@ -146,7 +146,8 @@ describe("PluginV2", () => {
|
|||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ structured: { text }, content: [] })),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -46,8 +46,7 @@ const make = (permission?: string) => {
|
|||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }) => Effect.succeed({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||
})
|
||||
return permission ? Tool.withPermission(tool, permission) : tool
|
||||
}
|
||||
|
|
@ -244,7 +243,8 @@ describe("ToolRegistry", () => {
|
|||
description: "Context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
execute: (_, context) =>
|
||||
Effect.sync(() => contexts.push(context)).pipe(Effect.as({ structured: { ok: true }, content: [] })),
|
||||
}),
|
||||
})
|
||||
yield* executeTool(service, {
|
||||
|
|
@ -276,7 +276,7 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
|
||||
it.effect("validates structured output while preserving executor-provided model content", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
|
|
@ -291,18 +291,23 @@ describe("ToolRegistry", () => {
|
|||
description: "Transform values",
|
||||
input: Schema.Struct({ value: Transformed }),
|
||||
output: Schema.Struct({ value: Transformed }),
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
execute: ({ value }) =>
|
||||
Effect.sync(() => executed.push(value)).pipe(
|
||||
Effect.as({ structured: { value }, content: [{ type: "text" as const, text: value }] }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: "true" })
|
||||
).toEqual({
|
||||
result: { type: "text", value: "yes" },
|
||||
output: { structured: { value: true }, content: [{ type: "text", text: "yes" }] },
|
||||
})
|
||||
expect(executed).toEqual(["yes"])
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
|
|
@ -329,7 +334,7 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
execute: () => Effect.succeed({ structured: { value: "invalid" }, content: [] }),
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
|
|
@ -411,8 +416,10 @@ describe("ToolRegistry", () => {
|
|||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@ const echo = Layer.effectDiscard(
|
|||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }, context) =>
|
||||
Effect.gen(function* () {
|
||||
authorizations.push(context)
|
||||
|
|
@ -146,7 +145,7 @@ const echo = Layer.effectDiscard(
|
|||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
return { text }
|
||||
return { structured: { text }, content: [{ type: "text" as const, text }] }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: Tool.make({
|
||||
|
|
@ -161,7 +160,7 @@ const echo = Layer.effectDiscard(
|
|||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
execute: () => Effect.succeed({ structured: { big: 1n }, content: [] }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
|
@ -608,7 +607,7 @@ describe("SessionRunnerLLM", () => {
|
|||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
return { answer: query.toUpperCase() }
|
||||
return { structured: { answer: query.toUpperCase() }, content: [] }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
|
@ -2834,7 +2833,9 @@ describe("SessionRunnerLLM", () => {
|
|||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
questions
|
||||
.ask({ sessionID: context.sessionID, questions: [] })
|
||||
.pipe(Effect.as({ structured: {}, content: [] }), Effect.orDie),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue