Skip to content
2 changes: 2 additions & 0 deletions docs/guides/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,8 @@ names the update command.
| `Ctrl+V` | Paste — text, or a clipboard **image** (a screenshot), which is saved to a temp file and its path inserted |
| `Ctrl+Y` | Copy the whole answer |
| `Ctrl+B` | Copy the last code block in the answer |
| `Ctrl+F` | Open the newest generated image or audio file. Available only for the verified image/audio cards shown by the TUI |
| `Ctrl+G` | Reveal the newest generated image or audio file in its folder. Like `Ctrl+F`, this is unavailable over SSH; use the displayed path on the remote machine instead |
| `Ctrl+O` | Show every step in full — the agent's narration, wrapped commands, timings — or fold it back to one row per step |

### Reading a turn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
# #2765: a generic ``table`` card (no new client code) so the thread
# view renders straight from tool data instead of model prose.
"get_thread": "table",
"generate_image": "media",
"generate_music": "media",
"generate_sound_effect": "media",
"text_to_speech": "media",
}

# HTTP-style status codes for the canonical ``error`` event's ``status`` field.
Expand Down Expand Up @@ -224,6 +228,7 @@ def _on_chunk(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
def _on_tool_result(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
self._result_seen_since_call = True
data = event.get("result_data")
has_card_payload = isinstance(data, dict) and bool(data)
if data is None:
# The handler's tool_result event is a summary view; carry the
# available structured bits so the generic result card has content.
Expand All @@ -237,7 +242,7 @@ def _on_tool_result(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
"data": data,
}
render = _RENDER_TOOL_TO_LANG.get(self._last_tool or "")
if render:
if render and has_card_payload:
canonical["render"] = render
return [canonical]

Expand Down
11 changes: 11 additions & 0 deletions hub/agents/email/python/tests/test_sse_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,17 @@ def test_tool_result_carries_tool_and_data():
]


def test_mapped_tool_without_a_card_payload_stays_a_plain_result():
t = _tr()
t.translate({"type": "tool_start", "tool": "get_thread"})
t.translate({"type": "tool_args", "tool": "get_thread", "args": {}})
[result] = t.translate(
{"type": "tool_result", "title": "Result", "summary": "plain text thread"}
)
assert "render" not in result
assert result["data"]["summary"] == "plain text thread"


def test_pre_scan_draws_no_card():
"""The triage reply is the single inbox view: a pre-scan card landing
mid-turn showed a partial list beside the answer still being written."""
Expand Down
6 changes: 6 additions & 0 deletions hub/agents/gaia/npm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Requires the `gaia` Python CLI 0.26.0+ on `PATH`.

- **Generated images and audio arrive as a `media` card.** A successful
`generate_image`, `generate_music`, `generate_sound_effect` or `text_to_speech`
result now carries `render: "media"` with the saved file's absolute path, type,
size and model, so a client can show and open it without reading a path out of
the answer. Only an existing local image or audio file becomes a card; anything
else stays a plain tool result.
- **The "not confirmed" line on an answer is now documented.** An answer that
ends with `I haven't confirmed this works — …` is the agent saying a check
failed, was blocked, or never ran after a change. README, SPEC and SKILL say
Expand Down
9 changes: 8 additions & 1 deletion hub/agents/gaia/python/gaia_agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1811,7 +1811,14 @@ async def _with_context(final: Dict[str, Any]) -> Dict[str, Any]:

async def _stream():
translator = CanonicalTranslator(
request.run_id, agent_id=AGENT_ID, reasoning=request.reasoning
request.run_id,
agent_id=AGENT_ID,
render_tool_map={
tool: render
for tool, render in SSEOutputHandler._RENDER_TOOL_TO_LANG.items()
if render == "media"
},
reasoning=request.reasoning,
)
terminated = False
last_write = time.monotonic()
Expand Down
11 changes: 10 additions & 1 deletion hub/agents/gaia/python/gaia_agent/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -1495,7 +1495,16 @@ def run_turn(
agent.console = handler
if state is not None:
state.attach(handler)
translator = CanonicalTranslator(run_id=None, agent_id=AGENT_ID, debug=dev)
translator = CanonicalTranslator(
run_id=None,
agent_id=AGENT_ID,
render_tool_map={
tool: render
for tool, render in SSEOutputHandler._RENDER_TOOL_TO_LANG.items()
if render == "media"
},
debug=dev,
)
result: Dict[str, Any] = {}

def _run() -> None:
Expand Down
4 changes: 3 additions & 1 deletion src/gaia/ui/email_sidecar/relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,9 @@ def _dispatch_one(
if isinstance(data, dict) and isinstance(data.get("mcp_output"), dict):
out["mcp_output"] = data["mcp_output"]
render = event.get("render")
if render:
# "media" is the terminal's card; the Agent UI draws generated media
# itself (``media_card``) from the path the payload carries.
if render and render != "media":
out["render"] = render
handler._emit(out)

Expand Down
4 changes: 2 additions & 2 deletions src/gaia/ui/generated_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _result_path(event: Mapping[str, Any], key: str) -> Optional[str]:
return None


def _is_remote_or_device(raw: str) -> bool:
def is_remote_or_device_path(raw: str) -> bool:
"""True for a UNC, device or NT-namespace spelling, judged without touching disk."""
if "\x00" in raw:
return True
Expand Down Expand Up @@ -182,7 +182,7 @@ def media_card(
if not raw:
continue
# Decide on the string alone first: resolving a UNC path reaches out to the share.
if _is_remote_or_device(raw) or not _lexically_in_media_dir(kind, raw):
if is_remote_or_device_path(raw) or not _lexically_in_media_dir(kind, raw):
logger.info("No inline %s for %s: not in %s", kind, raw, media_dir(kind))
return None
name = Path(raw).name
Expand Down
77 changes: 77 additions & 0 deletions src/gaia/ui/sse_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import uuid
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from typing import Any, ClassVar, Dict, Iterator, List, Optional

from gaia.agents.base.call_risk import NEEDS_PERSON_ARG, call_risk
Expand Down Expand Up @@ -47,6 +48,7 @@
grant_phrase,
tool_phrase,
)
from gaia.ui.generated_media import is_remote_or_device_path
from gaia.ui.turn_summary import TurnLedger, search_hits
from gaia.upload_names import display_file_name, upload_original_name
from gaia.utils.json_string import (
Expand All @@ -69,6 +71,13 @@
#: relayed result is reduced to.
RESULT_PATH_KEYS = ("file_path", "image_path", "audio_path")

#: Generated media file types that a client may later open through an explicit
#: card action. Keep this intentionally narrow: a successful tool result is
#: not enough to turn an arbitrary local file into a launchable UI payload.
MEDIA_OUTPUT_EXTENSIONS = frozenset(
{".png", ".jpg", ".jpeg", ".webp", ".wav", ".mp3", ".ogg", ".flac"}
)

#: A tool result with one of these statuses did not do its job.
FAILED_TOOL_STATUSES = FAILED_STATUSES

Expand Down Expand Up @@ -1133,6 +1142,13 @@ def report_phase(self, phase: str, message: str, **counts: int):
# #2765: a generic ``table`` card (no new client code) so the thread
# view renders straight from tool data instead of model prose.
"get_thread": "table",
# Media-producing tools return a verified local path. The client gets
# it through a typed card rather than scraping a model-written path out
# of prose, so opening it cannot become an agent-output execution sink.
"generate_image": "media",
"generate_music": "media",
"generate_sound_effect": "media",
"text_to_speech": "media",
}

def _render_card_payload(self, data: Any) -> Optional[Dict[str, Any]]:
Expand Down Expand Up @@ -1162,13 +1178,74 @@ def _render_card_payload(self, data: Any) -> Optional[Dict[str, Any]]:
envelope = parsed
else:
return None
if lang == "media":
return self._media_card_payload(envelope)
if not envelope.get("ok"):
return None
inner = envelope.get("data")
if not isinstance(inner, dict) or inner.get("kind") != lang:
return None
return inner

@staticmethod
def _media_card_payload(envelope: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Return a typed card only for a verified media file this tool wrote.

The terminal must never open a path copied from model prose or from an
unrelated tool's payload. The render map admits only media producers;
this second check requires their successful, absolute, regular-file
output before the path crosses the wire as an actionable card.
"""
if envelope.get("status") != "success":
return None
path_key = next(
(
key
for key in ("image_path", "audio_path", "file_path")
if isinstance(envelope.get(key), str) and envelope[key].strip()
),
None,
)
if path_key is None:
return None
# Judged on the string first: stat-ing a UNC path reaches out to the share.
if is_remote_or_device_path(envelope[path_key]):
return None
path = Path(envelope[path_key]).expanduser()
if not path.is_absolute() or path.suffix.lower() not in MEDIA_OUTPUT_EXTENSIONS:
return None
try:
stat = path.stat()
except OSError:
return None
if not path.is_file():
return None
image = path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
media_type = "image" if image else "audio"
payload: Dict[str, Any] = {
"kind": "media",
"path": str(path),
"media_type": media_type,
"size_bytes": stat.st_size,
# A relayed turn reaches the Agent UI as this payload alone; its
# inline image/audio card and Outputs read the tool's own keys.
"status": "success",
path_key: str(path),
}
for key in (
"model",
"size",
"steps",
"voice",
"format",
"duration_seconds",
"duration_requested",
"generation_time_s",
):
if envelope.get(key) is not None:
payload[key] = envelope[key]
return payload

# === Completion Methods ===

def print_turn_metrics(self, record: Dict[str, Any]):
Expand Down
6 changes: 5 additions & 1 deletion src/gaia/ui/sse_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ def _on_commentary(self, _event: Dict[str, Any]) -> List[Dict[str, Any]]:
def _on_tool_result(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
self._result_seen_since_call = True
data = event.get("result_data")
has_card_payload = isinstance(data, dict) and bool(data)
if data is None:
# The handler's tool_result event is a summary view; carry the
# available structured bits so the generic result card has content.
Expand All @@ -454,7 +455,10 @@ def _on_tool_result(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
render = event.get("render")
if not isinstance(render, str) or not render:
render = self._render_tool_map.get(self._last_tool or "")
if render:
# Render only an actual typed payload. A name alone must remain a normal
# tool result: otherwise unavailable media and plain-text results become
# invalid cards in clients.
if render and has_card_payload:
canonical["render"] = render
return [canonical]

Expand Down
38 changes: 38 additions & 0 deletions tests/unit/chat/ui/test_generated_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,44 @@ def test_handler_and_translation_carry_audio_path(gaia_dir):
assert relayed["data"]["audio_path"] == wav


def test_a_relayed_flagship_media_result_still_draws_the_inline_card(gaia_dir):
"""The terminal's ``media`` card must not displace the Agent UI's own card."""
from gaia.ui.email_sidecar.profiles import profile_for
from gaia.ui.email_sidecar.relay import _dispatch_one
from gaia.ui.sse_handler import SSEOutputHandler
from gaia.ui.sse_translation import CanonicalTranslator

wav = str(_wav(media_dir("audio") / "music_5.wav"))
sidecar = SSEOutputHandler()
sidecar._last_tool_name = "generate_music"
sidecar.pretty_print_json({"status": "success", "audio_path": wav}, "Result")
result = next(
e
for e in iter(sidecar.event_queue.get_nowait, None)
if e.get("type") == "tool_result"
)
translator = CanonicalTranslator(
run_id="r", agent_id="gaia", render_tool_map={"generate_music": "media"}
)
translator.translate({"type": "tool_start", "tool": "generate_music"})
canonical = next(
e for e in translator.translate(result) if e["type"] == "tool_result"
)
assert canonical["render"] == "media"

ui = SSEOutputHandler()
_dispatch_one(ui, canonical, profile_for("gaia"))
relayed = next(
e
for e in iter(ui.event_queue.get_nowait, None)
if e.get("type") == "tool_result"
)
assert "render" not in relayed
card = media_card(relayed, {"prompt": "rain"})
assert card["render"] == "audio"
assert card["data"]["src"] == "/api/media/audio/music_5.wav"


# ── The chat stream: the card is streamed, persisted and reloaded ────────────


Expand Down
Loading
Loading