From 58c38896aa1dfaf3f74425f73a87db177c3d9493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:06:08 +0000 Subject: [PATCH 1/4] Initial plan From 727c410fad50052ef3e121b7c832a061e6bb6bc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:26:09 +0000 Subject: [PATCH 2/4] Add hidden ToolInvocation injection for Java @CopilotTool methods Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- java/README.md | 19 ++++ .../copilot/tool/CopilotToolProcessor.java | 63 +++++++++-- .../rpc/ToolDefinitionFromObjectTest.java | 58 ++++++++++ ...InvocationAwareTools$$CopilotToolMeta.java | 37 ++++++ .../rpc/fixtures/InvocationAwareTools.java | 31 +++++ ...taticInvocationTools$$CopilotToolMeta.java | 29 +++++ .../rpc/fixtures/StaticInvocationTools.java | 22 ++++ .../tool/CopilotToolProcessorTest.java | 107 ++++++++++++++++++ 8 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java create mode 100644 java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java create mode 100644 java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java create mode 100644 java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java diff --git a/java/README.md b/java/README.md index 28544b014..5184c20c2 100644 --- a/java/README.md +++ b/java/README.md @@ -132,6 +132,25 @@ Or run it directly from the repository: jbang https://github.com/github/copilot-sdk/blob/main/java/jbang-example.java ``` +## Annotation-based tools and `ToolInvocation` context + +When you define tools with `@CopilotTool`, parameters of type `ToolInvocation` are injected as runtime context and are not exposed in the tool schema. + +```java +import com.github.copilot.rpc.ToolInvocation; +import com.github.copilot.tool.CopilotTool; +import com.github.copilot.tool.Param; + +class ProgressTools { + @CopilotTool("Reports the current phase and session") + public String reportProgress( + @Param("Current phase") String phase, + ToolInvocation invocation) { + return "phase=" + phase + ", sessionId=" + invocation.getSessionId(); + } +} +``` + ## Memory Sessions can opt into persistent memory, allowing the agent to read and write memory across turns. Memory is configured per session and applies to both `createSession` and `resumeSession`. diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 08a16bf39..3238985aa 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -48,6 +48,8 @@ @CopilotExperimental public class CopilotToolProcessor extends AbstractProcessor { + private static final String TOOL_INVOCATION_TYPE = "com.github.copilot.rpc.ToolInvocation"; + private final SchemaGenerator schemaGenerator = new SchemaGenerator(); @Override @@ -67,7 +69,17 @@ public boolean process(Set annotations, RoundEnvironment } // Validate @Param conflicts + int toolInvocationParamCount = 0; for (VariableElement param : method.getParameters()) { + if (isToolInvocationType(param.asType())) { + toolInvocationParamCount++; + if (param.getAnnotation(Param.class) != null) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@Param is not supported on ToolInvocation parameters because ToolInvocation is injected runtime context and not part of the tool schema", + param); + } + continue; + } Param paramAnnotation = param.getAnnotation(Param.class); if (paramAnnotation != null && paramAnnotation.required() && !paramAnnotation.defaultValue().isEmpty()) { @@ -88,10 +100,16 @@ public boolean process(Set annotations, RoundEnvironment param); } } + if (toolInvocationParamCount > 1) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "@CopilotTool methods may declare at most one ToolInvocation parameter; ToolInvocation is injected runtime context and not part of the tool schema", + method); + } // Validate single-record wrapper parameter metadata - if (method.getParameters().size() == 1) { - VariableElement singleParam = method.getParameters().get(0); + List schemaParameters = getSchemaParameters(method.getParameters()); + if (schemaParameters.size() == 1) { + VariableElement singleParam = schemaParameters.get(0); if (isRecord(singleParam.asType())) { Param paramAnnotation = singleParam.getAnnotation(Param.class); if (paramAnnotation != null) { @@ -262,18 +280,20 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { } private String generateSchemaWithParamMetadata(List parameters) { - if (parameters.isEmpty()) { + List schemaParameters = getSchemaParameters(parameters); + + if (schemaParameters.isEmpty()) { return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())"; } - if (parameters.size() == 1 && isRecord(parameters.get(0).asType())) { - return schemaGenerator.generateSchemaSource(parameters.get(0).asType(), processingEnv.getTypeUtils(), + if (schemaParameters.size() == 1 && isRecord(schemaParameters.get(0).asType())) { + return schemaGenerator.generateSchemaSource(schemaParameters.get(0).asType(), processingEnv.getTypeUtils(), processingEnv.getElementUtils()); } List propertyEntries = new ArrayList<>(); List requiredNames = new ArrayList<>(); - for (VariableElement param : parameters) { + for (VariableElement param : schemaParameters) { String paramName = getParamName(param); TypeMirror paramType = param.asType(); Param paramAnnotation = param.getAnnotation(Param.class); @@ -304,6 +324,20 @@ private String generateSchemaWithParamMetadata(List p return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; } + private List getSchemaParameters(List parameters) { + List filtered = new ArrayList<>(); + for (VariableElement param : parameters) { + if (!isToolInvocationType(param.asType())) { + filtered.add(param); + } + } + return filtered; + } + + private boolean isToolInvocationType(TypeMirror type) { + return TOOL_INVOCATION_TYPE.equals(processingEnv.getTypeUtils().erasure(type).toString()); + } + private String buildPropertySchema(String typeSchema, Param paramAnnotation, TypeMirror paramType) { if (paramAnnotation == null) { return typeSchema; @@ -328,20 +362,21 @@ private String buildPropertySchema(String typeSchema, Param paramAnnotation, Typ private String generateLambdaBody(ExecutableElement method) { List params = method.getParameters(); + List schemaParameters = getSchemaParameters(params); StringBuilder sb = new StringBuilder(); // Generate argument extraction - if (!params.isEmpty()) { + if (!schemaParameters.isEmpty()) { // Check if single-record-parameter shortcut applies - if (params.size() == 1 && isRecord(params.get(0).asType())) { - String typeName = getTypeString(params.get(0).asType()); - String paramName = params.get(0).getSimpleName().toString(); + if (schemaParameters.size() == 1 && isRecord(schemaParameters.get(0).asType())) { + String typeName = getTypeString(schemaParameters.get(0).asType()); + String paramName = schemaParameters.get(0).getSimpleName().toString(); sb.append(" ").append(typeName).append(" ").append(paramName) .append(" = mapper.convertValue(invocation.getArguments(), ").append(typeName) .append(".class);\n"); } else { sb.append("Map args = invocation.getArguments();\n"); - for (VariableElement param : params) { + for (VariableElement param : schemaParameters) { String paramName = getParamName(param); String varName = param.getSimpleName().toString(); TypeMirror paramType = param.asType(); @@ -404,7 +439,11 @@ private String generateArgList(List params) { if (i > 0) { sb.append(", "); } - sb.append(params.get(i).getSimpleName().toString()); + if (isToolInvocationType(params.get(i).asType())) { + sb.append("invocation"); + } else { + sb.append(params.get(i).getSimpleName().toString()); + } } return sb.toString(); } diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index 25765e057..aafa96649 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -27,10 +27,12 @@ import com.github.copilot.rpc.fixtures.ArgCoercionTools; import com.github.copilot.rpc.fixtures.DateTimeTools; import com.github.copilot.rpc.fixtures.DefaultValueTools; +import com.github.copilot.rpc.fixtures.InvocationAwareTools; import com.github.copilot.rpc.fixtures.MultiReturnTools; import com.github.copilot.rpc.fixtures.OptionalParamTools; import com.github.copilot.rpc.fixtures.OverrideTools; import com.github.copilot.rpc.fixtures.SimpleTools; +import com.github.copilot.rpc.fixtures.StaticInvocationTools; import com.github.copilot.rpc.fixtures.StaticTools; /** @@ -309,6 +311,62 @@ void fromObject_optionalLongAbsent() throws Exception { assertEquals("100", result); } + // ── Test 11: ToolInvocation injection ─────────────────────────────────────── + + @Test + void fromObject_toolInvocationInjection_instanceMethod() throws Exception { + var instance = new InvocationAwareTools(); + var tools = ToolDefinition.fromObject(instance); + var tool = findTool(tools, "report_progress"); + assertNotNull(tool); + + var result = tool.handler().invoke(createInvocation("report_progress", Map.of("phase", "analyzing")) + .setSessionId("session-123").setToolCallId("call-456")).get(); + assertEquals("phase=analyzing,sessionId=session-123,toolCallId=call-456,toolName=report_progress", result); + } + + @Test + void fromObject_toolInvocationInjection_schemaExcludesToolInvocation() { + var tools = ToolDefinition.fromObject(new InvocationAwareTools()); + var tool = findTool(tools, "report_progress"); + assertNotNull(tool); + + @SuppressWarnings("unchecked") + var schema = (Map) tool.parameters(); + @SuppressWarnings("unchecked") + var properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + var required = (List) schema.get("required"); + + assertTrue(properties.containsKey("phase")); + assertFalse(properties.containsKey("invocation")); + assertFalse(properties.containsKey("toolInvocation")); + assertEquals(List.of("phase"), required); + } + + @Test + void fromObject_toolInvocationInjection_asyncMethod() throws Exception { + var instance = new InvocationAwareTools(); + var tools = ToolDefinition.fromObject(instance); + var tool = findTool(tools, "report_progress_async"); + assertNotNull(tool); + + var result = tool.handler().invoke(createInvocation("report_progress_async", Map.of("phase", "planning")) + .setSessionId("session-789").setToolCallId("call-012")).get(); + assertEquals("async phase=planning,sessionId=session-789,toolCallId=call-012", result); + } + + @Test + void fromClass_toolInvocationInjection_staticMethod() throws Exception { + var tools = ToolDefinition.fromClass(StaticInvocationTools.class); + var tool = findTool(tools, "report_static"); + assertNotNull(tool); + + var result = tool.handler().invoke(createInvocation("report_static", Map.of("phase", "completed")) + .setSessionId("session-321").setToolCallId("call-654")).get(); + assertEquals("phase=completed,sessionId=session-321,toolCallId=call-654,toolName=report_static", result); + } + // ── Helpers ───────────────────────────────────────────────────────────────── private static ToolDefinition findTool(List tools, String name) { diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java new file mode 100644 index 000000000..e01ecd96c --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java @@ -0,0 +1,37 @@ +// Hand-written test fixture mimicking CopilotToolProcessor output for ToolInvocation injection. +package com.github.copilot.rpc.fixtures; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.ToolDefinition; +import com.github.copilot.tool.CopilotToolMetadataProvider; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class InvocationAwareTools$$CopilotToolMeta implements CopilotToolMetadataProvider { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public List definitions(InvocationAwareTools instance, ObjectMapper mapper) { + return List.of(new ToolDefinition("report_progress", "Reports progress with invocation context", + Map.of("type", "object", "properties", + Map.ofEntries(Map.entry("phase", Map.of("type", "string", "description", "Current phase"))), + "required", List.of("phase")), + invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + return CompletableFuture.completedFuture(instance.reportProgress(phase, invocation)); + }, null, null, null), + new ToolDefinition("report_progress_async", "Reports progress asynchronously with invocation context", + Map.of("type", "object", "properties", + Map.ofEntries( + Map.entry("phase", Map.of("type", "string", "description", "Current phase"))), + "required", List.of("phase")), + invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + return instance.reportProgressAsync(phase, invocation).thenApply(r -> (Object) r); + }, null, null, null)); + } +} diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java new file mode 100644 index 000000000..9e95c62cb --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc.fixtures; + +import java.util.concurrent.CompletableFuture; + +import com.github.copilot.rpc.ToolInvocation; +import com.github.copilot.tool.CopilotTool; +import com.github.copilot.tool.Param; + +/** + * Tool fixture for {@link ToolInvocation} runtime context injection. + */ +public class InvocationAwareTools { + + @CopilotTool("Reports progress with invocation context") + public String reportProgress(@Param(value = "Current phase", required = true) String phase, + ToolInvocation invocation) { + return "phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); + } + + @CopilotTool("Reports progress asynchronously with invocation context") + public CompletableFuture reportProgressAsync(@Param(value = "Current phase", required = true) String phase, + ToolInvocation invocation) { + return CompletableFuture.completedFuture("async phase=" + phase + ",sessionId=" + invocation.getSessionId() + + ",toolCallId=" + invocation.getToolCallId()); + } +} diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java new file mode 100644 index 000000000..c2fd7d7f6 --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java @@ -0,0 +1,29 @@ +// Hand-written test fixture mimicking CopilotToolProcessor output for static ToolInvocation injection. +package com.github.copilot.rpc.fixtures; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.ToolDefinition; +import com.github.copilot.tool.CopilotToolMetadataProvider; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class StaticInvocationTools$$CopilotToolMeta + implements + CopilotToolMetadataProvider { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public List definitions(StaticInvocationTools instance, ObjectMapper mapper) { + return List.of(new ToolDefinition("report_static", "Returns invocation context from a static tool", + Map.of("type", "object", "properties", + Map.ofEntries(Map.entry("phase", Map.of("type", "string", "description", "Current phase"))), + "required", List.of("phase")), + invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + return CompletableFuture.completedFuture(StaticInvocationTools.reportStatic(phase, invocation)); + }, null, null, null)); + } +} diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java new file mode 100644 index 000000000..5b3960540 --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc.fixtures; + +import com.github.copilot.rpc.ToolInvocation; +import com.github.copilot.tool.CopilotTool; +import com.github.copilot.tool.Param; + +/** + * Static tool fixture for {@link ToolInvocation} runtime context injection. + */ +public class StaticInvocationTools { + + @CopilotTool("Returns invocation context from a static tool") + public static String reportStatic(@Param(value = "Current phase", required = true) String phase, + ToolInvocation invocation) { + return "phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); + } +} diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index dbd2a7ed6..374041d7f 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -480,6 +480,113 @@ public String search(SearchArgs args) { "Single-record path should avoid local args map collision, got:\n" + generated); } + @Test + void supportsInjectedToolInvocation_forSchemaAndMethodCall() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class InvocationAwareTools { + @CopilotTool("Reports progress") + public String report(@Param("Phase") String phase, ToolInvocation toolInvocation) { + return phase + ":" + toolInvocation.getSessionId(); + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.InvocationAwareTools", source))); + assertNoErrors(result); + + String generated = result.getGeneratedSource("test.InvocationAwareTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for InvocationAwareTools$$CopilotToolMeta"); + assertTrue(generated.contains("Map.entry(\"phase\""), + "Expected normal parameter in schema, got:\n" + generated); + assertFalse(generated.contains("Map.entry(\"toolInvocation\""), + "ToolInvocation must not appear in schema properties, got:\n" + generated); + assertTrue(generated.contains("required\", List.of(\"phase\")"), + "Expected only normal parameters in required list, got:\n" + generated); + assertFalse(generated.contains("args.get(\"toolInvocation\")"), + "ToolInvocation must not be read from invocation arguments, got:\n" + generated); + assertTrue(generated.contains("instance.report(phase, invocation)"), + "ToolInvocation parameter should be injected from runtime invocation, got:\n" + generated); + } + + @Test + void supportsInjectedToolInvocation_forStaticAndAsyncMethods() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + import java.util.concurrent.CompletableFuture; + public class StaticInvocationAwareTools { + @CopilotTool("Reports progress statically") + public static String report(@Param("Phase") String phase, ToolInvocation toolInvocation) { + return phase + ":" + toolInvocation.getToolCallId(); + } + @CopilotTool("Reports progress asynchronously") + public CompletableFuture reportAsync(@Param("Phase") String phase, ToolInvocation toolInvocation) { + return CompletableFuture.completedFuture(phase + ":" + toolInvocation.getToolCallId()); + } + } + """; + + CompilationResult result = compileWithProcessor( + List.of(inMemorySource("test.StaticInvocationAwareTools", source))); + assertNoErrors(result); + + String generated = result.getGeneratedSource("test.StaticInvocationAwareTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for StaticInvocationAwareTools$$CopilotToolMeta"); + assertTrue(generated.contains("test.StaticInvocationAwareTools.report(phase, invocation)"), + "Expected static method call with injected invocation, got:\n" + generated); + assertTrue(generated.contains("return instance.reportAsync(phase, invocation).thenApply(r -> (Object) r);"), + "Expected async method call with injected invocation, got:\n" + generated); + } + + @Test + void emitsError_forDuplicateToolInvocationParameters() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + public class DuplicateInvocationTools { + @CopilotTool("Invalid duplicate ToolInvocation") + public String report(String phase, ToolInvocation first, ToolInvocation second) { + return phase; + } + } + """; + + CompilationResult result = compileWithProcessor( + List.of(inMemorySource("test.DuplicateInvocationTools", source))); + + assertTrue(hasErrorContaining(result, "at most one ToolInvocation parameter"), + "Expected compile error for duplicate ToolInvocation parameters, got: " + result.diagnostics); + } + + @Test + void emitsError_forParamAnnotatedToolInvocationParameter() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class AnnotatedInvocationTools { + @CopilotTool("Invalid @Param on ToolInvocation") + public String report(@Param("Invocation context") ToolInvocation invocation) { + return invocation.getToolName(); + } + } + """; + + CompilationResult result = compileWithProcessor( + List.of(inMemorySource("test.AnnotatedInvocationTools", source))); + + assertTrue(hasErrorContaining(result, "@Param is not supported on ToolInvocation parameters"), + "Expected compile error for @Param ToolInvocation parameter, got: " + result.diagnostics); + } + // ── Test: Typed default values in schema ──────────────────────────────────── @Test From 5f8375092a2454d294c52247f25f0f177665ea9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:37:35 +0000 Subject: [PATCH 3/4] Polish ToolInvocation fixture parameter annotations Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../github/copilot/rpc/ToolDefinitionFromObjectTest.java | 4 ++-- .../github/copilot/rpc/fixtures/InvocationAwareTools.java | 7 +++---- .../github/copilot/rpc/fixtures/StaticInvocationTools.java | 3 +-- .../com/github/copilot/tool/CopilotToolProcessorTest.java | 2 ++ 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index aafa96649..8518c2766 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -340,7 +340,6 @@ void fromObject_toolInvocationInjection_schemaExcludesToolInvocation() { assertTrue(properties.containsKey("phase")); assertFalse(properties.containsKey("invocation")); - assertFalse(properties.containsKey("toolInvocation")); assertEquals(List.of("phase"), required); } @@ -353,7 +352,8 @@ void fromObject_toolInvocationInjection_asyncMethod() throws Exception { var result = tool.handler().invoke(createInvocation("report_progress_async", Map.of("phase", "planning")) .setSessionId("session-789").setToolCallId("call-012")).get(); - assertEquals("async phase=planning,sessionId=session-789,toolCallId=call-012", result); + assertEquals("async phase=planning,sessionId=session-789,toolCallId=call-012,toolName=report_progress_async", + result); } @Test diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java index 9e95c62cb..8c36a8977 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java @@ -16,16 +16,15 @@ public class InvocationAwareTools { @CopilotTool("Reports progress with invocation context") - public String reportProgress(@Param(value = "Current phase", required = true) String phase, - ToolInvocation invocation) { + public String reportProgress(@Param("Current phase") String phase, ToolInvocation invocation) { return "phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); } @CopilotTool("Reports progress asynchronously with invocation context") - public CompletableFuture reportProgressAsync(@Param(value = "Current phase", required = true) String phase, + public CompletableFuture reportProgressAsync(@Param("Current phase") String phase, ToolInvocation invocation) { return CompletableFuture.completedFuture("async phase=" + phase + ",sessionId=" + invocation.getSessionId() - + ",toolCallId=" + invocation.getToolCallId()); + + ",toolCallId=" + invocation.getToolCallId() + ",toolName=" + invocation.getToolName()); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java index 5b3960540..cb5729079 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java @@ -14,8 +14,7 @@ public class StaticInvocationTools { @CopilotTool("Returns invocation context from a static tool") - public static String reportStatic(@Param(value = "Current phase", required = true) String phase, - ToolInvocation invocation) { + public static String reportStatic(@Param("Current phase") String phase, ToolInvocation invocation) { return "phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index 374041d7f..ffbff4b29 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -502,6 +502,8 @@ public String report(@Param("Phase") String phase, ToolInvocation toolInvocation assertNotNull(generated, "Expected generated source for InvocationAwareTools$$CopilotToolMeta"); assertTrue(generated.contains("Map.entry(\"phase\""), "Expected normal parameter in schema, got:\n" + generated); + assertFalse(generated.contains("Map.entry(\"invocation\""), + "ToolInvocation must not appear in schema properties, got:\n" + generated); assertFalse(generated.contains("Map.entry(\"toolInvocation\""), "ToolInvocation must not appear in schema properties, got:\n" + generated); assertTrue(generated.contains("required\", List.of(\"phase\")"), From ea6656b11347d56368dc9bc50951a5aabca361b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:10:13 +0000 Subject: [PATCH 4/4] Add Java ToolInvocation position-independence coverage Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- java/README.md | 14 +++ .../github/copilot/rpc/ToolInvocation.java | 6 ++ .../copilot/rpc/RecordInvocationArgs.java | 8 ++ .../rpc/ToolDefinitionFromObjectTest.java | 101 ++++++++++++++++++ ...InvocationAwareTools$$CopilotToolMeta.java | 37 +++++++ .../rpc/fixtures/InvocationAwareTools.java | 26 +++++ .../tool/CopilotToolProcessorTest.java | 97 +++++++++++++++++ 7 files changed, 289 insertions(+) create mode 100644 java/src/test/java/com/github/copilot/rpc/RecordInvocationArgs.java diff --git a/java/README.md b/java/README.md index 5184c20c2..3d1bf7a5b 100644 --- a/java/README.md +++ b/java/README.md @@ -135,6 +135,7 @@ jbang https://github.com/github/copilot-sdk/blob/main/java/jbang-example.java ## Annotation-based tools and `ToolInvocation` context When you define tools with `@CopilotTool`, parameters of type `ToolInvocation` are injected as runtime context and are not exposed in the tool schema. +`ToolInvocation` can appear before, between, or after schema-visible parameters. ```java import com.github.copilot.rpc.ToolInvocation; @@ -151,6 +152,19 @@ class ProgressTools { } ``` +Position examples: + +```java +@CopilotTool("Invocation first") +public String report(ToolInvocation invocation, @Param("Phase") String phase) { ... } + +@CopilotTool("Invocation only") +public String onlyContext(ToolInvocation invocation) { ... } + +@CopilotTool("Invocation middle") +public String report(@Param("Phase") String phase, ToolInvocation invocation, @Param("Limit") int limit) { ... } +``` + ## Memory Sessions can opt into persistent memory, allowing the agent to read and write memory across turns. Memory is configured per session and applies to both `createSession` and `resumeSession`. diff --git a/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java b/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java index dddfdd06f..048fede64 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java @@ -18,6 +18,12 @@ * When the assistant invokes a tool, this object contains the context including * the session ID, tool call ID, tool name, and arguments parsed from the * assistant's request. + *

+ * In annotation-based tools, methods annotated with + * {@link com.github.copilot.tool.CopilotTool} may declare a + * {@code ToolInvocation} parameter in any position (before, between, or after + * schema-visible parameters). It is always injected as runtime context and is + * never included in the tool's JSON schema. * * @see ToolHandler * @see ToolDefinition diff --git a/java/src/test/java/com/github/copilot/rpc/RecordInvocationArgs.java b/java/src/test/java/com/github/copilot/rpc/RecordInvocationArgs.java new file mode 100644 index 000000000..99cfe4706 --- /dev/null +++ b/java/src/test/java/com/github/copilot/rpc/RecordInvocationArgs.java @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +public record RecordInvocationArgs(String query, int limit) { +} diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index 8518c2766..37ea9f6a5 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -367,6 +367,107 @@ void fromClass_toolInvocationInjection_staticMethod() throws Exception { assertEquals("phase=completed,sessionId=session-321,toolCallId=call-654,toolName=report_static", result); } + @Test + void fromObject_toolInvocationInjection_firstParameter() throws Exception { + var tools = ToolDefinition.fromObject(new InvocationAwareTools()); + var tool = findTool(tools, "report_progress_first"); + assertNotNull(tool); + + @SuppressWarnings("unchecked") + var schema = (Map) tool.parameters(); + @SuppressWarnings("unchecked") + var properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + var required = (List) schema.get("required"); + + assertTrue(properties.containsKey("phase")); + assertFalse(properties.containsKey("invocation")); + assertEquals(List.of("phase"), required); + + var result = tool.handler().invoke(createInvocation("report_progress_first", Map.of("phase", "starting")) + .setSessionId("session-first").setToolCallId("call-first")).get(); + assertEquals( + "first phase=starting,sessionId=session-first,toolCallId=call-first,toolName=report_progress_first", + result); + } + + @Test + void fromObject_toolInvocationInjection_onlyParameter() throws Exception { + var tools = ToolDefinition.fromObject(new InvocationAwareTools()); + var tool = findTool(tools, "only_context"); + assertNotNull(tool); + + @SuppressWarnings("unchecked") + var schema = (Map) tool.parameters(); + @SuppressWarnings("unchecked") + var properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + var required = (List) schema.get("required"); + + assertTrue(properties.isEmpty()); + assertTrue(required.isEmpty()); + + var result = tool.handler().invoke( + createInvocation("only_context", Map.of()).setSessionId("session-only").setToolCallId("call-only")) + .get(); + assertEquals("only sessionId=session-only,toolCallId=call-only,toolName=only_context", result); + } + + @Test + void fromObject_toolInvocationInjection_middleParameter() throws Exception { + var tools = ToolDefinition.fromObject(new InvocationAwareTools()); + var tool = findTool(tools, "report_progress_middle"); + assertNotNull(tool); + + @SuppressWarnings("unchecked") + var schema = (Map) tool.parameters(); + @SuppressWarnings("unchecked") + var properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + var required = (List) schema.get("required"); + + assertTrue(properties.containsKey("phase")); + assertTrue(properties.containsKey("limit")); + assertFalse(properties.containsKey("invocation")); + assertEquals(List.of("phase", "limit"), required); + + var result = tool.handler() + .invoke(createInvocation("report_progress_middle", Map.of("phase", "running", "limit", 7)) + .setSessionId("session-middle").setToolCallId("call-middle")) + .get(); + assertEquals( + "middle phase=running,limit=7,sessionId=session-middle,toolCallId=call-middle,toolName=report_progress_middle", + result); + } + + @Test + void fromObject_toolInvocationInjection_singleRecordAndInvocation() throws Exception { + var tools = ToolDefinition.fromObject(new InvocationAwareTools()); + var tool = findTool(tools, "report_progress_with_record"); + assertNotNull(tool); + + @SuppressWarnings("unchecked") + var schema = (Map) tool.parameters(); + @SuppressWarnings("unchecked") + var properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + var required = (List) schema.get("required"); + + assertTrue(properties.containsKey("query")); + assertTrue(properties.containsKey("limit")); + assertFalse(properties.containsKey("args")); + assertFalse(properties.containsKey("invocation")); + assertEquals(List.of("query", "limit"), required); + + var result = tool.handler() + .invoke(createInvocation("report_progress_with_record", Map.of("query", "logs", "limit", 3)) + .setSessionId("session-record").setToolCallId("call-record")) + .get(); + assertEquals( + "record query=logs,limit=3,sessionId=session-record,toolCallId=call-record,toolName=report_progress_with_record", + result); + } + // ── Helpers ───────────────────────────────────────────────────────────────── private static ToolDefinition findTool(List tools, String name) { diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java index e01ecd96c..cc7150e57 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java @@ -2,6 +2,7 @@ package com.github.copilot.rpc.fixtures; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.RecordInvocationArgs; import com.github.copilot.rpc.ToolDefinition; import com.github.copilot.tool.CopilotToolMetadataProvider; @@ -32,6 +33,42 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return instance.reportProgressAsync(phase, invocation).thenApply(r -> (Object) r); + }, null, null, null), + new ToolDefinition("report_progress_first", "Reports progress with invocation first", + Map.of("type", "object", "properties", + Map.ofEntries( + Map.entry("phase", Map.of("type", "string", "description", "Current phase"))), + "required", List.of("phase")), + invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + return CompletableFuture.completedFuture(instance.reportProgressFirst(invocation, phase)); + }, null, null, null), + new ToolDefinition("only_context", "Reports context with invocation only", + Map.of("type", "object", "properties", Map.of(), "required", List.of()), + invocation -> CompletableFuture.completedFuture(instance.onlyContext(invocation)), null, null, + null), + new ToolDefinition("report_progress_middle", "Reports progress with invocation in the middle", Map.of( + "type", "object", "properties", + Map.ofEntries(Map.entry("phase", Map.of("type", "string", "description", "Current phase")), + Map.entry("limit", Map.of("type", "integer", "description", "Maximum items"))), + "required", List.of("phase", "limit")), invocation -> { + Map args = invocation.getArguments(); + String phase = (String) args.get("phase"); + int limit = ((Number) args.get("limit")).intValue(); + return CompletableFuture + .completedFuture(instance.reportProgressMiddle(phase, invocation, limit)); + }, null, null, null), + new ToolDefinition("report_progress_with_record", "Reports progress with record args and invocation", + Map.of("type", "object", "properties", + Map.ofEntries(Map.entry("query", Map.of("type", "string")), + Map.entry("limit", Map.of("type", "integer"))), + "required", List.of("query", "limit")), + invocation -> { + RecordInvocationArgs args = mapper.convertValue(invocation.getArguments(), + RecordInvocationArgs.class); + return CompletableFuture + .completedFuture(instance.reportProgressWithRecord(args, invocation)); }, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java index 8c36a8977..b3d1ace74 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java @@ -6,6 +6,7 @@ import java.util.concurrent.CompletableFuture; +import com.github.copilot.rpc.RecordInvocationArgs; import com.github.copilot.rpc.ToolInvocation; import com.github.copilot.tool.CopilotTool; import com.github.copilot.tool.Param; @@ -27,4 +28,29 @@ public CompletableFuture reportProgressAsync(@Param("Current phase") Str return CompletableFuture.completedFuture("async phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + invocation.getToolCallId() + ",toolName=" + invocation.getToolName()); } + + @CopilotTool("Reports progress with invocation first") + public String reportProgressFirst(ToolInvocation invocation, @Param("Current phase") String phase) { + return "first phase=" + phase + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); + } + + @CopilotTool("Reports context with invocation only") + public String onlyContext(ToolInvocation invocation) { + return "only sessionId=" + invocation.getSessionId() + ",toolCallId=" + invocation.getToolCallId() + + ",toolName=" + invocation.getToolName(); + } + + @CopilotTool("Reports progress with invocation in the middle") + public String reportProgressMiddle(@Param("Current phase") String phase, ToolInvocation invocation, + @Param("Maximum items") int limit) { + return "middle phase=" + phase + ",limit=" + limit + ",sessionId=" + invocation.getSessionId() + ",toolCallId=" + + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); + } + + @CopilotTool("Reports progress with record args and invocation") + public String reportProgressWithRecord(RecordInvocationArgs args, ToolInvocation invocation) { + return "record query=" + args.query() + ",limit=" + args.limit() + ",sessionId=" + invocation.getSessionId() + + ",toolCallId=" + invocation.getToolCallId() + ",toolName=" + invocation.getToolName(); + } } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index ffbff4b29..fefef6714 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -546,6 +546,103 @@ public CompletableFuture reportAsync(@Param("Phase") String phase, ToolI "Expected async method call with injected invocation, got:\n" + generated); } + @Test + void supportsInjectedToolInvocation_whenItIsTheOnlyParameter() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + public class InvocationOnlyTools { + @CopilotTool("Reports invocation context only") + public String onlyContext(ToolInvocation invocation) { + return invocation.getSessionId(); + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.InvocationOnlyTools", source))); + assertNoErrors(result); + + String generated = result.getGeneratedSource("test.InvocationOnlyTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for InvocationOnlyTools$$CopilotToolMeta"); + assertTrue(generated.contains("\"properties\", Map.of(), \"required\", List.of()"), + "Expected empty schema for invocation-only method, got:\n" + generated); + assertFalse(generated.contains("Map args = invocation.getArguments();"), + "Invocation-only method should not read argument map, got:\n" + generated); + assertTrue(generated.contains("instance.onlyContext(invocation)"), + "Invocation-only method should inject invocation directly, got:\n" + generated); + } + + @Test + void supportsInjectedToolInvocation_whenItAppearsFirstOrMiddle() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.Param; + public class InvocationPositionTools { + @CopilotTool("Invocation first") + public String reportFirst(ToolInvocation invocation, @Param("Phase") String phase) { + return phase + ":" + invocation.getToolCallId(); + } + @CopilotTool("Invocation middle") + public String reportMiddle(@Param("Phase") String phase, ToolInvocation invocation, @Param("Limit") int limit) { + return phase + ":" + limit + ":" + invocation.getToolCallId(); + } + } + """; + + CompilationResult result = compileWithProcessor( + List.of(inMemorySource("test.InvocationPositionTools", source))); + assertNoErrors(result); + + String generated = result.getGeneratedSource("test.InvocationPositionTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for InvocationPositionTools$$CopilotToolMeta"); + assertTrue(generated.contains("instance.reportFirst(invocation, phase)"), + "Expected invocation to be passed in first position, got:\n" + generated); + assertTrue(generated.contains("instance.reportMiddle(phase, invocation, limit)"), + "Expected invocation to be passed in middle position, got:\n" + generated); + assertFalse(generated.contains("args.get(\"invocation\")"), + "ToolInvocation must not be read from invocation arguments, got:\n" + generated); + assertTrue(generated.contains("Map.entry(\"phase\""), + "Expected schema-visible phase parameter, got:\n" + generated); + assertTrue(generated.contains("Map.entry(\"limit\""), + "Expected schema-visible limit parameter, got:\n" + generated); + assertFalse(generated.contains("Map.entry(\"invocation\""), + "ToolInvocation must not appear in schema properties, got:\n" + generated); + } + + @Test + void supportsInjectedToolInvocation_withSingleRecordSchemaParameter() { + String source = """ + package test; + import com.github.copilot.rpc.ToolInvocation; + import com.github.copilot.tool.CopilotTool; + public class RecordInvocationTools { + public record SearchArgs(String query, int limit) {} + @CopilotTool("Record plus invocation") + public String report(SearchArgs args, ToolInvocation invocation) { + return args.query() + ":" + invocation.getSessionId(); + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.RecordInvocationTools", source))); + assertNoErrors(result); + + String generated = result.getGeneratedSource("test.RecordInvocationTools$$CopilotToolMeta"); + assertNotNull(generated, "Expected generated source for RecordInvocationTools$$CopilotToolMeta"); + assertTrue(generated.contains( + "test.RecordInvocationTools.SearchArgs args = mapper.convertValue(invocation.getArguments(), test.RecordInvocationTools.SearchArgs.class);"), + "Expected single-record conversion for schema-visible parameter, got:\n" + generated); + assertTrue(generated.contains("instance.report(args, invocation)"), + "Expected record + invocation method call order, got:\n" + generated); + assertFalse(generated.contains("Map.entry(\"args\""), + "Single-record schema should be flattened, got:\n" + generated); + assertFalse(generated.contains("args.get(\"invocation\")"), + "ToolInvocation must not be read from invocation arguments, got:\n" + generated); + } + @Test void emitsError_forDuplicateToolInvocationParameters() { String source = """