Skip to content
8 changes: 8 additions & 0 deletions pantheon/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1879,6 +1879,14 @@ async def _run_stream(
- When specifying file paths, use relative paths or absolute paths within {workdir}
- The file manager and shell tools enforce this restriction at the code level
</workdir_constraint>"""
if context_variables.get("image_output_dir"):
img_dir = context_variables["image_output_dir"]
workdir_constraint += f"""

<image_output_constraint>
When you generate or save images (plots, charts, figures, etc.), ALWAYS save them to: {img_dir}
This directory is monitored so images saved here are automatically sent back to the user.
</image_output_constraint>"""
system_prompt += workdir_constraint

current_timestamp = time.time()
Expand Down
30 changes: 30 additions & 0 deletions pantheon/chatroom/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,7 @@ async def team_getter():

# Inject workdir from project metadata if in isolated mode
project = memory.extra_data.get("project", {})
workspace_path = None
if isinstance(project, dict):
workspace_mode = project.get("workspace_mode",
"isolated" if project.get("workspace_path") else "project")
Expand All @@ -1609,6 +1610,22 @@ async def team_getter():
context_variables = context_variables or {}
context_variables["workdir"] = workspace_path

# Set up a designated image output directory so agents save images
# to a known location and claw channels can detect them cheaply.
from pantheon.utils.image_detection import (
IMAGE_OUTPUT_DIR, snapshot_images, diff_snapshots, encode_images_to_uris,
)
image_output_path: str | None = None
if workspace_path:
import os
image_output_path = os.path.join(workspace_path, IMAGE_OUTPUT_DIR)
os.makedirs(image_output_path, exist_ok=True)
context_variables = context_variables or {}
context_variables["image_output_dir"] = image_output_path

# Pre-snapshot: only scan the designated image output directory
pre_image_snapshot = snapshot_images(image_output_path) if image_output_path else {}

thread = Thread(
team_getter, # Pass team getter
memory,
Expand All @@ -1631,6 +1648,19 @@ async def team_getter():
try:
await thread.run()

# Post-execution image detection: scan the designated image
# output directory for any newly created images.
if image_output_path and pre_image_snapshot is not None:
post_image_snapshot = snapshot_images(image_output_path)
new_image_paths = diff_snapshots(pre_image_snapshot, post_image_snapshot)
if new_image_paths:
uris = encode_images_to_uris(new_image_paths)
if uris:
await thread.process_step_message({
"role": "tool",
"raw_content": {"base64_uri": uris},
})

# Generate or update chat name in background (non-blocking)
# Only enabled for UI mode to avoid unnecessary LLM calls in REPL/API
if self._enable_auto_chat_name:
Expand Down
16 changes: 16 additions & 0 deletions pantheon/chatroom/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,22 @@ def _on_run_error(task: asyncio.Task):
logger.info(f"[STARTUP] PANTHEON_READY event emitted (service_id={service_id})")
# ────────────────────────────────────────────────────────────────────

# ── Auto-start configured Claw channels ─────────────────────────────
try:
from pantheon.claw import ClawConfigStore
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pantheon claw alreay worked without this?

claw_cfg = ClawConfigStore().load()
auto_channels = claw_cfg.get("auto_start") or []
if auto_channels:
gw_manager = chat_room._get_gateway_manager()
for ch in auto_channels:
ch = str(ch).strip()
if ch:
res = gw_manager.start_channel(ch, source="auto_start")
logger.info(f"[STARTUP] Claw auto-start {ch}: {res}")
except Exception as exc:
logger.warning(f"[STARTUP] Claw auto-start failed: {exc}")
# ────────────────────────────────────────────────────────────────────

if auto_ui:
# Determine frontend URL
if isinstance(auto_ui, str):
Expand Down
23 changes: 21 additions & 2 deletions pantheon/claw/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def _dispatch(self, coro):

async def _chat_exists(self, chat_id: str) -> bool:
try:
result = await self._chatroom.get_chat_messages(chat_id=chat_id, filter_out_images=True)
result = await self._chatroom.get_chat_messages(chat_id=chat_id, filter_out_images=False)
except Exception:
return False
return bool(result.get("success", False))
Expand Down Expand Up @@ -446,19 +446,38 @@ async def _create():
chat_name=created["chat_name"],
)

@staticmethod
def _build_message(user_text: str, image_uris: list[str] | None = None) -> list[dict[str, Any]]:
"""Build a message payload, optionally with images.

When *image_uris* are provided the message uses the multimodal content-
array format (``_llm_content`` for the LLM, ``content`` for display).
"""
if not image_uris:
return [{"role": "user", "content": user_text}]

llm_parts: list[dict[str, Any]] = []
if user_text:
llm_parts.append({"type": "text", "text": user_text})
for uri in image_uris:
llm_parts.append({"type": "image_url", "image_url": {"url": uri}})
display = user_text or f"[{len(image_uris)} image(s)]"
return [{"role": "user", "content": display, "_llm_content": llm_parts}]

async def run_chat(
self,
route: ConversationRoute,
user_text: str,
*,
image_uris: list[str] | None = None,
process_chunk=None,
process_step_message=None,
) -> dict[str, Any]:
entry = await self.ensure_chat(route)
return await self._dispatch(
self._chatroom.chat(
chat_id=entry["chat_id"],
message=[{"role": "user", "content": user_text}],
message=self._build_message(user_text, image_uris),
process_chunk=process_chunk,
process_step_message=process_step_message,
)
Expand Down
56 changes: 50 additions & 6 deletions pantheon/claw/channels/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

import discord

import aiohttp

from pantheon.claw.registry import ConversationRoute
from pantheon.claw.runtime import ChannelRuntime, text_chunks
from pantheon.claw.runtime import ChannelRuntime, data_uri_to_bytes, bytes_to_data_uri, text_chunks

logger = logging.getLogger("pantheon.claw.channels.discord")

Expand Down Expand Up @@ -77,17 +79,50 @@ def _should_handle(self, message: discord.Message) -> bool:
return False
return self.user in message.mentions

# ── Image helpers ─────────────────────────────────────────────────────────

@staticmethod
async def _download_attachments(message: discord.Message) -> list[str]:
"""Download image attachments and return data-URI list."""
uris: list[str] = []
for att in message.attachments:
ct = att.content_type or ""
if not ct.startswith("image/"):
continue
try:
data = await att.read()
uris.append(bytes_to_data_uri(data, att.filename or "image.png"))
except Exception:
logger.debug("Discord attachment download failed: %s", att.filename)
return uris

@staticmethod
async def _send_image(channel, data_uri: str) -> None:
"""Send a base64 data-URI as a file to a Discord channel."""
import io as _io
raw, mime = data_uri_to_bytes(data_uri)
if not raw:
return
ext = mime.split("/")[-1] if mime else "png"
buf = _io.BytesIO(raw)
try:
await channel.send(file=discord.File(buf, filename=f"image.{ext}"))
except Exception:
logger.warning("Discord image send failed")

# ── Analysis wrapper ─────────────────────────────────────────────────────

async def _analysis_wrapper(
self,
route: ConversationRoute,
message: discord.Message,
user_text: str,
image_uris: list[str] | None = None,
) -> None:
route_key = route.route_key()
placeholder = await message.reply("Thinking...")
llm_buf: list[str] = []
image_buf: list[str] = []
last_progress = ""
last_edit = 0.0

Expand All @@ -108,8 +143,9 @@ async def _set_progress(label: str) -> None:
last_progress = label

on_chunk = self.make_chunk_callback(llm_buf, on_update=lambda: _refresh(False))
on_step = self.make_step_callback(
on_step = self.make_image_step_callback(
llm_buf,
image_buf,
progress_cb=_set_progress,
refresh_cb=lambda: _refresh(True),
)
Expand All @@ -118,6 +154,7 @@ async def _set_progress(label: str) -> None:
result = await self._bridge.run_chat(
route,
user_text,
image_uris=image_uris,
process_chunk=on_chunk,
process_step_message=on_step,
)
Expand All @@ -132,6 +169,8 @@ async def _set_progress(label: str) -> None:
await message.reply(extra)
except Exception:
pass
for uri in image_buf:
await self._send_image(message.channel, uri)
except asyncio.CancelledError:
try:
await placeholder.edit(content="Cancelled.")
Expand Down Expand Up @@ -169,11 +208,14 @@ async def _handle_message(self, message: discord.Message) -> None:
route = self._route_from_message(message)
route_key = route.route_key()

# Download image attachments
image_uris = await self._download_attachments(message)

text = message.content or ""
if self.user is not None and not isinstance(message.channel, discord.DMChannel):
text = text.replace(self.user.mention, "").strip()

if not text:
if not text and not image_uris:
return

if text.startswith("/"):
Expand All @@ -191,12 +233,14 @@ async def _handle_message(self, message: discord.Message) -> None:

running = self._get_running(route_key)
if running is not None:
self._queue_message(route_key, body)
self._queue_message(route_key, body or "[image]")
await message.reply("Queued after current analysis.")
return

task = asyncio.create_task(self._analysis_wrapper(route, message, body))
self._set_task(route_key, task, body)
task = asyncio.create_task(
self._analysis_wrapper(route, message, body, image_uris=image_uris or None)
)
self._set_task(route_key, task, body or "[image]")

# ── Lifecycle ────────────────────────────────────────────────────────────

Expand Down
Loading
Loading