test_context_budget.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. assert max_prompt_tokens < 100_000
  141. assert total_prompt_tokens < 6_327_689 * 0.50
  142. usage = runner.get_context_usage(trace.trace_id)
  143. assert usage.compression_count >= 1
  144. assert usage.actual_prompt_tokens is not None
  145. @pytest.mark.asyncio
  146. async def test_oversized_minimum_context_fails_before_model_request(tmp_path):
  147. calls = 0
  148. async def llm_call(**_kwargs):
  149. nonlocal calls
  150. calls += 1
  151. return {"content": "must not be called", "tool_calls": None}
  152. store = FileSystemTraceStore(str(tmp_path))
  153. result = await AgentRunner(
  154. trace_store=store,
  155. llm_call=llm_call,
  156. ).run_result(
  157. [{"role": "user", "content": "x" * 8_000}],
  158. RunConfig(
  159. max_iterations=4,
  160. tools=[],
  161. tool_groups=[],
  162. knowledge=_knowledge_off(),
  163. compression=CompressionConfig(max_tokens=1_000),
  164. ),
  165. )
  166. assert calls == 0
  167. assert result["status"] == "failed"
  168. assert result["failure"]["code"] == "CONTEXT_BUDGET_EXCEEDED"
  169. events = await store.get_events(result["trace_id"])
  170. assert "context_budget_exceeded" in [event["event"] for event in events]
  171. @pytest.mark.asyncio
  172. @pytest.mark.parametrize("sentinel", ["worker-latest", "validator-latest", "repair-latest"])
  173. async def test_compression_refreshes_latest_protected_role_context(tmp_path, sentinel):
  174. store = FileSystemTraceStore(str(tmp_path))
  175. trace = Trace(
  176. trace_id=f"trace-{sentinel}",
  177. mode="agent",
  178. agent_role="worker",
  179. context={"root_trace_id": "root"},
  180. )
  181. await store.create_trace(trace)
  182. class Coordinator:
  183. async def task_context(self, root_trace_id):
  184. assert root_trace_id == "root"
  185. return '{"state_revision":"ledger:latest"}'
  186. runner = AgentRunner(trace_store=store, task_coordinator=Coordinator())
  187. config = RunConfig(
  188. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  189. protected_context_refresh={"role_context": {"sentinel": sentinel}},
  190. )
  191. context = await runner._current_execution_context(trace.trace_id, None, config)
  192. assert "ledger:latest" in context
  193. assert sentinel in context
  194. rebuilt = runner._rebuild_history_after_compression(
  195. [
  196. {"role": "system", "content": "policy"},
  197. {"role": "user", "content": "old attempt bootstrap"},
  198. ],
  199. {"role": "user", "content": f"summary\n\n{context}"},
  200. )
  201. assert sentinel in rebuilt[-1]["content"]
  202. @pytest.mark.parametrize(
  203. "kwargs",
  204. [
  205. {"trigger_ratio": 0.5, "target_ratio": 0.5},
  206. {"trigger_ratio": 1.0},
  207. {"fallback_safety_factor": 0.9},
  208. {"max_tokens": -1},
  209. ],
  210. )
  211. def test_compression_config_rejects_unsafe_budgets(kwargs):
  212. with pytest.raises(ValueError):
  213. CompressionConfig(**kwargs)
  214. def test_compression_summary_forbids_repeating_successful_immutable_reads():
  215. summary = build_summary_header(
  216. "The accepted artifact was read; next save the candidate."
  217. )
  218. assert "不要因为原文已压缩而用相同参数重复读取" in summary
  219. assert "直接执行摘要所列的下一个" in summary