Skip to content
Draft
Show file tree
Hide file tree
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
4 changes: 4 additions & 0 deletions config.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[OpenAI]
API_MODE=responses


57 changes: 57 additions & 0 deletions tests/unit/test_action_generator_structured_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pytest

from tinytroupe.agent.action_generator import ActionGenerator, ActionRefusedException
from tinytroupe.agent import TinyPerson, CognitiveActionModel


class FakeClient:
def __init__(self, message):
self._message = message

def send_message(self, *args, **kwargs):
return self._message


def test_prefers_parsed_payload(monkeypatch):
TinyPerson.clear_agents()
# Build a parsed payload consistent with CognitiveActionModel
parsed = {
"action": {"type": "THINK", "content": "test content", "target": ""},
"cognitive_state": {
"goals": "g",
"context": ["c"],
"attention": "a",
"emotions": "e",
},
}

message = {"role": "assistant", "content": "{\"action\":{}}", "parsed": parsed}

# Patch client used by action generator to return our fake message
from tinytroupe import openai_utils

monkeypatch.setattr(openai_utils, "client", lambda: FakeClient(message))

agent = TinyPerson(name="Tester")
ag = ActionGenerator()

action, role, content = ag._generate_tentative_action(agent, agent.current_messages)[0:3]

assert content == parsed
assert action == parsed["action"]
assert role == "assistant"


def test_refusal_raises(monkeypatch):
TinyPerson.clear_agents()
message = {"role": "assistant", "content": "{}", "refusal": "safety refusal"}

from tinytroupe import openai_utils

monkeypatch.setattr(openai_utils, "client", lambda: FakeClient(message))

agent = TinyPerson(name="Tester")
ag = ActionGenerator()

with pytest.raises(ActionRefusedException):
ag._generate_tentative_action(agent, agent.current_messages)
72 changes: 72 additions & 0 deletions tests/unit/test_openai_utils_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import types
from unittest.mock import patch

import tinytroupe.openai_utils as openai_utils


class _StubResponsesClient:
def __init__(self):
self.last_params = None

class _Responses:
def __init__(self, outer):
self._outer = outer

def create(self, **kwargs):
# Capture params for assertions
self._outer.last_params = kwargs

# Return minimal object with output_text like the SDK does
return types.SimpleNamespace(output_text="ok")

@property
def responses(self):
return _StubResponsesClient._Responses(self)


def test_send_message_uses_responses_api_when_api_mode_is_responses():
stub = _StubResponsesClient()

# Patch setup to force responses mode and inject stub client
original_setup = openai_utils.OpenAIClient._setup_from_config

def _setup_with_responses(self):
self.client = stub
self.api_mode = "responses"

try:
openai_utils.OpenAIClient._setup_from_config = _setup_with_responses

client = openai_utils.OpenAIClient()

messages = [
{"role": "system", "content": "You are terse."},
{"role": "user", "content": "Say ok."},
]

result = client.send_message(
current_messages=messages,
model="gpt-4.1-mini",
temperature=0.2,
max_tokens=128,
)

# Verify mapping to Responses API
assert stub.last_params is not None
assert stub.last_params.get("model") == "gpt-4.1-mini"
assert stub.last_params.get("temperature") == 0.2
assert stub.last_params.get("max_output_tokens") == 128

input_msgs = stub.last_params.get("input")
assert isinstance(input_msgs, list) and len(input_msgs) == 2
assert input_msgs[0]["role"] == "system"
assert input_msgs[1]["role"] == "user"
assert input_msgs[1]["content"][0]["text"] == "Say ok."

# Verify extractor returns assistant content
assert result["content"].lower().startswith("ok")

finally:
openai_utils.OpenAIClient._setup_from_config = original_setup


43 changes: 40 additions & 3 deletions tinytroupe/agent/action_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,19 @@ def _generate_tentative_action(self, agent, current_messages, feedback_from_prev

if not self.enable_reasoning_step:
logger.debug(f"[{agent.name}] Reasoning step disabled.")
next_message = openai_utils.client().send_message(current_messages_context, response_format=CognitiveActionModel)
# Prefer Responses API JSON Schema when API_MODE=responses; fallback to Pydantic class on legacy
response_format = CognitiveActionModel
try:
# If running in responses mode, provide a JSON Schema envelope with strict mode
from pydantic import TypeAdapter
schema = TypeAdapter(CognitiveActionModel).json_schema()
response_format = {
"type": "json_schema",
"json_schema": {"name": "CognitiveActionModel", "schema": schema, "strict": True},
}
except Exception:
pass
next_message = openai_utils.client().send_message(current_messages_context, response_format=response_format)

else:
logger.debug(f"[{agent.name}] Reasoning step enabled.")
Expand All @@ -302,11 +314,31 @@ def _generate_tentative_action(self, agent, current_messages, feedback_from_prev
current_messages_context.append({"role": "system",
"content": "Use the \"reasoning\" field to add any reasoning process you might wish to use before generating the next action and cognitive state. "})

next_message = openai_utils.client().send_message(current_messages_context, response_format=CognitiveActionModelWithReasoning)
response_format = CognitiveActionModelWithReasoning
try:
from pydantic import TypeAdapter
schema = TypeAdapter(CognitiveActionModelWithReasoning).json_schema()
response_format = {
"type": "json_schema",
"json_schema": {"name": "CognitiveActionModelWithReasoning", "schema": schema, "strict": True},
}
except Exception:
pass
next_message = openai_utils.client().send_message(current_messages_context, response_format=response_format)

logger.debug(f"[{agent.name}] Received message: {next_message}")

role, content = next_message["role"], utils.extract_json(next_message["content"])
# Prefer typed parsed payload when available; otherwise, fall back to JSON extraction
role = next_message.get("role", "assistant")

# Handle explicit refusal from provider payloads when present
refusal = next_message.get("refusal")
if refusal:
# Log and raise a specialized exception to surface actionable errors
logger.warning(f"[{agent.name}] Model refusal received: {refusal}")
raise ActionRefusedException(refusal)

content = next_message.get("parsed") or utils.extract_json(next_message["content"])

action = content['action']
logger.debug(f"{agent.name}'s action: {action}")
Expand Down Expand Up @@ -530,3 +562,8 @@ class PoorQualityActionException(Exception):
def __init__(self, message="The generated action is of poor quality"):
self.message = message
super().__init__(self.message)


class ActionRefusedException(Exception):
def __init__(self, refusal_message: str = "The model refused to generate an action"):
super().__init__(refusal_message)
2 changes: 2 additions & 0 deletions tinytroupe/config.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[OpenAI]
# Enable Responses API path for local runs
API_MODE=responses
#
# OpenAI or Azure OpenAI Service
#
Expand Down
Loading