Add temporary AI commands and request transcripts
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ config.json
|
|||||||
.env
|
.env
|
||||||
workspace/.process-logs/
|
workspace/.process-logs/
|
||||||
workspace/.background-processes.json
|
workspace/.background-processes.json
|
||||||
|
workspace/ai-transcript.jsonl*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
@@ -16,21 +17,24 @@ from app.ai.parser import AIInvocation
|
|||||||
from app.ai.prompts import PromptBook
|
from app.ai.prompts import PromptBook
|
||||||
from app.ai.page_extract import PageExtractor
|
from app.ai.page_extract import PageExtractor
|
||||||
from app.ai.processes import BackgroundProcessManager
|
from app.ai.processes import BackgroundProcessManager
|
||||||
|
from app.ai.transcript import AITranscriptLogger
|
||||||
from app.ai.tools import (
|
from app.ai.tools import (
|
||||||
FILE_TOOL_DISPATCH,
|
FILE_TOOL_DISPATCH,
|
||||||
FILE_TOOL_SCHEMAS,
|
FILE_TOOL_SCHEMAS,
|
||||||
PAGE_EXTRACT_SCHEMA,
|
PAGE_EXTRACT_SCHEMA,
|
||||||
PYTHON_EXEC_SCHEMA,
|
PYTHON_EXEC_SCHEMA,
|
||||||
|
TEMPORARY_COMMAND_CREATE_SCHEMA,
|
||||||
BACKGROUND_LIST_SCHEMA,
|
BACKGROUND_LIST_SCHEMA,
|
||||||
BACKGROUND_START_SCHEMA,
|
BACKGROUND_START_SCHEMA,
|
||||||
BACKGROUND_STOP_SCHEMA,
|
BACKGROUND_STOP_SCHEMA,
|
||||||
WEB_SEARCH_SCHEMA,
|
WEB_SEARCH_SCHEMA,
|
||||||
)
|
)
|
||||||
|
from app.commands.temporary import TemporaryCommandStore
|
||||||
from app.ai.web_search import BraveWebSearchClient, format_results
|
from app.ai.web_search import BraveWebSearchClient, format_results
|
||||||
from app.config import AIConfig, AIModelConfig, AIProviderConfig
|
from app.config import AIConfig, AIModelConfig, AIProviderConfig
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.commands.core import CommandContext
|
from app.commands.core import CommandContext, CommandRegistry
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -69,7 +73,7 @@ class HistoryMessage:
|
|||||||
|
|
||||||
|
|
||||||
class AIService:
|
class AIService:
|
||||||
def __init__(self, config: AIConfig) -> None:
|
def __init__(self, config: AIConfig, command_registry: "CommandRegistry") -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.prompt_book = PromptBook(
|
self.prompt_book = PromptBook(
|
||||||
config.prompt.root,
|
config.prompt.root,
|
||||||
@@ -80,10 +84,15 @@ class AIService:
|
|||||||
self.web_search = BraveWebSearchClient(config.web_search)
|
self.web_search = BraveWebSearchClient(config.web_search)
|
||||||
self.page_extractor = PageExtractor(config.page_extract)
|
self.page_extractor = PageExtractor(config.page_extract)
|
||||||
self.processes = BackgroundProcessManager(config.workspace_dir)
|
self.processes = BackgroundProcessManager(config.workspace_dir)
|
||||||
|
self.temporary_commands = TemporaryCommandStore(command_registry)
|
||||||
|
self.transcript = AITranscriptLogger(config.workspace_dir)
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.transcript.close()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return True
|
return True
|
||||||
@@ -228,6 +237,7 @@ class AIService:
|
|||||||
async def _build_tool_list(self) -> list[dict]:
|
async def _build_tool_list(self) -> list[dict]:
|
||||||
tools = FILE_TOOL_SCHEMAS + [
|
tools = FILE_TOOL_SCHEMAS + [
|
||||||
PYTHON_EXEC_SCHEMA,
|
PYTHON_EXEC_SCHEMA,
|
||||||
|
TEMPORARY_COMMAND_CREATE_SCHEMA,
|
||||||
BACKGROUND_START_SCHEMA,
|
BACKGROUND_START_SCHEMA,
|
||||||
BACKGROUND_STOP_SCHEMA,
|
BACKGROUND_STOP_SCHEMA,
|
||||||
BACKGROUND_LIST_SCHEMA,
|
BACKGROUND_LIST_SCHEMA,
|
||||||
@@ -363,10 +373,22 @@ class AIService:
|
|||||||
return f"Tool {name!r}: invalid JSON arguments: {exc}"
|
return f"Tool {name!r}: invalid JSON arguments: {exc}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name in {"python_exec", "background_start", "background_stop", "background_list"} and not ctx.is_owner:
|
if name in {
|
||||||
result = "Tool access denied: code execution and process control are owner-only"
|
"python_exec",
|
||||||
|
"temporary_command_create",
|
||||||
|
"background_start",
|
||||||
|
"background_stop",
|
||||||
|
"background_list",
|
||||||
|
} and not ctx.is_owner:
|
||||||
|
result = "Tool access denied: temporary commands, code execution, and process control are owner-only"
|
||||||
elif name == "python_exec":
|
elif name == "python_exec":
|
||||||
result = await self._execute_python_exec(arguments, ctx)
|
result = await self._execute_python_exec(arguments, ctx)
|
||||||
|
elif name == "temporary_command_create":
|
||||||
|
result = self.temporary_commands.create(
|
||||||
|
str(arguments.get("name", "")),
|
||||||
|
str(arguments.get("response", "")),
|
||||||
|
str(arguments.get("description", "")) or None,
|
||||||
|
)
|
||||||
elif name == "background_start":
|
elif name == "background_start":
|
||||||
result = await self.processes.start(
|
result = await self.processes.start(
|
||||||
str(arguments.get("name", "")), arguments.get("command"), arguments.get("cwd")
|
str(arguments.get("name", "")), arguments.get("command"), arguments.get("cwd")
|
||||||
@@ -414,6 +436,8 @@ class AIService:
|
|||||||
tools = await self._build_tool_list()
|
tools = await self._build_tool_list()
|
||||||
allowed_roots = self._get_allowed_roots()
|
allowed_roots = self._get_allowed_roots()
|
||||||
max_rounds = self.config.tool_max_rounds
|
max_rounds = self.config.tool_max_rounds
|
||||||
|
request_id = uuid4().hex
|
||||||
|
repeated_calls: dict[str, int] = {}
|
||||||
|
|
||||||
for round_num in range(max_rounds + 1):
|
for round_num in range(max_rounds + 1):
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
@@ -423,12 +447,33 @@ class AIService:
|
|||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
|
|
||||||
|
self.transcript.record(
|
||||||
|
"request",
|
||||||
|
request_id=request_id,
|
||||||
|
round=round_num,
|
||||||
|
model_alias=model.alias,
|
||||||
|
provider=model.provider.name,
|
||||||
|
payload=kwargs,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = await client.chat.completions.create(**kwargs)
|
response = await client.chat.completions.create(**kwargs)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self.transcript.record(
|
||||||
|
"request_error",
|
||||||
|
request_id=request_id,
|
||||||
|
round=round_num,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
log.exception("AI request failed for model %s", model.alias)
|
log.exception("AI request failed for model %s", model.alias)
|
||||||
raise RuntimeError(f"AI request failed: {exc}") from exc
|
raise RuntimeError(f"AI request failed: {exc}") from exc
|
||||||
|
|
||||||
|
self.transcript.record(
|
||||||
|
"response",
|
||||||
|
request_id=request_id,
|
||||||
|
round=round_num,
|
||||||
|
payload=response,
|
||||||
|
)
|
||||||
|
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
finish_reason = getattr(choice, "finish_reason", None)
|
finish_reason = getattr(choice, "finish_reason", None)
|
||||||
|
|
||||||
@@ -466,6 +511,24 @@ class AIService:
|
|||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
tc_name = getattr(getattr(tc, "function", None), "name", "?")
|
tc_name = getattr(getattr(tc, "function", None), "name", "?")
|
||||||
await self._maybe_call_progress(progress, f"🛠 Calling tool: {tc_name}")
|
await self._maybe_call_progress(progress, f"🛠 Calling tool: {tc_name}")
|
||||||
|
raw_arguments = getattr(getattr(tc, "function", None), "arguments", "{}") or "{}"
|
||||||
|
try:
|
||||||
|
signature_arguments = json.dumps(json.loads(raw_arguments), sort_keys=True)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
signature_arguments = str(raw_arguments)
|
||||||
|
signature = f"{tc_name}:{signature_arguments}"
|
||||||
|
repeated_calls[signature] = repeated_calls.get(signature, 0) + 1
|
||||||
|
if repeated_calls[signature] > 2:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Agentic loop repeated the identical {tc_name!r} tool call more than twice"
|
||||||
|
)
|
||||||
|
if repeated_calls[signature] > 1:
|
||||||
|
result_text = (
|
||||||
|
f"The identical {tc_name!r} call has already been performed. "
|
||||||
|
"Do not call it again; provide the final answer now."
|
||||||
|
)
|
||||||
|
await self._maybe_call_progress(progress, f"🛠 Suppressed duplicate tool call: {tc_name}")
|
||||||
|
else:
|
||||||
result_text = await self._execute_tool_call(
|
result_text = await self._execute_tool_call(
|
||||||
tc, ctx, allowed_roots, progress
|
tc, ctx, allowed_roots, progress
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,6 +219,27 @@ PYTHON_EXEC_SCHEMA: dict[str, Any] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEMPORARY_COMMAND_CREATE_SCHEMA: dict[str, Any] = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "temporary_command_create",
|
||||||
|
"description": (
|
||||||
|
"Create a simple owner-only Telegram command that replies with fixed text. "
|
||||||
|
"It exists only in memory and is removed when the bot restarts. Use this for "
|
||||||
|
"test or short-lived commands instead of writing a persistent command module."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Command name without the leading dot"},
|
||||||
|
"response": {"type": "string", "description": "Text returned when the command is used"},
|
||||||
|
"description": {"type": "string", "description": "Optional help text for the command"},
|
||||||
|
},
|
||||||
|
"required": ["name", "response"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
BACKGROUND_START_SCHEMA: dict[str, Any] = {
|
BACKGROUND_START_SCHEMA: dict[str, Any] = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
|
|||||||
63
app/ai/transcript.py
Normal file
63
app/ai/transcript.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
"""Convert SDK objects to values suitable for a JSONL audit trail."""
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
model_dump = getattr(value, "model_dump", None)
|
||||||
|
if callable(model_dump):
|
||||||
|
return _json_value(model_dump(exclude_none=True))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AITranscriptLogger:
|
||||||
|
"""Writes complete provider requests and responses to a dedicated rotating JSONL file."""
|
||||||
|
|
||||||
|
def __init__(self, workspace_dir: Path) -> None:
|
||||||
|
self.path = workspace_dir / "ai-transcript.jsonl"
|
||||||
|
self._logger = logging.getLogger(f"{__name__}.{id(self)}")
|
||||||
|
self._logger.setLevel(logging.INFO)
|
||||||
|
self._logger.propagate = False
|
||||||
|
handler = logging.handlers.RotatingFileHandler(
|
||||||
|
self.path,
|
||||||
|
maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8",
|
||||||
|
delay=True,
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
self._logger.addHandler(handler)
|
||||||
|
|
||||||
|
def record(self, event: str, **payload: Any) -> None:
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"event": event,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._logger.info(json.dumps(_json_value(entry), ensure_ascii=False, separators=(",", ":")))
|
||||||
|
except OSError:
|
||||||
|
log.exception("Failed to write AI transcript to %s", self.path)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
for handler in list(self._logger.handlers):
|
||||||
|
handler.close()
|
||||||
|
self._logger.removeHandler(handler)
|
||||||
@@ -15,6 +15,7 @@ from app.commands.core import (
|
|||||||
)
|
)
|
||||||
from app.commands.dispatcher import CommandDispatcher
|
from app.commands.dispatcher import CommandDispatcher
|
||||||
from app.commands.persistent import load_persistent_commands
|
from app.commands.persistent import load_persistent_commands
|
||||||
|
from app.commands.temporary import TemporaryCommandStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CheckResult",
|
"CheckResult",
|
||||||
@@ -33,4 +34,5 @@ __all__ = [
|
|||||||
"require_reply_check",
|
"require_reply_check",
|
||||||
"shared_access_check",
|
"shared_access_check",
|
||||||
"load_persistent_commands",
|
"load_persistent_commands",
|
||||||
|
"TemporaryCommandStore",
|
||||||
]
|
]
|
||||||
|
|||||||
47
app/commands/temporary.py
Normal file
47
app/commands/temporary.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.commands.core import CommandRegistry
|
||||||
|
|
||||||
|
|
||||||
|
_COMMAND_NAME_RE = re.compile(r"[a-z][a-z0-9_-]{0,31}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TemporaryCommandStore:
|
||||||
|
"""Registers simple commands that exist only for this bot process."""
|
||||||
|
|
||||||
|
registry: CommandRegistry
|
||||||
|
names: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def create(self, name: str, response: str, description: str | None = None) -> str:
|
||||||
|
normalized_name = name.strip().lower()
|
||||||
|
if not _COMMAND_NAME_RE.fullmatch(normalized_name):
|
||||||
|
raise ValueError(
|
||||||
|
"temporary_command_create: name must start with a letter and contain only "
|
||||||
|
"letters, digits, '-' or '_' (up to 32 characters)"
|
||||||
|
)
|
||||||
|
if self.registry.has_name(normalized_name):
|
||||||
|
raise ValueError(f"temporary_command_create: command .{normalized_name} already exists")
|
||||||
|
|
||||||
|
text = response.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("temporary_command_create: response must not be empty")
|
||||||
|
if len(text) > 3900:
|
||||||
|
raise ValueError("temporary_command_create: response must be at most 3900 characters")
|
||||||
|
|
||||||
|
command_description = (description or "Temporary AI-created command").strip()
|
||||||
|
|
||||||
|
async def handler(ctx: object) -> None:
|
||||||
|
await ctx.reply(text) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
self.registry.command(
|
||||||
|
normalized_name,
|
||||||
|
command_description,
|
||||||
|
usage=f".{normalized_name}",
|
||||||
|
category="temporary",
|
||||||
|
)(handler)
|
||||||
|
self.names.add(normalized_name)
|
||||||
|
return f"Created temporary owner-only command .{normalized_name}; it will be removed on bot restart"
|
||||||
@@ -41,7 +41,9 @@ class AccountManager:
|
|||||||
loaded = load_persistent_commands(self.config.ai.workspace_dir / "commands", self.command_registry)
|
loaded = load_persistent_commands(self.config.ai.workspace_dir / "commands", self.command_registry)
|
||||||
if loaded:
|
if loaded:
|
||||||
log.info("Loaded persistent commands: %s", ", ".join(loaded))
|
log.info("Loaded persistent commands: %s", ", ".join(loaded))
|
||||||
self.ai_service = AIService(self.config.ai) if self.config.ai is not None else None
|
self.ai_service = (
|
||||||
|
AIService(self.config.ai, self.command_registry) if self.config.ai is not None else None
|
||||||
|
)
|
||||||
self.states = [AccountState(config=account) for account in self.config.accounts]
|
self.states = [AccountState(config=account) for account in self.config.accounts]
|
||||||
self.github_webhook_server = GitHubWebhookServer(self, self.config.github_webhook)
|
self.github_webhook_server = GitHubWebhookServer(self, self.config.github_webhook)
|
||||||
|
|
||||||
@@ -162,3 +164,5 @@ class AccountManager:
|
|||||||
finally:
|
finally:
|
||||||
await self.github_webhook_server.stop()
|
await self.github_webhook_server.stop()
|
||||||
await self.stop_all()
|
await self.stop_all()
|
||||||
|
if self.ai_service is not None:
|
||||||
|
self.ai_service.close()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Инструменты выполнения доступны только владельцу аккаунта.
|
Инструменты выполнения доступны только владельцу аккаунта.
|
||||||
|
|
||||||
- Для коротких вычислений и проверки кода используй `python_exec`; в `ctx` доступны `chat_id`, `message_id`, `message_text`, `args`, `from_user` и `replied_message`.
|
- Для коротких вычислений и проверки кода используй `python_exec`; в `ctx` доступны `chat_id`, `message_id`, `message_text`, `args`, `from_user` и `replied_message`.
|
||||||
|
- Для тестовой или краткоживущей команды используй `temporary_command_create`: она создаёт owner-only команду в памяти, которая отвечает заданным текстом, и исчезает после перезапуска. Не создавай для этого файлы.
|
||||||
- Для долгоживущих задач (например, веб-сервера) используй `background_start` с argv-массивом без shell. Логи находятся в `workspace/.process-logs/`; для контроля используй `background_list` и `background_stop`.
|
- Для долгоживущих задач (например, веб-сервера) используй `background_start` с argv-массивом без shell. Логи находятся в `workspace/.process-logs/`; для контроля используй `background_list` и `background_stop`.
|
||||||
- Постоянные команды создавай как Python-модули в `workspace/commands/*.py`, импортируя `command_registry` и `CommandContext` из `app.commands`. После перезапуска они загружаются автоматически. По умолчанию команды owner-only; `.shared()` добавляй только по явной просьбе.
|
- Постоянные команды создавай как Python-модули в `workspace/commands/*.py`, импортируя `command_registry` и `CommandContext` из `app.commands`. После перезапуска они загружаются автоматически. По умолчанию команды owner-only; `.shared()` добавляй только по явной просьбе.
|
||||||
- Перед изменением существующей команды прочитай файл и сохраняй совместимость с текущим API.
|
- Перед изменением существующей команды прочитай файл и сохраняй совместимость с текущим API.
|
||||||
|
|||||||
92
tests/test_ai_tool_loop.py
Normal file
92
tests/test_ai_tool_loop.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.ai.service import AIService
|
||||||
|
from app.commands import CommandRegistry
|
||||||
|
from app.config import AIConfig, AIModelConfig, AIPromptConfig, AIProviderConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_response(call_id: str) -> SimpleNamespace:
|
||||||
|
call = SimpleNamespace(
|
||||||
|
id=call_id,
|
||||||
|
function=SimpleNamespace(
|
||||||
|
name="temporary_command_create",
|
||||||
|
arguments=json.dumps({"name": "test", "response": "ok"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
message=SimpleNamespace(content=None, tool_calls=[call]),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCompletions:
|
||||||
|
def __init__(self, responses: list[SimpleNamespace]) -> None:
|
||||||
|
self.responses = responses
|
||||||
|
self.requests: list[dict] = []
|
||||||
|
|
||||||
|
async def create(self, **kwargs: object) -> SimpleNamespace:
|
||||||
|
self.requests.append(dict(kwargs))
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class AIToolLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_suppresses_duplicate_tool_call_and_returns_final_answer(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
config = AIConfig(
|
||||||
|
default_model="test",
|
||||||
|
context_messages=0,
|
||||||
|
prompt=AIPromptConfig(root=root),
|
||||||
|
providers={"provider": AIProviderConfig("provider", "key", "https://example.test")},
|
||||||
|
models={"test": AIModelConfig("test", "provider", "model")},
|
||||||
|
workspace_dir=root / "workspace",
|
||||||
|
)
|
||||||
|
registry = CommandRegistry()
|
||||||
|
service = AIService(config, registry)
|
||||||
|
completions = _FakeCompletions(
|
||||||
|
[
|
||||||
|
_tool_response("one"),
|
||||||
|
_tool_response("two"),
|
||||||
|
SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
finish_reason="stop",
|
||||||
|
message=SimpleNamespace(content="Created .test", tool_calls=[]),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service._clients["provider"] = SimpleNamespace(
|
||||||
|
chat=SimpleNamespace(completions=completions)
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(
|
||||||
|
is_owner=True,
|
||||||
|
actor=None,
|
||||||
|
message=SimpleNamespace(chat=SimpleNamespace(id=1), id=1, text=".ai test"),
|
||||||
|
args="test",
|
||||||
|
replied_message=None,
|
||||||
|
replied_user=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.generate(ctx, SimpleNamespace(context_messages=0, model_flags=[], prompt="create test"))
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
self.assertEqual(response.text, "Created .test")
|
||||||
|
self.assertIsNotNone(registry.resolve("test"))
|
||||||
|
self.assertEqual(len(completions.requests), 3)
|
||||||
|
self.assertIn("already been performed", completions.requests[2]["messages"][-1]["content"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
22
tests/test_ai_transcript.py
Normal file
22
tests/test_ai_transcript.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.ai.transcript import AITranscriptLogger
|
||||||
|
|
||||||
|
|
||||||
|
class AITranscriptLoggerTests(unittest.TestCase):
|
||||||
|
def test_writes_jsonl_events(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
logger = AITranscriptLogger(Path(temp_dir))
|
||||||
|
logger.record("request", payload={"model": "test", "messages": []})
|
||||||
|
logger.record("response", payload={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
events = [json.loads(line) for line in logger.path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
logger.close()
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["request", "response"])
|
||||||
|
self.assertEqual(events[1]["payload"]["choices"][0]["message"]["content"], "ok")
|
||||||
31
tests/test_temporary_commands.py
Normal file
31
tests/test_temporary_commands.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.commands import CommandRegistry, TemporaryCommandStore
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryCommandStoreTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_created_command_is_owner_only_and_replies_from_memory(self) -> None:
|
||||||
|
registry = CommandRegistry()
|
||||||
|
store = TemporaryCommandStore(registry)
|
||||||
|
|
||||||
|
message = store.create("test-command", "Temporary response", "Test command")
|
||||||
|
command = registry.resolve("test-command")
|
||||||
|
|
||||||
|
self.assertIn(".test-command", message)
|
||||||
|
self.assertIsNotNone(command)
|
||||||
|
self.assertFalse(command.is_shared)
|
||||||
|
ctx = SimpleNamespace(reply=AsyncMock())
|
||||||
|
await command.handler(ctx)
|
||||||
|
ctx.reply.assert_awaited_once_with("Temporary response")
|
||||||
|
|
||||||
|
def test_rejects_existing_command_name(self) -> None:
|
||||||
|
registry = CommandRegistry()
|
||||||
|
store = TemporaryCommandStore(registry)
|
||||||
|
store.create("test", "First")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||||
|
store.create("test", "Second")
|
||||||
Reference in New Issue
Block a user