feat(#984): Swarm — ServiceLoader-based agent discovery + absorb#34
Merged
Skobeltsyn merged 6 commits intomainfrom May 4, 2026
Merged
feat(#984): Swarm — ServiceLoader-based agent discovery + absorb#34Skobeltsyn merged 6 commits intomainfrom
Skobeltsyn merged 6 commits intomainfrom
Conversation
A captain agent discovers sibling agent JARs on its classpath and
absorbs their full Agent<*, *> surfaces as tools, preserving each
sibling's personality (prompt, skills, knowledge, memory, observability
hooks, error handlers).
API:
// Each sibling JAR ships:
// META-INF/services/agents_engine.runtime.AgentProvider
// pointing at:
interface AgentProvider {
fun build(): Agent<*, *>
}
// Captain main():
fun main(args: Array<String>) {
val me = agent<String, String>("captain") { ... }
Swarm.discover()
.filterNot { it.name == me.name }
.forEach { sibling -> me.absorb(sibling) }
LiveRunner.serve(me, args)
}
In-JVM only — siblings live in the same classloader as the captain,
so the framework's typed Agent contract reaches them directly. No
serialization across JAR boundaries, no IPC overhead. Cross-language
remains MCP-stdio's job (separate ticket).
`absorb`:
- registers a tool named after the sibling agent
- the tool's executor calls sibling.invoke(query)
- auto-enables the tool across all of the captain's skills
- requires sibling to be Agent<String, *>; typed-input siblings throw
IllegalArgumentException with a clear message
- forbids absorbing self (collision against captain's own name)
- forbids two siblings with the same name (collision in toolMap)
Tests, TDD red first:
SwarmTest (9 tests):
- AgentProvider is a real interface
- Swarm.discover(emptyClassLoader) finds nothing
- Swarm.discover() finds the in-test fixture provider via
META-INF/services
- absorb adds a tool named after the sibling
- absorbed tool delegates to sibling and returns its output
- two same-name siblings → second absorb fails fast
- cannot absorb self
- typed-input sibling fails fast with helpful error
- absorbed tool is auto-available across captain's skills
SwarmJarIntegrationTest (1 end-to-end test):
- writes a real Java AgentProvider source to a temp dir
- compiles via javax.tools.JavaCompiler (always present in JDK)
- packs into a real JAR with manifest + META-INF/services
- URLClassLoader over the JAR with the test classpath as parent
- Swarm.discover(loader) finds the sibling
- sibling.invoke("hello") returns "from-jar:hello" — proves the
JAR-loaded code path is end-to-end functional, not just a name
on a list
- captain.absorb(sibling); the absorbed tool reaches the
JAR-loaded sibling correctly
The integration test relies on the JDK's bundled JavaCompiler — no
extra Gradle dependency. Provider source is Java (not Kotlin) to
avoid pulling kotlin-compiler-embeddable into the test JVM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the swarm integration tests to actually exercise the multi-JAR scenario the original feature describes. Previously only one sibling JAR was tested; now: - compileSiblingJar(...) helper extracted — hermetic per-JAR build: own work dir, own Java package, own provider class. Multiple calls produce independent JARs. Output dir is configurable so several JARs land in one shared "agents/" folder. - "multi-JAR — drop several jars into a folder and discover them all": builds two independently-compiled JARs (alpha-agent.jar, beta-agent.jar) in different Java packages, places both in the same agents/ folder, points one URLClassLoader at both, runs Swarm.discover, invokes each sibling, and verifies a captain can absorb both with each becoming an independently-callable tool. - "multi-JAR — captain glob-loads every jar in a folder": three JARs this time, loaded via Files.list(folder).filter(*.jar) — exactly what a launch script doing `java -cp 'agents/*'` would do. Proves "drop a JAR in the folder, it joins the swarm" works without per-sibling configuration. Test predicate uses contains-checks rather than exact set equality because the test classloader's parent has the in-test fixture provider (swarm-fixture-alpha) registered too. The multi-JAR scenario is what's under test; classloader-hierarchy filtering is a separate concern. 3 integration tests now (up from 1). 12 swarm tests total. All green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demos the full swarm UX end-to-end with three sibling agents:
fib — has tool `fibonacci` (its own personality is math-focused)
factor — has tool `factor_number` (prime-factor an integer)
exit — has tool `exit_app` (System.exit(0))
All three are registered as ServiceLoader providers in
META-INF/services/agents_engine.runtime.AgentProvider, alongside the
existing test fixture. The demo's main() picks `fib` as the captain,
filters Swarm.discover() to the SWARM_DEMO_NAMES set (so the in-test
SwarmTest fixture isn't accidentally absorbed), and absorbs the other
two. The captain then runs through LiveRunner.serve.
Captain prompt is router-style: explicit tool descriptions for
fibonacci / factor / exit, with "always call the tool, never reply in
plain text" guidance. This is what's needed to reliably make a
gpt-oss model delegate exit requests to the exit sibling rather than
hallucinating a "goodbye" text response.
End-to-end verified with piped input:
$ printf "fib(8)\\nfactor 60\\nbye\\n" | ./gradlew swarmDemo --console=plain -q
fib> The 8th Fibonacci number is **21**.
fib> The prime factorization of 60 is 2 × 2 × 3 × 5.
fib>
(exit agent — shutting down)
Each line proves a different path:
fib(8) → captain's own fibonacci tool
factor 60 → captain delegates to factor sibling, sibling's
LLM internally calls its own factor_number tool
bye → captain delegates to exit sibling, sibling's LLM
calls exit_app, exitProcess(0) fires
Same JavaExec + stdin-forwarded pattern as interactiveLiveShow /
interactivePipeline. Lives under src/test/kotlin so it never ships
in the published JAR.
Run: ./gradlew swarmDemo --console=plain -q
Prerequisites: Ollama signed in for gpt-oss:120b-cloud (or change
MODEL constant to a local model with good tool-call discipline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds compact tool-trace logging via onToolUse on each demo agent so
the user can see swarm dispatch happen in real time, including the
two-layer indirection for absorbed siblings.
Format: ` [<agentName>] <toolName>(args) → result` to stderr.
Sample run:
fib> fib(8)
[fib] fibonacci(n=8) → 21
The 8th Fibonacci number is **21**.
fib> factor 60
[factor] factor_number(n=60) → 2, 2, 3, 5
[fib] factor(query=factor 60) → 2, 2, 3, 5
The prime factorization of 60 is 2 × 2 × 3 × 5.
The factor case shows the swarm UX clearly: the inner sibling-side
trace fires first (deeper call), then the captain-side absorbed-tool
trace fires as that call returns. Two LLM dispatches, two onToolUse
firings, transparent to the user.
Exit doesn't trace — exitProcess(0) fires inside the tool executor
before AgenticLoop reaches the post-call onToolUse callback. That's
correct framework behavior; the exit agent prints its own
"(exit agent — shutting down)" line before exiting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the in-test single-classpath swarm demo with the production
"drop JARs into a folder" pathway. Three sibling agents are now
genuinely separate JARs:
build/tmp/jars_swarm_demo/fib.jar (captain — has main())
build/tmp/jars_swarm_demo/factor.jar
build/tmp/jars_swarm_demo/exit.jar
Each JAR is hermetic — own provider class, own agent build code, own
small helper copies (traceTool / readInt), own META-INF/services
descriptor. No cross-JAR class sharing; each could realistically come
from a different team.
Source layout split into three subpackages:
agents_engine.runtime.swarmdemo.fib
agents_engine.runtime.swarmdemo.factor
agents_engine.runtime.swarmdemo.exitagent
Per-JAR service descriptors live in
src/test/swarm-jar-resources/<name>/META-INF/services/agents_engine.runtime.AgentProvider
and get packed into the corresponding JAR at build time.
Three Gradle Jar tasks (jarSwarmFib / jarSwarmFactor / jarSwarmExit)
each pull only their subpackage's compiled classes plus the per-JAR
service file. Aggregate task `buildSwarmDemoJars` runs all three.
The `swarmDemo` task is now a JavaExec with classpath = framework
runtime + the three sibling JARs ONLY (sourceSets.test runtimeClasspath
deliberately excluded). ServiceLoader sees providers exclusively from
the JARs — proves real classloader-driven discovery, not the in-test
shortcut.
Captain main lives inside fib.jar (entry class
agents_engine.runtime.swarmdemo.fib.FibAgentKt).
End-to-end verified against the real JARs:
$ printf "fib(8)\\nfactor 60\\nbye\\n" | ./gradlew swarmDemo --console=plain -q
fib> [fib] fibonacci(n=8) → 21
The 8th Fibonacci number is **21**.
fib> [factor] factor_number(n=60) → 2, 2, 3, 5
[fib] factor(query=factor 60) → 2, 2, 3, 5
The prime factorization of 60 is 2 × 2 × 3 × 5.
fib>
(exit agent — shutting down)
Each line proves a different pathway:
fib(8) → captain's own fibonacci tool inside fib.jar
factor 60 → captain delegates to factor.jar's agent; that
agent's LLM calls its own factor_number tool
bye → captain delegates to exit.jar's agent; that
agent's LLM calls exit_app, exitProcess(0) fires
The two-layer trace for `factor 60` confirms classloader-isolated
JARs really run independently — onToolUse fires inside both the
captain (fib.jar) and the sibling (factor.jar), proving each agent's
observability hooks work in their own classloader scope.
Demo provider entries removed from the global
src/test/resources/META-INF/services/... since they now live in the
per-JAR descriptors. Only the SwarmTestProviderFixture remains there
for the unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds build/tmp/jars_swarm_demo_lib/ alongside the existing
build/tmp/jars_swarm_demo/ dir. The lib dir gets the framework JAR
(agents-kt-<version>.jar from the standard `jar` task) plus every
runtime dependency (kotlin-stdlib, kotlin-reflect, kotlinx-coroutines,
annotations).
`buildSwarmDemoJars` now depends on `copySwarmDemoLibs` so a single
build command produces everything needed:
$ ./gradlew buildSwarmDemoJars
$ java -cp 'build/tmp/jars_swarm_demo_lib/*:build/tmp/jars_swarm_demo/*' \
agents_engine.runtime.swarmdemo.fib.FibAgentKt
Verified — bare java runs the full swarm demo end-to-end against the
real JARs:
fib> [fib] fibonacci(n=8) → 21
fib> [factor] factor_number(n=60) → 2, 2, 3, 5
[fib] factor(query=factor 60) → 2, 2, 3, 5
No Gradle in the launch path. Once the JARs and libs are staged,
`java -cp ...` is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A captain agent discovers sibling agent JARs on its classpath and absorbs their full Agent<*, *> surfaces as tools, preserving each sibling's personality (prompt, skills, knowledge, memory, observability hooks, error handlers).
API:
In-JVM only — siblings live in the same classloader as the captain, so the framework's typed Agent contract reaches them directly. No serialization across JAR boundaries, no IPC overhead. Cross-language remains MCP-stdio's job (separate ticket).
absorb:Tests, TDD red first:
SwarmTest (9 tests):
- AgentProvider is a real interface
- Swarm.discover(emptyClassLoader) finds nothing
- Swarm.discover() finds the in-test fixture provider via META-INF/services
- absorb adds a tool named after the sibling
- absorbed tool delegates to sibling and returns its output
- two same-name siblings → second absorb fails fast
- cannot absorb self
- typed-input sibling fails fast with helpful error
- absorbed tool is auto-available across captain's skills
SwarmJarIntegrationTest (1 end-to-end test):
- writes a real Java AgentProvider source to a temp dir
- compiles via javax.tools.JavaCompiler (always present in JDK)
- packs into a real JAR with manifest + META-INF/services
- URLClassLoader over the JAR with the test classpath as parent
- Swarm.discover(loader) finds the sibling
- sibling.invoke("hello") returns "from-jar:hello" — proves the JAR-loaded code path is end-to-end functional, not just a name on a list
- captain.absorb(sibling); the absorbed tool reaches the JAR-loaded sibling correctly
The integration test relies on the JDK's bundled JavaCompiler — no extra Gradle dependency. Provider source is Java (not Kotlin) to avoid pulling kotlin-compiler-embeddable into the test JVM.