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

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)