{"slug": "ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in", "title": "🐝👁️ ai_swarm and ai_vision — Handoff Multi-Agent Swarms and Image Understanding in Pure Pipe", "summary": "Pipe released two new AI builtins, ai_swarm and ai_vision, enabling handoff multi-agent swarms and image understanding in its pure pipe language. ai_swarm wires named agents together using the handoff pattern from OpenAI's Swarm library, allowing one agent active at a time with full shared conversation and transfer via a reserved tool call. ai_vision answers questions about images using DeepSeek's new vision model, deepseek-v4-flash-vision-exp, and the update also fixes a .pipec bytecode cache bug.", "body_md": "[← All posts← Alle Beiträge](../blog.html)\n\n# 🐝👁️ ai_swarm and ai_vision — Handoff Multi-Agent Swarms and Image Understanding in Pure Pipe\n\n**Two new AI builtins: ai_swarm wires named agents together with the handoff pattern OpenAI's original \"Swarm\" library popularized — one agent active at a time, full shared conversation, transfer via a reserved tool call. ai_vision answers questions about images (URL, local file, or raw bytes) against DeepSeek's new vision model. Plus the sneaky .pipec bytecode cache bug we found — and fixed for good — while building them.**\n\nPipe already had two separate AI building blocks that never quite met: `agent`\n\n/`agent_ask`\n\ngives you a named, stateful conversation, but no tools. `ai_tool`\n\n/`ai_with_tools`\n\ngives you tool-calling, but no persistent identity. Neither lets one agent hand a conversation to another. That's the gap `ai_swarm`\n\ncloses.\n\n## The gap: agents that can't talk to each other\n\nA swarm is a set of named agents, each with its own system prompt and tool set, that can transfer control to one another mid-conversation — while the **full message history** carries forward, so nothing gets lost at the handoff.\n\n```\nai_provider \"deepseek\"\n\nfn get_invoice customer\n    \"Invoice #4471, 49.90 EUR, due 2026-09-15.\"\n\nai_tool \"get_invoice\" \"Look up a customer's latest invoice\" {customer: \"Customer name\"} get_invoice\n\nswarm_agent \"triage\" {system: \"Route billing questions to 'billing'. Handle anything else yourself.\", handoff: [\"billing\"]}\nswarm_agent \"billing\" {system: \"You handle billing questions using get_invoice.\", tools: [\"get_invoice\"], handoff: [\"triage\"]}\n\n\"What's on my latest invoice?\"\n    > ai_swarm \"triage\"\n    > print\n```\n\nRun against a real DeepSeek key, that returns the billing agent's answer after a clean handoff — `ai_swarm_trace`\n\ngives you the same result plus `{content, path, rounds}`\n\nso you can see exactly who handled the request: `[\"triage\", \"billing\"]`\n\n.\n\n## How handoff actually works\n\nThere's no magic router. When an agent declares `handoff`\n\ntargets, `ChatSwarm`\n\n(the new Go-side loop in `pkg/ai/swarm.go`\n\n) synthesizes one extra tool for that round — a reserved `__handoff__(to: enum[...])`\n\nthe model can call like any other tool. When it does, the loop doesn't run your executor: it swaps the system message for the target agent's prompt and continues the *same* message array into the next round. The conversation history — including the transferring agent's own turns — is never touched, so the new agent has full context without a summary or a second prompt.\n\n`ChatSwarm`\n\nmirrors `ChatWithTools`\n\n's round loop almost line for line, on purpose: it's the same proven request/response shape, just with one more branch for the reserved tool name. Tool execution during a swarm run goes through the exact same `executeTool`\n\n/`toolRegistry`\n\nmachinery `ai_with_tools`\n\nalready uses — a swarm agent's `tools`\n\nfield is just a list of names already registered with `ai_tool`\n\n.\n\n## ai_vision: content blocks, not a new Message type\n\nDeepSeek [shipped a vision model](https://api-docs.deepseek.com/guides/vision/) — `deepseek-v4-flash-vision-exp`\n\n— using the same OpenAI-compatible `/v1/chat/completions`\n\nshape Pipe already speaks everywhere else. The only difference: a user message's `content`\n\nis an array of `{type: \"text\", ...}`\n\n/ `{type: \"image_url\", ...}`\n\nblocks instead of a plain string.\n\nThe tempting move is widening `ai.Message.Content`\n\nfrom `string`\n\nto something richer. We didn't do that. It's used as a plain string in six separate provider structs (`pkg/ai/providers.go`\n\n) *and* in the response-cache key logic — a capability that (like `ai_with_tools`\n\n) only works with OpenAI-compatible providers to begin with isn't worth a wide, repetitive change to a typed path five other builtins depend on staying string-shaped.\n\nInstead, `ai.VisionChat`\n\nis a small, self-contained function that builds its own raw JSON body directly — the same pattern `ChatWithTools`\n\n's internals already use for tool-call messages. Zero changes to `Message`\n\n, `ChatRequest`\n\n, or any of the six provider implementations.\n\n```\nai_provider \"deepseek\" {model: \"deepseek-v4-flash-vision-exp\"}\n\n\"https://raw.githubusercontent.com/github/explore/main/topics/go/go.png\"\n    > ai_vision \"What does this logo depict?\"\n    > print\n-- -> \"This logo depicts the Go programming language (also commonly\n--     known as Golang)...\"\n```\n\n`image`\n\naccepts three forms: an `http(s)`\n\nURL passed straight through (the provider's servers fetch it, not Pipe), a local file path read through the same sandbox read-gate as `read_file`\n\n, or raw `bytes`\n\n. Local files and raw bytes get content-sniffed with Go's stdlib `http.DetectContentType`\n\n(no hand-written magic-byte table, no third-party dependency) and base64-encoded into a `data:`\n\nURL. Both the URL path and the local-file path are live-verified against a real DeepSeek key — same correct answer either way.\n\n## Sandbox gating: nothing new\n\nBoth builtins reuse gates that already existed rather than inventing a third one. `ai_swarm`\n\n/`ai_vision`\n\nget the same two-branch check as `ai_chat`\n\n: `profile.CanAI()`\n\nunder a registered profile, the CLI `--sandbox`\n\nflag's `Sandbox.AllowAI`\n\notherwise. The real backstop is `gateEgress(EgressChat, ...)`\n\ninside `ChatSwarm`\n\n/`VisionChat`\n\nthemselves — the same central sandbox gate every `Chat`\n\n/`Stream`\n\n/`Embed`\n\ncall has gone through since [round 5 of our sandbox audits](sandbox-audit-2.html). Reading a local image path goes through the exact same fs-read gate `read_file`\n\nuses, unaffected by `--sandbox`\n\n(which only restricts writes). Nothing here needed a new audit round — everything routes through gates we'd already hardened.\n\n## The bug we found: builtins move, bytecode caches don't know\n\nAdding `ai_swarm`\n\n's three new builtins in the middle of Pipe's builtin table — not at the end — quietly broke an unrelated example. `xor_cipher.pipe`\n\nstarted hanging instead of running, with the VM printing `encrypt: key must be 16, 24, or 32 bytes`\n\neven though the script never calls `encrypt`\n\n.\n\nThe cause: the compiler bakes each builtin's *position* in the table directly into the bytecode as an integer index (`BuiltinScope`\n\n). Insert a builtin anywhere but the end, and every later builtin's index shifts. A `.pipec`\n\ndisk cache compiled against the old table still looked \"valid\" — same source hash, same `CacheVersion`\n\nbyte — and fed the VM bytecode that resolved `OpGetBuiltin`\n\nto the *wrong function*. A leftover local cache from before our change called `encrypt`\n\nwhere the script meant something else entirely, and looped instead of erroring cleanly.\n\nThe comment already sitting next to `CacheVersion`\n\neven predicted this exact failure mode — it just depends on a human remembering to bump a constant for a change that has nothing to do with bytecode *format*. So instead of bumping it once, we made the class of bug impossible: the cache's dependency hash now includes a fingerprint of the ordered builtin-name table itself, so *any* future insertion, removal, or reorder self-invalidates every `.pipec`\n\non disk automatically.\n\n```\n// pkg/cache/cache.go — depsHash now also covers the builtin table\nfor _, b := range object.Builtins {\n    h.Write([]byte(b.Name))\n    h.Write([]byte{0})\n}\n```\n\nA new regression test (`TestLoadOrCompileInvalidatesOnBuiltinTableChange`\n\n) inserts a builtin mid-table and asserts the cache misses. Builtin position is now provably irrelevant to cache correctness — which is also why `ai_vision`\n\n's registration didn't need any special placement thought at all.\n\n## Honest limits\n\n**OpenAI-compatible providers only**—`openai`\n\n,`deepseek`\n\n,`ollama`\n\n,`openrouter`\n\n,`opencode`\n\n.`anthropic`\n\nuses a different tool-call and image-block shape and isn't supported by either builtin, the same inherited constraint`ai_with_tools`\n\nalready has.**Single image per**— DeepSeek's API allows up to 600. A`ai_vision`\n\ncall`list`\n\nof images is a straightforward extension of the same request shape if we need it later; not built now.**No provider/model validation**— same hands-off approach as the rest of`ai_provider`\n\n/`ai_model`\n\n. Point`ai_vision`\n\nat a non-vision model and you get the provider's own error, not a Pipe-side check.**No shared state across parallel swarm runs**— each`ai_swarm`\n\ncall owns its own message history; nothing is shared between concurrent`>>`\n\nswarm calls by design.\n\n## Try it\n\n```\nDEEPSEEK_API_KEY=\"sk-...\" pipe examples/swarm_demo.pipe\nDEEPSEEK_API_KEY=\"sk-...\" pipe examples/vision_demo.pipe\n```\n\n# 🐝👁️ ai_swarm und ai_vision — Handoff-Multi-Agent-Swarms und Bildverständnis in reinem Pipe\n\n**Zwei neue KI-Builtins: ai_swarm verdrahtet benannte Agenten mit dem Handoff-Pattern, das OpenAIs ursprüngliche „Swarm\"-Bibliothek populär gemacht hat — ein aktiver Agent zur Zeit, komplett geteilter Gesprächsverlauf, Übergabe per reserviertem Tool-Call. ai_vision beantwortet Fragen zu Bildern (URL, lokale Datei oder rohe Bytes) gegen DeepSeeks neues Vision-Modell. Dazu der hinterhältige .pipec-Bytecode-Cache-Bug, den wir dabei gefunden — und dauerhaft gefixt — haben.**\n\nPipe hatte bereits zwei getrennte KI-Bausteine, die sich nie ganz trafen: `agent`\n\n/`agent_ask`\n\ngibt dir eine benannte, zustandsbehaftete Konversation, aber keine Tools. `ai_tool`\n\n/`ai_with_tools`\n\ngibt dir Tool-Calling, aber keine dauerhafte Identität. Keins von beiden lässt einen Agenten eine Konversation an einen anderen übergeben. Genau diese Lücke schließt `ai_swarm`\n\n.\n\n## Die Lücke: Agenten, die nicht miteinander reden können\n\nEin Swarm ist eine Menge benannter Agenten, jeder mit eigenem System-Prompt und eigenen Tools, die sich mitten in der Konversation die Kontrolle zuschieben können — während der **komplette Gesprächsverlauf** mitwandert, sodass beim Handoff nichts verloren geht.\n\n```\nai_provider \"deepseek\"\n\nfn get_invoice kunde\n    \"Rechnung Nr. 4471, 49.90€, fällig 15.09.2026.\"\n\nai_tool \"get_invoice\" \"Aktuelle Rechnung eines Kunden abrufen\" {kunde: \"Kundenname\"} get_invoice\n\nswarm_agent \"triage\" {system: \"Leite Rechnungsfragen an 'billing' weiter. Alles andere beantwortest du selbst.\", handoff: [\"billing\"]}\nswarm_agent \"billing\" {system: \"Du beantwortest Rechnungsfragen mit get_invoice.\", tools: [\"get_invoice\"], handoff: [\"triage\"]}\n\n\"Was steht auf meiner letzten Rechnung?\"\n    > ai_swarm \"triage\"\n    > print\n```\n\nGegen einen echten DeepSeek-Key ausgeführt liefert das die Antwort des Billing-Agenten nach einem sauberen Handoff — `ai_swarm_trace`\n\ngibt dasselbe Ergebnis plus `{content, path, rounds}`\n\nzurück, sodass du genau siehst, wer die Anfrage bearbeitet hat: `[\"triage\", \"billing\"]`\n\n.\n\n## Wie Handoff tatsächlich funktioniert\n\nEs gibt keinen magischen Router. Wenn ein Agent `handoff`\n\n-Ziele deklariert, baut `ChatSwarm`\n\n(der neue Go-Loop in `pkg/ai/swarm.go`\n\n) für diese Runde ein zusätzliches Tool zusammen — ein reserviertes `__handoff__(to: enum[...])`\n\n, das das Modell wie jedes andere Tool aufrufen kann. Tut es das, führt der Loop nicht deinen Executor aus: Er tauscht die System-Message gegen den Prompt des Zielagenten aus und führt dasselbe Nachrichten-Array in der nächsten Runde fort. Der Gesprächsverlauf — inklusive der eigenen Züge des übergebenden Agenten — bleibt unangetastet, sodass der neue Agent vollen Kontext hat, ohne Zusammenfassung oder zweiten Prompt.\n\n`ChatSwarm`\n\nspiegelt `ChatWithTools`\n\ns Rundenlauf fast Zeile für Zeile — absichtlich: dieselbe bewährte Request/Response-Form, nur mit einem zusätzlichen Zweig für den reservierten Tool-Namen. Tool-Ausführung während eines Swarm-Laufs läuft über exakt dieselbe `executeTool`\n\n/`toolRegistry`\n\n-Maschinerie, die `ai_with_tools`\n\nbereits nutzt — die `tools`\n\n-Liste eines Swarm-Agenten sind einfach Namen, die bereits per `ai_tool`\n\nregistriert sind.\n\n## ai_vision: Content-Blöcke statt neuem Message-Typ\n\nDeepSeek hat [ein Vision-Modell veröffentlicht](https://api-docs.deepseek.com/guides/vision/) — `deepseek-v4-flash-vision-exp`\n\n— im selben OpenAI-kompatiblen `/v1/chat/completions`\n\n-Format, das Pipe überall sonst schon spricht. Der einzige Unterschied: Der `content`\n\neiner User-Message ist ein Array aus `{type: \"text\", ...}`\n\n/`{type: \"image_url\", ...}`\n\n-Blöcken statt eines reinen Strings.\n\nDer naheliegende Schritt wäre, `ai.Message.Content`\n\nvon `string`\n\nauf etwas Reichhaltigeres zu erweitern. Haben wir nicht gemacht. Er wird als reiner String in sechs separaten Provider-Structs (`pkg/ai/providers.go`\n\n) **und** in der Response-Cache-Key-Logik verwendet — eine Fähigkeit, die (wie `ai_with_tools`\n\n) ohnehin nur mit OpenAI-kompatiblen Providern funktioniert, rechtfertigt keine breite, sich wiederholende Änderung an einem typisierten Pfad, auf dessen String-Form fünf andere Builtins angewiesen sind.\n\nStattdessen ist `ai.VisionChat`\n\neine kleine, in sich geschlossene Funktion, die ihren eigenen rohen JSON-Body direkt baut — dasselbe Muster, das `ChatWithTools`\n\nintern schon für Tool-Call-Nachrichten nutzt. Null Änderungen an `Message`\n\n, `ChatRequest`\n\noder einer der sechs Provider-Implementierungen.\n\n```\nai_provider \"deepseek\" {model: \"deepseek-v4-flash-vision-exp\"}\n\n\"https://raw.githubusercontent.com/github/explore/main/topics/go/go.png\"\n    > ai_vision \"Was zeigt dieses Logo?\"\n    > print\n-- -> \"Dieses Logo zeigt die Programmiersprache Go\n--     (auch bekannt als Golang)...\"\n```\n\n`image`\n\nakzeptiert drei Formen: eine `http(s)`\n\n-URL, die unverändert durchgereicht wird (der Server des Providers holt sie, nicht Pipe), einen lokalen Dateipfad, gelesen über dasselbe Sandbox-Lese-Gate wie `read_file`\n\n, oder rohe `bytes`\n\n. Lokale Dateien und rohe Bytes werden mit Gos Standardbibliothek `http.DetectContentType`\n\ninhaltlich erkannt (keine handgeschriebene Magic-Byte-Tabelle, keine Drittanbieter-Abhängigkeit) und als `data:`\n\n-URL base64-kodiert. Beide Wege — URL und lokale Datei — sind live gegen einen echten DeepSeek-Key verifiziert, mit derselben korrekten Antwort.\n\n## Sandbox-Gating: nichts Neues\n\nBeide Builtins nutzen bereits bestehende Gates wieder, statt ein drittes zu erfinden. `ai_swarm`\n\n/`ai_vision`\n\nbekommen dieselbe Zwei-Zweig-Prüfung wie `ai_chat`\n\n: `profile.CanAI()`\n\nunter einem registrierten Profil, sonst das `Sandbox.AllowAI`\n\ndes CLI-`--sandbox`\n\n-Flags. Der eigentliche Rückhalt ist `gateEgress(EgressChat, ...)`\n\ninnerhalb von `ChatSwarm`\n\n/`VisionChat`\n\nselbst — derselbe zentrale Sandbox-Gate, den jeder `Chat`\n\n/`Stream`\n\n/`Embed`\n\n-Call seit [Runde 5 unserer Sandbox-Audits](sandbox-audit-2.html) durchläuft. Das Lesen eines lokalen Bildpfads läuft über exakt dasselbe fs-Lese-Gate wie `read_file`\n\n, unberührt von `--sandbox`\n\n(das nur Schreibzugriffe einschränkt). Dafür brauchte es keine neue Audit-Runde — alles läuft über bereits gehärtete Gates.\n\n## Der Bug, den wir gefunden haben: Builtins ziehen um, Bytecode-Caches wissen es nicht\n\n`ai_swarm`\n\ns drei neue Builtins in der Mitte von Pipes Builtin-Tabelle einzufügen — nicht am Ende — hat still und leise ein unabhängiges Beispiel kaputtgemacht. `xor_cipher.pipe`\n\nhing plötzlich, statt zu laufen, und die VM druckte `encrypt: key must be 16, 24, or 32 bytes`\n\n, obwohl das Skript `encrypt`\n\ngar nicht aufruft.\n\nDie Ursache: Der Compiler bäckt die *Position* jedes Builtins in der Tabelle direkt als Integer-Index (`BuiltinScope`\n\n) in den Bytecode ein. Fügt man ein Builtin irgendwo außer am Ende ein, verschieben sich die Indizes aller nachfolgenden Builtins. Ein `.pipec`\n\n-Cache auf der Platte, kompiliert gegen die alte Tabelle, sah weiterhin „gültig\" aus — gleicher Quell-Hash, gleiches `CacheVersion`\n\n-Byte — und fütterte die VM mit Bytecode, der `OpGetBuiltin`\n\nauf die *falsche Funktion* auflöste. Ein liegengebliebener lokaler Cache von vor unserer Änderung rief `encrypt`\n\nauf, wo das Skript etwas ganz anderes meinte, und lief in eine Schleife statt sauber zu fehlern.\n\nDer Kommentar, der direkt neben `CacheVersion`\n\nsteht, hatte genau dieses Fehlerbild sogar schon vorhergesagt — es hängt nur davon ab, dass ein Mensch daran denkt, eine Konstante für eine Änderung hochzuzählen, die nichts mit dem Bytecode-*Format* zu tun hat. Statt sie einmal hochzuzählen, haben wir die Fehlerklasse unmöglich gemacht: Der Dependency-Hash des Caches enthält jetzt einen Fingerprint der geordneten Builtin-Namen-Tabelle selbst, sodass **jedes** künftige Einfügen, Entfernen oder Umsortieren automatisch alle `.pipec`\n\n-Dateien auf der Platte invalidiert.\n\n```\n// pkg/cache/cache.go — depsHash deckt jetzt auch die Builtin-Tabelle ab\nfor _, b := range object.Builtins {\n    h.Write([]byte(b.Name))\n    h.Write([]byte{0})\n}\n```\n\nEin neuer Regressionstest (`TestLoadOrCompileInvalidatesOnBuiltinTableChange`\n\n) fügt ein Builtin mitten in die Tabelle ein und prüft, dass der Cache verfehlt wird. Die Position eines Builtins ist jetzt nachweislich irrelevant für die Cache-Korrektheit — weshalb auch `ai_vision`\n\ns Registrierung keinerlei besondere Überlegung zur Platzierung brauchte.\n\n## Ehrliche Grenzen\n\n**Nur OpenAI-kompatible Provider**—`openai`\n\n,`deepseek`\n\n,`ollama`\n\n,`openrouter`\n\n,`opencode`\n\n.`anthropic`\n\nnutzt ein anderes Tool-Call- und Bild-Block-Format und wird von keinem der beiden Builtins unterstützt — dieselbe geerbte Einschränkung, die`ai_with_tools`\n\nschon hat.**Nur ein Bild pro**— DeepSeeks API erlaubt bis zu 600. Eine`ai_vision`\n\n-Aufruf`list`\n\nvon Bildern wäre eine naheliegende Erweiterung derselben Request-Form, falls später gebraucht — jetzt nicht gebaut.**Keine Provider-/Modell-Validierung**— derselbe zurückhaltende Ansatz wie beim Rest von`ai_provider`\n\n/`ai_model`\n\n. Zeigt`ai_vision`\n\nauf ein Nicht-Vision-Modell, bekommt man den Fehler des Providers, keinen Pipe-seitigen Check.**Kein geteilter Zustand über parallele Swarm-Läufe hinweg**— jeder`ai_swarm`\n\n-Aufruf besitzt seinen eigenen Gesprächsverlauf; zwischen gleichzeitigen`>>`\n\n-Swarm-Calls wird absichtlich nichts geteilt.\n\n## Ausprobieren\n\n```\nDEEPSEEK_API_KEY=\"sk-...\" pipe examples/swarm_demo.pipe\nDEEPSEEK_API_KEY=\"sk-...\" pipe examples/vision_demo.pipe\n```\n\n", "url": "https://wpnews.pro/news/ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in", "canonical_source": "https://pipe-lang.com/blog/ai-swarm-and-vision.html", "published_at": "2026-08-30 00:00:00+00:00", "updated_at": "2026-08-30 01:19:03.092785+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "generative-ai", "computer-vision"], "entities": ["Pipe", "OpenAI", "DeepSeek", "ChatSwarm", "ai_swarm", "ai_vision"], "alternates": {"html": "https://wpnews.pro/news/ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in", "markdown": "https://wpnews.pro/news/ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in.md", "text": "https://wpnews.pro/news/ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in.txt", "jsonld": "https://wpnews.pro/news/ai-swarm-and-ai-vision-handoff-multi-agent-swarms-and-image-understanding-in.jsonld"}}