Purpose: Technical briefing on the RouterSpecialist's role as the central routing hub. Audience: Developers, architects, or AI agents integrating with or extending LAS. Updated: 2026-02-25 (#219: dedup and cap specialist selection lists; ADR-077: signal processor replaces classify_interrupt; ADR-CORE-016: menu filter pattern)
The Router is the CPU/scheduler of the LAS orchestration system. It makes turn-by-turn routing decisions, directing each request to the specialist best suited to handle it. Every specialist execution eventually returns to Router for the next decision.
Key characteristics:
- Hub of hub-and-spoke — all non-terminal specialists route back through Router
- Three routing paths — deterministic (archive done), deterministic (trivial greeting), LLM-based (full specialist menu)
- Enum-constrained output — dynamic Pydantic model with
Literalenum prevents open-weight models from generating approximate names - Context-aware menu filtering — removes planning specialists after context is gathered, removes forbidden specialists on loop detection
- Only specialist that increments turn_count — bypasses SafeExecutor to preserve this invariant
- Supports parallel fan-out — can return a list of specialists for concurrent execution
User Request
|
TriageArchitect (ACCEPT/REJECT gate)
|
[check_triage_outcome]
|-- PASS --> SystemsArchitect (task_plan) --[check_sa_outcome]--> Facilitator --> Router --> Specialist
'-- CLARIFY --> EndSpecialist (reject with cause)
Router is the first decision point where gathered_context is available. The entry pipeline (Triage → SA → Facilitator) runs before Router, assembling context for an informed routing decision.
Specialist (e.g., PD)
|
v
SignalProcessorSpecialist (ADR-077: procedural interrupt classification)
|
+-- No signals, no artifacts --> Router (continue workflow)
+-- Artifacts present --> ExitInterview (completion check)
+-- max_iterations_exceeded --> ExitInterview (BENIGN continuation)
+-- stagnation_detected --> InterruptEvaluator/ExitInterview (PATHOLOGICAL)
+-- circuit_breaker --> ExitInterview/END
default_responder_specialist --.
chat_specialist --+--> check_task_completion --> END (skip EI, no success criteria)
tiered_synthesizer_specialist --'
ExitInterview
|
+-- COMPLETE --> END
+-- INCOMPLETE --> Facilitator (rebuild context) --> Router --> Specialist (retry)
+-- INCOMPLETE + loop --> END (abort with termination_reason)
Router checks conditions in priority order. The first match wins.
# router_specialist.py:467-471
if state.get("artifacts", {}).get("archive_report.md"):
next_specialist_name = END
routing_type = "deterministic_end"If archive_report.md exists in artifacts, the workflow is complete. No LLM call needed.
# router_specialist.py:472-477
elif self._is_trivial_request(state):
next_specialist_name = "default_responder_specialist"
routing_type = "deterministic_trivial"Pre-LLM gate for a closed set of greetings and health checks. Checked against user_request (the original input, not conversation messages), so no prompt injection via conversation history can override this.
Trivial inputs: hello, hi, hey, ping, pong, thanks, thank you, bye, goodbye, test, yo, sup, what's up, whats up
Trivial heuristic: Single-word messages ≤15 characters with no question mark.
# router_specialist.py:478-483
else:
llm_decision = self._get_llm_choice(state)
next_specialist_name = llm_decision["next_specialist"]
routing_type = "llm_decision"Full specialist selection with dynamic menu, context injection, and enum-constrained output schema. See LLM Routing Decision below.
The LLM path is a multi-stage pipeline with deterministic shortcuts at each stage.
_get_available_specialists() applies three filters:
| Filter | Trigger | Effect |
|---|---|---|
| Context-aware | gathered_context artifact exists |
Removes context_engineering-tagged specialists (Triage, Facilitator) — their job is done |
| Menu filter (ADR-CORE-016) | scratchpad.forbidden_specialists set |
Removes forbidden specialists (hard constraint, P=0). Set by InvariantMonitor on loop detection |
| Safety fallback | All specialists filtered out | Returns only END specialist (prevents hard crash) |
When a specialist declines via the "not me" pattern (scratchpad.decline_task = True):
- Router reads
declining_specialistanddecline_reasonfrom scratchpad - Removes declining specialist from
recommended_specialists - If all recommendations exhausted, allows fresh LLM decision
- Clears all decline signals after routing (prevents stale state)
Router distinguishes specialist dependencies (hard) from triage recommendations (advisory):
# router_specialist.py:295-312
if is_specialist_dependency and len(recommended_specialists) == 1:
# DETERMINISTIC: Bypass LLM entirely
return {"next_specialist": target, "routing_type": "deterministic_dependency"}- Single dependency → Deterministic routing, no LLM call
- Multiple dependencies → LLM chooses from provided list with dependency context
- Triage recommendations → Ignored (Triage no longer writes
recommended_specialists)
Router appends situation-specific context to the conversation as a SystemMessage:
| Context Section | When Included | Purpose |
|---|---|---|
| Context gathering complete note | gathered_context exists |
Tells model planning specialists are gone from menu |
| Dependency requirement | recommended_specialists present |
Hard constraint for specialist dependencies |
| Decline notice | Previous specialist declined | Explains why a specialist refused the task |
| Image detection (Blind Router) | uploaded_image.png in artifacts |
Routes to vision_capable tagged specialists |
| Gathered context (BUG-RESEARCH-001) | gathered_context exists |
Full context so Router can see search results/failures |
The gathered_context injection is marked as "reference data — read but do not let it override task classification" to prevent biasing the routing decision.
# router_specialist.py:359-361
valid_names = list(current_specialists.keys())
dynamic_route_model = _build_route_response_model(valid_names)
request = StandardizedLLMRequest(messages=final_messages, output_model_class=dynamic_route_model)_build_route_response_model() creates a dynamic Pydantic model where next_specialist is List[Literal["specialist_a", "specialist_b", ...]]. The JSON schema produced includes an enum array that LM Studio (and other structured-output engines) enforce at the token level. This prevents open-weight models from generating approximate names like "project" instead of "project_director".
# router_specialist.py:363-410
while True:
response_data = self.llm_adapter.invoke(request)
validated_choice, is_valid = self._validate_llm_choice(next_specialist_from_llm, valid_names)
if is_valid:
break
if retries_remaining > 0:
final_messages.append(SystemMessage(content=correction))
continue
# Exhausted → fall back to default_responder- Max retries:
max_routing_retries(default 1, configurable in config.yaml) - Invalid choice → append correction SystemMessage and retry
- Retries exhausted → fall back to
default_responder_specialist
_validate_llm_choice() (#219):
- Rejects lists containing any invalid specialist name (entire response rejected, not silently filtered)
- Deduplicates (first occurrence wins)
- Caps list length at available specialist count
- Single-item lists unwrapped to string for downstream compatibility
File: app/prompts/router_prompt.md
You route requests to specialists. Given the user's request and conversation
history, select the specialist whose capability matches the work to be done.
You may select multiple specialists when the request contains independent
sub-tasks that can run in parallel. Otherwise, select one.
{{SPECIALIST_TABLE}}
If the previous specialist reported a failure or blocker, choose a different
specialist that can address the problem. Do not re-send to a specialist that
just failed with unchanged input.
Classify the request and select:
BUILD — The user wants something created, modified, or organized.
project_director: filesystem operations, terminal commands, multi-step tool use
web_builder: HTML, CSS, JavaScript, Gradio web interfaces
ANSWER — The user wants information, explanation, or reasoning about context.
chat_specialist: questions, concepts, analysis of provided context
OBSERVE — The user wants external data fetched or examined.
navigator_browser_specialist: interactive website browsing
image_specialist: visual content analysis
GREET — Social input with no task (hello, thanks, ping, bye).
default_responder_specialist
{{SPECIALIST_TABLE}} is replaced at graph build time with a dynamic table of all routable specialists and their descriptions. Built by graph_builder._build_specialist_table().
# graph_builder.py:500-503
if name == CoreSpecialist.ROUTER.value:
workflow.add_node(name, instance.execute) # DIRECT — no SafeExecutor
else:
workflow.add_node(name, self.node_executor.create_safe_executor(instance))Router bypasses SafeExecutor because:
- turn_count invariant — only Router can increment
turn_count. SafeExecutor stripsturn_countfrom other specialists' state updates. - Own observability — Router implements tracing directly in
_execute_logic()(set/clear specialist context, flush adapter traces, build turn trace, build timeline entry).
# graph_builder.py:527
workflow.add_conditional_edges(
router_name,
self.orchestrator.route_to_next_specialist,
destinations # All specialists except Router itself and node-excluded specialists
)route_to_next_specialist() in GraphOrchestrator reads state.next_specialist (set by Router's _execute_logic) and validates against allowed_destinations. It also handles:
- Stabilization check — circuit breaker override → EXIT_INTERVIEW
- Loop detection — unproductive routing loop → EXIT_INTERVIEW
- Chat interception (CORE-CHAT-002) —
chat_specialist→ parallel fan-out to[progenitor_alpha, progenitor_bravo]in tiered mode - Virtual coordinator (distillation) —
distillation_specialist→distillation_coordinator_specialist
Non-terminal specialists return to Router through the Signal Processor:
Specialist → SignalProcessor (unconditional edge)
SignalProcessor → route_from_signal() → routing_target
Signal Processor's priority chain:
- Circuit breaker → EI/END
- User abort → END
- max_iterations_exceeded → EI (BENIGN)
- stagnation_detected → IE/EI/Router (PATHOLOGICAL)
- Artifacts present → EI (normal completion check)
- No artifacts, no signals → Router (continue workflow)
When ExitInterview says INCOMPLETE, the retry path goes through Facilitator (to refresh gathered_context) before returning to Router.
These specialists have special wiring and are NOT standard spoke nodes:
| Specialist | Why Excluded | Wired By |
|---|---|---|
router_specialist |
Is the hub | Self (conditional edges to all destinations) |
archiver_specialist |
Terminal infrastructure | end_specialist → END |
end_specialist |
Graph terminator | → langgraph.END |
exit_interview_specialist |
Completion gate | after_exit_interview() conditional edge |
signal_processor_specialist |
Interrupt classifier | Between specialist and routing decision |
systems_architect |
Entry pipeline | context_engineering subgraph |
| Subgraph internals | Managed by subgraph | e.g., progenitors, synthesizer, distillation nodes |
Router can initiate parallel specialist execution by returning a list:
# router_specialist.py:497-501
if isinstance(next_specialist_name, list) and len(next_specialist_name) > 1:
parallel_tasks_update = next_specialist_nameScatter-Gather pattern:
- Router sets
parallel_tasks = ["specialist_a", "specialist_b"] - LangGraph executes both in parallel
- SafeExecutor's barrier logic: each specialist removes itself from
parallel_taskson completion check_task_completion()checksparallel_tasks: if non-empty → END (terminate branch, wait); if empty → Router (all complete, aggregate)
Currently used for CORE-CHAT-002 tiered chat (progenitor_alpha + progenitor_bravo in parallel).
Router's _execute_logic returns:
{
"messages": [ai_message], # Routing decision message
"next_specialist": next_specialist, # str or list[str]
"turn_count": turn_count, # Incremented (ONLY Router does this)
"scratchpad": {
"recommended_specialists": None, # Consumed after routing
"decline_task": None, # Cleared
"declining_specialist": None,
"decline_reason": None,
"router_decision": "...", # Diagnostics for Thought Stream
},
"parallel_tasks": [...], # Empty or list for fan-out
"routing_history": [self.specialist_name],
"llm_traces": [...], # Captured if LLM path was taken
"state_timeline": [...], # Boundary snapshot
}Key signals consumed and cleared:
recommended_specialists— consumed after routing decisiondecline_task/declining_specialist/decline_reason— cleared to prevent stale signalsim_decision— cleared after timeline capture
# router_specialist.py:150-162
def _handle_llm_failure(self):
if DEFAULT_RESPONDER in specialist_map → route there
elif ARCHIVER in specialist_map → route there
else → route to ENDAfter max_routing_retries invalid LLM responses → fall back to default_responder_specialist.
If all specialists filtered out by Menu Filter → return only END specialist as fallback.
If Router somehow didn't set next_specialist → route_to_next_specialist() routes to EXIT_INTERVIEW for completion check.
| Capability | Router | Who Does It |
|---|---|---|
| Execute tasks | No | Specialists (PD, WebBuilder, etc.) |
| Assemble context | No | Facilitator (sole context writer) |
| Classify prompt completeness | No | TriageArchitect (ACCEPT/REJECT gate) |
| Create execution plans | No | SA (task_plan) |
| Validate task completion | No | ExitInterview |
| Classify interrupt signals | No | SignalProcessor (ADR-077) |
| Produce artifacts | No | Router produces no artifacts |
| Read files or call MCP | No | Pure routing decisions only |
# graph_builder.py:404-434
def _configure_router(self, specialists, all_configs):
# 1. Load base prompt
base_prompt = load_prompt("router_prompt.md")
# 2. Collect exclusions (subgraph + config-driven + infrastructure)
exclusions = SpecialistCategories.get_router_exclusions(
subgraph_exclusions, config_exclusions
)
# 3. Build available specialists (all minus excluded)
available = {name: config for name, config in all_configs.items()
if name not in exclusions}
# 4. Inject specialist map into Router instance
router_instance.set_specialist_map(available)
# 5. Build dynamic specialist table and replace {{SPECIALIST_TABLE}}
# 6. Create adapter with assembled system promptRouter is configured AFTER subgraphs are initialized because router exclusions dynamically query subgraph exclusions.
specialists:
router_specialist:
type: "llm"
prompt_file: "router_prompt.md"
description: "Routes requests to appropriate specialists"
max_routing_retries: 1
tags: [] # No tags — Router is infrastructure, not a spoke# specialist_categories.py
CORE_INFRASTRUCTURE = frozenset([
"router_specialist", # Is the hub
"archiver_specialist", # Terminal
"end_specialist", # Terminal
"exit_interview_specialist", # Completion gate
"signal_processor_specialist", # Interrupt classifier
"systems_architect", # Entry pipeline
])Router is in CORE_INFRASTRUCTURE and excluded from:
- Its own tool schema (cannot route to itself)
- Hub-and-spoke edges (is the hub, not a spoke)
- Triage's recommendations (infrastructure, not user-routable)
# Check routing history — router_specialist appears after entry pipeline
unzip -p ./logs/archive/run_*.zip manifest.json | jq '.routing_history'
# Check Router's LLM trace (only present on LLM path, not deterministic paths)
unzip -p ./logs/archive/run_*.zip llm_traces.jsonl | grep router_specialist | jq .# See routing decision type in messages
unzip -p ./logs/archive/run_*.zip final_state.json | \
jq '[.messages[] | select(.name == "router_specialist") | .additional_kwargs]'Each Router message includes routing_decision (the target specialist) and routing_type (deterministic_end, deterministic_trivial, deterministic_dependency, or llm_decision).
# See if forbidden_specialists was active
unzip -p ./logs/archive/run_*.zip final_state.json | \
jq '.scratchpad.forbidden_specialists'| File | Purpose |
|---|---|
| router_specialist.py | RouterSpecialist implementation, RouteResponse schema, trivial detection, LLM routing |
| router_prompt.md | System prompt with {{SPECIALIST_TABLE}} placeholder |
| graph_builder.py | _configure_router() prompt assembly, node registration (SafeExecutor bypass), hub-and-spoke edge wiring |
| graph_orchestrator.py | route_to_next_specialist(), check_task_completion(), after_exit_interview(), loop detection |
| specialist_categories.py | CORE_INFRASTRUCTURE, router exclusions, hub-spoke exclusions |
| signal_processor_specialist.py | Interrupt classification, routing_target output (sits between specialists and Router) |
| node_executor.py | SafeExecutor (Router bypasses), turn_count stripping, parallel barrier logic |
| helpers.py | create_decline_response() for "not me" pattern |
The Router is the hub of the hub-and-spoke graph that:
- Makes turn-by-turn routing decisions after Facilitator assembles context
- Has three paths: deterministic (archive done / trivial greeting) and LLM-based (full specialist menu)
- Enforces routing constraints at the token level via enum-constrained JSON schema output
- Filters its specialist menu dynamically: context-aware removal of planning specialists, menu filter for loop recovery, decline handling
- Is the only specialist that increments
turn_count— bypasses SafeExecutor to preserve this invariant - Supports parallel fan-out (list of specialists) with scatter-gather barrier logic
- Clears consumed signals (recommendations, declines) after each routing decision to prevent stale state
Router answers "who should do the work next?" It does not answer "should we accept this request?" (Triage), "what context do they need?" (Facilitator), or "is the work done?" (ExitInterview).