fix: tool registry emits Anthropic schema, question lifecycle cleanup

- Tool.to_schema() emits input_schema (Anthropic Messages API) instead
  of OpenAI-style parameters
- QuestionManager: remove questions from _pending on answer and timeout,
  reject double answers, add list_pending()
- core/skills/__init__.py so skills are importable as a package
- revert optional-import fallbacks in logger/message_bus: nats-py and
  loguru are hard requirements, a broken install should fail loudly
- tests for wait_for_answer happy path and timeout cleanup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AJLYcbTHCe45fyFnMfEin
This commit is contained in:
2026-09-22 15:52:06 +05:30
parent be8103c1d2
commit 58bfa07385
6 changed files with 64 additions and 40 deletions

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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,

0
core/skills/__init__.py Normal file
View File

View File

@@ -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,

View File

@@ -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