Add temporary AI commands and request transcripts

This commit is contained in:
Azimkin
2026-08-28 23:53:42 +02:00
parent d3c02ca203
commit 01d3173254
11 changed files with 355 additions and 8 deletions

1
.gitignore vendored
View File

@@ -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*

View File

@@ -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,9 +511,27 @@ 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}")
result_text = await self._execute_tool_call( raw_arguments = getattr(getattr(tc, "function", None), "arguments", "{}") or "{}"
tc, ctx, allowed_roots, progress 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(
tc, ctx, allowed_roots, progress
)
messages.append({ messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tc.id, "tool_call_id": tc.id,

View File

@@ -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
View 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)

View File

@@ -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
View 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"

View File

@@ -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()

View File

@@ -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.

View 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()

View 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")

View 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")