Add temporary AI commands and request transcripts
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user