Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b213571f | |||
| 3841955fbc | |||
| 5735a14188 | |||
| 3e583a3f5a |
@@ -254,6 +254,7 @@ export const layer = Layer.effect(
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
progress: (output) => publication.withPermit(publisher.progressTool(event.id, output)),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
|
||||
@@ -43,11 +43,13 @@ type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
||||
|
||||
const outputState = (value: ToolOutput) => ({ structured: record(value.structured), content: value.content })
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
return outputState(settled)
|
||||
}
|
||||
|
||||
/** Persist one provider turn without executing tools or starting a continuation turn. */
|
||||
@@ -236,6 +238,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
|
||||
}
|
||||
|
||||
const progressTool = Effect.fn("SessionRunner.progressTool")(function* (callID: string, output: ToolOutput) {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called) return yield* Effect.die(`Tool progress before call: ${callID}`)
|
||||
if (tool.settled) return yield* Effect.void
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
...outputState(output),
|
||||
})
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
@@ -419,5 +434,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
progressTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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) => {
|
||||
@@ -183,7 +182,8 @@ export const layer = Layer.effectDiscard(
|
||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
const output = { applied, files: patchFiles }
|
||||
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -101,9 +101,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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(
|
||||
@@ -193,7 +190,7 @@ export const layer = Layer.effectDiscard(
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
const output = {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
@@ -204,6 +201,10 @@ export const layer = Layer.effectDiscard(
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
return Tool.result({
|
||||
output,
|
||||
content: [{ type: "text", text: toModelOutput(output, input.oldString, input.newString) }],
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -47,14 +47,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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({
|
||||
@@ -86,6 +78,19 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.map((output) =>
|
||||
Tool.result({
|
||||
output,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||
|
||||
@@ -63,17 +63,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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({
|
||||
@@ -120,6 +109,22 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.map((output) =>
|
||||
Tool.result({
|
||||
output,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
|
||||
}),
|
||||
|
||||
@@ -56,15 +56,18 @@ export const layer = Layer.effectDiscard(
|
||||
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
const result = yield* mcp
|
||||
.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> })
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
||||
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
|
||||
return Tool.result({ output: result.structured ?? {}, content: result.content.map(toContent) })
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -88,9 +91,10 @@ export const layer = Layer.effectDiscard(
|
||||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* events
|
||||
.subscribe(McpEvent.ToolsChanged)
|
||||
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ export const layer = Layer.effectDiscard(
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
@@ -77,7 +74,12 @@ export const layer = Layer.effectDiscard(
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
Effect.map((answers) =>
|
||||
Tool.result({
|
||||
output: { answers },
|
||||
content: [{ type: "text", text: toModelOutput(input.questions, answers) }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -40,14 +40,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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 = {
|
||||
@@ -82,9 +74,16 @@ export const layer = Layer.effectDiscard(
|
||||
limit: input.limit,
|
||||
})
|
||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
return yield* image
|
||||
const output = yield* image
|
||||
.normalize(resource, { ...content, encoding: "base64" })
|
||||
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
||||
return Tool.result({
|
||||
output,
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
],
|
||||
})
|
||||
}
|
||||
if ("encoding" in content && content.encoding === "base64")
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ExecuteInput = {
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (output: ToolOutput) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -61,11 +62,21 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const emitProgress = input.progress
|
||||
const pending = yield* settle(registration.tool, input.call, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
...(emitProgress
|
||||
? {
|
||||
progress: (output: ToolOutput) =>
|
||||
resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output }).pipe(
|
||||
Effect.flatMap((bounded) => emitProgress(bounded.output)),
|
||||
Effect.ignore,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Shell } from "../shell"
|
||||
import { Tool, type Content } from "./tool"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -42,24 +42,23 @@ const StructuredOutput = Schema.Struct({
|
||||
shellID: Schema.String.pipe(Schema.optional),
|
||||
truncated: Schema.Boolean,
|
||||
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
const Output = StructuredOutput
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
const modelOutput = (output: Output, warnings: ReadonlyArray<string> = []): string | undefined => {
|
||||
if (output.status === "running") return undefined
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
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}.`
|
||||
const warningText = warnings.length ? `\n\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}` : ""
|
||||
if (output.timeout)
|
||||
return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
const content = (body: string, output: Output, warnings: ReadonlyArray<string> = []) => {
|
||||
const status = modelOutput(output, warnings)
|
||||
return [{ type: "text" as const, text: body }, ...(status ? [{ type: "text" as const, text: status }] : [])]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +70,7 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
||||
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
|
||||
// TODO: Stream shell progress checkpoints without persisting every stdout/stderr chunk.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
@@ -126,7 +125,7 @@ export const Plugin = {
|
||||
: "Command cancelled"
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
text: `Shell command ${state}.\n\nCommand:\n${command}\n\n${state === "completed" ? "Output" : "Details"}:\n${text}`,
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -139,19 +138,6 @@ export const Plugin = {
|
||||
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
|
||||
},
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -217,13 +203,12 @@ export const Plugin = {
|
||||
})
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
const output = {
|
||||
shellID: background.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
return Tool.result({ output, content: content(BACKGROUND_STARTED, output, warnings) })
|
||||
}
|
||||
|
||||
const info = yield* shell.create({
|
||||
@@ -236,26 +221,25 @@ export const Plugin = {
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
const body = `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
const output = {
|
||||
exit: final.exit,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
return Tool.result({ output, content: content(body, output, warnings) })
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return {
|
||||
const output = {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
return Tool.result({ output, content: content(`${body}${notice}`, output, warnings) })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ export const Input = Schema.Struct({
|
||||
export const Output = Schema.Struct({
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
@@ -64,7 +63,6 @@ export const layer = Layer.effectDiscard(
|
||||
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 +85,11 @@ export const layer = Layer.effectDiscard(
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
const output = toModelOutput(skill, files)
|
||||
return Tool.result({
|
||||
output: { name: skill.name, directory },
|
||||
content: [{ type: "text", text: output }],
|
||||
})
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,6 @@ export const Input = Schema.Struct({
|
||||
export const Output = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
@@ -98,7 +97,6 @@ export const Plugin = {
|
||||
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 +144,10 @@ 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 Tool.result({
|
||||
output: { sessionID: child.id, status: "running" as const },
|
||||
content: [{ type: "text", text: BACKGROUND_STARTED }],
|
||||
})
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
@@ -158,12 +159,18 @@ 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 Tool.result({
|
||||
output: { sessionID: child.id, status: "running" as const },
|
||||
content: [{ type: "text", text: 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 Tool.result({
|
||||
output: { sessionID: child.id, status: "completed" as const },
|
||||
content: [{ type: "text", text: result?.info.output ?? NO_TEXT }],
|
||||
})
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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,8 @@ export const layer = Layer.effectDiscard(
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
const output = { todos: input.todos }
|
||||
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -35,7 +35,6 @@ const Output = Schema.Struct({
|
||||
url: Schema.String,
|
||||
contentType: Schema.String,
|
||||
format: Input.fields.format,
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
type Format = (typeof Input.Type)["format"]
|
||||
@@ -124,7 +123,6 @@ export const layer = Layer.effectDiscard(
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
@@ -164,12 +162,12 @@ export const layer = Layer.effectDiscard(
|
||||
try: () => convert(content, contentType, input.format),
|
||||
catch: (error) => error,
|
||||
})
|
||||
return {
|
||||
const result = {
|
||||
url: input.url,
|
||||
contentType,
|
||||
format: input.format,
|
||||
output,
|
||||
}
|
||||
return Tool.result({ output: result, content: [{ type: "text", text: output }] })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -181,7 +181,6 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
@@ -197,7 +196,6 @@ export const layer = Layer.effectDiscard(
|
||||
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* () {
|
||||
@@ -236,10 +234,7 @@ export const layer = Layer.effectDiscard(
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
return Tool.result({ output: { provider }, content: [{ type: "text", text: text ?? NO_RESULTS }] })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -57,7 +57,6 @@ export const layer = Layer.effectDiscard(
|
||||
"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 = {
|
||||
@@ -82,7 +81,8 @@ export const layer = Layer.effectDiscard(
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const output = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}),
|
||||
"edit",
|
||||
|
||||
@@ -45,8 +45,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(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
|
||||
})
|
||||
return permission ? Tool.withPermission(tool, permission) : tool
|
||||
}
|
||||
@@ -237,13 +236,21 @@ describe("ToolRegistry", () => {
|
||||
it.effect("passes complete invocation identity to the canonical handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const contexts: Array<Omit<Tool.Context, "progress">> = []
|
||||
yield* service.register({
|
||||
context: Tool.make({
|
||||
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({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
toolCallID: context.toolCallID,
|
||||
})
|
||||
}).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
})
|
||||
yield* executeTool(service, {
|
||||
@@ -255,6 +262,62 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits bounded progress snapshots from the tool context", () =>
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const service = yield* ToolRegistry.Service
|
||||
const progress: unknown[] = []
|
||||
yield* service.register({
|
||||
progress: Tool.make({
|
||||
description: "Progress",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: (_, context) =>
|
||||
context
|
||||
.progress({ output: { text: "loading" }, content: [{ type: "text", text: "loading" }] })
|
||||
.pipe(Effect.as(Tool.result({ output: { text: "done" }, content: [{ type: "text", text: "done" }] }))),
|
||||
}),
|
||||
})
|
||||
const settled = yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-progress", name: "progress", input: {} },
|
||||
progress: (output) => Effect.sync(() => progress.push(output)),
|
||||
})
|
||||
|
||||
expect(progress).toEqual([{ structured: { text: "loading" }, content: [{ type: "text", text: "loading" }] }])
|
||||
expect(bounds.map((input) => input.toolCallID)).toEqual(["call-progress", "call-progress"])
|
||||
expect(settled).toMatchObject({
|
||||
result: { type: "text", value: "done" },
|
||||
output: { structured: { text: "done" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not treat plain output-content objects as result envelopes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
literal: Tool.make({
|
||||
description: "Literal output object",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ output: Schema.String, content: Schema.Array(Schema.String) }),
|
||||
execute: () => Effect.succeed({ output: "value", content: ["not model content"] }),
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-literal", name: "literal", input: {} },
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "json", value: { output: "value", content: ["not model content"] } },
|
||||
output: { structured: { output: "value", content: ["not model content"] }, content: [] },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("encodes output and applies generic settlement bounding", () =>
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
@@ -290,8 +353,10 @@ 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(Tool.result({ output: { value }, content: [{ type: "text", text: String(value === "yes") }] })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -410,8 +475,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(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
@@ -110,7 +110,7 @@ const recoveryModel = Model.make({
|
||||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
|
||||
})
|
||||
const authorizations: Tool.Context[] = []
|
||||
const authorizations: Array<Omit<Tool.Context, "progress">> = []
|
||||
const executions: string[] = []
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
@@ -132,10 +132,14 @@ 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)
|
||||
authorizations.push({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
toolCallID: context.toolCallID,
|
||||
})
|
||||
executions.push(text)
|
||||
activeToolExecutions++
|
||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||
@@ -143,7 +147,7 @@ const echo = Layer.effectDiscard(
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
return { text }
|
||||
return Tool.result({ output: { text }, content: [{ type: "text", text }] })
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: Tool.make({
|
||||
@@ -580,7 +584,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const contexts: Array<Omit<Tool.Context, "progress">> = []
|
||||
yield* registry.register({
|
||||
location_context: Tool.make({
|
||||
description: "Read application context",
|
||||
@@ -588,7 +592,12 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
contexts.push({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
toolCallID: context.toolCallID,
|
||||
})
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -435,7 +435,7 @@ test("keeps locked deferred parity TODOs visible", async () => {
|
||||
"Replace token-based command-argument external-directory advisories with parser-based detection.",
|
||||
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
|
||||
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
|
||||
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
|
||||
"Stream shell progress checkpoints without persisting every stdout/stderr chunk.",
|
||||
"Persist job status and define restart recovery before exposing remote observation.",
|
||||
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
|
||||
"Revisit binary output handling if stdout/stderr decoding is text-only.",
|
||||
|
||||
@@ -190,7 +190,8 @@ describe("SubagentTool", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
||||
expect(settled.output?.structured).toMatchObject({ status: "completed" })
|
||||
expect(settled.output?.content).toEqual([{ type: "text", text: childText }])
|
||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("WebFetchTool registration", () => {
|
||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
result: { type: "text", value: "hello" },
|
||||
output: {
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
structured: { url, contentType: "text/plain", format: "text" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("WebSearchTool registration", () => {
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results" },
|
||||
structured: { provider: "parallel" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,11 +6,12 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
||||
|
||||
export interface Context {
|
||||
export interface Context<Output = unknown> {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
readonly progress: (state: State<Output>) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any>
|
||||
@@ -28,6 +29,33 @@ export type AnyTool = Definition<any, any>
|
||||
export const Failure = ToolFailure
|
||||
export type Failure = ToolFailure
|
||||
|
||||
const ResultTypeId = Symbol("@opencode-ai/plugin/Tool.Result")
|
||||
|
||||
export interface State<Output> {
|
||||
readonly output: Output
|
||||
readonly content?: ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
export interface Result<Output> extends State<Output> {
|
||||
readonly [ResultTypeId]: true
|
||||
}
|
||||
|
||||
class ResultValue<Output> implements Result<Output> {
|
||||
readonly output: Output
|
||||
readonly content?: ReadonlyArray<Content>
|
||||
|
||||
get [ResultTypeId]() {
|
||||
return true as const
|
||||
}
|
||||
|
||||
constructor(state: State<Output>) {
|
||||
this.output = state.output
|
||||
this.content = state.content
|
||||
}
|
||||
}
|
||||
|
||||
export const result = <Output>(state: State<Output>): Result<Output> => Object.freeze(new ResultValue(state))
|
||||
|
||||
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
@@ -37,72 +65,69 @@ export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||
|
||||
type Config<
|
||||
Input extends SchemaType<any>,
|
||||
Output extends SchemaType<any>,
|
||||
Structured extends SchemaType<any> = Output,
|
||||
> = {
|
||||
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
||||
readonly description: string
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
readonly structured?: Structured
|
||||
readonly toStructuredOutput?: (input: {
|
||||
readonly input: Schema.Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => Schema.Schema.Type<Structured>
|
||||
readonly execute: (
|
||||
input: Schema.Schema.Type<Input>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly input: Schema.Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
export type DynamicOutput = {
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<Content>
|
||||
context: Context<Schema.Schema.Type<Output>>,
|
||||
) => Effect.Effect<Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>, ToolFailure>
|
||||
}
|
||||
|
||||
/**
|
||||
* Config for a tool whose input shape is a raw JSON Schema not known at compile
|
||||
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
|
||||
* `execute` returns the already-projected structured value and model content.
|
||||
* Return `Tool.result(...)` when the output needs explicit model content.
|
||||
*/
|
||||
type DynamicConfig = {
|
||||
readonly description: string
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure>
|
||||
readonly execute: (input: unknown, context: Context) => Effect.Effect<unknown, ToolFailure>
|
||||
}
|
||||
|
||||
export interface RuntimeContext extends Omit<Context, "progress"> {
|
||||
readonly progress?: (output: ToolOutput) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly permission?: string
|
||||
readonly definition: (name: string) => ToolDefinition
|
||||
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
|
||||
readonly settle: (call: ToolCall, context: RuntimeContext) => Effect.Effect<ToolOutput, ToolFailure>
|
||||
}
|
||||
|
||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||
|
||||
export function make<
|
||||
Input extends SchemaType<any>,
|
||||
Output extends SchemaType<any>,
|
||||
Structured extends SchemaType<any> = Output,
|
||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
|
||||
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
config: Config<Input, Output>,
|
||||
): Definition<Input, Output>
|
||||
export function make(config: DynamicConfig): AnyTool
|
||||
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
|
||||
export function make(config: Config<any, any> | DynamicConfig): AnyTool {
|
||||
if ("jsonSchema" in config) return makeDynamic(config)
|
||||
return makeTyped(config)
|
||||
}
|
||||
|
||||
function makeTyped<
|
||||
Input extends SchemaType<any>,
|
||||
Output extends SchemaType<any>,
|
||||
Structured extends SchemaType<any> = Output,
|
||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
|
||||
const tool = Object.freeze({}) as Definition<Input, Structured>
|
||||
function makeTyped<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
config: Config<Input, Output>,
|
||||
): Definition<Input, Output> {
|
||||
const tool = Object.freeze({}) as Definition<Input, Output>
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
|
||||
const projectState = (state: State<Schema.Schema.Type<Output>>): Effect.Effect<ToolOutput, ToolFailure> =>
|
||||
Schema.encodeEffect(config.output)(state.output).pipe(
|
||||
Effect.map((output) => ToolOutput.make(output, contentOf(output, state.content))),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its output schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const project = (value: Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>) =>
|
||||
projectState(stateOf(value))
|
||||
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
const cached = definitions.get(name)
|
||||
@@ -111,7 +136,7 @@ function makeTyped<
|
||||
name,
|
||||
description: config.description,
|
||||
inputSchema: toJsonSchema(config.input),
|
||||
outputSchema: toJsonSchema(config.structured ?? config.output),
|
||||
outputSchema: toJsonSchema(config.output),
|
||||
})
|
||||
definitions.set(name, definition)
|
||||
return definition
|
||||
@@ -120,31 +145,15 @@ function makeTyped<
|
||||
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((input) =>
|
||||
config.execute(input, context).pipe(
|
||||
Effect.flatMap((output) =>
|
||||
Schema.encodeEffect(config.output)(output).pipe(
|
||||
Effect.flatMap((output) => {
|
||||
if (!config.structured || !config.toStructuredOutput)
|
||||
return Effect.succeed({ output, structured: output })
|
||||
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
|
||||
Effect.map((structured) => ({ output, structured })),
|
||||
)
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its output schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(({ output, structured }) => ({
|
||||
structured,
|
||||
content:
|
||||
config.toModelOutput?.({ input, output }).map(toModelContent) ??
|
||||
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
|
||||
})),
|
||||
),
|
||||
config
|
||||
.execute(input, {
|
||||
...context,
|
||||
progress: (state) =>
|
||||
context.progress
|
||||
? projectState(state).pipe(Effect.flatMap(context.progress), Effect.ignore)
|
||||
: Effect.void,
|
||||
})
|
||||
.pipe(Effect.flatMap(project)),
|
||||
),
|
||||
),
|
||||
})
|
||||
@@ -154,6 +163,8 @@ function makeTyped<
|
||||
function makeDynamic(config: DynamicConfig): AnyTool {
|
||||
const tool = Object.freeze({}) as AnyTool
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
const projectState = (state: State<unknown>) => ToolOutput.make(state.output, contentOf(state.output, state.content))
|
||||
const project = (value: unknown) => projectState(stateOf(value))
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
const cached = definitions.get(name)
|
||||
@@ -169,12 +180,28 @@ function makeDynamic(config: DynamicConfig): AnyTool {
|
||||
},
|
||||
settle: (call, context) =>
|
||||
config
|
||||
.execute(call.input, context)
|
||||
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
|
||||
.execute(call.input, {
|
||||
...context,
|
||||
progress: (state) => context.progress?.(projectState(state)).pipe(Effect.ignore) ?? Effect.void,
|
||||
})
|
||||
.pipe(Effect.map(project)),
|
||||
})
|
||||
return tool
|
||||
}
|
||||
|
||||
function stateOf<Output>(value: Output | Result<Output>): State<Output> {
|
||||
if (isResult(value)) return value
|
||||
return { output: value }
|
||||
}
|
||||
|
||||
function isResult(value: unknown): value is Result<unknown> {
|
||||
return typeof value === "object" && value !== null && ResultTypeId in value
|
||||
}
|
||||
|
||||
function contentOf(output: unknown, content: ReadonlyArray<Content> | undefined) {
|
||||
return content?.map(toModelContent) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : [])
|
||||
}
|
||||
|
||||
function toModelContent(part: Content) {
|
||||
if (part.type === "text") return { type: "text" as const, text: part.text }
|
||||
return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
|
||||
@@ -199,7 +226,7 @@ export const withPermission = <Input extends SchemaType<any>, Output extends Sch
|
||||
|
||||
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
|
||||
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
|
||||
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
|
||||
export const settle = (tool: AnyTool, call: ToolCall, context: RuntimeContext) => runtimeOf(tool).settle(call, context)
|
||||
|
||||
function runtimeOf(tool: AnyTool) {
|
||||
const runtime = runtimes.get(tool)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Failure, RegistrationError, make } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
export type { AnyTool, Content, Context, Definition } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
export { Failure, RegistrationError, make, result } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
export type { AnyTool, Content, Context, Definition, Result, State } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
|
||||
@@ -118,7 +118,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const subscriptions = [
|
||||
data.on("session.next.prompted", message),
|
||||
data.on("session.next.context.updated", message),
|
||||
data.on("session.next.synthetic", message),
|
||||
data.on("session.next.shell.started", message),
|
||||
data.on("session.next.agent.switched", message),
|
||||
data.on("session.next.model.switched", message),
|
||||
@@ -158,6 +157,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type === "synthetic") return rows
|
||||
if (message.type !== "assistant") {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
|
||||
@@ -62,6 +62,25 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("skips synthetic context messages", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{ type: "user", id: "user-1", text: "Run background shell", time: { created: 0 } },
|
||||
assistant("assistant-1", [{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 1 } }]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "synthetic-1",
|
||||
sessionID: "ses_1",
|
||||
text: "Shell command completed.\n\nOutput:\nok",
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "shell-1" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
|
||||
Reference in New Issue
Block a user