This commit is contained in:
+581
-7
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -11,6 +17,7 @@ from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.version import __version__
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
||||
@@ -19,7 +26,9 @@ Use continual plan tools when the user asks for multi-day or recurring marketpla
|
||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
||||
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
||||
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
||||
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
||||
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||
@@ -41,6 +50,7 @@ class OllamaAgent:
|
||||
num_ctx: int | None = None,
|
||||
provider: str = "ollama",
|
||||
api_key: str | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
@@ -50,11 +60,14 @@ class OllamaAgent:
|
||||
self.num_ctx = num_ctx
|
||||
self.provider = provider.strip().casefold() or "ollama"
|
||||
self.api_key = api_key
|
||||
self.reasoning_effort = reasoning_effort.strip().casefold() or "medium"
|
||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if self.provider == "openai":
|
||||
return await self._openai_health()
|
||||
if self.provider == "codex":
|
||||
return await self._codex_health()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
@@ -83,6 +96,8 @@ class OllamaAgent:
|
||||
health = await self.health()
|
||||
if not health["online"]:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
if health.get("model_available") is False:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
@@ -304,6 +319,13 @@ class OllamaAgent:
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if self.provider == "codex":
|
||||
return await self._codex_chat(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return await self._ollama_chat(
|
||||
query,
|
||||
messages,
|
||||
@@ -327,6 +349,15 @@ class OllamaAgent:
|
||||
):
|
||||
yield event
|
||||
return
|
||||
if self.provider == "codex":
|
||||
async for event in self._codex_chat_stream(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
):
|
||||
yield event
|
||||
return
|
||||
async for event in self._ollama_chat_stream(
|
||||
query,
|
||||
messages,
|
||||
@@ -410,6 +441,7 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
@@ -447,6 +479,7 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
@@ -487,6 +520,47 @@ class OllamaAgent:
|
||||
"done": True,
|
||||
}
|
||||
|
||||
async def _codex_chat(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
result = await self._codex_cli_turn(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return self._codex_structured_response(result)
|
||||
|
||||
async def _codex_chat_stream(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
result = await self._codex_cli_turn(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
response = self._codex_structured_response(result)
|
||||
message = response["message"]
|
||||
if message.get("content"):
|
||||
yield {"message": {"role": "assistant", "content": message["content"]}}
|
||||
yield {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": message.get("tool_calls") or [],
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
|
||||
def _messages_with_context(
|
||||
self,
|
||||
query: str,
|
||||
@@ -511,15 +585,57 @@ class OllamaAgent:
|
||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||
|
||||
async def _openai_health(self) -> dict[str, Any]:
|
||||
return await self._cloud_health("openai")
|
||||
|
||||
async def _codex_health(self) -> dict[str, Any]:
|
||||
command = self._codex_command()
|
||||
if not command:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "codex",
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "Codex CLI was not found on PATH.",
|
||||
"detail": "",
|
||||
}
|
||||
try:
|
||||
account, models = await self._codex_app_server_status()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": command,
|
||||
"provider": "codex",
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "Codex App Server is installed, but TraderAI could not connect to it.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
logged_in = bool(account)
|
||||
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
|
||||
return {
|
||||
"online": logged_in,
|
||||
"model": self.model,
|
||||
"base_url": command,
|
||||
"provider": "codex",
|
||||
"model_available": self.model in models if models else bool(self.model),
|
||||
"models": models,
|
||||
"message": "Codex App Server is online." if logged_in else "Codex CLI is installed, but not logged in with ChatGPT.",
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
async def _cloud_health(self, provider: str) -> dict[str, Any]:
|
||||
if not self.api_key:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "OpenAI is selected, but no OpenAI API key is configured.",
|
||||
"message": f"{self._provider_label()} is selected, but no API key is configured.",
|
||||
"detail": "",
|
||||
}
|
||||
try:
|
||||
@@ -532,10 +648,10 @@ class OllamaAgent:
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": f"OpenAI is unreachable at {self.base_url} or rejected the API key.",
|
||||
"message": f"{self._provider_label()} is unreachable at {self.base_url} or rejected the API key.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||
@@ -543,10 +659,10 @@ class OllamaAgent:
|
||||
"online": True,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": self.model in models,
|
||||
"models": models,
|
||||
"message": "OpenAI is online.",
|
||||
"message": f"{self._provider_label()} is online.",
|
||||
}
|
||||
|
||||
def _openai_headers(self) -> dict[str, str]:
|
||||
@@ -595,8 +711,27 @@ class OllamaAgent:
|
||||
normalized.append(entry)
|
||||
return normalized
|
||||
|
||||
def _codex_tool_catalog(self) -> list[dict[str, Any]]:
|
||||
tools: list[dict[str, Any]] = []
|
||||
for schema in self.tools.schemas:
|
||||
if schema.get("type") != "function":
|
||||
continue
|
||||
function = schema.get("function") or {}
|
||||
tools.append(
|
||||
{
|
||||
"name": function.get("name", ""),
|
||||
"description": function.get("description", ""),
|
||||
"parameters": function.get("parameters") or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
def _provider_label(self) -> str:
|
||||
return "OpenAI model" if self.provider == "openai" else "local model"
|
||||
if self.provider == "openai":
|
||||
return "OpenAI model"
|
||||
if self.provider == "codex":
|
||||
return "Codex model"
|
||||
return "local model"
|
||||
|
||||
@staticmethod
|
||||
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||
@@ -615,6 +750,431 @@ class OllamaAgent:
|
||||
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [tool_calls[index] for index in sorted(tool_calls)]
|
||||
|
||||
async def _codex_cli_turn(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
return await self._codex_app_server_turn(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def _codex_app_server_turn(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
prompt = self._codex_cli_prompt(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
final_text = ""
|
||||
|
||||
process = await self._start_codex_app_server()
|
||||
request_id = 1
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await self._codex_app_server_write(process, payload)
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=timeout)
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
|
||||
try:
|
||||
await send_request(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
thread = await send_request(
|
||||
"thread/start",
|
||||
{
|
||||
"model": self.model,
|
||||
"modelProvider": None,
|
||||
"cwd": str(Path.cwd()),
|
||||
"approvalPolicy": "never",
|
||||
"sandbox": "read-only",
|
||||
"baseInstructions": "You are TraderAI running through the local Codex App Server using ChatGPT OAuth.",
|
||||
"developerInstructions": (
|
||||
"Do not run shell commands, inspect files, or modify the workspace. "
|
||||
"Answer only with JSON matching the requested output schema."
|
||||
),
|
||||
"ephemeral": True,
|
||||
"experimentalRawEvents": False,
|
||||
"persistExtendedHistory": False,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
thread_id_value = ((thread.get("thread") or {}).get("id") or thread.get("threadId") or "").strip()
|
||||
if not thread_id_value:
|
||||
raise RuntimeError(f"Codex App Server did not return a thread id: {thread!r}")
|
||||
turn = await send_request(
|
||||
"turn/start",
|
||||
{
|
||||
"threadId": thread_id_value,
|
||||
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
||||
"cwd": str(Path.cwd()),
|
||||
"approvalPolicy": "never",
|
||||
"sandboxPolicy": {"type": "readOnly", "access": {"type": "fullAccess"}},
|
||||
"model": self.model,
|
||||
"effort": self.reasoning_effort,
|
||||
"summary": "none",
|
||||
"outputSchema": self._codex_output_schema(),
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
turn_id = ((turn.get("turn") or {}).get("id") or "").strip()
|
||||
if not turn_id:
|
||||
raise RuntimeError(f"Codex App Server did not return a turn id: {turn!r}")
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=240)
|
||||
method = message.get("method")
|
||||
params = message.get("params") or {}
|
||||
if method == "item/agentMessage/delta" and params.get("turnId") == turn_id:
|
||||
final_text += params.get("delta") or ""
|
||||
elif method == "item/completed" and params.get("turnId") == turn_id:
|
||||
item = params.get("item") or {}
|
||||
if item.get("type") == "agentMessage":
|
||||
final_text = item.get("text") or final_text
|
||||
elif method == "turn/completed" and (params.get("turn") or {}).get("id") == turn_id:
|
||||
turn_status = (params.get("turn") or {}).get("status")
|
||||
if turn_status != "completed":
|
||||
error = (params.get("turn") or {}).get("error") or {}
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server turn ended with status {turn_status}.")
|
||||
break
|
||||
elif method == "error":
|
||||
error = params.get("message") or params.get("error") or params
|
||||
raise RuntimeError(f"Codex App Server error: {error}")
|
||||
else:
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
finally:
|
||||
await self._stop_codex_app_server(process)
|
||||
return self._parse_codex_app_server_text(final_text)
|
||||
|
||||
def _codex_cli_prompt(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> str:
|
||||
conversation_lines: list[str] = []
|
||||
for message in self._messages_with_context(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
):
|
||||
role = message.get("role", "unknown")
|
||||
content = message.get("content", "")
|
||||
suffix = ""
|
||||
if role == "user" and message.get("images"):
|
||||
suffix = f" [attached images: {len(message.get('images') or [])}]"
|
||||
if role == "tool":
|
||||
suffix = f" [tool {message.get('tool_name') or ''}]"
|
||||
if role == "assistant" and message.get("tool_calls"):
|
||||
suffix = f" [tool calls: {json.dumps(message.get('tool_calls'), ensure_ascii=True)}]"
|
||||
conversation_lines.append(f"{role}{suffix}: {content}")
|
||||
tools_json = json.dumps(self._codex_tool_catalog(), ensure_ascii=True, indent=2)
|
||||
return (
|
||||
"You are TraderAI running through the local Codex App Server using ChatGPT OAuth.\n"
|
||||
"Do not run shell commands, inspect files, or modify the workspace.\n"
|
||||
"Your only job is to decide whether to answer directly or request exactly one TraderAI tool.\n\n"
|
||||
"Return JSON that matches the provided schema.\n"
|
||||
"- If you can answer now, set kind to final, put the user-facing reply in message, set tool_name to an empty string, and set arguments_json to '{}'.\n"
|
||||
"- If you need a tool, set kind to tool_call, set tool_name to the exact tool name, set message to an empty string, and set arguments_json to a valid JSON object string.\n"
|
||||
"- Never return more than one tool call at a time.\n"
|
||||
"- Prefer the TraderAI tools over guessing.\n\n"
|
||||
f"Available tools:\n{tools_json}\n\n"
|
||||
"Conversation transcript:\n"
|
||||
+ "\n".join(conversation_lines)
|
||||
)
|
||||
|
||||
def _codex_structured_response(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
if result.get("kind") == "tool_call":
|
||||
tool_name = str(result.get("tool_name") or "").strip()
|
||||
arguments_json = str(result.get("arguments_json") or "{}").strip() or "{}"
|
||||
return {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"codex-{uuid.uuid4()}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments_json,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": str(result.get("message") or ""),
|
||||
"tool_calls": [],
|
||||
}
|
||||
}
|
||||
|
||||
def _write_codex_schema(self) -> str:
|
||||
schema = self._codex_output_schema()
|
||||
with tempfile.NamedTemporaryFile("w", suffix="-traderai-codex-schema.json", delete=False, encoding="utf-8") as handle:
|
||||
json.dump(schema, handle, ensure_ascii=True)
|
||||
return handle.name
|
||||
|
||||
@staticmethod
|
||||
def _codex_output_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["final", "tool_call"]},
|
||||
"message": {"type": "string"},
|
||||
"tool_name": {"type": "string"},
|
||||
"arguments_json": {"type": "string"},
|
||||
},
|
||||
"required": ["kind", "message", "tool_name", "arguments_json"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
def _parse_codex_app_server_text(self, final_text: str) -> dict[str, Any]:
|
||||
if not final_text.strip():
|
||||
raise RuntimeError("Codex App Server returned an empty response.")
|
||||
try:
|
||||
parsed = json.loads(final_text)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex App Server returned non-JSON output: {final_text}") from exc
|
||||
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||
raise RuntimeError(f"Codex App Server returned an invalid result kind: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
def _parse_codex_exec_output(self, output: dict[str, Any]) -> dict[str, Any]:
|
||||
events = output.get("events") or []
|
||||
final_text = ""
|
||||
error_text = ""
|
||||
for event in events:
|
||||
if event.get("type") == "item.completed":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "agent_message":
|
||||
final_text = item.get("text") or final_text
|
||||
elif event.get("type") == "error":
|
||||
error_text = event.get("message") or error_text
|
||||
elif event.get("type") == "turn.failed":
|
||||
details = event.get("error") or {}
|
||||
error_text = details.get("message") or error_text
|
||||
if output.get("returncode") != 0 and not final_text:
|
||||
raise RuntimeError(error_text or output.get("stderr") or "Codex CLI failed.")
|
||||
try:
|
||||
parsed = json.loads(final_text)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex CLI returned non-JSON output: {final_text}") from exc
|
||||
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||
raise RuntimeError(f"Codex CLI returned an invalid result kind: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
def _codex_command(self, required: bool = False) -> str | None:
|
||||
configured = self.base_url.strip() if self.base_url else "codex"
|
||||
resolved = shutil.which(configured) or configured
|
||||
if required and not Path(resolved).exists() and shutil.which(resolved) is None:
|
||||
raise RuntimeError("Codex CLI was not found on PATH.")
|
||||
return resolved
|
||||
|
||||
async def _codex_app_server_status(self) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
process = await self._start_codex_app_server()
|
||||
request_id = 1
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await self._codex_app_server_write(process, payload)
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=30)
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
|
||||
try:
|
||||
await send_request(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
)
|
||||
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
account_result = await send_request("account/read", {"refreshToken": False})
|
||||
models: list[str] = []
|
||||
cursor: str | None = None
|
||||
for _ in range(20):
|
||||
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
page = await send_request("model/list", params)
|
||||
for item in page.get("data") or []:
|
||||
model = item.get("id") or item.get("model")
|
||||
if model:
|
||||
models.append(model)
|
||||
cursor = page.get("nextCursor")
|
||||
if not cursor:
|
||||
break
|
||||
return account_result.get("account"), sorted(set(models))
|
||||
finally:
|
||||
await self._stop_codex_app_server(process)
|
||||
|
||||
async def _start_codex_app_server(self) -> asyncio.subprocess.Process:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
self._codex_command(required=True),
|
||||
"app-server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def _codex_app_server_write(self, process: asyncio.subprocess.Process, payload: dict[str, Any]) -> None:
|
||||
if process.stdin is None:
|
||||
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||
await process.stdin.drain()
|
||||
|
||||
async def _codex_app_server_read(self, process: asyncio.subprocess.Process, timeout: int) -> dict[str, Any]:
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||
except TimeoutError as exc:
|
||||
raise RuntimeError("Codex App Server timed out.") from exc
|
||||
if not line:
|
||||
stderr = ""
|
||||
if process.stderr is not None:
|
||||
try:
|
||||
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||
except TimeoutError:
|
||||
stderr = ""
|
||||
raise RuntimeError(stderr or "Codex App Server exited without a response.")
|
||||
try:
|
||||
return json.loads(line.decode("utf-8", errors="replace"))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex App Server returned invalid JSON-RPC: {line!r}") from exc
|
||||
|
||||
async def _handle_codex_app_server_message(self, process: asyncio.subprocess.Process, message: dict[str, Any]) -> None:
|
||||
if "id" not in message or "method" not in message:
|
||||
return
|
||||
method = message.get("method")
|
||||
if method in {
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
"applyPatchApproval",
|
||||
"execCommandApproval",
|
||||
}:
|
||||
await self._codex_app_server_write(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {
|
||||
"decision": "deny",
|
||||
"message": "TraderAI does not allow Codex to run commands or change files.",
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
await self._codex_app_server_write(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32601, "message": f"TraderAI does not handle Codex App Server request {method}."},
|
||||
},
|
||||
)
|
||||
|
||||
async def _stop_codex_app_server(self, process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def _run_command(self, command: list[str], timeout: int = 120, stdin_text: str | None = None) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
payload = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise RuntimeError(f"Command timed out: {' '.join(command[:3])}")
|
||||
stdout_text = stdout.decode("utf-8", errors="replace")
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
events = []
|
||||
for line in stdout_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except ValueError:
|
||||
events.append({"type": "stdout", "text": line})
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"stdout": stdout_text,
|
||||
"stderr": stderr_text,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
def _codex_model_cache(self) -> list[str]:
|
||||
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||
if not cache_path.exists():
|
||||
return []
|
||||
try:
|
||||
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
models = []
|
||||
for item in body.get("models", []):
|
||||
slug = item.get("slug")
|
||||
if slug:
|
||||
models.append(slug)
|
||||
return sorted(set(models))
|
||||
|
||||
def _runtime_context(
|
||||
self,
|
||||
query: str,
|
||||
@@ -741,6 +1301,16 @@ class OllamaAgent:
|
||||
choice = (response.json().get("choices") or [{}])[0]
|
||||
message = choice.get("message") or {}
|
||||
return self._clean_generated_title(message.get("content", ""))
|
||||
if self.provider == "codex":
|
||||
result = await self._codex_app_server_turn(
|
||||
prompt,
|
||||
[
|
||||
{"role": "system", "content": "You write short chat titles."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
thread_id="title",
|
||||
)
|
||||
return self._clean_generated_title(result.get("message", ""))
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
@@ -831,6 +1401,10 @@ class OllamaAgent:
|
||||
"list_scmdb_versions": "Checking SCMDB versions",
|
||||
"search_scmdb_missions": "Searching SCMDB missions",
|
||||
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||
"search_scwiki_pages": "Searching Star Citizen Wiki",
|
||||
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
||||
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
||||
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
||||
"search_cornerstone_items": "Searching Cornerstone items",
|
||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||
|
||||
Reference in New Issue
Block a user