Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ mini-swe-agent/
# Nix
.direnv/
result
hermes_state.db
27 changes: 24 additions & 3 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,27 @@ def _compute_summary_budget(self, turns_to_summarize: List[Dict[str, Any]]) -> i
budget = int(content_tokens * _SUMMARY_RATIO)
return max(_MIN_SUMMARY_TOKENS, min(budget, self.max_summary_tokens))

@staticmethod
def _flatten_multimodal_content(content) -> str:
"""Convert multimodal content arrays to plain text for summarization.

Replaces image blocks with text placeholders so huge base64 data
never reaches the summarizer LLM.
"""
if isinstance(content, list):
text_parts = []
for block in content:
if not isinstance(block, dict):
text_parts.append(str(block))
elif block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") in ("image_url", "image"):
text_parts.append("[Attached image]")
else:
text_parts.append(str(block))
return "\n".join(text_parts)
return content if isinstance(content, str) else str(content or "")

def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
"""Serialize conversation turns into labeled text for the summarizer.

Expand All @@ -206,7 +227,7 @@ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = msg.get("content") or ""
content = self._flatten_multimodal_content(msg.get("content") or "")

# Tool results: keep more content than before (3000 chars)
if role == "tool":
Expand Down Expand Up @@ -510,7 +531,7 @@ def _find_tail_cut_by_tokens(

for i in range(n - 1, head_end - 1, -1):
msg = messages[i]
content = msg.get("content") or ""
content = self._flatten_multimodal_content(msg.get("content") or "")
msg_tokens = len(content) // _CHARS_PER_TOKEN + 10 # +10 for role/metadata
# Include tool call arguments in estimate
for tc in msg.get("tool_calls") or []:
Expand Down Expand Up @@ -653,7 +674,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -
for i in range(compress_end, n_messages):
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
original = msg.get("content") or ""
original = self._flatten_multimodal_content(msg.get("content") or "")
msg["content"] = summary + "\n\n" + original
Comment on lines +677 to 678

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 _merge_summary_into_tail permanently destroys multimodal image blocks in protected tail message

When compression triggers the _merge_summary_into_tail path (role alternation conflict), _flatten_multimodal_content() is called on the first tail message's content at agent/context_compressor.py:677. This converts a multimodal content array (with image blocks) into a plain text string, permanently replacing the structured content with summary + "\n\n" + flattened_text. The image blocks (base64 references) are replaced with "[Attached image]" placeholders and lost. After compression, when these messages are sent to the LLM, the model can no longer see the images that were in the protected tail — defeating the purpose of tail protection for multimodal messages.

Prompt for agents
In agent/context_compressor.py, the _merge_summary_into_tail code path at line 677 calls self._flatten_multimodal_content() on the first tail message's content before prepending the summary. This permanently destroys any multimodal content blocks (image references) in that message. The fix should preserve the original multimodal structure: if the content is a list (multimodal), prepend the summary as a new text block rather than flattening everything to a string. For example, if msg['content'] is a list, set msg['content'] to [{"type": "text", "text": summary + "\n\n"} ] + original_content_list. If the content is a plain string, the current behavior (string concatenation) is fine.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

_merge_summary_into_tail = False
compressed.append(msg)
Expand Down
13 changes: 12 additions & 1 deletion agent/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"clarify": "question", "skill_manage": "name",
}

# delegate_task: show goal (single) or individual task goals (batch)
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
goals = [_oneline(t.get("goal", "?"))[:40] for t in tasks if isinstance(t, dict)]
return f"{len(tasks)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks"
goal = args.get("goal", "")
return _oneline(goal) if goal else None

if tool_name == "process":
action = args.get("action", "")
sid = args.get("session_id", "")
Expand Down Expand Up @@ -956,7 +965,9 @@ def _wrap(line: str) -> str:
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
goals = [_oneline(t.get("goal", "?"))[:30] for t in tasks if isinstance(t, dict)]
detail = " | ".join(goals) if goals else "parallel"
return _wrap(f"┊ 🔀 delegate {len(tasks)}x: {_trunc(detail, 35)} {dur}")
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")

preview = build_tool_preview(tool_name, args) or ""
Expand Down
20 changes: 18 additions & 2 deletions agent/model_metadata.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 estimate_request_tokens_rough not updated for multimodal, causes massive token overcount triggering false compression

estimate_request_tokens_rough at agent/model_metadata.py:940-947 still uses sum(len(str(msg)) for msg in messages), which was NOT updated to handle multimodal content arrays like estimate_messages_tokens_rough was. When a user sends an image via native multimodal, the message dict contains a huge base64 string. str(msg) serializes the entire dict including the base64 data — a 100KB image becomes ~133K chars → ~33K estimated "tokens", while the actual API cost is ~1600 tokens per image.

This inflated estimate feeds into the preflight compression check at run_agent.py:6693-6696, where it's compared against context_compressor.threshold_tokens. With a typical 200K context and 50% threshold (100K tokens), a single image could push a short conversation over the threshold and trigger unnecessary context compression — summarizing and discarding messages that easily fit in the context window.

(Refers to lines 943-944)

Prompt for agents
The function estimate_request_tokens_rough in agent/model_metadata.py still uses sum(len(str(msg)) for msg in messages) to estimate message tokens, but this was not updated for multimodal content the way estimate_messages_tokens_rough was. When messages contain base64-encoded images, str(msg) includes the entire base64 string, causing massive overestimation (e.g. 33K tokens instead of 1600 for a single image). This triggers false positives in the preflight compression check in run_agent.py:run_conversation(). The fix should reuse estimate_messages_tokens_rough(messages) for the message portion of the estimate, since that function already handles multimodal content correctly with fixed ~1600 token costs per image. The system_prompt and tools portions can remain as-is since they don't contain multimodal content.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,24 @@ def estimate_tokens_rough(text: str) -> int:

def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
"""Rough token estimate for a message list (pre-flight only)."""
total_chars = sum(len(str(msg)) for msg in messages)
return total_chars // 4
total = 0
for msg in messages:
content = msg.get("content", "") if isinstance(msg, dict) else str(msg)
if isinstance(content, list):
# Multimodal content array — count text blocks normally,
# use fixed estimate for image blocks (base64 is huge but
# actual token cost is ~1600 per image for most providers).
for block in content:
btype = block.get("type", "") if isinstance(block, dict) else ""
if btype == "text":
total += len(block.get("text", "")) // 4
elif btype in ("image_url", "image"):
total += 1600 # approximate per-image token cost
else:
total += len(str(block)) // 4
else:
total += len(str(msg)) // 4
Comment on lines +922 to +923

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: estimate_messages_tokens_rough non-list path uses str(msg) instead of str(content)

In the new multimodal-aware estimate_messages_tokens_rough at agent/model_metadata.py:922-923, the non-list (plain string) branch uses len(str(msg)) // 4 — counting the entire message dict including role, tool_calls metadata, etc. But the list (multimodal) branch only counts content blocks, missing metadata tokens entirely. This is an inconsistency introduced by the refactor. For rough estimation this is unlikely to cause problems in practice (the metadata is small relative to content), but it means multimodal messages will slightly undercount compared to plain-text messages. The old code consistently used str(msg) for everything.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return total


def estimate_request_tokens_rough(
Expand Down
13 changes: 12 additions & 1 deletion agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ def _strip_yaml_frontmatter(content: str) -> str:
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
"bubbles, and videos (.mp4) play inline. You can also include image "
"URLs in markdown format ![alt](url) and they will be sent as native photos."
"URLs in markdown format ![alt](url) and they will be sent as native photos.\n\n"
"Amy's zipper starts HALF-OPEN on Telegram — Private Assistant Mode by default. "
"The user can change this during the session."
),
"discord": (
"You are in a Discord server or group chat communicating with your user. "
Expand Down Expand Up @@ -285,6 +287,15 @@ def _strip_yaml_frontmatter(content: str) -> str:
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
"characters, so be brief and direct."
),
"api_server": (
"You are accessed via an OpenAI-compatible API from a private frontend "
"(e.g. SillyTavern, Open WebUI, LobeChat). Markdown is fully supported. "
"You can send media files: include MEDIA:/absolute/path/to/file in your "
"response. You can also include image URLs in markdown format ![alt](url). "
"This is a private environment.\n\n"
"Amy's zipper starts HALF-OPEN — Private Assistant Mode by default. "
"The user can change this during the session."
),
}

CONTEXT_FILE_MAX_CHARS = 20_000
Expand Down
7 changes: 4 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4719,8 +4719,8 @@ def _handle_skin_command(self, cmd: str):
print(" Prompt + TUI colors updated.")

def _toggle_verbose(self):
"""Cycle tool progress mode: off → new → all → verbose → off."""
cycle = ["off", "new", "all", "verbose"]
"""Cycle tool progress mode: off → new → all → verbose → full → off."""
cycle = ["off", "new", "all", "verbose", "full"]
try:
idx = cycle.index(self.tool_progress_mode)
except ValueError:
Expand All @@ -4742,7 +4742,8 @@ def _toggle_verbose(self):
"off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.",
"new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).",
"all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.",
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, think blocks, and debug logs.",
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — detailed tool args (200 char limit).",
"full": f"{_Colors.BOLD}{_Colors.CYAN}Tool progress: FULL{_Colors.RESET} — complete args, no truncation.",
}
_cprint(labels.get(self.tool_progress_mode, ""))

Expand Down
9 changes: 8 additions & 1 deletion gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,14 @@ def __init__(self, config: PlatformConfig):
self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None
self._response_store = ResponseStore()
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
# Shared SessionDB singleton — avoids creating (and leaking) a new
# SQLite connection on every /v1/chat/completions request.
self._session_db = None
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception:
pass
Comment on lines +305 to +310

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: API server SessionDB now created eagerly in init instead of lazily

At gateway/platforms/api_server.py:305-310, the SessionDB is now created in __init__ instead of lazily on first request. The _ensure_session_db() method at line 386 still exists as a fallback. This is a reasonable change that prevents a new SQLite connection per request. However, if the database file is not yet available when the API server starts (e.g., first-time setup), the except Exception: pass will silently swallow the error and _ensure_session_db() will retry later. This is fine behavior but worth noting that the lazy fallback is still load-bearing.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


@staticmethod
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
Expand Down
11 changes: 11 additions & 0 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,17 @@ async def send_image_file(
"""Send a local image file natively via bridge."""
return await self._send_media_to_bridge(chat_id, image_path, "image", caption)

async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send a voice/audio file natively via bridge — plays as voice note in WhatsApp."""
return await self._send_media_to_bridge(chat_id, audio_path, "audio", caption)

async def send_video(
self,
chat_id: str,
Expand Down
Loading
Loading