-
Notifications
You must be signed in to change notification settings - Fork 3k
feat: add stop_run State key and TokenBudgetHook
#12411
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
ca4b6ca
de59efe
0e9eecf
95c6fc1
541c44a
e2fd05f
774e784
af83ca3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import sys | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from lazy_imports import LazyImporter | ||
|
|
||
| _import_structure = {"hooks": ["TokenBudgetHook"]} | ||
|
|
||
| if TYPE_CHECKING: | ||
| from .hooks import TokenBudgetHook as TokenBudgetHook | ||
| else: | ||
| sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from typing import Any | ||
|
|
||
| from haystack import logging | ||
| from haystack.components.agents.state.state import State | ||
| from haystack.components.agents.utils import _INPUT_TOKEN_KEYS, _OUTPUT_TOKEN_KEYS, _first_numeric | ||
| from haystack.core.serialization import default_from_dict, default_to_dict | ||
| from haystack.dataclasses import ChatMessage | ||
| from haystack.utils.experimental import _experimental | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| _FINAL_MESSAGE_TEXT = "The Agent stopped because the token budget was exceeded." | ||
|
|
||
|
|
||
| @_experimental | ||
| class TokenBudgetHook: | ||
| """ | ||
| Stop an Agent run when its token usage reaches a configured budget. | ||
|
|
||
| The hook runs at the `before_llm` hook point and checks the cumulative token usage recorded in the Agent state. | ||
| When the budget is reached, the run ends before the next LLM call with the exit reason `"token_budget_exceeded"`. | ||
|
|
||
| Only calls made by the Agent's chat generator contribute to `token_usage`; calls made by tools or other hooks are | ||
| not included. | ||
|
|
||
| <!-- test-ignore --> | ||
| ```python | ||
| from haystack.components.agents import Agent | ||
| from haystack.components.generators.chat import OpenAIChatGenerator | ||
| from haystack.hooks.budget import TokenBudgetHook | ||
|
|
||
| agent = Agent( | ||
| chat_generator=OpenAIChatGenerator(), | ||
| tools=[web_search], | ||
| hooks={"before_llm": [TokenBudgetHook(max_total_tokens=100_000)]}, | ||
| ) | ||
|
|
||
| result = agent.run(messages=[...]) | ||
| ``` | ||
| """ | ||
|
|
||
| allowed_hook_points = ("before_llm",) | ||
|
|
||
| def __init__(self, *, max_total_tokens: int, add_final_message: bool = False) -> None: | ||
| """ | ||
| Create a token budget hook. | ||
|
|
||
| :param max_total_tokens: Maximum cumulative token usage before the Agent is stopped. | ||
| :param add_final_message: Whether to append an assistant message explaining why the Agent stopped. | ||
| :raises ValueError: If `max_total_tokens` is less than 1. | ||
| """ | ||
| if max_total_tokens < 1: | ||
| raise ValueError(f"`max_total_tokens` must be a positive number of tokens, got {max_total_tokens}.") | ||
| self.max_total_tokens = max_total_tokens | ||
| self.add_final_message = add_final_message | ||
|
|
||
| def run(self, state: State) -> None: | ||
| """ | ||
| Stop the Agent if its cumulative token usage has reached the budget. | ||
|
|
||
| :param state: Agent state containing the cumulative token usage. | ||
| """ | ||
| usage = state.data.get("token_usage") or {} | ||
| # Not every chat generator reports `total_tokens`, so fall back to summing the input and output keys across | ||
| # the known naming conventions. | ||
| total_tokens = _first_numeric(usage, ("total_tokens",)) | ||
| if not total_tokens: | ||
| total_tokens = _first_numeric(usage, _INPUT_TOKEN_KEYS) + _first_numeric(usage, _OUTPUT_TOKEN_KEYS) | ||
| if total_tokens >= self.max_total_tokens: | ||
|
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. I think adding a logger warning message here could be helpful so it appears in logs if the Agent is running over the token budget.
Member
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. Added in af83ca3 |
||
| logger.warning( | ||
| "Agent reached its token budget of {max_total_tokens} ({total_tokens} used); requesting a stop.", | ||
| max_total_tokens=self.max_total_tokens, | ||
| total_tokens=total_tokens, | ||
| ) | ||
| state.set("stop_run", "token_budget_exceeded") | ||
|
Comment on lines
+74
to
+80
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. I also wonder if optionally setting a final message in This could be an optional init param of this hook. WDYT?
Member
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. I don't have strong opinions about this point.
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. I'm imagining the case if someone is hosting an Agent in Hayhooks and that whatever the last message is in State is what the end user will see. Right now it would be a tool call result which could be confusing. I think it can be solved here in the Hook or we would need to add a check for that in the Hayhooks app to provide an actionable message to the user
Member
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. OK, I see. What about a
Just an idea, I can improve the design (if you agree on the direction).
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. Yeah I like the direction!
Member
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. Implemented in a simplified way in 774e784
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. Looks good! And I agree with the off by default. I also realized that users anyways may prefer to create an after_run hook that manages what the final message should be based on reading the exit reason
Member
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. Yes, agree on |
||
| if self.add_final_message: | ||
| state.set("messages", [ChatMessage.from_assistant(_FINAL_MESSAGE_TEXT)]) | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| """ | ||
| Serialize this hook to a dictionary. | ||
|
|
||
| :returns: Serialized representation of the hook. | ||
| """ | ||
| return default_to_dict(self, max_total_tokens=self.max_total_tokens, add_final_message=self.add_final_message) | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, data: dict[str, Any]) -> "TokenBudgetHook": | ||
| """ | ||
| Create a hook from its serialized representation. | ||
|
|
||
| :param data: Serialized hook data. | ||
| :returns: The deserialized hook. | ||
| """ | ||
| return default_from_dict(cls, data=data) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| features: | ||
| - | | ||
| Added ``stop_run``, a control flag that lets an Agent hook end a run cleanly instead of raising an error. The | ||
| hook sets it to a reason of your choice (``state.set("stop_run", "my_reason")``), and the Agent stops without | ||
| another model call and reports that reason in ``exit_reason``. | ||
| - | | ||
| Added ``TokenBudgetHook`` (experimental), a ready-made hook that caps how many tokens an Agent run may spend. | ||
| The run ends with ``exit_reason`` set to ``"token_budget_exceeded"``, keeping the messages collected so far. | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| from haystack.hooks.budget import TokenBudgetHook | ||
|
|
||
| agent = Agent( | ||
| chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"), | ||
| tools=[search], | ||
| hooks={"before_llm": [TokenBudgetHook(max_total_tokens=100_000)]}, | ||
| ) |
Uh oh!
There was an error while loading. Please reload this page.