Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 184 additions & 15 deletions app/utils/oasis_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import json
import math
import os
import re
import time
import uuid
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional, Tuple

from camel.models.openai_model import OpenAIModel
from openai.types.chat.chat_completion import ChatCompletion
Expand All @@ -19,6 +20,130 @@

DEFAULT_CLI_SEMAPHORE = 3

# CLI providers return plain text, so tool calling is implemented at the
# prompt level: tool schemas are serialized into the prompt and the model is
# asked to answer with a single JSON object that either carries tool_calls or
# plain content. The response is parsed back into an OpenAI-shaped
# ChatCompletion so CAMEL's ChatAgent executes the tools exactly as it would
# with a native function-calling backend. Without this, OASIS agents can
# never act on the platform (their actions ARE tool calls) and simulation
# rounds silently stay empty.
_TOOL_PROMPT_HEADER = (
"You can act ONLY by calling the tools listed below.\n"
"AVAILABLE TOOLS (name | description | JSON parameters schema):\n"
)

_TOOL_PROMPT_FOOTER = (
"\nRESPONSE FORMAT - respond with a single JSON object and nothing else:\n"
'- To act: {"tool_calls": [{"name": "<tool_name>", "arguments": {<parameters>}}]}\n'
" You may include several tool calls in the list, in execution order.\n"
'- To answer in plain text with no action: {"content": "<your text>"}\n'
"Do not wrap the JSON in markdown fences. Only use tool names from the list above.\n"
)



def _serialize_tools(tools: List[Dict[str, Any]]) -> str:
lines = []
for tool in tools or []:
fn = tool.get("function", tool) if isinstance(tool, dict) else {}
name = fn.get("name", "?")
desc = (fn.get("description") or "").strip().replace("\n", " ")[:200]
params = json.dumps(fn.get("parameters", {}), ensure_ascii=False)
lines.append(f"- {name} | {desc} | parameters: {params}")
return "\n".join(lines)


def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Flatten tool-protocol messages into plain text the CLI can consume."""
norm: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content")
if role == "tool":
label = msg.get("name") or msg.get("tool_call_id") or "tool"
norm.append({"role": "user", "content": f"TOOL RESULT [{label}]: {content}"})
continue
calls = msg.get("tool_calls") or []
if calls:
# CAMEL serializa los assistant con tool_calls como content=''
# (nunca None): disparar el resumen por PRESENCIA de tool_calls
summary = "; ".join(
f"{(c.get('function') or {}).get('name', '?')}"
f"({(c.get('function') or {}).get('arguments', '')})"
for c in calls if isinstance(c, dict)
)
prefix = (content or "").strip()
merged = (prefix + " " if prefix else "") + f"[previously called tools: {summary}]"
norm.append({"role": role, "content": merged})
continue
if content is None:
continue
norm.append({"role": role, "content": content})
return norm


def _parse_cli_tool_response(text: str) -> Tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
"""Extract (tool_calls, content) from the CLI's raw text response.

Returns (calls, None) when valid tool calls were found, otherwise
(None, content) with the best plain-text interpretation.
"""
raw = (text or "").strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-zA-Z]*\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw).strip()

candidate = None
try:
candidate = json.loads(raw)
except json.JSONDecodeError:
# Escaneo con raw_decode desde cada '{': a diferencia de una regex
# codiciosa, encuentra el primer objeto JSON VALIDO aunque haya prosa
# con llaves alrededor.
decoder = json.JSONDecoder()
idx = raw.find("{")
while idx != -1:
try:
obj, _end = decoder.raw_decode(raw, idx)
if isinstance(obj, dict):
candidate = obj
break
except json.JSONDecodeError:
pass
idx = raw.find("{", idx + 1)

if isinstance(candidate, dict):
calls = candidate.get("tool_calls")
if isinstance(calls, list) and calls:
valid = []
for call in calls:
if not isinstance(call, dict):
continue
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
name = call.get("name") or fn.get("name")
args = call.get("arguments", fn.get("arguments", {}))
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {"raw": args}
if name:
valid.append({
"name": str(name),
"arguments": args if isinstance(args, dict) else {},
})
if valid:
return valid, None
if isinstance(candidate.get("content"), str):
return None, candidate["content"]
# El modelo intento el protocolo pero sin calls validos ni content
# string: devolver cadena vacia en vez de filtrar el blob JSON crudo
# como si fuera texto del agente.
return None, ""

return None, text


class CLIModel(OpenAIModel):
"""CAMEL model backend that proxies requests to Claude/Codex CLI."""
Expand Down Expand Up @@ -55,9 +180,31 @@ def _estimate_tokens(self, value: Any) -> int:
return self._estimate_tokens(json.dumps(value, ensure_ascii=False))
return self._estimate_tokens(str(value))

def _build_completion(self, messages: List[Dict[str, Any]], content: str) -> ChatCompletion:
def _build_completion(
self,
messages: List[Dict[str, Any]],
content: Optional[str],
tool_calls: Optional[List[Dict[str, Any]]] = None,
) -> ChatCompletion:
prompt_tokens = sum(self._estimate_tokens(message.get('content')) for message in messages)
completion_tokens = self._estimate_tokens(content)
completion_tokens = self._estimate_tokens(content if content is not None else tool_calls)

message: Dict[str, Any] = {'role': 'assistant', 'content': content}
finish_reason = 'stop'
if tool_calls:
message['content'] = None
message['tool_calls'] = [
{
'id': f'call_{uuid.uuid4().hex[:20]}',
'type': 'function',
'function': {
'name': call['name'],
'arguments': json.dumps(call['arguments'], ensure_ascii=False),
},
}
for call in tool_calls
]
finish_reason = 'tool_calls'

return ChatCompletion.model_validate(
{
Expand All @@ -68,11 +215,8 @@ def _build_completion(self, messages: List[Dict[str, Any]], content: str) -> Cha
'choices': [
{
'index': 0,
'message': {
'role': 'assistant',
'content': content,
},
'finish_reason': 'stop',
'message': message,
'finish_reason': finish_reason,
}
],
'usage': {
Expand All @@ -88,17 +232,42 @@ def _request_chat_completion(
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]] | None = None,
) -> ChatCompletion:
if tools:
logger.warning('CLIModel ignores tool schemas; tool calling is not supported in OASIS CLI mode')

temperature = float((self.model_config_dict or {}).get('temperature', 0.7) or 0.7)
max_tokens = int((self.model_config_dict or {}).get('max_tokens', 4096) or 4096)

norm_messages = _normalize_messages(messages)
if tools:
instruction = _TOOL_PROMPT_HEADER + _serialize_tools(tools) + _TOOL_PROMPT_FOOTER
norm_messages = norm_messages + [{"role": "user", "content": instruction}]

content = self._llm.chat(
messages=messages,
messages=norm_messages,
temperature=temperature,
max_tokens=max_tokens,
)
return self._build_completion(messages, content)

if tools:
calls, text = _parse_cli_tool_response(content)
if calls:
# Filtrar contra las tools realmente ofrecidas: CAMEL indexa
# self._internal_tools[name] sin try/except y un nombre
# inventado perderia la ronda entera del agente
allowed = {(t.get("function", t) or {}).get("name")
for t in tools if isinstance(t, dict)}
dropped = [c["name"] for c in calls if c["name"] not in allowed]
if dropped:
logger.warning(f"CLI tool-calling: dropped unknown tool name(s): {dropped}")
calls = [c for c in calls if c["name"] in allowed]
if calls:
logger.info(
f"CLI tool-calling: parsed {len(calls)} tool call(s): "
f"{[call['name'] for call in calls]}"
)
return self._build_completion(norm_messages, None, tool_calls=calls)
logger.info("CLI tool-calling: response carried no tool call; returning plain content")
return self._build_completion(norm_messages, text)

return self._build_completion(norm_messages, content)

async def _arequest_chat_completion(
self,
Expand All @@ -119,7 +288,7 @@ def _request_parse(
temperature = float((self.model_config_dict or {}).get('temperature', 0.3) or 0.3)
max_tokens = int((self.model_config_dict or {}).get('max_tokens', 4096) or 4096)
payload = self._llm.chat_json(
messages=messages,
messages=_normalize_messages(messages),
temperature=temperature,
max_tokens=max_tokens,
)
Expand All @@ -145,7 +314,7 @@ def create_oasis_model(config: Dict[str, Any], use_boost: bool = False):

model = config.get('llm_model') or provider

logger.info(f"OASIS model: provider={provider}, model={model}, mode=cli-bridge")
logger.info(f"OASIS model: provider={provider}, model={model}, mode=cli-bridge (prompt-level tool calling)")
return CLIModel(
model_type=model,
provider=provider,
Expand Down