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