Skip to content

Commit b5a295f

Browse files
authored
Merge pull request #42 from tsuz/feat/multi-agent
feat: multi-agent communication via async tool replies
2 parents ad98387 + d011c40 commit b5a295f

37 files changed

Lines changed: 2273 additions & 178 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Multi-Agent Integration
2+
3+
# End-to-end test of the examples/multi-agent-setup stack: build the images,
4+
# send a message to orchestrator-api, and verify the orchestrator → worker →
5+
# callback round-trip.
6+
#
7+
# Runs post-merge on main (and on manual dispatch) — not as a PR gate. It hits
8+
# the real Claude API, so it needs the CLAUDE_API_KEY repo secret; if the secret
9+
# is unset the job soft-skips rather than failing main.
10+
11+
on:
12+
push:
13+
branches: [main]
14+
paths:
15+
- examples/multi-agent-setup/**
16+
- api/**
17+
- frontend/**
18+
- processor-apps/processing/**
19+
- think/think-consumer/**
20+
- .github/workflows/multi-agent-integration.yml
21+
workflow_dispatch:
22+
23+
jobs:
24+
e2e:
25+
name: Orchestrator → Worker round-trip
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 30
28+
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- name: Check for CLAUDE_API_KEY secret
33+
id: guard
34+
env:
35+
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
36+
run: |
37+
if [ -z "$CLAUDE_API_KEY" ]; then
38+
echo "::warning::CLAUDE_API_KEY secret not set — skipping integration test"
39+
echo "skip=true" >> "$GITHUB_OUTPUT"
40+
fi
41+
42+
- name: Run integration test
43+
if: steps.guard.outputs.skip != 'true'
44+
working-directory: examples/multi-agent-setup
45+
env:
46+
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
47+
TIMEOUT: "300"
48+
run: ./integration-test.sh
49+
50+
- name: Tear down (backstop)
51+
if: always()
52+
working-directory: examples/multi-agent-setup
53+
run: docker compose -p multiagent-it down -v || true

‎api/chat-api/Dockerfile‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ WORKDIR /app
33
COPY pom.xml .
44
RUN mvn dependency:go-offline -q
55
COPY src ./src
6-
RUN mvn package -q -DskipTests
6+
# Skip test compilation+execution for the image build (tests run in CI via
7+
# `mvn test`); keeps the image build decoupled from test sources.
8+
RUN mvn package -q -Dmaven.test.skip=true
79

810
FROM eclipse-temurin:17-jre
911
WORKDIR /app

‎api/chat-api/pom.xml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,29 @@
5353
<artifactId>Java-WebSocket</artifactId>
5454
<version>1.5.7</version>
5555
</dependency>
56+
57+
<!-- Testing -->
58+
<dependency>
59+
<groupId>org.junit.jupiter</groupId>
60+
<artifactId>junit-jupiter</artifactId>
61+
<version>5.10.2</version>
62+
<scope>test</scope>
63+
</dependency>
64+
<dependency>
65+
<groupId>org.assertj</groupId>
66+
<artifactId>assertj-core</artifactId>
67+
<version>3.25.3</version>
68+
<scope>test</scope>
69+
</dependency>
5670
</dependencies>
5771

5872
<build>
5973
<plugins>
74+
<plugin>
75+
<groupId>org.apache.maven.plugins</groupId>
76+
<artifactId>maven-surefire-plugin</artifactId>
77+
<version>3.2.5</version>
78+
</plugin>
6079
<plugin>
6180
<groupId>org.apache.maven.plugins</groupId>
6281
<artifactId>maven-shade-plugin</artifactId>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.flightdeck.api;
2+
3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
7+
/**
8+
* Resolves a logical callback-service name to the trusted base URL configured for
9+
* it, then appends the fixed callback path ({@value #CALLBACK_PATH}).
10+
*
11+
* <p>The mapping is loaded once from the {@code ALLOWED_HOST_MAPPING} environment
12+
* variable: a comma-separated list of {@code name:baseUrl} entries. Only the first
13+
* colon of each entry separates the name from the URL, so the base URL keeps its
14+
* own {@code scheme://host[:port]} colons:
15+
*
16+
* <pre>
17+
* ALLOWED_HOST_MAPPING=my-agent-a:https://hosta.local,my-agent-c:http://hostc.local
18+
* </pre>
19+
*
20+
* <p><b>Why this exists.</b> A caller (a peer agent) supplies only the service
21+
* <em>name</em> in its {@code reply} descriptor; the destination URL is chosen
22+
* here from operator-controlled config, never from caller input. An untrusted
23+
* caller therefore cannot steer the server-side callback at an arbitrary host —
24+
* the SSRF primitive that an attacker-controlled {@code endpoint} would create is
25+
* structurally removed. Unknown names fail closed.
26+
*/
27+
final class CallbackRegistry {
28+
29+
/** Fixed path appended to every resolved base URL. */
30+
static final String CALLBACK_PATH = "/api/tools/response";
31+
32+
private static final Map<String, String> MAPPING =
33+
parse(ChatApiApp.env("ALLOWED_HOST_MAPPING", ""));
34+
35+
private CallbackRegistry() {}
36+
37+
/** True if {@code service} resolves to a configured base URL. */
38+
static boolean isKnown(String service) {
39+
return service != null && MAPPING.containsKey(service);
40+
}
41+
42+
/**
43+
* Resolves the full callback URL for a service name.
44+
*
45+
* @throws IllegalArgumentException if the name is not configured (fail closed)
46+
*/
47+
static String resolve(String service) {
48+
String base = service == null ? null : MAPPING.get(service);
49+
if (base == null) {
50+
throw new IllegalArgumentException("unknown callbackService: " + service);
51+
}
52+
return toCallbackUrl(base);
53+
}
54+
55+
/** Strips any trailing slash from the base URL and appends the fixed callback path. */
56+
static String toCallbackUrl(String base) {
57+
String trimmed = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
58+
return trimmed + CALLBACK_PATH;
59+
}
60+
61+
/** Parses {@code name:baseUrl} entries, splitting each on its FIRST colon only. */
62+
static Map<String, String> parse(String raw) {
63+
Map<String, String> mapping = new LinkedHashMap<>();
64+
if (raw == null || raw.isBlank()) {
65+
return Collections.unmodifiableMap(mapping);
66+
}
67+
for (String entry : raw.split(",")) {
68+
String e = entry.trim();
69+
if (e.isEmpty()) continue;
70+
int sep = e.indexOf(':');
71+
String name = sep > 0 ? e.substring(0, sep).trim() : "";
72+
String url = sep > 0 ? e.substring(sep + 1).trim() : "";
73+
if (name.isEmpty() || url.isEmpty()) {
74+
throw new IllegalArgumentException(
75+
"Malformed ALLOWED_HOST_MAPPING entry (expected name:baseUrl): " + entry);
76+
}
77+
mapping.put(name, url);
78+
}
79+
return Collections.unmodifiableMap(mapping);
80+
}
81+
}

‎api/chat-api/src/main/java/io/flightdeck/api/ChatApiApp.java‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@ public class ChatApiApp {
2121
private static final int WS_PORT = Integer.parseInt(env("WS_PORT", "8001"));
2222

2323
public static void main(String[] args) throws Exception {
24-
// 1. Kafka producer (chat → message-input)
24+
// 1. Kafka producers (chat → message-input, async callbacks → tool-use-result,
25+
// multi-agent reply routes → reply-to)
2526
KafkaMessageProducer producer = new KafkaMessageProducer();
27+
ToolResultProducer toolResultProducer = new ToolResultProducer();
28+
ReplyToProducer replyToProducer = new ReplyToProducer();
29+
30+
// Shared secret for verifying async tool callback tokens. Optional —
31+
// if unset, /api/tools/response rejects every callback.
32+
String callbackSecret = env("TOOL_CALLBACK_SECRET", "");
33+
if (callbackSecret.isBlank()) {
34+
log.warn("TOOL_CALLBACK_SECRET is not set — /api/tools/response will reject all callbacks");
35+
}
2636

2737
// 2. HTTP server for REST API
2838
HttpServer httpServer = HttpServer.create(new InetSocketAddress(PORT), 0);
29-
httpServer.createContext("/api/chat", new ChatHandler(producer));
39+
httpServer.createContext("/api/chat", new ChatHandler(producer, replyToProducer));
40+
httpServer.createContext("/api/tools/response",
41+
new ToolResponseHandler(toolResultProducer, callbackSecret));
3042
httpServer.setExecutor(null);
3143
httpServer.start();
3244
log.info("HTTP server started on port {}", PORT);
@@ -35,8 +47,9 @@ public static void main(String[] args) throws Exception {
3547
ChatWebSocketServer wsServer = new ChatWebSocketServer(WS_PORT);
3648
wsServer.start();
3749

38-
// 4. Kafka consumer (message-output → WebSocket chat response)
39-
OutputConsumer outputConsumer = new OutputConsumer(wsServer);
50+
// 4. Kafka consumer (message-output → WebSocket chat response, or → HTTP
51+
// callback for sessions that carry a reply-to descriptor)
52+
OutputConsumer outputConsumer = new OutputConsumer(wsServer, replyToProducer);
4053
Thread outputThread = new Thread(outputConsumer, "output-consumer");
4154
outputThread.setDaemon(true);
4255
outputThread.start();
@@ -55,6 +68,8 @@ public static void main(String[] args) throws Exception {
5568
try { wsServer.stop(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
5669
httpServer.stop(2);
5770
producer.close();
71+
toolResultProducer.close();
72+
replyToProducer.close();
5873
}));
5974

6075
log.info("Chat API ready — HTTP={} WS={}", PORT, WS_PORT);

‎api/chat-api/src/main/java/io/flightdeck/api/ChatHandler.java‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,33 @@
2424
* { "session_id": "...", "user_id": "user_42", "role": "user",
2525
* "content": "hello", "timestamp": "...",
2626
* "metadata": { "locale": "en-US", "client": "web" } }
27+
*
28+
* <p>For multi-agent calls the request may also carry a transport-level
29+
* {@code reply} descriptor naming where this session's terminal response should
30+
* be delivered, e.g.:
31+
* <pre>
32+
* { "session_id": "...", "content": "...",
33+
* "reply": { "callbackService": "my-agent-a", "bearerToken": "&lt;HMAC&gt;" } }
34+
* </pre>
35+
* {@code callbackService} is a logical name resolved server-side against
36+
* {@code ALLOWED_HOST_MAPPING} ({@link CallbackRegistry}); the caller never
37+
* supplies a URL, so the descriptor cannot steer the callback at an arbitrary
38+
* host. Unknown names are rejected here with a 400.
39+
* The {@code reply} object is NOT placed into the message content/metadata — it
40+
* is written to the reply-to topic (keyed by session_id) so it never reaches the
41+
* LLM. The agent processes the request as an ordinary chat.
2742
*/
2843
public class ChatHandler implements HttpHandler {
2944

3045
private static final Logger log = LoggerFactory.getLogger(ChatHandler.class);
3146
private static final ObjectMapper mapper = new ObjectMapper();
3247

3348
private final KafkaMessageProducer producer;
49+
private final ReplyToProducer replyToProducer;
3450

35-
public ChatHandler(KafkaMessageProducer producer) {
51+
public ChatHandler(KafkaMessageProducer producer, ReplyToProducer replyToProducer) {
3652
this.producer = producer;
53+
this.replyToProducer = replyToProducer;
3754
}
3855

3956
@Override
@@ -59,6 +76,28 @@ public void handle(HttpExchange exchange) throws IOException {
5976
String sessionId = requireField(body, "session_id");
6077
String content = requireField(body, "content");
6178

79+
// Transport-level reply routing (multi-agent). Written to the reply-to
80+
// topic keyed by session_id; never placed into the message content.
81+
if (body.hasNonNull("reply")) {
82+
JsonNode reply = body.get("reply");
83+
if (!reply.isObject()) {
84+
throw new IllegalArgumentException("'reply' must be an object");
85+
}
86+
String callbackService = reply.path("callbackService").asText("");
87+
if (callbackService.isBlank()) {
88+
throw new IllegalArgumentException("'reply' must include 'callbackService'");
89+
}
90+
// Fail closed: reject a descriptor naming a service this agent is not
91+
// configured to call back, so unroutable routes never reach the
92+
// reply-to topic and the caller gets an immediate 400.
93+
if (!CallbackRegistry.isKnown(callbackService)) {
94+
throw new IllegalArgumentException("unknown callbackService: " + callbackService);
95+
}
96+
replyToProducer.send(sessionId, mapper.writeValueAsString(reply));
97+
log.info("[{}] Stored reply-to descriptor (callbackService={})",
98+
sessionId, callbackService);
99+
}
100+
62101
// Build the full message-input payload
63102
ObjectNode message = mapper.createObjectNode();
64103
message.put("session_id", sessionId);

0 commit comments

Comments
 (0)