Skip to content

Commit 4afbcf3

Browse files
committed
feat(agent): surface grounded sources/citations; accept bare paths
1 parent 1863bb0 commit 4afbcf3

10 files changed

Lines changed: 474 additions & 17 deletions

effgen/core/agent.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,22 @@ def __str__(self) -> str:
266266
"""
267267
return self.output if self.output is not None else ""
268268

269+
def __await__(self):
270+
"""Fail clearly if someone ``await``s the result of the sync ``run()``.
271+
272+
``Agent.run()`` is synchronous and returns this object directly, so
273+
``await agent.run(...)`` would otherwise raise the opaque
274+
``object AgentResponse can't be used in 'await' expression``. Point the
275+
caller at the async entry point instead.
276+
"""
277+
raise TypeError(
278+
"Agent.run() is synchronous and already returns the AgentResponse — "
279+
"don't await it. Use `result = agent.run(...)`, or for async code "
280+
"`result = await agent.run_async(...)`."
281+
)
282+
# Unreachable; makes this a generator function so it's a valid __await__.
283+
yield # pragma: no cover
284+
269285
def __repr__(self) -> str:
270286
"""A detailed-but-compact developer view.
271287
@@ -1492,9 +1508,19 @@ async def __aenter__(self):
14921508
"""Async context manager entry."""
14931509
return self
14941510

1511+
async def aclose(self) -> None:
1512+
"""Async-friendly alias for :meth:`close`.
1513+
1514+
Cleanup is synchronous (it closes SQLite handles and stops worker
1515+
threads), so this simply awaits nothing and calls :meth:`close`. It
1516+
exists so ``await agent.aclose()`` works symmetrically with
1517+
``await agent.run_async(...)`` and inside ``async with agent:``.
1518+
"""
1519+
self.close()
1520+
14951521
async def __aexit__(self, exc_type, exc_val, exc_tb):
14961522
"""Async context manager exit — clean up resources."""
1497-
self.close()
1523+
await self.aclose()
14981524
return False
14991525

15001526
def __del__(self):

effgen/core/agent_react.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,21 +1081,30 @@ def _collect_citations(self, tool: Any, tool_name: str, result: Any) -> None:
10811081
if not self._is_retrieval_tool(tool, tool_name):
10821082
return
10831083

1084-
# Unwrap ToolResult → output dict (skip failed calls).
1084+
# Unwrap ToolResult → output (skip failed calls).
10851085
output = result
10861086
if hasattr(result, "output"):
10871087
if hasattr(result, "success") and not result.success:
10881088
return
10891089
output = result.output
1090-
if not isinstance(output, dict):
1091-
return
10921090

1091+
# Find the list of source items. Tools surface them three ways:
1092+
# - a bare list of rows (web_search → [{title, url, snippet}, ...]);
1093+
# - a dict wrapping a list under a well-known key (RAG/retrieval);
1094+
# - a single-document dict (url_fetch → {url, title, text}).
10931095
items = None
1094-
for key in ("results", "documents", "chunks", "matches", "passages", "sources"):
1095-
val = output.get(key)
1096-
if isinstance(val, list) and val:
1097-
items = val
1098-
break
1096+
if isinstance(output, list):
1097+
items = output
1098+
elif isinstance(output, dict):
1099+
for key in ("results", "documents", "chunks", "matches", "passages", "sources"):
1100+
val = output.get(key)
1101+
if isinstance(val, list) and val:
1102+
items = val
1103+
break
1104+
if items is None and (
1105+
output.get("url") or output.get("source") or output.get("file_path")
1106+
):
1107+
items = [output]
10991108
if not items:
11001109
return
11011110

@@ -1143,8 +1152,31 @@ def _attach_citations(self, response: "AgentResponse") -> None:
11431152
Populate ``response.sources`` / ``response.citations`` from the evidence
11441153
collected during the run (deduplicated, 1-based citation indices). Only
11451154
fills fields the assembly path left empty, so explicit callers win.
1155+
1156+
Two evidence sources are merged: passages mined from local retrieval/
1157+
search tools (``_collected_citations``), and grounded URLs a provider
1158+
cited natively (``metadata["grounding_chunks"]`` — OpenAI web_search
1159+
``url_citation`` annotations and Gemini search grounding). Only real,
1160+
retrieved URLs land here — never URLs scraped from the model's prose.
11461161
"""
1147-
raw = getattr(self, "_collected_citations", None)
1162+
raw = list(getattr(self, "_collected_citations", None) or [])
1163+
# Fold provider-native grounding chunks ({url, title}) into the same
1164+
# accumulator shape so the dedup/assembly below handles every path.
1165+
meta = response.metadata if isinstance(response.metadata, dict) else {}
1166+
for chunk in meta.get("grounding_chunks") or []:
1167+
if not isinstance(chunk, dict):
1168+
continue
1169+
url = chunk.get("url") or chunk.get("uri") or chunk.get("source")
1170+
if not url:
1171+
continue
1172+
raw.append({
1173+
"source": str(url),
1174+
"chunk_id": "",
1175+
"score": 0.0,
1176+
"quote": str(chunk.get("title") or chunk.get("snippet") or "").strip(),
1177+
"page": None,
1178+
"section": None,
1179+
})
11481180
if not raw:
11491181
return
11501182

effgen/core/agent_runtime.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,19 @@ def sanitize_final_answer(text: str | None) -> str | None:
170170
# 3. Drop trailing Observation/Thought/Question/Action bleed.
171171
s = _TRAILING_BLEED_RE.sub("", s)
172172
# 4. If a line-anchored answer label is present, the real answer is what
173-
# follows the LAST such label (when that tail is non-empty).
173+
# follows the LAST such label (when that tail is non-empty). A dangling
174+
# label with nothing after it (e.g. native web-search replies sometimes
175+
# end with a bare "Final Answer:") is scaffolding — drop the label and
176+
# keep the content before it.
174177
labels = list(_ANSWER_LABEL_RE.finditer(s))
175178
if labels:
176179
tail = s[labels[-1].end():].strip()
177180
if tail:
178181
s = tail
182+
else:
183+
head = s[:labels[-1].start()].strip()
184+
if head:
185+
s = head
179186
# 5. Tidy separators left by removed scaffolding, without disturbing
180187
# multi-line content (tables/code): collapse empty "| |" fragments and
181188
# runs of spaces, then strip a dangling leading/trailing bare pipe.
@@ -421,12 +428,28 @@ def _build_multimodal_prompt(self, task: str, inputs: Any) -> list[Any]:
421428
elif not isinstance(inputs, list):
422429
inputs = [inputs]
423430

431+
from pathlib import Path as _Path
432+
424433
content: list[ContentPart] = [TextPart(text=task)]
425434
for index, part in enumerate(inputs):
426-
if not hasattr(part, "type"):
435+
# Convenience: a bare path or URL string (or Path) is auto-wrapped
436+
# by extension into the matching image/audio/video part, so
437+
# inputs=["photo.png"] works without importing image_from().
438+
if isinstance(part, str | _Path):
439+
from effgen.core.multimodal import part_from
440+
from effgen.errors import InvalidMultimodalContent
441+
try:
442+
part = part_from(part)
443+
except InvalidMultimodalContent as exc:
444+
raise TypeError(
445+
f"Agent.run(inputs=[...]) item {index}: {exc}. "
446+
"Import the helper with: from effgen import image_from"
447+
) from exc
448+
elif not hasattr(part, "type"):
427449
raise TypeError(
428450
"Agent.run(inputs=[...]) expects effGen multimodal parts "
429-
f"(image_from/audio_from/video_from); item {index} is {type(part).__name__}"
451+
f"(image_from/audio_from/video_from); item {index} is {type(part).__name__}. "
452+
"Import them with: from effgen import image_from, audio_from, video_from"
430453
)
431454
content.append(part)
432455

effgen/core/multimodal.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,74 @@ def video_from(
274274
return VideoPart(frames=[frame.image for frame in frames], fps=fps, mime=mime)
275275
finally:
276276
vs.cleanup()
277+
278+
279+
# ---------------------------------------------------------------------------
280+
# Bare-path / URL coercion
281+
# ---------------------------------------------------------------------------
282+
283+
# Extension → media kind, for the common cases where mimetypes can't help
284+
# (or guesses differently across platforms).
285+
_AUDIO_EXTS = frozenset({
286+
".mp3", ".wav", ".flac", ".ogg", ".oga", ".m4a", ".aac", ".opus", ".webm", ".weba",
287+
})
288+
_VIDEO_EXTS = frozenset({
289+
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".mpeg", ".mpg", ".wmv", ".flv",
290+
})
291+
_IMAGE_EXTS = frozenset({
292+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic", ".heif",
293+
})
294+
295+
296+
def _media_kind_from_name(name: str) -> str | None:
297+
"""Best-effort image/audio/video classification from a path or URL string.
298+
299+
Returns ``"image"``, ``"audio"``, ``"video"`` or ``None`` (unknown).
300+
"""
301+
# Strip any URL query/fragment before looking at the extension.
302+
base = name.split("?", 1)[0].split("#", 1)[0]
303+
ext = os.path.splitext(base)[1].lower()
304+
# ``.webm`` is overloaded (audio or video); the extension maps are checked
305+
# video-first so a bare ``clip.webm`` becomes a VideoPart.
306+
if ext in _VIDEO_EXTS:
307+
return "video"
308+
if ext in _AUDIO_EXTS:
309+
return "audio"
310+
if ext in _IMAGE_EXTS:
311+
return "image"
312+
guessed, _ = mimetypes.guess_type(base)
313+
if guessed:
314+
top = guessed.split("/", 1)[0]
315+
if top in ("image", "audio", "video"):
316+
return top
317+
return None
318+
319+
320+
def part_from(source: str | Path) -> ImagePart | AudioPart | VideoPart:
321+
"""Wrap a bare file path or URL as the matching multimodal ContentPart.
322+
323+
Detects image/audio/video from the file extension (falling back to the
324+
MIME type) and delegates to :func:`image_from`, :func:`audio_from`, or
325+
:func:`video_from`. Use this when you have a path/URL and don't want to pick
326+
the specific helper yourself; :meth:`Agent.run` applies it automatically to
327+
bare paths passed in ``inputs=``.
328+
329+
Raises:
330+
InvalidMultimodalContent: if the media kind can't be determined from the
331+
name — pass ``image_from(...)`` / ``audio_from(...)`` /
332+
``video_from(...)`` explicitly in that case.
333+
"""
334+
name = str(source)
335+
kind = _media_kind_from_name(name)
336+
if kind == "image":
337+
return image_from(source)
338+
if kind == "audio":
339+
return audio_from(source)
340+
if kind == "video":
341+
return video_from(source)
342+
raise InvalidMultimodalContent(
343+
"source",
344+
f"Could not infer the media type of {name!r} from its extension. "
345+
"Wrap it explicitly with image_from(...), audio_from(...), or "
346+
"video_from(...) (importable from effgen).",
347+
)

effgen/models/openai_adapter.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,16 +1112,28 @@ def generate_with_native_tools(
11121112
# Extract the output text from the response
11131113
output_text = ""
11141114
tool_call_results: list[dict[str, Any]] = []
1115+
# Grounded source URLs the model actually cited (web_search url_citation
1116+
# annotations). Surfaced uniformly as ``grounding_chunks`` so the Agent
1117+
# can fill AgentResponse.sources / .citations from real provider data.
1118+
grounding_chunks: list[dict[str, Any]] = []
11151119

11161120
for item in response.output:
11171121
item_type = getattr(item, "type", None)
11181122
if item_type == "message":
11191123
for content_block in getattr(item, "content", []):
11201124
block_type = getattr(content_block, "type", None)
1121-
if block_type == "output_text":
1122-
output_text += getattr(content_block, "text", "")
1123-
elif block_type == "text":
1125+
if block_type in ("output_text", "text"):
11241126
output_text += getattr(content_block, "text", "")
1127+
for ann in getattr(content_block, "annotations", None) or []:
1128+
if getattr(ann, "type", None) != "url_citation":
1129+
continue
1130+
url = getattr(ann, "url", None)
1131+
if not url:
1132+
continue
1133+
grounding_chunks.append({
1134+
"url": url,
1135+
"title": getattr(ann, "title", None),
1136+
})
11251137
elif item_type == "web_search_call":
11261138
tool_call_results.append({"type": "web_search_call", "id": getattr(item, "id", "")})
11271139
elif item_type == "code_interpreter_call":
@@ -1185,6 +1197,7 @@ def generate_with_native_tools(
11851197
"response_id": getattr(response, "id", None),
11861198
"tool_calls": tool_call_results,
11871199
"native_tool_results": tool_call_results,
1200+
"grounding_chunks": grounding_chunks,
11881201
},
11891202
)
11901203

effgen/presets/registry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ class PresetConfig:
8080
"Prefer academic sources for scientific or medical questions. For current events, "
8181
"use news, rss_feed, reddit, or hackernews tools. For video content, use "
8282
"youtube_transcript to read captions and youtube_metadata for video details. "
83-
"Cite your sources and synthesize findings into clear answers."
83+
"Synthesize findings into clear answers. When you cite a source URL, use "
84+
"ONLY a URL that one of your tools actually returned in this conversation — "
85+
"copy it verbatim. Never invent, guess, or reconstruct a URL from memory; "
86+
"if you have no tool-returned URL for a claim, say so rather than fabricate one."
8487
),
8588
max_iterations=10,
8689
temperature=0.5,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Async cleanup symmetry and a clear error when awaiting the sync ``run()``.
2+
3+
``run()`` is sync and ``run_async()`` is async; cleanup should be reachable from
4+
both worlds. These offline tests pin:
5+
6+
* ``await agent.aclose()`` works and is idempotent with ``close()``;
7+
* ``async with agent:`` cleans up via the async path;
8+
* ``await agent.run(...)`` fails with a helpful message (not the opaque
9+
"object AgentResponse can't be used in 'await' expression").
10+
"""
11+
12+
import asyncio
13+
14+
import pytest
15+
16+
from effgen import create_agent
17+
from effgen.core.agent import AgentResponse
18+
19+
20+
def _agent():
21+
return create_agent("minimal", "x", require_model=False)
22+
23+
24+
def test_aclose_closes_and_is_idempotent():
25+
agent = _agent()
26+
27+
async def go():
28+
await agent.aclose()
29+
# Second call is a no-op, never raises.
30+
await agent.aclose()
31+
32+
asyncio.run(go())
33+
assert agent._closed is True
34+
35+
36+
def test_async_with_cleans_up():
37+
async def go():
38+
async with _agent() as agent:
39+
assert agent._closed is False
40+
assert agent._closed is True
41+
42+
asyncio.run(go())
43+
44+
45+
def test_await_sync_run_gives_helpful_error():
46+
resp = AgentResponse(output="hi")
47+
48+
async def go():
49+
with pytest.raises(TypeError) as exc:
50+
await resp
51+
msg = str(exc.value)
52+
assert "run_async" in msg
53+
assert "synchronous" in msg
54+
55+
asyncio.run(go())

tests/unit/test_answer_sanitization.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
("[calculator({'expression': '15*15'})] → 225", "225"),
3737
# List-prefixed final-answer label.
3838
("- Final Answer: done", "done"),
39+
# OpenAI native web-search replies sometimes end with a bare, dangling
40+
# "Final Answer:" label and nothing after it — strip the label, keep the
41+
# real answer that precedes it.
42+
(
43+
"The outage happened on July 19, 2024. ([blogs.microsoft.com](https://x))\n\nFinal Answer:",
44+
"The outage happened on July 19, 2024. ([blogs.microsoft.com](https://x))",
45+
),
46+
("Paris.\nFinal Answer: ", "Paris."),
3947
],
4048
)
4149
def test_strips_scaffolding(leaked, expected):

0 commit comments

Comments
 (0)