Skip to content

Commit cf572da

Browse files
authored
Merge pull request #98 from cofacts/fix-investigator-empty-response
fix: return error dict to writer when investigator returns empty
2 parents f17aa01 + d994ae7 commit cf572da

7 files changed

Lines changed: 400 additions & 0 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
types: [opened, synchronize]
9+
10+
jobs:
11+
test-adk:
12+
name: Test adk (pytest)
13+
runs-on: ubuntu-latest
14+
defaults:
15+
run:
16+
working-directory: adk
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v5
22+
with:
23+
enable-cache: true
24+
25+
- name: Set up Python
26+
run: uv python install
27+
28+
- name: Install dependencies
29+
run: uv sync --group dev
30+
31+
- name: Run pytest
32+
run: uv run pytest -v

adk/.dockerignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ __pycache__
1111

1212
# env files (injected at runtime)
1313
cofacts_ai/.env
14+
15+
# tests (not needed at runtime)
16+
cofacts_ai/tests

adk/cofacts_ai/agent.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,8 +586,18 @@ async def after_tool(
586586
),
587587
)
588588
return parsed
589+
if tool_response is None or (isinstance(tool_response, str) and not tool_response.strip()):
590+
return {
591+
"error": "timeout",
592+
"message": "[SYSTEM] Investigator returned empty. Possibly timeout. Retry with simpler/fewer queries.",
593+
}
589594
return tool_response
590595

596+
if tool_response is None or (isinstance(tool_response, str) and not tool_response.strip()):
597+
return {
598+
"error": "timeout",
599+
"message": "[SYSTEM] Verifier returned empty. Possibly timeout. Retry with fewer URLs or claims.",
600+
}
591601
if not isinstance(tool_response, str):
592602
return None
593603
try:

adk/cofacts_ai/tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import os
2+
3+
# cofacts_ai.agent calls load_dotenv() then setup_instrumentation() at module
4+
# import time. setup_instrumentation() performs a real Langfuse auth_check()
5+
# network call whenever both LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are
6+
# set. load_dotenv() defaults to override=False, so pre-setting these to
7+
# empty strings here (before any test module imports cofacts_ai.agent) makes
8+
# it skip instrumentation regardless of what's in cofacts_ai/.env.
9+
os.environ.setdefault("LANGFUSE_PUBLIC_KEY", "")
10+
os.environ.setdefault("LANGFUSE_SECRET_KEY", "")
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""Unit tests for ai_writer's callbacks: `after_tool` and
2+
`handle_writer_tool_error`.
3+
4+
`after_tool` post-processes investigator/verifier responses -- most
5+
importantly the empty/None/whitespace timeout-error protection, since a
6+
real server-side timeout is probabilistic and can't be reproduced
7+
deterministically over the network. `handle_writer_tool_error` converts
8+
any exception a writer tool raises into a structured error dict. Both are
9+
exercised purely through mocked tool/tool_context/tool_response inputs.
10+
"""
11+
12+
import json
13+
from types import SimpleNamespace
14+
from typing import Optional, cast
15+
from unittest.mock import AsyncMock
16+
17+
from google.adk.agents.callback_context import CallbackContext
18+
from google.adk.tools.base_tool import BaseTool
19+
20+
from cofacts_ai.agent import after_tool, handle_writer_tool_error
21+
22+
23+
def make_tool(name: str) -> BaseTool:
24+
"""Fake BaseTool -- after_tool/handle_writer_tool_error only read .name."""
25+
return cast(BaseTool, SimpleNamespace(name=name))
26+
27+
28+
def make_tool_context(function_call_id: Optional[str] = "fc-1") -> CallbackContext:
29+
"""Fake CallbackContext.
30+
31+
after_tool only touches tool_context.function_call_id (plain attribute
32+
read) and `await tool_context.save_artifact(...)`. function_call_id is
33+
set explicitly because AsyncMock auto-vivifies attribute access as more
34+
AsyncMock instances, which are always truthy.
35+
"""
36+
ctx = AsyncMock()
37+
ctx.function_call_id = function_call_id
38+
return cast(CallbackContext, ctx)
39+
40+
41+
INVESTIGATOR_TIMEOUT_ERROR = {
42+
"error": "timeout",
43+
"message": "[SYSTEM] Investigator returned empty. Possibly timeout. Retry with simpler/fewer queries.",
44+
}
45+
46+
VERIFIER_TIMEOUT_ERROR = {
47+
"error": "timeout",
48+
"message": "[SYSTEM] Verifier returned empty. Possibly timeout. Retry with fewer URLs or claims.",
49+
}
50+
51+
52+
class TestAfterToolInvestigator:
53+
async def test_empty_string_returns_timeout_error(self):
54+
result = await after_tool(
55+
tool=make_tool("investigator"),
56+
args={},
57+
tool_context=make_tool_context(),
58+
tool_response="",
59+
)
60+
assert result == INVESTIGATOR_TIMEOUT_ERROR
61+
62+
async def test_whitespace_only_returns_timeout_error(self):
63+
result = await after_tool(
64+
tool=make_tool("investigator"),
65+
args={},
66+
tool_context=make_tool_context(),
67+
tool_response=" \n\t",
68+
)
69+
assert result == INVESTIGATOR_TIMEOUT_ERROR
70+
71+
async def test_none_returns_timeout_error(self):
72+
result = await after_tool(
73+
tool=make_tool("investigator"),
74+
args={},
75+
tool_context=make_tool_context(),
76+
tool_response=None,
77+
)
78+
assert result == INVESTIGATOR_TIMEOUT_ERROR
79+
80+
async def test_valid_json_dict_without_widget_html(self):
81+
tool_context = make_tool_context()
82+
payload = json.dumps({"content": "x", "sources": []})
83+
84+
result = await after_tool(
85+
tool=make_tool("investigator"),
86+
args={},
87+
tool_context=tool_context,
88+
tool_response=payload,
89+
)
90+
91+
assert result == {"content": "x", "sources": []}
92+
tool_context.save_artifact.assert_not_awaited()
93+
94+
async def test_valid_json_with_widget_html_saves_artifact_and_strips_it(self):
95+
tool_context = make_tool_context(function_call_id="fc-42")
96+
payload = json.dumps(
97+
{
98+
"content": "x",
99+
"sources": [],
100+
"_search_widget_html": "<div>widget</div>",
101+
}
102+
)
103+
104+
result = await after_tool(
105+
tool=make_tool("investigator"),
106+
args={},
107+
tool_context=tool_context,
108+
tool_response=payload,
109+
)
110+
111+
assert result == {"content": "x", "sources": []}
112+
tool_context.save_artifact.assert_awaited_once()
113+
_, kwargs = tool_context.save_artifact.call_args
114+
assert kwargs["filename"] == "search-widget-fc-42.html"
115+
assert kwargs["artifact"].inline_data.mime_type == "text/html"
116+
assert kwargs["artifact"].inline_data.data == b"<div>widget</div>"
117+
118+
async def test_widget_html_present_but_no_function_call_id_skips_artifact_save(self):
119+
tool_context = make_tool_context(function_call_id=None)
120+
payload = json.dumps(
121+
{
122+
"content": "x",
123+
"sources": [],
124+
"_search_widget_html": "<div>widget</div>",
125+
}
126+
)
127+
128+
result = await after_tool(
129+
tool=make_tool("investigator"),
130+
args={},
131+
tool_context=tool_context,
132+
tool_response=payload,
133+
)
134+
135+
assert result == {"content": "x", "sources": []}
136+
tool_context.save_artifact.assert_not_awaited()
137+
138+
async def test_json_parse_failure_nonempty_garbage_passthrough(self):
139+
result = await after_tool(
140+
tool=make_tool("investigator"),
141+
args={},
142+
tool_context=make_tool_context(),
143+
tool_response="not json {{",
144+
)
145+
assert result == "not json {{"
146+
147+
async def test_valid_json_but_not_a_dict_passthrough(self):
148+
result = await after_tool(
149+
tool=make_tool("investigator"),
150+
args={},
151+
tool_context=make_tool_context(),
152+
tool_response="[1, 2, 3]",
153+
)
154+
assert result == "[1, 2, 3]"
155+
156+
async def test_non_string_non_none_response_passthrough(self):
157+
already_a_dict = {"already": "a dict"}
158+
result = await after_tool(
159+
tool=make_tool("investigator"),
160+
args={},
161+
tool_context=make_tool_context(),
162+
tool_response=already_a_dict,
163+
)
164+
assert result is already_a_dict
165+
166+
167+
class TestAfterToolVerifier:
168+
async def test_empty_string_returns_timeout_error(self):
169+
result = await after_tool(
170+
tool=make_tool("verifier"),
171+
args={},
172+
tool_context=make_tool_context(),
173+
tool_response="",
174+
)
175+
assert result == VERIFIER_TIMEOUT_ERROR
176+
177+
async def test_whitespace_only_returns_timeout_error(self):
178+
result = await after_tool(
179+
tool=make_tool("verifier"),
180+
args={},
181+
tool_context=make_tool_context(),
182+
tool_response=" \n",
183+
)
184+
assert result == VERIFIER_TIMEOUT_ERROR
185+
186+
async def test_none_returns_timeout_error(self):
187+
result = await after_tool(
188+
tool=make_tool("verifier"),
189+
args={},
190+
tool_context=make_tool_context(),
191+
tool_response=None,
192+
)
193+
assert result == VERIFIER_TIMEOUT_ERROR
194+
195+
async def test_valid_json_returns_parsed_dict(self):
196+
payload = json.dumps({"content": "c", "sources": [{"title": "t", "url": "u"}]})
197+
result = await after_tool(
198+
tool=make_tool("verifier"),
199+
args={},
200+
tool_context=make_tool_context(),
201+
tool_response=payload,
202+
)
203+
assert result == {"content": "c", "sources": [{"title": "t", "url": "u"}]}
204+
205+
async def test_invalid_json_returns_none(self):
206+
result = await after_tool(
207+
tool=make_tool("verifier"),
208+
args={},
209+
tool_context=make_tool_context(),
210+
tool_response="{not valid json",
211+
)
212+
assert result is None
213+
214+
async def test_non_string_response_returns_none(self):
215+
result = await after_tool(
216+
tool=make_tool("verifier"),
217+
args={},
218+
tool_context=make_tool_context(),
219+
tool_response=[1, 2, 3],
220+
)
221+
assert result is None
222+
223+
224+
class TestAfterToolDispatch:
225+
async def test_unrelated_tool_name_returns_none_immediately(self):
226+
tool_context = make_tool_context()
227+
result = await after_tool(
228+
tool=make_tool("search_cofacts_database"),
229+
args={},
230+
tool_context=tool_context,
231+
tool_response=json.dumps({"content": "x"}),
232+
)
233+
assert result is None
234+
tool_context.save_artifact.assert_not_awaited()
235+
236+
237+
class TestHandleWriterToolError:
238+
def test_formats_generic_exception(self):
239+
result = handle_writer_tool_error(
240+
tool=make_tool("investigator"),
241+
args={},
242+
tool_context=None,
243+
error=ValueError("boom"),
244+
)
245+
assert result == {
246+
"error": "ValueError",
247+
"message": (
248+
"[SYSTEM] Tool 'investigator' failed with ValueError: boom. "
249+
"Please note this failure and continue with available information."
250+
),
251+
}
252+
253+
def test_uses_actual_exception_type_name(self):
254+
result = handle_writer_tool_error(
255+
tool=make_tool("verifier"),
256+
args={},
257+
tool_context=None,
258+
error=RuntimeError("oops"),
259+
)
260+
assert result == {
261+
"error": "RuntimeError",
262+
"message": (
263+
"[SYSTEM] Tool 'verifier' failed with RuntimeError: oops. "
264+
"Please note this failure and continue with available information."
265+
),
266+
}

adk/pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,14 @@ dependencies = [
1818
"asyncpg>=0.31.0",
1919
"uvicorn>=0.41.0",
2020
]
21+
22+
[dependency-groups]
23+
dev = [
24+
"pytest>=9.1.1",
25+
"pytest-asyncio>=1.4.0",
26+
]
27+
28+
[tool.pytest.ini_options]
29+
asyncio_mode = "auto"
30+
testpaths = ["cofacts_ai/tests"]
31+
pythonpath = ["."]

0 commit comments

Comments
 (0)