Files
call-center/docs/architecture/l1-l2-routing-engine.md
T
Hermes Agent 2243f305b8 feat: L1->L2 agent pool and routing engine for voice escalation
Replaces the hardcoded single-extension redirect for AI->human call
escalation with a real Agent Pool + Routing Engine:

- agents/escalations/routing_rules tables (migration 0031), asterisk_call_links
  gains tenant_id/current_level/required_skills_json/priority.
- services/routing_service/engine.py: level/tenant/skill filtered agent
  selection with atomic (CAS) reservation, no double-booking.
- routing-service: /agents CRUD + /internal/routing/reserve-agent and
  /internal/routing/release-agent.
- asterisk-bridge-service: voice_ai.request_handoff now uses the Routing
  Engine automatically for any queue_code configured in
  ASTERISK_QUEUE_LEVEL_MAP_JSON (all other queue_codes keep the existing
  static ASTERISK_TRANSFER_TARGET_MAP_JSON behavior unchanged); new
  POST /asterisk/live-calls/{call_id}/escalations entrypoint; agent is
  released back to AVAILABLE and the escalation closed when the call ends.

Targets the Tele2 Kazgaz DID +77476456048 (from-tele2-kazgaz context) as the
first queue wired to real L2 routing instead of AI-only.

Known gap (documented in docs/architecture/l1-l2-routing-engine.md):
automatic no-answer retry-to-next-agent needs a small, separately reviewed
dialplan change and is left for a follow-up MR rather than guessed at blind.

Tests: services/routing_service/engine.py covered by
tests/test_routing_engine.py (selection filtering, atomic reservation,
release); existing test_asterisk_bridge_service.py and
test_routing_service_pg_counter.py suites still pass unmodified.
2026-08-28 16:22:32 +05:00

3.6 KiB

L1 -> L2 Routing Engine (Phase 1)

Implements a real Agent Pool + Routing Engine for voice escalation from Voice AI (L1) to live L2 operators, replacing the previous static single-extension redirect target.

What changed

  • agents table (services/routing_service/engine.py): agents are rows with level, tenant_ids, skills, status, max_concurrent_calls, enabled - not a hardcoded list. Selection filters by level/tenant/skills and picks the longest-idle, least-loaded match. Reservation is an atomic UPDATE ... WHERE status='AVAILABLE' (rowcount-checked), so two concurrent calls can never reserve the same agent.
  • escalations table: one row per escalation attempt (from_level, to_level, reason_code, required_skills, priority, status).
  • routing_rules table: seeded data (not code) describing which level-to-level transitions are allowed; L3 and multi-tenant rules can be added later without code changes.
  • asterisk_call_links gained tenant_id, current_level, required_skills_json, priority.
  • POST /asterisk/live-calls/{call_id}/escalations (asterisk-bridge-service): explicit escalation entrypoint. voice_ai.request_handoff (used by the AI runtime) now also uses the Agent Pool automatically for any queue_code configured in ASTERISK_QUEUE_LEVEL_MAP_JSON; every other queue_code keeps the old static ASTERISK_TRANSFER_TARGET_MAP_JSON behavior unchanged.
  • routing-service gained /agents CRUD + /internal/routing/reserve-agent and /internal/routing/release-agent, called by asterisk-bridge-service the same way it already calls interaction-service/ai-voice-runtime-service (bridge-issued bearer token, ROUTING_SERVICE_URL).
  • Agent is released back to AVAILABLE when the call ends (process_call_ended in bridge_processing.py), which also closes the matching open escalations row.

Config for a given DID/queue

ASTERISK_QUEUE_LEVEL_MAP_JSON={"<queue_code>":{"level":"L2","tenant_id":"konturai"}}

Any queue_code not listed here keeps behaving exactly as before (static ASTERISK_TRANSFER_TARGET_MAP_JSON redirect) - this is additive, not a replacement of the legacy mechanism.

Seed agents rows via POST /agents (admin/supervisor). Level L2, extension must match a real SIP endpoint (currently only 2001/2002 exist on the telecom server).

Known gap - no-answer retry

Today the Routing Engine picks and reserves one agent and issues a single AMI Redirect to their extension; if that extension does not answer, the caller currently depends on whatever the dialplan does at that extension (unchanged from before this change). Automatic "agent didn't answer -> release and pick next" requires either:

  1. A small dialplan addition at the target extension's context: Dial with a fixed timeout and, on failure, a UserEvent back into the bridge (mirrors the existing MVPCCAIFallbackToHuman pattern) so asterisk_bridge_service can call /internal/routing/reserve-agent again with the failed agent in exclude_agent_ids, or
  2. Switching from Redirect to AMI Originate with Timeout and reacting to OriginateResponse.

Left out of Phase 1 deliberately: changing dialplan behavior without full visibility into what a given extension's context currently does on no-answer risks silently dropping a live customer call. Needs a follow-up MR with an explicit, reviewed dialplan change.

Not in Phase 1

  • L3 (technical specialists) - agents.level='L3' and routing_rules already support it; enabling it is a data-only follow-up once real L3 agents exist.
  • SLA config / callback (ТЗ sections 20-21).
  • Metrics (ТЗ section 38).