Skip to content

Commit 554600e

Browse files
authored
Merge pull request #20 from RyanWangZf/zifeng/rwd
add file edit and image read tools
2 parents b8d721b + 6bfb5eb commit 554600e

8 files changed

Lines changed: 1737 additions & 53 deletions

File tree

biodsa/agents/base_agent.py

Lines changed: 231 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
77
from langchain_core.language_models.base import BaseLanguageModel
88
from langchain_core.tools import BaseTool
9+
from langchain_core.messages import SystemMessage, AIMessage, ToolMessage, HumanMessage
10+
from langchain_core.messages.utils import count_tokens_approximately
911
from langchain_anthropic import ChatAnthropic
1012
# from langchain_together import Together
1113
from langchain_openai import ChatOpenAI
@@ -16,6 +18,12 @@
1618

1719
from biodsa.sandbox.sandbox_interface import ExecutionSandboxWrapper, UploadDataset
1820
from biodsa.agents.state import CodeExecutionResult
21+
from biodsa.utils.render_utils import render_message_colored
22+
from biodsa.agents.llm_config import (
23+
SupportedApiType,
24+
SupportedModelName,
25+
ALL_SUPPORTED_MODELS,
26+
)
1927

2028
def run_with_retry(func: Callable, max_retries: int = 5, min_wait: float = 1.0, max_wait: float = 30.0, timeout: Optional[float] = None, arg=None, **kwargs):
2129
"""
@@ -91,10 +99,10 @@ class BaseAgent():
9199

92100
def __init__(
93101
self,
94-
api_type: Literal["azure"],
102+
api_type: SupportedApiType,
95103
api_key: str,
96-
model_name: Literal["gpt-4o", "gpt-4o-mini", "o3-mini"] = None,
97-
endpoint: str=None,
104+
model_name: SupportedModelName = None,
105+
endpoint: str = None,
98106
max_completion_tokens=5000,
99107
container_id: str = None,
100108
model_kwargs: Dict[str, Any] = None,
@@ -129,7 +137,12 @@ def __init__(
129137

130138
# load model config
131139
self.model_name = model_name
132-
140+
if model_name is not None and model_name not in ALL_SUPPORTED_MODELS:
141+
logging.warning(
142+
"model_name %r not in llm_config.ALL_SUPPORTED_MODELS; "
143+
"add it to biodsa/agents/llm_config.py if this is a supported model.",
144+
model_name,
145+
)
133146
self.api_type = api_type
134147

135148
self.max_completion_tokens = max_completion_tokens
@@ -310,6 +323,220 @@ def _set_model_kwargs(self, model_name: str) -> Dict[str, Any]:
310323
model_kwargs["max_completion_tokens"] = 5000
311324
return model_kwargs
312325

326+
# ------------------------------------------------------------------
327+
# Rendering helpers (colored terminal output)
328+
# ------------------------------------------------------------------
329+
@staticmethod
330+
def _print_message(message: BaseMessage, show_tool_calls: bool = True) -> None:
331+
"""
332+
Print a single LangChain message with colored formatting.
333+
334+
Uses :func:`biodsa.utils.render_utils.render_message_colored`.
335+
All agents can call ``self._print_message(msg)`` for consistent
336+
terminal output.
337+
"""
338+
print(render_message_colored(message, show_tool_calls=show_tool_calls))
339+
340+
def _print_stream_chunk(self, chunk: Dict[str, Any], show_tool_calls: bool = True) -> None:
341+
"""
342+
Print the last message from a LangGraph stream chunk.
343+
344+
Typical usage inside a ``for stream_mode, chunk in graph.stream(...)``
345+
loop::
346+
347+
for stream_mode, chunk in self.agent_graph.stream(
348+
inputs, stream_mode=["values"], config=config
349+
):
350+
self._print_stream_chunk(chunk)
351+
result = chunk
352+
353+
Args:
354+
chunk: A dict with a ``"messages"`` key (list of BaseMessage).
355+
show_tool_calls: Whether to display tool call details.
356+
"""
357+
messages = chunk.get("messages")
358+
if not messages:
359+
return
360+
self._print_message(messages[-1], show_tool_calls=show_tool_calls)
361+
362+
# ------------------------------------------------------------------
363+
# Multimodal tool-message helpers
364+
# ------------------------------------------------------------------
365+
@staticmethod
366+
def _build_tool_message(
367+
tool_output: Any,
368+
name: str,
369+
tool_call_id: str,
370+
) -> ToolMessage:
371+
"""
372+
Build a ``ToolMessage`` from a tool's return value.
373+
374+
If the tool returned a
375+
:class:`~biodsa.tool_wrappers.multimodal_tools.MultimodalToolResult` the
376+
message will carry LangChain-standard content blocks (text +
377+
images) so that vision-capable LLMs can see the images.
378+
379+
For plain ``str`` returns the message is a simple text message.
380+
"""
381+
# Lazy import to avoid circular deps at module level
382+
from biodsa.tool_wrappers.multimodal_tools import MultimodalToolResult
383+
384+
if isinstance(tool_output, MultimodalToolResult):
385+
content = tool_output.to_langchain_content()
386+
return ToolMessage(
387+
content=content, name=name, tool_call_id=tool_call_id
388+
)
389+
return ToolMessage(
390+
content=str(tool_output), name=name, tool_call_id=tool_call_id
391+
)
392+
393+
@staticmethod
394+
def _content_to_text(content) -> str:
395+
"""
396+
Extract plain text from a message's ``content`` field.
397+
398+
Handles both ``str`` content and the list-of-dicts multimodal
399+
format (skipping image/audio/video blocks and base64 data).
400+
"""
401+
if isinstance(content, str):
402+
return content
403+
if isinstance(content, list):
404+
parts: List[str] = []
405+
for block in content:
406+
if isinstance(block, dict):
407+
btype = block.get("type", "")
408+
if btype == "text":
409+
parts.append(block.get("text", ""))
410+
elif btype in ("image", "image_url"):
411+
parts.append("[image]")
412+
elif btype in ("file", "audio", "video"):
413+
parts.append(f"[{btype}]")
414+
# skip base64 data entirely
415+
elif isinstance(block, str):
416+
parts.append(block)
417+
return "\n".join(parts)
418+
return str(content)
419+
420+
def _compact_messages(
421+
self,
422+
messages: List[BaseMessage],
423+
token_threshold: int = 80000,
424+
compact_model_name: Optional[str] = None,
425+
timeout: Optional[float] = None,
426+
) -> List[BaseMessage]:
427+
"""
428+
Compact a message list when it exceeds *token_threshold*.
429+
430+
Uses a cheaper / smaller model to summarize the middle messages
431+
(tool calls, tool results, intermediate AI responses) into a single
432+
background briefing so the main model receives:
433+
434+
[system_prompt, compacted_background, original_user_message]
435+
436+
This avoids excessive input-token cost on the primary model while
437+
preserving the essential context.
438+
439+
Args:
440+
messages: Full message list (system + user + tool rounds).
441+
token_threshold: Approximate token count above which compaction
442+
triggers. Default 80 000.
443+
compact_model_name: Model to use for the summary call. Falls back
444+
to ``"gpt-5-mini"`` then ``self.model_name``.
445+
timeout: Per-call timeout for the summariser (seconds).
446+
Falls back to ``self.llm_timeout``.
447+
448+
Returns:
449+
Either the original *messages* (if under threshold or compaction
450+
fails) or a compacted 3-message list.
451+
"""
452+
token_count = count_tokens_approximately(messages)
453+
if token_count <= token_threshold:
454+
return messages
455+
456+
compact_model = compact_model_name or "gpt-5-mini"
457+
call_timeout = timeout if timeout is not None else self.llm_timeout
458+
459+
logging.info(
460+
"compact_messages: ~%d tokens (threshold %d); summarising with %s.",
461+
token_count, token_threshold, compact_model,
462+
)
463+
464+
# --- locate boundaries ---
465+
system_msg = messages[0] if messages and isinstance(messages[0], SystemMessage) else None
466+
first_human_idx = next(
467+
(i for i, m in enumerate(messages) if isinstance(m, HumanMessage)),
468+
None,
469+
)
470+
if system_msg is None or first_human_idx is None:
471+
return messages # can't split sensibly
472+
473+
user_msg = messages[first_human_idx]
474+
middle = messages[first_human_idx + 1:]
475+
if not middle:
476+
return messages
477+
478+
# --- serialise middle messages to plain text ---
479+
text_parts = []
480+
for m in middle:
481+
role = getattr(m, "type", type(m).__name__)
482+
content = self._content_to_text(getattr(m, "content", "") or "")
483+
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None):
484+
tc = m.tool_calls[0]
485+
text_parts.append(
486+
f"[{role}] Called tool '{tc.get('name', '?')}' "
487+
f"with args: {tc.get('args', {})}\n{content}"
488+
)
489+
elif isinstance(m, ToolMessage):
490+
name = getattr(m, "name", "?")
491+
text_parts.append(f"[{role} ({name})]\n{content}")
492+
else:
493+
text_parts.append(f"[{role}]\n{content}")
494+
495+
background_text = "\n---\n".join(text_parts)
496+
497+
# --- call the compact model ---
498+
compact_llm = self._get_model(
499+
api=self.api_type,
500+
model_name=compact_model,
501+
api_key=self.api_key,
502+
endpoint=self.endpoint,
503+
)
504+
summary_prompt = [
505+
SystemMessage(content=(
506+
"You are a concise summarizer. Summarize the following agent "
507+
"conversation history into a compact background briefing. "
508+
"Focus on: what actions were taken (tool calls and results), "
509+
"key findings, what was created/updated, and any errors. "
510+
"Keep it concise (under 1000 words). Do NOT include raw file "
511+
"contents; just note what was read and the key takeaways."
512+
)),
513+
HumanMessage(content=f"Conversation history to summarize:\n\n{background_text}"),
514+
]
515+
516+
try:
517+
summary_response = run_with_retry(
518+
compact_llm.invoke, arg=summary_prompt, timeout=call_timeout,
519+
)
520+
summary_text = summary_response.content or ""
521+
except Exception as e:
522+
logging.warning("compact_messages failed (%s); returning original.", e)
523+
return messages
524+
525+
compacted = [
526+
system_msg,
527+
SystemMessage(content=(
528+
"# Background (compacted from earlier conversation)\n\n"
529+
+ summary_text
530+
)),
531+
user_msg,
532+
]
533+
new_count = count_tokens_approximately(compacted)
534+
logging.info(
535+
"compact_messages: ~%d → ~%d tokens (summary %d chars).",
536+
token_count, new_count, len(summary_text),
537+
)
538+
return compacted
539+
313540
def generate(self, **kwargs) -> Dict[str, Any]:
314541
"""
315542
Base method for generating code.

0 commit comments

Comments
 (0)