Files
discord_bot/fjerkroa_bot/openai_responder.py
T

741 lines
38 KiB
Python

import asyncio
import base64
import hashlib
import json
import logging
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
import openai
from .ai_responder import AIResponder, exponential_backoff, sanitize_external_text
from .codex import CODEX_SEARCH_TOOL
from .codex import DEFAULT_LIMIT as CODEX_DEFAULT_LIMIT
from .codex import CodexSearch
from .igdblib import IGDBQuery
from .leonardo_draw import LeonardoAIDrawMixIn
from .news import GET_NEWS_TOOL, query_news
from .quota import QuotaLedger
from .url_reader import FETCH_URL_TOOL, URLReader
from .weather import GET_WEATHER_TOOL, Weather
from .websearch import DEFAULT_RESULTS as WEB_DEFAULT_RESULTS
from .websearch import WEB_SEARCH_TOOL, WebSearch
# The response envelope, enforced server-side via structured outputs
# (ENV-19). All fields required, closed object, nullable where the
# protocol allows null.
ENVELOPE_SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": ["string", "null"], "description": "The message to post, or null when staying silent."},
"answer_needed": {"type": "boolean", "description": "Whether the answer should actually be posted."},
"channel": {"type": ["string", "null"], "description": "Target channel name, or null for the origin channel."},
"staff": {"type": ["string", "null"], "description": "Alert text for the staff channel, or null."},
"picture": {"type": ["string", "null"], "description": "Image generation prompt, or null."},
"picture_count": {"type": "integer", "description": "How many images to generate (1-4), 1 unless more were asked for."},
"picture_edit": {"type": "boolean", "description": "Whether the picture refers to an earlier image."},
"hack": {"type": "boolean", "description": "Whether the user tried to manipulate the assistant."},
},
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_count", "picture_edit", "hack"],
"additionalProperties": False,
}
ENVELOPE_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
# Same schema in the Responses API shape (ENV-22): text.format is flat, not nested under json_schema
ENVELOPE_TEXT_FORMAT = {"format": {"type": "json_schema", "name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
DEFAULT_RESPONSES_TOOL_ROUNDS = 4
# Consolidation output (SPEC-002 MEM-02/03): new self-authored facts + one episode summary
CONSOLIDATION_SCHEMA = {
"type": "object",
"properties": {
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"user": {"type": "string", "description": "The user the fact is about — only facts users stated about themselves."},
"fact": {"type": "string", "description": "One short durable fact (name, preference, running joke, life event)."},
},
"required": ["user", "fact"],
"additionalProperties": False,
},
},
"episode": {"type": ["string", "null"], "description": "2-3 sentence summary of the conversation, or null if nothing happened."},
},
"required": ["facts", "episode"],
"additionalProperties": False,
}
CONSOLIDATION_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {"name": "consolidation", "strict": True, "schema": CONSOLIDATION_SCHEMA},
}
# Follow-up task proposal (SPEC-005 TSK-08): one task or null
TASKGEN_SCHEMA = {
"type": "object",
"properties": {
"task": {
"type": ["object", "null"],
"properties": {
"channel": {"type": ["string", "null"], "description": "Target channel, or null for the main chat channel."},
"prompt": {"type": "string", "description": "Instruction the assistant will act on when the task runs."},
"due_hours": {"type": "number", "description": "Hours from now until the task should run (0 = now)."},
},
"required": ["channel", "prompt", "due_hours"],
"additionalProperties": False,
}
},
"required": ["task"],
"additionalProperties": False,
}
TASKGEN_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "task_proposal", "strict": True, "schema": TASKGEN_SCHEMA}}
TASKGEN_SYSTEM = (
"You plan the self-initiated actions of a Discord assistant. Given recent conversation summaries, propose AT MOST ONE follow-up"
" worth doing on the assistant's own initiative (ask how something announced went, revisit an open question, congratulate on an"
" event). Only propose something genuinely worthwhile — when in doubt, return a null task."
)
# Reply/ignore + factual pre-pass (SPEC-010 BEH-01): one cheap call
CLASSIFIER_SCHEMA = {
"type": "object",
"properties": {
"reply": {"type": "boolean", "description": "Should the assistant answer this message?"},
"factual": {"type": "boolean", "description": "Does the user want concrete information (hours, prices, availability)?"},
"emoji": {"type": ["string", "null"], "description": "Optional single emoji reaction when not replying, else null."},
},
"required": ["reply", "factual", "emoji"],
"additionalProperties": False,
}
CLASSIFIER_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {"name": "reply_verdict", "strict": True, "schema": CLASSIFIER_SCHEMA},
}
CLASSIFIER_SYSTEM = (
"You watch a group chat that has an assistant bot. Decide whether the assistant should answer the LAST message:"
" reply=true when it addresses the assistant, asks something the assistant can help with, or continues a conversation"
" with the assistant; reply=false for human-to-human chatter the assistant should not butt into."
" factual=true when the user wants concrete information (opening hours, prices, availability, addresses)."
" When reply=false you may suggest one fitting emoji reaction, else null."
)
CONSOLIDATION_SYSTEM = (
"You maintain the long-term memory of a Discord assistant. From the observation log, extract NEW durable facts that users stated"
" about THEMSELVES only (never record what one user claims about another user), and write one short episode summary of the"
" conversation. Skip facts already known. Return an empty facts list and a null episode when there is nothing durable."
)
async def openai_chat(client, *args, **kwargs):
return await client.chat.completions.create(*args, **kwargs)
async def openai_responses(client, *args, **kwargs):
return await client.responses.create(*args, **kwargs)
async def openai_image(client, *args, **kwargs):
return await client.images.generate(*args, **kwargs)
async def openai_image_edit(client, *args, **kwargs):
return await client.images.edit(*args, **kwargs)
class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", "")))
# After a rate limit the next attempt runs on retry-model (ENV-15 / D2)
self._use_retry_model = False
# Daily usage metering + hard budget, fail-closed (SAF-04/05)
self.ledger = QuotaLedger(self.store, lambda: self.config)
# Initialize IGDB if enabled
self.igdb = None
igdb_client_id = self.config.get("igdb-client-id")
igdb_client_secret = self.config.get("igdb-client-secret")
igdb_access_token = self.config.get("igdb-access-token")
logging.info("IGDB Configuration Check:")
logging.info(f" enable-game-info: {self.config.get('enable-game-info', 'NOT SET')}")
logging.info(f" igdb-client-id: {'SET' if igdb_client_id else 'NOT SET'}")
logging.info(f" igdb-client-secret: {'SET' if igdb_client_secret else 'NOT SET'}")
logging.info(f" igdb-access-token: {'SET' if igdb_access_token else 'NOT SET'}")
if self.config.get("enable-game-info", False) and igdb_client_id and (igdb_client_secret or igdb_access_token):
try:
self.igdb = IGDBQuery(igdb_client_id, igdb_access_token, client_secret=igdb_client_secret)
logging.info("✅ IGDB integration SUCCESSFULLY enabled for game information")
logging.info(f" Client ID: {igdb_client_id[:8]}...")
logging.info(f" Available functions: {len(self.igdb.get_openai_functions())}")
except Exception as e:
logging.error(f"❌ Failed to initialize IGDB: {e}")
self.igdb = None
else:
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
# URL reading tool (SPEC-011); shares the image cache for page images
self.url_reader = URLReader(lambda: self.config, self.image_cache)
# Codex Mechanicus search (SPEC-014); Luma's own archive at binaric.tech
self.codex = CodexSearch(lambda: self.config)
# Web search (SPEC-015) via Exa; general "look it up" beyond fetch_url/news/codex
self.web_search = WebSearch(lambda: self.config)
self.weather = Weather(lambda: self.config)
def _available_tools(self) -> List[Dict[str, Any]]:
"""Assemble the function-tool list from every enabled provider (URL-01)."""
functions: List[Dict[str, Any]] = []
if self.igdb and self.config.get("enable-game-info", False):
try:
igdb_functions = self.igdb.get_openai_functions()
if isinstance(igdb_functions, list):
functions.extend(igdb_functions)
except (TypeError, AttributeError) as err:
logging.warning(f"Error setting up IGDB functions: {err}")
if self.url_reader.enabled():
functions.append(FETCH_URL_TOOL)
if self.codex.enabled(): # CDX-01
functions.append(CODEX_SEARCH_TOOL)
if self.config.get("enable-news-tool", False) and self.store is not None: # NEWS-10
functions.append(GET_NEWS_TOOL)
if self.web_search.enabled(): # WEB-01
functions.append(WEB_SEARCH_TOOL)
if self.weather.enabled(): # WEA-01
functions.append(GET_WEATHER_TOOL)
return functions
async def _dispatch_tool(self, name: str, args: Dict[str, Any], author: str) -> Any:
"""Route a tool call to its provider (IGDB, URL reader, or codex)."""
if name == "fetch_url":
per_user_cap = int(self.config.get("url-daily-per-user", 20))
if self.ledger._get(f"url-fetch:{author}") >= per_user_cap: # URL-07
return {"error": "daily URL fetch limit reached"}
self.ledger._add(f"url-fetch:{author}", 1)
return await self.url_reader.fetch(str(args.get("url", "")), self.channel, author or "user")
if name == "codex_search":
per_user_cap = int(self.config.get("codex-daily-per-user", 50))
if self.ledger._get(f"codex:{author}") >= per_user_cap: # CDX-06
return {"error": "daily codex search limit reached"}
self.ledger._add(f"codex:{author}", 1)
limit = int(self.config.get("codex-limit", CODEX_DEFAULT_LIMIT))
return await self.codex.search(str(args.get("query", "")), str(args.get("lang", "en")), limit)
if name == "get_news":
per_user_cap = int(self.config.get("news-daily-per-user", 30))
if self.ledger._get(f"news:{author}") >= per_user_cap: # NEWS-12
return {"error": "daily news lookup limit reached"}
self.ledger._add(f"news:{author}", 1)
summary_chars = int(self.config.get("news-summary-chars", 200))
return query_news(self.store, args.get("topic"), args.get("source"), args.get("limit", 10), summary_chars)
if name == "web_search":
per_user_cap = int(self.config.get("web-daily-per-user", 30))
if self.ledger._get(f"web:{author}") >= per_user_cap: # WEB-05
return {"error": "daily web search limit reached"}
self.ledger._add(f"web:{author}", 1)
return await self.web_search.search(str(args.get("query", "")), int(args.get("num_results", WEB_DEFAULT_RESULTS)))
if name == "get_weather":
per_user_cap = int(self.config.get("weather-daily-per-user", 30))
if self.ledger._get(f"weather:{author}") >= per_user_cap: # WEA-04
return {"error": "daily weather lookup limit reached"}
self.ledger._add(f"weather:{author}", 1)
return await self.weather.forecast(args.get("location"))
return await self._execute_igdb_function(name, args)
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
if not self.ledger.budget_ok():
raise RuntimeError("daily budget exhausted - refusing image call")
model = self.config.get("image-model", "gpt-image-2")
kwargs: Dict[str, Any] = {"model": model, "prompt": description, "size": self.config.get("image-size", "1024x1024")}
if "image-quality" in self.config:
kwargs["quality"] = self.config["image-quality"]
if model.startswith("gpt-image"):
kwargs["n"] = max(1, min(int(count), 4))
else:
# legacy models: single image, base64 must be requested (IMG-04)
kwargs["n"] = 1
kwargs["response_format"] = "b64_json"
for _ in range(3):
try:
response = await openai_image(self.client, **kwargs)
buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data]
self.ledger.add_images(len(buffers))
logging.info(f"generated {len(buffers)} image(s) on {model} for: {repr(description)}")
return buffers
except Exception as err:
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
@staticmethod
def _last_author(messages: List[Dict[str, Any]]) -> Optional[str]:
try:
content = messages[-1]["content"]
if not isinstance(content, str):
content = content[0]["text"]
return str(json.loads(content).get("user")) or None
except Exception:
return None
def _record_usage(self, result: Any) -> None:
usage = getattr(result, "usage", None)
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
if not isinstance(prompt_tokens, int): # Responses API names them input/output (ENV-22)
prompt_tokens = getattr(usage, "input_tokens", None)
if not isinstance(completion_tokens, int):
completion_tokens = getattr(usage, "output_tokens", None)
if isinstance(prompt_tokens, int) and isinstance(completion_tokens, int):
self.ledger.add_tokens(prompt_tokens, completion_tokens)
@staticmethod
def _responses_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Chat-format history -> Responses input items; vision parts become input_image (ENV-22)."""
items: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role == "tool":
continue
content = msg.get("content")
if isinstance(content, list):
parts: List[Dict[str, Any]] = []
for part in content:
if part.get("type") == "text":
parts.append({"type": "input_text", "text": part.get("text", "")})
elif part.get("type") == "image_url":
parts.append({"type": "input_image", "image_url": part.get("image_url", {}).get("url", "")})
items.append({"role": role, "content": parts})
else:
items.append({"role": role, "content": str(content)})
return items
@staticmethod
def _responses_refused(result: Any) -> bool:
for item in getattr(result, "output", []) or []:
if getattr(item, "type", None) == "message":
for part in getattr(item, "content", []) or []:
if getattr(part, "type", None) == "refusal":
return True
return False
async def _chat_via_responses(self, messages: List[Dict[str, Any]], limit: int, model: str) -> Tuple[Optional[Dict[str, Any]], int]:
"""Responder call via /v1/responses: tools + reasoning allowed, stateless with encrypted reasoning (ENV-22/23)."""
context: List[Any] = self._responses_input(messages)
kwargs: Dict[str, Any] = {
"model": model,
"input": context,
"text": ENVELOPE_TEXT_FORMAT,
"store": False, # nothing retained server-side (ENV-23)
"include": ["reasoning.encrypted_content"],
"reasoning": {"effort": str(self.config.get("reasoning-effort", "none"))},
}
author = self._last_author(messages)
if author:
# hashed, never the raw Discord name (SAF-10)
kwargs["safety_identifier"] = "discord-" + hashlib.sha256(author.encode()).hexdigest()[:16]
available_tools = self._available_tools()
if available_tools:
kwargs["tools"] = [{"type": "function", **func} for func in available_tools]
kwargs["tool_choice"] = "auto"
logging.info(f"🔧 Tools available to AI: {[func['name'] for func in available_tools]}")
rounds = int(self.config.get("responses-tool-rounds", DEFAULT_RESPONSES_TOOL_ROUNDS))
for _ in range(max(1, rounds) + 1):
result = await openai_responses(self.client, **kwargs)
self._record_usage(result)
if self._responses_refused(result):
logging.warning("model refused (responses path)") # ENV-24
return None, limit
calls = [item for item in (getattr(result, "output", []) or []) if getattr(item, "type", None) == "function_call"]
if not calls or "tools" not in kwargs:
answer = {"content": getattr(result, "output_text", None) or "", "role": "assistant"}
self.rate_limit_backoff = exponential_backoff()
self._use_retry_model = False
logging.info(f"generated response {getattr(result, 'usage', None)}: {repr(answer)}")
return answer, limit
tool_names = [call.name for call in calls]
logging.info(f"🔧 OpenAI requested function calls: {tool_names}")
# Pass ALL output items back — reasoning items keep the chain of thought (ENV-23)
context = context + [item if isinstance(item, dict) else item.model_dump() for item in result.output]
for call in calls:
function_args = json.loads(call.arguments) if call.arguments else {}
logging.info(f"🔧 Executing tool: {call.name} with args: {function_args}")
function_result = await self._dispatch_tool(call.name, function_args, author or "")
logging.info(f"🔧 Tool result: {type(function_result)} - {str(function_result)[:200]}...")
context.append(
{
"type": "function_call_output",
"call_id": call.call_id,
# tool text is external input — sanitize before prompting (SAF-03)
"output": sanitize_external_text(json.dumps(function_result), 8000) if function_result else "No results found",
}
)
kwargs["input"] = context
rounds -= 1
if rounds <= 0:
# loop exhausted: force a tool-less final answer (ENV-23)
kwargs.pop("tools", None)
kwargs.pop("tool_choice", None)
return None, limit
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
# Safety check for mock objects in tests
if not isinstance(messages, list) or len(messages) == 0:
logging.warning("Invalid messages format in chat method")
return None, limit
# Hard daily budget, fail-closed (SAF-04)
if not self.ledger.budget_ok():
logging.error("daily budget exhausted - refusing model call")
return None, limit
try:
# Clean up any orphaned tool messages from previous conversations
clean_messages = []
for i, msg in enumerate(messages):
if msg.get("role") == "tool":
# Skip tool messages that don't have a corresponding assistant message with tool_calls
if i == 0 or messages[i - 1].get("role") != "assistant" or not messages[i - 1].get("tool_calls"):
logging.debug(f"Removing orphaned tool message at position {i}")
continue
clean_messages.append(msg)
messages = clean_messages
last_message_content = messages[-1]["content"]
if isinstance(last_message_content, str):
model = self.config["model"]
elif "model-vision" in self.config:
model = self.config["model-vision"]
else:
messages[-1]["content"] = messages[-1]["content"][0]["text"]
if getattr(self, "_factual", False) and "factual-model" in self.config:
model = self.config["factual-model"] # BEH-10: facts get the stronger tier
if self._use_retry_model and "retry-model" in self.config:
model = self.config["retry-model"]
except (KeyError, IndexError, TypeError) as e:
logging.warning(f"Error accessing message content: {e}")
return None, limit
try:
if bool(self.config.get("use-responses-api", False)):
return await self._chat_via_responses(messages, limit, model) # ENV-22
# Prepare function calls if IGDB is enabled
chat_kwargs = {
"model": model,
"messages": messages,
"response_format": ENVELOPE_RESPONSE_FORMAT,
}
author = self._last_author(messages)
if author:
# hashed, never the raw Discord name (SAF-10)
chat_kwargs["safety_identifier"] = "discord-" + hashlib.sha256(author.encode()).hexdigest()[:16]
available_tools = self._available_tools()
if available_tools:
chat_kwargs["tools"] = [{"type": "function", "function": func} for func in available_tools]
chat_kwargs["tool_choice"] = "auto"
# gpt-5.6 rejects tools + reasoning on chat/completions (ENV-21)
chat_kwargs["reasoning_effort"] = self.config.get("reasoning-effort", "none")
logging.info(f"🔧 Tools available to AI: {[func['name'] for func in available_tools]}")
result = await openai_chat(self.client, **chat_kwargs)
self._record_usage(result)
# Handle function calls if present
message = result.choices[0].message
# A refusal is a failed attempt, not an answer (ENV-18)
refusal = getattr(message, "refusal", None)
if isinstance(refusal, str) and refusal:
logging.warning(f"model refused: {refusal}")
return None, limit
# Log what we received from OpenAI
logging.debug(f"📨 OpenAI Response: content={bool(message.content)}, has_tool_calls={hasattr(message, 'tool_calls')}")
if hasattr(message, "tool_calls") and message.tool_calls:
tool_names = [tc.function.name for tc in message.tool_calls]
logging.info(f"🔧 OpenAI requested function calls: {tool_names}")
# Any offered tool may have been called (IGDB or fetch_url)
has_tool_calls = bool(hasattr(message, "tool_calls") and message.tool_calls and available_tools)
# Clean up any existing tool messages in the history to avoid conflicts
if has_tool_calls:
messages = [msg for msg in messages if msg.get("role") != "tool"]
if has_tool_calls:
logging.info(f"🎮 Processing {len(message.tool_calls)} IGDB function call(s)...")
try:
# Process function calls - serialize tool_calls properly
tool_calls_data = []
for tc in message.tool_calls:
tool_calls_data.append(
{"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}}
)
messages.append({"role": "assistant", "content": message.content or "", "tool_calls": tool_calls_data})
# Execute function calls
for tool_call in message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
logging.info(f"🔧 Executing tool: {function_name} with args: {function_args}")
# Route to the right provider (IGDB or URL reader)
function_result = await self._dispatch_tool(function_name, function_args, self._last_author(messages) or "")
logging.info(f"🔧 Tool result: {type(function_result)} - {str(function_result)[:200]}...")
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
# IGDB text is external input — sanitize before prompting (SAF-03)
"content": (
sanitize_external_text(json.dumps(function_result), 8000) if function_result else "No results found"
),
}
)
# Get final response after function execution - remove tools for final call
final_chat_kwargs = {
"model": model,
"messages": messages,
"response_format": ENVELOPE_RESPONSE_FORMAT,
}
logging.debug(f"🔧 Sending final request to OpenAI with {len(messages)} messages (no tools)")
logging.debug(f"🔧 Last few messages: {messages[-3:] if len(messages) > 3 else messages}")
final_result = await openai_chat(self.client, **final_chat_kwargs)
self._record_usage(final_result)
answer_obj = final_result.choices[0].message
logging.debug(
f"🔧 Final OpenAI response: content_length={len(answer_obj.content) if answer_obj.content else 0}, has_tool_calls={hasattr(answer_obj, 'tool_calls') and answer_obj.tool_calls}"
)
if answer_obj.content:
logging.debug(f"🔧 Response preview: {answer_obj.content[:200]}")
else:
logging.warning(f"🔧 OpenAI returned NULL content despite {final_result.usage.completion_tokens} completion tokens")
# If OpenAI returns null content after function calling, use empty string
if not answer_obj.content and function_result:
logging.warning("OpenAI returned null after function calling, using empty string")
answer_obj.content = ""
except Exception as e:
# If function calling fails, fall back to regular response
logging.warning(f"Function calling failed, using regular response: {e}")
answer_obj = message
else:
answer_obj = message
# Handle null content from OpenAI
content = answer_obj.content
if content is None:
logging.warning("OpenAI returned null content, using empty string")
content = ""
answer = {"content": content, "role": answer_obj.role}
self.rate_limit_backoff = exponential_backoff()
self._use_retry_model = False
logging.info(f"generated response {result.usage}: {repr(answer)}")
return answer, limit
except openai.BadRequestError as err:
if "maximum context length is" in str(err) and limit > 4:
logging.warning(f"context length exceeded, reduce the limit {limit}: {str(err)}")
limit -= 1
return None, limit
raise err
except openai.RateLimitError as err:
rate_limit_sleep = next(self.rate_limit_backoff)
self._use_retry_model = True
logging.warning(f"got an rate limit error, sleep for {rate_limit_sleep} seconds: {str(err)}")
await asyncio.sleep(rate_limit_sleep)
except Exception as err:
import traceback
logging.warning(f"failed to generate response: {repr(err)}")
logging.debug(f"Full traceback: {traceback.format_exc()}")
return None, limit
async def edit_openai(self, description: str, paths: List[Any], count: int = 1) -> List[BytesIO]:
"""Edit/remix from cached inputs, ≤4 files (IMG-13)."""
if not self.ledger.budget_ok():
raise RuntimeError("daily budget exhausted - refusing image edit")
model = self.config.get("image-model", "gpt-image-2")
handles = [open(path, "rb") for path in paths[:4]]
try:
response = await openai_image_edit(
self.client,
model=model,
image=handles if len(handles) > 1 else handles[0],
prompt=description,
n=max(1, min(int(count), 4)),
size=self.config.get("image-size", "1024x1024"),
)
finally:
for handle in handles:
handle.close()
buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data]
self.ledger.add_images(len(buffers))
logging.info(f"edited {len(buffers)} image(s) on {model} from {len(handles)} input(s)")
return buffers
async def propose_task(self) -> Optional[Dict[str, Any]]:
"""One follow-up proposal from recent episodes on memory-model (TSK-08)."""
if "memory-model" not in self.config or self.store is None or not self.ledger.budget_ok():
return None
channel = self.config.get("chat-channel", "chat")
episodes = await asyncio.to_thread(self.store.recent_episodes, channel, 5)
if not episodes:
return None
episode_lines = "\n".join(f"- {episode}" for episode in episodes)
messages = [
{"role": "system", "content": TASKGEN_SYSTEM},
{"role": "user", "content": f"Recent conversation summaries in #{channel}:\n{episode_lines}"},
]
try:
result = await openai_chat(
self.client, model=self.config["memory-model"], messages=messages, response_format=TASKGEN_RESPONSE_FORMAT
)
self._record_usage(result)
return json.loads(result.choices[0].message.content)
except Exception as err:
logging.warning(f"task proposal failed: {repr(err)}")
return None
async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03)."""
if "classifier-model" not in self.config or not self.ledger.budget_ok():
return None
tail = "\n".join(str(entry.get("content", ""))[:300] for entry in history_tail[-6:])
messages = [
{"role": "system", "content": CLASSIFIER_SYSTEM},
{"role": "user", "content": f"Recent chat:\n{tail}\n\nLAST message:\n{str(message)}"},
]
try:
result = await openai_chat(
self.client, model=self.config["classifier-model"], messages=messages, response_format=CLASSIFIER_RESPONSE_FORMAT
)
self._record_usage(result)
return json.loads(result.choices[0].message.content)
except Exception as err:
logging.warning(f"classifier failed - failing open: {repr(err)}")
return None
async def consolidate(self, observations: List[Dict[str, Any]], known_facts: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Batched memory consolidation on memory-model (MEM-02)."""
if "memory-model" not in self.config or not self.ledger.budget_ok():
return None
observation_lines = "\n".join(f"[{obs['kind']}] {obs['user']}: {obs['content']}" for obs in observations)
known_lines = "\n".join(f"- {fact['user']}: {fact['fact']}" for fact in known_facts) or "(none)"
messages = [
{"role": "system", "content": CONSOLIDATION_SYSTEM},
{"role": "user", "content": f"Known facts:\n{known_lines}\n\nObservation log:\n{observation_lines}"},
]
try:
result = await openai_chat(
self.client, model=self.config["memory-model"], messages=messages, response_format=CONSOLIDATION_RESPONSE_FORMAT
)
self._record_usage(result)
parsed = json.loads(result.choices[0].message.content)
logging.info(f"memory consolidation: {len(parsed.get('facts', []))} new facts, episode={bool(parsed.get('episode'))}")
return parsed
except Exception as err:
logging.warning(f"memory consolidation failed: {repr(err)}")
return None
async def _execute_igdb_function(self, function_name: str, function_args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Execute IGDB function calls from OpenAI.
"""
logging.info(f"🎮 _execute_igdb_function called: {function_name}")
if not self.igdb:
logging.error("🎮 IGDB function called but self.igdb is None!")
return {"error": "IGDB not available"}
try:
if function_name == "search_games":
query = function_args.get("query", "")
limit = function_args.get("limit", 5)
logging.info(f"🎮 Searching IGDB for: '{query}' (limit: {limit})")
if not query:
logging.warning("🎮 No search query provided to search_games")
return {"error": "No search query provided"}
results = await asyncio.to_thread(self.igdb.search_games, query, limit)
logging.info(f"🎮 IGDB search returned: {len(results) if results and isinstance(results, list) else 0} results")
if results and isinstance(results, list) and len(results) > 0:
return {"games": results}
else:
return {"games": [], "message": f"No games found matching '{query}'"}
elif function_name == "get_games_by_release_date":
year = function_args.get("year")
month = function_args.get("month")
platform = function_args.get("platform")
limit = function_args.get("limit", 10)
logging.info(
f"🎮 Searching IGDB for games releasing in {year}/{month or 'all'} on {platform or 'all platforms'} (limit: {limit})"
)
if not year:
logging.warning("🎮 No year provided to get_games_by_release_date")
return {"error": "No year provided"}
results = await asyncio.to_thread(self.igdb.get_games_by_release_date, year, month, platform, limit)
logging.info(
f"🎮 IGDB release date search returned: {len(results) if results and isinstance(results, list) else 0} results"
)
if results and isinstance(results, list) and len(results) > 0:
return {"games": results}
else:
period = f"{year}/{month}" if month else str(year)
platform_text = f" on {platform}" if platform else ""
return {"games": [], "message": f"No games found releasing in {period}{platform_text}"}
elif function_name == "get_games_by_platform":
platform = function_args.get("platform", "")
genre = function_args.get("genre")
limit = function_args.get("limit", 10)
logging.info(f"🎮 Searching IGDB for games on {platform} {f'in {genre} genre' if genre else ''} (limit: {limit})")
if not platform:
logging.warning("🎮 No platform provided to get_games_by_platform")
return {"error": "No platform provided"}
results = await asyncio.to_thread(self.igdb.get_games_by_platform, platform, genre, limit)
logging.info(f"🎮 IGDB platform search returned: {len(results) if results and isinstance(results, list) else 0} results")
if results and isinstance(results, list) and len(results) > 0:
return {"games": results}
else:
genre_text = f" in {genre} genre" if genre else ""
return {"games": [], "message": f"No games found for {platform}{genre_text}"}
elif function_name == "get_game_details":
game_id = function_args.get("game_id")
logging.info(f"🎮 Getting IGDB details for game ID: {game_id}")
if not game_id:
logging.warning("🎮 No game ID provided to get_game_details")
return {"error": "No game ID provided"}
result = await asyncio.to_thread(self.igdb.get_game_details, game_id)
logging.info(f"🎮 IGDB game details returned: {bool(result)}")
if result:
return {"game": result}
else:
return {"error": f"Game with ID {game_id} not found"}
else:
return {"error": f"Unknown function: {function_name}"}
except Exception as e:
logging.error(f"Error executing IGDB function {function_name}: {e}")
return {"error": f"Failed to execute {function_name}: {str(e)}"}