Skip to content

Commit 0cb1e38

Browse files
authored
feat: extract swarm_ai as standalone Hex-publishable package (#350)
1 parent 50038f7 commit 0cb1e38

65 files changed

Lines changed: 763 additions & 521 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@frontman/frontman-core": patch
3+
---
4+
5+
Extract Swarm agent execution framework from frontman_server into standalone swarm_ai Hex package. Rename all Swarm.* modules to SwarmAi.* and update telemetry atoms accordingly. frontman_server now depends on swarm_ai via path dep for monorepo development.

.github/workflows/changelog-check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ jobs:
3535
exit 0
3636
fi
3737
38-
# Check for direct CHANGELOG.md edit
39-
if echo "$CHANGED" | grep -q '^CHANGELOG.md$'; then
38+
# Check for direct CHANGELOG.md edit (root or per-package)
39+
if echo "$CHANGED" | grep -q 'CHANGELOG.md$'; then
4040
echo "Found CHANGELOG.md update"
4141
exit 0
4242
fi

.github/workflows/ci.yml

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,48 @@ jobs:
453453
# parallel for faster feedback.
454454
# ============================================================================
455455

456+
test-swarm-ai:
457+
name: Test swarm_ai
458+
runs-on: self-hosted
459+
steps:
460+
- name: Checkout code
461+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
462+
463+
- name: Install OpenSSL dev libraries
464+
run: |
465+
sudo apt-get update -qq
466+
sudo apt-get install -y -qq libssl-dev libncurses5-dev
467+
468+
- name: Fix mise binary permissions
469+
run: chmod -R +x ~/.local/share/mise/bin/ 2>/dev/null || true
470+
471+
# Uses mise.toml as single source of truth for Erlang/Elixir versions
472+
- name: Setup Erlang/Elixir via mise
473+
uses: jdx/mise-action@6d1e696aa24c1aa1bcc1adea0212707c71ab78a8 # v3.6.1
474+
with:
475+
install: true
476+
cache: true
477+
478+
- name: Install dependencies
479+
working-directory: apps/swarm_ai
480+
run: |
481+
rm -rf _build deps
482+
mix deps.get
483+
484+
- name: Run tests
485+
working-directory: apps/swarm_ai
486+
run: mix test
487+
488+
- name: Validate Hex package
489+
working-directory: apps/swarm_ai
490+
run: mix hex.build --unpack
491+
492+
- name: Cleanup workspace
493+
if: always()
494+
run: |
495+
rm -rf apps/swarm_ai/_build apps/swarm_ai/deps
496+
497+
456498
test-frontman-server:
457499
name: Test frontman-server
458500
runs-on: self-hosted
@@ -512,7 +554,7 @@ jobs:
512554
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
513555
with:
514556
path: apps/frontman_server/_build
515-
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock') }}
557+
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock', 'apps/swarm_ai/lib/**') }}
516558
restore-keys: ${{ runner.os }}-build-
517559

518560
- name: Install dependencies
@@ -576,7 +618,7 @@ jobs:
576618
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
577619
with:
578620
path: apps/frontman_server/_build
579-
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock') }}
621+
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock', 'apps/swarm_ai/lib/**') }}
580622
restore-keys: ${{ runner.os }}-build-
581623

582624
- name: Install dependencies
@@ -626,7 +668,7 @@ jobs:
626668
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
627669
with:
628670
path: apps/frontman_server/_build
629-
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock') }}
671+
key: ${{ runner.os }}-build-${{ hashFiles('apps/frontman_server/mix.lock', 'apps/swarm_ai/lib/**') }}
630672
restore-keys: ${{ runner.os }}-build-
631673

632674
# PLT cache includes mise.toml hash to invalidate when Erlang/Elixir versions change

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
- main
77
paths:
88
- 'apps/frontman_server/**'
9+
- 'apps/swarm_ai/**'
910
- 'libs/**'
1011
- 'rescript.json'
1112
- 'mise.toml'

.github/workflows/release-pr.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@ jobs:
9999
[ -f "$changelog" ] || continue
100100
if git status --porcelain -- "$changelog" | grep -q .; then
101101
PKG_DIR=$(dirname "$changelog")
102-
PKG_NAME=$(node -e "console.log(require('./${PKG_DIR}/package.json').name)" 2>/dev/null || echo "$(basename "$PKG_DIR")")
102+
if [ -f "${PKG_DIR}/package.json" ]; then
103+
PKG_NAME=$(node -e "console.log(require('./${PKG_DIR}/package.json').name)")
104+
elif [ -f "${PKG_DIR}/mix.exs" ]; then
105+
PKG_NAME=$(sed -n 's/.*app: :\([a-z_]*\).*/\1/p' "${PKG_DIR}/mix.exs" | head -1)
106+
else
107+
PKG_NAME=$(basename "$PKG_DIR")
108+
fi
103109
SECTION=$(awk '/^## /{if(found) exit; found=1; next} found' "$changelog")
104110
if [ -n "$SECTION" ]; then
105111
CHANGES="${CHANGES}"$'\n\n'"#### ${PKG_NAME}"$'\n\n'"${SECTION}"

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ endef
2828
ssl-setup tunnel \
2929
worktree-create worktree-create-from worktree-list worktree-remove worktree-clean \
3030
worktree-status worktree-devpod worktree-urls worktree-hosts worktree-register worktree-registry \
31-
publish publish-astro publish-vite publish-nextjs release \
31+
publish publish-astro publish-vite publish-nextjs publish-swarm-ai release \
3232
kill-all-processes open-dogfooding pull-webapi
3333

3434
help: ## Display available commands
@@ -324,6 +324,9 @@ publish-vite: ## Publish @frontman-ai/vite to npm (pass OTP=<code> for 2FA)
324324
publish-nextjs: ## Publish @frontman-ai/nextjs to npm (pass OTP=<code> for 2FA)
325325
cd libs/frontman-nextjs && $(MAKE) publish OTP=$(OTP)
326326

327+
publish-swarm-ai: ## Publish swarm_ai to Hex (dry run by default, HEX_PUBLISH=1 for real)
328+
cd apps/swarm_ai && $(MAKE) hex-publish HEX_PUBLISH=$(HEX_PUBLISH)
329+
327330
release: ## Create a release PR from pending changesets
328331
@printf "$(CYAN)Checking release prerequisites...$(RESET)\n"
329332
@git fetch origin main --quiet

apps/frontman_server/lib/frontman_server/agents.ex

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ defmodule FrontmanServer.Agents do
2525
alias FrontmanServer.Providers
2626
alias FrontmanServer.Providers.ResolvedKey
2727
alias FrontmanServer.Tasks
28-
alias Swarm.LLM.Chunk
29-
alias Swarm.Message
28+
alias SwarmAi.LLM.Chunk
29+
alias SwarmAi.Message
3030

3131
@doc """
3232
Checks if an agent is currently running for the given task.
@@ -74,7 +74,7 @@ defmodule FrontmanServer.Agents do
7474
- `:tools` - List of tool definitions for LLM (default: [])
7575
- `:model` - LLM model spec (defaults to provider default)
7676
- `:env_api_key` - Map of provider => api_key from client's environment
77-
- `:agent` - Custom agent struct implementing Swarm.Agent (for testing)
77+
- `:agent` - Custom agent struct implementing SwarmAi.Agent (for testing)
7878
7979
## Returns
8080
- `{:ok, pid}` - Agent started successfully
@@ -116,7 +116,7 @@ defmodule FrontmanServer.Agents do
116116
# Dialyzer warning suppressed: the anonymous function calls execute_agent which
117117
# has the same protocol dispatch issue. See execute_agent comment for details.
118118
@dialyzer {:nowarn_function, run_agent: 5}
119-
@spec run_agent(Scope.t(), Swarm.Agent.t(), String.t(), [Message.t()], keyword()) ::
119+
@spec run_agent(Scope.t(), SwarmAi.Agent.t(), String.t(), [Message.t()], keyword()) ::
120120
{:ok, pid()} | {:error, term()}
121121
defp run_agent(scope, agent, task_id, messages, opts) do
122122
on_event = Keyword.fetch!(opts, :on_event)
@@ -204,7 +204,7 @@ defmodule FrontmanServer.Agents do
204204
case Registry.lookup(FrontmanServer.AgentRegistry, {:tool_call, tool_call_id}) do
205205
[{_pid, %{caller_pid: caller}}] ->
206206
# MCP tool - send result to waiting executor
207-
# Encode non-string results since Swarm.Message.ContentPart.text/1 requires strings
207+
# Encode non-string results since SwarmAi.Message.ContentPart.text/1 requires strings
208208
encoded = encode_result_for_swarm(result)
209209
send(caller, {:tool_result, tool_call_id, encoded, is_error})
210210
:ok
@@ -223,7 +223,7 @@ defmodule FrontmanServer.Agents do
223223
224224
## Options
225225
- `:tools` - List of tool definitions for LLM (default: [])
226-
- `:agent` - Custom agent struct implementing Swarm.Agent (for testing)
226+
- `:agent` - Custom agent struct implementing SwarmAi.Agent (for testing)
227227
"""
228228
@spec notify_user_message(Scope.t(), String.t(), list(FrontmanServer.Tools.MCP.t()), keyword()) ::
229229
:ok
@@ -423,7 +423,7 @@ defmodule FrontmanServer.Agents do
423423

424424
defp to_swarm_tool_calls(tool_calls) do
425425
Enum.map(tool_calls, fn tc ->
426-
%Swarm.ToolCall{
426+
%SwarmAi.ToolCall{
427427
id: tc.id,
428428
name: ReqLLM.ToolCall.name(tc),
429429
arguments: ReqLLM.ToolCall.args_json(tc)
@@ -442,7 +442,7 @@ defmodule FrontmanServer.Agents do
442442
Phoenix.PubSub.broadcast(FrontmanServer.PubSub, Tasks.topic(task_id), message)
443443
end
444444

445-
# Encode non-string results to JSON for Swarm.Message.ContentPart.text/1
445+
# Encode non-string results to JSON for SwarmAi.Message.ContentPart.text/1
446446
defp encode_result_for_swarm(value) when is_binary(value), do: value
447447
defp encode_result_for_swarm(value), do: Jason.encode!(value)
448448

@@ -476,7 +476,7 @@ defmodule FrontmanServer.Agents do
476476
tool_executor =
477477
ToolExecutor.make_executor(scope, task_id, mcp_tools: mcp_tools, llm_opts: llm_opts)
478478

479-
Logger.info("Starting agent execution for task #{task_id} via Swarm.run_streaming")
479+
Logger.info("Starting agent execution for task #{task_id} via SwarmAi.run_streaming")
480480

481481
# Emit task start telemetry - creates the root OTEL span for this task
482482
TelemetryEvents.task_start(task_id)
@@ -485,7 +485,7 @@ defmodule FrontmanServer.Agents do
485485
# Pass task_id in metadata for telemetry correlation.
486486
# Swarm returns loop_id for execution identification and crash reporting.
487487
result =
488-
Swarm.run_streaming(agent, messages,
488+
SwarmAi.run_streaming(agent, messages,
489489
metadata: %{task_id: task_id},
490490
tool_executor: tool_executor,
491491
on_chunk: &handle_stream_chunk(&1, on_event),
@@ -538,7 +538,7 @@ defmodule FrontmanServer.Agents do
538538
end
539539
end
540540

541-
defp build_response_metadata(%Swarm.LLM.Response{} = response) do
541+
defp build_response_metadata(%SwarmAi.LLM.Response{} = response) do
542542
metadata = %{}
543543

544544
metadata =
@@ -558,7 +558,7 @@ defmodule FrontmanServer.Agents do
558558
metadata
559559
end
560560

561-
defp to_reqllm_tool_call(%Swarm.ToolCall{} = tc) do
561+
defp to_reqllm_tool_call(%SwarmAi.ToolCall{} = tc) do
562562
ReqLLM.ToolCall.new(tc.id, tc.name, tc.arguments)
563563
end
564564
end

apps/frontman_server/lib/frontman_server/agents/llm_client.ex

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule FrontmanServer.Agents.LLMClient do
22
@moduledoc """
3-
Swarm.LLM implementation using ReqLLM.
3+
SwarmAi.LLM implementation using ReqLLM.
44
55
Stream-first design: returns a lazy stream of chunks that can be
66
consumed with callbacks or collected into a Response.
@@ -17,7 +17,7 @@ defmodule FrontmanServer.Agents.LLMClient do
1717

1818
typedstruct do
1919
field(:model, String.t(), default: @default_model)
20-
field(:tools, [Swarm.Tool.t()], default: [])
20+
field(:tools, [SwarmAi.Tool.t()], default: [])
2121
# llm_opts must include :api_key (resolved at domain layer)
2222
field(:llm_opts, keyword(), default: [])
2323
end
@@ -33,21 +33,21 @@ defmodule FrontmanServer.Agents.LLMClient do
3333
## Options
3434
3535
- `:model` - Model spec string (default: "openrouter:openai/gpt-5.1-codex")
36-
- `:tools` - List of Swarm.Tool structs
36+
- `:tools` - List of SwarmAi.Tool structs
3737
- `:llm_opts` - Options for ReqLLM, must include `:api_key`
3838
"""
3939
def new(opts \\ []) do
4040
struct!(__MODULE__, opts)
4141
end
4242

4343
@doc """
44-
Converts Swarm.Tool to ReqLLM.Tool format.
44+
Converts SwarmAi.Tool to ReqLLM.Tool format.
4545
Normalizes schemas for OpenAI-compatible providers that require strict mode.
4646
4747
When `requires_mcp_prefix: true` is passed in opts, tool names are prefixed with `mcp_`.
4848
"""
49-
@spec to_reqllm_tool(Swarm.Tool.t(), String.t(), keyword()) :: ReqLLM.Tool.t()
50-
def to_reqllm_tool(%Swarm.Tool{} = tool, model, opts \\ []) do
49+
@spec to_reqllm_tool(SwarmAi.Tool.t(), String.t(), keyword()) :: ReqLLM.Tool.t()
50+
def to_reqllm_tool(%SwarmAi.Tool{} = tool, model, opts \\ []) do
5151
provider = SchemaTransformer.provider_for_model(model)
5252
schema = SchemaTransformer.transform(tool.parameter_schema, provider)
5353
strict? = provider == :openai_strict
@@ -79,12 +79,12 @@ defmodule FrontmanServer.Agents.LLMClient do
7979
def strip_mcp_prefix(name), do: name
8080
end
8181

82-
defimpl Swarm.LLM, for: FrontmanServer.Agents.LLMClient do
82+
defimpl SwarmAi.LLM, for: FrontmanServer.Agents.LLMClient do
8383
alias FrontmanServer.Agents.{LLMClient, SchemaTransformer}
84-
alias Swarm.LLM.{Chunk, Usage}
85-
alias Swarm.Message
86-
alias Swarm.Message.ContentPart
87-
alias Swarm.ToolCall
84+
alias SwarmAi.LLM.{Chunk, Usage}
85+
alias SwarmAi.Message
86+
alias SwarmAi.Message.ContentPart
87+
alias SwarmAi.ToolCall
8888

8989
require Logger
9090

@@ -277,7 +277,7 @@ defimpl Swarm.LLM, for: FrontmanServer.Agents.LLMClient do
277277
end)
278278
end
279279

280-
# --- Swarm.Message -> ReqLLM.Message conversion ---
280+
# --- SwarmAi.Message -> ReqLLM.Message conversion ---
281281

282282
defp to_reqllm_message(%Message{} = msg, requires_mcp_prefix?) do
283283
%ReqLLM.Message{

apps/frontman_server/lib/frontman_server/agents/root_agent.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule FrontmanServer.Agents.RootAgent do
33
The main coordinating agent that handles user requests.
44
55
This agent receives user messages, can use tools (including spawning sub-agents),
6-
and coordinates the overall task execution. It implements the Swarm.Agent protocol
6+
and coordinates the overall task execution. It implements the SwarmAi.Agent protocol
77
directly, owning its system prompt generation logic.
88
99
The system prompt is dynamically built based on context:
@@ -19,7 +19,7 @@ defmodule FrontmanServer.Agents.RootAgent do
1919
alias FrontmanServer.Agents.{LLMClient, Prompts}
2020

2121
typedstruct do
22-
field(:tools, [Swarm.Tool.t()], default: [])
22+
field(:tools, [SwarmAi.Tool.t()], default: [])
2323
field(:has_selected_component, boolean(), default: false)
2424
field(:has_current_page, boolean(), default: false)
2525
field(:has_typescript_react, boolean(), default: false)
@@ -37,7 +37,7 @@ defmodule FrontmanServer.Agents.RootAgent do
3737
3838
## Options
3939
40-
- `:tools` - List of Swarm.Tool structs available to the agent
40+
- `:tools` - List of SwarmAi.Tool structs available to the agent
4141
- `:has_selected_component` - Whether a component is selected in the codebase
4242
- `:has_current_page` - Whether current page context is available
4343
- `:framework` - Framework name (e.g., "nextjs") for framework-specific guidance
@@ -61,7 +61,7 @@ defmodule FrontmanServer.Agents.RootAgent do
6161
end
6262
end
6363

64-
defimpl Swarm.Agent, for: FrontmanServer.Agents.RootAgent do
64+
defimpl SwarmAi.Agent, for: FrontmanServer.Agents.RootAgent do
6565
alias FrontmanServer.Agents.{LLMClient, Prompts, RootAgent}
6666

6767
def system_prompt(%RootAgent{} = agent) do

0 commit comments

Comments
 (0)