Skip to content

Commit 4d7efa0

Browse files
Copilotedburns
andauthored
feat(java): Add ToolDefinition.fromObject() and fromClass() static methods
Adds static methods that load processor-generated $$CopilotToolMeta classes and return List<ToolDefinition> with fully working tool definitions (schema + invocation handlers). - fromObject(Object): discovers tools from an instance with @copilotTool methods - fromClass(Class<?>): discovers tools from a class with static @copilotTool methods - Private getConfiguredMapper(): provides ObjectMapper matching JsonRpcClient config - Throws IllegalStateException with helpful message if generated class not found - Both methods annotated with @CopilotExperimental Includes comprehensive test suite (ToolDefinitionFromObjectTest) covering: - Basic discovery and schema verification - Handler invocation for String, void, and CompletableFuture returns - Argument coercion with primitives, String, boolean, and enums - Default value handling when arguments are omitted - Error case for missing generated class - java.time argument deserialization (validates JavaTimeModule contract) - Override tool flag propagation - ToolDefer.NONE → null mapping (defer absent from JSON output) Closes #1761 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent 0ca2b6c commit 4d7efa0

14 files changed

Lines changed: 692 additions & 0 deletions

‎java/src/main/java/com/github/copilot/rpc/ToolDefinition.java‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44

55
package com.github.copilot.rpc;
66

7+
import java.lang.reflect.Method;
8+
import java.util.List;
79
import java.util.Map;
810

911
import com.fasterxml.jackson.annotation.JsonIgnore;
1012
import com.fasterxml.jackson.annotation.JsonInclude;
1113
import com.fasterxml.jackson.annotation.JsonProperty;
14+
import com.fasterxml.jackson.databind.DeserializationFeature;
15+
import com.fasterxml.jackson.databind.ObjectMapper;
16+
import com.fasterxml.jackson.databind.SerializationFeature;
17+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
18+
import com.github.copilot.CopilotExperimental;
1219

1320
/**
1421
* Defines a tool that can be invoked by the AI assistant.
@@ -163,4 +170,93 @@ public static ToolDefinition createWithDefer(String name, String description, Ma
163170
ToolHandler handler, ToolDefer defer) {
164171
return new ToolDefinition(name, description, schema, handler, null, null, defer);
165172
}
173+
174+
/**
175+
* Discovers tool definitions from an object whose methods are annotated with
176+
* {@code @CopilotTool}. Requires that the {@code CopilotToolProcessor}
177+
* annotation processor ran at compile time (generating the
178+
* {@code $$CopilotToolMeta} companion class).
179+
*
180+
* @param instance
181+
* the object containing {@code @CopilotTool}-annotated methods
182+
* @return list of tool definitions with working invocation handlers
183+
* @throws IllegalStateException
184+
* if the generated {@code $$CopilotToolMeta} class is not found
185+
* (annotation processor did not run)
186+
* @since 1.0.2
187+
*/
188+
@CopilotExperimental
189+
public static List<ToolDefinition> fromObject(Object instance) {
190+
if (instance == null) {
191+
throw new IllegalArgumentException("instance must not be null");
192+
}
193+
Class<?> clazz = instance.getClass();
194+
return loadDefinitions(clazz, instance);
195+
}
196+
197+
/**
198+
* Discovers tool definitions from a class with static
199+
* {@code @CopilotTool}-annotated methods. Requires that the
200+
* {@code CopilotToolProcessor} annotation processor ran at compile time
201+
* (generating the {@code $$CopilotToolMeta} companion class).
202+
*
203+
* @param clazz
204+
* the class containing static {@code @CopilotTool}-annotated methods
205+
* @return list of tool definitions with working invocation handlers
206+
* @throws IllegalStateException
207+
* if the generated {@code $$CopilotToolMeta} class is not found
208+
* (annotation processor did not run)
209+
* @since 1.0.2
210+
*/
211+
@CopilotExperimental
212+
public static List<ToolDefinition> fromClass(Class<?> clazz) {
213+
if (clazz == null) {
214+
throw new IllegalArgumentException("clazz must not be null");
215+
}
216+
return loadDefinitions(clazz, null);
217+
}
218+
219+
@SuppressWarnings("unchecked")
220+
private static List<ToolDefinition> loadDefinitions(Class<?> clazz, Object instance) {
221+
String metaClassName = clazz.getName() + "$$CopilotToolMeta";
222+
try {
223+
Class<?> metaClass = Class.forName(metaClassName, true, clazz.getClassLoader());
224+
Method defs = metaClass.getDeclaredMethod("definitions", clazz, ObjectMapper.class);
225+
defs.setAccessible(true);
226+
return (List<ToolDefinition>) defs.invoke(null, instance, getConfiguredMapper());
227+
} catch (ClassNotFoundException e) {
228+
throw new IllegalStateException("Generated class " + metaClassName + " not found. "
229+
+ "Ensure the CopilotToolProcessor annotation processor ran during compilation. "
230+
+ "Add the copilot-sdk-java dependency to your annotation processor path.", e);
231+
} catch (ReflectiveOperationException e) {
232+
throw new IllegalStateException("Failed to invoke " + metaClassName + ".definitions()", e);
233+
}
234+
}
235+
236+
/**
237+
* Returns the SDK-configured ObjectMapper for tool argument/result
238+
* serialization. Configuration mirrors
239+
* {@code JsonRpcClient.createObjectMapper()}.
240+
*/
241+
private static ObjectMapper getConfiguredMapper() {
242+
return ConfiguredMapperHolder.INSTANCE;
243+
}
244+
245+
/**
246+
* Lazy holder for the configured ObjectMapper (thread-safe, initialized on
247+
* first access).
248+
*/
249+
private static final class ConfiguredMapperHolder {
250+
static final ObjectMapper INSTANCE = createMapper();
251+
252+
private static ObjectMapper createMapper() {
253+
// Configuration must match JsonRpcClient.createObjectMapper()
254+
var mapper = new ObjectMapper();
255+
mapper.registerModule(new JavaTimeModule());
256+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
257+
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
258+
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
259+
return mapper;
260+
}
261+
}
166262
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.rpc;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertNotNull;
10+
import static org.junit.jupiter.api.Assertions.assertNull;
11+
import static org.junit.jupiter.api.Assertions.assertThrows;
12+
import static org.junit.jupiter.api.Assertions.assertTrue;
13+
14+
import java.util.List;
15+
import java.util.Map;
16+
17+
import org.junit.jupiter.api.Test;
18+
19+
import com.fasterxml.jackson.annotation.JsonInclude;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.databind.SerializationFeature;
23+
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
24+
import com.fasterxml.jackson.databind.node.ObjectNode;
25+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
26+
import com.github.copilot.AllowCopilotExperimental;
27+
import com.github.copilot.rpc.fixtures.ArgCoercionTools;
28+
import com.github.copilot.rpc.fixtures.DateTimeTools;
29+
import com.github.copilot.rpc.fixtures.DefaultValueTools;
30+
import com.github.copilot.rpc.fixtures.MultiReturnTools;
31+
import com.github.copilot.rpc.fixtures.OverrideTools;
32+
import com.github.copilot.rpc.fixtures.SimpleTools;
33+
34+
/**
35+
* End-to-end tests for {@link ToolDefinition#fromObject(Object)}.
36+
* <p>
37+
* The annotation processor generates {@code $$CopilotToolMeta} companion
38+
* classes for the fixture classes during test compilation.
39+
*/
40+
@AllowCopilotExperimental
41+
class ToolDefinitionFromObjectTest {
42+
43+
// ── Test 1: Basic end-to-end ────────────────────────────────────────────────
44+
45+
@Test
46+
void fromObject_returnsCorrectNumberOfTools() {
47+
var tools = ToolDefinition.fromObject(new SimpleTools());
48+
assertEquals(2, tools.size());
49+
}
50+
51+
@Test
52+
void fromObject_toolNamesAndDescriptions() {
53+
var tools = ToolDefinition.fromObject(new SimpleTools());
54+
var tool1 = findTool(tools, "greet_user");
55+
assertNotNull(tool1);
56+
assertEquals("Greets a user by name", tool1.description());
57+
58+
var tool2 = findTool(tools, "add_numbers");
59+
assertNotNull(tool2);
60+
assertEquals("Adds two numbers together", tool2.description());
61+
}
62+
63+
@Test
64+
void fromObject_toolParameterSchema() {
65+
var tools = ToolDefinition.fromObject(new SimpleTools());
66+
var tool = findTool(tools, "greet_user");
67+
assertNotNull(tool);
68+
@SuppressWarnings("unchecked")
69+
var schema = (Map<String, Object>) tool.parameters();
70+
assertEquals("object", schema.get("type"));
71+
@SuppressWarnings("unchecked")
72+
var properties = (Map<String, Object>) schema.get("properties");
73+
assertTrue(properties.containsKey("name"));
74+
@SuppressWarnings("unchecked")
75+
var required = (List<String>) schema.get("required");
76+
assertTrue(required.contains("name"));
77+
}
78+
79+
@Test
80+
void fromObject_handlerInvocation() throws Exception {
81+
var instance = new SimpleTools();
82+
var tools = ToolDefinition.fromObject(instance);
83+
var tool = findTool(tools, "greet_user");
84+
assertNotNull(tool);
85+
86+
var result = tool.handler().invoke(createInvocation("greet_user", Map.of("name", "Alice"))).get();
87+
assertEquals("Hello, Alice!", result);
88+
}
89+
90+
// ── Test 2: Handler return type patterns ────────────────────────────────────
91+
92+
@Test
93+
void fromObject_stringReturn() throws Exception {
94+
var tools = ToolDefinition.fromObject(new MultiReturnTools());
95+
var tool = findTool(tools, "string_method");
96+
assertNotNull(tool);
97+
var result = tool.handler().invoke(createInvocation("string_method", Map.of())).get();
98+
assertEquals("hello", result);
99+
}
100+
101+
@Test
102+
void fromObject_voidReturn() throws Exception {
103+
var tools = ToolDefinition.fromObject(new MultiReturnTools());
104+
var tool = findTool(tools, "void_method");
105+
assertNotNull(tool);
106+
var result = tool.handler().invoke(createInvocation("void_method", Map.of())).get();
107+
assertEquals("Success", result);
108+
}
109+
110+
@Test
111+
void fromObject_asyncReturn() throws Exception {
112+
var tools = ToolDefinition.fromObject(new MultiReturnTools());
113+
var tool = findTool(tools, "async_method");
114+
assertNotNull(tool);
115+
var result = tool.handler().invoke(createInvocation("async_method", Map.of())).get();
116+
assertEquals("async result", result);
117+
}
118+
119+
// ── Test 3: Argument coercion ───────────────────────────────────────────────
120+
121+
@Test
122+
void fromObject_argumentCoercion() throws Exception {
123+
var instance = new ArgCoercionTools();
124+
var tools = ToolDefinition.fromObject(instance);
125+
var tool = findTool(tools, "mixed_args");
126+
assertNotNull(tool);
127+
128+
var result = tool.handler().invoke(
129+
createInvocation("mixed_args", Map.of("text", "hello", "count", 5, "flag", true, "color", "RED")))
130+
.get();
131+
assertEquals("hello-5-true-RED", result);
132+
}
133+
134+
// ── Test 4: Default value ───────────────────────────────────────────────────
135+
136+
@Test
137+
void fromObject_defaultValue() throws Exception {
138+
var instance = new DefaultValueTools();
139+
var tools = ToolDefinition.fromObject(instance);
140+
var tool = findTool(tools, "with_default");
141+
assertNotNull(tool);
142+
143+
// Omit "count" key — should use default value 42
144+
var result = tool.handler().invoke(createInvocation("with_default", Map.of("label", "test"))).get();
145+
assertEquals("test:42", result);
146+
}
147+
148+
// ── Test 5: Error case — missing generated class ────────────────────────────
149+
150+
@Test
151+
void fromObject_throwsOnMissingMetaClass() {
152+
// A class that was never processed by CopilotToolProcessor
153+
var ex = assertThrows(IllegalStateException.class, () -> ToolDefinition.fromObject("a plain String"));
154+
assertTrue(ex.getMessage().contains("not found"));
155+
assertTrue(ex.getMessage().contains("CopilotToolProcessor"));
156+
}
157+
158+
// ── Test 6: java.time argument ──────────────────────────────────────────────
159+
160+
@Test
161+
void fromObject_javaTimeArgument() throws Exception {
162+
var instance = new DateTimeTools();
163+
var tools = ToolDefinition.fromObject(instance);
164+
var tool = findTool(tools, "schedule_event");
165+
assertNotNull(tool);
166+
167+
var result = tool.handler().invoke(createInvocation("schedule_event", Map.of("when", "2024-06-15T10:30:00")))
168+
.get();
169+
assertEquals("Scheduled at 2024-06-15T10:30", result);
170+
}
171+
172+
// ── Test 7: Override tool ────────────────────────────────────────────────────
173+
174+
@Test
175+
void fromObject_overrideTool() {
176+
var tools = ToolDefinition.fromObject(new OverrideTools());
177+
var tool = findTool(tools, "grep");
178+
assertNotNull(tool);
179+
assertEquals(Boolean.TRUE, tool.overridesBuiltInTool());
180+
}
181+
182+
// ── Test 8: ToolDefer.NONE → null mapping (defer absent from JSON) ──────────
183+
184+
@Test
185+
void fromObject_deferNone_absentFromJson() throws Exception {
186+
var tools = ToolDefinition.fromObject(new SimpleTools());
187+
var tool = findTool(tools, "greet_user");
188+
assertNotNull(tool);
189+
// The defer field should be null (NONE maps to null)
190+
assertNull(tool.defer());
191+
192+
// Serialize to JSON and verify "defer" key is absent
193+
var mapper = new ObjectMapper();
194+
mapper.registerModule(new JavaTimeModule());
195+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
196+
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
197+
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
198+
199+
String json = mapper.writeValueAsString(tool);
200+
assertFalse(json.contains("\"defer\""), "defer key should be absent from JSON, got: " + json);
201+
}
202+
203+
// ── Helpers ─────────────────────────────────────────────────────────────────
204+
205+
private static ToolDefinition findTool(List<ToolDefinition> tools, String name) {
206+
return tools.stream().filter(t -> name.equals(t.name())).findFirst().orElse(null);
207+
}
208+
209+
private static ToolInvocation createInvocation(String toolName, Map<String, ?> args) {
210+
ObjectNode argsNode = JsonNodeFactory.instance.objectNode();
211+
ObjectMapper mapper = new ObjectMapper();
212+
argsNode.setAll((ObjectNode) mapper.valueToTree(args));
213+
return new ToolInvocation().setToolName(toolName).setArguments(argsNode);
214+
}
215+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// GENERATED by CopilotToolProcessor — do not edit (hand-written test fixture)
2+
package com.github.copilot.rpc.fixtures;
3+
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.github.copilot.rpc.ToolDefinition;
6+
7+
import java.util.*;
8+
import java.util.concurrent.CompletableFuture;
9+
10+
final class ArgCoercionTools$$CopilotToolMeta {
11+
12+
private static Map<String, Object> withMeta(Map<String, Object> base, String description, Object defaultValue) {
13+
var result = new LinkedHashMap<String, Object>(base);
14+
if (description != null)
15+
result.put("description", description);
16+
if (defaultValue != null)
17+
result.put("default", defaultValue);
18+
return Collections.unmodifiableMap(result);
19+
}
20+
21+
@SuppressWarnings({"unchecked", "rawtypes"})
22+
static List<ToolDefinition> definitions(ArgCoercionTools instance, ObjectMapper mapper) {
23+
return List
24+
.of(new ToolDefinition("mixed_args", "Method with mixed argument types", Map.of(
25+
"type", "object", "properties", Map
26+
.ofEntries(
27+
Map.entry("text",
28+
(Map<String, Object>) (Map) withMeta(Map.of("type", "string"),
29+
"Text input", null)),
30+
Map.entry("count",
31+
(Map<String, Object>) (Map) withMeta(Map.of("type", "integer"),
32+
"A count", null)),
33+
Map.entry("flag",
34+
(Map<String, Object>) (Map) withMeta(Map.of("type", "boolean"),
35+
"A flag", null)),
36+
Map.entry("color",
37+
(Map<String, Object>) (Map) withMeta(Map.of("type", "string", "enum",
38+
List.of("RED", "GREEN", "BLUE")), "A color", null))),
39+
"required", List.of("text", "count", "flag", "color")), invocation -> {
40+
Map<String, Object> args = invocation.getArguments();
41+
String text = (String) args.get("text");
42+
int count = ((Number) args.get("count")).intValue();
43+
boolean flag = (Boolean) args.get("flag");
44+
ArgCoercionTools.Color color = ArgCoercionTools.Color.valueOf((String) args.get("color"));
45+
return CompletableFuture.completedFuture(instance.mixedArgs(text, count, flag, color));
46+
}, null, null, null));
47+
}
48+
}

0 commit comments

Comments
 (0)