diff --git a/.lastmerge b/.lastmerge index c5649a512..717eb5b9d 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 +ea90f076091371810c66d05590f65e2863f79bdf diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index b2a798ada..723ab2832 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -20,6 +20,7 @@ import java.util.regex.Pattern; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.TelemetryConfig; /** * Manages the lifecycle of the Copilot CLI server process. @@ -110,6 +111,28 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); } + // Set telemetry environment variables if configured + TelemetryConfig telemetry = options.getTelemetry(); + if (telemetry != null) { + pb.environment().put("COPILOT_OTEL_ENABLED", "true"); + if (telemetry.getOtlpEndpoint() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); + } + if (telemetry.getFilePath() != null) { + pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); + } + if (telemetry.getExporterType() != null) { + pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType()); + } + if (telemetry.getSourceName() != null) { + pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName()); + } + if (telemetry.getCaptureContent() != null) { + pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + telemetry.getCaptureContent() ? "true" : "false"); + } + } + Process process = pb.start(); // Forward stderr to logger in background diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 452e82671..82405062a 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -119,7 +119,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; - private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; + private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS; /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -709,6 +709,9 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques invocation.setSessionId(sessionId); handler.handle(permissionRequest, invocation).thenAccept(result -> { try { + if (PermissionRequestResultKind.NO_RESULT.equals(result.getKind())) { + return; // Leave the permission request unanswered (for extensions) + } rpc.invoke("session.permissions.handlePendingPermissionRequest", Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class); } catch (Exception e) { @@ -1000,7 +1003,36 @@ public CompletableFuture abort() { * @since 1.0.11 */ public CompletableFuture setModel(String model) { + return setModel(model, null); + } + + /** + * Changes the model and reasoning effort for this session. + *

+ * The new model and reasoning effort take effect for the next message. + * Conversation history is preserved. + * + *

{@code
+     * session.setModel("gpt-4.1", "high").get();
+     * }
+ * + * @param model + * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * @param reasoningEffort + * the reasoning effort level (e.g., {@code "low"}, {@code "medium"}, + * {@code "high"}, {@code "xhigh"}), or {@code null} to use the + * default + * @return a future that completes when the model switch is acknowledged + * @throws IllegalStateException + * if this session has been terminated + * @since 1.1.0 + */ + public CompletableFuture setModel(String model, String reasoningEffort) { ensureNotTerminated(); + if (reasoningEffort != null) { + return rpc.invoke("session.model.switchTo", + Map.of("sessionId", sessionId, "modelId", model, "reasoningEffort", reasoningEffort), Void.class); + } return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class); } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java index b7c3dca21..9cac455f4 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java @@ -29,11 +29,12 @@ * Example: * *
{@code
- * // Default: propagate errors (stop dispatch on first error, log the error)
- * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS);
- *
- * // Opt-in to suppress errors (continue dispatching, log each error)
+ * // Default: suppress errors (continue dispatching after errors, log each
+ * // error)
  * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS);
+ *
+ * // Opt-in to propagate errors (stop dispatch on first error, log the error)
+ * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS);
  * }
* * @see CopilotSession#setEventErrorPolicy(EventErrorPolicy) @@ -44,7 +45,7 @@ public enum EventErrorPolicy { /** * Suppress errors: log the error and continue dispatching to remaining - * listeners. + * listeners (default). *

* When a handler throws an exception, the error is logged at * {@link java.util.logging.Level#WARNING} and remaining handlers still execute. @@ -54,8 +55,7 @@ public enum EventErrorPolicy { SUPPRESS_AND_LOG_ERRORS, /** - * Propagate errors: log the error and stop dispatch on first listener error - * (default). + * Propagate errors: log the error and stop dispatch on first listener error. *

* When a handler throws an exception, the error is logged at * {@link java.util.logging.Level#WARNING} and no further handlers are invoked. diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 5127f6eee..354533566 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -55,7 +55,7 @@ public abstract sealed class AbstractSessionEvent permits SessionModelChangeEvent, SessionModeChangedEvent, SessionPlanChangedEvent, SessionWorkspaceFileChangedEvent, SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, SessionUsageInfoEvent, SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, SessionContextChangedEvent, - SessionTaskCompleteEvent, + SessionTaskCompleteEvent, SessionToolsUpdatedEvent, SessionBackgroundTasksChangedEvent, // Assistant events AssistantTurnStartEvent, AssistantIntentEvent, AssistantReasoningEvent, AssistantReasoningDeltaEvent, AssistantMessageEvent, AssistantMessageDeltaEvent, AssistantStreamingDeltaEvent, AssistantTurnEndEvent, diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java index 8eb11f5b8..fdf8db197 100644 --- a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -38,6 +38,7 @@ public void setData(ExternalToolRequestedData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record ExternalToolRequestedData(@JsonProperty("requestId") String requestId, @JsonProperty("sessionId") String sessionId, @JsonProperty("toolCallId") String toolCallId, - @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments) { + @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments, + @JsonProperty("traceparent") String traceparent, @JsonProperty("tracestate") String tracestate) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java new file mode 100644 index 000000000..4c8e9d875 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.background_tasks_changed + *

+ * Emitted when the set of background tasks for the session changes. + * + * @since 1.1.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionBackgroundTasksChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private Object data; + + @Override + public String getType() { + return "session.background_tasks_changed"; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index 75971b29e..832187266 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -66,6 +66,8 @@ public class SessionEventParser { TYPE_MAP.put("session.compaction_complete", SessionCompactionCompleteEvent.class); TYPE_MAP.put("session.context_changed", SessionContextChangedEvent.class); TYPE_MAP.put("session.task_complete", SessionTaskCompleteEvent.class); + TYPE_MAP.put("session.tools_updated", SessionToolsUpdatedEvent.class); + TYPE_MAP.put("session.background_tasks_changed", SessionBackgroundTasksChangedEvent.class); TYPE_MAP.put("user.message", UserMessageEvent.class); TYPE_MAP.put("pending_messages.modified", PendingMessagesModifiedEvent.class); TYPE_MAP.put("assistant.turn_start", AssistantTurnStartEvent.class); diff --git a/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java index 57d0b5499..8b992114f 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java @@ -33,6 +33,8 @@ public void setData(SessionModelChangeData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionModelChangeData(@JsonProperty("previousModel") String previousModel, - @JsonProperty("newModel") String newModel) { + @JsonProperty("newModel") String newModel, + @JsonProperty("previousReasoningEffort") String previousReasoningEffort, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java index bf305bc30..b8d60ba64 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java @@ -35,6 +35,7 @@ public void setData(SessionResumeData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionResumeData(@JsonProperty("resumeTime") OffsetDateTime resumeTime, - @JsonProperty("eventCount") double eventCount) { + @JsonProperty("eventCount") double eventCount, @JsonProperty("selectedModel") String selectedModel, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java index 317b4a470..135b523eb 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java @@ -36,6 +36,7 @@ public void setData(SessionStartData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionStartData(@JsonProperty("sessionId") String sessionId, @JsonProperty("version") double version, @JsonProperty("producer") String producer, @JsonProperty("copilotVersion") String copilotVersion, - @JsonProperty("startTime") OffsetDateTime startTime, @JsonProperty("selectedModel") String selectedModel) { + @JsonProperty("startTime") OffsetDateTime startTime, @JsonProperty("selectedModel") String selectedModel, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java new file mode 100644 index 000000000..800a51c0f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.tools_updated + *

+ * Emitted when the set of available tools for the session changes. + * + * @since 1.1.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionToolsUpdatedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private Object data; + + @Override + public String getType() { + return "session.tools_updated"; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 4fd55d3ba..12b60710f 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -42,11 +42,17 @@ public class CopilotClientOptions { private String cliUrl; private String logLevel = "info"; private boolean autoStart = true; - private boolean autoRestart = true; + /** + * @deprecated AutoRestart has no effect and will be removed in a future + * release. + */ + @Deprecated + private boolean autoRestart = false; private Map environment; private String gitHubToken; private Boolean useLoggedInUser; private Supplier>> onListModels; + private TelemetryConfig telemetry; /** * Gets the path to the Copilot CLI executable. @@ -236,8 +242,11 @@ public CopilotClientOptions setAutoStart(boolean autoStart) { /** * Returns whether the client should automatically restart the server on crash. * - * @return {@code true} to auto-restart (default), {@code false} otherwise + * @return {@code true} to auto-restart, {@code false} otherwise + * @deprecated AutoRestart has no effect and will be removed in a future + * release. */ + @Deprecated public boolean isAutoRestart() { return autoRestart; } @@ -249,7 +258,10 @@ public boolean isAutoRestart() { * @param autoRestart * {@code true} to auto-restart, {@code false} otherwise * @return this options instance for method chaining + * @deprecated AutoRestart has no effect and will be removed in a future + * release. */ + @Deprecated public CopilotClientOptions setAutoRestart(boolean autoRestart) { this.autoRestart = autoRestart; return this; @@ -378,6 +390,30 @@ public CopilotClientOptions setOnListModels(Supplier + * When set to a non-{@code null} instance, the CLI server is started with + * OpenTelemetry instrumentation enabled. + * + * @param telemetry + * the telemetry configuration, or {@code null} to disable + * @return this options instance for method chaining + */ + public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { + this.telemetry = telemetry; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -404,6 +440,7 @@ public CopilotClientOptions clone() { copy.gitHubToken = this.gitHubToken; copy.useLoggedInUser = this.useLoggedInUser; copy.onListModels = this.onListModels; + copy.telemetry = this.telemetry; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java index f85a0df1c..c08823b97 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java @@ -51,6 +51,14 @@ public final class PermissionRequestResultKind { public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind( "denied-interactively-by-user"); + /** + * Leave the pending permission request unanswered (for extensions that do not + * handle a particular permission). + * + * @since 1.1.0 + */ + public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); + private final String value; /** diff --git a/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java new file mode 100644 index 000000000..f072fb501 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * OpenTelemetry configuration for the Copilot CLI server. + *

+ * When set on {@link CopilotClientOptions#setTelemetry(TelemetryConfig)}, the + * CLI server is started with OpenTelemetry instrumentation enabled. The + * configuration maps to the following environment variables: + * + *

    + *
  • {@link #setOtlpEndpoint(String)} → + * {@code OTEL_EXPORTER_OTLP_ENDPOINT}
  • + *
  • {@link #setFilePath(String)} → + * {@code COPILOT_OTEL_FILE_EXPORTER_PATH}
  • + *
  • {@link #setExporterType(String)} → + * {@code COPILOT_OTEL_EXPORTER_TYPE}
  • + *
  • {@link #setSourceName(String)} → {@code COPILOT_OTEL_SOURCE_NAME}
  • + *
  • {@link #setCaptureContent(Boolean)} → + * {@code OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT}
  • + *
+ * + *

Example Usage

+ * + *
{@code
+ * var options = new CopilotClientOptions()
+ * 		.setTelemetry(new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setExporterType("otlp-http"));
+ * }
+ * + * @see CopilotClientOptions#setTelemetry(TelemetryConfig) + * @since 1.1.0 + */ +public class TelemetryConfig { + + private String otlpEndpoint; + private String filePath; + private String exporterType; + private String sourceName; + private Boolean captureContent; + + /** + * Gets the OTLP exporter endpoint URL. + * + * @return the OTLP endpoint, or {@code null} if not set + */ + public String getOtlpEndpoint() { + return otlpEndpoint; + } + + /** + * Sets the OTLP exporter endpoint URL. + *

+ * Maps to the {@code OTEL_EXPORTER_OTLP_ENDPOINT} environment variable. + * + * @param otlpEndpoint + * the OTLP endpoint URL (e.g., {@code "http://localhost:4318"}) + * @return this config instance for method chaining + */ + public TelemetryConfig setOtlpEndpoint(String otlpEndpoint) { + this.otlpEndpoint = otlpEndpoint; + return this; + } + + /** + * Gets the file path for the file exporter. + * + * @return the file path, or {@code null} if not set + */ + public String getFilePath() { + return filePath; + } + + /** + * Sets the file path for the file exporter. + *

+ * Maps to the {@code COPILOT_OTEL_FILE_EXPORTER_PATH} environment variable. + * + * @param filePath + * the output file path for telemetry data + * @return this config instance for method chaining + */ + public TelemetryConfig setFilePath(String filePath) { + this.filePath = filePath; + return this; + } + + /** + * Gets the exporter type. + * + * @return the exporter type, or {@code null} if not set + */ + public String getExporterType() { + return exporterType; + } + + /** + * Sets the exporter type. + *

+ * Maps to the {@code COPILOT_OTEL_EXPORTER_TYPE} environment variable. Typical + * values are {@code "otlp-http"} or {@code "file"}. + * + * @param exporterType + * the exporter type string + * @return this config instance for method chaining + */ + public TelemetryConfig setExporterType(String exporterType) { + this.exporterType = exporterType; + return this; + } + + /** + * Gets the source name for telemetry spans. + * + * @return the source name, or {@code null} if not set + */ + public String getSourceName() { + return sourceName; + } + + /** + * Sets the source name for telemetry spans. + *

+ * Maps to the {@code COPILOT_OTEL_SOURCE_NAME} environment variable. + * + * @param sourceName + * the source name + * @return this config instance for method chaining + */ + public TelemetryConfig setSourceName(String sourceName) { + this.sourceName = sourceName; + return this; + } + + /** + * Returns whether to capture message content as part of telemetry. + * + * @return {@code true} to capture content, {@code false} to suppress it, or + * {@code null} to use the default + */ + public Boolean getCaptureContent() { + return captureContent; + } + + /** + * Sets whether to capture message content as part of telemetry. + *

+ * Maps to the {@code OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT} + * environment variable. + * + * @param captureContent + * {@code true} to capture content, {@code false} to suppress it + * @return this config instance for method chaining + */ + public TelemetryConfig setCaptureContent(Boolean captureContent) { + this.captureContent = captureContent; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java index 9b3087a42..5b9a06300 100644 --- a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java +++ b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java @@ -52,6 +52,10 @@ * when {@code true}, indicates that this tool intentionally * overrides a built-in CLI tool with the same name; {@code null} or * {@code false} means the tool is purely custom + * @param skipPermission + * when {@code true}, the CLI will skip the permission prompt for + * invocations of this tool; {@code null} or {@code false} uses the + * default permission behaviour * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -59,7 +63,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, - @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool) { + @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, + @JsonProperty("skipPermission") Boolean skipPermission) { /** * Creates a tool definition with a JSON schema for parameters. @@ -79,7 +84,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null); + return new ToolDefinition(name, description, schema, handler, null, null); } /** @@ -103,6 +108,29 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true); + return new ToolDefinition(name, description, schema, handler, true, null); + } + + /** + * Creates a tool definition that skips the CLI permission prompt. + *

+ * Use this factory method for tools that are safe to invoke without user + * confirmation. The CLI will not present a permission prompt before calling the + * handler. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @return a new tool definition with the skip-permission flag set + * @since 1.1.0 + */ + public static ToolDefinition createWithSkipPermission(String name, String description, Map schema, + ToolHandler handler) { + return new ToolDefinition(name, description, schema, handler, null, true); } } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 42b2c8964..868db2166 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -110,6 +110,36 @@ var session = client.createSession( ).get(); ``` +### Skipping Permission Prompts for Safe Tools + +For tools that are inherently safe (e.g., read-only lookups), you can skip the CLI permission +prompt by using `ToolDefinition.createWithSkipPermission()`. The CLI will invoke the tool +directly without presenting a permission request. + +```java +var safeLookupTool = ToolDefinition.createWithSkipPermission( + "safe_lookup", + "Look up a value by ID — read-only, no side effects", + Map.of( + "type", "object", + "properties", Map.of( + "id", Map.of("type", "string", "description", "Lookup ID") + ), + "required", List.of("id") + ), + invocation -> { + String id = (String) invocation.getArguments().get("id"); + return CompletableFuture.completedFuture("Value for " + id); + } +); + +var session = client.createSession( + new SessionConfig() + .setTools(List.of(safeLookupTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) +).get(); +``` + --- ## Switching Models Mid-Session @@ -126,6 +156,9 @@ var session = client.createSession( // Switch to a different model mid-conversation session.setModel("gpt-4.1").get(); +// Switch model and set reasoning effort at the same time +session.setModel("claude-sonnet-4.6", "high").get(); + // Next message will use the new model session.sendAndWait(new MessageOptions().setPrompt("Continue with the new model")).get(); ``` @@ -624,6 +657,10 @@ The `PermissionRequestResultKind` class provides well-known constants for common | `PermissionRequestResultKind.DENIED_BY_RULES` | `"denied-by-rules"` | Denied by policy rules | | `PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER` | `"denied-no-approval-rule-and-could-not-request-from-user"` | No rule and user could not be prompted | | `PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER` | `"denied-interactively-by-user"` | User denied interactively | +| `PermissionRequestResultKind.NO_RESULT` | `"no-result"` | Leave the request unanswered (pass to another handler) | + +Use `NO_RESULT` in extension scenarios where your handler is not responsible for a particular +permission request and wants to let another handler (or the CLI default) handle it instead. You can also pass a raw string to `setKind(String)` for custom or extension values. Use [`PermissionHandler.APPROVE_ALL`](apidocs/com/github/copilot/sdk/json/PermissionHandler.html) to approve all @@ -890,22 +927,22 @@ session.setEventErrorHandler(null); ### Event Error Policy -By default, the SDK propagates errors and stops dispatch on the first handler -error (`EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS`). You can opt in to -**suppress** errors so that all handlers execute despite errors: +By default, the SDK **suppresses** handler errors and continues dispatching to all remaining +handlers (`EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS`). Errors are always logged at `WARNING` +level. You can opt in to the strict **propagate** policy to stop dispatch on the first error: ```java -session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); ``` The `EventErrorHandler` (if set) is always invoked regardless of the policy — the policy only controls whether remaining handlers execute after the error -handler returns. Errors are always logged at `WARNING` level. +handler returns. | Policy | Behavior | |---|---| -| `PROPAGATE_AND_LOG_ERRORS` (default) | Log the error; dispatch halts after the first error | -| `SUPPRESS_AND_LOG_ERRORS` | Log the error; all remaining handlers execute | +| `SUPPRESS_AND_LOG_ERRORS` (default) | Log the error; all remaining handlers execute | +| `PROPAGATE_AND_LOG_ERRORS` | Log the error; dispatch halts after the first error | You can combine both for full control: @@ -919,17 +956,46 @@ session.setEventErrorHandler((event, ex) -> Or switch policies dynamically: ```java -// Start strict (propagate errors, stop dispatch) -session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); - -// Later, switch to lenient mode (suppress errors, continue) +// Lenient (suppress errors, continue) — the default session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + +// Strict: stop dispatch on first error +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); ``` See [EventErrorPolicy](apidocs/com/github/copilot/sdk/EventErrorPolicy.html) and [EventErrorHandler](apidocs/com/github/copilot/sdk/EventErrorHandler.html) Javadoc for details. --- +## OpenTelemetry Support + +Enable distributed tracing for the Copilot CLI server by setting a `TelemetryConfig` on +`CopilotClientOptions`. When configured, the CLI is started with OpenTelemetry instrumentation +enabled, producing traces for each request. + +```java +var client = new CopilotClient( + new CopilotClientOptions() + .setTelemetry(new TelemetryConfig() + .setOtlpEndpoint("http://localhost:4318") + .setExporterType("otlp-http")) +); +``` + +### TelemetryConfig Options + +| Option | Environment Variable | Description | +|--------|----------------------|-------------| +| `setOtlpEndpoint(String)` | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP exporter endpoint URL | +| `setFilePath(String)` | `COPILOT_OTEL_FILE_EXPORTER_PATH` | File path for the file exporter | +| `setExporterType(String)` | `COPILOT_OTEL_EXPORTER_TYPE` | Exporter type: `"otlp-http"` or `"file"` | +| `setSourceName(String)` | `COPILOT_OTEL_SOURCE_NAME` | Source name for telemetry spans | +| `setCaptureContent(Boolean)` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Whether to capture message content | + +See [TelemetryConfig](apidocs/com/github/copilot/sdk/json/TelemetryConfig.html) Javadoc for details. + +--- + ## Next Steps - 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort diff --git a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java index b21f96e83..5d68a560e 100644 --- a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java +++ b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java @@ -28,6 +28,7 @@ void wellKnownKinds_haveExpectedValues() { PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER.getValue()); assertEquals("denied-interactively-by-user", PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER.getValue()); + assertEquals("no-result", PermissionRequestResultKind.NO_RESULT.getValue()); } @Test @@ -113,7 +114,7 @@ void jsonRoundTrip_allWellKnownKinds() throws Exception { PermissionRequestResultKind[] kinds = {PermissionRequestResultKind.APPROVED, PermissionRequestResultKind.DENIED_BY_RULES, PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER, - PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER,}; + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER, PermissionRequestResultKind.NO_RESULT,}; for (PermissionRequestResultKind kind : kinds) { var result = new PermissionRequestResult().setKind(kind); String json = mapper.writeValueAsString(result); diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 2e9da4fd2..df8cd9938 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -179,7 +179,7 @@ void testHandlerReceivesCorrectEventData() { }); SessionStartEvent startEvent = createSessionStartEvent(); - startEvent.setData(new SessionStartEvent.SessionStartData("my-session-123", 0, null, null, null, null)); + startEvent.setData(new SessionStartEvent.SessionStartData("my-session-123", 0, null, null, null, null, null)); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); @@ -384,8 +384,8 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { // ==================================================================== @Test - void testDefaultPolicyPropagatesAndLogs() { - // Default policy is PROPAGATE_AND_LOG_ERRORS — stops dispatch on first error + void testDefaultPolicySuppressesAndLogs() { + // Default policy is SUPPRESS_AND_LOG_ERRORS — continues dispatch after errors var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); @@ -394,7 +394,7 @@ void testDefaultPolicyPropagatesAndLogs() { sessionLogger.setLevel(Level.OFF); try { - // Both handlers throw — with PROPAGATE only one should execute + // Both handlers throw — with SUPPRESS both should execute session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("boom 1"); @@ -407,9 +407,9 @@ void testDefaultPolicyPropagatesAndLogs() { assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Only one handler should execute (default PROPAGATE_AND_LOG_ERRORS policy) + // Both handlers should execute (default SUPPRESS_AND_LOG_ERRORS policy) int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with default PROPAGATE_AND_LOG_ERRORS policy"); + assertEquals(2, totalCalls, "Both handlers should execute with default SUPPRESS_AND_LOG_ERRORS policy"); } finally { sessionLogger.setLevel(originalLevel); } @@ -582,7 +582,7 @@ void testErrorHandlerReceivesCorrectEventType() { // ==================================================================== @Test - void testDefaultPolicyPropagatesOnError() { + void testDefaultPolicySuppressesOnError() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); @@ -595,7 +595,7 @@ void testDefaultPolicyPropagatesOnError() { // just consume }); - // Both handlers throw — with PROPAGATE only one should execute + // Both handlers throw — with SUPPRESS both should execute session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error 1"); @@ -608,9 +608,9 @@ void testDefaultPolicyPropagatesOnError() { dispatchEvent(createAssistantMessageEvent("Test")); - // Default is PROPAGATE_AND_LOG_ERRORS — only one handler runs + // Default is SUPPRESS_AND_LOG_ERRORS — both handlers run int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with default PROPAGATE_AND_LOG_ERRORS policy"); + assertEquals(2, totalCalls, "Both handlers should execute with default SUPPRESS_AND_LOG_ERRORS policy"); } finally { sessionLogger.setLevel(originalLevel); } @@ -855,7 +855,7 @@ private SessionStartEvent createSessionStartEvent() { private SessionStartEvent createSessionStartEvent(String sessionId) { var event = new SessionStartEvent(); - var data = new SessionStartEvent.SessionStartData(sessionId, 0, null, null, null, null); + var data = new SessionStartEvent.SessionStartData(sessionId, 0, null, null, null, null, null); event.setData(data); return event; } diff --git a/src/test/java/com/github/copilot/sdk/ToolsTest.java b/src/test/java/com/github/copilot/sdk/ToolsTest.java index 538da74d2..731daf83d 100644 --- a/src/test/java/com/github/copilot/sdk/ToolsTest.java +++ b/src/test/java/com/github/copilot/sdk/ToolsTest.java @@ -360,4 +360,52 @@ void testOverridesBuiltInToolWithCustomTool() throws Exception { session.close(); } } + + /** + * Verifies that a tool created with {@code skipPermission=true} is invoked + * without triggering a permission request. + * + * @see Snapshot: tools/skippermission_sent_in_tool_definition + */ + @Test + void testSkipPermissionSentInToolDefinition() throws Exception { + ctx.configureForTest("tools", "skippermission_sent_in_tool_definition"); + + var parameters = new HashMap(); + var properties = new HashMap(); + properties.put("id", Map.of("type", "string", "description", "Lookup ID")); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", List.of("id")); + + boolean[] didRunPermissionRequest = {false}; + + ToolDefinition safeLookup = ToolDefinition.createWithSkipPermission("safe_lookup", + "A tool that skips permission", parameters, (invocation) -> { + Map args = invocation.getArguments(); + String id = (String) args.get("id"); + return CompletableFuture.completedFuture("RESULT: " + id); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setTools(List.of(safeLookup)).setOnPermissionRequest((request, invocation) -> { + didRunPermissionRequest[0] = true; + var result = new PermissionRequestResult(); + result.setKind(com.github.copilot.sdk.json.PermissionRequestResultKind.NO_RESULT); + return CompletableFuture.completedFuture(result); + })).get(); + + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt("Use safe_lookup to look up 'test123'")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertTrue(response.getData().content().contains("RESULT"), + "Response should contain RESULT: " + response.getData().content()); + assertFalse(didRunPermissionRequest[0], "Permission handler should not be called for skip-permission tool"); + + session.close(); + } + } }