Skip to content

Add spawn_agent: multi-agent delegation with background execution and messaging - #416

Open
C-K-Loan wants to merge 9 commits into
Blaizzy:mainfrom
C-K-Loan:spawn-agent-v2
Open

Add spawn_agent: multi-agent delegation with background execution and messaging#416
C-K-Loan wants to merge 9 commits into
Blaizzy:mainfrom
C-K-Loan:spawn-agent-v2

Conversation

@C-K-Loan

@C-K-Loan C-K-Loan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

This PR introduces the spawn_agent() tool, along with helper tools list_agents(), check_agent(), steer_agent().

Agents can be spawned as a fork of the main agent (inheriting its context) or start with empty context. The main agent can choose to run sub-agents in the background or not (blocking vs. non-blocking).

New tools

Adds 4 new tool schemas to the model's tool list, ~606 marginal prompt tokens (measured live against the server: baseline 13 tokens, +606 with just these 4 tools added, vs. +1,700 for all 13 native tools combined).

spawn_agent(task, mode: fresh|branch, context, model, run_in_background): returns {ok, agent_id, status, stop_reason, answer, error}. If run_in_background is false, blocks until the sub-agent finishes and status/answer reflect its final result. If true, returns immediately with status: "queued" and no answer yet

  • task: the sub-agent's instruction
  • mode: fresh (default): starts with just the task. branch: clones this conversation's history first, then adds the task
  • context: facts the sub-agent needs, without full history. Ignored in branch mode
  • model: a downloaded model ID, e.g. mlx-community/Qwen3-4B-4bit. Omit to use the current model
  • run_in_background: if true, returns an agent_id immediately instead of waiting. Check via check_agent/list_agents. Defaults to false

list_agents(): returns {ok, agents: [{agent_id, task, status, stop_reason}]}, one entry per sub-agent spawned this session

check_agent(agent_id, wait): returns {ok, agent_id, status, stop_reason, answer, error} for the given agent

  • agent_id: from spawn_agent or list_agents
  • wait: block until the agent reaches a terminal state instead of returning its current status

steer_agent(agent_id, message): returns {ok, error}. ok: false if the agent is unknown or already finished

  • agent_id: from spawn_agent or list_agents
  • message: queued into the running sub-agent, delivered at its next turn

Sub-agents cannot call any of the 4 tools above on themselves (recursive spawning is blocked, and they'd otherwise waste rounds calling list_agents/check_agent with fabricated IDs).

New Files:

File Purpose
ChatSpawnAgentTool.swift spawn_agent tool schema + executor, two-phase prepare()/run() design so run_in_background can return immediately
ChatAgentRegistryTools.swift list_agents/check_agent/steer_agent tool schemas + executors
ChatAgentRegistry.swift @MainActor in-memory registry of spawned agents (status, result, cancel), owned by ChatViewModel, survives session switches but not app relaunch
ChatAgentLoop.swift Sub-agent's own round-loop, including subAgentToolDefinitions() which filters out switch_model/spawn_agent/list_agents/check_agent/steer_agent

Updated Files:

File Change
ChatToolRegistry.swift Wiring for the 4 new tools into the model's tool list
ChatSessionStore.swift Link up new sub-agent messages to the session store
ChatViewModel.swift Edits runChatLoop so multiple sequential spawn_agent calls run in parallel, not sequentially: adds runConcurrentSpawnAgentBatch (blocking, TaskGroup-based, capped at 5 concurrent) and startBackgroundSpawnAgent (non-blocking, fire-and-forget)
ChatView.swift New UI for showing sub-agent chats: per-sub-agent transcript view, live decode-metrics widget, and a stop/cancel button per running agent
ChatToolRegistryTests.swift Coverage for all 4 tools, concurrent/background execution, and cancellation

spawn_agent multi-agent demo
Full quality: https://github.com/C-K-Loan/nativ/blob/demo-assets/demos/spawn-agent-multi-agent-demo.mp4

Improvements left for follow up PRs:

  • Choose running sub-agents concurrently vs. sequentially. Depending on available resources, the optimal choice differs.
  • Make the choice of tools available to sub-agents configurable in the UI; right now it's hardcoded in ChatAgentLoop.swift.
  • For background sub-agent spawns, the main agent is never informed of completion and has to fetch results manually via check_agent(id). Instead, we should resume the coordinator's turn as soon as a background agent finishes (queuing the resume if the coordinator is mid-turn), rather than waiting on the whole batch or requiring a manual poll.
  • Investigate performance flakyness. Sometimes my sub-agents grinded with 20 tok/s sometimes 3 tok/s for the same prompt. Might be related to KV-Caching mechanisms, not sure
  • Memory aware model spawns for sub-agents: spawn_agent() has no logic to dedicde if requested model actually fits before spawning it, it just fires a request. Make the embedded server's model cache memory-aware #268 has a draft implementation for this I would refactor and address after this is merged and once I have access to more URAM to properly test it :)

@Lazarus-931

Copy link
Copy Markdown
Collaborator

hi @C-K-Loan, this seems really nice!

quick q:

  1. won't sub-agent transcripts being written, let's say 5 concurrently freeze the UI?
  2. and won't foreground agents ignore the cancel request because they never are inside ChatAgentRegistry.tasks

@C-K-Loan

Copy link
Copy Markdown
Contributor Author

Hi @Lazarus-931, glad to hear and thanks for catching these issues!

  1. Yes each streamed token causes UI to update. Adding some throttle
  2. Yes let me fix that

@Lazarus-931

Copy link
Copy Markdown
Collaborator

nice:)

conflicts as well!

…ncurrency

Reviewer feedback: streamed deltas published on every token with no rate
limit, so several concurrent sub-agents each streaming tokens could pile
up unthrottled MainActor traffic. Gate publishes to ~20Hz per agent;
completion always flushes unconditionally so nothing is ever lost.
Reviewer feedback: runConcurrentSpawnAgentBatch never called setTask,
only startBackgroundSpawnAgent did -- so the cancel button was a silent
no-op for the common (non-background) concurrent case. Split execute()
into prepare()+run() like the background path already does, register a
real Task via setTask, and reflect the registry's actual final status
(previously always shown as succeeded regardless of the true outcome).
@C-K-Loan

Copy link
Copy Markdown
Contributor Author

Addressed both issues in latest 2 commits should be ready to go :) @Lazarus-931

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants