MASTERCLASS · NOV 15, 3:00 PM Agentic AI and Multi-Agent Systems Development — book your seat; limited seats left. PILOT BATCHES START NOV 23 Enquire or enrol soon — seats are filling fast. Call 9827710731 6 PILOT BATCHES Special discounts for pilot batches. Hurry up! Call 9827710731

LOGICRAFT.AI BLOG

Multi-Agent Systems in Production: When They Help and When They Fail

Multi-agent systems can be excellent for e-commerce workflows that need separation of concerns, such as routing a customer issue to order, refund and inventory specialists.

Multi-Agent Systems in Production: When They Help and When They Fail

Multi-agent systems can be excellent for e-commerce workflows that need separation of concerns, such as routing a customer issue to order, refund and inventory specialists. They fail when the problem is simple, agents share unclear state, or the system lacks validation, observability and human escalation. The MAST taxonomy (Cemri et al., "Why Do Multi-Agent LLM Systems Fail?") groups 14 failure modes into three categories: system-design and specification issues, inter-agent misalignment, and weak task verification.

Scope. Sections 1 and 2 are deterministic simulations that teach the mechanics. Section 3 is a tested service boundary with demo adapters that must be replaced by the retailer's authoritative order, inventory and policy services.

1. When multi-agent systems help

A customer writes: "My order #12345 arrived damaged, but I also want to know whether the blue version is back in stock." A single monolithic prompt would have to classify intent, fetch the order, apply refund policy, check inventory, decide on human approval and write a safe reply. As policies and integrations grow, that prompt becomes brittle, hard to test and hard to audit.

AgentResponsibility
TriageClassifies the request into supported intents (production: LLM with a strict JSON schema)
OrderRetrieves the order, but only if it belongs to the authenticated customer
RefundApplies refund policy deterministically and reports eligibility, never payment
InventoryChecks the exact variant the customer asked about; unknown stays unknown
ResponseWrites the reply only from validated agent outputs

Specialists pay off when tasks are independent, policy-bound, tool-heavy or need different permissions. OpenAI's orchestration guide makes the same point: start with one agent and add specialists only when they materially improve capability isolation, policy isolation, prompt clarity or trace legibility, splitting only when a branch truly needs different instructions, tools or policy.

Typed handoffs with honest outcomes

Each agent returns one of four explicit outcomes: success, retryable failure, non-retryable failure, or escalate to a human. That lets the orchestrator retry only transient failures, within an attempt cap and a time budget. Retrying every exception re-runs deterministic answers such as "order not found" or "window expired", and worse, converts them into a failed step. In a simulation where the refund agent raised an exception for an expired window, the orchestrator simply left the refund out and still reported "completed": the customer asked for a refund and the reply never mentioned it. Here every refund decision is reported. Other fixes:

Money is Decimal, with quantity times unit price; auto-approval limits are per currency, and a currency without a limit goes to review.

Ownership is checked against the authenticated customer, not just whether the order exists.

Inventory uses the variant the customer asked about rather than the first item on the order, and asks when none was given.

Customer text says "qualifies for a refund" and "we'll confirm once it is issued", never that money has moved.

# support_orchestrator.py -- deterministic teaching simulation of typed multi-agent handoffs
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
from decimal import Decimal
from enum import StrEnum
class Outcome(StrEnum):
    OK = "ok"
    RETRYABLE = "retryable_failure"          # transient: timeout, 429, 5xx
    NON_RETRYABLE = "non_retryable_failure"  # bad input, not found, policy "no"
    ESCALATE = "escalate_to_human"
@dataclass(frozen=True)
class AgentResult:
    agent: str
    outcome: Outcome
    data: dict = field(default_factory=dict)
    reason: str | None = None
ORDERS = {"12345": {"customer_id": "C-100", "status": "DELIVERED", "delivered_days_ago": 3,
                    "damage_reported": True, "currency": "USD",
                    "items": [{"sku": "TSHIRT-BLUE-M", "qty": 1, "unit_price": Decimal("25.00")}]}}
INVENTORY = {"TSHIRT-BLUE-M": {"in_stock": False, "restock_days": 7},
             "TSHIRT-BLUE-L": {"in_stock": True, "restock_days": 0}}
POLICY = {"damage_window_days": 14, "auto_limit": {"USD": Decimal("50.00")}}
def triage_agent(message: str) -> AgentResult:   # production: LLM + strict JSON schema
    t = message.casefold()
    intents = [name for name, rx in (("refund", r"\b(damaged?|broken|refund)\b"),
                                     ("inventory", r"\b(in stock|back in stock|available)\b"))
               if re.search(rx, t)]
    if not intents:
        return AgentResult("triage", Outcome.ESCALATE, reason="no supported intent")
    return AgentResult("triage", Outcome.OK, {"intents": intents})
def order_agent(order_id: str, customer_id: str) -> AgentResult:
    order = ORDERS.get(order_id)
    if order is None or order["customer_id"] != customer_id:   # ownership, not just existence
        return AgentResult("order", Outcome.NON_RETRYABLE, reason="order not available")
    return AgentResult("order", Outcome.OK, {"order": order})
def refund_agent(order: dict) -> AgentResult:    # deterministic policy, never an LLM
    if order["status"] != "DELIVERED" or not order["damage_reported"]:
        return AgentResult("refund", Outcome.OK, {"decision": "ineligible"})
    if order["delivered_days_ago"] > POLICY["damage_window_days"]:
        return AgentResult("refund", Outcome.OK, {"decision": "window_expired"})
    amount = sum(i["qty"] * i["unit_price"] for i in order["items"])
    limit = POLICY["auto_limit"].get(order["currency"])        # no limit for this currency
    decision = "eligible_auto" if limit is not None and amount <= limit else "needs_review"
    return AgentResult("refund", Outcome.OK, {"decision": decision, "amount": str(amount)})
def inventory_agent(sku: str | None) -> AgentResult:
    if not sku:          # never guess the variant from the first order line
        return AgentResult("inventory", Outcome.OK, {"state": "need_variant"})
    item = INVENTORY.get(sku)
    if item is None:
        return AgentResult("inventory", Outcome.OK, {"state": "unknown"})   # not "available"
    return AgentResult("inventory", Outcome.OK,
                       {"state": "available" if item["in_stock"] else "unavailable",
                        "restock_days": item["restock_days"]})
def run(agent: str, fn, *args, attempts: int = 3, budget_s: float = 2.0) -> AgentResult:
    """Retry only transient failures, within an attempt cap and a time budget."""
    deadline = time.monotonic() + budget_s
    for attempt in range(1, attempts + 1):
        try:
            result = fn(*args)
        except TimeoutError:
            result = AgentResult(agent, Outcome.RETRYABLE, reason="timeout")
        if result.outcome is not Outcome.RETRYABLE:
            return result
        if time.monotonic() + 0.05 * attempt > deadline:
            break
        time.sleep(0.05 * attempt)
    return AgentResult(agent, Outcome.ESCALATE, reason=f"{agent} unavailable after retries")
REFUND_TEXT = {"eligible_auto": "Your damaged item qualifies for a refund; we'll confirm once it is issued.",
               "needs_review": "A specialist will review your refund request.",
               "window_expired": "The damage-report window for this order has passed.",
               "ineligible": "This order doesn't meet the damaged-item refund criteria."}
def orchestrate(order_id: str, customer_id: str, message: str,
                requested_sku: str | None = None) -> dict:
    trace = [run("triage", triage_agent, message)]
    if trace[-1].outcome is not Outcome.OK:
        return {"status": "human_handoff", "trace": trace}
    trace.append(run("order", order_agent, order_id, customer_id))
    if trace[-1].outcome is not Outcome.OK:
        return {"status": "not_found" if trace[-1].outcome is Outcome.NON_RETRYABLE
                else "human_handoff", "trace": trace}
    order, intents, lines, status = trace[-1].data["order"], trace[0].data["intents"], [], "completed"
    if "refund" in intents:
        r = run("refund", refund_agent, order)
        trace.append(r)
        if r.outcome is not Outcome.OK or r.data["decision"] == "needs_review":
            status = "human_review"       # every refund outcome is reported, never dropped
        lines.append(REFUND_TEXT.get(r.data.get("decision"), REFUND_TEXT["needs_review"]))
    if "inventory" in intents:
        inv = run("inventory", inventory_agent, requested_sku)
        trace.append(inv)
        state = inv.data.get("state", "unknown")
        lines.append({"available": "That variant is in stock.",
                      "unavailable": f"That variant is out of stock (about {inv.data.get('restock_days')} days).",
                      "need_variant": "Which size and colour should we check?"}
                     .get(state, "We couldn't confirm stock right now."))
    # The response is assembled only from validated agent outputs, never free-form recall.
    return {"status": status, "customer_message": " ".join(lines), "trace": trace}

Production checklist. Start with a single agent or workflow and add agents only for clear isolation benefits. Give each agent an input and output schema, an allow-listed tool set and defined failure behaviour. Keep refund eligibility and limits in deterministic code, and validate every tool response before it enters shared context. Cap retries, latency and token spend with timeouts and circuit breakers. Trace each request with per-agent spans, test missing IDs, conflicting requests, prompt injection and empty tool responses, monitor cost, error, escalation and resolution quality, and keep a kill switch per specialist.

2. When multi-agent systems fail

The retailer now adds pricing, marketing, fraud, recommendation and response agents to every ticket. A customer asks for a refund; the system recommends another product, changes a discount, delays the refund pending fraud review and sends an inconsistent message. The problem is no longer capability but uncontrolled coordination.

Failure modeE-commerce example
Role ambiguityTwo agents both decide refund eligibility
Conflicting objectivesThe fraud agent blocks a refund the support agent already promised
Error propagationA wrong order total flows into refund, loyalty and notification agents
Shared-memory pollutionA fabricated stock level becomes the basis for a replacement offer
Tool schema driftThe inventory API renames in_stock; agents silently misread it
Runaway loopsAn agent keeps calling a failing shipping API with small prompt changes
Context decayA long conversation makes the agent forget refund-window rules

A failure you can see, and the guard that stops it

A failure demo has to fail visibly. If the approval limit is 100, a quantity-for-total bug that reports 2 instead of 50 approves exactly as the correct total would, so the bug never shows; and a stock lookup that hits an existing record never exercises the fabricating default. Here three $25 shirts total $75, above the $50 auto-limit, so the bug (total = 3) wrongly approves the refund. The SKU has no inventory record, and the default of True fabricates availability. The customer is promised both a refund and a replacement that cannot ship.

In the guarded version, only the orchestrator writes facts, and only after validation. A pricing mismatch escalates to a human, and an unknown stock state stays "unknown" all the way into the customer message. The demo shows both the rejection and the correct path (a $75 refund needing review, with stock unconfirmed).

# failure_demo.py -- the same task with unchecked shared state, then with orchestrator-owned facts
from __future__ import annotations
from decimal import Decimal
ITEMS = [{"sku": "TSHIRT-BLUE-XL", "qty": 3, "unit_price": Decimal("25.00")}]
AUTO_REFUND_LIMIT = Decimal("50.00")      # same sample policy as the orchestrator
STOCK = {"TSHIRT-BLUE-M": False}           # no record at all for the XL variant
def failing_workflow() -> dict:
    shared: dict = {}                        # every agent reads and writes anything
    shared["order_total"] = sum(i["qty"] for i in ITEMS)             # bug: 3, not 75.00
    shared["refund_approved"] = shared["order_total"] <= AUTO_REFUND_LIMIT  # wrong decision
    shared["stock"] = STOCK.get(ITEMS[0]["sku"], True)                 # fabricates "in stock"
    shared["message"] = ("Your refund has been approved. " if shared["refund_approved"] else "") + \
                        ("A replacement is on its way." if shared["stock"] else "")
    return shared
class Rejected(Exception):
    pass
def validated_total(items: list[dict], reported: Decimal) -> Decimal:
    expected = sum(i["qty"] * i["unit_price"] for i in items)
    if reported != expected:
        raise Rejected(f"pricing mismatch: expected {expected}, got {reported}")
    return expected
def guarded_workflow(pricing_agent) -> dict:
    """Only the orchestrator writes facts, and only after validation. Unknown stays unknown."""
    facts: dict = {}
    try:
        facts["order_total"] = validated_total(ITEMS, pricing_agent(ITEMS))
    except Rejected as exc:
        return {"status": "escalate_to_human", "reason": str(exc), "facts": facts}
    facts["refund"] = "needs_review" if facts["order_total"] > AUTO_REFUND_LIMIT else "eligible"
    stock = STOCK.get(ITEMS[0]["sku"])
    facts["stock"] = "unknown" if stock is None else ("available" if stock else "unavailable")
    message = {"needs_review": "A specialist will review your refund. ",
               "eligible": "Your refund is eligible and will be confirmed when issued. "}[facts["refund"]]
    message += {"unknown": "We couldn't confirm stock for that variant.",
                "available": "That variant is in stock.",
                "unavailable": "That variant is out of stock."}[facts["stock"]]
    return {"status": "completed", "facts": facts, "message": message}

Production checklist. Give every agent a one-sentence responsibility and merge overlaps. Make the orchestrator the only writer of authoritative state, with typed handoffs and JSON-schema validation. Keep refunds, payments, personal data and customer communication under deterministic control. Version prompts, tools, agent definitions and policies together, build evaluation sets for each agent and the end-to-end flow, and alert on repeated tool failures, validation failures, conflicting decisions and unusual cost. Prefer a single-agent or rule-based workflow unless multi-agent design measurably improves quality, safety or maintainability.

Practical rule. Use multiple agents for isolated capabilities, separate permissions, parallel work or clearer audit trails; avoid them when the workflow is a single decision, API call or deterministic rule. The winning production design is usually a few narrowly scoped agents, strict validation at every boundary, deterministic policy enforcement and automatic human escalation.

3. Production e-commerce reference implementation

The service resolves a support request read-only. The request flows from verified identity through an ownership-scoped order lookup, deterministic policy and the inventory adapter to a validated response. An LLM may help classify or draft, but it cannot set prices, authorise refunds or call payment APIs; refund execution is a separate idempotent command. The details that matter:

Enforces scopes. They were parsed from the token but never checked; the endpoint now requires support:read.

Keeps human review visible. A refund needing review was overwritten to "partial" whenever stock was unknown, silently dropping the review. Review now takes precedence.

Respects currency. A single 50.00 limit applied to every currency, so 3,000 JPY would never auto-approve while 49 GBP would. Limits are now per currency, and unknown currencies go to review.

Degrades safely. Only TimeoutError was caught, so a connection error or 5xx from inventory became a 500. Every adapter failure now maps to "unknown".

Removes misleading plumbing. Caller-supplied request IDs are length- and charset-checked before logging, and the accepted-but-unused Idempotency-Key header is gone. A read-only endpoint should not imply a guarantee it does not give.

Fails closed. Adapters are injected dependencies that return 503 until wired, so the demo data cannot ship by accident, and ownership is enforced in the repository query.

# app.py -- read-only support resolution: verified identity, ownership, deterministic policy
# Dependencies: fastapi, uvicorn[standard], pydantic>=2, pyjwt[crypto]
from __future__ import annotations
import logging
import os
import re
import uuid
from dataclasses import dataclass
from decimal import Decimal
from typing import Annotated, Literal, Protocol
import jwt
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field
log = logging.getLogger("commerce.support")
app = FastAPI(title="Commerce Support Orchestrator")
bearer = HTTPBearer(auto_error=False)
REQUEST_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
Decision = Literal["not_requested", "ineligible", "eligible_for_auto_refund", "eligible_for_review"]
Status = Literal["completed", "partial", "human_review", "not_found"]
class Principal(BaseModel):
    subject: str
    customer_id: str
def principal(cred: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> Principal:
    try:
        claims = jwt.decode(cred.credentials if cred else "", os.environ["JWT_PUBLIC_KEY"],
                            algorithms=["RS256"], issuer=os.environ["JWT_ISSUER"],
                            audience=os.environ["JWT_AUDIENCE"],
                            options={"require": ["exp", "iat", "iss", "aud", "sub"]})
    except jwt.PyJWTError:
        raise HTTPException(401, "invalid_access_token") from None
    if "support:read" not in str(claims.get("scope", "")).split():   # enforce, not just parse
        raise HTTPException(403, "insufficient_scope")
    if not isinstance(cid := claims.get("customer_id"), str) or not cid:
        raise HTTPException(401, "invalid_access_token")
    return Principal(subject=claims["sub"], customer_id=cid)
class SupportRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,64}$")
    message: str = Field(min_length=1, max_length=4000)
    requested_sku: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,64}$")
class InventoryResult(BaseModel):
    state: Literal["available", "unavailable", "unknown"]
    expected_restock_days: int | None = Field(default=None, ge=0, le=365)
class SupportResponse(BaseModel):
    request_id: str
    status: Status
    customer_message: str
    refund_decision: Decision
    inventory: InventoryResult | None = None
@dataclass(frozen=True)
class Order:
    order_id: str
    customer_id: str
    status: str
    delivered_days_ago: int | None
    damage_reported: bool
    currency: str
    total: Decimal
class Orders(Protocol):
    def get_owned(self, order_id: str, customer_id: str) -> Order | None: ...
class Inventory(Protocol):
    def check(self, sku: str, timeout_s: float) -> InventoryResult: ...
class RefundPolicy:
    """Eligibility only; executing a refund is a separate idempotent command."""
    def __init__(self, window_days: int = 14,
                 auto_limits: dict[str, Decimal] | None = None) -> None:
        self.window_days = window_days
        self.auto_limits = auto_limits or {"USD": Decimal("50.00"), "EUR": Decimal("45.00")}
    def evaluate(self, o: Order) -> tuple[Decision, str]:
        if not o.damage_reported or o.status != "DELIVERED":
            return "ineligible", "This order doesn't meet the damaged-item refund criteria."
        if o.delivered_days_ago is None or o.delivered_days_ago < 0:
            return "eligible_for_review", "A specialist needs to confirm the delivery details."
        if o.delivered_days_ago > self.window_days:
            return "ineligible", "The damage-report window for this order has passed."
        limit = self.auto_limits.get(o.currency)   # a USD limit must not apply to JPY totals
        if limit is not None and o.total <= limit:
            return "eligible_for_auto_refund", "Your refund request qualifies for automatic processing."
        return "eligible_for_review", "A specialist will review your refund request."
def get_orders() -> Orders:
    raise HTTPException(503, "order_service_unavailable")      # wire the real adapter here
def get_inventory() -> Inventory:
    raise HTTPException(503, "inventory_service_unavailable")
@app.middleware("http")
async def request_id(request: Request, call_next):
    supplied = request.headers.get("x-request-id", "")
    request.state.request_id = supplied if REQUEST_ID.fullmatch(supplied) else str(uuid.uuid4())
    response = await call_next(request)
    response.headers["X-Request-ID"] = request.state.request_id
    return response
@app.post("/v1/support/resolve", response_model=SupportResponse)
def resolve(body: SupportRequest, request: Request,
            who: Annotated[Principal, Depends(principal)],
            orders: Annotated[Orders, Depends(get_orders)],
            inventory: Annotated[Inventory, Depends(get_inventory)]):
    rid = request.state.request_id
    order = orders.get_owned(body.order_id, who.customer_id)   # ownership enforced in the query
    if order is None:
        return SupportResponse(request_id=rid, status="not_found", refund_decision="not_requested",
                               customer_message="We couldn't find that order on your account.")
    text = body.message.casefold()
    wants_refund = bool(re.search(r"\b(refund|damaged?|broken)\b", text))
    wants_stock = bool(re.search(r"\b(in stock|available|replacement)\b", text))
    decision: Decision = "not_requested"
    parts = [f"About order {order.order_id}:"]
    if wants_refund:
        decision, msg = RefundPolicy().evaluate(order)
        parts.append(msg)
    stock: InventoryResult | None = None
    if wants_stock:
        if not body.requested_sku:
            stock = InventoryResult(state="unknown")
            parts.append("Which size and colour should we check?")
        else:
            try:
                stock = inventory.check(body.requested_sku, timeout_s=2.0)
            except Exception:                      # timeout, 5xx, schema drift: all unknown
                log.warning("inventory_unavailable request_id=%s", rid)
                stock = InventoryResult(state="unknown")
            parts.append({"available": "That variant is in stock.",
                          "unavailable": "That variant is out of stock."}
                         .get(stock.state, "We couldn't confirm stock right now."))
    # Precedence matters: a refund needing review must not be downgraded to "partial"
    # just because stock was unknown.
    status: Status = ("human_review" if decision == "eligible_for_review"
                      else "partial" if stock and stock.state == "unknown" else "completed")
    log.info("support_resolved request_id=%s decision=%s status=%s", rid, decision, status)
    return SupportResponse(request_id=rid, status=status, customer_message=" ".join(parts),
                           refund_decision=decision, inventory=stock)

Before launch: back adapters with authoritative services or PostgreSQL using least-privilege roles, TLS and bounded pools. Implement refund execution as a separate command with a durable idempotency table (unique per customer and operation, storing request hash and result) plus a transactional outbox or provider idempotency key. Retry only transient errors with jittered backoff, a retry budget and an overall deadline. Validate the requested SKU against the catalogue and treat stale stock as unknown. Add OpenTelemetry traces without message bodies or tokens. Then complete threat modelling, scanning, penetration testing, privacy review and a staged rollout with a kill switch. Tests cover expired, wrong-audience and missing tokens, missing scope, cross-customer access, review precedence under an inventory outage, currency handling and request-ID sanitisation.

Back to all blogs