Coverage for agentos/core/task_router.py: 0%

158 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-05 23:00 +0800

1""" 

2AgentOS Task Router — Intelligent Multi-Model Task Routing 

3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 

4 

5Production-grade task routing engine that decides which language model 

6to use per-request based on: 

7 - Task complexity classification (simple/medium/complex) 

8 - Latency budget constraints 

9 - Cost optimization (cheapest model that meets quality bar) 

10 - Fallback chains (primary → fallback → degraded) 

11 - Model capability registry (tool-calling, vision, reasoning, etc.) 

12 

13Architecture: 

14 TaskClassifier → classify task complexity from prompt analysis 

15 ModelRegistry → register model capabilities and routing weights 

16 RouteDecision → immutable routing decision with reasoning 

17 TaskRouter → orchestrates classification + model selection 

18""" 

19 

20from __future__ import annotations 

21 

22import re 

23import time 

24from dataclasses import dataclass, field 

25from enum import Enum 

26from typing import Any, Callable, Dict, List, Optional, Set, Tuple 

27 

28from agentos.core.cost_tracker import CostTracker 

29 

30 

31# --------------------------------------------------------------------------- 

32# Task Complexity 

33# --------------------------------------------------------------------------- 

34 

35 

36class TaskComplexity(str, Enum): 

37 """Estimated complexity of a user task.""" 

38 SIMPLE = "simple" # Single-step, well-defined (translate, summarize) 

39 MEDIUM = "medium" # Multi-step reasoning (analysis, code review) 

40 COMPLEX = "complex" # Multi-agent orchestration, long-form generation 

41 

42 

43class TaskCategory(str, Enum): 

44 """Semantic category of the task.""" 

45 CHAT = "chat" 

46 CODE = "code" 

47 REASONING = "reasoning" 

48 CREATIVE = "creative" 

49 ANALYSIS = "analysis" 

50 TOOL_USE = "tool_use" 

51 VISION = "vision" 

52 TRANSLATION = "translation" 

53 

54 

55# --------------------------------------------------------------------------- 

56# Model Capability 

57# --------------------------------------------------------------------------- 

58 

59 

60@dataclass 

61class ModelSpec: 

62 """Specification of a model's capabilities and routing profile.""" 

63 model_id: str 

64 provider: str 

65 tier: str # "premium" / "standard" / "budget" / "fallback" 

66 max_tokens: int = 128000 

67 latency_category: str = "medium" # "fast" / "medium" / "slow" 

68 

69 # Capability flags 

70 supports_tool_calling: bool = False 

71 supports_vision: bool = False 

72 supports_reasoning: bool = False 

73 supports_code: bool = False 

74 supports_function_calling: bool = False 

75 

76 # Routing weights (higher = more likely to be selected for that category) 

77 category_weights: Dict[TaskCategory, float] = field(default_factory=dict) 

78 

79 # Quality bar: tasks of this complexity must use this model or better 

80 min_complexity: TaskComplexity = TaskComplexity.SIMPLE 

81 

82 # Cost consideration (relative cost multiplier, GPT-4o-mini = 1.0 baseline) 

83 cost_multiplier: float = 1.0 

84 

85 # Excluded task categories 

86 excluded_categories: Set[TaskCategory] = field(default_factory=set) 

87 

88 

89# Default model registry 

90DEFAULT_MODEL_SPECS: List[ModelSpec] = [ 

91 ModelSpec( 

92 model_id="gpt-4o", 

93 provider="openai", 

94 tier="premium", 

95 supports_tool_calling=True, 

96 supports_vision=True, 

97 supports_function_calling=True, 

98 min_complexity=TaskComplexity.COMPLEX, 

99 cost_multiplier=4.5, 

100 latency_category="medium", 

101 category_weights={ 

102 TaskCategory.REASONING: 0.9, 

103 TaskCategory.ANALYSIS: 0.85, 

104 TaskCategory.CODE: 0.8, 

105 TaskCategory.TOOL_USE: 0.95, 

106 }, 

107 ), 

108 ModelSpec( 

109 model_id="gpt-4o-mini", 

110 provider="openai", 

111 tier="standard", 

112 supports_tool_calling=True, 

113 supports_function_calling=True, 

114 min_complexity=TaskComplexity.MEDIUM, 

115 cost_multiplier=1.0, 

116 latency_category="fast", 

117 category_weights={ 

118 TaskCategory.CHAT: 1.0, 

119 TaskCategory.TRANSLATION: 0.9, 

120 TaskCategory.CODE: 0.7, 

121 }, 

122 ), 

123 ModelSpec( 

124 model_id="claude-sonnet-4-20250514", 

125 provider="anthropic", 

126 tier="premium", 

127 max_tokens=200000, 

128 supports_tool_calling=True, 

129 supports_reasoning=True, 

130 supports_code=True, 

131 min_complexity=TaskComplexity.COMPLEX, 

132 cost_multiplier=3.0, 

133 latency_category="medium", 

134 category_weights={ 

135 TaskCategory.CODE: 1.0, 

136 TaskCategory.REASONING: 0.95, 

137 TaskCategory.ANALYSIS: 0.9, 

138 TaskCategory.CREATIVE: 0.85, 

139 }, 

140 ), 

141 ModelSpec( 

142 model_id="claude-haiku-3-5-sonnet-20241022", 

143 provider="anthropic", 

144 tier="standard", 

145 supports_tool_calling=True, 

146 latency_category="fast", 

147 min_complexity=TaskComplexity.MEDIUM, 

148 cost_multiplier=0.8, 

149 category_weights={ 

150 TaskCategory.CHAT: 0.9, 

151 TaskCategory.TRANSLATION: 0.8, 

152 }, 

153 ), 

154 ModelSpec( 

155 model_id="deepseek-v3", 

156 provider="deepseek", 

157 tier="standard", 

158 supports_tool_calling=False, 

159 min_complexity=TaskComplexity.COMPLEX, 

160 cost_multiplier=0.25, 

161 latency_category="fast", 

162 category_weights={ 

163 TaskCategory.CODE: 0.85, 

164 TaskCategory.REASONING: 0.8, 

165 TaskCategory.CHAT: 0.7, 

166 }, 

167 ), 

168 ModelSpec( 

169 model_id="gemini-2.5-flash", 

170 provider="google", 

171 tier="standard", 

172 supports_tool_calling=True, 

173 supports_vision=True, 

174 min_complexity=TaskComplexity.MEDIUM, 

175 cost_multiplier=0.15, 

176 latency_category="fast", 

177 category_weights={ 

178 TaskCategory.CHAT: 0.85, 

179 TaskCategory.VISION: 0.95, 

180 TaskCategory.TRANSLATION: 0.7, 

181 }, 

182 ), 

183 ModelSpec( 

184 model_id="llama-4-maverick", 

185 provider="meta", 

186 tier="budget", 

187 min_complexity=TaskComplexity.SIMPLE, 

188 cost_multiplier=0.1, 

189 latency_category="fast", 

190 category_weights={ 

191 TaskCategory.CHAT: 0.6, 

192 }, 

193 ), 

194] 

195 

196 

197# --------------------------------------------------------------------------- 

198# Task Classifier 

199# --------------------------------------------------------------------------- 

200 

201 

202class TaskClassifier: 

203 """ 

204 Classify task complexity based on prompt heuristics. 

205 

206 Heuristics (no LLM call needed): 

207 - Length: > 500 tokens → MEDIUM+; > 2000 tokens → COMPLEX 

208 - Multi-step indicators: "first"/"then"/"next"/"finally" → MEDIUM+ 

209 - Agent keywords: "plan"/"orchestrate"/"coordinate"/"multi-agent" → COMPLEX 

210 - Code: code blocks, "function"/"class"/"import" → MEDIUM+ 

211 - Reasoning: "why"/"explain"/"analyze"/"compare" → MEDIUM+ 

212 - Tool use: "search"/"fetch"/"call"/"run"/"execute" → MEDIUM+ 

213 """ 

214 

215 # Token estimate: ~1.3 tokens per word (rough heuristic) 

216 TOKENS_PER_WORD = 1.3 

217 

218 SIMPLE_THRESHOLD_TOKENS = 500 

219 COMPLEX_THRESHOLD_TOKENS = 2000 

220 

221 MULTI_STEP_PATTERNS = [ 

222 re.compile(r"\b(first|then|next|finally|after that|step \d)\b", re.IGNORECASE), 

223 re.compile(r"\d+\.\s+\w+"), # Numbered list 

224 ] 

225 

226 AGENT_PATTERNS = [ 

227 re.compile(r"\b(plan|orchestrate|coordinate|multi.agent|dispatch|delegate)\b", re.IGNORECASE), 

228 ] 

229 

230 CODE_PATTERNS = [ 

231 re.compile(r"```"), 

232 re.compile(r"\b(def |class |import |function |const |let |var )", re.IGNORECASE), 

233 ] 

234 

235 REASONING_PATTERNS = [ 

236 re.compile(r"\b(analy[sz]e|explain|compare|contrast|evaluate|assess|why)\b", re.IGNORECASE), 

237 ] 

238 

239 TOOL_PATTERNS = [ 

240 re.compile(r"\b(search|fetch|call|run|execute|invoke|download|upload)\b", re.IGNORECASE), 

241 ] 

242 

243 def classify( 

244 self, 

245 prompt: str, 

246 available_tools: Optional[List[str]] = None, 

247 ) -> Tuple[TaskComplexity, List[TaskCategory]]: 

248 """ 

249 Classify a task from its prompt. 

250 

251 Returns (complexity, categories). 

252 """ 

253 words = prompt.split() 

254 estimated_tokens = max(1, int(len(words) * self.TOKENS_PER_WORD)) 

255 

256 # Determine categories 

257 categories: Set[TaskCategory] = set() 

258 categories.add(TaskCategory.CHAT) # Default 

259 

260 if self._match_any(self.CODE_PATTERNS, prompt): 

261 categories.add(TaskCategory.CODE) 

262 

263 if self._match_any(self.REASONING_PATTERNS, prompt): 

264 categories.add(TaskCategory.REASONING) 

265 categories.add(TaskCategory.ANALYSIS) 

266 

267 if self._match_any(self.TOOL_PATTERNS, prompt) or available_tools: 

268 categories.add(TaskCategory.TOOL_USE) 

269 

270 # Determine complexity 

271 is_multi_step = self._match_any(self.MULTI_STEP_PATTERNS, prompt) 

272 is_agent = self._match_any(self.AGENT_PATTERNS, prompt) 

273 

274 if estimated_tokens > self.COMPLEX_THRESHOLD_TOKENS or is_agent: 

275 complexity = TaskComplexity.COMPLEX 

276 elif estimated_tokens > self.SIMPLE_THRESHOLD_TOKENS or is_multi_step or len(categories) > 2: 

277 complexity = TaskComplexity.MEDIUM 

278 else: 

279 complexity = TaskComplexity.SIMPLE 

280 

281 return complexity, list(categories) 

282 

283 @staticmethod 

284 def _match_any(patterns: List[re.Pattern], text: str) -> bool: 

285 """Check if any pattern matches the text.""" 

286 return any(p.search(text) for p in patterns) 

287 

288 

289# --------------------------------------------------------------------------- 

290# Route Decision 

291# --------------------------------------------------------------------------- 

292 

293 

294@dataclass(frozen=True) 

295class RouteDecision: 

296 """Immutable routing decision.""" 

297 selected_model: str 

298 provider: str 

299 complexity: TaskComplexity 

300 categories: List[TaskCategory] 

301 fallback_models: List[str] 

302 reasoning: str 

303 latency_ms: float = 0.0 

304 timestamp: float = field(default_factory=time.time) 

305 

306 def to_dict(self) -> Dict[str, Any]: 

307 return { 

308 "selected_model": self.selected_model, 

309 "provider": self.provider, 

310 "complexity": self.complexity.value, 

311 "categories": [c.value for c in self.categories], 

312 "fallback_models": self.fallback_models, 

313 "reasoning": self.reasoning, 

314 "latency_ms": round(self.latency_ms * 1000, 2), 

315 } 

316 

317 

318# --------------------------------------------------------------------------- 

319# Task Router 

320# --------------------------------------------------------------------------- 

321 

322 

323class TaskRouter: 

324 """ 

325 Routes tasks to the optimal model based on task analysis. 

326 

327 Strategy (in order): 

328 1. Classify task complexity and categories 

329 2. Filter models by capability requirements (tool-calling, vision, etc.) 

330 3. Filter by min_complexity (only use models that meet quality bar) 

331 4. Score remaining models by category weight × (1 / cost_multiplier) 

332 5. Select top model + 2 fallbacks 

333 """ 

334 

335 def __init__( 

336 self, 

337 model_specs: Optional[List[ModelSpec]] = None, 

338 cost_tracker: Optional[CostTracker] = None, 

339 ): 

340 self._models = model_specs or DEFAULT_MODEL_SPECS 

341 self._classifier = TaskClassifier() 

342 self._cost_tracker = cost_tracker 

343 self._decision_log: List[RouteDecision] = [] 

344 

345 @property 

346 def models(self) -> List[ModelSpec]: 

347 return list(self._models) 

348 

349 def register_model(self, spec: ModelSpec) -> None: 

350 """Register a new model for routing.""" 

351 self._models.append(spec) 

352 

353 def route( 

354 self, 

355 prompt: str, 

356 *, 

357 required_capabilities: Optional[Set[str]] = None, 

358 latency_budget_ms: Optional[int] = None, 

359 available_tools: Optional[List[str]] = None, 

360 ) -> RouteDecision: 

361 """ 

362 Route a task to the best model. 

363 

364 Args: 

365 prompt: The user's task prompt 

366 required_capabilities: Set of required capabilities (e.g., {"tool_calling", "vision"}) 

367 latency_budget_ms: Maximum acceptable latency in ms 

368 available_tools: Tools available for this task 

369 

370 Returns: 

371 RouteDecision with selected model and fallbacks 

372 """ 

373 start = time.time() 

374 

375 complexity, categories = self._classifier.classify(prompt, available_tools) 

376 

377 # Filter candidates 

378 candidates = self._filter_candidates(complexity, categories, required_capabilities) 

379 

380 if not candidates: 

381 # Fallback: drop min_complexity constraint 

382 candidates = self._filter_candidates(complexity, categories, required_capabilities, relax_complexity=True) 

383 

384 if not candidates: 

385 # Ultimate fallback: cheapest model 

386 cheapest = min(self._models, key=lambda m: m.cost_multiplier) 

387 candidates = [cheapest] 

388 

389 # Score and rank 

390 ranked = self._rank_candidates(candidates, categories, latency_budget_ms) 

391 selected = ranked[0] 

392 fallbacks = [r.model_id for r in ranked[1:3]] if len(ranked) > 1 else [] 

393 

394 decision = RouteDecision( 

395 selected_model=selected.model_id, 

396 provider=selected.provider, 

397 complexity=complexity, 

398 categories=categories, 

399 fallback_models=fallbacks, 

400 reasoning=self._build_reasoning(selected, complexity, categories), 

401 latency_ms=time.time() - start, 

402 ) 

403 self._decision_log.append(decision) 

404 return decision 

405 

406 def _filter_candidates( 

407 self, 

408 complexity: TaskComplexity, 

409 categories: List[TaskCategory], 

410 required_capabilities: Optional[Set[str]] = None, 

411 relax_complexity: bool = False, 

412 ) -> List[ModelSpec]: 

413 """Filter models by complexity bar, category exclusion, and capabilities.""" 

414 caps = required_capabilities or set() 

415 candidates: List[ModelSpec] = [] 

416 

417 for model in self._models: 

418 # Complexity bar (can relax if no candidates) 

419 if not relax_complexity: 

420 complexity_levels = { 

421 TaskComplexity.SIMPLE: 0, 

422 TaskComplexity.MEDIUM: 1, 

423 TaskComplexity.COMPLEX: 2, 

424 } 

425 model_level = complexity_levels[model.min_complexity] 

426 task_level = complexity_levels[complexity] 

427 if model_level < task_level: 

428 continue 

429 

430 # Category exclusion 

431 if any(cat in model.excluded_categories for cat in categories): 

432 continue 

433 

434 # Capability requirements 

435 if "tool_calling" in caps and not model.supports_tool_calling: 

436 continue 

437 if "vision" in caps and not model.supports_vision: 

438 continue 

439 if "reasoning" in caps and not model.supports_reasoning: 

440 continue 

441 

442 candidates.append(model) 

443 

444 return candidates 

445 

446 def _rank_candidates( 

447 self, 

448 candidates: List[ModelSpec], 

449 categories: List[TaskCategory], 

450 latency_budget_ms: Optional[int] = None, 

451 ) -> List[ModelSpec]: 

452 """Score and rank candidates by category weight / cost.""" 

453 LATENCY_SCORES = {"fast": 1.2, "medium": 1.0, "slow": 0.7} 

454 

455 def score(model: ModelSpec) -> float: 

456 # Category affinity 

457 cat_score = sum( 

458 model.category_weights.get(cat, 0.0) for cat in categories 

459 ) / max(1, len(categories)) 

460 

461 # Cost efficiency (inverse) 

462 cost_score = 1.0 / max(0.01, model.cost_multiplier) 

463 

464 # Latency bonus 

465 latency_score = LATENCY_SCORES.get(model.latency_category, 1.0) 

466 

467 # Final: weighted sum 

468 return cat_score * 0.5 + cost_score * 0.3 + latency_score * 0.2 

469 

470 ranked = sorted(candidates, key=score, reverse=True) 

471 

472 # Filter by latency budget if specified 

473 if latency_budget_ms is not None: 

474 SLOW_THRESHOLD_MS = 2000 

475 if latency_budget_ms < SLOW_THRESHOLD_MS: 

476 ranked = [m for m in ranked if m.latency_category != "slow"] 

477 

478 return ranked 

479 

480 def _build_reasoning( 

481 self, 

482 selected: ModelSpec, 

483 complexity: TaskComplexity, 

484 categories: List[TaskCategory], 

485 ) -> str: 

486 """Build human-readable reasoning for the routing decision.""" 

487 cat_names = ", ".join(c.value for c in categories) 

488 return ( 

489 f"Task complexity: {complexity.value}. Categories: {cat_names}. " 

490 f"Selected {selected.model_id} ({selected.provider}, {selected.tier} tier) " 

491 f"for optimal quality/cost balance." 

492 ) 

493 

494 def get_decision_log(self) -> List[RouteDecision]: 

495 return list(self._decision_log) 

496 

497 def clear_log(self) -> None: 

498 self._decision_log.clear() 

499 

500 def get_statistics(self) -> Dict[str, Any]: 

501 """Return routing statistics.""" 

502 if not self._decision_log: 

503 return {} 

504 

505 model_counts: Dict[str, int] = {} 

506 complexity_counts: Dict[str, int] = {} 

507 total_latency = 0.0 

508 

509 for d in self._decision_log: 

510 model_counts[d.selected_model] = model_counts.get(d.selected_model, 0) + 1 

511 complexity_counts[d.complexity.value] = complexity_counts.get(d.complexity.value, 0) + 1 

512 total_latency += d.latency_ms 

513 

514 return { 

515 "total_requests": len(self._decision_log), 

516 "model_distribution": model_counts, 

517 "complexity_distribution": complexity_counts, 

518 "avg_latency_ms": round(total_latency * 1000 / len(self._decision_log), 2), 

519 }