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
104 changes: 78 additions & 26 deletions back/bots/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
from django.db import models
import uuid
from langchain_aws import ChatBedrock
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain.agents import create_agent
from tavily import TavilyClient
Comment on lines 4 to 8
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

create_agent is removed in this PR, but the existing unit test back/bots/tests/test_chat.py patches bots.models.chat.create_agent. With this change, that test will start failing (patch target no longer exists). Update the tests to patch/mock the new bind_tools(...).invoke(...) flow instead.

Copilot uses AI. Check for mistakes.
import logging
import base64
Expand Down Expand Up @@ -116,47 +115,100 @@ def web_search(query: str) -> str:
return f"Error during search: {str(e)}"


# Create chat model
# Create chat model with tool binding
chat_model = ChatBedrock(model_id=self.ai.model_id)
tools = [web_search]

# Create modern agent with tool calling support
# This is the recommended approach per LangChain docs
agent = create_agent(
model=chat_model,
tools=tools,
system_prompt=self.get_system_message(),
debug=settings.DEBUG
)
# Bind tools to the model for proper tool calling
model_with_tools = chat_model.bind_tools(tools)
Comment on lines +118 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't bypass ai in the tool-enabled path.

Line 119 creates a new ChatBedrock instead of reusing self.ai, so this branch ignores the injected client/callbacks used by get_response_standard(). That makes tool-enabled chats behave differently from the non-tool path and leaves this code path without the same seam the current tests were mocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@back/bots/models/chat.py` around lines 118 - 123, The branch that enables
tools incorrectly constructs a new ChatBedrock instance
(ChatBedrock(model_id=...)) instead of reusing the injected client self.ai,
causing loss of injected callbacks and differing behavior; change the code to
use self.ai (which already implements bind_tools) when creating model_with_tools
(e.g., call self.ai.bind_tools([web_search]) or equivalent) so the tool-enabled
path reuses the same client and preserves the seams used by
get_response_standard and tests.


# Extract text input from message_list for agent
agent_input = self._extract_agent_input(message_list)

logger.info(f"Invoking agent with input: {agent_input[:100]}...")
logger.info("🤖 AGENT_INVOKE_START: web_search tool available")

# Invoke agent - the CompiledStateGraph handles tool loop internally
response = agent.invoke({"messages": [HumanMessage(content=agent_input)]})
# Build and run the agent loop manually
messages = [SystemMessage(content=self.get_system_message()), HumanMessage(content=agent_input)]

Comment on lines 125 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve prior turns when web search is enabled.

Lines 125-132 throw away the history that get_input() just assembled and restart the model with only SystemMessage + last HumanMessage. Any follow-up question, and any multimodal context already present in message_list, is lost as soon as enable_web_search is on.

🧩 Minimal fix
-            # Build and run the agent loop manually
-            messages = [SystemMessage(content=self.get_system_message()), HumanMessage(content=agent_input)]
+            # Build and run the agent loop manually with the existing chat history
+            messages = list(message_list)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@back/bots/models/chat.py` around lines 125 - 133, The code currently discards
prior turns by building messages as only SystemMessage + last HumanMessage
(using agent_input); instead, preserve the full prior conversation/multimodal
context by using the original message list (or the output of the existing
get_input/_extract_agent_input flow) when creating the agent messages: convert
message_list (or the previously assembled messages from get_input) into LLM
message objects, prepend the SystemMessage from get_system_message(), and then
append the new HumanMessage(content=agent_input) rather than replacing history;
update the code around agent_input, _extract_agent_input, get_system_message,
and the messages list construction so prior turns remain when web search is
enabled.

# Agentagent loop - keep invoking until no more tool calls
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

Typo in comment: # Agentagent loop should be corrected (e.g., # Agent loop).

Suggested change
# Agentagent loop - keep invoking until no more tool calls
# Agent loop - keep invoking until no more tool calls

Copilot uses AI. Check for mistakes.
max_iterations = 5
iteration = 0

while iteration < max_iterations:
iteration += 1
logger.info(f"🤖 AGENT_LOOP_ITERATION: {iteration}")

# Call the model
response = model_with_tools.invoke(messages)
messages.append(response)
Comment on lines +143 to +144
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Accumulate token usage for every model invoke.

model_with_tools.invoke(messages) can run multiple times here, but usage_metadata is taken only from the last AIMessage. Tool-enabled chats will under-report tokens whenever a tool is used, which skews any quota/billing logic that reads input_tokens / output_tokens.

📊 Suggested fix
             response_text = ""
             usage_metadata = {"input_tokens": 0, "output_tokens": 0}
@@
                 # Call the model
                 response = model_with_tools.invoke(messages)
+                if getattr(response, "usage_metadata", None):
+                    usage_metadata["input_tokens"] += response.usage_metadata.get("input_tokens", 0)
+                    usage_metadata["output_tokens"] += response.usage_metadata.get("output_tokens", 0)
                 messages.append(response)
@@
-                    # Extract token usage
-                    if hasattr(msg, 'usage_metadata') and msg.usage_metadata:
-                        usage_metadata = msg.usage_metadata
-                    
                     logger.info(f"🤖 FINAL_RESPONSE: {len(response_text)} chars")
                     break

Also applies to: 176-197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@back/bots/models/chat.py` around lines 143 - 144, The code calls
model_with_tools.invoke(messages) which may append multiple AIMessage responses
but only captures usage_metadata from the last AIMessage, causing under-reported
token usage; update the handling after each invoke to accumulate token counts
from every AIMessage's usage_metadata (e.g., sum input_tokens/output_tokens from
each AIMessage in messages or from the returned response objects) after each
call to model_with_tools.invoke and before appending additional messages so that
total input/output/token usage reflects all tool-enabled invocations; apply the
same accumulation fix where model_with_tools.invoke is used (including the block
around lines 176-197) and ensure you update whatever counters or usage fields
(input_tokens, output_tokens, total_tokens, usage_metadata) used by
billing/quota logic.


# Check if model wants to call a tool
if not response.tool_calls:
logger.info(f"🤖 AGENT_LOOP_COMPLETE: no more tool calls after {iteration} iterations")
break
Comment on lines +134 to +149
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

The manual tool loop stops after max_iterations, but if the model is still returning tool calls at that point there may be no final AIMessage without tool_calls. In that case response_text falls back to the last AIMessage which is likely a tool-call message (often empty), resulting in a blank or incorrect assistant reply. Handle the max-iteration exit explicitly (e.g., raise/log an error and return a safe message, or force one final model call after the last ToolMessage) so callers always get a real final response.

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +149
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle loop exhaustion explicitly.

If the model is still returning tool_calls on the fifth pass, the loop exits without a terminal assistant answer. The fallback at Lines 202-211 then persists the last AIMessage, which in that case is usually an empty/partial tool-call stub, and back/bots/views/get_chat_response.py:82-83 returns that straight to the client.

🛑 Safer fallback
             max_iterations = 5
             iteration = 0
+            exhausted = True

             while iteration < max_iterations:
                 iteration += 1
                 logger.info(f"🤖 AGENT_LOOP_ITERATION: {iteration}")
@@
                 # Check if model wants to call a tool
                 if not response.tool_calls:
+                    exhausted = False
                     logger.info(f"🤖 AGENT_LOOP_COMPLETE: no more tool calls after {iteration} iterations")
                     break
@@
-            if not response_text:
+            if exhausted:
+                logger.error("🤖 AGENT_LOOP_ABORTED: max iterations reached without a final response")
+                response_text = "I'm sorry, I couldn't complete the web search. Please try again."
+            elif not response_text:
                 # Fallback: get the last message
                 for msg in reversed(messages):
                     if isinstance(msg, AIMessage):

Also applies to: 202-211

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@back/bots/models/chat.py` around lines 135 - 149, The loop can exit on
max_iterations while the last response still contains a tool_call stub, so after
the while using max_iterations/iteration and the last response from
model_with_tools.invoke, detect if iteration >= max_iterations and
response.tool_calls is truthy; in that case produce and append a proper terminal
assistant message (e.g., an AIMessage summarizing that the agent could not
finish the task or asking to try again) or re-invoke model_with_tools.invoke
with instructions to return a final assistant answer (no tool_calls) before
persisting — update the code paths around the loop and the fallback persistence
(the messages list, response variable, and the code that saves the last
AIMessage) to ensure a non-tool_call terminal assistant message is saved and
returned.


# Process tool calls
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
logger.info(f"🔍 AGENT_TOOL_CALL: {tool_name} with args: {tool_args}")

# Execute the tool
if tool_name == "web_search":
tool_result = web_search.invoke(tool_args)
else:
tool_result = f"Unknown tool: {tool_name}"

logger.info(f"🔍 AGENT_TOOL_RESULT: {tool_result[:100]}")

# Add tool result to messages
messages.append(ToolMessage(
content=tool_result,
tool_call_id=tool_call["id"],
name=tool_name
))

logger.info("🤖 AGENT_INVOKE_COMPLETE: got response")
logger.info("🤖 AGENT_LOOP_COMPLETE: extracting final response")

# Extract response text from the agent result
# The response is a dict with 'messages' key containing final messages
# Extract response text from the final message
# The messages list now contains: System, HumanMessage, AIMessage (with tool call), ToolMessage, AIMessage (final response)
response_text = ""
usage_metadata = {"input_tokens": 0, "output_tokens": 0}

if isinstance(response, dict) and "messages" in response:
for msg in reversed(response["messages"]):
if isinstance(msg, AIMessage):
# Find the last AIMessage (should be the final response)
for msg in reversed(messages):
if isinstance(msg, AIMessage) and not msg.tool_calls:
# This is the final response (no tool calls)
if isinstance(msg.content, str):
response_text = msg.content
# Extract token usage from the message metadata
if hasattr(msg, 'usage_metadata') and msg.usage_metadata:
usage_metadata = msg.usage_metadata
elif isinstance(msg.content, list):
# Extract text from content list
text_parts = []
for item in msg.content:
if isinstance(item, dict) and item.get('type') == 'text':
text_parts.append(item.get('text', ''))
elif isinstance(item, str):
text_parts.append(item)
response_text = "".join(text_parts).strip()

# Extract token usage
if hasattr(msg, 'usage_metadata') and msg.usage_metadata:
usage_metadata = msg.usage_metadata

logger.info(f"🤖 FINAL_RESPONSE: {len(response_text)} chars")
break

if not response_text:
# Fallback: get the last message
for msg in reversed(messages):
if isinstance(msg, AIMessage):
if isinstance(msg.content, str):
response_text = msg.content
elif isinstance(msg.content, list):
text_parts = [item.get('text', '') for item in msg.content if isinstance(item, dict) and item.get('type') == 'text']
response_text = "".join(text_parts).strip()
break
elif isinstance(response, dict) and "output" in response:
response_text = response["output"]
else:
response_text = str(response)

message_order = self.messages.count()

Expand Down
9 changes: 5 additions & 4 deletions back/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
import os
import sys
import django
import requests
from django.contrib.auth.models import User
from rest_framework_simplejwt.tokens import RefreshToken
from bots.models import Bot, AiModel

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
sys.path.insert(0, '/home/ubuntu/repos/bots/back')

django.setup()

import requests
from django.contrib.auth.models import User
from rest_framework_simplejwt.tokens import RefreshToken
from bots.models import Bot, AiModel


def run_integration_test():
print("=" * 60)
Expand Down
Loading