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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions java/src/main/java/com/github/copilot/rpc/ToolDefinition.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@

package com.github.copilot.rpc;

import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.github.copilot.CopilotExperimental;

/**
* Defines a tool that can be invoked by the AI assistant.
Expand Down Expand Up @@ -163,4 +170,93 @@ public static ToolDefinition createWithDefer(String name, String description, Ma
ToolHandler handler, ToolDefer defer) {
return new ToolDefinition(name, description, schema, handler, null, null, defer);
}

/**
* Discovers tool definitions from an object whose methods are annotated with
* {@code @CopilotTool}. Requires that the {@code CopilotToolProcessor}
* annotation processor ran at compile time (generating the
* {@code $$CopilotToolMeta} companion class).
*
* @param instance
* the object containing {@code @CopilotTool}-annotated methods
* @return list of tool definitions with working invocation handlers
* @throws IllegalStateException
* if the generated {@code $$CopilotToolMeta} class is not found
* (annotation processor did not run)
* @since 1.0.2
*/
@CopilotExperimental
public static List<ToolDefinition> fromObject(Object instance) {
if (instance == null) {
throw new IllegalArgumentException("instance must not be null");
}
Class<?> clazz = instance.getClass();
return loadDefinitions(clazz, instance);
}

/**
* Discovers tool definitions from a class with static
* {@code @CopilotTool}-annotated methods. Requires that the
* {@code CopilotToolProcessor} annotation processor ran at compile time
* (generating the {@code $$CopilotToolMeta} companion class).
*
* @param clazz
* the class containing static {@code @CopilotTool}-annotated methods
* @return list of tool definitions with working invocation handlers
* @throws IllegalStateException
* if the generated {@code $$CopilotToolMeta} class is not found
* (annotation processor did not run)
* @since 1.0.2
*/
@CopilotExperimental
public static List<ToolDefinition> fromClass(Class<?> clazz) {
if (clazz == null) {
throw new IllegalArgumentException("clazz must not be null");
}
return loadDefinitions(clazz, null);
}

@SuppressWarnings("unchecked")
private static List<ToolDefinition> loadDefinitions(Class<?> clazz, Object instance) {
String metaClassName = clazz.getName() + "$$CopilotToolMeta";
try {
Class<?> metaClass = Class.forName(metaClassName, true, clazz.getClassLoader());
Method defs = metaClass.getDeclaredMethod("definitions", clazz, ObjectMapper.class);
defs.setAccessible(true);
return (List<ToolDefinition>) defs.invoke(null, instance, getConfiguredMapper());
} catch (ClassNotFoundException e) {
throw new IllegalStateException("Generated class " + metaClassName + " not found. "
+ "Ensure the CopilotToolProcessor annotation processor ran during compilation. "
+ "Add the copilot-sdk-java dependency to your annotation processor path.", e);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Failed to invoke " + metaClassName + ".definitions()", e);
}
}

/**
* Returns the SDK-configured ObjectMapper for tool argument/result
* serialization. Configuration mirrors
* {@code JsonRpcClient.createObjectMapper()}.
*/
private static ObjectMapper getConfiguredMapper() {
return ConfiguredMapperHolder.INSTANCE;
}

/**
* Lazy holder for the configured ObjectMapper (thread-safe, initialized on
* first access).
*/
private static final class ConfiguredMapperHolder {
static final ObjectMapper INSTANCE = createMapper();

private static ObjectMapper createMapper() {
// Configuration must match JsonRpcClient.createObjectMapper()
var mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
return mapper;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

package com.github.copilot.rpc;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.github.copilot.AllowCopilotExperimental;
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.MultiReturnTools;
import com.github.copilot.rpc.fixtures.OverrideTools;
import com.github.copilot.rpc.fixtures.SimpleTools;

/**
* End-to-end tests for {@link ToolDefinition#fromObject(Object)}.
* <p>
* The annotation processor generates {@code $$CopilotToolMeta} companion
* classes for the fixture classes during test compilation.
*/
@AllowCopilotExperimental
class ToolDefinitionFromObjectTest {

// ── Test 1: Basic end-to-end ────────────────────────────────────────────────

@Test
void fromObject_returnsCorrectNumberOfTools() {
var tools = ToolDefinition.fromObject(new SimpleTools());
assertEquals(2, tools.size());
}

@Test
void fromObject_toolNamesAndDescriptions() {
var tools = ToolDefinition.fromObject(new SimpleTools());
var tool1 = findTool(tools, "greet_user");
assertNotNull(tool1);
assertEquals("Greets a user by name", tool1.description());

var tool2 = findTool(tools, "add_numbers");
assertNotNull(tool2);
assertEquals("Adds two numbers together", tool2.description());
}

@Test
void fromObject_toolParameterSchema() {
var tools = ToolDefinition.fromObject(new SimpleTools());
var tool = findTool(tools, "greet_user");
assertNotNull(tool);
@SuppressWarnings("unchecked")
var schema = (Map<String, Object>) tool.parameters();
assertEquals("object", schema.get("type"));
@SuppressWarnings("unchecked")
var properties = (Map<String, Object>) schema.get("properties");
assertTrue(properties.containsKey("name"));
@SuppressWarnings("unchecked")
var required = (List<String>) schema.get("required");
assertTrue(required.contains("name"));
}

@Test
void fromObject_handlerInvocation() throws Exception {
var instance = new SimpleTools();
var tools = ToolDefinition.fromObject(instance);
var tool = findTool(tools, "greet_user");
assertNotNull(tool);

var result = tool.handler().invoke(createInvocation("greet_user", Map.of("name", "Alice"))).get();
assertEquals("Hello, Alice!", result);
}

// ── Test 2: Handler return type patterns ────────────────────────────────────

@Test
void fromObject_stringReturn() throws Exception {
var tools = ToolDefinition.fromObject(new MultiReturnTools());
var tool = findTool(tools, "string_method");
assertNotNull(tool);
var result = tool.handler().invoke(createInvocation("string_method", Map.of())).get();
assertEquals("hello", result);
}

@Test
void fromObject_voidReturn() throws Exception {
var tools = ToolDefinition.fromObject(new MultiReturnTools());
var tool = findTool(tools, "void_method");
assertNotNull(tool);
var result = tool.handler().invoke(createInvocation("void_method", Map.of())).get();
assertEquals("Success", result);
}

@Test
void fromObject_asyncReturn() throws Exception {
var tools = ToolDefinition.fromObject(new MultiReturnTools());
var tool = findTool(tools, "async_method");
assertNotNull(tool);
var result = tool.handler().invoke(createInvocation("async_method", Map.of())).get();
assertEquals("async result", result);
}

// ── Test 3: Argument coercion ───────────────────────────────────────────────

@Test
void fromObject_argumentCoercion() throws Exception {
var instance = new ArgCoercionTools();
var tools = ToolDefinition.fromObject(instance);
var tool = findTool(tools, "mixed_args");
assertNotNull(tool);

var result = tool.handler().invoke(
createInvocation("mixed_args", Map.of("text", "hello", "count", 5, "flag", true, "color", "RED")))
.get();
assertEquals("hello-5-true-RED", result);
}

// ── Test 4: Default value ───────────────────────────────────────────────────

@Test
void fromObject_defaultValue() throws Exception {
var instance = new DefaultValueTools();
var tools = ToolDefinition.fromObject(instance);
var tool = findTool(tools, "with_default");
assertNotNull(tool);

// Omit "count" key — should use default value 42
var result = tool.handler().invoke(createInvocation("with_default", Map.of("label", "test"))).get();
assertEquals("test:42", result);
}

// ── Test 5: Error case — missing generated class ────────────────────────────

@Test
void fromObject_throwsOnMissingMetaClass() {
// A class that was never processed by CopilotToolProcessor
var ex = assertThrows(IllegalStateException.class, () -> ToolDefinition.fromObject("a plain String"));
assertTrue(ex.getMessage().contains("not found"));
assertTrue(ex.getMessage().contains("CopilotToolProcessor"));
}

// ── Test 6: java.time argument ──────────────────────────────────────────────

@Test
void fromObject_javaTimeArgument() throws Exception {
var instance = new DateTimeTools();
var tools = ToolDefinition.fromObject(instance);
var tool = findTool(tools, "schedule_event");
assertNotNull(tool);

var result = tool.handler().invoke(createInvocation("schedule_event", Map.of("when", "2024-06-15T10:30:00")))
.get();
assertEquals("Scheduled at 2024-06-15T10:30", result);
}

// ── Test 7: Override tool ────────────────────────────────────────────────────

@Test
void fromObject_overrideTool() {
var tools = ToolDefinition.fromObject(new OverrideTools());
var tool = findTool(tools, "grep");
assertNotNull(tool);
assertEquals(Boolean.TRUE, tool.overridesBuiltInTool());
}

// ── Test 8: ToolDefer.NONE → null mapping (defer absent from JSON) ──────────

@Test
void fromObject_deferNone_absentFromJson() throws Exception {
var tools = ToolDefinition.fromObject(new SimpleTools());
var tool = findTool(tools, "greet_user");
assertNotNull(tool);
// The defer field should be null (NONE maps to null)
assertNull(tool.defer());

// Serialize to JSON and verify "defer" key is absent
var mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);

String json = mapper.writeValueAsString(tool);
assertFalse(json.contains("\"defer\""), "defer key should be absent from JSON, got: " + json);
}

// ── Helpers ─────────────────────────────────────────────────────────────────

private static ToolDefinition findTool(List<ToolDefinition> tools, String name) {
return tools.stream().filter(t -> name.equals(t.name())).findFirst().orElse(null);
}

private static ToolInvocation createInvocation(String toolName, Map<String, ?> args) {
ObjectNode argsNode = JsonNodeFactory.instance.objectNode();
ObjectMapper mapper = new ObjectMapper();
argsNode.setAll((ObjectNode) mapper.valueToTree(args));
return new ToolInvocation().setToolName(toolName).setArguments(argsNode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// GENERATED by CopilotToolProcessor — do not edit (hand-written test fixture)
package com.github.copilot.rpc.fixtures;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.copilot.rpc.ToolDefinition;

import java.util.*;
import java.util.concurrent.CompletableFuture;

final class ArgCoercionTools$$CopilotToolMeta {

private static Map<String, Object> withMeta(Map<String, Object> base, String description, Object defaultValue) {
var result = new LinkedHashMap<String, Object>(base);
if (description != null)
result.put("description", description);
if (defaultValue != null)
result.put("default", defaultValue);
return Collections.unmodifiableMap(result);
}

@SuppressWarnings({"unchecked", "rawtypes"})
static List<ToolDefinition> definitions(ArgCoercionTools instance, ObjectMapper mapper) {
return List
.of(new ToolDefinition("mixed_args", "Method with mixed argument types", Map.of(
"type", "object", "properties", Map
.ofEntries(
Map.entry("text",
(Map<String, Object>) (Map) withMeta(Map.of("type", "string"),
"Text input", null)),
Map.entry("count",
(Map<String, Object>) (Map) withMeta(Map.of("type", "integer"),
"A count", null)),
Map.entry("flag",
(Map<String, Object>) (Map) withMeta(Map.of("type", "boolean"),
"A flag", null)),
Map.entry("color",
(Map<String, Object>) (Map) withMeta(Map.of("type", "string", "enum",
List.of("RED", "GREEN", "BLUE")), "A color", null))),
"required", List.of("text", "count", "flag", "color")), invocation -> {
Map<String, Object> args = invocation.getArguments();
String text = (String) args.get("text");
int count = ((Number) args.get("count")).intValue();
boolean flag = (Boolean) args.get("flag");
ArgCoercionTools.Color color = ArgCoercionTools.Color.valueOf((String) args.get("color"));
return CompletableFuture.completedFuture(instance.mixedArgs(text, count, flag, color));
}, null, null, null));
}
}
Loading