Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,24 @@ var session = await client.CreateSessionAsync(new SessionConfig
});
```

#### Skipping Permission Prompts

Set `skip_permission` in the tool's `AdditionalProperties` to allow it to execute without triggering a permission prompt:

```csharp
var safeLookup = AIFunctionFactory.Create(
async ([Description("Lookup ID")] string id) => {
// your logic
},
"safe_lookup",
"A read-only lookup that needs no confirmation",
new AIFunctionFactoryOptions
{
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?> { ["skip_permission"] = true })
});
```

### System Message Customization

Control the system prompt using `SystemMessage` in session config:
Expand Down
7 changes: 5 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1470,13 +1470,16 @@ internal record ToolDefinition(
string Name,
string? Description,
JsonElement Parameters, /* JSON schema */
bool? OverridesBuiltInTool = null)
bool? OverridesBuiltInTool = null,
bool? SkipPermission = null)
{
public static ToolDefinition FromAIFunction(AIFunction function)
{
var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true;
var skipPerm = function.AdditionalProperties.TryGetValue("skip_permission", out var skipVal) && skipVal is true;
return new ToolDefinition(function.Name, function.Description, function.JsonSchema,
overrides ? true : null);
overrides ? true : null,
skipPerm ? true : null);
}
}

Expand Down
3 changes: 3 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ public class ToolInvocation
/// <summary>Gets the kind indicating the permission was denied interactively by the user.</summary>
public static PermissionRequestResultKind DeniedInteractivelyByUser { get; } = new("denied-interactively-by-user");

/// <summary>Gets the kind indicating the permission was denied interactively by the user.</summary>
public static PermissionRequestResultKind NoResult { get; } = new("no-result");

/// <summary>Gets the underlying string value of this <see cref="PermissionRequestResultKind"/>.</summary>
public string Value => _value ?? string.Empty;

Expand Down
36 changes: 36 additions & 0 deletions dotnet/test/ToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,42 @@ static string CustomGrep([Description("Search query")] string query)
=> $"CUSTOM_GREP_RESULT: {query}";
}

[Fact]
public async Task SkipPermission_Sent_In_Tool_Definition()
{
[Description("A tool that skips permission")]
static string SafeLookup([Description("Lookup ID")] string id)
=> $"RESULT: {id}";

var tool = AIFunctionFactory.Create((Delegate)SafeLookup, new AIFunctionFactoryOptions
{
Name = "safe_lookup",
AdditionalProperties = new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?> { ["skip_permission"] = true })
});

var didRunPermissionRequest = false;
var session = await CreateSessionAsync(new SessionConfig
{
Tools = [tool],
OnPermissionRequest = (_, _) =>
{
didRunPermissionRequest = true;
return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.NoResult });
}
});

await session.SendAsync(new MessageOptions
{
Prompt = "Use safe_lookup to look up 'test123'"
});

var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
Assert.NotNull(assistantMessage);
Assert.Contains("RESULT", assistantMessage!.Data.Content ?? string.Empty);
Assert.False(didRunPermissionRequest);
}

[Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")]
public async Task Can_Return_Binary_Result()
{
Expand Down
12 changes: 12 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ editFile := copilot.DefineTool("edit_file", "Custom file editor with project-spe
editFile.OverridesBuiltInTool = true
```

#### Skipping Permission Prompts

Set `SkipPermission = true` on a tool to allow it to execute without triggering a permission prompt:

```go
safeLookup := copilot.DefineTool("safe_lookup", "A read-only lookup that needs no confirmation",
func(params LookupParams, inv copilot.ToolInvocation) (any, error) {
// your logic
})
safeLookup.SkipPermission = true
```

## Streaming

Enable streaming to receive assistant response chunks as they're generated:
Expand Down
46 changes: 46 additions & 0 deletions go/internal/e2e/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,52 @@ func TestTools(t *testing.T) {
}
})

t.Run("skipPermission sent in tool definition", func(t *testing.T) {
ctx.ConfigureForTest(t)

type LookupParams struct {
ID string `json:"id" jsonschema:"ID to look up"`
}

safeLookupTool := copilot.DefineTool("safe_lookup", "A safe lookup that skips permission",
func(params LookupParams, inv copilot.ToolInvocation) (string, error) {
return "RESULT: " + params.ID, nil
})
safeLookupTool.SkipPermission = true

didRunPermissionRequest := false
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) {
didRunPermissionRequest = true
return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindNoResult}, nil
},
Tools: []copilot.Tool{
safeLookupTool,
},
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}

_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use safe_lookup to look up 'test123'"})
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

answer, err := testharness.GetFinalAssistantMessage(t.Context(), session)
if err != nil {
t.Fatalf("Failed to get assistant message: %v", err)
}

if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "RESULT: test123") {
t.Errorf("Expected answer to contain 'RESULT: test123', got %v", answer.Data.Content)
}

if didRunPermissionRequest {
t.Errorf("Expected permission handler to NOT be called for skipPermission tool")
}
})

t.Run("overrides built-in tool with custom tool", func(t *testing.T) {
ctx.ConfigureForTest(t)

Expand Down
4 changes: 4 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ const (

// PermissionRequestResultKindDeniedInteractivelyByUser indicates the permission was denied interactively by the user.
PermissionRequestResultKindDeniedInteractivelyByUser PermissionRequestResultKind = "denied-interactively-by-user"

// PermissionRequestResultKindNoResult indicates no permission decision was made.
PermissionRequestResultKindNoResult PermissionRequestResultKind = "no-result"
)

// PermissionRequestResult represents the result of a permission request
Expand Down Expand Up @@ -414,6 +417,7 @@ type Tool struct {
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"`
SkipPermission bool `json:"skipPermission,omitempty"`
Handler ToolHandler `json:"-"`
}

Expand Down
13 changes: 13 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,19 @@ defineTool("edit_file", {
})
```

#### Skipping Permission Prompts

Set `skipPermission: true` on a tool definition to allow it to execute without triggering a permission prompt:

```ts
defineTool("safe_lookup", {
description: "A read-only lookup that needs no confirmation",
parameters: z.object({ id: z.string() }),
skipPermission: true,
handler: async ({ id }) => { /* your logic */ },
})
```

### System Message Customization

Control the system prompt using `systemMessage` in session config:
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export class CopilotClient {
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
skipPermission: tool.skipPermission,
})),
systemMessage: config.systemMessage,
availableTools: config.availableTools,
Expand Down Expand Up @@ -682,6 +683,7 @@ export class CopilotClient {
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool,
skipPermission: tool.skipPermission,
})),
provider: config.provider,
requestPermission: true,
Expand Down
5 changes: 5 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ export interface Tool<TArgs = unknown> {
* will return an error.
*/
overridesBuiltInTool?: boolean;
/**
* When true, the tool can execute without a permission prompt.
*/
skipPermission?: boolean;
}

/**
Expand All @@ -180,6 +184,7 @@ export function defineTool<T = unknown>(
parameters?: ZodSchema<T> | Record<string, unknown>;
handler: ToolHandler<T>;
overridesBuiltInTool?: boolean;
skipPermission?: boolean;
}
): Tool<T> {
return { name, ...config };
Expand Down
26 changes: 26 additions & 0 deletions nodejs/test/e2e/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,32 @@ describe("Custom tools", async () => {
expect(customToolRequests[0].toolName).toBe("encrypt_string");
});

it("skipPermission sent in tool definition", async () => {
let didRunPermissionRequest = false;
const session = await client.createSession({
onPermissionRequest: () => {
didRunPermissionRequest = true;
return { kind: "no-result" };
},
tools: [
defineTool("safe_lookup", {
description: "A safe lookup that skips permission",
parameters: z.object({
id: z.string().describe("ID to look up"),
}),
handler: ({ id }) => `RESULT: ${id}`,
skipPermission: true,
}),
],
});

const assistantMessage = await session.sendAndWait({
prompt: "Use safe_lookup to look up 'test123'",
});
expect(assistantMessage?.data.content).toContain("RESULT: test123");
expect(didRunPermissionRequest).toBe(false);
});

it("overrides built-in tool with custom tool", async () => {
const session = await client.createSession({
onPermissionRequest: approveAll,
Expand Down
10 changes: 10 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,16 @@ async def edit_file(params: EditFileParams) -> str:
# your logic
```

#### Skipping Permission Prompts

Set `skip_permission=True` on a tool definition to allow it to execute without triggering a permission prompt:

```python
@define_tool(name="safe_lookup", description="A read-only lookup that needs no confirmation", skip_permission=True)
async def safe_lookup(params: LookupParams) -> str:
# your logic
```

## Image Support

The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path:
Expand Down
4 changes: 4 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ async def create_session(self, config: SessionConfig) -> CopilotSession:
definition["parameters"] = tool.parameters
if tool.overrides_built_in_tool:
definition["overridesBuiltInTool"] = True
if tool.skip_permission:
definition["skipPermission"] = True
tool_defs.append(definition)

payload: dict[str, Any] = {}
Expand Down Expand Up @@ -697,6 +699,8 @@ async def resume_session(self, session_id: str, config: ResumeSessionConfig) ->
definition["parameters"] = tool.parameters
if tool.overrides_built_in_tool:
definition["overridesBuiltInTool"] = True
if tool.skip_permission:
definition["skipPermission"] = True
tool_defs.append(definition)

payload: dict[str, Any] = {"sessionId": session_id}
Expand Down
8 changes: 8 additions & 0 deletions python/copilot/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def define_tool(
*,
description: str | None = None,
overrides_built_in_tool: bool = False,
skip_permission: bool = False,
) -> Callable[[Callable[..., Any]], Tool]: ...


Expand All @@ -37,6 +38,7 @@ def define_tool(
handler: Callable[[T, ToolInvocation], R],
params_type: type[T],
overrides_built_in_tool: bool = False,
skip_permission: bool = False,
) -> Tool: ...


Expand All @@ -47,6 +49,7 @@ def define_tool(
handler: Callable[[Any, ToolInvocation], Any] | None = None,
params_type: type[BaseModel] | None = None,
overrides_built_in_tool: bool = False,
skip_permission: bool = False,
) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]:
"""
Define a tool with automatic JSON schema generation from Pydantic models.
Expand Down Expand Up @@ -79,6 +82,10 @@ def lookup_issue(params: LookupIssueParams) -> str:
handler: Optional handler function (if not using as decorator)
params_type: Optional Pydantic model type for parameters (inferred from
type hints when using as decorator)
overrides_built_in_tool: When True, explicitly indicates this tool is intended
to override a built-in tool of the same name. If not set and the
name clashes with a built-in tool, the runtime will return an error.
skip_permission: When True, the tool can execute without a permission prompt.

Returns:
A Tool instance
Expand Down Expand Up @@ -154,6 +161,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult:
parameters=schema,
handler=wrapped_handler,
overrides_built_in_tool=overrides_built_in_tool,
skip_permission=skip_permission,
)

# If handler is provided, call decorator immediately
Expand Down
1 change: 1 addition & 0 deletions python/copilot/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class Tool:
handler: ToolHandler
parameters: dict[str, Any] | None = None
overrides_built_in_tool: bool = False
skip_permission: bool = False


# System message configuration (discriminated union)
Expand Down
28 changes: 28 additions & 0 deletions python/e2e/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]:
assert "135460" in response_content.replace(",", "")
assert "204356" in response_content.replace(",", "")

async def test_skippermission_sent_in_tool_definition(self, ctx: E2ETestContext):
class LookupParams(BaseModel):
id: str = Field(description="ID to look up")

@define_tool(
"safe_lookup",
description="A safe lookup that skips permission",
skip_permission=True,
)
def safe_lookup(params: LookupParams, invocation: ToolInvocation) -> str:
return f"RESULT: {params.id}"

did_run_permission_request = False

def tracking_handler(request, invocation):
nonlocal did_run_permission_request
did_run_permission_request = True
return PermissionRequestResult(kind="no-result")

session = await ctx.client.create_session(
{"tools": [safe_lookup], "on_permission_request": tracking_handler}
)

await session.send({"prompt": "Use safe_lookup to look up 'test123'"})
assistant_message = await get_final_assistant_message(session)
assert "RESULT: test123" in assistant_message.data.content
assert not did_run_permission_request

async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext):
class GrepParams(BaseModel):
query: str = Field(description="Search query")
Expand Down
Loading
Loading