test_context_budget.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import math
  2. import pytest
  3. from agent.core.runner import AgentRunner, RunConfig
  4. from agent.trace.compaction import (
  5. CompressionConfig,
  6. calibrate_prompt_estimate,
  7. measure_prompt_tokens,
  8. )
  9. from agent.trace.models import Trace
  10. from agent.trace.store import FileSystemTraceStore
  11. def _knowledge_off():
  12. from agent.tools.builtin.knowledge import KnowledgeConfig
  13. return KnowledgeConfig(
  14. enable_extraction=False,
  15. enable_completion_extraction=False,
  16. enable_injection=False,
  17. )
  18. def test_prompt_measurement_includes_tools_and_provider_calibration():
  19. config = CompressionConfig(max_tokens=100_000)
  20. messages = [{"role": "user", "content": "x" * 4_000}]
  21. schemas = [{
  22. "type": "function",
  23. "function": {
  24. "name": "large_tool",
  25. "description": "y" * 4_000,
  26. "parameters": {"type": "object"},
  27. },
  28. }]
  29. raw = measure_prompt_tokens(messages, [], config, "unknown-model", 1.0)
  30. measured = measure_prompt_tokens(messages, schemas, config, "unknown-model", 1.0)
  31. factor = calibrate_prompt_estimate(
  32. actual_prompt_tokens=measured.estimated_tokens * 2,
  33. estimated_prompt_tokens=measured.estimated_tokens,
  34. previous_factor=1.25,
  35. )
  36. calibrated = measure_prompt_tokens(
  37. messages,
  38. schemas,
  39. config,
  40. "unknown-model",
  41. factor,
  42. )
  43. assert measured.tool_schema_tokens > 0
  44. assert measured.estimated_tokens > raw.estimated_tokens
  45. assert factor == 2
  46. assert calibrated.calibrated_tokens == measured.estimated_tokens * 2
  47. assert calibrated.trigger_tokens == 80_000
  48. assert calibrated.target_tokens == 30_000
  49. @pytest.mark.asyncio
  50. async def test_build_900036_growth_replay_compresses_before_hard_limit(tmp_path):
  51. store = FileSystemTraceStore(str(tmp_path))
  52. trace = Trace(trace_id="growth-replay", mode="agent", agent_role="legacy")
  53. await store.create_trace(trace)
  54. runner = AgentRunner(trace_store=store)
  55. config = RunConfig(
  56. model="unregistered-build-model",
  57. compression=CompressionConfig(max_tokens=100_000),
  58. knowledge=_knowledge_off(),
  59. )
  60. schemas = [{
  61. "type": "function",
  62. "function": {
  63. "name": "offline_step",
  64. "description": "append one deterministic fixture step",
  65. "parameters": {"type": "object", "properties": {}},
  66. },
  67. }]
  68. history = [
  69. {"role": "system", "content": "keep system policy"},
  70. {"role": "user", "content": "offline 97-round context fixture"},
  71. ]
  72. total_prompt_tokens = 0
  73. max_prompt_tokens = 0
  74. for turn in range(97):
  75. history.extend([
  76. {"role": "assistant", "content": f"turn {turn}", "tool_calls": []},
  77. {"role": "tool", "content": "x" * 4_000},
  78. ])
  79. history, _, _, needs_compression = await runner._manage_context_usage(
  80. trace.trace_id,
  81. history,
  82. None,
  83. config,
  84. sequence=turn + 1,
  85. head_seq=0,
  86. tool_schemas=schemas,
  87. )
  88. current = await store.get_trace(trace.trace_id)
  89. measurement = measure_prompt_tokens(
  90. history,
  91. schemas,
  92. config.compression,
  93. config.model,
  94. current.runtime_state.get("context_budget", {}).get("calibration_factor"),
  95. )
  96. simulated_actual = math.ceil(measurement.estimated_tokens * 1.10)
  97. total_prompt_tokens += simulated_actual
  98. max_prompt_tokens = max(max_prompt_tokens, simulated_actual)
  99. await runner._record_prompt_measurement(
  100. current,
  101. config,
  102. history,
  103. schemas,
  104. simulated_actual,
  105. )
  106. if needs_compression:
  107. history = [
  108. history[0],
  109. history[1],
  110. {"role": "user", "content": "compact execution summary"},
  111. ]
  112. current = await store.get_trace(trace.trace_id)
  113. compacted = measure_prompt_tokens(
  114. history,
  115. schemas,
  116. config.compression,
  117. config.model,
  118. current.runtime_state["context_budget"]["calibration_factor"],
  119. )
  120. failure = await runner._finish_context_compression(
  121. current,
  122. compacted,
  123. before_message_count=turn * 2 + 4,
  124. after_message_count=len(history),
  125. )
  126. assert failure is None
  127. assert compacted.calibrated_tokens <= compacted.target_tokens
  128. events = await store.get_events(trace.trace_id)
  129. event_names = [event["event"] for event in events]
  130. assert "context_compression_started" in event_names
  131. assert "context_compression_completed" in event_names
  132. assert max_prompt_tokens < 100_000
  133. assert total_prompt_tokens < 6_327_689 * 0.50
  134. usage = runner.get_context_usage(trace.trace_id)
  135. assert usage.compression_count >= 1
  136. assert usage.actual_prompt_tokens is not None
  137. @pytest.mark.asyncio
  138. async def test_oversized_minimum_context_fails_before_model_request(tmp_path):
  139. calls = 0
  140. async def llm_call(**_kwargs):
  141. nonlocal calls
  142. calls += 1
  143. return {"content": "must not be called", "tool_calls": None}
  144. store = FileSystemTraceStore(str(tmp_path))
  145. result = await AgentRunner(
  146. trace_store=store,
  147. llm_call=llm_call,
  148. ).run_result(
  149. [{"role": "user", "content": "x" * 8_000}],
  150. RunConfig(
  151. max_iterations=4,
  152. tools=[],
  153. tool_groups=[],
  154. knowledge=_knowledge_off(),
  155. compression=CompressionConfig(max_tokens=1_000),
  156. ),
  157. )
  158. assert calls == 0
  159. assert result["status"] == "failed"
  160. assert result["failure"]["code"] == "CONTEXT_BUDGET_EXCEEDED"
  161. events = await store.get_events(result["trace_id"])
  162. assert "context_budget_exceeded" in [event["event"] for event in events]
  163. @pytest.mark.parametrize(
  164. "kwargs",
  165. [
  166. {"trigger_ratio": 0.5, "target_ratio": 0.5},
  167. {"trigger_ratio": 1.0},
  168. {"fallback_safety_factor": 0.9},
  169. {"max_tokens": -1},
  170. ],
  171. )
  172. def test_compression_config_rejects_unsafe_budgets(kwargs):
  173. with pytest.raises(ValueError):
  174. CompressionConfig(**kwargs)