Skip to content

Commit 10f3ca7

Browse files
Copilotpatniko
andcommitted
Add requiresApproval field to custom tools in Python and Go SDKs
Co-authored-by: patniko <26906478+patniko@users.noreply.github.com>
1 parent 0d4383c commit 10f3ca7

7 files changed

Lines changed: 129 additions & 18 deletions

File tree

go/client.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,35 @@ func (c *Client) handleToolCallRequest(params map[string]interface{}) (map[strin
11801180
return map[string]interface{}{"result": buildUnsupportedToolResult(toolName)}, nil
11811181
}
11821182

1183+
// Check if tool requires approval
1184+
if session.toolRequiresApprovalCheck(toolName) {
1185+
permissionRequest := map[string]interface{}{
1186+
"kind": "tool",
1187+
"toolCallId": toolCallID,
1188+
"toolName": toolName,
1189+
}
1190+
1191+
permissionResult, err := session.handlePermissionRequest(permissionRequest)
1192+
if err != nil || permissionResult.Kind != "approved" {
1193+
deniedReason := "denied"
1194+
if err == nil {
1195+
deniedReason = permissionResult.Kind
1196+
}
1197+
return map[string]interface{}{
1198+
"result": ToolResult{
1199+
TextResultForLLM: func() string {
1200+
if deniedReason == "denied-interactively-by-user" {
1201+
return "Tool execution was denied by user."
1202+
}
1203+
return "Tool execution was denied."
1204+
}(),
1205+
ResultType: "denied",
1206+
ToolTelemetry: map[string]interface{}{},
1207+
},
1208+
}, nil
1209+
}
1210+
}
1211+
11831212
arguments := params["arguments"]
11841213
result := c.executeToolCall(sessionID, toolCallID, toolName, arguments, handler)
11851214

go/session.go

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,17 @@ type sessionHandler struct {
4646
// })
4747
type Session struct {
4848
// SessionID is the unique identifier for this session.
49-
SessionID string
50-
workspacePath string
51-
client *JSONRPCClient
52-
handlers []sessionHandler
53-
nextHandlerID uint64
54-
handlerMutex sync.RWMutex
55-
toolHandlers map[string]ToolHandler
56-
toolHandlersM sync.RWMutex
57-
permissionHandler PermissionHandler
58-
permissionMux sync.RWMutex
49+
SessionID string
50+
workspacePath string
51+
client *JSONRPCClient
52+
handlers []sessionHandler
53+
nextHandlerID uint64
54+
handlerMutex sync.RWMutex
55+
toolHandlers map[string]ToolHandler
56+
toolRequiresApproval map[string]bool
57+
toolHandlersM sync.RWMutex
58+
permissionHandler PermissionHandler
59+
permissionMux sync.RWMutex
5960
}
6061

6162
// WorkspacePath returns the path to the session workspace directory when infinite
@@ -71,11 +72,12 @@ func (s *Session) WorkspacePath() string {
7172
// to create sessions with proper initialization.
7273
func NewSession(sessionID string, client *JSONRPCClient, workspacePath string) *Session {
7374
return &Session{
74-
SessionID: sessionID,
75-
workspacePath: workspacePath,
76-
client: client,
77-
handlers: make([]sessionHandler, 0),
78-
toolHandlers: make(map[string]ToolHandler),
75+
SessionID: sessionID,
76+
workspacePath: workspacePath,
77+
client: client,
78+
handlers: make([]sessionHandler, 0),
79+
toolHandlers: make(map[string]ToolHandler),
80+
toolRequiresApproval: make(map[string]bool),
7981
}
8082
}
8183

@@ -262,11 +264,13 @@ func (s *Session) registerTools(tools []Tool) {
262264
defer s.toolHandlersM.Unlock()
263265

264266
s.toolHandlers = make(map[string]ToolHandler)
267+
s.toolRequiresApproval = make(map[string]bool)
265268
for _, tool := range tools {
266269
if tool.Name == "" || tool.Handler == nil {
267270
continue
268271
}
269272
s.toolHandlers[tool.Name] = tool.Handler
273+
s.toolRequiresApproval[tool.Name] = tool.RequiresApproval
270274
}
271275
}
272276

@@ -279,6 +283,15 @@ func (s *Session) getToolHandler(name string) (ToolHandler, bool) {
279283
return handler, ok
280284
}
281285

286+
// toolRequiresApprovalCheck checks if a tool requires approval before execution.
287+
// Returns true if the tool requires approval, false otherwise.
288+
func (s *Session) toolRequiresApprovalCheck(name string) bool {
289+
s.toolHandlersM.RLock()
290+
defer s.toolHandlersM.RUnlock()
291+
requiresApproval, ok := s.toolRequiresApproval[name]
292+
return ok && requiresApproval
293+
}
294+
282295
// registerPermissionHandler registers a permission handler for this session.
283296
//
284297
// When the assistant needs permission to perform certain actions (e.g., file

go/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ type SystemMessageConfig struct {
7878
type PermissionRequest struct {
7979
Kind string `json:"kind"`
8080
ToolCallID string `json:"toolCallId,omitempty"`
81+
ToolName string `json:"toolName,omitempty"`
8182
Extra map[string]interface{} `json:"-"` // Additional fields vary by kind
8283
}
8384

@@ -198,6 +199,10 @@ type Tool struct {
198199
Description string // optional
199200
Parameters map[string]interface{}
200201
Handler ToolHandler
202+
// RequiresApproval controls whether the tool requires user approval before execution.
203+
// When true, the OnPermissionRequest handler will be called before invoking the tool.
204+
// When false (default), the tool executes without requesting permission.
205+
RequiresApproval bool
201206
}
202207

203208
// ToolInvocation describes a tool call initiated by Copilot

python/copilot/client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,38 @@ async def _handle_tool_call_request(self, params: dict) -> dict:
10541054
if not handler:
10551055
return {"result": self._build_unsupported_tool_result(tool_name)}
10561056

1057+
# Check if tool requires approval
1058+
if session._tool_requires_approval(tool_name):
1059+
try:
1060+
permission_result = await session._handle_permission_request(
1061+
{
1062+
"kind": "tool",
1063+
"toolCallId": tool_call_id,
1064+
"toolName": tool_name,
1065+
}
1066+
)
1067+
1068+
if permission_result.get("kind") != "approved":
1069+
denied_reason = permission_result.get("kind", "denied")
1070+
return {
1071+
"result": {
1072+
"textResultForLlm": "Tool execution was denied by user."
1073+
if denied_reason == "denied-interactively-by-user"
1074+
else "Tool execution was denied.",
1075+
"resultType": "denied",
1076+
"toolTelemetry": {},
1077+
}
1078+
}
1079+
except Exception: # pylint: disable=broad-except
1080+
# If permission handler fails or is not configured, deny the tool execution
1081+
return {
1082+
"result": {
1083+
"textResultForLlm": "Tool execution requires permission but no permission handler is configured.",
1084+
"resultType": "denied",
1085+
"toolTelemetry": {},
1086+
}
1087+
}
1088+
10571089
arguments = params.get("arguments")
10581090
result = await self._execute_tool_call(
10591091
session_id,

python/copilot/session.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] =
6868
self._event_handlers: set[Callable[[SessionEvent], None]] = set()
6969
self._event_handlers_lock = threading.Lock()
7070
self._tool_handlers: dict[str, ToolHandler] = {}
71+
self._tool_requires_approval: dict[str, bool] = {}
7172
self._tool_handlers_lock = threading.Lock()
7273
self._permission_handler: Optional[PermissionHandler] = None
7374
self._permission_handler_lock = threading.Lock()
@@ -250,12 +251,14 @@ def _register_tools(self, tools: Optional[list[Tool]]) -> None:
250251
"""
251252
with self._tool_handlers_lock:
252253
self._tool_handlers.clear()
254+
self._tool_requires_approval.clear()
253255
if not tools:
254256
return
255257
for tool in tools:
256258
if not tool.name or not tool.handler:
257259
continue
258260
self._tool_handlers[tool.name] = tool.handler
261+
self._tool_requires_approval[tool.name] = tool.requires_approval
259262

260263
def _get_tool_handler(self, name: str) -> Optional[ToolHandler]:
261264
"""
@@ -274,6 +277,22 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]:
274277
with self._tool_handlers_lock:
275278
return self._tool_handlers.get(name)
276279

280+
def _tool_requires_approval(self, name: str) -> bool:
281+
"""
282+
Check if a tool requires approval before execution.
283+
284+
Note:
285+
This method is internal and should not be called directly.
286+
287+
Args:
288+
name: The name of the tool to check.
289+
290+
Returns:
291+
True if the tool requires approval, False otherwise.
292+
"""
293+
with self._tool_handlers_lock:
294+
return self._tool_requires_approval.get(name, False)
295+
277296
def _register_permission_handler(self, handler: Optional[PermissionHandler]) -> None:
278297
"""
279298
Register a handler for permission requests.

python/copilot/tools.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def define_tool(
4343
description: str | None = None,
4444
handler: Callable[[Any, ToolInvocation], Any] | None = None,
4545
params_type: type[BaseModel] | None = None,
46+
requires_approval: bool = False,
4647
) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]:
4748
"""
4849
Define a tool with automatic JSON schema generation from Pydantic models.
@@ -56,7 +57,7 @@ def define_tool(
5657
class LookupIssueParams(BaseModel):
5758
id: str = Field(description="Issue identifier")
5859
59-
@define_tool(description="Fetch issue details")
60+
@define_tool(description="Fetch issue details", requires_approval=True)
6061
def lookup_issue(params: LookupIssueParams) -> str:
6162
return fetch_issue(params.id).summary
6263
@@ -66,7 +67,8 @@ def lookup_issue(params: LookupIssueParams) -> str:
6667
"lookup_issue",
6768
description="Fetch issue details",
6869
handler=lambda params, inv: fetch_issue(params.id).summary,
69-
params_type=LookupIssueParams
70+
params_type=LookupIssueParams,
71+
requires_approval=True
7072
)
7173
7274
Args:
@@ -75,6 +77,9 @@ def lookup_issue(params: LookupIssueParams) -> str:
7577
handler: Optional handler function (if not using as decorator)
7678
params_type: Optional Pydantic model type for parameters (inferred from
7779
type hints when using as decorator)
80+
requires_approval: Whether the tool requires user approval before execution.
81+
When True, the on_permission_request handler will be called
82+
before invoking the tool. Defaults to False.
7883
7984
Returns:
8085
A Tool instance
@@ -149,6 +154,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult:
149154
description=description or "",
150155
parameters=schema,
151156
handler=wrapped_handler,
157+
requires_approval=requires_approval,
152158
)
153159

154160
# If handler is provided, call decorator immediately

python/copilot/types.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ class Tool:
8888
description: str
8989
handler: ToolHandler
9090
parameters: dict[str, Any] | None = None
91+
requires_approval: bool = False
92+
"""
93+
Controls whether the tool requires user approval before execution.
94+
When True, the on_permission_request handler will be called before invoking the tool.
95+
When False (default), the tool executes without requesting permission.
96+
"""
9197

9298

9399
# System message configuration (discriminated union)
@@ -121,8 +127,9 @@ class SystemMessageReplaceConfig(TypedDict):
121127
class PermissionRequest(TypedDict, total=False):
122128
"""Permission request from the server"""
123129

124-
kind: Literal["shell", "write", "mcp", "read", "url"]
130+
kind: Literal["shell", "write", "mcp", "read", "url", "tool"]
125131
toolCallId: str
132+
toolName: str
126133
# Additional fields vary by kind
127134

128135

0 commit comments

Comments
 (0)