From 17bf16b82feb815dd7f61bbd22c23301554a03ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Mon, 16 Mar 2026 00:55:41 +0100 Subject: [PATCH] feat(router): tighten routing match semantics --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 2 ++ foundrygate/router.py | 46 +++++++++++++++++++++--------------- tests/test_routing.py | 54 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d1156..2a4e534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is intentionally lightweight and human-readable. Group entries by rel - Added richer client usage reporting in `GET /api/stats` and the dashboard, including per-client tokens, failures, success rate, and aggregate client totals +### Changed + +- Tightened `static` and `heuristic` match semantics so combined fields now behave as cumulative constraints unless `any:` is used explicitly + ## v1.0.0 - 2026-03-15 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 95ccc52..9364989 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,6 +43,8 @@ The current chat path is: 6. optional LLM classifier 7. fallback chain if the chosen provider fails +Within one `static` or `heuristic` match block, configured fields now behave as cumulative constraints. Use explicit `any:` only when you want OR behavior across subconditions. This keeps combined rules explainable and avoids accidental matches when only one of several intended constraints is present. + Before a candidate is accepted, FoundryGate also scores and validates route fit against provider metadata such as context window, input/output token limits, cache hints, locality, health, latency, and recent failure state. ## Provider layer diff --git a/foundrygate/router.py b/foundrygate/router.py index 58a57fb..a10c631 100644 --- a/foundrygate/router.py +++ b/foundrygate/router.py @@ -840,33 +840,34 @@ def _match_static(self, match: dict, ctx: _RoutingContext) -> bool: if "any" in match: return any(self._match_static(sub, ctx) for sub in match["any"]) + matched_any = False + # model_requested if "model_requested" in match: + matched_any = True patterns = match["model_requested"] if isinstance(patterns, str): patterns = [patterns] - if any(p in ctx.model_requested for p in patterns): - return True - if match.keys() == {"model_requested"}: + if not any(p in ctx.model_requested for p in patterns): return False # system_prompt_contains if "system_prompt_contains" in match: + matched_any = True keywords = match["system_prompt_contains"] lower_sys = ctx.system_prompt.lower() - if any(kw.lower() in lower_sys for kw in keywords): - return True - if match.keys() == {"system_prompt_contains"}: + if not any(kw.lower() in lower_sys for kw in keywords): return False # header_contains if "header_contains" in match: + matched_any = True for header_name, patterns in match["header_contains"].items(): header_val = ctx.headers.get(header_name, "").lower() - if any(p.lower() in header_val for p in patterns): - return True + if not any(p.lower() in header_val for p in patterns): + return False - return False + return matched_any # ── Layer 2: Heuristic Rules ─────────────────────────────── @@ -896,23 +897,29 @@ def _match_heuristic(self, match: dict, ctx: _RoutingContext) -> bool: if match.get("fallthrough"): return True + matched_any = False + # has_tools if "has_tools" in match: - if match["has_tools"] == ctx.has_tools: - return True - return False + matched_any = True + if match["has_tools"] != ctx.has_tools: + return False # estimated_tokens if "estimated_tokens" in match: + matched_any = True tok_match = match["estimated_tokens"] - if "less_than" in tok_match and ctx.total_tokens < tok_match["less_than"]: - return True - if "greater_than" in tok_match and ctx.total_tokens > tok_match["greater_than"]: - return True - return False + token_ok = True + if "less_than" in tok_match: + token_ok = token_ok and ctx.total_tokens < tok_match["less_than"] + if "greater_than" in tok_match: + token_ok = token_ok and ctx.total_tokens > tok_match["greater_than"] + if not token_ok: + return False # message_keywords if "message_keywords" in match: + matched_any = True kw_cfg = match["message_keywords"] keywords = kw_cfg.get("any_of", []) min_matches = kw_cfg.get("min_matches", 1) @@ -923,9 +930,10 @@ def _match_heuristic(self, match: dict, ctx: _RoutingContext) -> bool: search_text = ctx.last_user_message.lower() hit_count = sum(1 for kw in keywords if kw.lower() in search_text) - return hit_count >= min_matches + if hit_count < min_matches: + return False - return False + return matched_any # ── Layer 3: LLM Classifier ──────────────────────────────── diff --git a/tests/test_routing.py b/tests/test_routing.py index 1f89954..1ff1f25 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -135,6 +135,33 @@ async def test_subagent_header(self, router): assert d.provider_name == "deepseek-chat" assert d.rule_name == "subagent" + def test_static_match_requires_all_fields_by_default(self, router): + ctx = types.SimpleNamespace( + model_requested="auto", + system_prompt="delegated task planner", + headers={"x-openclaw-source": "primary-agent"}, + ) + assert ( + router._match_static( # noqa: SLF001 + { + "system_prompt_contains": ["delegated task"], + "header_contains": {"x-openclaw-source": ["subagent"]}, + }, + ctx, + ) + is False + ) + assert ( + router._match_static( # noqa: SLF001 + { + "system_prompt_contains": ["delegated task"], + "header_contains": {"x-openclaw-source": ["primary-agent"]}, + }, + ctx, + ) + is True + ) + # ── Heuristic routing ───────────────────────────────────────── @@ -216,6 +243,33 @@ async def test_system_prompt_not_scored(self, router): # Should NOT be deepseek-reasoner despite system prompt keywords assert d.provider_name != "deepseek-reasoner" + def test_heuristic_match_requires_all_fields_by_default(self, router): + ctx = types.SimpleNamespace( + has_tools=True, + total_tokens=90, + last_user_message="search files and summarize the result", + ) + assert ( + router._match_heuristic( # noqa: SLF001 + { + "has_tools": True, + "estimated_tokens": {"greater_than": 100}, + }, + ctx, + ) + is False + ) + assert ( + router._match_heuristic( # noqa: SLF001 + { + "has_tools": True, + "estimated_tokens": {"less_than": 100}, + }, + ctx, + ) + is True + ) + # ── Health fallback ────────────────────────────────────────────