mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b213571f | |||
| 3841955fbc | |||
| 5735a14188 | |||
| 3e583a3f5a |
@@ -254,6 +254,7 @@ export const layer = Layer.effect(
|
|||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
call: event,
|
call: event,
|
||||||
|
progress: (output) => publication.withPermit(publisher.progressTool(event.id, output)),
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.flatMap((settlement) =>
|
Effect.flatMap((settlement) =>
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ type SettledOutput =
|
|||||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
| { 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 => {
|
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
||||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||||
if (!settled) throw new Error(`Unsupported tool result: ${message(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. */
|
/** 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}`)
|
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* (
|
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||||
event: LLMEvent,
|
event: LLMEvent,
|
||||||
outputPaths: ReadonlyArray<string> = [],
|
outputPaths: ReadonlyArray<string> = [],
|
||||||
@@ -419,5 +434,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
stepSettlement: () => stepSettlement,
|
stepSettlement: () => stepSettlement,
|
||||||
startAssistant,
|
startAssistant,
|
||||||
assistantMessageID: assistantMessageIDForTool,
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const fail = (path: string) => {
|
const fail = (path: string) => {
|
||||||
@@ -183,7 +182,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||||
{ discard: true },
|
{ 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"))))
|
}).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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ input, output }) => [
|
|
||||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
|
||||||
],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
@@ -193,7 +190,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
content: joinBom(next.text, source.bom || next.bom),
|
content: joinBom(next.text, source.bom || next.bom),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return {
|
const output = {
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file: result.resource,
|
file: result.resource,
|
||||||
@@ -204,6 +201,10 @@ export const layer = Layer.effectDiscard(
|
|||||||
],
|
],
|
||||||
replacements,
|
replacements,
|
||||||
} satisfies Output
|
} 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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: toModelOutput(
|
|
||||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
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(
|
}).pipe(
|
||||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
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) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
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}` }))),
|
}).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: {} },
|
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
|
||||||
execute: (input) =>
|
execute: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
|
const result = yield* mcp
|
||||||
Effect.catchTags({
|
.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> })
|
||||||
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
.pipe(
|
||||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
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)
|
if (result.isError)
|
||||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
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* reconcile.pipe(Effect.forkScoped)
|
||||||
yield* events
|
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||||
.subscribe(McpEvent.ToolsChanged)
|
Stream.runForEach(() => reconcile),
|
||||||
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ input, output }) => [
|
|
||||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
|
||||||
],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
permission
|
permission
|
||||||
.assert({
|
.assert({
|
||||||
@@ -77,7 +74,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
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) => {
|
execute: (input, context) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -82,9 +74,16 @@ export const layer = Layer.effectDiscard(
|
|||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
})
|
})
|
||||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
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" })
|
.normalize(resource, { ...content, encoding: "base64" })
|
||||||
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
.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")
|
if ("encoding" in content && content.encoding === "base64")
|
||||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type ExecuteInput = {
|
|||||||
readonly agent: AgentV2.ID
|
readonly agent: AgentV2.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly call: ToolCall
|
readonly call: ToolCall
|
||||||
|
readonly progress?: (output: ToolOutput) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -61,11 +62,21 @@ const registryLayer = Layer.effect(
|
|||||||
}
|
}
|
||||||
if (advertised && registration.identity !== advertised)
|
if (advertised && registration.identity !== advertised)
|
||||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
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, {
|
const pending = yield* settle(registration.tool, input.call, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
assistantMessageID: input.assistantMessageID,
|
assistantMessageID: input.assistantMessageID,
|
||||||
toolCallID: input.call.id,
|
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(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output })),
|
Effect.map((output) => ({ output })),
|
||||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { PluginRuntime } from "../plugin/runtime"
|
|||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
import { Tool, type Content } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
@@ -42,24 +42,23 @@ const StructuredOutput = Schema.Struct({
|
|||||||
shellID: Schema.String.pipe(Schema.optional),
|
shellID: Schema.String.pipe(Schema.optional),
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
timeout: Schema.Boolean.pipe(Schema.optional),
|
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
status: Schema.Literals(["completed", "running"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Output = Schema.Struct({
|
const Output = StructuredOutput
|
||||||
...StructuredOutput.fields,
|
|
||||||
output: Schema.String,
|
|
||||||
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
|
|
||||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
|
|
||||||
type Output = typeof Output.Type
|
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
|
if (output.status === "running") return undefined
|
||||||
const warnings = output.warnings?.length
|
const warningText = warnings.length ? `\n\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}` : ""
|
||||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
if (output.timeout)
|
||||||
: ""
|
return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command timed out before completion.`
|
||||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||||
return `${warnings.trimStart()}${warnings ? "\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: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
// 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 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: 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: 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.
|
// 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"
|
: "Command cancelled"
|
||||||
return runtime.session.synthetic({
|
return runtime.session.synthetic({
|
||||||
sessionID,
|
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 }),
|
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.`,
|
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,
|
input: Input,
|
||||||
output: Output,
|
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) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -217,13 +203,12 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
yield* runtime.job.background(info.id)
|
yield* runtime.job.background(info.id)
|
||||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||||
return {
|
const output = {
|
||||||
output: BACKGROUND_STARTED,
|
|
||||||
shellID: background.id,
|
shellID: background.id,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
status: "running" as const,
|
status: "running" as const,
|
||||||
...(warnings.length ? { warnings } : {}),
|
|
||||||
}
|
}
|
||||||
|
return Tool.result({ output, content: content(BACKGROUND_STARTED, output, warnings) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* shell.create({
|
const info = yield* shell.create({
|
||||||
@@ -236,26 +221,25 @@ export const Plugin = {
|
|||||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||||
|
|
||||||
if (final.status === "timeout") {
|
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,
|
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,
|
truncated: false,
|
||||||
timeout: true,
|
timeout: true,
|
||||||
status: "completed" as const,
|
status: "completed" as const,
|
||||||
...(warnings.length ? { warnings } : {}),
|
|
||||||
}
|
}
|
||||||
|
return Tool.result({ output, content: content(body, output, warnings) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncated = page.size > page.cursor
|
const truncated = page.size > page.cursor
|
||||||
const body = page.output || "(no output)"
|
const body = page.output || "(no output)"
|
||||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||||
return {
|
const output = {
|
||||||
exit: final.exit,
|
exit: final.exit,
|
||||||
output: `${body}${notice}`,
|
|
||||||
truncated,
|
truncated,
|
||||||
status: "completed" as const,
|
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}` }))),
|
}).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({
|
export const Output = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
directory: Schema.String,
|
directory: Schema.String,
|
||||||
output: Schema.String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const description = [
|
export const description = [
|
||||||
@@ -64,7 +63,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* skills.list()
|
const current = yield* skills.list()
|
||||||
@@ -87,11 +85,11 @@ export const layer = Layer.effectDiscard(
|
|||||||
.toSorted()
|
.toSorted()
|
||||||
.slice(0, FILE_LIMIT)
|
.slice(0, FILE_LIMIT)
|
||||||
: []
|
: []
|
||||||
return {
|
const output = toModelOutput(skill, files)
|
||||||
name: skill.name,
|
return Tool.result({
|
||||||
directory,
|
output: { name: skill.name, directory },
|
||||||
output: toModelOutput(skill, files),
|
content: [{ type: "text", text: output }],
|
||||||
}
|
})
|
||||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const Input = Schema.Struct({
|
|||||||
export const Output = Schema.Struct({
|
export const Output = Schema.Struct({
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
status: Schema.Literals(["completed", "running"]),
|
status: Schema.Literals(["completed", "running"]),
|
||||||
output: Schema.String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const description = [
|
export const description = [
|
||||||
@@ -98,7 +97,6 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parent = yield* runtime.session
|
const parent = yield* runtime.session
|
||||||
@@ -146,7 +144,10 @@ export const Plugin = {
|
|||||||
if (background) {
|
if (background) {
|
||||||
yield* runtime.job.background(info.id)
|
yield* runtime.job.background(info.id)
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
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(
|
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||||
@@ -158,12 +159,18 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
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")
|
if (result?.info.status === "error")
|
||||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -45,7 +44,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
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" }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const Output = Schema.Struct({
|
|||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
contentType: Schema.String,
|
contentType: Schema.String,
|
||||||
format: Input.fields.format,
|
format: Input.fields.format,
|
||||||
output: Schema.String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type Format = (typeof Input.Type)["format"]
|
type Format = (typeof Input.Type)["format"]
|
||||||
@@ -124,7 +123,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.try({
|
yield* Effect.try({
|
||||||
@@ -164,12 +162,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
try: () => convert(content, contentType, input.format),
|
try: () => convert(content, contentType, input.format),
|
||||||
catch: (error) => error,
|
catch: (error) => error,
|
||||||
})
|
})
|
||||||
return {
|
const result = {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
contentType,
|
contentType,
|
||||||
format: input.format,
|
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}` }))),
|
}).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({
|
const Output = Schema.Struct({
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
text: Schema.String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
@@ -197,7 +196,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -236,10 +234,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return {
|
return Tool.result({ output: { provider }, content: [{ type: "text", text: text ?? NO_RESULTS }] })
|
||||||
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}` })))
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -82,7 +81,8 @@ export const layer = Layer.effectDiscard(
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
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}` }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||||
}),
|
}),
|
||||||
"edit",
|
"edit",
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ const make = (permission?: string) => {
|
|||||||
description: "Echo text",
|
description: "Echo text",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) => Effect.succeed({ text }),
|
execute: ({ text }) => Effect.succeed(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
})
|
})
|
||||||
return permission ? Tool.withPermission(tool, permission) : tool
|
return permission ? Tool.withPermission(tool, permission) : tool
|
||||||
}
|
}
|
||||||
@@ -237,13 +236,21 @@ describe("ToolRegistry", () => {
|
|||||||
it.effect("passes complete invocation identity to the canonical handler", () =>
|
it.effect("passes complete invocation identity to the canonical handler", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
const contexts: Tool.Context[] = []
|
const contexts: Array<Omit<Tool.Context, "progress">> = []
|
||||||
yield* service.register({
|
yield* service.register({
|
||||||
context: Tool.make({
|
context: Tool.make({
|
||||||
description: "Context",
|
description: "Context",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
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, {
|
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", () =>
|
it.effect("encodes output and applies generic settlement bounding", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
bounds.length = 0
|
bounds.length = 0
|
||||||
@@ -290,8 +353,10 @@ describe("ToolRegistry", () => {
|
|||||||
description: "Transform values",
|
description: "Transform values",
|
||||||
input: Schema.Struct({ value: Transformed }),
|
input: Schema.Struct({ value: Transformed }),
|
||||||
output: Schema.Struct({ value: Transformed }),
|
output: Schema.Struct({ value: Transformed }),
|
||||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
execute: ({ value }) =>
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.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 }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) =>
|
execute: ({ text }) =>
|
||||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
Deferred.succeed(started, undefined).pipe(
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.as(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Scope.provide(scope))
|
.pipe(Scope.provide(scope))
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const recoveryModel = Model.make({
|
|||||||
provider: "fake",
|
provider: "fake",
|
||||||
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
|
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 executions: string[] = []
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
PermissionV2.Service,
|
PermissionV2.Service,
|
||||||
@@ -132,10 +132,14 @@ const echo = Layer.effectDiscard(
|
|||||||
description: "Echo text",
|
description: "Echo text",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
execute: ({ text }, context) =>
|
execute: ({ text }, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
authorizations.push(context)
|
authorizations.push({
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
assistantMessageID: context.assistantMessageID,
|
||||||
|
toolCallID: context.toolCallID,
|
||||||
|
})
|
||||||
executions.push(text)
|
executions.push(text)
|
||||||
activeToolExecutions++
|
activeToolExecutions++
|
||||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||||
@@ -143,7 +147,7 @@ const echo = Layer.effectDiscard(
|
|||||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||||
}
|
}
|
||||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||||
return { text }
|
return Tool.result({ output: { text }, content: [{ type: "text", text }] })
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||||
}),
|
}),
|
||||||
defect: Tool.make({
|
defect: Tool.make({
|
||||||
@@ -580,7 +584,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* setup
|
yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const contexts: Tool.Context[] = []
|
const contexts: Array<Omit<Tool.Context, "progress">> = []
|
||||||
yield* registry.register({
|
yield* registry.register({
|
||||||
location_context: Tool.make({
|
location_context: Tool.make({
|
||||||
description: "Read application context",
|
description: "Read application context",
|
||||||
@@ -588,7 +592,12 @@ describe("SessionRunnerLLM", () => {
|
|||||||
output: Schema.Struct({ answer: Schema.String }),
|
output: Schema.Struct({ answer: Schema.String }),
|
||||||
execute: ({ query }, context) =>
|
execute: ({ query }, context) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
contexts.push(context)
|
contexts.push({
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
assistantMessageID: context.assistantMessageID,
|
||||||
|
toolCallID: context.toolCallID,
|
||||||
|
})
|
||||||
return { answer: query.toUpperCase() }
|
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.",
|
"Replace token-based command-argument external-directory advisories with parser-based detection.",
|
||||||
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
|
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
|
||||||
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
|
"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.",
|
"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 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.",
|
"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))
|
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||||
expect(child).toMatchObject({
|
expect(child).toMatchObject({
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe("WebFetchTool registration", () => {
|
|||||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||||
result: { type: "text", value: "hello" },
|
result: { type: "text", value: "hello" },
|
||||||
output: {
|
output: {
|
||||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
structured: { url, contentType: "text/plain", format: "text" },
|
||||||
content: [{ type: "text", text: "hello" }],
|
content: [{ type: "text", text: "hello" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
expect(settled).toEqual({
|
expect(settled).toEqual({
|
||||||
result: { type: "text", value: "parallel results" },
|
result: { type: "text", value: "parallel results" },
|
||||||
output: {
|
output: {
|
||||||
structured: { provider: "parallel", text: "parallel results" },
|
structured: { provider: "parallel" },
|
||||||
content: [{ type: "text", text: "parallel results" }],
|
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 { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
||||||
|
|
||||||
export interface Context {
|
export interface Context<Output = unknown> {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly toolCallID: string
|
readonly toolCallID: string
|
||||||
|
readonly progress: (state: State<Output>) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SchemaType<A> = Schema.Codec<A, any>
|
export type SchemaType<A> = Schema.Codec<A, any>
|
||||||
@@ -28,6 +29,33 @@ export type AnyTool = Definition<any, any>
|
|||||||
export const Failure = ToolFailure
|
export const Failure = ToolFailure
|
||||||
export type 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", {
|
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
@@ -37,72 +65,69 @@ export type Content =
|
|||||||
| { readonly type: "text"; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||||
|
|
||||||
type Config<
|
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
||||||
Input extends SchemaType<any>,
|
|
||||||
Output extends SchemaType<any>,
|
|
||||||
Structured extends SchemaType<any> = Output,
|
|
||||||
> = {
|
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: Input
|
readonly input: Input
|
||||||
readonly output: Output
|
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: (
|
readonly execute: (
|
||||||
input: Schema.Schema.Type<Input>,
|
input: Schema.Schema.Type<Input>,
|
||||||
context: Context,
|
context: Context<Schema.Schema.Type<Output>>,
|
||||||
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
|
) => Effect.Effect<Schema.Schema.Type<Output> | Result<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config for a tool whose input shape is a raw JSON Schema not known at compile
|
* 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`;
|
* 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 = {
|
type DynamicConfig = {
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
readonly outputSchema?: 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 = {
|
type Runtime = {
|
||||||
readonly permission?: string
|
readonly permission?: string
|
||||||
readonly definition: (name: string) => ToolDefinition
|
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>()
|
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||||
|
|
||||||
export function make<
|
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||||
Input extends SchemaType<any>,
|
config: Config<Input, Output>,
|
||||||
Output extends SchemaType<any>,
|
): Definition<Input, Output>
|
||||||
Structured extends SchemaType<any> = Output,
|
|
||||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
|
|
||||||
export function make(config: DynamicConfig): AnyTool
|
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)
|
if ("jsonSchema" in config) return makeDynamic(config)
|
||||||
return makeTyped(config)
|
return makeTyped(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTyped<
|
function makeTyped<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||||
Input extends SchemaType<any>,
|
config: Config<Input, Output>,
|
||||||
Output extends SchemaType<any>,
|
): Definition<Input, Output> {
|
||||||
Structured extends SchemaType<any> = Output,
|
const tool = Object.freeze({}) as Definition<Input, Output>
|
||||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
|
|
||||||
const tool = Object.freeze({}) as Definition<Input, Structured>
|
|
||||||
const definitions = new Map<string, ToolDefinition>()
|
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, {
|
runtimes.set(tool, {
|
||||||
definition: (name) => {
|
definition: (name) => {
|
||||||
const cached = definitions.get(name)
|
const cached = definitions.get(name)
|
||||||
@@ -111,7 +136,7 @@ function makeTyped<
|
|||||||
name,
|
name,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
inputSchema: toJsonSchema(config.input),
|
inputSchema: toJsonSchema(config.input),
|
||||||
outputSchema: toJsonSchema(config.structured ?? config.output),
|
outputSchema: toJsonSchema(config.output),
|
||||||
})
|
})
|
||||||
definitions.set(name, definition)
|
definitions.set(name, definition)
|
||||||
return definition
|
return definition
|
||||||
@@ -120,31 +145,15 @@ function makeTyped<
|
|||||||
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
|
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||||
Effect.flatMap((input) =>
|
Effect.flatMap((input) =>
|
||||||
config.execute(input, context).pipe(
|
config
|
||||||
Effect.flatMap((output) =>
|
.execute(input, {
|
||||||
Schema.encodeEffect(config.output)(output).pipe(
|
...context,
|
||||||
Effect.flatMap((output) => {
|
progress: (state) =>
|
||||||
if (!config.structured || !config.toStructuredOutput)
|
context.progress
|
||||||
return Effect.succeed({ output, structured: output })
|
? projectState(state).pipe(Effect.flatMap(context.progress), Effect.ignore)
|
||||||
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
|
: Effect.void,
|
||||||
Effect.map((structured) => ({ output, structured })),
|
})
|
||||||
)
|
.pipe(Effect.flatMap(project)),
|
||||||
}),
|
|
||||||
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 }] : []),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -154,6 +163,8 @@ function makeTyped<
|
|||||||
function makeDynamic(config: DynamicConfig): AnyTool {
|
function makeDynamic(config: DynamicConfig): AnyTool {
|
||||||
const tool = Object.freeze({}) as AnyTool
|
const tool = Object.freeze({}) as AnyTool
|
||||||
const definitions = new Map<string, ToolDefinition>()
|
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, {
|
runtimes.set(tool, {
|
||||||
definition: (name) => {
|
definition: (name) => {
|
||||||
const cached = definitions.get(name)
|
const cached = definitions.get(name)
|
||||||
@@ -169,12 +180,28 @@ function makeDynamic(config: DynamicConfig): AnyTool {
|
|||||||
},
|
},
|
||||||
settle: (call, context) =>
|
settle: (call, context) =>
|
||||||
config
|
config
|
||||||
.execute(call.input, context)
|
.execute(call.input, {
|
||||||
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
|
...context,
|
||||||
|
progress: (state) => context.progress?.(projectState(state)).pipe(Effect.ignore) ?? Effect.void,
|
||||||
|
})
|
||||||
|
.pipe(Effect.map(project)),
|
||||||
})
|
})
|
||||||
return tool
|
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) {
|
function toModelContent(part: Content) {
|
||||||
if (part.type === "text") return { type: "text" as const, text: part.text }
|
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 }
|
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 permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
|
||||||
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(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) {
|
function runtimeOf(tool: AnyTool) {
|
||||||
const runtime = runtimes.get(tool)
|
const runtime = runtimes.get(tool)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Failure, RegistrationError, make } 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 } 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 = [
|
const subscriptions = [
|
||||||
data.on("session.next.prompted", message),
|
data.on("session.next.prompted", message),
|
||||||
data.on("session.next.context.updated", 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.shell.started", message),
|
||||||
data.on("session.next.agent.switched", message),
|
data.on("session.next.agent.switched", message),
|
||||||
data.on("session.next.model.switched", message),
|
data.on("session.next.model.switched", message),
|
||||||
@@ -158,6 +157,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
|
|
||||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||||
|
if (message.type === "synthetic") return rows
|
||||||
if (message.type !== "assistant") {
|
if (message.type !== "assistant") {
|
||||||
completePrevious(rows)
|
completePrevious(rows)
|
||||||
rows.push({ type: "message", messageID: message.id })
|
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", () => {
|
test("groups across empty assistant reasoning parts", () => {
|
||||||
const messages: SessionMessage[] = [
|
const messages: SessionMessage[] = [
|
||||||
assistant("assistant-1", [
|
assistant("assistant-1", [
|
||||||
|
|||||||
Reference in New Issue
Block a user