Skip to content

Commit 478471a

Browse files
authored
feat: add context_tokens to internal Agents State (#12102)
1 parent 07e163e commit 478471a

10 files changed

Lines changed: 307 additions & 17 deletions

File tree

docs-website/docs/pipeline-components/agents-1/hooks.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ The Agent manages a few state keys that hooks interact with. Like the run-metada
4646
- `continue_run`: Set by an `on_exit` hook to keep the Agent running.
4747
- `tools`: The tools available in the current step, for hooks to inspect.
4848
- `hook_context`: Request-scoped resources passed to `Agent.run(hook_context={...})` / `run_async(hook_context={...})`. Hooks read it with `state.data["hook_context"]` or `state.data.get("hook_context")` — use it for per-request resources such as a user ID, a WebSocket, or a database client. Avoid the plain `state.get("hook_context")` here: `State.get` returns a deep copy of the value, which often fails for the kinds of resources stored in this dict (such as a WebSocket or a database client).
49+
- `context_tokens`: An approximate count of the tokens currently in the context window, refreshed after each LLM call with that reply's prompt-plus-completion tokens (read it with `state.get("context_tokens")`). Unlike `token_usage`, which accumulates over the run, this is replaced on every call. It's `0` until the first reply that reports usage and doesn't count messages appended after the latest call. A `before_llm` hook can read it to trigger context compaction once it crosses a threshold.
4950

5051
Hooks can also read the automatically tracked run metadata: `step_count`, `token_usage`, and `tool_call_counts`.
5152

docs-website/docs/pipeline-components/agents-1/state.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ If you don't specify a handler, State automatically assigns a default based on t
7171
The `Agent` manages some state keys itself and rejects them in a user-provided `state_schema` with a `ValueError`:
7272

7373
- The run-metadata keys `step_count`, `token_usage`, `tool_call_counts`, and `exit_reason`, which the Agent populates automatically during a run: tools and hooks can read them from the live `State`, and they are returned in the result dictionary. `exit_reason` reports why the Agent stopped (`"text"`, the name of the tool that triggered a tool exit condition, or `"max_agent_steps"`).
74-
- The hook-facing keys `continue_run` (set by an `on_exit` hook to keep the Agent running), `tools` (the tools available in the current step, for hooks to inspect), and `hook_context` (the request-scoped resources passed to `Agent.run(hook_context={...})`).
74+
- The hook-facing keys `continue_run` (set by an `on_exit` hook to keep the Agent running), `tools` (the tools available in the current step, for hooks to inspect), `hook_context` (the request-scoped resources passed to `Agent.run(hook_context={...})`), and `context_tokens` (an approximate count of the tokens currently in the context window, refreshed after each LLM call for hooks to read — for example, to trigger context compaction). Unlike the run-metadata keys, these are not returned in the result dictionary.
7575

7676
If one of your state keys clashes, rename it (for example, `my_token_usage`).
7777
:::

haystack/components/agents/agent.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from haystack.components.agents.state.state_utils import merge_lists
2020
from haystack.components.agents.tool_calling import _run_tool, _run_tool_async
21+
from haystack.components.agents.utils import _record_context_tokens
2122
from haystack.components.builders import ChatPromptBuilder
2223
from haystack.components.generators.chat.types import ChatGenerator
2324
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
@@ -82,10 +83,14 @@
8283
# - `continue_run`: set by an `on_exit` hook to keep the Agent running instead of stopping (re-read each exit attempt).
8384
# - `tools`: the flattened tools available in the current step, so a hook can inspect them (e.g. HITL confirmation).
8485
# - `hook_context`: per-run request-scoped resources passed to `run`/`run_async` for hooks to read.
86+
# - `context_tokens`: approximate current context-window size, refreshed after each LLM call, for hooks to read
87+
# (e.g. a `before_llm` hook that triggers compaction once the context grows too large). Kept internal rather than
88+
# exposed as an output because it is a best-effort snapshot; see `_record_context_tokens`.
8589
_INTERNAL_STATE_KEYS: dict[str, dict[str, Any]] = {
8690
"continue_run": {"type": bool, "handler": replace_values},
8791
"tools": {"type": list, "handler": replace_values},
8892
"hook_context": {"type": dict[str, Any], "handler": replace_values},
93+
"context_tokens": {"type": int, "handler": replace_values},
8994
}
9095

9196

@@ -938,6 +943,7 @@ def _initialize_fresh_execution(
938943
state.set("messages", messages)
939944
state.set("step_count", 0)
940945
state.set("token_usage", {})
946+
state.set("context_tokens", 0)
941947
state.set("tool_call_counts", {tool.name: 0 for tool in flat_tools})
942948
state.set("exit_reason", None)
943949
state.set("continue_run", False)
@@ -1187,6 +1193,7 @@ def _run_step(self, exe_context: _ExecutionContext, agent_span: tracing.Span) ->
11871193
llm_messages = result["replies"]
11881194
exe_context.state.set("messages", llm_messages)
11891195
_record_llm_usage(exe_context.state, llm_messages)
1196+
_record_context_tokens(exe_context.state, llm_messages)
11901197

11911198
# Stop on the "no tool call" exit: no tools available, or a plain assistant text reply (see _is_text_exit).
11921199
if not current_tools or _is_text_exit(llm_messages):
@@ -1251,6 +1258,7 @@ async def _run_step_async(self, exe_context: _ExecutionContext, agent_span: trac
12511258
llm_messages = result["replies"]
12521259
exe_context.state.set("messages", llm_messages)
12531260
_record_llm_usage(exe_context.state, llm_messages)
1261+
_record_context_tokens(exe_context.state, llm_messages)
12541262

12551263
# Stop on the "no tool call" exit: no tools available, or a plain assistant text reply (see _is_text_exit).
12561264
if not current_tools or _is_text_exit(llm_messages):
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from typing import Any
6+
7+
from haystack.components.agents.state.state import State
8+
from haystack.dataclasses import ChatMessage
9+
10+
# Input/output token key conventions across chat generators: most report OpenAI-style
11+
# `prompt_tokens`/`completion_tokens`; OpenAIResponsesChatGenerator reports `input_tokens`/`output_tokens`.
12+
_INPUT_TOKEN_KEYS = ("prompt_tokens", "input_tokens")
13+
_OUTPUT_TOKEN_KEYS = ("completion_tokens", "output_tokens")
14+
15+
16+
def _first_numeric(usage: dict[str, Any], keys: tuple[str, ...]) -> int:
17+
"""
18+
Return the first numeric value found under `keys` in `usage`, or 0 if none is present.
19+
20+
:param usage: A ChatMessage `meta["usage"]` payload.
21+
:param keys: Candidate keys to check, in priority order.
22+
:returns: The first `int`/`float` value (as an `int`), or 0. bool values are skipped (not token counts).
23+
"""
24+
for key in keys:
25+
value = usage.get(key)
26+
# bool is an int subclass, so exclude it explicitly: True/False is not a token count.
27+
if isinstance(value, bool):
28+
continue
29+
if isinstance(value, (int, float)):
30+
return int(value)
31+
return 0
32+
33+
34+
def _context_tokens_from_usage(usage: dict[str, Any]) -> int:
35+
"""
36+
Sum the input and output tokens reported in a single `meta["usage"]` dict.
37+
38+
:param usage: A ChatMessage `meta["usage"]` payload.
39+
:returns: Input plus output tokens, or 0 if neither key convention is present.
40+
"""
41+
return _first_numeric(usage, _INPUT_TOKEN_KEYS) + _first_numeric(usage, _OUTPUT_TOKEN_KEYS)
42+
43+
44+
def _record_context_tokens(state: State, llm_messages: list[ChatMessage]) -> None:
45+
"""
46+
Store the approximate current context-window token count from the latest LLM call.
47+
48+
A chat-generator call returns a single reply, so only the last message is inspected. Unlike
49+
`token_usage`, which accumulates across the run, this value is replaced each call with that reply's
50+
prompt-plus-completion tokens. Only writes when usage is reported, so generators that don't surface
51+
usage leave the previous value untouched.
52+
53+
:param state: The Agent's State, used to write the latest `context_tokens` count.
54+
:param llm_messages: The ChatMessage objects returned from the latest LLM call.
55+
"""
56+
if not llm_messages:
57+
return
58+
usage = llm_messages[-1].meta.get("usage")
59+
if isinstance(usage, dict):
60+
tokens = _context_tokens_from_usage(usage)
61+
if tokens:
62+
state.set("context_tokens", tokens)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
enhancements:
3+
- |
4+
The ``Agent`` now tracks an approximate current context-window size in its internal ``State`` under
5+
``context_tokens``, refreshed after every LLM call with that reply's prompt-plus-completion tokens
6+
(normalized across the ``prompt_tokens``/``completion_tokens`` and ``input_tokens``/``output_tokens``
7+
key conventions). Unlike ``token_usage``, which accumulates across the whole run, ``context_tokens``
8+
is replaced each call. Hooks can read it via ``state.get("context_tokens")`` — for example, a
9+
``before_llm`` hook that triggers context compaction once the value crosses a threshold. It is a
10+
best-effort snapshot: it is ``0`` when the generator does not report usage, and does not count
11+
messages appended after the latest call until the next call refreshes it.

test/components/agents/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0

0 commit comments

Comments
 (0)