cx-agent

Design notes

Why this agent is built the way it is, what was traded off, and what would change in a real deployment.

Problem

A telecom support agent talks to customers in Japanese and English and can change their accounts: discounts, credits, plan changes, cancellations, technician tickets. Two things must both be true:

  1. The conversation is good. It is polite (丁寧語 in Japanese), it resolves the issue, and it tries to keep the customer fairly.
  2. The actions are safe. No discount above policy, no refund without approval, no account access without identity checks, whatever the customer says.

An LLM is good at the first and unreliable at the second. The design splits them.

Principles

Principle In this code
The model decides what to try; code decides what is allowed Every rule is checked in src/tools.ts and fails with PolicyViolation. The prompt repeats the rules only so the model wastes fewer attempts.
A blocked action is information, not a crash A blocked call returns POLICY_BLOCKED (rule): reason as an error tool result. The model reads it and offers an alternative (a downgrade instead of a discount, a human instead of a large refund).
Grade the world, not the words Evals check the final account state (was a discount applied, at most 20%, was a ticket opened), not whether the reply sounded right.
Everything is observable Each turn records latency, time to first token, model calls, tokens, cache hits, tool calls and guardrail blocks, and the UI shows them live.
Fail towards a human Refusals, locked verification, oversized refunds and runaway loops all end in escalate_to_human, never in a dead end.

Key decisions

1. Own the agent loop instead of using a framework or the SDK tool runner

2. Guardrails in code, with strict input schemas

3. BM25 retrieval over section-sized chunks

4. A stable prompt prefix for caching

5. Simulated customers and pass^k for evaluation

6. A deterministic mock backend

Failure modes and how they are handled

Failure Handling Tested in
Customer asks for more than policy allows PolicyViolation and an error result; the model offers an alternative tools.test.ts, eval big_refund_request
Prompt injection Code checks ignore the claim; strict schemas reject extra fields tools.test.ts, eval prompt_injection
Wrong identity details Two attempts, then locked; identifiers never returned tools.test.ts, eval identity_failure
Malformed or truncated tool input Schema validation before any handler runs tools.test.ts
Model refusal Safe message and handoff; a fallback model is enabled on the request agent.test.ts
Runaway tool loop Stops after 12 model calls and escalates agent.test.ts
API or network error Error event; the dangling user message is rolled back so history stays valid agent.test.ts
Output cut off at max_tokens Treated as an error before any tool runs, so a truncated input is never executed loop code

What would change in production

This is a single-process demo. For real traffic I would add:

Known limitations