Skip to content

Commit abb8574

Browse files
committed
Add a basic A2A server extension
For now, the extension does two things: - Simplifies the AgentCard creation - Creates an implementation of AgentExecutor The implementation still has significant omissions, as can be seen in the various `TODO` comments, but it does show that the basic approach works Relates to: #1895
1 parent 90f5697 commit abb8574

File tree

19 files changed

+905
-19
lines changed

19 files changed

+905
-19
lines changed

a2a/a2a-server/deployment/pom.xml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>io.quarkiverse.langchain4j</groupId>
7+
<artifactId>quarkus-langchain4j-a2a-server-parent</artifactId>
8+
<version>999-SNAPSHOT</version>
9+
</parent>
10+
<artifactId>quarkus-langchain4j-a2a-server-deployment</artifactId>
11+
<name>Quarkus LangChain4j - A2A - Server - Deployment</name>
12+
<dependencies>
13+
<dependency>
14+
<groupId>io.quarkiverse.langchain4j</groupId>
15+
<artifactId>quarkus-langchain4j-core-deployment</artifactId>
16+
<version>${project.version}</version>
17+
</dependency>
18+
19+
<dependency>
20+
<groupId>io.quarkiverse.langchain4j</groupId>
21+
<artifactId>quarkus-langchain4j-a2a-server</artifactId>
22+
<version>${project.version}</version>
23+
</dependency>
24+
25+
<dependency>
26+
<groupId>io.quarkus</groupId>
27+
<artifactId>quarkus-reactive-routes-deployment</artifactId>
28+
</dependency>
29+
30+
<dependency>
31+
<groupId>io.quarkus</groupId>
32+
<artifactId>quarkus-smallrye-health-spi</artifactId>
33+
</dependency>
34+
<dependency>
35+
<groupId>io.quarkus</groupId>
36+
<artifactId>quarkus-smallrye-health-deployment</artifactId>
37+
<optional>true</optional>
38+
</dependency>
39+
<dependency>
40+
<groupId>io.quarkus</groupId>
41+
<artifactId>quarkus-junit5-internal</artifactId>
42+
<scope>test</scope>
43+
</dependency>
44+
<dependency>
45+
<groupId>io.quarkiverse.langchain4j</groupId>
46+
<artifactId>quarkus-langchain4j-openai-deployment</artifactId>
47+
<scope>test</scope>
48+
<version>${project.version}</version>
49+
</dependency>
50+
<dependency>
51+
<groupId>io.quarkiverse.langchain4j</groupId>
52+
<artifactId>quarkus-langchain4j-openai-testing-internal</artifactId>
53+
<scope>test</scope>
54+
<version>${project.version}</version>
55+
</dependency>
56+
<dependency>
57+
<groupId>org.wiremock</groupId>
58+
<artifactId>wiremock-standalone</artifactId>
59+
<version>${wiremock.version}</version>
60+
<scope>compile</scope>
61+
</dependency>
62+
<dependency>
63+
<groupId>org.assertj</groupId>
64+
<artifactId>assertj-core</artifactId>
65+
<version>${assertj.version}</version>
66+
<scope>test</scope>
67+
</dependency>
68+
</dependencies>
69+
<build>
70+
<plugins>
71+
<plugin>
72+
<artifactId>maven-compiler-plugin</artifactId>
73+
<configuration>
74+
<annotationProcessorPaths>
75+
<path>
76+
<groupId>io.quarkus</groupId>
77+
<artifactId>quarkus-extension-processor</artifactId>
78+
</path>
79+
</annotationProcessorPaths>
80+
</configuration>
81+
</plugin>
82+
</plugins>
83+
</build>
84+
</project>
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package io.quarkiverse.langchain4j.a2a.server.deployment;
2+
3+
import java.lang.reflect.Modifier;
4+
import java.util.ArrayList;
5+
import java.util.Arrays;
6+
import java.util.Collection;
7+
import java.util.Collections;
8+
import java.util.List;
9+
10+
import jakarta.enterprise.inject.spi.DeploymentException;
11+
import jakarta.inject.Inject;
12+
import jakarta.inject.Singleton;
13+
14+
import org.jboss.jandex.AnnotationInstance;
15+
import org.jboss.jandex.AnnotationTransformation;
16+
import org.jboss.jandex.AnnotationValue;
17+
import org.jboss.jandex.ClassInfo;
18+
import org.jboss.jandex.IndexView;
19+
import org.jboss.jandex.MethodInfo;
20+
import org.jboss.jandex.MethodParameterInfo;
21+
22+
import io.a2a.server.agentexecution.AgentExecutor;
23+
import io.a2a.spec.AgentCapabilities;
24+
import io.a2a.spec.AgentExtension;
25+
import io.a2a.spec.AgentSkill;
26+
import io.a2a.spec.Message;
27+
import io.quarkiverse.langchain4j.RegisterAiService;
28+
import io.quarkiverse.langchain4j.a2a.server.AgentCardBuilderCustomizer;
29+
import io.quarkiverse.langchain4j.a2a.server.ExposeA2AAgent;
30+
import io.quarkiverse.langchain4j.a2a.server.runtime.A2AServerRecorder;
31+
import io.quarkiverse.langchain4j.a2a.server.runtime.card.AgentCardProducer;
32+
import io.quarkiverse.langchain4j.a2a.server.runtime.executor.QuarkusBaseAgentExecutor;
33+
import io.quarkiverse.langchain4j.deployment.AiServicesUtil;
34+
import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem;
35+
import io.quarkus.arc.deployment.GeneratedBeanBuildItem;
36+
import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor;
37+
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
38+
import io.quarkus.arc.processor.DotNames;
39+
import io.quarkus.deployment.annotations.BuildProducer;
40+
import io.quarkus.deployment.annotations.BuildStep;
41+
import io.quarkus.deployment.annotations.ExecutionTime;
42+
import io.quarkus.deployment.annotations.Record;
43+
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
44+
import io.quarkus.gizmo.ClassCreator;
45+
import io.quarkus.gizmo.ClassOutput;
46+
import io.quarkus.gizmo.FieldDescriptor;
47+
import io.quarkus.gizmo.MethodCreator;
48+
import io.quarkus.gizmo.MethodDescriptor;
49+
import io.quarkus.gizmo.ResultHandle;
50+
51+
public class A2AServerProcessor {
52+
53+
@BuildStep
54+
@Record(ExecutionTime.RUNTIME_INIT)
55+
public void beans(CombinedIndexBuildItem indexBuildItem,
56+
A2AServerRecorder recorder,
57+
BuildProducer<AnnotationsTransformerBuildItem> annotationsTransformerProducer,
58+
BuildProducer<SyntheticBeanBuildItem> syntheticBeanProducer,
59+
BuildProducer<GeneratedBeanBuildItem> generatedBeanProducer) {
60+
61+
IndexView index = indexBuildItem.getIndex();
62+
Collection<AnnotationInstance> exposeInstances = index.getAnnotations(ExposeA2AAgent.class);
63+
if (exposeInstances.isEmpty()) {
64+
65+
// We veto the io.quarkiverse.langchain4j.a2a.server.runtime.card.AgentCardProducer when there is no agent
66+
annotationsTransformerProducer.produce(new AnnotationsTransformerBuildItem(vetoingClassTransformation(
67+
AgentCardProducer.class.getName())));
68+
69+
return;
70+
}
71+
if (exposeInstances.size() > 1) {
72+
throw new DeploymentException("Multiple expose instances found for '" + ExposeA2AAgent.class.getName()
73+
+ "'. Currently, only exposing a single A2A agent is supported");
74+
}
75+
AnnotationInstance exposeInstance = exposeInstances.iterator().next();
76+
ClassInfo targetClass = exposeInstance.target().asClass();
77+
if (!targetClass.hasDeclaredAnnotation(RegisterAiService.class)) {
78+
throw new DeploymentException(
79+
"'@ExposeA2AAgent' can only be placed on an AI Service that is annotated with @RegisterAiService."
80+
+ " Offending class is '"
81+
+ targetClass.name() + "'");
82+
}
83+
84+
createAgentCardBean(recorder, syntheticBeanProducer, exposeInstance);
85+
ClassOutput generatedBeanOutput = new GeneratedBeanGizmoAdaptor(generatedBeanProducer);
86+
generateAgentExecutor(targetClass, index, generatedBeanOutput);
87+
}
88+
89+
private AnnotationTransformation vetoingClassTransformation(String className) {
90+
return AnnotationTransformation
91+
.forClasses()
92+
.when(tc -> {
93+
return tc.declaration().asClass().name().toString().equals(className);
94+
})
95+
.transform(tc -> tc.add(AnnotationInstance.builder(DotNames.VETOED).buildWithTarget(tc.declaration())));
96+
}
97+
98+
private void createAgentCardBean(A2AServerRecorder recorder,
99+
BuildProducer<SyntheticBeanBuildItem> syntheticBeanProducer,
100+
AnnotationInstance exposeInstance) {
101+
String agentName = exposeInstance.value("name").asString();
102+
String agentDescription = exposeInstance.value("description").asString();
103+
104+
AnnotationValue streamingValue = exposeInstance.value("streaming");
105+
boolean streaming = streamingValue != null ? streamingValue.asBoolean() : false;
106+
107+
AnnotationValue pushNotificationsValue = exposeInstance.value("pushNotifications");
108+
boolean pushNotifications = pushNotificationsValue != null ? pushNotificationsValue.asBoolean() : false;
109+
110+
AnnotationValue stateTransitionHistoryValue = exposeInstance.value("stateTransitionHistory");
111+
boolean stateTransitionHistory = stateTransitionHistoryValue != null ? stateTransitionHistoryValue.asBoolean() : false;
112+
113+
// TODO: I have no idea what to do with this
114+
List<AgentExtension> extensions = Collections.emptyList();
115+
116+
// TODO: I assume we need to figure these out depending on the the method parameters and return type of the
117+
// AI service
118+
List<String> defaultInputModes = Collections.emptyList();
119+
List<String> defaultOutputModes = Collections.emptyList();
120+
121+
AnnotationInstance[] skillInstances = exposeInstance.value("skills").asNestedArray();
122+
List<AgentSkill> skills = new ArrayList<>();
123+
for (AnnotationInstance skillInstance : skillInstances) {
124+
AgentSkill.Builder skillBuilder = new AgentSkill.Builder();
125+
skillBuilder.id(skillInstance.value("id").asString());
126+
skillBuilder.name(skillInstance.value("name").asString());
127+
skillBuilder.description(skillInstance.value("description").asString());
128+
String[] tags = skillInstance.value("tags").asStringArray();
129+
if (tags != null) {
130+
skillBuilder.tags(Arrays.asList(tags));
131+
}
132+
String[] examples = skillInstance.value("examples").asStringArray();
133+
if (examples != null) {
134+
skillBuilder.examples(Arrays.asList(examples));
135+
}
136+
skills.add(skillBuilder.build());
137+
}
138+
139+
var configurator = SyntheticBeanBuildItem
140+
.configure(AgentCardBuilderCustomizer.class)
141+
.setRuntimeInit()
142+
.runtimeValue(
143+
recorder.staticInfoCustomizer(agentName, agentDescription,
144+
new AgentCapabilities(streaming, pushNotifications, stateTransitionHistory,
145+
extensions),
146+
defaultInputModes, defaultOutputModes, skills));
147+
148+
syntheticBeanProducer.produce(configurator.done());
149+
}
150+
151+
/**
152+
* Generates an implementation of {@link AgentExecutor} that looks something like:
153+
*
154+
* <pre>
155+
* &#64;Singleton
156+
* public class WeatherAgent$AgentExecutor extends QuarkusBaseAgentExecutor {
157+
*
158+
* private final WeatherAgent aiService;
159+
*
160+
* &#64;Inject
161+
* public WeatherAgent$AgentExecutor(WeatherAgent aiService) {
162+
* this.aiService = aiService;
163+
* }
164+
*
165+
* protected List<Part<?>> invoke(Message message) {
166+
* String aiServiceResult = aiService.chat(textPartsToString(message));
167+
* return stringResultToParts(aiServiceResult);
168+
* }
169+
* }
170+
* </pre>
171+
*/
172+
private void generateAgentExecutor(ClassInfo classInfo, IndexView index,
173+
ClassOutput classOutput) {
174+
List<MethodInfo> aiServiceMethods = AiServicesUtil.determineAiServiceMethods(classInfo, index);
175+
if (aiServiceMethods.size() != 1) {
176+
throw new DeploymentException(
177+
"'@ExposeA2AAgent' can only be placed on an AI Service that has a single method. Offending class "
178+
+ "is '"
179+
+ classInfo.name() + "'");
180+
}
181+
182+
String implClassName = classInfo.name().packagePrefix() + "." + classInfo.simpleName()
183+
+ "$AgentExecutor";
184+
ClassCreator.Builder classCreatorBuilder = ClassCreator.builder()
185+
.classOutput(classOutput)
186+
.className(implClassName)
187+
.superClass(QuarkusBaseAgentExecutor.class);
188+
try (ClassCreator classCreator = classCreatorBuilder.build()) {
189+
classCreator.addAnnotation(Singleton.class);
190+
191+
FieldDescriptor aiServiceField = classCreator.getFieldCreator("aiService", classInfo.name().toString())
192+
.setModifiers(Modifier.PRIVATE | Modifier.FINAL)
193+
.getFieldDescriptor();
194+
{
195+
MethodCreator ctor = classCreator.getMethodCreator(MethodDescriptor.INIT, "V",
196+
classInfo.name().toString());
197+
ctor.setModifiers(Modifier.PUBLIC);
198+
ctor.addAnnotation(Inject.class);
199+
ctor.invokeSpecialMethod(MethodDescriptor.ofConstructor(QuarkusBaseAgentExecutor.class),
200+
ctor.getThis());
201+
ctor.writeInstanceField(aiServiceField, ctor.getThis(),
202+
ctor.getMethodParam(0));
203+
ctor.returnValue(null);
204+
}
205+
206+
{
207+
MethodCreator invoke = classCreator
208+
.getMethodCreator(MethodDescriptor.ofMethod(implClassName, "invoke", List.class,
209+
Message.class));
210+
invoke.setModifiers(Modifier.PROTECTED);
211+
212+
MethodInfo aiServiceMethod = aiServiceMethods.iterator().next();
213+
214+
List<ResultHandle> aiServiceMethodParamHandles = new ArrayList<>();
215+
List<MethodParameterInfo> aiServiceMethodParams = aiServiceMethod.parameters();
216+
if (aiServiceMethodParams.size() != 1) {
217+
// TODO: implement
218+
throw new RuntimeException("Not yet implemented");
219+
}
220+
aiServiceMethodParams.forEach(p -> {
221+
if (!p.type().name().equals(DotNames.STRING)) {
222+
// TODO: implement
223+
throw new RuntimeException("Not yet implemented");
224+
}
225+
aiServiceMethodParamHandles.add(invoke.invokeVirtualMethod(
226+
MethodDescriptor.ofMethod(implClassName, "textPartsToString", String.class,
227+
Message.class),
228+
invoke.getThis(), invoke.getMethodParam(0)));
229+
});
230+
231+
ResultHandle aiServiceResultHandle = invoke.invokeInterfaceMethod(MethodDescriptor.of(aiServiceMethod),
232+
invoke.readInstanceField(aiServiceField, invoke.getThis()),
233+
aiServiceMethodParamHandles.toArray(new ResultHandle[aiServiceMethodParams.size()]));
234+
235+
if (!aiServiceMethod.returnType().name().equals(DotNames.STRING)) {
236+
// TODO: implement
237+
throw new RuntimeException("Not yet implemented");
238+
}
239+
240+
ResultHandle result = invoke.invokeVirtualMethod(
241+
MethodDescriptor.ofMethod(implClassName, "stringResultToParts", List.class,
242+
String.class),
243+
invoke.getThis(), aiServiceResultHandle);
244+
245+
invoke.returnValue(result);
246+
}
247+
}
248+
}
249+
}

0 commit comments

Comments
 (0)