diff --git a/core/interactive_question.py b/core/interactive_question.py index 09535f3..9abe6d4 100644 --- a/core/interactive_question.py +++ b/core/interactive_question.py @@ -110,24 +110,33 @@ class QuestionManager: raise TimeoutError(f"Question {question.question_id} timed out waiting for human input") finally: self._futures.pop(question.question_id, None) + self._pending.pop(question.question_id, None) def answer_question(self, question_id: str, answer: Any) -> bool: - """Called when human operator submits an answer from the UI.""" + """Called when human operator submits an answer from the UI. + + Returns False if the question is unknown or was already answered.""" question = self._pending.get(question_id) if not question: logger.warning(f"No pending question found for id {question_id}") return False + if question.answered: + logger.warning(f"Question {question_id} already answered; ignoring") + return False question.answered = True question.answer = answer fut = self._futures.get(question_id) if fut and not fut.done(): - fut.set_result(answer) - return True - + fut.set_result(answer) # wait_for_answer() removes it from _pending + else: + self._pending.pop(question_id, None) # nobody waiting; don't leak return True + def list_pending(self) -> List[Question]: + return [q for q in self._pending.values() if not q.answered] + def get_pending(self, question_id: str) -> Optional[Question]: return self._pending.get(question_id) diff --git a/core/logger.py b/core/logger.py index ca44df3..806a74a 100644 --- a/core/logger.py +++ b/core/logger.py @@ -1,33 +1,27 @@ -"""Structured logging for LogiFlow AI — loguru setup with standard logging fallback.""" +"""Structured logging for LogiFlow AI — single loguru setup imported by all modules.""" import sys import os -import logging from pathlib import Path +from loguru import logger -try: - from loguru import logger +Path("logs").mkdir(exist_ok=True) - Path("logs").mkdir(exist_ok=True) +logger.remove() - logger.remove() +logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level=os.getenv("LOG_LEVEL", "INFO"), + colorize=False, +) - logger.add( - sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - level=os.getenv("LOG_LEVEL", "INFO"), - colorize=False, - ) - - logger.add( - "logs/logiflow_{time:YYYY-MM-DD}.log", - rotation="100 MB", - retention="30 days", - compression="gz", - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - ) -except ImportError: - logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s") - logger = logging.getLogger("logiflow") +logger.add( + "logs/logiflow_{time:YYYY-MM-DD}.log", + rotation="100 MB", + retention="30 days", + compression="gz", + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", +) __all__ = ["logger"] diff --git a/core/message_bus.py b/core/message_bus.py index e33e26d..a79ac9a 100644 --- a/core/message_bus.py +++ b/core/message_bus.py @@ -8,11 +8,8 @@ from collections import defaultdict from dataclasses import dataclass from enum import Enum -try: - import nats - import nats.js.errors -except ImportError: - nats = None +import nats +import nats.js.errors from core.types import AgentMessage, MessageType from core.logger import logger @@ -59,9 +56,6 @@ class MessageBus: async def connect(self): """Connect to NATS and create the logistics JetStream stream.""" - if nats is None: - logger.warning("nats-py not installed; message bus running in local in-memory mode") - return self._nc = await nats.connect( servers=[f"nats://{NATS_HOST}:{NATS_PORT}"], user=NATS_USER, diff --git a/core/skills/__init__.py b/core/skills/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tool_registry.py b/core/tool_registry.py index 377585e..b5e2e20 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -33,7 +33,7 @@ class Tool: metadata: Dict[str, Any] = field(default_factory=dict) def to_schema(self) -> Dict[str, Any]: - """Generate OpenAI/Claude compatible JSON Schema for tool calling.""" + """Generate an Anthropic Messages API tool definition (name / description / input_schema).""" properties = {} required = [] @@ -56,7 +56,7 @@ class Tool: return { "name": self.name, "description": self.description, - "parameters": { + "input_schema": { "type": "object", "properties": properties, "required": required, diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 4c82df3..dc6a758 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -27,7 +27,8 @@ async def test_tool_registry_registration_and_execution(): schemas = registry.get_schemas() assert len(schemas) == 1 assert schemas[0]["name"] == "test_tool" - assert "order_id" in schemas[0]["parameters"]["properties"] + assert "order_id" in schemas[0]["input_schema"]["properties"] + assert "parameters" not in schemas[0] # Anthropic shape, not OpenAI result = await registry.execute_tool("test_tool", order_id="ORD-100") assert result == {"order": "ORD-100", "items": 1} @@ -50,6 +51,32 @@ async def test_question_manager_flow(): assert q.options[0].badge == "12 AM–9 AM" assert not q.answered - qm.answer_question(q.question_id, "morning") + assert qm.list_pending() == [q] + assert qm.answer_question(q.question_id, "morning") is True assert q.answered is True assert q.answer == "morning" + assert qm.get_pending(q.question_id) is None # cleaned up, not leaked + assert qm.answer_question(q.question_id, "afternoon") is False # double-answer rejected + + +@pytest.mark.asyncio +async def test_question_manager_wait_and_answer(): + qm = QuestionManager() + q = qm.create_question(prompt="Reassign?", options=["yes", "no"], allow_custom_input=False) + + async def operator(): + await asyncio.sleep(0.01) + qm.answer_question(q.question_id, "yes") + + asyncio.create_task(operator()) + assert await qm.wait_for_answer(q, timeout_s=1.0) == "yes" + assert qm.get_pending(q.question_id) is None + + +@pytest.mark.asyncio +async def test_question_manager_timeout_cleans_up(): + qm = QuestionManager() + q = qm.create_question(prompt="Reassign?", options=["yes", "no"]) + with pytest.raises(TimeoutError): + await qm.wait_for_answer(q, timeout_s=0.01) + assert qm.get_pending(q.question_id) is None