tool.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. export * as CodeModeTool from "./tool.js"
  2. import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
  3. import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
  4. import { Effect, Ref, Schema, Semaphore } from "effect"
  5. import { definition } from "../tool/runtime.js"
  6. const ExecuteFile = Schema.Struct({
  7. data: Schema.String,
  8. mime: Schema.String,
  9. name: Schema.optionalKey(Schema.String),
  10. })
  11. const ExecuteCall = Schema.Struct({
  12. tool: Schema.String,
  13. status: Schema.Literals(["running", "completed", "error"]),
  14. input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
  15. })
  16. type ExecuteCall = typeof ExecuteCall.Type
  17. const ExecuteOutput = Schema.Struct({
  18. output: Schema.String,
  19. toolCalls: Schema.Array(ExecuteCall),
  20. error: Schema.optionalKey(Schema.Literal(true)),
  21. files: Schema.Array(ExecuteFile),
  22. })
  23. type CollectedFiles = {
  24. readonly index: number
  25. readonly files: Array<typeof ExecuteFile.Type>
  26. }
  27. // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
  28. const description = [
  29. "Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
  30. "Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
  31. "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
  32. 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
  33. "Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
  34. "Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
  35. ].join("\n")
  36. export const create = (
  37. registrations: ReadonlyMap<string, Info>,
  38. executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
  39. ) => {
  40. return {
  41. name: "execute",
  42. description,
  43. input: CodeMode.Input,
  44. output: ExecuteOutput,
  45. execute: ({ code }, context) =>
  46. Effect.gen(function* () {
  47. const callIndex = yield* Ref.make(0)
  48. const files = yield* Ref.make<Array<CollectedFiles>>([])
  49. const calls = yield* Ref.make<Array<ExecuteCall>>([])
  50. const lock = Semaphore.makeUnsafe(1)
  51. const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
  52. lock.withPermit(
  53. Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
  54. )
  55. const result = yield* runtime(
  56. registrations,
  57. (name, tool, input) =>
  58. Effect.gen(function* () {
  59. const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
  60. const executed = yield* executeTool(name, tool, input, context).pipe(
  61. Effect.mapError((failure) => toolError(failure.message, failure)),
  62. )
  63. const content =
  64. typeof executed.content === "string"
  65. ? [{ type: "text" as const, text: executed.content }]
  66. : (executed.content ?? [])
  67. const outputFileParts = outputFiles(content)
  68. if (outputFileParts.length > 0)
  69. yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
  70. return executed.output
  71. }),
  72. {
  73. onToolCallStart: ({ index, name, input }) => {
  74. const shown = displayInput(input)
  75. return updateCalls((items) => {
  76. const next = [...items]
  77. next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
  78. return next
  79. })
  80. },
  81. onToolCallEnd: ({ index, name, input, outcome }) => {
  82. const shown = displayInput(input)
  83. return updateCalls((items) => {
  84. const next = [...items]
  85. next[index] = {
  86. ...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
  87. status: outcome === "success" ? "completed" : "error",
  88. }
  89. return next
  90. })
  91. },
  92. },
  93. ).execute(code)
  94. const toolCalls = yield* Ref.get(calls)
  95. const collected = (yield* Ref.get(files))
  96. .toSorted((left, right) => left.index - right.index)
  97. .flatMap((item) => item.files)
  98. const output = formatResult(result)
  99. const value: typeof ExecuteOutput.Type = {
  100. output,
  101. toolCalls,
  102. files: collected,
  103. ...(result.ok ? {} : { error: true }),
  104. }
  105. const content: Array<Content> = [{ type: "text", text: value.output }]
  106. content.push(
  107. ...value.files.map((file) => ({
  108. type: "file" as const,
  109. uri: `data:${file.mime};base64,${file.data}`,
  110. mime: file.mime,
  111. ...(file.name === undefined ? {} : { name: file.name }),
  112. })),
  113. )
  114. const metadata: Metadata = {
  115. toolCalls: value.toolCalls,
  116. ...(value.error ? { error: true } : {}),
  117. }
  118. return {
  119. output: value,
  120. content,
  121. metadata,
  122. }
  123. }),
  124. } satisfies Info
  125. }
  126. export const catalog = (registrations: ReadonlyMap<string, Info>) => {
  127. const pinned = new Set(
  128. Array.from(registrations.values())
  129. .filter((registration) => registration.options?.pinned === true)
  130. .map(qualifiedName),
  131. )
  132. return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
  133. .catalog()
  134. .map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
  135. }
  136. function runtime(
  137. registrations: ReadonlyMap<string, Info>,
  138. executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
  139. hooks?: CodeMode.ToolCallHooks,
  140. ) {
  141. const tools: Record<string, Tool.Tool<never>> = {}
  142. for (const [name, registration] of registrations) {
  143. const child = definition(registration)
  144. const path = qualifiedName(registration)
  145. tools[path] = Tool.make({
  146. description: child.description,
  147. input: child.inputSchema,
  148. output: child.outputSchema,
  149. execute: (input) => executeTool(name, registration, input),
  150. })
  151. }
  152. return CodeMode.make<typeof tools>({ tools, ...hooks })
  153. }
  154. function qualifiedName(registration: Info) {
  155. const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
  156. if (registration.options?.namespace === undefined) return normalized
  157. return `${registration.options.namespace}.${normalized}`
  158. }
  159. // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
  160. function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
  161. if (input === null || input === undefined) return
  162. if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
  163. if (Object.keys(input).length === 0) return
  164. return input as Record<string, typeof Schema.Json.Type>
  165. }
  166. function formatResult(result: CodeMode.Result) {
  167. const output = result.ok
  168. ? formatValue(result.value)
  169. : [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
  170. .join("\n")
  171. .trim()
  172. const warnings =
  173. result.ok && result.warnings && result.warnings.length > 0
  174. ? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
  175. : undefined
  176. const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
  177. return [output, warnings, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
  178. }
  179. function formatValue(value: CodeMode.DataValue) {
  180. if (typeof value === "string") return value
  181. return JSON.stringify(value, null, 2) ?? String(value)
  182. }
  183. function outputFiles(content: ReadonlyArray<Content>): Array<typeof ExecuteFile.Type> {
  184. return content.flatMap((part) => {
  185. if (part.type !== "file") return []
  186. const prefix = `data:${part.mime};base64,`
  187. if (!part.uri.startsWith(prefix)) return []
  188. return [
  189. {
  190. data: part.uri.slice(prefix.length),
  191. mime: part.mime,
  192. ...(part.name === undefined ? {} : { name: part.name }),
  193. },
  194. ]
  195. })
  196. }