You read “multi-agent workflows” and built an orchestrator. Five specialist agents, a router on top, the whole org chart in code.
Now a simple refund question takes eight seconds and three API calls. Half the time the orchestrator answers directly instead of delegating. The other half it delegates, and the specialist replies with no idea which order the customer was asking about.
You added a layer of agents and got slower, pricier, and less reliable. The demo looked clever. Production doesn’t care.
A sub-agent is just an agent you let another agent call
Here’s the whole feature, stripped of the hype. In the Laravel AI SDK, you hand one agent to another by returning it from tools():
class CustomerSupportAgent implements Agent, HasTools
{
use Promptable;
public function instructions(): string
{
return 'You help customers with account, order, and billing questions. '
. 'Delegate refund eligibility questions to the refunds specialist.';
}
public function tools(): iterable
{
return [new RefundsAgent];
}
}
That’s it. RefundsAgent is a normal agent. Returned from tools(), it becomes something the support agent can call, like any other tool. The SDK wraps it for you.
So multi-agent isn’t a new way of building software. It’s a tool call where the tool happens to be another LLM. That reframe is the whole point, because it tells you when to reach for it.
A sub-agent earns its place when the specialist needs something the parent can’t share: its own model, its own provider, its own instructions, its own tools. If your “sub-agents” all run the same model with the same style of instructions and the same tools, you didn’t build a team. You built one agent and a latency tax.
When the split is real
Here’s a refunds specialist that actually justifies being its own agent:
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\{Agent, CanActAsTool, HasTools};
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
#[Provider(Lab::Anthropic)]
class RefundsAgent implements Agent, CanActAsTool, HasTools
{
use Promptable;
public function name(): string
{
return 'refunds_specialist';
}
public function description(): string
{
return 'Determine whether an order is eligible for a refund and explain the next step.';
}
public function instructions(): string
{
return 'You are a refunds specialist. Use the order details and the refund policy '
. 'to give concise eligibility guidance.';
}
public function tools(): iterable
{
return [new LookupOrder];
}
}
(LookupOrder is an ordinary tool you’d scaffold with php artisan make:tool LookupOrder to fetch order details.)
Look at what’s different from the parent. It runs on Anthropic instead of the default provider, set with one #[Provider] attribute. It has a tool the support agent doesn’t. Its instructions are narrow. That’s a real specialist, and splitting it out keeps the parent’s prompt small and its tool list short.
The CanActAsTool interface does quiet but important work. It sets the name() and description() the parent sees when deciding whether to delegate. Skip it, and the SDK falls back to the class basename plus a generic description, leaving the parent to guess what RefundsAgent does from its name alone. A sharp description() is how you make routing reliable. Write it like a tool description, not a code comment.
Gotcha 1: the sub-agent runs blind
This is the one that produces the “specialist has no idea which order they’re asking about” bug.
Each sub-agent invocation runs in isolation. It does not get the parent’s conversation history. If the customer spent three messages explaining that order #4471 arrived broken, the refunds agent sees none of it. It sees only the task the parent hands over.
So the parent’s job is to pass a complete, self-contained task. That’s why the support agent’s instruction tells it to delegate the question, and why the SDK’s own fallback even instructs the parent to “pass a clear, self-contained task description.” Treat every delegation like briefing a contractor who just walked in. Everything they need is in the brief, or it doesn’t exist.
Gotcha 2: you don’t decide when it delegates
You never call the sub-agent. The parent LLM does, when it judges the prompt warrants it. That’s a feature and a trap.
You influence the decision in exactly two places: the parent’s instructions() (“Delegate refund eligibility questions to the refunds specialist”) and the sub-agent’s description(). Leave either vague and the parent will answer refund questions itself, badly, instead of routing them.
And because the parent can call tools in a loop, a bad prompt can send it bouncing between agents. Cap it:
use Laravel\Ai\Attributes\MaxSteps;
#[MaxSteps(5)]
class CustomerSupportAgent implements Agent, HasTools
{
// ...
}
MaxSteps is your circuit breaker. Set it low enough that a confused orchestrator fails fast instead of burning twenty calls before you notice.
Gotcha 3: every hop is a full round-trip
A delegation looks like a function call. It isn’t. Each one is a full LLM round-trip, with its own latency and its own line on the bill. One parent plus one specialist is two model calls for a single user message. Nest an orchestrator over a manager over a researcher and one prompt becomes three round-trips stacked end to end.
That’s the math behind the eight-second refund. Before you nest agents, ask whether a single agent with two tools answers the same question in one call. Usually it does.
Prove the routing with a test
Non-deterministic delegation sounds untestable. It isn’t. Fake each agent independently and script the hand-off, the same way you’d test any AI feature.
use Laravel\Ai\Prompts\AgentPrompt;
use Laravel\Ai\Responses\Data\ToolCall;
public function test_support_agent_delegates_refund_questions(): void
{
CustomerSupportAgent::fake([
new ToolCall('call_1', 'refunds_specialist', ['task' => 'Check refund eligibility for order #4471']),
'You are eligible for a full refund on order #4471.',
]);
RefundsAgent::fake(['Order #4471 is within the 30-day window and qualifies.']);
$response = (new CustomerSupportAgent)->prompt('Can I get a refund on order #4471?');
RefundsAgent::assertPrompted(fn (AgentPrompt $prompt) => $prompt->contains('#4471'));
$this->assertSame('refunds_specialist', $response->toolCalls->first()->name);
$this->assertSame(
'Order #4471 is within the 30-day window and qualifies.',
$response->toolResults->first()->result,
);
}
The parent’s fake returns a ToolCall first, which triggers the delegation, then its final answer. The specialist’s fake returns its result. Then you assert the two things that matter: the parent routed to the right specialist, and the specialist received a task that carried the order number.
That last assertion is Gotcha 1 as a test. If a refactor drops the order number from the delegated task, it goes red before a customer ever gets a useless reply.
A test proves the wiring works. It doesn’t prove the parent routes correctly across a hundred real questions. For that you want an eval: a set of tickets each labeled with the specialist that should handle them, scored on how often the orchestrator picks right. Test the plumbing, eval the judgment.
What you can do now
Before, multi-agent was a word you reached for because it sounded like the next level. Now it’s a decision you make on purpose.
You know a sub-agent is just an agent behind a tool call, so you split one out only when the specialist needs its own model, tools, or instructions. You know it runs blind, so you brief it completely. You know the parent picks when to delegate, so you write the description that earns the call. And you can prove the hand-off in a test before it ships.
Most teams bolt on agents until the whole thing buckles under its own latency. You’ll add the second agent the day you can say exactly why the first one couldn’t do the job.