feat: add QR login and browser-backed web tools
This commit is contained in:
@@ -11,6 +11,7 @@ from app.agentic import (
|
||||
AgentSessionStore,
|
||||
AgentSyntaxError,
|
||||
_build_context_messages,
|
||||
_create_llm,
|
||||
_download_message_media,
|
||||
render_prompt_template,
|
||||
parse_agent_invocation,
|
||||
@@ -22,6 +23,26 @@ from app.config import AiConfig, AiProviderConfig
|
||||
|
||||
|
||||
class AgentInvocationTests(unittest.TestCase):
|
||||
def test_glm_uses_chat_completions_and_deepseek_uses_responses(self) -> None:
|
||||
provider = AiProviderConfig("https://example.test", "key", "model")
|
||||
with (
|
||||
patch("app.agentic.ChatCompletionsLLM") as chat_llm,
|
||||
patch("app.agentic.OpenAIBasedLLM") as responses_llm,
|
||||
):
|
||||
_create_llm(provider, parse_agent_invocation("prompt", AiConfig()))
|
||||
_create_llm(provider, parse_agent_invocation(".ds prompt", AiConfig()))
|
||||
|
||||
chat_llm.assert_called_once_with(
|
||||
api_url="https://example.test",
|
||||
api_key="key",
|
||||
model="model",
|
||||
)
|
||||
responses_llm.assert_called_once_with(
|
||||
api_url="https://example.test",
|
||||
api_key="key",
|
||||
model="model",
|
||||
)
|
||||
|
||||
def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
|
||||
value = strip_prompt_comments(
|
||||
"// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast"
|
||||
@@ -168,9 +189,11 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch("app.agentic.OpenAIBasedLLM") as llm_class,
|
||||
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
|
||||
patch("app.agentic.BraveSearchTools") as brave_tools,
|
||||
patch("app.agentic.WebPageTools") as web_page_tools,
|
||||
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
|
||||
):
|
||||
brave_tools.return_value.tools.return_value = []
|
||||
web_page_tools.return_value.tools.return_value = []
|
||||
result = await run_agent_turn(
|
||||
SimpleNamespace(),
|
||||
command,
|
||||
@@ -188,6 +211,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
|
||||
brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0)
|
||||
web_page_tools.assert_called_once_with(timeout_seconds=45.0, max_chars=60_000)
|
||||
result.handle.close()
|
||||
|
||||
async def test_reply_to_ai_response_reuses_existing_session(self) -> None:
|
||||
|
||||
@@ -45,6 +45,8 @@ class ConfigAiTests(unittest.TestCase):
|
||||
{
|
||||
"glm": {"api_key": "glm-key", "model": "glm-model"},
|
||||
"deepseek": {"api_key": "ds-key", "model": "ds-model"},
|
||||
"web_page_timeout_seconds": 20,
|
||||
"web_page_max_chars": 12345,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -52,6 +54,8 @@ class ConfigAiTests(unittest.TestCase):
|
||||
self.assertEqual(config.glm.model, "glm-model")
|
||||
self.assertEqual(config.deepseek.api_key, "ds-key")
|
||||
self.assertEqual(config.deepseek.model, "ds-model")
|
||||
self.assertEqual(config.web_page_timeout_seconds, 20)
|
||||
self.assertEqual(config.web_page_max_chars, 12345)
|
||||
|
||||
def test_ai_provider_env_overrides(self) -> None:
|
||||
env = {
|
||||
@@ -59,6 +63,8 @@ class ConfigAiTests(unittest.TestCase):
|
||||
"GLM_MODEL": "env-glm-model",
|
||||
"DEEPSEEK_API_KEY": "env-ds",
|
||||
"DEEPSEEK_MODEL": "env-ds-model",
|
||||
"AI_WEB_PAGE_TIMEOUT_SECONDS": "30",
|
||||
"AI_WEB_PAGE_MAX_CHARS": "54321",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config({})
|
||||
@@ -67,6 +73,8 @@ class ConfigAiTests(unittest.TestCase):
|
||||
self.assertEqual(config.glm.model, "env-glm-model")
|
||||
self.assertEqual(config.deepseek.api_key, "env-ds")
|
||||
self.assertEqual(config.deepseek.model, "env-ds-model")
|
||||
self.assertEqual(config.web_page_timeout_seconds, 30)
|
||||
self.assertEqual(config.web_page_max_chars, 54321)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,6 +21,7 @@ session_dir = "sessions"
|
||||
[[accounts]]
|
||||
name = "personal"
|
||||
session = "personal"
|
||||
use_qr = true
|
||||
|
||||
[elevenlabs]
|
||||
api_key = "test-key"
|
||||
@@ -34,6 +35,7 @@ model_id = "scribe_v2"
|
||||
|
||||
self.assertEqual(config.api_id, 123456)
|
||||
self.assertEqual(config.accounts[0].session, "personal")
|
||||
self.assertTrue(config.accounts[0].use_qr)
|
||||
self.assertEqual(config.elevenlabs.api_key, "test-key")
|
||||
self.assertEqual(config.session_dir, root / "sessions")
|
||||
self.assertEqual(config.ai.prompt_dir, root / "prompts")
|
||||
|
||||
101
tests/test_llm.py
Normal file
101
tests/test_llm.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage
|
||||
from micro_agents.conversation import ReasoningMessage
|
||||
|
||||
from app.llm import ChatCompletionsLLM
|
||||
|
||||
|
||||
class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_uses_chat_completions_with_system_prompt_and_thinking(self) -> None:
|
||||
response_message = SimpleNamespace(
|
||||
content="done",
|
||||
reasoning_content="reasoning",
|
||||
tool_calls=None,
|
||||
)
|
||||
create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=response_message)],
|
||||
)
|
||||
)
|
||||
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
|
||||
llm._ai = SimpleNamespace( # type: ignore[assignment]
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
|
||||
result = await llm.ask(
|
||||
Conversation([UserMessage("hello")]),
|
||||
[],
|
||||
instructions="system prompt",
|
||||
thinking=True,
|
||||
)
|
||||
|
||||
request = create.await_args.kwargs
|
||||
self.assertEqual(request["model"], "glm-model")
|
||||
self.assertEqual(
|
||||
request["messages"],
|
||||
[
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "hello"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(request["extra_body"], {"thinking": {"type": "enabled"}})
|
||||
self.assertIsInstance(result[0], ReasoningMessage)
|
||||
self.assertIsInstance(result[1], LlmMessage)
|
||||
|
||||
async def test_serializes_and_parses_tool_calls(self) -> None:
|
||||
model_call = SimpleNamespace(
|
||||
id="call-2",
|
||||
function=SimpleNamespace(name="lookup", arguments='{"query":"news"}'),
|
||||
)
|
||||
create = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="",
|
||||
reasoning_content=None,
|
||||
tool_calls=[model_call],
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
|
||||
llm._ai = SimpleNamespace( # type: ignore[assignment]
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
|
||||
def lookup(query: str) -> str:
|
||||
"""Look up a query."""
|
||||
return query
|
||||
|
||||
conversation = Conversation(
|
||||
[
|
||||
UserMessage("find it"),
|
||||
ReasoningMessage("need a tool"),
|
||||
LlmMessage("checking"),
|
||||
ToolCallMessage("call-1", "lookup", {"query": "weather"}),
|
||||
ToolCallResponseMessage("call-1", "sunny"),
|
||||
]
|
||||
)
|
||||
result = await llm.ask(conversation, [Tool(lookup)], thinking=False)
|
||||
|
||||
request = create.await_args.kwargs
|
||||
self.assertEqual(request["extra_body"], {"thinking": {"type": "disabled"}})
|
||||
self.assertEqual(request["tools"][0]["type"], "function")
|
||||
self.assertEqual(request["tools"][0]["function"]["name"], "lookup")
|
||||
self.assertEqual(request["messages"][1]["role"], "assistant")
|
||||
self.assertEqual(request["messages"][1]["reasoning_content"], "need a tool")
|
||||
self.assertEqual(request["messages"][2]["role"], "tool")
|
||||
self.assertIsInstance(result[0], ToolCallMessage)
|
||||
self.assertEqual(result[0].tool_name(), "lookup")
|
||||
self.assertEqual(result[0].arguments(), {"query": "news"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
tests/test_manager_qr.py
Normal file
37
tests/test_manager_qr.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.config import AccountConfig, AppConfig
|
||||
from app.manager import AccountManager
|
||||
|
||||
|
||||
class AccountManagerQrTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_qr_flag_is_passed_when_starting_account(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
config = AppConfig(
|
||||
api_id=123456,
|
||||
api_hash="hash",
|
||||
session_dir=Path(temp_dir),
|
||||
accounts=[AccountConfig(name="personal", session="personal", use_qr=True)],
|
||||
)
|
||||
manager = AccountManager(config)
|
||||
client = MagicMock()
|
||||
client.start = AsyncMock()
|
||||
client.get_me = AsyncMock(return_value=MagicMock(id=42))
|
||||
|
||||
with (
|
||||
patch.object(AccountManager, "_make_client", return_value=client),
|
||||
patch("app.manager.register_common_handlers"),
|
||||
):
|
||||
await manager.start_account(manager.states[0])
|
||||
|
||||
client.start.assert_awaited_once_with(use_qr=True)
|
||||
self.assertEqual(manager.states[0].status, "running")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
81
tests/test_web_tools.py
Normal file
81
tests/test_web_tools.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.web_tools import WebPageTools, _ensure_public_url
|
||||
|
||||
|
||||
class WebPageToolsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_private_network_urls_are_rejected(self) -> None:
|
||||
for url in (
|
||||
"http://127.0.0.1/admin",
|
||||
"http://10.0.0.1/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]/",
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
with self.assertRaisesRegex(ValueError, "private network"):
|
||||
await _ensure_public_url(url)
|
||||
|
||||
async def test_reads_page_as_bounded_markdown(self) -> None:
|
||||
crawl_result = SimpleNamespace(
|
||||
success=True,
|
||||
url="https://example.com/article",
|
||||
redirected_url=None,
|
||||
status_code=200,
|
||||
metadata={"title": "Example"},
|
||||
markdown="abcdefghij",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"app.web_tools._ensure_public_url",
|
||||
AsyncMock(side_effect=lambda value: value),
|
||||
) as ensure_public,
|
||||
patch(
|
||||
"app.web_tools._crawl_with_playwright",
|
||||
AsyncMock(return_value=crawl_result),
|
||||
) as crawl,
|
||||
):
|
||||
payload = json.loads(
|
||||
await WebPageTools(timeout_seconds=12, max_chars=5).read_web_page(
|
||||
"https://example.com/article"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(payload["markdown"], "abcde")
|
||||
self.assertTrue(payload["truncated"])
|
||||
self.assertEqual(payload["title"], "Example")
|
||||
self.assertEqual(ensure_public.await_count, 2)
|
||||
crawl.assert_awaited_once_with("https://example.com/article", 12)
|
||||
|
||||
async def test_crawl_failure_is_reported_to_agent(self) -> None:
|
||||
crawl_result = SimpleNamespace(
|
||||
success=False,
|
||||
url="https://example.com",
|
||||
redirected_url=None,
|
||||
error_message="blocked",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"app.web_tools._ensure_public_url",
|
||||
AsyncMock(side_effect=lambda value: value),
|
||||
),
|
||||
patch(
|
||||
"app.web_tools._crawl_with_playwright",
|
||||
AsyncMock(return_value=crawl_result),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "blocked"):
|
||||
await WebPageTools().read_web_page("https://example.com")
|
||||
|
||||
def test_tool_is_registered_for_all_agent_sessions(self) -> None:
|
||||
tools = WebPageTools().tools()
|
||||
|
||||
self.assertEqual([tool.name for tool in tools], ["read_web_page"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user