Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/agent_box/router/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,16 @@ def _fast_classify(text: str) -> tuple[str, str] | None:
- '开个 foo 项目'
- '搞个新项目叫 foo'
- '加一个项目叫 foo'\n\n"
switch_project — change which existing project is the active one:
switch_project — change which existing project is the active one.
ONLY trigger when the message EXPLICITLY pairs a switch/change word (切换 / 切到 /
switch to / change project to) with a project name. Bare 用(use) / 打开(open) /
换(change) + a noun is NOT a switch on its own; if the noun is followed by a real
task it is FORWARD:
- 'switch to foo'
- 'use foo project'
- 'change to foo'
- 'open foo project'
- 'activate foo'
- 'change project to foo'
- '切换到 foo'
- '切到 foo 项目'
- '换到 foo'
- '用 foo 项目'
- '打开 foo 项目'
- '切换项目到 foo'
- '回到默认项目' (→ name='_default')\n\n"
list_projects — see what projects exist or which one is current:
- 'list projects'
Expand Down Expand Up @@ -204,7 +203,10 @@ def _fast_classify(text: str) -> tuple[str, str] | None:
- '新建一个文件 foo.py' (文件, 不是项目)
- '加一个 foo 函数' (函数, 不是项目)
- '解释一下这段代码'
- '运行测试'\n\n"
- '运行测试'
- '用 pandas 处理这个数据' (用 + 任务, 不是切项目)
- '打开这个文件看一下' (打开 + 文件, 不是切项目)
- '换个写法' (换 + 写法, 不是切项目)\n\n"
When the user names a project that is not in the active list, STILL call the matching tool with that exact name — do not silently substitute a different existing project, and do not fall back to FORWARD just because the name is unknown.
"""

Expand Down
61 changes: 61 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for agent_box.router."""

import os
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace

Expand Down Expand Up @@ -192,6 +193,11 @@ def test_fast_classify_no_match():
assert _fast_classify("fix the bug in main.py") is None
assert _fast_classify("切换到 demo") is None
assert _fast_classify("hello") is None
# Work instructions containing generic verbs (用/打开/换) must NOT match
# the compact regex fast-path — they belong to the LLM classifier.
assert _fast_classify("用 pandas 处理这个数据") is None
assert _fast_classify("打开这个文件看一下") is None
assert _fast_classify("换个写法") is None


@pytest.mark.anyio
Expand Down Expand Up @@ -318,3 +324,58 @@ async def test_switch_model_default_via_llm(tmp_projects: SessionManager):
result = await router.route(_msg("reset model to default"))
assert "Reset" in result.reply
assert tmp_projects.get("_default").model is None


# ── Live prompt classification (exercises the real router LLM) ──
#
# These cases validate the system prompt itself: that work instructions are
# classified as FORWARD and explicit project-switch phrases as switch_project.
# Skipped unless ANTHROPIC_API_KEY is set, so normal test runs are unaffected.

_live = pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY"),
reason="needs ANTHROPIC_API_KEY for live router classification",
)


@_live
@pytest.mark.anyio
@pytest.mark.parametrize(
"text,expect_tool",
[
# FORWARD — generic verbs (用/打开/换) in a work context must NOT switch
("用 pandas 处理这个数据", None),
("打开这个文件看一下", None),
("换个写法重写这段代码", None),
("用这个库做数据分析", None),
("打开浏览器看看效果", None),
# switch_project — explicit switch word + project name
("切换到 demo 项目", "switch_project"),
("切到 foo 项目", "switch_project"),
("switch to bar", "switch_project"),
("change project to baz", "switch_project"),
],
)
async def test_router_live_prompt_classification(
tmp_projects: SessionManager, text: str, expect_tool: str | None
):
from agent_box.router.router import Router

router = Router(tmp_projects)
try:
tool = await router._classify(text)
finally:
# Close the httpx-backed client inside the live loop so its connection
# pool isn't GC'd after the loop closes ("Event loop is closed").
await router._client.close()

if expect_tool is None:
assert tool is None, (
f"expected FORWARD for {text!r}, but classifier called "
f"{getattr(tool, 'name', tool)!r}"
)
else:
got = getattr(tool, "name", None)
assert got == expect_tool, (
f"expected {expect_tool} for {text!r}, but classifier returned {got!r}"
)
Loading