Sagents.SubAgentServer (Sagents v0.8.0-rc.2)

Copy Markdown

GenServer wrapper for SubAgent providing blocking API.

Simple Design

SubAgentServer is a simple wrapper that:

  1. Holds a SubAgent struct in its state
  2. Provides a blocking API for execute/resume
  3. Uses Registry for named access (via sub-agent ID)
  4. NO PubSub (simpler than AgentServer)
  5. NO auto-shutdown - lifecycle managed by parent agent

The GenServer state is SIMPLE - just the SubAgent struct! Everything is delegated to SubAgent functions.

New Architecture

The SubAgent struct HOLDS the execution state including the LLMChain. This makes the server's job trivial:

  • Store the SubAgent struct
  • Delegate execute/resume to SubAgent module
  • Return results

Lifecycle

  1. Spawned - Created by task tool under SubAgentsDynamicSupervisor
  2. Execute - Tool calls execute/1, blocking until completion or interrupt
  3. Interrupt (optional) - Returns {:interrupt, interrupt_data} if HITL needed
  4. Resume (optional) - Tool calls resume/2 with decisions, blocks again
  5. Complete - Returns {:ok, final_result} when done
  6. Shutdown - Cleaned up when parent agent terminates

Usage

# Create a SubAgent struct
subagent = SubAgent.new_from_config(
  parent_agent_id: "main-agent",
  instructions: "Research renewable energy",
  agent_config: agent,
  parent_state: parent_state
)

# Start the server
{:ok, _pid} = SubAgentServer.start_link(subagent: subagent)

# Execute synchronously (blocks until completion or interrupt)
case SubAgentServer.execute(subagent.id) do
  {:ok, final_result} ->
    {:ok, final_result}

  {:interrupt, interrupt_data} ->
    # SubAgent needs HITL approval
    # Propagate interrupt to parent
    {:interrupt, %{
      type: :subagent_hitl,
      sub_agent_id: subagent.id,
      interrupt_data: interrupt_data
    }}

  {:error, reason} ->
    {:error, reason}
end

# Resume after user provides decisions
case SubAgentServer.resume(subagent.id, decisions) do
  {:ok, final_result} -> {:ok, final_result}
  {:interrupt, interrupt_data} -> # Another interrupt
  {:error, reason} -> {:error, reason}
end

Supervision

SubAgentServers are supervised by SubAgentsDynamicSupervisor with :temporary restart strategy. If a SubAgent crashes:

  • The supervisor logs the crash
  • The blocking call receives an exit signal
  • The parent agent can handle the error
  • No automatic restart (SubAgents are ephemeral)

Summary

Functions

Cancel a running SubAgentServer process.

Returns a specification to start this module under a supervisor.

Execute the SubAgent synchronously.

Get the registry name for a SubAgent.

Get the current status of the SubAgent.

Get the current SubAgent struct.

Resume the SubAgent after an HITL interrupt.

Start a SubAgentServer.

Stop a SubAgentServer process.

Find the PID of a SubAgent by ID.

Functions

cancel(sub_agent_id)

@spec cancel(String.t()) :: :ok

Cancel a running SubAgentServer process.

Called when the parent AgentServer is cancelled — the sub-agent's work is being abandoned because there is no longer anyone to return results to.

Broadcasts {:subagent_status_changed, :cancelled} and {:subagent_cancelled, %{final_messages, turn_count}} on the parent's debug topic BEFORE terminating, so observers (debugger) see the terminal event instead of the sub-agent silently vanishing.

If the sub-agent is blocked in an LLM call and cannot respond to the pre-cancel broadcast within a short window, it is terminated regardless — the parent cancel must not be delayed.

Idempotent: returns :ok if the sub-agent is already gone.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

execute(sub_agent_id)

@spec execute(String.t()) ::
  {:ok, String.t()}
  | {:ok, String.t(), term()}
  | {:interrupt, map()}
  | {:error, term()}

Execute the SubAgent synchronously.

This function blocks until the SubAgent either:

  • Completes successfully: {:ok, final_result}
  • Encounters an HITL interrupt: {:interrupt, interrupt_data}
  • Fails with an error: {:error, reason}

Important: This uses :infinity timeout because SubAgents may perform multiple LLM calls and tool executions. The blocking is intentional - the caller waits for the SubAgent to finish.

Examples

case SubAgentServer.execute("main-agent-sub-1") do
  {:ok, final_result} -> handle_completion(final_result)
  {:interrupt, interrupt_data} -> propagate_interrupt(interrupt_data)
  {:error, reason} -> handle_error(reason)
end

get_name(sub_agent_id)

@spec get_name(String.t()) :: GenServer.name()

Get the registry name for a SubAgent.

Examples

name = SubAgentServer.get_name("main-agent-sub-1")

get_status(sub_agent_id)

@spec get_status(String.t()) :: atom()

Get the current status of the SubAgent.

Returns one of: :idle, :running, :interrupted, :completed, :error

Examples

status = SubAgentServer.get_status("main-agent-sub-1")

get_subagent(sub_agent_id)

@spec get_subagent(String.t()) :: Sagents.SubAgent.t()

Get the current SubAgent struct.

Note: This is primarily for debugging. In normal operation, the SubAgent should stay encapsulated in the process.

Examples

subagent = SubAgentServer.get_subagent("main-agent-sub-1")

resume(sub_agent_id, decisions)

@spec resume(String.t(), [map()]) ::
  {:ok, String.t()}
  | {:ok, String.t(), term()}
  | {:interrupt, map()}
  | {:error, term()}

Resume the SubAgent after an HITL interrupt.

This function blocks until the SubAgent either:

  • Completes successfully: {:ok, final_result}
  • Encounters another HITL interrupt: {:interrupt, interrupt_data}
  • Fails with an error: {:error, reason}

Parameters

  • sub_agent_id - The SubAgent identifier
  • decisions - List of decision maps from human reviewer (see LangChain.Agent.resume/3)

Examples

decisions = [
  %{type: :approve},
  %{type: :edit, arguments: %{"path" => "safe.txt"}}
]

case SubAgentServer.resume("main-agent-sub-1", decisions) do
  {:ok, final_result} -> handle_completion(final_result)
  {:interrupt, interrupt_data} -> propagate_interrupt(interrupt_data)
  {:error, reason} -> handle_error(reason)
end

start_link(opts)

Start a SubAgentServer.

Options

  • :subagent - The SubAgent struct (required)

Examples

subagent = SubAgent.new_from_config(
  parent_agent_id: "main-agent",
  instructions: "Research renewable energy",
  agent_config: agent,
  parent_state: parent_state
)

{:ok, pid} = SubAgentServer.start_link(subagent: subagent)

stop(sub_agent_id)

@spec stop(String.t()) :: :ok

Stop a SubAgentServer process.

Called after the sub-agent has completed or errored to free the process. This is a synchronous call that waits for the process to terminate.

Returns :ok if stopped successfully, or :ok if the process was already gone (idempotent).

whereis(sub_agent_id)

@spec whereis(String.t()) :: pid() | nil

Find the PID of a SubAgent by ID.

Returns nil if the SubAgent doesn't exist.

Examples

pid = SubAgentServer.whereis("main-agent-sub-1")