From 3e00ddd6a18a045c68a93c7b3b851aa3ce1fd401 Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 12:52:26 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(#984):=20Swarm=20=E2=80=94=20ServiceLo?= =?UTF-8?q?ader-based=20agent=20discovery=20+=20absorb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) { val me = agent("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; 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) --- README.md | 1 + docs/prd.md | 1 + .../kotlin/agents_engine/runtime/Swarm.kt | 106 ++++++++++++ .../runtime/SwarmJarIntegrationTest.kt | 162 ++++++++++++++++++ .../kotlin/agents_engine/runtime/SwarmTest.kt | 148 ++++++++++++++++ .../agents_engine.runtime.AgentProvider | 1 + 6 files changed, 419 insertions(+) create mode 100644 src/main/kotlin/agents_engine/runtime/Swarm.kt create mode 100644 src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt create mode 100644 src/test/kotlin/agents_engine/runtime/SwarmTest.kt create mode 100644 src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider diff --git a/README.md b/README.md index a89c84e..9c866fc 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes - **MCP server** — `McpServer.from(agent)` exposes an agent as an MCP-conformant server with explicit `tools/listChanged: false` capability (#619). - **`McpRunner` standalone** — picocli-style one-liner main for shipping agents as MCP services. - **`LiveShow` / `LiveRunner`** — REPL deployment with string-concatenated conversation history. Six factory overloads (Agent, Pipeline, Forum, Parallel, Loop, Branch) for any String-input structure; `--once ""` for non-interactive use; built-in `/quit`, `/clear`, `/help` slash commands; user-extensible (#981). +- **`Swarm` + `absorb`** — drop sibling agent JARs into a folder, the captain ServiceLoader-discovers them and absorbs each as a tool with full agent personality preserved (prompt, skills, knowledge, memory). In-JVM, no IPC, no static-typing-across-JARs limitation MCP-stdio would impose (#984). - **Frozen-after-construction agents** — structural mutators (skills, tools, memory, model, budget, prompt, error handlers, routing) reject post-construction calls (#697, #708). - **Encapsulated tool/skill maps** — `Agent.toolMap` and `Agent.skills` are read-only `Map` views; mutation only via DSL or framework-internal escape hatches (#659, #667). - **`LlmProviderException`** — provider-boundary errors (auth, model-not-found, capability mismatch) surface distinctly from output-parse errors (#702). diff --git a/docs/prd.md b/docs/prd.md index 08fd3b2..f7d37bb 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -3938,6 +3938,7 @@ Notation: `[x]` shipped, `[ ]` planned. Mirrors the README's roadmap so contribu - [x] Supply-chain hygiene — pinned Gradle wrapper, dependency-locking via `gradle.lockfile`, `gradle/verification-metadata.xml` SHA-256 verification, `updateVerificationMetadata` cross-platform Gradle task (#858, #872, #883) - [x] `loadResource(path)` / `loadResourceOrNull(path)` — read agent system prompts from classpath resources; fail-fast at agent construction when path is missing; UTF-8 decoded; leading-slash normalized (#980) - [x] `LiveShow` / `LiveRunner` — REPL deployment surface mirroring MCP's two-layer split (`LiveShow.from(x).start()` + `LiveRunner.serve(x, args)`). Six factory overloads cover `Agent` / `Pipeline` / `Forum` / `Parallel` / `Loop` / `Branch` (any String-input structure). String-concatenated conversation history with `--- user ---` / `--- assistant ---` delimiters and configurable cap. Built-in `/quit`, `/exit`, `/clear`, `/help` plus user-extensible `slash(name) { }`. `--once ""` for non-interactive single-turn use. ANSI color theme, ASCII Agents.KT banner, in-place cat spinner, lifecycle hooks (`onTurnStart` / `onTurnEnd` / `onErrorReported`), `renderOutput` post-processor (#981, #983) +- [x] `Swarm` — ServiceLoader-based agent discovery: each sibling JAR ships a `META-INF/services/agents_engine.runtime.AgentProvider`; the captain calls `Swarm.discover()` and `me.absorb(sibling)` to expose each sibling's `Agent<*, *>` surface as a tool with full personality preserved (prompt, skills, knowledge, memory). In-JVM only (single-classloader); cross-language is MCP's job (#984) - [ ] `>>` — security/education wrap ### Phase 2: Runtime + Distribution *(Q2 2026)* diff --git a/src/main/kotlin/agents_engine/runtime/Swarm.kt b/src/main/kotlin/agents_engine/runtime/Swarm.kt new file mode 100644 index 0000000..9285d01 --- /dev/null +++ b/src/main/kotlin/agents_engine/runtime/Swarm.kt @@ -0,0 +1,106 @@ +package agents_engine.runtime + +import agents_engine.core.Agent +import agents_engine.model.ToolDef +import java.util.ServiceLoader + +/** + * Service-loadable contract for an agent shipped from a separate JAR (#984). + * + * Each agent JAR places `META-INF/services/agents_engine.runtime.AgentProvider` + * pointing at a class that implements this interface. A captain agent then + * calls [Swarm.discover] to find and build all sibling providers from the + * classpath, and [Agent.absorb] to expose each sibling as a tool on itself. + * + * In-JVM (not MCP-stdio) by design — preserves the full `Agent` + * surface (prompt, skills, knowledge, memory, observability hooks, error + * handlers) of every sibling. Trade-off: JVM-only, no process isolation. See + * the issue description for the rationale. + */ +fun interface AgentProvider { + fun build(): Agent<*, *> +} + +/** + * Discovers sibling agents on the classpath via [ServiceLoader]. Every + * registered [AgentProvider] is instantiated; its `build()` is invoked once; + * results are returned as a list in classloader-iteration order. + */ +object Swarm { + + /** Discover via the calling thread's context classloader (the typical case). */ + fun discover(): List> = + discover(Thread.currentThread().contextClassLoader ?: this::class.java.classLoader) + + /** + * Discover via [classLoader]. Used by tests that wire a custom + * [URLClassLoader] over compiled-on-the-fly JARs, and by tooling that + * needs explicit control of which classloader's services are seen. + */ + fun discover(classLoader: ClassLoader): List> = + ServiceLoader.load(AgentProvider::class.java, classLoader) + .iterator() + .asSequence() + .map { it.build() } + .toList() +} + +/** + * Absorb a sibling agent into this captain — registers a tool named after + * `sibling.name` whose executor delegates to `sibling.invoke(...)`. The + * absorbed tool is auto-enabled across the captain's skills, so the + * captain's LLM can reach the sibling without any per-skill `tools(...)` + * declaration. + * + * Constraints: + * - sibling.name must not collide with any existing tool on the captain + * (or with the captain's own name — would mean absorbing self). + * - sibling must accept `String` input. Typed-input siblings (`Agent` + * where `X != String`) require schema-driven invocation; out of scope for + * v1. They throw [IllegalArgumentException] at absorb time. + * + * The tool input is `query: String`. The framework's existing tool-call path + * will pass it through to `sibling.invoke(query)` and return the sibling's + * output as the tool's result (rendered via `toString()`). + */ +fun Agent<*, *>.absorb(sibling: Agent<*, *>) { + require(sibling.name != this.name) { + "cannot absorb self: agent \"${this.name}\" cannot absorb itself" + } + require(sibling.name !in this.toolMap) { + "agent \"${this.name}\" already has a tool named \"${sibling.name}\". " + + "Two siblings with the same name? Pick unique agent names per JAR." + } + // Accept-string check. We can't reflect Kotlin generic type params from a + // built Agent, so we sample a known String input through the sibling's + // public type contract: every Agent can have its first skill's + // inType inspected. Skills carry KClass<*> for inType. + val firstSkill = sibling.skills.values.firstOrNull() + ?: throw IllegalArgumentException( + "sibling \"${sibling.name}\" has no skills — nothing to absorb", + ) + require(firstSkill.inType == String::class) { + "sibling \"${sibling.name}\" expects ${firstSkill.inType.simpleName} input, " + + "but absorb only supports Agent for v1. " + + "Consider exposing the typed agent via a String-input adapter." + } + + val tool = ToolDef( + name = sibling.name, + description = buildString { + append("Delegate to the \"") + append(sibling.name) + append("\" agent. Skills: ") + append(sibling.skills.values.joinToString("; ") { "${it.name} — ${it.description}" }) + }, + ) { args -> + val query = args["query"]?.toString() + ?: args.values.firstOrNull()?.toString() + ?: "" + @Suppress("UNCHECKED_CAST") + val asString = sibling as Agent + asString.invoke(query)?.toString() ?: "null" + } + registerBuiltInTool(tool) + enableAutoTool(tool.name) +} diff --git a/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt new file mode 100644 index 0000000..7183578 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt @@ -0,0 +1,162 @@ +package agents_engine.runtime + +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest +import javax.tools.JavaCompiler +import javax.tools.StandardLocation +import javax.tools.ToolProvider +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * End-to-end integration for #984 — compiles a real AgentProvider Java source + * on the fly, packages it into a real JAR, loads it via [URLClassLoader] (with + * the test classpath as parent so the loaded class can see [AgentProvider] and + * the framework's [agents_engine.core.Agent] machinery), and verifies that + * [Swarm.discover] finds the agent through `ServiceLoader`. + * + * Uses `javax.tools.JavaCompiler` (always shipped with the JDK), so no extra + * Gradle dependency is needed. The provider source is intentionally Java + * (not Kotlin) so we don't need to drag the Kotlin compiler into the test JVM. + */ +@OptIn(kotlin.io.path.ExperimentalPathApi::class) +class SwarmJarIntegrationTest { + + @Test + fun `compile, JAR-package, classload, and discover a sibling agent`() { + val workDir = createTempDirectory("swarm-jar-it-") + try { + // 1. Write the Java source for an AgentProvider implementation. + // The build() method returns an Agent constructed via the + // framework's agent() / Skill DSL — same classes the test JVM + // already has on its classpath. + val src = workDir.resolve("ExternalAgentProvider.java") + src.writeText( + """ + package external; + import agents_engine.runtime.AgentProvider; + import agents_engine.core.Agent; + public class ExternalAgentProvider implements AgentProvider { + @Override + public Agent build() { + // Build via the public Java-friendly factory in + // SwarmJarFixtureBridge — keeps this Java source small + // and avoids fighting Kotlin's reified-generics from + // a Java caller. + return agents_engine.runtime.SwarmJarFixtureBridge.makeAgent("jar-sibling"); + } + } + """.trimIndent() + ) + + // 2. Compile the source with javax.tools.JavaCompiler. + val classesDir = workDir.resolve("classes").apply { createDirectories() } + val compiler: JavaCompiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { + "no JDK Java compiler available — running on a JRE? need a JDK" + } + val fileManager = compiler.getStandardFileManager(null, null, Charsets.UTF_8) + fileManager.use { fm -> + fm.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, listOf(classesDir)) + // Use the test JVM's classpath so AgentProvider, Agent, and + // SwarmJarFixtureBridge resolve. + fm.setLocation( + StandardLocation.CLASS_PATH, + System.getProperty("java.class.path").split(File.pathSeparator).map { File(it) }, + ) + val units = fm.getJavaFileObjectsFromPaths(listOf(src)) + val task = compiler.getTask(null, fm, null, null, null, units) + val ok = task.call() ?: false + assertTrue(ok, "Java compilation must succeed") + } + + // 3. Write the META-INF/services descriptor next to the compiled class. + val servicesDir = classesDir.resolve("META-INF/services").apply { createDirectories() } + servicesDir.resolve("agents_engine.runtime.AgentProvider") + .writeText("external.ExternalAgentProvider\n") + + // 4. Pack everything into a real JAR file. + val jarPath = workDir.resolve("external-agent.jar") + packJar(classesDir, jarPath) + + // 5. URLClassLoader over the JAR with the test classpath as parent. + // Parent-first delegation gives the loaded provider access to + // AgentProvider / Agent / SwarmJarFixtureBridge. + val loader = URLClassLoader( + arrayOf(jarPath.toUri().toURL()), + this::class.java.classLoader, + ) + + // 6. Run the real Swarm.discover with that classloader. + val discovered = Swarm.discover(loader) + val names = discovered.map { it.name } + assertTrue( + "jar-sibling" in names, + "expected the JAR-loaded sibling in discovery; got: $names", + ) + + // 7. Sanity: the discovered Agent really invokes — proving the + // JAR-loaded code path is end-to-end functional, not just a + // name on a list. + val sibling = discovered.single { it.name == "jar-sibling" } + @Suppress("UNCHECKED_CAST") + val asString = sibling as agents_engine.core.Agent + assertEquals("from-jar:hello", asString.invoke("hello")) + + // 8. Absorb it onto a captain and verify the absorbed tool works. + val captain = agents_engine.core.agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + val tool = captain.toolMap["jar-sibling"]!! + assertEquals("from-jar:world", tool.executor(mapOf("query" to "world")).toString()) + } finally { + workDir.deleteRecursively() + } + } + + private fun packJar(classesDir: Path, jarPath: Path) { + val manifest = Manifest().apply { mainAttributes.putValue("Manifest-Version", "1.0") } + Files.newOutputStream(jarPath).use { fos -> + JarOutputStream(fos, manifest).use { jar -> + Files.walk(classesDir).use { paths -> + for (path in paths) { + if (Files.isDirectory(path)) continue + val rel = classesDir.relativize(path).toString().replace(File.separatorChar, '/') + jar.putNextEntry(JarEntry(rel)) + Files.newInputStream(path).use { it.copyTo(jar) } + jar.closeEntry() + } + } + } + } + } +} + +/** + * Bridge for [SwarmJarIntegrationTest] — the JAR-compiled Java source calls + * here so the test source itself stays small and Kotlin stays in the test + * sources (not inside the dynamically compiled JAR). + */ +internal object SwarmJarFixtureBridge { + @JvmStatic + fun makeAgent(name: String): agents_engine.core.Agent<*, *> = + agents_engine.core.agent(name) { + skills { + skill("op", "op") { + implementedBy { "from-jar:$it" } + } + } + } +} diff --git a/src/test/kotlin/agents_engine/runtime/SwarmTest.kt b/src/test/kotlin/agents_engine/runtime/SwarmTest.kt new file mode 100644 index 0000000..33fd04f --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/SwarmTest.kt @@ -0,0 +1,148 @@ +package agents_engine.runtime + +import agents_engine.core.agent +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +// Tests for #984 — Swarm: ServiceLoader-based agent discovery + absorb. +// +// Unit tests exercise the AgentProvider/Swarm/absorb mechanics with fixtures +// declared in this module's test sources. The JAR-compilation integration +// test lives in SwarmJarIntegrationTest. +class SwarmTest { + + private fun namedAgent(name: String, transform: (String) -> String = { "OUT:$it" }) = + agent(name) { + skills { skill("op", "op") { implementedBy(transform) } } + } + + @Test + fun `AgentProvider is a real interface usable as a ServiceLoader contract`() { + // Ensure the type exists, has the right shape, and is loadable. + val cls = AgentProvider::class.java + assertTrue(cls.isInterface, "AgentProvider must be an interface") + val build = cls.getDeclaredMethod("build") + assertNotNull(build) + } + + @Test + fun `Swarm discover with explicit classloader uses that classloader's services`() { + // We construct a classloader that has NO AgentProvider services registered. + // Even if the parent classloader has some (from test fixtures), the + // discovery method must respect the supplied loader. + val emptyLoader = java.net.URLClassLoader(emptyArray(), null) + val results = Swarm.discover(emptyLoader) + assertEquals(emptyList(), results, "isolated classloader should yield no providers") + } + + @Test + fun `Swarm discover finds the in-test fixture provider on the default classloader`() { + // SwarmTestProviderFixture is declared below + registered via + // src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider. + val results = Swarm.discover() + val names = results.map { it.name } + assertTrue( + "swarm-fixture-alpha" in names, + "expected fixture agent in discovery; got: $names", + ) + } + + @Test + fun `absorb adds a tool with the sibling's name`() { + val sibling = namedAgent("helper") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "captain-out" } } } + } + captain.absorb(sibling) + assertTrue( + "helper" in captain.toolMap, + "absorbed sibling should appear as a tool: ${captain.toolMap.keys}", + ) + } + + @Test + fun `absorbed tool delegates to the sibling and returns its output`() { + val sibling = namedAgent("helper") { "HELPER-SAW:$it" } + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + + // Invoke the absorbed tool directly via the tool map. + val tool = captain.toolMap["helper"]!! + val result = tool.executor(mapOf("query" to "hello")) + assertEquals("HELPER-SAW:hello", result.toString()) + } + + @Test + fun `absorbing two siblings with the same name fails fast`() { + val a = namedAgent("dup") + val b = namedAgent("dup") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(a) + assertThrows { captain.absorb(b) } + } + + @Test + fun `absorb is a no-op when sibling name collides with the captain's name (skip self)`() { + // If user accidentally tries to absorb their own captain into itself — + // collision against the agent's own name. Should error rather than + // create a recursive tool. + val captain = agent("self") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + // Trying to absorb captain into itself. + assertThrows { captain.absorb(captain) } + } + + @Test + fun `absorb of a non-String input sibling fails fast with a helpful error`() { + @Suppress("unused") + data class TypedInput(val v: String) + + val sibling = agent("typed") { + skills { skill("op", "op") { implementedBy { "ok" } } } + } + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + val ex = assertThrows { captain.absorb(sibling) } + assertTrue( + ex.message.orEmpty().contains("String", ignoreCase = true), + "error should mention String input requirement: ${ex.message}", + ) + } + + @Test + fun `absorbed tool is auto-available across the captain's skills`() { + // Auto-tool means the captain can call the absorbed sibling from any + // of its skills without listing it explicitly in tools(...). + val sibling = namedAgent("worker") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + // The framework's existing autoToolNames mechanism is internal but + // we can probe it via reflection of the public toolMap presence + // PLUS knowing that absorb wires it up. Verifying via the toolMap is + // sufficient — any further integration is through the agentic loop. + assertTrue("worker" in captain.toolMap) + } +} + +/** + * In-test AgentProvider fixture for [SwarmTest.discover finds the in-test + * fixture provider]. Registered via + * src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider. + */ +class SwarmTestProviderFixture : AgentProvider { + override fun build(): agents_engine.core.Agent<*, *> = + agent("swarm-fixture-alpha") { + skills { skill("op", "op") { implementedBy { "fixture:$it" } } } + } +} diff --git a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..5de495d --- /dev/null +++ b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.SwarmTestProviderFixture From 2dfa6939947627bfcbfe302b6efcbaa5891a426c Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 13:19:12 +0300 Subject: [PATCH 2/6] =?UTF-8?q?test(#984):=20real=20multi-JAR=20integratio?= =?UTF-8?q?n=20=E2=80=94=20drop=20several=20JARs=20in=20a=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../runtime/SwarmJarIntegrationTest.kt | 297 +++++++++++++----- 1 file changed, 220 insertions(+), 77 deletions(-) diff --git a/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt index 7183578..01a684f 100644 --- a/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt +++ b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt @@ -1,6 +1,5 @@ package agents_engine.runtime -import java.io.ByteArrayOutputStream import java.io.File import java.net.URLClassLoader import java.nio.file.Files @@ -17,115 +16,258 @@ import kotlin.io.path.deleteRecursively import kotlin.io.path.writeText import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * End-to-end integration for #984 — compiles a real AgentProvider Java source - * on the fly, packages it into a real JAR, loads it via [URLClassLoader] (with - * the test classpath as parent so the loaded class can see [AgentProvider] and - * the framework's [agents_engine.core.Agent] machinery), and verifies that - * [Swarm.discover] finds the agent through `ServiceLoader`. + * End-to-end integration for #984 — really compiles, packages, and classloads + * sibling JARs, then runs `Swarm.discover` against them. + * + * Two scenarios: + * - single-JAR — basic round-trip + * - multi-JAR (the actual swarm UX) — drop two independently-compiled JARs + * into one folder, point a `URLClassLoader` at all of them, verify both + * are discovered, both invoke, and a captain can absorb both * * Uses `javax.tools.JavaCompiler` (always shipped with the JDK), so no extra - * Gradle dependency is needed. The provider source is intentionally Java - * (not Kotlin) so we don't need to drag the Kotlin compiler into the test JVM. + * Gradle dependency. Provider sources are intentionally Java (not Kotlin) so + * we don't drag the Kotlin compiler into the test JVM. */ @OptIn(kotlin.io.path.ExperimentalPathApi::class) class SwarmJarIntegrationTest { @Test - fun `compile, JAR-package, classload, and discover a sibling agent`() { - val workDir = createTempDirectory("swarm-jar-it-") + fun `compile, JAR-package, classload, and discover one sibling`() { + val workDir = createTempDirectory("swarm-jar-it-single-") try { - // 1. Write the Java source for an AgentProvider implementation. - // The build() method returns an Agent constructed via the - // framework's agent() / Skill DSL — same classes the test JVM - // already has on its classpath. - val src = workDir.resolve("ExternalAgentProvider.java") - src.writeText( - """ - package external; - import agents_engine.runtime.AgentProvider; - import agents_engine.core.Agent; - public class ExternalAgentProvider implements AgentProvider { - @Override - public Agent build() { - // Build via the public Java-friendly factory in - // SwarmJarFixtureBridge — keeps this Java source small - // and avoids fighting Kotlin's reified-generics from - // a Java caller. - return agents_engine.runtime.SwarmJarFixtureBridge.makeAgent("jar-sibling"); - } - } - """.trimIndent() + val jar = compileSiblingJar( + workDir = workDir, + javaPackage = "external", + providerSimpleName = "ExternalAgentProvider", + agentName = "jar-sibling", + jarFileName = "external-agent.jar", ) + val loader = URLClassLoader(arrayOf(jar.toUri().toURL()), this::class.java.classLoader) + val discovered = Swarm.discover(loader) - // 2. Compile the source with javax.tools.JavaCompiler. - val classesDir = workDir.resolve("classes").apply { createDirectories() } - val compiler: JavaCompiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { - "no JDK Java compiler available — running on a JRE? need a JDK" - } - val fileManager = compiler.getStandardFileManager(null, null, Charsets.UTF_8) - fileManager.use { fm -> - fm.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, listOf(classesDir)) - // Use the test JVM's classpath so AgentProvider, Agent, and - // SwarmJarFixtureBridge resolve. - fm.setLocation( - StandardLocation.CLASS_PATH, - System.getProperty("java.class.path").split(File.pathSeparator).map { File(it) }, - ) - val units = fm.getJavaFileObjectsFromPaths(listOf(src)) - val task = compiler.getTask(null, fm, null, null, null, units) - val ok = task.call() ?: false - assertTrue(ok, "Java compilation must succeed") - } + val names = discovered.map { it.name } + assertTrue("jar-sibling" in names, "expected the JAR-loaded sibling; got: $names") - // 3. Write the META-INF/services descriptor next to the compiled class. - val servicesDir = classesDir.resolve("META-INF/services").apply { createDirectories() } - servicesDir.resolve("agents_engine.runtime.AgentProvider") - .writeText("external.ExternalAgentProvider\n") + val sibling = discovered.single { it.name == "jar-sibling" } + @Suppress("UNCHECKED_CAST") + val asString = sibling as agents_engine.core.Agent + assertEquals("from-jar:hello", asString.invoke("hello")) - // 4. Pack everything into a real JAR file. - val jarPath = workDir.resolve("external-agent.jar") - packJar(classesDir, jarPath) + val captain = agents_engine.core.agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + val tool = captain.toolMap["jar-sibling"]!! + assertEquals("from-jar:world", tool.executor(mapOf("query" to "world")).toString()) + } finally { + workDir.deleteRecursively() + } + } - // 5. URLClassLoader over the JAR with the test classpath as parent. - // Parent-first delegation gives the loaded provider access to - // AgentProvider / Agent / SwarmJarFixtureBridge. + @Test + fun `multi-JAR — drop several jars into a folder and discover them all`() { + // The original swarm UX: many separate agent JARs sit in one folder, + // a captain process points its classloader at all of them. Each JAR + // brings its own provider. ServiceLoader iterates every entry. + val workDir = createTempDirectory("swarm-jar-it-multi-") + val jarFolder = workDir.resolve("agents").apply { createDirectories() } + try { + // Each "agent" is a separately-compiled JAR with its own + // package and provider class — proving they don't share build + // artifacts and could realistically come from different teams. + val alphaJar = compileSiblingJar( + workDir = workDir.resolve("alpha-build").apply { createDirectories() }, + javaPackage = "swarm.alpha", + providerSimpleName = "AlphaProvider", + agentName = "alpha-jar-agent", + jarFileName = "alpha-agent.jar", + outputDir = jarFolder, + ) + val betaJar = compileSiblingJar( + workDir = workDir.resolve("beta-build").apply { createDirectories() }, + javaPackage = "swarm.beta", + providerSimpleName = "BetaProvider", + agentName = "beta-jar-agent", + jarFileName = "beta-agent.jar", + outputDir = jarFolder, + ) + + // Point a single URLClassLoader at both JARs from the folder — + // exactly what a captain main() would do on startup if it + // shell-globbed the folder, or what a launch script does with + // `java -cp 'agents/*'`. val loader = URLClassLoader( - arrayOf(jarPath.toUri().toURL()), + arrayOf(alphaJar.toUri().toURL(), betaJar.toUri().toURL()), this::class.java.classLoader, ) - // 6. Run the real Swarm.discover with that classloader. val discovered = Swarm.discover(loader) - val names = discovered.map { it.name } + val names = discovered.map { it.name }.toSet() + + // The URLClassLoader's parent is the test classloader, which has + // the in-test fixture provider registered. So discovery yields + // both JAR siblings PLUS that fixture. We assert only that the + // JAR siblings are present — the multi-JAR scenario is what's + // under test, not classloader hierarchy details. + assertTrue( + "alpha-jar-agent" in names, + "alpha JAR should be discovered; got: $names", + ) assertTrue( - "jar-sibling" in names, - "expected the JAR-loaded sibling in discovery; got: $names", + "beta-jar-agent" in names, + "beta JAR should be discovered; got: $names", ) - // 7. Sanity: the discovered Agent really invokes — proving the - // JAR-loaded code path is end-to-end functional, not just a - // name on a list. - val sibling = discovered.single { it.name == "jar-sibling" } + // Each sibling really runs — the JAR boundary is fully traversable. + val alpha = discovered.single { it.name == "alpha-jar-agent" } + val beta = discovered.single { it.name == "beta-jar-agent" } + @Suppress("UNCHECKED_CAST") - val asString = sibling as agents_engine.core.Agent - assertEquals("from-jar:hello", asString.invoke("hello")) + val alphaStr = alpha as agents_engine.core.Agent - // 8. Absorb it onto a captain and verify the absorbed tool works. + @Suppress("UNCHECKED_CAST") + val betaStr = beta as agents_engine.core.Agent + + assertEquals("from-jar:ping", alphaStr.invoke("ping")) + assertEquals("from-jar:pong", betaStr.invoke("pong")) + + // The captain absorbs both — each becomes a tool, each callable + // independently. This is the full swarm UX. val captain = agents_engine.core.agent("captain") { skills { skill("op", "op") { implementedBy { "x" } } } } - captain.absorb(sibling) - val tool = captain.toolMap["jar-sibling"]!! - assertEquals("from-jar:world", tool.executor(mapOf("query" to "world")).toString()) + captain.absorb(alpha) + captain.absorb(beta) + + assertTrue("alpha-jar-agent" in captain.toolMap) + assertTrue("beta-jar-agent" in captain.toolMap) + + val alphaTool = captain.toolMap["alpha-jar-agent"]!! + val betaTool = captain.toolMap["beta-jar-agent"]!! + assertEquals( + "from-jar:fromAlpha", + alphaTool.executor(mapOf("query" to "fromAlpha")).toString(), + ) + assertEquals( + "from-jar:fromBeta", + betaTool.executor(mapOf("query" to "fromBeta")).toString(), + ) + } finally { + workDir.deleteRecursively() + } + } + + @Test + fun `multi-JAR — captain glob-loads every jar in a folder`() { + // Variant: simulate the production UX more closely by glob-loading + // every *.jar in the folder rather than passing each path explicitly. + // The captain doesn't need to know how many siblings there are or + // what they're called — drop a JAR into the folder and it joins. + val workDir = createTempDirectory("swarm-jar-it-glob-") + val jarFolder = workDir.resolve("agents").apply { createDirectories() } + try { + compileSiblingJar( + workDir = workDir.resolve("a"), javaPackage = "swarm.a", + providerSimpleName = "AProvider", agentName = "agent-a", + jarFileName = "a.jar", outputDir = jarFolder, + ) + compileSiblingJar( + workDir = workDir.resolve("b"), javaPackage = "swarm.b", + providerSimpleName = "BProvider", agentName = "agent-b", + jarFileName = "b.jar", outputDir = jarFolder, + ) + compileSiblingJar( + workDir = workDir.resolve("c"), javaPackage = "swarm.c", + providerSimpleName = "CProvider", agentName = "agent-c", + jarFileName = "c.jar", outputDir = jarFolder, + ) + + // Glob the folder — what a launch script would do. + val urls = Files.list(jarFolder).use { stream -> + stream + .filter { it.toString().endsWith(".jar") } + .map { it.toUri().toURL() } + .toList() + }.toTypedArray() + assertEquals(3, urls.size, "should have globbed three JARs") + + val loader = URLClassLoader(urls, this::class.java.classLoader) + val discovered = Swarm.discover(loader) + val names = discovered.map { it.name }.toSet() + + // Assert all three JAR-supplied agents are present — the parent + // classloader's in-test fixture may also appear; we don't care + // about it for this assertion. + assertTrue("agent-a" in names, "missing agent-a; got: $names") + assertTrue("agent-b" in names, "missing agent-b; got: $names") + assertTrue("agent-c" in names, "missing agent-c; got: $names") } finally { workDir.deleteRecursively() } } + /** + * Compile a Java AgentProvider source, write its META-INF/services + * descriptor, and pack into a JAR file. Returns the JAR path. + * + * Each call is hermetic — its own work dir, its own package, its own + * provider class — so multiple invocations produce independent JARs. + */ + private fun compileSiblingJar( + workDir: Path, + javaPackage: String, + providerSimpleName: String, + agentName: String, + jarFileName: String, + outputDir: Path = workDir, + ): Path { + Files.createDirectories(workDir) + + val src = workDir.resolve("$providerSimpleName.java") + src.writeText( + """ + package $javaPackage; + import agents_engine.runtime.AgentProvider; + import agents_engine.core.Agent; + public class $providerSimpleName implements AgentProvider { + @Override + public Agent build() { + return agents_engine.runtime.SwarmJarFixtureBridge.makeAgent("$agentName"); + } + } + """.trimIndent() + ) + + val classesDir = workDir.resolve("classes").apply { createDirectories() } + val compiler: JavaCompiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { + "no JDK Java compiler — running on a JRE? need a JDK" + } + compiler.getStandardFileManager(null, null, Charsets.UTF_8).use { fm -> + fm.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, listOf(classesDir)) + fm.setLocation( + StandardLocation.CLASS_PATH, + System.getProperty("java.class.path").split(File.pathSeparator).map { File(it) }, + ) + val units = fm.getJavaFileObjectsFromPaths(listOf(src)) + val task = compiler.getTask(null, fm, null, null, null, units) + val ok = task.call() ?: false + assertTrue(ok, "Java compilation must succeed for $providerSimpleName") + } + + val servicesDir = classesDir.resolve("META-INF/services").apply { createDirectories() } + servicesDir.resolve("agents_engine.runtime.AgentProvider") + .writeText("$javaPackage.$providerSimpleName\n") + + val jarPath = outputDir.resolve(jarFileName) + Files.createDirectories(outputDir) + packJar(classesDir, jarPath) + return jarPath + } + private fun packJar(classesDir: Path, jarPath: Path) { val manifest = Manifest().apply { mainAttributes.putValue("Manifest-Version", "1.0") } Files.newOutputStream(jarPath).use { fos -> @@ -147,7 +289,8 @@ class SwarmJarIntegrationTest { /** * Bridge for [SwarmJarIntegrationTest] — the JAR-compiled Java source calls * here so the test source itself stays small and Kotlin stays in the test - * sources (not inside the dynamically compiled JAR). + * sources (not inside the dynamically compiled JARs). The same bridge is + * shared by all on-the-fly compiled JARs in the multi-JAR tests. */ internal object SwarmJarFixtureBridge { @JvmStatic From d687d872c2d605fbbb0b30c426fd8e7f35a4c71a Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 14:02:10 +0300 Subject: [PATCH 3/6] =?UTF-8?q?test(#984):=20swarmDemo=20Gradle=20task=20?= =?UTF-8?q?=E2=80=94=20three=20siblings,=20captain=20absorbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.gradle.kts | 12 ++ .../kotlin/agents_engine/runtime/SwarmDemo.kt | 191 ++++++++++++++++++ .../agents_engine.runtime.AgentProvider | 3 + 3 files changed, 206 insertions(+) create mode 100644 src/test/kotlin/agents_engine/runtime/SwarmDemo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 1323cdf..71d7214 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -203,6 +203,18 @@ tasks.register("interactivePipeline") { standardInput = System.`in` } +// #984 — full swarm demo. Three sibling agents (fib / factor / exit) live on +// the test classpath via ServiceLoader. The demo's main() picks `fib` as +// captain and absorbs the other two as tools; from the REPL the user can +// trigger any of the three. Same JavaExec + stdin-forwarded pattern. +tasks.register("swarmDemo") { + description = "Run the swarm demo: captain `fib` absorbs `factor` + `exit` siblings" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("agents_engine.runtime.SwarmDemoKt") + standardInput = System.`in` +} + java { withSourcesJar() withJavadocJar() diff --git a/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt b/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt new file mode 100644 index 0000000..7da8f3b --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt @@ -0,0 +1,191 @@ +package agents_engine.runtime + +import agents_engine.core.Agent +import agents_engine.core.agent +import kotlin.system.exitProcess + +// Manual swarm demo for #984. Three sibling agents share one JVM via +// ServiceLoader; the demo's main() picks `fib` as captain and absorbs the +// others. From the captain's REPL the user can: +// +// fib> compute fib(10) -> uses captain's own `fibonacci` tool +// fib> factor 84 -> delegates to the `factor` sibling +// fib> bye, close the app please -> delegates to the `exit` sibling +// +// Lives under src/test/kotlin so it never ships in the published JAR. +// Wired up via the `swarmDemo` Gradle task. +// +// Prerequisites: +// - Ollama running (localhost:11434) +// - `ollama signin` (cloud variant) OR change MODEL below to a local one + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +/** The three demo agent names — used to filter Swarm.discover output. */ +internal val SWARM_DEMO_NAMES = setOf("fib", "factor", "exit") + +internal fun buildFibAgent(): Agent = agent("fib") { + prompt(""" + You are a router-style assistant. Inspect the user's request + and pick the right tool; do NOT answer in plain text when a + tool is appropriate. + + Your tools: + - `fibonacci` — compute the nth Fibonacci. Use for any "fib(n)" + or "Fibonacci" request. + - `factor` — delegate to the factor sibling. Use whenever the + user says "factor", "prime factors", or asks to break a number + down into its prime factorization. Call it with the user's + original request as the `query` argument. + - `exit` — delegate to the exit sibling. Use whenever the user + says exit / quit / leave / close / bye / goodbye / "I'm done". + Call it with the user's original request as the `query` + argument. NEVER reply with "session closed" in plain text; + ALWAYS call the `exit` tool. + + After a tool returns, render the result in 1–2 short sentences. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("fibonacci", "Compute the nth Fibonacci number. Argument: n (integer ≥ 0).") { args -> + val n = readInt(args, "n") + fib(n).toString() + } + } + skills { + skill("compute", "Run a math calculation, possibly via tools") { + tools("fibonacci") + } + } +} + +internal fun buildFactorAgent(): Agent = agent("factor") { + prompt(""" + You factor integers into prime factors. + Use the `factor_number` tool with argument n (integer ≥ 2). + Reply with the comma-separated prime factors, nothing else. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("factor_number", "Compute the prime factors of n. Argument: n (integer ≥ 2).") { args -> + val n = readInt(args, "n") + require(n >= 2) { "n must be ≥ 2 to factor" } + primeFactors(n).joinToString(", ") + } + } + skills { + skill("factor", "Prime-factor an integer") { + tools("factor_number") + } + } +} + +internal fun buildExitAgent(): Agent = agent("exit") { + prompt(""" + You have exactly ONE tool: `exit_app`. Call it with no arguments + when the user wants to close, exit, quit, leave, or end the + session. Do NOT call it for other requests. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("exit_app", "Close the application. Use when the user asks to exit, quit, or end.") { _ -> + println() + println("(exit agent — shutting down)") + exitProcess(0) + } + } + skills { + skill("close", "End the session on user request") { + tools("exit_app") + } + } +} + +private fun fib(n: Int): Long { + require(n >= 0) { "n must be ≥ 0" } + var a = 0L; var b = 1L + repeat(n) { val t = a + b; a = b; b = t } + return a +} + +private fun primeFactors(nIn: Int): List { + var n = nIn + val result = mutableListOf() + var p = 2 + while (p.toLong() * p.toLong() <= n) { + while (n % p == 0) { result += p; n /= p } + p++ + } + if (n > 1) result += n + return result +} + +/** + * Read an integer arg from the executor's args map. Tools receive arbitrary + * `Map` from the LLM — the model may pass `n` as Int, Long, + * Double, or as a stringified number. Coerce all of those. + */ +private fun readInt(args: Map, key: String): Int { + val v = args[key] ?: args.values.firstOrNull() + ?: error("missing argument: $key") + return when (v) { + is Number -> v.toInt() + is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") + else -> error("'$v' is not an integer") + } +} + +// ─── ServiceLoader providers ───────────────────────────────────────────── + +class SwarmDemoFibProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFibAgent() +} + +class SwarmDemoFactorProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFactorAgent() +} + +class SwarmDemoExitProvider : AgentProvider { + override fun build(): Agent<*, *> = buildExitAgent() +} + +// ─── Captain main() ────────────────────────────────────────────────────── + +fun main(args: Array) { + // Discover all swarm members in one shot. Filter to just the three demo + // names so we don't accidentally absorb the in-test fixture provider that + // also lives on the test classpath. + val members = Swarm.discover().filter { it.name in SWARM_DEMO_NAMES } + val me = members.singleOrNull { it.name == "fib" } + ?: error("captain 'fib' not found among discovered providers: ${members.map { it.name }}") + val siblings = members.filter { it.name != me.name } + + println() + println("============================================================") + println("Swarm demo (#984) — captain: ${me.name}") + println("Discovered ${siblings.size} siblings: ${siblings.joinToString { it.name }}") + println("Each sibling carries its own personality (prompt + tools);") + println("the captain absorbed them as tools and can call them via") + println("its LLM-driven dispatch.") + println() + println("Try:") + println(" fib> what's fib(10)?") + println(" fib> factor 84") + println(" fib> bye, please exit the app") + println("============================================================") + println() + + siblings.forEach { sibling -> me.absorb(sibling) } + + // me came back from Swarm.discover as Agent<*, *>; narrow to the + // String-input overload of LiveRunner.serve. Safe because the demo + // built it from buildFibAgent(), which is Agent. + @Suppress("UNCHECKED_CAST") + val captain = me as Agent + val rc = LiveRunner.serve(captain, args) { + prompt = "fib> " + } + exitProcess(rc) +} diff --git a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider index 5de495d..39068dc 100644 --- a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider +++ b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider @@ -1 +1,4 @@ agents_engine.runtime.SwarmTestProviderFixture +agents_engine.runtime.SwarmDemoFibProvider +agents_engine.runtime.SwarmDemoFactorProvider +agents_engine.runtime.SwarmDemoExitProvider From 70a1de43761109904c2567a8fac7e3d9b4584e6e Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 14:08:21 +0300 Subject: [PATCH 4/6] =?UTF-8?q?test(#984):=20swarmDemo=20=E2=80=94=20wire?= =?UTF-8?q?=20onToolUse=20tracing=20on=20all=20three=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` [] (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) --- src/test/kotlin/agents_engine/runtime/SwarmDemo.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt b/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt index 7da8f3b..b4e7c41 100644 --- a/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt +++ b/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt @@ -26,6 +26,13 @@ private const val PORT = 11434 /** The three demo agent names — used to filter Swarm.discover output. */ internal val SWARM_DEMO_NAMES = setOf("fib", "factor", "exit") +/** Compact tool-trace formatter shared by all three demo agents. */ +private fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + internal fun buildFibAgent(): Agent = agent("fib") { prompt(""" You are a router-style assistant. Inspect the user's request @@ -59,6 +66,7 @@ internal fun buildFibAgent(): Agent = agent("fib") { tools("fibonacci") } } + onToolUse { name, args, result -> traceTool("fib", name, args, result) } } internal fun buildFactorAgent(): Agent = agent("factor") { @@ -80,6 +88,7 @@ internal fun buildFactorAgent(): Agent = agent("factor") { tools("factor_number") } } + onToolUse { name, args, result -> traceTool("factor", name, args, result) } } internal fun buildExitAgent(): Agent = agent("exit") { @@ -101,6 +110,7 @@ internal fun buildExitAgent(): Agent = agent("exit") { tools("exit_app") } } + onToolUse { name, args, result -> traceTool("exit", name, args, result) } } private fun fib(n: Int): Long { From ae6d28f2a70568657ee683dd1c97d99e09f2db76 Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 14:18:34 +0300 Subject: [PATCH 5/6] test(#984): swarmDemo packages real JARs into build/tmp/jars_swarm_demo/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- build.gradle.kts | 75 ++++++- .../kotlin/agents_engine/runtime/SwarmDemo.kt | 201 ------------------ .../runtime/swarmdemo/exitagent/ExitAgent.kt | 44 ++++ .../runtime/swarmdemo/factor/FactorAgent.kt | 66 ++++++ .../runtime/swarmdemo/fib/FibAgent.kt | 114 ++++++++++ .../agents_engine.runtime.AgentProvider | 3 - .../agents_engine.runtime.AgentProvider | 1 + .../agents_engine.runtime.AgentProvider | 1 + .../agents_engine.runtime.AgentProvider | 1 + 9 files changed, 295 insertions(+), 211 deletions(-) delete mode 100644 src/test/kotlin/agents_engine/runtime/SwarmDemo.kt create mode 100644 src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt create mode 100644 src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt create mode 100644 src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt create mode 100644 src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider create mode 100644 src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider create mode 100644 src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider diff --git a/build.gradle.kts b/build.gradle.kts index 71d7214..2ee8fa3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -203,15 +203,76 @@ tasks.register("interactivePipeline") { standardInput = System.`in` } -// #984 — full swarm demo. Three sibling agents (fib / factor / exit) live on -// the test classpath via ServiceLoader. The demo's main() picks `fib` as -// captain and absorbs the other two as tools; from the REPL the user can -// trigger any of the three. Same JavaExec + stdin-forwarded pattern. +// #984 — full swarm demo. Three sibling agents (fib / factor / exit) live as +// SEPARATE JAR files in build/tmp/jars_swarm_demo/, each with its own +// META-INF/services descriptor. The captain main is packaged inside fib.jar. +// At runtime, ServiceLoader walks the JARs on the classpath and finds all +// three providers — the same path a production swarm uses when JARs are +// dropped into a folder. +val swarmDemoJarsDir: Provider = layout.buildDirectory.dir("tmp/jars_swarm_demo") + +// Helper to register one swarm sibling Jar task. Each task pulls only its +// own subpackage's compiled classes plus its per-JAR service descriptor; +// no cross-JAR class sharing. +fun registerSwarmDemoJar( + taskName: String, + jarFileName: String, + classSubpackage: String, + resourcesPath: String, +) = tasks.register(taskName) { + description = "Pack swarm demo agent classes into $jarFileName" + group = "build" + dependsOn("compileTestKotlin") + archiveFileName.set(jarFileName) + destinationDirectory.set(swarmDemoJarsDir) + sourceSets.test.get().output.classesDirs.forEach { classesDir -> + from(classesDir) { + include("agents_engine/runtime/swarmdemo/$classSubpackage/**") + } + } + from(resourcesPath) +} + +val jarSwarmFib = registerSwarmDemoJar( + taskName = "jarSwarmFib", + jarFileName = "fib.jar", + classSubpackage = "fib", + resourcesPath = "src/test/swarm-jar-resources/fib", +) +val jarSwarmFactor = registerSwarmDemoJar( + taskName = "jarSwarmFactor", + jarFileName = "factor.jar", + classSubpackage = "factor", + resourcesPath = "src/test/swarm-jar-resources/factor", +) +val jarSwarmExit = registerSwarmDemoJar( + taskName = "jarSwarmExit", + jarFileName = "exit.jar", + classSubpackage = "exitagent", + resourcesPath = "src/test/swarm-jar-resources/exit", +) + +// Aggregate task to build all three. +tasks.register("buildSwarmDemoJars") { + description = "Build all three swarm demo JARs into build/tmp/jars_swarm_demo/" + group = "build" + dependsOn(jarSwarmFib, jarSwarmFactor, jarSwarmExit) +} + tasks.register("swarmDemo") { - description = "Run the swarm demo: captain `fib` absorbs `factor` + `exit` siblings" + description = "Run the swarm demo: captain `fib.jar` absorbs `factor.jar` + `exit.jar` siblings" group = "verification" - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("agents_engine.runtime.SwarmDemoKt") + dependsOn("buildSwarmDemoJars") + + // Classpath = framework runtime + the three sibling JARs ONLY. We + // deliberately do NOT include sourceSets.test.runtimeClasspath, so + // ServiceLoader finds providers exclusively from the JARs (proves the + // real "drop JARs into a folder" path, not the in-test shortcut). + classpath = files( + sourceSets.main.get().runtimeClasspath, + fileTree(swarmDemoJarsDir) { include("*.jar") }, + ) + mainClass.set("agents_engine.runtime.swarmdemo.fib.FibAgentKt") standardInput = System.`in` } diff --git a/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt b/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt deleted file mode 100644 index b4e7c41..0000000 --- a/src/test/kotlin/agents_engine/runtime/SwarmDemo.kt +++ /dev/null @@ -1,201 +0,0 @@ -package agents_engine.runtime - -import agents_engine.core.Agent -import agents_engine.core.agent -import kotlin.system.exitProcess - -// Manual swarm demo for #984. Three sibling agents share one JVM via -// ServiceLoader; the demo's main() picks `fib` as captain and absorbs the -// others. From the captain's REPL the user can: -// -// fib> compute fib(10) -> uses captain's own `fibonacci` tool -// fib> factor 84 -> delegates to the `factor` sibling -// fib> bye, close the app please -> delegates to the `exit` sibling -// -// Lives under src/test/kotlin so it never ships in the published JAR. -// Wired up via the `swarmDemo` Gradle task. -// -// Prerequisites: -// - Ollama running (localhost:11434) -// - `ollama signin` (cloud variant) OR change MODEL below to a local one - -private const val MODEL = "gpt-oss:120b-cloud" -private const val HOST = "localhost" -private const val PORT = 11434 - -/** The three demo agent names — used to filter Swarm.discover output. */ -internal val SWARM_DEMO_NAMES = setOf("fib", "factor", "exit") - -/** Compact tool-trace formatter shared by all three demo agents. */ -private fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { - val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } - val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" - System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") -} - -internal fun buildFibAgent(): Agent = agent("fib") { - prompt(""" - You are a router-style assistant. Inspect the user's request - and pick the right tool; do NOT answer in plain text when a - tool is appropriate. - - Your tools: - - `fibonacci` — compute the nth Fibonacci. Use for any "fib(n)" - or "Fibonacci" request. - - `factor` — delegate to the factor sibling. Use whenever the - user says "factor", "prime factors", or asks to break a number - down into its prime factorization. Call it with the user's - original request as the `query` argument. - - `exit` — delegate to the exit sibling. Use whenever the user - says exit / quit / leave / close / bye / goodbye / "I'm done". - Call it with the user's original request as the `query` - argument. NEVER reply with "session closed" in plain text; - ALWAYS call the `exit` tool. - - After a tool returns, render the result in 1–2 short sentences. - """.trimIndent()) - model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } - tools { - tool("fibonacci", "Compute the nth Fibonacci number. Argument: n (integer ≥ 0).") { args -> - val n = readInt(args, "n") - fib(n).toString() - } - } - skills { - skill("compute", "Run a math calculation, possibly via tools") { - tools("fibonacci") - } - } - onToolUse { name, args, result -> traceTool("fib", name, args, result) } -} - -internal fun buildFactorAgent(): Agent = agent("factor") { - prompt(""" - You factor integers into prime factors. - Use the `factor_number` tool with argument n (integer ≥ 2). - Reply with the comma-separated prime factors, nothing else. - """.trimIndent()) - model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } - tools { - tool("factor_number", "Compute the prime factors of n. Argument: n (integer ≥ 2).") { args -> - val n = readInt(args, "n") - require(n >= 2) { "n must be ≥ 2 to factor" } - primeFactors(n).joinToString(", ") - } - } - skills { - skill("factor", "Prime-factor an integer") { - tools("factor_number") - } - } - onToolUse { name, args, result -> traceTool("factor", name, args, result) } -} - -internal fun buildExitAgent(): Agent = agent("exit") { - prompt(""" - You have exactly ONE tool: `exit_app`. Call it with no arguments - when the user wants to close, exit, quit, leave, or end the - session. Do NOT call it for other requests. - """.trimIndent()) - model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } - tools { - tool("exit_app", "Close the application. Use when the user asks to exit, quit, or end.") { _ -> - println() - println("(exit agent — shutting down)") - exitProcess(0) - } - } - skills { - skill("close", "End the session on user request") { - tools("exit_app") - } - } - onToolUse { name, args, result -> traceTool("exit", name, args, result) } -} - -private fun fib(n: Int): Long { - require(n >= 0) { "n must be ≥ 0" } - var a = 0L; var b = 1L - repeat(n) { val t = a + b; a = b; b = t } - return a -} - -private fun primeFactors(nIn: Int): List { - var n = nIn - val result = mutableListOf() - var p = 2 - while (p.toLong() * p.toLong() <= n) { - while (n % p == 0) { result += p; n /= p } - p++ - } - if (n > 1) result += n - return result -} - -/** - * Read an integer arg from the executor's args map. Tools receive arbitrary - * `Map` from the LLM — the model may pass `n` as Int, Long, - * Double, or as a stringified number. Coerce all of those. - */ -private fun readInt(args: Map, key: String): Int { - val v = args[key] ?: args.values.firstOrNull() - ?: error("missing argument: $key") - return when (v) { - is Number -> v.toInt() - is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") - else -> error("'$v' is not an integer") - } -} - -// ─── ServiceLoader providers ───────────────────────────────────────────── - -class SwarmDemoFibProvider : AgentProvider { - override fun build(): Agent<*, *> = buildFibAgent() -} - -class SwarmDemoFactorProvider : AgentProvider { - override fun build(): Agent<*, *> = buildFactorAgent() -} - -class SwarmDemoExitProvider : AgentProvider { - override fun build(): Agent<*, *> = buildExitAgent() -} - -// ─── Captain main() ────────────────────────────────────────────────────── - -fun main(args: Array) { - // Discover all swarm members in one shot. Filter to just the three demo - // names so we don't accidentally absorb the in-test fixture provider that - // also lives on the test classpath. - val members = Swarm.discover().filter { it.name in SWARM_DEMO_NAMES } - val me = members.singleOrNull { it.name == "fib" } - ?: error("captain 'fib' not found among discovered providers: ${members.map { it.name }}") - val siblings = members.filter { it.name != me.name } - - println() - println("============================================================") - println("Swarm demo (#984) — captain: ${me.name}") - println("Discovered ${siblings.size} siblings: ${siblings.joinToString { it.name }}") - println("Each sibling carries its own personality (prompt + tools);") - println("the captain absorbed them as tools and can call them via") - println("its LLM-driven dispatch.") - println() - println("Try:") - println(" fib> what's fib(10)?") - println(" fib> factor 84") - println(" fib> bye, please exit the app") - println("============================================================") - println() - - siblings.forEach { sibling -> me.absorb(sibling) } - - // me came back from Swarm.discover as Agent<*, *>; narrow to the - // String-input overload of LiveRunner.serve. Safe because the demo - // built it from buildFibAgent(), which is Agent. - @Suppress("UNCHECKED_CAST") - val captain = me as Agent - val rc = LiveRunner.serve(captain, args) { - prompt = "fib> " - } - exitProcess(rc) -} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt new file mode 100644 index 0000000..fb9ef75 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt @@ -0,0 +1,44 @@ +package agents_engine.runtime.swarmdemo.exitagent + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider +import kotlin.system.exitProcess + +// exit.jar — sibling agent for the swarm demo (#984). Self-contained. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +fun buildExitAgent(): Agent = agent("exit") { + prompt(""" + You have exactly ONE tool: `exit_app`. Call it with no arguments + when the user wants to close, exit, quit, leave, or end the + session. Do NOT call it for other requests. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("exit_app", "Close the application. Use when the user asks to exit, quit, or end.") { _ -> + println() + println("(exit agent — shutting down)") + exitProcess(0) + } + } + skills { + skill("close", "End the session on user request") { + tools("exit_app") + } + } + onToolUse { name, args, result -> traceTool("exit", name, args, result) } +} + +class ExitProvider : AgentProvider { + override fun build(): Agent<*, *> = buildExitAgent() +} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt new file mode 100644 index 0000000..9b61a20 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt @@ -0,0 +1,66 @@ +package agents_engine.runtime.swarmdemo.factor + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider + +// factor.jar — sibling agent for the swarm demo (#984). Self-contained: +// own copies of helpers so the JAR doesn't depend on any sibling's classes. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +internal fun readInt(args: Map, key: String): Int { + val v = args[key] ?: args.values.firstOrNull() + ?: error("missing argument: $key") + return when (v) { + is Number -> v.toInt() + is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") + else -> error("'$v' is not an integer") + } +} + +internal fun primeFactors(nIn: Int): List { + var n = nIn + val result = mutableListOf() + var p = 2 + while (p.toLong() * p.toLong() <= n) { + while (n % p == 0) { result += p; n /= p } + p++ + } + if (n > 1) result += n + return result +} + +fun buildFactorAgent(): Agent = agent("factor") { + prompt(""" + You factor integers into prime factors. + Use the `factor_number` tool with argument n (integer ≥ 2). + Reply with the comma-separated prime factors, nothing else. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("factor_number", "Compute the prime factors of n. Argument: n (integer ≥ 2).") { args -> + val n = readInt(args, "n") + require(n >= 2) { "n must be ≥ 2 to factor" } + primeFactors(n).joinToString(", ") + } + } + skills { + skill("factor", "Prime-factor an integer") { + tools("factor_number") + } + } + onToolUse { name, args, result -> traceTool("factor", name, args, result) } +} + +class FactorProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFactorAgent() +} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt new file mode 100644 index 0000000..49f59a6 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt @@ -0,0 +1,114 @@ +package agents_engine.runtime.swarmdemo.fib + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider +import agents_engine.runtime.LiveRunner +import agents_engine.runtime.Swarm +import agents_engine.runtime.absorb +import kotlin.system.exitProcess + +// Captain JAR for the swarm demo (#984). Lives in build/tmp/jars_swarm_demo/fib.jar. +// Self-contained: own copies of small helpers so the JAR doesn't need to +// pull in shared "common" classes from anywhere else. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +internal fun readInt(args: Map, key: String): Int { + val v = args[key] ?: args.values.firstOrNull() + ?: error("missing argument: $key") + return when (v) { + is Number -> v.toInt() + is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") + else -> error("'$v' is not an integer") + } +} + +internal fun fib(n: Int): Long { + require(n >= 0) { "n must be ≥ 0" } + var a = 0L; var b = 1L + repeat(n) { val t = a + b; a = b; b = t } + return a +} + +fun buildFibAgent(): Agent = agent("fib") { + prompt(""" + You are a router-style assistant. Inspect the user's request + and pick the right tool; do NOT answer in plain text when a + tool is appropriate. + + Your tools: + - `fibonacci` — compute the nth Fibonacci. Use for any "fib(n)" + or "Fibonacci" request. + - `factor` — delegate to the factor sibling. Use whenever the + user says "factor", "prime factors", or asks to break a number + down into its prime factorization. Call it with the user's + original request as the `query` argument. + - `exit` — delegate to the exit sibling. Use whenever the user + says exit / quit / leave / close / bye / goodbye / "I'm done". + Call it with the user's original request as the `query` + argument. NEVER reply with "session closed" in plain text; + ALWAYS call the `exit` tool. + + After a tool returns, render the result in 1–2 short sentences. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("fibonacci", "Compute the nth Fibonacci number. Argument: n (integer ≥ 0).") { args -> + val n = readInt(args, "n") + fib(n).toString() + } + } + skills { + skill("compute", "Run a math calculation, possibly via tools") { + tools("fibonacci") + } + } + onToolUse { name, args, result -> traceTool("fib", name, args, result) } +} + +class FibProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFibAgent() +} + +// Captain main — packaged into fib.jar. The swarmDemo Gradle task launches +// this with classpath = framework + fib.jar + factor.jar + exit.jar; the +// classpath does NOT include the test source classes, so ServiceLoader +// finds providers only from the three JARs. +fun main(args: Array) { + val members = Swarm.discover() + val me = members.singleOrNull { it.name == "fib" } + ?: error("captain 'fib' not found among discovered providers: ${members.map { it.name }}") + val siblings = members.filter { it.name != me.name } + + println() + println("============================================================") + println("Swarm demo (#984) — captain: ${me.name}") + println("Discovered ${siblings.size} siblings: ${siblings.joinToString { it.name }}") + println("Each sibling carries its own personality (prompt + tools);") + println("the captain absorbed them as tools and dispatches via its LLM.") + println() + println("Try:") + println(" fib> what's fib(10)?") + println(" fib> factor 84") + println(" fib> bye, please exit the app") + println("============================================================") + println() + + siblings.forEach { sibling -> me.absorb(sibling) } + + @Suppress("UNCHECKED_CAST") + val captain = me as Agent + val rc = LiveRunner.serve(captain, args) { + prompt = "fib> " + } + exitProcess(rc) +} diff --git a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider index 39068dc..5de495d 100644 --- a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider +++ b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider @@ -1,4 +1 @@ agents_engine.runtime.SwarmTestProviderFixture -agents_engine.runtime.SwarmDemoFibProvider -agents_engine.runtime.SwarmDemoFactorProvider -agents_engine.runtime.SwarmDemoExitProvider diff --git a/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..5e25f4d --- /dev/null +++ b/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.exitagent.ExitProvider diff --git a/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..665416d --- /dev/null +++ b/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.factor.FactorProvider diff --git a/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..f8c91e7 --- /dev/null +++ b/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.fib.FibProvider From 3e8ee9015c6c10c84d4636c6ed1a1460e8cc0698 Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 4 May 2026 14:21:22 +0300 Subject: [PATCH 6/6] =?UTF-8?q?test(#984):=20copySwarmDemoLibs=20=E2=80=94?= =?UTF-8?q?=20stage=20runtime=20deps=20for=20bare-java=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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) --- build.gradle.kts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2ee8fa3..215ee1b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -252,11 +252,23 @@ val jarSwarmExit = registerSwarmDemoJar( resourcesPath = "src/test/swarm-jar-resources/exit", ) -// Aggregate task to build all three. +// Stage the framework JAR + every runtime dependency next to the demo +// JARs so the swarm demo is launchable with a pure `java -cp ...` command, +// no Gradle needed. Output goes to build/tmp/jars_swarm_demo_lib/. +tasks.register("copySwarmDemoLibs") { + description = "Stage framework + runtime libs next to the swarm demo JARs" + group = "build" + dependsOn("jar") // produces build/libs/agents-kt-.jar + from(tasks.named("jar")) + from(configurations.runtimeClasspath) + into(layout.buildDirectory.dir("tmp/jars_swarm_demo_lib")) +} + +// Aggregate task — builds all three demo JARs and stages their runtime deps. tasks.register("buildSwarmDemoJars") { - description = "Build all three swarm demo JARs into build/tmp/jars_swarm_demo/" + description = "Build the three swarm demo JARs (and stage runtime libs) so the demo can be launched with bare `java`" group = "build" - dependsOn(jarSwarmFib, jarSwarmFactor, jarSwarmExit) + dependsOn(jarSwarmFib, jarSwarmFactor, jarSwarmExit, "copySwarmDemoLibs") } tasks.register("swarmDemo") {