-
Notifications
You must be signed in to change notification settings - Fork 42
fix/over-counts tool outputs and blocks legitimate conversations #3316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
925f32f
84fa1e7
1f5e7c5
f446003
bb28e0c
585845f
159aec8
f8aa7d6
28cd2dd
f955491
c3e27b6
5a36694
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,8 @@ | ||
| from apps.channels.models import ChannelPlatform, ExperimentChannel | ||
| from apps.pipelines.utils import compute_max_char_limit | ||
|
|
||
|
|
||
| def get_channels_context(experiment) -> tuple[list[ExperimentChannel], dict[ChannelPlatform, bool]]: | ||
| channels = experiment.experimentchannel_set.exclude(platform__in=ChannelPlatform.team_global_platforms()).all() | ||
| used_platforms = {channel.platform_enum for channel in channels} | ||
| available_platforms = ChannelPlatform.for_dropdown(used_platforms, experiment.team) | ||
| return channels, available_platforms | ||
|
|
||
|
|
||
| def get_max_char_limit(experiment_version) -> int | None: | ||
| pipeline = experiment_version.pipeline | ||
| if not pipeline: | ||
| return None | ||
| return compute_max_char_limit(pipeline) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
|
|
||
| from langchain.agents.middleware import AgentMiddleware | ||
| from langchain.agents.middleware.summarization import SummarizationMiddleware | ||
| from langchain_core.messages import BaseMessage, RemoveMessage | ||
| from langchain_core.messages import BaseMessage, HumanMessage, RemoveMessage, SystemMessage, ToolMessage | ||
| from langchain_core.messages.utils import count_tokens_approximately | ||
| from langgraph.graph.message import ( | ||
| REMOVE_ALL_MESSAGES, | ||
|
|
@@ -14,6 +14,15 @@ | |
| from apps.chat.conversation import COMPRESSION_MARKER | ||
| from apps.pipelines.exceptions import MessageTooLargeError | ||
|
|
||
|
|
||
| def get_token_counter(model=None): | ||
| """Returns a token-counting callable for the given model, falling back to | ||
| langchain's approximate counter when no model is provided.""" | ||
| if model is None: | ||
| return count_tokens_approximately | ||
| return model.get_num_tokens_from_messages | ||
|
|
||
|
|
||
| if TYPE_CHECKING: | ||
| from apps.pipelines.nodes.nodes import PipelineNode | ||
|
|
||
|
|
@@ -134,22 +143,42 @@ def persist_summary(self, messages: list[BaseMessage]): | |
|
|
||
|
|
||
| class MessageSizeValidationMiddleware(AgentMiddleware): | ||
| """Raises MessageTooLargeError before the model call if the full context exceeds the token budget. | ||
| """Raises MessageTooLargeError if the current user message or accumulated history | ||
| exceeds the model's token budget. | ||
|
|
||
| Runs after history compression and includes all messages in state | ||
| (history + current user input + any attachments). | ||
| Two checks run on each call: the last HumanMessage is validated individually, and | ||
| the total conversation history (HumanMessages + AIMessages) is validated together. | ||
| ToolMessages and SystemMessages are excluded from the total — tool responses are | ||
| transient and the token_limit already has system-prompt tokens subtracted. | ||
| """ | ||
|
|
||
| def __init__(self, token_limit: int): | ||
| def __init__(self, token_limit: int | None, token_counter): | ||
| self._token_limit = token_limit | ||
| self._token_counter = token_counter | ||
|
|
||
| def before_model(self, state, runtime): | ||
| if not self._token_limit: | ||
| if self._token_limit is None: | ||
| return None | ||
| all_messages = state["messages"] | ||
| human_messages = [m for m in all_messages if isinstance(m, HumanMessage)] | ||
| if not human_messages: | ||
| return None | ||
| token_count = count_tokens_approximately(state["messages"]) | ||
|
|
||
| # Validate the current user message individually. | ||
| token_count = self._token_counter([human_messages[-1]]) | ||
| if token_count > self._token_limit: | ||
| raise MessageTooLargeError( | ||
| f"Your message is too large for this model. " | ||
| f"It uses approximately {token_count} tokens, but only {self._token_limit} tokens are available." | ||
| ) | ||
|
|
||
| # Validate total history, excluding tool responses and the system prompt. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SmittieC you can check on this approach and let me know what are your thoughts? |
||
| conversation_messages = [m for m in all_messages if not isinstance(m, (ToolMessage, SystemMessage))] | ||
| total_token_count = self._token_counter(conversation_messages) | ||
| if total_token_count > self._token_limit: | ||
| raise MessageTooLargeError( | ||
| f"The conversation history is too large for this model. " | ||
| f"The total context uses approximately {total_token_count} tokens, but only " | ||
| f"{self._token_limit} tokens are available. Please start a new conversation." | ||
| ) | ||
| return None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,6 @@ | |
| from langchain.agents import create_agent | ||
| from langchain.agents.middleware import AgentState | ||
| from langchain_core.messages import AIMessage | ||
| from langchain_core.messages.utils import count_tokens_approximately | ||
| from langchain_core.tools import BaseTool | ||
|
|
||
| from apps.chat.agent.tools import SearchCollectionByIdTool, SearchIndexTool, SearchToolConfig, get_node_tools | ||
|
|
@@ -15,7 +14,6 @@ | |
| from apps.files.models import File | ||
| from apps.pipelines.nodes.base import PipelineNode, PipelineState | ||
| from apps.pipelines.nodes.helpers import get_system_message | ||
| from apps.pipelines.nodes.history_middleware import MessageSizeValidationMiddleware | ||
| from apps.pipelines.nodes.tool_callbacks import ToolCallbacks | ||
| from apps.service_providers.llm_service.datamodels import LlmChatResponse | ||
| from apps.service_providers.llm_service.prompt_context import PromptTemplateContext | ||
|
|
@@ -102,31 +100,19 @@ def build_node_agent( | |
| tools = _get_configured_tools(node, session=session, tool_callbacks=tool_callbacks) | ||
| system_message = get_system_message(prompt_template=node.prompt, prompt_context=prompt_context) | ||
|
|
||
| middleware = [] | ||
| if history_middleware := node.build_history_middleware(system_message=system_message): | ||
| middleware.append(history_middleware) | ||
| # MessageSizeValidationMiddleware temporarily disabled — over-counts tool outputs and blocks | ||
| # legitimate conversations. Re-enable after switching to a tool-aware token check. | ||
| model = node.get_chat_model() | ||
| middleware = node.build_history_middleware(system_message=system_message, model=model) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Keep the history middleware as it was before and always include
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well noted. I will do so. |
||
|
|
||
| return create_agent( | ||
| # TODO: I think this will fail with google builtin tools | ||
| model=node.get_chat_model(), | ||
| model=model, | ||
| tools=tools, | ||
| system_prompt=system_message, | ||
| middleware=middleware, | ||
| state_schema=StateSchema, | ||
| ) | ||
|
|
||
|
|
||
| def _build_size_validation_middleware(node: PipelineNode, system_message) -> MessageSizeValidationMiddleware | None: | ||
| max_token_limit = node.repo.get_llm_provider_model(node.llm_provider_model_id).max_token_limit | ||
| if not max_token_limit: | ||
| return None | ||
| system_tokens = count_tokens_approximately([system_message]) | ||
| effective_limit = max(max_token_limit - system_tokens, 0) | ||
| return MessageSizeValidationMiddleware(token_limit=effective_limit) | ||
|
|
||
|
|
||
| def _process_files(node: PipelineNode, cited_files: set[File], generated_files: set[File]) -> dict: | ||
| """`cited_files` is a list of files that are cited in the response whereas generated files are those generated | ||
| by the LLM | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite follow why tool responses are excluded. They are also sent to the LLM along with the history + user's new message, so it should be included, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will include that as well in this PR.
Previously, I thought we were only validating the latest user message against the configured
max_token_limit. Since some models support hundreds of thousands of tokens, whereas in our case we may configure a lower limit, such as8192, I assumed the validation was only applied to the latest message and not to the entire payload.From your explanation, I now understand that the validation should apply to the full request sent to the LLM, including the conversation history and tool responses, since all of that contributes to actual token usage.