fix tools calls
This commit is contained in:
@@ -10,8 +10,11 @@ import pytest
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from app.core.deep_agent import (
|
||||
_build_system_prompt,
|
||||
_datetime_context_injection,
|
||||
_infer_floating_domain,
|
||||
_normalize_tagged_list_lines,
|
||||
_request_context_block,
|
||||
run_floating,
|
||||
run_floating_stream,
|
||||
run_home,
|
||||
@@ -91,8 +94,12 @@ async def test_run_floating_stream_emits_domain_then_tokens_with_mocked_tool_res
|
||||
"floating_domain",
|
||||
{"type": "timeline", "id": "tl-1", "section": None},
|
||||
)
|
||||
assert ("token", "stream-") in events
|
||||
assert ("token", "ok") in events
|
||||
# _run_single_agent_stream uses ainvoke (not astream); the final token is
|
||||
# the second LLM response which echoes the tool result.
|
||||
token_events = [e for e in events if e[0] == "token"]
|
||||
assert token_events, "Expected at least one token event"
|
||||
combined = "".join(str(e[1]) for e in token_events)
|
||||
assert "Mock Task" in combined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -286,3 +293,213 @@ async def test_run_floating_stream_returns_fallback_when_sanitization_would_empt
|
||||
events.append(event)
|
||||
|
||||
assert ("token", "No results found.") in events
|
||||
|
||||
|
||||
# ── _datetime_context_injection ────────────────────────────────────────────────
|
||||
|
||||
def _fp(tz: str, now_iso: str) -> dict:
|
||||
return {"timezone": tz, "now_iso": now_iso, "date_format": "dd/MM/yyyy", "time_format": "24h"}
|
||||
|
||||
|
||||
def _parse_ms(block: str, key: str) -> tuple[int, int]:
|
||||
"""Extract [start, end] from a 'key [start, end]' line in the DATE CONTEXT block."""
|
||||
import re
|
||||
m = re.search(rf"^{key}\s+\[(\d+),\s*(\d+)\]", block, re.MULTILINE)
|
||||
assert m, f"Key '{key}' not found in block:\n{block}"
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
|
||||
def test_datetime_context_injection_europe_rome_late_evening():
|
||||
"""22:16 CEST on 2026-04-26 — 'tomorrow' must be 2026-04-27 00:00→23:59:59.999 CEST."""
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime, timezone
|
||||
|
||||
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-04-26T20:16:02.155Z")})
|
||||
assert "DATE CONTEXT" in block
|
||||
assert "Europe/Rome" in block
|
||||
|
||||
tz = ZoneInfo("Europe/Rome")
|
||||
today_start = int(datetime(2026, 4, 26, tzinfo=tz).timestamp() * 1000)
|
||||
today_end = int(datetime(2026, 4, 27, tzinfo=tz).timestamp() * 1000) - 1
|
||||
tomorrow_start = today_end + 1
|
||||
tomorrow_end = int(datetime(2026, 4, 28, tzinfo=tz).timestamp() * 1000) - 1
|
||||
|
||||
t_s, t_e = _parse_ms(block, "today")
|
||||
assert t_s == today_start
|
||||
assert t_e == today_end
|
||||
|
||||
tm_s, tm_e = _parse_ms(block, "tomorrow")
|
||||
assert tm_s == tomorrow_start
|
||||
assert tm_e == tomorrow_end
|
||||
|
||||
# Sanity: window is exactly 86 400 000 ms (1 day, CEST has no DST jump on this date)
|
||||
assert today_end - today_start + 1 == 86_400_000
|
||||
assert tomorrow_end - tomorrow_start + 1 == 86_400_000
|
||||
|
||||
|
||||
def test_datetime_context_injection_utc():
|
||||
"""UTC timezone: boundaries are clean UTC midnights."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
block = _datetime_context_injection({"format_prefs": _fp("UTC", "2026-01-15T10:00:00Z")})
|
||||
t_s, t_e = _parse_ms(block, "today")
|
||||
expected_start = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
assert t_s == expected_start
|
||||
assert t_e == expected_start + 86_400_000 - 1
|
||||
|
||||
|
||||
def test_datetime_context_injection_dst_spring_forward():
|
||||
"""Europe/Rome DST spring-forward 2026-03-29: that day is 23h, not 24h."""
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime
|
||||
|
||||
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-03-29T08:00:00Z")})
|
||||
tz = ZoneInfo("Europe/Rome")
|
||||
day_start = int(datetime(2026, 3, 29, tzinfo=tz).timestamp() * 1000)
|
||||
day_end = int(datetime(2026, 3, 30, tzinfo=tz).timestamp() * 1000) - 1
|
||||
|
||||
t_s, t_e = _parse_ms(block, "today")
|
||||
assert t_s == day_start
|
||||
assert t_e == day_end
|
||||
assert t_e - t_s + 1 == 23 * 3_600_000 # 23-hour day
|
||||
|
||||
|
||||
def test_datetime_context_injection_dst_fall_back():
|
||||
"""Europe/Rome DST fall-back 2026-10-25: that day is 25h."""
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime
|
||||
|
||||
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-10-25T08:00:00Z")})
|
||||
tz = ZoneInfo("Europe/Rome")
|
||||
day_start = int(datetime(2026, 10, 25, tzinfo=tz).timestamp() * 1000)
|
||||
day_end = int(datetime(2026, 10, 26, tzinfo=tz).timestamp() * 1000) - 1
|
||||
|
||||
t_s, t_e = _parse_ms(block, "today")
|
||||
assert t_s == day_start
|
||||
assert t_e == day_end
|
||||
assert t_e - t_s + 1 == 25 * 3_600_000 # 25-hour day
|
||||
|
||||
|
||||
def test_datetime_context_injection_year_boundary():
|
||||
"""Dec 31 → Jan 1: last_year, this_year, next_month cross year boundary correctly."""
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime
|
||||
|
||||
block = _datetime_context_injection({"format_prefs": _fp("UTC", "2026-12-31T23:00:00Z")})
|
||||
tz = ZoneInfo("UTC")
|
||||
|
||||
yr_s, yr_e = _parse_ms(block, "this_year")
|
||||
assert yr_s == int(datetime(2026, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||
assert yr_e == int(datetime(2027, 1, 1, tzinfo=tz).timestamp() * 1000) - 1
|
||||
|
||||
ly_s, ly_e = _parse_ms(block, "last_year")
|
||||
assert ly_s == int(datetime(2025, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||
assert ly_e == yr_s - 1
|
||||
|
||||
nm_s, _ = _parse_ms(block, "next_month")
|
||||
assert nm_s == int(datetime(2027, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||
|
||||
|
||||
def test_datetime_context_injection_missing_format_prefs():
|
||||
assert _datetime_context_injection({}) == ""
|
||||
assert _datetime_context_injection({"format_prefs": None}) == ""
|
||||
assert _datetime_context_injection({"format_prefs": "bad"}) == ""
|
||||
|
||||
|
||||
# ── _request_context_block ─────────────────────────────────────────────────────
|
||||
|
||||
def test_request_context_block_scope_and_project():
|
||||
ctx = {"scope": {"type": "task", "id": "t-1"}, "resolved_project_id": "proj-uuid"}
|
||||
block = _request_context_block(ctx)
|
||||
assert "scope" in block
|
||||
assert "resolved_project_id: proj-uuid" in block
|
||||
|
||||
|
||||
def test_request_context_block_empty():
|
||||
assert _request_context_block({}) == ""
|
||||
assert _request_context_block({"scope": None}) == ""
|
||||
|
||||
|
||||
# ── _build_system_prompt ───────────────────────────────────────────────────────
|
||||
|
||||
def test_build_system_prompt_substitutes_all_slots(monkeypatch):
|
||||
"""All five slots must appear in the compiled output; no raw placeholder remains."""
|
||||
# Patch get_prompt_or_fallback to return None prompt_obj so we use fallback .format() path
|
||||
import app.core.deep_agent as da
|
||||
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda name, fallback: (fallback, None))
|
||||
|
||||
ctx = {
|
||||
"format_prefs": _fp("Europe/Rome", "2026-04-26T20:16:02.155Z"),
|
||||
"core_memory": {"language": "it"},
|
||||
"relational_memory": ["Alice — client"],
|
||||
"proactive_hints": ["User prefers morning meetings"],
|
||||
"scope": {"type": "task"},
|
||||
"resolved_project_id": "proj-1",
|
||||
}
|
||||
from app.core.deep_agent import _HOME_SYSTEM_PROMPT
|
||||
text, _ = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, ctx)
|
||||
|
||||
# No unresolved placeholders
|
||||
assert "{date_context}" not in text
|
||||
assert "{language_instruction}" not in text
|
||||
assert "{relational_memory}" not in text
|
||||
assert "{proactive_hints}" not in text
|
||||
assert "{request_context}" not in text
|
||||
|
||||
# Content was injected
|
||||
assert "DATE CONTEXT" in text
|
||||
assert "Italian" in text
|
||||
assert "Alice" in text
|
||||
assert "morning meetings" in text
|
||||
assert "proj-1" in text
|
||||
|
||||
|
||||
def test_build_system_prompt_empty_format_prefs(monkeypatch):
|
||||
"""Missing format_prefs must not raise — date_context slot renders empty string."""
|
||||
import app.core.deep_agent as da
|
||||
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda name, fallback: (fallback, None))
|
||||
|
||||
from app.core.deep_agent import _HOME_SYSTEM_PROMPT
|
||||
text, _ = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, {})
|
||||
# Prompt renders without error; date section is empty but structure holds
|
||||
assert "# Date filtering" in text
|
||||
assert "{date_context}" not in text
|
||||
|
||||
|
||||
def test_human_message_is_bare_message(monkeypatch):
|
||||
"""After the refactor HumanMessage content must equal the raw user message exactly."""
|
||||
import app.core.deep_agent as da
|
||||
from langchain_core.messages import HumanMessage as LCHumanMessage
|
||||
|
||||
captured: list[list] = []
|
||||
|
||||
class _CaptureLLM:
|
||||
def bind_tools(self, _):
|
||||
return self
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
captured.append(list(messages))
|
||||
return AIMessage(content="risposta")
|
||||
|
||||
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda n, f: (f, None))
|
||||
monkeypatch.setattr(da, "get_agent_llm", lambda _: _CaptureLLM())
|
||||
monkeypatch.setattr(da, "_all_tools_for_user", lambda *_: [])
|
||||
monkeypatch.setattr(da, "get_langfuse", lambda: None)
|
||||
monkeypatch.setattr(da, "set_tool_result_collector", lambda _: None)
|
||||
monkeypatch.setattr(da, "clear_tool_result_collector", lambda: None)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
chunks = []
|
||||
ctx = {"format_prefs": _fp("UTC", "2026-04-27T10:00:00Z")}
|
||||
async for ev in da.run_home_stream("u1", "Cosa devo fare domani?", ctx):
|
||||
chunks.append(ev)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_run())
|
||||
|
||||
assert captured, "LLM was never called"
|
||||
messages = captured[0]
|
||||
human = next(m for m in messages if isinstance(m, LCHumanMessage))
|
||||
assert human.content == "Cosa devo fare domani?"
|
||||
assert "Context:" not in human.content
|
||||
|
||||
Reference in New Issue
Block a user