feat: add QR login and browser-backed web tools
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
TG_API_ID=123456
|
TG_API_ID=123456
|
||||||
TG_API_HASH=your_api_hash_here
|
TG_API_HASH=your_api_hash_here
|
||||||
SESSION_DIR=sessions
|
SESSION_DIR=sessions
|
||||||
ACCOUNTS_JSON=[{"name":"personal","session":"personal"},{"name":"work","session":"work"}]
|
ACCOUNTS_JSON=[{"name":"personal","session":"personal","use_qr":true},{"name":"work","session":"work","use_qr":true}]
|
||||||
BOT_CONFIG=config.toml
|
BOT_CONFIG=config.toml
|
||||||
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
||||||
ELEVENLABS_MODEL_ID=scribe_v2
|
ELEVENLABS_MODEL_ID=scribe_v2
|
||||||
@@ -18,3 +18,5 @@ DEEPSEEK_MODEL=deepseek-v4-flash
|
|||||||
# AI_MAX_TOOL_ROUNDS=16
|
# AI_MAX_TOOL_ROUNDS=16
|
||||||
BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here
|
BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here
|
||||||
# BRAVE_SEARCH_TIMEOUT_SECONDS=10
|
# BRAVE_SEARCH_TIMEOUT_SECONDS=10
|
||||||
|
# AI_WEB_PAGE_TIMEOUT_SECONDS=45
|
||||||
|
# AI_WEB_PAGE_MAX_CHARS=60000
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.log
|
||||||
sessions/
|
sessions/
|
||||||
*.acl.json
|
*.acl.json
|
||||||
config.json
|
config.json
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Repository guidance for automated coding tools and contributors.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
|
uv run crawl4ai-setup
|
||||||
uv run python main.py
|
uv run python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,9 +24,11 @@ Copy `config.example.toml` to `config.toml` and fill in credentials, or use envi
|
|||||||
|
|
||||||
Key env vars: `TG_API_ID`, `TG_API_HASH`, `SESSION_DIR`, `ACCOUNTS_JSON` (JSON list) or `ACCOUNT_SESSIONS` (comma-separated session names), `BOT_CONFIG` (path to config file), and `BOT_DOTENV_PATH` (path to `.env`).
|
Key env vars: `TG_API_ID`, `TG_API_HASH`, `SESSION_DIR`, `ACCOUNTS_JSON` (JSON list) or `ACCOUNT_SESSIONS` (comma-separated session names), `BOT_CONFIG` (path to config file), and `BOT_DOTENV_PATH` (path to `.env`).
|
||||||
|
|
||||||
|
Set `use_qr = true` on an account to authorize a new user session by scanning the QR code printed in the terminal. Existing authorized sessions start normally. If the account has 2FA enabled, Kurigram asks for its password in the terminal after the QR code is scanned.
|
||||||
|
|
||||||
The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL_ID`, `ELEVENLABS_LANGUAGE_CODE`, and `ELEVENLABS_REQUEST_TIMEOUT_SECONDS`.
|
The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL_ID`, `ELEVENLABS_LANGUAGE_CODE`, and `ELEVENLABS_REQUEST_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`.
|
The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`.
|
||||||
|
|
||||||
AI prompt templates are always loaded from `prompts/MASTER.txt` and `prompts/USER.txt` for every invocation. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎.
|
AI prompt templates are always loaded from `prompts/MASTER.txt` and `prompts/USER.txt` for every invocation. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools
|
|||||||
|
|
||||||
from app.agent_tools import AgentToolManager, TelegramTools
|
from app.agent_tools import AgentToolManager, TelegramTools
|
||||||
from app.config import AiConfig, AiProviderConfig
|
from app.config import AiConfig, AiProviderConfig
|
||||||
|
from app.llm import ChatCompletionsLLM
|
||||||
|
from app.web_tools import WebPageTools
|
||||||
|
|
||||||
|
|
||||||
class AgentError(RuntimeError):
|
class AgentError(RuntimeError):
|
||||||
@@ -401,6 +403,15 @@ def _provider(config: AiConfig, invocation: AgentInvocation) -> AiProviderConfig
|
|||||||
return config.deepseek if invocation.use_deepseek else config.glm
|
return config.deepseek if invocation.use_deepseek else config.glm
|
||||||
|
|
||||||
|
|
||||||
|
def _create_llm(provider: AiProviderConfig, invocation: AgentInvocation) -> Any:
|
||||||
|
llm_class = OpenAIBasedLLM if invocation.use_deepseek else ChatCompletionsLLM
|
||||||
|
return llm_class(
|
||||||
|
api_url=provider.api_url,
|
||||||
|
api_key=provider.api_key,
|
||||||
|
model=provider.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_agent_turn(
|
async def run_agent_turn(
|
||||||
client: Any,
|
client: Any,
|
||||||
command_message: Any,
|
command_message: Any,
|
||||||
@@ -433,11 +444,7 @@ async def run_agent_turn(
|
|||||||
if not provider.api_key:
|
if not provider.api_key:
|
||||||
raise AgentError("The selected AI provider has no API key")
|
raise AgentError("The selected AI provider has no API key")
|
||||||
|
|
||||||
llm = OpenAIBasedLLM(
|
llm = _create_llm(provider, invocation)
|
||||||
api_url=provider.api_url,
|
|
||||||
api_key=provider.api_key,
|
|
||||||
model=provider.model,
|
|
||||||
)
|
|
||||||
templates = load_prompt_templates(config.prompt_dir)
|
templates = load_prompt_templates(config.prompt_dir)
|
||||||
context = _agent_context(command_message, account, invocation)
|
context = _agent_context(command_message, account, invocation)
|
||||||
context["base_dir"] = config.base_dir
|
context["base_dir"] = config.base_dir
|
||||||
@@ -455,6 +462,10 @@ async def run_agent_turn(
|
|||||||
template_context=context,
|
template_context=context,
|
||||||
)
|
)
|
||||||
tools = [
|
tools = [
|
||||||
|
*WebPageTools(
|
||||||
|
timeout_seconds=config.web_page_timeout_seconds,
|
||||||
|
max_chars=config.web_page_max_chars,
|
||||||
|
).tools(),
|
||||||
*BraveSearchTools(
|
*BraveSearchTools(
|
||||||
api_key=config.brave_api_key,
|
api_key=config.brave_api_key,
|
||||||
timeout_seconds=config.brave_timeout_seconds,
|
timeout_seconds=config.brave_timeout_seconds,
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ async def echo(ctx: "CommandContext") -> None:
|
|||||||
category="shared",
|
category="shared",
|
||||||
).shared()
|
).shared()
|
||||||
async def ai(ctx: "CommandContext") -> None:
|
async def ai(ctx: "CommandContext") -> None:
|
||||||
|
failed = False
|
||||||
try:
|
try:
|
||||||
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
|
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
|
||||||
turn = await run_agent_turn(
|
turn = await run_agent_turn(
|
||||||
@@ -431,6 +432,9 @@ async def ai(ctx: "CommandContext") -> None:
|
|||||||
ctx.account.ai_sessions.register_response(turn.handle, sent)
|
ctx.account.ai_sessions.register_response(turn.handle, sent)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("AI command failed for message %s", getattr(ctx.message, "id", None))
|
log.exception("AI command failed for message %s", getattr(ctx.message, "id", None))
|
||||||
|
failed = True
|
||||||
|
|
||||||
|
if failed:
|
||||||
await _dislike(ctx.message)
|
await _dislike(ctx.message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class AccountConfig:
|
|||||||
no_updates: bool = False
|
no_updates: bool = False
|
||||||
takeout: bool = False
|
takeout: bool = False
|
||||||
hide_password: bool = False
|
hide_password: bool = False
|
||||||
|
use_qr: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -69,6 +70,8 @@ class AiConfig:
|
|||||||
max_tool_rounds: int = 16
|
max_tool_rounds: int = 16
|
||||||
brave_api_key: str | None = None
|
brave_api_key: str | None = None
|
||||||
brave_timeout_seconds: float = 10.0
|
brave_timeout_seconds: float = 10.0
|
||||||
|
web_page_timeout_seconds: float = 45.0
|
||||||
|
web_page_max_chars: int = 60_000
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -93,6 +96,20 @@ def _parse_int(value: Any, *, field_name: str) -> int:
|
|||||||
raise ValueError(f"{field_name} must be an integer") from exc
|
raise ValueError(f"{field_name} must be an integer") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: Any, *, field_name: str) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
if isinstance(value, int):
|
||||||
|
return bool(value)
|
||||||
|
raise ValueError(f"{field_name} must be a boolean")
|
||||||
|
|
||||||
|
|
||||||
def _parse_float(value: Any, *, field_name: str) -> float:
|
def _parse_float(value: Any, *, field_name: str) -> float:
|
||||||
try:
|
try:
|
||||||
return float(value)
|
return float(value)
|
||||||
@@ -206,6 +223,14 @@ def _parse_ai_config(raw: Any) -> AiConfig:
|
|||||||
raw_config.get("brave_timeout_seconds", 10),
|
raw_config.get("brave_timeout_seconds", 10),
|
||||||
field_name="ai.brave_timeout_seconds",
|
field_name="ai.brave_timeout_seconds",
|
||||||
)
|
)
|
||||||
|
web_page_timeout_seconds = _parse_float(
|
||||||
|
raw_config.get("web_page_timeout_seconds", 45),
|
||||||
|
field_name="ai.web_page_timeout_seconds",
|
||||||
|
)
|
||||||
|
web_page_max_chars = _parse_int(
|
||||||
|
raw_config.get("web_page_max_chars", 60_000),
|
||||||
|
field_name="ai.web_page_max_chars",
|
||||||
|
)
|
||||||
|
|
||||||
raw_brave_key = raw_config.get("brave_api_key")
|
raw_brave_key = raw_config.get("brave_api_key")
|
||||||
brave_api_key = str(raw_brave_key).strip() if raw_brave_key is not None else None
|
brave_api_key = str(raw_brave_key).strip() if raw_brave_key is not None else None
|
||||||
@@ -234,6 +259,16 @@ def _parse_ai_config(raw: Any) -> AiConfig:
|
|||||||
os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
|
os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
|
||||||
field_name="BRAVE_SEARCH_TIMEOUT_SECONDS",
|
field_name="BRAVE_SEARCH_TIMEOUT_SECONDS",
|
||||||
)
|
)
|
||||||
|
if os.environ.get("AI_WEB_PAGE_TIMEOUT_SECONDS") is not None:
|
||||||
|
web_page_timeout_seconds = _parse_float(
|
||||||
|
os.environ["AI_WEB_PAGE_TIMEOUT_SECONDS"],
|
||||||
|
field_name="AI_WEB_PAGE_TIMEOUT_SECONDS",
|
||||||
|
)
|
||||||
|
if os.environ.get("AI_WEB_PAGE_MAX_CHARS") is not None:
|
||||||
|
web_page_max_chars = _parse_int(
|
||||||
|
os.environ["AI_WEB_PAGE_MAX_CHARS"],
|
||||||
|
field_name="AI_WEB_PAGE_MAX_CHARS",
|
||||||
|
)
|
||||||
|
|
||||||
if max_context_messages < 0:
|
if max_context_messages < 0:
|
||||||
raise ValueError("ai.max_context_messages must not be negative")
|
raise ValueError("ai.max_context_messages must not be negative")
|
||||||
@@ -243,6 +278,10 @@ def _parse_ai_config(raw: Any) -> AiConfig:
|
|||||||
raise ValueError("ai.max_tool_rounds must be greater than zero")
|
raise ValueError("ai.max_tool_rounds must be greater than zero")
|
||||||
if brave_timeout_seconds <= 0:
|
if brave_timeout_seconds <= 0:
|
||||||
raise ValueError("ai.brave_timeout_seconds must be greater than zero")
|
raise ValueError("ai.brave_timeout_seconds must be greater than zero")
|
||||||
|
if web_page_timeout_seconds <= 0:
|
||||||
|
raise ValueError("ai.web_page_timeout_seconds must be greater than zero")
|
||||||
|
if web_page_max_chars < 1:
|
||||||
|
raise ValueError("ai.web_page_max_chars must be greater than zero")
|
||||||
|
|
||||||
return AiConfig(
|
return AiConfig(
|
||||||
glm=glm,
|
glm=glm,
|
||||||
@@ -252,6 +291,8 @@ def _parse_ai_config(raw: Any) -> AiConfig:
|
|||||||
max_tool_rounds=max_tool_rounds,
|
max_tool_rounds=max_tool_rounds,
|
||||||
brave_api_key=brave_api_key,
|
brave_api_key=brave_api_key,
|
||||||
brave_timeout_seconds=brave_timeout_seconds,
|
brave_timeout_seconds=brave_timeout_seconds,
|
||||||
|
web_page_timeout_seconds=web_page_timeout_seconds,
|
||||||
|
web_page_max_chars=web_page_max_chars,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -314,6 +355,10 @@ def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_ap
|
|||||||
no_updates=bool(raw_account.get("no_updates", False)),
|
no_updates=bool(raw_account.get("no_updates", False)),
|
||||||
takeout=bool(raw_account.get("takeout", False)),
|
takeout=bool(raw_account.get("takeout", False)),
|
||||||
hide_password=bool(raw_account.get("hide_password", False)),
|
hide_password=bool(raw_account.get("hide_password", False)),
|
||||||
|
use_qr=_parse_bool(
|
||||||
|
raw_account.get("use_qr", False),
|
||||||
|
field_name=f"accounts[{index}].use_qr",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
228
app/llm.py
Normal file
228
app/llm.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from micro_agents import (
|
||||||
|
Conversation,
|
||||||
|
LlmMessage,
|
||||||
|
Media,
|
||||||
|
Tool,
|
||||||
|
ToolCallMessage,
|
||||||
|
ToolCallResponseMessage,
|
||||||
|
UserMessage,
|
||||||
|
)
|
||||||
|
from micro_agents.conversation import Message, ReasoningMessage
|
||||||
|
from micro_agents.llm import OpenAIBasedLLM
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionsLLM(OpenAIBasedLLM):
|
||||||
|
"""micro-agents adapter for OpenAI-compatible Chat Completions APIs."""
|
||||||
|
|
||||||
|
async def ask(
|
||||||
|
self,
|
||||||
|
conversation: Conversation,
|
||||||
|
tools: list[Tool],
|
||||||
|
*,
|
||||||
|
instructions: str | None = None,
|
||||||
|
thinking: bool | None = None,
|
||||||
|
) -> list[Message]:
|
||||||
|
messages = self._serialize_conversation(conversation, instructions)
|
||||||
|
serialized_tools = self._serialize_tools(tools)
|
||||||
|
request: dict[str, Any] = {
|
||||||
|
"model": self._model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"extra_body": {
|
||||||
|
"thinking": {"type": "enabled" if thinking else "disabled"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if serialized_tools:
|
||||||
|
request["tools"] = serialized_tools
|
||||||
|
|
||||||
|
response = await self._ai.chat.completions.create(**cast(Any, request))
|
||||||
|
if not response.choices:
|
||||||
|
raise RuntimeError("Chat Completions API returned no choices")
|
||||||
|
|
||||||
|
message = response.choices[0].message
|
||||||
|
result: list[Message] = []
|
||||||
|
reasoning = getattr(message, "reasoning_content", None)
|
||||||
|
if reasoning:
|
||||||
|
result.append(ReasoningMessage(str(reasoning)))
|
||||||
|
if message.content:
|
||||||
|
result.append(LlmMessage(message.content))
|
||||||
|
|
||||||
|
for call in message.tool_calls or ():
|
||||||
|
try:
|
||||||
|
arguments = json.loads(call.function.arguments)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Model returned invalid tool-call arguments") from exc
|
||||||
|
if not isinstance(arguments, dict):
|
||||||
|
raise ValueError("Model tool-call arguments must be an object")
|
||||||
|
result.append(ToolCallMessage(call.id, call.function.name, arguments))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _serialize_tools(self, tools: list[Tool]) -> list[dict[str, object]]:
|
||||||
|
response_tools = super()._serialize_tools(tools)
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for tool in response_tools:
|
||||||
|
function = {
|
||||||
|
key: tool[key]
|
||||||
|
for key in ("name", "description", "parameters")
|
||||||
|
if key in tool and tool[key] is not None
|
||||||
|
}
|
||||||
|
result.append({"type": "function", "function": function})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_conversation(
|
||||||
|
cls,
|
||||||
|
conversation: Conversation,
|
||||||
|
instructions: str | None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
if instructions:
|
||||||
|
result.append({"role": "system", "content": instructions})
|
||||||
|
|
||||||
|
pending_assistant: dict[str, object] | None = None
|
||||||
|
pending_attachments: list[Media] = []
|
||||||
|
|
||||||
|
def flush_assistant() -> None:
|
||||||
|
nonlocal pending_assistant
|
||||||
|
if pending_assistant is None:
|
||||||
|
return
|
||||||
|
pending_assistant.setdefault("content", "")
|
||||||
|
result.append(pending_assistant)
|
||||||
|
pending_assistant = None
|
||||||
|
|
||||||
|
def flush_attachments() -> None:
|
||||||
|
if not pending_attachments:
|
||||||
|
return
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": cls._serialize_user_content(
|
||||||
|
"Attachments returned by tools:",
|
||||||
|
pending_attachments,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pending_attachments.clear()
|
||||||
|
|
||||||
|
for message in conversation.messages:
|
||||||
|
if isinstance(message, ToolCallResponseMessage):
|
||||||
|
flush_assistant()
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": message.unique_id(),
|
||||||
|
"content": message.text(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pending_attachments.extend(message.attachments())
|
||||||
|
continue
|
||||||
|
|
||||||
|
flush_attachments()
|
||||||
|
if isinstance(message, UserMessage):
|
||||||
|
flush_assistant()
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": cls._serialize_user_content(
|
||||||
|
message.message(),
|
||||||
|
message.media(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(message, ReasoningMessage):
|
||||||
|
flush_assistant()
|
||||||
|
pending_assistant = {
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": message.text(),
|
||||||
|
}
|
||||||
|
elif isinstance(message, LlmMessage):
|
||||||
|
if pending_assistant is None:
|
||||||
|
pending_assistant = {"role": "assistant"}
|
||||||
|
pending_assistant["content"] = message.message()
|
||||||
|
elif isinstance(message, ToolCallMessage):
|
||||||
|
if pending_assistant is None:
|
||||||
|
pending_assistant = {"role": "assistant"}
|
||||||
|
calls = cast(list[dict[str, object]], pending_assistant.setdefault("tool_calls", []))
|
||||||
|
calls.append(
|
||||||
|
{
|
||||||
|
"id": message.unique_id(),
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": message.tool_name(),
|
||||||
|
"arguments": json.dumps(
|
||||||
|
message.arguments(),
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown conversation message: {type(message).__name__}")
|
||||||
|
|
||||||
|
flush_assistant()
|
||||||
|
flush_attachments()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_user_content(
|
||||||
|
cls,
|
||||||
|
text: str,
|
||||||
|
media_items: list[Media],
|
||||||
|
) -> str | list[dict[str, object]]:
|
||||||
|
if not media_items:
|
||||||
|
return text
|
||||||
|
content: list[dict[str, object]] = []
|
||||||
|
if text:
|
||||||
|
content.append({"type": "text", "text": text})
|
||||||
|
content.extend(cls._serialize_media(media) for media in media_items)
|
||||||
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_media(media: Media) -> dict[str, object]:
|
||||||
|
if media.kind == "image":
|
||||||
|
if media.url is not None:
|
||||||
|
url = media.url
|
||||||
|
elif media.data is not None:
|
||||||
|
mime_type = media.mime_type or "image/jpeg"
|
||||||
|
encoded = base64.b64encode(media.data).decode("ascii")
|
||||||
|
url = f"data:{mime_type};base64,{encoded}"
|
||||||
|
else:
|
||||||
|
raise ValueError("Chat Completions image input requires data or URL")
|
||||||
|
return {
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": url, "detail": media.detail},
|
||||||
|
}
|
||||||
|
|
||||||
|
if media.kind == "audio":
|
||||||
|
if media.data is None:
|
||||||
|
raise ValueError("Chat Completions audio input requires inline data")
|
||||||
|
mime_type = media.mime_type or ""
|
||||||
|
audio_format = "mp3" if mime_type == "audio/mpeg" else "wav"
|
||||||
|
return {
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": {
|
||||||
|
"data": base64.b64encode(media.data).decode("ascii"),
|
||||||
|
"format": audio_format,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
file: dict[str, object] = {}
|
||||||
|
if media.file_id is not None:
|
||||||
|
file["file_id"] = media.file_id
|
||||||
|
elif media.data is not None:
|
||||||
|
mime_type = media.mime_type or "application/octet-stream"
|
||||||
|
encoded = base64.b64encode(media.data).decode("ascii")
|
||||||
|
file["file_data"] = f"data:{mime_type};base64,{encoded}"
|
||||||
|
file["filename"] = media.filename or "file"
|
||||||
|
elif media.url is not None:
|
||||||
|
file["file_data"] = media.url
|
||||||
|
file["filename"] = media.filename or "file"
|
||||||
|
return {"type": "file", "file": file}
|
||||||
@@ -78,7 +78,7 @@ class AccountManager:
|
|||||||
register_common_handlers(client, self, state)
|
register_common_handlers(client, self, state)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.start()
|
await client.start(use_qr=account.use_qr)
|
||||||
state.me = await client.get_me()
|
state.me = await client.get_me()
|
||||||
state.status = "running"
|
state.status = "running"
|
||||||
log.info("Started account %s (%s)", account.name, account.session)
|
log.info("Started account %s (%s)", account.name, account.session)
|
||||||
|
|||||||
124
app/web_tools.py
Normal file
124
app/web_tools.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from micro_agents import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class WebPageTools:
|
||||||
|
def __init__(self, *, timeout_seconds: float = 45, max_chars: int = 60_000):
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
if max_chars < 1:
|
||||||
|
raise ValueError("max_chars must be positive")
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._max_chars = max_chars
|
||||||
|
|
||||||
|
def tools(self) -> list[Tool]:
|
||||||
|
return [Tool(self.read_web_page, name="read_web_page", strict=False)]
|
||||||
|
|
||||||
|
async def read_web_page(self, url: str) -> str:
|
||||||
|
"""Read a public web page in a Playwright browser and return cleaned Markdown.
|
||||||
|
|
||||||
|
Use this before analyzing, summarizing, or quoting a URL. Unlike web_search,
|
||||||
|
this loads the page itself and can render JavaScript-driven content.
|
||||||
|
"""
|
||||||
|
safe_url = await _ensure_public_url(url)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
_crawl_with_playwright(safe_url, self._timeout_seconds),
|
||||||
|
timeout=self._timeout_seconds + 5,
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise RuntimeError("Web page crawl timed out") from exc
|
||||||
|
|
||||||
|
final_url = str(getattr(result, "redirected_url", None) or getattr(result, "url", safe_url))
|
||||||
|
await _ensure_public_url(final_url)
|
||||||
|
if not getattr(result, "success", False):
|
||||||
|
error = str(getattr(result, "error_message", None) or "unknown crawl error")
|
||||||
|
raise RuntimeError(f"Could not read web page: {error}")
|
||||||
|
|
||||||
|
markdown_result = getattr(result, "markdown", None)
|
||||||
|
markdown = str(markdown_result or "").strip()
|
||||||
|
if not markdown:
|
||||||
|
raise RuntimeError("Web page did not contain readable content")
|
||||||
|
|
||||||
|
truncated = len(markdown) > self._max_chars
|
||||||
|
if truncated:
|
||||||
|
markdown = markdown[: self._max_chars].rstrip()
|
||||||
|
|
||||||
|
metadata = getattr(result, "metadata", None)
|
||||||
|
title = metadata.get("title") if isinstance(metadata, dict) else None
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"url": final_url,
|
||||||
|
"title": title,
|
||||||
|
"status_code": getattr(result, "status_code", None),
|
||||||
|
"truncated": truncated,
|
||||||
|
"markdown": markdown,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _crawl_with_playwright(url: str, timeout_seconds: float) -> Any:
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
|
||||||
|
|
||||||
|
browser_config = BrowserConfig(
|
||||||
|
browser_type="chromium",
|
||||||
|
headless=True,
|
||||||
|
text_mode=True,
|
||||||
|
light_mode=True,
|
||||||
|
enable_stealth=True,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
page_timeout=max(1, int(timeout_seconds * 1000)),
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
remove_overlay_elements=True,
|
||||||
|
remove_consent_popups=True,
|
||||||
|
exclude_social_media_links=True,
|
||||||
|
process_iframes=False,
|
||||||
|
scan_full_page=False,
|
||||||
|
verbose=False,
|
||||||
|
log_console=False,
|
||||||
|
)
|
||||||
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||||
|
return await crawler.arun(url=url, config=run_config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_public_url(value: str) -> str:
|
||||||
|
url = value.strip()
|
||||||
|
if not url or len(url) > 2048:
|
||||||
|
raise ValueError("url must contain at most 2048 characters")
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise ValueError("Only http and https URLs are allowed")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ValueError("Credentials in URLs are not allowed")
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL must include a hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
addresses = [ipaddress.ip_address(hostname)]
|
||||||
|
except ValueError:
|
||||||
|
addresses = await asyncio.to_thread(_resolve_addresses, hostname, parsed.port)
|
||||||
|
if not addresses or any(not address.is_global for address in addresses):
|
||||||
|
raise ValueError("Local and private network URLs are not allowed")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_addresses(hostname: str, port: int | None) -> Sequence[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||||
|
try:
|
||||||
|
records = socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise ValueError("URL hostname could not be resolved") from exc
|
||||||
|
return tuple({ipaddress.ip_address(record[4][0]) for record in records})
|
||||||
@@ -5,10 +5,12 @@ session_dir = "sessions"
|
|||||||
[[accounts]]
|
[[accounts]]
|
||||||
name = "personal"
|
name = "personal"
|
||||||
session = "personal"
|
session = "personal"
|
||||||
|
use_qr = true
|
||||||
|
|
||||||
[[accounts]]
|
[[accounts]]
|
||||||
name = "work"
|
name = "work"
|
||||||
session = "work"
|
session = "work"
|
||||||
|
use_qr = true
|
||||||
|
|
||||||
[elevenlabs]
|
[elevenlabs]
|
||||||
api_key = "your_elevenlabs_api_key_here"
|
api_key = "your_elevenlabs_api_key_here"
|
||||||
@@ -22,6 +24,8 @@ max_media_bytes = 5242880
|
|||||||
max_tool_rounds = 16
|
max_tool_rounds = 16
|
||||||
brave_api_key = "your_brave_search_api_key_here"
|
brave_api_key = "your_brave_search_api_key_here"
|
||||||
brave_timeout_seconds = 10
|
brave_timeout_seconds = 10
|
||||||
|
web_page_timeout_seconds = 45
|
||||||
|
web_page_max_chars = 60000
|
||||||
|
|
||||||
[ai.glm]
|
[ai.glm]
|
||||||
api_url = "https://api.z.ai/api/paas/v4"
|
api_url = "https://api.z.ai/api/paas/v4"
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Missing Telegram values are rendered as an empty string.
|
|||||||
|
|
||||||
You are a helpful assistant embedded in a Telegram client. Answer in the user's language.
|
You are a helpful assistant embedded in a Telegram client. Answer in the user's language.
|
||||||
|
|
||||||
|
When the request contains or depends on a web-page URL, use read_web_page before analyzing, summarizing, or quoting that page. Use web_search to discover relevant URLs, then read the selected pages with read_web_page before drawing conclusions.
|
||||||
|
|
||||||
The caller is {{caller_user.name}} (id={{caller_user.id}}, username={{caller_user.username}}).
|
The caller is {{caller_user.name}} (id={{caller_user.id}}, username={{caller_user.username}}).
|
||||||
The account owner is {{owner.name}} (id={{owner.id}}, username={{owner.username}}).
|
The account owner is {{owner.name}} (id={{owner.id}}, username={{owner.username}}).
|
||||||
Caller is owner: {{is_owner}}.
|
Caller is owner: {{is_owner}}.
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ version = "0.1.0"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
"crawl4ai==0.9.3",
|
||||||
"kurigram==2.2.24",
|
"kurigram==2.2.24",
|
||||||
"micro-agents==0.1.5",
|
"micro-agents==0.1.5",
|
||||||
"micro-agents-tools==0.1.5",
|
"micro-agents-tools==0.1.5",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
|
"qrcode",
|
||||||
"tgcrypto",
|
"tgcrypto",
|
||||||
"uvloop; sys_platform != 'win32'",
|
"uvloop; sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.agentic import (
|
|||||||
AgentSessionStore,
|
AgentSessionStore,
|
||||||
AgentSyntaxError,
|
AgentSyntaxError,
|
||||||
_build_context_messages,
|
_build_context_messages,
|
||||||
|
_create_llm,
|
||||||
_download_message_media,
|
_download_message_media,
|
||||||
render_prompt_template,
|
render_prompt_template,
|
||||||
parse_agent_invocation,
|
parse_agent_invocation,
|
||||||
@@ -22,6 +23,26 @@ from app.config import AiConfig, AiProviderConfig
|
|||||||
|
|
||||||
|
|
||||||
class AgentInvocationTests(unittest.TestCase):
|
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:
|
def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
|
||||||
value = strip_prompt_comments(
|
value = strip_prompt_comments(
|
||||||
"// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast"
|
"// 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.OpenAIBasedLLM") as llm_class,
|
||||||
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
|
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
|
||||||
patch("app.agentic.BraveSearchTools") as brave_tools,
|
patch("app.agentic.BraveSearchTools") as brave_tools,
|
||||||
|
patch("app.agentic.WebPageTools") as web_page_tools,
|
||||||
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
|
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
|
||||||
):
|
):
|
||||||
brave_tools.return_value.tools.return_value = []
|
brave_tools.return_value.tools.return_value = []
|
||||||
|
web_page_tools.return_value.tools.return_value = []
|
||||||
result = await run_agent_turn(
|
result = await run_agent_turn(
|
||||||
SimpleNamespace(),
|
SimpleNamespace(),
|
||||||
command,
|
command,
|
||||||
@@ -188,6 +211,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
|
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
|
||||||
brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0)
|
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()
|
result.handle.close()
|
||||||
|
|
||||||
async def test_reply_to_ai_response_reuses_existing_session(self) -> None:
|
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"},
|
"glm": {"api_key": "glm-key", "model": "glm-model"},
|
||||||
"deepseek": {"api_key": "ds-key", "model": "ds-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.glm.model, "glm-model")
|
||||||
self.assertEqual(config.deepseek.api_key, "ds-key")
|
self.assertEqual(config.deepseek.api_key, "ds-key")
|
||||||
self.assertEqual(config.deepseek.model, "ds-model")
|
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:
|
def test_ai_provider_env_overrides(self) -> None:
|
||||||
env = {
|
env = {
|
||||||
@@ -59,6 +63,8 @@ class ConfigAiTests(unittest.TestCase):
|
|||||||
"GLM_MODEL": "env-glm-model",
|
"GLM_MODEL": "env-glm-model",
|
||||||
"DEEPSEEK_API_KEY": "env-ds",
|
"DEEPSEEK_API_KEY": "env-ds",
|
||||||
"DEEPSEEK_MODEL": "env-ds-model",
|
"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):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
config = _parse_ai_config({})
|
config = _parse_ai_config({})
|
||||||
@@ -67,6 +73,8 @@ class ConfigAiTests(unittest.TestCase):
|
|||||||
self.assertEqual(config.glm.model, "env-glm-model")
|
self.assertEqual(config.glm.model, "env-glm-model")
|
||||||
self.assertEqual(config.deepseek.api_key, "env-ds")
|
self.assertEqual(config.deepseek.api_key, "env-ds")
|
||||||
self.assertEqual(config.deepseek.model, "env-ds-model")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ session_dir = "sessions"
|
|||||||
[[accounts]]
|
[[accounts]]
|
||||||
name = "personal"
|
name = "personal"
|
||||||
session = "personal"
|
session = "personal"
|
||||||
|
use_qr = true
|
||||||
|
|
||||||
[elevenlabs]
|
[elevenlabs]
|
||||||
api_key = "test-key"
|
api_key = "test-key"
|
||||||
@@ -34,6 +35,7 @@ model_id = "scribe_v2"
|
|||||||
|
|
||||||
self.assertEqual(config.api_id, 123456)
|
self.assertEqual(config.api_id, 123456)
|
||||||
self.assertEqual(config.accounts[0].session, "personal")
|
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.elevenlabs.api_key, "test-key")
|
||||||
self.assertEqual(config.session_dir, root / "sessions")
|
self.assertEqual(config.session_dir, root / "sessions")
|
||||||
self.assertEqual(config.ai.prompt_dir, root / "prompts")
|
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