Skip to content

Commit b8f5595

Browse files
committed
feat(plugin): add programmatic tool invocation support (PTC)
Introduce Programmatic Tool Calling (PTC) support for plugins, enabling plugins to invoke tools directly within a session without relying on LLM vendor-specific APIs. This change exposes a unified tool invocation API that: - Allows plugins to execute tools programmatically - Reduces LLM round-trips and token consumption - Decouples plugin PTC implementation from underlying model providers (e.g. Anthropic-specific APIs) To support this, the JS SDK (v1/v2) is regenerated to expose the new `execute` API and related types, allowing plugin authors to implement PTC in a provider-agnostic way. Without this change, plugins in opencode can only achieve PTC via vendor-dependent LLM APIs and cannot invoke tools directly. Signed-off-by: yuguorui <[email protected]> --- [1] https://platform.claude.com/cookbook/tool-use-programmatic-tool-calling-ptc
1 parent ddd9c71 commit b8f5595

File tree

6 files changed

+277
-0
lines changed

6 files changed

+277
-0
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Hono } from "hono"
2+
import { describeRoute, validator, resolver } from "hono-openapi"
3+
import { errors } from "./error"
4+
import z from "zod"
5+
import { Session } from "../session"
6+
import { Agent } from "../agent/agent"
7+
import { Storage } from "../storage/storage"
8+
import { ToolRegistry } from "../tool/registry"
9+
import { Tool } from "../tool/tool"
10+
import { PermissionNext } from "@/permission/next"
11+
12+
export const ExperimentalRoute = new Hono().post(
13+
"/tool/execute",
14+
describeRoute({
15+
summary: "Execute tool",
16+
description: "Execute a specific tool with the provided arguments. Returns the tool output.",
17+
operationId: "tool.execute",
18+
responses: {
19+
200: {
20+
description: "Tool execution result",
21+
content: {
22+
"application/json": {
23+
schema: resolver(
24+
z
25+
.object({
26+
title: z.string(),
27+
output: z.string(),
28+
metadata: z.record(z.string(), z.any()).optional(),
29+
})
30+
.meta({ ref: "ToolExecuteResult" }),
31+
),
32+
},
33+
},
34+
},
35+
...errors(400, 404),
36+
},
37+
}),
38+
validator(
39+
"json",
40+
z.object({
41+
sessionID: z.string().meta({ description: "Session ID for context" }),
42+
messageID: z.string().meta({ description: "Message ID for context" }),
43+
providerID: z.string().meta({ description: "Provider ID for tool filtering" }),
44+
toolID: z.string().meta({ description: "Tool ID to execute" }),
45+
args: z.record(z.string(), z.any()).meta({ description: "Tool arguments" }),
46+
agent: z.string().optional().meta({ description: "Agent name (optional)" }),
47+
callID: z.string().optional().meta({ description: "Tool call ID (optional)" }),
48+
}),
49+
),
50+
async (c) => {
51+
const body = c.req.valid("json")
52+
const session = await Session.get(body.sessionID)
53+
const agentName = body.agent ?? (await Agent.defaultAgent())
54+
const agent = await Agent.get(agentName)
55+
if (!agent) {
56+
throw new Storage.NotFoundError({ message: `Agent not found: ${agentName}` })
57+
}
58+
59+
const tools = await ToolRegistry.tools(body.providerID, agent)
60+
const tool = tools.find((t) => t.id === body.toolID)
61+
if (!tool) {
62+
throw new Storage.NotFoundError({ message: `Tool not found: ${body.toolID}` })
63+
}
64+
65+
const abortController = new AbortController()
66+
let currentMetadata: { title?: string; metadata?: Record<string, any> } = {}
67+
68+
const ctx: Tool.Context = {
69+
sessionID: body.sessionID,
70+
messageID: body.messageID,
71+
agent: agentName,
72+
abort: abortController.signal,
73+
callID: body.callID,
74+
metadata: (input) => {
75+
currentMetadata = input
76+
},
77+
ask: async (req) => {
78+
await PermissionNext.ask({
79+
...req,
80+
sessionID: session.id,
81+
tool: body.callID ? { messageID: body.messageID, callID: body.callID } : undefined,
82+
ruleset: PermissionNext.merge(agent.permission, session.permission ?? []),
83+
})
84+
},
85+
}
86+
87+
const result = await tool.execute(body.args, ctx)
88+
return c.json({
89+
title: result.title || currentMetadata.title || "",
90+
output: result.output,
91+
metadata: result.metadata,
92+
})
93+
},
94+
)

packages/opencode/src/server/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { Command } from "../command"
3131
import { ProviderAuth } from "../provider/auth"
3232
import { Global } from "../global"
3333
import { ProjectRoute } from "./project"
34+
import { ExperimentalRoute } from "./experimental"
3435
import { ToolRegistry } from "../tool/registry"
3536
import { zodToJsonSchema } from "zod-to-json-schema"
3637
import { SessionPrompt } from "../session/prompt"
@@ -75,6 +76,7 @@ export namespace Server {
7576
}
7677

7778
const app = new Hono()
79+
app.route("/experimental", ExperimentalRoute)
7880
export const App: () => Hono = lazy(
7981
() =>
8082
// TODO: Break server.ts into smaller route files to fix type inference

packages/sdk/js/src/gen/sdk.gen.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import type {
3636
ToolListData,
3737
ToolListResponses,
3838
ToolListErrors,
39+
ToolExecuteData,
40+
ToolExecuteResponses,
41+
ToolExecuteErrors,
3942
InstanceDisposeData,
4043
InstanceDisposeResponses,
4144
PathGetData,
@@ -390,6 +393,17 @@ class Tool extends _HeyApiClient {
390393
...options,
391394
})
392395
}
396+
397+
public execute<ThrowOnError extends boolean = false>(options?: Options<ToolExecuteData, ThrowOnError>) {
398+
return (options?.client ?? this._client).post<ToolExecuteResponses, ToolExecuteErrors, ThrowOnError>({
399+
url: "/experimental/tool/execute",
400+
...options,
401+
headers: {
402+
"Content-Type": "application/json",
403+
...options?.headers,
404+
},
405+
})
406+
}
393407
}
394408

395409
class Instance extends _HeyApiClient {

packages/sdk/js/src/gen/types.gen.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1996,6 +1996,52 @@ export type ToolListResponses = {
19961996

19971997
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
19981998

1999+
export type ToolExecuteData = {
2000+
body?: {
2001+
agent?: string
2002+
args?: {
2003+
[key: string]: unknown
2004+
}
2005+
messageID?: string
2006+
providerID?: string
2007+
sessionID?: string
2008+
toolID?: string
2009+
}
2010+
path?: never
2011+
query?: {
2012+
directory?: string
2013+
}
2014+
url: "/experimental/tool/execute"
2015+
}
2016+
2017+
export type ToolExecuteErrors = {
2018+
/**
2019+
* Bad request
2020+
*/
2021+
400: string
2022+
/**
2023+
* Tool not found
2024+
*/
2025+
404: string
2026+
/**
2027+
* Tool execution failed
2028+
*/
2029+
500: string
2030+
}
2031+
2032+
export type ToolExecuteError = ToolExecuteErrors[keyof ToolExecuteErrors]
2033+
2034+
export type ToolExecuteResponses = {
2035+
/**
2036+
* Tool execution result
2037+
*/
2038+
200: {
2039+
output?: string
2040+
}
2041+
}
2042+
2043+
export type ToolExecuteResponse = ToolExecuteResponses[keyof ToolExecuteResponses]
2044+
19992045
export type InstanceDisposeData = {
20002046
body?: never
20012047
path?: never

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ import type {
136136
SessionUpdateResponses,
137137
SubtaskPartInput,
138138
TextPartInput,
139+
ToolExecuteErrors,
140+
ToolExecuteResponses,
139141
ToolIdsErrors,
140142
ToolIdsResponses,
141143
ToolListErrors,
@@ -651,6 +653,55 @@ export class Tool extends HeyApiClient {
651653
...params,
652654
})
653655
}
656+
657+
/**
658+
* Execute tool
659+
*
660+
* Execute a specific tool with the provided arguments. Returns the tool output.
661+
*/
662+
public execute<ThrowOnError extends boolean = false>(
663+
parameters?: {
664+
directory?: string
665+
sessionID?: string
666+
messageID?: string
667+
providerID?: string
668+
toolID?: string
669+
args?: {
670+
[key: string]: unknown
671+
}
672+
agent?: string
673+
callID?: string
674+
},
675+
options?: Options<never, ThrowOnError>,
676+
) {
677+
const params = buildClientParams(
678+
[parameters],
679+
[
680+
{
681+
args: [
682+
{ in: "query", key: "directory" },
683+
{ in: "body", key: "sessionID" },
684+
{ in: "body", key: "messageID" },
685+
{ in: "body", key: "providerID" },
686+
{ in: "body", key: "toolID" },
687+
{ in: "body", key: "args" },
688+
{ in: "body", key: "agent" },
689+
{ in: "body", key: "callID" },
690+
],
691+
},
692+
],
693+
)
694+
return (options?.client ?? this.client).post<ToolExecuteResponses, ToolExecuteErrors, ThrowOnError>({
695+
url: "/experimental/tool/execute",
696+
...options,
697+
...params,
698+
headers: {
699+
"Content-Type": "application/json",
700+
...options?.headers,
701+
...params.headers,
702+
},
703+
})
704+
}
654705
}
655706

656707
export class Instance extends HeyApiClient {

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,14 @@ export type ToolListItem = {
17671767

17681768
export type ToolList = Array<ToolListItem>
17691769

1770+
export type ToolExecuteResult = {
1771+
title: string
1772+
output: string
1773+
metadata?: {
1774+
[key: string]: unknown
1775+
}
1776+
}
1777+
17701778
export type Path = {
17711779
home: string
17721780
state: string
@@ -2481,6 +2489,68 @@ export type ToolListResponses = {
24812489

24822490
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
24832491

2492+
export type ToolExecuteData = {
2493+
body?: {
2494+
/**
2495+
* Session ID for context
2496+
*/
2497+
sessionID: string
2498+
/**
2499+
* Message ID for context
2500+
*/
2501+
messageID: string
2502+
/**
2503+
* Provider ID for tool filtering
2504+
*/
2505+
providerID: string
2506+
/**
2507+
* Tool ID to execute
2508+
*/
2509+
toolID: string
2510+
/**
2511+
* Tool arguments
2512+
*/
2513+
args: {
2514+
[key: string]: unknown
2515+
}
2516+
/**
2517+
* Agent name (optional)
2518+
*/
2519+
agent?: string
2520+
/**
2521+
* Tool call ID (optional)
2522+
*/
2523+
callID?: string
2524+
}
2525+
path?: never
2526+
query?: {
2527+
directory?: string
2528+
}
2529+
url: "/experimental/tool/execute"
2530+
}
2531+
2532+
export type ToolExecuteErrors = {
2533+
/**
2534+
* Bad request
2535+
*/
2536+
400: BadRequestError
2537+
/**
2538+
* Not found
2539+
*/
2540+
404: NotFoundError
2541+
}
2542+
2543+
export type ToolExecuteError = ToolExecuteErrors[keyof ToolExecuteErrors]
2544+
2545+
export type ToolExecuteResponses = {
2546+
/**
2547+
* Tool execution result
2548+
*/
2549+
200: ToolExecuteResult
2550+
}
2551+
2552+
export type ToolExecuteResponse = ToolExecuteResponses[keyof ToolExecuteResponses]
2553+
24842554
export type InstanceDisposeData = {
24852555
body?: never
24862556
path?: never

0 commit comments

Comments
 (0)