The first version of most AI features is not a system.
It is one giant prompt doing too many jobs.
It is supposed to understand the user, check account facts, retrieve policies, write a response, avoid legal risk, match the brand tone, and maybe decide whether to escalate. Then one edge case arrives — a refund request with a partially used subscription — and the prompt starts negotiating with itself.
That is usually when teams say, “We need better prompting.”
Often, the real problem is architectural.
A single prompt becomes a god object. It holds competing responsibilities, hidden assumptions, and constraints that are hard to test. Multi-agent orchestration is not about creating a mystical swarm of autonomous bots. It is about doing the boring, Laravel-style thing: breaking a large problem into bounded services, coordinating them with typed contracts, and using queues, events, validation, logging, and failure policies to keep the system honest.
Laravel is a good place to build this because it already gives you the pieces: service container, queues, batches, events, validation, HTTP client, rate limiting, caching, structured logging, and database persistence.
The hard part is not calling a model. The hard part is coordinating specialists safely.
TL;DR
- A giant prompt becomes fragile when it tries to be researcher, analyst, writer, reviewer, and policy engine at once.
- Model agents as bounded specialists with explicit inputs, outputs, tools, and permissions.
- Use a lightweight router to classify work, not to do the work.
- Coordinate through typed messages, not loose prompt fragments.
- Use Laravel’s container, queues, batches, validation, and logging to make orchestration operational.
- Give specialists permissioned tool adapters instead of implicit knowledge.
- Add budgets, timeouts, retries, and escalation paths from the beginning.
- Do not use multi-agent orchestration when one deterministic service or one simple prompt is enough.
📋 Table of Contents
- The Giant Prompt Is a God Object in Disguise
- 1. Define Specialists Around Capabilities, Not Vibe-Based Roles
- 2. Route With a Lightweight Classifier, Not Another Expert
- 3. Coordinate With Typed Messages, Not Prompt Fragments
- 4. Use Laravel’s Container to Wire the Specialist Graph
- 5. Fan Out Slow Specialists With Queues and Batches
- 6. Give Specialists Explicit Tools, Not Implicit Knowledge
- 7. Add Budgets, Timeouts, Retries, and Escalation
- 8. Trace Every Step Like a Distributed Request
- When to Use Multi-Agent Orchestration in Laravel
- Production Checklist
The Giant Prompt Is a God Object in Disguise
A giant prompt usually starts with good intentions.
You add a few instructions for tone. Then a section for billing rules. Then a section for support policy. Then safety rules. Then examples. Then edge cases. Then another section telling the model not to use the earlier section unless certain conditions are met.
Eventually, the prompt becomes a monolith.
It has the same problems as a monolithic class:
- Too many responsibilities.
- Hidden coupling.
- Fragile behavior changes.
- Difficult testing.
- Unclear ownership.
- Unexpected side effects.
- No clean way to replace one part.
If you change refund wording, technical support answers regress. If you add a compliance constraint, the agent becomes too cautious for simple questions. If you add brand voice examples, the model starts ignoring retrieved policy details.
Multi-agent orchestration gives you another option.
Instead of asking one prompt to do everything, you divide the work into specialists:
- A router decides what kind of request this is.
- A billing specialist fetches account facts.
- A policy specialist retrieves relevant rules.
- A response writer drafts user-facing text.
- A compliance reviewer checks forbidden claims.
- A support agent coordinator assembles the final result.
Some of these specialists may call a language model. Others may be deterministic PHP services. That is important: multi-agent does not mean every step must be an LLM call. In production, the best agent systems often mix model reasoning with ordinary code.
1. Define Specialists Around Capabilities, Not Vibe-Based Roles
Scenario:
A team creates a “research agent,” a “thinking agent,” and a “writing agent.” It sounds sophisticated, but the boundaries are unclear. The research agent starts making decisions. The writing agent invents facts. The thinking agent duplicates work done by the others.
Why it matters:
Agent boundaries should be based on capability and accountability, not vague cognitive labels.
A useful specialist answers questions like:
- What data can it access?
- What tools can it call?
- What decisions can it make?
- What must it not do?
- What output shape does it produce?
- Who reviews its output?
- How is it tested?
Start with a small contract.
<?php
declare(strict_types=1);
namespace App\Agents\Contracts;
interface Agent
{
public function name(): string;
public function handle(AgentTask $task): AgentResult;
}
Then define the task and result as typed objects.
<?php
declare(strict_types=1);
namespace App\Agents\Contracts;
final readonly class AgentTask
{
public function __construct(
public string $executionId,
public string $tenantId,
public string $goal,
public array $input,
public AgentContext $context,
) {}
public function withInput(array $input): self
{
return new self(
$this->executionId,
$this->tenantId,
$this->goal,
$input,
$this->context,
);
}
}
<?php
declare(strict_types=1);
namespace App\Agents\Contracts;
final readonly class AgentResult
{
private function __construct(
public string $status,
public array $output,
public array $citations,
public ?string $error,
) {}
public static function ok(array $output, array $citations = []): self
{
return new self('ok', $output, $citations, null);
}
public static function failed(string $error): self
{
return new self('failed', [], [], $error);
}
public function isOk(): bool
{
return $this->status === 'ok';
}
}
A specialist can now be tested in isolation.
<?php
declare(strict_types=1);
namespace App\Agents\Billing;
use App\Agents\Contracts\Agent;
use App\Agents\Contracts\AgentContext;
use App\Agents\Contracts\AgentResult;
use App\Agents\Contracts\AgentTask;
final class BillingFactsAgent implements Agent
{
public function __construct(
private OrderLookupService $orders,
) {}
public function name(): string
{
return 'billing_facts';
}
public function handle(AgentTask $task): AgentResult
{
$orderId = $task->input['order_id'] ?? null;
if (! is_string($orderId)) {
return AgentResult::failed('Missing order_id.');
}
try {
$order = $this->orders->findForTenant($orderId, $task->tenantId);
return AgentResult::ok([
'order' => [
'id' => $order->id,
'status' => $order->status,
'total_cents' => $order->total_cents,
'currency' => $order->currency,
'refundable' => $order->isRefundable(),
],
]);
} catch (\Throwable $e) {
report($e);
return AgentResult::failed('Unable to fetch billing facts.');
}
}
}
This agent does not write the final response. It does not decide refund policy. It does not improvise. It produces billing facts.
Why this works:
Each specialist has a narrow responsibility. You can replace the model, change the prompt, or swap the data source without redesigning the whole system.
💡 Practical note: If a specialist needs a long paragraph to explain what it does, its boundary is probably wrong.
2. Route With a Lightweight Classifier, Not Another Expert
Scenario:
Every user request goes through the full refund, technical support, billing, and compliance pipeline. Simple questions become slow and expensive. Complex questions still fail because the system never identified the real intent.
Why it matters:
Routing is a separate concern from execution. A router should classify the request, not solve it.
A good router can be partially deterministic. You do not need a model call for every obvious case.
<?php
declare(strict_types=1);
namespace App\Support\Routing;
enum SupportIntent: string
{
case Refund = 'refund';
case Technical = 'technical';
case AccountAccess = 'account_access';
case BillingQuestion = 'billing_question';
case Unknown = 'unknown';
}
<?php
declare(strict_types=1);
namespace App\Support\Routing;
final class SupportRouter
{
public function __construct(
private IntentClassifier $classifier,
) {}
public function route(string $message): SupportIntent
{
$normalized = mb_strtolower(trim($message));
if (str_contains($normalized, 'refund') || str_contains($normalized, 'charge')) {
return SupportIntent::Refund;
}
if (str_contains($normalized, 'password') || str_contains($normalized, 'login')) {
return SupportIntent::AccountAccess;
}
if (str_contains($normalized, 'error') || str_contains($normalized, 'bug')) {
return SupportIntent::Technical;
}
return $this->classifier->classify($message) ?? SupportIntent::Unknown;
}
}
The IntentClassifier can be anything: rules, embeddings, a small model, a vendor model, or a human-in-the-loop fallback.
<?php
declare(strict_types=1);
namespace App\Support\Routing;
interface IntentClassifier
{
public function classify(string $message): ?SupportIntent;
}
The important part is the boundary: the router returns an intent, not a final answer.
Once the intent is known, you can choose an orchestration path.
$intent = $router->route($request->message);
return match ($intent) {
SupportIntent::Refund => app(RefundSupportOrchestrator::class)->handle($task),
SupportIntent::Technical => app(TechnicalSupportOrchestrator::class)->handle($task),
SupportIntent::AccountAccess => app(AccountAccessOrchestrator::class)->handle($task),
SupportIntent::BillingQuestion => app(BillingQuestionOrchestrator::class)->handle($task),
SupportIntent::Unknown => AgentResult::failed('Unable to classify request.'),
};
Why this works:
You avoid paying the cost of every specialist on every request. You also make the system easier to debug: if the wrong workflow runs, the router is a natural first place to inspect.
Where teams get this wrong:
They make the router too powerful. The router should not call tools, write responses, or mutate state. Its job is to classify and hand off.
3. Coordinate With Typed Messages, Not Prompt Fragments
Scenario:
One agent passes a string to another agent. The second agent does not know which parts are facts, which parts are user claims, which parts are policy citations, and which parts are internal notes. It treats everything as equally true.
Why it matters:
Agents need to distinguish between data classes.
A user saying “I was charged twice” is not the same as a billing system confirming a duplicate charge. A retrieved policy snippet is not the same as an approved exception. An internal note is not the same as user-visible text.
Use a context envelope.
<?php
declare(strict_types=1);
namespace App\Agents\Contracts;
use Illuminate\Support\Arr;
final readonly class AgentContext
{
public function __construct(
public array $facts,
public array $userClaims,
public array $policyExcerpts,
public array $sources,
public array $permissions,
public string $visibility,
) {}
public function redactedFor(string $role): self
{
$facts = $this->facts;
if ($role !== 'support_admin') {
$facts = Arr::except($facts, [
'email',
'phone',
'billing_address',
'payment_method_last4',
]);
}
return new self(
$facts,
$this->userClaims,
$this->policyExcerpts,
$this->sources,
$this->permissions,
$this->visibility,
);
}
}
Now specialists receive structured context instead of a blob of text.
$context = new AgentContext(
facts: [
'order_status' => 'paid',
'refundable' => true,
],
userClaims: [
'User says they were charged twice.',
],
policyExcerpts: [
'policy_id: refund_rules_v3',
'Refunds are allowed within 30 days if usage is below threshold.',
],
sources: [
'billing_service',
'policy_store',
],
permissions: ['read_orders', 'read_policies'],
visibility: 'customer_support',
);
The final writer can then be instructed differently based on category:
- Verified facts can be stated.
- User claims need confirmation.
- Policy excerpts can be cited.
- Internal notes should not appear in customer-facing text.
Why this works:
You reduce the chance that one agent contaminates another with unverified information. You also make it easier to audit why a final response said what it said.
⚠️ Gotcha: Do not pass secrets, tokens, or excessive PII through agent context unless the specialist genuinely needs them.
4. Use Laravel’s Container to Wire the Specialist Graph
Scenario:
Your orchestrator manually creates agents, tools, repositories, HTTP clients, and prompt builders. Tests become painful. Every new dependency ripples through the constructor chain.
Why it matters:
Agent orchestration is still dependency management. Laravel’s service container is designed for this.
Define an interface for external model access so your specialists are not coupled to one vendor or one client.
<?php
declare(strict_types=1);
namespace App\Ai\Contracts;
interface LanguageModel
{
public function complete(string $systemPrompt, array $messages): string;
}
Then bind implementations in a service provider.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Ai\Contracts\LanguageModel;
use App\Ai\VendorLanguageModelAdapter;
use App\Agents\Billing\BillingFactsAgent;
use App\Agents\Billing\OrderLookupService;
use App\Agents\Policy\PolicyAgent;
use App\Agents\Policy\PolicyRepository;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
final class AiAgentsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(LanguageModel::class, function (Application $app) {
return new VendorLanguageModelAdapter(
apiKey: config('services.ai.key'),
model: config('services.ai.model'),
timeout: config('services.ai.timeout', 20),
);
});
$this->app->bind(BillingFactsAgent::class, function (Application $app) {
return new BillingFactsAgent(
$app->make(OrderLookupService::class),
);
});
$this->app->bind(PolicyAgent::class, function (Application $app) {
return new PolicyAgent(
$app->make(LanguageModel::class),
$app->make(PolicyRepository::class),
);
});
}
}
The exact adapter implementation depends on the model provider you use. The point is that your domain code depends on LanguageModel, not on a concrete SDK class scattered across the application.
An orchestrator can now receive specialists through constructor injection.
<?php
declare(strict_types=1);
namespace App\Agents\Support;
use App\Agents\Billing\BillingFactsAgent;
use App\Agents\Contracts\AgentResult;
use App\Agents\Contracts\AgentTask;
use App\Agents\Policy\PolicyAgent;
use App\Agents\Writer\ResponseWriterAgent;
final class RefundSupportOrchestrator
{
public function __construct(
private BillingFactsAgent $billing,
private PolicyAgent $policy,
private ResponseWriterAgent $writer,
) {}
public function handle(AgentTask $task): AgentResult
{
$billingResult = $this->billing->handle($task);
if (! $billingResult->isOk()) {
return $billingResult;
}
$policyTask = $task->withInput([
...$task->input,
'order' => $billingResult->output['order'] ?? null,
]);
$policyResult = $this->policy->handle($policyTask);
if (! $policyResult->isOk()) {
return $policyResult;
}
$writerTask = $task->withInput([
...$task->input,
'billing' => $billingResult->output,
'policy' => $policyResult->output,
'citations' => $policyResult->citations,
]);
return $this->writer->handle($writerTask);
}
}
Why this works:
The orchestration graph becomes explicit. You can swap agents in tests, decorate them with logging, or replace one implementation without changing the entire flow.
Practical note:
Do not register every agent as a singleton by default. If an agent carries request state, it should not be shared. Stateless agents can be shared safely.
5. Fan Out Slow Specialists With Queues and Batches
Scenario:
The billing lookup calls an external API. Policy retrieval searches a document store. The compliance reviewer calls another model. The user waits for all of it synchronously, and the request times out.
Why it matters:
Some agent steps are fast. Others are slow, expensive, or rate-limited. Treating them all as synchronous request work creates fragile endpoints.
Laravel’s queue and batch APIs are useful here.
Create a job that runs one specialist step.
<?php
declare(strict_types=1);
namespace App\Jobs\Agents;
use App\Agents\Contracts\AgentRegistry;
use App\Agents\Contracts\AgentResult;
use App\Agents\Contracts\AgentTask;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
final class RunAgentStep implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public string $agentRunId,
public string $agentName,
public AgentTask $task,
) {}
public function handle(
AgentRegistry $registry,
AgentStepRepository $steps,
): void {
$agent = $registry->resolve($this->agentName);
$result = $agent->handle($this->task);
$steps->record(
agentRunId: $this->agentRunId,
agentName: $this->agentName,
result: $result,
);
if (! $result->isOk()) {
$this->fail(new RuntimeException($result->error ?? 'Agent step failed.'));
}
}
}
A simple registry can map names to agent classes.
<?php
declare(strict_types=1);
namespace App\Agents\Contracts;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
final class AgentRegistry
{
/**
* @param array<string, class-string<Agent>> $agents
*/
public function __construct(
private array $agents,
private Container $container,
) {}
public function resolve(string $name): Agent
{
if (! isset($this->agents[$name])) {
throw new InvalidArgumentException("Unknown agent: {$name}");
}
return $this->container->make($this->agents[$name]);
}
}
Then fan out independent steps with a batch.
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$run = AgentRun::create([
'tenant_id' => $tenantId,
'intent' => $intent->value,
'status' => 'running',
]);
Bus::batch([
new RunAgentStep($run->id, 'billing_facts', $task),
new RunAgentStep($run->id, 'policy_retrieval', $task),
])
->then(function (Batch $batch) use ($run) {
FinalizeSupportResponse::dispatch($run->id);
})
->catch(function (Batch $batch, \Throwable $e) use ($run) {
EscalateAgentRun::dispatch($run->id, $e->getMessage());
})
->onQueue('agents')
->dispatch();
This structure works well when:
- Steps are independent.
- Some steps are slow.
- You can finalize asynchronously.
- The user can receive a “processing” state.
- You need retries and failure visibility.
It is not always the right answer. If the full answer must be returned immediately and the specialists are fast, a synchronous orchestrator is simpler.
Why this works:
You get durable execution, retries, batch callbacks, and better visibility into long-running agent work.
🔍 Why this matters: Queues turn agent execution from a fragile HTTP request into an inspectable background process.
6. Give Specialists Explicit Tools, Not Implicit Knowledge
Scenario:
A specialist needs to check an order. Instead of calling a controlled service, the prompt says, “Use the order information if available.” The model then guesses field names, invents an order status, or uses stale context from earlier in the conversation.
Why it matters:
Tools are how agents touch real systems. If the tool boundary is vague, the agent boundary is fake.
Define tools as adapters with schemas and validation.
<?php
declare(strict_types=1);
namespace App\Agents\Tools\Contracts;
interface Tool
{
public function name(): string;
public function schema(): array;
public function run(array $input): array;
}
A Laravel-backed tool can use Eloquent, validation, and authorization.
<?php
declare(strict_types=1);
namespace App\Agents\Tools;
use App\Models\Order;
use App\Agents\Tools\Contracts\Tool;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
final class GetOrderTool implements Tool
{
public function name(): string
{
return 'get_order';
}
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'string'],
'user_id' => ['type' => 'string'],
],
'required' => ['order_id', 'user_id'],
'additionalProperties' => false,
];
}
public function run(array $input): array
{
$valid = Validator::make($input, [
'order_id' => ['required', 'string'],
'user_id' => ['required', 'string'],
])->validate();
$order = Order::query()
->whereKey($valid['order_id'])
->where('user_id', $valid['user_id'])
->first();
if (! $order) {
throw ValidationException::withMessages([
'order_id' => ['Order not found for this user.'],
]);
}
return [
'id' => $order->id,
'status' => $order->status,
'total_cents' => $order->total_cents,
'currency' => $order->currency,
'refundable' => $order->isRefundable(),
];
}
}
For external APIs, wrap Laravel’s HTTP client with timeouts and retries.
use Illuminate\Support\Facades\Http;
$response = Http::timeout(8)
->retry(2, 200)
->withToken($token)
->get("https://api.internal.example.com/v1/orders/{$orderId}");
if ($response->failed()) {
throw new RuntimeException('Order service request failed.');
}
return $response->json();
Why this works:
The agent does not “know” how to access the database or API directly. It calls a controlled adapter that validates input, enforces ownership, and returns a predictable shape.
Important production rule:
Read tools and write tools should not be treated the same. A tool that fetches an order is different from a tool that creates a refund, deletes a resource, or sends an email. Write tools need stronger confirmation, permissions, and audit trails.
7. Add Budgets, Timeouts, Retries, and Escalation
Scenario:
A specialist retries a failing model call indefinitely. Another agent makes ten tool calls to answer a simple question. A batch gets stuck because one step keeps timing out. The user sees nothing, and cost climbs.
Why it matters:
Agent systems need the same operational limits as any distributed system.
At minimum, define:
- Maximum attempts per step.
- Maximum total steps per run.
- Timeout per model or tool call.
- Maximum cost or token budget.
- Fallback behavior.
- Escalation path.
- Dead-letter handling.
A simple budget guard can prevent runaway loops.
<?php
declare(strict_types=1);
namespace App\Agents\Support;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
final class AgentStepGuard
{
public function ensureWithinBudget(
string $executionId,
string $agentName,
int $maxAttempts,
): void {
$key = "agent-budget:{$executionId}:{$agentName}";
$attempts = (int) Cache::get($key, 0) + 1;
Cache::put($key, $attempts, now()->addMinutes(10));
if ($attempts > $maxAttempts) {
throw new RuntimeException("Attempt budget exceeded for {$agentName}.");
}
}
}
Use it before running a step.
$guard->ensureWithinBudget($task->executionId, $agent->name(), maxAttempts: 3);
$result = $agent->handle($task);
For external calls, use timeouts aggressively.
Http::timeout(5)->retry(2, 250)->get($url);
For final user-facing behavior, decide what happens when the pipeline cannot complete.
Possible fallbacks:
- Ask the user for clarification.
- Return a safe generic response.
- Create a support ticket.
- Escalate to a human reviewer.
- Store the partial result and continue later.
- Disable the AI feature for that request.
Why this works:
You stop treating agent failure as an exceptional mystery. Failure becomes a designed state.
🚨 Production warning: If an agent can write data, send messages, or spend money, retries must be idempotent. Otherwise, retries become duplicate side effects.
8. Trace Every Step Like a Distributed Request
Scenario:
The final response is wrong. Nobody can tell whether the router chose the wrong intent, the billing agent returned stale data, the policy agent retrieved the wrong document, or the writer ignored the policy output.
Why it matters:
Multi-agent orchestration is a distributed system, even when it lives inside one Laravel application. You need traces.
Give every run an execution ID.
use Illuminate\Support\Str;
$executionId = (string) Str::uuid();
Log structured events around each step.
use Illuminate\Support\Facades\Log;
Log::info('agent.run.started', [
'execution_id' => $executionId,
'tenant_id' => $tenantId,
'intent' => $intent->value,
]);
Log::info('agent.step.started', [
'execution_id' => $executionId,
'agent' => $agent->name(),
]);
Log::info('agent.step.completed', [
'execution_id' => $executionId,
'agent' => $agent->name(),
'status' => $result->status,
'latency_ms' => $latencyMs,
]);
Persist step results in the database.
A useful agent_steps table might include:
idagent_run_idagent_namestatusinput_summaryoutput_summarycitationserrorlatency_mstokens_usedcreated_at
Do not store full sensitive payloads unless you have a retention and redaction policy. Store summaries, hashes, references, or redacted snapshots instead.
You should be able to answer:
- Which intent was selected?
- Which specialists ran?
- Which tools were called?
- Which tool inputs were validated?
- Which documents were retrieved?
- Which citations appeared in the final response?
- Where did the run fail?
- How long did each step take?
- Which step caused escalation?
Why this works:
Debugging stops being guesswork. You can reproduce failures, improve prompts, adjust routing, and add regression tests based on real traces.
When to Use Multi-Agent Orchestration in Laravel
Multi-agent orchestration is not automatically better. It adds moving parts. The right structure depends on the task.
| Situation | Recommended Structure |
|---|---|
| Simple text transformation | One small prompt or deterministic code |
| FAQ answering from one doc source | Single RAG pipeline |
| Form extraction or classification | One specialist plus validation |
| Customer support with billing and policy rules | Multiple specialists with router |
| Workflow that writes data or sends messages | Orchestrated specialists with tools and approval gates |
| Long-running analysis | Queue-based agent graph |
| High-risk compliance task | Specialists plus human review |
| Low-latency UI autocomplete | Avoid heavy orchestration |
A useful decision rule:
Use one prompt when the task has one responsibility, one source of truth, and low side-effect risk. Use orchestration when the task has multiple sources of truth, multiple permissions, multiple failure modes, or irreversible actions.
Do not build a multi-agent system just because the architecture sounds advanced. Build it because the problem has genuine boundaries.
Production Checklist
Before shipping a multi-agent Laravel system, check these:
- [ ] Each specialist has one clear capability.
- [ ] Agents receive typed tasks and return typed results.
- [ ] The router classifies requests but does not execute side effects.
- [ ] Context is split into facts, claims, policy excerpts, and internal notes.
- [ ] Sensitive data is redacted before entering unnecessary agent context.
- [ ] Tools validate input and enforce ownership.
- [ ] Write tools require stronger confirmation than read tools.
- [ ] Model clients are behind an application interface.
- [ ] Slow steps run through queues or batches.
- [ ] Every run has an execution ID.
- [ ] Every step is logged with structured metadata.
- [ ] Timeouts, retries, and attempt budgets are configured.
- [ ] Failures can escalate to a human or safe fallback.
- [ ] Agent runs and steps are persisted for auditability.
- [ ] The orchestration graph is tested independently of the final text.
The goal is not to replace one giant prompt with ten smaller prompts and hope for the best.
The goal is to turn AI behavior into ordinary Laravel engineering: explicit contracts, controlled dependencies, testable services, observable execution, and failure paths you can reason about.
Top comments (2)
Budget guards are the part I keep relearning. Four specialists fanned out, one retry storm and the cheapest request of the day became the most expensive. Do you cap per execution or per user?
Both — but for different reasons.
I’d treat the per-execution budget as the hard safety limit, and the per-user budget as the aggregate fairness/quota limit.
For example:
Per execution → max tokens / model cost / retries / wall-clock timePer user → max concurrent executions + rolling daily/monthly spendThe execution-level guard is what prevents the exact failure mode you described: four specialists fan out, a retry storm starts, and one run suddenly becomes disproportionately expensive.
The user-level guard handles the second-order problem: a single user launching hundreds of “individually valid” executions concurrently.
I’d also make the budget hierarchical:
User budget → Execution budget → Agent budget → Tool/retry budgetAnd importantly, retries should consume the same execution budget rather than getting a fresh allowance. Otherwise a retry storm can effectively bypass the original guard.
So if I had to pick only one: per-execution first. But in production, I’d use both.