From 1a51959e2e2f920a3eacb72a1b41cd834fac3032 Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Mon, 12 Oct 2026 11:15:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(compaction):=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=95=95=EC=B6=95=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1=20(4.89.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한국어 과소 추정, provider 넘침 무대응, 요약 실패 시 이력 삭제, 기록 전 압축된 메시지 유실, 창과 무관한 도구 결과 상한, 이번 질문을 자르던 입력 맞춤을 고친다. - 추정 하나(core.text_tokens) + provider 보고 토큰 기준점, 보정 0.9~2.5 - 80% 문턱 → 50% 목표, 정리 먼저, 요약은 요청당 1번·턴당 6번, 작은 구간은 건너뜀 - Stage 6 넘침 시 강한 압축 후 1회 재시도, 오류의 실제 한도로 창 교정 - 체크포인트 요약(6항목, 도구 포함), 사용자 말 원문 보존, 실패 시 약한 압박은 그대로 - 압축으로 빠진 미기록 메시지를 STM·아카이브가 먼저 기록 - 도구 결과 창 25% 상한, CLI 이력 클램프, 메모리 노드 문맥 연결, 주입 상한 창 비례 - 창 표 갱신(Claude 1M, gpt-5 272k, Bedrock·Azure·Gemma), Claude Code argv 128KiB 회피 - /compact 를 실제 압축 요청으로 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 41 ++ docs/events.md | 51 +- pyproject.toml | 2 +- src/xgen_agent_runtime/core/compaction.py | 439 +++++++++++--- src/xgen_agent_runtime/core/context_prune.py | 76 ++- src/xgen_agent_runtime/core/errors.py | 77 +++ src/xgen_agent_runtime/core/state.py | 5 + src/xgen_agent_runtime/core/text_tokens.py | 203 +++++++ src/xgen_agent_runtime/core/token_estimate.py | 327 ++++++++-- src/xgen_agent_runtime/events/catalog.py | 35 +- src/xgen_agent_runtime/host/context_budget.py | 174 +++++- .../host/conversation_archive.py | 10 +- src/xgen_agent_runtime/host/memory.py | 15 + src/xgen_agent_runtime/host/runner.py | 48 +- src/xgen_agent_runtime/host/token_budget.py | 88 ++- .../host/token_estimator.py | 114 +--- src/xgen_agent_runtime/host/turn_executor.py | 144 +++-- .../llm_client/anthropic.py | 17 +- src/xgen_agent_runtime/llm_client/base.py | 12 + .../llm_client/claude_code.py | 46 ++ src/xgen_agent_runtime/llm_client/google.py | 19 +- src/xgen_agent_runtime/llm_client/openai.py | 14 +- .../llm_client/translators/_cli.py | 49 +- src/xgen_agent_runtime/memory/retriever.py | 16 +- src/xgen_agent_runtime/memory/strategy.py | 13 +- .../slash_commands/built_in/compact.py | 54 +- .../artifact/default/compactors.py | 572 +++++++++++++++--- .../s02_context/artifact/default/stage.py | 107 +++- .../s04_guard/artifact/default/stage.py | 21 +- .../stages/s06_api/artifact/default/stage.py | 121 +++- .../s06_api/artifact/default/tool_loop.py | 14 +- .../stages/s10_tool/persistence.py | 55 +- .../s18_memory/artifact/default/stage.py | 12 +- tests/unit/test_aux_usage.py | 9 +- tests/unit/test_budget_compaction_recovery.py | 62 +- tests/unit/test_compaction_policy_489.py | 484 +++++++++++++++ tests/unit/test_compaction_stm_watermark.py | 31 +- tests/unit/test_compaction_toggle.py | 37 +- tests/unit/test_llm_summary_compactor.py | 9 +- .../unit/test_multi_dim_budget_controller.py | 20 +- tests/unit/test_slash_built_in_control.py | 45 +- tests/unit/test_ttft_critical_path.py | 25 +- 42 files changed, 3068 insertions(+), 645 deletions(-) create mode 100644 src/xgen_agent_runtime/core/text_tokens.py create mode 100644 tests/unit/test_compaction_policy_489.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f08130..08692609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [4.89.0] - 2026-10-12 + +### Changed: 컨텍스트 압축을 다시 짰다 (언제, 얼마나, 무엇을 남기는지) + +2026-10-11 검토에서 압축이 너무 늦게 돌거나(한국어 과소 추정), 돌아도 대화를 잃거나(요약 실패 시 이력 삭제), +provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾았다. Codex · Claude Code 의 방식(provider 가 센 +토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. + +- **추정 하나** (`core.text_tokens`): 압축 판단과 호출 전 입력 맞춤이 같은 글자 부류 기반 추정을 쓴다. 예전 압축 쪽은 + 4자당 1토큰이라 한국어를 2~3배 적게 셌다. 성공한 호출마다 provider 가 보고한 입력 토큰을 기준점으로 삼고 + (`record_prompt_usage`, 캐시 읽기·쓰기 포함), 그 뒤에 붙은 것만 추정한다. 보정 비율은 0.9~2.5 로 묶는다. +- **문턱과 목표** (`core.compaction`): 다음 요청이 창의 80% 를 넘으면 50% 까지 줄인다. 먼저 결정적 정리(오래된 도구 + 결과·도구 호출 인자·이미지·PDF)로, 모자라면 모델 요약으로. 요약은 요청 하나에 한 번, 턴당 6번까지이고, 요약할 + 구간이 창의 5%(작은 창은 최대 15%, 2,000토큰 바닥)보다 작으면 하지 않는다. 턴 계수는 `begin_turn` 이 비운다. +- **넘침 복구** (Stage 6): provider 가 창을 넘었다고 거절하면(OpenAI · vLLM · Gemini 400 도 이제 `TOKEN_LIMIT`) + 강하게 압축하고 한 번 다시 보낸다. 오류에 적힌 실제 한도가 믿던 창보다 작으면 그 턴의 창을 그 값으로 바꾼다. +- **Stage 4**: Stage 3 이 시스템 프롬프트와 도구를 붙인 뒤라 첫 반복의 추정이 완전한 첫 지점이다. 여기서도 80% 를 본다. + guard 회복은 창의 50% 를 목표로 한다. +- **요약 체크포인트** (`LLMSummaryCompactor`): 앞부분을 `[Context checkpoint]` 메시지 하나로 바꾼다. 요약은 사용자 + 요청 · 한 일 · 결정 · 문제 · 남은 일 · 현재 상태 여섯 항목이고, 도구 호출과 결과까지 읽는다(예전에는 글 블록만, 그것도 + 앞 12,000자만). 사용자가 한 말은 창의 8% 안에서 그대로 붙이고, 지금 하던 요청도 그대로 붙인다. 이전 체크포인트는 + 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 두고(예전에는 지웠다), 강한 압박(guard · 넘침)에서만 + 결정적 다이제스트로 줄인다. 턴당 요약을 다 쓴 강한 압박도 다이제스트로 줄인다. +- **기억 기록**: 기록되기 전에 요약으로 빠진 메시지를 STM 기록 · 대화 아카이브가 먼저 기록한다 + (`memory.compacted_unrecorded`, 기록기마다 최대 300개). 워터마크가 없던 기록기는 0 으로 본다. +- **도구 결과 상한**: 도구별 상한(기본 10만 자)과 별개로 창의 25% 를 넘지 못한다. 저장소가 없으면 앞뒤만 남긴다. +- **호스트 입력 맞춤** (`fit_input_to_budget`): API 백엔드는 이력을 요약 바닥(5,000토큰)만큼만 세어, 압축할 수 있는 + 지난 대화 때문에 이번 질문을 자르지 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(200k, `[1m]` 은 1M, codex 272k) + 으로 맞추고, 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 보내는 것(core + 열린 도구)만 센다. +- **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 시스템 프롬프트에 붙인다 + (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 창의 25% 안으로 줄인다(가장 최근 쪽이 남는다). +- **내장 메모리 주입**: 주입 상한(기본 10,000자)을 창의 10% 로 묶고, 입력 맞춤의 예약도 같은 값에서 계산한다. + 자동 검색 층은 압축 기록(`compactions`)을 올리지 않는다. +- **창 표**: Claude Opus 4.6 이후 · Sonnet 4.6 이후 · Haiku 5.5 · Fable · Mythos 1M, gpt-5 계열 272k(입력 한도), + gpt-5.5 · gpt-6 922k, Gemma, Bedrock 의 Llama · Nova(Claude 밖 Bedrock 기본 32k), Azure 기본 128k, claude_code · codex. + 짧은 접두가 엉뚱한 이름에 걸리지 않게 이름 앞이나 구분자 뒤에서만 맞춘다. +- **Claude Code argv**: 128KiB 를 넘는 시스템 프롬프트는 `--system-prompt-file` 로, 넘는 프롬프트는 stream-json stdin 으로 보낸다. +- **`/compact`**: 상태에 수동 압축 요청을 남기고 Stage 2 가 다음 패스에서 문턱과 무관하게 요약한다(예전 명령은 아무 + 파이프라인도 등록하지 않는 전략을 찾아 늘 실패했다). +- 새 사건: `context.overflow` · `context.compaction_skipped` · `context.compaction_deferred`. + ## [4.88.0] - 2026-10-11 ### Added — 호스트가 이번 턴 안내를 턴 노트로 넘기는 선택 훅 `turn_notes()` diff --git a/docs/events.md b/docs/events.md index f1ff6933..50953b41 100644 --- a/docs/events.md +++ b/docs/events.md @@ -3,8 +3,8 @@ -> Generated from `xgen_agent_runtime.events.catalog` on 2026-10-11. -> Catalogue version: **17** · events: **140** +> Generated from `xgen_agent_runtime.events.catalog` on 2026-10-12. +> Catalogue version: **17** · events: **143** Every event name the engine emits, value == wire string. The enum is a *names registry*, not a rename — consumers matching raw strings @@ -297,11 +297,14 @@ Enum member: `EventTypes.CONTEXT_COMPACTED` | Field | Description | |---|---| -| `strategy` | str — compactor name/class | -| `trigger` | str? — proactive \| requested \| guard \| internal_loop \| background | +| `strategy` | str — compactor name/class, or 'prune' when the deterministic pass alone met the target | +| `trigger` | str? — proactive \| requested \| manual \| guard \| overflow \| internal_loop \| background | | `messages_before` | int? | | `messages_after` | int? | | `saved_tokens_estimate` | int? | +| `tokens_after_estimate` | int? — projected prompt tokens after the relief | +| `target_tokens` | int? — where the relief aimed (50% of the window by default) | +| `target_met` | bool? | ### `context.pruned` @@ -310,10 +313,12 @@ Enum member: `EventTypes.CONTEXT_PRUNED` | Field | Description | |---|---| | `deduped` | int — duplicate tool results rewritten to a back-reference | -| `images_stripped` | int — stale base64 images replaced with a marker | +| `images_stripped` | int — stale base64 images/documents replaced with a marker | | `trimmed` | int — oversized stale tool results shortened | +| `inputs_trimmed` | int — oversized stale tool-call arguments shortened | | `chars_saved` | int — text chars removed (images excluded) | -| `trigger` | str? — cost (Stage 2 token threshold) \| absent when run inside compaction | +| `aggressive` | bool? — the hard-pressure profile (guard / overflow) | +| `trigger` | str? — cost (Stage 2 token threshold) \| the compaction trigger it ran under | | `threshold_tokens` | int? — the prune_over_tokens threshold that fired (cost trigger) | | `tokens_before` | int? — projected prompt tokens before the pass (cost trigger) | | `tokens_after` | int? — projected prompt tokens after the pass (cost trigger) | @@ -375,6 +380,40 @@ Enum member: `EventTypes.CONTEXT_COMPACTION_TARGET_MISSED` | `tokens_after_estimate` | int | | `target_tokens` | int | +### `context.compaction_skipped` + +Enum member: `EventTypes.CONTEXT_COMPACTION_SKIPPED` + +| Field | Description | +|---|---| +| `strategy` | str — compactor name/class | +| `trigger` | str | +| `reason` | str — turn_compaction_limit \| already_summarized_for_this_request \| nothing_to_summarize \| summary_region_too_small | +| `tokens_estimate` | int? | +| `target_tokens` | int | + +### `context.compaction_deferred` + +Enum member: `EventTypes.CONTEXT_COMPACTION_DEFERRED` + +| Field | Description | +|---|---| +| `compactor` | str | +| `reason` | str — why no summary was produced (soft pressure leaves the history unchanged) | +| `attempts` | int — model calls tried | + +### `context.overflow` + +Enum member: `EventTypes.CONTEXT_OVERFLOW` + +| Field | Description | +|---|---| +| `message` | str — the provider's error, first 500 chars | +| `reported_tokens` | int — prompt size the provider named, 0 when it named none | +| `estimated_tokens` | int — our estimate for the rejected request | +| `window` | int — the window we believed | +| `provider_limit` | int — window the provider named, 0 when none (used for the rest of the turn when smaller) | + ### `context.short_term_window` Enum member: `EventTypes.CONTEXT_SHORT_TERM_WINDOW` diff --git a/pyproject.toml b/pyproject.toml index 892b1d77..8cbdd04e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xgen-agent-runtime" -version = "4.88.0" +version = "4.89.0" description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API" readme = "README.md" license = "Apache-2.0" diff --git a/src/xgen_agent_runtime/core/compaction.py b/src/xgen_agent_runtime/core/compaction.py index 604bd848..aa71d5d0 100644 --- a/src/xgen_agent_runtime/core/compaction.py +++ b/src/xgen_agent_runtime/core/compaction.py @@ -1,28 +1,85 @@ -"""Shared compaction runner — one place that runs a compactor, emits a -uniform event, and persists the snapshot to a memory provider. - -Both the Stage 2 proactive trigger (context near 80%) and the Stage 4 -reactive token-budget guard (context near the hard ceiling) compact the -SAME ``state.messages`` with the SAME compactor instance. Centralising -"compact + record" here guarantees they log and persist identically, and -that the snapshot is never written twice (a host wrapper that records its -own snapshot sets ``compactor.persists_own_compaction = True``). +"""Shared compaction runner and pressure policy: the ONE place that decides whether, and how, the +conversation is shrunk. + +Every trigger goes through :func:`run_compaction`: + +* Stage 2 *proactive* (start of an iteration, estimate above :data:`PROACTIVE_RATIO` of the window), +* Stage 4 *proactive* (after Stage 3 built the system prompt and tool list: the only point where the + first iteration's estimate is complete) and the Stage 4 *guard* (the next request would not leave + room for the answer), +* Stage 6 *overflow* (the provider rejected the request as too long: compact and retry once), +* Stage 16 *requested* and the ``/compact`` command. + +What the runner guarantees (4.89.0) +----------------------------------- +* **Cheap first.** The deterministic prune (dedupe repeated tool output, drop stale images/PDFs, + trim stale large results and tool inputs) runs before any model call. Under hard pressure it + runs again with a smaller protected tail. +* **One summary per request.** Stage 2, Stage 4 and the guard can all see pressure before the same + API call; only the first may summarize. A second summary of ``[summary, ack, two messages]`` + removes nothing and costs a model call (2026-10-12: observed re-summarizing every iteration). +* **No pointless summaries.** A compactor that can say what it would summarize + (``plan(state, target_tokens)``) is skipped when that region is too small to matter, unless the + pressure is hard. +* **A ceiling per turn.** At most :data:`MAX_LLM_COMPACTIONS_PER_TURN` summaries per turn; past that + only the deterministic prune runs and the guard decides. +* **Nothing summarized is forgotten by memory.** Messages that leave ``state.messages`` before the + turn's recorders ran (STM, conversation archive) are kept in ``state.metadata`` and recorded + first, in order. Synthetic summary messages are never recorded as if the user had said them. """ from __future__ import annotations import logging -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from xgen_agent_runtime.core.state import PipelineState from xgen_agent_runtime.core.token_estimate import estimate_prompt_tokens logger = logging.getLogger(__name__) +#: Proactive trigger: compact when the next request is estimated above this share of the window. +PROACTIVE_RATIO = 0.8 +#: Where compaction aims. Low enough that the next compaction is many tool rounds away: compacting +#: to just under the trigger made every few calls compact again, and every compaction loses detail. +TARGET_RATIO = 0.5 +#: Summaries allowed in one turn. A turn that needs more is refilling as fast as it is compacted; +#: further summaries only pile summaries on summaries. +MAX_LLM_COMPACTIONS_PER_TURN = 6 +#: A summarizable region smaller than this share of the window is not worth a model call. +MIN_SUMMARY_REGION_RATIO = 0.05 +#: ...nor one below this many tokens: on a small window at most :data:`_MIN_REGION_WINDOW_CAP` of +#: it, or an 8k model could never summarize at all and every relief would wait for the guard. +MIN_SUMMARY_REGION_TOKENS = 2_000 +_MIN_REGION_WINDOW_CAP = 0.15 + + +def min_summary_region(window: int) -> int: + """Smallest summarizable region (tokens) worth a model call for this window.""" + window = max(0, int(window)) + return max( + int(window * MIN_SUMMARY_REGION_RATIO), + min(MIN_SUMMARY_REGION_TOKENS, int(window * _MIN_REGION_WINDOW_CAP)), + ) + + +#: ``state.shared`` slots (strings are the contract). +#: Number of API calls Stage 6 has completed in this turn (advanced by :func:`note_api_call`). +API_CALLS_KEY = "_context_api_calls" +#: Value of :data:`API_CALLS_KEY` when the last model summary ran: "already summarized for this call". +SUMMARIZED_AT_KEY = "_context_summarized_at_call" +#: Model summaries run in this turn. +LLM_COMPACTIONS_KEY = "_context_llm_compactions" +#: The compactor reads the current request here: ``{"trigger", "target_tokens", "hard"}``. +COMPACTION_CONTEXT_KEY = "_context_compaction_request" + #: Stage-18's STM-recording watermark (index into ``state.messages``). #: Duplicated here (not imported from s18) so the core has no stage dep; #: the string is the contract. _STATE_LAST_RECORDED = "memory.last_recorded_idx" +_STRATEGY_RECORDED = "memory.provider_strategy_recorded_idx" +_ARCHIVED = "geny_bridge.conversation_archived_idx" +_WINDOW_LEN = "memory.short_term_window_len" #: EVERY metadata key that holds an index into ``state.messages`` and therefore #: stops meaning what it says the moment compaction shrinks that list. One key @@ -34,43 +91,49 @@ RECORDED_INDEX_KEYS = ( _STATE_LAST_RECORDED, # ProviderDrivenStrategy / host history preload / Stage 2 short-term window. - "memory.provider_strategy_recorded_idx", + _STRATEGY_RECORDED, # Host conversation rollup (``ConversationArchivingStrategy``). - "geny_bridge.conversation_archived_idx", + _ARCHIVED, # Window length — the rollup reads it as its watermark default, so it is an # index in every way that matters here. - "memory.short_term_window_len", + _WINDOW_LEN, ) +#: ``state.metadata`` slot: messages compaction removed before they were recorded, +#: per recorder (``"stm"`` / ``"archive"``), oldest first. +PENDING_RECORDS_KEY = "memory.compacted_unrecorded" +RECORDER_STM = "stm" +RECORDER_ARCHIVE = "archive" +#: Newest messages kept per recorder. A host with no recorder (memory off) never takes them, and a +#: state reused across many compactions must not grow without bound. +PENDING_RECORDS_CAP = 300 + + +def _int(value: Any) -> int: + try: + return max(0, int(value or 0)) + except (TypeError, ValueError): + return 0 + def reconcile_recorded_index(before: List[Any], after: List[Any], metadata: dict) -> None: - """Translate EVERY message-index watermark across a compaction (audit D3). - - Stage 18 records ``state.messages[last_idx:]`` as STM turns and sets - ``last_idx = len(messages)``. Compaction shrinks ``state.messages``, - so a watermark of 60 against a now-15-long list makes - ``messages[60:]`` empty forever — every subsequent turn silently - stops being recorded until the list regrows past 60. - - Four keys carry such an index (:data:`RECORDED_INDEX_KEYS`) and all four - are translated together. Translating only one of them is the same bug - wearing a different key. - - Compactors keep a SUFFIX of the real messages (the same dict objects, - by identity) and prepend synthetic summary messages. We find that - kept suffix by identity and remap the watermark so already-recorded - messages stay recorded and the genuinely-new tail still gets picked - up next turn. Pure index arithmetic on object identity — no message - is mutated. - """ - if not any( - isinstance(metadata.get(key), int) and metadata.get(key, 0) > 0 - for key in RECORDED_INDEX_KEYS - ): - return # nothing recorded yet — nothing to translate + """Translate every message-index watermark across a compaction (audit D3), and keep what the + recorders have not seen yet. + + Stage 18 records ``state.messages[last_idx:]`` as STM turns and the host archive records + ``messages[archived_idx:]``. Compactors keep a SUFFIX of the real messages (the same dict + objects) and prepend synthetic summary messages. We find that kept suffix by identity, then for + each recorder: - # Longest suffix of ``after`` whose objects are the trailing objects - # of ``before`` (by identity) is the kept region. + * messages between its watermark and the start of the kept suffix were summarized away before + it recorded them: they go to :data:`PENDING_RECORDS_KEY` and are recorded first; + * its watermark moves past the synthetic head (a summary is not something the user said) and + keeps pointing at the same kept message otherwise. + + A recorder that has not recorded anything yet (no watermark) is treated as watermark 0. + Before 4.89.0 such a recorder was left at 0 and later recorded the synthetic summary as a + user turn, while the summarized messages were never recorded at all. + """ before_ids = [id(m) for m in before] after_ids = [id(m) for m in after] kept = 0 @@ -81,19 +144,79 @@ def reconcile_recorded_index(before: List[Any], after: List[Any], metadata: dict ai -= 1 start = len(before) - kept # first before-index that survived n_synthetic = len(after) - kept # summary messages prepended + if start == 0 and n_synthetic == 0: + return # nothing changed - for key in RECORDED_INDEX_KEYS: - old_idx = metadata.get(key) - if not isinstance(old_idx, int) or old_idx <= 0: - continue + def _translate(old_idx: int) -> int: if old_idx <= start: - # Recorded boundary sits entirely in the summarized region: the - # kept suffix was never recorded, so record all of it next turn. - new_idx = n_synthetic - else: - # Boundary lands inside the kept suffix: shift by the prefix delta. - new_idx = n_synthetic + (old_idx - start) - metadata[key] = max(0, min(new_idx, len(after))) + return n_synthetic + return n_synthetic + (old_idx - start) + + pending: Dict[str, List[Any]] = metadata.get(PENDING_RECORDS_KEY) or {} + if not isinstance(pending, dict): + pending = {} + + stm_old = max(_int(metadata.get(_STATE_LAST_RECORDED)), _int(metadata.get(_STRATEGY_RECORDED))) + archive_old = _int(metadata.get(_ARCHIVED, metadata.get(_WINDOW_LEN, 0))) + for recorder, old_idx in ((RECORDER_STM, stm_old), (RECORDER_ARCHIVE, archive_old)): + if old_idx < start: + lost = [m for m in before[old_idx:start] if isinstance(m, dict)] + if lost: + pending[recorder] = (list(pending.get(recorder) or []) + lost)[ + -PENDING_RECORDS_CAP: + ] + if pending: + metadata[PENDING_RECORDS_KEY] = pending + + stm_new = max(0, min(_translate(stm_old), len(after))) + metadata[_STATE_LAST_RECORDED] = stm_new + metadata[_STRATEGY_RECORDED] = stm_new + metadata[_ARCHIVED] = max(0, min(_translate(archive_old), len(after))) + window_len = _int(metadata.get(_WINDOW_LEN)) + if window_len > 0: + metadata[_WINDOW_LEN] = max(0, min(_translate(window_len), len(after))) + + +def take_pending_records(state: Any, recorder: str) -> List[Any]: + """Messages compaction removed before ``recorder`` saw them: returned once, then forgotten.""" + metadata = getattr(state, "metadata", None) + if not isinstance(metadata, dict): + return [] + pending = metadata.get(PENDING_RECORDS_KEY) + if not isinstance(pending, dict): + return [] + out = pending.pop(recorder, None) or [] + if not pending: + metadata.pop(PENDING_RECORDS_KEY, None) + return [m for m in out if isinstance(m, dict)] + + +def peek_pending_records(state: Any, recorder: str) -> List[Any]: + """Like :func:`take_pending_records` but leaves them in place.""" + metadata = getattr(state, "metadata", None) + pending = metadata.get(PENDING_RECORDS_KEY) if isinstance(metadata, dict) else None + if not isinstance(pending, dict): + return [] + return [m for m in (pending.get(recorder) or []) if isinstance(m, dict)] + + +def note_api_call(state: Any) -> None: + """Stage 6: one more request went out. Advances the per-call compaction marker.""" + shared = getattr(state, "shared", None) + if isinstance(shared, dict): + shared[API_CALLS_KEY] = _int(shared.get(API_CALLS_KEY)) + 1 + + +def summarized_for_this_call(state: Any) -> bool: + """True when a model summary already ran before the upcoming request.""" + shared = getattr(state, "shared", None) + if not isinstance(shared, dict) or SUMMARIZED_AT_KEY not in shared: + return False + return shared.get(SUMMARIZED_AT_KEY) == _int(shared.get(API_CALLS_KEY)) + + +def window_of(state: Any) -> int: + return _int(getattr(state, "context_window_budget", 0)) or 200_000 def _compactor_name(compactor: Any) -> str: @@ -119,6 +242,51 @@ def _summary_text(state: PipelineState) -> str: return "" +def _prune(state: PipelineState, *, aggressive: bool, trigger: str) -> Dict[str, int]: + """Deterministic pre-pass. In-place, count/order-preserving, pair-safe.""" + try: + from xgen_agent_runtime.core.context_prune import AGGRESSIVE, prune_messages + + metrics = prune_messages(state.messages or [], **(AGGRESSIVE if aggressive else {})) + except Exception: # noqa: BLE001 (relief, never a gate) + logger.debug("deterministic prune pass failed", exc_info=True) + return {} + if any(metrics.get(k) for k in ("deduped", "images_stripped", "trimmed", "inputs_trimmed")): + # The memo keys on list length + head/tail ids, which the prune preserves. + state.shared.pop("_prompt_tokens_memo", None) + state.add_event("context.pruned", dict(metrics, aggressive=aggressive, trigger=trigger)) + return metrics + + +def _llm_gate( + state: PipelineState, compactor: Any, *, trigger: str, target_tokens: Optional[int], hard: bool +) -> Optional[str]: + """Why a model-backed compactor must NOT run now (``None`` = go ahead).""" + if not getattr(compactor, "uses_llm", False): + return None + shared = state.shared + explicit = trigger in ("requested", "manual") + if not explicit and _int(shared.get(LLM_COMPACTIONS_KEY)) >= MAX_LLM_COMPACTIONS_PER_TURN: + return "turn_compaction_limit" + # Soft pressure only: a guard or a provider overflow after a summary means that summary was not + # enough for THIS request, and the request cannot go out as it is. + if not explicit and not hard and summarized_for_this_call(state): + return "already_summarized_for_this_request" + plan = getattr(compactor, "plan", None) + if callable(plan) and not hard and not explicit: + try: + outline = plan(state, target_tokens) + except Exception: # noqa: BLE001 (a planning bug must not stop compaction) + logger.debug("compactor plan failed", exc_info=True) + return None + if outline is None: + return "nothing_to_summarize" + region = _int(getattr(outline, "region_tokens", 0)) + if region < min_summary_region(window_of(state)): + return "summary_region_too_small" + return None + + async def run_compaction( state: PipelineState, compactor: Any, @@ -126,36 +294,98 @@ async def run_compaction( trigger: str, provider: Optional[Any] = None, target_tokens: Optional[int] = None, + hard: bool = False, ) -> dict: - """Run ``compactor`` against ``state`` and return a result summary dict. + """Relieve context pressure with ``compactor`` and return a result summary dict. + + ``target_tokens``: where the relief should land. With a target, a deterministic prune that + already reaches it ends the call (no model summary); ``None`` means the caller has decided + compaction is needed, so the compactor always runs (aiming at :data:`TARGET_RATIO` of the + window). ``requested``/``manual`` triggers always run the compactor too. + + ``hard`` marks pressure the request cannot survive (guard, provider overflow): the prune runs + aggressively, the small-region skip is off, and a compactor that cannot reach its model falls + back to a deterministic digest instead of leaving the history unchanged. - Emits a ``context.compacted`` event carrying ``trigger`` ("proactive" - from Stage 2, "guard" from Stage 4) and, when a provider exposes - ``record_compaction`` and the compactor does not persist its own - snapshot, records the snapshot to the provider's "compactions" - category. Never raises — compaction is best-effort relief, not a + Emits ``context.compacted`` (and ``context.compaction_skipped`` when the policy declined) and, + when a provider exposes ``record_compaction`` and the compactor does not persist its own + snapshot, records the snapshot. Never raises: compaction is best-effort relief, not a correctness gate; failures are logged as events and swallowed. """ before_list = list(state.messages or []) before_msgs = len(before_list) before_tokens = estimate_prompt_tokens(state) + prune_may_suffice = target_tokens is not None and trigger not in ("requested", "manual") + if target_tokens is None: + target_tokens = int(window_of(state) * TARGET_RATIO) - # Deterministic pre-pass BEFORE the (expensive, lossy) compactor: dedup - # repeated tool outputs, strip stale base64 images, trim oversized stale - # results. In-place, count/order-preserving, pair-safe — so the watermark - # reconcile below and the compactor itself see a normal message list. The - # token-estimate memo keys on list length + head/tail ids, which this - # pass preserves, so it must be dropped for after_tokens to be honest. - try: - from xgen_agent_runtime.core.context_prune import prune_messages + _prune(state, aggressive=False, trigger=trigger) + if hard and estimate_prompt_tokens(state) > target_tokens: + _prune(state, aggressive=True, trigger=trigger) - prune_metrics = prune_messages(state.messages or []) - if any(prune_metrics.get(k) for k in ("deduped", "images_stripped", "trimmed")): - state.shared.pop("_prompt_tokens_memo", None) - state.add_event("context.pruned", dict(prune_metrics)) - except Exception: # noqa: BLE001 — relief, never a gate - logger.debug("deterministic prune pass failed", exc_info=True) + after_prune = estimate_prompt_tokens(state) + if prune_may_suffice and after_prune <= target_tokens: + state.add_event( + "context.compacted", + { + "strategy": "prune", + "trigger": trigger, + "messages_before": before_msgs, + "messages_after": len(state.messages or []), + "saved_tokens_estimate": max(0, before_tokens - after_prune), + "tokens_after_estimate": after_prune, + "target_tokens": target_tokens, + "target_met": True, + }, + ) + return { + "ok": True, + "before_messages": before_msgs, + "after_messages": len(state.messages or []), + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - after_prune), + "after_tokens_estimate": after_prune, + "target_tokens": target_tokens, + "target_met": True, + "summarized": False, + } + + skip = _llm_gate(state, compactor, trigger=trigger, target_tokens=target_tokens, hard=hard) + digest_only = False + if skip == "turn_compaction_limit" and hard: + # Out of model summaries for this turn, yet the request cannot go out as it is: the + # compactor relieves it with its deterministic digest instead of a model call. + skip, digest_only = None, True + if skip is not None: + state.add_event( + "context.compaction_skipped", + { + "strategy": _compactor_name(compactor), + "trigger": trigger, + "reason": skip, + "tokens_estimate": after_prune, + "target_tokens": target_tokens, + }, + ) + return { + "ok": True, + "skipped": skip, + "before_messages": before_msgs, + "after_messages": len(state.messages or []), + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - after_prune), + "after_tokens_estimate": after_prune, + "target_tokens": target_tokens, + "target_met": after_prune <= target_tokens, + "summarized": False, + } + state.shared[COMPACTION_CONTEXT_KEY] = { + "trigger": trigger, + "target_tokens": target_tokens, + "hard": bool(hard), + "digest_only": digest_only, + } try: await compactor.compact(state) except Exception as exc: # noqa: BLE001 — best effort @@ -165,16 +395,29 @@ async def run_compaction( ) logger.warning("Compaction (%s) failed: %s", trigger, exc) return {"ok": False, "before_messages": before_msgs, "after_messages": before_msgs} + finally: + state.shared.pop(COMPACTION_CONTEXT_KEY, None) - # Keep Stage-18's STM watermark valid across the shrink (audit D3). - reconcile_recorded_index(before_list, list(state.messages or []), state.metadata) + after_list = list(state.messages or []) + changed = len(after_list) != before_msgs or any( + a is not b for a, b in zip(after_list, before_list) + ) + if changed: + # Keep every recorder's watermark valid across the shrink (audit D3) and remember what + # they have not recorded yet. + reconcile_recorded_index(before_list, after_list, state.metadata) + state.shared.pop("_prompt_tokens_memo", None) + if getattr(compactor, "uses_llm", False): + state.shared[SUMMARIZED_AT_KEY] = _int(state.shared.get(API_CALLS_KEY)) + if not digest_only: + state.shared[LLM_COMPACTIONS_KEY] = _int(state.shared.get(LLM_COMPACTIONS_KEY)) + 1 - after_msgs = len(state.messages or []) + after_msgs = len(after_list) after_tokens = estimate_prompt_tokens(state) replaced = max(0, before_msgs - after_msgs) saved_tokens = max(0, before_tokens - after_tokens) - target_met = target_tokens is None or after_tokens <= target_tokens + target_met = after_tokens <= target_tokens state.add_event( "context.compacted", { @@ -201,7 +444,8 @@ async def run_compaction( # Persist the snapshot unless the compactor already does it itself. if ( - replaced > 0 + changed + and replaced > 0 and provider is not None and not getattr(compactor, "persists_own_compaction", False) and hasattr(provider, "record_compaction") @@ -231,4 +475,49 @@ async def run_compaction( "after_tokens_estimate": after_tokens, "target_tokens": target_tokens, "target_met": target_met, + "summarized": bool(changed), } + + +async def relieve_if_over( + state: PipelineState, + compactor: Any, + *, + trigger: str, + provider: Optional[Any] = None, + ratio: float = PROACTIVE_RATIO, +) -> Optional[dict]: + """Proactive check shared by Stage 2 and Stage 4: compact when the estimate crosses ``ratio``.""" + if compactor is None: + return None + budget = window_of(state) + if estimate_prompt_tokens(state) <= budget * ratio: + return None + return await run_compaction( + state, + compactor, + trigger=trigger, + provider=provider, + target_tokens=int(budget * TARGET_RATIO), + ) + + +__all__ = [ + "PROACTIVE_RATIO", + "TARGET_RATIO", + "MAX_LLM_COMPACTIONS_PER_TURN", + "RECORDED_INDEX_KEYS", + "PENDING_RECORDS_KEY", + "RECORDER_STM", + "RECORDER_ARCHIVE", + "PENDING_RECORDS_CAP", + "min_summary_region", + "reconcile_recorded_index", + "take_pending_records", + "peek_pending_records", + "note_api_call", + "summarized_for_this_call", + "run_compaction", + "relieve_if_over", + "window_of", +] diff --git a/src/xgen_agent_runtime/core/context_prune.py b/src/xgen_agent_runtime/core/context_prune.py index 2aa5662f..7d2dae67 100644 --- a/src/xgen_agent_runtime/core/context_prune.py +++ b/src/xgen_agent_runtime/core/context_prune.py @@ -61,7 +61,7 @@ import json from typing import Any, Dict, List -__all__ = ["DEFAULT_PRUNE_OVER_TOKENS", "prune_messages", "PruneMetrics"] +__all__ = ["DEFAULT_PRUNE_OVER_TOKENS", "AGGRESSIVE", "prune_messages", "PruneMetrics"] #: Stage 2 runs the prune when the projected prompt exceeds this many tokens, #: regardless of the context window. See the module docstring for the evidence. @@ -76,8 +76,24 @@ #: How much head text a trimmed output keeps. DEFAULT_TRIM_KEEP_CHARS = 600 +#: Stale tool-call INPUTS (Write/Edit file bodies, long commands) longer than this are trimmed too: +#: a 40 KB file body in an old ``Write`` call is resent on every request otherwise. +DEFAULT_INPUT_TRIM_OVER_CHARS = 4000 +DEFAULT_INPUT_TRIM_KEEP_CHARS = 400 + +#: Settings for HARD pressure (the guard or a provider overflow: the request cannot go out as is): +#: protect only the last exchange and trim harder. Still deterministic, still pair-safe. +AGGRESSIVE = { + "protect_last": 2, + "trim_over_chars": 1000, + "trim_keep_chars": 300, + "input_trim_over_chars": 1000, + "input_trim_keep_chars": 200, +} + _DUP_NOTE = "[duplicate tool output — identical to a more recent result]" _IMAGE_NOTE = "[image removed during context compaction]" +_DOCUMENT_NOTE = "[document removed during context compaction]" class PruneMetrics(Dict[str, int]): @@ -125,24 +141,57 @@ def _content_size(block: Dict[str, Any]) -> int: def _strip_images_in(blocks: List[Any]) -> int: - """Replace base64 image blocks with a small text marker, recursing into - tool_result content lists. Returns the number of images removed.""" + """Replace base64 image and document (PDF) blocks with a small text marker, + recursing into tool_result content lists. Returns the number removed.""" removed = 0 for i, b in enumerate(blocks): if not isinstance(b, dict): continue + btype = b.get("type") if ( - b.get("type") == "image" + btype in ("image", "document") and isinstance(b.get("source"), dict) and b["source"].get("type") == "base64" ): - blocks[i] = {"type": "text", "text": _IMAGE_NOTE} + blocks[i] = { + "type": "text", + "text": _IMAGE_NOTE if btype == "image" else _DOCUMENT_NOTE, + } removed += 1 - elif b.get("type") == "tool_result" and isinstance(b.get("content"), list): + elif btype == "tool_result" and isinstance(b.get("content"), list): removed += _strip_images_in(b["content"]) return removed +def _trim_tool_inputs(message: Dict[str, Any], over: int, keep: int) -> int: + """Shorten long string values inside stale ``tool_use`` inputs. Keeps the input a valid + object of the same keys (providers validate it), so pairing and schema shape survive. + Returns chars removed.""" + content = message.get("content") + if not isinstance(content, list): + return 0 + saved = 0 + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + params = block.get("input") + if not isinstance(params, dict): + continue + trimmed: Dict[str, Any] = {} + for key, value in params.items(): + if isinstance(value, str) and len(value) > over: + dropped = len(value) - keep + trimmed[key] = ( + value[:keep] + f"\n… [{dropped} chars trimmed during context compaction]" + ) + saved += dropped + if trimmed: + # A new dict, not an in-place edit: the same input object may still be held by the + # tool-call record or a rollout recorder that must keep what was actually sent. + block["input"] = {**params, **trimmed} + return saved + + def _trim_result(block: Dict[str, Any], keep: int) -> int: """Trim an oversized tool_result to its head + explicit marker. Returns chars removed.""" @@ -182,14 +231,16 @@ def prune_messages( min_dup_chars: int = DEFAULT_MIN_DUP_CHARS, trim_over_chars: int = DEFAULT_TRIM_OVER_CHARS, trim_keep_chars: int = DEFAULT_TRIM_KEEP_CHARS, + input_trim_over_chars: int = DEFAULT_INPUT_TRIM_OVER_CHARS, + input_trim_keep_chars: int = DEFAULT_INPUT_TRIM_KEEP_CHARS, ) -> PruneMetrics: """Run the deterministic prune over *messages* in place. - Returns metrics: ``deduped`` (results back-referenced), ``images_stripped``, - ``trimmed`` (oversized results shortened), ``chars_saved`` (text chars - removed, images excluded). + Returns metrics: ``deduped`` (results back-referenced), ``images_stripped`` (images and PDF + documents), ``trimmed`` (oversized results shortened), ``inputs_trimmed`` (stale tool calls + whose long input strings were shortened), ``chars_saved`` (text chars removed, images excluded). """ - metrics = PruneMetrics(deduped=0, images_stripped=0, trimmed=0, chars_saved=0) + metrics = PruneMetrics(deduped=0, images_stripped=0, trimmed=0, inputs_trimmed=0, chars_saved=0) if not messages: return metrics cutoff = max(0, len(messages) - max(0, protect_last)) @@ -224,6 +275,11 @@ def prune_messages( content = msg.get("content") if isinstance(content, list): metrics["images_stripped"] += _strip_images_in(content) + if msg.get("role") == "assistant": + saved_inputs = _trim_tool_inputs(msg, input_trim_over_chars, input_trim_keep_chars) + if saved_inputs > 0: + metrics["inputs_trimmed"] += 1 + metrics["chars_saved"] += saved_inputs for block in _iter_tool_results(msg): size = _content_size(block) if size > trim_over_chars: diff --git a/src/xgen_agent_runtime/core/errors.py b/src/xgen_agent_runtime/core/errors.py index 3b906656..cfffd36b 100644 --- a/src/xgen_agent_runtime/core/errors.py +++ b/src/xgen_agent_runtime/core/errors.py @@ -30,6 +30,7 @@ (no ``None`` checks needed downstream). """ +import re from enum import Enum from typing import Optional @@ -331,3 +332,79 @@ def __init__( ): super().__init__(message, code=code, cause=cause) self.stage_order = stage_order + + +# ─────────────────────────────────────────────── context overflow detection ─ + +#: Phrases providers use when the PROMPT is larger than the model accepts (lower-case). One list for +#: every client: before 4.89.0 only the Anthropic client recognised an overflow, so OpenAI / vLLM / +#: Azure / Gemini overflows were plain BAD_REQUEST and nothing tried to recover. +CONTEXT_OVERFLOW_PHRASES: tuple = ( + "maximum context length", # OpenAI chat, vLLM, most OpenAI-compatible servers + "context_length_exceeded", # OpenAI error code (Chat Completions / Responses) + "reduce the length of the messages", # OpenAI + "prompt is too long", # Anthropic + "exceed context limit", # Anthropic: "input length and `max_tokens` exceed context limit" + "input length exceeds", + "input is too long", # Bedrock + "exceeds the maximum number of tokens", # Gemini + "exceeds the context window", + "context window exceeded", + "longer than the model's context length", +) + + +def is_context_overflow_message(message: str) -> bool: + """True when an error message says the prompt does not fit the model's context window.""" + text = str(message or "").lower() + return any(phrase in text for phrase in CONTEXT_OVERFLOW_PHRASES) + + +_OVERFLOW_COUNT_PATTERNS = ( + # Anthropic: "prompt is too long: 210000 tokens > 200000 maximum" + re.compile(r"prompt is too long:\s*([\d,]+)\s*tokens", re.I), + # Anthropic: "input length and `max_tokens` exceed context limit: 190000 + 21333 > 200000" + re.compile(r"exceed context limit:\s*([\d,]+)\s*\+", re.I), + # OpenAI: "However, your messages resulted in 140000 tokens" + re.compile(r"messages resulted in\s*([\d,]+)\s*tokens", re.I), + # vLLM / OpenAI: "you requested 40000 tokens (32000 in the messages, 8000 in the completion)" + re.compile(r"\(([\d,]+)\s*in the messages", re.I), + # vLLM >= 0.6: "your request has 40000 input tokens" + re.compile(r"request has\s*([\d,]+)\s*input tokens", re.I), + # Gemini: "The input token count (1234567) exceeds the maximum number of tokens allowed" + re.compile(r"input token count\s*\(?\s*([\d,]+)", re.I), +) + + +_OVERFLOW_LIMIT_PATTERNS = ( + # Anthropic: "prompt is too long: 210000 tokens > 200000 maximum" + re.compile(r"tokens\s*>\s*([\d,]+)\s*maximum", re.I), + # Anthropic: "input length and `max_tokens` exceed context limit: 190000 + 21333 > 200000" + re.compile(r"exceed context limit:\s*[\d,]+\s*\+\s*[\d,]+\s*>\s*([\d,]+)", re.I), + # OpenAI / vLLM: "This model's maximum context length is 128000 tokens." + re.compile(r"maximum context length is\s*([\d,]+)", re.I), + # Gemini: "... exceeds the maximum number of tokens allowed (1048576)." + re.compile(r"maximum number of tokens allowed\s*\(?\s*([\d,]+)", re.I), +) + + +def _first_int(patterns: tuple, message: str) -> int: + text = str(message or "") + for pattern in patterns: + match = pattern.search(text) + if match: + try: + return int(match.group(1).replace(",", "")) + except ValueError: + continue + return 0 + + +def parse_overflow_tokens(message: str) -> int: + """The prompt size a provider reported in its overflow error, or 0 when it said none.""" + return _first_int(_OVERFLOW_COUNT_PATTERNS, message) + + +def parse_overflow_limit(message: str) -> int: + """The context window a provider named in its overflow error, or 0 when it named none.""" + return _first_int(_OVERFLOW_LIMIT_PATTERNS, message) diff --git a/src/xgen_agent_runtime/core/state.py b/src/xgen_agent_runtime/core/state.py index 279b8c82..93f1cca4 100644 --- a/src/xgen_agent_runtime/core/state.py +++ b/src/xgen_agent_runtime/core/state.py @@ -439,6 +439,11 @@ def begin_turn(self) -> None: # 사람이 거부한 동작(stages/s10_tool/denial_guard.py) — 그 턴의 답이다. 다음 턴에 # 사용자가 허락하면 다시 물을 수 있어야 한다. "tool.denied_calls", + # 압축 정책의 턴 단위 계수(core/compaction.py): 턴당 요약 상한과 "이번 요청에 이미 + # 요약했다" 표시. 남겨 두면 재사용 상태에서 여섯 번째 요약 뒤로는 guard 회복조차 못 한다. + "_context_llm_compactions", + "_context_summarized_at_call", + "_context_api_calls", ): self.shared.pop(_k, None) diff --git a/src/xgen_agent_runtime/core/text_tokens.py b/src/xgen_agent_runtime/core/text_tokens.py new file mode 100644 index 00000000..544a629e --- /dev/null +++ b/src/xgen_agent_runtime/core/text_tokens.py @@ -0,0 +1,203 @@ +"""텍스트 토큰 추정: 런타임의 모든 크기 판단이 쓰는 **한 함수**. + +압축 시점(Stage 2·4·6 의 판단), 호출 전 입력 맞춤(host), 도구 결과 상한이 모두 이 추정을 쓴다. +예전에는 압축 판단만 ``글자 수 ÷ 4`` 를 써서 한국어를 2~3배 적게 셌고, 그래서 80% 압축과 호출 +직전 확인이 제때 켜지지 않아 공급자가 먼저 "컨텍스트 초과" 로 거절했다(2026-10-12 조사). +같은 대화를 두 추정기가 다르게 세면 한쪽이 맞춘 크기를 다른 쪽이 넘쳤다고 본다. 그래서 하나다. + +**모델도 데이터 파일도 네트워크도 쓰지 않는다.** 문자를 부류로 나누고 연속 구간 단위로 센다 +(라틴은 여러 글자가 한 토큰, 한글은 음절마다, 숫자는 두세 자리씩). 계수는 실제 토크나이저와 +대조해 맞췄다(o200k 평균 절대 오차 9.0%, cl100k 8.7%). ``host.token_estimator`` 와 +``xgen_sdk.tokens`` 가 같은 함수다. 계수를 고치면 함께 고친다. + +공급자 보정계수: 토크나이저가 OpenAI 계열과 다른 공급자는 같은 글이 더 많은 토큰이 된다. +추정이 모자라면 공급자 400 으로 턴이 죽고, 넘치면 조금 일찍 압축할 뿐이라 **과대추정** 쪽으로 맞춘다. +""" + +from __future__ import annotations + +import unicodedata +from typing import Any + +__all__ = [ + "estimate_tokens", + "estimate_tokens_cached", + "HANGUL_MODERN", + "HANGUL_LEGACY", + "PROVIDER_TOKEN_FACTOR", + "DEFAULT_PROVIDER_FACTOR", + "SAFETY_MULTIPLIER", + "provider_token_factor", + "CONSERVATIVE_CHARS_PER_TOKEN", + "chars_for_tokens", +] + +#: 한글 음절당 토큰: 최신 토크나이저(o200k 계열). 기본값. +HANGUL_MODERN = 0.75 +#: 한글 음절당 토큰: 구형(cl100k 계열). +HANGUL_LEGACY = 1.28 + +#: 라틴 문자 연속 구간에서 토큰 하나가 먹는 글자 수(최소 1토큰). +_LATIN_CHARS_PER_TOKEN = 7.0 +#: 숫자 연속 구간: BPE 는 보통 두세 자리씩 묶는다. +_DIGIT_CHARS_PER_TOKEN = 3.0 +#: 한자·가나는 글자당 한 토큰에 가깝다. +_CJK_PER_CHAR = 1.0 +#: 공백은 **첫 칸이 뒤 단어에 붙으므로** 나머지만 센다. +_SPACE_PER_EXTRA = 0.25 +#: 줄바꿈은 대체로 한 토큰. +_NEWLINE_PER_CHAR = 1.0 +#: 문장부호·기호(ASCII)는 앞뒤와 자주 뭉친다. +_PUNCT_PER_CHAR = 0.6 +#: 이모지 등 비-ASCII 기호는 여러 바이트라 토큰을 더 먹는다. +_SYMBOL_PER_CHAR = 2.0 + +_SPACE = "sp" +_NEWLINE = "nl" +_DIGIT = "num" +_LATIN = "lat" +_HANGUL = "han" +_CJK = "cjk" +_PUNCT = "pun" +_SYMBOL = "sym" + + +def _class_of(ch: str) -> str: + """문자 하나의 부류. 여기서만 유니코드를 본다.""" + code = ord(ch) + if ch in " \t": + return _SPACE + if ch in "\r\n": + return _NEWLINE + if "0" <= ch <= "9": + return _DIGIT + if ("a" <= ch <= "z") or ("A" <= ch <= "Z"): + return _LATIN + # 한글 음절 + 자모 + if 0xAC00 <= code <= 0xD7A3 or 0x1100 <= code <= 0x11FF or 0x3130 <= code <= 0x318F: + return _HANGUL + # 한자 + if 0x4E00 <= code <= 0x9FFF or 0x3400 <= code <= 0x4DBF: + return _CJK + # 가나 + if 0x3040 <= code <= 0x30FF: + return _CJK + if code < 0x80: + return _PUNCT + # 키릴·그리스·아랍 등 다른 문자 체계는 라틴과 비슷하게 뭉친다. + if unicodedata.category(ch).startswith("L"): + return _LATIN + return _SYMBOL + + +def estimate_tokens(text: Any, *, hangul: float = HANGUL_MODERN) -> int: + """``text`` 의 토큰 수 추정(올림). 빈 값이면 0. + + 문자열이 아니면 ``str()`` 로 바꿔 센다. 호출부마다 변환을 되풀이하지 않게. + + Args: + hangul: 한글 음절당 토큰. 구형 계열이면 :data:`HANGUL_LEGACY`. + """ + if text is None: + return 0 + if not isinstance(text, str): + text = str(text) + if not text: + return 0 + + total = 0.0 + index = 0 + length = len(text) + while index < length: + kind = _class_of(text[index]) + end = index + 1 + while end < length and _class_of(text[end]) == kind: + end += 1 + run = end - index + + if kind == _LATIN: + total += max(1.0, run / _LATIN_CHARS_PER_TOKEN) + elif kind == _DIGIT: + total += max(1.0, run / _DIGIT_CHARS_PER_TOKEN) + elif kind == _HANGUL: + total += run * hangul + elif kind == _CJK: + total += run * _CJK_PER_CHAR + elif kind == _SPACE: + total += (run - 1) * _SPACE_PER_EXTRA + elif kind == _NEWLINE: + total += run * _NEWLINE_PER_CHAR + elif kind == _PUNCT: + total += run * _PUNCT_PER_CHAR + else: + total += run * _SYMBOL_PER_CHAR + + index = end + + return max(1, int(total + 0.5)) + + +# ───────────────────────────────────────────────────────────────────────── +# 공급자 보정: 과대추정 쪽(모자라면 400, 넘치면 조금 일찍 압축할 뿐) +# ───────────────────────────────────────────────────────────────────────── + +#: 공급자별 보정계수. o200k 계열(OpenAI)이 1.0 이다. +PROVIDER_TOKEN_FACTOR = { + "openai": 1.0, + "azure": 1.0, + "azure_foundry": 1.0, + "anthropic": 1.15, + "bedrock": 1.15, + "claude_code": 1.15, + "google": 1.20, + "vertex": 1.20, + "vllm": 1.20, +} +#: 모르는 공급자. +DEFAULT_PROVIDER_FACTOR = 1.20 +#: 모든 추정에 더하는 여유. +SAFETY_MULTIPLIER = 1.05 + + +def provider_token_factor(provider: Any) -> float: + """``provider`` 의 보정계수(여유 포함 전).""" + return PROVIDER_TOKEN_FACTOR.get(str(provider or "").lower().strip(), DEFAULT_PROVIDER_FACTOR) + + +#: 긴 글의 추정 캐시. 같은 글은 같은 값이다. (해시, 길이)로 찾는다. 파이썬 문자열은 해시를 객체에 +#: 저장하므로 두 번째부터는 상수 시간이다. 대화가 커지면 매 반복 같은 메시지를 다시 세던 비용이 사라진다. +_CACHE_MIN_CHARS = 512 +_CACHE_MAX_ENTRIES = 4096 +_cache: dict = {} + + +def estimate_tokens_cached(text: Any) -> int: + """:func:`estimate_tokens` 와 같은 값. 긴 글은 캐시에서 찾는다.""" + if text is None: + return 0 + if not isinstance(text, str): + text = str(text) + if len(text) < _CACHE_MIN_CHARS: + return estimate_tokens(text) if text else 0 + key = (hash(text), len(text)) + hit = _cache.get(key) + if hit is not None: + return hit + value = estimate_tokens(text) + if len(_cache) >= _CACHE_MAX_ENTRIES: + # 가장 오래 들어온 것부터 비운다(dict 는 넣은 순서를 지킨다). + for _ in range(_CACHE_MAX_ENTRIES // 4): + _cache.pop(next(iter(_cache))) + _cache[key] = value + return value + + +#: 글자 수 → 토큰 상한 환산에 쓰는 글자/토큰 비. 한국어는 음절당 0.75 토큰 × 보정 +#: 1.2 ≈ 글자당 0.95 토큰이라 글자 하나를 토큰 하나로 본다. "이 토큰 예산이면 몇 글자까지 넣어도 +#: 되나" 를 물을 때 쓴다(영어에는 넉넉하게 작은 값이다). +CONSERVATIVE_CHARS_PER_TOKEN = 1.0 + + +def chars_for_tokens(tokens: int) -> int: + """``tokens`` 토큰 예산에 안전하게 들어가는 글자 수(어느 언어든).""" + return max(0, int(int(tokens) * CONSERVATIVE_CHARS_PER_TOKEN)) diff --git a/src/xgen_agent_runtime/core/token_estimate.py b/src/xgen_agent_runtime/core/token_estimate.py index 0fb03d57..0bf73e7b 100644 --- a/src/xgen_agent_runtime/core/token_estimate.py +++ b/src/xgen_agent_runtime/core/token_estimate.py @@ -1,49 +1,122 @@ -"""Cheap, single-source token estimation for context-budget decisions. - -The Stage 2 *proactive* compaction trigger and the Stage 4 *reactive* -token-budget guard must share ONE notion of "how big is the next API -call" — otherwise compaction (which shrinks ``state.messages``) cannot -relieve a guard that measures something else. Before 2.5.0 the guard -read ``state.token_usage`` (session/turn-cumulative usage, which -compaction never lowers) and compared it against the per-call context -window, so a long tool-loop turn could trip the guard with no way for -compaction to help. This module is the shared estimator both stages now -use against ``state.context_window_budget``. - -The estimate is deliberately rough (≈4 chars/token, the heuristic Stage 2 -has always used) — it *gates compaction*, it does not bill. Image blocks -are counted at a flat per-image estimate rather than their base64 length: -a single 1568px screenshot is ~1.6k vision tokens but tens of thousands -of base64 characters, and counting the characters would trip compaction -on the first image. +"""Single-source estimate of the NEXT request's input size, for every context-budget decision. + +The Stage 2 proactive compaction trigger, the Stage 4 budget guard, the Stage 6 overflow recovery +and the Stage 16 token dimension all ask one question, "how big is the next API call?", and must +get ONE answer, otherwise compaction (which shrinks ``state.messages``) cannot relieve a check that +measures something else. + +How the answer is built (4.89.0) +-------------------------------- +1. **What the provider reported last.** After every successful call Stage 6 records the prompt + size the provider *counted* (:func:`record_prompt_usage`) together with an identity anchor of + the message list it sent. While that anchor still describes the live list (same head/tail + objects), the estimate is ``reported + estimate(everything that changed since)``. This is the + Codex CLI scheme: real tokenizer numbers for the bulk, an estimate only for the delta. +2. **Otherwise, a script-aware estimate.** :func:`xgen_agent_runtime.core.text_tokens.estimate_tokens` + counts character-class runs (Hangul per syllable, Latin per ~7 chars …), multiplied by the + provider factor and a safety margin. The old ``len(text) // 4`` under-counted Korean 2-3x, so the + 80% trigger and the guard fired after the provider had already rejected the request. +3. **Calibration.** Each reported/estimated pair yields a ratio; later pure estimates in the same + turn (e.g. right after a compaction, when no anchor applies) are scaled by it. + +Images count a flat 1,600 tokens (a full-size screenshot); a base64 PDF counts per page. Counting +base64 characters would trip compaction on the first screenshot. """ from __future__ import annotations -from typing import Any, List, Union +import base64 +import binascii +import re +from typing import Any, Dict, List, Optional, Union + +from xgen_agent_runtime.core.text_tokens import ( + SAFETY_MULTIPLIER, + estimate_tokens_cached, + provider_token_factor, +) # Anthropic vision tokens land roughly here for a typical full-size image. -# A flat estimate beats counting base64 characters (which over-counts by -# ~50x and would trip compaction on a single screenshot). _IMAGE_TOKEN_ESTIMATE = 1_600 -_CHARS_PER_TOKEN = 4 +#: One PDF page (its text plus the page image the provider renders): roughly 1.5k-3k tokens. +_PDF_PAGE_TOKEN_ESTIMATE = 2_500 +#: A document whose pages cannot be counted is never cheaper than one image. +_DOCUMENT_TOKEN_FLOOR = 1_600 +_PDF_PAGE_RE = re.compile(rb"/Type\s*/Page(?![A-Za-z])") +#: Image-ish block types across canonical / OpenAI-shaped content. +_IMAGE_TYPES = frozenset({"image", "image_url", "input_image"}) + +#: ``state.shared`` keys. Strings are the contract (hosts and tests may read them). +_ESTIMATE_MEMO_KEY = "_prompt_tokens_memo" +USAGE_ANCHOR_KEY = "_prompt_usage_anchor" +CALIBRATION_KEY = "_prompt_tokens_calibration" +#: Calibration is clamped: one odd report must not make every later estimate absurd. +_CALIBRATION_MIN = 0.9 +_CALIBRATION_MAX = 2.5 + +_pdf_page_cache: Dict[tuple, int] = {} + +def _estimate_text(text: Any) -> int: + """Raw (uncorrected) token estimate of a text.""" + return estimate_tokens_cached(text) -def _estimate_text(text: str) -> int: - return len(text) // _CHARS_PER_TOKEN + +def _pdf_pages(data: str) -> int: + """Pages in a base64 PDF (0 when it cannot be decoded). Cached by content.""" + key = (hash(data), len(data)) + cached = _pdf_page_cache.get(key) + if cached is not None: + return cached + try: + raw = base64.b64decode(data, validate=False) + except (binascii.Error, ValueError): + raw = b"" + pages = len(_PDF_PAGE_RE.findall(raw)) if raw else 0 + if len(_pdf_page_cache) > 256: + _pdf_page_cache.clear() + _pdf_page_cache[key] = pages + return pages + + +def _estimate_document(block: Dict[str, Any]) -> int: + source = block.get("source") + if isinstance(source, dict): + stype = source.get("type") + data = source.get("data") + if stype == "text" and isinstance(data, str): + return _estimate_text(data) + if stype == "content" and isinstance(source.get("content"), list): + return sum(_estimate_block(b) for b in source["content"]) + if stype == "base64" and isinstance(data, str): + pages = _pdf_pages(data) + if pages: + return pages * _PDF_PAGE_TOKEN_ESTIMATE + # Unknown layout: about 3 KB of PDF per page is typical; never below one image. + return max( + _DOCUMENT_TOKEN_FLOOR, (len(data) * 3 // 4) // 3_000 * _PDF_PAGE_TOKEN_ESTIMATE + ) + text = block.get("text") or block.get("content") + if isinstance(text, str): + return _estimate_text(text) + return _DOCUMENT_TOKEN_FLOOR def _estimate_block(block: Any) -> int: - """Estimate one content block (text / image / tool_use / tool_result).""" + """Raw estimate of one content block (text / image / document / tool_use / tool_result …).""" if not isinstance(block, dict): return _estimate_text(str(block)) btype = block.get("type") - if btype == "image" or "source" in block: + if btype in _IMAGE_TYPES: return _IMAGE_TOKEN_ESTIMATE + if btype == "document": + return _estimate_document(block) if btype == "reasoning": - # OpenAI 의 생각 항목 — 암호화된 내용의 글자 수는 토큰이 아니다. 요약 + 작은 고정값. + # OpenAI reasoning items: the encrypted payload's length is not tokens. Summary + small fixed. return _estimate_text(str(block.get("summary") or "")) + 64 total = 0 + if btype == "tool_use": + total += _estimate_text(str(block.get("name") or "")) + 8 for key in ("text", "content", "input", "thinking"): val = block.get(key) if isinstance(val, str): @@ -65,34 +138,57 @@ def _estimate_content(content: Union[str, List[Any], Any]) -> int: return _estimate_text(str(content)) +def _raw_message(message: Any) -> int: + if not isinstance(message, dict): + return 0 + # +4: role/turn framing every provider adds per message. + return _estimate_content(message.get("content", "")) + 4 + + def estimate_message_tokens(messages: List[Any]) -> int: - """Rough token estimate of a message list (content only).""" - return sum( - _estimate_content(m.get("content", "")) for m in (messages or []) if isinstance(m, dict) - ) + """Raw token estimate of a message list (content only, no provider factor).""" + return sum(_raw_message(m) for m in (messages or [])) -#: ``state.shared`` slot for the per-turn estimate memo (TTFT program). -_ESTIMATE_MEMO_KEY = "_prompt_tokens_memo" +def _raw_system_and_tools(state: Any) -> int: + total = _estimate_content(getattr(state, "system", "") or "") + tools = getattr(state, "tools", None) + if tools: + total += sum(_estimate_text(str(tool)) for tool in tools) + return total + + +def _provider(state: Any) -> str: + client = getattr(state, "llm_client", None) + return str(getattr(client, "provider", "") or "") + + +def _scale(state: Any) -> float: + """Raw → provider-corrected tokens.""" + return provider_token_factor(_provider(state)) * SAFETY_MULTIPLIER + + +def _calibration(state: Any) -> float: + shared = getattr(state, "shared", None) + value = shared.get(CALIBRATION_KEY) if isinstance(shared, dict) else None + if isinstance(value, (int, float)) and value > 0: + return float(value) + return 1.0 def _estimate_fingerprint(state: Any) -> tuple: - """Cheap identity of the inputs the estimate depends on. - - The estimate is a full scan of system + every message + every tool - schema — O(context). Stage 2's proactive check and Stage 4's budget - guard both scan the SAME unchanged state within one iteration, so a - fingerprint memo halves the per-iteration cost (2026-07-12 TTFT - audit, finding B4). Mutations that matter all move the fingerprint: - appends change the count, compaction replaces the head message - (new object id), Stage 3 rebuilding system changes its length, and - a tool-registry rebuild changes the tools count. In-place content - edits with identical length CAN slip through — acceptable for an - estimator that is documented as ±rough. + """Cheap identity of the inputs the estimate depends on (memo key). + + Appends change the count, compaction replaces the head message (new id), Stage 3 rebuilding the + system prompt changes its length, a tool-registry rebuild changes the tools count, and a new + provider report replaces the anchor object. In-place edits that keep every length (the + deterministic prune) pop the memo explicitly. """ messages = getattr(state, "messages", []) or [] system = getattr(state, "system", "") or "" tools = getattr(state, "tools", None) or [] + shared = getattr(state, "shared", None) + anchor = shared.get(USAGE_ANCHOR_KEY) if isinstance(shared, dict) else None if isinstance(system, str): sys_len = len(system) else: @@ -103,22 +199,54 @@ def _estimate_fingerprint(state: Any) -> tuple: id(messages[-1]) if messages else 0, sys_len, len(tools), + id(anchor) if anchor is not None else 0, + _calibration(state), ) +def _anchored(state: Any, anchor: Dict[str, Any]) -> Optional[int]: + """``reported + estimate(delta)`` while the anchor still describes the live message list.""" + messages = getattr(state, "messages", []) or [] + n = int(anchor.get("n") or 0) + if n <= 0 or len(messages) < n: + return None + if id(messages[0]) != anchor.get("head") or id(messages[n - 1]) != anchor.get("tail"): + return None # compaction / preload rewrote the list the provider counted + prefix_raw = estimate_message_tokens(messages[:n]) + tail_raw = estimate_message_tokens(messages[n:]) + sys_tools_raw = _raw_system_and_tools(state) + # In-place edits to the counted prefix (the deterministic prune) and system/tool changes show up + # as raw deltas: negative when something shrank. + delta_raw = ( + tail_raw + + (prefix_raw - int(anchor.get("prefix_raw") or 0)) + + (sys_tools_raw - int(anchor.get("sys_tools_raw") or 0)) + ) + delta = int(delta_raw * _scale(state) * _calibration(state)) + return max(0, int(anchor.get("tokens") or 0) + delta) + + +def estimate_messages_scaled(state: Any, messages: List[Any]) -> int: + """Provider-corrected estimate of ``messages`` alone (no anchor): for planning a cut.""" + return int(estimate_message_tokens(messages) * _scale(state) * _calibration(state)) + + +def estimate_overhead_tokens(state: Any) -> int: + """Provider-corrected estimate of the system prompt and tool schemas.""" + return int(_raw_system_and_tools(state) * _scale(state) * _calibration(state)) + + +def estimate_text_scaled(state: Any, text: Any) -> int: + """Provider-corrected estimate of a text (prompts the runtime builds itself).""" + return int(_estimate_text(text) * _scale(state) * _calibration(state)) + + def estimate_prompt_tokens(state: Any) -> int: - """Rough INPUT-token estimate for the next API call. - - Sums the system prompt, the message list, and the tool schemas — - everything that travels in the request and therefore counts against - ``state.context_window_budget``. Used identically by the Stage 2 - compaction trigger and the Stage 4 token-budget guard so that - compacting ``state.messages`` measurably lowers the same number the - guard checks. - - Memoized per state via a fingerprint in ``state.shared`` (see - ``_estimate_fingerprint``) so repeat callers within one iteration - don't re-scan an unchanged context. + """INPUT-token estimate for the next API call (system + messages + tools). + + Used identically by the Stage 2 compaction trigger, the Stage 4 budget guard, the Stage 6 + overflow recovery and the Stage 16 token dimension, so compacting ``state.messages`` measurably + lowers the number every one of them checks. Memoized per state via a fingerprint. """ shared = getattr(state, "shared", None) fingerprint = _estimate_fingerprint(state) @@ -127,12 +255,87 @@ def estimate_prompt_tokens(state: Any) -> int: if isinstance(memo, tuple) and len(memo) == 2 and memo[0] == fingerprint: return memo[1] - total = _estimate_content(getattr(state, "system", "") or "") - total += estimate_message_tokens(getattr(state, "messages", []) or []) - tools = getattr(state, "tools", None) - if tools: - total += sum(_estimate_text(str(tool)) for tool in tools) + total: Optional[int] = None + anchor = shared.get(USAGE_ANCHOR_KEY) if isinstance(shared, dict) else None + if isinstance(anchor, dict): + total = _anchored(state, anchor) + if total is None: + raw = _raw_system_and_tools(state) + estimate_message_tokens( + getattr(state, "messages", []) or [] + ) + total = int(raw * _scale(state) * _calibration(state)) if isinstance(shared, dict): shared[_ESTIMATE_MEMO_KEY] = (fingerprint, total) return total + + +def record_prompt_usage(state: Any, prompt_tokens: int) -> None: + """Stage 6, after a successful call: remember what the provider counted for ``state.messages``. + + ``prompt_tokens`` is the FULL prompt the provider billed (uncached + cache read + cache write); + see ``BaseClient.prompt_tokens_from_usage``. A zero/absent report (CLI backends, mocks) leaves + the previous anchor alone. + """ + shared = getattr(state, "shared", None) + messages = getattr(state, "messages", None) or [] + try: + reported = int(prompt_tokens or 0) + except (TypeError, ValueError): + return + if not isinstance(shared, dict) or reported <= 0 or not messages: + return + prefix_raw = estimate_message_tokens(messages) + sys_tools_raw = _raw_system_and_tools(state) + pure = int((prefix_raw + sys_tools_raw) * _scale(state)) + if pure > 0: + ratio = reported / pure + shared[CALIBRATION_KEY] = min(_CALIBRATION_MAX, max(_CALIBRATION_MIN, ratio)) + shared[USAGE_ANCHOR_KEY] = { + "tokens": reported, + "n": len(messages), + "head": id(messages[0]), + "tail": id(messages[-1]), + "prefix_raw": prefix_raw, + "sys_tools_raw": sys_tools_raw, + } + shared.pop(_ESTIMATE_MEMO_KEY, None) + + +def calibrate_from_overflow(state: Any, actual_tokens: int) -> None: + """A provider rejected the request and said how big it really was: trust that number. + + Drops the anchor (the rejected list is about to be compacted) and raises the calibration so the + compaction target is measured against the provider's count, not our under-estimate. + """ + shared = getattr(state, "shared", None) + if not isinstance(shared, dict): + return + try: + actual = int(actual_tokens or 0) + except (TypeError, ValueError): + return + # The anchor goes first: the ratio must correct the estimate that is used from here on (raw × + # scale × calibration), not the anchored one the rejected request was measured with. + shared.pop(USAGE_ANCHOR_KEY, None) + shared.pop(_ESTIMATE_MEMO_KEY, None) + if actual <= 0: + return + current = estimate_prompt_tokens(state) + if current > 0 and actual > current: + ratio = _calibration(state) * (actual / current) + shared[CALIBRATION_KEY] = min(_CALIBRATION_MAX, max(_CALIBRATION_MIN, ratio)) + shared.pop(_ESTIMATE_MEMO_KEY, None) + + +__all__ = [ + "estimate_message_tokens", + "estimate_messages_scaled", + "estimate_overhead_tokens", + "estimate_text_scaled", + "estimate_prompt_tokens", + "record_prompt_usage", + "calibrate_from_overflow", + "USAGE_ANCHOR_KEY", + "CALIBRATION_KEY", +] diff --git a/src/xgen_agent_runtime/events/catalog.py b/src/xgen_agent_runtime/events/catalog.py index 5cf9d56a..1446762d 100644 --- a/src/xgen_agent_runtime/events/catalog.py +++ b/src/xgen_agent_runtime/events/catalog.py @@ -132,6 +132,9 @@ class EventTypes(str, Enum): CONTEXT_COMPACTION_SCHEDULED = "context.compaction_scheduled" CONTEXT_COMPACTION_REQUESTED = "context.compaction_requested" CONTEXT_COMPACTION_TARGET_MISSED = "context.compaction_target_missed" + CONTEXT_COMPACTION_SKIPPED = "context.compaction_skipped" + CONTEXT_COMPACTION_DEFERRED = "context.compaction_deferred" + CONTEXT_OVERFLOW = "context.overflow" #: 단기 기억 창(4.36.0) — 호스트가 이력을 preload 하지 않은 턴에서 STM 의 최근 논리 턴을 #: messages 앞에 되살렸다(가까운 턴은 도구까지, 먼 턴은 대화만). CONTEXT_SHORT_TERM_WINDOW = "context.short_term_window" @@ -451,18 +454,42 @@ class EventTypes(str, Enum): "degraded": "list[str] — 예산 강등 단계", }, EventTypes.CONTEXT_COMPACTED: { - "strategy": "str — compactor name/class", - "trigger": "str? — proactive | requested | guard | internal_loop | background", + "strategy": "str — compactor name/class, or 'prune' when the deterministic pass alone met the target", + "trigger": "str? — proactive | requested | manual | guard | overflow | internal_loop | background", "messages_before": "int?", "messages_after": "int?", "saved_tokens_estimate": "int?", + "tokens_after_estimate": "int? — projected prompt tokens after the relief", + "target_tokens": "int? — where the relief aimed (50% of the window by default)", + "target_met": "bool?", + }, + EventTypes.CONTEXT_COMPACTION_SKIPPED: { + "strategy": "str — compactor name/class", + "trigger": "str", + "reason": "str — turn_compaction_limit | already_summarized_for_this_request | nothing_to_summarize | summary_region_too_small", + "tokens_estimate": "int?", + "target_tokens": "int", + }, + EventTypes.CONTEXT_COMPACTION_DEFERRED: { + "compactor": "str", + "reason": "str — why no summary was produced (soft pressure leaves the history unchanged)", + "attempts": "int — model calls tried", + }, + EventTypes.CONTEXT_OVERFLOW: { + "message": "str — the provider's error, first 500 chars", + "reported_tokens": "int — prompt size the provider named, 0 when it named none", + "estimated_tokens": "int — our estimate for the rejected request", + "window": "int — the window we believed", + "provider_limit": "int — window the provider named, 0 when none (used for the rest of the turn when smaller)", }, EventTypes.CONTEXT_PRUNED: { "deduped": "int — duplicate tool results rewritten to a back-reference", - "images_stripped": "int — stale base64 images replaced with a marker", + "images_stripped": "int — stale base64 images/documents replaced with a marker", "trimmed": "int — oversized stale tool results shortened", + "inputs_trimmed": "int — oversized stale tool-call arguments shortened", "chars_saved": "int — text chars removed (images excluded)", - "trigger": "str? — cost (Stage 2 token threshold) | absent when run inside compaction", + "aggressive": "bool? — the hard-pressure profile (guard / overflow)", + "trigger": "str? — cost (Stage 2 token threshold) | the compaction trigger it ran under", "threshold_tokens": "int? — the prune_over_tokens threshold that fired (cost trigger)", "tokens_before": "int? — projected prompt tokens before the pass (cost trigger)", "tokens_after": "int? — projected prompt tokens after the pass (cost trigger)", diff --git a/src/xgen_agent_runtime/host/context_budget.py b/src/xgen_agent_runtime/host/context_budget.py index a059a2c3..176f8ca4 100644 --- a/src/xgen_agent_runtime/host/context_budget.py +++ b/src/xgen_agent_runtime/host/context_budget.py @@ -13,8 +13,9 @@ (사용자 텍스트 보존) 2) 그래도 초과면 사용자 텍스트를 truncate (최후 수단, 중간 생략 마커) -대화 이력(preload)은 건드리지 않는다 — 그건 파이프라인 Stage 2/4 의 몫이고, -같은 것을 두 층에서 자르면 어느 쪽이 잘랐는지 알 수 없게 된다. +대화 이력(preload)은 API 백엔드에서는 건드리지 않는다. 파이프라인 압축(Stage 2/4/6)이 +요약 체크포인트까지 줄일 수 있으므로 그 크기만큼만 센다(4.89.0). 압축이 없는 CLI 백엔드는 +가장 오래된 이력부터 뺀다. 토큰 계산·윈도우 해석은 agent_xgen 과 **한 소스**(helper/token_budget)를 쓴다 — 두 에이전트 노드의 예산 의미가 갈라지면 안 된다. @@ -39,6 +40,8 @@ class BudgetFit: text: str rag_block: str clamped: bool + #: 이력을 잘라야 했을 때(압축이 없는 CLI 백엔드)의 새 이력. ``None`` 이면 그대로. + history: Optional[List[Any]] = None #: 진단용 — provider 컨텍스트 오류와 대조할 압축 전 구성요소 추정치. window: int = 0 budget: int = 0 @@ -77,9 +80,8 @@ def resolve_window( def _estimate_tools_tokens(registry: Any, provider: str, model: str) -> int: """executor ToolRegistry 의 스키마가 프롬프트에 차지할 토큰 근사. - ``registry.to_api_format()`` (name/description/input_schema) 직렬화를 - 카운트한다. deferred 노출 모델에서는 실제 전송분이 더 작을 수 있으나 - 과대추정은 안전한 방향이다. 직렬화 실패 시 도구당 상수로 강등. + Stage 3 이 실제로 보내는 것(``to_api_format(exposed_only=True)``: core + 열린 도구)을 + 센다. 직렬화 실패 시 도구당 상수로 강등. """ if registry is None: return 0 @@ -88,7 +90,13 @@ def _estimate_tools_tokens(registry: Any, provider: str, model: str) -> int: import json - schemas = registry.to_api_format() + try: + # What Stage 3 actually sends: core + activated tools. Deferred tools stay out of the + # request until ToolSearch promotes them: counting the whole catalogue (hierarchy + # mode, the default) used to clamp the user's message on small windows for nothing. + schemas = registry.to_api_format(exposed_only=True) + except TypeError: + schemas = registry.to_api_format() blob = json.dumps(schemas, ensure_ascii=False, default=str) return count_text_tokens(blob, provider, model) except Exception: # noqa: BLE001 @@ -98,6 +106,100 @@ def _estimate_tools_tokens(registry: Any, provider: str, model: str) -> int: return 0 +#: 파이프라인 압축이 이력을 최소한 이만큼은 남긴다(체크포인트 요약 + 여유). 입력 맞춤은 이력을 +#: 이 크기까지 줄어들 수 있는 것으로 본다. 이력 때문에 사용자의 새 질문을 자르지 않는다. +_COMPACTED_HISTORY_FLOOR = 5_000 + +#: 내장 메모리 주입(Stage 2 → 시스템 프롬프트의 Relevant Knowledge)이 창에서 차지할 수 있는 몫. +#: 주입 상한은 글자(기본 10,000자)라 8k~32k 창에서는 그것만으로 창의 3분의 1을 넘었다. +MEMORY_INJECT_WINDOW_SHARE = 0.10 +#: 주입 예약의 상한(창 비율): 작은 창에서 예약이 입력 자리를 다 먹지 않게. +_MEMORY_RESERVE_WINDOW_SHARE = 0.15 +#: 주입 블록의 머리글·구분선 몫(토큰). +_MEMORY_INJECT_FRAME_TOKENS = 600 +#: 메모리 노드가 넘긴 이전 대화 문자열이 창에서 차지할 수 있는 몫. 노드 기본값은 100,000자다. +HISTORY_CONTEXT_WINDOW_SHARE = 0.25 + + +def memory_inject_cap_chars(window: int, configured: int) -> int: + """창에 맞춘 내장 메모리 주입 상한(글자). 창을 모르면 설정값 그대로.""" + from xgen_agent_runtime.core.text_tokens import chars_for_tokens + + configured = max(0, int(configured)) + if not window or window <= 0: + return configured + return min(configured, chars_for_tokens(int(window * MEMORY_INJECT_WINDOW_SHARE))) + + +def memory_reserved_tokens(window: int, configured_chars: int) -> int: + """fit 이후 시스템 프롬프트에 붙을 내장 메모리 주입의 선예약분(토큰). + + 주입 상한(:func:`memory_inject_cap_chars`)을 글자당 1토큰으로 보수적으로 환산하고 머리글 몫을 + 더한다. 예전에는 창과 무관하게 3,000토큰이었다. 큰 창에서는 실제 주입(최대 10,000자, 한국어면 + 거의 같은 수의 토큰)보다 작았고, 8k 창에서는 입력 자리의 절반이었다. + """ + from xgen_agent_runtime.core.text_tokens import CONSERVATIVE_CHARS_PER_TOKEN + + cap = memory_inject_cap_chars(window, configured_chars) + tokens = int(cap / CONSERVATIVE_CHARS_PER_TOKEN) + _MEMORY_INJECT_FRAME_TOKENS + if window and window > 0: + tokens = min(tokens, int(window * _MEMORY_RESERVE_WINDOW_SHARE)) + return max(0, tokens) + + +def fit_history_context(context: str, window: int, provider: str, model: str) -> str: + """메모리 노드의 이전 대화 문자열을 창의 :data:`HISTORY_CONTEXT_WINDOW_SHARE` 안으로. + + 문자열은 오래된 대화 → 최근 대화 순이라 넘치면 가운데를 줄인다(머리글과 가장 최근 대화가 + 남는다). 창을 모르면 그대로. + """ + if not context or not window or window <= 0: + return context + try: + from xgen_agent_runtime.host.token_budget import truncate_text_to_token_budget + + fitted, _cut = truncate_text_to_token_budget( + context, + max(256, int(window * HISTORY_CONTEXT_WINDOW_SHARE)), + provider=provider, + model=model, + front_ratio=0.2, + omit_notice="\n\n[... 더 오래된 대화 생략 ...]\n\n", + ) + return fitted + except Exception: # noqa: BLE001 (fail-open) + logger.warning("[GENY_BUDGET] 이전 대화 문자열 맞춤 실패: 그대로 둔다", exc_info=True) + return context + + +def _next_history_start(history: List[Any], start: int, keep_last: int = 2) -> int: + """``history[start:]`` 에서 가장 오래된 메시지 하나를 뺀 다음의 시작 위치. 더 뺄 수 없으면 ``start``. + + 새 첫 메시지는 user 여야 한다. 앞쪽 assistant·도구 결과는 함께 빠진다(도구 결과가 짝을 잃지 + 않게). 마지막 ``keep_last`` 개는 남긴다. + """ + end = len(history) + if end - start <= keep_last: + return start + nxt = start + 1 + while end - nxt > keep_last and not ( + isinstance(history[nxt], dict) + and history[nxt].get("role") == "user" + and not _tool_result_only(history[nxt]) + ): + nxt += 1 + return nxt + + +def _tool_result_only(message: Any) -> bool: + content = message.get("content") if isinstance(message, dict) else None + return ( + isinstance(content, list) + and bool(content) + and all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content) + ) + + def fit_input_to_budget( *, text: str, @@ -110,19 +212,26 @@ def fit_input_to_budget( max_tokens: int, window: int, reserved_tokens: int = 0, + history_compressible: bool = True, ) -> BudgetFit: """이번 턴의 입력측(text + rag_block)을 유효 예산 안으로 맞춘다. 예산 = window − max_tokens(출력 예약) − 안전마진 − reserved_tokens. 구성요소: - overhead = system_prompt + tool 스키마 - 고정분 = 대화 이력(preload; 파이프라인 압축의 몫이라 여기선 계정만) + overhead = system_prompt + tool 스키마(실제로 보내는 것) + 이력 = preload 된 지난 대화 가변분 = rag_block → text 순으로 truncate - ``reserved_tokens`` — fit **이후** 턴 중에 system 으로 주입되는 것들의 - 선예약분. 대표적으로 내장 메모리(Pinned Facts/Relevant Knowledge)는 - Stage 2 가 최대 10k자(≈3k 토큰)를 주입하는데, fit 시점의 system_prompt - 에는 아직 없다 — 예약 없이 꽉 채우면 그만큼 provider 400 으로 넘친다. + 이력의 몫(4.89.0): + ``history_compressible=True`` (API 백엔드): 파이프라인 압축(Stage 2/4/6)이 이력을 요약 + 체크포인트 크기(:data:`_COMPACTED_HISTORY_FLOOR`)까지 줄일 수 있다. 그래서 RAG·질문의 자리를 + 정할 때 이력은 그 바닥만큼만 센다. 예전에는 이력 전체를 세어, 압축할 수 있는 지난 대화 때문에 + 압축할 수 없는 **이번 질문**을 1,024토큰까지 잘랐다. + ``history_compressible=False`` (CLI 백엔드: 런타임 압축이 없고 지난 대화가 첫 메시지 하나로 + 접혀 CLI 도 줄이지 못한다): 넘치면 **가장 오래된 이력부터** 빼고(마지막 두 메시지는 남김), + 그다음 RAG, 마지막에 질문을 자른다. 잘린 이력은 ``BudgetFit.history`` 로 돌려준다. + + ``reserved_tokens``: fit **이후** 턴 중에 system 으로 주입되는 것들(내장 메모리 등)의 선예약분. fail-open: 어떤 예외도 입력을 그대로 돌려준다 — 예산 로직의 버그가 턴을 죽여선 안 된다 (agent_xgen 과 동일 원칙). @@ -132,6 +241,7 @@ def fit_input_to_budget( return result try: from xgen_agent_runtime.host.token_budget import ( + count_message_tokens, count_messages_tokens, count_text_tokens, effective_input_budget, @@ -152,6 +262,34 @@ def fit_input_to_budget( if total <= budget: return result + if history_compressible: + hist_need = min(hist_t, _COMPACTED_HISTORY_FLOOR) + else: + # 오래된 이력부터 뺀다. 이번 턴의 입력보다 먼저. 메시지별 토큰은 한 번만 센다. + msgs = list(history or []) + per = [count_message_tokens(m, provider, model) for m in msgs] + start = 0 + while sys_t + tools_t + hist_t + text_t + rag_t > budget: + nxt = _next_history_start(msgs, start) + if nxt == start: + break + hist_t -= sum(per[start:nxt]) + start = nxt + if start: + result.history = msgs[start:] + result.clamped = True + hist_need = hist_t + + if sys_t + tools_t + hist_need + text_t + rag_t <= budget: + if history_compressible: + logger.info( + "[GENY_BUDGET] 입력 %d > 예산 %d: 이력(%d)은 파이프라인 압축에 맡기고 입력은 그대로", + total, + budget, + hist_t, + ) + return result + logger.warning( "[GENY_BUDGET] 입력 %d > 예산 %d (window=%d sys=%d tools=%d hist=%d text=%d rag=%d) — clamp", total, @@ -164,7 +302,7 @@ def fit_input_to_budget( rag_t, ) - overhead = sys_t + tools_t + hist_t + overhead = sys_t + tools_t + hist_need # ── 1) RAG 블록 truncate (사용자 텍스트 보존) ── new_rag = rag_block if rag_block: @@ -184,14 +322,12 @@ def fit_input_to_budget( available_for_text = budget - overhead - rag_t if text and text_t > available_for_text: if available_for_text <= 0: - # system/tools/이력만으로 예산 초과 — 입력 공간이 없다. - # 파이프라인의 이력 압축(Stage 2/4)이 hist 를 줄일 수 있으므로 - # 최소한의 입력은 남긴다 (완전 소거는 질문 자체를 지운다). + # system/tools 만으로 예산 초과: 입력 공간이 없다. 최소한의 입력은 남긴다 + # (완전 소거는 질문 자체를 지운다). logger.error( - "[GENY_BUDGET] overhead(%d)+hist(%d)+rag(%d) 만으로 예산(%d) 초과 — " - "이력 압축(Stage 2/4)에 위임하고 입력은 최소 보존", + "[GENY_BUDGET] overhead(%d)+hist(%d)+rag(%d) 만으로 예산(%d) 초과: 입력은 최소 보존", sys_t + tools_t, - hist_t, + hist_need, rag_t, budget, ) diff --git a/src/xgen_agent_runtime/host/conversation_archive.py b/src/xgen_agent_runtime/host/conversation_archive.py index 66aefd3f..16f3ac17 100644 --- a/src/xgen_agent_runtime/host/conversation_archive.py +++ b/src/xgen_agent_runtime/host/conversation_archive.py @@ -145,7 +145,11 @@ def _new_utterances(state: Any) -> Tuple[List[Tuple[str, str]], int]: state.metadata.get(_ARCHIVED_KEY, state.metadata.get("memory.short_term_window_len", 0)) ) fresh: List[Tuple[str, str]] = [] - for msg in messages[archived_upto:]: + # Messages a mid-turn compaction summarized away before the archive saw them come first. + # Peeked here, dropped only when the archive commits its watermark (a failed write retries). + from xgen_agent_runtime.core.compaction import RECORDER_ARCHIVE, peek_pending_records + + for msg in peek_pending_records(state, RECORDER_ARCHIVE) + list(messages[archived_upto:]): role = str(msg.get("role", "")) if isinstance(msg, dict) else "" if role not in ("user", "assistant"): continue @@ -155,12 +159,15 @@ def _new_utterances(state: Any) -> Tuple[List[Tuple[str, str]], int]: return fresh, len(messages) async def _archive(self, state: Any) -> None: + from xgen_agent_runtime.core.compaction import RECORDER_ARCHIVE, take_pending_records + provider = self._provider if provider is None: return fresh, watermark = self._new_utterances(state) if not fresh: state.metadata[_ARCHIVED_KEY] = watermark + take_pending_records(state, RECORDER_ARCHIVE) return now = datetime.now() @@ -239,6 +246,7 @@ async def _archive(self, state: Any) -> None: ), ) state.metadata[_ARCHIVED_KEY] = watermark + take_pending_records(state, RECORDER_ARCHIVE) # ── strategy hook ──────────────────────────────────────────── #: MemoryStage 경로 전체 상한(s) — 저장 백엔드가 어떤 상태든(테이블 미생성, diff --git a/src/xgen_agent_runtime/host/memory.py b/src/xgen_agent_runtime/host/memory.py index 82486c9f..1a233410 100644 --- a/src/xgen_agent_runtime/host/memory.py +++ b/src/xgen_agent_runtime/host/memory.py @@ -72,3 +72,18 @@ def history_messages(memory_value: Any) -> List[Dict[str, Any]]: if text: history.append({"role": role, "content": text}) return history + + +def history_context(memory_value: Any) -> str: + """메모리 노드가 ``(최근 메시지, 컨텍스트 문자열)`` 튜플로 보낸 **문자열 쪽**. + + DB Memory 노드는 최근 N개(기본 2개)만 메시지로 넘기고, 그보다 앞선 대화 중 관련 있는 것은 + "[이전 대화 기록 - 참고용]" 문자열로 묶어 둘째 칸에 넣는다(옛 agent_xgen 은 이것을 시스템 + 프롬프트 끝에 붙였다). :func:`history_messages` 는 첫째 칸만 읽으므로, 이 함수가 없으면 그 + 앞선 대화는 모델에게 가지 않는다. 튜플이 아니거나 문자열이 비었으면 ``""``. + """ + if isinstance(memory_value, tuple) and len(memory_value) >= 2: + context = memory_value[1] + if isinstance(context, str) and context.strip(): + return context.strip() + return "" diff --git a/src/xgen_agent_runtime/host/runner.py b/src/xgen_agent_runtime/host/runner.py index dfcab3b3..42798844 100644 --- a/src/xgen_agent_runtime/host/runner.py +++ b/src/xgen_agent_runtime/host/runner.py @@ -547,16 +547,18 @@ def build_pipeline( executor 기본값(200k)이 쓰이는데, 작은 윈도우 모델(vLLM 32k 등)에서는 압축이 트리거되기 전에 provider 400 이 먼저 난다 — 호출자(agent_geny)가 token_budget 헬퍼로 해석한 실측/카탈로그 값을 반드시 넘겨야 한다. - Stage 2(80% proactive)·Stage 4 guard·Stage 16 토큰-비 루프 정지가 전부 - 이 값을 기준으로 동작한다. + Stage 2(80% proactive)·Stage 4 guard·Stage 6 넘침 복구·Stage 10 도구 결과 + 상한·내장 메모리 주입 상한이 전부 이 값을 기준으로 동작한다. ``enable_compaction`` — 노드의 "컨텍스트 자동 압축" 토글. True(기본): Stage 2 가 **항상** 등록되고(메모리 유무 무관 — 압축은 메모리 - 기능이 아니다) LLMSummaryCompactor 로 80% 초과 시 같은 모델 요약-압축, - Stage 4 에 TokenBudgetGuard 가 등록되어 예산 부족 시 compact→1회 재검사 - (auto-wire 는 Pipeline._init_state). False: 압축 전면 꺼짐 — Stage 2 는 - 메모리 배선용으로만 등록되고(compaction_enabled=False → 프루닝·요약· - guard 회복까지 전부 스킵, executor 3.3.0 계약), guard 미등록. + 기능이 아니다) 다음 요청 추정이 창의 80% 를 넘으면 LLMSummaryCompactor 가 + 50% 까지 같은 모델로 요약해 체크포인트로 바꾼다(core.compaction). Stage 4 + TokenBudgetGuard 는 예산 부족 시 강하게 압축(공격적 정리 + 요약, 실패하면 + 결정적 다이제스트)한 뒤 1회 재검사하고, Stage 6 은 provider 의 "prompt too + long" 거절에 같은 강한 압축 후 1회 재시도한다. False: 압축 전면 꺼짐. + Stage 2 는 메모리 배선용으로만 등록되고(compaction_enabled=False → 프루닝· + 요약·guard 회복까지 전부 스킵), guard 미등록. ``prune_over_tokens`` — 결정적 prune 의 **비용 트리거** (4.35.0, core/context_prune.py). 예상 프롬프트가 이 토큰 수를 넘으면 매 반복 앞에서 @@ -777,10 +779,23 @@ def build_pipeline( ) pipeline._memory_provider = memory_provider + retrieval_hooks = provider_hooks(memory_provider) + if context_window_budget and int(context_window_budget) > 0: + # 주입 상한(글자)을 창에 맞춘다. 기본 10,000자는 8k~32k 창에서 그것만으로 3분의 1을 + # 넘는다. provider 의 hooks 는 그대로 두고 이 턴의 검색기만 줄인 판을 받는다. + import dataclasses + + from xgen_agent_runtime.host.context_budget import memory_inject_cap_chars + from xgen_agent_runtime.memory.provider import MemoryHooks + + base_hooks = retrieval_hooks or MemoryHooks() + cap = memory_inject_cap_chars( + int(context_window_budget), base_hooks.max_inject_chars + ) + if cap < base_hooks.max_inject_chars: + retrieval_hooks = dataclasses.replace(base_hooks, max_inject_chars=cap) pipeline.attach_runtime( - memory_retriever=MemoryAwareRetriever( - memory_provider, hooks=provider_hooks(memory_provider) - ), + memory_retriever=MemoryAwareRetriever(memory_provider, hooks=retrieval_hooks), memory_strategy=ConversationArchivingStrategy(memory_provider), system_builder=ComposablePromptBuilder( blocks=[ @@ -1315,14 +1330,22 @@ def _asks(msg: Any) -> bool: and not _is_tool_result_only(msg.get("content")) ) + from xgen_agent_runtime.core.compaction import ( + RECORDER_STM, + peek_pending_records, + take_pending_records, + ) + messages = list(state.messages) start = _recorded_upto(state) - pending = [m for m in messages[start:] if isinstance(m, dict)] + # Messages a mid-turn compaction summarized away (the user's question among them) first. + compacted = peek_pending_records(state, RECORDER_STM) + pending = compacted + [m for m in messages[start:] if isinstance(m, dict)] try: window_len = int(state.metadata.get(WINDOW_LEN_KEY, 0) or 0) except (TypeError, ValueError): window_len = 0 - if not any(_asks(m) for m in messages[window_len:]): + if not any(_asks(m) for m in compacted + messages[window_len:]): question = TurnInput.from_raw(input_text).text.strip() if question: pending.insert(0, {"role": "user", "content": question}) @@ -1338,6 +1361,7 @@ async def _write() -> None: loop.run_until_complete(asyncio.wait_for(_write(), timeout=10.0)) state.metadata[_STATE_LAST_RECORDED] = len(messages) state.metadata[_STRATEGY_RECORDED] = len(messages) + take_pending_records(state, RECORDER_STM) logger.info( "geny_bridge: unfinished turn kept in short-term memory (%s, %d messages)", reason, diff --git a/src/xgen_agent_runtime/host/token_budget.py b/src/xgen_agent_runtime/host/token_budget.py index ccde3c2e..c30b0b53 100644 --- a/src/xgen_agent_runtime/host/token_budget.py +++ b/src/xgen_agent_runtime/host/token_budget.py @@ -25,7 +25,7 @@ import time from typing import Any, Dict, List, Optional, Tuple -from xgen_agent_runtime.host.token_estimator import estimate_tokens +from xgen_agent_runtime.core.text_tokens import estimate_tokens, estimate_tokens_cached # noqa: F401 logger = logging.getLogger("token-budget") @@ -35,16 +35,12 @@ # tiktoken(o200k_base) 은 OpenAI 기준. 타 provider 는 토크나이저가 달라 토큰 수가 # 더 많을 수 있으므로 보정계수로 *과대추정* 한다 (안전한 방향). 추가로 전역 안전마진. # ───────────────────────────────────────────────────────────────────────── -_PROVIDER_TOKEN_FACTOR = { - "openai": 1.0, - "anthropic": 1.15, - "bedrock": 1.15, - "google": 1.20, - "vertex": 1.20, - "vllm": 1.20, -} -_DEFAULT_PROVIDER_FACTOR = 1.20 -_SAFETY_MULTIPLIER = 1.05 # 모든 추정에 5% 가산 +# 계수 표의 원본은 core.text_tokens 다(압축 판단도 같은 표를 쓴다). 여기 이름은 예전 호출부를 위한 별칭. +from xgen_agent_runtime.core.text_tokens import ( # noqa: E402 + DEFAULT_PROVIDER_FACTOR as _DEFAULT_PROVIDER_FACTOR, + PROVIDER_TOKEN_FACTOR as _PROVIDER_TOKEN_FACTOR, + SAFETY_MULTIPLIER as _SAFETY_MULTIPLIER, +) # 이미지 1장당 근사 토큰 비용 (멀티모달). provider/해상도별로 다르나 보수적 상수. _IMAGE_TOKEN_COST = 1200 @@ -60,41 +56,77 @@ # - agent 노드의 context_window 파라미터는 언제나 최우선 override. # ───────────────────────────────────────────────────────────────────────── _MODEL_CONTEXT_WINDOW: List[Tuple[str, int]] = [ - # OpenAI + # OpenAI. gpt-5 계열은 창이 400k 여도 **입력은 272k** 까지다(나머지는 출력 몫): 창 값을 400k 로 + # 두면 유효 예산이 실제 입력 한도를 넘어 압축보다 provider 400 이 먼저 난다. 그래서 입력 한도를 + # 창으로 쓴다. gpt-5.5 이후·gpt-6 은 1.05M 창에 출력 128k(2026-10-12 OpenAI 모델 문서 확인). ("gpt-4.1", 1_047_576), ("gpt-4o", 128_000), ("gpt-4-turbo", 128_000), ("gpt-4", 128_000), ("gpt-3.5", 16_385), + ("gpt-35-turbo", 16_385), # Azure 배포 이름 ("o1", 200_000), ("o3", 200_000), ("o4", 200_000), - ("gpt-5", 400_000), - # Anthropic / Bedrock Claude + ("gpt-5", 272_000), + ("gpt-5.5", 922_000), + ("gpt-6", 922_000), + # Anthropic / Bedrock / Vertex Claude. Opus 4.6 이후·Sonnet 4.6 이후·Haiku 5.5·Fable·Mythos 는 + # 1M 이 기본이다(베타 헤더 불필요). 그 밖은 200k(2026-10-12 Anthropic 문서 확인). ("claude-3-5", 200_000), ("claude-3-7", 200_000), ("claude-3", 200_000), ("claude-sonnet-4", 200_000), + ("claude-sonnet-4-6", 1_000_000), ("claude-opus-4", 200_000), + ("claude-opus-4-6", 1_000_000), + ("claude-opus-4-7", 1_000_000), + ("claude-opus-4-8", 1_000_000), ("claude-haiku-4", 200_000), ("claude-4", 200_000), + ("claude-opus-5", 1_000_000), + ("claude-sonnet-5", 1_000_000), + ("claude-haiku-5", 1_000_000), + ("claude-fable", 1_000_000), + ("claude-mythos", 1_000_000), ("claude", 200_000), - # Google Gemini + # Google Gemini / Gemma (Gemma 는 gemini 접두에 걸리지 않으므로 provider 기본값 1M 으로 새지 않게) ("gemini-1.5", 1_048_576), ("gemini-2.0", 1_048_576), ("gemini-2.5", 1_048_576), ("gemini", 1_048_576), + ("gemma-3-1b", 32_768), + ("gemma-3", 131_072), + ("gemma-2", 8_192), + ("gemma", 8_192), + # Bedrock 의 Claude 밖 모델 (ID 는 "meta.llama3-1-…", "us.amazon.nova-pro-v1:0" 꼴: 구분자 뒤에서 잡힌다) + ("llama3-1", 128_000), + ("llama3-2", 128_000), + ("llama3-3", 128_000), + ("llama4", 128_000), + ("nova-micro", 128_000), + ("nova-lite", 300_000), + ("nova-pro", 300_000), ] # provider 기준 기본값 (모델 매칭 실패 시). _PROVIDER_DEFAULT_WINDOW = { "openai": 128_000, + # Azure 는 모델 이름이 배포 이름이라 표에 안 잡히는 일이 흔하다. 현행 배포 모델의 바닥. + "azure": 128_000, + "azure_foundry": 128_000, "anthropic": 200_000, - "bedrock": 200_000, "google": 1_048_576, "vertex": 1_048_576, "vllm": 32_768, + # CLI 백엔드: 모델 이름이 별칭(sonnet/opus)이라 표에 안 잡힌다. 런타임은 이 값으로 첫 메시지에 + # 접어 넣는 지난 대화의 양만 맞춘다. 작게 잡으면 더 일찍 덜어낼 뿐 실패하지 않는다. + "claude_code": 200_000, + "codex": 272_000, } +#: Bedrock 은 Claude 밖의 모델(Llama 8k~128k, Mistral 32k, Titan 8k)도 서빙한다. provider 기본값을 +#: Claude 의 200k 로 두면 작은 모델이 압축 전에 400 을 낸다. Claude 는 위 표에서 잡힌다. +_BEDROCK_NON_CLAUDE_DEFAULT = 32_000 # 완전 미상(provider 도 모름)일 때 보수적 기본값. DEFAULT_CONTEXT_WINDOW = 32_000 @@ -200,22 +232,26 @@ def resolve_context_window( return catalog_win name = (model or "").lower().strip() + if prov == "claude_code" and name.endswith("[1m]"): + return 1_000_000 # Claude Code 의 1M 별칭(opus[1m]) + if prov == "codex": + # Codex CLI 는 모델의 API 창(gpt-5.5 1.05M)과 무관하게 자기 창(272k 입력)으로 돈다. + return _PROVIDER_DEFAULT_WINDOW["codex"] if name: + # 이름 앞이나 구분자(. / :) 뒤에서 시작하는 접두만: 'apac.anthropic.claude-…', + # 'meta.llama3-1-70b-…', 'Qwen/…'. 그냥 포함으로 보면 'o1' 같은 짧은 접두가 엉뚱한 이름 속에서 + # 걸린다. 가장 긴 접두가 이긴다. + segments = [name] + [name[i + 1 :] for i, ch in enumerate(name) if ch in "./:"] best_len, best_win = -1, None for prefix, win in _MODEL_CONTEXT_WINDOW: - if name.startswith(prefix) and len(prefix) > best_len: - best_len, best_win = len(prefix), win - if best_win is not None: - return best_win - # 접두가 아니라 포함이라도 잡아본다 (예: 'apac.anthropic.claude-...'). - best_len, best_win = -1, None - for prefix, win in _MODEL_CONTEXT_WINDOW: - if prefix in name and len(prefix) > best_len: + if len(prefix) > best_len and any(seg.startswith(prefix) for seg in segments): best_len, best_win = len(prefix), win if best_win is not None: return best_win - return _PROVIDER_DEFAULT_WINDOW.get((provider or "").lower(), DEFAULT_CONTEXT_WINDOW) + if prov == "bedrock": + return _BEDROCK_NON_CLAUDE_DEFAULT + return _PROVIDER_DEFAULT_WINDOW.get(prov, DEFAULT_CONTEXT_WINDOW) # ───────────────────────────────────────────────────────────────────────── @@ -298,7 +334,7 @@ def _raw_token_count(text: str) -> int: """보정 전 원시 토큰 수. 네트워크를 타지 않는다(:mod:`.token_estimator`).""" if not text: return 0 - return estimate_tokens(text) + return estimate_tokens_cached(text) def count_text_tokens( diff --git a/src/xgen_agent_runtime/host/token_estimator.py b/src/xgen_agent_runtime/host/token_estimator.py index 515c5d8e..bdf47cfa 100644 --- a/src/xgen_agent_runtime/host/token_estimator.py +++ b/src/xgen_agent_runtime/host/token_estimator.py @@ -36,115 +36,17 @@ from __future__ import annotations -import unicodedata -from typing import Any +# 본체는 core 로 옮겼다(4.89.0): 압축 판단(core)과 호출 전 입력 맞춤(host)이 같은 함수를 써야 +# 같은 대화를 같은 크기로 본다. core 는 host 를 import 하지 않으므로 core 쪽이 원본이다. +from xgen_agent_runtime.core.text_tokens import ( # noqa: F401 + HANGUL_LEGACY, + HANGUL_MODERN, + _class_of, + estimate_tokens, +) __all__ = [ "estimate_tokens", "HANGUL_MODERN", "HANGUL_LEGACY", ] - -#: 한글 음절당 토큰 — 최신 토크나이저(o200k 계열). 기본값. -HANGUL_MODERN = 0.75 -#: 한글 음절당 토큰 — 구형(cl100k 계열). -HANGUL_LEGACY = 1.28 - -#: 라틴 문자 연속 구간에서 토큰 하나가 먹는 글자 수(최소 1토큰). -_LATIN_CHARS_PER_TOKEN = 7.0 -#: 숫자 연속 구간 — BPE 는 보통 두세 자리씩 묶는다. -_DIGIT_CHARS_PER_TOKEN = 3.0 -#: 한자·가나는 글자당 한 토큰에 가깝다. -_CJK_PER_CHAR = 1.0 -#: 공백은 **첫 칸이 뒤 단어에 붙으므로** 나머지만 센다. -_SPACE_PER_EXTRA = 0.25 -#: 줄바꿈은 대체로 한 토큰. -_NEWLINE_PER_CHAR = 1.0 -#: 문장부호·기호(ASCII)는 앞뒤와 자주 뭉친다. -_PUNCT_PER_CHAR = 0.6 -#: 이모지 등 비-ASCII 기호는 여러 바이트라 토큰을 더 먹는다. -_SYMBOL_PER_CHAR = 2.0 - -_SPACE = "sp" -_NEWLINE = "nl" -_DIGIT = "num" -_LATIN = "lat" -_HANGUL = "han" -_CJK = "cjk" -_PUNCT = "pun" -_SYMBOL = "sym" - - -def _class_of(ch: str) -> str: - """문자 하나의 부류. 여기서만 유니코드를 본다.""" - code = ord(ch) - if ch in " \t": - return _SPACE - if ch in "\r\n": - return _NEWLINE - if "0" <= ch <= "9": - return _DIGIT - if ("a" <= ch <= "z") or ("A" <= ch <= "Z"): - return _LATIN - # 한글 음절 + 자모 - if 0xAC00 <= code <= 0xD7A3 or 0x1100 <= code <= 0x11FF or 0x3130 <= code <= 0x318F: - return _HANGUL - # 한자 - if 0x4E00 <= code <= 0x9FFF or 0x3400 <= code <= 0x4DBF: - return _CJK - # 가나 - if 0x3040 <= code <= 0x30FF: - return _CJK - if code < 0x80: - return _PUNCT - # 키릴·그리스·아랍 등 다른 문자 체계는 라틴과 비슷하게 뭉친다. - if unicodedata.category(ch).startswith("L"): - return _LATIN - return _SYMBOL - - -def estimate_tokens(text: Any, *, hangul: float = HANGUL_MODERN) -> int: - """``text`` 의 토큰 수 추정(올림). 빈 값이면 0. - - 문자열이 아니면 ``str()`` 로 바꿔 센다 — 호출부마다 변환을 되풀이하지 않게. - - Args: - hangul: 한글 음절당 토큰. 구형 계열이면 :data:`HANGUL_LEGACY`. - """ - if text is None: - return 0 - if not isinstance(text, str): - text = str(text) - if not text: - return 0 - - total = 0.0 - index = 0 - length = len(text) - while index < length: - kind = _class_of(text[index]) - end = index + 1 - while end < length and _class_of(text[end]) == kind: - end += 1 - run = end - index - - if kind == _LATIN: - total += max(1.0, run / _LATIN_CHARS_PER_TOKEN) - elif kind == _DIGIT: - total += max(1.0, run / _DIGIT_CHARS_PER_TOKEN) - elif kind == _HANGUL: - total += run * hangul - elif kind == _CJK: - total += run * _CJK_PER_CHAR - elif kind == _SPACE: - total += (run - 1) * _SPACE_PER_EXTRA - elif kind == _NEWLINE: - total += run * _NEWLINE_PER_CHAR - elif kind == _PUNCT: - total += run * _PUNCT_PER_CHAR - else: - total += run * _SYMBOL_PER_CHAR - - index = end - - return max(1, int(total + 0.5)) diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index d3e9618c..7ddadb39 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -232,7 +232,7 @@ def run(self, host: Any, **kwargs): # node_name 은 워크플로 executor 가 kwargs 에 실어 준다(node_info nodeName) # = 노드의 self.nodeName. build_pipeline name= 에만 쓰인다. node_name = kwargs.get("node_name") or "" - from xgen_agent_runtime.host.memory import history_messages + from xgen_agent_runtime.host.memory import history_context, history_messages from xgen_agent_runtime.host.rag import collect_rag from xgen_agent_runtime.host.runner import ( DEFAULT_MAX_CONTINUATION_SLICES, @@ -990,70 +990,100 @@ def run_dir_cleanup(_d: str = run_dir) -> None: logger.warning("agents/geny: 요청 파일 첨부 실패 (건너뜀)", exc_info=True) # ── 컨텍스트 예산 (컨텍스트 자동 압축 토글) ───────────────── - # system_prompt 가 최종형이 된 지점 — 여기서 윈도우를 해석하고, - # 토글 ON 이면 입력측(text/rag)을 예산 안으로 맞춘 뒤 융합한다. - # 이력(preload)은 자르지 않는다: 그건 파이프라인 Stage 2/4 의 몫. - # claude_code 는 CLI 가 자체 컨텍스트를 관리하므로 전부 스킵. + # system_prompt 가 최종형이 된 지점: 여기서 윈도우를 해석하고, 토글 ON 이면 입력측 + # (text/rag)을 예산 안으로 맞춘 뒤 융합한다. 이력은 백엔드마다 다르다: + # API 백엔드: 파이프라인 압축(Stage 2/4/6)이 이력을 요약 체크포인트까지 줄이므로 + # 이력은 그 바닥만큼만 센다(질문을 이력 때문에 자르지 않는다). + # CLI 백엔드(claude_code·codex): 런타임 압축이 없고 이력은 첫 메시지 하나로 접혀 + # CLI 의 자체 압축도 못 줄인다. 넘치면 가장 오래된 이력부터 덜어 낸다. + from xgen_agent_runtime.host.context_budget import ( + fit_history_context, + fit_input_to_budget, + memory_reserved_tokens, + resolve_window, + ) + enable_compaction = bool(kwargs.get("enable_compaction", True)) max_tokens_val = int(kwargs.get("max_tokens", 8192)) - budget_window = 0 clamped = False - if provider not in _CLI_BACKENDS: - from xgen_agent_runtime.host.context_budget import ( - fit_input_to_budget, - resolve_window, + budget_window = resolve_window( + provider, + model, + int(kwargs.get("context_window") or 0), + base_url=base_url, + vllm_probe=host.fetch_vllm_max_model_len, + ) + # 메모리 노드가 (최근 메시지, 이전 대화 문자열) 로 보낸 둘째 칸: 최근 N개(기본 2개)보다 + # 앞선 대화다. 옛 agent_xgen 처럼 시스템 프롬프트 끝에 붙인다. 노드 기본값이 10만 자라 + # 창의 일정 몫 안으로 줄인다(가장 최근 쪽이 남는다). + _history_ctx = history_context(kwargs.get("memory")) + if _history_ctx: + _history_ctx = fit_history_context(_history_ctx, budget_window, provider, model) + system_prompt = ( + (system_prompt + "\n\n" + _history_ctx) if system_prompt else _history_ctx ) - - budget_window = resolve_window( - provider, - model, - int(kwargs.get("context_window") or 0), - base_url=base_url, - vllm_probe=host.fetch_vllm_max_model_len, + logger.info( + "agents/geny: 메모리 노드의 이전 대화 %d자를 시스템 프롬프트에 붙임", + len(_history_ctx), ) - if enable_compaction and budget_window > 0: - fit = fit_input_to_budget( - text=text, + _memory_reserve = 0 + if memory_provider is not None: + # 내장 메모리 주입(Stage 2)은 fit 이후 system 에 붙는다. 그만큼 미리 비워 둔다. + # 주입 상한은 build_pipeline 이 같은 창으로 줄인다(memory_inject_cap_chars). + from xgen_agent_runtime.memory.provider import MemoryHooks, provider_hooks + + _hooks = provider_hooks(memory_provider) or MemoryHooks() + _memory_reserve = memory_reserved_tokens(budget_window, _hooks.max_inject_chars) + if enable_compaction and budget_window > 0: + + def _fit(_text: str): + return fit_input_to_budget( + text=_text, rag_block=rag_block, system_prompt=system_prompt, history=state.messages, - registry=registry, + # CLI 도 다리(MCP)가 붙으면 XGEN 도구 스키마가 CLI 창을 쓴다. 같이 센다. + registry=registry if _tools_reach_model else None, provider=provider, model=model, max_tokens=max_tokens_val, window=budget_window, - # 내장 메모리 주입(Stage 2, 최대 10k자≈3k 토큰)은 fit - # 이후 system 에 붙는다 — 예약 없이 꽉 채우면 넘친다. - reserved_tokens=3_000 if memory_provider is not None else 0, + reserved_tokens=_memory_reserve, + history_compressible=not _is_cli, + ) + + fit = _fit(text) + if fit.clamped and _fast_path is not None and _fast_path.active: + # A partial snapshot is not a snapshot. If budgeting + # trims any of it, recompute from the original request + # and keep the normal agent loop/witness semantics. + _fast_path = None + _fast_path_reason = "context_budget" + fit = _fit(_fast_path_original_text) + # 스냅숏을 붙인 판이 아니라 원래 요청으로 돌아간다(다시 맞춘 결과가 잘리지 않았어도). + text, rag_block = fit.text, fit.rag_block + logger.info("agents/geny: workspace fast path 폴백 (context_budget)") + if fit.clamped: + text, rag_block, clamped = fit.text, fit.rag_block, fit.clamped + if fit.history is not None: + # CLI: 덜어 낸 이력이 이번 턴의 이력이다. preload 워터마크도 새 길이로 맞춘다. + # 기록 대상에서 빠지는 것은 여전히 "지난 대화" 전부다. + from xgen_agent_runtime.host.conversation_archive import ( + _ARCHIVED_KEY, + STM_RECORDED_KEY, + ) + + _dropped = len(state.messages) - len(fit.history) + state.messages = list(fit.history) + for _key in (STM_RECORDED_KEY, _ARCHIVED_KEY): + if _key in state.metadata: + state.metadata[_key] = max(0, int(state.metadata[_key]) - _dropped) + logger.warning( + "agents/geny: 입력 클램프 적용 (window=%d budget=%d before=%d)", + fit.window, + fit.budget, + fit.total_before, ) - if fit.clamped: - # A partial snapshot is not a snapshot. If budgeting - # trims any of it, recompute from the original request - # and keep the normal agent loop/witness semantics. - if _fast_path is not None and _fast_path.active: - _fast_path = None - _fast_path_reason = "context_budget" - fit = fit_input_to_budget( - text=_fast_path_original_text, - rag_block=rag_block, - system_prompt=system_prompt, - history=state.messages, - registry=registry, - provider=provider, - model=model, - max_tokens=max_tokens_val, - window=budget_window, - reserved_tokens=(3_000 if memory_provider is not None else 0), - ) - logger.info("agents/geny: workspace fast path 폴백 (context_budget)") - text, rag_block, clamped = fit.text, fit.rag_block, fit.clamped - if fit.clamped: - logger.warning( - "agents/geny: 입력 클램프 적용 (window=%d budget=%d before=%d)", - fit.window, - fit.budget, - fit.total_before, - ) if _fast_path is not None and _fast_path.active: register_snapshot_witnesses(state, _fast_path) @@ -1167,12 +1197,12 @@ def run_dir_cleanup(_d: str = run_dir) -> None: tool_context=None if _is_cli else run_tool_context, # run_tool_context 가 없는 턴(내장 도구 없음)에도 Stage 10 에 필터가 실리게. tool_result_filter=None if _is_cli else _result_filter, - # 모델의 실제 윈도우 — 압축 임계(80%)·guard·루프 토큰-비 정지의 - # 공통 기준. 0(미해석)이면 executor 기본값(200k) 유지. + # 모델의 실제 윈도우: 압축 문턱(80%)·Stage 4 guard·넘침 복구·도구 결과 + # 상한의 공통 기준. 0(미해석)이면 executor 기본값(200k) 유지. context_window_budget=budget_window, - # claude_code 는 CLI 가 자체 컨텍스트(오토-컴팩션)를 관리한다 — - # 파이프라인 층의 압축이 겹치면 같은 전사를 두 주체가 자르게 - # 되므로 CLI 백엔드에서는 항상 끈다 (파라미터 설명과 일치). + # CLI 백엔드(claude_code·codex)는 CLI 가 자체 컨텍스트(자동 압축)를 관리한다. + # 파이프라인 층의 압축이 겹치면 같은 전사를 두 주체가 자르게 되므로 항상 끈다. + # 첫 메시지에 접히는 지난 대화는 위 예산 단계가 CLI 창에 맞춘다. enable_compaction=(enable_compaction and provider not in _CLI_BACKENDS), credentials=credentials, # 호스트가 캐시 토큰 기록을 갖춘 뒤 명시적으로 켠다 (기본 off). diff --git a/src/xgen_agent_runtime/llm_client/anthropic.py b/src/xgen_agent_runtime/llm_client/anthropic.py index c8f235c8..dda64151 100644 --- a/src/xgen_agent_runtime/llm_client/anthropic.py +++ b/src/xgen_agent_runtime/llm_client/anthropic.py @@ -293,8 +293,9 @@ def _retry_kwargs_after_deprecation( # contains the substring ``token``, so the next thinking-shape drift # would have been diagnosed as "reduce your context" instead of "your # request shape is stale" (audit §3.4). The two categories drive very -# different recovery: TOKEN_LIMIT tells s06 retry logic to compact the -# conversation; BAD_REQUEST is fatal and should bubble immediately. +# different recovery: TOKEN_LIMIT makes Stage 6 compact the conversation and +# retry once (4.89.0: before that nothing acted on it and the turn failed); +# BAD_REQUEST is fatal and should bubble immediately. # # Classify TOKEN_LIMIT only on phrases the live API actually anchors # overflow errors with, and short-circuit anything that *looks like a @@ -308,6 +309,8 @@ def _retry_kwargs_after_deprecation( # The live API's combined-budget phrasing: # ``input length and `max_tokens` exceed context limit: …`` "exceed context limit", + # Bedrock's phrasing for the same overflow. + "input is too long", ) #: Substrings that mark a request-*validation* 400 (pydantic-style param @@ -343,6 +346,16 @@ def _classify_bad_request_message(msg_lower: str) -> ErrorCategory: class AnthropicClient(BaseClient): """Real Anthropic API client using the official SDK.""" + def prompt_tokens_from_usage(self, usage: Any) -> int: + """Anthropic (and Bedrock) report cache reads/writes OUTSIDE ``input_tokens``.""" + total = 0 + for field in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"): + try: + total += int(getattr(usage, field, 0) or 0) + except (TypeError, ValueError): + continue + return total + provider = "anthropic" capabilities = ClientCapabilities( supports_thinking=True, diff --git a/src/xgen_agent_runtime/llm_client/base.py b/src/xgen_agent_runtime/llm_client/base.py index 6aa5f1c9..7f14339c 100644 --- a/src/xgen_agent_runtime/llm_client/base.py +++ b/src/xgen_agent_runtime/llm_client/base.py @@ -586,6 +586,18 @@ def _report_drift_healed( } ) + def prompt_tokens_from_usage(self, usage: Any) -> int: + """The FULL prompt size a response's usage says the provider counted. + + OpenAI-shaped usage (OpenAI, Azure, vLLM, Gemini) already includes cached prompt tokens in + ``input_tokens``; :class:`AnthropicClient` overrides this because Anthropic reports cache + reads/writes separately. Stage 6 anchors the context estimate on this number. + """ + try: + return int(getattr(usage, "input_tokens", 0) or 0) + except (TypeError, ValueError): + return 0 + def _classify_error(self, e: Exception) -> APIError: """Translate a vendor exception into a canonical :class:`APIError`. diff --git a/src/xgen_agent_runtime/llm_client/claude_code.py b/src/xgen_agent_runtime/llm_client/claude_code.py index 8af1bbb3..dc3b3a8c 100644 --- a/src/xgen_agent_runtime/llm_client/claude_code.py +++ b/src/xgen_agent_runtime/llm_client/claude_code.py @@ -59,6 +59,9 @@ build_stream_json_stdin, messages_have_images, claude_code_argv, + fits_argv, + flatten_messages_to_prompt, + system_prompt_text, resolve_auth_channel, parse_json_output_to_response, ) @@ -510,9 +513,48 @@ def _make_runner( timeout_s=effective_timeout, ) + def _system_prompt_file(self, request: APIRequest) -> str: + """Write a system prompt too long for argv to a file; ``""`` when it fits inline. + + The name is the content hash, so the same prompt yields the same argv (the hot spare + still matches) and repeated turns reuse one file. Files untouched for a day are removed + on the next write. + """ + text = system_prompt_text(request.system) + if not text or fits_argv(text): + return "" + import hashlib + import tempfile + import time + + folder = os.path.join(tempfile.gettempdir(), "xgen-claude-code-system") + try: + os.makedirs(folder, mode=0o700, exist_ok=True) + path = os.path.join(folder, hashlib.sha256(text.encode("utf-8")).hexdigest() + ".md") + if os.path.exists(path): + os.utime(path) + else: + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w", encoding="utf-8") as fp: + fp.write(text) + os.replace(tmp, path) + cutoff = time.time() - 86_400 + for name in os.listdir(folder): + old = os.path.join(folder, name) + with contextlib.suppress(OSError): + if old != path and os.path.getmtime(old) < cutoff: + os.unlink(old) + return path + except OSError: + logger.warning( + "claude_code: system prompt file write failed, sending inline", exc_info=True + ) + return "" + def _build_argv(self, request: APIRequest) -> List[str]: return claude_code_argv( request, + system_prompt_file=self._system_prompt_file(request), bare_mode=self._bare_mode, auth_mode=self._auth_mode, has_api_key=bool(self._api_key), @@ -699,6 +741,10 @@ async def _send(self, request: APIRequest, *, purpose: str = "") -> APIResponse: # its image and the model answered "I don't see an image". if not request.stream and messages_have_images(request.messages): request = replace(request, stream=True) + # Same wire switch for a prompt too long to be one argv string (Linux 128 KiB per string): + # stream-json stdin has no such limit. + if not request.stream and not fits_argv(flatten_messages_to_prompt(request.messages)): + request = replace(request, stream=True) cli_version = await self._ensure_cli_version() argv = self._build_argv(request) diff --git a/src/xgen_agent_runtime/llm_client/google.py b/src/xgen_agent_runtime/llm_client/google.py index 8963f621..0031a288 100644 --- a/src/xgen_agent_runtime/llm_client/google.py +++ b/src/xgen_agent_runtime/llm_client/google.py @@ -9,7 +9,7 @@ import logging from typing import Any, AsyncIterator, Dict, List, Optional -from xgen_agent_runtime.core.errors import APIError, ErrorCategory +from xgen_agent_runtime.core.errors import APIError, ErrorCategory, is_context_overflow_message from xgen_agent_runtime.core.state import TokenUsage from xgen_agent_runtime.llm_client.base import BaseClient, ClientCapabilities from xgen_agent_runtime.llm_client.translators import ( @@ -473,9 +473,14 @@ def _classify_error(self, e: Exception) -> APIError: str(e), category=ErrorCategory.SERVER_ERROR, status_code=code, cause=e ) if code == 400 or status in ("INVALID_ARGUMENT", "FAILED_PRECONDITION"): - return APIError( - str(e), category=ErrorCategory.BAD_REQUEST, status_code=code, cause=e + # "The input token count (N) exceeds the maximum number of tokens allowed (M)": + # TOKEN_LIMIT lets Stage 6 compact and retry once (4.89.0). + category = ( + ErrorCategory.TOKEN_LIMIT + if is_context_overflow_message(str(e)) + else ErrorCategory.BAD_REQUEST ) + return APIError(str(e), category=category, status_code=code, cause=e) # Partition by class/range when the specific code is novel. if isinstance(e, genai_errors.ServerError) or ( isinstance(code, int) and 500 <= code < 600 @@ -515,7 +520,11 @@ def _classify_api_core(e: Exception) -> Optional[ErrorCategory]: if isinstance(e, gac_exceptions.DeadlineExceeded): return ErrorCategory.TIMEOUT if isinstance(e, gac_exceptions.InvalidArgument): - return ErrorCategory.BAD_REQUEST + return ( + ErrorCategory.TOKEN_LIMIT + if is_context_overflow_message(str(e)) + else ErrorCategory.BAD_REQUEST + ) if isinstance(e, (gac_exceptions.ServiceUnavailable, gac_exceptions.InternalServerError)): return ErrorCategory.SERVER_ERROR return None @@ -537,6 +546,8 @@ def _classify_by_message(self, e: Exception) -> APIError: return APIError(str(e), category=ErrorCategory.AUTH, cause=e) if "unavailable" in error_str or "503" in error_str or "500" in error_str: return APIError(str(e), category=ErrorCategory.SERVER_ERROR, cause=e) + if is_context_overflow_message(error_str): + return APIError(str(e), category=ErrorCategory.TOKEN_LIMIT, cause=e) if "invalid argument" in error_str or "400" in error_str: return APIError(str(e), category=ErrorCategory.BAD_REQUEST, cause=e) return APIError(str(e), category=ErrorCategory.UNKNOWN, cause=e) diff --git a/src/xgen_agent_runtime/llm_client/openai.py b/src/xgen_agent_runtime/llm_client/openai.py index f124a32a..970a219f 100644 --- a/src/xgen_agent_runtime/llm_client/openai.py +++ b/src/xgen_agent_runtime/llm_client/openai.py @@ -29,7 +29,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional from urllib.parse import urlsplit -from xgen_agent_runtime.core.errors import APIError, ErrorCategory +from xgen_agent_runtime.core.errors import APIError, ErrorCategory, is_context_overflow_message from xgen_agent_runtime.core.state import TokenUsage from xgen_agent_runtime.llm_client.base import BaseClient, ClientCapabilities from xgen_agent_runtime.llm_client.translators import ( @@ -1029,6 +1029,9 @@ async def _stream_responses_once( category = ( ErrorCategory.RATE_LIMITED if "rate" in code + else ErrorCategory.TOKEN_LIMIT + if code == "context_length_exceeded" + or is_context_overflow_message(str(message)) else ErrorCategory.SERVER_ERROR if code in ("server_error", "") else ErrorCategory.BAD_REQUEST @@ -1156,7 +1159,14 @@ def _classify_error(self, e: Exception) -> APIError: if isinstance(e, openai.AuthenticationError): return APIError(str(e), category=ErrorCategory.AUTH, status_code=401, cause=e) if isinstance(e, openai.BadRequestError): - return APIError(str(e), category=ErrorCategory.BAD_REQUEST, status_code=400, cause=e) + # The prompt did not fit: TOKEN_LIMIT lets Stage 6 compact and retry once (4.89.0). + # OpenAI, vLLM, Azure and OpenAI-compatible servers all land here. + category = ( + ErrorCategory.TOKEN_LIMIT + if is_context_overflow_message(str(e)) + else ErrorCategory.BAD_REQUEST + ) + return APIError(str(e), category=category, status_code=400, cause=e) if isinstance(e, openai.InternalServerError): return APIError(str(e), category=ErrorCategory.SERVER_ERROR, status_code=500, cause=e) if isinstance(e, APIError): diff --git a/src/xgen_agent_runtime/llm_client/translators/_cli.py b/src/xgen_agent_runtime/llm_client/translators/_cli.py index d0ea1489..c6206895 100644 --- a/src/xgen_agent_runtime/llm_client/translators/_cli.py +++ b/src/xgen_agent_runtime/llm_client/translators/_cli.py @@ -102,6 +102,31 @@ def resolve_auth_channel(auth_mode: str, has_api_key: bool) -> str: return "api_key" if has_api_key else "oauth" +#: One argv string must stay under Linux ``MAX_ARG_STRLEN`` (131,072 bytes). Above this the +#: Claude Code client moves the system prompt to a file and the prompt to stdin. +ARGV_SAFE_BYTES = 100_000 + + +def system_prompt_text(system: Any) -> str: + """The system prompt as one string (Anthropic-shaped blocks: text parts only).""" + if not system: + return "" + if isinstance(system, str): + return system + if isinstance(system, list): + parts: List[str] = [] + for block in system: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text", ""))) + return "\n".join(parts) + return str(system) + + +def fits_argv(text: str) -> bool: + """True when *text* is safe as a single argv string.""" + return len((text or "").encode("utf-8")) <= ARGV_SAFE_BYTES + + def claude_code_argv( request: APIRequest, *, @@ -115,9 +140,17 @@ def claude_code_argv( allow_tools: Sequence[str] = (), disallow_tools: Sequence[str] = (), extra_args: Sequence[str] = (), + system_prompt_file: str = "", ) -> List[str]: """Build the argv list (excluding the binary) for one Claude Code call. + ``system_prompt_file``: when set, the system prompt travels as + ``--system-prompt-file `` instead of inline. Linux caps ONE argv + string at ``MAX_ARG_STRLEN`` (128 KiB); a long system prompt (a memory + node's earlier conversation, a large skill set: Korean is 3 bytes a + character) made ``exec`` fail with E2BIG before the CLI even started. + The client writes the file (:data:`ARGV_SAFE_BYTES`). + The mapping is intentionally narrow — only flags Claude Code's ``--print`` mode honours. Fields the CLI does not accept (temperature, top_p, top_k, stop_sequences, tool_choice) are dropped silently by the @@ -183,18 +216,10 @@ def claude_code_argv( argv += ["--model", str(request.model)] # System prompt: --system-prompt fully replaces the CLI's default. - if request.system: - if isinstance(request.system, str): - sys_text = request.system - elif isinstance(request.system, list): - # Anthropic-shaped system blocks — flatten text only. - parts: List[str] = [] - for block in request.system: - if isinstance(block, dict) and block.get("type") == "text": - parts.append(str(block.get("text", ""))) - sys_text = "\n".join(parts) - else: - sys_text = str(request.system) + if system_prompt_file: + argv += ["--system-prompt-file", system_prompt_file] + else: + sys_text = system_prompt_text(request.system) if sys_text: argv += ["--system-prompt", sys_text] diff --git a/src/xgen_agent_runtime/memory/retriever.py b/src/xgen_agent_runtime/memory/retriever.py index b42bc3f8..e01c5af2 100644 --- a/src/xgen_agent_runtime/memory/retriever.py +++ b/src/xgen_agent_runtime/memory/retriever.py @@ -79,6 +79,8 @@ def _scan_rows(hooks: MemoryHooks) -> int: #: 뺄 분류(``search_exclude_categories``)가 있으면 거른 뒤에도 k 개가 남도록 이 배수만큼 더 받는다. _EXCLUDED_OVERFETCH = 5 +#: 압축 기록이 쌓이는 노트 분류(``record_compaction``). 자동 검색 층에서 늘 빠진다. +_COMPACTIONS_CATEGORY = "compactions" def _layer_cap(hooks: MemoryHooks, layer: str) -> int: @@ -379,11 +381,17 @@ async def _fetch_summary() -> Any: # Ambient-noise exclusion for the AUTOMATIC search layers (explicit # memory_search tool calls are unaffected): screen-observation style # buffers can dominate a vault and drown real recall. - _excluded = set(getattr(hooks, "search_exclude_categories", ()) or ()) - _fetch_k = hooks.max_results * _EXCLUDED_OVERFETCH if _excluded else hooks.max_results + _configured_excluded = set(getattr(hooks, "search_exclude_categories", ()) or ()) + _fetch_k = ( + hooks.max_results * _EXCLUDED_OVERFETCH if _configured_excluded else hooks.max_results + ) + # 압축 기록("compactions")은 늘 뺀다. 이 세션의 것은 체크포인트로 이미 messages 에 있고, + # 지난 세션의 것은 같은 대화의 아카이브를 줄인 판이다. 자동 층에 올리면 같은 요약이 "관련 + # 지식" 으로 한 번 더 들어간다. 명시적 memory_search 는 그대로 찾는다. + _excluded = _configured_excluded | {_COMPACTIONS_CATEGORY} def _drop_excluded(hits): - if not _excluded or not hits: + if not hits: return hits kept = [] for h in hits: @@ -789,6 +797,8 @@ async def _load_vector( for h in hits: if _is_current_session_record(h, getattr(self, "_current_session_id", "")): continue + if (getattr(h, "metadata", None) or {}).get("category") == _COMPACTIONS_CATEGORY: + continue # 프리페치 없이 불린 경로도 같은 규칙(위 _drop_excluded) text = h.content or "" if not text or _dedup_key(h) in already: continue diff --git a/src/xgen_agent_runtime/memory/strategy.py b/src/xgen_agent_runtime/memory/strategy.py index e548d54f..9c64a18d 100644 --- a/src/xgen_agent_runtime/memory/strategy.py +++ b/src/xgen_agent_runtime/memory/strategy.py @@ -92,7 +92,16 @@ async def update(self, state: PipelineState) -> None: if provider is None: return last_recorded = _recorded_upto(state) - new_msgs = state.messages[last_recorded:] + # Messages a mid-turn compaction summarized away before they were recorded come first + # (core.compaction keeps them for exactly this). + from xgen_agent_runtime.core.compaction import ( + RECORDER_STM, + peek_pending_records, + take_pending_records, + ) + + compacted = peek_pending_records(state, RECORDER_STM) + new_msgs = compacted + list(state.messages[last_recorded:]) if not new_msgs: return @@ -112,6 +121,8 @@ async def update(self, state: PipelineState) -> None: state.metadata[_RECORDED_KEY] = len(state.messages) state.metadata[_STAGE_RECORDED_KEY] = len(state.messages) + if compacted: + take_pending_records(state, RECORDER_STM) if recorded: try: state.add_event( diff --git a/src/xgen_agent_runtime/slash_commands/built_in/compact.py b/src/xgen_agent_runtime/slash_commands/built_in/compact.py index 66555627..f3838bb6 100644 --- a/src/xgen_agent_runtime/slash_commands/built_in/compact.py +++ b/src/xgen_agent_runtime/slash_commands/built_in/compact.py @@ -1,8 +1,8 @@ -"""``/compact`` — manually trigger context summarization (Stage 19).""" +"""``/compact``: summarize the conversation so far at the next model request.""" from __future__ import annotations -from xgen_agent_runtime.slash_commands.built_in._helpers import find_strategy, need_pipeline +from xgen_agent_runtime.core.shared_keys import SharedKeys from xgen_agent_runtime.slash_commands.types import ( SlashCategory, SlashCommand, @@ -12,41 +12,31 @@ class CompactCommand(SlashCommand): + """Queue a manual compaction on the live pipeline state. + + Runtime compaction belongs to Stage 2 (``core.compaction.run_compaction``): it consumes + ``SharedKeys.CONTEXT_COMPACTION_REQUEST`` at the start of the next pass and summarizes + synchronously, ``trigger="manual"``: even when the context is not yet near the window. + The command only places that request; the host passes the state as + ``ctx.extras["state"]``. (Earlier versions looked for a "Stage 19 summarize strategy", + which no pipeline registers, so the command always reported failure.) + """ + name = "compact" - description = "Manually trigger context summarization (Stage 19)." + description = "Summarize the conversation so far before the next model request." category = SlashCategory.CONTROL async def execute(self, args, ctx: SlashContext) -> SlashResult: - early = need_pipeline(ctx) - if early is not None: - return early - summarizer = ( - find_strategy(ctx.pipeline, "summarize_strategy") - or find_strategy(ctx.pipeline, "summarizer") - or find_strategy(ctx.pipeline, "summary_provider") - ) - if summarizer is None: - return SlashResult( - content="No summarize strategy configured (Stage 19 inactive).", - success=False, - ) - for method in ("summarize_now", "compact", "run"): - fn = getattr(summarizer, method, None) - if callable(fn): - try: - result = fn() - if hasattr(result, "__await__"): - result = await result - except Exception as exc: # noqa: BLE001 - return SlashResult(content=f"Compaction failed: {exc}", success=False) - tokens_saved = getattr(result, "tokens_compressed", None) if result else None - msg = "✓ Context summarized." - if tokens_saved: - msg += f" ({tokens_saved} tokens compressed)" - return SlashResult(content=msg) + state = (ctx.extras or {}).get("state") + if state is None or not isinstance(getattr(state, "shared", None), dict): + return SlashResult(content="No active conversation state to compact.", success=False) + request = {"source": "manual", "iteration": int(getattr(state, "iteration", 0) or 0)} + state.shared[SharedKeys.CONTEXT_COMPACTION_REQUEST] = request + add_event = getattr(state, "add_event", None) + if callable(add_event): + add_event("context.compaction_requested", dict(request)) return SlashResult( - content="Summarize strategy does not expose summarize_now / compact / run.", - success=False, + content="✓ The conversation will be summarized before the next model request." ) diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py index b0f28973..94b293de 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py @@ -2,7 +2,10 @@ from __future__ import annotations -from typing import Any, Callable, Dict, Optional +import asyncio +import json +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple from xgen_agent_runtime.core.config import ModelConfig from xgen_agent_runtime.core.message_repair import strip_leading_orphan_tool_results @@ -139,33 +142,218 @@ async def compact(self, state: PipelineState) -> None: ] + recent +#: Opens every synthetic checkpoint message: how a later compaction (and a reader of the transcript) +#: tells a checkpoint from something the user said. +CHECKPOINT_MARKER = "[Context checkpoint]" +_CHECKPOINT_INTRO = ( + f"{CHECKPOINT_MARKER} The conversation before this point no longer fit the context window, so it " + "was compacted. Below is a summary of it, written so you can continue the work without the " + "earlier messages." +) +#: The assistant turn that keeps roles alternating after the checkpoint (only when the next kept +#: message is a user message). +CHECKPOINT_ACK = "Understood. I have the checkpoint summary and will continue from it." +#: Acks written by runtimes before 4.89.0: recognised so a later compaction folds them in too. +_LEGACY_ACKS = ("Understood, I have the context from our previous conversation.",) + +#: ``state.shared`` slot carrying verbatim user messages across compactions within a turn. +PINNED_USERS_KEY = "_compaction_pinned_users" + +_SUMMARY_PROMPT = """You are compacting the history of an ongoing agent conversation because it no longer fits the model's context window. Write a handoff summary that lets the agent continue the work seamlessly WITHOUT the older messages. + +Rules: +- Write in the language the conversation uses. +- Plain text only. Do not call tools. +- Facts found by tools must survive: the raw tool output will be gone. Keep concrete file paths, identifiers, commands, numbers, error messages and URLs. +- If the transcript starts with an earlier checkpoint summary, fold everything it says into this one: this summary replaces it. +- Be complete on facts and terse on prose. Stay under about {words} words. + +Use these sections (skip one only if it would be empty): +1. User requests and intent: every request, constraint and preference the user stated; quote the wording where it matters. +2. Work done: what was tried, which tools ran on what, and what came back. +3. Decisions: what was decided and why. +4. Problems: errors met and how they were resolved, or that they are still open. +5. Open items: what remains to be done. +6. Current state: what was being worked on right before this point, and the immediate next step. + + +{transcript} +""" + +#: Per-message clipping inside the summarizer's transcript (chars). +_CLIP_USER = 6_000 +_CLIP_ASSISTANT = 4_000 +_CLIP_TOOL_INPUT = 400 +_CLIP_TOOL_RESULT_HEAD = 1_500 +_CLIP_TOOL_RESULT_TAIL = 500 +#: Verbatim user messages kept beside the summary: share of the window, with an absolute cap. +_PINNED_RATIO = 0.08 +_PINNED_MAX_TOKENS = 20_000 +#: Summary length (output tokens): window/16, between these bounds. +_SUMMARY_MIN_TOKENS = 1_024 +_SUMMARY_MAX_TOKENS = 4_096 +#: OpenAI reasoning models spend completion tokens on reasoning before the text. +_REASONING_HEADROOM = 4_096 +_OPENAI_FAMILY = ("openai", "azure", "azure_foundry") + + +def _content_blocks(content: Any) -> list: + if isinstance(content, list): + return [b for b in content if isinstance(b, dict)] + if isinstance(content, str): + return [{"type": "text", "text": content}] if content else [] + return [] + + +def _message_text(message: Any) -> str: + if not isinstance(message, dict): + return "" + content = message.get("content", "") + if isinstance(content, str): + return content + return "\n".join( + str(b.get("text", "")) for b in _content_blocks(content) if b.get("type") == "text" + ) + + +def _is_tool_result_only(message: Any) -> bool: + if not isinstance(message, dict) or message.get("role") != "user": + return False + blocks = _content_blocks(message.get("content")) + return bool(blocks) and all(b.get("type") == "tool_result" for b in blocks) + + +def _is_checkpoint(message: Any) -> bool: + return ( + isinstance(message, dict) + and message.get("role") == "user" + and _message_text(message).lstrip().startswith(CHECKPOINT_MARKER) + ) + + +def _is_ack(message: Any) -> bool: + return ( + isinstance(message, dict) + and message.get("role") == "assistant" + and _message_text(message).strip() in (CHECKPOINT_ACK,) + _LEGACY_ACKS + ) + + +def _is_real_user(message: Any) -> bool: + """A user message a person wrote (not a tool result, not a checkpoint).""" + return ( + isinstance(message, dict) + and message.get("role") == "user" + and not _is_tool_result_only(message) + and not _is_checkpoint(message) + and bool(_message_text(message).strip()) + ) + + +def _clip(text: str, limit: int, tail: int = 0) -> str: + text = str(text or "") + if len(text) <= limit: + return text + if tail <= 0: + tail = limit // 4 + head = max(0, limit - tail) + return f"{text[:head]}\n… [{len(text) - head - tail} chars omitted] …\n{text[-tail:]}" + + +def _render_block(role: str, block: Dict[str, Any]) -> List[str]: + btype = block.get("type") + if btype == "text": + limit = _CLIP_USER if role == "user" else _CLIP_ASSISTANT + text = str(block.get("text") or "").strip() + return [f"{role}: {_clip(text, limit)}"] if text else [] + if btype == "tool_use": + try: + args = json.dumps(block.get("input"), ensure_ascii=False, default=str) + except (TypeError, ValueError): + args = str(block.get("input")) + return [f"assistant → tool {block.get('name', '?')}({_clip(args, _CLIP_TOOL_INPUT)})"] + if btype == "tool_result": + inner = block.get("content") + if isinstance(inner, list): + parts = [] + for sub in inner: + if isinstance(sub, dict) and sub.get("type") == "text": + parts.append(str(sub.get("text") or "")) + elif isinstance(sub, dict) and sub.get("type") in ("image", "document"): + parts.append(f"[{sub.get('type')}]") + inner = "\n".join(parts) + text = str(inner or "").strip() + label = "tool error" if block.get("is_error") else "tool result" + body = _clip( + text, _CLIP_TOOL_RESULT_HEAD + _CLIP_TOOL_RESULT_TAIL, tail=_CLIP_TOOL_RESULT_TAIL + ) + return [f"← {label}: {body or '(empty)'}"] + if btype in ("image", "image_url", "input_image"): + return [f"{role}: [image]"] + if btype == "document": + return [f"{role}: [document]"] + return [] + + +def _render_message(message: Any) -> List[str]: + if not isinstance(message, dict): + return [] + role = str(message.get("role") or "") + if _is_checkpoint(message): + return ["(earlier checkpoint summary)\n" + _message_text(message).strip()] + if _is_ack(message): + return [] + lines: List[str] = [] + for block in _content_blocks(message.get("content")): + lines.extend(_render_block(role, block)) + return lines + + +@dataclass +class CompactionPlan: + """What :meth:`LLMSummaryCompactor.compact` would do: also what the runner's gate reads.""" + + #: ``messages[:cut]`` are summarized, ``messages[cut:]`` are kept verbatim. + cut: int + #: Estimated tokens of the NEW material in the summarized region (an earlier checkpoint excluded). + region_tokens: int + #: Earlier user messages kept verbatim beside the summary, oldest first. + pinned: List[str] + #: The request this turn is working on, when it falls inside the summarized region. + current_request: str = "" + + class LLMSummaryCompactor(SummaryCompactor): - """Summary compactor that calls ``state.llm_client`` when an override is set. - - Falls back to the static :class:`SummaryCompactor` placeholder when: - - no model override is configured on the hosting stage, or - - ``state.llm_client`` is ``None``, or - - the LLM call raises. - - This preserves the no-cost-by-default rule: pipelines that don't opt - into per-stage compaction models see no new LLM calls. - - Args: - keep_recent: Number of recent messages to keep verbatim. - summary_text: Optional static fallback; used when the LLM returns - empty text. - resolve_cfg: Callable taking ``state`` and returning the effective - :class:`ModelConfig`. Typically bound to - ``lambda s: parent_stage.resolve_model_config(s)`` by the - enclosing stage. - has_override: Callable returning True iff the enclosing stage has - an explicit override. When False, the compactor skips the LLM - call even though ``resolve_cfg`` would still return a config - (built from state defaults). - client_getter: Callable taking ``state`` and returning the - :class:`BaseClient`. Defaults to ``state.llm_client``. + """Summarizes the older part of the conversation with the agent's own model. + + What survives a compaction (4.89.0) + ----------------------------------- + * **Recent messages verbatim**: the longest recent suffix that fits the target, at most + ``keep_recent`` messages, never opening on a tool result whose call was dropped, and always + at least the last exchange. + * **What the user asked, verbatim**: earlier user messages within a budget (8% of the window, + at most 20k tokens), newest first, and always the request the turn is working on even when it + is in the summarized part. The summary is lossy; the user's words should not be. + * **A structured summary** of the rest, built from user and assistant text AND tool calls and + their (clipped) results: facts found by tools used to vanish because only text blocks were + summarized. When the transcript is too long for the summarizer it keeps the NEWEST part (and + any earlier checkpoint): it used to keep the oldest 12,000 characters, dropping exactly the + context closest to the current work. + + Failure + ------- + The summary call is retried once. If there is still no summary: under soft pressure (the + proactive 80% trigger) the history is left as it is: the guard still protects the request; + under hard pressure (guard, provider overflow, explicit request) a deterministic digest of the + summarized part replaces it. Before 4.89.0 any failure replaced the older history with a + one-line placeholder. + + No model wired (no ``state.model``/client, or a manifest without an override) counts as a + failure without a call: the no-cost-by-default rule still holds. """ + uses_llm = True + def __init__( self, keep_recent: int = 10, @@ -186,16 +374,165 @@ def name(self) -> str: @property def description(self) -> str: - return "LLM-backed summary compactor (falls back to placeholder when no override is set)" + return "LLM-backed summary compactor (falls back to a deterministic digest when no model is wired)" + + # ── planning ───────────────────────────────────────────────────── + + @staticmethod + def _summary_tokens(window: int, provider: str = "") -> int: + tokens = min(_SUMMARY_MAX_TOKENS, max(_SUMMARY_MIN_TOKENS, window // 16)) + if provider in _OPENAI_FAMILY: + tokens = min(tokens + _REASONING_HEADROOM, max(_SUMMARY_MIN_TOKENS, window // 8)) + return tokens + + def plan( + self, state: PipelineState, target_tokens: Optional[int] = None + ) -> Optional[CompactionPlan]: + """Where to cut, what to pin. ``None`` when there is nothing to summarize.""" + from xgen_agent_runtime.core.compaction import TARGET_RATIO, window_of + from xgen_agent_runtime.core.token_estimate import ( + estimate_messages_scaled, + estimate_overhead_tokens, + estimate_text_scaled, + ) - async def compact(self, state: PipelineState) -> None: - if len(state.messages) <= self._keep_recent: - return + msgs = list(state.messages or []) + n = len(msgs) + if n <= 1: + return None + window = window_of(state) + target = int(target_tokens or window * TARGET_RATIO) + provider = str(getattr(self._client_getter(state), "provider", "") or "") + + # Minimal tail: the last message; when it is a tool result, also the call it answers. + cut = n - 1 + if _is_tool_result_only(msgs[cut]) and cut > 0 and msgs[cut - 1].get("role") == "assistant": + cut -= 1 + + pinned_budget = min(int(window * _PINNED_RATIO), _PINNED_MAX_TOKENS) + budget = ( + target + - estimate_overhead_tokens(state) + - self._summary_tokens(window, provider) + - 400 + - pinned_budget + ) + kept_tokens = estimate_messages_scaled(state, msgs[cut:]) + while cut > 0 and (n - (cut - 1)) <= max(1, self._keep_recent): + t = estimate_messages_scaled(state, [msgs[cut - 1]]) + if kept_tokens + t > budget: + break + kept_tokens += t + cut -= 1 + # Never open the kept part on a tool result whose call is being summarized. + while cut < n - 1 and _is_tool_result_only(msgs[cut]): + cut += 1 + if cut <= 0: + return None + + region = msgs[:cut] + start = 0 + if region and _is_checkpoint(region[0]): + start = 1 + if len(region) > 1 and _is_ack(region[1]): + start = 2 + new_material = region[start:] + if not new_material: + return None # only an earlier checkpoint would be summarized again + + current = "" + for m in reversed(msgs): + if _is_real_user(m): + current = _message_text(m).strip() if any(m is r for r in region) else "" + break + + carried = list(state.shared.get(PINNED_USERS_KEY) or []) + candidates = carried + [_message_text(m).strip() for m in new_material if _is_real_user(m)] + if current and candidates and candidates[-1] == current: + candidates = candidates[:-1] # shown separately as the request being worked on + pinned: List[str] = [] + used = estimate_text_scaled(state, current) if current else 0 + for text in reversed(candidates): # newest first + cost = estimate_text_scaled(state, text) + if used + cost > pinned_budget: + break + pinned.insert(0, text) + used += cost + if current and estimate_text_scaled(state, current) > pinned_budget: + current = _clip(current, max(400, int(pinned_budget))) + + return CompactionPlan( + cut=cut, + region_tokens=estimate_messages_scaled(state, new_material), + pinned=pinned, + current_request=current, + ) - # Resolve the model config. Explicit wiring wins; otherwise self-wire - # from the live state's model so selecting the ``llm_summary`` compactor - # in a manifest "just works" (proper LLM compaction instead of the - # static placeholder) without the host threading resolve_cfg through. + # ── transcript ─────────────────────────────────────────────────── + + def _transcript(self, state: PipelineState, region: List[Any], budget_tokens: int) -> str: + """Render the summarized region, keeping the NEWEST lines (and an earlier checkpoint).""" + from xgen_agent_runtime.core.token_estimate import estimate_text_scaled + + rendered: List[List[str]] = [_render_message(m) for m in region] + head: List[str] = [] + body = rendered + if region and _is_checkpoint(region[0]): + head = [_clip("\n".join(rendered[0]), max(2_000, int(budget_tokens * 0.3)))] + body = rendered[1:] + lines = [line for chunk in body for line in chunk] + used = sum(estimate_text_scaled(state, h) for h in head) + kept: List[str] = [] + for line in reversed(lines): + cost = estimate_text_scaled(state, line) + 1 + if used + cost > budget_tokens: + break + kept.insert(0, line) + used += cost + omitted = len(lines) - len(kept) + if omitted: + kept.insert(0, f"[… {omitted} earlier lines omitted to fit the summarizer …]") + return "\n".join(head + kept) + + # ── fallbacks ──────────────────────────────────────────────────── + + def _digest(self, region: List[Any], old_count: int) -> str: + """Deterministic stand-in when no model summary could be made: no call, no invention.""" + lines = [ + self._summary_text + or ( + f"[Summary of {old_count} previous messages. The model summary was unavailable, " + "so this is a mechanical digest of what happened.]" + ) + ] + calls: List[str] = [] + last_text = "" + for m in region: + if not isinstance(m, dict): + continue + if _is_checkpoint(m): + lines.append("Earlier checkpoint:\n" + _clip(_message_text(m).strip(), 3_000)) + continue + for block in _content_blocks(m.get("content")): + if block.get("type") == "tool_use": + try: + args = json.dumps(block.get("input"), ensure_ascii=False, default=str) + except (TypeError, ValueError): + args = str(block.get("input")) + calls.append(f"- {block.get('name', '?')}({_clip(args, 160)})") + if m.get("role") == "assistant" and not _is_ack(m): + text = _message_text(m).strip() + if text: + last_text = text + if calls: + lines.append("Tool calls made (most recent last):\n" + "\n".join(calls[-20:])) + if last_text: + lines.append("Last assistant note:\n" + _clip(last_text, 1_500)) + return "\n\n".join(lines) + + # ── compaction ─────────────────────────────────────────────────── + + def _resolve(self, state: PipelineState) -> Tuple[Optional[Callable], bool]: resolve = self._resolve_cfg has_override = self._has_override() if resolve is None: @@ -203,97 +540,134 @@ async def compact(self, state: PipelineState) -> None: if state_model: resolve = lambda s: ModelConfig( # noqa: E731 model=getattr(s, "model", state_model) or state_model, - max_tokens=2048, + max_tokens=_SUMMARY_MAX_TOKENS, temperature=0.0, thinking_enabled=False, ) has_override = True + return resolve, has_override - if not has_override or resolve is None: - await super().compact(state) - return + async def _summarize( + self, state: PipelineState, region: List[Any], resolve: Callable, client: Any + ) -> Tuple[Optional[str], Any, int]: + """(summary or None, model config, attempts).""" + from dataclasses import replace as _replace - client = self._client_getter(state) - if client is None: - await super().compact(state) - return + from xgen_agent_runtime.core.compaction import window_of - old_count = len(state.messages) - self._keep_recent - old_msgs = state.messages[: -self._keep_recent] - recent = _safe_recent(state.messages, self._keep_recent) - - transcript_lines = [] - for m in old_msgs: - role = m.get("role", "") - content = m.get("content", "") - if isinstance(content, list): - text_parts = [ - b.get("text", "") - for b in content - if isinstance(b, dict) and b.get("type") == "text" - ] - content = "\n".join(text_parts) - transcript_lines.append(f"{role}: {content}") - transcript = "\n".join(transcript_lines)[:12000] - - prompt = ( - "The conversation context is too long for the model. Compress the OLDER " - "portion below into a faithful recap so the agent loses nothing " - "load-bearing. ALWAYS preserve: concrete facts & figures, the user's " - "requests / preferences / commitments, decisions made and why, named " - "entities, and any unresolved items / open threads. Drop only redundant " - "chatter. Keep it under ~500 words; write a flowing recap, not a bullet " - "list, in the conversation's own language.\n\n" - f"\n{transcript}\n" - ) + window = window_of(state) + provider = str(getattr(client, "provider", "") or "") + summary_tokens = self._summary_tokens(window, provider) + transcript_budget = max(2_000, min(int(window * 0.6), window - summary_tokens - 3_000)) + transcript = self._transcript(state, region, transcript_budget) + words = max(300, min(1_500, summary_tokens // 3)) + prompt = _SUMMARY_PROMPT.format(words=words, transcript=transcript) cfg = resolve(state) if getattr(cfg, "thinking_level", None): - # 사용자가 고른 생각의 강도는 답을 위한 것이다 — 요약은 모델 기본으로(예전과 같게). - from dataclasses import replace as _replace - + # The user's thinking level is for answers: the summary uses the model default. cfg = _replace(cfg, thinking_level=None) - try: - resp = await client.create_message( - model_config=cfg, - messages=[{"role": "user", "content": prompt}], - purpose="s02.compact", - ) - # 압축도 모델을 부른 것이다: 메인 루프 밖 호출이라 보조 사용량으로 남긴다. - # (백그라운드 압축은 state 대신 그림자를 받는다. 기록이 압축을 깨면 안 된다.) + if self._resolve_cfg is None and getattr(cfg, "max_tokens", 0) != summary_tokens: + cfg = _replace(cfg, max_tokens=summary_tokens) + + attempts = 0 + for attempt in range(2): + attempts = attempt + 1 + try: + resp = await client.create_message( + model_config=cfg, + messages=[{"role": "user", "content": prompt}], + purpose="s02.compact", + ) + except Exception as exc: # noqa: BLE001 (retried once, then the caller decides) + state.add_event( + "memory.compaction.llm_failed", + {"error": str(exc), "compactor": self.name, "attempt": attempts}, + ) + if attempt == 0: + await asyncio.sleep(1.0) + continue + # The summary is a model call outside the main loop: report it as auxiliary usage. + # (Background compaction gets a shadow instead of the state; recording must not break it.) _record_aux = getattr(state, "add_aux_usage", None) if callable(_record_aux): try: _record_aux("compact", getattr(resp, "usage", None)) except Exception: # noqa: BLE001 pass - summary_text = (resp.text or "").strip() - if not summary_text: - summary_text = self._summary_text or ( - f"[Summary of {old_count} previous messages.]" - ) - except Exception as exc: + text = (resp.text or "").strip() + if text: + return text, cfg, attempts state.add_event( "memory.compaction.llm_failed", - {"error": str(exc), "compactor": self.name}, + {"error": "empty summary", "compactor": self.name, "attempt": attempts}, ) - await super().compact(state) - return + return None, cfg, attempts - state.messages = [ - {"role": "user", "content": summary_text}, - { - "role": "assistant", - "content": "Understood, I have the context from our previous conversation.", - }, - ] + recent + async def compact(self, state: PipelineState) -> None: + from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY + + request = ( + state.shared.get(COMPACTION_CONTEXT_KEY) if isinstance(state.shared, dict) else None + ) + request = request if isinstance(request, dict) else {} + # A direct call (no runner request) is an explicit ask to compact. + hard = bool(request.get("hard", True)) + outline = self.plan(state, request.get("target_tokens")) + if outline is None: + return + msgs = list(state.messages) + region = msgs[: outline.cut] + kept = msgs[outline.cut :] + old_count = len(region) + + resolve, has_override = self._resolve(state) + client = self._client_getter(state) if (has_override and resolve is not None) else None + summary: Optional[str] = None + cfg: Any = None + attempts = 0 + # digest_only: the turn already used its model summaries (core.compaction) but the request + # cannot go out as it is: relieve it without another model call. + if client is not None and resolve is not None and not request.get("digest_only"): + summary, cfg, attempts = await self._summarize(state, region, resolve, client) + if summary is None: + if not hard: + state.add_event( + "context.compaction_deferred", + {"compactor": self.name, "reason": "no summary", "attempts": attempts}, + ) + return # soft pressure: losing history is worse than waiting for the guard + summary = self._digest(region, old_count) + source = "digest" + else: + source = "model" + + sections = [_CHECKPOINT_INTRO, summary] + if outline.pinned: + sections.append( + "Earlier user messages, verbatim (oldest first):\n" + + "\n".join(f"- {text}" for text in outline.pinned) + ) + if outline.current_request: + sections.append("The request being worked on, verbatim:\n" + outline.current_request) + head: List[Dict[str, Any]] = [{"role": "user", "content": "\n\n".join(sections)}] + if kept and isinstance(kept[0], dict) and kept[0].get("role") == "user": + head.append({"role": "assistant", "content": CHECKPOINT_ACK}) + state.messages = head + strip_leading_orphan_tool_results(kept) + state.shared[PINNED_USERS_KEY] = list(outline.pinned) + ( + [outline.current_request] if outline.current_request else [] + ) state.add_event( "memory.compaction.summarized", { - "model": cfg.model, - "provider": getattr(client, "provider", ""), + "model": getattr(cfg, "model", "") if cfg is not None else "", + "provider": getattr(client, "provider", "") if client is not None else "", "old_count": old_count, - "summary_chars": len(summary_text), + "kept_count": len(kept), + "pinned_user_messages": len(outline.pinned) + (1 if outline.current_request else 0), + "summary_chars": len(summary), + "source": source, + "attempts": attempts, }, ) diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py index d2e4ab96..49c07c68 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py @@ -6,7 +6,13 @@ import logging from typing import Any, Dict, List, Optional -from xgen_agent_runtime.core.compaction import reconcile_recorded_index, run_compaction +from xgen_agent_runtime.core.compaction import ( + PROACTIVE_RATIO, + TARGET_RATIO, + reconcile_recorded_index, + run_compaction, + window_of, +) from xgen_agent_runtime.core.schema import ConfigField, ConfigSchema from xgen_agent_runtime.core.shared_keys import SharedKeys from xgen_agent_runtime.core.slot import StrategySlot @@ -60,7 +66,14 @@ def __init__(self, state: PipelineState): self.model = getattr(state, "model", "") self.llm_client = getattr(state, "llm_client", None) self.context_window_budget = getattr(state, "context_window_budget", 200_000) - self.shared: Dict[str, Any] = {} + # Planning reads the system prompt/tool size and the turn's calibration. + self.system = getattr(state, "system", "") + self.tools = list(getattr(state, "tools", None) or []) + self.shared: Dict[str, Any] = { + k: v + for k, v in (getattr(state, "shared", None) or {}).items() + if k in ("_prompt_tokens_calibration", "_compaction_pinned_users") + } self.events: List[tuple] = [] #: 압축 호출의 보조 사용량. 결과를 받을 때 실제 state 로 옮긴다(이벤트와 같다). self.aux_usage: List[tuple] = [] @@ -377,6 +390,14 @@ async def _preload_short_term_window(self, state: PipelineState) -> None: cfg = WindowConfig.from_hooks(self._window_hooks()) if not cfg.enabled: return + # 창은 대화 기록의 일부일 뿐이다. 작은 모델(vLLM 32k)에서 4만 자 창은 그것만으로 80% 압축 + # 문턱을 넘어, 매 턴 첫 호출 전에 같은 지난 턴을 다시 요약했다. 창을 모델 창의 20% 안으로 둔다. + from xgen_agent_runtime.core.compaction import window_of + from xgen_agent_runtime.core.text_tokens import chars_for_tokens + + cfg.max_chars = max( + 1_000, min(cfg.max_chars, chars_for_tokens(int(window_of(state) * 0.2))) + ) try: window, report = await asyncio.wait_for( load_window(self._provider, cfg), timeout=self._retrieval_timeout_s or None @@ -469,20 +490,16 @@ async def execute(self, input: Any, state: PipelineState) -> Any: ) state.metadata["memory_context"] = memory_text - # Proactive compaction: when the projected next-call context - # (system + messages + tools) crosses 80% of the window, compact - # so the Stage 4 token-budget guard's 95% safety net rarely has - # to. Both stages use ``estimate_prompt_tokens`` so compaction - # measurably lowers the same number the guard checks. + # Proactive compaction (4.89.0 policy, core.compaction): when the projected next call + # (system + messages + tools) crosses 80% of the window, relieve it down to 50%: deterministic + # prune first, a model summary only when that is not enough. Stage 4 runs the same + # check after Stage 3, which is the only place the FIRST iteration's estimate includes the + # system prompt and tools; whichever runs first marks the request so the other does not + # summarize it twice. # - # TTFT program (finding B3): an LLM-backed compactor is a whole - # second model round-trip that used to run synchronously in - # front of the first token. Now: a finished background summary - # is applied first (cheap list swap); if still over 80% but - # under the 90% hard line, the summary is computed in the - # BACKGROUND (overlapping this turn's generation) and applied - # at the next turn's Stage 2. Past 90% — or for cheap non-LLM - # compactors — compaction stays synchronous as the safety net. + # TTFT program (finding B3): a host that keeps its pipeline across turns may defer an + # LLM summary in the 80-90% band to the background (applied at the next turn's Stage 2). + # One-shot hosts turn that off (the result would always be discarded). estimated_tokens = estimate_prompt_tokens(state) forced_request = state.shared.pop(SharedKeys.CONTEXT_COMPACTION_REQUEST, None) @@ -500,7 +517,10 @@ async def execute(self, input: Any, state: PipelineState) -> Any: except Exception: # noqa: BLE001 — relief, never a gate logger.debug("cost-triggered prune failed", exc_info=True) else: - if any(metrics.get(k) for k in ("deduped", "images_stripped", "trimmed")): + if any( + metrics.get(k) + for k in ("deduped", "images_stripped", "trimmed", "inputs_trimmed") + ): state.shared.pop("_prompt_tokens_memo", None) before = estimated_tokens estimated_tokens = estimate_prompt_tokens(state) @@ -519,22 +539,25 @@ async def execute(self, input: Any, state: PipelineState) -> Any: if await self._apply_bg_compaction(state): state.shared.pop("_prompt_tokens_memo", None) estimated_tokens = estimate_prompt_tokens(state) - budget = state.context_window_budget + budget = window_of(state) if forced_request: - # A Stage-16 token dimension requested maintenance for - # pending work. Run synchronously: deferring again would - # let the next Stage-4/API boundary hit the hard ceiling. + # A Stage-16 token dimension (or /compact) requested maintenance for pending + # work. Run synchronously: deferring again would let the next Stage-4/API boundary + # hit the hard ceiling. self.cancel_bg_compaction() + manual = ( + isinstance(forced_request, dict) and forced_request.get("source") == "manual" + ) await run_compaction( state, self._compactor, - trigger="requested", + trigger="manual" if manual else "requested", provider=self._provider, - target_tokens=int(budget * 0.7), + target_tokens=int(budget * TARGET_RATIO), ) state.shared.pop("_prompt_tokens_memo", None) estimated_tokens = estimate_prompt_tokens(state) - elif estimated_tokens > budget * 0.8: + elif estimated_tokens > budget * PROACTIVE_RATIO: defer_to_background = ( self._background_compaction and isinstance(self._compactor, LLMSummaryCompactor) @@ -548,7 +571,7 @@ async def execute(self, input: Any, state: PipelineState) -> Any: self._compactor, trigger="proactive", provider=self._provider, - target_tokens=int(budget * 0.7), + target_tokens=int(budget * TARGET_RATIO), ) estimated_tokens = estimate_prompt_tokens(state) elif forced_request: @@ -602,7 +625,33 @@ def _schedule_bg_compaction(self, state: PipelineState) -> None: snapshot_len = len(state.messages) if snapshot_len == 0: return + from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, _llm_gate + + target = int(window_of(state) * TARGET_RATIO) + # Same policy as the synchronous path: turn limit, once per request, region worth a call. + skip = _llm_gate( + state, self._compactor, trigger="background", target_tokens=target, hard=False + ) + if skip is not None: + state.add_event( + "context.compaction_skipped", + { + "strategy": str( + getattr(self._compactor, "name", "") or type(self._compactor).__name__ + ), + "trigger": "background", + "reason": skip, + "target_tokens": target, + }, + ) + return shadow = _CompactionShadow(state) + # Soft pressure: a failed background summary must leave history alone (no digest). + shadow.shared[COMPACTION_CONTEXT_KEY] = { + "trigger": "background", + "target_tokens": target, + "hard": False, + } snapshot_ids = tuple(id(message) for message in state.messages) async def _run() -> _CompactionShadow: @@ -666,6 +715,16 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: # Keep Stage-18's STM watermark valid across the background swap # (audit D3) — same contract as run_compaction's synchronous path. reconcile_recorded_index(before_list, list(state.messages), state.metadata) + # Count it like a synchronous summary: the turn limit and "already summarized for this + # request" see it, so the proactive check right after does not summarize again. + from xgen_agent_runtime.core.compaction import ( + API_CALLS_KEY, + LLM_COMPACTIONS_KEY, + SUMMARIZED_AT_KEY, + ) + + state.shared[SUMMARIZED_AT_KEY] = int(state.shared.get(API_CALLS_KEY) or 0) + state.shared[LLM_COMPACTIONS_KEY] = int(state.shared.get(LLM_COMPACTIONS_KEY) or 0) + 1 for event_type, data in shadow.events: state.add_event(event_type, data) compactor_name = str(getattr(self._compactor, "name", "") or type(self._compactor).__name__) diff --git a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py index 67dc0f85..d770364a 100644 --- a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py @@ -153,6 +153,20 @@ def update_config(self, config: Dict[str, Any]) -> None: async def execute(self, input: Any, state: PipelineState) -> Any: guards = self._guard_chain.items + # Proactive capacity check, AFTER Stage 3 (4.89.0). Stage 2 runs the same 80% check before + # Stage 3 builds the system prompt and tool list, so on the first iteration of a turn its + # estimate is missing both: this is the first point where the estimate is complete. The + # shared policy (core.compaction) keeps the two from summarizing the same request twice. + if self._budget_compactor is not None: + from xgen_agent_runtime.core.compaction import relieve_if_over + + await relieve_if_over( + state, + self._budget_compactor, + trigger="proactive", + provider=self._memory_provider, + ) + # 2026-06-09 audit ("validated-but-inert" table): both knobs below # passed schema validation and then were never read here. An # operator who edited them saw a green check and no behaviour @@ -265,12 +279,17 @@ async def _recover_budget(self, state: PipelineState, result: GuardResult) -> bo ) # Lazy import: keeps the guard stage import-light and avoids a # core→stage cycle at module load. - from xgen_agent_runtime.core.compaction import run_compaction + from xgen_agent_runtime.core.compaction import TARGET_RATIO, run_compaction, window_of outcome = await run_compaction( state, self._budget_compactor, trigger="guard", provider=self._memory_provider, + # A prune that already lands here is enough; otherwise summarize down to it. + target_tokens=int(window_of(state) * TARGET_RATIO), + # The request cannot go out as it is: aggressive prune, no small-region skip, and a + # deterministic digest if the model summary fails. + hard=True, ) return bool(outcome.get("ok")) diff --git a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py index d646c184..a172ca4f 100644 --- a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py @@ -20,7 +20,9 @@ import time from typing import Any, AsyncIterator, Dict, List, Optional, Union +from xgen_agent_runtime.core.compaction import note_api_call from xgen_agent_runtime.core.errors import APIError, ErrorCategory, ExecutorErrorCode +from xgen_agent_runtime.core.token_estimate import record_prompt_usage from xgen_agent_runtime.core.message_repair import ( normalize_messages_for_request, retire_tool_calls_by_name, @@ -494,26 +496,43 @@ async def call_once( t_request = time.monotonic() state.shared["_api_call_t0"] = t_request state.shared.pop("_api_ttft_emitted", None) - try: + + async def _invoke() -> APIResponse: if use_stream: - response = await self._call_streaming_with_retry( - client, cfg, state, extra_messages=extra_messages - ) - else: - response = await self._call_with_retry( + return await self._call_streaming_with_retry( client, cfg, state, extra_messages=extra_messages ) - state.add_event( - "api.ttft", - { - "ttft_ms": round((time.monotonic() - t_request) * 1000.0, 1), - "provider": getattr(client, "provider", ""), - "model": cfg.model, - "stream": False, - "iteration": state.iteration, - "first_visible": "complete", - }, - ) + result = await self._call_with_retry( + client, cfg, state, extra_messages=extra_messages + ) + state.add_event( + "api.ttft", + { + "ttft_ms": round((time.monotonic() - t_request) * 1000.0, 1), + "provider": getattr(client, "provider", ""), + "model": cfg.model, + "stream": False, + "iteration": state.iteration, + "first_visible": "complete", + }, + ) + return result + + try: + try: + response = await _invoke() + except APIError as e: + # The provider said the prompt does not fit (4.89.0): compact the history the + # way the guard does and retry ONCE. Before 4.89.0 nothing acted on this and the + # turn failed: the most common way a long Korean or tool-heavy turn died, + # because the old estimate under-counted and the guard never fired. + if not ( + e.category == ErrorCategory.TOKEN_LIMIT + and not extra_messages + and await self._recover_overflow(state, e) + ): + raise + response = await _invoke() except APIError as e: # Structured error envelope (2.2.0, audit §3.2 / Tier 1-1): # before this event, a host UI that wanted "auth failed, @@ -538,6 +557,15 @@ async def call_once( error_payload["cli_version"] = str(cli_version) state.add_event("api.error", error_payload) raise + # The provider's own count of this prompt anchors every later size decision this turn + # (core.token_estimate). Only for the pipeline-mode call: an internal loop's call also + # carries ``extra_messages`` that are not in ``state.messages`` yet. + note_api_call(state) + if not extra_messages: + try: + record_prompt_usage(state, client.prompt_tokens_from_usage(response.usage)) + except Exception: # noqa: BLE001 (an estimate anchor must never fail a call) + pass state.add_event( "api.response", { @@ -569,6 +597,65 @@ async def call_once( return response + async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool: + """Compact after a provider "prompt too long" rejection. True when a retry is worthwhile. + + Uses the Context stage's compactor (``state._context_compactor``: ``None`` when the host + turned compaction off, e.g. CLI backends) as hard pressure: aggressive prune, then a model + summary, falling back to a deterministic digest. Once per request: the caller retries once + and a second rejection propagates. + """ + compactor = getattr(state, "_context_compactor", None) + if compactor is None: + return False + from xgen_agent_runtime.core.compaction import run_compaction, window_of + from xgen_agent_runtime.core.errors import parse_overflow_limit, parse_overflow_tokens + from xgen_agent_runtime.core.token_estimate import ( + calibrate_from_overflow, + estimate_prompt_tokens, + ) + + reported = parse_overflow_tokens(str(error)) + limit = parse_overflow_limit(str(error)) + estimated = estimate_prompt_tokens(state) + believed = window_of(state) + # Measure the target against the provider's own count, not the estimate it just refuted. + calibrate_from_overflow(state, reported) + if 0 < limit < believed: + # The window table or catalog was wrong for this deployment (a Bedrock region without + # 1M, an Azure deployment of a smaller model). The provider's own number wins for the + # rest of the turn: otherwise compaction aims at a window that does not exist and the + # retry fails the same way. + state.context_window_budget = limit + state.add_event( + "context.overflow", + { + "message": str(error)[:500], + "reported_tokens": reported, + "estimated_tokens": estimated, + "window": believed, + "provider_limit": limit, + }, + ) + outcome = await run_compaction( + state, + compactor, + trigger="overflow", + provider=getattr(state, "_context_memory_provider", None), + # No target: the provider already said this request does not fit, so the compactor + # runs even when our (corrected) estimate says a prune would do. It aims at + # TARGET_RATIO of the (possibly corrected) window. + target_tokens=None, + hard=True, + ) + shrunk = bool(outcome.get("ok")) and ( + bool(outcome.get("summarized")) or int(outcome.get("saved_tokens_estimate") or 0) > 0 + ) + if shrunk: + # Anything rendered from the rejected attempt is discarded before the retry. + self._signal_stream_restart(state) + return shrunk + def _build_request(self, state: PipelineState) -> APIRequest: """Assemble a canonical :class:`APIRequest` from state. diff --git a/src/xgen_agent_runtime/stages/s06_api/artifact/default/tool_loop.py b/src/xgen_agent_runtime/stages/s06_api/artifact/default/tool_loop.py index 4b940d9f..df2c2453 100644 --- a/src/xgen_agent_runtime/stages/s06_api/artifact/default/tool_loop.py +++ b/src/xgen_agent_runtime/stages/s06_api/artifact/default/tool_loop.py @@ -33,7 +33,6 @@ from xgen_agent_runtime.core.schema import ConfigField, ConfigSchema from xgen_agent_runtime.core.state import PipelineState, TokenUsage -from xgen_agent_runtime.core.token_estimate import estimate_prompt_tokens from xgen_agent_runtime.stages.s06_api.interface import ToolLoopCall, ToolLoopStrategy from xgen_agent_runtime.stages.s06_api.types import APIResponse @@ -425,19 +424,18 @@ async def run( inner_cost_usd += response.usage.cost_usd or 0.0 turns += 1 + # Same policy as Stage 2/4 (core.compaction): over 80% of the window, relieve to 50%. compactor = state._context_compactor - projected = estimate_prompt_tokens(state) - if compactor is not None and projected > state.context_window_budget * 0.8: - from xgen_agent_runtime.core.compaction import run_compaction + if compactor is not None: + from xgen_agent_runtime.core.compaction import relieve_if_over - await run_compaction( + if await relieve_if_over( state, compactor, trigger="internal_loop", provider=state._context_memory_provider, - target_tokens=int(state.context_window_budget * 0.7), - ) - state.shared.pop("_prompt_tokens_memo", None) + ): + state.shared.pop("_prompt_tokens_memo", None) response = await call(None) except BaseException: diff --git a/src/xgen_agent_runtime/stages/s10_tool/persistence.py b/src/xgen_agent_runtime/stages/s10_tool/persistence.py index b7ba40c7..7ccd69b8 100644 --- a/src/xgen_agent_runtime/stages/s10_tool/persistence.py +++ b/src/xgen_agent_runtime/stages/s10_tool/persistence.py @@ -135,6 +135,37 @@ def _summarise(body: str, *, limit: int, path: str, reachable: bool) -> str: return f"[tool result truncated: {len(body)} chars > {limit} limit]\n{where}\nPreview:\n{peek}" +#: Share of the context window one tool result may take before it is moved to disk. +_WINDOW_SHARE = 0.25 +#: Never cap below this (a 4k-window model still gets a usable result). +_WINDOW_CAP_FLOOR = 2_000 + + +def _window_cap(context: ToolContext) -> Optional[int]: + """Characters a single result may keep inline for this turn's model, or ``None`` if unknown.""" + state = getattr(context, "state_view", None) + try: + window = int(getattr(state, "context_window_budget", 0) or 0) + except (TypeError, ValueError): + window = 0 + if window <= 0: + return None + from xgen_agent_runtime.core.text_tokens import chars_for_tokens + + return max(_WINDOW_CAP_FLOOR, chars_for_tokens(int(window * _WINDOW_SHARE))) + + +def _clip_inline(body: str, limit: int) -> str: + """Head and tail of ``body`` within ``limit`` chars, with an explicit marker.""" + tail = limit // 4 + head = max(0, limit - tail - 200) + return ( + f"[tool result clipped: {len(body)} chars > {limit} for this model's context window; " + "the middle is omitted and no copy was kept: re-run with a narrower query if needed]\n" + f"{body[:head]}\n…\n{body[-tail:] if tail else ''}" + ) + + def maybe_persist_large_result( result: ToolResult, *, @@ -171,19 +202,37 @@ def maybe_persist_large_result( # Tool has already persisted externally; nothing to do. return result + # The tool's own cap ignores the model: 100k chars is ~25-90k tokens, so on a 32k-window model a + # single result used to overflow the request by itself. The effective cap is also bounded by a + # quarter of the context window (4.89.0). + window_cap = _window_cap(context) + limit = capabilities.max_result_chars + if window_cap is not None: + limit = min(limit, window_cap) + rendered = _render_content(result.content) - if len(rendered) <= capabilities.max_result_chars: + if len(rendered) <= limit: return result storage_path = context.storage_path if not storage_path: + if window_cap is not None and len(rendered) > window_cap: + # No sink, and inlining would not fit the model: keep head and tail inline instead of + # sending a request the provider will reject. + _LOG.warning( + "tool %s returned %d chars (> window cap %d) and no storage_path; clipping inline", + tool_name, + len(rendered), + window_cap, + ) + return replace(result, display_text=_clip_inline(rendered, window_cap)) # No sink configured — log once per call so ops can spot misconfig, # but return the original result so correctness is preserved. _LOG.warning( "tool %s returned %d chars (> %d) but no storage_path; inlining the full payload", tool_name, len(rendered), - capabilities.max_result_chars, + limit, ) return result @@ -217,7 +266,7 @@ def maybe_persist_large_result( if summary is None: summary = _summarise( rendered, - limit=capabilities.max_result_chars, + limit=limit, path=target_path, reachable=_reachable(target_path, context), ) diff --git a/src/xgen_agent_runtime/stages/s18_memory/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s18_memory/artifact/default/stage.py index f2feff13..bdf0b6df 100644 --- a/src/xgen_agent_runtime/stages/s18_memory/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s18_memory/artifact/default/stage.py @@ -233,7 +233,15 @@ async def _drive_provider(self, state: PipelineState) -> None: # Incrementally record any newly-appended messages as STM turns. last_idx = _recorded_upto(state) - new_msgs = state.messages[last_idx:] + # Messages a mid-turn compaction summarized away before they were recorded come first. + from xgen_agent_runtime.core.compaction import ( + RECORDER_STM, + peek_pending_records, + take_pending_records, + ) + + compacted = peek_pending_records(state, RECORDER_STM) + new_msgs = compacted + list(state.messages[last_idx:]) for msg in new_msgs: # STM also stores dehydrated copies — base64 payloads stay only # in the live ``state.messages`` for the current pipeline run. @@ -246,6 +254,8 @@ async def _drive_provider(self, state: PipelineState) -> None: if new_msgs: state.metadata[_STATE_LAST_RECORDED] = len(state.messages) state.metadata[_STRATEGY_RECORDED] = len(state.messages) + if compacted: + take_pending_records(state, RECORDER_STM) is_terminal = state.loop_decision in _TERMINAL_DECISIONS if is_terminal and self._hooks.should_record_execution(state): diff --git a/tests/unit/test_aux_usage.py b/tests/unit/test_aux_usage.py index 072e78ee..0afacde9 100644 --- a/tests/unit/test_aux_usage.py +++ b/tests/unit/test_aux_usage.py @@ -15,7 +15,10 @@ from xgen_agent_runtime.host.runner import turn_usage from xgen_agent_runtime.llm_client import BaseClient, ClientCapabilities from xgen_agent_runtime.llm_client.types import APIResponse, ContentBlock -from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import LLMSummaryCompactor +from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( + CHECKPOINT_MARKER, + LLMSummaryCompactor, +) class _Client(BaseClient): @@ -88,5 +91,7 @@ async def test_background_compaction_usage_moves_to_the_real_state(): client_getter=lambda s: s.llm_client, ) await comp.compact(shadow) - assert shadow.messages[0]["content"] == "RECAP", "기록이 압축을 깨지 않는다" + head = shadow.messages[0]["content"] + # 4.89.0: the summary sits inside the checkpoint message, after its intro. + assert head.startswith(CHECKPOINT_MARKER) and "RECAP" in head, "기록이 압축을 깨지 않는다" assert [p for p, _ in shadow.aux_usage] == ["compact"] diff --git a/tests/unit/test_budget_compaction_recovery.py b/tests/unit/test_budget_compaction_recovery.py index 5c55a813..d6110515 100644 --- a/tests/unit/test_budget_compaction_recovery.py +++ b/tests/unit/test_budget_compaction_recovery.py @@ -43,11 +43,16 @@ class _KeepLastCompactor(HistoryCompactor): - """Trivial compactor: keep the last N messages. Records call count.""" + """Trivial compactor: keep the last N messages. Records call count. - def __init__(self, keep_last: int = 2, *, persists_own: bool = False): + ``hard_only``: leave the history alone under soft pressure (the guard's proactive 80% pass) + so a test can reach the guard's own recovery path. + """ + + def __init__(self, keep_last: int = 2, *, persists_own: bool = False, hard_only: bool = False): self._keep_last = keep_last self.persists_own_compaction = persists_own + self._hard_only = hard_only self.calls = 0 @property @@ -55,7 +60,11 @@ def name(self) -> str: return "keep_last" async def compact(self, state: PipelineState) -> None: + from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY + self.calls += 1 + if self._hard_only and not (state.shared.get(COMPACTION_CONTEXT_KEY) or {}).get("hard"): + return if len(state.messages) > self._keep_last: state.messages = state.messages[-self._keep_last :] @@ -82,12 +91,23 @@ def _events(state: PipelineState, etype: str): class TestEstimator: def test_sums_system_messages_tools(self): + system = "The system prompt explains the task. " * 100 + messages = [{"role": "user", "content": "Please summarize the attached report. " * 200}] + tools = [{"name": "t", "description": "Reads a file from the workspace. " * 100}] + + def _only(**parts): + st = PipelineState() + for key, value in parts.items(): + setattr(st, key, value) + return estimate_prompt_tokens(st) + s = PipelineState() - s.system = "x" * 4000 # ~1000 tok - s.messages = [{"role": "user", "content": "y" * 8000}] # ~2000 tok - s.tools = [{"name": "t", "description": "z" * 4000}] # ~1000+ tok + s.system, s.messages, s.tools = system, messages, tools est = estimate_prompt_tokens(s) - assert est >= 4000 # 1000 + 2000 + ~1000+ + parts = [_only(system=system), _only(messages=messages), _only(tools=tools)] + assert all(p > 0 for p in parts) + # Each part counts in full (rounding aside): none is dropped from the projection. + assert est >= sum(parts) - 3 def test_image_block_is_flat_not_base64_length(self): huge_b64 = "A" * 200_000 # would be ~50k tokens if counted literally @@ -139,10 +159,12 @@ def test_cumulative_usage_no_longer_trips_guard(self): class TestGuardStageRecovery: @pytest.mark.asyncio - async def test_compact_then_recheck_passes(self): + async def test_proactive_relief_runs_before_the_guard(self): + """4.89.0: Stage 4 is the first point where the estimate includes the system prompt and + tools, so the 80% proactive pass runs here too: before the guard has to intervene.""" s = PipelineState() s.context_window_budget = 5000 - s.messages = _msgs(40) # ~20000 projected, over + s.messages = _msgs(40) comp = _KeepLastCompactor(keep_last=2) stage = GuardStage(guards=[TokenBudgetGuard(min_remaining_tokens=500)]) stage.attach_budget_recovery(comp) @@ -152,6 +174,24 @@ async def test_compact_then_recheck_passes(self): assert out == "IN" assert comp.calls == 1 assert len(s.messages) == 2 + assert len(_events(s, "guard.check")) == 1 + assert not _events(s, "guard.compacting") + assert _events(s, "context.compacted")[0]["data"]["trigger"] == "proactive" + + @pytest.mark.asyncio + async def test_compact_then_recheck_passes(self): + s = PipelineState() + s.context_window_budget = 5000 + s.messages = _msgs(40) # ~20000 projected, over + comp = _KeepLastCompactor(keep_last=2, hard_only=True) + stage = GuardStage(guards=[TokenBudgetGuard(min_remaining_tokens=500)]) + stage.attach_budget_recovery(comp) + + out = await stage.execute("IN", s) + + assert out == "IN" + assert comp.calls == 2 # the proactive pass (no relief here), then the guard's recovery + assert len(s.messages) == 2 # both the original check and the recheck were emitted checks = _events(s, "guard.check") assert len(checks) == 2 @@ -172,7 +212,7 @@ async def test_still_over_after_compaction_hard_rejects(self): with pytest.raises(GuardRejectError) as exc: await stage.execute("IN", s) assert exc.value.guard_name == "token_budget" - assert comp.calls == 1 # compacted once, then gave up + assert comp.calls == 2 # the proactive pass, one recovery, then gave up @pytest.mark.asyncio async def test_no_compactor_degrades_to_reject(self): @@ -190,7 +230,7 @@ async def test_recovery_in_non_fail_fast_mode(self): s = PipelineState() s.context_window_budget = 5000 s.messages = _msgs(40) - comp = _KeepLastCompactor(keep_last=2) + comp = _KeepLastCompactor(keep_last=2, hard_only=True) stage = GuardStage( guards=[TokenBudgetGuard(min_remaining_tokens=500)], fail_fast=False ) @@ -198,7 +238,7 @@ async def test_recovery_in_non_fail_fast_mode(self): out = await stage.execute("IN", s) assert out == "IN" - assert comp.calls == 1 + assert comp.calls == 2 # ── run_compaction helper ─────────────────────────────────────────── diff --git a/tests/unit/test_compaction_policy_489.py b/tests/unit/test_compaction_policy_489.py new file mode 100644 index 00000000..2241e657 --- /dev/null +++ b/tests/unit/test_compaction_policy_489.py @@ -0,0 +1,484 @@ +"""4.89.0 context-compaction overhaul: the behaviours the fix rests on. + +Each test pins one decision of the new policy (core.compaction + core.token_estimate + the +LLM summary compactor + Stage 6 overflow recovery + the host's input budget). They are the +regression guard for the problems found in the 2026-10-11 review: Korean under-counting, no +reaction to a provider "prompt too long", summaries that dropped history on failure, compacted +messages lost from memory, a window-blind tool-result cap, and an input clamp that cut the +user's question instead of the history. +""" + +from __future__ import annotations + +import pytest + +from xgen_agent_runtime.core.compaction import ( + COMPACTION_CONTEXT_KEY, + LLM_COMPACTIONS_KEY, + MAX_LLM_COMPACTIONS_PER_TURN, + note_api_call, + run_compaction, +) +from xgen_agent_runtime.core.config import ModelConfig +from xgen_agent_runtime.core.errors import ( + APIError, + ErrorCategory, + is_context_overflow_message, + parse_overflow_limit, + parse_overflow_tokens, +) +from xgen_agent_runtime.core.state import PipelineState, TokenUsage +from xgen_agent_runtime.core.token_estimate import ( + CALIBRATION_KEY, + estimate_prompt_tokens, + record_prompt_usage, +) +from xgen_agent_runtime.llm_client.types import APIResponse, ContentBlock +from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( + CHECKPOINT_MARKER, + LLMSummaryCompactor, + TruncateCompactor, +) + + +def _conversation(n: int, text: str = "message") -> list: + return [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"{text} {i}"} for i in range(n) + ] + + +def _events(state: PipelineState, kind: str) -> list: + return [e["data"] for e in state.events if e["type"] == kind] + + +class _SummaryClient: + provider = "fake" + + def __init__(self, text: str = "SUMMARY OF EARLIER WORK"): + self.text = text + self.calls = 0 + + async def create_message(self, *, model_config, messages, purpose: str = ""): + self.calls += 1 + return APIResponse(content=[ContentBlock(type="text", text=self.text)], stop_reason="end_turn") + + +class _FailingClient(_SummaryClient): + async def create_message(self, **kwargs): + self.calls += 1 + raise RuntimeError("summary model down") + + +def _llm_compactor(keep_recent: int = 4) -> LLMSummaryCompactor: + return LLMSummaryCompactor( + keep_recent=keep_recent, + resolve_cfg=lambda s: ModelConfig(model="fake", max_tokens=512), + has_override=lambda: True, + ) + + +# ── estimate ──────────────────────────────────────────────────────── + + +def test_korean_is_not_counted_at_four_chars_per_token(): + state = PipelineState() + state.messages = [{"role": "user", "content": "한국어 문장은 글자마다 토큰이 거의 하나씩 든다. " * 200}] + chars = len(state.messages[0]["content"]) + # The old estimate was chars / 4; real tokenizers spend close to a token per Hangul syllable. + assert estimate_prompt_tokens(state) > chars / 2 + + +def test_provider_count_anchors_the_estimate(): + state = PipelineState() + state.messages = _conversation(6) + record_prompt_usage(state, 50_000) # the provider counted far more than our estimate + assert estimate_prompt_tokens(state) >= 50_000 + state.messages.append({"role": "user", "content": "one more"}) + anchored = estimate_prompt_tokens(state) + assert 50_000 < anchored < 50_100 # reported + a small delta for the appended message + # The ratio corrects later unanchored estimates too, but within its clamp. + assert 0.9 <= state.shared[CALIBRATION_KEY] <= 2.5 + + +# ── provider overflow ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "message,count,limit", + [ + ("prompt is too long: 210000 tokens > 200000 maximum", 210_000, 200_000), + ( + "This model's maximum context length is 128000 tokens. However, your messages " + "resulted in 140000 tokens. Please reduce the length of the messages.", + 140_000, + 128_000, + ), + ( + "This model's maximum context length is 32768 tokens. However, you requested 40000 " + "tokens (32000 in the messages, 8000 in the completion).", + 32_000, + 32_768, + ), + ( + "The input token count (1200000) exceeds the maximum number of tokens allowed (1048576).", + 1_200_000, + 1_048_576, + ), + ], +) +def test_overflow_messages_are_recognised_and_parsed(message, count, limit): + assert is_context_overflow_message(message) + assert parse_overflow_tokens(message) == count + assert parse_overflow_limit(message) == limit + + +def test_unrelated_bad_request_is_not_an_overflow(): + assert not is_context_overflow_message("max_tokens: Field required") + + +class _OverflowOnce: + """APIProvider double: the first call is rejected as too long, later calls succeed.""" + + name = "overflow-once" + + def __init__(self, limit_message: str): + self._message = limit_message + self.requests: list = [] + + async def create_message(self, request): + self.requests.append(request) + if len(self.requests) == 1: + raise APIError(self._message, category=ErrorCategory.TOKEN_LIMIT, status_code=400) + return APIResponse( + content=[ContentBlock(type="text", text="done")], + stop_reason="end_turn", + usage=TokenUsage(input_tokens=100, output_tokens=5), + ) + + +@pytest.mark.asyncio +async def test_stage6_compacts_and_retries_once_on_overflow(): + from xgen_agent_runtime.stages.s06_api.artifact.default.stage import APIStage + + provider = _OverflowOnce("prompt is too long: 250000 tokens > 200000 maximum") + state = PipelineState() + state.stream = False + state.context_window_budget = 1_000_000 # what the table believed + state.messages = _conversation(30) + state._context_compactor = TruncateCompactor(keep_last=4) + + response = await APIStage(provider=provider).execute(None, state) + + assert response.text == "done" + assert len(provider.requests) == 2 + assert len(provider.requests[1].messages) < len(provider.requests[0].messages) + # The provider's own limit replaces the wrong table value for the rest of the turn. + assert state.context_window_budget == 200_000 + overflow = _events(state, "context.overflow")[0] + assert overflow["reported_tokens"] == 250_000 and overflow["provider_limit"] == 200_000 + + +@pytest.mark.asyncio +async def test_stage6_without_a_compactor_surfaces_the_overflow(): + from xgen_agent_runtime.stages.s06_api.artifact.default.stage import APIStage + + provider = _OverflowOnce("prompt is too long: 250000 tokens > 200000 maximum") + state = PipelineState() + state.stream = False + state.messages = _conversation(30) + + with pytest.raises(APIError) as exc: + await APIStage(provider=provider).execute(None, state) + assert exc.value.category == ErrorCategory.TOKEN_LIMIT + assert len(provider.requests) == 1 + + +# ── the summary compactor ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_checkpoint_keeps_user_messages_verbatim(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.context_window_budget = 200_000 + state.messages = _conversation(20, "ask") + state.messages[0]["content"] = "Please migrate the billing tables, keep the audit columns." + + await _llm_compactor().compact(state) + + head = state.messages[0]["content"] + assert head.startswith(CHECKPOINT_MARKER) + assert "SUMMARY OF EARLIER WORK" in head + assert "Please migrate the billing tables, keep the audit columns." in head + assert state.messages[-1]["content"] == "ask 19" + + +@pytest.mark.asyncio +async def test_kept_tail_never_opens_on_an_orphaned_tool_result(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.messages = _conversation(10) + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}]}, + ] + + await _llm_compactor(keep_recent=1).compact(state) + + # Every tool_result left in the history still has its tool_use (providers reject orphans). + calls = { + b["id"] + for m in state.messages + if isinstance(m.get("content"), list) + for b in m["content"] + if b.get("type") == "tool_use" + } + results = [ + b["tool_use_id"] + for m in state.messages + if isinstance(m.get("content"), list) + for b in m["content"] + if b.get("type") == "tool_result" + ] + assert results == ["t1"] and set(results) <= calls + + +@pytest.mark.asyncio +async def test_soft_pressure_without_a_summary_leaves_history_alone(): + state = PipelineState() + state.model = "fake" + state.llm_client = _FailingClient() + state.messages = _conversation(20) + before = list(state.messages) + state.shared[COMPACTION_CONTEXT_KEY] = {"trigger": "proactive", "hard": False} + + await _llm_compactor().compact(state) + + assert state.messages == before + assert _events(state, "context.compaction_deferred") + + +@pytest.mark.asyncio +async def test_hard_pressure_without_a_summary_falls_back_to_a_digest(): + state = PipelineState() + state.model = "fake" + state.llm_client = _FailingClient() + state.messages = _conversation(20) + state.shared[COMPACTION_CONTEXT_KEY] = {"trigger": "guard", "hard": True} + + await _llm_compactor().compact(state) + + assert len(state.messages) < 20 + assert "[Summary of" in state.messages[0]["content"] + + +# ── policy gates ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_one_soft_summary_per_request(): + state = PipelineState() + state.model = "fake" + client = _SummaryClient() + state.llm_client = client + state.context_window_budget = 4_000 + state.messages = _conversation(60, "long message " * 30) + compactor = _llm_compactor() + + first = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + state.messages += _conversation(40, "long message " * 30) + second = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + + assert first["summarized"] and client.calls == 1 + assert second.get("skipped") == "already_summarized_for_this_request" + note_api_call(state) # the request went out: the next one may summarize again + third = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + assert third["summarized"] and client.calls == 2 + + +@pytest.mark.asyncio +async def test_hard_pressure_past_the_turn_limit_uses_the_digest(): + state = PipelineState() + state.model = "fake" + client = _SummaryClient() + state.llm_client = client + state.messages = _conversation(30, "long message " * 30) + state.shared[LLM_COMPACTIONS_KEY] = MAX_LLM_COMPACTIONS_PER_TURN + + out = await run_compaction(state, _llm_compactor(), trigger="guard", target_tokens=10, hard=True) + + assert out["summarized"] + assert client.calls == 0 # no model call past the limit + assert "[Summary of" in state.messages[0]["content"] + + +def test_turn_counters_reset_on_a_new_turn(): + state = PipelineState() + state.shared[LLM_COMPACTIONS_KEY] = MAX_LLM_COMPACTIONS_PER_TURN + state.begin_turn() + assert LLM_COMPACTIONS_KEY not in state.shared + + +# ── memory recording across a compaction ──────────────────────────── + + +@pytest.mark.asyncio +async def test_messages_summarized_before_recording_still_reach_stm(): + from xgen_agent_runtime.memory.strategy import ProviderDrivenStrategy + + class _Recorder: + def __init__(self): + self.turns = [] + + async def record_turn(self, turn): + self.turns.append(turn) + + state = PipelineState() + state.messages = _conversation(12) + originals = [m["content"] for m in state.messages] + # A compaction inside the turn, before Stage 18 recorded anything. + await run_compaction(state, TruncateCompactor(keep_last=4), trigger="guard") + recorder = _Recorder() + + await ProviderDrivenStrategy(recorder).update(state) + + recorded = [str(t.content) for t in recorder.turns] + assert all(any(text in r for r in recorded) for text in originals) + assert not any("previous messages" in r for r in recorded) # the synthetic head is not a turn + + +# ── tool results and prune ────────────────────────────────────────── + + +def test_tool_result_cap_follows_the_window(tmp_path): + from xgen_agent_runtime.stages.s10_tool.persistence import maybe_persist_large_result + from xgen_agent_runtime.tools.base import ToolCapabilities, ToolContext, ToolResult + + class _View: + context_window_budget = 8_192 + + context = ToolContext(storage_path=None, state_view=_View()) + result = ToolResult(content="z" * 50_000) + out = maybe_persist_large_result( + result, + tool_use_id="t1", + tool_name="Read", + capabilities=ToolCapabilities(max_result_chars=100_000), + context=context, + ) + assert out.display_text and len(out.display_text) < 4_000 + assert "clipped" in out.display_text + + +def test_stale_tool_call_arguments_are_trimmed_without_touching_the_original(): + from xgen_agent_runtime.core.context_prune import prune_messages + + original_input = {"path": "a.py", "content": "x" * 10_000} + messages = [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "w", "name": "Write", "input": original_input}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "w", "content": "ok"}]}, + ] + _conversation(8) + + metrics = prune_messages(messages) + + assert metrics["inputs_trimmed"] == 1 + trimmed = messages[0]["content"][0]["input"] + assert trimmed["path"] == "a.py" and len(trimmed["content"]) < 1_000 + assert len(original_input["content"]) == 10_000 # the tool-call record keeps what was sent + + +# ── host input budget ─────────────────────────────────────────────── + + +def test_api_history_is_left_to_compaction_not_the_question(): + from xgen_agent_runtime.host.context_budget import fit_input_to_budget + + history = _conversation(40, "지난 대화 내용입니다. " * 200) + question = "이번 질문은 잘리면 안 된다. " * 50 + fit = fit_input_to_budget( + text=question, + rag_block="", + system_prompt="system", + history=history, + registry=None, + provider="openai", + model="gpt-4o", + max_tokens=4_096, + window=32_000, + ) + assert fit.text == question + assert fit.history is None + + +def test_cli_drops_the_oldest_history_first(): + from xgen_agent_runtime.host.context_budget import fit_input_to_budget + + history = _conversation(40, "지난 대화 내용입니다. " * 200) + question = "이번 질문은 잘리면 안 된다." + fit = fit_input_to_budget( + text=question, + rag_block="", + system_prompt="system", + history=history, + registry=None, + provider="claude_code", + model="sonnet", + max_tokens=4_096, + window=32_000, + history_compressible=False, + ) + assert fit.text == question + assert fit.history is not None and 2 <= len(fit.history) < len(history) + assert fit.history[-1] is history[-1] + assert fit.history[0]["role"] == "user" + + +def test_memory_node_context_string_is_kept_and_fitted(): + from xgen_agent_runtime.host.context_budget import fit_history_context + from xgen_agent_runtime.host.memory import history_context, history_messages + + value = ([{"role": "user", "content": "recent"}], "[이전 대화 기록]\n" + "오래된 대화 " * 20_000 + "가장 최근 대화") + assert history_messages(value) == [{"role": "user", "content": "recent"}] + context = history_context(value) + fitted = fit_history_context(context, 32_000, "openai", "gpt-4o") + assert len(fitted) < len(context) + assert fitted.startswith("[이전 대화 기록]") and fitted.endswith("가장 최근 대화") + + +@pytest.mark.parametrize( + "provider,model,window", + [ + ("anthropic", "claude-opus-5-5", 1_000_000), + ("anthropic", "claude-sonnet-4-5-20250929", 200_000), + ("bedrock", "global.anthropic.claude-sonnet-4-6", 1_000_000), + ("bedrock", "mistral.mistral-large-2407-v1:0", 32_000), + ("openai", "gpt-5", 272_000), + ("codex", "gpt-5.5", 272_000), + ("azure", "my-deployment", 128_000), + ("google", "gemma-3-27b-it", 131_072), + ], +) +def test_window_table(provider, model, window): + from xgen_agent_runtime.host.token_budget import resolve_context_window + + assert resolve_context_window(provider, model) == window + + +# ── Claude Code argv ──────────────────────────────────────────────── + + +def test_long_claude_code_system_prompt_travels_as_a_file(): + from xgen_agent_runtime.llm_client.claude_code import ClaudeCodeCLIClient + from xgen_agent_runtime.llm_client.types import APIRequest + + client = ClaudeCodeCLIClient(binary_path="/bin/true") + system = "시스템 프롬프트 " * 20_000 # > 128 KiB in UTF-8 + argv = client._build_argv( + APIRequest(model="sonnet", messages=[{"role": "user", "content": "hi"}], system=system, stream=True) + ) + assert "--system-prompt" not in argv + path = argv[argv.index("--system-prompt-file") + 1] + with open(path, encoding="utf-8") as fp: + assert fp.read() == system diff --git a/tests/unit/test_compaction_stm_watermark.py b/tests/unit/test_compaction_stm_watermark.py index 25c5022b..24103f74 100644 --- a/tests/unit/test_compaction_stm_watermark.py +++ b/tests/unit/test_compaction_stm_watermark.py @@ -54,13 +54,32 @@ def test_watermark_never_exceeds_new_length(): assert 0 <= meta[_STATE_LAST_RECORDED] <= 3 -def test_noop_when_nothing_recorded(): +def test_absent_watermark_counts_as_nothing_recorded(): + """4.89.0: no watermark means nothing was recorded yet (a compaction inside the first turn, + before Stage 18 ran). The summarized messages are kept for the recorders, and the watermark + moves past the synthetic head so the summary is not recorded as something the user said.""" + from xgen_agent_runtime.core.compaction import PENDING_RECORDS_KEY + + for meta in ({}, {_STATE_LAST_RECORDED: 0}): + before = _msgs(10) + kept = before[-2:] + after = [{"role": "user", "content": "SUMMARY"}] + kept + reconcile_recorded_index(before, after, meta) + assert meta[_STATE_LAST_RECORDED] == 1 + assert meta[PENDING_RECORDS_KEY]["stm"] == before[:8] + assert meta[PENDING_RECORDS_KEY]["archive"] == before[:8] + + +def test_pending_records_are_capped(): + from xgen_agent_runtime.core.compaction import PENDING_RECORDS_CAP, PENDING_RECORDS_KEY + meta = {} - reconcile_recorded_index(_msgs(10), _msgs(3), meta) - assert _STATE_LAST_RECORDED not in meta - meta2 = {_STATE_LAST_RECORDED: 0} - reconcile_recorded_index(_msgs(10), _msgs(3), meta2) - assert meta2[_STATE_LAST_RECORDED] == 0 + for _ in range(3): + before = _msgs(PENDING_RECORDS_CAP) + reconcile_recorded_index(before, [{"role": "user", "content": "S"}] + before[-1:], meta) + meta[_STATE_LAST_RECORDED] = 0 + meta["geny_bridge.conversation_archived_idx"] = 0 + assert len(meta[PENDING_RECORDS_KEY]["stm"]) == PENDING_RECORDS_CAP def test_truncate_compactor_keeps_recording_after_shrink(): diff --git a/tests/unit/test_compaction_toggle.py b/tests/unit/test_compaction_toggle.py index 92920aed..9deabd23 100644 --- a/tests/unit/test_compaction_toggle.py +++ b/tests/unit/test_compaction_toggle.py @@ -143,16 +143,40 @@ def _build(enabled: bool): assert guard_on._budget_compactor is None, "재동기화가 기존 배선을 걷어내야 한다" +class _InstantLLMClient: + """요약을 바로 돌려주는 가짜 클라이언트: 압축기는 모델이 있어야 요약한다(4.89.0: 모델 없이 + 약한 압박이면 이력을 그대로 두고 guard 에 맡긴다).""" + + provider = "fake" + + async def create_message(self, *, model_config, messages, purpose: str = ""): + class _Resp: + text = "요약: 이전 대화 내용." + usage = None + + return _Resp() + + def _band_state(budget: int = 1_000) -> PipelineState: - """80–90% 구간의 상태 — 백그라운드 유예 경계를 정확히 밟는다.""" + """80~90% 구간의 상태: 백그라운드 유예 경계를 정확히 밟는다. + + 길이를 고정하지 않고 추정기로 맞춘다. 추정기가 바뀌어도(4.89.0 한글 실측 보정) 전제가 그대로다. + """ from xgen_agent_runtime.core.token_estimate import estimate_prompt_tokens state = PipelineState() state.context_window_budget = budget - for i in range(20): - role = "user" if i % 2 == 0 else "assistant" - state.messages.append({"role": role, "content": f"m{i} " + ("가" * 165)}) - est = estimate_prompt_tokens(state) + state.model = "fake" + state.llm_client = _InstantLLMClient() + est = 0 + for size in range(1, 2_000): + state.messages = [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i} " + ("가" * size)} + for i in range(20) + ] + est = estimate_prompt_tokens(state) + if est > budget * 0.8: + break assert budget * 0.8 < est <= budget * 0.9, f"테스트 전제 붕괴: est={est}" return state @@ -160,8 +184,7 @@ def _band_state(budget: int = 1_000) -> PipelineState: def test_background_compaction_off_forces_sync() -> None: """원샷 호스트 계약: bg OFF → 80–90% 구간에서도 **즉시** 압축한다. - LLMSummaryCompactor 는 클라이언트가 없으면 정적 플레이스홀더로 강등되므로 - (self-wire 폴백), 동기 경로가 네트워크 없이 완결된다. + 상태에 가짜 클라이언트를 붙여(self-wire) 동기 경로가 네트워크 없이 완결된다. """ from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( LLMSummaryCompactor, diff --git a/tests/unit/test_llm_summary_compactor.py b/tests/unit/test_llm_summary_compactor.py index e845b021..24ea672b 100644 --- a/tests/unit/test_llm_summary_compactor.py +++ b/tests/unit/test_llm_summary_compactor.py @@ -12,6 +12,7 @@ from xgen_agent_runtime.llm_client import BaseClient, ClientCapabilities from xgen_agent_runtime.llm_client.types import APIResponse, ContentBlock from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( + CHECKPOINT_MARKER, LLMSummaryCompactor, SummaryCompactor, ) @@ -62,7 +63,10 @@ async def test_no_override_falls_back_to_placeholder(): client_getter=lambda s: s.llm_client, ) await comp.compact(state) - assert len(state.messages) == 10 + 2 + # 4.89.0: one checkpoint message + the 10 kept. The kept tail starts with an assistant + # message (msg 15), so no synthetic acknowledgement is needed to keep roles alternating. + assert len(state.messages) == 1 + 10 + assert state.messages[1]["content"] == "msg 15" assert "[Summary of 15 previous messages" in state.messages[0]["content"] @@ -78,7 +82,8 @@ async def test_override_and_client_triggers_llm_call(): client_getter=lambda s: s.llm_client, ) await comp.compact(state) - assert state.messages[0]["content"] == "REAL SUMMARY" + head = state.messages[0]["content"] + assert head.startswith(CHECKPOINT_MARKER) and "REAL SUMMARY" in head assert len(client.calls) == 1 assert client.calls[0]["purpose"] == "s02.compact" events = [e for e in state.events if e["type"] == "memory.compaction.summarized"] diff --git a/tests/unit/test_multi_dim_budget_controller.py b/tests/unit/test_multi_dim_budget_controller.py index 38b19a5f..bbdb9d39 100644 --- a/tests/unit/test_multi_dim_budget_controller.py +++ b/tests/unit/test_multi_dim_budget_controller.py @@ -44,14 +44,24 @@ def _state( s.token_usage = TokenUsage(input_tokens=total_tokens, output_tokens=0) s.context_window_budget = context_window_budget # 2.51.0 (audit R2): TokenBudget/loop controllers now measure the - # ACTUAL next-request size via estimate_prompt_tokens (~4 chars/token) - # rather than cumulative token_usage. Materialize a user message of - # the requested size so the estimator reports ``total_tokens``. + # ACTUAL next-request size via estimate_prompt_tokens rather than + # cumulative token_usage. Materialize a user message the estimator + # reports as about ``total_tokens`` (within a couple of percent). if total_tokens > 0: - s.messages = [{"role": "user", "content": "x" * (total_tokens * 4)}] + s.messages = [{"role": "user", "content": _text_of_about(total_tokens)}] return s +def _text_of_about(tokens: int) -> str: + from xgen_agent_runtime.core.token_estimate import estimate_prompt_tokens + + unit = "budget word " + probe = PipelineState() + probe.messages = [{"role": "user", "content": unit * 1000}] + per_unit = estimate_prompt_tokens(probe) / 1000 + return unit * max(1, round(tokens / per_unit)) + + # ───────────────────────────────────────────────────────────────── # Individual dimensions # ───────────────────────────────────────────────────────────────── @@ -92,7 +102,7 @@ class TestTokenBudget: def test_uses_state_window_by_default(self): b = TokenBudget(threshold_ratio=0.85) assert b.is_exceeded(_state(total_tokens=100_000, context_window_budget=200_000)) is False - assert b.is_exceeded(_state(total_tokens=170_001, context_window_budget=200_000)) is True + assert b.is_exceeded(_state(total_tokens=180_000, context_window_budget=200_000)) is True def test_explicit_max_overrides_state(self): b = TokenBudget(max_tokens=1000, threshold_ratio=0.5) diff --git a/tests/unit/test_slash_built_in_control.py b/tests/unit/test_slash_built_in_control.py index 831418a6..243ecb35 100644 --- a/tests/unit/test_slash_built_in_control.py +++ b/tests/unit/test_slash_built_in_control.py @@ -124,30 +124,37 @@ class _Bare: # ── /compact ───────────────────────────────────────────────────────── -class _FakeSummarizer: - def __init__(self, tokens_compressed=42): - self.called = False - self._tokens = tokens_compressed - - async def summarize_now(self): - self.called = True - return type("R", (), {"tokens_compressed": self._tokens})() - - class TestCompact: + """4.89.0: ``/compact`` queues a manual compaction on the live state; Stage 2 runs it at the + next pass even when the context is far from the window.""" + @pytest.mark.asyncio - async def test_invokes_summarizer(self): - summarizer = _FakeSummarizer() - pipeline = _FakePipeline(strategies={"summarize_strategy": summarizer}) - result = await CompactCommand().execute([], SlashContext(pipeline=pipeline)) + async def test_queues_a_manual_request_that_stage_2_runs(self): + from xgen_agent_runtime.core.shared_keys import SharedKeys + from xgen_agent_runtime.core.state import PipelineState + from xgen_agent_runtime.stages.s02_context import ContextStage + from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( + TruncateCompactor, + ) + + state = PipelineState() + state.messages = [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(12) + ] + result = await CompactCommand().execute([], SlashContext(extras={"state": state})) assert result.success is True - assert "42 tokens compressed" in result.content - assert summarizer.called is True + assert state.shared[SharedKeys.CONTEXT_COMPACTION_REQUEST]["source"] == "manual" + + await ContextStage(compactor=TruncateCompactor(keep_last=4)).execute(None, state) + + assert len(state.messages) < 12 + compacted = [e for e in state.events if e["type"] == "context.compacted"] + assert compacted and compacted[-1]["data"]["trigger"] == "manual" + assert SharedKeys.CONTEXT_COMPACTION_REQUEST not in state.shared @pytest.mark.asyncio - async def test_no_summarizer(self): - ctx = SlashContext(pipeline=_FakePipeline()) - result = await CompactCommand().execute([], ctx) + async def test_without_a_state_reports_failure(self): + result = await CompactCommand().execute([], SlashContext(pipeline=_FakePipeline())) assert result.success is False diff --git a/tests/unit/test_ttft_critical_path.py b/tests/unit/test_ttft_critical_path.py index 0017eefd..c8e27176 100644 --- a/tests/unit/test_ttft_critical_path.py +++ b/tests/unit/test_ttft_critical_path.py @@ -172,6 +172,24 @@ def _llm_compactor() -> LLMSummaryCompactor: ) +def _band_messages(state: PipelineState, n: int, *, alternate: bool) -> None: + """Fill ``state`` to the 80~90% band of its window with ``n`` messages, sized by the estimator.""" + budget = state.context_window_budget + est = 0 + for size in range(1, 4_000): + state.messages = [ + { + "role": ("user" if i % 2 == 0 else "assistant") if alternate else "user", + "content": "글" * size, + } + for i in range(n) + ] + est = estimate_prompt_tokens(state) + if est > budget * 0.8: + break + assert budget * 0.8 < est <= budget * 0.9, f"band setup broke: est={est}" + + class TestBackgroundCompaction: @pytest.mark.asyncio async def test_llm_compaction_deferred_then_applied_next_turn(self): @@ -182,9 +200,7 @@ async def test_llm_compaction_deferred_then_applied_next_turn(self): state = PipelineState() state.llm_client = _InstantLLMClient() state.context_window_budget = 1000 - for i in range(12): - role = "user" if i % 2 == 0 else "assistant" - state.messages.append({"role": role, "content": "글" * 280}) + _band_messages(state, 12, alternate=True) await stage.execute("in", state) # ~85% → schedule, not block assert any(e["type"] == "context.compaction_scheduled" for e in state.events) @@ -220,8 +236,7 @@ async def test_stale_background_result_discarded_when_prefix_rewritten(self): state = PipelineState() state.llm_client = _InstantLLMClient() state.context_window_budget = 1000 - for i in range(12): - state.messages.append({"role": "user", "content": "글" * 280}) + _band_messages(state, 12, alternate=False) await stage.execute("in", state) assert stage._bg_compaction is not None From 623ac28f3b25339104bf8f0171d560fb07f7c6c4 Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Mon, 12 Oct 2026 11:57:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(compaction):=201=EC=B0=A8=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=B0=98=EC=98=81=20(=ED=84=B4=20=EB=A7=A5?= =?UTF-8?q?=EB=9D=BD=20=EC=B6=94=EC=A0=95,=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=20=EA=B8=B0=EC=A4=80,=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=ED=9A=9F=EC=88=98=20=EC=A0=9C=ED=95=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 갈래 검토(핵심 정책, 호스트와 클라이언트, 실제 파이프라인 시나리오)에서 나온 문제를 고친다. - 추정이 Stage 3 의 턴 맥락을 세지 않아 보정이 2.5 로 부풀고 guard 가 들어갈 요청을 거절하던 것 - 문턱·목표를 입력 예산(창 - 답 몫) 기준으로, 호스트 입력 맞춤과 guard 가 같은 답 몫을 쓰게 - 실패한 요약 시도도 요청당·턴당 한도에 넣고, 두 번 실패하면 약한 압박은 모델을 더 부르지 않게 - 요약 요청 자체의 넘침은 provider 수치로 대본을 줄여 재시도 - 강한 압박에서 병렬 도구 결과 합이 창을 넘으면 비율대로 줄여 턴을 살림 - 도구 결과 상한을 글자가 아니라 토큰으로(480자 미리보기 회귀 수정), 첨부 PDF 는 강한 압박에서만 삭제 - 메모리 노드 문자열을 질문보다 먼저 줄이는 구성요소로, 백그라운드 요약 중복과 고정 메시지 유실 - 같은 턴 두 번째 압축에서 현재 요청 표시 유지, 메시지별 추정 기억으로 긴 이력 성능 - 창 표(Gemma 4, Bedrock 은 Claude 전용, 숫자 경계, claude_code 는 CLI 창), Vertex 넘침 문구, 답 몫 때문의 넘침은 압축 안 함, Claude Code 프롬프트 파일 권한·경합·Windows 한도 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 68 ++-- docs/events.md | 3 +- src/xgen_agent_runtime/core/compaction.py | 229 +++++++++++-- src/xgen_agent_runtime/core/context_prune.py | 46 ++- src/xgen_agent_runtime/core/errors.py | 13 +- src/xgen_agent_runtime/core/state.py | 1 + src/xgen_agent_runtime/core/text_tokens.py | 26 +- src/xgen_agent_runtime/core/token_estimate.py | 97 +++++- src/xgen_agent_runtime/events/catalog.py | 3 +- src/xgen_agent_runtime/host/context_budget.py | 121 +++++-- src/xgen_agent_runtime/host/runner.py | 8 +- src/xgen_agent_runtime/host/token_budget.py | 48 ++- src/xgen_agent_runtime/host/turn_executor.py | 28 +- .../llm_client/anthropic.py | 14 +- src/xgen_agent_runtime/llm_client/base.py | 12 + .../llm_client/claude_code.py | 68 +++- .../llm_client/translators/_cli.py | 11 +- src/xgen_agent_runtime/memory/retriever.py | 8 +- .../slash_commands/built_in/compact.py | 9 +- .../artifact/default/compactors.py | 98 ++++-- .../s02_context/artifact/default/stage.py | 58 +++- .../s04_guard/artifact/default/stage.py | 4 +- .../stages/s06_api/artifact/default/stage.py | 14 +- .../stages/s10_tool/persistence.py | 116 +++++-- tests/test_host_turn_executor_gates.py | 1 + tests/unit/test_compaction_policy_489.py | 308 +++++++++++++++++- tests/unit/test_compaction_toggle.py | 8 +- tests/unit/test_slash_built_in_control.py | 12 +- tests/unit/test_ttft_critical_path.py | 7 +- 29 files changed, 1180 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08692609..3c9e020d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,40 +10,62 @@ this project adheres to [Semantic Versioning](https://semver.org/). 2026-10-11 검토에서 압축이 너무 늦게 돌거나(한국어 과소 추정), 돌아도 대화를 잃거나(요약 실패 시 이력 삭제), provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾았다. Codex · Claude Code 의 방식(provider 가 센 -토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. +토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. 고친 뒤 세 갈래(핵심 정책, +호스트와 클라이언트, 실제 파이프라인 시나리오)로 검토해 나온 문제도 이 판에서 고쳤다. - **추정 하나** (`core.text_tokens`): 압축 판단과 호출 전 입력 맞춤이 같은 글자 부류 기반 추정을 쓴다. 예전 압축 쪽은 - 4자당 1토큰이라 한국어를 2~3배 적게 셌다. 성공한 호출마다 provider 가 보고한 입력 토큰을 기준점으로 삼고 - (`record_prompt_usage`, 캐시 읽기·쓰기 포함), 그 뒤에 붙은 것만 추정한다. 보정 비율은 0.9~2.5 로 묶는다. -- **문턱과 목표** (`core.compaction`): 다음 요청이 창의 80% 를 넘으면 50% 까지 줄인다. 먼저 결정적 정리(오래된 도구 - 결과·도구 호출 인자·이미지·PDF)로, 모자라면 모델 요약으로. 요약은 요청 하나에 한 번, 턴당 6번까지이고, 요약할 - 구간이 창의 5%(작은 창은 최대 15%, 2,000토큰 바닥)보다 작으면 하지 않는다. 턴 계수는 `begin_turn` 이 비운다. -- **넘침 복구** (Stage 6): provider 가 창을 넘었다고 거절하면(OpenAI · vLLM · Gemini 400 도 이제 `TOKEN_LIMIT`) - 강하게 압축하고 한 번 다시 보낸다. 오류에 적힌 실제 한도가 믿던 창보다 작으면 그 턴의 창을 그 값으로 바꾼다. -- **Stage 4**: Stage 3 이 시스템 프롬프트와 도구를 붙인 뒤라 첫 반복의 추정이 완전한 첫 지점이다. 여기서도 80% 를 본다. - guard 회복은 창의 50% 를 목표로 한다. + 4자당 1토큰이라 한국어를 2~3배 적게 셌다. 요청에 붙는 것은 모두 센다(시스템 프롬프트, 도구, Stage 3 이 요청 사본에만 + 붙이는 턴 맥락: 날짜 · 고른 기억 · 턴 노트). 성공한 호출마다 provider 가 보고한 입력 토큰을 기준점으로 삼고 + (`record_prompt_usage`, 캐시 읽기 · 쓰기 포함, Claude Code CLI 포함), 그 뒤에 붙은 것만 추정한다. 보정 비율은 + 0.9~2.5 로 묶고, 기준점 추정은 보정된 단순 추정의 80% 아래로 내려가지 않는다. 메시지별 추정은 객체마다 기억해 + 긴 대화에서 반복마다 전부 다시 세지 않는다(긴 글 캐시는 LRU). +- **입력 예산과 문턱** (`core.compaction`): 창에서 답 몫(`output_reserve`: max(4096, max_tokens + 2048), 창의 절반 + 이하)을 뺀 입력 예산의 80% 를 넘으면 50% 까지 줄인다. Stage 4 guard 도 같은 몫을 남기므로 약한 압축이 늘 guard 보다 + 먼저 온다(예전에는 창의 80% 기준이라 32k 창 · max_tokens 8192 에서 guard 가 68% 에서 먼저 터졌다). 결정적 + 정리(오래된 도구 결과 · 도구 호출 인자 · 이미지)를 먼저, 모자라면 모델 요약. 첨부 PDF 는 강한 압박에서만 지운다. +- **요약 횟수**: 요약 시도는 요청 하나에 한 번, 턴당 6번이고 실패한 시도도 센다. 두 번 실패하면 그 턴의 약한 압박은 + 모델을 더 부르지 않는다. 요약할 구간이 창의 5%(작은 창은 최대 15%, 2,000 바닥)보다 작으면 하지 않는다. Stage 16 의 + 압축 요청도 이 규칙을 따른다(사람이 거는 `/compact` 만 예외). 턴 계수는 `begin_turn` 이 비운다. +- **강한 압박** (guard · 넘침): 공격적 정리 + 요약, 요약을 못 하면 결정적 다이제스트. 그래도 요청이 입력 예산을 넘으면 + 남은 도구 결과(병렬 호출 한 번의 결과들)를 비율대로 줄인다. 예전에는 guard 가 턴을 거절했다. +- **넘침 복구** (Stage 6): provider 가 창을 넘었다고 거절하면(OpenAI · vLLM · Gemini(AI Studio · Vertex) 400 도 이제 + `TOKEN_LIMIT`) 강하게 압축하고 한 번 다시 보낸다. 오류에 적힌 실제 한도가 믿던 창보다 작으면 그 턴의 창을 그 값으로 + 바꾼다. 메시지가 이미 작고 답 몫 때문에 넘친 경우(vLLM "100 in the messages, 8192 in the completion")는 압축하지 않는다. + 숫자를 적지 않는 넘침 문구에는 창 교정 없이 압축과 재시도만 한다. - **요약 체크포인트** (`LLMSummaryCompactor`): 앞부분을 `[Context checkpoint]` 메시지 하나로 바꾼다. 요약은 사용자 요청 · 한 일 · 결정 · 문제 · 남은 일 · 현재 상태 여섯 항목이고, 도구 호출과 결과까지 읽는다(예전에는 글 블록만, 그것도 - 앞 12,000자만). 사용자가 한 말은 창의 8% 안에서 그대로 붙이고, 지금 하던 요청도 그대로 붙인다. 이전 체크포인트는 - 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 두고(예전에는 지웠다), 강한 압박(guard · 넘침)에서만 - 결정적 다이제스트로 줄인다. 턴당 요약을 다 쓴 강한 압박도 다이제스트로 줄인다. + 앞 12,000자만). 사용자가 한 말은 창의 8% 안에서 그대로 붙이고, 지금 하던 요청은 같은 턴의 두 번째 압축에서도 따로 + 붙인다. 이전 체크포인트는 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 둔다(예전에는 지웠다). + 요약 요청 자체가 너무 길다고 거절되면 provider 수치로 대본을 줄여 다시 보내고, 그 비율을 턴의 보정에 반영한다. + 기준점이 없을 때(턴 첫 호출)는 대본을 창의 40% 로 잡는다. +- **백그라운드 요약** (상태를 유지하는 호스트): 미룬 요청에 표시를 남겨 Stage 4 가 같은 요청을 다시 요약하지 않고, + 결과를 적용할 때 고정한 사용자 말도 함께 옮긴다. - **기억 기록**: 기록되기 전에 요약으로 빠진 메시지를 STM 기록 · 대화 아카이브가 먼저 기록한다 (`memory.compacted_unrecorded`, 기록기마다 최대 300개). 워터마크가 없던 기록기는 0 으로 본다. -- **도구 결과 상한**: 도구별 상한(기본 10만 자)과 별개로 창의 25% 를 넘지 못한다. 저장소가 없으면 앞뒤만 남긴다. -- **호스트 입력 맞춤** (`fit_input_to_budget`): API 백엔드는 이력을 요약 바닥(5,000토큰)만큼만 세어, 압축할 수 있는 - 지난 대화 때문에 이번 질문을 자르지 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(200k, `[1m]` 은 1M, codex 272k) - 으로 맞추고, 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 보내는 것(core + 열린 도구)만 센다. +- **도구 결과 상한**: 도구별 상한(기본 10만 자, 넘으면 4.88 과 같이 저장 후 미리보기)과 별개로, 모델이 읽는 글 + (display_text 가 있으면 그것)이 창의 25% 토큰을 넘으면 앞뒤만 남긴다. 글자가 아니라 토큰으로 잰다(영어 6만 자는 + 200k 창에서 그대로 간다). +- **호스트 입력 맞춤** (`fit_input_to_budget`): guard 와 같은 답 몫을 남긴다. API 백엔드는 이력을 체크포인트가 차지할 수 + 있는 최대 크기(요약 + 사용자 말 몫, 최소 5,000)만큼만 세어, 압축할 수 있는 지난 대화 때문에 이번 질문을 자르지 + 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(claude_code 200k, `[1m]` 별칭은 1M, codex 272k)으로 맞추고, + 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 보내는 것(core + 열린 도구)만 센다. "입력 일부를 생략" 안내는 + 사용자 입력(질문 · 참조)이 잘렸을 때만 나간다. - **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 시스템 프롬프트에 붙인다 - (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 창의 25% 안으로 줄인다(가장 최근 쪽이 남는다). + (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 창의 25% 안으로 줄이고, 입력 맞춤에서 질문 · 참조보다 + 먼저 줄인다(가장 최근 쪽이 남는다). - **내장 메모리 주입**: 주입 상한(기본 10,000자)을 창의 10% 로 묶고, 입력 맞춤의 예약도 같은 값에서 계산한다. - 자동 검색 층은 압축 기록(`compactions`)을 올리지 않는다. + 자동 검색 층은 압축 기록(`compactions`)을 올리지 않고, 그만큼 조금 더 받아 온다. - **창 표**: Claude Opus 4.6 이후 · Sonnet 4.6 이후 · Haiku 5.5 · Fable · Mythos 1M, gpt-5 계열 272k(입력 한도), - gpt-5.5 · gpt-6 922k, Gemma, Bedrock 의 Llama · Nova(Claude 밖 Bedrock 기본 32k), Azure 기본 128k, claude_code · codex. - 짧은 접두가 엉뚱한 이름에 걸리지 않게 이름 앞이나 구분자 뒤에서만 맞춘다. -- **Claude Code argv**: 128KiB 를 넘는 시스템 프롬프트는 `--system-prompt-file` 로, 넘는 프롬프트는 stream-json stdin 으로 보낸다. + gpt-5-chat 128k, gpt-5.5 · gpt-6 922k, Gemma(4 · 3 은 128k, 3n · 1b · 270m 은 32k, 2 는 8k), Azure 기본 128k. + Bedrock 은 이 런타임에서 Claude 만 부르므로 기본 200k 그대로다. 접두는 이름 앞이나 구분자(. / :) 뒤에서만, 숫자를 + 이어 가지 않을 때만 맞춘다('gpt-5.5' 는 'gpt-5.50' 에 맞지 않는다). +- **Claude Code argv**: 약 100KB(Windows 는 명령줄 전체 한도 때문에 1.2만 자)를 넘는 시스템 프롬프트는 + `--system-prompt-file` 로, 넘는 프롬프트는 stream-json stdin 으로 보낸다. 파일은 사용자 전용 폴더(0700, 확인함)에 + 0600 으로 쓰고 하루 지나면 지운다. - **`/compact`**: 상태에 수동 압축 요청을 남기고 Stage 2 가 다음 패스에서 문턱과 무관하게 요약한다(예전 명령은 아무 - 파이프라인도 등록하지 않는 전략을 찾아 늘 실패했다). + 파이프라인도 등록하지 않는 전략을 찾아 늘 실패했다). 압축이 꺼진 에이전트에서는 실패로 답한다. - 새 사건: `context.overflow` · `context.compaction_skipped` · `context.compaction_deferred`. + 바뀐 것이 없으면 `context.compacted` 대신 `context.compaction_skipped`(reason `no_change`)를 낸다. ## [4.88.0] - 2026-10-11 diff --git a/docs/events.md b/docs/events.md index 50953b41..8a667583 100644 --- a/docs/events.md +++ b/docs/events.md @@ -318,6 +318,7 @@ Enum member: `EventTypes.CONTEXT_PRUNED` | `inputs_trimmed` | int — oversized stale tool-call arguments shortened | | `chars_saved` | int — text chars removed (images excluded) | | `aggressive` | bool? — the hard-pressure profile (guard / overflow) | +| `results_fitted` | int? — tool results shortened in proportion so a hard-pressure request fits | | `trigger` | str? — cost (Stage 2 token threshold) \| the compaction trigger it ran under | | `threshold_tokens` | int? — the prune_over_tokens threshold that fired (cost trigger) | | `tokens_before` | int? — projected prompt tokens before the pass (cost trigger) | @@ -388,7 +389,7 @@ Enum member: `EventTypes.CONTEXT_COMPACTION_SKIPPED` |---|---| | `strategy` | str — compactor name/class | | `trigger` | str | -| `reason` | str — turn_compaction_limit \| already_summarized_for_this_request \| nothing_to_summarize \| summary_region_too_small | +| `reason` | str — turn_compaction_limit \| summary_model_failing \| already_summarized_for_this_request \| nothing_to_summarize \| summary_region_too_small \| no_change | | `tokens_estimate` | int? | | `target_tokens` | int | diff --git a/src/xgen_agent_runtime/core/compaction.py b/src/xgen_agent_runtime/core/compaction.py index aa71d5d0..10ba8454 100644 --- a/src/xgen_agent_runtime/core/compaction.py +++ b/src/xgen_agent_runtime/core/compaction.py @@ -3,7 +3,8 @@ Every trigger goes through :func:`run_compaction`: -* Stage 2 *proactive* (start of an iteration, estimate above :data:`PROACTIVE_RATIO` of the window), +* Stage 2 *proactive* (start of an iteration, estimate above :data:`PROACTIVE_RATIO` of the input + budget: the window minus the room kept for the answer, see :func:`input_budget_of`), * Stage 4 *proactive* (after Stage 3 built the system prompt and tool list: the only point where the first iteration's estimate is complete) and the Stage 4 *guard* (the next request would not leave room for the answer), @@ -21,8 +22,9 @@ * **No pointless summaries.** A compactor that can say what it would summarize (``plan(state, target_tokens)``) is skipped when that region is too small to matter, unless the pressure is hard. -* **A ceiling per turn.** At most :data:`MAX_LLM_COMPACTIONS_PER_TURN` summaries per turn; past that - only the deterministic prune runs and the guard decides. +* **A ceiling per turn.** At most :data:`MAX_LLM_COMPACTIONS_PER_TURN` summary attempts per turn, and + after :data:`MAX_SUMMARY_FAILURES` failed ones soft pressure stops calling the model. Past either + limit hard pressure still relieves the request with the compactor's deterministic digest. * **Nothing summarized is forgotten by memory.** Messages that leave ``state.messages`` before the turn's recorders ran (STM, conversation archive) are kept in ``state.metadata`` and recorded first, in order. Synthetic summary messages are never recorded as if the user had said them. @@ -38,11 +40,15 @@ logger = logging.getLogger(__name__) -#: Proactive trigger: compact when the next request is estimated above this share of the window. +#: Proactive trigger: compact when the next request is estimated above this share of the input +#: budget (:func:`input_budget_of`). PROACTIVE_RATIO = 0.8 -#: Where compaction aims. Low enough that the next compaction is many tool rounds away: compacting -#: to just under the trigger made every few calls compact again, and every compaction loses detail. +#: Where compaction aims, as a share of the input budget. Low enough that the next compaction is many +#: tool rounds away: compacting to just under the trigger made every few calls compact again, and +#: every compaction loses detail. TARGET_RATIO = 0.5 +#: Failed summary attempts after which soft pressure stops calling the model for the rest of the turn. +MAX_SUMMARY_FAILURES = 2 #: Summaries allowed in one turn. A turn that needs more is refilling as fast as it is compacted; #: further summaries only pile summaries on summaries. MAX_LLM_COMPACTIONS_PER_TURN = 6 @@ -68,9 +74,13 @@ def min_summary_region(window: int) -> int: API_CALLS_KEY = "_context_api_calls" #: Value of :data:`API_CALLS_KEY` when the last model summary ran: "already summarized for this call". SUMMARIZED_AT_KEY = "_context_summarized_at_call" -#: Model summaries run in this turn. +#: Model summary attempts in this turn (successful or not). LLM_COMPACTIONS_KEY = "_context_llm_compactions" -#: The compactor reads the current request here: ``{"trigger", "target_tokens", "hard"}``. +#: Failed model summary attempts in a row in this turn. +LLM_FAILURES_KEY = "_context_llm_failures" +#: The compactor reads the current request here (``{"trigger", "target_tokens", "hard", +#: "digest_only"}``) and reports back into the same dict: ``model_calls`` (summary calls it made) +#: and ``source`` (``"model"`` or ``"digest"`` when it rewrote the history). COMPACTION_CONTEXT_KEY = "_context_compaction_request" #: Stage-18's STM-recording watermark (index into ``state.messages``). @@ -219,6 +229,30 @@ def window_of(state: Any) -> int: return _int(getattr(state, "context_window_budget", 0)) or 200_000 +def output_reserve(window: int, max_tokens: int) -> int: + """Room kept free for the answer: ``max(4096, max_tokens + 2048)``, at most half the window. + + The Stage 4 ``TokenBudgetGuard`` the host wires (``host.runner``) reserves exactly this, so the + proactive trigger (80% of what is left) always comes before the guard. Before 4.89.0 the + trigger was 80% of the whole window while the guard fired at ``window - max_tokens - 2048``: + on a 32k model with ``max_tokens=8192`` that is 68% of the window, so every compaction was a + guard emergency. + """ + window = max(0, int(window or 0)) + reserve = max(4096, max(0, int(max_tokens or 0)) + 2048) + if window > 0: + # Never more than half the window, so a max_tokens set close to the window (a config error) + # still leaves room for the input instead of failing every turn. + reserve = min(reserve, window // 2) + return reserve + + +def input_budget_of(state: Any) -> int: + """Tokens the next request may use: the window minus :func:`output_reserve`.""" + window = window_of(state) + return max(1, window - output_reserve(window, _int(getattr(state, "max_tokens", 0)))) + + def _compactor_name(compactor: Any) -> str: return str(getattr(compactor, "name", None) or type(compactor).__name__) @@ -243,11 +277,18 @@ def _summary_text(state: PipelineState) -> str: def _prune(state: PipelineState, *, aggressive: bool, trigger: str) -> Dict[str, int]: - """Deterministic pre-pass. In-place, count/order-preserving, pair-safe.""" + """Deterministic pre-pass. In-place, count/order-preserving, pair-safe. + + Attached documents (PDFs) are removed only under hard pressure: the request may still be about + them (2026-10-12 review: a 12-page PDF alone passed the cost threshold and was stripped from the + request being answered). + """ try: from xgen_agent_runtime.core.context_prune import AGGRESSIVE, prune_messages - metrics = prune_messages(state.messages or [], **(AGGRESSIVE if aggressive else {})) + options: Dict[str, Any] = dict(AGGRESSIVE) if aggressive else {} + options["strip_documents"] = aggressive + metrics = prune_messages(state.messages or [], **options) except Exception: # noqa: BLE001 (relief, never a gate) logger.debug("deterministic prune pass failed", exc_info=True) return {} @@ -265,9 +306,14 @@ def _llm_gate( if not getattr(compactor, "uses_llm", False): return None shared = state.shared - explicit = trigger in ("requested", "manual") + # Only a person asking (/compact) bypasses the policy. A Stage 16 "requested" compaction is the + # loop's own pressure signal and obeys the same limits (it used to bypass them all and could + # summarize on every iteration of a turn that compaction could not bring under the threshold). + explicit = trigger == "manual" if not explicit and _int(shared.get(LLM_COMPACTIONS_KEY)) >= MAX_LLM_COMPACTIONS_PER_TURN: return "turn_compaction_limit" + if not explicit and _int(shared.get(LLM_FAILURES_KEY)) >= MAX_SUMMARY_FAILURES: + return "summary_model_failing" # Soft pressure only: a guard or a provider overflow after a summary means that summary was not # enough for THIS request, and the request cannot go out as it is. if not explicit and not hard and summarized_for_this_call(state): @@ -301,7 +347,7 @@ async def run_compaction( ``target_tokens``: where the relief should land. With a target, a deterministic prune that already reaches it ends the call (no model summary); ``None`` means the caller has decided compaction is needed, so the compactor always runs (aiming at :data:`TARGET_RATIO` of the - window). ``requested``/``manual`` triggers always run the compactor too. + input budget). A ``manual`` trigger (``/compact``) always runs the compactor too. ``hard`` marks pressure the request cannot survive (guard, provider overflow): the prune runs aggressively, the small-region skip is off, and a compactor that cannot reach its model falls @@ -315,9 +361,9 @@ async def run_compaction( before_list = list(state.messages or []) before_msgs = len(before_list) before_tokens = estimate_prompt_tokens(state) - prune_may_suffice = target_tokens is not None and trigger not in ("requested", "manual") + prune_may_suffice = target_tokens is not None and trigger != "manual" if target_tokens is None: - target_tokens = int(window_of(state) * TARGET_RATIO) + target_tokens = int(input_budget_of(state) * TARGET_RATIO) _prune(state, aggressive=False, trigger=trigger) if hard and estimate_prompt_tokens(state) > target_tokens: @@ -352,9 +398,9 @@ async def run_compaction( skip = _llm_gate(state, compactor, trigger=trigger, target_tokens=target_tokens, hard=hard) digest_only = False - if skip == "turn_compaction_limit" and hard: - # Out of model summaries for this turn, yet the request cannot go out as it is: the - # compactor relieves it with its deterministic digest instead of a model call. + if skip in ("turn_compaction_limit", "summary_model_failing") and hard: + # Out of model summaries for this turn (or the summary model keeps failing), yet the request + # cannot go out as it is: the compactor relieves it with its deterministic digest. skip, digest_only = None, True if skip is not None: state.add_event( @@ -380,15 +426,17 @@ async def run_compaction( "summarized": False, } - state.shared[COMPACTION_CONTEXT_KEY] = { + request: Dict[str, Any] = { "trigger": trigger, "target_tokens": target_tokens, "hard": bool(hard), "digest_only": digest_only, } + state.shared[COMPACTION_CONTEXT_KEY] = request try: await compactor.compact(state) - except Exception as exc: # noqa: BLE001 — best effort + except Exception as exc: # noqa: BLE001 (best effort) + _count_attempt(state, compactor, request) state.add_event( "context.compaction_failed", {"compactor": _compactor_name(compactor), "trigger": trigger, "error": str(exc)}, @@ -397,6 +445,7 @@ async def run_compaction( return {"ok": False, "before_messages": before_msgs, "after_messages": before_msgs} finally: state.shared.pop(COMPACTION_CONTEXT_KEY, None) + _count_attempt(state, compactor, request) after_list = list(state.messages or []) changed = len(after_list) != before_msgs or any( @@ -407,10 +456,61 @@ async def run_compaction( # they have not recorded yet. reconcile_recorded_index(before_list, after_list, state.metadata) state.shared.pop("_prompt_tokens_memo", None) - if getattr(compactor, "uses_llm", False): - state.shared[SUMMARIZED_AT_KEY] = _int(state.shared.get(API_CALLS_KEY)) - if not digest_only: - state.shared[LLM_COMPACTIONS_KEY] = _int(state.shared.get(LLM_COMPACTIONS_KEY)) + 1 + if hard: + _fit_tool_results(state, trigger) + if not changed and estimate_prompt_tokens(state) < before_tokens: + # The compactor left the list alone but the prune (or the tool-result fit) shrank it in + # place: report that relief. + now = estimate_prompt_tokens(state) + state.add_event( + "context.compacted", + { + "strategy": "prune", + "trigger": trigger, + "messages_before": before_msgs, + "messages_after": before_msgs, + "saved_tokens_estimate": before_tokens - now, + "tokens_after_estimate": now, + "target_tokens": target_tokens, + "target_met": now <= target_tokens, + }, + ) + return { + "ok": True, + "before_messages": before_msgs, + "after_messages": before_msgs, + "replaced": 0, + "saved_tokens_estimate": before_tokens - now, + "after_tokens_estimate": now, + "target_tokens": target_tokens, + "target_met": now <= target_tokens, + "summarized": False, + } + elif not changed: + # Nothing changed (a soft summary that did not come back, nothing to cut): not a compaction. + # Reporting it as ``context.compacted`` made hosts that count compactions over-count. + state.add_event( + "context.compaction_skipped", + { + "strategy": _compactor_name(compactor), + "trigger": trigger, + "reason": "no_change", + "tokens_estimate": after_prune, + "target_tokens": target_tokens, + }, + ) + return { + "ok": True, + "skipped": "no_change", + "before_messages": before_msgs, + "after_messages": before_msgs, + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - after_prune), + "after_tokens_estimate": after_prune, + "target_tokens": target_tokens, + "target_met": after_prune <= target_tokens, + "summarized": False, + } after_msgs = len(after_list) after_tokens = estimate_prompt_tokens(state) @@ -475,10 +575,82 @@ async def run_compaction( "after_tokens_estimate": after_tokens, "target_tokens": target_tokens, "target_met": target_met, - "summarized": bool(changed), + "summarized": True, } +#: Under hard pressure the request must end up below this share of the input budget. +_HARD_FIT_RATIO = 0.95 + + +def _fit_tool_results(state: PipelineState, trigger: str) -> int: + """Last resort under hard pressure: when the request still does not fit after the prune and the + compactor, shorten the tool results that are left (the newest ones, in the protected tail) in + proportion. One round of parallel tool calls can return more than the window even when each + result is within its own cap; before this the guard rejected the turn. Returns results trimmed. + """ + limit = int(input_budget_of(state) * _HARD_FIT_RATIO) + estimate = estimate_prompt_tokens(state) + if estimate <= limit: + return 0 + from xgen_agent_runtime.core.context_prune import shrink_tool_results + from xgen_agent_runtime.core.token_estimate import estimate_messages_scaled + + results = [ + block + for message in state.messages or [] + if isinstance(message, dict) and isinstance(message.get("content"), list) + for block in message["content"] + if isinstance(block, dict) and block.get("type") == "tool_result" + ] + result_tokens = estimate_messages_scaled(state, [{"role": "user", "content": results}]) + if result_tokens <= 0: + return 0 + keep = max(0.02, (result_tokens - (estimate - limit)) / result_tokens * 0.9) + if keep >= 1.0: + return 0 + metrics = shrink_tool_results(state.messages or [], keep) + if metrics["trimmed"]: + state.shared.pop("_prompt_tokens_memo", None) + state.add_event( + "context.pruned", + { + "trigger": trigger, + "aggressive": True, + "results_fitted": metrics["trimmed"], + "chars_saved": metrics["chars_saved"], + "tokens_before": estimate, + "tokens_after": estimate_prompt_tokens(state), + }, + ) + return metrics["trimmed"] + + +def _count_attempt(state: PipelineState, compactor: Any, request: Dict[str, Any]) -> None: + """Book a compactor run against the per-request and per-turn limits. + + A model-backed compactor reports the summary calls it made (``model_calls``) and what replaced + the history (``source``). Any call (successful or not) uses up this request's one soft summary + and one of the turn's attempts; a call that did not produce a model summary counts as a failure. + Before this, only a run that changed the history was counted, so a failing summary model was + retried by Stage 2 and Stage 4 on every request (2026-10-12 review: 20 failed calls in a turn). + """ + if not getattr(compactor, "uses_llm", False): + return + shared = state.shared + calls = _int(request.get("model_calls")) + source = request.get("source") + if calls <= 0 and source is None: + return + shared[SUMMARIZED_AT_KEY] = _int(shared.get(API_CALLS_KEY)) + if calls > 0: + shared[LLM_COMPACTIONS_KEY] = _int(shared.get(LLM_COMPACTIONS_KEY)) + 1 + if source == "model": + shared[LLM_FAILURES_KEY] = 0 + else: + shared[LLM_FAILURES_KEY] = _int(shared.get(LLM_FAILURES_KEY)) + 1 + + async def relieve_if_over( state: PipelineState, compactor: Any, @@ -487,10 +659,11 @@ async def relieve_if_over( provider: Optional[Any] = None, ratio: float = PROACTIVE_RATIO, ) -> Optional[dict]: - """Proactive check shared by Stage 2 and Stage 4: compact when the estimate crosses ``ratio``.""" + """Proactive check shared by Stage 2, Stage 4 and the internal tool loop: compact when the + estimate crosses ``ratio`` of the input budget, down to :data:`TARGET_RATIO` of it.""" if compactor is None: return None - budget = window_of(state) + budget = input_budget_of(state) if estimate_prompt_tokens(state) <= budget * ratio: return None return await run_compaction( @@ -506,6 +679,10 @@ async def relieve_if_over( "PROACTIVE_RATIO", "TARGET_RATIO", "MAX_LLM_COMPACTIONS_PER_TURN", + "MAX_SUMMARY_FAILURES", + "LLM_FAILURES_KEY", + "output_reserve", + "input_budget_of", "RECORDED_INDEX_KEYS", "PENDING_RECORDS_KEY", "RECORDER_STM", diff --git a/src/xgen_agent_runtime/core/context_prune.py b/src/xgen_agent_runtime/core/context_prune.py index 7d2dae67..5b8db9b3 100644 --- a/src/xgen_agent_runtime/core/context_prune.py +++ b/src/xgen_agent_runtime/core/context_prune.py @@ -140,16 +140,17 @@ def _content_size(block: Dict[str, Any]) -> int: return 0 -def _strip_images_in(blocks: List[Any]) -> int: - """Replace base64 image and document (PDF) blocks with a small text marker, - recursing into tool_result content lists. Returns the number removed.""" +def _strip_images_in(blocks: List[Any], *, documents: bool = False) -> int: + """Replace base64 image blocks (and, with ``documents``, PDF document blocks) with a small text + marker, recursing into tool_result content lists. Returns the number removed.""" removed = 0 + kinds = ("image", "document") if documents else ("image",) for i, b in enumerate(blocks): if not isinstance(b, dict): continue btype = b.get("type") if ( - btype in ("image", "document") + btype in kinds and isinstance(b.get("source"), dict) and b["source"].get("type") == "base64" ): @@ -159,7 +160,7 @@ def _strip_images_in(blocks: List[Any]) -> int: } removed += 1 elif btype == "tool_result" and isinstance(b.get("content"), list): - removed += _strip_images_in(b["content"]) + removed += _strip_images_in(b["content"], documents=documents) return removed @@ -224,6 +225,28 @@ def _trim_result(block: Dict[str, Any], keep: int) -> int: return 0 +def shrink_tool_results( + messages: List[Dict[str, Any]], factor: float, *, min_keep: int = 300 +) -> Dict[str, int]: + """Shorten every tool_result in *messages* to ``factor`` of its text, in place (head kept, with + the usual marker). The last resort under hard pressure: one round of parallel tool calls can + return more than the window even when each result is within its own cap, and the protected tail + is exactly where those results sit. Returns ``{"trimmed", "chars_saved"}``.""" + trimmed = saved = 0 + for msg in messages or []: + if not isinstance(msg, dict): + continue + for block in _iter_tool_results(msg): + size = _content_size(block) + keep = max(min_keep, int(size * max(0.0, factor))) + if size > keep: + removed = _trim_result(block, keep) + if removed > 0: + trimmed += 1 + saved += removed + return {"trimmed": trimmed, "chars_saved": saved} + + def prune_messages( messages: List[Dict[str, Any]], *, @@ -233,12 +256,17 @@ def prune_messages( trim_keep_chars: int = DEFAULT_TRIM_KEEP_CHARS, input_trim_over_chars: int = DEFAULT_INPUT_TRIM_OVER_CHARS, input_trim_keep_chars: int = DEFAULT_INPUT_TRIM_KEEP_CHARS, + strip_documents: bool = False, ) -> PruneMetrics: """Run the deterministic prune over *messages* in place. - Returns metrics: ``deduped`` (results back-referenced), ``images_stripped`` (images and PDF - documents), ``trimmed`` (oversized results shortened), ``inputs_trimmed`` (stale tool calls - whose long input strings were shortened), ``chars_saved`` (text chars removed, images excluded). + Returns metrics: ``deduped`` (results back-referenced), ``images_stripped`` (images, and PDF + documents when ``strip_documents``), ``trimmed`` (oversized results shortened), + ``inputs_trimmed`` (stale tool calls whose long input strings were shortened), ``chars_saved`` + (text chars removed, images excluded). + + ``strip_documents``: attached PDFs are often what the request is about, so they are removed only + under hard pressure (``core.compaction`` passes it with the aggressive profile). """ metrics = PruneMetrics(deduped=0, images_stripped=0, trimmed=0, inputs_trimmed=0, chars_saved=0) if not messages: @@ -274,7 +302,7 @@ def prune_messages( continue content = msg.get("content") if isinstance(content, list): - metrics["images_stripped"] += _strip_images_in(content) + metrics["images_stripped"] += _strip_images_in(content, documents=strip_documents) if msg.get("role") == "assistant": saved_inputs = _trim_tool_inputs(msg, input_trim_over_chars, input_trim_keep_chars) if saved_inputs > 0: diff --git a/src/xgen_agent_runtime/core/errors.py b/src/xgen_agent_runtime/core/errors.py index cfffd36b..a242575f 100644 --- a/src/xgen_agent_runtime/core/errors.py +++ b/src/xgen_agent_runtime/core/errors.py @@ -347,7 +347,9 @@ def __init__( "exceed context limit", # Anthropic: "input length and `max_tokens` exceed context limit" "input length exceeds", "input is too long", # Bedrock - "exceeds the maximum number of tokens", # Gemini + "exceeds the maximum number of tokens", # Gemini (AI Studio) + "model only supports up to", # Gemini on Vertex: "input token count is N but model only supports up to M" + "more than the max tokens limit", # Gemini on Vertex: "Input request contains (N) tokens, which is more than ..." "exceeds the context window", "context window exceeded", "longer than the model's context length", @@ -372,7 +374,10 @@ def is_context_overflow_message(message: str) -> bool: # vLLM >= 0.6: "your request has 40000 input tokens" re.compile(r"request has\s*([\d,]+)\s*input tokens", re.I), # Gemini: "The input token count (1234567) exceeds the maximum number of tokens allowed" - re.compile(r"input token count\s*\(?\s*([\d,]+)", re.I), + # Vertex: "... because the input token count is 1234567 but model only supports up to 1048576" + re.compile(r"input token count\s*(?:is\s*)?\(?\s*([\d,]+)", re.I), + # Vertex: "Input request contains (1234567) tokens, which is more than the max tokens limit" + re.compile(r"contains\s*\(\s*([\d,]+)\s*\)\s*tokens", re.I), ) @@ -385,6 +390,10 @@ def is_context_overflow_message(message: str) -> bool: re.compile(r"maximum context length is\s*([\d,]+)", re.I), # Gemini: "... exceeds the maximum number of tokens allowed (1048576)." re.compile(r"maximum number of tokens allowed\s*\(?\s*([\d,]+)", re.I), + # Vertex: "... but model only supports up to 1048576." + re.compile(r"only supports up to\s*([\d,]+)", re.I), + # Vertex: "... more than the max tokens limit allowed (1048576)." + re.compile(r"max tokens limit(?:\s*allowed)?\s*\(?\s*([\d,]+)", re.I), ) diff --git a/src/xgen_agent_runtime/core/state.py b/src/xgen_agent_runtime/core/state.py index 93f1cca4..f7b15ca7 100644 --- a/src/xgen_agent_runtime/core/state.py +++ b/src/xgen_agent_runtime/core/state.py @@ -442,6 +442,7 @@ def begin_turn(self) -> None: # 압축 정책의 턴 단위 계수(core/compaction.py): 턴당 요약 상한과 "이번 요청에 이미 # 요약했다" 표시. 남겨 두면 재사용 상태에서 여섯 번째 요약 뒤로는 guard 회복조차 못 한다. "_context_llm_compactions", + "_context_llm_failures", "_context_summarized_at_call", "_context_api_calls", ): diff --git a/src/xgen_agent_runtime/core/text_tokens.py b/src/xgen_agent_runtime/core/text_tokens.py index 544a629e..0274dec8 100644 --- a/src/xgen_agent_runtime/core/text_tokens.py +++ b/src/xgen_agent_runtime/core/text_tokens.py @@ -16,7 +16,9 @@ from __future__ import annotations +import threading import unicodedata +from collections import OrderedDict from typing import Any __all__ = [ @@ -166,9 +168,12 @@ def provider_token_factor(provider: Any) -> float: #: 긴 글의 추정 캐시. 같은 글은 같은 값이다. (해시, 길이)로 찾는다. 파이썬 문자열은 해시를 객체에 #: 저장하므로 두 번째부터는 상수 시간이다. 대화가 커지면 매 반복 같은 메시지를 다시 세던 비용이 사라진다. +#: 최근에 쓴 것부터 남긴다(LRU). 들어온 순서로 비우면 긴 대화를 처음부터 훑을 때마다 상한을 넘는 순간 +#: 모든 조회가 빗나갔다(2026-10-12 검토: 긴 글 5,000개에서 한 번 훑는 데 1.9초). _CACHE_MIN_CHARS = 512 -_CACHE_MAX_ENTRIES = 4096 -_cache: dict = {} +_CACHE_MAX_ENTRIES = 16_384 +_cache: "OrderedDict[tuple, int]" = OrderedDict() +_cache_lock = threading.Lock() def estimate_tokens_cached(text: Any) -> int: @@ -180,15 +185,16 @@ def estimate_tokens_cached(text: Any) -> int: if len(text) < _CACHE_MIN_CHARS: return estimate_tokens(text) if text else 0 key = (hash(text), len(text)) - hit = _cache.get(key) - if hit is not None: - return hit + with _cache_lock: + hit = _cache.get(key) + if hit is not None: + _cache.move_to_end(key) + return hit value = estimate_tokens(text) - if len(_cache) >= _CACHE_MAX_ENTRIES: - # 가장 오래 들어온 것부터 비운다(dict 는 넣은 순서를 지킨다). - for _ in range(_CACHE_MAX_ENTRIES // 4): - _cache.pop(next(iter(_cache))) - _cache[key] = value + with _cache_lock: + _cache[key] = value + while len(_cache) > _CACHE_MAX_ENTRIES: + _cache.popitem(last=False) return value diff --git a/src/xgen_agent_runtime/core/token_estimate.py b/src/xgen_agent_runtime/core/token_estimate.py index 0bf73e7b..aece86fa 100644 --- a/src/xgen_agent_runtime/core/token_estimate.py +++ b/src/xgen_agent_runtime/core/token_estimate.py @@ -28,7 +28,9 @@ import base64 import binascii import re -from typing import Any, Dict, List, Optional, Union +import threading +from collections import OrderedDict +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from xgen_agent_runtime.core.text_tokens import ( SAFETY_MULTIPLIER, @@ -49,6 +51,8 @@ #: ``state.shared`` keys. Strings are the contract (hosts and tests may read them). _ESTIMATE_MEMO_KEY = "_prompt_tokens_memo" USAGE_ANCHOR_KEY = "_prompt_usage_anchor" +#: An anchored estimate never drops below this share of the plain estimate. +_ANCHOR_FLOOR = 0.8 CALIBRATION_KEY = "_prompt_tokens_calibration" #: Calibration is clamped: one odd report must not make every later estimate absurd. _CALIBRATION_MIN = 0.9 @@ -138,23 +142,91 @@ def _estimate_content(content: Union[str, List[Any], Any]) -> int: return _estimate_text(str(content)) -def _raw_message(message: Any) -> int: +#: Raw estimates of message and tool objects, keyed by ``id()`` and checked against a structural +#: signature (hashes of every string inside, which Python caches on the string object). Counting is +#: a per-character loop; without this every pass re-counted the whole history, and a long session +#: spent hundreds of milliseconds per iteration on the event loop (2026-10-12 review: 330 ms for +#: 6,000 messages, three passes per iteration). An in-place edit (the prune) changes the signature. +_OBJECT_MEMO: "OrderedDict[int, Tuple[int, int]]" = OrderedDict() +_OBJECT_MEMO_MAX = 65_536 +_object_memo_lock = threading.Lock() + + +def _signature(value: Any, depth: int = 0) -> int: + if isinstance(value, str): + return hash(value) + if depth > 6: + return id(value) + if isinstance(value, dict): + return hash(tuple((k, _signature(v, depth + 1)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return hash(tuple(_signature(v, depth + 1) for v in value)) + try: + return hash(value) + except TypeError: + return id(value) + + +def _memoized(obj: Any, compute: Callable[[Any], int]) -> int: + if not isinstance(obj, (dict, list)): + return compute(obj) + key = id(obj) + sig = _signature(obj) + with _object_memo_lock: + hit = _OBJECT_MEMO.get(key) + if hit is not None and hit[0] == sig: + _OBJECT_MEMO.move_to_end(key) + return hit[1] + value = compute(obj) + with _object_memo_lock: + _OBJECT_MEMO[key] = (sig, value) + _OBJECT_MEMO.move_to_end(key) + while len(_OBJECT_MEMO) > _OBJECT_MEMO_MAX: + _OBJECT_MEMO.popitem(last=False) + return value + + +def _raw_message_uncached(message: Any) -> int: if not isinstance(message, dict): return 0 # +4: role/turn framing every provider adds per message. return _estimate_content(message.get("content", "")) + 4 +def _raw_message(message: Any) -> int: + return _memoized(message, _raw_message_uncached) + + def estimate_message_tokens(messages: List[Any]) -> int: """Raw token estimate of a message list (content only, no provider factor).""" return sum(_raw_message(m) for m in (messages or [])) +def _raw_tool(tool: Any) -> int: + return _estimate_text(str(tool)) + + +#: ``state.shared`` slot Stage 3 fills with the turn's volatile context (date, retrieved memory, turn +#: notes). Stage 6 adds it to the request copy, so it is part of every request's size. +TURN_CONTEXT_KEY = "turn_context_text" + + +def _turn_context(state: Any) -> str: + shared = getattr(state, "shared", None) + value = shared.get(TURN_CONTEXT_KEY) if isinstance(shared, dict) else None + return value if isinstance(value, str) else "" + + def _raw_system_and_tools(state: Any) -> int: + """Everything in the request besides ``state.messages``: system prompt, tool schemas, and the + turn context Stage 6 injects. Leaving the turn context out made the first provider report look + like a 2.5x under-count, and that calibration then inflated every later estimate until the + guard rejected requests that fit (2026-10-12 review).""" total = _estimate_content(getattr(state, "system", "") or "") tools = getattr(state, "tools", None) if tools: - total += sum(_estimate_text(str(tool)) for tool in tools) + total += sum(_memoized(tool, _raw_tool) for tool in tools) + total += _estimate_text(_turn_context(state)) return total @@ -198,6 +270,7 @@ def _estimate_fingerprint(state: Any) -> tuple: id(messages[0]) if messages else 0, id(messages[-1]) if messages else 0, sys_len, + len(_turn_context(state)), len(tools), id(anchor) if anchor is not None else 0, _calibration(state), @@ -255,15 +328,19 @@ def estimate_prompt_tokens(state: Any) -> int: if isinstance(memo, tuple) and len(memo) == 2 and memo[0] == fingerprint: return memo[1] - total: Optional[int] = None + raw = _raw_system_and_tools(state) + estimate_message_tokens( + getattr(state, "messages", []) or [] + ) + total = int(raw * _scale(state) * _calibration(state)) anchor = shared.get(USAGE_ANCHOR_KEY) if isinstance(shared, dict) else None if isinstance(anchor, dict): - total = _anchored(state, anchor) - if total is None: - raw = _raw_system_and_tools(state) + estimate_message_tokens( - getattr(state, "messages", []) or [] - ) - total = int(raw * _scale(state) * _calibration(state)) + anchored = _anchored(state, anchor) + if anchored is not None: + # The anchor is exact for what the provider counted, but a large removal from that + # prefix (a stripped 60-page PDF) subtracts OUR per-page guess, which can exceed what + # the provider had counted and drive the result to zero. Never go far below the plain + # (calibrated) estimate. + total = max(anchored, int(total * _ANCHOR_FLOOR)) if isinstance(shared, dict): shared[_ESTIMATE_MEMO_KEY] = (fingerprint, total) diff --git a/src/xgen_agent_runtime/events/catalog.py b/src/xgen_agent_runtime/events/catalog.py index 1446762d..6a30a35a 100644 --- a/src/xgen_agent_runtime/events/catalog.py +++ b/src/xgen_agent_runtime/events/catalog.py @@ -466,7 +466,7 @@ class EventTypes(str, Enum): EventTypes.CONTEXT_COMPACTION_SKIPPED: { "strategy": "str — compactor name/class", "trigger": "str", - "reason": "str — turn_compaction_limit | already_summarized_for_this_request | nothing_to_summarize | summary_region_too_small", + "reason": "str — turn_compaction_limit | summary_model_failing | already_summarized_for_this_request | nothing_to_summarize | summary_region_too_small | no_change", "tokens_estimate": "int?", "target_tokens": "int", }, @@ -489,6 +489,7 @@ class EventTypes(str, Enum): "inputs_trimmed": "int — oversized stale tool-call arguments shortened", "chars_saved": "int — text chars removed (images excluded)", "aggressive": "bool? — the hard-pressure profile (guard / overflow)", + "results_fitted": "int? — tool results shortened in proportion so a hard-pressure request fits", "trigger": "str? — cost (Stage 2 token threshold) | the compaction trigger it ran under", "threshold_tokens": "int? — the prune_over_tokens threshold that fired (cost trigger)", "tokens_before": "int? — projected prompt tokens before the pass (cost trigger)", diff --git a/src/xgen_agent_runtime/host/context_budget.py b/src/xgen_agent_runtime/host/context_budget.py index 176f8ca4..bc3909e7 100644 --- a/src/xgen_agent_runtime/host/context_budget.py +++ b/src/xgen_agent_runtime/host/context_budget.py @@ -42,6 +42,11 @@ class BudgetFit: clamped: bool #: 이력을 잘라야 했을 때(압축이 없는 CLI 백엔드)의 새 이력. ``None`` 이면 그대로. history: Optional[List[Any]] = None + #: 맞춘 뒤의 메모리 노드 이전 대화 문자열(잘리지 않았으면 받은 그대로). + history_context: str = "" + #: 사용자 입력(text·rag)이 잘렸는가. 이력·이전 대화 문자열만 줄었으면 False: 사용자에게 "입력 일부를 + #: 생략했다" 고 알릴 일이 아니다. + input_clamped: bool = False #: 진단용 — provider 컨텍스트 오류와 대조할 압축 전 구성요소 추정치. window: int = 0 budget: int = 0 @@ -106,9 +111,28 @@ def _estimate_tools_tokens(registry: Any, provider: str, model: str) -> int: return 0 -#: 파이프라인 압축이 이력을 최소한 이만큼은 남긴다(체크포인트 요약 + 여유). 입력 맞춤은 이력을 -#: 이 크기까지 줄어들 수 있는 것으로 본다. 이력 때문에 사용자의 새 질문을 자르지 않는다. +#: 파이프라인 압축이 이력을 최소한 이만큼은 남긴다. 입력 맞춤은 이력을 이 크기까지 줄어들 수 있는 +#: 것으로 본다. 이력 때문에 사용자의 새 질문을 자르지 않는다. _COMPACTED_HISTORY_FLOOR = 5_000 +#: 이전 대화 문자열을 이보다 작게 줄일 바에는 뺀다(머리글만 남는다). +_MIN_HISTORY_CONTEXT_TOKENS = 256 + + +def _compacted_history_floor(window: int) -> int: + """압축 뒤 이력(체크포인트)이 차지할 수 있는 최대 토큰: 요약 + 원문으로 남기는 사용자 말 + 머리글. + + 5,000 고정이었을 때는 큰 창에서 체크포인트(사용자 말만 창의 8%, 최대 2만)가 그보다 커서, 입력 맞춤이 + 받아들인 질문과 RAG 를 압축으로도 담지 못했다. + """ + try: + from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( + checkpoint_tokens_bound, + ) + + return max(_COMPACTED_HISTORY_FLOOR, checkpoint_tokens_bound(window)) + except Exception: # noqa: BLE001 (fail-open) + return _COMPACTED_HISTORY_FLOOR + #: 내장 메모리 주입(Stage 2 → 시스템 프롬프트의 Relevant Knowledge)이 창에서 차지할 수 있는 몫. #: 주입 상한은 글자(기본 10,000자)라 8k~32k 창에서는 그것만으로 창의 3분의 1을 넘었다. @@ -147,20 +171,16 @@ def memory_reserved_tokens(window: int, configured_chars: int) -> int: return max(0, tokens) -def fit_history_context(context: str, window: int, provider: str, model: str) -> str: - """메모리 노드의 이전 대화 문자열을 창의 :data:`HISTORY_CONTEXT_WINDOW_SHARE` 안으로. - - 문자열은 오래된 대화 → 최근 대화 순이라 넘치면 가운데를 줄인다(머리글과 가장 최근 대화가 - 남는다). 창을 모르면 그대로. - """ - if not context or not window or window <= 0: +def fit_history_context_tokens(context: str, tokens: int, provider: str, model: str) -> str: + """이전 대화 문자열을 ``tokens`` 안으로(머리글과 가장 최근 대화가 남게 가운데를 줄인다).""" + if not context: return context try: from xgen_agent_runtime.host.token_budget import truncate_text_to_token_budget fitted, _cut = truncate_text_to_token_budget( context, - max(256, int(window * HISTORY_CONTEXT_WINDOW_SHARE)), + max(_MIN_HISTORY_CONTEXT_TOKENS, int(tokens)), provider=provider, model=model, front_ratio=0.2, @@ -172,6 +192,18 @@ def fit_history_context(context: str, window: int, provider: str, model: str) -> return context +def fit_history_context(context: str, window: int, provider: str, model: str) -> str: + """메모리 노드의 이전 대화 문자열을 창의 :data:`HISTORY_CONTEXT_WINDOW_SHARE` 안으로. 창을 모르면 그대로. + + 입력 맞춤(:func:`fit_input_to_budget`)이 남는 자리에 맞춰 한 번 더 줄인다. + """ + if not context or not window or window <= 0: + return context + return fit_history_context_tokens( + context, int(window * HISTORY_CONTEXT_WINDOW_SHARE), provider, model + ) + + def _next_history_start(history: List[Any], start: int, keep_last: int = 2) -> int: """``history[start:]`` 에서 가장 오래된 메시지 하나를 뺀 다음의 시작 위치. 더 뺄 수 없으면 ``start``. @@ -213,34 +245,48 @@ def fit_input_to_budget( window: int, reserved_tokens: int = 0, history_compressible: bool = True, + history_context: str = "", ) -> BudgetFit: - """이번 턴의 입력측(text + rag_block)을 유효 예산 안으로 맞춘다. + """이번 턴의 입력측을 유효 예산 안으로 맞춘다. + + 예산 = window − 출력 예약(:func:`core.compaction.output_reserve`, Stage 4 guard 와 같은 값) + − 안전마진 − reserved_tokens. 예전에는 출력 예약을 max_tokens 로만 잡아 guard 가 요구하는 것보다 + 2,048토큰 더 채웠고, RAG 를 꽉 채운 턴은 provider 가 받았을 요청을 guard 가 거절했다. - 예산 = window − max_tokens(출력 예약) − 안전마진 − reserved_tokens. 구성요소: overhead = system_prompt + tool 스키마(실제로 보내는 것) 이력 = preload 된 지난 대화 - 가변분 = rag_block → text 순으로 truncate + 가변분 = history_context → rag_block → text 순으로 줄인다 이력의 몫(4.89.0): ``history_compressible=True`` (API 백엔드): 파이프라인 압축(Stage 2/4/6)이 이력을 요약 - 체크포인트 크기(:data:`_COMPACTED_HISTORY_FLOOR`)까지 줄일 수 있다. 그래서 RAG·질문의 자리를 - 정할 때 이력은 그 바닥만큼만 센다. 예전에는 이력 전체를 세어, 압축할 수 있는 지난 대화 때문에 - 압축할 수 없는 **이번 질문**을 1,024토큰까지 잘랐다. + 체크포인트까지 줄일 수 있다. 그래서 RAG·질문의 자리를 정할 때 이력은 체크포인트가 차지할 수 + 있는 최대 크기(:func:`_compacted_history_floor`)만큼만 센다. 예전에는 이력 전체를 세어, 압축할 수 + 있는 지난 대화 때문에 압축할 수 없는 **이번 질문**을 1,024토큰까지 잘랐다. ``history_compressible=False`` (CLI 백엔드: 런타임 압축이 없고 지난 대화가 첫 메시지 하나로 접혀 CLI 도 줄이지 못한다): 넘치면 **가장 오래된 이력부터** 빼고(마지막 두 메시지는 남김), - 그다음 RAG, 마지막에 질문을 자른다. 잘린 이력은 ``BudgetFit.history`` 로 돌려준다. + 그다음 가변분을 줄인다. 잘린 이력은 ``BudgetFit.history`` 로 돌려준다. + + ``history_context``: 메모리 노드가 넘긴 이전 대화 문자열. 시스템 프롬프트 끝에 붙어 압축이 줄일 수 + 없으므로 여기서 가장 먼저 줄인다(맞춘 값은 ``BudgetFit.history_context``). ``reserved_tokens``: fit **이후** 턴 중에 system 으로 주입되는 것들(내장 메모리 등)의 선예약분. - fail-open: 어떤 예외도 입력을 그대로 돌려준다 — 예산 로직의 버그가 - 턴을 죽여선 안 된다 (agent_xgen 과 동일 원칙). + fail-open: 어떤 예외도 입력을 그대로 돌려준다. 예산 로직의 버그가 턴을 죽여선 안 된다. """ - result = BudgetFit(text=text, rag_block=rag_block, clamped=False, window=window) + result = BudgetFit( + text=text, + rag_block=rag_block, + clamped=False, + window=window, + history_context=history_context, + ) if not window or window <= 0: return result try: + from xgen_agent_runtime.core.compaction import output_reserve from xgen_agent_runtime.host.token_budget import ( + budget_margin, count_message_tokens, count_messages_tokens, count_text_tokens, @@ -248,28 +294,32 @@ def fit_input_to_budget( truncate_text_to_token_budget, ) - budget = effective_input_budget(window, max_tokens) + budget = min( + effective_input_budget(window, max_tokens), + window - output_reserve(window, max_tokens) - budget_margin(window), + ) budget = max(1, budget - max(0, int(reserved_tokens))) sys_t = count_text_tokens(system_prompt, provider, model) tools_t = _estimate_tools_tokens(registry, provider, model) hist_t = count_messages_tokens(history, provider, model) if history else 0 text_t = count_text_tokens(text, provider, model) rag_t = count_text_tokens(rag_block, provider, model) if rag_block else 0 + ctx_t = count_text_tokens(history_context, provider, model) if history_context else 0 - total = sys_t + tools_t + hist_t + text_t + rag_t + total = sys_t + tools_t + hist_t + text_t + rag_t + ctx_t result.budget = budget result.total_before = total if total <= budget: return result if history_compressible: - hist_need = min(hist_t, _COMPACTED_HISTORY_FLOOR) + hist_need = min(hist_t, _compacted_history_floor(window)) else: # 오래된 이력부터 뺀다. 이번 턴의 입력보다 먼저. 메시지별 토큰은 한 번만 센다. msgs = list(history or []) per = [count_message_tokens(m, provider, model) for m in msgs] start = 0 - while sys_t + tools_t + hist_t + text_t + rag_t > budget: + while sys_t + tools_t + hist_t + text_t + rag_t + ctx_t > budget: nxt = _next_history_start(msgs, start) if nxt == start: break @@ -280,7 +330,7 @@ def fit_input_to_budget( result.clamped = True hist_need = hist_t - if sys_t + tools_t + hist_need + text_t + rag_t <= budget: + if sys_t + tools_t + hist_need + text_t + rag_t + ctx_t <= budget: if history_compressible: logger.info( "[GENY_BUDGET] 입력 %d > 예산 %d: 이력(%d)은 파이프라인 압축에 맡기고 입력은 그대로", @@ -291,18 +341,33 @@ def fit_input_to_budget( return result logger.warning( - "[GENY_BUDGET] 입력 %d > 예산 %d (window=%d sys=%d tools=%d hist=%d text=%d rag=%d) — clamp", + "[GENY_BUDGET] 입력 %d > 예산 %d (window=%d sys=%d tools=%d hist=%d ctx=%d text=%d rag=%d), clamp", total, budget, window, sys_t, tools_t, hist_t, + ctx_t, text_t, rag_t, ) overhead = sys_t + tools_t + hist_need + # ── 0) 메모리 노드의 이전 대화 문자열: 가장 오래된 쪽부터 ── + if history_context: + room = budget - overhead - text_t - rag_t + if ctx_t > room: + new_ctx = ( + fit_history_context_tokens(history_context, room, provider, model) + if room >= _MIN_HISTORY_CONTEXT_TOKENS + else "" + ) + result.history_context = new_ctx + result.clamped = True + ctx_t = count_text_tokens(new_ctx, provider, model) if new_ctx else 0 + overhead += ctx_t + # ── 1) RAG 블록 truncate (사용자 텍스트 보존) ── new_rag = rag_block if rag_block: @@ -316,6 +381,7 @@ def fit_input_to_budget( if cut: result.rag_block = new_rag result.clamped = True + result.input_clamped = True rag_t = count_text_tokens(new_rag, provider, model) # ── 2) 그래도 초과면 사용자 텍스트 truncate (최후 수단) ── @@ -340,7 +406,8 @@ def fit_input_to_budget( if cut: result.text = new_text result.clamped = True - except Exception as exc: # noqa: BLE001 — fail-open + result.input_clamped = True + except Exception as exc: # noqa: BLE001 (fail-open) logger.warning("[GENY_BUDGET] 예산 적용 중 오류(무시하고 진행): %s", exc, exc_info=True) return result diff --git a/src/xgen_agent_runtime/host/runner.py b/src/xgen_agent_runtime/host/runner.py index 42798844..9c02786e 100644 --- a/src/xgen_agent_runtime/host/runner.py +++ b/src/xgen_agent_runtime/host/runner.py @@ -671,9 +671,11 @@ def build_pipeline( TokenBudgetGuard, ) - headroom = max(4096, int(max_tokens) + 2048) - if context_window_budget and int(context_window_budget) > 0: - headroom = min(headroom, max(1024, int(context_window_budget) // 2)) + from xgen_agent_runtime.core.compaction import output_reserve + + # The same reserve the compaction policy measures its input budget against, so the 80% + # trigger always comes before the guard (core.compaction.output_reserve). + headroom = output_reserve(int(context_window_budget or 0), int(max_tokens)) builder.with_guard(guards=[TokenBudgetGuard(min_remaining_tokens=headroom)]) if memory_provider is not None: diff --git a/src/xgen_agent_runtime/host/token_budget.py b/src/xgen_agent_runtime/host/token_budget.py index c30b0b53..52ab5305 100644 --- a/src/xgen_agent_runtime/host/token_budget.py +++ b/src/xgen_agent_runtime/host/token_budget.py @@ -69,6 +69,7 @@ ("o3", 200_000), ("o4", 200_000), ("gpt-5", 272_000), + ("gpt-5-chat", 128_000), ("gpt-5.5", 922_000), ("gpt-6", 922_000), # Anthropic / Bedrock / Vertex Claude. Opus 4.6 이후·Sonnet 4.6 이후·Haiku 5.5·Fable·Mythos 는 @@ -95,18 +96,15 @@ ("gemini-2.0", 1_048_576), ("gemini-2.5", 1_048_576), ("gemini", 1_048_576), - ("gemma-3-1b", 32_768), + ("gemma-4", 131_072), ("gemma-3", 131_072), + ("gemma-3-1b", 32_768), + ("gemma-3-270m", 32_768), + ("gemma-3n", 32_768), ("gemma-2", 8_192), - ("gemma", 8_192), - # Bedrock 의 Claude 밖 모델 (ID 는 "meta.llama3-1-…", "us.amazon.nova-pro-v1:0" 꼴: 구분자 뒤에서 잡힌다) - ("llama3-1", 128_000), - ("llama3-2", 128_000), - ("llama3-3", 128_000), - ("llama4", 128_000), - ("nova-micro", 128_000), - ("nova-lite", 300_000), - ("nova-pro", 300_000), + # 모르는 Gemma 판. 작게 잡으면 질문이 잘리고(8k 창 + max_tokens 8k 는 입력 자리가 없다), 크게 잡으면 + # provider 가 한도를 말해 줄 때 넘침 복구가 창을 고친다. 중간값. + ("gemma", 32_768), ] # provider 기준 기본값 (모델 매칭 실패 시). @@ -116,6 +114,9 @@ "azure": 128_000, "azure_foundry": 128_000, "anthropic": 200_000, + # BedrockClient 는 AnthropicClient 이고 Claude 만 부른다. 이름에 모델명이 없는 별칭(sonnet)·추론 프로필 + # ARN 도 Claude 다. + "bedrock": 200_000, "google": 1_048_576, "vertex": 1_048_576, "vllm": 32_768, @@ -124,9 +125,6 @@ "claude_code": 200_000, "codex": 272_000, } -#: Bedrock 은 Claude 밖의 모델(Llama 8k~128k, Mistral 32k, Titan 8k)도 서빙한다. provider 기본값을 -#: Claude 의 200k 로 두면 작은 모델이 압축 전에 400 을 낸다. Claude 는 위 표에서 잡힌다. -_BEDROCK_NON_CLAUDE_DEFAULT = 32_000 # 완전 미상(provider 도 모름)일 때 보수적 기본값. DEFAULT_CONTEXT_WINDOW = 32_000 @@ -232,8 +230,10 @@ def resolve_context_window( return catalog_win name = (model or "").lower().strip() - if prov == "claude_code" and name.endswith("[1m]"): - return 1_000_000 # Claude Code 의 1M 별칭(opus[1m]) + if prov == "claude_code": + # CLI 가 쓰는 창은 모델 이름이 아니라 CLI 와 요금제가 정한다(1M 은 [1m] 별칭으로 고를 때). 정식 ID 를 + # 넘겨도 표의 API 창을 쓰지 않는다. 런타임은 이 값으로 첫 메시지에 접는 지난 대화만 맞춘다. + return 1_000_000 if name.endswith("[1m]") else _PROVIDER_DEFAULT_WINDOW["claude_code"] if prov == "codex": # Codex CLI 는 모델의 API 창(gpt-5.5 1.05M)과 무관하게 자기 창(272k 입력)으로 돈다. return _PROVIDER_DEFAULT_WINDOW["codex"] @@ -244,16 +244,23 @@ def resolve_context_window( segments = [name] + [name[i + 1 :] for i, ch in enumerate(name) if ch in "./:"] best_len, best_win = -1, None for prefix, win in _MODEL_CONTEXT_WINDOW: - if len(prefix) > best_len and any(seg.startswith(prefix) for seg in segments): + if len(prefix) > best_len and any(_prefix_matches(seg, prefix) for seg in segments): best_len, best_win = len(prefix), win if best_win is not None: return best_win - if prov == "bedrock": - return _BEDROCK_NON_CLAUDE_DEFAULT return _PROVIDER_DEFAULT_WINDOW.get(prov, DEFAULT_CONTEXT_WINDOW) +def _prefix_matches(name: str, prefix: str) -> bool: + """``name`` starts with ``prefix`` and does not continue its number: 'gpt-5.5' is not a prefix + of 'gpt-5.50', 'claude-opus-4-6' is not one of 'claude-opus-4-60'.""" + if not name.startswith(prefix): + return False + rest = name[len(prefix) :] + return not (rest and rest[0].isdigit() and prefix[-1:].isdigit()) + + # ───────────────────────────────────────────────────────────────────────── # 이미지 입력 — 모델이 그림을 볼 수 있는가. Read 가 이미지를 모델에게 건넬지("보여 준다"), # "이 모델은 못 본다" 고 말할지를 가른다. 못 보는 모델에 그림을 보내면 provider 가 400 으로 @@ -311,6 +318,11 @@ def resolve_supports_vision( return bool(best) +def budget_margin(context_window: int) -> int: + """추정 오차에 남기는 안전마진: 창의 2%, 최소 1,024토큰.""" + return max(_BUDGET_MARGIN_MIN, int(max(0, int(context_window or 0)) * _BUDGET_MARGIN_RATIO)) + + def effective_input_budget( context_window: int, reserved_output_tokens: int, diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index 7ddadb39..977c6241 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -1014,18 +1014,12 @@ def run_dir_cleanup(_d: str = run_dir) -> None: vllm_probe=host.fetch_vllm_max_model_len, ) # 메모리 노드가 (최근 메시지, 이전 대화 문자열) 로 보낸 둘째 칸: 최근 N개(기본 2개)보다 - # 앞선 대화다. 옛 agent_xgen 처럼 시스템 프롬프트 끝에 붙인다. 노드 기본값이 10만 자라 - # 창의 일정 몫 안으로 줄인다(가장 최근 쪽이 남는다). + # 앞선 대화다. 옛 agent_xgen 처럼 시스템 프롬프트 끝에 붙인다(아래 맞춤 뒤). 노드 기본값이 + # 10만 자라 먼저 창의 일정 몫 안으로 줄이고, 입력 맞춤이 남는 자리에 맞춰 한 번 더 줄인다 + # (이번 질문·RAG 보다 먼저, 가장 최근 쪽이 남는다). _history_ctx = history_context(kwargs.get("memory")) if _history_ctx: _history_ctx = fit_history_context(_history_ctx, budget_window, provider, model) - system_prompt = ( - (system_prompt + "\n\n" + _history_ctx) if system_prompt else _history_ctx - ) - logger.info( - "agents/geny: 메모리 노드의 이전 대화 %d자를 시스템 프롬프트에 붙임", - len(_history_ctx), - ) _memory_reserve = 0 if memory_provider is not None: # 내장 메모리 주입(Stage 2)은 fit 이후 system 에 붙는다. 그만큼 미리 비워 둔다. @@ -1050,10 +1044,11 @@ def _fit(_text: str): window=budget_window, reserved_tokens=_memory_reserve, history_compressible=not _is_cli, + history_context=_history_ctx, ) fit = _fit(text) - if fit.clamped and _fast_path is not None and _fast_path.active: + if fit.input_clamped and _fast_path is not None and _fast_path.active: # A partial snapshot is not a snapshot. If budgeting # trims any of it, recompute from the original request # and keep the normal agent loop/witness semantics. @@ -1063,8 +1058,10 @@ def _fit(_text: str): # 스냅숏을 붙인 판이 아니라 원래 요청으로 돌아간다(다시 맞춘 결과가 잘리지 않았어도). text, rag_block = fit.text, fit.rag_block logger.info("agents/geny: workspace fast path 폴백 (context_budget)") + _history_ctx = fit.history_context if fit.clamped: - text, rag_block, clamped = fit.text, fit.rag_block, fit.clamped + # 사용자에게 알리는 것은 사용자 입력(text·rag)이 잘렸을 때뿐이다. + text, rag_block, clamped = fit.text, fit.rag_block, fit.input_clamped if fit.history is not None: # CLI: 덜어 낸 이력이 이번 턴의 이력이다. preload 워터마크도 새 길이로 맞춘다. # 기록 대상에서 빠지는 것은 여전히 "지난 대화" 전부다. @@ -1085,6 +1082,15 @@ def _fit(_text: str): fit.total_before, ) + if _history_ctx: + system_prompt = ( + (system_prompt + "\n\n" + _history_ctx) if system_prompt else _history_ctx + ) + logger.info( + "agents/geny: 메모리 노드의 이전 대화 %d자를 시스템 프롬프트에 붙임", + len(_history_ctx), + ) + if _fast_path is not None and _fast_path.active: register_snapshot_witnesses(state, _fast_path) logger.info( diff --git a/src/xgen_agent_runtime/llm_client/anthropic.py b/src/xgen_agent_runtime/llm_client/anthropic.py index dda64151..6a94ba85 100644 --- a/src/xgen_agent_runtime/llm_client/anthropic.py +++ b/src/xgen_agent_runtime/llm_client/anthropic.py @@ -14,7 +14,11 @@ from xgen_agent_runtime.core.errors import APIError, ErrorCategory from xgen_agent_runtime.core.state import TokenUsage -from xgen_agent_runtime.llm_client.base import BaseClient, ClientCapabilities +from xgen_agent_runtime.llm_client.base import ( + BaseClient, + ClientCapabilities, + prompt_tokens_with_cache, +) from xgen_agent_runtime.llm_client.translators import canonical_messages_to_anthropic from xgen_agent_runtime.llm_client.types import APIRequest, APIResponse, ContentBlock @@ -348,13 +352,7 @@ class AnthropicClient(BaseClient): def prompt_tokens_from_usage(self, usage: Any) -> int: """Anthropic (and Bedrock) report cache reads/writes OUTSIDE ``input_tokens``.""" - total = 0 - for field in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"): - try: - total += int(getattr(usage, field, 0) or 0) - except (TypeError, ValueError): - continue - return total + return prompt_tokens_with_cache(usage) provider = "anthropic" capabilities = ClientCapabilities( diff --git a/src/xgen_agent_runtime/llm_client/base.py b/src/xgen_agent_runtime/llm_client/base.py index 7f14339c..32f7440c 100644 --- a/src/xgen_agent_runtime/llm_client/base.py +++ b/src/xgen_agent_runtime/llm_client/base.py @@ -129,6 +129,18 @@ def supports(self, feature: str) -> bool: return bool(getattr(self, f"supports_{feature}", False)) +def prompt_tokens_with_cache(usage: Any) -> int: + """Anthropic-shaped usage (Anthropic, Bedrock, Claude Code CLI) reports cache reads and writes + OUTSIDE ``input_tokens``; the prompt the provider counted is their sum.""" + total = 0 + for name in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"): + try: + total += int(getattr(usage, name, 0) or 0) + except (TypeError, ValueError): + continue + return total + + class BaseClient(ABC): """Abstract LLM client. Concrete subclasses live in this package.""" diff --git a/src/xgen_agent_runtime/llm_client/claude_code.py b/src/xgen_agent_runtime/llm_client/claude_code.py index dc3b3a8c..c710ead1 100644 --- a/src/xgen_agent_runtime/llm_client/claude_code.py +++ b/src/xgen_agent_runtime/llm_client/claude_code.py @@ -52,7 +52,11 @@ aiter_bytes, detect_binary, ) -from xgen_agent_runtime.llm_client.base import BaseClient, ClientCapabilities +from xgen_agent_runtime.llm_client.base import ( + BaseClient, + ClientCapabilities, + prompt_tokens_with_cache, +) from xgen_agent_runtime.llm_client.translators._cli import ( StreamJsonAccumulator, assemble_response_from_stream_json, @@ -142,6 +146,25 @@ def _thinking_off(request: Optional[APIRequest]) -> bool: return request is not None and getattr(request, "thinking_level", None) == "off" +def _private_prompt_folder() -> str: + """``/xgen-claude-code-system-``, created 0700 and verified to be ours and private.""" + import stat + import tempfile + + uid = os.getuid() if hasattr(os, "getuid") else None + folder = os.path.join( + tempfile.gettempdir(), f"xgen-claude-code-system-{uid if uid is not None else 'user'}" + ) + os.makedirs(folder, mode=0o700, exist_ok=True) + if uid is not None: + info = os.lstat(folder) + if not stat.S_ISDIR(info.st_mode) or info.st_uid != uid: + raise OSError(f"{folder} is not a directory owned by this user") + if stat.S_IMODE(info.st_mode) & 0o077: + os.chmod(folder, 0o700) + return folder + + class ClaudeCodeCLIClient(BaseClient): """Subprocess-backed Claude Code client.""" @@ -513,12 +536,17 @@ def _make_runner( timeout_s=effective_timeout, ) + def prompt_tokens_from_usage(self, usage: Any) -> int: + """The CLI reports Anthropic-shaped usage: cache reads/writes outside ``input_tokens``.""" + return prompt_tokens_with_cache(usage) + def _system_prompt_file(self, request: APIRequest) -> str: """Write a system prompt too long for argv to a file; ``""`` when it fits inline. - The name is the content hash, so the same prompt yields the same argv (the hot spare - still matches) and repeated turns reuse one file. Files untouched for a day are removed - on the next write. + The name is the content hash, so the same prompt yields the same argv (the hot spare still + matches) and repeated turns reuse one file. The folder is private to this OS user (0700, + checked, not just requested) and files are 0600: a system prompt can carry the user's earlier + conversation. Files untouched for a day are removed on the next write. """ text = system_prompt_text(request.system) if not text or fits_argv(text): @@ -527,27 +555,33 @@ def _system_prompt_file(self, request: APIRequest) -> str: import tempfile import time - folder = os.path.join(tempfile.gettempdir(), "xgen-claude-code-system") try: - os.makedirs(folder, mode=0o700, exist_ok=True) + folder = _private_prompt_folder() path = os.path.join(folder, hashlib.sha256(text.encode("utf-8")).hexdigest() + ".md") if os.path.exists(path): os.utime(path) - else: - tmp = f"{path}.{os.getpid()}.tmp" - with open(tmp, "w", encoding="utf-8") as fp: + return path + fd, tmp = tempfile.mkstemp(dir=folder, suffix=".tmp") # unique per call, 0600 + try: + with os.fdopen(fd, "w", encoding="utf-8") as fp: fp.write(text) os.replace(tmp, path) - cutoff = time.time() - 86_400 - for name in os.listdir(folder): - old = os.path.join(folder, name) - with contextlib.suppress(OSError): - if old != path and os.path.getmtime(old) < cutoff: - os.unlink(old) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + cutoff = time.time() - 86_400 + for name in os.listdir(folder): + old = os.path.join(folder, name) + with contextlib.suppress(OSError): + if old != path and os.path.getmtime(old) < cutoff: + os.unlink(old) return path except OSError: - logger.warning( - "claude_code: system prompt file write failed, sending inline", exc_info=True + logger.error( + "claude_code: could not write the system prompt file; the prompt is too long for " + "the command line and the CLI will not start", + exc_info=True, ) return "" diff --git a/src/xgen_agent_runtime/llm_client/translators/_cli.py b/src/xgen_agent_runtime/llm_client/translators/_cli.py index c6206895..08bd3208 100644 --- a/src/xgen_agent_runtime/llm_client/translators/_cli.py +++ b/src/xgen_agent_runtime/llm_client/translators/_cli.py @@ -103,8 +103,11 @@ def resolve_auth_channel(auth_mode: str, has_api_key: bool) -> str: #: One argv string must stay under Linux ``MAX_ARG_STRLEN`` (131,072 bytes). Above this the -#: Claude Code client moves the system prompt to a file and the prompt to stdin. +#: Claude Code client moves the system prompt to a file and the prompt to stdin. Windows caps the +#: WHOLE command line at 32,767 characters (the desktop host runs the CLI there), so the bar is +#: much lower: the rest of the argv (MCP config, tool lists) needs room too. ARGV_SAFE_BYTES = 100_000 +ARGV_SAFE_CHARS_WINDOWS = 12_000 def system_prompt_text(system: Any) -> str: @@ -123,7 +126,11 @@ def system_prompt_text(system: Any) -> str: def fits_argv(text: str) -> bool: - """True when *text* is safe as a single argv string.""" + """True when *text* is safe as a single argv string on this platform.""" + import os + + if os.name == "nt": + return len(text or "") <= ARGV_SAFE_CHARS_WINDOWS return len((text or "").encode("utf-8")) <= ARGV_SAFE_BYTES diff --git a/src/xgen_agent_runtime/memory/retriever.py b/src/xgen_agent_runtime/memory/retriever.py index e01c5af2..60d3fe07 100644 --- a/src/xgen_agent_runtime/memory/retriever.py +++ b/src/xgen_agent_runtime/memory/retriever.py @@ -81,6 +81,8 @@ def _scan_rows(hooks: MemoryHooks) -> int: _EXCLUDED_OVERFETCH = 5 #: 압축 기록이 쌓이는 노트 분류(``record_compaction``). 자동 검색 층에서 늘 빠진다. _COMPACTIONS_CATEGORY = "compactions" +#: 그것만 빠질 때의 여유 배수. +_COMPACTIONS_OVERFETCH = 2 def _layer_cap(hooks: MemoryHooks, layer: str) -> int: @@ -382,8 +384,10 @@ async def _fetch_summary() -> Any: # memory_search tool calls are unaffected): screen-observation style # buffers can dominate a vault and drown real recall. _configured_excluded = set(getattr(hooks, "search_exclude_categories", ()) or ()) - _fetch_k = ( - hooks.max_results * _EXCLUDED_OVERFETCH if _configured_excluded else hooks.max_results + # Compaction records are always dropped (below), so always fetch a little more than k: + # otherwise every compaction note in the top k leaves the layer with fewer than k results. + _fetch_k = hooks.max_results * ( + _EXCLUDED_OVERFETCH if _configured_excluded else _COMPACTIONS_OVERFETCH ) # 압축 기록("compactions")은 늘 뺀다. 이 세션의 것은 체크포인트로 이미 messages 에 있고, # 지난 세션의 것은 같은 대화의 아카이브를 줄인 판이다. 자동 층에 올리면 같은 요약이 "관련 diff --git a/src/xgen_agent_runtime/slash_commands/built_in/compact.py b/src/xgen_agent_runtime/slash_commands/built_in/compact.py index f3838bb6..55cfeee5 100644 --- a/src/xgen_agent_runtime/slash_commands/built_in/compact.py +++ b/src/xgen_agent_runtime/slash_commands/built_in/compact.py @@ -16,7 +16,8 @@ class CompactCommand(SlashCommand): Runtime compaction belongs to Stage 2 (``core.compaction.run_compaction``): it consumes ``SharedKeys.CONTEXT_COMPACTION_REQUEST`` at the start of the next pass and summarizes - synchronously, ``trigger="manual"``: even when the context is not yet near the window. + synchronously, ``trigger="manual"``, even when the context is not yet near the window. If the + summary model fails the history is left unchanged (a manual request is soft pressure). The command only places that request; the host passes the state as ``ctx.extras["state"]``. (Earlier versions looked for a "Stage 19 summarize strategy", which no pipeline registers, so the command always reported failure.) @@ -30,6 +31,12 @@ async def execute(self, args, ctx: SlashContext) -> SlashResult: state = (ctx.extras or {}).get("state") if state is None or not isinstance(getattr(state, "shared", None), dict): return SlashResult(content="No active conversation state to compact.", success=False) + if getattr(state, "_context_compactor", None) is None: + # Stage 2 would drop the request silently (compaction is off for this agent, e.g. a + # CLI backend that manages its own context). + return SlashResult( + content="Context compaction is turned off for this agent.", success=False + ) request = {"source": "manual", "iteration": int(getattr(state, "iteration", 0) or 0)} state.shared[SharedKeys.CONTEXT_COMPACTION_REQUEST] = request add_event = getattr(state, "add_event", None) diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py index 94b293de..34b73c56 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py @@ -156,8 +156,11 @@ async def compact(self, state: PipelineState) -> None: #: Acks written by runtimes before 4.89.0: recognised so a later compaction folds them in too. _LEGACY_ACKS = ("Understood, I have the context from our previous conversation.",) -#: ``state.shared`` slot carrying verbatim user messages across compactions within a turn. +#: ``state.shared`` slot carrying verbatim earlier user messages across compactions. PINNED_USERS_KEY = "_compaction_pinned_users" +#: ``state.shared`` slot carrying the request being worked on once it has been summarized away, so a +#: second compaction still labels it as the current request instead of an earlier message. +CURRENT_REQUEST_KEY = "_compaction_current_request" _SUMMARY_PROMPT = """You are compacting the history of an ongoing agent conversation because it no longer fits the model's context window. Write a handoff summary that lets the agent continue the work seamlessly WITHOUT the older messages. @@ -197,6 +200,15 @@ async def compact(self, state: PipelineState) -> None: _OPENAI_FAMILY = ("openai", "azure", "azure_foundry") +def checkpoint_tokens_bound(window: int) -> int: + """The most a checkpoint message can take for this window: the summary, the verbatim user + messages (and the current request) within their budget, and the intro/ack framing.""" + window = max(0, int(window or 0)) + summary = min(_SUMMARY_MAX_TOKENS, max(_SUMMARY_MIN_TOKENS, window // 16)) + pinned = min(int(window * _PINNED_RATIO), _PINNED_MAX_TOKENS) + return summary + pinned + 600 + + def _content_blocks(content: Any) -> list: if isinstance(content, list): return [b for b in content if isinstance(b, dict)] @@ -342,11 +354,16 @@ class LLMSummaryCompactor(SummaryCompactor): Failure ------- - The summary call is retried once. If there is still no summary: under soft pressure (the - proactive 80% trigger) the history is left as it is: the guard still protects the request; - under hard pressure (guard, provider overflow, explicit request) a deterministic digest of the - summarized part replaces it. Before 4.89.0 any failure replaced the older history with a - one-line placeholder. + The summary call is retried once (with a shorter transcript when the summary request itself was + too long for the model). If there is still no summary: under soft pressure (the proactive + trigger, a Stage 16 request, ``/compact``) the history is left as it is and the guard still + protects the request; under hard pressure (guard, provider overflow, or a direct call without a + runner request) a deterministic digest of the summarized part replaces it. Before 4.89.0 any + failure replaced the older history with a one-line placeholder. + + The compactor reports back into the runner's request dict (``core.compaction``): how many model + calls it made and whether a model summary or the digest replaced the history, so failed calls + count against the per-request and per-turn limits. No model wired (no ``state.model``/client, or a manifest without an override) counts as a failure without a call: the no-cost-by-default rule still holds. @@ -389,7 +406,7 @@ def plan( self, state: PipelineState, target_tokens: Optional[int] = None ) -> Optional[CompactionPlan]: """Where to cut, what to pin. ``None`` when there is nothing to summarize.""" - from xgen_agent_runtime.core.compaction import TARGET_RATIO, window_of + from xgen_agent_runtime.core.compaction import TARGET_RATIO, input_budget_of, window_of from xgen_agent_runtime.core.token_estimate import ( estimate_messages_scaled, estimate_overhead_tokens, @@ -401,7 +418,7 @@ def plan( if n <= 1: return None window = window_of(state) - target = int(target_tokens or window * TARGET_RATIO) + target = int(target_tokens or input_budget_of(state) * TARGET_RATIO) provider = str(getattr(self._client_getter(state), "provider", "") or "") # Minimal tail: the last message; when it is a tool result, also the call it answers. @@ -440,13 +457,23 @@ def plan( if not new_material: return None # only an earlier checkpoint would be summarized again - current = "" - for m in reversed(msgs): - if _is_real_user(m): - current = _message_text(m).strip() if any(m is r for r in region) else "" - break + carried = [str(t) for t in (state.shared.get(PINNED_USERS_KEY) or []) if str(t).strip()] + carried_current = str(state.shared.get(CURRENT_REQUEST_KEY) or "").strip() + last_real = next((m for m in reversed(msgs) if _is_real_user(m)), None) + if last_real is not None: + # The newest thing the user wrote is the request being worked on; it is shown on its own + # when it is summarized, and stays verbatim in the kept tail otherwise. A request carried + # from an earlier compaction is now just an earlier message. + current = ( + _message_text(last_real).strip() if any(last_real is r for r in region) else "" + ) + if carried_current: + carried.append(carried_current) + else: + # Every message the user wrote is already inside an earlier checkpoint: the request + # being worked on is the one that checkpoint carried. + current = carried_current - carried = list(state.shared.get(PINNED_USERS_KEY) or []) candidates = carried + [_message_text(m).strip() for m in new_material if _is_real_user(m)] if current and candidates and candidates[-1] == current: candidates = candidates[:-1] # shown separately as the request being worked on @@ -554,14 +581,23 @@ async def _summarize( from dataclasses import replace as _replace from xgen_agent_runtime.core.compaction import window_of + from xgen_agent_runtime.core.errors import ( + APIError, + ErrorCategory, + parse_overflow_limit, + parse_overflow_tokens, + ) + from xgen_agent_runtime.core.token_estimate import CALIBRATION_KEY, estimate_text_scaled window = window_of(state) provider = str(getattr(client, "provider", "") or "") summary_tokens = self._summary_tokens(window, provider) - transcript_budget = max(2_000, min(int(window * 0.6), window - summary_tokens - 3_000)) - transcript = self._transcript(state, region, transcript_budget) + # Our estimate is only as good as the calibration. Before the first provider report of a + # turn (every turn of a host that builds a fresh state) there is none, and a provider that + # counts 1.8x more turned a 60% transcript into a request the summarizer itself rejected. + share = 0.6 if CALIBRATION_KEY in (state.shared or {}) else 0.4 + transcript_budget = max(1_000, min(int(window * share), window - summary_tokens - 3_000)) words = max(300, min(1_500, summary_tokens // 3)) - prompt = _SUMMARY_PROMPT.format(words=words, transcript=transcript) cfg = resolve(state) if getattr(cfg, "thinking_level", None): @@ -573,6 +609,8 @@ async def _summarize( attempts = 0 for attempt in range(2): attempts = attempt + 1 + transcript = self._transcript(state, region, transcript_budget) + prompt = _SUMMARY_PROMPT.format(words=words, transcript=transcript) try: resp = await client.create_message( model_config=cfg, @@ -584,7 +622,21 @@ async def _summarize( "memory.compaction.llm_failed", {"error": str(exc), "compactor": self.name, "attempt": attempts}, ) - if attempt == 0: + if isinstance(exc, APIError) and exc.category == ErrorCategory.TOKEN_LIMIT: + # The summary request itself did not fit. Sending the same prompt again fails + # the same way: use the provider's numbers to shrink the transcript, and keep + # the correction for every later estimate of this turn. + reported = parse_overflow_tokens(str(exc)) + limit = parse_overflow_limit(str(exc)) or window + sent = estimate_text_scaled(state, prompt) + if reported > 0 and sent > 0 and isinstance(state.shared, dict): + current = float(state.shared.get(CALIBRATION_KEY) or 1.0) + state.shared[CALIBRATION_KEY] = min( + 2.5, max(current, current * reported / sent) + ) + ratio = (limit - summary_tokens - 2_000) / reported if reported > 0 else 0.5 + transcript_budget = max(500, int(transcript_budget * min(0.8, max(0.1, ratio)))) + elif attempt == 0: await asyncio.sleep(1.0) continue # The summary is a model call outside the main loop: report it as auxiliary usage. @@ -630,6 +682,7 @@ async def compact(self, state: PipelineState) -> None: # cannot go out as it is: relieve it without another model call. if client is not None and resolve is not None and not request.get("digest_only"): summary, cfg, attempts = await self._summarize(state, region, resolve, client) + request["model_calls"] = attempts if summary is None: if not hard: state.add_event( @@ -654,9 +707,12 @@ async def compact(self, state: PipelineState) -> None: if kept and isinstance(kept[0], dict) and kept[0].get("role") == "user": head.append({"role": "assistant", "content": CHECKPOINT_ACK}) state.messages = head + strip_leading_orphan_tool_results(kept) - state.shared[PINNED_USERS_KEY] = list(outline.pinned) + ( - [outline.current_request] if outline.current_request else [] - ) + request["source"] = source + state.shared[PINNED_USERS_KEY] = list(outline.pinned) + if outline.current_request: + state.shared[CURRENT_REQUEST_KEY] = outline.current_request + else: + state.shared.pop(CURRENT_REQUEST_KEY, None) state.add_event( "memory.compaction.summarized", { diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py index 49c07c68..ca87d357 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py @@ -7,10 +7,14 @@ from typing import Any, Dict, List, Optional from xgen_agent_runtime.core.compaction import ( + API_CALLS_KEY, PROACTIVE_RATIO, + SUMMARIZED_AT_KEY, TARGET_RATIO, + input_budget_of, reconcile_recorded_index, run_compaction, + summarized_for_this_call, window_of, ) from xgen_agent_runtime.core.schema import ConfigField, ConfigSchema @@ -52,6 +56,16 @@ logger = logging.getLogger(__name__) +#: ``state.shared`` keys the background summary reads, and (for the pinned/current ones) writes back. +_SHADOW_SHARED_KEYS = ( + "_prompt_tokens_calibration", + "_compaction_pinned_users", + "_compaction_current_request", + "turn_context_text", +) +_SHADOW_RESULT_KEYS = ("_compaction_pinned_users", "_compaction_current_request") + + class _CompactionShadow: """Minimal state stand-in for background compaction (TTFT program). @@ -66,13 +80,14 @@ def __init__(self, state: PipelineState): self.model = getattr(state, "model", "") self.llm_client = getattr(state, "llm_client", None) self.context_window_budget = getattr(state, "context_window_budget", 200_000) + self.max_tokens = getattr(state, "max_tokens", 0) # Planning reads the system prompt/tool size and the turn's calibration. self.system = getattr(state, "system", "") self.tools = list(getattr(state, "tools", None) or []) self.shared: Dict[str, Any] = { k: v for k, v in (getattr(state, "shared", None) or {}).items() - if k in ("_prompt_tokens_calibration", "_compaction_pinned_users") + if k in _SHADOW_SHARED_KEYS } self.events: List[tuple] = [] #: 압축 호출의 보조 사용량. 결과를 받을 때 실제 state 로 옮긴다(이벤트와 같다). @@ -392,7 +407,6 @@ async def _preload_short_term_window(self, state: PipelineState) -> None: return # 창은 대화 기록의 일부일 뿐이다. 작은 모델(vLLM 32k)에서 4만 자 창은 그것만으로 80% 압축 # 문턱을 넘어, 매 턴 첫 호출 전에 같은 지난 턴을 다시 요약했다. 창을 모델 창의 20% 안으로 둔다. - from xgen_agent_runtime.core.compaction import window_of from xgen_agent_runtime.core.text_tokens import chars_for_tokens cfg.max_chars = max( @@ -539,7 +553,7 @@ async def execute(self, input: Any, state: PipelineState) -> Any: if await self._apply_bg_compaction(state): state.shared.pop("_prompt_tokens_memo", None) estimated_tokens = estimate_prompt_tokens(state) - budget = window_of(state) + budget = input_budget_of(state) if forced_request: # A Stage-16 token dimension (or /compact) requested maintenance for pending # work. Run synchronously: deferring again would let the next Stage-4/API boundary @@ -562,6 +576,7 @@ async def execute(self, input: Any, state: PipelineState) -> Any: self._background_compaction and isinstance(self._compactor, LLMSummaryCompactor) and estimated_tokens <= budget * 0.9 + and not summarized_for_this_call(state) ) if defer_to_background: self._schedule_bg_compaction(state) @@ -627,7 +642,7 @@ def _schedule_bg_compaction(self, state: PipelineState) -> None: return from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, _llm_gate - target = int(window_of(state) * TARGET_RATIO) + target = int(input_budget_of(state) * TARGET_RATIO) # Same policy as the synchronous path: turn limit, once per request, region worth a call. skip = _llm_gate( state, self._compactor, trigger="background", target_tokens=target, hard=False @@ -672,6 +687,13 @@ async def _run() -> _CompactionShadow: "len": snapshot_len, "message_ids": snapshot_ids, } + # This request's summary is in flight: Stage 4's proactive check must not summarize the + # same request synchronously (it did, and the background result was then discarded). The + # attempt counts against the turn's limit now, whether or not its result is ever applied. + from xgen_agent_runtime.core.compaction import LLM_COMPACTIONS_KEY + + state.shared[SUMMARIZED_AT_KEY] = int(state.shared.get(API_CALLS_KEY) or 0) + state.shared[LLM_COMPACTIONS_KEY] = int(state.shared.get(LLM_COMPACTIONS_KEY) or 0) + 1 state.add_event( "context.compaction_scheduled", { @@ -697,6 +719,16 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: # 요약을 버리더라도 모델은 이미 불렀다: 사용량은 먼저 옮긴다. for purpose, usage in shadow.aux_usage: state.add_aux_usage(purpose, usage) + # A background summary that called the model and came back empty is a failed attempt, like + # a synchronous one (core.compaction counts those toward the soft-pressure cut-off). + from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, LLM_FAILURES_KEY + + report = shadow.shared.get(COMPACTION_CONTEXT_KEY) or {} + if int(report.get("model_calls") or 0) > 0: + if report.get("source") == "model": + state.shared[LLM_FAILURES_KEY] = 0 + else: + state.shared[LLM_FAILURES_KEY] = int(state.shared.get(LLM_FAILURES_KEY) or 0) + 1 n = int(info["len"]) msgs = state.messages @@ -715,16 +747,16 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: # Keep Stage-18's STM watermark valid across the background swap # (audit D3) — same contract as run_compaction's synchronous path. reconcile_recorded_index(before_list, list(state.messages), state.metadata) - # Count it like a synchronous summary: the turn limit and "already summarized for this - # request" see it, so the proactive check right after does not summarize again. - from xgen_agent_runtime.core.compaction import ( - API_CALLS_KEY, - LLM_COMPACTIONS_KEY, - SUMMARIZED_AT_KEY, - ) - + # "Already summarized for this request": the proactive check right after the swap must not + # summarize again. (The attempt was counted toward the turn's limit when it was scheduled.) state.shared[SUMMARIZED_AT_KEY] = int(state.shared.get(API_CALLS_KEY) or 0) - state.shared[LLM_COMPACTIONS_KEY] = int(state.shared.get(LLM_COMPACTIONS_KEY) or 0) + 1 + # The verbatim user messages the checkpoint pinned (and the request it carried) belong to the + # real state now; without them the next synchronous compaction dropped those messages. + for key in _SHADOW_RESULT_KEYS: + if key in shadow.shared: + state.shared[key] = shadow.shared[key] + else: + state.shared.pop(key, None) for event_type, data in shadow.events: state.add_event(event_type, data) compactor_name = str(getattr(self._compactor, "name", "") or type(self._compactor).__name__) diff --git a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py index d770364a..99aa3997 100644 --- a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py @@ -279,7 +279,7 @@ async def _recover_budget(self, state: PipelineState, result: GuardResult) -> bo ) # Lazy import: keeps the guard stage import-light and avoids a # core→stage cycle at module load. - from xgen_agent_runtime.core.compaction import TARGET_RATIO, run_compaction, window_of + from xgen_agent_runtime.core.compaction import TARGET_RATIO, input_budget_of, run_compaction outcome = await run_compaction( state, @@ -287,7 +287,7 @@ async def _recover_budget(self, state: PipelineState, result: GuardResult) -> bo trigger="guard", provider=self._memory_provider, # A prune that already lands here is enough; otherwise summarize down to it. - target_tokens=int(window_of(state) * TARGET_RATIO), + target_tokens=int(input_budget_of(state) * TARGET_RATIO), # The request cannot go out as it is: aggressive prune, no small-region skip, and a # deterministic digest if the model summary fails. hard=True, diff --git a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py index a172ca4f..7b8f516d 100644 --- a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py @@ -608,7 +608,12 @@ async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool compactor = getattr(state, "_context_compactor", None) if compactor is None: return False - from xgen_agent_runtime.core.compaction import run_compaction, window_of + from xgen_agent_runtime.core.compaction import ( + TARGET_RATIO, + input_budget_of, + run_compaction, + window_of, + ) from xgen_agent_runtime.core.errors import parse_overflow_limit, parse_overflow_tokens from xgen_agent_runtime.core.token_estimate import ( calibrate_from_overflow, @@ -637,6 +642,11 @@ async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool "provider_limit": limit, }, ) + if 0 < reported <= int(input_budget_of(state) * TARGET_RATIO): + # The messages are already small: the request was too long because of the room asked + # for the answer (vLLM counts max_tokens: "100 in the messages, 8192 in the completion"). + # Compacting cannot fix that, so do not pay for a summary and a doomed retry. + return False outcome = await run_compaction( state, compactor, @@ -644,7 +654,7 @@ async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool provider=getattr(state, "_context_memory_provider", None), # No target: the provider already said this request does not fit, so the compactor # runs even when our (corrected) estimate says a prune would do. It aims at - # TARGET_RATIO of the (possibly corrected) window. + # TARGET_RATIO of the (possibly corrected) input budget. target_tokens=None, hard=True, ) diff --git a/src/xgen_agent_runtime/stages/s10_tool/persistence.py b/src/xgen_agent_runtime/stages/s10_tool/persistence.py index 7ccd69b8..725c09d0 100644 --- a/src/xgen_agent_runtime/stages/s10_tool/persistence.py +++ b/src/xgen_agent_runtime/stages/s10_tool/persistence.py @@ -135,14 +135,14 @@ def _summarise(body: str, *, limit: int, path: str, reachable: bool) -> str: return f"[tool result truncated: {len(body)} chars > {limit} limit]\n{where}\nPreview:\n{peek}" -#: Share of the context window one tool result may take before it is moved to disk. +#: Share of the context window one tool result may take in the request (4.89.0). _WINDOW_SHARE = 0.25 -#: Never cap below this (a 4k-window model still gets a usable result). -_WINDOW_CAP_FLOOR = 2_000 +#: Never cap below this many tokens (a 4k-window model still gets a usable result). +_WINDOW_CAP_FLOOR_TOKENS = 1_000 -def _window_cap(context: ToolContext) -> Optional[int]: - """Characters a single result may keep inline for this turn's model, or ``None`` if unknown.""" +def _window_cap_tokens(context: ToolContext) -> Optional[int]: + """Tokens a single result may take in this turn's request, or ``None`` if the window is unknown.""" state = getattr(context, "state_view", None) try: window = int(getattr(state, "context_window_budget", 0) or 0) @@ -150,22 +150,65 @@ def _window_cap(context: ToolContext) -> Optional[int]: window = 0 if window <= 0: return None - from xgen_agent_runtime.core.text_tokens import chars_for_tokens + return max(_WINDOW_CAP_FLOOR_TOKENS, int(window * _WINDOW_SHARE)) - return max(_WINDOW_CAP_FLOOR, chars_for_tokens(int(window * _WINDOW_SHARE))) + +def _view_tokens(text: str) -> int: + """Script-aware token estimate of what the model will read, with the conservative factor (the + provider is not known here). Characters are not tokens: 100k chars of English is ~25k tokens, + of Korean ~100k.""" + from xgen_agent_runtime.core.text_tokens import ( + DEFAULT_PROVIDER_FACTOR, + SAFETY_MULTIPLIER, + estimate_tokens_cached, + ) + + return int(estimate_tokens_cached(text) * DEFAULT_PROVIDER_FACTOR * SAFETY_MULTIPLIER) -def _clip_inline(body: str, limit: int) -> str: - """Head and tail of ``body`` within ``limit`` chars, with an explicit marker.""" - tail = limit // 4 - head = max(0, limit - tail - 200) +def _clip_inline(body: str, keep_chars: int, *, saved_at: str = "") -> str: + """Head and tail of ``body`` within about ``keep_chars`` chars, with an explicit marker.""" + tail = keep_chars // 4 + head = max(0, keep_chars - tail) + where = ( + f"The full result is saved at: {saved_at}" + if saved_at + else "Re-run with a narrower query (a line range, a filter) if you need the omitted part" + ) return ( - f"[tool result clipped: {len(body)} chars > {limit} for this model's context window; " - "the middle is omitted and no copy was kept: re-run with a narrower query if needed]\n" + f"[tool result clipped to fit this model's context window: {len(body)} chars, the middle " + f"is omitted. {where}.]\n" f"{body[:head]}\n…\n{body[-tail:] if tail else ''}" ) +def _fit_to_window(result: ToolResult, *, tool_name: str, context: ToolContext) -> ToolResult: + """Clip what the model will read (``display_text`` when set, else the content) to a quarter of + the context window. The tool's own character cap ignores the model: 100k chars is 25k-100k + tokens, so on a 32k-window model one result could overflow the request by itself.""" + cap = _window_cap_tokens(context) + if cap is None: + return result + view = ( + result.display_text if result.display_text is not None else _render_content(result.content) + ) + tokens = _view_tokens(view) + if tokens <= cap: + return result + keep = max(400, int(len(view) * cap / tokens * 0.95)) + saved_at = "" + if result.persist_full and _reachable(result.persist_full, context): + saved_at = result.persist_full + _LOG.info( + "tool %s result ~%d tokens > window cap %d; clipping to %d chars", + tool_name, + tokens, + cap, + keep, + ) + return replace(result, display_text=_clip_inline(view, keep, saved_at=saved_at)) + + def maybe_persist_large_result( result: ToolResult, *, @@ -173,6 +216,29 @@ def maybe_persist_large_result( tool_name: str, capabilities: ToolCapabilities, context: ToolContext, +) -> ToolResult: + """Apply the tool's character cap (:func:`_persist_over_tool_cap`), then bound what the model + reads by the context window (:func:`_fit_to_window`, 4.89.0). ``max_result_chars`` of 0 or + below opts out of both.""" + if capabilities.max_result_chars <= 0: + return result + out = _persist_over_tool_cap( + result, + tool_use_id=tool_use_id, + tool_name=tool_name, + capabilities=capabilities, + context=context, + ) + return _fit_to_window(out, tool_name=tool_name, context=context) + + +def _persist_over_tool_cap( + result: ToolResult, + *, + tool_use_id: str, + tool_name: str, + capabilities: ToolCapabilities, + context: ToolContext, ) -> ToolResult: """Return a possibly-rewritten ``ToolResult`` with large bodies on disk. @@ -202,37 +268,19 @@ def maybe_persist_large_result( # Tool has already persisted externally; nothing to do. return result - # The tool's own cap ignores the model: 100k chars is ~25-90k tokens, so on a 32k-window model a - # single result used to overflow the request by itself. The effective cap is also bounded by a - # quarter of the context window (4.89.0). - window_cap = _window_cap(context) - limit = capabilities.max_result_chars - if window_cap is not None: - limit = min(limit, window_cap) - rendered = _render_content(result.content) - if len(rendered) <= limit: + if len(rendered) <= capabilities.max_result_chars: return result storage_path = context.storage_path if not storage_path: - if window_cap is not None and len(rendered) > window_cap: - # No sink, and inlining would not fit the model: keep head and tail inline instead of - # sending a request the provider will reject. - _LOG.warning( - "tool %s returned %d chars (> window cap %d) and no storage_path; clipping inline", - tool_name, - len(rendered), - window_cap, - ) - return replace(result, display_text=_clip_inline(rendered, window_cap)) # No sink configured — log once per call so ops can spot misconfig, # but return the original result so correctness is preserved. _LOG.warning( "tool %s returned %d chars (> %d) but no storage_path; inlining the full payload", tool_name, len(rendered), - limit, + capabilities.max_result_chars, ) return result @@ -266,7 +314,7 @@ def maybe_persist_large_result( if summary is None: summary = _summarise( rendered, - limit=limit, + limit=capabilities.max_result_chars, path=target_path, reachable=_reachable(target_path, context), ) diff --git a/tests/test_host_turn_executor_gates.py b/tests/test_host_turn_executor_gates.py index 3c2d3fb7..4eaa2904 100644 --- a/tests/test_host_turn_executor_gates.py +++ b/tests/test_host_turn_executor_gates.py @@ -319,6 +319,7 @@ def _fit(**kwargs): text="partial snapshot", rag_block=kwargs["rag_block"], clamped=True, + input_clamped=True, window=kwargs["window"], budget=1, total_before=999, diff --git a/tests/unit/test_compaction_policy_489.py b/tests/unit/test_compaction_policy_489.py index 2241e657..c5e639e7 100644 --- a/tests/unit/test_compaction_policy_489.py +++ b/tests/unit/test_compaction_policy_489.py @@ -124,6 +124,12 @@ def test_provider_count_anchors_the_estimate(): 1_200_000, 1_048_576, ), + ( + "Unable to submit request because the input token count is 1200000 but model only " + "supports up to 1048576. Reduce the input token count and try again.", + 1_200_000, + 1_048_576, + ), ], ) def test_overflow_messages_are_recognised_and_parsed(message, count, limit): @@ -352,24 +358,35 @@ async def record_turn(self, turn): # ── tool results and prune ────────────────────────────────────────── -def test_tool_result_cap_follows_the_window(tmp_path): +def _persist(content, window, storage_path=None): from xgen_agent_runtime.stages.s10_tool.persistence import maybe_persist_large_result from xgen_agent_runtime.tools.base import ToolCapabilities, ToolContext, ToolResult class _View: - context_window_budget = 8_192 + context_window_budget = window - context = ToolContext(storage_path=None, state_view=_View()) - result = ToolResult(content="z" * 50_000) - out = maybe_persist_large_result( - result, + return maybe_persist_large_result( + ToolResult(content=content), tool_use_id="t1", tool_name="Read", capabilities=ToolCapabilities(max_result_chars=100_000), - context=context, + context=ToolContext(storage_path=storage_path, state_view=_View()), ) - assert out.display_text and len(out.display_text) < 4_000 - assert "clipped" in out.display_text + + +def test_tool_result_cap_follows_the_window_in_tokens(): + korean = "검색 결과 문단입니다. " * 5_000 # ~65k chars, about as many tokens + out = _persist(korean, 8_192) + assert out.display_text and "clipped" in out.display_text + assert len(out.display_text) < 4_000 # a quarter of 8k tokens, at about a token per syllable + + +def test_a_medium_english_result_is_not_cut_on_a_large_window(tmp_path): + # 63k chars of English is ~13k tokens: far under a quarter of a 200k window. The first 4.89.0 + # draft counted a char as a token and replaced it with a 480-char preview. + english = "The quick brown fox jumps over the lazy dog near the river bank. " * 1_000 + out = _persist(english, 200_000, storage_path=str(tmp_path)) + assert out.display_text is None and out.persist_full is None def test_stale_tool_call_arguments_are_trimmed_without_touching_the_original(): @@ -453,7 +470,11 @@ def test_memory_node_context_string_is_kept_and_fitted(): ("anthropic", "claude-opus-5-5", 1_000_000), ("anthropic", "claude-sonnet-4-5-20250929", 200_000), ("bedrock", "global.anthropic.claude-sonnet-4-6", 1_000_000), - ("bedrock", "mistral.mistral-large-2407-v1:0", 32_000), + ("bedrock", "sonnet", 200_000), # BedrockClient only calls Claude; aliases carry no name + ("claude_code", "claude-opus-5-5", 200_000), # the CLI's window, not the API table + ("claude_code", "opus[1m]", 1_000_000), + ("openai", "gpt-5.50", 272_000), # not 'gpt-5.5' (digit boundary) + ("google", "gemma-4-31b-it", 131_072), ("openai", "gpt-5", 272_000), ("codex", "gpt-5.5", 272_000), ("azure", "my-deployment", 128_000), @@ -482,3 +503,270 @@ def test_long_claude_code_system_prompt_travels_as_a_file(): path = argv[argv.index("--system-prompt-file") + 1] with open(path, encoding="utf-8") as fp: assert fp.read() == system + + +# ── review round 1 (2026-10-12) ───────────────────────────────────── + + +def test_turn_context_is_part_of_the_estimate_and_does_not_inflate_calibration(): + state = PipelineState() + state.messages = _conversation(4) + without = estimate_prompt_tokens(state) + state.shared["turn_context_text"] = "오늘 날짜와 이번 질문에 고른 기억. " * 200 + with_context = estimate_prompt_tokens(state) + assert with_context > without + 1_000 + # The provider counts the turn context too: a report equal to our estimate keeps the ratio ~1. + record_prompt_usage(state, with_context) + assert state.shared[CALIBRATION_KEY] < 1.1 + + +def test_repeated_estimates_reuse_per_message_counts(monkeypatch): + from xgen_agent_runtime.core import token_estimate as te + + messages = _conversation(300, "a long enough message body " * 5) + te.estimate_message_tokens(messages) + calls = [] + real = te._raw_message_uncached + monkeypatch.setattr(te, "_raw_message_uncached", lambda m: calls.append(1) or real(m)) + te.estimate_message_tokens(messages) + assert calls == [] + messages[0]["content"] = "changed in place" + te.estimate_message_tokens(messages) + assert len(calls) == 1 + + +def test_anchor_never_collapses_after_a_large_removal(): + state = PipelineState() + pdf = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0=" * 10}} + state.messages = [{"role": "user", "content": [pdf, {"type": "text", "text": "summarize"}]}] + _conversation(3) + plain_before = estimate_prompt_tokens(state) + record_prompt_usage(state, max(1, plain_before // 3)) # the provider counted far less + state.messages[0]["content"] = [{"type": "text", "text": "[document removed]"}] + state.shared.pop("_prompt_tokens_memo", None) + assert estimate_prompt_tokens(state) > 0 + + +@pytest.mark.asyncio +async def test_failed_summaries_are_capped_and_hard_pressure_uses_the_digest(): + from xgen_agent_runtime.core.compaction import LLM_FAILURES_KEY, MAX_SUMMARY_FAILURES + + state = PipelineState() + state.model = "fake" + client = _FailingClient() + state.llm_client = client + state.context_window_budget = 8_000 + state.messages = _conversation(40, "long message " * 30) + compactor = _llm_compactor() + + first = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + same_request = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + assert first.get("skipped") == "no_change" and client.calls == 2 # one attempt, retried once + assert same_request.get("skipped") == "already_summarized_for_this_request" + for _ in range(MAX_SUMMARY_FAILURES): + note_api_call(state) + await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + assert state.shared[LLM_FAILURES_KEY] >= MAX_SUMMARY_FAILURES + calls = client.calls + note_api_call(state) + soft = await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + assert soft.get("skipped") == "summary_model_failing" and client.calls == calls + hard = await run_compaction(state, compactor, trigger="guard", target_tokens=10, hard=True) + assert hard["summarized"] and client.calls == calls + assert "[Summary of" in state.messages[0]["content"] + + +@pytest.mark.asyncio +async def test_a_summary_request_that_is_too_long_is_retried_shorter(): + class _TooLongOnce(_SummaryClient): + def __init__(self): + super().__init__() + self.prompts = [] + + async def create_message(self, *, model_config, messages, purpose: str = ""): + self.prompts.append(messages[0]["content"]) + if len(self.prompts) == 1: + raise APIError( + "prompt is too long: 60000 tokens > 32000 maximum", + category=ErrorCategory.TOKEN_LIMIT, + ) + return await super().create_message(model_config=model_config, messages=messages) + + state = PipelineState() + state.model = "fake" + client = _TooLongOnce() + state.llm_client = client + state.context_window_budget = 32_000 + state.messages = _conversation(120, "이전 작업 내용을 길게 적은 메시지입니다. " * 40) + state.shared[COMPACTION_CONTEXT_KEY] = {"trigger": "guard", "hard": True} + + await _llm_compactor().compact(state) + + assert len(client.prompts) == 2 and len(client.prompts[1]) < len(client.prompts[0]) * 0.8 + assert "SUMMARY OF EARLIER WORK" in state.messages[0]["content"] + assert state.shared[CALIBRATION_KEY] > 1.0 + + +def test_proactive_threshold_comes_before_the_guard_on_a_small_window(): + from xgen_agent_runtime.core.compaction import PROACTIVE_RATIO, input_budget_of, output_reserve + + state = PipelineState() + state.context_window_budget = 32_000 + state.max_tokens = 8_192 + guard_limit = 32_000 - output_reserve(32_000, 8_192) # what the host's TokenBudgetGuard allows + assert input_budget_of(state) == guard_limit + assert input_budget_of(state) * PROACTIVE_RATIO < guard_limit + + +def test_host_budget_leaves_the_guards_headroom(): + from xgen_agent_runtime.core.compaction import output_reserve + from xgen_agent_runtime.host.context_budget import fit_input_to_budget + + fit = fit_input_to_budget( + text="질문", + rag_block="참조 문서 " * 40_000, + system_prompt="system", + history=None, + registry=None, + provider="vllm", + model="m", + max_tokens=8_192, + window=32_000, + ) + assert fit.input_clamped and fit.budget <= 32_000 - output_reserve(32_000, 8_192) + + +def test_memory_node_context_is_trimmed_before_the_question(): + from xgen_agent_runtime.host.context_budget import fit_input_to_budget + + question = "이번 질문은 그대로 가야 한다. " * 300 + context = "[이전 대화 기록]\n" + "오래된 대화 " * 30_000 + "가장 최근 대화" + fit = fit_input_to_budget( + text=question, + rag_block="", + system_prompt="system", + history=None, + registry=None, + provider="vllm", + model="m", + max_tokens=4_096, + window=32_000, + history_context=context, + ) + assert fit.text == question and not fit.input_clamped + assert len(fit.history_context) < len(context) and fit.history_context.endswith("가장 최근 대화") + + +@pytest.mark.asyncio +async def test_overflow_caused_by_max_tokens_is_not_compacted(): + from xgen_agent_runtime.stages.s06_api.artifact.default.stage import APIStage + + provider = _OverflowOnce( + "This model's maximum context length is 8192 tokens. However, you requested 8292 tokens " + "(100 in the messages, 8192 in the completion)." + ) + state = PipelineState() + state.stream = False + state.context_window_budget = 8_192 + state.messages = _conversation(30) + state._context_compactor = TruncateCompactor(keep_last=4) + + with pytest.raises(APIError): + await APIStage(provider=provider).execute(None, state) + assert len(provider.requests) == 1 and len(state.messages) == 30 + + +def test_cost_prune_keeps_attached_documents_and_hard_pressure_removes_them(): + from xgen_agent_runtime.core.context_prune import prune_messages + + def _messages(): + doc = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0="}} + return [{"role": "user", "content": [doc, {"type": "text", "text": "read this"}]}] + _conversation(10) + + soft = _messages() + prune_messages(soft) + assert soft[0]["content"][0]["type"] == "document" + hard = _messages() + prune_messages(hard, strip_documents=True) + assert hard[0]["content"][0]["type"] == "text" + + +@pytest.mark.asyncio +async def test_the_current_request_stays_labelled_across_two_compactions(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.messages = [{"role": "user", "content": "Refactor the billing module."}] + for i in range(12): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "x" * 50}]}, + ] + compactor = _llm_compactor(keep_recent=2) + await compactor.compact(state) + for i in range(12, 24): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "x" * 50}]}, + ] + await compactor.compact(state) + + head = state.messages[0]["content"] + assert "The request being worked on, verbatim:\nRefactor the billing module." in head + assert "Earlier user messages" not in head + + +@pytest.mark.asyncio +async def test_a_deferred_background_summary_is_not_repeated_by_stage_4_and_keeps_its_pins(): + import asyncio + + from xgen_agent_runtime.core.compaction import input_budget_of + from xgen_agent_runtime.stages.s02_context import ContextStage + from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import PINNED_USERS_KEY + from xgen_agent_runtime.stages.s04_guard.artifact.default.guards import TokenBudgetGuard + from xgen_agent_runtime.stages.s04_guard.artifact.default.stage import GuardStage + + client = _SummaryClient() + compactor = _llm_compactor(keep_recent=2) + state = PipelineState() + state.model = "fake" + state.llm_client = client + state.context_window_budget = 4_000 + state.max_tokens = 0 + band = input_budget_of(state) + for size in range(1, 4_000): + state.messages = [{"role": "user" if i % 2 == 0 else "assistant", "content": f"REQ{i} " + "글" * size} for i in range(12)] + if estimate_prompt_tokens(state) > band * 0.8: + break + assert estimate_prompt_tokens(state) <= band * 0.9 + + stage2 = ContextStage(compactor=compactor) + guard = GuardStage(guards=[TokenBudgetGuard(min_remaining_tokens=10)]) + guard.attach_budget_recovery(compactor) + await stage2.execute("in", state) + await guard.execute("in", state) + await asyncio.sleep(0) + assert stage2._bg_compaction is not None and len(state.messages) == 12 # nothing synchronous + + state.iteration = 0 + await stage2.execute("in", state) + assert client.calls == 1 + assert state.messages[0]["content"].startswith(CHECKPOINT_MARKER) + assert PINNED_USERS_KEY in state.shared + + +def test_claude_code_prompt_file_is_private(tmp_path, monkeypatch): + import os + import stat + import tempfile + + from xgen_agent_runtime.llm_client.claude_code import ClaudeCodeCLIClient + from xgen_agent_runtime.llm_client.types import APIRequest + + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + client = ClaudeCodeCLIClient(binary_path="/bin/true") + argv = client._build_argv( + APIRequest(model="sonnet", messages=[{"role": "user", "content": "hi"}], system="가" * 60_000, stream=True) + ) + path = argv[argv.index("--system-prompt-file") + 1] + assert stat.S_IMODE(os.stat(os.path.dirname(path)).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 diff --git a/tests/unit/test_compaction_toggle.py b/tests/unit/test_compaction_toggle.py index 9deabd23..f698c41f 100644 --- a/tests/unit/test_compaction_toggle.py +++ b/tests/unit/test_compaction_toggle.py @@ -158,16 +158,18 @@ class _Resp: def _band_state(budget: int = 1_000) -> PipelineState: - """80~90% 구간의 상태: 백그라운드 유예 경계를 정확히 밟는다. + """80~90% 구간의 상태: 백그라운드 유예 경계를 정확히 밟는다(4.89.0: 창에서 답 몫을 뺀 입력 예산 기준). 길이를 고정하지 않고 추정기로 맞춘다. 추정기가 바뀌어도(4.89.0 한글 실측 보정) 전제가 그대로다. """ + from xgen_agent_runtime.core.compaction import input_budget_of from xgen_agent_runtime.core.token_estimate import estimate_prompt_tokens state = PipelineState() state.context_window_budget = budget state.model = "fake" state.llm_client = _InstantLLMClient() + band = input_budget_of(state) est = 0 for size in range(1, 2_000): state.messages = [ @@ -175,9 +177,9 @@ def _band_state(budget: int = 1_000) -> PipelineState: for i in range(20) ] est = estimate_prompt_tokens(state) - if est > budget * 0.8: + if est > band * 0.8: break - assert budget * 0.8 < est <= budget * 0.9, f"테스트 전제 붕괴: est={est}" + assert band * 0.8 < est <= band * 0.9, f"테스트 전제 붕괴: est={est} band={band}" return state diff --git a/tests/unit/test_slash_built_in_control.py b/tests/unit/test_slash_built_in_control.py index 243ecb35..8ac656f2 100644 --- a/tests/unit/test_slash_built_in_control.py +++ b/tests/unit/test_slash_built_in_control.py @@ -141,11 +141,13 @@ async def test_queues_a_manual_request_that_stage_2_runs(self): state.messages = [ {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(12) ] + compactor = TruncateCompactor(keep_last=4) + state._context_compactor = compactor # what Pipeline._init_state wires when compaction is on result = await CompactCommand().execute([], SlashContext(extras={"state": state})) assert result.success is True assert state.shared[SharedKeys.CONTEXT_COMPACTION_REQUEST]["source"] == "manual" - await ContextStage(compactor=TruncateCompactor(keep_last=4)).execute(None, state) + await ContextStage(compactor=compactor).execute(None, state) assert len(state.messages) < 12 compacted = [e for e in state.events if e["type"] == "context.compacted"] @@ -157,6 +159,14 @@ async def test_without_a_state_reports_failure(self): result = await CompactCommand().execute([], SlashContext(pipeline=_FakePipeline())) assert result.success is False + @pytest.mark.asyncio + async def test_with_compaction_off_reports_failure(self): + from xgen_agent_runtime.core.state import PipelineState + + result = await CompactCommand().execute([], SlashContext(extras={"state": PipelineState()})) + assert result.success is False + assert "turned off" in result.content + # ── /config ────────────────────────────────────────────────────────── diff --git a/tests/unit/test_ttft_critical_path.py b/tests/unit/test_ttft_critical_path.py index c8e27176..fd0675cd 100644 --- a/tests/unit/test_ttft_critical_path.py +++ b/tests/unit/test_ttft_critical_path.py @@ -173,8 +173,11 @@ def _llm_compactor() -> LLMSummaryCompactor: def _band_messages(state: PipelineState, n: int, *, alternate: bool) -> None: - """Fill ``state`` to the 80~90% band of its window with ``n`` messages, sized by the estimator.""" - budget = state.context_window_budget + """Fill ``state`` to the 80~90% band of its input budget (the window minus the answer's + reserve) with ``n`` messages, sized by the estimator.""" + from xgen_agent_runtime.core.compaction import input_budget_of + + budget = input_budget_of(state) est = 0 for size in range(1, 4_000): state.messages = [ From ea05f4fad61bf6b62e27af5526aadae51466a75e Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Mon, 12 Oct 2026 12:39:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(compaction):=202=EC=B0=A8=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=B0=98=EC=98=81=20(=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4,=20=EA=B0=95=ED=95=9C=20=EC=95=95=EB=B0=95?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C,=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A4=91=EB=B3=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2차 세 갈래 검토(핵심 정책, 호스트와 클라이언트, 실제 파이프라인 시나리오)에서 나온 문제를 고친다. - 요약 체크포인트가 지금 요청의 이미지·PDF 를 그대로 싣고, 정리도 그 첨부는 지우지 않는다 - 강한 압박은 요약 먼저, 그래도 넘칠 때만 공격적 정리, 결과 속 이미지와 글, 마지막에 요청 첨부 (닫을 수 없는 차이면 결과를 그대로 둔다, 작은 구간은 강한 압박에서도 요약하지 않는다) - 백그라운드 요약이 도는 동안 약한 압박이 같은 이력을 다시 요약하지 않게, 실패는 그 턴에만 - 이전 체크포인트와 현재 요청을 토큰 기준으로 자르고, 현재 요청에 압축 목표의 절반까지 따로 배정, 계획이 실제 필요한 만큼만 자리를 남기게 - 런타임 안내를 사용자 요청으로 보지 않고, 다른 대화의 고정 말을 가져오지 않음, 모델 없음도 실패로 셈 - 호스트 맞춤이 Stage 3 의 숨긴 도구 목록·턴 안내·날짜를 세고, 메모리 노드 문맥을 턴 안내로 옮김 - 도구 결과 창 상한을 입력 예산 기준으로, 모든 도구에, 이미지와 오류 첫 줄 보존, 실패해도 그대로 - guard 기본 여유를 정책의 답 몫으로, vLLM 은 창 표를 쓰지 않음, 잘라내기 수렴, Claude Code 폴더 대체·CLI 여유 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 71 +-- docs/events.md | 3 +- src/xgen_agent_runtime/core/compaction.py | 445 +++++++++++------- src/xgen_agent_runtime/core/context_prune.py | 64 ++- src/xgen_agent_runtime/core/token_estimate.py | 3 + src/xgen_agent_runtime/events/catalog.py | 3 +- src/xgen_agent_runtime/host/context_budget.py | 15 +- src/xgen_agent_runtime/host/runner.py | 3 +- src/xgen_agent_runtime/host/token_budget.py | 12 +- src/xgen_agent_runtime/host/turn_executor.py | 37 +- .../llm_client/claude_code.py | 30 +- .../llm_client/translators/_cli.py | 7 +- .../artifact/default/compactors.py | 148 ++++-- .../s02_context/artifact/default/stage.py | 39 +- .../s04_guard/artifact/default/guards.py | 34 +- .../stages/s10_tool/persistence.py | 140 ++++-- tests/unit/test_compaction_policy_489.py | 331 +++++++++++++ 17 files changed, 1052 insertions(+), 333 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9e020d..bff2fa60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,58 +10,65 @@ this project adheres to [Semantic Versioning](https://semver.org/). 2026-10-11 검토에서 압축이 너무 늦게 돌거나(한국어 과소 추정), 돌아도 대화를 잃거나(요약 실패 시 이력 삭제), provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾았다. Codex · Claude Code 의 방식(provider 가 센 -토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. 고친 뒤 세 갈래(핵심 정책, -호스트와 클라이언트, 실제 파이프라인 시나리오)로 검토해 나온 문제도 이 판에서 고쳤다. +토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. 고친 뒤 두 차례 세 갈래 +(핵심 정책, 호스트와 클라이언트, 실제 파이프라인 시나리오)로 검토해 나온 문제도 이 판에서 고쳤다. - **추정 하나** (`core.text_tokens`): 압축 판단과 호출 전 입력 맞춤이 같은 글자 부류 기반 추정을 쓴다. 예전 압축 쪽은 4자당 1토큰이라 한국어를 2~3배 적게 셌다. 요청에 붙는 것은 모두 센다(시스템 프롬프트, 도구, Stage 3 이 요청 사본에만 붙이는 턴 맥락: 날짜 · 고른 기억 · 턴 노트). 성공한 호출마다 provider 가 보고한 입력 토큰을 기준점으로 삼고 (`record_prompt_usage`, 캐시 읽기 · 쓰기 포함, Claude Code CLI 포함), 그 뒤에 붙은 것만 추정한다. 보정 비율은 0.9~2.5 로 묶고, 기준점 추정은 보정된 단순 추정의 80% 아래로 내려가지 않는다. 메시지별 추정은 객체마다 기억해 - 긴 대화에서 반복마다 전부 다시 세지 않는다(긴 글 캐시는 LRU). + 긴 대화에서 반복마다 전부 다시 세지 않는다(긴 글 캐시는 LRU, 마지막 메시지의 제자리 수정도 알아챈다). - **입력 예산과 문턱** (`core.compaction`): 창에서 답 몫(`output_reserve`: max(4096, max_tokens + 2048), 창의 절반 - 이하)을 뺀 입력 예산의 80% 를 넘으면 50% 까지 줄인다. Stage 4 guard 도 같은 몫을 남기므로 약한 압축이 늘 guard 보다 - 먼저 온다(예전에는 창의 80% 기준이라 32k 창 · max_tokens 8192 에서 guard 가 68% 에서 먼저 터졌다). 결정적 - 정리(오래된 도구 결과 · 도구 호출 인자 · 이미지)를 먼저, 모자라면 모델 요약. 첨부 PDF 는 강한 압박에서만 지운다. -- **요약 횟수**: 요약 시도는 요청 하나에 한 번, 턴당 6번이고 실패한 시도도 센다. 두 번 실패하면 그 턴의 약한 압박은 - 모델을 더 부르지 않는다. 요약할 구간이 창의 5%(작은 창은 최대 15%, 2,000 바닥)보다 작으면 하지 않는다. Stage 16 의 - 압축 요청도 이 규칙을 따른다(사람이 거는 `/compact` 만 예외). 턴 계수는 `begin_turn` 이 비운다. -- **강한 압박** (guard · 넘침): 공격적 정리 + 요약, 요약을 못 하면 결정적 다이제스트. 그래도 요청이 입력 예산을 넘으면 - 남은 도구 결과(병렬 호출 한 번의 결과들)를 비율대로 줄인다. 예전에는 guard 가 턴을 거절했다. + 이하)을 뺀 입력 예산의 80% 를 넘으면 50% 까지 줄인다. Stage 4 guard 도 같은 몫을 남기므로(`TokenBudgetGuard` 기본값 + 0 = 이 몫) 약한 압축이 늘 guard 보다 먼저 온다. 예전에는 창의 80% 기준이라 32k 창 · max_tokens 8192 에서 guard 가 + 68% 에서 먼저 터졌다. 결정적 정리(오래된 도구 결과 · 도구 호출 인자 · 이미지)를 먼저, 모자라면 모델 요약. + 지금 답하는 요청에 붙은 이미지와 PDF 는 정리에서 지우지 않는다. +- **요약 횟수**: 약한 압박의 요약 시도는 요청 하나에 한 번, 턴당 6번이고 실패한 시도와 모델이 없는 경우도 센다. 두 번 + 실패하면 그 턴의 약한 압박은 모델을 더 부르지 않는다. 백그라운드 요약이 도는 동안에는 약한 압박이 같은 이력을 다시 + 요약하지 않는다. 요약할 구간이 창의 5%(작은 창은 최대 15%, 2,000 바닥)보다 작으면 하지 않는다(강한 압박도 같다). + Stage 16 의 압축 요청도 이 규칙을 따른다(사람이 거는 `/compact` 만 예외). 턴 계수는 `begin_turn` 이 비운다. +- **강한 압박** (guard · 넘침): 요약을 먼저(실패하면 결정적 다이제스트) 하고, 그래도 입력 예산을 넘을 때만 손실을 + 싼 것부터 낸다. 공격적 정리(오래된 이미지 · PDF, 오래된 결과 짧게), 남은 도구 결과(결과 속 이미지 먼저, 그다음 글을 + 비율대로), 마지막으로 지금 요청의 첨부. 모두 해도 안 들어가면 결과를 그대로 두고 guard 가 거절한다. - **넘침 복구** (Stage 6): provider 가 창을 넘었다고 거절하면(OpenAI · vLLM · Gemini(AI Studio · Vertex) 400 도 이제 `TOKEN_LIMIT`) 강하게 압축하고 한 번 다시 보낸다. 오류에 적힌 실제 한도가 믿던 창보다 작으면 그 턴의 창을 그 값으로 바꾼다. 메시지가 이미 작고 답 몫 때문에 넘친 경우(vLLM "100 in the messages, 8192 in the completion")는 압축하지 않는다. 숫자를 적지 않는 넘침 문구에는 창 교정 없이 압축과 재시도만 한다. - **요약 체크포인트** (`LLMSummaryCompactor`): 앞부분을 `[Context checkpoint]` 메시지 하나로 바꾼다. 요약은 사용자 요청 · 한 일 · 결정 · 문제 · 남은 일 · 현재 상태 여섯 항목이고, 도구 호출과 결과까지 읽는다(예전에는 글 블록만, 그것도 - 앞 12,000자만). 사용자가 한 말은 창의 8% 안에서 그대로 붙이고, 지금 하던 요청은 같은 턴의 두 번째 압축에서도 따로 - 붙인다. 이전 체크포인트는 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 둔다(예전에는 지웠다). - 요약 요청 자체가 너무 길다고 거절되면 provider 수치로 대본을 줄여 다시 보내고, 그 비율을 턴의 보정에 반영한다. - 기준점이 없을 때(턴 첫 호출)는 대본을 창의 40% 로 잡는다. -- **백그라운드 요약** (상태를 유지하는 호스트): 미룬 요청에 표시를 남겨 Stage 4 가 같은 요청을 다시 요약하지 않고, - 결과를 적용할 때 고정한 사용자 말도 함께 옮긴다. + 앞 12,000자만). 지금 하던 요청은 원문 그대로(압축 목표의 절반까지) 첨부(이미지 · PDF)와 함께 붙고, 같은 턴의 두 번째 + 압축에서도 따로 붙는다. 그 밖의 사용자 말은 창의 8% 안에서 그대로 붙인다. 런타임이 넣는 안내(산출물 대조, 턴 예산)는 + 사용자 말로 보지 않고, 다른 대화로 바뀐 상태에서는 예전 고정 말을 가져오지 않는다. 이전 체크포인트는 토큰 기준으로 + 읽혀 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 둔다(예전에는 지웠다). 요약 요청 자체가 너무 + 길다고 거절되면 provider 수치로 대본을 줄여 다시 보내고 그 비율을 턴의 보정에 반영한다. 기준점이 없을 때(턴 첫 + 호출)는 대본을 창의 40% 로 잡는다. +- **백그라운드 요약** (상태를 유지하는 호스트): 도는 동안 표시를 남겨 같은 이력을 다시 요약하지 않고, 결과를 적용할 때 + 고정한 사용자 말도 함께 옮긴다. 실패는 그 요약을 건 턴에만 센다. - **기억 기록**: 기록되기 전에 요약으로 빠진 메시지를 STM 기록 · 대화 아카이브가 먼저 기록한다 (`memory.compacted_unrecorded`, 기록기마다 최대 300개). 워터마크가 없던 기록기는 0 으로 본다. - **도구 결과 상한**: 도구별 상한(기본 10만 자, 넘으면 4.88 과 같이 저장 후 미리보기)과 별개로, 모델이 읽는 글 - (display_text 가 있으면 그것)이 창의 25% 토큰을 넘으면 앞뒤만 남긴다. 글자가 아니라 토큰으로 잰다(영어 6만 자는 - 200k 창에서 그대로 간다). -- **호스트 입력 맞춤** (`fit_input_to_budget`): guard 와 같은 답 몫을 남긴다. API 백엔드는 이력을 체크포인트가 차지할 수 - 있는 최대 크기(요약 + 사용자 말 몫, 최소 5,000)만큼만 세어, 압축할 수 있는 지난 대화 때문에 이번 질문을 자르지 - 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(claude_code 200k, `[1m]` 별칭은 1M, codex 272k)으로 맞추고, - 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 보내는 것(core + 열린 도구)만 센다. "입력 일부를 생략" 안내는 - 사용자 입력(질문 · 참조)이 잘렸을 때만 나간다. -- **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 시스템 프롬프트에 붙인다 - (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 창의 25% 안으로 줄이고, 입력 맞춤에서 질문 · 참조보다 - 먼저 줄인다(가장 최근 쪽이 남는다). + (display_text 가 있으면 그것)이 입력 예산의 25% 토큰을 넘으면 앞뒤만 남긴다. 모든 도구에 적용한다(ToolBatch 포함). + 글자가 아니라 토큰으로 재고(영어 6만 자는 200k 창에서 그대로 간다), 결과 속 이미지는 남기며, 구조화 오류는 + `ERROR ` 첫 줄을 지킨다. 실패해도 결과를 그대로 돌려준다. +- **호스트 입력 맞춤** (`fit_input_to_budget`): guard 와 같은 답 몫을 남기고, 맞춤 뒤에 Stage 3 이 붙이는 숨긴 도구 목록 · + 이번 턴 안내 · 날짜도 센다. API 백엔드는 이력을 체크포인트가 차지할 수 있는 최대 크기만큼만 세어, 압축할 수 있는 지난 + 대화 때문에 이번 질문을 자르지 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(claude_code 200k, `[1m]` 별칭은 1M, + codex 272k, claude_code 는 CLI 의 답 몫과 도구 몫을 더 남김)으로 맞추고, 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 + 보내는 것(core + 열린 도구)만 센다. "입력 일부를 생략" 안내는 사용자 입력(질문 · 참조)이 잘렸을 때만 나간다. +- **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 이번 턴 안내로 요청에 붙인다 + (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 시스템 프롬프트가 아닌 자리라 프롬프트 캐시와 + Claude Code 예열 프로세스를 깨지 않는다. 창의 25% 안으로 줄이고, 입력 맞춤에서 질문 · 참조보다 먼저 줄인다. - **내장 메모리 주입**: 주입 상한(기본 10,000자)을 창의 10% 로 묶고, 입력 맞춤의 예약도 같은 값에서 계산한다. 자동 검색 층은 압축 기록(`compactions`)을 올리지 않고, 그만큼 조금 더 받아 온다. - **창 표**: Claude Opus 4.6 이후 · Sonnet 4.6 이후 · Haiku 5.5 · Fable · Mythos 1M, gpt-5 계열 272k(입력 한도), gpt-5-chat 128k, gpt-5.5 · gpt-6 922k, Gemma(4 · 3 은 128k, 3n · 1b · 270m 은 32k, 2 는 8k), Azure 기본 128k. - Bedrock 은 이 런타임에서 Claude 만 부르므로 기본 200k 그대로다. 접두는 이름 앞이나 구분자(. / :) 뒤에서만, 숫자를 - 이어 가지 않을 때만 맞춘다('gpt-5.5' 는 'gpt-5.50' 에 맞지 않는다). -- **Claude Code argv**: 약 100KB(Windows 는 명령줄 전체 한도 때문에 1.2만 자)를 넘는 시스템 프롬프트는 - `--system-prompt-file` 로, 넘는 프롬프트는 stream-json stdin 으로 보낸다. 파일은 사용자 전용 폴더(0700, 확인함)에 - 0600 으로 쓰고 하루 지나면 지운다. + Bedrock 은 이 런타임에서 Claude 만 부르므로 기본 200k 그대로다. vLLM 은 표를 쓰지 않는다(같은 모델도 창을 줄여 띄우는 + 일이 흔하다: 서버 실측, 카탈로그, 기본 32k 순). 접두는 이름 앞이나 구분자(. / :) 뒤에서만, 숫자를 이어 가지 않을 + 때만 맞춘다('gpt-5.5' 는 'gpt-5.50' 에 맞지 않는다). +- **Claude Code argv**: 약 100KB(Windows 는 명령줄 한도 때문에 6,000자)를 넘는 시스템 프롬프트는 + `--system-prompt-file` 로, 넘는 프롬프트는 stream-json stdin 으로 보낸다. 파일은 사용자 전용 폴더에 0600 으로 쓰고 + (POSIX 는 폴더의 주인과 권한을 확인하고, 이름을 남이 쓰고 있으면 프로세스 전용 임시 폴더로 바꾼다) 하루 지나면 지운다. - **`/compact`**: 상태에 수동 압축 요청을 남기고 Stage 2 가 다음 패스에서 문턱과 무관하게 요약한다(예전 명령은 아무 파이프라인도 등록하지 않는 전략을 찾아 늘 실패했다). 압축이 꺼진 에이전트에서는 실패로 답한다. - 새 사건: `context.overflow` · `context.compaction_skipped` · `context.compaction_deferred`. diff --git a/docs/events.md b/docs/events.md index 8a667583..cb10be2a 100644 --- a/docs/events.md +++ b/docs/events.md @@ -319,6 +319,7 @@ Enum member: `EventTypes.CONTEXT_PRUNED` | `chars_saved` | int — text chars removed (images excluded) | | `aggressive` | bool? — the hard-pressure profile (guard / overflow) | | `results_fitted` | int? — tool results shortened in proportion so a hard-pressure request fits | +| `current_attachments_stripped` | int? — images/documents of the request itself removed (the very last resort) | | `trigger` | str? — cost (Stage 2 token threshold) \| the compaction trigger it ran under | | `threshold_tokens` | int? — the prune_over_tokens threshold that fired (cost trigger) | | `tokens_before` | int? — projected prompt tokens before the pass (cost trigger) | @@ -389,7 +390,7 @@ Enum member: `EventTypes.CONTEXT_COMPACTION_SKIPPED` |---|---| | `strategy` | str — compactor name/class | | `trigger` | str | -| `reason` | str — turn_compaction_limit \| summary_model_failing \| already_summarized_for_this_request \| nothing_to_summarize \| summary_region_too_small \| no_change | +| `reason` | str — turn_compaction_limit \| summary_model_failing \| already_summarized_for_this_request \| background_summary_running \| nothing_to_summarize \| summary_region_too_small \| no_change | | `tokens_estimate` | int? | | `target_tokens` | int | diff --git a/src/xgen_agent_runtime/core/compaction.py b/src/xgen_agent_runtime/core/compaction.py index 10ba8454..ee92e2bf 100644 --- a/src/xgen_agent_runtime/core/compaction.py +++ b/src/xgen_agent_runtime/core/compaction.py @@ -13,9 +13,10 @@ What the runner guarantees (4.89.0) ----------------------------------- -* **Cheap first.** The deterministic prune (dedupe repeated tool output, drop stale images/PDFs, - trim stale large results and tool inputs) runs before any model call. Under hard pressure it - runs again with a smaller protected tail. +* **Cheap first.** The deterministic prune (dedupe repeated tool output, drop stale images, trim + stale large results and tool inputs) runs before any model call. Under hard pressure the + compactor runs next, and only if the request still does not fit come the losses: the aggressive + prune, the newest tool results shortened, and last the attachments of the request itself. * **One summary per request.** Stage 2, Stage 4 and the guard can all see pressure before the same API call; only the first may summarize. A second summary of ``[summary, ack, two messages]`` removes nothing and costs a model call (2026-10-12: observed re-summarizing every iteration). @@ -299,37 +300,71 @@ def _prune(state: PipelineState, *, aggressive: bool, trigger: str) -> Dict[str, return metrics +#: Attribute on the state holding a background summary task (Stage 2, hosts that keep a state across +#: turns). Not in ``state.shared``, which may be serialized. +BACKGROUND_TASK_ATTR = "_context_background_task" + + +def background_summary_running(state: Any) -> bool: + """True while a background summary scheduled by Stage 2 has not finished.""" + task = getattr(state, BACKGROUND_TASK_ATTR, None) + done = getattr(task, "done", None) + return callable(done) and not done() + + +def _plan_reason( + state: PipelineState, compactor: Any, target_tokens: Optional[int] +) -> Optional[str]: + """Why the compactor's own plan is not worth a model call, or ``None``.""" + plan = getattr(compactor, "plan", None) + if not callable(plan): + return None + try: + outline = plan(state, target_tokens) + except Exception: # noqa: BLE001 (a planning bug must not stop compaction) + logger.debug("compactor plan failed", exc_info=True) + return None + if outline is None: + return "nothing_to_summarize" + region = _int(getattr(outline, "region_tokens", 0)) + if region < min_summary_region(window_of(state)): + return "summary_region_too_small" + return None + + def _llm_gate( state: PipelineState, compactor: Any, *, trigger: str, target_tokens: Optional[int], hard: bool ) -> Optional[str]: - """Why a model-backed compactor must NOT run now (``None`` = go ahead).""" - if not getattr(compactor, "uses_llm", False): + """Why a model-backed compactor must NOT run now (``None`` = go ahead). + + Only a person asking (``/compact``) bypasses the policy. A Stage 16 "requested" compaction is + the loop's own pressure signal and obeys the same limits (it used to bypass them all and could + summarize on every iteration of a turn that compaction could not bring under the threshold). + Under hard pressure the limits do not stop relief (the runner falls back to the digest), but a + region too small to matter is still not summarized: summarizing one question made the request + bigger (2026-10-12 review). + """ + if not getattr(compactor, "uses_llm", False) or trigger == "manual": return None shared = state.shared - # Only a person asking (/compact) bypasses the policy. A Stage 16 "requested" compaction is the - # loop's own pressure signal and obeys the same limits (it used to bypass them all and could - # summarize on every iteration of a turn that compaction could not bring under the threshold). - explicit = trigger == "manual" - if not explicit and _int(shared.get(LLM_COMPACTIONS_KEY)) >= MAX_LLM_COMPACTIONS_PER_TURN: + if not hard: + if _int(shared.get(LLM_COMPACTIONS_KEY)) >= MAX_LLM_COMPACTIONS_PER_TURN: + return "turn_compaction_limit" + if _int(shared.get(LLM_FAILURES_KEY)) >= MAX_SUMMARY_FAILURES: + return "summary_model_failing" + if summarized_for_this_call(state): + return "already_summarized_for_this_request" + if background_summary_running(state): + # The summary of this history is already being made; a synchronous one now would be + # thrown away with it (the prefix check) and cost a call. + return "background_summary_running" + reason = _plan_reason(state, compactor, target_tokens) + if reason is not None: + return reason + if hard and _int(shared.get(LLM_COMPACTIONS_KEY)) >= MAX_LLM_COMPACTIONS_PER_TURN: return "turn_compaction_limit" - if not explicit and _int(shared.get(LLM_FAILURES_KEY)) >= MAX_SUMMARY_FAILURES: + if hard and _int(shared.get(LLM_FAILURES_KEY)) >= MAX_SUMMARY_FAILURES: return "summary_model_failing" - # Soft pressure only: a guard or a provider overflow after a summary means that summary was not - # enough for THIS request, and the request cannot go out as it is. - if not explicit and not hard and summarized_for_this_call(state): - return "already_summarized_for_this_request" - plan = getattr(compactor, "plan", None) - if callable(plan) and not hard and not explicit: - try: - outline = plan(state, target_tokens) - except Exception: # noqa: BLE001 (a planning bug must not stop compaction) - logger.debug("compactor plan failed", exc_info=True) - return None - if outline is None: - return "nothing_to_summarize" - region = _int(getattr(outline, "region_tokens", 0)) - if region < min_summary_region(window_of(state)): - return "summary_region_too_small" return None @@ -349,14 +384,17 @@ async def run_compaction( compaction is needed, so the compactor always runs (aiming at :data:`TARGET_RATIO` of the input budget). A ``manual`` trigger (``/compact``) always runs the compactor too. - ``hard`` marks pressure the request cannot survive (guard, provider overflow): the prune runs - aggressively, the small-region skip is off, and a compactor that cannot reach its model falls - back to a deterministic digest instead of leaving the history unchanged. - - Emits ``context.compacted`` (and ``context.compaction_skipped`` when the policy declined) and, - when a provider exposes ``record_compaction`` and the compactor does not persist its own - snapshot, records the snapshot. Never raises: compaction is best-effort relief, not a - correctness gate; failures are logged as events and swallowed. + ``hard`` marks pressure the request cannot survive (guard, provider overflow). The compactor + runs first, on the history as it is (the summarizer reads tool results before they are cut); + a compactor that cannot reach its model falls back to a deterministic digest. Only if the + request is still over the input budget does the last-resort relief run, cheapest loss first: + the aggressive prune (older images and documents, stale results cut short), then the remaining + tool results shortened in proportion, then the attachments of the request being answered. + + Emits ``context.compacted`` (and ``context.compaction_skipped`` when nothing changed or the + policy declined) and, when a provider exposes ``record_compaction`` and the compactor does not + persist its own snapshot, records the snapshot. Never raises: compaction is best-effort relief, + not a correctness gate; failures are logged as events and swallowed. """ before_list = list(state.messages or []) before_msgs = len(before_list) @@ -366,39 +404,13 @@ async def run_compaction( target_tokens = int(input_budget_of(state) * TARGET_RATIO) _prune(state, aggressive=False, trigger=trigger) - if hard and estimate_prompt_tokens(state) > target_tokens: - _prune(state, aggressive=True, trigger=trigger) - after_prune = estimate_prompt_tokens(state) if prune_may_suffice and after_prune <= target_tokens: - state.add_event( - "context.compacted", - { - "strategy": "prune", - "trigger": trigger, - "messages_before": before_msgs, - "messages_after": len(state.messages or []), - "saved_tokens_estimate": max(0, before_tokens - after_prune), - "tokens_after_estimate": after_prune, - "target_tokens": target_tokens, - "target_met": True, - }, - ) - return { - "ok": True, - "before_messages": before_msgs, - "after_messages": len(state.messages or []), - "replaced": 0, - "saved_tokens_estimate": max(0, before_tokens - after_prune), - "after_tokens_estimate": after_prune, - "target_tokens": target_tokens, - "target_met": True, - "summarized": False, - } + return _report_prune(state, trigger, before_msgs, before_tokens, after_prune, target_tokens) skip = _llm_gate(state, compactor, trigger=trigger, target_tokens=target_tokens, hard=hard) digest_only = False - if skip in ("turn_compaction_limit", "summary_model_failing") and hard: + if hard and skip in ("turn_compaction_limit", "summary_model_failing"): # Out of model summaries for this turn (or the summary model keeps failing), yet the request # cannot go out as it is: the compactor relieves it with its deterministic digest. skip, digest_only = None, True @@ -413,105 +425,92 @@ async def run_compaction( "target_tokens": target_tokens, }, ) - return { - "ok": True, - "skipped": skip, - "before_messages": before_msgs, - "after_messages": len(state.messages or []), - "replaced": 0, - "saved_tokens_estimate": max(0, before_tokens - after_prune), - "after_tokens_estimate": after_prune, + if not hard: + return { + "ok": True, + "skipped": skip, + "before_messages": before_msgs, + "after_messages": len(state.messages or []), + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - after_prune), + "after_tokens_estimate": after_prune, + "target_tokens": target_tokens, + "target_met": after_prune <= target_tokens, + "summarized": False, + } + + changed = False + after_list = before_list + if skip is None: + request: Dict[str, Any] = { + "trigger": trigger, "target_tokens": target_tokens, - "target_met": after_prune <= target_tokens, - "summarized": False, + "hard": bool(hard), + "digest_only": digest_only, } - - request: Dict[str, Any] = { - "trigger": trigger, - "target_tokens": target_tokens, - "hard": bool(hard), - "digest_only": digest_only, - } - state.shared[COMPACTION_CONTEXT_KEY] = request - try: - await compactor.compact(state) - except Exception as exc: # noqa: BLE001 (best effort) + state.shared[COMPACTION_CONTEXT_KEY] = request + try: + await compactor.compact(state) + except Exception as exc: # noqa: BLE001 (best effort) + _count_attempt(state, compactor, request) + state.add_event( + "context.compaction_failed", + {"compactor": _compactor_name(compactor), "trigger": trigger, "error": str(exc)}, + ) + logger.warning("Compaction (%s) failed: %s", trigger, exc) + if hard: + _relieve_hard(state, trigger) + return {"ok": False, "before_messages": before_msgs, "after_messages": before_msgs} + finally: + state.shared.pop(COMPACTION_CONTEXT_KEY, None) _count_attempt(state, compactor, request) - state.add_event( - "context.compaction_failed", - {"compactor": _compactor_name(compactor), "trigger": trigger, "error": str(exc)}, + + after_list = list(state.messages or []) + changed = len(after_list) != before_msgs or any( + a is not b for a, b in zip(after_list, before_list) ) - logger.warning("Compaction (%s) failed: %s", trigger, exc) - return {"ok": False, "before_messages": before_msgs, "after_messages": before_msgs} - finally: - state.shared.pop(COMPACTION_CONTEXT_KEY, None) - _count_attempt(state, compactor, request) + if changed: + # Keep every recorder's watermark valid across the shrink (audit D3) and remember what + # they have not recorded yet. + reconcile_recorded_index(before_list, after_list, state.metadata) + state.shared.pop("_prompt_tokens_memo", None) - after_list = list(state.messages or []) - changed = len(after_list) != before_msgs or any( - a is not b for a, b in zip(after_list, before_list) - ) - if changed: - # Keep every recorder's watermark valid across the shrink (audit D3) and remember what - # they have not recorded yet. - reconcile_recorded_index(before_list, after_list, state.metadata) - state.shared.pop("_prompt_tokens_memo", None) if hard: - _fit_tool_results(state, trigger) - if not changed and estimate_prompt_tokens(state) < before_tokens: - # The compactor left the list alone but the prune (or the tool-result fit) shrank it in - # place: report that relief. + _relieve_hard(state, trigger) + + if not changed: now = estimate_prompt_tokens(state) - state.add_event( - "context.compacted", - { - "strategy": "prune", - "trigger": trigger, - "messages_before": before_msgs, - "messages_after": before_msgs, - "saved_tokens_estimate": before_tokens - now, - "tokens_after_estimate": now, - "target_tokens": target_tokens, - "target_met": now <= target_tokens, - }, - ) + if now < before_tokens: + # The compactor left the list alone but the prune (or the hard relief) shrank it in + # place: report that relief. + return _report_prune(state, trigger, before_msgs, before_tokens, now, target_tokens) + if skip is None: + # Nothing changed (a soft summary that did not come back, nothing to cut): not a + # compaction. Reporting it as ``context.compacted`` made hosts over-count compactions. + state.add_event( + "context.compaction_skipped", + { + "strategy": _compactor_name(compactor), + "trigger": trigger, + "reason": "no_change", + "tokens_estimate": now, + "target_tokens": target_tokens, + }, + ) return { "ok": True, + "skipped": skip or "no_change", "before_messages": before_msgs, "after_messages": before_msgs, "replaced": 0, - "saved_tokens_estimate": before_tokens - now, + "saved_tokens_estimate": max(0, before_tokens - now), "after_tokens_estimate": now, "target_tokens": target_tokens, "target_met": now <= target_tokens, "summarized": False, } - elif not changed: - # Nothing changed (a soft summary that did not come back, nothing to cut): not a compaction. - # Reporting it as ``context.compacted`` made hosts that count compactions over-count. - state.add_event( - "context.compaction_skipped", - { - "strategy": _compactor_name(compactor), - "trigger": trigger, - "reason": "no_change", - "tokens_estimate": after_prune, - "target_tokens": target_tokens, - }, - ) - return { - "ok": True, - "skipped": "no_change", - "before_messages": before_msgs, - "after_messages": before_msgs, - "replaced": 0, - "saved_tokens_estimate": max(0, before_tokens - after_prune), - "after_tokens_estimate": after_prune, - "target_tokens": target_tokens, - "target_met": after_prune <= target_tokens, - "summarized": False, - } + after_list = list(state.messages or []) after_msgs = len(after_list) after_tokens = estimate_prompt_tokens(state) replaced = max(0, before_msgs - after_msgs) @@ -544,8 +543,7 @@ async def run_compaction( # Persist the snapshot unless the compactor already does it itself. if ( - changed - and replaced > 0 + replaced > 0 and provider is not None and not getattr(compactor, "persists_own_compaction", False) and hasattr(provider, "record_compaction") @@ -559,7 +557,7 @@ async def run_compaction( session_id=getattr(state, "session_id", "") or "", trigger=trigger, ) - except Exception as exc: # noqa: BLE001 — best effort + except Exception as exc: # noqa: BLE001 (best effort) state.add_event( "context.compaction_record_failed", {"compactor": _compactor_name(compactor), "error": str(exc)}, @@ -579,59 +577,148 @@ async def run_compaction( } +def _report_prune( + state: PipelineState, + trigger: str, + before_msgs: int, + before_tokens: int, + now: int, + target_tokens: int, +) -> dict: + """``context.compacted`` (strategy ``prune``) for relief made in place, without the compactor.""" + state.add_event( + "context.compacted", + { + "strategy": "prune", + "trigger": trigger, + "messages_before": before_msgs, + "messages_after": len(state.messages or []), + "saved_tokens_estimate": max(0, before_tokens - now), + "tokens_after_estimate": now, + "target_tokens": target_tokens, + "target_met": now <= target_tokens, + }, + ) + return { + "ok": True, + "before_messages": before_msgs, + "after_messages": len(state.messages or []), + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - now), + "after_tokens_estimate": now, + "target_tokens": target_tokens, + "target_met": now <= target_tokens, + "summarized": False, + } + + #: Under hard pressure the request must end up below this share of the input budget. _HARD_FIT_RATIO = 0.95 -def _fit_tool_results(state: PipelineState, trigger: str) -> int: - """Last resort under hard pressure: when the request still does not fit after the prune and the - compactor, shorten the tool results that are left (the newest ones, in the protected tail) in - proportion. One round of parallel tool calls can return more than the window even when each - result is within its own cap; before this the guard rejected the turn. Returns results trimmed. - """ +def _relieve_hard(state: PipelineState, trigger: str) -> None: + """Last-resort relief under hard pressure, cheapest loss first, each step only while the + request is still over the input budget: the aggressive prune, then the remaining tool results + in proportion (:func:`_fit_tool_results`), then the attachments of the request being answered.""" limit = int(input_budget_of(state) * _HARD_FIT_RATIO) - estimate = estimate_prompt_tokens(state) - if estimate <= limit: - return 0 + if estimate_prompt_tokens(state) <= limit: + return + _prune(state, aggressive=True, trigger=trigger) + if estimate_prompt_tokens(state) <= limit: + return + _fit_tool_results(state, trigger, limit) + if estimate_prompt_tokens(state) <= limit: + return + from xgen_agent_runtime.core.context_prune import strip_current_attachments + + removed = strip_current_attachments(state.messages or []) + if removed: + state.shared.pop("_prompt_tokens_memo", None) + state.add_event( + "context.pruned", + { + "trigger": trigger, + "aggressive": True, + "deduped": 0, + "images_stripped": removed, + "trimmed": 0, + "inputs_trimmed": 0, + "chars_saved": 0, + "current_attachments_stripped": removed, + }, + ) + + +def _fit_tool_results(state: PipelineState, trigger: str, limit: int) -> int: + """Shorten the tool results that are left (the newest ones, in the protected tail) so the + request fits ``limit``: images inside results first, then text in proportion. One round of + parallel tool calls can return more than the window even when each result is within its own + cap; before this the guard rejected the turn. Does nothing when even cutting every result to a + stub could not close the gap (the guard then rejects with the results intact). Returns results + changed. + """ from xgen_agent_runtime.core.context_prune import shrink_tool_results from xgen_agent_runtime.core.token_estimate import estimate_messages_scaled - results = [ - block - for message in state.messages or [] - if isinstance(message, dict) and isinstance(message.get("content"), list) - for block in message["content"] - if isinstance(block, dict) and block.get("type") == "tool_result" - ] - result_tokens = estimate_messages_scaled(state, [{"role": "user", "content": results}]) - if result_tokens <= 0: + estimate = estimate_prompt_tokens(state) + if estimate <= limit: return 0 - keep = max(0.02, (result_tokens - (estimate - limit)) / result_tokens * 0.9) - if keep >= 1.0: + + def _results() -> List[Any]: + return [ + block + for message in state.messages or [] + if isinstance(message, dict) and isinstance(message.get("content"), list) + for block in message["content"] + if isinstance(block, dict) and block.get("type") == "tool_result" + ] + + result_tokens = estimate_messages_scaled(state, [{"role": "user", "content": _results()}]) + if result_tokens <= 0 or result_tokens * 0.95 < estimate - limit: return 0 - metrics = shrink_tool_results(state.messages or [], keep) - if metrics["trimmed"]: + totals = {"trimmed": 0, "chars_saved": 0, "images_stripped": 0} + metrics = shrink_tool_results(state.messages or [], 1.0, strip_images=True) + for key in totals: + totals[key] += metrics.get(key, 0) + if metrics["images_stripped"]: + state.shared.pop("_prompt_tokens_memo", None) + estimate_mid = estimate_prompt_tokens(state) + if estimate_mid > limit: + result_tokens = estimate_messages_scaled(state, [{"role": "user", "content": _results()}]) + if result_tokens > 0: + keep = max(0.02, (result_tokens - (estimate_mid - limit)) / result_tokens * 0.9) + if keep < 1.0: + metrics = shrink_tool_results(state.messages or [], keep) + for key in totals: + totals[key] += metrics.get(key, 0) + changed = totals["trimmed"] + totals["images_stripped"] + if changed: state.shared.pop("_prompt_tokens_memo", None) state.add_event( "context.pruned", { "trigger": trigger, "aggressive": True, - "results_fitted": metrics["trimmed"], - "chars_saved": metrics["chars_saved"], + "deduped": 0, + "images_stripped": totals["images_stripped"], + "trimmed": totals["trimmed"], + "inputs_trimmed": 0, + "chars_saved": totals["chars_saved"], + "results_fitted": totals["trimmed"], "tokens_before": estimate, "tokens_after": estimate_prompt_tokens(state), }, ) - return metrics["trimmed"] + return changed def _count_attempt(state: PipelineState, compactor: Any, request: Dict[str, Any]) -> None: """Book a compactor run against the per-request and per-turn limits. A model-backed compactor reports the summary calls it made (``model_calls``) and what replaced - the history (``source``). Any call (successful or not) uses up this request's one soft summary - and one of the turn's attempts; a call that did not produce a model summary counts as a failure. + the history (``source``: ``"model"``, ``"digest"``, or ``"unavailable"`` when no model is wired). + Any call (successful or not) uses up this request's one soft summary and one of the turn's + attempts; a call that did not produce a model summary, or no model at all, counts as a failure. Before this, only a run that changed the history was counted, so a failing summary model was retried by Stage 2 and Stage 4 on every request (2026-10-12 review: 20 failed calls in a turn). """ @@ -645,10 +732,10 @@ def _count_attempt(state: PipelineState, compactor: Any, request: Dict[str, Any] shared[SUMMARIZED_AT_KEY] = _int(shared.get(API_CALLS_KEY)) if calls > 0: shared[LLM_COMPACTIONS_KEY] = _int(shared.get(LLM_COMPACTIONS_KEY)) + 1 - if source == "model": - shared[LLM_FAILURES_KEY] = 0 - else: - shared[LLM_FAILURES_KEY] = _int(shared.get(LLM_FAILURES_KEY)) + 1 + if source == "model": + shared[LLM_FAILURES_KEY] = 0 + elif calls > 0 or source == "unavailable": + shared[LLM_FAILURES_KEY] = _int(shared.get(LLM_FAILURES_KEY)) + 1 async def relieve_if_over( @@ -683,6 +770,8 @@ async def relieve_if_over( "LLM_FAILURES_KEY", "output_reserve", "input_budget_of", + "BACKGROUND_TASK_ATTR", + "background_summary_running", "RECORDED_INDEX_KEYS", "PENDING_RECORDS_KEY", "RECORDER_STM", diff --git a/src/xgen_agent_runtime/core/context_prune.py b/src/xgen_agent_runtime/core/context_prune.py index 5b8db9b3..d7fbf523 100644 --- a/src/xgen_agent_runtime/core/context_prune.py +++ b/src/xgen_agent_runtime/core/context_prune.py @@ -218,6 +218,13 @@ def _trim_result(block: Dict[str, Any], keep: int) -> int: else: budget -= len(text) if dropped > 0: + # Drop the text parts the trim emptied (providers reject empty text blocks), keeping + # every non-text part and at least one text part for the marker. + emptied = [p for p in _result_text_parts(block) if not p.get("text")] + if emptied and len(emptied) < len(_result_text_parts(block)): + block["content"] = [b for b in content if not any(b is e for e in emptied)] + elif emptied: + block["content"] = [b for b in content if not any(b is e for e in emptied[1:])] parts = _result_text_parts(block) if parts: parts[-1]["text"] += f"\n… [{dropped} chars trimmed during context compaction]" @@ -226,17 +233,24 @@ def _trim_result(block: Dict[str, Any], keep: int) -> int: def shrink_tool_results( - messages: List[Dict[str, Any]], factor: float, *, min_keep: int = 300 + messages: List[Dict[str, Any]], + factor: float, + *, + min_keep: int = 300, + strip_images: bool = False, ) -> Dict[str, int]: """Shorten every tool_result in *messages* to ``factor`` of its text, in place (head kept, with - the usual marker). The last resort under hard pressure: one round of parallel tool calls can - return more than the window even when each result is within its own cap, and the protected tail - is exactly where those results sit. Returns ``{"trimmed", "chars_saved"}``.""" - trimmed = saved = 0 + the usual marker); with ``strip_images`` also replace the images inside results with a marker. + The last resort under hard pressure: one round of parallel tool calls can return more than the + window even when each result is within its own cap, and the protected tail is exactly where + those results sit. Returns ``{"trimmed", "chars_saved", "images_stripped"}``.""" + trimmed = saved = images = 0 for msg in messages or []: if not isinstance(msg, dict): continue for block in _iter_tool_results(msg): + if strip_images and isinstance(block.get("content"), list): + images += _strip_images_in(block["content"]) size = _content_size(block) keep = max(min_keep, int(size * max(0.0, factor))) if size > keep: @@ -244,7 +258,37 @@ def shrink_tool_results( if removed > 0: trimmed += 1 saved += removed - return {"trimmed": trimmed, "chars_saved": saved} + return {"trimmed": trimmed, "chars_saved": saved, "images_stripped": images} + + +def current_request_index(messages: List[Dict[str, Any]]) -> int: + """Index of the newest user message that is not only tool results (the request being answered, + or a checkpoint carrying it), or -1.""" + for i in range(len(messages or []) - 1, -1, -1): + msg = messages[i] + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + content = msg.get("content") + if ( + isinstance(content, list) + and content + and all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content) + ): + continue + return i + return -1 + + +def strip_current_attachments(messages: List[Dict[str, Any]]) -> int: + """Remove the images and documents of the request being answered. The very last resort under + hard pressure, after everything else; returns how many were removed.""" + idx = current_request_index(messages) + if idx < 0: + return 0 + content = messages[idx].get("content") + if not isinstance(content, list): + return 0 + return _strip_images_in(content, documents=True) def prune_messages( @@ -257,6 +301,7 @@ def prune_messages( input_trim_over_chars: int = DEFAULT_INPUT_TRIM_OVER_CHARS, input_trim_keep_chars: int = DEFAULT_INPUT_TRIM_KEEP_CHARS, strip_documents: bool = False, + protect_current_request: bool = True, ) -> PruneMetrics: """Run the deterministic prune over *messages* in place. @@ -267,6 +312,10 @@ def prune_messages( ``strip_documents``: attached PDFs are often what the request is about, so they are removed only under hard pressure (``core.compaction`` passes it with the aggressive profile). + + ``protect_current_request``: the images and documents of the request being answered + (:func:`current_request_index`) are never stripped here, however many tool rounds followed it. + ``core.compaction`` removes them only as the very last resort (:func:`strip_current_attachments`). """ metrics = PruneMetrics(deduped=0, images_stripped=0, trimmed=0, inputs_trimmed=0, chars_saved=0) if not messages: @@ -296,12 +345,13 @@ def prune_messages( seen.add(digest) # Pass 2 — stale-region cleanup: image stripping + oversize trimming. + current = current_request_index(messages) if protect_current_request else -1 for mi in range(cutoff): msg = messages[mi] if not isinstance(msg, dict): continue content = msg.get("content") - if isinstance(content, list): + if isinstance(content, list) and mi != current: metrics["images_stripped"] += _strip_images_in(content, documents=strip_documents) if msg.get("role") == "assistant": saved_inputs = _trim_tool_inputs(msg, input_trim_over_chars, input_trim_keep_chars) diff --git a/src/xgen_agent_runtime/core/token_estimate.py b/src/xgen_agent_runtime/core/token_estimate.py index aece86fa..e9021fd6 100644 --- a/src/xgen_agent_runtime/core/token_estimate.py +++ b/src/xgen_agent_runtime/core/token_estimate.py @@ -269,6 +269,9 @@ def _estimate_fingerprint(state: Any) -> tuple: len(messages), id(messages[0]) if messages else 0, id(messages[-1]) if messages else 0, + # In-place edits of the newest message (Stage 16 appends its turn-budget note into the last + # tool result) keep every length the same; its content signature catches them. + _signature(messages[-1]) if messages else 0, sys_len, len(_turn_context(state)), len(tools), diff --git a/src/xgen_agent_runtime/events/catalog.py b/src/xgen_agent_runtime/events/catalog.py index 6a30a35a..fb1f6799 100644 --- a/src/xgen_agent_runtime/events/catalog.py +++ b/src/xgen_agent_runtime/events/catalog.py @@ -466,7 +466,7 @@ class EventTypes(str, Enum): EventTypes.CONTEXT_COMPACTION_SKIPPED: { "strategy": "str — compactor name/class", "trigger": "str", - "reason": "str — turn_compaction_limit | summary_model_failing | already_summarized_for_this_request | nothing_to_summarize | summary_region_too_small | no_change", + "reason": "str — turn_compaction_limit | summary_model_failing | already_summarized_for_this_request | background_summary_running | nothing_to_summarize | summary_region_too_small | no_change", "tokens_estimate": "int?", "target_tokens": "int", }, @@ -490,6 +490,7 @@ class EventTypes(str, Enum): "chars_saved": "int — text chars removed (images excluded)", "aggressive": "bool? — the hard-pressure profile (guard / overflow)", "results_fitted": "int? — tool results shortened in proportion so a hard-pressure request fits", + "current_attachments_stripped": "int? — images/documents of the request itself removed (the very last resort)", "trigger": "str? — cost (Stage 2 token threshold) | the compaction trigger it ran under", "threshold_tokens": "int? — the prune_over_tokens threshold that fired (cost trigger)", "tokens_before": "int? — projected prompt tokens before the pass (cost trigger)", diff --git a/src/xgen_agent_runtime/host/context_budget.py b/src/xgen_agent_runtime/host/context_budget.py index bc3909e7..13a737ff 100644 --- a/src/xgen_agent_runtime/host/context_budget.py +++ b/src/xgen_agent_runtime/host/context_budget.py @@ -121,15 +121,16 @@ def _estimate_tools_tokens(registry: Any, provider: str, model: str) -> int: def _compacted_history_floor(window: int) -> int: """압축 뒤 이력(체크포인트)이 차지할 수 있는 최대 토큰: 요약 + 원문으로 남기는 사용자 말 + 머리글. - 5,000 고정이었을 때는 큰 창에서 체크포인트(사용자 말만 창의 8%, 최대 2만)가 그보다 커서, 입력 맞춤이 - 받아들인 질문과 RAG 를 압축으로도 담지 못했다. + 5,000 고정이었을 때는 큰 창에서 체크포인트(사용자 말만 창의 8%, 최대 2만)가 그보다 커서 입력 맞춤이 + 받아들인 질문과 RAG 를 압축으로도 담지 못했고, 작은 창(8k 체크포인트 상한 약 2.3k)에서는 반대로 질문을 + 필요 이상 잘랐다. """ try: from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import ( checkpoint_tokens_bound, ) - return max(_COMPACTED_HISTORY_FLOOR, checkpoint_tokens_bound(window)) + return checkpoint_tokens_bound(window) except Exception: # noqa: BLE001 (fail-open) return _COMPACTED_HISTORY_FLOOR @@ -246,6 +247,7 @@ def fit_input_to_budget( reserved_tokens: int = 0, history_compressible: bool = True, history_context: str = "", + extra_overhead_text: str = "", ) -> BudgetFit: """이번 턴의 입력측을 유효 예산 안으로 맞춘다. @@ -267,9 +269,12 @@ def fit_input_to_budget( 접혀 CLI 도 줄이지 못한다): 넘치면 **가장 오래된 이력부터** 빼고(마지막 두 메시지는 남김), 그다음 가변분을 줄인다. 잘린 이력은 ``BudgetFit.history`` 로 돌려준다. - ``history_context``: 메모리 노드가 넘긴 이전 대화 문자열. 시스템 프롬프트 끝에 붙어 압축이 줄일 수 + ``history_context``: 메모리 노드가 넘긴 이전 대화 문자열. 이번 턴 안내로 요청에 붙어 압축이 줄일 수 없으므로 여기서 가장 먼저 줄인다(맞춘 값은 ``BudgetFit.history_context``). + ``extra_overhead_text``: 맞춤 이후 Stage 3 이 요청에 더 붙이는 글(숨긴 도구 목록, 이번 턴 안내). 세지 + 않으면 맞춘 요청이 guard 의 한도를 넘었다(2026-10-12 검토: 32k 창에서 목록과 안내 1.3k 토큰). + ``reserved_tokens``: fit **이후** 턴 중에 system 으로 주입되는 것들(내장 메모리 등)의 선예약분. fail-open: 어떤 예외도 입력을 그대로 돌려준다. 예산 로직의 버그가 턴을 죽여선 안 된다. @@ -300,6 +305,8 @@ def fit_input_to_budget( ) budget = max(1, budget - max(0, int(reserved_tokens))) sys_t = count_text_tokens(system_prompt, provider, model) + if extra_overhead_text: + sys_t += count_text_tokens(extra_overhead_text, provider, model) tools_t = _estimate_tools_tokens(registry, provider, model) hist_t = count_messages_tokens(history, provider, model) if history else 0 text_t = count_text_tokens(text, provider, model) diff --git a/src/xgen_agent_runtime/host/runner.py b/src/xgen_agent_runtime/host/runner.py index 9c02786e..e3596349 100644 --- a/src/xgen_agent_runtime/host/runner.py +++ b/src/xgen_agent_runtime/host/runner.py @@ -675,7 +675,8 @@ def build_pipeline( # The same reserve the compaction policy measures its input budget against, so the 80% # trigger always comes before the guard (core.compaction.output_reserve). - headroom = output_reserve(int(context_window_budget or 0), int(max_tokens)) + # An unknown window is the policy's default (core.compaction.window_of), not "no cap". + headroom = output_reserve(int(context_window_budget or 0) or 200_000, int(max_tokens)) builder.with_guard(guards=[TokenBudgetGuard(min_remaining_tokens=headroom)]) if memory_provider is not None: diff --git a/src/xgen_agent_runtime/host/token_budget.py b/src/xgen_agent_runtime/host/token_budget.py index 52ab5305..4896397a 100644 --- a/src/xgen_agent_runtime/host/token_budget.py +++ b/src/xgen_agent_runtime/host/token_budget.py @@ -237,7 +237,9 @@ def resolve_context_window( if prov == "codex": # Codex CLI 는 모델의 API 창(gpt-5.5 1.05M)과 무관하게 자기 창(272k 입력)으로 돈다. return _PROVIDER_DEFAULT_WINDOW["codex"] - if name: + if name and prov != "vllm": + # 자체 서빙(vLLM)은 표를 쓰지 않는다: 같은 모델도 --max-model-len 을 줄여 띄우는 일이 흔해 모델의 + # 원래 창은 과대다. 서버 실측(위) → 카탈로그 → vLLM 기본값 순이다. # 이름 앞이나 구분자(. / :) 뒤에서 시작하는 접두만: 'apac.anthropic.claude-…', # 'meta.llama3-1-70b-…', 'Qwen/…'. 그냥 포함으로 보면 'o1' 같은 짧은 접두가 엉뚱한 이름 속에서 # 걸린다. 가장 긴 접두가 이긴다. @@ -487,13 +489,15 @@ def truncate_text_to_token_budget( ) keep = max(1, int((max_tokens - notice_tokens) / scale) - 1) - for _ in range(4): + # 앞과 뒤의 글 밀도가 전체 평균과 다를 수 있다(영어 본문 + 한국어 머리말): 잰 값에 비례해 줄여 맞춘다. + for _ in range(8): front_budget = max(1, int(keep * front_ratio)) back_budget = max(1, keep - front_budget) truncated = _cut_middle(text, front_budget, back_budget, omit_notice) - if count_text_tokens(truncated, provider, model) <= max_tokens or keep <= 1: + counted = count_text_tokens(truncated, provider, model) + if counted <= max_tokens or keep <= 1: return truncated, True - keep = max(1, int(keep * 0.9)) + keep = max(1, int(keep * min(0.9, max_tokens / counted * 0.95))) return truncated, True diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index 977c6241..24131960 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -1014,20 +1014,37 @@ def run_dir_cleanup(_d: str = run_dir) -> None: vllm_probe=host.fetch_vllm_max_model_len, ) # 메모리 노드가 (최근 메시지, 이전 대화 문자열) 로 보낸 둘째 칸: 최근 N개(기본 2개)보다 - # 앞선 대화다. 옛 agent_xgen 처럼 시스템 프롬프트 끝에 붙인다(아래 맞춤 뒤). 노드 기본값이 - # 10만 자라 먼저 창의 일정 몫 안으로 줄이고, 입력 맞춤이 남는 자리에 맞춰 한 번 더 줄인다 - # (이번 질문·RAG 보다 먼저, 가장 최근 쪽이 남는다). + # 앞선 대화다. 이번 턴 안내(턴 노트)로 요청 사본에 붙인다(아래 맞춤 뒤). 옛 agent_xgen 은 + # 시스템 프롬프트 끝에 붙였는데, 그 자리는 매 턴 바뀌어 프롬프트 캐시와 Claude Code 예열 + # 프로세스를 매번 깬다. 노드 기본값이 10만 자라 먼저 창의 일정 몫 안으로 줄이고, 입력 맞춤이 + # 남는 자리에 맞춰 한 번 더 줄인다(이번 질문·RAG 보다 먼저, 가장 최근 쪽이 남는다). _history_ctx = history_context(kwargs.get("memory")) if _history_ctx: _history_ctx = fit_history_context(_history_ctx, budget_window, provider, model) - _memory_reserve = 0 + _late_reserve = 0 if memory_provider is not None: # 내장 메모리 주입(Stage 2)은 fit 이후 system 에 붙는다. 그만큼 미리 비워 둔다. # 주입 상한은 build_pipeline 이 같은 창으로 줄인다(memory_inject_cap_chars). from xgen_agent_runtime.memory.provider import MemoryHooks, provider_hooks _hooks = provider_hooks(memory_provider) or MemoryHooks() - _memory_reserve = memory_reserved_tokens(budget_window, _hooks.max_inject_chars) + _late_reserve = memory_reserved_tokens(budget_window, _hooks.max_inject_chars) + # 날짜 줄(Stage 3 DateTimeBlock) 몫. + _late_reserve += 200 + if provider == "claude_code": + # CLI 는 자기 도구 스키마를 더하고 답 몫을 따로 잡는다(관측: maxOutputTokens 32,000). + _late_reserve += 12_000 + max(0, 32_000 - max_tokens_val) + # Stage 3 이 맞춤 뒤에 요청에 붙이는 글: 숨긴 도구 목록과 이번 턴 안내. + _extra_overhead = "\n\n".join( + str(n) for n in (state.shared.get(SharedKeys.TURN_NOTES) or []) + ) + if _tools_reach_model and registry is not None: + try: + from xgen_agent_runtime.tools.catalog import deferred_catalog_text + + _extra_overhead = _extra_overhead + "\n\n" + deferred_catalog_text(registry) + except Exception: # noqa: BLE001 (an estimate input, never a gate) + pass if enable_compaction and budget_window > 0: def _fit(_text: str): @@ -1042,9 +1059,10 @@ def _fit(_text: str): model=model, max_tokens=max_tokens_val, window=budget_window, - reserved_tokens=_memory_reserve, + reserved_tokens=_late_reserve, history_compressible=not _is_cli, history_context=_history_ctx, + extra_overhead_text=_extra_overhead, ) fit = _fit(text) @@ -1083,11 +1101,10 @@ def _fit(_text: str): ) if _history_ctx: - system_prompt = ( - (system_prompt + "\n\n" + _history_ctx) if system_prompt else _history_ctx - ) + _notes = [str(n) for n in (state.shared.get(SharedKeys.TURN_NOTES) or [])] + state.shared[SharedKeys.TURN_NOTES] = _notes + [_history_ctx] logger.info( - "agents/geny: 메모리 노드의 이전 대화 %d자를 시스템 프롬프트에 붙임", + "agents/geny: 메모리 노드의 이전 대화 %d자를 이번 턴 안내로 붙임", len(_history_ctx), ) diff --git a/src/xgen_agent_runtime/llm_client/claude_code.py b/src/xgen_agent_runtime/llm_client/claude_code.py index c710ead1..19ebe732 100644 --- a/src/xgen_agent_runtime/llm_client/claude_code.py +++ b/src/xgen_agent_runtime/llm_client/claude_code.py @@ -146,23 +146,35 @@ def _thinking_off(request: Optional[APIRequest]) -> bool: return request is not None and getattr(request, "thinking_level", None) == "off" +#: Process-private fallback folder (``mkdtemp``), used when the shared per-user name is taken. +_FALLBACK_PROMPT_FOLDER: Optional[str] = None + + def _private_prompt_folder() -> str: - """``/xgen-claude-code-system-``, created 0700 and verified to be ours and private.""" + """``/xgen-claude-code-system-``, created 0700 and verified (POSIX) to be ours and + private. If another local user already holds that name (a directory or a symlink), a fresh + ``mkdtemp`` folder of this process is used instead, so the long prompt still has a home.""" import stat import tempfile + global _FALLBACK_PROMPT_FOLDER uid = os.getuid() if hasattr(os, "getuid") else None folder = os.path.join( tempfile.gettempdir(), f"xgen-claude-code-system-{uid if uid is not None else 'user'}" ) - os.makedirs(folder, mode=0o700, exist_ok=True) - if uid is not None: - info = os.lstat(folder) - if not stat.S_ISDIR(info.st_mode) or info.st_uid != uid: - raise OSError(f"{folder} is not a directory owned by this user") - if stat.S_IMODE(info.st_mode) & 0o077: - os.chmod(folder, 0o700) - return folder + try: + os.makedirs(folder, mode=0o700, exist_ok=True) + if uid is not None: + info = os.lstat(folder) + if not stat.S_ISDIR(info.st_mode) or info.st_uid != uid: + raise OSError(f"{folder} is not a directory owned by this user") + if stat.S_IMODE(info.st_mode) & 0o077: + os.chmod(folder, 0o700) + return folder + except OSError: + if _FALLBACK_PROMPT_FOLDER is None or not os.path.isdir(_FALLBACK_PROMPT_FOLDER): + _FALLBACK_PROMPT_FOLDER = tempfile.mkdtemp(prefix="xgen-claude-code-system-") + return _FALLBACK_PROMPT_FOLDER class ClaudeCodeCLIClient(BaseClient): diff --git a/src/xgen_agent_runtime/llm_client/translators/_cli.py b/src/xgen_agent_runtime/llm_client/translators/_cli.py index 08bd3208..7d80b923 100644 --- a/src/xgen_agent_runtime/llm_client/translators/_cli.py +++ b/src/xgen_agent_runtime/llm_client/translators/_cli.py @@ -104,10 +104,11 @@ def resolve_auth_channel(auth_mode: str, has_api_key: bool) -> str: #: One argv string must stay under Linux ``MAX_ARG_STRLEN`` (131,072 bytes). Above this the #: Claude Code client moves the system prompt to a file and the prompt to stdin. Windows caps the -#: WHOLE command line at 32,767 characters (the desktop host runs the CLI there), so the bar is -#: much lower: the rest of the argv (MCP config, tool lists) needs room too. +#: WHOLE command line at 32,767 characters (the desktop host runs the CLI there), and 8,191 when the +#: binary is a ``.cmd`` shim run through cmd.exe, so the bar is much lower: the rest of the argv +#: (MCP config, tool lists) needs room too. ARGV_SAFE_BYTES = 100_000 -ARGV_SAFE_CHARS_WINDOWS = 12_000 +ARGV_SAFE_CHARS_WINDOWS = 6_000 def system_prompt_text(system: Any) -> str: diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py index 34b73c56..567930c7 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py @@ -4,7 +4,7 @@ import asyncio import json -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Tuple from xgen_agent_runtime.core.config import ModelConfig @@ -192,6 +192,9 @@ async def compact(self, state: PipelineState) -> None: #: Verbatim user messages kept beside the summary: share of the window, with an absolute cap. _PINNED_RATIO = 0.08 _PINNED_MAX_TOKENS = 20_000 +#: The request being worked on may take up to this share of the compaction target (or the pinned +#: budget, if larger). +_CURRENT_REQUEST_SHARE = 0.5 #: Summary length (output tokens): window/16, between these bounds. _SUMMARY_MIN_TOKENS = 1_024 _SUMMARY_MAX_TOKENS = 4_096 @@ -201,8 +204,9 @@ async def compact(self, state: PipelineState) -> None: def checkpoint_tokens_bound(window: int) -> int: - """The most a checkpoint message can take for this window: the summary, the verbatim user - messages (and the current request) within their budget, and the intro/ack framing.""" + """The most a checkpoint message can take for this window apart from the request being worked + on (which the host counts as the question itself): the summary, the earlier user messages kept + verbatim within their budget, and the intro/ack framing.""" window = max(0, int(window or 0)) summary = min(_SUMMARY_MAX_TOKENS, max(_SUMMARY_MIN_TOKENS, window // 16)) pinned = min(int(window * _PINNED_RATIO), _PINNED_MAX_TOKENS) @@ -251,17 +255,61 @@ def _is_ack(message: Any) -> bool: ) +#: User-role messages the runtime itself adds (Stage 16's deliverable check, the turn input budget +#: note). They are instructions to the model, not something the person asked. +_RUNTIME_NOTE_PREFIXES = ("[Deliverable check", "[Turn budget") +#: Content blocks that are attachments rather than words. +_ATTACHMENT_TYPES = ("image", "document", "image_url", "input_image") + + +def _is_runtime_note(message: Any) -> bool: + return _message_text(message).lstrip().startswith(_RUNTIME_NOTE_PREFIXES) + + def _is_real_user(message: Any) -> bool: - """A user message a person wrote (not a tool result, not a checkpoint).""" + """A user message a person wrote (not a tool result, a checkpoint, or a runtime note).""" return ( isinstance(message, dict) and message.get("role") == "user" and not _is_tool_result_only(message) and not _is_checkpoint(message) + and not _is_runtime_note(message) and bool(_message_text(message).strip()) ) +def _has_tool_result(message: Any) -> bool: + """A user message carrying any tool result (alone, or mixed with text a host appended).""" + if not isinstance(message, dict) or message.get("role") != "user": + return False + return any(b.get("type") == "tool_result" for b in _content_blocks(message.get("content"))) + + +def _attachments(message: Any) -> List[Dict[str, Any]]: + if not isinstance(message, dict): + return [] + return [ + b for b in _content_blocks(message.get("content")) if b.get("type") in _ATTACHMENT_TYPES + ] + + +def _summary_length_tokens(window: int) -> int: + """How long a summary may be for this window (output tokens, without reasoning headroom).""" + return min(_SUMMARY_MAX_TOKENS, max(_SUMMARY_MIN_TOKENS, int(window) // 16)) + + +def _clip_tokens(state: PipelineState, text: str, tokens: int) -> str: + """:func:`_clip` with a TOKEN budget (head and tail kept). Characters are not tokens: a + character limit equal to a token budget kept a third of an English summary.""" + from xgen_agent_runtime.core.token_estimate import estimate_text_scaled + + text = str(text or "") + cost = estimate_text_scaled(state, text) + if cost <= tokens or not text: + return text + return _clip(text, max(400, int(len(text) * tokens / cost * 0.95))) + + def _clip(text: str, limit: int, tail: int = 0) -> str: text = str(text or "") if len(text) <= limit: @@ -333,6 +381,8 @@ class CompactionPlan: pinned: List[str] #: The request this turn is working on, when it falls inside the summarized region. current_request: str = "" + #: Images and documents of that request, carried into the checkpoint as they are. + attachments: List[Dict[str, Any]] = field(default_factory=list) class LLMSummaryCompactor(SummaryCompactor): @@ -419,20 +469,40 @@ def plan( return None window = window_of(state) target = int(target_tokens or input_budget_of(state) * TARGET_RATIO) - provider = str(getattr(self._client_getter(state), "provider", "") or "") - # Minimal tail: the last message; when it is a tool result, also the call it answers. + # Pins carried from an earlier checkpoint are valid only while that checkpoint still heads + # the history: a host that keeps the state object but swaps in another conversation must + # not get the old conversation's words pinned into the new checkpoint. + has_checkpoint = _is_checkpoint(msgs[0]) + carried = ( + [str(t) for t in (state.shared.get(PINNED_USERS_KEY) or []) if str(t).strip()] + if has_checkpoint + else [] + ) + carried_current = ( + str(state.shared.get(CURRENT_REQUEST_KEY) or "").strip() if has_checkpoint else "" + ) + + # Minimal tail: the last message; when it carries tool results, also the call they answer. cut = n - 1 - if _is_tool_result_only(msgs[cut]) and cut > 0 and msgs[cut - 1].get("role") == "assistant": + if _has_tool_result(msgs[cut]) and cut > 0 and msgs[cut - 1].get("role") == "assistant": cut -= 1 pinned_budget = min(int(window * _PINNED_RATIO), _PINNED_MAX_TOKENS) + # The request being worked on is the most important text in the checkpoint: it gets its own, + # larger budget (it used to share the 8% with every earlier message and was cut first). + current_budget = max(pinned_budget, int(target * _CURRENT_REQUEST_SHARE)) + # Room the checkpoint needs: the summary's length (not the reasoning headroom of the summary + # call) and the user's own words, never more than they actually wrote. + user_tokens = sum( + estimate_text_scaled(state, _message_text(m)) for m in msgs[:cut] if _is_real_user(m) + ) + sum(estimate_text_scaled(state, t) for t in carried + [carried_current] if t) budget = ( target - estimate_overhead_tokens(state) - - self._summary_tokens(window, provider) + - _summary_length_tokens(window) - 400 - - pinned_budget + - min(user_tokens, pinned_budget + current_budget) ) kept_tokens = estimate_messages_scaled(state, msgs[cut:]) while cut > 0 and (n - (cut - 1)) <= max(1, self._keep_recent): @@ -441,8 +511,8 @@ def plan( break kept_tokens += t cut -= 1 - # Never open the kept part on a tool result whose call is being summarized. - while cut < n - 1 and _is_tool_result_only(msgs[cut]): + # Never open the kept part on a message with tool results whose call is being summarized. + while cut < n - 1 and _has_tool_result(msgs[cut]): cut += 1 if cut <= 0: return None @@ -457,42 +527,46 @@ def plan( if not new_material: return None # only an earlier checkpoint would be summarized again - carried = [str(t) for t in (state.shared.get(PINNED_USERS_KEY) or []) if str(t).strip()] - carried_current = str(state.shared.get(CURRENT_REQUEST_KEY) or "").strip() last_real = next((m for m in reversed(msgs) if _is_real_user(m)), None) + attachments: List[Dict[str, Any]] = [] if last_real is not None: # The newest thing the user wrote is the request being worked on; it is shown on its own - # when it is summarized, and stays verbatim in the kept tail otherwise. A request carried - # from an earlier compaction is now just an earlier message. - current = ( - _message_text(last_real).strip() if any(last_real is r for r in region) else "" - ) + # (with its attachments) when it is summarized, and stays verbatim in the kept tail + # otherwise. A request carried from an earlier compaction is now an earlier message. + if any(last_real is r for r in region): + current = _message_text(last_real).strip() + attachments = _attachments(last_real) + else: + current = "" if carried_current: carried.append(carried_current) else: # Every message the user wrote is already inside an earlier checkpoint: the request - # being worked on is the one that checkpoint carried. + # being worked on (and its attachments) is the one that checkpoint carried. current = carried_current + if has_checkpoint: + attachments = _attachments(msgs[0]) candidates = carried + [_message_text(m).strip() for m in new_material if _is_real_user(m)] if current and candidates and candidates[-1] == current: candidates = candidates[:-1] # shown separately as the request being worked on + if current: + current = _clip_tokens(state, current, current_budget) pinned: List[str] = [] - used = estimate_text_scaled(state, current) if current else 0 + used = 0 for text in reversed(candidates): # newest first cost = estimate_text_scaled(state, text) if used + cost > pinned_budget: break pinned.insert(0, text) used += cost - if current and estimate_text_scaled(state, current) > pinned_budget: - current = _clip(current, max(400, int(pinned_budget))) return CompactionPlan( cut=cut, region_tokens=estimate_messages_scaled(state, new_material), pinned=pinned, current_request=current, + attachments=attachments, ) # ── transcript ─────────────────────────────────────────────────── @@ -505,7 +579,11 @@ def _transcript(self, state: PipelineState, region: List[Any], budget_tokens: in head: List[str] = [] body = rendered if region and _is_checkpoint(region[0]): - head = [_clip("\n".join(rendered[0]), max(2_000, int(budget_tokens * 0.3)))] + # The earlier summary is folded into this one, so the summarizer must read all of it + # when it can (a token budget, not characters: the old character limit kept a third). + head = [ + _clip_tokens(state, "\n".join(rendered[0]), max(2_000, int(budget_tokens * 0.4))) + ] body = rendered[1:] lines = [line for chunk in body for line in chunk] used = sum(estimate_text_scaled(state, h) for h in head) @@ -523,7 +601,7 @@ def _transcript(self, state: PipelineState, region: List[Any], budget_tokens: in # ── fallbacks ──────────────────────────────────────────────────── - def _digest(self, region: List[Any], old_count: int) -> str: + def _digest(self, state: PipelineState, region: List[Any], old_count: int) -> str: """Deterministic stand-in when no model summary could be made: no call, no invention.""" lines = [ self._summary_text @@ -538,7 +616,14 @@ def _digest(self, region: List[Any], old_count: int) -> str: if not isinstance(m, dict): continue if _is_checkpoint(m): - lines.append("Earlier checkpoint:\n" + _clip(_message_text(m).strip(), 3_000)) + from xgen_agent_runtime.core.compaction import window_of + + # The earlier summary is the best record of what came before: keep as much of it + # as a summary may take (it used to be cut to 3,000 characters on every digest). + earlier = _clip_tokens( + state, _message_text(m).strip(), _summary_length_tokens(window_of(state)) + ) + lines.append("Earlier checkpoint:\n" + earlier) continue for block in _content_blocks(m.get("content")): if block.get("type") == "tool_use": @@ -684,13 +769,16 @@ async def compact(self, state: PipelineState) -> None: summary, cfg, attempts = await self._summarize(state, region, resolve, client) request["model_calls"] = attempts if summary is None: + if client is None or resolve is None: + # No model wired counts as a failed attempt (core.compaction stops re-planning). + request["source"] = "unavailable" if not hard: state.add_event( "context.compaction_deferred", {"compactor": self.name, "reason": "no summary", "attempts": attempts}, ) return # soft pressure: losing history is worse than waiting for the guard - summary = self._digest(region, old_count) + summary = self._digest(state, region, old_count) source = "digest" else: source = "model" @@ -703,7 +791,13 @@ async def compact(self, state: PipelineState) -> None: ) if outline.current_request: sections.append("The request being worked on, verbatim:\n" + outline.current_request) - head: List[Dict[str, Any]] = [{"role": "user", "content": "\n\n".join(sections)}] + text = "\n\n".join(sections) + # The request's images and documents ride along as they are: a summary cannot carry them, + # and the model would otherwise answer about a PDF it no longer has. + content: Any = ( + [{"type": "text", "text": text}, *outline.attachments] if outline.attachments else text + ) + head: List[Dict[str, Any]] = [{"role": "user", "content": content}] if kept and isinstance(kept[0], dict) and kept[0].get("role") == "user": head.append({"role": "assistant", "content": CHECKPOINT_ACK}) state.messages = head + strip_leading_orphan_tool_results(kept) diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py index ca87d357..2ada0f57 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py @@ -686,7 +686,14 @@ async def _run() -> _CompactionShadow: "task": task, "len": snapshot_len, "message_ids": snapshot_ids, + # Which run scheduled it: a failure collected in a later turn belongs to that turn. + "run": getattr(state, "_run_count", 0), } + # Every soft check (this stage, Stage 4, the internal loop) sees the summary in flight and + # does not make a second, synchronous one of the same history (core.compaction gate). + from xgen_agent_runtime.core.compaction import BACKGROUND_TASK_ATTR + + setattr(state, BACKGROUND_TASK_ATTR, task) # This request's summary is in flight: Stage 4's proactive check must not summarize the # same request synchronously (it did, and the background result was then discarded). The # attempt counts against the turn's limit now, whether or not its result is ever applied. @@ -713,6 +720,14 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: if not task.done(): return False self._bg_compaction = None + from xgen_agent_runtime.core.compaction import ( + BACKGROUND_TASK_ATTR, + COMPACTION_CONTEXT_KEY, + LLM_FAILURES_KEY, + ) + + if getattr(state, BACKGROUND_TASK_ATTR, None) is task: + setattr(state, BACKGROUND_TASK_ATTR, None) if task.cancelled() or task.exception() is not None: return False # already logged by the done-callback shadow: _CompactionShadow = task.result() @@ -720,11 +735,11 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: for purpose, usage in shadow.aux_usage: state.add_aux_usage(purpose, usage) # A background summary that called the model and came back empty is a failed attempt, like - # a synchronous one (core.compaction counts those toward the soft-pressure cut-off). - from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, LLM_FAILURES_KEY - + # a synchronous one, but only for the turn that scheduled it (a new turn starts clean). report = shadow.shared.get(COMPACTION_CONTEXT_KEY) or {} - if int(report.get("model_calls") or 0) > 0: + if int(report.get("model_calls") or 0) > 0 and info.get("run") == getattr( + state, "_run_count", 0 + ): if report.get("source") == "model": state.shared[LLM_FAILURES_KEY] = 0 else: @@ -733,12 +748,18 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: n = int(info["len"]) msgs = state.messages current_prefix_ids = tuple(id(message) for message in msgs[:n]) - if len(msgs) < n or n == 0 or current_prefix_ids != info["message_ids"]: - # Prefix rewritten since the snapshot (e.g. the Stage 4 guard - # compacted synchronously) — the summary no longer matches. + if ( + len(msgs) < n + or n == 0 + or current_prefix_ids != info["message_ids"] + or len(shadow.messages) >= n + ): + # Not applied: the prefix was rewritten since the snapshot (a synchronous guard + # compaction), or the compactor changed nothing (its summary failed, or there was + # nothing to cut). Its events still tell what happened. + for event_type, data in shadow.events: + state.add_event(event_type, data) return False - if len(shadow.messages) >= n: - return False # compactor was a no-op (below its keep threshold) before = len(msgs) replaced = before - (len(shadow.messages) + (before - n)) diff --git a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/guards.py b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/guards.py index c0d79376..c6a1b9bd 100644 --- a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/guards.py +++ b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/guards.py @@ -28,8 +28,11 @@ class TokenBudgetGuard(Guard): the job of ``cost_budget`` and the Stage 16 loop's token dimension. """ - def __init__(self, min_remaining_tokens: int = 10_000): - self._min_remaining = min_remaining_tokens + def __init__(self, min_remaining_tokens: int = 0): + #: 0 = the compaction policy's answer reserve for this request + #: (``core.compaction.output_reserve``: max(4096, max_tokens + 2048), at most half the + #: window), so the proactive 80% relief always comes before this guard. + self._min_remaining = int(min_remaining_tokens or 0) @property def name(self) -> str: @@ -37,10 +40,12 @@ def name(self) -> str: @property def description(self) -> str: - return ( - f"Ensures the next request leaves >= {self._min_remaining} tokens " - "of context headroom (compacts history first)" + need = ( + f"{self._min_remaining} tokens" + if self._min_remaining + else "the answer's room (max(4096, max_tokens + 2048), at most half the window)" ) + return f"Ensures the next request leaves >= {need} of context headroom (compacts history first)" @classmethod def config_schema(cls) -> ConfigSchema: @@ -55,9 +60,10 @@ def config_schema(cls) -> ConfigSchema: "Headroom (for the response) the next request must leave " "free in the context window. When the projected request " "would leave less, history is compacted and re-checked " - "before the turn is rejected." + "before the turn is rejected. 0 = derive it from max_tokens like the " + "compaction policy (max(4096, max_tokens + 2048), at most half the window)." ), - default=10_000, + default=0, min_value=0, ), ], @@ -72,16 +78,22 @@ def get_config(self) -> Dict[str, Any]: return {"min_remaining_tokens": self._min_remaining} def check(self, state: PipelineState) -> GuardResult: + from xgen_agent_runtime.core.compaction import output_reserve, window_of + projected = estimate_prompt_tokens(state) - remaining = state.context_window_budget - projected - if remaining < self._min_remaining: + window = window_of(state) + need = self._min_remaining or output_reserve( + window, int(getattr(state, "max_tokens", 0) or 0) + ) + remaining = window - projected + if remaining < need: return GuardResult( passed=False, guard_name=self.name, message=( f"Context near limit: ~{projected} of " - f"{state.context_window_budget} tokens projected, " - f"{remaining} free (need {self._min_remaining})" + f"{window} tokens projected, " + f"{remaining} free (need {need})" ), action="compact", ) diff --git a/src/xgen_agent_runtime/stages/s10_tool/persistence.py b/src/xgen_agent_runtime/stages/s10_tool/persistence.py index 725c09d0..8419b5db 100644 --- a/src/xgen_agent_runtime/stages/s10_tool/persistence.py +++ b/src/xgen_agent_runtime/stages/s10_tool/persistence.py @@ -142,7 +142,12 @@ def _summarise(body: str, *, limit: int, path: str, reachable: bool) -> str: def _window_cap_tokens(context: ToolContext) -> Optional[int]: - """Tokens a single result may take in this turn's request, or ``None`` if the window is unknown.""" + """Tokens a single result may take in this turn's request, or ``None`` if the window is unknown. + + A quarter of the INPUT budget (the window minus the room kept for the answer, + ``core.compaction.input_budget_of``): on an 8k model with ``max_tokens`` 8192 a quarter of the + window was half of what the request could hold. + """ state = getattr(context, "state_view", None) try: window = int(getattr(state, "context_window_budget", 0) or 0) @@ -150,7 +155,15 @@ def _window_cap_tokens(context: ToolContext) -> Optional[int]: window = 0 if window <= 0: return None - return max(_WINDOW_CAP_FLOOR_TOKENS, int(window * _WINDOW_SHARE)) + from xgen_agent_runtime.core.compaction import input_budget_of + + return max(_WINDOW_CAP_FLOOR_TOKENS, int(input_budget_of(state) * _WINDOW_SHARE)) + + +#: Above this many characters the token estimate is taken from a sample (head and tail) and +#: scaled: a per-character count of a multi-megabyte result blocked the event loop for seconds. +_SAMPLE_OVER_CHARS = 200_000 +_SAMPLE_CHARS = 100_000 def _view_tokens(text: str) -> int: @@ -163,50 +176,97 @@ def _view_tokens(text: str) -> int: estimate_tokens_cached, ) - return int(estimate_tokens_cached(text) * DEFAULT_PROVIDER_FACTOR * SAFETY_MULTIPLIER) + if len(text) > _SAMPLE_OVER_CHARS: + sample = text[:_SAMPLE_CHARS] + text[-_SAMPLE_CHARS:] + raw = estimate_tokens_cached(sample) * len(text) / len(sample) + else: + raw = estimate_tokens_cached(text) + return int(raw * DEFAULT_PROVIDER_FACTOR * SAFETY_MULTIPLIER) -def _clip_inline(body: str, keep_chars: int, *, saved_at: str = "") -> str: - """Head and tail of ``body`` within about ``keep_chars`` chars, with an explicit marker.""" - tail = keep_chars // 4 - head = max(0, keep_chars - tail) +def _clip_marker(total_chars: int, saved_at: str) -> str: where = ( f"The full result is saved at: {saved_at}" if saved_at else "Re-run with a narrower query (a line range, a filter) if you need the omitted part" ) return ( - f"[tool result clipped to fit this model's context window: {len(body)} chars, the middle " - f"is omitted. {where}.]\n" - f"{body[:head]}\n…\n{body[-tail:] if tail else ''}" + f"[tool result clipped to fit this model's context window: {total_chars} chars, the middle " + f"is omitted. {where}.]" ) +def _clip_to_tokens(text: str, cap: int) -> str: + """Head and tail of ``text`` whose estimate is within ``cap`` tokens (re-measured: the head and + tail can be denser than the average).""" + tokens = _view_tokens(text) + if tokens <= cap: + return text + keep = max(200, int(len(text) * cap / tokens * 0.95)) + clipped = text + for _ in range(6): + tail = keep // 4 + head = max(0, keep - tail) + clipped = f"{text[:head]}\n…\n{text[-tail:] if tail else ''}" + measured = _view_tokens(clipped) + if measured <= cap or keep <= 200: + break + keep = max(200, int(keep * min(0.9, cap / measured * 0.95))) + return clipped + + +def _error_header(content: Any) -> str: + """The first line ``ToolResult.to_api_format`` gives a structured error, kept through a clip.""" + if isinstance(content, dict): + err = content.get("error") + if ( + isinstance(err, dict) + and isinstance(err.get("code"), str) + and isinstance(err.get("message"), str) + ): + return f"ERROR {err['code']}: {err['message']}" + return "" + + def _fit_to_window(result: ToolResult, *, tool_name: str, context: ToolContext) -> ToolResult: """Clip what the model will read (``display_text`` when set, else the content) to a quarter of - the context window. The tool's own character cap ignores the model: 100k chars is 25k-100k - tokens, so on a 32k-window model one result could overflow the request by itself.""" + the input budget. The tool's own character cap ignores the model: 100k chars is 25k-100k tokens, + so on a 32k-window model one result could overflow the request by itself. + + Text is clipped head-and-tail; image blocks in a block-list result are kept as they are (they + are counted separately). A structured error keeps its ``ERROR : `` line. + """ cap = _window_cap_tokens(context) if cap is None: return result + saved_at = "" + if result.persist_full and _reachable(result.persist_full, context): + saved_at = result.persist_full + + if result.display_text is None and isinstance(result.content, list): + texts = [b for b in result.content if isinstance(b, dict) and b.get("type") == "text"] + joined = "\n".join(str(b.get("text") or "") for b in texts) + if not texts or _view_tokens(joined) <= cap: + return result + clipped = _clip_to_tokens(joined, cap) + kept = [b for b in result.content if not (isinstance(b, dict) and b.get("type") == "text")] + _LOG.info("tool %s result text over window cap %d; clipping (images kept)", tool_name, cap) + marker = _clip_marker(len(joined), saved_at) + return replace(result, content=[{"type": "text", "text": f"{marker}\n{clipped}"}] + kept) + view = ( result.display_text if result.display_text is not None else _render_content(result.content) ) - tokens = _view_tokens(view) - if tokens <= cap: + if _view_tokens(view) <= cap: return result - keep = max(400, int(len(view) * cap / tokens * 0.95)) - saved_at = "" - if result.persist_full and _reachable(result.persist_full, context): - saved_at = result.persist_full + header = _error_header(result.content) if result.display_text is None else "" + clipped = _clip_to_tokens(view, cap) _LOG.info( - "tool %s result ~%d tokens > window cap %d; clipping to %d chars", - tool_name, - tokens, - cap, - keep, + "tool %s result over window cap %d; clipping to %d chars", tool_name, cap, len(clipped) ) - return replace(result, display_text=_clip_inline(view, keep, saved_at=saved_at)) + lines = [header] if header else [] + lines += [_clip_marker(len(view), saved_at), clipped] + return replace(result, display_text="\n".join(lines)) def maybe_persist_large_result( @@ -218,18 +278,26 @@ def maybe_persist_large_result( context: ToolContext, ) -> ToolResult: """Apply the tool's character cap (:func:`_persist_over_tool_cap`), then bound what the model - reads by the context window (:func:`_fit_to_window`, 4.89.0). ``max_result_chars`` of 0 or - below opts out of both.""" - if capabilities.max_result_chars <= 0: - return result - out = _persist_over_tool_cap( - result, - tool_use_id=tool_use_id, - tool_name=tool_name, - capabilities=capabilities, - context=context, - ) - return _fit_to_window(out, tool_name=tool_name, context=context) + reads by the context window (:func:`_fit_to_window`, 4.89.0). + + ``max_result_chars`` of 0 (ToolBatch) or below opts out of the tool's own cap only: the window + bound protects the request and applies to every tool. Never raises: a failure here returns the + result as it was (the turn must not die in result bookkeeping). + """ + out = result + if capabilities.max_result_chars > 0: + out = _persist_over_tool_cap( + result, + tool_use_id=tool_use_id, + tool_name=tool_name, + capabilities=capabilities, + context=context, + ) + try: + return _fit_to_window(out, tool_name=tool_name, context=context) + except Exception: # noqa: BLE001 (fail-open) + _LOG.warning("tool %s: window fit failed; result left as is", tool_name, exc_info=True) + return out def _persist_over_tool_cap( diff --git a/tests/unit/test_compaction_policy_489.py b/tests/unit/test_compaction_policy_489.py index c5e639e7..20fe44ce 100644 --- a/tests/unit/test_compaction_policy_489.py +++ b/tests/unit/test_compaction_policy_489.py @@ -310,6 +310,7 @@ async def test_hard_pressure_past_the_turn_limit_uses_the_digest(): state.model = "fake" client = _SummaryClient() state.llm_client = client + state.context_window_budget = 8_000 # the region must be worth summarizing (min_summary_region) state.messages = _conversation(30, "long message " * 30) state.shared[LLM_COMPACTIONS_KEY] = MAX_LLM_COMPACTIONS_PER_TURN @@ -770,3 +771,333 @@ def test_claude_code_prompt_file_is_private(tmp_path, monkeypatch): path = argv[argv.index("--system-prompt-file") + 1] assert stat.S_IMODE(os.stat(os.path.dirname(path)).st_mode) == 0o700 assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + +# ── review round 2 (2026-10-12): host and clients ─────────────────── + + +def test_host_fit_counts_what_stage_3_adds_later(): + from xgen_agent_runtime.host.context_budget import fit_input_to_budget + + kwargs = dict( + text="질문", + rag_block="참조 문서 " * 40_000, + system_prompt="system", + history=None, + registry=None, + provider="vllm", + model="m", + max_tokens=8_192, + window=32_000, + ) + plain = fit_input_to_budget(**kwargs) + with_notes = fit_input_to_budget(**kwargs, extra_overhead_text="숨긴 도구 목록과 이번 턴 안내 " * 300) + assert len(with_notes.rag_block) < len(plain.rag_block) + + +def test_window_fit_keeps_images_and_error_headers_and_covers_toolbatch(): + from xgen_agent_runtime.stages.s10_tool.persistence import maybe_persist_large_result + from xgen_agent_runtime.tools.base import ToolCapabilities, ToolContext, ToolResult + + class _View: + context_window_budget = 8_192 + max_tokens = 1_024 + + context = ToolContext(storage_path=None, state_view=_View()) + image = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0K"}} + mixed = maybe_persist_large_result( + ToolResult(content=[{"type": "text", "text": "가나다라 " * 5_000}, image]), + tool_use_id="t1", + tool_name="Read", + capabilities=ToolCapabilities(max_result_chars=100_000), + context=context, + ) + assert image in mixed.content and "clipped" in mixed.content[0]["text"] + + error = maybe_persist_large_result( + ToolResult(content={"error": {"code": "db_timeout", "message": "query timed out"}, "log": "줄 " * 20_000}, is_error=True), + tool_use_id="t2", + tool_name="Query", + capabilities=ToolCapabilities(max_result_chars=100_000), + context=context, + ) + assert error.display_text.startswith("ERROR db_timeout: query timed out") and error.is_error + + batch = maybe_persist_large_result( + ToolResult(content="결과 " * 20_000), + tool_use_id="t3", + tool_name="ToolBatch", + capabilities=ToolCapabilities(max_result_chars=0), + context=context, + ) + assert batch.display_text and "clipped" in batch.display_text + + +def test_window_fit_stays_under_the_cap_when_head_and_tail_are_denser(): + from xgen_agent_runtime.stages.s10_tool.persistence import _clip_to_tokens, _view_tokens + + text = "한국어 머리말 " * 2_000 + "plain english middle part " * 20_000 + "한국어 꼬리말 " * 2_000 + assert _view_tokens(_clip_to_tokens(text, 4_000)) <= 4_000 + + +def test_window_fit_never_raises(): + from xgen_agent_runtime.stages.s10_tool.persistence import maybe_persist_large_result + from xgen_agent_runtime.tools.base import ToolCapabilities, ToolContext, ToolResult + + class _Broken: + @property + def context_window_budget(self): + raise RuntimeError("boom") + + result = ToolResult(content="ok") + out = maybe_persist_large_result( + result, + tool_use_id="t1", + tool_name="Read", + capabilities=ToolCapabilities(max_result_chars=100_000), + context=ToolContext(storage_path=None, state_view=_Broken()), + ) + assert out.content == "ok" + + +def test_truncation_converges_on_mixed_density_text(): + from xgen_agent_runtime.host.token_budget import count_text_tokens, truncate_text_to_token_budget + + text = "한국어 머리말 " * 3_000 + "english middle " * 30_000 + "한국어 꼬리말 " * 3_000 + for budget in (256, 1_024, 4_000): + out, cut = truncate_text_to_token_budget(text, budget, provider="vllm", model="m") + assert cut and count_text_tokens(out, "vllm", "m") <= budget + + +def test_vllm_without_a_probe_does_not_use_the_native_window(): + from xgen_agent_runtime.host.token_budget import resolve_context_window + + assert resolve_context_window("vllm", "google/gemma-3-27b-it") == 32_768 + + +def test_claude_code_prompt_folder_falls_back_when_the_name_is_taken(tmp_path, monkeypatch): + import os + import tempfile + + from xgen_agent_runtime.llm_client import claude_code + + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(claude_code, "_FALLBACK_PROMPT_FOLDER", None) + taken = tmp_path / f"xgen-claude-code-system-{os.getuid()}" + taken.symlink_to(tmp_path) # someone else's link squatting the name + folder = claude_code._private_prompt_folder() + assert folder != str(taken) and os.path.isdir(folder) + + +# ── review round 2 (2026-10-12): core policy ──────────────────────── + +_PDF = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0x"}} +_IMG = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0K"}} + + +@pytest.mark.asyncio +async def test_a_summarized_request_keeps_its_attachments(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.messages = [{"role": "user", "content": [_PDF, _IMG, {"type": "text", "text": "계약서를 검토해 줘"}]}] + for i in range(10): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "x" * 50}]}, + ] + compactor = _llm_compactor(keep_recent=2) + await compactor.compact(state) + head = state.messages[0]["content"] + assert isinstance(head, list) and _PDF in head and _IMG in head + # and again when the next compaction folds this checkpoint + for i in range(10, 20): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "x" * 50}]}, + ] + await compactor.compact(state) + head = state.messages[0]["content"] + assert _PDF in head and "계약서를 검토해 줘" in head[0]["text"] + + +def test_cost_prune_keeps_the_current_requests_image_after_many_rounds(): + from xgen_agent_runtime.core.context_prune import prune_messages, strip_current_attachments + + messages = [{"role": "user", "content": [_IMG, {"type": "text", "text": "이 그림을 설명해 줘"}]}] + for i in range(8): + messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "ok"}]}, + ] + prune_messages(messages) + prune_messages(messages, strip_documents=True, protect_last=2) + assert messages[0]["content"][0] == _IMG + assert strip_current_attachments(messages) == 1 # the very last resort removes it + + +@pytest.mark.asyncio +async def test_hard_pressure_summarizes_before_it_strips_the_requests_pdf(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.context_window_budget = 32_000 + state.max_tokens = 4_096 + state.messages = [{"role": "user", "content": [_PDF, {"type": "text", "text": "요약해 줘"}]}] + for i in range(12): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "결과 " * 900}]}, + ] + await run_compaction(state, _llm_compactor(keep_recent=2), trigger="overflow", hard=True) + assert _PDF in state.messages[0]["content"] + + +@pytest.mark.asyncio +async def test_a_running_background_summary_blocks_soft_summaries_of_later_requests(): + from xgen_agent_runtime.core.compaction import BACKGROUND_TASK_ATTR + + class _Running: + def done(self): + return False + + state = PipelineState() + state.model = "fake" + client = _SummaryClient() + state.llm_client = client + state.context_window_budget = 8_000 + state.messages = _conversation(40, "long message " * 30) + setattr(state, BACKGROUND_TASK_ATTR, _Running()) + note_api_call(state) + out = await run_compaction(state, _llm_compactor(), trigger="proactive", target_tokens=10) + assert out.get("skipped") == "background_summary_running" and client.calls == 0 + + +@pytest.mark.asyncio +async def test_the_earlier_checkpoint_is_folded_whole_when_it_fits(): + state = PipelineState() + state.model = "fake" + client = _SummaryClient() + prompts = [] + real = client.create_message + + async def _capture(**kwargs): + prompts.append(kwargs["messages"][0]["content"]) + return await real(**kwargs) + + client.create_message = _capture + state.llm_client = client + state.context_window_budget = 32_000 + facts = "\n".join(f"- Fact {i}: scripts/deploy_{i}.sh must run with --region eu-west-{i}" for i in range(110)) + state.messages = [ + {"role": "user", "content": f"{CHECKPOINT_MARKER} earlier summary\n{facts}"}, + {"role": "assistant", "content": "Understood. I have the checkpoint summary and will continue from it."}, + ] + _conversation(20) + await _llm_compactor(keep_recent=2).compact(state) + assert all(f"Fact {i}:" in prompts[0] for i in range(110)) + + +def test_runtime_notes_are_not_the_users_request(): + from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import _is_real_user + from xgen_agent_runtime.stages.s16_loop.completion_review import _REVIEW_HEADER + from xgen_agent_runtime.stages.s16_loop.turn_budget import _SOFT_NOTE + + assert not _is_real_user({"role": "user", "content": _REVIEW_HEADER + "\nfile.csv: MISSING"}) + assert not _is_real_user({"role": "user", "content": _SOFT_NOTE.format(used=1, hard=2)}) + assert _is_real_user({"role": "user", "content": "[참고] 이 표를 정리해 줘"}) + + +@pytest.mark.asyncio +async def test_pins_of_a_replaced_conversation_are_not_carried(): + from xgen_agent_runtime.stages.s02_context.artifact.default.compactors import PINNED_USERS_KEY + + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.shared[PINNED_USERS_KEY] = ["OLD CONVERSATION SECRET"] + state.messages = _conversation(20) # a different conversation, no checkpoint at the head + await _llm_compactor().compact(state) + assert "OLD CONVERSATION SECRET" not in state.messages[0]["content"] + + +@pytest.mark.asyncio +async def test_no_model_wired_counts_as_a_failure(): + from xgen_agent_runtime.core.compaction import LLM_FAILURES_KEY + + state = PipelineState() + state.context_window_budget = 8_000 + state.messages = _conversation(40, "long message " * 30) + compactor = LLMSummaryCompactor(keep_recent=4) # no override, no state.model: no model + await run_compaction(state, compactor, trigger="proactive", target_tokens=10) + assert state.shared[LLM_FAILURES_KEY] == 1 + + +@pytest.mark.asyncio +async def test_kept_tail_never_opens_on_a_mixed_tool_result_message(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + state.messages = _conversation(10) + [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}, {"type": "text", "text": "host note"}]}, + {"role": "assistant", "content": "done"}, + ] + await _llm_compactor(keep_recent=2).compact(state) + for message in state.messages: + blocks = message["content"] if isinstance(message["content"], list) else [] + if any(b.get("type") == "tool_result" for b in blocks): + index = state.messages.index(message) + assert any(b.get("type") == "tool_use" for b in state.messages[index - 1]["content"]) + + +def test_fit_leaves_results_alone_when_it_cannot_close_the_gap_and_strips_result_images(): + from xgen_agent_runtime.core.compaction import _fit_tool_results + + state = PipelineState() + state.context_window_budget = 32_000 + state.max_tokens = 4_096 + state.messages = [{"role": "user", "content": "질문 " * 40_000}] + [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "결과 " * 2_000}]}, + ] + before = state.messages[-1]["content"][0]["content"] + assert _fit_tool_results(state, "guard", 10_000) == 0 # hopeless: the question alone is over + assert state.messages[-1]["content"][0]["content"] == before + + state.messages = [{"role": "user", "content": "질문"}] + for i in range(14): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"i{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"i{i}", "content": [_IMG, {"type": "text", "text": "a"}]}]}, + ] + state.shared.pop("_prompt_tokens_memo", None) + assert _fit_tool_results(state, "guard", 10_000) > 0 + assert estimate_prompt_tokens(state) <= 10_000 + + +def test_trimmed_results_keep_no_empty_text_parts(): + from xgen_agent_runtime.core.context_prune import shrink_tool_results + + block = {"type": "tool_result", "tool_use_id": "t", "content": [{"type": "text", "text": "a" * 2_000} for _ in range(4)]} + shrink_tool_results([{"role": "user", "content": [block]}], 0.1) + assert all(part.get("text") for part in block["content"]) + + +def test_estimate_sees_an_in_place_edit_of_the_newest_message(): + state = PipelineState() + state.messages = _conversation(3) + before = estimate_prompt_tokens(state) + state.messages[-1]["content"] = state.messages[-1]["content"] + " 추가된 긴 안내 " * 100 + assert estimate_prompt_tokens(state) > before + + +def test_guard_default_headroom_is_the_policys_answer_reserve(): + from xgen_agent_runtime.core.compaction import output_reserve + from xgen_agent_runtime.stages.s04_guard.artifact.default.guards import TokenBudgetGuard + + state = PipelineState() + state.context_window_budget = 32_000 + state.max_tokens = 8_192 + state.messages = [{"role": "user", "content": "가" * 30_000}] + result = TokenBudgetGuard().check(state) + assert not result.passed and f"need {output_reserve(32_000, 8_192)}" in result.message From 5285feea94a019fa67dbcf5a0feb2015d68c9ef5 Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Mon, 12 Oct 2026 13:20:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(compaction):=203=EC=B0=A8=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=80=ED=86=A0=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EC=A4=84=EC=9D=BC=20=EC=88=98=20=EC=97=86=EB=8A=94=20=EB=AA=AB?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EB=AC=B8=ED=84=B1,=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=20=EB=B2=94=EC=9C=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문턱과 목표를 압축이 줄일 수 없는 몫(시스템·도구·턴 맥락·지금 요청의 첨부)을 뺀 나머지 기준으로. 큰 PDF 를 붙인 턴이 요청마다 요약하던 문제(6회 → 0회) - 지금 요청의 범위를 런타임 안내를 빼고, 바로 앞에 따로 보낸 첨부와 파일 블록까지. 첨부만 보낸 요청도 요청 - 마지막 수단의 요청 첨부 삭제는 하나씩, 닫을 수 없는 차이면 하지 않음. 압축기 예외 뒤 덜어낸 만큼 인정 - 도구 결과 맞춤을 다시 재며 여러 번, 비워진 조각 제거를 선형으로 - 백그라운드 요약 표시를 shared 의 시각 값으로(복사·직렬화 가능), Stage 16 요청이 취소 뒤 바로 요약, 실패는 그 턴에만(이어 달리기 포함), 적용할 때 첨부가 든 체크포인트의 글도 기록 - guard 여유를 매 점검 상태에서 계산, CLI 턴의 숨긴 도구 목록 중복 계산 제거 - 메모리 노드 문맥은 API 는 시스템 프롬프트 끝, CLI 는 이번 턴 안내 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 31 ++-- src/xgen_agent_runtime/core/compaction.py | 133 +++++++++++++---- src/xgen_agent_runtime/core/context_prune.py | 107 ++++++++++---- src/xgen_agent_runtime/host/runner.py | 11 +- src/xgen_agent_runtime/host/turn_executor.py | 33 +++-- .../artifact/default/compactors.py | 53 ++++--- .../s02_context/artifact/default/stage.py | 49 ++++--- .../s04_guard/artifact/default/stage.py | 4 +- .../stages/s06_api/artifact/default/stage.py | 4 +- tests/unit/test_compaction_policy_489.py | 135 +++++++++++++++++- 10 files changed, 427 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff2fa60..fad8fff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ this project adheres to [Semantic Versioning](https://semver.org/). 2026-10-11 검토에서 압축이 너무 늦게 돌거나(한국어 과소 추정), 돌아도 대화를 잃거나(요약 실패 시 이력 삭제), provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾았다. Codex · Claude Code 의 방식(provider 가 센 -토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. 고친 뒤 두 차례 세 갈래 -(핵심 정책, 호스트와 클라이언트, 실제 파이프라인 시나리오)로 검토해 나온 문제도 이 판에서 고쳤다. +토큰을 기준으로 삼기, 넘침 시 압축 후 재시도, 사용자 말은 그대로 남기기)에 맞춰 고쳤다. 고친 뒤 세 차례 검토(두 번은 +핵심 정책 · 호스트와 클라이언트 · 실제 파이프라인 시나리오의 세 갈래, 마지막은 확인 검토)에서 나온 문제도 이 판에서 고쳤다. - **추정 하나** (`core.text_tokens`): 압축 판단과 호출 전 입력 맞춤이 같은 글자 부류 기반 추정을 쓴다. 예전 압축 쪽은 4자당 1토큰이라 한국어를 2~3배 적게 셌다. 요청에 붙는 것은 모두 센다(시스템 프롬프트, 도구, Stage 3 이 요청 사본에만 @@ -20,9 +20,11 @@ provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾 0.9~2.5 로 묶고, 기준점 추정은 보정된 단순 추정의 80% 아래로 내려가지 않는다. 메시지별 추정은 객체마다 기억해 긴 대화에서 반복마다 전부 다시 세지 않는다(긴 글 캐시는 LRU, 마지막 메시지의 제자리 수정도 알아챈다). - **입력 예산과 문턱** (`core.compaction`): 창에서 답 몫(`output_reserve`: max(4096, max_tokens + 2048), 창의 절반 - 이하)을 뺀 입력 예산의 80% 를 넘으면 50% 까지 줄인다. Stage 4 guard 도 같은 몫을 남기므로(`TokenBudgetGuard` 기본값 - 0 = 이 몫) 약한 압축이 늘 guard 보다 먼저 온다. 예전에는 창의 80% 기준이라 32k 창 · max_tokens 8192 에서 guard 가 - 68% 에서 먼저 터졌다. 결정적 정리(오래된 도구 결과 · 도구 호출 인자 · 이미지)를 먼저, 모자라면 모델 요약. + 이하)을 뺀 입력 예산을 기준으로, 압축이 줄일 수 없는 몫(시스템 프롬프트 · 도구 · 턴 맥락 · 지금 요청의 첨부, + `fixed_tokens`)을 뺀 나머지의 80% 를 넘으면 50% 까지 줄인다(`soft_level`). 큰 PDF 를 붙인 턴이 요약 직후에도 문턱을 + 넘어 요청마다 요약하던 일을 막는다. Stage 4 guard 도 같은 답 몫을 남기므로(`TokenBudgetGuard` 기본값 0 = 이 몫, 매 + 점검마다 상태에서 계산) 약한 압축이 늘 guard 보다 먼저 온다. 예전에는 창의 80% 기준이라 32k 창 · max_tokens 8192 에서 + guard 가 68% 에서 먼저 터졌다. 결정적 정리(오래된 도구 결과 · 도구 호출 인자 · 이미지)를 먼저, 모자라면 모델 요약. 지금 답하는 요청에 붙은 이미지와 PDF 는 정리에서 지우지 않는다. - **요약 횟수**: 약한 압박의 요약 시도는 요청 하나에 한 번, 턴당 6번이고 실패한 시도와 모델이 없는 경우도 센다. 두 번 실패하면 그 턴의 약한 압박은 모델을 더 부르지 않는다. 백그라운드 요약이 도는 동안에는 약한 압박이 같은 이력을 다시 @@ -30,21 +32,23 @@ provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾 Stage 16 의 압축 요청도 이 규칙을 따른다(사람이 거는 `/compact` 만 예외). 턴 계수는 `begin_turn` 이 비운다. - **강한 압박** (guard · 넘침): 요약을 먼저(실패하면 결정적 다이제스트) 하고, 그래도 입력 예산을 넘을 때만 손실을 싼 것부터 낸다. 공격적 정리(오래된 이미지 · PDF, 오래된 결과 짧게), 남은 도구 결과(결과 속 이미지 먼저, 그다음 글을 - 비율대로), 마지막으로 지금 요청의 첨부. 모두 해도 안 들어가면 결과를 그대로 두고 guard 가 거절한다. + 비율대로 다시 재며), 마지막으로 지금 요청의 첨부를 하나씩. 어떤 단계든 그것으로 닫을 수 없는 차이면 건드리지 않고 + guard 가 거절한다. 압축기가 예외를 내도 덜어 낸 만큼은 인정해 guard 가 다시 보고 Stage 6 이 다시 보낸다. - **넘침 복구** (Stage 6): provider 가 창을 넘었다고 거절하면(OpenAI · vLLM · Gemini(AI Studio · Vertex) 400 도 이제 `TOKEN_LIMIT`) 강하게 압축하고 한 번 다시 보낸다. 오류에 적힌 실제 한도가 믿던 창보다 작으면 그 턴의 창을 그 값으로 바꾼다. 메시지가 이미 작고 답 몫 때문에 넘친 경우(vLLM "100 in the messages, 8192 in the completion")는 압축하지 않는다. 숫자를 적지 않는 넘침 문구에는 창 교정 없이 압축과 재시도만 한다. - **요약 체크포인트** (`LLMSummaryCompactor`): 앞부분을 `[Context checkpoint]` 메시지 하나로 바꾼다. 요약은 사용자 요청 · 한 일 · 결정 · 문제 · 남은 일 · 현재 상태 여섯 항목이고, 도구 호출과 결과까지 읽는다(예전에는 글 블록만, 그것도 - 앞 12,000자만). 지금 하던 요청은 원문 그대로(압축 목표의 절반까지) 첨부(이미지 · PDF)와 함께 붙고, 같은 턴의 두 번째 - 압축에서도 따로 붙는다. 그 밖의 사용자 말은 창의 8% 안에서 그대로 붙인다. 런타임이 넣는 안내(산출물 대조, 턴 예산)는 + 앞 12,000자만). 지금 하던 요청은 원문 그대로(압축 목표의 절반까지) 첨부(이미지 · PDF · 파일, 바로 앞에 따로 보낸 + 첨부 포함)와 함께 붙고, 같은 턴의 두 번째 압축에서도 따로 붙는다. 첨부만 보낸 요청도 요청이다. 그 밖의 사용자 말은 창의 8% 안에서 그대로 붙인다. 런타임이 넣는 안내(산출물 대조, 턴 예산)는 사용자 말로 보지 않고, 다른 대화로 바뀐 상태에서는 예전 고정 말을 가져오지 않는다. 이전 체크포인트는 토큰 기준으로 읽혀 새 요약에 접힌다. 약한 압박에서 요약이 실패하면 이력을 그대로 둔다(예전에는 지웠다). 요약 요청 자체가 너무 길다고 거절되면 provider 수치로 대본을 줄여 다시 보내고 그 비율을 턴의 보정에 반영한다. 기준점이 없을 때(턴 첫 호출)는 대본을 창의 40% 로 잡는다. -- **백그라운드 요약** (상태를 유지하는 호스트): 도는 동안 표시를 남겨 같은 이력을 다시 요약하지 않고, 결과를 적용할 때 - 고정한 사용자 말도 함께 옮긴다. 실패는 그 요약을 건 턴에만 센다. +- **백그라운드 요약** (상태를 유지하는 호스트): 도는 동안 표시(`state.shared` 의 시각 값, 복사 · 직렬화 가능)를 남겨 같은 + 이력을 다시 요약하지 않고, 결과를 적용할 때 고정한 사용자 말도 함께 옮긴다. 실패는 그 요약을 건 턴에만 센다(이어 + 달리기 조각 포함). Stage 16 의 압축 요청은 도는 요약을 취소하고 바로 요약한다. - **기억 기록**: 기록되기 전에 요약으로 빠진 메시지를 STM 기록 · 대화 아카이브가 먼저 기록한다 (`memory.compacted_unrecorded`, 기록기마다 최대 300개). 워터마크가 없던 기록기는 0 으로 본다. - **도구 결과 상한**: 도구별 상한(기본 10만 자, 넘으면 4.88 과 같이 저장 후 미리보기)과 별개로, 모델이 읽는 글 @@ -56,9 +60,10 @@ provider 의 "prompt too long" 에는 아무것도 하지 않는 문제를 찾 대화 때문에 이번 질문을 자르지 않는다. CLI 백엔드(claude_code · codex)도 CLI 창(claude_code 200k, `[1m]` 별칭은 1M, codex 272k, claude_code 는 CLI 의 답 몫과 도구 몫을 더 남김)으로 맞추고, 넘치면 가장 오래된 이력부터 뺀다. 도구는 실제로 보내는 것(core + 열린 도구)만 센다. "입력 일부를 생략" 안내는 사용자 입력(질문 · 참조)이 잘렸을 때만 나간다. -- **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 이번 턴 안내로 요청에 붙인다 - (`history_context`). 예전에는 버려져 최근 2개 메시지만 모델에 갔다. 시스템 프롬프트가 아닌 자리라 프롬프트 캐시와 - Claude Code 예열 프로세스를 깨지 않는다. 창의 25% 안으로 줄이고, 입력 맞춤에서 질문 · 참조보다 먼저 줄인다. +- **메모리 노드 문맥**: DB 메모리 노드가 `(최근 메시지, 이전 대화 문자열)` 로 보낸 둘째 칸을 붙인다(`history_context`). + 예전에는 버려져 최근 2개 메시지만 모델에 갔다. API 백엔드는 시스템 프롬프트 끝(옛 agent_xgen 과 같은 자리, 한 턴의 여러 + 호출에서 접두 캐시에 든다), CLI 백엔드는 이번 턴 안내(시스템 프롬프트가 argv 로 가서 매 턴 바뀌면 예열 프로세스를 못 + 쓴다). 창의 25% 안으로 줄이고, 입력 맞춤에서 질문 · 참조보다 먼저 줄인다. - **내장 메모리 주입**: 주입 상한(기본 10,000자)을 창의 10% 로 묶고, 입력 맞춤의 예약도 같은 값에서 계산한다. 자동 검색 층은 압축 기록(`compactions`)을 올리지 않고, 그만큼 조금 더 받아 온다. - **창 표**: Claude Opus 4.6 이후 · Sonnet 4.6 이후 · Haiku 5.5 · Fable · Mythos 1M, gpt-5 계열 272k(입력 한도), diff --git a/src/xgen_agent_runtime/core/compaction.py b/src/xgen_agent_runtime/core/compaction.py index ee92e2bf..452c637e 100644 --- a/src/xgen_agent_runtime/core/compaction.py +++ b/src/xgen_agent_runtime/core/compaction.py @@ -254,6 +254,44 @@ def input_budget_of(state: Any) -> int: return max(1, window - output_reserve(window, _int(getattr(state, "max_tokens", 0)))) +def fixed_tokens(state: Any) -> int: + """What no compaction can remove from the next request: the system prompt, tool schemas and turn + context, and the attachments of the request being answered (a checkpoint carries those too).""" + from xgen_agent_runtime.core.context_prune import ATTACHMENT_TYPES, current_request_span + from xgen_agent_runtime.core.token_estimate import ( + estimate_messages_scaled, + estimate_overhead_tokens, + ) + + messages = state.messages or [] + blocks = [ + b + for i in current_request_span(messages) + if isinstance(messages[i].get("content"), list) + for b in messages[i]["content"] + if isinstance(b, dict) and b.get("type") in ATTACHMENT_TYPES + ] + attached = ( + estimate_messages_scaled(state, [{"role": "user", "content": blocks}]) if blocks else 0 + ) + return estimate_overhead_tokens(state) + attached + + +def soft_level(state: Any, ratio: float) -> int: + """``ratio`` of the room compaction can actually work with: the fixed part (:func:`fixed_tokens`) + plus ``ratio`` of what the input budget leaves after it. + + The proactive trigger is ``soft_level(PROACTIVE_RATIO)`` and the target + ``soft_level(TARGET_RATIO)``. Measured on the whole budget, a large attached PDF kept every + request above the trigger right after a summary, so the turn summarized on every request + (2026-10-12 review: 6 summaries on 6 requests). With a small fixed part this is about the plain + share of the budget. + """ + budget = input_budget_of(state) + fixed = min(budget, fixed_tokens(state)) + return fixed + int((budget - fixed) * ratio) + + def _compactor_name(compactor: Any) -> str: return str(getattr(compactor, "name", None) or type(compactor).__name__) @@ -300,16 +338,22 @@ def _prune(state: PipelineState, *, aggressive: bool, trigger: str) -> Dict[str, return metrics -#: Attribute on the state holding a background summary task (Stage 2, hosts that keep a state across -#: turns). Not in ``state.shared``, which may be serialized. -BACKGROUND_TASK_ATTR = "_context_background_task" +#: ``state.shared`` slot: wall-clock time until which a background summary scheduled by Stage 2 +#: (hosts that keep a state across turns) counts as running. A plain number, so the state stays +#: copyable and picklable; Stage 2 clears it when it collects or cancels the summary, and it expires +#: on its own if the stage never comes back. +BACKGROUND_UNTIL_KEY = "_context_background_until" +#: How long a background summary may count as running at most. +BACKGROUND_MAX_SECONDS = 300.0 def background_summary_running(state: Any) -> bool: - """True while a background summary scheduled by Stage 2 has not finished.""" - task = getattr(state, BACKGROUND_TASK_ATTR, None) - done = getattr(task, "done", None) - return callable(done) and not done() + """True while a background summary scheduled by Stage 2 has not been collected.""" + import time + + shared = getattr(state, "shared", None) + until = shared.get(BACKGROUND_UNTIL_KEY) if isinstance(shared, dict) else None + return isinstance(until, (int, float)) and until > time.time() def _plan_reason( @@ -401,7 +445,7 @@ async def run_compaction( before_tokens = estimate_prompt_tokens(state) prune_may_suffice = target_tokens is not None and trigger != "manual" if target_tokens is None: - target_tokens = int(input_budget_of(state) * TARGET_RATIO) + target_tokens = soft_level(state, TARGET_RATIO) _prune(state, aggressive=False, trigger=trigger) after_prune = estimate_prompt_tokens(state) @@ -460,7 +504,20 @@ async def run_compaction( logger.warning("Compaction (%s) failed: %s", trigger, exc) if hard: _relieve_hard(state, trigger) - return {"ok": False, "before_messages": before_msgs, "after_messages": before_msgs} + now = estimate_prompt_tokens(state) + # The prune and the hard relief may still have made the request fit: report that, so the + # guard re-checks and Stage 6 retries instead of failing a request that now fits. + return { + "ok": now < before_tokens, + "before_messages": before_msgs, + "after_messages": len(state.messages or []), + "replaced": 0, + "saved_tokens_estimate": max(0, before_tokens - now), + "after_tokens_estimate": now, + "target_tokens": target_tokens, + "target_met": now <= target_tokens, + "summarized": False, + } finally: state.shared.pop(COMPACTION_CONTEXT_KEY, None) _count_attempt(state, compactor, request) @@ -627,13 +684,23 @@ def _relieve_hard(state: PipelineState, trigger: str) -> None: if estimate_prompt_tokens(state) <= limit: return _fit_tool_results(state, trigger, limit) - if estimate_prompt_tokens(state) <= limit: + estimate = estimate_prompt_tokens(state) + if estimate <= limit: return from xgen_agent_runtime.core.context_prune import strip_current_attachments - removed = strip_current_attachments(state.messages or []) - if removed: + # The request's own attachments, one at a time and only if removing them can close the gap: a + # request over by a few hundred tokens lost every image, and one that could not fit anyway lost + # them before the guard rejected it. + attached = fixed_tokens(state) - _overhead_only(state) + if attached <= 0 or estimate - attached > limit: + return + removed = 0 + while estimate > limit and strip_current_attachments(state.messages or [], limit=1): + removed += 1 state.shared.pop("_prompt_tokens_memo", None) + estimate = estimate_prompt_tokens(state) + if removed: state.add_event( "context.pruned", { @@ -649,6 +716,12 @@ def _relieve_hard(state: PipelineState, trigger: str) -> None: ) +def _overhead_only(state: PipelineState) -> int: + from xgen_agent_runtime.core.token_estimate import estimate_overhead_tokens + + return estimate_overhead_tokens(state) + + def _fit_tool_results(state: PipelineState, trigger: str, limit: int) -> int: """Shorten the tool results that are left (the newest ones, in the protected tail) so the request fits ``limit``: images inside results first, then text in proportion. One round of @@ -682,15 +755,24 @@ def _results() -> List[Any]: totals[key] += metrics.get(key, 0) if metrics["images_stripped"]: state.shared.pop("_prompt_tokens_memo", None) - estimate_mid = estimate_prompt_tokens(state) - if estimate_mid > limit: + # Text in proportion, re-measured: the cut is by characters, and a result whose head is denser + # than its tail (a Korean body with an ASCII footer) needs another pass. + for _ in range(3): + estimate_mid = estimate_prompt_tokens(state) + if estimate_mid <= limit: + break result_tokens = estimate_messages_scaled(state, [{"role": "user", "content": _results()}]) - if result_tokens > 0: - keep = max(0.02, (result_tokens - (estimate_mid - limit)) / result_tokens * 0.9) - if keep < 1.0: - metrics = shrink_tool_results(state.messages or [], keep) - for key in totals: - totals[key] += metrics.get(key, 0) + if result_tokens <= 0: + break + keep = max(0.02, (result_tokens - (estimate_mid - limit)) / result_tokens * 0.9) + if keep >= 1.0: + break + metrics = shrink_tool_results(state.messages or [], keep) + for key in totals: + totals[key] += metrics.get(key, 0) + state.shared.pop("_prompt_tokens_memo", None) + if not metrics.get("trimmed"): + break changed = totals["trimmed"] + totals["images_stripped"] if changed: state.shared.pop("_prompt_tokens_memo", None) @@ -747,18 +829,17 @@ async def relieve_if_over( ratio: float = PROACTIVE_RATIO, ) -> Optional[dict]: """Proactive check shared by Stage 2, Stage 4 and the internal tool loop: compact when the - estimate crosses ``ratio`` of the input budget, down to :data:`TARGET_RATIO` of it.""" + estimate crosses :func:`soft_level` at ``ratio``, down to the target level.""" if compactor is None: return None - budget = input_budget_of(state) - if estimate_prompt_tokens(state) <= budget * ratio: + if estimate_prompt_tokens(state) <= soft_level(state, ratio): return None return await run_compaction( state, compactor, trigger=trigger, provider=provider, - target_tokens=int(budget * TARGET_RATIO), + target_tokens=soft_level(state, TARGET_RATIO), ) @@ -770,8 +851,10 @@ async def relieve_if_over( "LLM_FAILURES_KEY", "output_reserve", "input_budget_of", - "BACKGROUND_TASK_ATTR", + "BACKGROUND_UNTIL_KEY", "background_summary_running", + "fixed_tokens", + "soft_level", "RECORDED_INDEX_KEYS", "PENDING_RECORDS_KEY", "RECORDER_STM", diff --git a/src/xgen_agent_runtime/core/context_prune.py b/src/xgen_agent_runtime/core/context_prune.py index d7fbf523..a4dcaa92 100644 --- a/src/xgen_agent_runtime/core/context_prune.py +++ b/src/xgen_agent_runtime/core/context_prune.py @@ -221,10 +221,10 @@ def _trim_result(block: Dict[str, Any], keep: int) -> int: # Drop the text parts the trim emptied (providers reject empty text blocks), keeping # every non-text part and at least one text part for the marker. emptied = [p for p in _result_text_parts(block) if not p.get("text")] - if emptied and len(emptied) < len(_result_text_parts(block)): - block["content"] = [b for b in content if not any(b is e for e in emptied)] - elif emptied: - block["content"] = [b for b in content if not any(b is e for e in emptied[1:])] + if emptied: + keep_one = len(emptied) == len(_result_text_parts(block)) + drop = {id(e) for e in (emptied[1:] if keep_one else emptied)} + block["content"] = [b for b in content if id(b) not in drop] parts = _result_text_parts(block) if parts: parts[-1]["text"] += f"\n… [{dropped} chars trimmed during context compaction]" @@ -261,34 +261,87 @@ def shrink_tool_results( return {"trimmed": trimmed, "chars_saved": saved, "images_stripped": images} +#: User-role messages the runtime itself adds (Stage 16's deliverable check, the turn input budget +#: note): instructions to the model, never the person's request. Shared with the summary compactor. +RUNTIME_NOTE_PREFIXES = ("[Deliverable check", "[Turn budget") +#: Content blocks that are attachments rather than words. +ATTACHMENT_TYPES = ("image", "document", "image_url", "input_image", "file") + + +def _message_text(message: Dict[str, Any]) -> str: + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join( + str(b.get("text") or "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ) + return "" + + +def is_runtime_note(message: Any) -> bool: + return isinstance(message, dict) and _message_text(message).lstrip().startswith( + RUNTIME_NOTE_PREFIXES + ) + + +def _is_request_message(message: Any) -> bool: + if not isinstance(message, dict) or message.get("role") != "user" or is_runtime_note(message): + return False + content = message.get("content") + return not ( + isinstance(content, list) + and content + and all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content) + ) + + def current_request_index(messages: List[Dict[str, Any]]) -> int: - """Index of the newest user message that is not only tool results (the request being answered, - or a checkpoint carrying it), or -1.""" + """Index of the newest user message that is the person's request (not only tool results, not a + runtime note; a checkpoint carrying the request counts), or -1.""" for i in range(len(messages or []) - 1, -1, -1): - msg = messages[i] - if not isinstance(msg, dict) or msg.get("role") != "user": - continue - content = msg.get("content") - if ( - isinstance(content, list) - and content - and all(isinstance(b, dict) and b.get("type") == "tool_result" for b in content) - ): - continue - return i + if _is_request_message(messages[i]): + return i return -1 -def strip_current_attachments(messages: List[Dict[str, Any]]) -> int: - """Remove the images and documents of the request being answered. The very last resort under - hard pressure, after everything else; returns how many were removed.""" +def current_request_span(messages: List[Dict[str, Any]]) -> List[int]: + """The request being answered: :func:`current_request_index` and the user messages right before + it with no reply in between (a file sent first, the question in the next message).""" idx = current_request_index(messages) if idx < 0: - return 0 - content = messages[idx].get("content") - if not isinstance(content, list): - return 0 - return _strip_images_in(content, documents=True) + return [] + span = [idx] + j = idx - 1 + while j >= 0 and _is_request_message(messages[j]): + span.insert(0, j) + j -= 1 + return span + + +def strip_current_attachments(messages: List[Dict[str, Any]], *, limit: int = 0) -> int: + """Remove images and documents of the request being answered, at most ``limit`` (0 = all). The + very last resort under hard pressure; returns how many were removed.""" + removed = 0 + for idx in reversed(current_request_span(messages)): + content = messages[idx].get("content") + if not isinstance(content, list): + continue + for i, b in enumerate(content): + if limit and removed >= limit: + return removed + if not isinstance(b, dict) or b.get("type") not in ("image", "document"): + continue + if not (isinstance(b.get("source"), dict) and b["source"].get("type") == "base64"): + continue + content[i] = { + "type": "text", + "text": _IMAGE_NOTE if b["type"] == "image" else _DOCUMENT_NOTE, + } + removed += 1 + return removed def prune_messages( @@ -345,13 +398,13 @@ def prune_messages( seen.add(digest) # Pass 2 — stale-region cleanup: image stripping + oversize trimming. - current = current_request_index(messages) if protect_current_request else -1 + current = set(current_request_span(messages)) if protect_current_request else set() for mi in range(cutoff): msg = messages[mi] if not isinstance(msg, dict): continue content = msg.get("content") - if isinstance(content, list) and mi != current: + if isinstance(content, list) and mi not in current: metrics["images_stripped"] += _strip_images_in(content, documents=strip_documents) if msg.get("role") == "assistant": saved_inputs = _trim_tool_inputs(msg, input_trim_over_chars, input_trim_keep_chars) diff --git a/src/xgen_agent_runtime/host/runner.py b/src/xgen_agent_runtime/host/runner.py index e3596349..ed738df6 100644 --- a/src/xgen_agent_runtime/host/runner.py +++ b/src/xgen_agent_runtime/host/runner.py @@ -671,13 +671,10 @@ def build_pipeline( TokenBudgetGuard, ) - from xgen_agent_runtime.core.compaction import output_reserve - - # The same reserve the compaction policy measures its input budget against, so the 80% - # trigger always comes before the guard (core.compaction.output_reserve). - # An unknown window is the policy's default (core.compaction.window_of), not "no cap". - headroom = output_reserve(int(context_window_budget or 0) or 200_000, int(max_tokens)) - builder.with_guard(guards=[TokenBudgetGuard(min_remaining_tokens=headroom)]) + # The guard's default headroom (0) is the reserve the compaction policy measures its input + # budget against (core.compaction.output_reserve), computed from the state at every check: + # the 80% trigger always comes before the guard, also after an overflow lowered the window. + builder.with_guard(guards=[TokenBudgetGuard()]) if memory_provider is not None: # ⚠ 스테이지 등록이 선행 조건 — attach_runtime 의 슬롯 배선은 스테이지가 diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index 24131960..8a987d00 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -1014,10 +1014,9 @@ def run_dir_cleanup(_d: str = run_dir) -> None: vllm_probe=host.fetch_vllm_max_model_len, ) # 메모리 노드가 (최근 메시지, 이전 대화 문자열) 로 보낸 둘째 칸: 최근 N개(기본 2개)보다 - # 앞선 대화다. 이번 턴 안내(턴 노트)로 요청 사본에 붙인다(아래 맞춤 뒤). 옛 agent_xgen 은 - # 시스템 프롬프트 끝에 붙였는데, 그 자리는 매 턴 바뀌어 프롬프트 캐시와 Claude Code 예열 - # 프로세스를 매번 깬다. 노드 기본값이 10만 자라 먼저 창의 일정 몫 안으로 줄이고, 입력 맞춤이 - # 남는 자리에 맞춰 한 번 더 줄인다(이번 질문·RAG 보다 먼저, 가장 최근 쪽이 남는다). + # 앞선 대화다. 아래 맞춤 뒤에 API 백엔드는 시스템 프롬프트 끝에(옛 agent_xgen 과 같다), CLI + # 백엔드는 이번 턴 안내로 붙인다. 노드 기본값이 10만 자라 먼저 창의 일정 몫 안으로 줄이고, + # 입력 맞춤이 남는 자리에 맞춰 한 번 더 줄인다(이번 질문·RAG 보다 먼저, 가장 최근 쪽이 남는다). _history_ctx = history_context(kwargs.get("memory")) if _history_ctx: _history_ctx = fit_history_context(_history_ctx, budget_window, provider, model) @@ -1034,17 +1033,17 @@ def run_dir_cleanup(_d: str = run_dir) -> None: if provider == "claude_code": # CLI 는 자기 도구 스키마를 더하고 답 몫을 따로 잡는다(관측: maxOutputTokens 32,000). _late_reserve += 12_000 + max(0, 32_000 - max_tokens_val) - # Stage 3 이 맞춤 뒤에 요청에 붙이는 글: 숨긴 도구 목록과 이번 턴 안내. - _extra_overhead = "\n\n".join( - str(n) for n in (state.shared.get(SharedKeys.TURN_NOTES) or []) - ) - if _tools_reach_model and registry is not None: + # Stage 3 이 맞춤 뒤에 요청에 붙이는 글: 이번 턴 안내와(SDK 경로) 숨긴 도구 목록. CLI 경로는 + # 목록을 위에서 이미 시스템 프롬프트에 붙였다. + _late_parts = [str(n) for n in (state.shared.get(SharedKeys.TURN_NOTES) or [])] + if _tools_reach_model and registry is not None and not _is_cli: try: from xgen_agent_runtime.tools.catalog import deferred_catalog_text - _extra_overhead = _extra_overhead + "\n\n" + deferred_catalog_text(registry) + _late_parts.append(deferred_catalog_text(registry)) except Exception: # noqa: BLE001 (an estimate input, never a gate) pass + _extra_overhead = "\n\n".join(p for p in _late_parts if p) if enable_compaction and budget_window > 0: def _fit(_text: str): @@ -1100,13 +1099,25 @@ def _fit(_text: str): fit.total_before, ) - if _history_ctx: + if _history_ctx and _is_cli: + # CLI: 시스템 프롬프트는 argv(또는 파일)로 가서 매 턴 바뀌면 예열 프로세스를 못 쓴다. + # 이번 턴 안내로 요청 사본에 붙인다. _notes = [str(n) for n in (state.shared.get(SharedKeys.TURN_NOTES) or [])] state.shared[SharedKeys.TURN_NOTES] = _notes + [_history_ctx] logger.info( "agents/geny: 메모리 노드의 이전 대화 %d자를 이번 턴 안내로 붙임", len(_history_ctx), ) + elif _history_ctx: + # API: 시스템 프롬프트 끝(옛 agent_xgen 과 같은 자리). 한 턴의 여러 호출에서 접두 캐시에 + # 들어간다(요청 사본의 턴 안내는 매 호출 다시 읽힌다). + system_prompt = ( + (system_prompt + "\n\n" + _history_ctx) if system_prompt else _history_ctx + ) + logger.info( + "agents/geny: 메모리 노드의 이전 대화 %d자를 시스템 프롬프트에 붙임", + len(_history_ctx), + ) if _fast_path is not None and _fast_path.active: register_snapshot_witnesses(state, _fast_path) diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py index 567930c7..27a19d45 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/compactors.py @@ -8,6 +8,13 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from xgen_agent_runtime.core.config import ModelConfig + +# User-role messages the runtime itself adds, and which blocks are attachments: one definition, +# shared with the prune. +from xgen_agent_runtime.core.context_prune import ( + ATTACHMENT_TYPES as _ATTACHMENT_TYPES, + RUNTIME_NOTE_PREFIXES as _RUNTIME_NOTE_PREFIXES, +) from xgen_agent_runtime.core.message_repair import strip_leading_orphan_tool_results from xgen_agent_runtime.core.schema import ConfigField, ConfigSchema from xgen_agent_runtime.core.state import PipelineState @@ -255,26 +262,20 @@ def _is_ack(message: Any) -> bool: ) -#: User-role messages the runtime itself adds (Stage 16's deliverable check, the turn input budget -#: note). They are instructions to the model, not something the person asked. -_RUNTIME_NOTE_PREFIXES = ("[Deliverable check", "[Turn budget") -#: Content blocks that are attachments rather than words. -_ATTACHMENT_TYPES = ("image", "document", "image_url", "input_image") - - def _is_runtime_note(message: Any) -> bool: return _message_text(message).lstrip().startswith(_RUNTIME_NOTE_PREFIXES) def _is_real_user(message: Any) -> bool: - """A user message a person wrote (not a tool result, a checkpoint, or a runtime note).""" + """A user message a person sent (not a tool result, a checkpoint, or a runtime note): words, or + attachments alone (an image-only request is still the request).""" return ( isinstance(message, dict) and message.get("role") == "user" and not _is_tool_result_only(message) and not _is_checkpoint(message) and not _is_runtime_note(message) - and bool(_message_text(message).strip()) + and (bool(_message_text(message).strip()) or bool(_attachments(message))) ) @@ -352,6 +353,10 @@ def _render_block(role: str, block: Dict[str, Any]) -> List[str]: return [f"{role}: [image]"] if btype == "document": return [f"{role}: [document]"] + if btype == "file": + where = block.get("workspace_path") or block.get("path") or block.get("url") or "" + name = block.get("name") or "file" + return [f"{role}: [file {name}{f' at {where}' if where else ''}]"] return [] @@ -456,7 +461,7 @@ def plan( self, state: PipelineState, target_tokens: Optional[int] = None ) -> Optional[CompactionPlan]: """Where to cut, what to pin. ``None`` when there is nothing to summarize.""" - from xgen_agent_runtime.core.compaction import TARGET_RATIO, input_budget_of, window_of + from xgen_agent_runtime.core.compaction import TARGET_RATIO, soft_level, window_of from xgen_agent_runtime.core.token_estimate import ( estimate_messages_scaled, estimate_overhead_tokens, @@ -468,7 +473,7 @@ def plan( if n <= 1: return None window = window_of(state) - target = int(target_tokens or input_budget_of(state) * TARGET_RATIO) + target = int(target_tokens or soft_level(state, TARGET_RATIO)) # Pins carried from an earlier checkpoint are valid only while that checkpoint still heads # the history: a host that keeps the state object but swaps in another conversation must @@ -527,17 +532,21 @@ def plan( if not new_material: return None # only an earlier checkpoint would be summarized again - last_real = next((m for m in reversed(msgs) if _is_real_user(m)), None) + last_index = next((i for i in range(n - 1, -1, -1) if _is_real_user(msgs[i])), -1) + last_real = msgs[last_index] if last_index >= 0 else None attachments: List[Dict[str, Any]] = [] if last_real is not None: - # The newest thing the user wrote is the request being worked on; it is shown on its own - # (with its attachments) when it is summarized, and stays verbatim in the kept tail - # otherwise. A request carried from an earlier compaction is now an earlier message. - if any(last_real is r for r in region): - current = _message_text(last_real).strip() - attachments = _attachments(last_real) - else: - current = "" + # The newest thing the user sent is the request being worked on; it is shown on its own + # when it is summarized, and stays verbatim in the kept tail otherwise. A request carried + # from an earlier compaction is now an earlier message. + current = _message_text(last_real).strip() if last_index < cut else "" + # Its attachments, and those of the user messages sent right before it (a file first, + # the question in the next message), whenever the message holding them is summarized. + j = last_index + while j >= 0 and _is_real_user(msgs[j]): + if j < cut: + attachments = _attachments(msgs[j]) + attachments + j -= 1 if carried_current: carried.append(carried_current) else: @@ -547,7 +556,9 @@ def plan( if has_checkpoint: attachments = _attachments(msgs[0]) - candidates = carried + [_message_text(m).strip() for m in new_material if _is_real_user(m)] + candidates = carried + [ + text for m in new_material if _is_real_user(m) and (text := _message_text(m).strip()) + ] if current and candidates and candidates[-1] == current: candidates = candidates[:-1] # shown separately as the request being worked on if current: diff --git a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py index 2ada0f57..19bf790a 100644 --- a/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s02_context/artifact/default/stage.py @@ -8,15 +8,18 @@ from xgen_agent_runtime.core.compaction import ( API_CALLS_KEY, + BACKGROUND_MAX_SECONDS, + BACKGROUND_UNTIL_KEY, PROACTIVE_RATIO, SUMMARIZED_AT_KEY, TARGET_RATIO, - input_budget_of, reconcile_recorded_index, run_compaction, + soft_level, summarized_for_this_call, window_of, ) + from xgen_agent_runtime.core.schema import ConfigField, ConfigSchema from xgen_agent_runtime.core.shared_keys import SharedKeys from xgen_agent_runtime.core.slot import StrategySlot @@ -56,6 +59,10 @@ logger = logging.getLogger(__name__) +#: ``state.shared`` counter of turns this state has run (advanced at each turn's first pass, not on a +#: continuation slice): a background summary's failure is booked only in the turn that scheduled it. +_TURN_SEQ_KEY = "_context_turn_seq" + #: ``state.shared`` keys the background summary reads, and (for the pinned/current ones) writes back. _SHADOW_SHARED_KEYS = ( "_prompt_tokens_calibration", @@ -456,6 +463,7 @@ async def execute(self, input: Any, state: PipelineState) -> Any: if state.iteration == 0 and not state._is_continuation_slice: state.metadata.pop("memory_context", None) state.metadata.pop("memory_pinned", None) + state.shared[_TURN_SEQ_KEY] = int(state.shared.get(_TURN_SEQ_KEY) or 0) + 1 # TTFT program (finding B2): retrieval results are only injected # into the prompt at iteration 0 (below) — later tool-loop @@ -553,12 +561,12 @@ async def execute(self, input: Any, state: PipelineState) -> Any: if await self._apply_bg_compaction(state): state.shared.pop("_prompt_tokens_memo", None) estimated_tokens = estimate_prompt_tokens(state) - budget = input_budget_of(state) if forced_request: # A Stage-16 token dimension (or /compact) requested maintenance for pending # work. Run synchronously: deferring again would let the next Stage-4/API boundary # hit the hard ceiling. self.cancel_bg_compaction() + state.shared.pop(BACKGROUND_UNTIL_KEY, None) manual = ( isinstance(forced_request, dict) and forced_request.get("source") == "manual" ) @@ -567,15 +575,15 @@ async def execute(self, input: Any, state: PipelineState) -> Any: self._compactor, trigger="manual" if manual else "requested", provider=self._provider, - target_tokens=int(budget * TARGET_RATIO), + target_tokens=soft_level(state, TARGET_RATIO), ) state.shared.pop("_prompt_tokens_memo", None) estimated_tokens = estimate_prompt_tokens(state) - elif estimated_tokens > budget * PROACTIVE_RATIO: + elif estimated_tokens > soft_level(state, PROACTIVE_RATIO): defer_to_background = ( self._background_compaction and isinstance(self._compactor, LLMSummaryCompactor) - and estimated_tokens <= budget * 0.9 + and estimated_tokens <= soft_level(state, 0.9) and not summarized_for_this_call(state) ) if defer_to_background: @@ -586,7 +594,7 @@ async def execute(self, input: Any, state: PipelineState) -> Any: self._compactor, trigger="proactive", provider=self._provider, - target_tokens=int(budget * TARGET_RATIO), + target_tokens=soft_level(state, TARGET_RATIO), ) estimated_tokens = estimate_prompt_tokens(state) elif forced_request: @@ -642,7 +650,7 @@ def _schedule_bg_compaction(self, state: PipelineState) -> None: return from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, _llm_gate - target = int(input_budget_of(state) * TARGET_RATIO) + target = soft_level(state, TARGET_RATIO) # Same policy as the synchronous path: turn limit, once per request, region worth a call. skip = _llm_gate( state, self._compactor, trigger="background", target_tokens=target, hard=False @@ -686,14 +694,14 @@ async def _run() -> _CompactionShadow: "task": task, "len": snapshot_len, "message_ids": snapshot_ids, - # Which run scheduled it: a failure collected in a later turn belongs to that turn. - "run": getattr(state, "_run_count", 0), + # Which turn scheduled it: a failure collected in a later turn belongs to that turn. + "turn": state.shared.get(_TURN_SEQ_KEY), } # Every soft check (this stage, Stage 4, the internal loop) sees the summary in flight and # does not make a second, synchronous one of the same history (core.compaction gate). - from xgen_agent_runtime.core.compaction import BACKGROUND_TASK_ATTR + import time - setattr(state, BACKGROUND_TASK_ATTR, task) + state.shared[BACKGROUND_UNTIL_KEY] = time.time() + BACKGROUND_MAX_SECONDS # This request's summary is in flight: Stage 4's proactive check must not summarize the # same request synchronously (it did, and the background result was then discarded). The # attempt counts against the turn's limit now, whether or not its result is ever applied. @@ -720,14 +728,9 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: if not task.done(): return False self._bg_compaction = None - from xgen_agent_runtime.core.compaction import ( - BACKGROUND_TASK_ATTR, - COMPACTION_CONTEXT_KEY, - LLM_FAILURES_KEY, - ) + from xgen_agent_runtime.core.compaction import COMPACTION_CONTEXT_KEY, LLM_FAILURES_KEY - if getattr(state, BACKGROUND_TASK_ATTR, None) is task: - setattr(state, BACKGROUND_TASK_ATTR, None) + state.shared.pop(BACKGROUND_UNTIL_KEY, None) if task.cancelled() or task.exception() is not None: return False # already logged by the done-callback shadow: _CompactionShadow = task.result() @@ -737,8 +740,8 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: # A background summary that called the model and came back empty is a failed attempt, like # a synchronous one, but only for the turn that scheduled it (a new turn starts clean). report = shadow.shared.get(COMPACTION_CONTEXT_KEY) or {} - if int(report.get("model_calls") or 0) > 0 and info.get("run") == getattr( - state, "_run_count", 0 + if int(report.get("model_calls") or 0) > 0 and info.get("turn") == state.shared.get( + _TURN_SEQ_KEY ): if report.get("source") == "model": state.shared[LLM_FAILURES_KEY] = 0 @@ -802,6 +805,12 @@ async def _apply_bg_compaction(self, state: PipelineState) -> bool: summary_head = "" if state.messages and isinstance(state.messages[0], dict): head_content = state.messages[0].get("content", "") + if isinstance(head_content, list): # a checkpoint carrying attachments + head_content = "\n".join( + str(b.get("text") or "") + for b in head_content + if isinstance(b, dict) and b.get("type") == "text" + ) summary_head = head_content if isinstance(head_content, str) else "" try: await self._provider.record_compaction( diff --git a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py index 99aa3997..097e6363 100644 --- a/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s04_guard/artifact/default/stage.py @@ -279,7 +279,7 @@ async def _recover_budget(self, state: PipelineState, result: GuardResult) -> bo ) # Lazy import: keeps the guard stage import-light and avoids a # core→stage cycle at module load. - from xgen_agent_runtime.core.compaction import TARGET_RATIO, input_budget_of, run_compaction + from xgen_agent_runtime.core.compaction import TARGET_RATIO, run_compaction, soft_level outcome = await run_compaction( state, @@ -287,7 +287,7 @@ async def _recover_budget(self, state: PipelineState, result: GuardResult) -> bo trigger="guard", provider=self._memory_provider, # A prune that already lands here is enough; otherwise summarize down to it. - target_tokens=int(input_budget_of(state) * TARGET_RATIO), + target_tokens=soft_level(state, TARGET_RATIO), # The request cannot go out as it is: aggressive prune, no small-region skip, and a # deterministic digest if the model summary fails. hard=True, diff --git a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py index 7b8f516d..ceb87aab 100644 --- a/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py +++ b/src/xgen_agent_runtime/stages/s06_api/artifact/default/stage.py @@ -610,8 +610,8 @@ async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool return False from xgen_agent_runtime.core.compaction import ( TARGET_RATIO, - input_budget_of, run_compaction, + soft_level, window_of, ) from xgen_agent_runtime.core.errors import parse_overflow_limit, parse_overflow_tokens @@ -642,7 +642,7 @@ async def _recover_overflow(self, state: PipelineState, error: APIError) -> bool "provider_limit": limit, }, ) - if 0 < reported <= int(input_budget_of(state) * TARGET_RATIO): + if 0 < reported <= soft_level(state, TARGET_RATIO): # The messages are already small: the request was too long because of the room asked # for the answer (vLLM counts max_tokens: "100 in the messages, 8192 in the completion"). # Compacting cannot fix that, so do not pay for a summary and a doomed retry. diff --git a/tests/unit/test_compaction_policy_489.py b/tests/unit/test_compaction_policy_489.py index 20fe44ce..662d07b7 100644 --- a/tests/unit/test_compaction_policy_489.py +++ b/tests/unit/test_compaction_policy_489.py @@ -955,11 +955,11 @@ async def test_hard_pressure_summarizes_before_it_strips_the_requests_pdf(): @pytest.mark.asyncio async def test_a_running_background_summary_blocks_soft_summaries_of_later_requests(): - from xgen_agent_runtime.core.compaction import BACKGROUND_TASK_ATTR + import copy + import pickle + import time - class _Running: - def done(self): - return False + from xgen_agent_runtime.core.compaction import BACKGROUND_UNTIL_KEY state = PipelineState() state.model = "fake" @@ -967,10 +967,13 @@ def done(self): state.llm_client = client state.context_window_budget = 8_000 state.messages = _conversation(40, "long message " * 30) - setattr(state, BACKGROUND_TASK_ATTR, _Running()) + state.shared[BACKGROUND_UNTIL_KEY] = time.time() + 60 note_api_call(state) out = await run_compaction(state, _llm_compactor(), trigger="proactive", target_tokens=10) assert out.get("skipped") == "background_summary_running" and client.calls == 0 + # The marker is a plain number: the state can still be copied and pickled. + copy.deepcopy(state.shared) + pickle.dumps(state.shared) @pytest.mark.asyncio @@ -1101,3 +1104,125 @@ def test_guard_default_headroom_is_the_policys_answer_reserve(): state.messages = [{"role": "user", "content": "가" * 30_000}] result = TokenBudgetGuard().check(state) assert not result.passed and f"need {output_reserve(32_000, 8_192)}" in result.message + + +# ── review round 3 (2026-10-12) ───────────────────────────────────── + + +def test_thresholds_leave_room_above_a_large_attachment(): + from xgen_agent_runtime.core.compaction import ( + PROACTIVE_RATIO, + TARGET_RATIO, + input_budget_of, + soft_level, + ) + + pdf = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0x" + "/Type /Page " * 8}} + state = PipelineState() + state.context_window_budget = 64_000 + state.max_tokens = 4_096 + state.messages = [{"role": "user", "content": [pdf, {"type": "text", "text": "요약해 줘"}]}] + budget = input_budget_of(state) + trigger, target = soft_level(state, PROACTIVE_RATIO), soft_level(state, TARGET_RATIO) + # The trigger moves above the PDF, so a summary down to the target leaves room before the next. + assert trigger > budget * PROACTIVE_RATIO and target > budget * TARGET_RATIO + assert target < trigger <= budget + + +@pytest.mark.asyncio +async def test_a_stage_16_request_cancels_the_background_summary_and_runs(): + import time + + from xgen_agent_runtime.core.compaction import BACKGROUND_UNTIL_KEY + from xgen_agent_runtime.core.shared_keys import SharedKeys + from xgen_agent_runtime.stages.s02_context import ContextStage + + state = PipelineState() + state.model = "fake" + client = _SummaryClient() + state.llm_client = client + state.context_window_budget = 8_000 + state.messages = _conversation(40, "long message " * 30) + state.shared[BACKGROUND_UNTIL_KEY] = time.time() + 60 + state.shared[SharedKeys.CONTEXT_COMPACTION_REQUEST] = {"source": "budget_aware"} + await ContextStage(compactor=_llm_compactor()).execute(None, state) + assert BACKGROUND_UNTIL_KEY not in state.shared and client.calls == 1 + + +def test_runtime_notes_are_not_the_request_for_the_prune_either(): + from xgen_agent_runtime.core.context_prune import current_request_index, current_request_span + from xgen_agent_runtime.stages.s16_loop.completion_review import _REVIEW_HEADER + + messages = [ + {"role": "user", "content": [_PDF]}, + {"role": "user", "content": [_IMG, {"type": "text", "text": "이 둘을 비교해 줘"}]}, + {"role": "assistant", "content": "done"}, + {"role": "user", "content": _REVIEW_HEADER + "\nreport.csv: MISSING"}, + ] + assert current_request_index(messages) == 1 + assert current_request_span(messages) == [0, 1] + + +@pytest.mark.asyncio +async def test_a_file_sent_before_the_question_survives_compaction(): + state = PipelineState() + state.model = "fake" + state.llm_client = _SummaryClient() + file_block = {"type": "file", "name": "sales.xlsx", "workspace_path": "/workspace/sales.xlsx"} + state.messages = [ + {"role": "user", "content": [_PDF, file_block]}, + {"role": "user", "content": "첨부한 두 파일을 비교해 줘"}, + ] + for i in range(10): + state.messages += [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{i}", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "x" * 50}]}, + ] + await _llm_compactor(keep_recent=2).compact(state) + head = state.messages[0]["content"] + assert _PDF in head and file_block in head + assert "첨부한 두 파일을 비교해 줘" in head[0]["text"] + + +@pytest.mark.asyncio +async def test_the_requests_images_go_one_at_a_time_and_not_when_it_cannot_help(): + from xgen_agent_runtime.core.compaction import _relieve_hard, input_budget_of + + def _state(question_chars): + st = PipelineState() + st.context_window_budget = 16_000 + st.max_tokens = 4_096 + st.messages = [{"role": "user", "content": [_IMG, dict(_IMG), dict(_IMG), {"type": "text", "text": "가" * question_chars}]}] + return st + + over_a_little = _state(1) + while estimate_prompt_tokens(over_a_little) <= int(input_budget_of(over_a_little) * 0.95): + over_a_little.messages[0]["content"][3]["text"] += "가" * 200 + over_a_little.shared.pop("_prompt_tokens_memo", None) + _relieve_hard(over_a_little, "guard") + images = [b for b in over_a_little.messages[0]["content"] if b.get("type") == "image"] + assert len(images) == 2 + + hopeless = _state(40_000) + _relieve_hard(hopeless, "guard") + assert len([b for b in hopeless.messages[0]["content"] if b.get("type") == "image"]) == 3 + + +@pytest.mark.asyncio +async def test_relief_still_counts_when_the_compactor_raises(): + class _Boom: + name = "boom" + uses_llm = False + + async def compact(self, state): + raise RuntimeError("kaboom") + + state = PipelineState() + state.context_window_budget = 16_000 + state.max_tokens = 4_096 + state.messages = [{"role": "user", "content": "질문"}] + [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "결과 " * 9_000}]}, + ] + out = await run_compaction(state, _Boom(), trigger="guard", hard=True) + assert out["ok"] and out["saved_tokens_estimate"] > 0