diff --git a/java/README.md b/java/README.md
index 28544b014..3d1bf7a5b 100644
--- a/java/README.md
+++ b/java/README.md
@@ -132,6 +132,39 @@ 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.
+`ToolInvocation` can appear before, between, or after schema-visible parameters.
+
+```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();
+ }
+}
+```
+
+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/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 extends TypeElement> 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 extends TypeElement> 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 extends VariableElement> 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 extends VariableElement> parameters) {
- if (parameters.isEmpty()) {
+ List extends VariableElement> 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 extends VariableElement> p
return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")";
}
+ private List extends VariableElement> getSchemaParameters(List extends VariableElement> 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 extends VariableElement> params = method.getParameters();
+ List extends VariableElement> 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 extends VariableElement> 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/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 25765e057..37ea9f6a5 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,163 @@ 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"));
+ 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,toolName=report_progress_async",
+ 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);
+ }
+
+ @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
new file mode 100644
index 000000000..cc7150e57
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java
@@ -0,0 +1,74 @@
+// 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.RecordInvocationArgs;
+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),
+ 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
new file mode 100644
index 000000000..b3d1ace74
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools.java
@@ -0,0 +1,56 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.rpc.fixtures;
+
+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;
+
+/**
+ * Tool fixture for {@link ToolInvocation} runtime context injection.
+ */
+public class InvocationAwareTools {
+
+ @CopilotTool("Reports progress with invocation context")
+ 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("Current phase") String phase,
+ ToolInvocation invocation) {
+ 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/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..cb5729079
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools.java
@@ -0,0 +1,21 @@
+/*---------------------------------------------------------------------------------------------
+ * 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("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 dbd2a7ed6..fefef6714 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,212 @@ 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(\"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\")"),
+ "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 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 = """
+ 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