|
6 | 6 | from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError |
7 | 7 | from langchain_core.language_models.base import BaseLanguageModel |
8 | 8 | 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 |
9 | 11 | from langchain_anthropic import ChatAnthropic |
10 | 12 | # from langchain_together import Together |
11 | 13 | from langchain_openai import ChatOpenAI |
|
16 | 18 |
|
17 | 19 | from biodsa.sandbox.sandbox_interface import ExecutionSandboxWrapper, UploadDataset |
18 | 20 | 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 | +) |
19 | 27 |
|
20 | 28 | 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): |
21 | 29 | """ |
@@ -91,10 +99,10 @@ class BaseAgent(): |
91 | 99 |
|
92 | 100 | def __init__( |
93 | 101 | self, |
94 | | - api_type: Literal["azure"], |
| 102 | + api_type: SupportedApiType, |
95 | 103 | 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, |
98 | 106 | max_completion_tokens=5000, |
99 | 107 | container_id: str = None, |
100 | 108 | model_kwargs: Dict[str, Any] = None, |
@@ -129,7 +137,12 @@ def __init__( |
129 | 137 |
|
130 | 138 | # load model config |
131 | 139 | 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 | + ) |
133 | 146 | self.api_type = api_type |
134 | 147 |
|
135 | 148 | self.max_completion_tokens = max_completion_tokens |
@@ -310,6 +323,220 @@ def _set_model_kwargs(self, model_name: str) -> Dict[str, Any]: |
310 | 323 | model_kwargs["max_completion_tokens"] = 5000 |
311 | 324 | return model_kwargs |
312 | 325 |
|
| 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 | + |
313 | 540 | def generate(self, **kwargs) -> Dict[str, Any]: |
314 | 541 | """ |
315 | 542 | Base method for generating code. |
|
0 commit comments