test_context_budget.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import math
  2. import pytest
  3. from agent.core.prompts import build_summary_header
  4. from agent.core.runner import AgentRunner, RunConfig
  5. from agent.orchestration import CompletionPolicy
  6. from agent.trace.compaction import (
  7. CompressionConfig,
  8. calibrate_prompt_estimate,
  9. measure_prompt_tokens,
  10. )
  11. from agent.trace.models import Trace
  12. from agent.trace.store import FileSystemTraceStore
  13. def _knowledge_off():
  14. from agent.tools.builtin.knowledge import KnowledgeConfig
  15. return KnowledgeConfig(
  16. enable_extraction=False,
  17. enable_completion_extraction=False,
  18. enable_injection=False,
  19. )
  20. def test_prompt_measurement_includes_tools_and_provider_calibration():
  21. config = CompressionConfig(max_tokens=100_000)
  22. messages = [{"role": "user", "content": "x" * 4_000}]
  23. schemas = [
  24. {
  25. "type": "function",
  26. "function": {
  27. "name": "large_tool",
  28. "description": "y" * 4_000,
  29. "parameters": {"type": "object"},
  30. },
  31. }
  32. ]
  33. raw = measure_prompt_tokens(messages, [], config, "unknown-model", 1.0)
  34. measured = measure_prompt_tokens(messages, schemas, config, "unknown-model", 1.0)
  35. factor = calibrate_prompt_estimate(
  36. actual_prompt_tokens=measured.estimated_tokens * 2,
  37. estimated_prompt_tokens=measured.estimated_tokens,
  38. previous_factor=1.25,
  39. )
  40. calibrated = measure_prompt_tokens(
  41. messages,
  42. schemas,
  43. config,
  44. "unknown-model",
  45. factor,
  46. )
  47. assert measured.tool_schema_tokens > 0
  48. assert measured.estimated_tokens > raw.estimated_tokens
  49. assert factor == 2
  50. assert calibrated.calibrated_tokens == measured.estimated_tokens * 2
  51. assert calibrated.trigger_tokens == 80_000
  52. assert calibrated.target_tokens == 30_000
  53. @pytest.mark.asyncio
  54. async def test_build_900036_growth_replay_compresses_before_hard_limit(tmp_path):
  55. store = FileSystemTraceStore(str(tmp_path))
  56. trace = Trace(trace_id="growth-replay", mode="agent", agent_role="legacy")
  57. await store.create_trace(trace)
  58. runner = AgentRunner(trace_store=store)
  59. config = RunConfig(
  60. model="unregistered-build-model",
  61. compression=CompressionConfig(max_tokens=100_000),
  62. knowledge=_knowledge_off(),
  63. )
  64. schemas = [
  65. {
  66. "type": "function",
  67. "function": {
  68. "name": "offline_step",
  69. "description": "append one deterministic fixture step",
  70. "parameters": {"type": "object", "properties": {}},
  71. },
  72. }
  73. ]
  74. history = [
  75. {"role": "system", "content": "keep system policy"},
  76. {"role": "user", "content": "offline 97-round context fixture"},
  77. ]
  78. total_prompt_tokens = 0
  79. max_prompt_tokens = 0
  80. for turn in range(97):
  81. history.extend(
  82. [
  83. {"role": "assistant", "content": f"turn {turn}", "tool_calls": []},
  84. {"role": "tool", "content": "x" * 4_000},
  85. ]
  86. )
  87. history, _, _, needs_compression = await runner._manage_context_usage(
  88. trace.trace_id,
  89. history,
  90. None,
  91. config,
  92. sequence=turn + 1,
  93. head_seq=0,
  94. tool_schemas=schemas,
  95. )
  96. current = await store.get_trace(trace.trace_id)
  97. measurement = measure_prompt_tokens(
  98. history,
  99. schemas,
  100. config.compression,
  101. config.model,
  102. current.runtime_state.get("context_budget", {}).get("calibration_factor"),
  103. )
  104. simulated_actual = math.ceil(measurement.estimated_tokens * 1.10)
  105. total_prompt_tokens += simulated_actual
  106. max_prompt_tokens = max(max_prompt_tokens, simulated_actual)
  107. await runner._record_prompt_measurement(
  108. current,
  109. config,
  110. history,
  111. schemas,
  112. simulated_actual,
  113. )
  114. if needs_compression:
  115. history = [
  116. history[0],
  117. history[1],
  118. {"role": "user", "content": "compact execution summary"},
  119. ]
  120. current = await store.get_trace(trace.trace_id)
  121. compacted = measure_prompt_tokens(
  122. history,
  123. schemas,
  124. config.compression,
  125. config.model,
  126. current.runtime_state["context_budget"]["calibration_factor"],
  127. )
  128. failure = await runner._finish_context_compression(
  129. current,
  130. compacted,
  131. before_message_count=turn * 2 + 4,
  132. after_message_count=len(history),
  133. )
  134. assert failure is None
  135. assert compacted.calibrated_tokens <= compacted.target_tokens
  136. events = await store.get_events(trace.trace_id)
  137. event_names = [event["event"] for event in events]
  138. assert "context_compression_started" in event_names
  139. assert "context_compression_completed" in event_names
  140. prompt_events = [
  141. event for event in events if event["event"] == "prompt_envelope_measured"
  142. ]
  143. assert len(prompt_events) == 97
  144. assert prompt_events[-1]["actual_prompt_tokens"] > 0
  145. assert prompt_events[-1]["packing_mode"] == "runner"
  146. assert max_prompt_tokens < 100_000
  147. assert total_prompt_tokens < 6_327_689 * 0.50
  148. usage = runner.get_context_usage(trace.trace_id)
  149. assert usage.compression_count >= 1
  150. assert usage.actual_prompt_tokens is not None
  151. @pytest.mark.asyncio
  152. async def test_oversized_minimum_context_fails_before_model_request(tmp_path):
  153. calls = 0
  154. async def llm_call(**_kwargs):
  155. nonlocal calls
  156. calls += 1
  157. return {"content": "must not be called", "tool_calls": None}
  158. store = FileSystemTraceStore(str(tmp_path))
  159. result = await AgentRunner(
  160. trace_store=store,
  161. llm_call=llm_call,
  162. ).run_result(
  163. [{"role": "user", "content": "x" * 8_000}],
  164. RunConfig(
  165. max_iterations=4,
  166. tools=[],
  167. tool_groups=[],
  168. knowledge=_knowledge_off(),
  169. compression=CompressionConfig(max_tokens=1_000),
  170. ),
  171. )
  172. assert calls == 0
  173. assert result["status"] == "failed"
  174. assert result["failure"]["code"] == "CONTEXT_BUDGET_EXCEEDED"
  175. events = await store.get_events(result["trace_id"])
  176. assert "context_budget_exceeded" in [event["event"] for event in events]
  177. @pytest.mark.asyncio
  178. @pytest.mark.parametrize("sentinel", ["worker-latest", "validator-latest", "repair-latest"])
  179. async def test_compression_refreshes_latest_protected_role_context(tmp_path, sentinel):
  180. store = FileSystemTraceStore(str(tmp_path))
  181. trace = Trace(
  182. trace_id=f"trace-{sentinel}",
  183. mode="agent",
  184. agent_role="worker",
  185. context={"root_trace_id": "root"},
  186. )
  187. await store.create_trace(trace)
  188. class Coordinator:
  189. async def task_context(self, root_trace_id):
  190. assert root_trace_id == "root"
  191. return '{"state_revision":"ledger:latest"}'
  192. runner = AgentRunner(trace_store=store, task_coordinator=Coordinator())
  193. config = RunConfig(
  194. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  195. protected_context_refresh={"role_context": {"sentinel": sentinel}},
  196. )
  197. context = await runner._current_execution_context(trace.trace_id, None, config)
  198. assert "ledger:latest" in context
  199. assert sentinel in context
  200. rebuilt = runner._rebuild_history_after_compression(
  201. [
  202. {"role": "system", "content": "policy"},
  203. {"role": "user", "content": "old attempt bootstrap"},
  204. ],
  205. {"role": "user", "content": f"summary\n\n{context}"},
  206. )
  207. assert sentinel in rebuilt[-1]["content"]
  208. @pytest.mark.parametrize(
  209. "kwargs",
  210. [
  211. {"trigger_ratio": 0.5, "target_ratio": 0.5},
  212. {"trigger_ratio": 1.0},
  213. {"fallback_safety_factor": 0.9},
  214. {"max_tokens": -1},
  215. ],
  216. )
  217. def test_compression_config_rejects_unsafe_budgets(kwargs):
  218. with pytest.raises(ValueError):
  219. CompressionConfig(**kwargs)
  220. def test_compression_summary_forbids_repeating_successful_immutable_reads():
  221. summary = build_summary_header(
  222. "The accepted artifact was read; next save the candidate."
  223. )
  224. assert "不要因为原文已压缩而用相同参数重复读取" in summary
  225. assert "直接执行摘要所列的下一个" in summary