Skip to content

Commit e583e43

Browse files
committed
feat: context window management (Feature 5)
Adds a configurable sliding window to prevent context overflow during long single-agent runs. Truncation is applied to both LLM call sites in NodeExecutor (generate and execute_self_critic) while preserving the full message history in state for checkpointing. - BaseAgentConfig: max_context_messages field (None = disabled, int >= 2) with BASE_AGENT_MAX_CONTEXT_MESSAGES env var and validation - BaseAgent: max_context_messages kwarg wired from config - NodeExecutor._truncate_messages(): keeps first msg (user task) + last N-1 msgs - 21 unit tests covering logic, config validation, and node wiring
1 parent 22832db commit e583e43

8 files changed

Lines changed: 330 additions & 57 deletions

File tree

BaseAgent/base_agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
checkpoint_db_path: str | None = None,
6666
require_approval: str | None = None,
6767
skills_directory: str | None = None,
68+
max_context_messages: int | None = None,
6869
spec: AgentSpec | None = None,
6970
):
7071
"""
@@ -81,6 +82,10 @@ def __init__(
8182
"always" (default) — interrupt before every code block;
8283
"never" — never interrupt.
8384
skills_directory: Path to a directory of SKILL.md files to load on startup.
85+
max_context_messages: Sliding window size for context management. None (default)
86+
disables truncation. Integer >= 2 limits the number of messages passed to
87+
the LLM per call, always preserving the first message (user task) plus the
88+
most recent messages. The full history is retained in state for checkpointing.
8489
spec: Optional agent identity and persona. When provided, ``spec.name``,
8590
``spec.role``, ``spec.tool_names``, ``spec.skill_names``,
8691
``spec.llm``, ``spec.source``, and ``spec.temperature`` override
@@ -109,6 +114,7 @@ def __init__(
109114
self.checkpoint_db_path = checkpoint_db_path if checkpoint_db_path is not None else default_config.checkpoint_db_path
110115
self.require_approval = require_approval if require_approval is not None else default_config.require_approval
111116
self.skills_directory = skills_directory if skills_directory is not None else default_config.skills_directory
117+
self.max_context_messages = max_context_messages if max_context_messages is not None else default_config.max_context_messages
112118
self.thread_id: str | None = None
113119
self._interrupted: bool = False
114120
self._run_config: dict | None = None

BaseAgent/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ class BaseAgentConfig:
5959
# Skills
6060
skills_directory: str | None = None # directory of SKILL.md files to load on startup
6161

62+
# Context window management
63+
max_context_messages: int | None = None # None = disabled; int >= 2 = sliding window size
64+
6265
def __post_init__(self):
6366
"""Load any environment variable overrides if they exist."""
6467
# Check for environment variable overrides (optional)
@@ -90,6 +93,16 @@ def __post_init__(self):
9093
self.require_approval = val
9194
if os.getenv("BASE_AGENT_SKILLS_DIRECTORY"):
9295
self.skills_directory = os.getenv("BASE_AGENT_SKILLS_DIRECTORY")
96+
if os.getenv("BASE_AGENT_MAX_CONTEXT_MESSAGES"):
97+
try:
98+
val = int(os.getenv("BASE_AGENT_MAX_CONTEXT_MESSAGES"))
99+
except ValueError:
100+
raise ValueError("BASE_AGENT_MAX_CONTEXT_MESSAGES must be an integer")
101+
if val < 2:
102+
raise ValueError("BASE_AGENT_MAX_CONTEXT_MESSAGES must be >= 2")
103+
self.max_context_messages = val
104+
if self.max_context_messages is not None and self.max_context_messages < 2:
105+
raise ValueError("max_context_messages must be None or >= 2")
93106

94107
def to_dict(self) -> dict:
95108
"""Convert config to dictionary for easy access."""
@@ -105,6 +118,7 @@ def to_dict(self) -> dict:
105118
"checkpoint_db_path": self.checkpoint_db_path,
106119
"require_approval": self.require_approval,
107120
"skills_directory": self.skills_directory,
121+
"max_context_messages": self.max_context_messages,
108122
}
109123

110124

BaseAgent/nodes.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ class NodeExecutor:
2828
def __init__(self, agent: "BaseAgent"):
2929
self.agent = agent
3030

31+
# ------------------------------------------------------------------
32+
# Context window management
33+
# ------------------------------------------------------------------
34+
35+
def _truncate_messages(self, messages: list) -> list:
36+
"""Sliding window: keep first message + last (N-1) messages.
37+
38+
The first message in state["input"] is the initial user HumanMessage
39+
(the task). It is always preserved so the LLM retains its objective.
40+
The system prompt is prepended separately in generate() and is not
41+
part of state["input"].
42+
43+
When max_context_messages is None (default), all messages are passed
44+
through unchanged.
45+
"""
46+
if not messages:
47+
return messages
48+
max_msgs = self.agent.max_context_messages
49+
if max_msgs is None or len(messages) <= max_msgs:
50+
return messages
51+
return [messages[0]] + messages[-(max_msgs - 1):]
52+
3153
# ------------------------------------------------------------------
3254
# Core nodes
3355
# ------------------------------------------------------------------
@@ -44,8 +66,8 @@ def generate(self, state: "AgentState") -> "AgentState":
4466
"cache_control": {"type": "ephemeral"}
4567
}
4668

47-
# Add the system prompt to the input to LLM
48-
input = [system_message] + state["input"]
69+
# Add the system prompt to the input to LLM; apply sliding window if configured
70+
input = [system_message] + self._truncate_messages(state["input"])
4971
output = agent.llm.invoke(input)
5072

5173
usage_metrics = extract_usage_metrics(agent.source, output, model=getattr(agent.llm, "model_name", None))
@@ -191,8 +213,10 @@ def execute_self_critic(self, state: "AgentState", test_time_scale_round: int) -
191213
"""LLM feedback generation for self-critic mode."""
192214
agent = self.agent
193215
if agent.critic_count < test_time_scale_round:
194-
# Generate feedback based on message history
195-
input = state["input"]
216+
# Generate feedback based on message history; apply sliding window if configured.
217+
# Note: no system message is prepended here — first element of state["input"]
218+
# is the user HumanMessage (the task).
219+
input = self._truncate_messages(state["input"])
196220
feedback_prompt = get_feedback_prompt(agent.user_task)
197221
feedback = agent.llm.invoke(input + [HumanMessage(content=feedback_prompt)])
198222

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""Unit tests for Feature 5: Context Window Management.
2+
3+
Tests cover:
4+
- _truncate_messages() logic (edge cases, boundary conditions)
5+
- BaseAgentConfig validation for max_context_messages
6+
- Wiring: generate() and execute_self_critic() pass truncated input to llm.invoke()
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
from unittest.mock import MagicMock, patch
13+
14+
import pytest
15+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
16+
17+
from BaseAgent.config import BaseAgentConfig
18+
from BaseAgent.nodes import NodeExecutor
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Helpers
23+
# ---------------------------------------------------------------------------
24+
25+
26+
def make_agent(max_context_messages=None):
27+
"""Minimal mock agent for NodeExecutor tests."""
28+
agent = MagicMock()
29+
agent.source = "Anthropic"
30+
agent.system_prompt = "You are a helpful assistant."
31+
agent.use_tool_retriever = False
32+
agent.timeout_seconds = 30
33+
agent.critic_count = 0
34+
agent.user_task = "test task"
35+
agent.max_context_messages = max_context_messages
36+
agent.llm.invoke.return_value = MagicMock(
37+
content="<solution>answer</solution>", usage_metadata=None
38+
)
39+
return agent
40+
41+
42+
def make_messages(n: int) -> list:
43+
"""Return a list of n alternating HumanMessage / AIMessage objects."""
44+
msgs = []
45+
for i in range(n):
46+
if i % 2 == 0:
47+
msgs.append(HumanMessage(content=f"human {i}"))
48+
else:
49+
msgs.append(AIMessage(content=f"ai {i}"))
50+
return msgs
51+
52+
53+
def make_state(messages=None):
54+
return {
55+
"input": messages if messages is not None else [HumanMessage(content="task")],
56+
"next_step": None,
57+
"pending_code": None,
58+
"pending_language": None,
59+
}
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# _truncate_messages unit tests
64+
# ---------------------------------------------------------------------------
65+
66+
67+
class TestTruncateMessages:
68+
def test_empty_list(self):
69+
executor = NodeExecutor(make_agent())
70+
assert executor._truncate_messages([]) == []
71+
72+
def test_disabled_by_default(self):
73+
"""max_context_messages=None passes all messages through unchanged."""
74+
executor = NodeExecutor(make_agent(max_context_messages=None))
75+
msgs = make_messages(20)
76+
result = executor._truncate_messages(msgs)
77+
assert result is msgs # same object, no copy
78+
79+
def test_no_truncation_below_limit(self):
80+
"""Fewer messages than the limit: no truncation."""
81+
executor = NodeExecutor(make_agent(max_context_messages=10))
82+
msgs = make_messages(5)
83+
result = executor._truncate_messages(msgs)
84+
assert result == msgs
85+
86+
def test_no_truncation_at_exact_limit(self):
87+
"""Exactly at the limit: no truncation (boundary condition)."""
88+
executor = NodeExecutor(make_agent(max_context_messages=5))
89+
msgs = make_messages(5)
90+
result = executor._truncate_messages(msgs)
91+
assert result == msgs
92+
93+
def test_truncation_above_limit(self):
94+
"""10 messages, limit 5: keeps first + last 4."""
95+
executor = NodeExecutor(make_agent(max_context_messages=5))
96+
msgs = make_messages(10)
97+
result = executor._truncate_messages(msgs)
98+
assert len(result) == 5
99+
assert result[0] is msgs[0] # first message preserved
100+
assert result[1:] == msgs[-4:] # last 4 messages
101+
102+
def test_first_message_always_preserved(self):
103+
"""The initial user task (index 0) is always the first element."""
104+
executor = NodeExecutor(make_agent(max_context_messages=3))
105+
msgs = make_messages(20)
106+
result = executor._truncate_messages(msgs)
107+
assert result[0] is msgs[0]
108+
109+
def test_state_input_not_mutated_by_generate(self):
110+
"""state["input"] must not be modified during generate()."""
111+
agent = make_agent(max_context_messages=3)
112+
executor = NodeExecutor(agent)
113+
msgs = make_messages(10)
114+
state = make_state(list(msgs)) # copy so we can compare
115+
original_len = len(state["input"])
116+
with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None):
117+
executor.generate(state)
118+
# state["input"] grows by 1 (the AI response is appended), but is not truncated
119+
assert len(state["input"]) == original_len + 1
120+
121+
122+
# ---------------------------------------------------------------------------
123+
# BaseAgentConfig validation tests
124+
# ---------------------------------------------------------------------------
125+
126+
127+
class TestConfigValidation:
128+
def test_default_is_none(self):
129+
config = BaseAgentConfig()
130+
assert config.max_context_messages is None
131+
132+
def test_valid_value(self):
133+
config = BaseAgentConfig(max_context_messages=10)
134+
assert config.max_context_messages == 10
135+
136+
def test_value_2_is_minimum(self):
137+
config = BaseAgentConfig(max_context_messages=2)
138+
assert config.max_context_messages == 2
139+
140+
def test_value_1_raises(self):
141+
with pytest.raises(ValueError, match="max_context_messages"):
142+
BaseAgentConfig(max_context_messages=1)
143+
144+
def test_value_0_raises(self):
145+
with pytest.raises(ValueError, match="max_context_messages"):
146+
BaseAgentConfig(max_context_messages=0)
147+
148+
def test_negative_raises(self):
149+
with pytest.raises(ValueError, match="max_context_messages"):
150+
BaseAgentConfig(max_context_messages=-5)
151+
152+
def test_env_var_valid(self, monkeypatch):
153+
monkeypatch.setenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", "20")
154+
config = BaseAgentConfig()
155+
assert config.max_context_messages == 20
156+
monkeypatch.delenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", raising=False)
157+
158+
def test_env_var_too_small_raises(self, monkeypatch):
159+
monkeypatch.setenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", "1")
160+
with pytest.raises(ValueError, match="BASE_AGENT_MAX_CONTEXT_MESSAGES"):
161+
BaseAgentConfig()
162+
monkeypatch.delenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", raising=False)
163+
164+
def test_env_var_non_integer_raises(self, monkeypatch):
165+
monkeypatch.setenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", "abc")
166+
with pytest.raises(ValueError, match="BASE_AGENT_MAX_CONTEXT_MESSAGES"):
167+
BaseAgentConfig()
168+
monkeypatch.delenv("BASE_AGENT_MAX_CONTEXT_MESSAGES", raising=False)
169+
170+
def test_to_dict_includes_field(self):
171+
config = BaseAgentConfig(max_context_messages=15)
172+
d = config.to_dict()
173+
assert "max_context_messages" in d
174+
assert d["max_context_messages"] == 15
175+
176+
177+
# ---------------------------------------------------------------------------
178+
# Wiring tests: generate() passes truncated input to llm.invoke()
179+
# ---------------------------------------------------------------------------
180+
181+
182+
class TestGenerateNodeWiring:
183+
def test_generate_truncates_llm_input(self):
184+
"""generate() must pass a truncated list to llm.invoke() when limit is set."""
185+
agent = make_agent(max_context_messages=3)
186+
executor = NodeExecutor(agent)
187+
188+
msgs = make_messages(10)
189+
state = make_state(list(msgs))
190+
191+
with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None):
192+
executor.generate(state)
193+
194+
call_args = agent.llm.invoke.call_args[0][0]
195+
# call_args = [SystemMessage] + truncated(state["input"])
196+
# truncated = [msgs[0]] + msgs[-2:] = 3 messages
197+
# total = 1 (system) + 3 = 4
198+
assert len(call_args) == 4
199+
assert isinstance(call_args[0], SystemMessage)
200+
assert call_args[1] is msgs[0] # first message preserved
201+
202+
def test_generate_no_truncation_when_disabled(self):
203+
"""generate() passes all messages when max_context_messages=None."""
204+
agent = make_agent(max_context_messages=None)
205+
executor = NodeExecutor(agent)
206+
207+
msgs = make_messages(10)
208+
state = make_state(list(msgs))
209+
210+
with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None):
211+
executor.generate(state)
212+
213+
call_args = agent.llm.invoke.call_args[0][0]
214+
# 1 system + 10 messages = 11
215+
assert len(call_args) == 11
216+
217+
218+
# ---------------------------------------------------------------------------
219+
# Wiring tests: execute_self_critic() passes truncated input to llm.invoke()
220+
# ---------------------------------------------------------------------------
221+
222+
223+
class TestSelfCriticWiring:
224+
def test_self_critic_truncates_llm_input(self):
225+
"""execute_self_critic() must pass a truncated list to llm.invoke()."""
226+
agent = make_agent(max_context_messages=3)
227+
# Set critic_count=0 and test_time_scale_round=1 to exercise the branch
228+
agent.critic_count = 0
229+
agent.llm.invoke.return_value = MagicMock(content="feedback text", usage_metadata=None)
230+
231+
executor = NodeExecutor(agent)
232+
msgs = make_messages(10)
233+
state = make_state(list(msgs))
234+
state["next_step"] = "generate"
235+
236+
with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None):
237+
with patch("BaseAgent.nodes.get_feedback_prompt", return_value="give feedback"):
238+
executor.execute_self_critic(state, test_time_scale_round=1)
239+
240+
call_args = agent.llm.invoke.call_args[0][0]
241+
# truncated = [msgs[0]] + msgs[-2:] = 3 messages
242+
# + 1 feedback HumanMessage appended after truncation = 4 total
243+
assert len(call_args) == 4
244+
assert call_args[0] is msgs[0] # first message preserved
245+
assert isinstance(call_args[-1], HumanMessage) # feedback appended last
246+
247+
def test_self_critic_no_truncation_when_disabled(self):
248+
"""execute_self_critic() passes all messages when max_context_messages=None."""
249+
agent = make_agent(max_context_messages=None)
250+
agent.critic_count = 0
251+
agent.llm.invoke.return_value = MagicMock(content="feedback", usage_metadata=None)
252+
253+
executor = NodeExecutor(agent)
254+
msgs = make_messages(10)
255+
state = make_state(list(msgs))
256+
257+
with patch("BaseAgent.nodes.extract_usage_metrics", return_value=None):
258+
with patch("BaseAgent.nodes.get_feedback_prompt", return_value="give feedback"):
259+
executor.execute_self_critic(state, test_time_scale_round=1)
260+
261+
call_args = agent.llm.invoke.call_args[0][0]
262+
# 10 messages + 1 feedback HumanMessage = 11
263+
assert len(call_args) == 11

BaseAgent/tests/test_interrupt_resume.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def make_agent(require_approval="never", source="Anthropic"):
2525
agent.critic_count = 0
2626
agent.user_task = "test task"
2727
agent.require_approval = require_approval
28+
agent.max_context_messages = None
2829
resp = MagicMock()
2930
resp.content = "<solution>answer</solution>"
3031
resp.usage_metadata = None

BaseAgent/tests/test_nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def make_agent(source="Anthropic", use_tool_retriever=False):
1919
agent.timeout_seconds = 30
2020
agent.critic_count = 0
2121
agent.user_task = "test task"
22+
agent.max_context_messages = None
2223
# llm.invoke returns a mock response
2324
agent.llm.invoke.return_value = MagicMock(content="<solution>answer</solution>", usage_metadata=None)
2425
return agent

0 commit comments

Comments
 (0)