Delegation (Agent-as-Tools)
Agents delegation (also called the agent-as-tools pattern) lets one agent hand off work to other, more specialized agents and aggregate their results. Splitting capability across focused agents keeps prompts smaller and instructions clearer, which generally makes responses faster and more accurate; by contrast, overloading a single agent with too many instructions and responsibilities can increase latency, cost, and the risk of inaccuracy or hallucination.
This example builds a simple multi-agent workflow where a coordinator delegates order creation and delivery to two specialized sub-agents.
Internally, we expose each sub-agent as a tool called delegate
with a single parameter:
task
: a clear description of the work for the sub-agent, written without ambiguous pronouns so it can stand alone.
flowchart LR Coordinator[Coordinator agent] -->|transfer_to_order| OrderAgent[Order agent] Coordinator -->|transfer_to_delivery| DeliveryAgent[Delivery agent] OrderAgent -->|order tasks result| Coordinator DeliveryAgent -->|delivery tasks result| Coordinator Coordinator --> User[Final response]
The flow works like this:
- The coordinate agent calls the suitable sub-agent via the
transfer_to_{agent}
tool. The tool takes the task string and rewrites it into a singleUserMessage
. - It invokes the target agent’s run method with that message.
- The sub-agent executes independently and returns a structured result.
- The coordinator receives that result and uses it—along with its own reasoning—to craft a combined response for the overall workflow.
Implementation
Section titled “Implementation”import { Agent, tool, type AgentItem } from "@hoangvvo/llm-agent";import { zodTool } from "@hoangvvo/llm-agent/zod";import { z } from "zod";import { getModel } from "./get-model.ts";
// Implement the agent delegation pattern, where a main agent delegates tasks// to sub-agents. The main agent uses the results from the sub-agents'// execution to make informed decisions and coordinate overall behavior.function delegate<TContext>(agent: Agent<TContext>, description: string) { return tool({ name: `transfer_to_${agent.name}`, description: `Use this tool to transfer the task to ${agent.name}, which can help with:${description}`, parameters: { type: "object", properties: { task: { type: "string", description: "A clear and concise description of the task the agent should achieve." + " Replace any possessive pronouns or ambiguous terms with the actual entity names if possible" + " so there is enough information for the agent to process without additional context", }, }, required: ["task"], additionalProperties: false, }, async execute(args: { task: string }, context: TContext) { console.log(`[-> ${agent.name} agent]:`, args.task);
const result = await agent.run({ context, input: [ { type: "message", role: "user", content: [{ type: "text", text: args.task }], }, ], });
return { content: result.content, is_error: false, }; }, });}
const model = getModel("openai", "gpt-4o");
interface Order { customer_name: string; address: string; quantity: number; completionTime: Date;}
class MyContext { readonly #orders: Order[]; constructor() { this.#orders = []; }
addOrder(order: Order) { this.#orders.push(order); }
getOrders() { return this.#orders; }
pruneOrders() { const now = new Date(); // remove completed orders this.#orders.splice( 0, this.#orders.length, ...this.#orders.filter(({ completionTime }) => completionTime > now), ); }}
// Order processing agentconst orderAgent = new Agent<MyContext>({ name: "order", model, instructions: [ "You are an order processing agent. Your job is to handle customer orders efficiently and accurately.", ], tools: [ zodTool({ name: "create_order", description: "Create a new customer order", parameters: z.object({ customer_name: z.string(), address: z.string(), quantity: z.number(), }), execute(args, context) { console.log( `[delivery.create_order] Creating order for ${args.customer_name} with quantity ${String(args.quantity)}`, );
context.addOrder({ ...args, // Randomly finish between 1 to 10 seconds completionTime: new Date( Date.now() + Math.floor(Math.random() * 10000) + 1000, ), });
return Promise.resolve({ content: [ { type: "text", text: JSON.stringify({ status: "creating" }) }, ], is_error: false, }); }, }), zodTool({ name: "get_orders", description: "Retrieve the list of customer orders and their status (completed or pending)", parameters: z.object({}), execute(_args, context) { const now = new Date();
const orders = context.getOrders();
const result = orders.map( ({ customer_name, quantity, address, completionTime }) => ({ customer_name, quantity, address, status: completionTime <= now ? "completed" : "pending", }) as const, );
const completedCount = result.filter( (order) => order.status === "completed", ).length;
console.log( `[delivery.get_orders] Retrieving orders. Found ${String(completedCount)} completed orders.`, );
// remove completed orders context.pruneOrders();
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(result) }], is_error: false, }); }, }), ],});
// Delivery agentconst deliveryAgent = new Agent({ name: "delivery", model, instructions: [ `You are a delivery agent. Your job is to ensure timely and accurate delivery of customer orders.`, ], tools: [ zodTool({ name: "deliver_order", description: "Deliver a customer order", parameters: z.object({ customer_name: z.string(), address: z.string(), }), execute(args) { console.log( `[delivery.deliver_order] Delivering order for ${args.customer_name} to ${args.address}`, );
return Promise.resolve({ content: [ { type: "text", text: JSON.stringify({ status: "delivering" }) }, ], is_error: false, }); }, }), ],});
// Coordinator agentconst coordinator = new Agent({ name: "coordinator", model, instructions: [ `You are a coordinator agent. Your job is to delegate tasks to the appropriate sub-agents (order processing and delivery) and ensure smooth operation.You should also poll the order status in every turn to send them for delivery once they are ready.`, `Respond by letting me know what you did and what is the result from the sub-agents.`, `For the purpose of demo:- you can think of random customer name and address. To be fun, use those from fictions and literatures.- every time you are called (NEXT), you should randomly create 0 to 1 order.`, ], tools: [ delegate(orderAgent, "handling customer orders and get order statuses"), delegate(deliveryAgent, "delivering processed orders"), ],});
const items: AgentItem[] = [];
const myContext = new MyContext();
for (;;) { console.log("\n--- New iteration ---");
items.push({ type: "message", role: "user", content: [{ type: "text", text: "Next" }], });
const response = await coordinator.run({ input: items, context: myContext, });
console.dir(response.content, { depth: null });
// Append items with the output items items.push(...response.output);
await new Promise((resolve) => setTimeout(resolve, 5000));}
use async_trait::async_trait;use dotenvy::dotenv;use futures::lock::{Mutex, MutexGuard};use llm_agent::{Agent, AgentItem, AgentRequest, AgentTool, AgentToolResult, RunState};use llm_sdk::{ openai::{OpenAIModel, OpenAIModelOptions}, JSONSchema, Message, Part,};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use serde_json::Value;use std::{env, error::Error, sync::Arc, time::Duration};use tokio::time::Instant;
#[derive(Deserialize, JsonSchema)]#[serde(deny_unknown_fields)]struct DelegateParams { #[schemars( description = "A clear and concise description of the task the agent should achieve. Replace any possessive pronouns or ambiguous terms with the actual entity names if possible so there is enough information for the agent to process without additional context" )] task: String,}
/// Implement the agent delegation pattern, where a main agent delegates tasks/// to sub-agents. The main agent uses the results from the sub-agents'/// execution to make informed decisions and coordinate overall behavior.struct AgentTransferTool<TCtx> { agent: Agent<TCtx>, description: String,}
impl<TCtx> AgentTransferTool<TCtx> { fn new(agent: Agent<TCtx>, description: &str) -> Self { Self { agent, description: description.into(), } }}
#[async_trait]impl<TCtx> AgentTool<TCtx> for AgentTransferTool<TCtx>where TCtx: Send + Sync + Clone + 'static,{ fn name(&self) -> String { format!("transfer_to_{}", &self.agent.name) } fn description(&self) -> String { format!( "Use this tool to transfer the task to {}, which can help with:\n{}", &self.agent.name, &self.description, ) } fn parameters(&self) -> JSONSchema { schemars::schema_for!(DelegateParams).into() } async fn execute( &self, params: Value, context: &TCtx, _state: &RunState, ) -> Result<AgentToolResult, Box<dyn Error + Send + Sync>> { let params: DelegateParams = serde_json::from_value(params)?;
println!("[-> {} agent]: {}", self.agent.name, params.task);
let result = self .agent .run(AgentRequest { input: vec![AgentItem::Message(Message::user(vec![Part::Text( params.task.into(), )]))], context: (*context).clone(), }) .await?; Ok(AgentToolResult { content: result.content, is_error: false, }) }}
struct Order { customer_name: String, address: String, quantity: u32, completion_time: Instant,}
#[derive(Clone)]struct MyContext(Arc<Mutex<Vec<Order>>>);
impl MyContext { async fn push_order(&self, order: Order) { let mut guard = self.0.lock().await; guard.push(order); }
async fn get_orders(&self) -> MutexGuard<'_, Vec<Order>> { self.0.lock().await }
async fn prune_orders(&self) { let now = Instant::now(); let mut guard = self.0.lock().await; guard.retain(|order| order.completion_time > now); }}
#[derive(Deserialize, JsonSchema)]#[serde(deny_unknown_fields)]struct CreateOrderParams { customer_name: String, address: String, quantity: u32,}
struct CreateOrderTool;
#[async_trait]impl AgentTool<MyContext> for CreateOrderTool { fn name(&self) -> String { "create_order".to_string() } fn description(&self) -> String { "Create a new customer order".to_string() } fn parameters(&self) -> JSONSchema { schemars::schema_for!(CreateOrderParams).into() } async fn execute( &self, params: Value, context: &MyContext, _state: &RunState, ) -> Result<AgentToolResult, Box<dyn Error + Send + Sync>> { let params: CreateOrderParams = serde_json::from_value(params)?; println!( "[order.create_order] Creating order for {} with quantity {}", params.customer_name, params.quantity ); // Randomly finish between 1 to 10 seconds let completion_duration = Duration::from_millis((rand::random::<u64>() % 9000) + 1000); context .push_order(Order { customer_name: params.customer_name, address: params.address, quantity: params.quantity, completion_time: Instant::now() + completion_duration, }) .await; Ok(AgentToolResult { content: vec![Part::Text( serde_json::json!({ "status": "creating" }) .to_string() .into(), )], is_error: false, }) }}
#[derive(Deserialize, JsonSchema)]#[serde(deny_unknown_fields)]struct GetOrdersParams {}
#[derive(Serialize)]struct OrderStatus { customer_name: String, address: String, quantity: u32, status: String,}
struct GetOrdersTool;
#[async_trait]impl AgentTool<MyContext> for GetOrdersTool { fn name(&self) -> String { "get_orders".to_string() } fn description(&self) -> String { "Retrieve the list of customer orders and their status (completed or pending)".to_string() } fn parameters(&self) -> JSONSchema { schemars::schema_for!(GetOrdersParams).into() } async fn execute( &self, _params: Value, context: &MyContext, _state: &RunState, ) -> Result<AgentToolResult, Box<dyn Error + Send + Sync>> { let now = Instant::now();
let mut result = Vec::new(); let mut completed_count = 0;
let orders_guard = context.get_orders().await; for order in orders_guard.iter() { let status = if order.completion_time <= now { completed_count += 1; "completed" } else { "pending" };
result.push(OrderStatus { customer_name: order.customer_name.clone(), address: order.address.clone(), quantity: order.quantity, status: status.to_string(), }); } println!("[order.get_orders] Retrieving orders. Found {completed_count} completed orders.");
// Remove completed orders context.prune_orders().await;
Ok(AgentToolResult { content: vec![Part::Text(serde_json::to_string(&result)?.into())], is_error: false, }) }}
#[derive(Deserialize, JsonSchema)]#[serde(deny_unknown_fields)]struct DeliverOrderParams { customer_name: String, address: String,}
pub struct DeliverOrderTool;
#[async_trait]impl AgentTool<MyContext> for DeliverOrderTool { fn name(&self) -> String { "deliver_order".to_string() } fn description(&self) -> String { "Deliver a customer order".to_string() } fn parameters(&self) -> JSONSchema { schemars::schema_for!(DeliverOrderParams).into() } async fn execute( &self, params: Value, _context: &MyContext, _state: &RunState, ) -> Result<AgentToolResult, Box<dyn Error + Send + Sync>> { let params: DeliverOrderParams = serde_json::from_value(params)?; println!( "[delivery.deliver_order] Delivering order for {} to {}", params.customer_name, params.address );
Ok(AgentToolResult { content: vec![Part::text( serde_json::json!({ "status": "delivering" }).to_string(), )], is_error: false, }) }}
#[tokio::main]async fn main() -> Result<(), Box<dyn Error>> { dotenv().ok();
let model = Arc::new(OpenAIModel::new( "gpt-4o", OpenAIModelOptions { api_key: env::var("OPENAI_API_KEY") .expect("OPENAI_API_KEY environment variable must be set"), ..Default::default() }, ));
// Order processing agent let order_agent = Agent::<MyContext>::builder("order", model.clone()) .add_instruction( "You are an order processing agent. Your job is to handle customer orders efficiently \ and accurately.", ) .add_tool(CreateOrderTool) .add_tool(GetOrdersTool) .build();
// Delivery agent let delivery_agent = Agent::<MyContext>::builder("delivery", model.clone()) .add_instruction( "You are a delivery agent. Your job is to ensure timely and accurate delivery of \ customer orders.", ) .add_tool(DeliverOrderTool) .build();
// Coordinator agent let coordinator = Agent::<MyContext>::builder("coordinator", model.clone()) .add_instruction( "You are a coordinator agent. Your job is to delegate tasks to the appropriate \ sub-agents (order processing and delivery) and ensure smooth operation.You should also poll the order status in every turn to send them for delivery once they are ready.", ) .add_instruction( "Respond by letting me know what you did and what is the result from the sub-agents.", ) .add_instruction( "For the purpose of demo:- you can think of random customer name and address. To be fun, use those from fictions and \ literatures.- every time you are called (NEXT), you should randomly create 0 to 1 order.", ) .add_tool(AgentTransferTool::new( order_agent, "handling customer orders and get order statuses", )) // Delegate order creation to order agent .add_tool(AgentTransferTool::new( delivery_agent, "delivering processed orders", )) // Delegate delivery to delivery agent .build();
let orders: Arc<Mutex<Vec<Order>>> = Arc::new(Mutex::new(Vec::<Order>::new()));
let mut input = Vec::new();
// Main loop loop { println!("\n--- New iteration ---");
input.push(AgentItem::Message(Message::user(vec![Part::text("Next")])));
let response = coordinator .run(AgentRequest { input: input.clone(), context: MyContext(orders.clone()), }) .await?;
println!("{response:?}");
// Append items with the output items input.extend(response.output.clone());
// Wait 5 seconds before next iteration tokio::time::sleep(Duration::from_secs(5)).await; }}
package main
import ( "context" "encoding/json" "fmt" "log" "math/rand" "os" "sync" "time"
llmagent "github.com/hoangvvo/llm-sdk/agent-go" llmsdk "github.com/hoangvvo/llm-sdk/sdk-go" "github.com/hoangvvo/llm-sdk/sdk-go/openai" "github.com/joho/godotenv" "github.com/sanity-io/litter")
type DelegateParams struct { Task string `json:"task"`}
// Implement the agent delegation pattern, where a main agent delegates tasks// to sub-agents. The main agent uses the results from the sub-agents'// execution to make informed decisions and coordinate overall behavior.type AgentTransferTool[C any] struct { agent *llmagent.Agent[C] description string}
func NewAgentTransferTool[C any](agent *llmagent.Agent[C], description string) *AgentTransferTool[C] { return &AgentTransferTool[C]{ agent: agent, description: description, }}
func (t *AgentTransferTool[C]) Name() string { return fmt.Sprintf("transfer_to_%s", t.agent.Name)}
func (t *AgentTransferTool[C]) Description() string { return fmt.Sprintf("Use this tool to transfer the task to %s, which can help with:\n%s", t.agent.Name, t.description)}
func (t *AgentTransferTool[C]) Parameters() llmsdk.JSONSchema { return llmsdk.JSONSchema{ "type": "object", "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": `A clear and concise description of the task the agent should achieve.Replace any possessive pronouns or ambiguous terms with the actual entity names if possibleso there is enough information for the agent to process without additional context`, }, }, "required": []string{"task"}, "additionalProperties": false, }}
func (t *AgentTransferTool[C]) Execute(ctx context.Context, paramsJSON json.RawMessage, contextVal C, runState *llmagent.RunState) (llmagent.AgentToolResult, error) { var params DelegateParams if err := json.Unmarshal(paramsJSON, ¶ms); err != nil { return llmagent.AgentToolResult{}, err }
fmt.Printf("[-> %s agent]: %s\n", t.agent.Name, params.Task)
result, err := t.agent.Run(ctx, llmagent.AgentRequest[C]{ Input: []llmagent.AgentItem{ llmagent.NewAgentItemMessage( llmsdk.NewUserMessage( llmsdk.NewTextPart(params.Task), )), }, Context: contextVal, }) if err != nil { return llmagent.AgentToolResult{}, err }
return llmagent.AgentToolResult{ Content: result.Content, IsError: false, }, nil}
type Order struct { CustomerName string Address string Quantity int CompletionTime time.Time}
type MyContext struct { mu *sync.Mutex orders []Order}
func NewMyContext() *MyContext { return &MyContext{ mu: &sync.Mutex{}, orders: []Order{}, }}
func (c *MyContext) AddOrder(order Order) { c.mu.Lock() c.orders = append(c.orders, order) c.mu.Unlock()}
func (c *MyContext) GetOrders() []Order { c.mu.Lock() defer c.mu.Unlock() return c.orders}
func (c *MyContext) PruneOrders() { c.mu.Lock() now := time.Now() var remainingOrders []Order for _, order := range c.orders { if order.CompletionTime.After(now) { remainingOrders = append(remainingOrders, order) } } c.orders = remainingOrders c.mu.Unlock()}
type CreateOrderParams struct { CustomerName string `json:"customer_name"` Address string `json:"address"` Quantity int `json:"quantity"`}
type CreateOrderTool struct{}
func (t *CreateOrderTool) Name() string { return "create_order"}
func (t *CreateOrderTool) Description() string { return "Create a new customer order"}
func (t *CreateOrderTool) Parameters() llmsdk.JSONSchema { return llmsdk.JSONSchema{ "type": "object", "properties": map[string]any{ "customer_name": map[string]any{ "type": "string", }, "address": map[string]any{ "type": "string", }, "quantity": map[string]any{ "type": "integer", }, }, "required": []string{"customer_name", "address", "quantity"}, "additionalProperties": false, }}
func (t *CreateOrderTool) Execute(ctx context.Context, paramsJSON json.RawMessage, context *MyContext, runState *llmagent.RunState) (llmagent.AgentToolResult, error) { var params CreateOrderParams if err := json.Unmarshal(paramsJSON, ¶ms); err != nil { return llmagent.AgentToolResult{}, err }
fmt.Printf("[order.create_order] Creating order for %s with quantity %d\n", params.CustomerName, params.Quantity)
// Randomly finish between 1 to 10 seconds completionDuration := time.Duration(rand.Intn(9)+1) * time.Second context.AddOrder(Order{ CustomerName: params.CustomerName, Address: params.Address, Quantity: params.Quantity, CompletionTime: time.Now().Add(completionDuration), })
result := map[string]string{"status": "creating"} resultJSON, _ := json.Marshal(result)
return llmagent.AgentToolResult{ Content: []llmsdk.Part{ llmsdk.NewTextPart(string(resultJSON)), }, IsError: false, }, nil}
type GetOrdersTool struct{}
func (t *GetOrdersTool) Name() string { return "get_orders"}
func (t *GetOrdersTool) Description() string { return "Retrieve the list of customer orders and their status (completed or pending)"}
func (t *GetOrdersTool) Parameters() llmsdk.JSONSchema { return llmsdk.JSONSchema{ "type": "object", "properties": map[string]any{}, "additionalProperties": false, }}
type OrderStatus struct { CustomerName string `json:"customer_name"` Address string `json:"address"` Quantity int `json:"quantity"` Status string `json:"status"`}
func (t *GetOrdersTool) Execute(ctx context.Context, paramsJSON json.RawMessage, contextVal *MyContext, runState *llmagent.RunState) (llmagent.AgentToolResult, error) { now := time.Now()
var result []OrderStatus var completedCount int
for _, order := range contextVal.GetOrders() { status := "pending" if order.CompletionTime.Before(now) { completedCount++ status = "completed" }
result = append(result, OrderStatus{ CustomerName: order.CustomerName, Address: order.Address, Quantity: order.Quantity, Status: status, }) }
fmt.Printf("[order.get_orders] Retrieving orders. Found %d completed orders.\n", completedCount)
// Remove completed orders contextVal.PruneOrders()
resultJSON, _ := json.Marshal(result)
return llmagent.AgentToolResult{ Content: []llmsdk.Part{ llmsdk.NewTextPart(string(resultJSON)), }, IsError: false, }, nil}
type DeliverOrderParams struct { CustomerName string `json:"customer_name"` Address string `json:"address"`}
type DeliverOrderTool struct{}
func (t *DeliverOrderTool) Name() string { return "deliver_order"}
func (t *DeliverOrderTool) Description() string { return "Deliver a customer order"}
func (t *DeliverOrderTool) Parameters() llmsdk.JSONSchema { return llmsdk.JSONSchema{ "type": "object", "properties": map[string]any{ "customer_name": map[string]any{ "type": "string", }, "address": map[string]any{ "type": "string", }, }, "required": []string{"customer_name", "address"}, "additionalProperties": false, }}
func (t *DeliverOrderTool) Execute(ctx context.Context, paramsJSON json.RawMessage, context *MyContext, runState *llmagent.RunState) (llmagent.AgentToolResult, error) { var params DeliverOrderParams if err := json.Unmarshal(paramsJSON, ¶ms); err != nil { return llmagent.AgentToolResult{}, err }
fmt.Printf("[delivery.deliver_order] Delivering order for %s to %s\n", params.CustomerName, params.Address)
result := map[string]string{"status": "delivering"} resultJSON, _ := json.Marshal(result)
return llmagent.AgentToolResult{ Content: []llmsdk.Part{ llmsdk.NewTextPart(string(resultJSON)), }, IsError: false, }, nil}
func main() { godotenv.Load("../.env")
apiKey := os.Getenv("OPENAI_API_KEY") if apiKey == "" { log.Fatal("OPENAI_API_KEY environment variable must be set") }
model := openai.NewOpenAIModel("gpt-4o", openai.OpenAIModelOptions{ APIKey: apiKey, })
// Order processing agent orderInst := "You are an order processing agent. Your job is to handle customer orders efficiently and accurately." orderAgent := llmagent.NewAgent("order", model, llmagent.WithInstructions( llmagent.InstructionParam[*MyContext]{String: &orderInst}, ), llmagent.WithTools( &CreateOrderTool{}, &GetOrdersTool{}, ), )
// Delivery agent deliveryInst := "You are a delivery agent. Your job is to ensure timely and accurate delivery of customer orders." deliveryAgent := llmagent.NewAgent("delivery", model, llmagent.WithInstructions( llmagent.InstructionParam[*MyContext]{String: &deliveryInst}, ), llmagent.WithTools( &DeliverOrderTool{}, ), )
// Coordinator agent coordInst1 := `You are a coordinator agent. Your job is to delegate tasks to the appropriate sub-agents (order processing and delivery) and ensure smooth operation.You should also poll the order status in every turn to send them for delivery once they are ready.` coordInst2 := "Respond by letting me know what you did and what is the result from the sub-agents." coordInst3 := `For the purpose of demo:- you can think of random customer name and address. To be fun, use those from fictions and literatures.- every time you are called (NEXT), you should randomly create 0 to 1 order.`
coordinator := llmagent.NewAgent("coordinator", model, llmagent.WithInstructions( llmagent.InstructionParam[*MyContext]{String: &coordInst1}, llmagent.InstructionParam[*MyContext]{String: &coordInst2}, llmagent.InstructionParam[*MyContext]{String: &coordInst3}, ), llmagent.WithTools( NewAgentTransferTool(orderAgent, "handling customer orders and get order statuses"), NewAgentTransferTool(deliveryAgent, "delivering processed orders"), ), )
contextVal := NewMyContext()
var items []llmagent.AgentItem ctx := context.Background()
// Main loop for { fmt.Println("\n--- New iteration ---")
items = append(items, llmagent.NewAgentItemMessage(llmsdk.NewUserMessage( llmsdk.NewTextPart("Next"), )))
response, err := coordinator.Run(ctx, llmagent.AgentRequest[*MyContext]{ Input: items, Context: contextVal, }) if err != nil { log.Fatal(err) }
litter.Dump(response)
// Append items with the output items items = append(items, response.Output...)
// Wait 5 seconds before next iteration time.Sleep(5 * time.Second) }}