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
workspace/.process-logs/
workspace/.background-processes.json
workspace/ai-transcript.jsonl*

View File

@@ -8,6 +8,7 @@ import logging
from pathlib import Path
import sys
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from uuid import uuid4
import httpx
from openai import AsyncOpenAI
@@ -16,21 +17,24 @@ from app.ai.parser import AIInvocation
from app.ai.prompts import PromptBook
from app.ai.page_extract import PageExtractor
from app.ai.processes import BackgroundProcessManager
from app.ai.transcript import AITranscriptLogger
from app.ai.tools import (
FILE_TOOL_DISPATCH,
FILE_TOOL_SCHEMAS,
PAGE_EXTRACT_SCHEMA,
PYTHON_EXEC_SCHEMA,
TEMPORARY_COMMAND_CREATE_SCHEMA,
BACKGROUND_LIST_SCHEMA,
BACKGROUND_START_SCHEMA,
BACKGROUND_STOP_SCHEMA,
WEB_SEARCH_SCHEMA,
)
from app.commands.temporary import TemporaryCommandStore
from app.ai.web_search import BraveWebSearchClient, format_results
from app.config import AIConfig, AIModelConfig, AIProviderConfig
if TYPE_CHECKING:
from app.commands.core import CommandContext
from app.commands.core import CommandContext, CommandRegistry
log = logging.getLogger(__name__)
@@ -69,7 +73,7 @@ class HistoryMessage:
class AIService:
def __init__(self, config: AIConfig) -> None:
def __init__(self, config: AIConfig, command_registry: "CommandRegistry") -> None:
self.config = config
self.prompt_book = PromptBook(
config.prompt.root,
@@ -80,10 +84,15 @@ class AIService:
self.web_search = BraveWebSearchClient(config.web_search)
self.page_extractor = PageExtractor(config.page_extract)
self.processes = BackgroundProcessManager(config.workspace_dir)
self.temporary_commands = TemporaryCommandStore(command_registry)
self.transcript = AITranscriptLogger(config.workspace_dir)
async def initialize(self) -> None:
return None
def close(self) -> None:
self.transcript.close()
@property
def enabled(self) -> bool:
return True
@@ -228,6 +237,7 @@ class AIService:
async def _build_tool_list(self) -> list[dict]:
tools = FILE_TOOL_SCHEMAS + [
PYTHON_EXEC_SCHEMA,
TEMPORARY_COMMAND_CREATE_SCHEMA,
BACKGROUND_START_SCHEMA,
BACKGROUND_STOP_SCHEMA,
BACKGROUND_LIST_SCHEMA,
@@ -363,10 +373,22 @@ class AIService:
return f"Tool {name!r}: invalid JSON arguments: {exc}"
try:
if name in {"python_exec", "background_start", "background_stop", "background_list"} and not ctx.is_owner:
result = "Tool access denied: code execution and process control are owner-only"
if name in {
"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":
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":
result = await self.processes.start(
str(arguments.get("name", "")), arguments.get("command"), arguments.get("cwd")
@@ -414,6 +436,8 @@ class AIService:
tools = await self._build_tool_list()
allowed_roots = self._get_allowed_roots()
max_rounds = self.config.tool_max_rounds
request_id = uuid4().hex
repeated_calls: dict[str, int] = {}
for round_num in range(max_rounds + 1):
kwargs: dict[str, Any] = {
@@ -423,12 +447,33 @@ class AIService:
if 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:
response = await client.chat.completions.create(**kwargs)
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)
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]
finish_reason = getattr(choice, "finish_reason", None)
@@ -466,9 +511,27 @@ class AIService:
for tc in tool_calls:
tc_name = getattr(getattr(tc, "function", None), "name", "?")
await self._maybe_call_progress(progress, f"🛠 Calling tool: {tc_name}")
result_text = await self._execute_tool_call(
tc, ctx, allowed_roots, progress
)
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(
tc, ctx, allowed_roots, progress
)
messages.append({
"role": "tool",
"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] = {
"type": "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.persistent import load_persistent_commands
from app.commands.temporary import TemporaryCommandStore
__all__ = [
"CheckResult",
@@ -33,4 +34,5 @@ __all__ = [
"require_reply_check",
"shared_access_check",
"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)
if 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.github_webhook_server = GitHubWebhookServer(self, self.config.github_webhook)
@@ -162,3 +164,5 @@ class AccountManager:
finally:
await self.github_webhook_server.stop()
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`.
- Для тестовой или краткоживущей команды используй `temporary_command_create`: она создаёт owner-only команду в памяти, которая отвечает заданным текстом, и исчезает после перезапуска. Не создавай для этого файлы.
- Для долгоживущих задач (например, веб-сервера) используй `background_start` с argv-массивом без shell. Логи находятся в `workspace/.process-logs/`; для контроля используй `background_list` и `background_stop`.
- Постоянные команды создавай как Python-модули в `workspace/commands/*.py`, импортируя `command_registry` и `CommandContext` из `app.commands`. После перезапуска они загружаются автоматически. По умолчанию команды owner-only; `.shared()` добавляй только по явной просьбе.
- Перед изменением существующей команды прочитай файл и сохраняй совместимость с текущим 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")