diff --git a/backend/.env.example b/backend/.env.example index 4f2c369d6..b99ff3f62 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -28,3 +28,6 @@ WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com" CHANNEL_DELIVERY_POLL_SECONDS="1.0" CHANNEL_DELIVERY_MAX_ATTEMPTS="8" CHANNEL_RICH_RENDER_ENABLED="true" +# ACP(Active Context Pruning)上下文压缩机制全局开关。默认关闭:偏好被忽略、 +# 强制走 legacy 固定阈值压缩;开启后运行设置页可选 ACP 并暴露阈值配置。 +ACP_ENABLED="false" diff --git a/backend/app/api/ui_config.py b/backend/app/api/ui_config.py index 0e2b968f9..ca06c33b0 100644 --- a/backend/app/api/ui_config.py +++ b/backend/app/api/ui_config.py @@ -12,6 +12,7 @@ from sqlmodel import Session from app import paths +from app.config import get_settings from app.db import get_session from app.db.models import UIConfig, User, utc_now from app.harness.sandbox import diagnostics, windows_install_command @@ -55,6 +56,12 @@ class UIConfigRead(BaseModel): sandbox_status_code: str | None = None sandbox_status_message: str | None = None sandbox_status_remediation: str | None = None + context_compression_mode: Literal["acp", "legacy"] = "legacy" + acp_model_context_limit: int = 128000 + acp_nudge_max_pct: float = 0.70 + acp_nudge_emergency_pct: float = 0.85 + acp_nudge_min_pct: float = 0.45 + acp_enabled: bool = False updated_at: str model_config = ConfigDict(from_attributes=True) @@ -91,6 +98,23 @@ class UIConfigUpdateRequest(BaseModel): harness_storage_path: str = Field(default="", max_length=1024) sandbox_network_mode: Literal["all", "allowlist", "deny"] = "all" sandbox_allowed_domains: list[str] = Field(default_factory=list, max_length=200) + context_compression_mode: Literal["acp", "legacy"] = "legacy" + acp_model_context_limit: int = Field(default=128000, ge=1) + acp_nudge_max_pct: float = Field(default=0.70, ge=0.0, le=1.0) + acp_nudge_emergency_pct: float = Field(default=0.85, ge=0.0, le=1.0) + acp_nudge_min_pct: float = Field(default=0.45, ge=0.0, le=1.0) + + @model_validator(mode="after") + def _validate_acp_threshold_order(self) -> "UIConfigUpdateRequest": + if not ( + 0.0 + <= self.acp_nudge_min_pct + <= self.acp_nudge_max_pct + <= self.acp_nudge_emergency_pct + <= 1.0 + ): + raise ValueError("ACP 阈值必须满足 0 ≤ min ≤ max ≤ emergency ≤ 1") + return self @model_validator(mode="after") def validate_context_budgets(self) -> UIConfigUpdateRequest: @@ -176,6 +200,16 @@ def ui_config_read(row: UIConfig, *, restart_scheduled: bool = False) -> UIConfi report.message if report is not None else "沙盒已由管理员关闭。" ), sandbox_status_remediation=report.remediation if report is not None else None, + context_compression_mode=( + row.context_compression_mode + if row.context_compression_mode in {"acp", "legacy"} + else "legacy" + ), + acp_model_context_limit=row.acp_model_context_limit, + acp_nudge_max_pct=row.acp_nudge_max_pct, + acp_nudge_emergency_pct=row.acp_nudge_emergency_pct, + acp_nudge_min_pct=row.acp_nudge_min_pct, + acp_enabled=get_settings().acp_enabled, updated_at=row.updated_at.isoformat(), ) @@ -225,6 +259,11 @@ def update_enterprise_ui_config( row.harness_storage_path = storage_path row.sandbox_network_mode = request.sandbox_network_mode row.sandbox_allowed_domains = request.sandbox_allowed_domains + row.context_compression_mode = request.context_compression_mode + row.acp_model_context_limit = request.acp_model_context_limit + row.acp_nudge_max_pct = request.acp_nudge_max_pct + row.acp_nudge_emergency_pct = request.acp_nudge_emergency_pct + row.acp_nudge_min_pct = request.acp_nudge_min_pct row.updated_at = utc_now() db.add(row) db.commit() diff --git a/backend/app/config.py b/backend/app/config.py index bb9e0d628..5c0f1964b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): # 回滚为逐行展示的旧样式;binding 的 config_json.compact_trace=false 可对单个 # 绑定回滚。 channel_feishu_trace_compact_sop: bool = True + # ACP(Active Context Pruning)上下文压缩机制全局开关:关闭时偏好被忽略、 + # 强制走 legacy 固定阈值压缩;状态经 ui-config API 暴露给前端置灰 ACP 选项。 + acp_enabled: bool = False model_config = SettingsConfigDict( env_file=_os.environ.get("ULTRARAG_DOTENV", ".env"), diff --git a/backend/app/core/acp/__init__.py b/backend/app/core/acp/__init__.py new file mode 100644 index 000000000..ffac45714 --- /dev/null +++ b/backend/app/core/acp/__init__.py @@ -0,0 +1,43 @@ +"""ACP (Active Context Pruning) framework-agnostic compression kernel. + +The kernel is standalone: it depends only on the Python standard library +and never imports staffDeck business modules. The model (caller) writes +summaries; the kernel handles block management, checkpoints, retrieval, +nudging, and shadow-price accounting. +""" + +from .blocks import Block, BlockStore +from .checkpoint import CheckpointRecord, CheckpointStore +from .config import AcpConfig +from .engine import AcpEngine, AcpError, CompressResult, DecompressResult, SearchResult +from .nudge import NudgeRecommendation +from .pricing import AcpLedger, LedgerEntry, TokenMeter +from .search import SearchHit, search_blocks +from .status import BlockLedgerEntry, CheckpointMappingEntry, ContextStatus +from .tiers import MAX_TIER, can_distill, next_tier, tier_label + +__all__ = [ + "AcpConfig", + "AcpEngine", + "AcpError", + "AcpLedger", + "Block", + "BlockLedgerEntry", + "BlockStore", + "CheckpointMappingEntry", + "CheckpointRecord", + "CheckpointStore", + "CompressResult", + "ContextStatus", + "DecompressResult", + "LedgerEntry", + "MAX_TIER", + "NudgeRecommendation", + "SearchHit", + "SearchResult", + "TokenMeter", + "can_distill", + "next_tier", + "search_blocks", + "tier_label", +] \ No newline at end of file diff --git a/backend/app/core/acp/blocks.py b/backend/app/core/acp/blocks.py new file mode 100644 index 000000000..eef0f5354 --- /dev/null +++ b/backend/app/core/acp/blocks.py @@ -0,0 +1,80 @@ +"""Message-level atomic blocks and the ordered block store. + +Each message added to the kernel becomes exactly one block; blocks are the +atomic unit of compression. staffDeck has no standard function calling, so +block boundaries are message-level rather than tool-call/result pairs. +""" + +from dataclasses import dataclass +from typing import Sequence + + +@dataclass(frozen=True) +class Block: + """An immutable message-level block. + + ``is_summary`` marks blocks produced by compression; their original + content lives in the checkpoint record (``checkpoint_id``) so the + visible state stays small while recovery stays possible. + """ + + block_id: int + message_id: str + content: str + tier: int = 1 + is_summary: bool = False + checkpoint_id: int | None = None + skip: bool = False + + +class BlockStore: + """Ordered collection of blocks with stable, monotonically increasing ids.""" + + def __init__(self) -> None: + self._blocks: list[Block] = [] + self._next_id = 1 + + def add(self, message_id: str, content: str, *, skip: bool = False) -> Block: + block = Block( + block_id=self._next_id, + message_id=message_id, + content=content, + skip=skip, + ) + self._next_id += 1 + self._blocks.append(block) + return block + + def add_many(self, messages: Sequence[tuple[str, str]]) -> list[Block]: + return [self.add(message_id, content) for message_id, content in messages] + + def get(self, block_id: int) -> Block | None: + for block in self._blocks: + if block.block_id == block_id: + return block + return None + + def all(self) -> tuple[Block, ...]: + return tuple(self._blocks) + + def slice(self, seq_start: int, seq_end: int) -> list[Block]: + return self._blocks[seq_start : seq_end + 1] + + def replace(self, seq_start: int, seq_end: int, new_block: Block) -> list[Block]: + removed = self._blocks[seq_start : seq_end + 1] + self._blocks[seq_start : seq_end + 1] = [new_block] + return removed + + def replace_by_id(self, block_id: int, replacement: Sequence[Block]) -> Block | None: + for index, block in enumerate(self._blocks): + if block.block_id == block_id: + removed = self._blocks[index] + self._blocks[index : index + 1] = list(replacement) + return removed + return None + + def next_id(self) -> int: + return self._next_id + + def __len__(self) -> int: + return len(self._blocks) \ No newline at end of file diff --git a/backend/app/core/acp/checkpoint.py b/backend/app/core/acp/checkpoint.py new file mode 100644 index 000000000..062be2c82 --- /dev/null +++ b/backend/app/core/acp/checkpoint.py @@ -0,0 +1,57 @@ +"""Checkpoint records enabling recovery of compressed content. + +Every compress operation writes one checkpoint holding the sequence +mapping (compressed range), the summary block id, and the original blocks +with their full content. Checkpoints are immutable and never deleted, so +decompression and audit trails stay possible at any depth. +""" + +import time +from dataclasses import dataclass, field + +from .blocks import Block + + +@dataclass(frozen=True) +class CheckpointRecord: + """Immutable record of one compress operation.""" + + checkpoint_id: int + seq_start: int + seq_end: int + summary_block_id: int + tier: int + original_blocks: tuple[Block, ...] + token_delta: int + created_at: float = field(default_factory=time.time) + + +class CheckpointStore: + """Append-only store of checkpoint records.""" + + def __init__(self) -> None: + self._records: dict[int, CheckpointRecord] = {} + self._next_id = 1 + + def add(self, record: CheckpointRecord) -> CheckpointRecord: + self._records[record.checkpoint_id] = record + self._next_id = max(self._next_id, record.checkpoint_id + 1) + return record + + def get(self, checkpoint_id: int) -> CheckpointRecord | None: + return self._records.get(checkpoint_id) + + def find_by_summary_block(self, block_id: int) -> CheckpointRecord | None: + for record in self._records.values(): + if record.summary_block_id == block_id: + return record + return None + + def all(self) -> tuple[CheckpointRecord, ...]: + return tuple(self._records.values()) + + def next_id(self) -> int: + return self._next_id + + def __len__(self) -> int: + return len(self._records) \ No newline at end of file diff --git a/backend/app/core/acp/config.py b/backend/app/core/acp/config.py new file mode 100644 index 000000000..498baed59 --- /dev/null +++ b/backend/app/core/acp/config.py @@ -0,0 +1,49 @@ +"""ACP kernel configuration. + +Holds the tunable thresholds for context-pressure nudging and lexical +search. Values are validated eagerly so misconfiguration fails fast at +construction time instead of surfacing as surprising runtime behaviour. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AcpConfig: + """Tunable parameters for the ACP compression kernel. + + Percentages are fractions in ``(0, 1]`` and must satisfy + ``min <= max <= emergency`` so the pressure zones stay well ordered. + """ + + model_context_limit: int = 128000 + nudge_max_context_limit_pct: float = 0.70 + nudge_emergency_threshold_pct: float = 0.85 + nudge_min_context_limit_pct: float = 0.45 + search_top_k: int = 5 + search_min_score: float = 0.0 + search_ngram_size: int = 3 + max_tier: int = 3 + + def __post_init__(self) -> None: + if self.model_context_limit <= 0: + raise ValueError("model_context_limit must be positive") + for name, value in ( + ("nudge_max_context_limit_pct", self.nudge_max_context_limit_pct), + ("nudge_emergency_threshold_pct", self.nudge_emergency_threshold_pct), + ("nudge_min_context_limit_pct", self.nudge_min_context_limit_pct), + ): + if not 0 < value <= 1: + raise ValueError(f"{name} must be in (0, 1], got {value}") + if not ( + self.nudge_min_context_limit_pct + <= self.nudge_max_context_limit_pct + <= self.nudge_emergency_threshold_pct + ): + raise ValueError("nudge thresholds must satisfy min <= max <= emergency") + if self.search_top_k <= 0: + raise ValueError("search_top_k must be positive") + if self.search_ngram_size < 2: + raise ValueError("search_ngram_size must be at least 2") + if self.max_tier < 2: + raise ValueError("max_tier must be at least 2") \ No newline at end of file diff --git a/backend/app/core/acp/engine.py b/backend/app/core/acp/engine.py new file mode 100644 index 000000000..1c14153c4 --- /dev/null +++ b/backend/app/core/acp/engine.py @@ -0,0 +1,422 @@ +"""Orchestration of the four ACP operations. + +compress replaces a block range with a caller-provided summary text (the +kernel never calls an LLM), decompress restores hidden original content, +search_context performs lexical retrieval, and status reports the +compression ledger. +""" + +import time +from dataclasses import dataclass +from typing import Any, Sequence + +from .blocks import Block, BlockStore +from .checkpoint import CheckpointRecord, CheckpointStore +from .config import AcpConfig +from .nudge import NudgeRecommendation, evaluate_pressure +from .pricing import AcpLedger, TokenMeter +from .search import SearchHit, search_blocks +from .status import BlockLedgerEntry, CheckpointMappingEntry, ContextStatus + + +@dataclass(frozen=True) +class AcpError: + """Structured error returned instead of raising.""" + + code: str + message: str + detail: dict[str, object] | None = None + + +@dataclass(frozen=True) +class CompressResult: + """Outcome of a successful compress operation.""" + + summary_block_id: int + checkpoint_id: int + tier: int + removed_block_ids: tuple[int, ...] + token_delta: int + ledger_balance: int + + +@dataclass(frozen=True) +class DecompressResult: + """Outcome of a successful decompress operation.""" + + restored_block_ids: tuple[int, ...] + checkpoint_id: int + ledger_balance: int + + +@dataclass(frozen=True) +class SearchResult: + """Outcome of a search_context operation.""" + + query: str + hits: tuple[SearchHit, ...] + total: int + truncated: bool + matched: bool + + +class AcpEngine: + """Framework-agnostic ACP compression kernel facade.""" + + def __init__(self, config: AcpConfig | None = None, meter: TokenMeter | None = None) -> None: + self._config = config or AcpConfig() + self._store = BlockStore() + self._checkpoints = CheckpointStore() + self._meter = meter + self._ledger = AcpLedger(meter=meter) + + # -- ingestion ----------------------------------------------------- + + def add_message(self, message_id: str, content: str, *, skip: bool = False) -> Block: + """Add one message as a single atomic block.""" + return self._store.add(message_id, content, skip=skip) + + def add_messages(self, messages: Sequence[tuple[str, str]]) -> list[Block]: + """Add ``(message_id, content)`` pairs, one block per message.""" + return self._store.add_many(messages) + + def get_block(self, block_id: int) -> Block | None: + """Look up a block by id, or ``None`` when absent.""" + return self._store.get(block_id) + + def blocks(self) -> tuple[Block, ...]: + """Current ordered blocks (visible state).""" + return self._store.all() + + # -- operations ---------------------------------------------------- + + def compress( + self, seq_start: int, seq_end: int, summary_text: str + ) -> CompressResult | AcpError: + """Replace the block range ``[seq_start, seq_end]`` with a summary. + + The summary text is provided by the caller (the model); the kernel + never calls an LLM. Compressing a single summary block again + distills it to the next tier. Returns a structured error for + invalid ranges, skipped blocks, or tier exhaustion. + """ + blocks = self._store.all() + if seq_start < 0 or seq_end < seq_start or seq_end >= len(blocks): + return AcpError( + code="invalid_range", + message=f"range [{seq_start}, {seq_end}] out of bounds for {len(blocks)} blocks", + detail={"seq_start": seq_start, "seq_end": seq_end, "total_blocks": len(blocks)}, + ) + target = self._store.slice(seq_start, seq_end) + if any(block.skip for block in target): + return AcpError( + code="block_skipped", + message="range contains explicitly skipped blocks", + detail={"skipped_block_ids": [block.block_id for block in target if block.skip]}, + ) + if len(target) == 1 and target[0].is_summary: + tier = target[0].tier + 1 + if tier > self._config.max_tier: + return AcpError( + code="tier_limit", + message=f"block already at max tier {self._config.max_tier}", + detail={"block_id": target[0].block_id, "tier": target[0].tier}, + ) + else: + tier = 1 + checkpoint_id = self._checkpoints.next_id() + summary_block = Block( + block_id=self._store.next_id(), + message_id=f"acp_summary_{checkpoint_id}", + content=summary_text, + tier=tier, + is_summary=True, + checkpoint_id=checkpoint_id, + ) + removed = self._store.replace(seq_start, seq_end, summary_block) + removed_tokens = self._ledger.estimate("".join(block.content for block in removed)) + summary_tokens = self._ledger.estimate(summary_text) + source = "meter" if self._meter is not None else "estimate" + self._ledger.credit("compress", removed_tokens, source=source) + self._ledger.debit("compress", summary_tokens, source=source) + record = CheckpointRecord( + checkpoint_id=checkpoint_id, + seq_start=seq_start, + seq_end=seq_end, + summary_block_id=summary_block.block_id, + tier=tier, + original_blocks=tuple(removed), + token_delta=removed_tokens - summary_tokens, + created_at=time.time(), + ) + self._checkpoints.add(record) + return CompressResult( + summary_block_id=summary_block.block_id, + checkpoint_id=checkpoint_id, + tier=tier, + removed_block_ids=tuple(block.block_id for block in removed), + token_delta=removed_tokens - summary_tokens, + ledger_balance=self._ledger.balance, + ) + + def decompress(self, block_id: int) -> DecompressResult | AcpError: + """Restore the hidden original content of a summary block. + + The summary block is replaced in place by its original blocks read + from the checkpoint; checkpoint records are never deleted, so the + operation stays traceable and repeatable. + """ + block = self._store.get(block_id) + if block is None: + return AcpError( + code="block_not_found", + message=f"block {block_id} not found", + detail={"block_id": block_id}, + ) + if not block.is_summary or block.checkpoint_id is None: + return AcpError( + code="not_a_summary", + message=f"block {block_id} is not a compressed summary block", + detail={"block_id": block_id}, + ) + record = self._checkpoints.get(block.checkpoint_id) + if record is None: + return AcpError( + code="checkpoint_missing", + message=f"checkpoint {block.checkpoint_id} missing", + detail={"checkpoint_id": block.checkpoint_id}, + ) + if not record.original_blocks: + # Evicted originals (serialization cap) must never be replaced + # with nothing: that would silently delete the visible summary. + return AcpError( + code="CHECKPOINT_ORIGINALS_EVICTED", + message=( + f"checkpoint {record.checkpoint_id} originals were evicted; " + "decompress unavailable" + ), + detail={"checkpoint_id": record.checkpoint_id, "block_id": block_id}, + ) + removed = self._store.replace_by_id(block_id, record.original_blocks) + assert removed is not None + restored_tokens = self._ledger.estimate( + "".join(original.content for original in record.original_blocks) + ) + summary_tokens = self._ledger.estimate(block.content) + source = "meter" if self._meter is not None else "estimate" + self._ledger.debit("decompress", restored_tokens, source=source) + self._ledger.credit("decompress", summary_tokens, source=source) + return DecompressResult( + restored_block_ids=tuple(original.block_id for original in record.original_blocks), + checkpoint_id=record.checkpoint_id, + ledger_balance=self._ledger.balance, + ) + + def search_context(self, query: str, top_k: int | None = None) -> SearchResult: + """Lexically retrieve blocks matching ``query``. + + Both visible block content and hidden checkpointed content are + searched; hits link back to their blocks so the caller can + decompress them for details. An empty query match returns an empty + result with ``matched=False`` instead of raising. + """ + limit = top_k if top_k is not None else self._config.search_top_k + hidden_texts = { + record.summary_block_id: "".join(block.content for block in record.original_blocks) + for record in self._checkpoints.all() + } + all_hits = search_blocks( + self._store.all(), + query, + min_score=self._config.search_min_score, + ngram_size=self._config.search_ngram_size, + hidden_texts=hidden_texts, + ) + hits = all_hits[:limit] + return SearchResult( + query=query, + hits=tuple(hits), + total=len(all_hits), + truncated=len(all_hits) > limit, + matched=bool(all_hits), + ) + + def status(self) -> ContextStatus: + """Report the context breakdown, block ledger, and checkpoint mapping.""" + blocks = self._store.all() + tier_counts: dict[int, int] = {} + for block in blocks: + tier_counts[block.tier] = tier_counts.get(block.tier, 0) + 1 + return ContextStatus( + total_blocks=len(blocks), + total_chars=sum(len(block.content) for block in blocks), + summary_blocks=sum(1 for block in blocks if block.is_summary), + original_blocks=sum(1 for block in blocks if not block.is_summary), + tier_counts=tier_counts, + ledger_balance=self._ledger.balance, + ledger_warnings=self._ledger.warnings, + blocks=tuple( + BlockLedgerEntry( + block_id=block.block_id, + message_id=block.message_id, + tier=block.tier, + is_summary=block.is_summary, + content_length=len(block.content), + ) + for block in blocks + ), + checkpoints=tuple( + CheckpointMappingEntry( + checkpoint_id=record.checkpoint_id, + seq_start=record.seq_start, + seq_end=record.seq_end, + summary_block_id=record.summary_block_id, + tier=record.tier, + ) + for record in self._checkpoints.all() + ), + ) + + def nudge(self, current_tokens: int) -> NudgeRecommendation | None: + """Evaluate context pressure; advisory only, never mandatory.""" + return evaluate_pressure(current_tokens, self._config) + + # -- persistence ---------------------------------------------------- + + def to_state(self, *, max_originals: int | None = None) -> dict[str, Any]: + """Serialize the kernel state into a JSON-safe dict. + + ``max_originals`` bounds how many most-recent checkpoints keep their + original blocks; older checkpoints keep index metadata with empty + originals (``originals_evicted=True``). ``None`` keeps all originals. + """ + checkpoints = self._checkpoints.all() + evicted = ( + set(checkpoints[:-max_originals]) if max_originals is not None else set() + ) + return { + "blocks": [ + { + "block_id": block.block_id, + "message_id": block.message_id, + "content": block.content, + "tier": block.tier, + "is_summary": block.is_summary, + "checkpoint_id": block.checkpoint_id, + "skip": block.skip, + } + for block in self._store.all() + ], + "checkpoints": [ + { + "checkpoint_id": record.checkpoint_id, + "seq_start": record.seq_start, + "seq_end": record.seq_end, + "summary_block_id": record.summary_block_id, + "tier": record.tier, + "token_delta": record.token_delta, + "created_at": record.created_at, + "originals_evicted": record in evicted, + "original_blocks": [ + { + "block_id": block.block_id, + "message_id": block.message_id, + "content": block.content, + "tier": block.tier, + "is_summary": block.is_summary, + "checkpoint_id": block.checkpoint_id, + "skip": block.skip, + } + for block in record.original_blocks + ] + if record not in evicted + else [], + } + for record in checkpoints + ], + "next_block_id": self._store.next_id(), + "next_checkpoint_id": self._checkpoints.next_id(), + "ledger_balance": self._ledger.balance, + "ledger_warnings": list(self._ledger.warnings), + } + + def from_state(self, state: dict[str, Any]) -> None: + """Restore the kernel state from a ``to_state`` dict, in place. + + Block ids are preserved so model-issued decompress references stay + valid across turns. Checkpoints whose originals were evicted restore + with empty ``original_blocks``; decompress then refuses with + ``CHECKPOINT_ORIGINALS_EVICTED`` instead of deleting the summary. + """ + blocks = state.get("blocks") + if isinstance(blocks, list): + restored = [ + Block( + block_id=int(raw.get("block_id") or 0), + message_id=str(raw.get("message_id") or ""), + content=str(raw.get("content") or ""), + tier=int(raw.get("tier") or 1), + is_summary=bool(raw.get("is_summary")), + checkpoint_id=raw.get("checkpoint_id"), + skip=bool(raw.get("skip")), + ) + for raw in blocks + if isinstance(raw, dict) + ] + self._store._blocks = restored + next_block_id = state.get("next_block_id") + if isinstance(next_block_id, int) and next_block_id > 0: + self._store._next_id = next_block_id + else: + self._store._next_id = ( + max((block.block_id for block in restored), default=0) + 1 + ) + checkpoints = state.get("checkpoints") + if isinstance(checkpoints, list): + records: dict[int, CheckpointRecord] = {} + for raw in checkpoints: + if not isinstance(raw, dict): + continue + checkpoint_id = int(raw.get("checkpoint_id") or 0) + originals = tuple( + Block( + block_id=int(item.get("block_id") or 0), + message_id=str(item.get("message_id") or ""), + content=str(item.get("content") or ""), + tier=int(item.get("tier") or 1), + is_summary=bool(item.get("is_summary")), + checkpoint_id=item.get("checkpoint_id"), + skip=bool(item.get("skip")), + ) + for item in raw.get("original_blocks") or [] + if isinstance(item, dict) + ) + records[checkpoint_id] = CheckpointRecord( + checkpoint_id=checkpoint_id, + seq_start=int(raw.get("seq_start") or 0), + seq_end=int(raw.get("seq_end") or 0), + summary_block_id=int(raw.get("summary_block_id") or 0), + tier=int(raw.get("tier") or 1), + original_blocks=originals, + token_delta=int(raw.get("token_delta") or 0), + created_at=float(raw.get("created_at") or 0.0), + ) + self._checkpoints._records = records + next_checkpoint_id = state.get("next_checkpoint_id") + if isinstance(next_checkpoint_id, int) and next_checkpoint_id > 0: + self._checkpoints._next_id = next_checkpoint_id + else: + self._checkpoints._next_id = max(records, default=0) + 1 + balance = state.get("ledger_balance") + if isinstance(balance, int): + self._ledger._balance = max(0, balance) + + # -- introspection ------------------------------------------------- + + @property + def config(self) -> AcpConfig: + return self._config + + @property + def ledger(self) -> AcpLedger: + return self._ledger \ No newline at end of file diff --git a/backend/app/core/acp/nudge.py b/backend/app/core/acp/nudge.py new file mode 100644 index 000000000..58a101a87 --- /dev/null +++ b/backend/app/core/acp/nudge.py @@ -0,0 +1,56 @@ +"""Pressure evaluation producing advisory nudge recommendations. + +Nudges are advisory only: the kernel never forces compression. The model +decides whether and what to compress. +""" + +from dataclasses import dataclass + +from .config import AcpConfig + + +@dataclass(frozen=True) +class NudgeRecommendation: + """Advisory recommendation to consider compressing context.""" + + level: str + current_tokens: int + limit_tokens: int + usage_pct: float + message: str + + +def evaluate_pressure(current_tokens: int, config: AcpConfig) -> NudgeRecommendation | None: + """Evaluate context pressure against configured thresholds. + + Returns ``None`` when pressure is below the nudge threshold, a normal + recommendation at or above ``nudge_max_context_limit_pct``, and an + emergency recommendation at or above ``nudge_emergency_threshold_pct``. + """ + tokens = max(0, current_tokens) + usage_pct = tokens / config.model_context_limit + if usage_pct < config.nudge_min_context_limit_pct: + return None + if usage_pct >= config.nudge_emergency_threshold_pct: + return NudgeRecommendation( + level="emergency", + current_tokens=tokens, + limit_tokens=config.model_context_limit, + usage_pct=usage_pct, + message=( + f"紧急:上下文使用率已达 {usage_pct:.0%},建议立即压缩历史消息" + "以释放空间(压缩由模型自主决定,非强制)。" + ), + ) + if usage_pct >= config.nudge_max_context_limit_pct: + return NudgeRecommendation( + level="normal", + current_tokens=tokens, + limit_tokens=config.model_context_limit, + usage_pct=usage_pct, + message=( + f"提示:上下文使用率已达 {usage_pct:.0%},可考虑压缩历史消息" + "以释放空间(压缩由模型自主决定,非强制)。" + ), + ) + return None \ No newline at end of file diff --git a/backend/app/core/acp/pricing.py b/backend/app/core/acp/pricing.py new file mode 100644 index 000000000..1566ca6a9 --- /dev/null +++ b/backend/app/core/acp/pricing.py @@ -0,0 +1,192 @@ +"""Shadow-price accounting with an injectable token meter. + +The kernel never assumes it knows the host's token pricing: callers inject +a meter backed by real usage accounting (upstream issue #54). The ledger +defensively clamps debits so the balance can never go negative, and flags +a warning whenever clamping occurs. + +``RealUsageMeter`` is the production meter: it is fed by the host LLM +client's real ``input_tokens`` observations and calibrates per-text token +counts to the host tokenizer's ratio. When no real usage is available it +falls back to the crude char/4 estimate and flags the entry as estimated so +estimates never masquerade as real accounting. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Protocol + + +class TokenMeter(Protocol): + """Injected token meter; the caller provides real usage accounting.""" + + def estimate_tokens(self, text: str) -> int: ... + + +@dataclass(frozen=True) +class LedgerEntry: + """One accounting operation on the ledger.""" + + operation: str + tokens: int + balance: int + source: str + warning: str | None = None + estimated: bool = False + + +class RealUsageMeter: + """TokenMeter fed by real LLM usage; falls back to estimation with a flag. + + ``usage_source`` is an optional callable returning the latest real usage + observation as ``{"input_tokens": int, "source_chars": int}`` (or None); + ``record_usage`` feeds the meter directly. Both paths are equivalent. + When a real observation exists, ``estimate_tokens`` calibrates the host + tokenizer's tokens-per-char ratio; otherwise it falls back to the char/4 + estimate and marks the call as estimated (issue #54: estimates must never + masquerade as real accounting). + """ + + def __init__( + self, + usage_source: Callable[[], dict[str, int] | None] | None = None, + ) -> None: + self._usage_source = usage_source + self._recorded_tokens: int | None = None + self._recorded_chars: int | None = None + self._last_estimated = False + + def record_usage(self, input_tokens: int | None, source_chars: int | None = None) -> None: + """Feed one real usage observation; absent usage clears the meter.""" + if input_tokens is None or input_tokens < 0: + self._recorded_tokens = None + self._recorded_chars = None + return + self._recorded_tokens = int(input_tokens) + self._recorded_chars = max(1, int(source_chars or 0)) + + def _observation(self) -> dict[str, int] | None: + if self._usage_source is not None: + observation = self._usage_source() + if isinstance(observation, dict) and observation.get("input_tokens") is not None: + return observation + recorded_tokens = self._recorded_tokens + if recorded_tokens is not None: + return { + "input_tokens": recorded_tokens, + "source_chars": self._recorded_chars or 1, + } + return None + + def estimate_tokens(self, text: str) -> int: + observation = self._observation() + if observation is not None: + ratio = observation["input_tokens"] / max(1, observation.get("source_chars") or 1) + self._last_estimated = False + return max(1, round(len(text) * ratio)) + self._last_estimated = True + return max(1, (len(text) + 3) // 4) + + @property + def last_estimate_estimated(self) -> bool: + """Whether the most recent ``estimate_tokens`` call fell back to estimation.""" + return self._last_estimated + + @property + def latest_usage_tokens(self) -> int | None: + """Latest real input_tokens observation, or None when absent.""" + observation = self._observation() + return observation["input_tokens"] if observation is not None else None + + +class AcpLedger: + """Token ledger whose balance is guaranteed never to go negative.""" + + def __init__(self, meter: TokenMeter | None = None, initial_balance: int = 0) -> None: + self._meter = meter + self._balance = max(0, initial_balance) + self._entries: list[LedgerEntry] = [] + self._warnings: list[str] = [] + self._last_estimate_estimated = False + + def estimate(self, text: str) -> int: + """Estimate tokens for ``text`` via the injected meter, or a fallback.""" + if self._meter is not None: + tokens = max(0, self._meter.estimate_tokens(text)) + self._last_estimate_estimated = bool( + getattr(self._meter, "last_estimate_estimated", False) + ) + return tokens + self._last_estimate_estimated = True + return max(1, (len(text) + 3) // 4) + + def credit( + self, + operation: str, + tokens: int, + source: str = "meter", + estimated: bool | None = None, + ) -> LedgerEntry: + tokens = max(0, tokens) + if estimated is None: + estimated = self._last_estimate_estimated + self._balance += tokens + entry = LedgerEntry( + operation=operation, + tokens=tokens, + balance=self._balance, + source=source, + estimated=estimated, + ) + self._entries.append(entry) + return entry + + def debit( + self, + operation: str, + tokens: int, + source: str = "meter", + estimated: bool | None = None, + ) -> LedgerEntry: + tokens = max(0, tokens) + if estimated is None: + estimated = self._last_estimate_estimated + if tokens > self._balance: + warning = f"debit of {tokens} exceeds balance {self._balance}; clamped to 0" + self._warnings.append(warning) + entry = LedgerEntry( + operation=operation, + tokens=self._balance, + balance=0, + source="clamped", + warning=warning, + estimated=estimated, + ) + self._balance = 0 + else: + self._balance -= tokens + entry = LedgerEntry( + operation=operation, + tokens=tokens, + balance=self._balance, + source=source, + estimated=estimated, + ) + self._entries.append(entry) + return entry + + @property + def balance(self) -> int: + return self._balance + + @property + def warnings(self) -> tuple[str, ...]: + return tuple(self._warnings) + + @property + def entries(self) -> tuple[LedgerEntry, ...]: + return tuple(self._entries) + + @property + def never_negative(self) -> bool: + return all(entry.balance >= 0 for entry in self._entries) \ No newline at end of file diff --git a/backend/app/core/acp/search.py b/backend/app/core/acp/search.py new file mode 100644 index 000000000..7813b2f88 --- /dev/null +++ b/backend/app/core/acp/search.py @@ -0,0 +1,158 @@ +"""Lexical retrieval over blocks: stemming + CJK bigram + char n-gram. + +No vector database is involved. Latin words are stemmed, CJK text is +indexed as bigrams (plus unigrams for short queries), and character +n-grams provide fuzzy matching for longer query terms. +""" + +import re +from collections import Counter +from dataclasses import dataclass +from typing import Mapping, Sequence + +from .blocks import Block + +_CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]") +_WORD_RE = re.compile(r"[a-zA-Z]+") +_STEM_SUFFIXES = ("ies", "es", "ed", "ing", "ly", "s") + + +def stem(word: str) -> str: + """Strip common English suffixes for light stemming.""" + lowered = word.lower() + for suffix in _STEM_SUFFIXES: + if len(lowered) > 4 and lowered.endswith(suffix): + if suffix == "ies": + return lowered[:-3] + "y" + return lowered[: -len(suffix)] + return lowered + + +def cjk_bigrams(text: str) -> list[str]: + """Bigrams over consecutive CJK characters.""" + chars = _CJK_RE.findall(text) + return [chars[i] + chars[i + 1] for i in range(len(chars) - 1)] + + +def cjk_unigrams(text: str) -> list[str]: + """Individual CJK characters (for short queries).""" + return _CJK_RE.findall(text) + + +def char_ngrams(text: str, size: int = 3) -> set[str]: + """Character n-grams over the whole text for fuzzy matching.""" + normalized = re.sub(r"\s+", "", text.lower()) + if len(normalized) < size: + return {normalized} if normalized else set() + return {normalized[i : i + size] for i in range(len(normalized) - size + 1)} + + +def analyze(text: str, ngram_size: int = 3) -> Counter[str]: + """Weighted term analysis of ``text`` for scoring.""" + terms: Counter[str] = Counter() + for word in _WORD_RE.findall(text): + terms[stem(word)] += 2 + for bigram in cjk_bigrams(text): + terms[bigram] += 1.5 + for char in cjk_unigrams(text): + terms[char] += 1 + return terms + + +@dataclass(frozen=True) +class SearchHit: + """A retrieval hit linked to its block.""" + + block_id: int + message_id: str + tier: int + snippet: str + score: float + matched_terms: tuple[str, ...] + source: str = "visible" + + +def _match_score( + query_terms: Counter[str], + content_terms: Counter[str], + query_ngrams: set[str], + content_text: str, + ngram_size: int, +) -> float: + score = 0.0 + for term, weight in query_terms.items(): + if content_terms[term] > 0: + score += weight + if query_ngrams: + overlap = query_ngrams & char_ngrams(content_text, size=ngram_size) + score += len(overlap) * 0.5 + return score + + +def _snippet(text: str, query: str, radius: int = 40) -> str: + lowered = text.lower() + for term in analyze(query): + index = lowered.find(term) + if index >= 0: + start = max(0, index - radius) + end = min(len(text), index + len(term) + radius) + prefix = "…" if start > 0 else "" + suffix = "…" if end < len(text) else "" + return f"{prefix}{text[start:end]}{suffix}" + return text[: radius * 2] + + +def search_blocks( + blocks: Sequence[Block], + query: str, + *, + min_score: float = 0.0, + ngram_size: int = 3, + hidden_texts: Mapping[int, str] | None = None, +) -> list[SearchHit]: + """Rank blocks by lexical relevance to ``query``. + + Each block yields at most one hit, sourced from either its visible + content or its hidden (checkpointed) content, whichever scores higher. + Hits are sorted by descending score. + """ + query_terms = analyze(query, ngram_size=ngram_size) + query_ngrams = char_ngrams(query, size=ngram_size) + hidden_texts = hidden_texts or {} + hits: list[SearchHit] = [] + for block in blocks: + visible_terms = analyze(block.content, ngram_size=ngram_size) + visible_score = _match_score( + query_terms, visible_terms, query_ngrams, block.content, ngram_size + ) + hidden = hidden_texts.get(block.block_id, "") + hidden_terms = analyze(hidden, ngram_size=ngram_size) if hidden else Counter() + hidden_score = ( + _match_score(query_terms, hidden_terms, query_ngrams, hidden, ngram_size) + if hidden + else 0.0 + ) + if visible_score <= 0 and hidden_score <= 0: + continue + if hidden_score > visible_score: + score, terms, source, snippet_text = hidden_score, hidden_terms, "hidden", hidden + else: + score, terms, source, snippet_text = ( + visible_score, visible_terms, "visible", block.content + ) + matched = tuple(sorted(term for term in query_terms if terms[term] > 0)) + hits.append( + SearchHit( + block_id=block.block_id, + message_id=block.message_id, + tier=block.tier, + snippet=_snippet(snippet_text, query), + score=score, + matched_terms=matched, + source=source, + ) + ) + hits.sort(key=lambda hit: hit.score, reverse=True) + if min_score > 0: + hits = [hit for hit in hits if hit.score >= min_score] + return hits \ No newline at end of file diff --git a/backend/app/core/acp/status.py b/backend/app/core/acp/status.py new file mode 100644 index 000000000..6ff642008 --- /dev/null +++ b/backend/app/core/acp/status.py @@ -0,0 +1,40 @@ +"""Status report structure for the acp_status operation.""" + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class BlockLedgerEntry: + """One block's entry in the status ledger.""" + + block_id: int + message_id: str + tier: int + is_summary: bool + content_length: int + + +@dataclass(frozen=True) +class CheckpointMappingEntry: + """Checkpoint mapping surfaced in the status report.""" + + checkpoint_id: int + seq_start: int + seq_end: int + summary_block_id: int + tier: int + + +@dataclass(frozen=True) +class ContextStatus: + """Snapshot of the compression ledger and block state.""" + + total_blocks: int + total_chars: int + summary_blocks: int + original_blocks: int + tier_counts: dict[int, int] = field(default_factory=dict) + ledger_balance: int = 0 + ledger_warnings: tuple[str, ...] = () + blocks: tuple[BlockLedgerEntry, ...] = () + checkpoints: tuple[CheckpointMappingEntry, ...] = () \ No newline at end of file diff --git a/backend/app/core/acp/tiers.py b/backend/app/core/acp/tiers.py new file mode 100644 index 000000000..7f39082cf --- /dev/null +++ b/backend/app/core/acp/tiers.py @@ -0,0 +1,27 @@ +"""Tiered distillation of summary nodes. + +Compressing a summary block again produces a higher tier (tier2, tier3). +Each tier keeps its own checkpoint, so the chain stays traceable back to +the original messages. +""" + +MAX_TIER = 3 + + +def next_tier(tier: int) -> int: + """Return the tier produced by distilling ``tier`` once more.""" + if tier < 1: + raise ValueError(f"tier must be >= 1, got {tier}") + if tier >= MAX_TIER: + raise ValueError(f"cannot distill beyond max tier {MAX_TIER}") + return tier + 1 + + +def can_distill(tier: int) -> bool: + """Whether a block at ``tier`` can be distilled once more.""" + return 1 <= tier < MAX_TIER + + +def tier_label(tier: int) -> str: + """Human-readable label for a tier, e.g. ``tier2``.""" + return f"tier{tier}" \ No newline at end of file diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py index 36b26a24f..3001e7aa3 100644 --- a/backend/app/core/agent_loop.py +++ b/backend/app/core/agent_loop.py @@ -2,8 +2,9 @@ import logging from collections.abc import Callable, Iterator +from dataclasses import asdict, is_dataclass from time import sleep -from typing import Any, Literal +from typing import Any, Literal, cast from sqlmodel import Session, select @@ -13,6 +14,10 @@ visible_skill, ) from app.channels.service_outbox import stage_channel_delivery +from app.config import get_settings +from app.core.acp import AcpConfig, AcpEngine, AcpError +from app.core.acp.nudge import NudgeRecommendation +from app.core.acp.pricing import RealUsageMeter from app.core.agent_identity_prompt import AgentIdentityPrompt from app.core.cancellation import clear_chat_turn_cancelled from app.core.conversation_context import ( @@ -42,6 +47,7 @@ AgentProfile, ChannelBinding, ChatSession, + HarnessAgentLoopRecord, HarnessTurnRecord, HumanHandoffRequest, Message, @@ -57,6 +63,7 @@ restore_truncated_atomic_references, ) from app.llm import LLMClient, LLMError +from app.llm.client import latest_llm_usage_observation from app.llm.model_config_resolver import ( resolve_model_config_for_runtime, ) @@ -83,6 +90,8 @@ MAX_TOOL_ACTIONS_PER_TURN_LIMIT = 100 GRAPH_PENDING_STEPS_SLOT = "_graph_pending_steps" CANCELLED_ASSISTANT_REPLY = "已停止生成" +DEFAULT_CONTEXT_COMPRESSION_MODE = "legacy" +ACP_CHECKPOINT_ORIGINALS_CAP = 5 ExecutionFinalizeState = Literal["continued", "completed", "handoff"] @@ -133,6 +142,218 @@ def _single_line_text(value: object) -> str: return AgentIdentityPrompt.single_line(value) +def _resolve_compression_mode(preference: str, *, acp_enabled: bool) -> str: + """Apply the ACP feature flag: disabled forces legacy regardless of preference.""" + if preference != "acp" or not acp_enabled: + return "legacy" + return "acp" + + +def _acp_config_for_tenant(db: Session, tenant_id: str) -> AcpConfig: + """Build the kernel config from user thresholds, falling back to upstream defaults.""" + row = db.get(UIConfig, tenant_id) if hasattr(db, "get") else None + if row is None: + return AcpConfig() + try: + return AcpConfig( + model_context_limit=max(1, int(row.acp_model_context_limit or 128000)), + nudge_max_context_limit_pct=float(row.acp_nudge_max_pct or 0.70), + nudge_emergency_threshold_pct=float(row.acp_nudge_emergency_pct or 0.85), + nudge_min_context_limit_pct=float(row.acp_nudge_min_pct or 0.45), + ) + except ValueError: + logger.error( + "invalid ACP thresholds for tenant %s; using defaults", tenant_id + ) + return AcpConfig() + + +def _acp_nudge_message(recommendation: NudgeRecommendation) -> str: + """Compose the advisory nudge copy with the reuse-check hint. + + The kernel message already frames compression as model-decided and + non-mandatory; the hint reminds the model to reuse previously compressed + blocks before compressing again. + """ + hint = ( + "如之前已压缩过历史消息,可先检查压缩块索引(acp_status)并复用其中仍有效的内容" + "(acp_decompress / acp_search_context 可找回细节)。" + ) + return f"{recommendation.message}\n{hint}" + + +def _attach_acp_nudge( + context: dict[str, object], + engine: AcpEngine, + meter: RealUsageMeter, +) -> None: + """Attach an advisory nudge to the session context when pressure crosses a threshold. + + Pressure uses the meter's real usage when available; otherwise the + pre-check estimate from the projected context metadata (``_estimate_tokens`` + stays pre-check only and never writes into the ledger). The nudge is + advisory only — the model decides whether to compress (autoNudge). + """ + pressure_tokens = meter.latest_usage_tokens + estimated = pressure_tokens is None + if pressure_tokens is None: + pressure_tokens = int((context.get("metadata") or {}).get("estimated_tokens") or 0) + recommendation = engine.nudge(pressure_tokens) + if recommendation is None: + return + context["nudge"] = { + "level": recommendation.level, + "message": _acp_nudge_message(recommendation), + "usage_pct": recommendation.usage_pct, + "current_tokens": recommendation.current_tokens, + "limit_tokens": recommendation.limit_tokens, + "estimated": estimated, + } + + +def _acp_state_from_context(context_state: dict[str, Any] | None) -> dict[str, Any]: + source = context_state if isinstance(context_state, dict) else {} + acp_state = source.get("acp") + return dict(acp_state) if isinstance(acp_state, dict) else {} + + +def _restore_acp_engine(engine: AcpEngine, acp_state: dict[str, Any]) -> None: + """Rebuild the in-memory kernel from the persisted acp sub-state.""" + engine.from_state(acp_state) + + +def _serialize_acp_engine( + engine: AcpEngine, + *, + ingested_message_ids: list[str], + roles: dict[str, str], + compaction_count: int, + max_originals: int = ACP_CHECKPOINT_ORIGINALS_CAP, + compacted_message_ids: set[str] | None = None, +) -> dict[str, Any]: + """Persist the kernel state into the acp sub-state. + + Checkpoint ORIGINAL text is bounded: only the ``max_originals`` most + recent checkpoints keep their original blocks; older checkpoints keep + their index metadata with empty originals so ``context_state_json`` + cannot grow unboundedly with compression count (plan fix F11). + ``compacted_message_ids`` records every message id already covered by an + ACP summary so the legacy path never re-summarizes them after a mode + switch back (plan fix F8). + """ + state = engine.to_state(max_originals=max_originals) + for block in state["blocks"]: + block["role"] = roles.get(block["message_id"], "user") + for checkpoint in state["checkpoints"]: + for block in checkpoint["original_blocks"]: + block["role"] = roles.get(block["message_id"], "user") + state["roles"] = dict(roles) + state["ingested_message_ids"] = list(ingested_message_ids) + state["compaction_count"] = compaction_count + derived = { + block.message_id + for record in engine._checkpoints.all() + for block in record.original_blocks + } + state["compacted_message_ids"] = sorted(derived | set(compacted_message_ids or [])) + return state + + +def _acp_result_to_dict(result: object) -> dict[str, Any]: + if not is_dataclass(result): + return {"value": str(result)} + return asdict(cast(Any, result)) + + +def _acp_op_int_argument(arguments: dict[str, Any], key: str) -> int | None: + """Parse an integer op argument; bools and non-ints are rejected.""" + value = arguments.get(key) + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def _execute_acp_ops( + engine: AcpEngine, ops: list[dict[str, Any]] +) -> tuple[list[dict[str, Any]], bool]: + """Execute pending model acp_ops against the session-level engine. + + Each op is a dict mapping op name -> arguments, e.g. + ``{"compress": {"seq_start": 0, "seq_end": 2, "summary": "..."}}``. + Every op runs: invalid arguments yield structured ``INVALID_ARGUMENTS`` + errors and engine errors are collected per op, so one failing op never + discards the successful mutations of earlier ops. Returns + ``(results, ok)`` where ``ok`` is False when any op failed, letting the + caller fall back to the legacy summary path without breaking the turn. + """ + results: list[dict[str, Any]] = [] + for op in ops: + if not isinstance(op, dict): + continue + for name, arguments in op.items(): + if not isinstance(arguments, dict): + logger.warning("acp op %s ignored: arguments must be a dict", name) + continue + try: + if name == "compress": + seq_start = _acp_op_int_argument(arguments, "seq_start") + seq_end = _acp_op_int_argument(arguments, "seq_end") + summary = str(arguments.get("summary") or "").strip() + if seq_start is None or seq_end is None or not summary: + result: object = AcpError( + "INVALID_ARGUMENTS", + "acp_compress 需要整数 seq_start/seq_end 与非空 summary。", + ) + else: + result = engine.compress(seq_start, seq_end, summary) + elif name == "decompress": + block_id = _acp_op_int_argument(arguments, "block_id") + if block_id is None: + result = AcpError( + "INVALID_ARGUMENTS", "acp_decompress 需要整数 block_id。" + ) + else: + result = engine.decompress(block_id) + elif name == "search_context": + query = str(arguments.get("query") or "").strip() + top_k = arguments.get("top_k") + if not query: + result = AcpError( + "INVALID_ARGUMENTS", "acp_search_context 需要非空 query。" + ) + elif top_k is not None and ( + isinstance(top_k, bool) + or not isinstance(top_k, int) + or not 1 <= top_k <= 100 + ): + result = AcpError( + "INVALID_ARGUMENTS", + "acp_search_context top_k 必须是 1-100 的整数。", + ) + else: + result = engine.search_context(query, top_k=top_k) + elif name == "status": + result = engine.status() + else: + logger.warning("unknown acp op %s ignored", name) + continue + except Exception as exc: + logger.warning("acp op %s raised: %s", name, exc) + result = AcpError("INTERNAL_ERROR", str(exc)) + if isinstance(result, AcpError): + logger.warning("acp op %s failed: %s", name, result.message) + results.append( + { + "op": name, + "success": False, + "error": {"code": result.code, "message": result.message}, + } + ) + else: + results.append({"op": name, "success": True, "result": _acp_result_to_dict(result)}) + return results, all(result.get("success") for result in results) + + class AgentLoopPreconditionError(Exception): def __init__(self, code: str, message: str): super().__init__(message) @@ -1229,6 +1450,37 @@ def _get_agent_loop_max_actions( value = row.agent_loop_max_actions if row else MAX_TOOL_ACTIONS_PER_TURN return max(1, min(int(value), MAX_TOOL_ACTIONS_PER_TURN_LIMIT)) + def _get_context_compression_mode( + self, tenant_id: str, agent_id: str | None = None + ) -> str: + """Resolve the tenant's context compression preference. + + Precedence chain mirrors ``_get_agent_loop_max_actions``: + 1. AgentProfile-level override — future hook: ``metadata_json`` may + carry a per-agent ``context_compression_mode``; read tolerantly + when present (no new profile fields are introduced). + 2. ``UIConfig.context_compression_mode`` (tenant-level preference). + 3. Constant default ``"legacy"``. + The ACP_ENABLED feature flag is applied by the caller via + ``_resolve_compression_mode``. + """ + if not hasattr(self.db, "get"): + return DEFAULT_CONTEXT_COMPRESSION_MODE + agent = self.db.get(AgentProfile, agent_id) if agent_id else None + if agent is not None and ( + agent.tenant_id != tenant_id or agent.status != "active" + ): + agent = None + if agent is not None: + metadata = agent.metadata_json + if isinstance(metadata, dict): + value = str(metadata.get("context_compression_mode") or "").strip() + if value in {"acp", "legacy"}: + return value + row = self.db.get(UIConfig, tenant_id) + value = str(row.context_compression_mode or "").strip() if row else "" + return value if value in {"acp", "legacy"} else DEFAULT_CONTEXT_COMPRESSION_MODE + def _get_conversation_context_settings( self, tenant_id: str, @@ -1406,25 +1658,168 @@ def _conversation_context( ).all() ) visible_rows = visible_message_rows(rows) - context = build_conversation_context( - [ - ConversationProjection.message_context_entry( - row, - content=visible_message_content(row), - ) - for row in visible_rows - ], - settings=self._get_conversation_context_settings(chat_session.tenant_id), - context_state=chat_session.context_state_json, - summary_builder=self._context_summary_builder(model_config) if model_config else None, + entries = [ + ConversationProjection.message_context_entry( + row, + content=visible_message_content(row), + ) + for row in visible_rows + ] + mode = _resolve_compression_mode( + self._get_context_compression_mode( + chat_session.tenant_id, chat_session.agent_id + ), + acp_enabled=get_settings().acp_enabled, ) + if mode == "acp": + context = self._acp_conversation_context( + chat_session, entries, model_config=model_config + ) + else: + context = build_conversation_context( + entries, + settings=self._get_conversation_context_settings(chat_session.tenant_id), + context_state=chat_session.context_state_json, + summary_builder=( + self._context_summary_builder(model_config, chat_session.id) + if model_config + else None + ), + ) next_state = context.get("context_state") if isinstance(next_state, dict) and next_state != (chat_session.context_state_json or {}): chat_session.context_state_json = next_state self.db.add(chat_session) return context - def _context_summary_builder(self, model_config: ModelConfig) -> Callable[[str, str, int], str]: + def _acp_conversation_context( + self, + chat_session: ChatSession, + entries: list[dict[str, Any]], + *, + model_config: ModelConfig | None = None, + ) -> dict[str, object]: + """ACP-routed context: execute pending model acp_ops against the + session-level AcpEngine, then project the resulting blocks. + + Summaries come from the model's ``acp_compress`` op — the legacy + ``_context_summary_builder`` (second LLM call) is bypassed here. + """ + acp_state = _acp_state_from_context(chat_session.context_state_json) + meter = RealUsageMeter( + usage_source=lambda: latest_llm_usage_observation(chat_session.id) + ) + engine = AcpEngine( + config=_acp_config_for_tenant(self.db, chat_session.tenant_id), + meter=meter, + ) + _restore_acp_engine(engine, acp_state) + ingested = set(acp_state.get("ingested_message_ids") or []) + roles = dict(acp_state.get("roles") or {}) + compacted = set(acp_state.get("compacted_message_ids") or []) + for entry in entries: + message_id = str(entry.get("id") or "") + if not message_id or message_id in ingested: + continue + engine.add_message(message_id, str(entry.get("content") or "")) + ingested.add(message_id) + roles[message_id] = str(entry.get("role") or "user") + pending_ops, loop = self._pending_acp_ops(chat_session) + try: + results, ok = _execute_acp_ops(engine, pending_ops) + if not ok: + logger.warning( + "acp ops failed for session %s; falling back to legacy summary path", + chat_session.id, + ) + if any(result.get("success") for result in results): + # Successful ops already mutated the engine; persist the + # state so those mutations survive the legacy fallback. + compaction_count = int(acp_state.get("compaction_count") or 0) + sum( + 1 + for result in results + if result.get("op") == "compress" and result.get("success") + ) + next_acp_state = _serialize_acp_engine( + engine, + ingested_message_ids=sorted(ingested), + roles=roles, + compaction_count=compaction_count, + compacted_message_ids=compacted, + ) + next_state = dict(chat_session.context_state_json or {}) + next_state["acp"] = next_acp_state + chat_session.context_state_json = next_state + if hasattr(chat_session, "_sa_instance_state"): + self.db.add(chat_session) + return build_conversation_context( + entries, + context_state=chat_session.context_state_json, + summary_builder=( + self._context_summary_builder(model_config, chat_session.id) + if model_config + else None + ), + ) + compaction_count = int(acp_state.get("compaction_count") or 0) + sum( + 1 + for result in results + if result.get("op") == "compress" and result.get("success") + ) + next_acp_state = _serialize_acp_engine( + engine, + ingested_message_ids=sorted(ingested), + roles=roles, + compaction_count=compaction_count, + compacted_message_ids=compacted, + ) + for op_name, state_key in (("search_context", "last_search"), ("status", "last_status")): + for result in results: + if result.get("op") == op_name and result.get("success"): + next_acp_state[state_key] = result["result"] + break + next_state = dict(chat_session.context_state_json or {}) + next_state["acp"] = next_acp_state + context = build_conversation_context( + entries, + context_state=next_state, + compression_mode="acp", + ) + _attach_acp_nudge(context, engine, meter) + return context + finally: + if loop is not None: + self._clear_pending_acp_ops(loop) + + def _pending_acp_ops( + self, chat_session: ChatSession + ) -> tuple[list[dict[str, Any]], HarnessAgentLoopRecord | None]: + """Read pending acp_ops from the latest general agent-loop checkpoint.""" + loop = self.db.exec( + select(HarnessAgentLoopRecord) + .where( + HarnessAgentLoopRecord.session_id == chat_session.id, + HarnessAgentLoopRecord.loop_key == f"general:{chat_session.id}", + ) + .order_by(HarnessAgentLoopRecord.created_at.desc()) + ).first() + if loop is None or not isinstance(loop.checkpoint_json, dict): + return [], loop + raw_ops = loop.checkpoint_json.get("acp_ops") + if not isinstance(raw_ops, list): + return [], loop + return [op for op in raw_ops if isinstance(op, dict)], loop + + def _clear_pending_acp_ops(self, loop: HarnessAgentLoopRecord) -> None: + checkpoint = dict(loop.checkpoint_json or {}) + if "acp_ops" in checkpoint: + checkpoint.pop("acp_ops", None) + loop.checkpoint_json = checkpoint + self.db.add(loop) + + def _context_summary_builder( + self, model_config: ModelConfig, session_id: str | None = None + ) -> Callable[[str, str, int], str]: def summarize(label: str, source: str, token_budget: int) -> str: payload = stage_payload( phase="Context Compression", @@ -1441,7 +1836,9 @@ def summarize(label: str, source: str, token_budget: int) -> str: ) with llm_operation("context.compact"): return ( - LLMClient(model_config).generate_text(unified_system_prompt(), payload).strip() + LLMClient(model_config, session_id=session_id) + .generate_text(unified_system_prompt(), payload) + .strip() ) return summarize diff --git a/backend/app/core/capability_manifest.py b/backend/app/core/capability_manifest.py index 1657d7ef3..ab2380a13 100644 --- a/backend/app/core/capability_manifest.py +++ b/backend/app/core/capability_manifest.py @@ -60,6 +60,8 @@ def build( agent_id: str | None, skill: Skill | None, step_id: str | None, + *, + context_compression_mode: str | None = None, ) -> CapabilityManifest: if agent_id and get_agent(self.db, tenant_id, agent_id) is None: raise CapabilityAuthorizationError("当前员工不存在、已归档或不属于该租户。") @@ -68,6 +70,10 @@ def build( unavailable: list[CapabilityDescriptor] = [] available.extend(_internal_capability_descriptors()) + if context_compression_mode == "acp": + # Legacy preference must keep these names out of allowed_names() + # so a model call hits the existing illegal-tool error path. + available.extend(_acp_capability_descriptors()) ui_config = self.db.get(UIConfig, tenant_id) sandbox_enabled = bool(getattr(ui_config, "sandbox_enabled", False)) @@ -462,6 +468,111 @@ def _internal_capability_descriptors() -> list[CapabilityDescriptor]: ] +def _acp_capability_descriptors() -> list[CapabilityDescriptor]: + """Internal ACP compression capabilities (wire names == tool_name values).""" + return [ + CapabilityDescriptor( + capability_id="builtin.acp.compress", + name="acp_compress", + kind="internal", + description=( + "Compress a range of transcript blocks into one summary block " + "written by you. The original content is preserved in a " + "checkpoint and can be restored later with acp_decompress or " + "found with acp_search_context. Use this when the transcript " + "grows too large." + ), + input_schema={ + "type": "object", + "properties": { + "seq_start": { + "type": "integer", + "minimum": 0, + "description": "First block sequence index to compress (inclusive).", + }, + "seq_end": { + "type": "integer", + "minimum": 0, + "description": "Last block sequence index to compress (inclusive).", + }, + "summary": { + "type": "string", + "minLength": 1, + "description": "Summary text replacing the compressed blocks.", + }, + }, + "required": ["seq_start", "seq_end", "summary"], + "additionalProperties": False, + }, + metadata={"provider": "harness", "side_effect": "read"}, + ), + CapabilityDescriptor( + capability_id="builtin.acp.decompress", + name="acp_decompress", + kind="internal", + description=( + "Restore the original transcript entries hidden by an earlier " + "acp_compress. Pass the summary block_id returned by " + "acp_compress or listed by acp_status." + ), + input_schema={ + "type": "object", + "properties": { + "block_id": { + "type": "integer", + "minimum": 1, + "description": "Summary block id to restore.", + }, + }, + "required": ["block_id"], + "additionalProperties": False, + }, + metadata={"provider": "harness", "side_effect": "read"}, + ), + CapabilityDescriptor( + capability_id="builtin.acp.search_context", + name="acp_search_context", + kind="internal", + description=( + "Lexically search both visible transcript blocks and hidden " + "checkpointed content. Use this to locate details that were " + "compressed earlier, then acp_decompress the matching block." + ), + input_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "minLength": 1}, + "top_k": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 8, + }, + }, + "required": ["query"], + "additionalProperties": False, + }, + metadata={"provider": "harness", "side_effect": "read"}, + ), + CapabilityDescriptor( + capability_id="builtin.acp.status", + name="acp_status", + kind="internal", + description=( + "Report the current compression ledger: block counts, tiers, " + "checkpoints, and token balance. Use this to decide whether " + "to compress the transcript." + ), + input_schema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + metadata={"provider": "harness", "side_effect": "read"}, + ), + ] + + def tool_snapshot_digest(db: Session, tool: Tool) -> str: """Hash every persisted field that can change an external tool invocation.""" diff --git a/backend/app/core/conversation_context.py b/backend/app/core/conversation_context.py index 1bee683ea..fa5c2ff9b 100644 --- a/backend/app/core/conversation_context.py +++ b/backend/app/core/conversation_context.py @@ -57,12 +57,15 @@ def build_conversation_context( settings: ConversationContextSettings | None = None, context_state: dict[str, Any] | None = None, summary_builder: SummaryBuilder | None = None, + compression_mode: str = "legacy", ) -> dict[str, object]: resolved = (settings or ConversationContextSettings()).normalized() if token_budget is not None: resolved = replace(resolved, token_budget=token_budget).normalized() normalized = _normalize_messages(messages, resolved.allowed_roles) state = _normalize_state(context_state) + if compression_mode == "acp": + return _build_acp_context(normalized, state, token_budget) unsummarized, summarized_count = _messages_after_cursor(normalized, state) projected = _project_messages(state, unsummarized, resolved) trigger_tokens = max( @@ -75,6 +78,19 @@ def build_conversation_context( recent = _recent_rounds(unsummarized, resolved.recent_round_limit) older_count = len(unsummarized) - len(recent) older = unsummarized[:older_count] + # Skip already-summarized messages (legacy or ACP summary blocks + # carrying the shared prefixes) so they are never destructively + # re-summarized when a session switches back to legacy (plan fix F8). + # ACP-compressed message ids are excluded too: their content already + # lives in ACP summary blocks, so re-summarizing the raw rows would + # duplicate the compression work (plan fix F8). + compacted_ids = _acp_compacted_message_ids(state) + older = [ + message + for message in older + if not _is_summary_message(message) + and message["_message_id"] not in compacted_ids + ] if older: previous_history = _joined_existing_history(state) state["long_term_summary"] = _summarize( @@ -127,7 +143,7 @@ def build_conversation_context( def _normalize_state(value: dict[str, Any] | None) -> dict[str, Any]: source = value if isinstance(value, dict) else {} - return { + normalized = { "long_term_summary": str(source.get("long_term_summary") or "").strip(), "medium_term_summary": str(source.get("medium_term_summary") or "").strip(), "summarized_through_message_id": str( @@ -135,6 +151,139 @@ def _normalize_state(value: dict[str, Any] | None) -> dict[str, Any]: ).strip(), "compaction_count": max(0, int(source.get("compaction_count") or 0)), } + # Preserve and extend: unknown keys (including the ACP sub-state) pass + # through untouched so legacy 4-key sessions migrate losslessly and no + # future state key is ever dropped. + for key, item in source.items(): + if key not in normalized: + normalized[key] = item + return normalized + + +def _build_acp_context( + normalized: list[dict[str, Any]], + state: dict[str, Any], + token_budget: int, +) -> dict[str, object]: + """Project the ACP block state into the standard context contract. + + Summary blocks become user messages using the EXISTING summary prefixes + (first summary -> long-term prefix, later summaries -> medium-term + prefix) so the request-layer ``_is_history_summary_message`` protection + in ``llm/client.py`` treats them exactly like legacy summaries. + """ + acp_state = state.get("acp") + blocks = acp_state.get("blocks") if isinstance(acp_state, dict) else None + if not isinstance(blocks, list) or not blocks: + # Migration path: a legacy 4-key session entering ACP mode projects + # its existing summaries without triggering a new compaction. + return _project_legacy_into_acp(normalized, state, token_budget) + projected: list[dict[str, Any]] = [] + summary_count = 0 + for block in blocks: + if not isinstance(block, dict): + continue + content = str(block.get("content") or "").strip() + if not content: + continue + if block.get("is_summary"): + prefix = LONG_SUMMARY_PREFIX if summary_count == 0 else MEDIUM_SUMMARY_PREFIX + projected.append({"role": "user", "content": f"{prefix}\n{content}"}) + summary_count += 1 + else: + projected.append( + {"role": str(block.get("role") or "user"), "content": content} + ) + projected = _fit_acp_projected_messages(projected, token_budget) + summary_text = "\n".join( + str(block.get("content") or "").strip() + for block in blocks + if isinstance(block, dict) and block.get("is_summary") + ) + return { + "messages": projected, + "compacted_summary": summary_text, + "context_state": state, + "metadata": { + "token_budget": token_budget, + "compaction_trigger_ratio": COMPACTION_TRIGGER_RATIO, + "compaction_trigger_tokens": max( + 1, math.floor(token_budget * COMPACTION_TRIGGER_RATIO) + ), + "estimated_tokens": _messages_tokens(projected), + "total_messages": len(normalized), + "included_messages": len(projected), + "omitted_messages": max(0, len(normalized) - len(projected)), + "compacted": summary_count > 0, + "compacted_now": False, + "long_term_summary": summary_count > 0, + "medium_term_summary": summary_count > 1, + "recent_round_limit": RECENT_ROUND_LIMIT, + "current_turn_time": normalized[-1].get("_created_at") if normalized else None, + "compression_mode": "acp", + }, + } + + +def _project_legacy_into_acp( + normalized: list[dict[str, Any]], + state: dict[str, Any], + token_budget: int, +) -> dict[str, object]: + """Project a legacy 4-key state inside ACP mode without compacting.""" + unsummarized, summarized_count = _messages_after_cursor(normalized, state) + projected = _project_messages(state, unsummarized) + projected = _fit_projected_messages(projected, token_budget) + summary = _joined_existing_history(state) + return { + "messages": projected, + "compacted_summary": summary, + "context_state": state, + "metadata": { + "token_budget": token_budget, + "compaction_trigger_ratio": COMPACTION_TRIGGER_RATIO, + "compaction_trigger_tokens": max( + 1, math.floor(token_budget * COMPACTION_TRIGGER_RATIO) + ), + "estimated_tokens": _messages_tokens(projected), + "total_messages": len(normalized), + "included_messages": len(projected), + "omitted_messages": summarized_count, + "compacted": bool(summary), + "compacted_now": False, + "long_term_summary": bool(state.get("long_term_summary")), + "medium_term_summary": bool(state.get("medium_term_summary")), + "recent_round_limit": RECENT_ROUND_LIMIT, + "current_turn_time": normalized[-1].get("_created_at") if normalized else None, + "compression_mode": "acp", + }, + } + + +def _fit_acp_projected_messages( + messages: list[dict[str, Any]], token_budget: int +) -> list[dict[str, Any]]: + """Budget fitter that protects ALL leading summary-prefixed messages. + + ACP can produce more than the two legacy summary messages, so unlike + ``_fit_projected_messages`` every leading summary is treated as + non-removable. + """ + projected = list(messages) + summary_count = sum( + 1 + for message in projected + if str(message.get("content") or "").startswith( + (LONG_SUMMARY_PREFIX, MEDIUM_SUMMARY_PREFIX) + ) + ) + while len(projected) > summary_count + 1 and _messages_tokens(projected) > token_budget: + projected.pop(summary_count) + if projected and _messages_tokens(projected) > token_budget: + last = projected[-1] + remaining = max(1, token_budget - _messages_tokens(projected[:-1])) + projected[-1] = _trim_message(last, remaining) + return projected def _normalize_messages( @@ -217,6 +366,23 @@ def _public_message(message: dict[str, Any]) -> dict[str, Any]: } +def _is_summary_message(message: dict[str, Any]) -> bool: + return str(message.get("content") or "").startswith( + (LONG_SUMMARY_PREFIX, MEDIUM_SUMMARY_PREFIX) + ) + + +def _acp_compacted_message_ids(state: dict[str, Any]) -> set[str]: + """Message ids already covered by ACP summary blocks in the acp sub-state.""" + acp_state = state.get("acp") + if not isinstance(acp_state, dict): + return set() + raw = acp_state.get("compacted_message_ids") + if not isinstance(raw, list): + return set() + return {str(item) for item in raw if str(item)} + + def _recent_rounds( messages: list[dict[str, Any]], round_limit: int ) -> list[dict[str, Any]]: diff --git a/backend/app/core/harness_agent.py b/backend/app/core/harness_agent.py index b24efd30c..f675a4da9 100644 --- a/backend/app/core/harness_agent.py +++ b/backend/app/core/harness_agent.py @@ -2,14 +2,17 @@ import hashlib import json +import logging import time from collections.abc import Callable -from dataclasses import is_dataclass, replace +from dataclasses import asdict, is_dataclass, replace from typing import Any, Literal from pydantic import BaseModel, Field, ValidationError from app import paths +from app.core.acp import AcpConfig, AcpEngine, AcpError +from app.core.acp.pricing import RealUsageMeter, TokenMeter from app.core.harness_attachments import ( ValidatedTaskImagePayload, isolated_attachment_context, @@ -21,15 +24,29 @@ ) from app.db.models import ModelConfig from app.llm import LLMClient, LLMError +from app.llm.client import latest_llm_usage_observation from app.observability.spans import llm_operation from app.session.slot_policy import strip_router_generated_message_slots +logger = logging.getLogger(__name__) + PROMPT_PATH = paths.resource_dir() / "app" / "llm" / "prompts" / "harness_agent_prompt.md" MAX_SUCCESSFUL_KNOWLEDGE_SEARCHES_PER_TASK = 2 ToolInvoker = Callable[[str, dict[str, Any]], dict[str, Any]] TraceSink = Callable[[str, dict[str, Any]], None] CancellationCheck = Callable[[], bool] +ACP_CAPABILITY_NAMES = frozenset( + {"acp_compress", "acp_decompress", "acp_search_context", "acp_status"} +) +# Mirror the session-layer originals cap so the task-level checkpoint state +# cannot grow unboundedly with compression count either. +ACP_CHECKPOINT_ORIGINALS_CAP = 5 +# compress/decompress mutate the transcript in place (summary entry replaces +# the compressed range / restored entries replace the summary entry), so the +# regular assistant+tool append is skipped for them. +_TRANSCRIPT_SYNCED_ACP_CAPABILITIES = frozenset({"acp_compress", "acp_decompress"}) + class HarnessExecutionCancelled(RuntimeError): pass @@ -49,11 +66,18 @@ class HarnessAction(BaseModel): next_step_id: str | None = None task_summary: str = "" structured_result: Any | None = None + # Optional ACP (Active Context Pruning) operations attached by the model + # alongside a normal action. Backward-compatible extension: absent for + # every existing protocol consumer, so no protocol break. + acp_ops: dict[str, Any] | None = None class HarnessTaskAgent: """Runs one isolated TaskRequirement without outer conversation messages.""" + def __init__(self, context_compression_mode: str = "legacy") -> None: + self._context_compression_mode = context_compression_mode + def run( self, requirement: TaskRequirement, @@ -67,6 +91,11 @@ def run( step_deadline_monotonic: float | None = None, step_timeout_seconds: int | None = None, checkpoint: dict[str, Any] | None = None, + context_compression_mode: str | None = None, + acp_config: AcpConfig | None = None, + session_id: str | None = None, + frame_kind: str | None = None, + session_acp_nudge: dict[str, Any] | None = None, ) -> TaskExecutionResult: max_actions = max(1, min(int(max_actions), 100)) checkpoint = dict(checkpoint or {}) @@ -105,6 +134,24 @@ def run( recent_task_summaries = _string_list( checkpoint.get("recent_task_summaries") )[-8:] + acp_mode = (context_compression_mode or self._context_compression_mode) == "acp" + acp_meter = ( + RealUsageMeter( + usage_source=lambda: latest_llm_usage_observation(session_id) + ) + if acp_mode + else None + ) + acp_engine = ( + _acp_engine_for_transcript( + transcript, + checkpoint.get("acp_state"), + meter=acp_meter, + config=acp_config, + ) + if acp_mode + else None + ) # A non-retryable failure only blocks an identical call inside this # invocation of the AgentLoop. Persisting the signature in the # checkpoint made a later user turn inherit an obsolete failure even @@ -113,6 +160,7 @@ def run( allowed_names = requirement.capability_manifest.allowed_names() system_prompt = PROMPT_PATH.read_text(encoding="utf-8").strip() pending_actions: list[HarnessAction] = [] + collected_acp_ops: list[dict[str, Any]] = [] def finish(result: TaskExecutionResult) -> TaskExecutionResult: summary = " ".join( @@ -131,7 +179,7 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: "version": 1, "task_frame_id": requirement.task_frame_id, "step_id": current_step_id, - "transcript": _transcript_for_model(transcript), + "transcript": _transcript_for_model(transcript, acp_mode=acp_mode), "citations": citations[-20:], "evidence_results": evidence_results[-10:], "capability_results": capability_results[-20:], @@ -143,6 +191,16 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: "loaded_general_skill_names": loaded_general_skill_names[-20:], "recent_task_summaries": recent_task_summaries[-8:], } + if acp_mode and acp_engine is not None: + result.loop_checkpoint["acp_state"] = _serialize_acp_engine_state( + acp_engine + ) + # Task-layer acp_ops target the task transcript's sequence layout; + # the session layer reads the same general:{session_id} frame for + # conversation frames, so exporting them there would mis-index the + # session engine. Only non-conversation (task) frames export ops. + if collected_acp_ops and frame_kind != "conversation": + result.loop_checkpoint["acp_ops"] = list(collected_acp_ops) return result for iteration in range(1, max_actions + 1): @@ -183,6 +241,12 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: payload["agent_loop_memory"] = { "recent_task_summaries": list(recent_task_summaries), } + if acp_mode and acp_engine is not None and acp_meter is not None: + nudge = _acp_task_nudge(acp_engine, acp_meter, transcript) + if nudge is not None: + payload["acp_nudge"] = nudge + if session_acp_nudge: + payload["session_acp_nudge"] = dict(session_acp_nudge) if attachment_context is not None: payload["conversation_context"] = attachment_context try: @@ -204,6 +268,7 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: client = _deadline_llm_client( model_config, step_deadline_monotonic, + session_id=session_id, ) raw = _generate_harness_action_json( client, @@ -307,6 +372,8 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: action_count=iteration, error={"code": "HARNESS_ACTION_INVALID", "message": str(exc)}, )) + if action is not None and isinstance(action.acp_ops, dict) and action.acp_ops: + collected_acp_ops.append(action.acp_ops) _raise_if_cancelled(is_cancelled) if _deadline_expired(step_deadline_monotonic): return finish(_step_timeout_result( @@ -447,7 +514,16 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: else: try: _raise_if_cancelled(is_cancelled) - result = invoke_tool(tool_name, dict(action.arguments or {})) + if acp_mode and tool_name in ACP_CAPABILITY_NAMES: + assert acp_engine is not None + result = _invoke_acp_capability( + acp_engine, + tool_name, + dict(action.arguments or {}), + transcript, + ) + else: + result = invoke_tool(tool_name, dict(action.arguments or {})) _raise_if_cancelled(is_cancelled) except (HarnessExecutionCancelled, HarnessExecutionFenced): raise @@ -464,21 +540,34 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: bounded_result = _bounded_capability_result(tool_name, result) if _is_loaded_general_skill_result(tool_name, result): loaded_general_skill_names.append(tool_name) - transcript.extend( - [ - { - "role": "assistant", - "action": "tool", - "tool_name": tool_name, - "arguments": action.arguments, - }, - { - "role": "tool", - "tool_name": tool_name, - "result": bounded_result, - }, - ] - ) + if ( + acp_mode + and tool_name in _TRANSCRIPT_SYNCED_ACP_CAPABILITIES + and result.get("success") is True + ): + # compress/decompress already replaced the compressed range + # with the summary entry (or restored the original entries) + # inside the kernel dispatch, keeping transcript and block + # store in sync; nothing is appended here. + pass + else: + transcript.extend( + [ + { + "role": "assistant", + "action": "tool", + "tool_name": tool_name, + "arguments": action.arguments, + }, + { + "role": "tool", + "tool_name": tool_name, + "result": bounded_result, + }, + ] + ) + if acp_mode and acp_engine is not None: + _append_transcript_blocks(acp_engine, transcript[-2:]) if tool_name not in {"capability_search", "capability_describe"}: capability_results.append(bounded_result) if _deadline_expired(step_deadline_monotonic): @@ -648,15 +737,30 @@ def _harness_actions_from_raw(raw: object) -> list[HarnessAction]: if isinstance(raw, dict) and set(raw) == {"actions"}: items = raw.get("actions") if not isinstance(items, list): - return [HarnessAction.model_validate(items)] + return [HarnessAction.model_validate(_sanitize_acp_ops(items))] if not items: raise ValueError("Harness action sequence must not be empty.") - actions = [HarnessAction.model_validate(item) for item in items] + actions = [HarnessAction.model_validate(_sanitize_acp_ops(item)) for item in items] if any(action.action == "finish" for action in actions[:-1]): raise ValueError("A finish action must be the final item in an action sequence.") return actions +def _sanitize_acp_ops(item: object) -> object: + """Drop malformed acp_ops so a bad op never fails the turn.""" + if not isinstance(item, dict) or "acp_ops" not in item: + return item + if isinstance(item["acp_ops"], dict): + return item + logger.warning( + "ignoring malformed acp_ops (expected dict, got %s)", + type(item["acp_ops"]).__name__, + ) + sanitized = dict(item) + sanitized.pop("acp_ops", None) + return sanitized + + def _adapt_general_skill_structured_result( raw: object, *, @@ -787,9 +891,10 @@ def _deadline_expired(deadline_monotonic: float | None) -> bool: def _deadline_llm_client( model_config: ModelConfig, deadline_monotonic: float | None, + session_id: str | None = None, ) -> LLMClient: if deadline_monotonic is None: - return LLMClient(model_config) + return LLMClient(model_config, session_id=session_id) remaining = max(deadline_monotonic - time.monotonic(), 0.1) configured = getattr(model_config, "timeout_seconds", None) timeout_seconds = min(float(configured), remaining) if configured else remaining @@ -799,7 +904,7 @@ def _deadline_llm_client( limited_config = model_config.model_copy( update={"timeout_seconds": timeout_seconds} ) - return LLMClient(limited_config) + return LLMClient(limited_config, session_id=session_id) def _step_timeout_result( @@ -880,6 +985,7 @@ def _transcript_for_model( transcript: list[dict[str, Any]], *, keep_recent_entries: int = 6, + acp_mode: bool = False, ) -> list[dict[str, Any]]: """Project persistent tool history into a bounded execution context. @@ -887,8 +993,14 @@ def _transcript_for_model( needs the newest interactions plus stable receipts for older operations. GeneralSkill package instructions are retained because they define the workflow the current AgentLoop is following. + + Under ACP the model drives compression itself via acp_compress, so the + full transcript is projected and the legacy receipt/cap path is bypassed. """ + if acp_mode: + return [dict(entry) for entry in transcript] + cutoff = max(0, len(transcript) - keep_recent_entries) latest_skill_instruction_index: dict[str, int] = {} for index, entry in enumerate(transcript): @@ -978,6 +1090,215 @@ def _is_general_skill_instruction_entry(entry: dict[str, Any]) -> bool: return isinstance(result, dict) and bool(result.get("success")) +def _acp_engine_for_transcript( + transcript: list[dict[str, Any]], + acp_state: object, + meter: TokenMeter | None = None, + config: AcpConfig | None = None, +) -> AcpEngine: + """Build the task-level kernel, restoring persisted state when possible. + + The kernel is framework-agnostic and has no persistence API, so the task + wrapper restores its internal stores directly (same format as the session + layer). Block ids are preserved so model-issued decompress references stay + valid across turns. A malformed or missing state falls back to seeding the + engine from the transcript; compressed content is then unrecoverable but + the task never crashes. + """ + engine = AcpEngine(config=config, meter=meter) + if isinstance(acp_state, dict): + _restore_acp_engine_state(engine, acp_state) + if len(engine.blocks()) != len(transcript): + engine = AcpEngine(config=config, meter=meter) + _append_transcript_blocks(engine, transcript) + return engine + + +def _acp_task_nudge( + engine: AcpEngine, + meter: RealUsageMeter, + transcript: list[dict[str, Any]], +) -> dict[str, Any] | None: + """Evaluate task-context pressure and build the advisory nudge payload. + + Real usage (the meter source) wins when available; otherwise the + pre-check estimate of the serialized transcript is used and flagged. + The nudge is advisory only — the model decides whether to compress. + """ + pressure_tokens = meter.latest_usage_tokens + estimated = pressure_tokens is None + if pressure_tokens is None: + pressure_tokens = meter.estimate_tokens( + json.dumps(transcript, ensure_ascii=False, default=str) + ) + recommendation = engine.nudge(pressure_tokens) + if recommendation is None: + return None + hint = ( + "如之前已压缩过任务记录,可先查看 acp_status 并复用仍有效的压缩块" + "(acp_decompress / acp_search_context 可找回细节)。" + ) + return { + "level": recommendation.level, + "message": f"{recommendation.message}\n{hint}", + "usage_pct": recommendation.usage_pct, + "current_tokens": recommendation.current_tokens, + "limit_tokens": recommendation.limit_tokens, + "estimated": estimated, + } + + +def _restore_acp_engine_state(engine: AcpEngine, acp_state: dict[str, Any]) -> None: + """Rebuild the task-level kernel from the persisted acp sub-state.""" + engine.from_state(acp_state) + + +def _serialize_acp_engine_state(engine: AcpEngine) -> dict[str, Any]: + """Persist the task-level kernel into the loop checkpoint. + + Every block content is the serialized transcript entry itself, so no + separate roles/ingested-id bookkeeping is needed (unlike the session + layer). Checkpoint originals are capped like the session layer so the + checkpoint state cannot grow unboundedly with compression count. + """ + return engine.to_state(max_originals=ACP_CHECKPOINT_ORIGINALS_CAP) + + +def _append_transcript_blocks( + engine: AcpEngine, + entries: list[dict[str, Any]], +) -> None: + for entry in entries: + engine.add_message( + f"t{len(engine.blocks())}", + json.dumps(entry, ensure_ascii=False, default=str), + ) + + +def _invoke_acp_capability( + engine: AcpEngine, + tool_name: str, + arguments: dict[str, Any], + transcript: list[dict[str, Any]], +) -> dict[str, Any]: + """Dispatch one ACP tool call to the task-level kernel. + + compress/decompress keep the transcript in sync with the kernel block + store: the compressed range is replaced by a summary entry (or the + summary entry is replaced by the restored original entries). Kernel + errors surface as structured capability errors the model can see. + """ + if tool_name == "acp_compress": + seq_start = _acp_int_argument(arguments, "seq_start") + seq_end = _acp_int_argument(arguments, "seq_end") + summary = str(arguments.get("summary") or "").strip() + if seq_start is None or seq_end is None or not summary: + return _acp_failure( + "INVALID_ARGUMENTS", + "acp_compress 需要整数 seq_start/seq_end 与非空 summary。", + ) + result = engine.compress(seq_start, seq_end, summary) + if isinstance(result, AcpError): + return _acp_failure(result.code, result.message, result.detail) + transcript[seq_start : seq_end + 1] = [ + { + "role": "tool", + "tool_name": "acp_compress", + "result": { + "success": True, + "data": { + "summary": summary, + "summary_block_id": result.summary_block_id, + "checkpoint_id": result.checkpoint_id, + "tier": result.tier, + "removed_block_ids": list(result.removed_block_ids), + "token_delta": result.token_delta, + "ledger_balance": result.ledger_balance, + }, + }, + } + ] + return {"success": True, "data": asdict(result)} + if tool_name == "acp_decompress": + block_id = _acp_int_argument(arguments, "block_id") + if block_id is None: + return _acp_failure("INVALID_ARGUMENTS", "acp_decompress 需要整数 block_id。") + position = _block_position(engine, block_id) + if position is None: + return _acp_failure( + "BLOCK_NOT_FOUND", + f"block {block_id} 不在当前压缩账本中。", + {"block_id": block_id}, + ) + result = engine.decompress(block_id) + if isinstance(result, AcpError): + return _acp_failure(result.code, result.message, result.detail) + restored = engine.blocks()[position : position + len(result.restored_block_ids)] + transcript[position : position + 1] = [ + _deserialize_transcript_entry(block.content) for block in restored + ] + return {"success": True, "data": asdict(result)} + if tool_name == "acp_search_context": + query = str(arguments.get("query") or "").strip() + if not query: + return _acp_failure("INVALID_ARGUMENTS", "acp_search_context 需要非空 query。") + top_k = arguments.get("top_k") + if top_k is not None and (isinstance(top_k, bool) or not isinstance(top_k, int)): + return _acp_failure("INVALID_ARGUMENTS", "acp_search_context top_k 必须是整数。") + return {"success": True, "data": asdict(engine.search_context(query, top_k=top_k))} + if tool_name == "acp_status": + return {"success": True, "data": asdict(engine.status())} + return _acp_failure( + "UNSUPPORTED_INTERNAL_CAPABILITY", + f"不支持的 ACP 能力:{tool_name}", + ) + + +def _acp_failure( + code: str, + message: str, + detail: dict[str, object] | None = None, +) -> dict[str, Any]: + error: dict[str, Any] = {"code": code, "message": message} + if detail: + error["detail"] = detail + return {"success": False, "error": error} + + +def _acp_int_argument(arguments: dict[str, Any], key: str) -> int | None: + value = arguments.get(key) + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def _block_position(engine: AcpEngine, block_id: int) -> int | None: + for index, block in enumerate(engine.blocks()): + if block.block_id == block_id: + return index + return None + + +def _deserialize_transcript_entry(content: str) -> dict[str, Any]: + try: + value = json.loads(content) + except (TypeError, ValueError): + value = None + if isinstance(value, dict): + return value + return { + "role": "tool", + "tool_name": "acp_decompress", + "result": { + "success": False, + "error": { + "code": "ACP_RESTORE_INVALID", + "message": "压缩块内容无法还原为 transcript 条目。", + }, + }, + } + + def _history_receipt(value: Any) -> dict[str, Any]: serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) return { diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index fc982a1e6..2345d8673 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -97,6 +97,7 @@ def __init__( ensure_execution_lease: Any | None = None, trace_sink: Callable[[str, dict[str, Any]], None] | None = None, step_deadline_monotonic: float | None = None, + context_compression_mode: str = "legacy", ) -> None: self.db = db self.tenant_id = tenant_id @@ -104,6 +105,7 @@ def __init__( self.task_frame_id = task_frame_id self.model_config = model_config self.manifest = manifest + self.context_compression_mode = context_compression_mode self.active_skill = active_skill self.active_skill_id = ( active_skill.skill_id if active_skill is not None else None @@ -390,6 +392,7 @@ def _currently_authorized_descriptor( self.agent_id, self.active_skill, self.active_step_id, + context_compression_mode=self.context_compression_mode, ) except CapabilityAuthorizationError: return None diff --git a/backend/app/core/harness_v2_engine.py b/backend/app/core/harness_v2_engine.py index 7f99513ae..322d3751a 100644 --- a/backend/app/core/harness_v2_engine.py +++ b/backend/app/core/harness_v2_engine.py @@ -12,6 +12,7 @@ from app.core.cancellation import is_chat_turn_cancelled from app.core.capability_discovery import project_capability_manifest from app.core.capability_manifest import CapabilityManifestBuilder +from app.config import get_settings from app.core.harness_agent import ( HarnessExecutionCancelled, HarnessExecutionFenced, @@ -556,6 +557,11 @@ def run(self, request: ChatTurnRequest) -> ChatTurnResponse: continue last_skill = active_skill or last_skill + session_nudge = ( + conversation_context.get("nudge") + if isinstance(conversation_context, dict) + else None + ) combined, step_result = self._run_frame( execution_request, session, @@ -569,6 +575,9 @@ def run(self, request: ChatTurnRequest) -> ChatTurnResponse: *self.store.referenced_session_results(row), ], remaining_turn_actions, + session_nudge=( + dict(session_nudge) if isinstance(session_nudge, dict) else None + ), ) remaining_turn_actions = max( 0, @@ -769,8 +778,28 @@ def _run_frame( memory_context: list[dict[str, object]], prior_frame_results: list[dict[str, Any]], max_actions: int, + session_nudge: dict[str, Any] | None = None, ) -> tuple[TaskExecutionResult, StepAgentResult]: self.store.mark_running(row) + # Same preference chain as the session layer (U4): the owner resolves + # the tenant preference; the ACP_ENABLED flag forces legacy when off. + # Lazy import: agent_loop imports this module at module level, so a + # top-level import here would be circular. + from app.core.agent_loop import _resolve_compression_mode + + context_compression_mode = _resolve_compression_mode( + self.owner._get_context_compression_mode( + request.tenant_id, session.agent_id + ), + acp_enabled=get_settings().acp_enabled, + ) + acp_config = None + if context_compression_mode == "acp": + # Lazy import: agent_loop imports this module at module level, so + # a top-level import here would be circular. + from app.core.agent_loop import _acp_config_for_tenant + + acp_config = _acp_config_for_tenant(self.db, request.tenant_id) agent_loop = self.store.ensure_agent_loop(row) loop_checkpoint = dict(agent_loop.checkpoint_json or {}) self.active_frame_id = row.id @@ -831,6 +860,7 @@ def _run_frame( session.agent_id, active_skill, frame.target_step_id, + context_compression_mode=context_compression_mode, ) # Keep the complete frozen manifest server-side for authorization, # while compiling the TaskRequirement only from the safe model @@ -934,6 +964,7 @@ def trace(event_type: str, payload: dict[str, Any]) -> None: ), trace_sink=trace, step_deadline_monotonic=step_deadline_monotonic, + context_compression_mode=context_compression_mode, ) result = self.task_agent.run( @@ -947,6 +978,11 @@ def trace(event_type: str, payload: dict[str, Any]) -> None: step_deadline_monotonic=step_deadline_monotonic, step_timeout_seconds=step_timeout_seconds, checkpoint=loop_checkpoint, + context_compression_mode=context_compression_mode, + acp_config=acp_config, + session_id=session.id, + frame_kind=frame.kind, + session_acp_nudge=session_nudge, ) deferred_continuation = False if frame.kind == "sop": diff --git a/backend/app/db/database.py b/backend/app/db/database.py index c01b92033..004095db2 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -474,6 +474,41 @@ def _migrate_sqlite_skill_schema() -> None: conn.execute( text("ALTER TABLE ui_configs ADD COLUMN harness_storage_path VARCHAR") ) + if "context_compression_mode" not in ui_columns: + conn.execute( + text( + "ALTER TABLE ui_configs ADD COLUMN context_compression_mode " + "VARCHAR(32) NOT NULL DEFAULT 'legacy'" + ) + ) + if "acp_model_context_limit" not in ui_columns: + conn.execute( + text( + "ALTER TABLE ui_configs ADD COLUMN acp_model_context_limit " + "INTEGER NOT NULL DEFAULT 128000" + ) + ) + if "acp_nudge_max_pct" not in ui_columns: + conn.execute( + text( + "ALTER TABLE ui_configs ADD COLUMN acp_nudge_max_pct " + "FLOAT NOT NULL DEFAULT 0.70" + ) + ) + if "acp_nudge_emergency_pct" not in ui_columns: + conn.execute( + text( + "ALTER TABLE ui_configs ADD COLUMN acp_nudge_emergency_pct " + "FLOAT NOT NULL DEFAULT 0.85" + ) + ) + if "acp_nudge_min_pct" not in ui_columns: + conn.execute( + text( + "ALTER TABLE ui_configs ADD COLUMN acp_nudge_min_pct " + "FLOAT NOT NULL DEFAULT 0.45" + ) + ) if "team_tasks" in tables: team_task_columns = { diff --git a/backend/app/db/models.py b/backend/app/db/models.py index fe2e15fda..6ab75ebf2 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -654,6 +654,11 @@ class UIConfig(SQLModel, table=True): sandbox_network_mode: str = Field(default="all") sandbox_allowed_domains: list[str] = Field(default_factory=list, sa_column=Column(JSON)) harness_storage_path: Optional[str] = None + context_compression_mode: str = Field(default="legacy") + acp_model_context_limit: int = 128000 + acp_nudge_max_pct: float = 0.70 + acp_nudge_emergency_pct: float = 0.85 + acp_nudge_min_pct: float = 0.45 created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 8f9e85cb3..817beadae 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -7,6 +7,7 @@ import json import math import re +import time from typing import Any from urllib.parse import urlsplit @@ -38,6 +39,60 @@ from app.observability.spans import current_llm_operation, llm_span_attributes, start_llm_call from app.security.encryption import decrypt_secret +# Latest real usage observations for the ACP shadow-price meter +# (core/acp/pricing.py), scoped per session so concurrent sessions never +# pollute each other. Absent usage clears the entry so the meter falls back +# to its estimate path instead of reusing a stale value (issue #54). +_USAGE_OBSERVATION_TTL_SECONDS = 300.0 +_usage_observations: dict[str, dict[str, object]] = {} + + +def _record_usage_observation( + session_id: str | None, + input_tokens: int | None, + source_chars: int | None, +) -> None: + """Record the latest real usage observation for the ACP shadow-price meter.""" + if not session_id: + return + if input_tokens is None or input_tokens < 0: + _usage_observations.pop(session_id, None) + return + _usage_observations[session_id] = { + "input_tokens": int(input_tokens), + "source_chars": max(1, int(source_chars or 0)), + "observed_at": time.time(), + } + + +def _touch_usage_observation(session_id: str | None) -> None: + """Refresh a session's observation timestamp without overwriting content.""" + if not session_id: + return + observation = _usage_observations.get(session_id) + if observation is not None: + observation["observed_at"] = time.time() + + +def latest_llm_usage_observation(session_id: str | None = None) -> dict[str, int] | None: + """Latest real ``(input_tokens, source_chars)`` observation for a session. + + Entries older than the TTL are dropped so a session that stopped calling + the LLM never feeds the meter a stale observation. + """ + if not session_id: + return None + observation = _usage_observations.get(session_id) + if observation is None: + return None + if time.time() - float(observation.get("observed_at") or 0) > _USAGE_OBSERVATION_TTL_SECONDS: + _usage_observations.pop(session_id, None) + return None + return { + "input_tokens": int(observation["input_tokens"]), + "source_chars": int(observation["source_chars"]), + } + class LLMError(Exception): """Raised when an LLM provider request or response normalization fails.""" @@ -89,7 +144,8 @@ class _CurrentStageText(str): class LLMClient: - def __init__(self, model_config: ModelConfig): + def __init__(self, model_config: ModelConfig, session_id: str | None = None): + self._session_id = session_id try: protocol = ModelApiProtocol( getattr(model_config, "api_protocol", "openai_chat_completions") @@ -259,6 +315,11 @@ def generate_text( raise content = _completion_message_content(completion) metrics = _completion_span_metrics(completion) + _record_usage_observation( + getattr(self, "_session_id", None), + metrics.get("input_tokens"), + request_shape.get("request_text_chars"), + ) response_message = _observable_completion_message(completion) response_payload = _observable_provider_payload(completion) if content.strip(): @@ -444,6 +505,11 @@ def generate_text_stream( raise if emitted_text: response_text = "".join(recorded_parts) + _record_usage_observation( + getattr(self, "_session_id", None), + stream_usage_metrics.get("input_tokens"), + request_shape.get("request_text_chars"), + ) response_message = { "role": "assistant", "content": response_text, @@ -473,6 +539,9 @@ def generate_text_stream( ), ) return + # Empty stream: keep the session's observation fresh (TTL) + # without overwriting its content with a no-text response. + _touch_usage_observation(getattr(self, "_session_id", None)) span.finish( provider_setup_ms=provider_setup_ms, ttft_ms=None, diff --git a/backend/app/llm/prompts/harness_agent_prompt.md b/backend/app/llm/prompts/harness_agent_prompt.md index b98bdb1c9..a17001d3e 100644 --- a/backend/app/llm/prompts/harness_agent_prompt.md +++ b/backend/app/llm/prompts/harness_agent_prompt.md @@ -134,6 +134,22 @@ prior_task_results 还可能包含由当前 Slot 中标识符精确引用的、 - 首行直接进入有信息量的回答。不要添加“结构化完成报告”“完成报告”“总结报告”等报告标题, 也不要机械套用“结论 / 过程要点 / 交付物”三段式。 +上下文压缩(ACP,仅当 payload 出现 acp_nudge 或 acp_* 能力时生效): +- acp_nudge 是建议性提示,不是强制要求:上下文压力接近模型窗口上限时出现,你可以自主决定 + 是否压缩历史消息,也可以忽略并继续当前任务。 +- session_acp_nudge 是会话层(用户对话)的上下文压力提示,同样非强制;会话层压力高时, + 说明用户对话历史也需要压缩,可在输出动作的同时附带 acp_ops 触发会话级压缩。 +- 压缩前先检查是否已有可复用的压缩块:调用 acp_status 查看账本与块索引,acp_search_context + 检索被压缩内容,acp_decompress 找回细节;只有确认旧压缩块无法满足当前需要时才执行新的 + acp_compress。 +- acp_compress 用 seq_start/seq_end 指定 transcript 条目范围(含两端),summary 必须是保留 + 关键事实的中文摘要;压缩后原文进入 checkpoint,可随时找回。 +- acp_ops 输出字段:可在任意动作 JSON 中附带可选字段 acp_ops,用于触发会话级压缩/解压/ + 搜索/状态操作,结构为 {"操作名": {"参数": 值}},例如: + {"action": "finish", "status": "completed", "acp_ops": {"compress": {"seq_start": 0, "seq_end": 2, "summary": "中文摘要"}}} + 支持 compress(seq_start/seq_end/summary)、decompress(block_id)、 + search_context(query/top_k)、status(无参数)。 + 每次只输出一个 JSON object: 调用工具: diff --git a/backend/app/llm/stage_protocol.py b/backend/app/llm/stage_protocol.py index ccdbcf11e..cee275d70 100644 --- a/backend/app/llm/stage_protocol.py +++ b/backend/app/llm/stage_protocol.py @@ -153,6 +153,13 @@ def stage_payload( if isinstance(memory_context, list) else str(memory_context or "").strip() ) + nudge = None + if isinstance(conversation_context, dict): + nudge = conversation_context.get("nudge") + if isinstance(nudge, dict): + message = str(nudge.get("message") or "").strip() + if message: + instructions = f"{instructions.strip()}\n\n{message}" return { STAGE_PROTOCOL_KEY: { "phase": phase, diff --git a/backend/tests/test_acp_blocks.py b/backend/tests/test_acp_blocks.py new file mode 100644 index 000000000..a1d394891 --- /dev/null +++ b/backend/tests/test_acp_blocks.py @@ -0,0 +1,83 @@ +"""Tests for message-level atomic blocks and the block store.""" + +from app.core.acp.blocks import Block, BlockStore + + +def test_each_message_becomes_one_block_with_sequential_ids() -> None: + store = BlockStore() + first = store.add("m1", "hello") + second = store.add("m2", "world") + assert first.block_id == 1 + assert second.block_id == 2 + assert first.message_id == "m1" + assert first.content == "hello" + assert not first.is_summary + assert first.tier == 1 + + +def test_add_many_creates_one_block_per_message() -> None: + store = BlockStore() + blocks = store.add_many([("m1", "a"), ("m2", "b"), ("m3", "c")]) + assert [block.block_id for block in blocks] == [1, 2, 3] + assert len(store) == 3 + + +def test_empty_content_block_is_allowed() -> None: + store = BlockStore() + block = store.add("m1", "") + assert block.content == "" + assert len(store) == 1 + + +def test_skip_flag_is_recorded_on_block() -> None: + store = BlockStore() + skipped = store.add("m1", "system instruction", skip=True) + normal = store.add("m2", "user message") + assert skipped.skip + assert not normal.skip + + +def test_get_returns_none_for_missing_block() -> None: + store = BlockStore() + store.add("m1", "hello") + assert store.get(99) is None + + +def test_replace_range_returns_removed_blocks() -> None: + store = BlockStore() + store.add_many([("m1", "a"), ("m2", "b"), ("m3", "c")]) + replacement = Block(block_id=4, message_id="acp_summary_1", content="abc", is_summary=True) + removed = store.replace(0, 2, replacement) + assert [block.block_id for block in removed] == [1, 2, 3] + assert store.all() == (replacement,) + + +def test_replace_by_id_swaps_block_in_place() -> None: + store = BlockStore() + store.add_many([("m1", "a"), ("m2", "b")]) + summary = Block( + block_id=3, + message_id="acp_summary_1", + content="ab", + is_summary=True, + checkpoint_id=1, + ) + store.replace(0, 1, summary) + assert store.all() == (summary,) + removed = store.replace_by_id( + 3, + [ + Block(block_id=1, message_id="m1", content="a"), + Block(block_id=2, message_id="m2", content="b"), + ], + ) + assert removed is not None + assert removed.block_id == 3 + assert [block.block_id for block in store.all()] == [1, 2] + + +def test_next_id_tracks_allocations() -> None: + store = BlockStore() + assert store.next_id() == 1 + store.add("m1", "a") + assert store.next_id() == 2 \ No newline at end of file diff --git a/backend/tests/test_acp_capability.py b/backend/tests/test_acp_capability.py new file mode 100644 index 000000000..cdc1d16fe --- /dev/null +++ b/backend/tests/test_acp_capability.py @@ -0,0 +1,604 @@ +"""Task-layer ACP capability registration and dispatch (U5). + +The four internal capabilities (acp_compress / acp_decompress / +acp_search_context / acp_status) are only advertised when the tenant +preference resolves to ACP; under legacy the names stay out of +``allowed_names()`` and a model call hits the illegal-tool error path. +""" + +import json +from copy import deepcopy + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from app.core.capability_manifest import ( + CapabilityManifestBuilder, + _acp_capability_descriptors, +) +from app.core.harness_agent import HarnessTaskAgent, _transcript_for_model +from app.core.task_request_compiler import ( + CapabilityDescriptor, + CapabilityManifest, + TaskRequirement, +) +from app.db.models import ModelConfig, Tenant +from app.core import harness_agent as harness_agent_module + +ACP_NAMES = {"acp_compress", "acp_decompress", "acp_search_context", "acp_status"} + + +def _model_config() -> ModelConfig: + return ModelConfig( + id="model-test", + tenant_id="tenant-demo", + name="测试模型", + api_key_encrypted="test", + model="test-model", + ) + + +def _acp_requirement(*, include_acp: bool = True) -> TaskRequirement: + available: list[CapabilityDescriptor] = [ + CapabilityDescriptor( + capability_id="allowed", + name="allowed.tool", + kind="tool", + ) + ] + if include_acp: + available.extend(_acp_capability_descriptors()) + return TaskRequirement( + task_frame_id="task-1", + kind="conversation", + goal="查询物流", + requirements=["查询 ORDER-1 的物流"], + capability_manifest=CapabilityManifest(available=available), + ) + + +def _fake_llm(monkeypatch, actions, payloads: list | None = None): + class FakeLLMClient: + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): + pass + + def generate_json( + self, system_prompt: str, payload: dict[str, object] + ) -> dict[str, object]: + if payloads is not None: + payloads.append(deepcopy(payload)) + return next(actions) + + monkeypatch.setattr(harness_agent_module, "LLMClient", FakeLLMClient) + + +def _tool_invoke(invoked: list | None = None): + def invoke_tool(name: str, arguments: dict[str, object]) -> dict[str, object]: + if invoked is not None: + invoked.append((name, arguments)) + return { + "success": True, + "data": {"status": "in_transit", "note": "ORDER-1 已发货"}, + } + + return invoke_tool + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def test_acp_capabilities_only_injected_when_mode_is_acp() -> None: + engine = _test_engine() + with Session(engine) as db: + db.add(Tenant(id="tenant-demo", name="Demo")) + db.commit() + acp_manifest = CapabilityManifestBuilder(db).build( + "tenant-demo", None, None, None, context_compression_mode="acp" + ) + legacy_manifest = CapabilityManifestBuilder(db).build( + "tenant-demo", None, None, None, context_compression_mode="legacy" + ) + default_manifest = CapabilityManifestBuilder(db).build( + "tenant-demo", None, None, None + ) + + assert ACP_NAMES <= acp_manifest.allowed_names() + assert not (ACP_NAMES & legacy_manifest.allowed_names()) + assert not (ACP_NAMES & default_manifest.allowed_names()) + descriptors = { + item.name: item + for item in acp_manifest.available + if item.name in ACP_NAMES + } + assert len(descriptors) == 4 + assert all(item.kind == "internal" for item in descriptors.values()) + assert all(item.available for item in descriptors.values()) + assert all( + item.capability_id.startswith("builtin.acp.") for item in descriptors.values() + ) + + +def test_acp_compress_in_task_produces_summary_block_and_checkpoint( + monkeypatch, +) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": { + "seq_start": 0, + "seq_end": 1, + "summary": "已查询 ORDER-1 物流状态。", + }, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + invoked: list[tuple[str, dict[str, object]]] = [] + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(invoked), + max_actions=3, + context_compression_mode="acp", + ) + + assert result.status == "completed" + assert invoked == [("allowed.tool", {"query": "ORDER-1"})] + transcript = result.loop_checkpoint["transcript"] + assert len(transcript) == 1 + summary_entry = transcript[0] + assert summary_entry["role"] == "tool" + assert summary_entry["tool_name"] == "acp_compress" + data = summary_entry["result"]["data"] + assert data["summary"] == "已查询 ORDER-1 物流状态。" + assert data["checkpoint_id"] == 1 + assert data["summary_block_id"] == 3 + assert data["removed_block_ids"] == [1, 2] + acp_state = result.loop_checkpoint["acp_state"] + assert acp_state["blocks"][0]["is_summary"] is True + assert len(acp_state["checkpoints"]) == 1 + originals = acp_state["checkpoints"][0]["original_blocks"] + assert len(originals) == 2 + assert "ORDER-1" in originals[1]["content"] + projected = _transcript_for_model(transcript, acp_mode=True) + assert projected[0]["tool_name"] == "acp_compress" + + +def test_acp_decompress_restores_original_entries(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": { + "seq_start": 0, + "seq_end": 1, + "summary": "已查询 ORDER-1 物流状态。", + }, + }, + { + "action": "tool", + "tool_name": "acp_decompress", + "arguments": {"block_id": 3}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=4, + context_compression_mode="acp", + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert len(transcript) == 2 + assert transcript[0]["role"] == "assistant" + assert transcript[0]["tool_name"] == "allowed.tool" + assert transcript[1]["role"] == "tool" + assert transcript[1]["tool_name"] == "allowed.tool" + assert "ORDER-1 已发货" in json.dumps(transcript, ensure_ascii=False) + acp_state = result.loop_checkpoint["acp_state"] + assert acp_state["blocks"][0]["is_summary"] is False + assert len(acp_state["checkpoints"]) == 1 + + +def test_acp_status_returns_compression_stats(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + {"action": "tool", "tool_name": "acp_status", "arguments": {}}, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=3, + context_compression_mode="acp", + ) + + assert result.status == "completed" + status_entry = next( + entry + for entry in result.loop_checkpoint["transcript"] + if entry.get("role") == "tool" and entry.get("tool_name") == "acp_status" + ) + assert status_entry["result"]["success"] is True + data = status_entry["result"]["data"] + assert data["total_blocks"] == 2 + assert data["original_blocks"] == 2 + assert data["summary_blocks"] == 0 + assert data["ledger_balance"] >= 0 + assert len(data["blocks"]) == 2 + + +def test_legacy_preference_excludes_acp_capabilities_and_illegal_call_is_structured( + monkeypatch, +) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": {"seq_start": 0, "seq_end": 1, "summary": "x"}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(include_acp=False), + _model_config(), + _tool_invoke(), + max_actions=2, + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert transcript[0]["tool_name"] == "acp_compress" + assert transcript[0]["result"]["success"] is False + assert transcript[0]["result"]["error"]["code"] == "TOOL_NOT_AVAILABLE" + assert "acp_state" not in result.loop_checkpoint + + +def test_unknown_tool_name_yields_structured_error(monkeypatch) -> None: + actions = iter( + [ + {"action": "tool", "tool_name": "acp_unknown", "arguments": {}}, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=2, + context_compression_mode="acp", + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert transcript[0]["tool_name"] == "acp_unknown" + assert transcript[0]["result"]["success"] is False + assert transcript[0]["result"]["error"]["code"] == "TOOL_NOT_AVAILABLE" + + +def test_acp_search_context_hits_compressed_content_round_trip(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": { + "seq_start": 0, + "seq_end": 1, + "summary": "已查询物流状态。", + }, + }, + { + "action": "tool", + "tool_name": "acp_search_context", + "arguments": {"query": "ORDER-1"}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=4, + context_compression_mode="acp", + ) + + assert result.status == "completed" + search_entry = next( + entry + for entry in result.loop_checkpoint["transcript"] + if entry.get("role") == "tool" and entry.get("tool_name") == "acp_search_context" + ) + assert search_entry["result"]["success"] is True + data = search_entry["result"]["data"] + assert data["matched"] is True + assert data["total"] >= 1 + assert data["hits"][0]["source"] == "hidden" + assert data["hits"][0]["block_id"] == 3 + + +def test_acp_state_round_trips_across_turns(monkeypatch) -> None: + first_actions = iter( + [ + { + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": { + "seq_start": 0, + "seq_end": 1, + "summary": "已查询 ORDER-1 物流状态。", + }, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, first_actions) + first = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=3, + context_compression_mode="acp", + ) + assert first.loop_checkpoint["transcript"][0]["tool_name"] == "acp_compress" + + second_actions = iter( + [ + { + "action": "tool", + "tool_name": "acp_decompress", + "arguments": {"block_id": 3}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, second_actions) + second = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=2, + context_compression_mode="acp", + checkpoint=first.loop_checkpoint, + ) + + assert second.status == "completed" + transcript = second.loop_checkpoint["transcript"] + assert len(transcript) == 2 + assert transcript[0]["role"] == "assistant" + assert transcript[0]["tool_name"] == "allowed.tool" + assert "ORDER-1 已发货" in json.dumps(transcript, ensure_ascii=False) + + +def test_acp_compress_invalid_range_returns_structured_error(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": {"seq_start": 0, "seq_end": 99, "summary": "x"}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=2, + context_compression_mode="acp", + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert transcript[0]["tool_name"] == "acp_compress" + assert transcript[1]["result"]["success"] is False + assert transcript[1]["result"]["error"]["code"] == "invalid_range" + + +def test_acp_compress_invalid_arguments_returns_structured_error(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "acp_compress", + "arguments": {"seq_start": 0, "seq_end": 1}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=2, + context_compression_mode="acp", + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert transcript[0]["tool_name"] == "acp_compress" + assert transcript[1]["result"]["success"] is False + assert transcript[1]["result"]["error"]["code"] == "INVALID_ARGUMENTS" + + +def test_acp_decompress_unknown_block_returns_structured_error(monkeypatch) -> None: + actions = iter( + [ + { + "action": "tool", + "tool_name": "acp_decompress", + "arguments": {"block_id": 999}, + }, + { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + }, + ] + ) + _fake_llm(monkeypatch, actions) + + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=2, + context_compression_mode="acp", + ) + + assert result.status == "completed" + transcript = result.loop_checkpoint["transcript"] + assert transcript[0]["tool_name"] == "acp_decompress" + assert transcript[1]["result"]["success"] is False + assert transcript[1]["result"]["error"]["code"] == "BLOCK_NOT_FOUND" + + +def test_legacy_transcript_projection_keeps_receipt_path_untouched() -> None: + transcript = [ + { + "role": "assistant", + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": "ORDER-1"}, + }, + { + "role": "tool", + "tool_name": "allowed.tool", + "result": {"success": True, "data": {"note": "x" * 500}}, + }, + ] + # 8 more entries push the first pair beyond the recent-6 window. + for index in range(4): + transcript.extend( + [ + { + "role": "assistant", + "action": "tool", + "tool_name": "allowed.tool", + "arguments": {"query": f"ORDER-{index}"}, + }, + { + "role": "tool", + "tool_name": "allowed.tool", + "result": {"success": True, "data": {"note": "y" * 500}}, + }, + ] + ) + legacy = _transcript_for_model(transcript) + assert legacy[0]["tool_name"] == "allowed.tool" + assert "history_receipt" in legacy[1]["result"] + acp = _transcript_for_model(transcript, acp_mode=True) + assert acp == transcript + assert "history_receipt" not in acp[1]["result"] \ No newline at end of file diff --git a/backend/tests/test_acp_checkpoint.py b/backend/tests/test_acp_checkpoint.py new file mode 100644 index 000000000..65bc436dc --- /dev/null +++ b/backend/tests/test_acp_checkpoint.py @@ -0,0 +1,70 @@ +"""Tests for checkpoint records and the checkpoint store.""" + +import pytest + +from app.core.acp.blocks import Block +from app.core.acp.checkpoint import CheckpointRecord, CheckpointStore + + +def _record(checkpoint_id: int = 1) -> CheckpointRecord: + return CheckpointRecord( + checkpoint_id=checkpoint_id, + seq_start=0, + seq_end=2, + summary_block_id=4, + tier=1, + original_blocks=( + Block(block_id=1, message_id="m1", content="a"), + Block(block_id=2, message_id="m2", content="b"), + Block(block_id=3, message_id="m3", content="c"), + ), + token_delta=10, + ) + + +def test_checkpoint_record_holds_seq_mapping_and_boundary_info() -> None: + record = _record() + assert record.seq_start == 0 + assert record.seq_end == 2 + assert record.summary_block_id == 4 + assert record.tier == 1 + assert [block.block_id for block in record.original_blocks] == [1, 2, 3] + + +def test_checkpoint_store_add_get_round_trip() -> None: + store = CheckpointStore() + store.add(_record()) + record = store.get(1) + assert record is not None + assert record.checkpoint_id == 1 + assert store.get(99) is None + + +def test_find_by_summary_block() -> None: + store = CheckpointStore() + store.add(_record()) + record = store.find_by_summary_block(4) + assert record is not None + assert record.checkpoint_id == 1 + assert store.find_by_summary_block(99) is None + + +def test_checkpoint_ids_increment() -> None: + store = CheckpointStore() + assert store.next_id() == 1 + store.add(_record(1)) + assert store.next_id() == 2 + + +def test_checkpoint_records_are_immutable() -> None: + record = _record() + with pytest.raises(AttributeError): + record.tier = 2 # type: ignore[misc] + + +def test_checkpoint_store_is_append_only() -> None: + store = CheckpointStore() + store.add(_record(1)) + store.add(_record(2)) + assert len(store) == 2 + assert [record.checkpoint_id for record in store.all()] == [1, 2] \ No newline at end of file diff --git a/backend/tests/test_acp_engine.py b/backend/tests/test_acp_engine.py new file mode 100644 index 000000000..49d23995a --- /dev/null +++ b/backend/tests/test_acp_engine.py @@ -0,0 +1,250 @@ +"""Integration tests for the AcpEngine facade.""" + +from app.core.acp import AcpEngine, AcpError, CompressResult, DecompressResult, SearchResult + + +def test_compress_decompress_round_trip_restores_original() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta"), ("m3", "gamma")]) + result = engine.compress(0, 2, "summary of alpha beta gamma") + assert isinstance(result, CompressResult) + assert result.tier == 1 + assert result.removed_block_ids == (1, 2, 3) + assert engine.blocks() == (engine.get_block(result.summary_block_id),) + restored = engine.decompress(result.summary_block_id) + assert isinstance(restored, DecompressResult) + assert restored.restored_block_ids == (1, 2, 3) + assert [block.content for block in engine.blocks()] == ["alpha", "beta", "gamma"] + + +def test_compress_invalid_range_returns_structured_error() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha")]) + result = engine.compress(1, 2, "summary") + assert isinstance(result, AcpError) + assert result.code == "invalid_range" + result = engine.compress(2, 1, "summary") + assert isinstance(result, AcpError) + assert result.code == "invalid_range" + result = engine.compress(-1, 0, "summary") + assert isinstance(result, AcpError) + assert result.code == "invalid_range" + + +def test_compress_single_message_block() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + result = engine.compress(1, 1, "beta summary") + assert isinstance(result, CompressResult) + assert result.removed_block_ids == (2,) + assert [block.message_id for block in engine.blocks()] == ["m1", "acp_summary_1"] + + +def test_compress_range_with_skipped_block_returns_error() -> None: + engine = AcpEngine() + engine.add_message("m1", "system instruction", skip=True) + engine.add_message("m2", "user message") + result = engine.compress(0, 1, "summary") + assert isinstance(result, AcpError) + assert result.code == "block_skipped" + + +def test_compress_empty_content_block() -> None: + engine = AcpEngine() + engine.add_message("m1", "") + engine.add_message("m2", "beta") + result = engine.compress(0, 1, "summary") + assert isinstance(result, CompressResult) + assert result.removed_block_ids == (1, 2) + + +def test_decompress_non_summary_block_returns_error() -> None: + engine = AcpEngine() + engine.add_message("m1", "alpha") + result = engine.decompress(1) + assert isinstance(result, AcpError) + assert result.code == "not_a_summary" + + +def test_decompress_missing_block_returns_error() -> None: + engine = AcpEngine() + result = engine.decompress(99) + assert isinstance(result, AcpError) + assert result.code == "block_not_found" + + +def test_compress_search_decompress_closed_loop() -> None: + engine = AcpEngine() + engine.add_messages( + [("m1", "退款政策 refund policy"), ("m2", "发货时间 shipping schedule")] + ) + result = engine.compress(0, 1, "历史消息摘要:退款与发货") + assert isinstance(result, CompressResult) + search = engine.search_context("退款") + assert isinstance(search, SearchResult) + assert search.matched + assert search.hits[0].block_id == result.summary_block_id + restored = engine.decompress(result.summary_block_id) + assert isinstance(restored, DecompressResult) + assert [block.content for block in engine.blocks()] == [ + "退款政策 refund policy", + "发货时间 shipping schedule", + ] + + +def test_search_hits_hidden_original_content() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "量子纠缠实验数据"), ("m2", "普通对话内容")]) + result = engine.compress(0, 1, "历史消息摘要") + assert isinstance(result, CompressResult) + search = engine.search_context("量子纠缠") + assert isinstance(search, SearchResult) + assert search.matched + assert search.hits[0].block_id == result.summary_block_id + assert search.hits[0].source == "hidden" + + +def test_nudge_via_engine() -> None: + engine = AcpEngine() + assert engine.nudge(1000) is None + recommendation = engine.nudge(int(128000 * 0.8)) + assert recommendation is not None + assert recommendation.level == "normal" + + +def test_status_via_engine() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + engine.compress(0, 1, "summary") + status = engine.status() + assert status.total_blocks == 1 + assert status.ledger_balance >= 0 + + +def test_ledger_never_negative_across_multiple_compressions() -> None: + """Regression for upstream issue #54: mixed estimates must not drive the ledger negative.""" + + class ErraticMeter: + def __init__(self) -> None: + self._calls = 0 + + def estimate_tokens(self, text: str) -> int: + self._calls += 1 + return 10 if self._calls % 2 else 100000 + + engine = AcpEngine(meter=ErraticMeter()) + engine.add_messages([(f"m{i}", f"message content {i}") for i in range(6)]) + for _ in range(3): + result = engine.compress(0, 1, "summary") + assert isinstance(result, CompressResult) + assert result.ledger_balance >= 0 + restored = engine.decompress(result.summary_block_id) + assert isinstance(restored, DecompressResult) + assert restored.ledger_balance >= 0 + assert engine.ledger.never_negative + + +def test_to_state_from_state_round_trip_preserves_engine() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta"), ("m3", "gamma")]) + result = engine.compress(0, 1, "summary of alpha beta") + assert isinstance(result, CompressResult) + state = engine.to_state() + + restored = AcpEngine() + restored.from_state(state) + + assert [block.message_id for block in restored.blocks()] == [ + "acp_summary_1", + "m3", + ] + assert restored.blocks()[0].is_summary is True + assert restored.blocks()[0].checkpoint_id == 1 + assert restored._store.next_id() == engine._store.next_id() + assert restored._checkpoints.next_id() == engine._checkpoints.next_id() + assert restored.ledger.balance == engine.ledger.balance + checkpoint = restored._checkpoints.get(1) + assert checkpoint is not None + assert [block.content for block in checkpoint.original_blocks] == ["alpha", "beta"] + decompressed = restored.decompress(result.summary_block_id) + assert isinstance(decompressed, DecompressResult) + assert [block.content for block in restored.blocks()] == ["alpha", "beta", "gamma"] + + +def test_to_state_max_originals_evicts_old_checkpoint_originals() -> None: + engine = AcpEngine() + engine.add_messages([(f"m{i}", f"content {i}") for i in range(8)]) + for index in range(3): + result = engine.compress(0, 1, f"summary {index}") + assert isinstance(result, CompressResult) + + state = engine.to_state(max_originals=2) + checkpoints = state["checkpoints"] + assert len(checkpoints) == 3 + assert checkpoints[0]["originals_evicted"] is True + assert checkpoints[0]["original_blocks"] == [] + assert checkpoints[1]["originals_evicted"] is False + assert len(checkpoints[1]["original_blocks"]) == 2 + assert checkpoints[2]["originals_evicted"] is False + assert len(checkpoints[2]["original_blocks"]) == 2 + + restored = AcpEngine() + restored.from_state(state) + assert restored._checkpoints.get(1).original_blocks == () + assert len(restored._checkpoints.get(3).original_blocks) == 2 + + +def test_decompress_evicted_checkpoint_returns_error_and_keeps_summary() -> None: + # Simulate a serialized state where the checkpoint's originals were + # evicted by the originals cap: the summary block stays visible but the + # checkpoint carries no original blocks. + state = { + "blocks": [ + { + "block_id": 1, + "message_id": "acp_summary_1", + "content": "摘要", + "tier": 1, + "is_summary": True, + "checkpoint_id": 1, + "skip": False, + } + ], + "checkpoints": [ + { + "checkpoint_id": 1, + "seq_start": 0, + "seq_end": 1, + "summary_block_id": 1, + "tier": 1, + "token_delta": 0, + "created_at": 0.0, + "originals_evicted": True, + "original_blocks": [], + } + ], + "next_block_id": 2, + "next_checkpoint_id": 2, + "ledger_balance": 0, + "ledger_warnings": [], + } + restored = AcpEngine() + restored.from_state(state) + + result = restored.decompress(1) + assert isinstance(result, AcpError) + assert result.code == "CHECKPOINT_ORIGINALS_EVICTED" + assert restored.blocks()[0].block_id == 1 + assert restored.blocks()[0].is_summary is True + + +def test_to_state_is_json_serializable() -> None: + import json + + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + engine.compress(0, 1, "summary") + state = engine.to_state(max_originals=1) + round_tripped = json.loads(json.dumps(state)) + assert round_tripped["blocks"][0]["is_summary"] is True + assert round_tripped["checkpoints"][0]["originals_evicted"] is False \ No newline at end of file diff --git a/backend/tests/test_acp_nudge.py b/backend/tests/test_acp_nudge.py new file mode 100644 index 000000000..e86393dc6 --- /dev/null +++ b/backend/tests/test_acp_nudge.py @@ -0,0 +1,59 @@ +"""Tests for pressure evaluation and nudge recommendations.""" + +import pytest + +from app.core.acp.config import AcpConfig +from app.core.acp.nudge import evaluate_pressure + + +def test_below_max_threshold_returns_none() -> None: + config = AcpConfig() + assert evaluate_pressure(int(128000 * 0.5), config) is None + + +def test_at_max_threshold_returns_normal_nudge() -> None: + config = AcpConfig() + recommendation = evaluate_pressure(int(128000 * 0.70), config) + assert recommendation is not None + assert recommendation.level == "normal" + assert recommendation.usage_pct == pytest.approx(0.70) + assert recommendation.limit_tokens == 128000 + + +def test_at_emergency_threshold_returns_emergency_nudge() -> None: + config = AcpConfig() + recommendation = evaluate_pressure(int(128000 * 0.85), config) + assert recommendation is not None + assert recommendation.level == "emergency" + + +def test_below_min_threshold_returns_none() -> None: + config = AcpConfig() + assert evaluate_pressure(int(128000 * 0.1), config) is None + + +def test_negative_tokens_are_clamped_to_zero() -> None: + config = AcpConfig() + assert evaluate_pressure(-100, config) is None + + +def test_nudge_is_advisory_and_never_mandatory() -> None: + config = AcpConfig() + recommendation = evaluate_pressure(128000, config) + assert recommendation is not None + assert "非强制" in recommendation.message + + +def test_config_validation_fails_fast() -> None: + with pytest.raises(ValueError): + AcpConfig(model_context_limit=0) + with pytest.raises(ValueError): + AcpConfig(nudge_max_context_limit_pct=1.5) + with pytest.raises(ValueError): + AcpConfig(nudge_min_context_limit_pct=0.9, nudge_max_context_limit_pct=0.7) + with pytest.raises(ValueError): + AcpConfig(search_top_k=0) + with pytest.raises(ValueError): + AcpConfig(search_ngram_size=1) + with pytest.raises(ValueError): + AcpConfig(max_tier=1) \ No newline at end of file diff --git a/backend/tests/test_acp_nudge_injection.py b/backend/tests/test_acp_nudge_injection.py new file mode 100644 index 000000000..a448842d2 --- /dev/null +++ b/backend/tests/test_acp_nudge_injection.py @@ -0,0 +1,433 @@ +"""U6 nudge injection tests: session-layer and task-layer advisory nudges. + +Covers pressure evaluation against real usage (the meter source) with the +pre-check estimate as fallback, threshold crossing (normal vs emergency +copy), and the guarantee that nudges appear only in ACP mode. +""" + +from copy import deepcopy +from types import SimpleNamespace + +from app.core import agent_loop as agent_loop_module +from app.core import harness_agent as harness_agent_module +from app.core.acp import AcpConfig, AcpEngine +from app.core.acp.nudge import NudgeRecommendation +from app.core.acp.pricing import RealUsageMeter +from app.core.agent_loop import ( + AgentLoop, + _acp_nudge_message, + _attach_acp_nudge, +) +from app.core.capability_manifest import _acp_capability_descriptors +from app.core.conversation_context import build_conversation_context +from app.core.harness_agent import HarnessTaskAgent, _acp_task_nudge +from app.core.task_request_compiler import ( + CapabilityDescriptor, + CapabilityManifest, + TaskRequirement, +) +from app.db.models import ModelConfig +from app.llm.stage_protocol import STAGE_PROTOCOL_KEY, stage_payload + +HIGH_USAGE = {"input_tokens": 100_000, "source_chars": 400_000} +EMERGENCY_USAGE = {"input_tokens": 115_000, "source_chars": 460_000} +LOW_USAGE = {"input_tokens": 30_000, "source_chars": 120_000} + + +def _model_config() -> ModelConfig: + return ModelConfig( + id="model-test", + tenant_id="tenant-demo", + name="测试模型", + api_key_encrypted="test", + model="test-model", + ) + + +def _acp_requirement(*, include_acp: bool = True) -> TaskRequirement: + available: list[CapabilityDescriptor] = [ + CapabilityDescriptor( + capability_id="allowed", + name="allowed.tool", + kind="tool", + ) + ] + if include_acp: + available.extend(_acp_capability_descriptors()) + return TaskRequirement( + task_frame_id="task-1", + kind="conversation", + goal="查询物流", + requirements=["查询 ORDER-1 的物流"], + capability_manifest=CapabilityManifest(available=available), + ) + + +def _fake_llm(monkeypatch, actions, payloads: list | None = None): + class FakeLLMClient: + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): + pass + + def generate_json( + self, system_prompt: str, payload: dict[str, object] + ) -> dict[str, object]: + if payloads is not None: + payloads.append(deepcopy(payload)) + return next(actions) + + monkeypatch.setattr(harness_agent_module, "LLMClient", FakeLLMClient) + + +def _tool_invoke(): + def invoke_tool(name: str, arguments: dict[str, object]) -> dict[str, object]: + return { + "success": True, + "data": {"status": "in_transit", "note": "ORDER-1 已发货"}, + } + + return invoke_tool + + +def _fake_db(): + return SimpleNamespace( + get=lambda _model, _key: None, + exec=lambda _stmt: SimpleNamespace(first=lambda: None), + ) + + +def _finish_action() -> dict[str, object]: + return { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + } + + +# -- session-layer nudge helpers ------------------------------------------- + + +def test_acp_nudge_message_normal_and_emergency_copy() -> None: + normal = NudgeRecommendation( + level="normal", + current_tokens=90_000, + limit_tokens=128_000, + usage_pct=0.70, + message="提示:上下文使用率已达 70%,可考虑压缩历史消息(非强制)。", + ) + emergency = NudgeRecommendation( + level="emergency", + current_tokens=110_000, + limit_tokens=128_000, + usage_pct=0.86, + message="紧急:上下文使用率已达 86%,建议立即压缩历史消息(非强制)。", + ) + normal_text = _acp_nudge_message(normal) + emergency_text = _acp_nudge_message(emergency) + assert "非强制" in normal_text + assert "acp_status" in normal_text + assert "紧急" in emergency_text + assert "非强制" in emergency_text + assert "acp_decompress" in emergency_text + + +def test_session_nudge_uses_real_usage_when_available() -> None: + engine = AcpEngine( + config=AcpConfig(model_context_limit=1_000, nudge_max_context_limit_pct=0.5) + ) + meter = RealUsageMeter() + meter.record_usage(input_tokens=600, source_chars=2_400) + context: dict[str, object] = {"metadata": {"estimated_tokens": 100}} + _attach_acp_nudge(context, engine, meter) + assert context["nudge"]["estimated"] is False + assert context["nudge"]["current_tokens"] == 600 + assert context["nudge"]["level"] == "normal" + + +def test_session_nudge_falls_back_to_estimate_with_flag() -> None: + engine = AcpEngine( + config=AcpConfig(model_context_limit=1_000, nudge_max_context_limit_pct=0.5) + ) + meter = RealUsageMeter() + context: dict[str, object] = {"metadata": {"estimated_tokens": 600}} + _attach_acp_nudge(context, engine, meter) + assert context["nudge"]["estimated"] is True + assert context["nudge"]["current_tokens"] == 600 + assert context["nudge"]["level"] == "normal" + + +def test_session_nudge_absent_below_threshold() -> None: + engine = AcpEngine( + config=AcpConfig(model_context_limit=1_000, nudge_max_context_limit_pct=0.5) + ) + meter = RealUsageMeter() + meter.record_usage(input_tokens=100, source_chars=400) + context: dict[str, object] = {"metadata": {"estimated_tokens": 100}} + _attach_acp_nudge(context, engine, meter) + assert "nudge" not in context + + +def test_session_nudge_emergency_escalates() -> None: + engine = AcpEngine( + config=AcpConfig( + model_context_limit=1_000, + nudge_max_context_limit_pct=0.5, + nudge_emergency_threshold_pct=0.8, + ) + ) + meter = RealUsageMeter() + meter.record_usage(input_tokens=900, source_chars=3_600) + context: dict[str, object] = {"metadata": {"estimated_tokens": 100}} + _attach_acp_nudge(context, engine, meter) + assert context["nudge"]["level"] == "emergency" + assert "紧急" in context["nudge"]["message"] + + +# -- session-layer integration --------------------------------------------- + + +def test_session_context_attaches_nudge_when_real_usage_crosses_threshold( + monkeypatch, +) -> None: + monkeypatch.setattr( + agent_loop_module, "latest_llm_usage_observation", lambda _session_id=None: HIGH_USAGE + ) + loop = AgentLoop.__new__(AgentLoop) + loop.db = _fake_db() + chat_session = SimpleNamespace( + tenant_id="tenant_test", + agent_id=None, + id="session_1", + context_state_json=None, + ) + context = AgentLoop._acp_conversation_context( + loop, + chat_session, + [{"id": "m1", "role": "user", "content": "你好"}], + ) + assert context["nudge"]["level"] == "normal" + assert context["nudge"]["estimated"] is False + assert "非强制" in context["nudge"]["message"] + + +def test_session_context_no_nudge_below_threshold(monkeypatch) -> None: + monkeypatch.setattr( + agent_loop_module, "latest_llm_usage_observation", lambda _session_id=None: LOW_USAGE + ) + loop = AgentLoop.__new__(AgentLoop) + loop.db = _fake_db() + chat_session = SimpleNamespace( + tenant_id="tenant_test", + agent_id=None, + id="session_1", + context_state_json=None, + ) + context = AgentLoop._acp_conversation_context( + loop, + chat_session, + [{"id": "m1", "role": "user", "content": "你好"}], + ) + assert "nudge" not in context + + +def test_session_stage_payload_contains_nudge_only_in_acp_mode(monkeypatch) -> None: + monkeypatch.setattr( + agent_loop_module, "latest_llm_usage_observation", lambda _session_id=None: HIGH_USAGE + ) + loop = AgentLoop.__new__(AgentLoop) + loop.db = _fake_db() + chat_session = SimpleNamespace( + tenant_id="tenant_test", + agent_id=None, + id="session_1", + context_state_json=None, + ) + acp_context = AgentLoop._acp_conversation_context( + loop, + chat_session, + [{"id": "m1", "role": "user", "content": "你好"}], + ) + payload = stage_payload( + phase="Router", + user_message="你好", + conversation_context=acp_context, + memory_context=None, + instructions="阶段规则原文", + stage_data={}, + output_contract="{}", + ) + instructions = payload[STAGE_PROTOCOL_KEY]["instructions"] + assert "阶段规则原文" in instructions + assert "非强制" in instructions + assert "acp_status" in instructions + + legacy_context = build_conversation_context([{"role": "user", "content": "你好"}]) + assert "nudge" not in legacy_context + legacy_payload = stage_payload( + phase="Router", + user_message="你好", + conversation_context=legacy_context, + memory_context=None, + instructions="阶段规则原文", + stage_data={}, + output_contract="{}", + ) + assert "非强制" not in legacy_payload[STAGE_PROTOCOL_KEY]["instructions"] + + +def test_stage_payload_ignores_malformed_nudge() -> None: + payload = stage_payload( + phase="Router", + user_message="你好", + conversation_context={"nudge": "not-a-dict"}, + memory_context=None, + instructions="阶段规则原文", + stage_data={}, + output_contract="{}", + ) + assert payload[STAGE_PROTOCOL_KEY]["instructions"] == "阶段规则原文" + + +# -- task-layer nudge ------------------------------------------------------ + + +def test_task_nudge_uses_real_usage_when_available() -> None: + engine = AcpEngine( + config=AcpConfig(model_context_limit=1_000, nudge_max_context_limit_pct=0.5) + ) + meter = RealUsageMeter() + meter.record_usage(input_tokens=600, source_chars=2_400) + nudge = _acp_task_nudge(engine, meter, []) + assert nudge is not None + assert nudge["estimated"] is False + assert nudge["current_tokens"] == 600 + assert nudge["level"] == "normal" + + +def test_task_nudge_falls_back_to_estimate_with_flag() -> None: + engine = AcpEngine() + meter = RealUsageMeter() + transcript = [ + { + "role": "tool", + "tool_name": "allowed.tool", + "result": {"success": True, "data": {"note": "内容" * 5_000}}, + } + for _ in range(40) + ] + nudge = _acp_task_nudge(engine, meter, transcript) + assert nudge is not None + assert nudge["estimated"] is True + assert nudge["level"] == "normal" + assert "非强制" in nudge["message"] + + +def test_task_nudge_absent_below_threshold() -> None: + engine = AcpEngine() + meter = RealUsageMeter() + meter.record_usage(input_tokens=30_000, source_chars=120_000) + assert _acp_task_nudge(engine, meter, []) is None + + +def test_task_nudge_emergency_escalates() -> None: + engine = AcpEngine() + meter = RealUsageMeter() + meter.record_usage(input_tokens=115_000, source_chars=460_000) + nudge = _acp_task_nudge(engine, meter, []) + assert nudge is not None + assert nudge["level"] == "emergency" + assert "紧急" in nudge["message"] + + +def test_task_payload_contains_nudge_only_in_acp_mode(monkeypatch) -> None: + monkeypatch.setattr( + harness_agent_module, "latest_llm_usage_observation", lambda _session_id=None: HIGH_USAGE + ) + payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), payloads) + HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + ) + assert payloads[0]["acp_nudge"]["level"] == "normal" + assert "非强制" in payloads[0]["acp_nudge"]["message"] + + legacy_payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), legacy_payloads) + HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + ) + assert "acp_nudge" not in legacy_payloads[0] + + +def test_task_payload_no_nudge_below_threshold(monkeypatch) -> None: + monkeypatch.setattr( + harness_agent_module, "latest_llm_usage_observation", lambda _session_id=None: LOW_USAGE + ) + payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), payloads) + HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + ) + assert "acp_nudge" not in payloads[0] + + +def test_task_payload_emergency_nudge_escalates(monkeypatch) -> None: + monkeypatch.setattr( + harness_agent_module, "latest_llm_usage_observation", lambda _session_id=None: EMERGENCY_USAGE + ) + payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), payloads) + HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + ) + assert payloads[0]["acp_nudge"]["level"] == "emergency" + assert "紧急" in payloads[0]["acp_nudge"]["message"] + + +def test_task_payload_nudge_flagged_estimated_when_usage_absent(monkeypatch) -> None: + monkeypatch.setattr( + harness_agent_module, "latest_llm_usage_observation", lambda _session_id=None: None + ) + payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), payloads) + HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + ) + assert "acp_nudge" not in payloads[0] + + +def test_task_payload_nudge_does_not_block_finish(monkeypatch) -> None: + """autoNudge semantics: the nudge is advisory and never hard-blocks.""" + monkeypatch.setattr( + harness_agent_module, "latest_llm_usage_observation", lambda _session_id=None: EMERGENCY_USAGE + ) + payloads: list[dict[str, object]] = [] + _fake_llm(monkeypatch, iter([_finish_action()]), payloads) + result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + ) + assert result.status == "completed" + assert payloads[0]["acp_nudge"]["level"] == "emergency" \ No newline at end of file diff --git a/backend/tests/test_acp_pricing.py b/backend/tests/test_acp_pricing.py new file mode 100644 index 000000000..5b62ef3a7 --- /dev/null +++ b/backend/tests/test_acp_pricing.py @@ -0,0 +1,175 @@ +"""Tests for shadow-price accounting with an injectable token meter.""" + +from app.core.acp import AcpEngine, CompressResult +from app.core.acp.pricing import AcpLedger, RealUsageMeter, TokenMeter + + +class FixedMeter: + """Mock meter returning a fixed token count per call.""" + + def __init__(self, tokens: int) -> None: + self._tokens = tokens + self.calls = 0 + + def estimate_tokens(self, text: str) -> int: + self.calls += 1 + return self._tokens + + +def test_credit_increases_balance() -> None: + ledger = AcpLedger() + entry = ledger.credit("compress", 100) + assert entry.balance == 100 + assert ledger.balance == 100 + + +def test_debit_decreases_balance() -> None: + ledger = AcpLedger(initial_balance=100) + entry = ledger.debit("decompress", 40) + assert entry.balance == 60 + assert ledger.balance == 60 + + +def test_debit_never_drives_balance_negative() -> None: + ledger = AcpLedger(initial_balance=10) + entry = ledger.debit("decompress", 500) + assert entry.balance == 0 + assert ledger.balance == 0 + assert entry.source == "clamped" + assert entry.warning is not None + assert ledger.warnings + + +def test_never_negative_across_mixed_operations() -> None: + ledger = AcpLedger(initial_balance=50) + ledger.credit("compress", 200) + ledger.debit("decompress", 1000) + ledger.credit("compress", 30) + ledger.debit("decompress", 9999) + assert ledger.never_negative + assert ledger.balance == 0 + assert len(ledger.warnings) == 2 + + +def test_injected_meter_values_are_used() -> None: + meter: TokenMeter = FixedMeter(42) + ledger = AcpLedger(meter=meter) + assert ledger.estimate("any text") == 42 + assert meter.calls == 1 + + +def test_fallback_estimate_without_meter() -> None: + ledger = AcpLedger() + assert ledger.estimate("") == 1 + assert ledger.estimate("abcd") == 1 + assert ledger.estimate("abcdefgh") == 2 + + +def test_entries_are_recorded_in_order() -> None: + ledger = AcpLedger(initial_balance=10) + ledger.credit("compress", 5) + ledger.debit("decompress", 3) + entries = ledger.entries + assert [entry.operation for entry in entries] == ["compress", "decompress"] + assert [entry.balance for entry in entries] == [15, 12] + + +def test_negative_charges_are_clamped_to_zero() -> None: + ledger = AcpLedger(initial_balance=10) + ledger.credit("compress", -5) + assert ledger.balance == 10 + ledger.debit("decompress", -5) + assert ledger.balance == 10 + + +def test_real_usage_meter_calibrates_to_host_tokenizer() -> None: + meter = RealUsageMeter() + meter.record_usage(input_tokens=100, source_chars=400) + assert meter.latest_usage_tokens == 100 + assert meter.estimate_tokens("x" * 40) == 10 + assert meter.last_estimate_estimated is False + + +def test_real_usage_meter_falls_back_to_estimate_with_flag() -> None: + meter = RealUsageMeter() + assert meter.latest_usage_tokens is None + assert meter.estimate_tokens("abcdefgh") == 2 + assert meter.last_estimate_estimated is True + + +def test_real_usage_meter_reads_usage_source_callable() -> None: + source = {"input_tokens": 200, "source_chars": 800} + meter = RealUsageMeter(usage_source=lambda: source) + assert meter.latest_usage_tokens == 200 + assert meter.estimate_tokens("x" * 80) == 20 + assert meter.last_estimate_estimated is False + source = None + assert meter.latest_usage_tokens is None + assert meter.estimate_tokens("abcdefgh") == 2 + assert meter.last_estimate_estimated is True + + +def test_real_usage_meter_absent_usage_clears_recorded_observation() -> None: + meter = RealUsageMeter() + meter.record_usage(input_tokens=100, source_chars=400) + meter.record_usage(input_tokens=None) + assert meter.latest_usage_tokens is None + assert meter.estimate_tokens("abcdefgh") == 2 + assert meter.last_estimate_estimated is True + + +def test_ledger_entries_flagged_estimated_when_meter_falls_back() -> None: + meter = RealUsageMeter() + ledger = AcpLedger(meter=meter, initial_balance=100) + ledger.credit("compress", ledger.estimate("removed text")) + ledger.debit("compress", ledger.estimate("summary")) + assert all(entry.estimated for entry in ledger.entries) + + +def test_ledger_entries_not_flagged_when_real_usage_exists() -> None: + meter = RealUsageMeter() + meter.record_usage(input_tokens=100, source_chars=400) + ledger = AcpLedger(meter=meter, initial_balance=100) + ledger.credit("compress", ledger.estimate("removed text")) + ledger.debit("compress", ledger.estimate("summary")) + assert not any(entry.estimated for entry in ledger.entries) + + +def test_ledger_never_negative_with_real_usage_meter_across_rounds() -> None: + """Issue #54 regression: real-usage accounting never drives the ledger negative.""" + meter = RealUsageMeter() + ledger = AcpLedger(meter=meter, initial_balance=50) + for round_index in range(5): + meter.record_usage(input_tokens=1000 + round_index, source_chars=4000) + ledger.credit("compress", ledger.estimate("removed " * 100)) + ledger.debit("compress", ledger.estimate("summary")) + ledger.debit("decompress", ledger.estimate("restored " * 100)) + assert ledger.never_negative + assert ledger.balance >= 0 + + +def test_ledger_never_negative_when_usage_absent() -> None: + """Issue #54 regression: estimate fallback still never drives the ledger negative.""" + meter = RealUsageMeter() + ledger = AcpLedger(meter=meter, initial_balance=10) + for _ in range(5): + ledger.credit("compress", ledger.estimate("removed " * 200)) + ledger.debit("compress", ledger.estimate("summary")) + ledger.debit("decompress", ledger.estimate("restored " * 500)) + assert ledger.never_negative + assert ledger.balance >= 0 + + +def test_engine_compress_rounds_never_negative_with_real_usage_meter() -> None: + """Issue #54 regression at the engine level with the production meter.""" + meter = RealUsageMeter() + engine = AcpEngine(meter=meter) + engine.add_messages([(f"m{i}", f"message content {i} " + "中" * 200) for i in range(6)]) + for round_index in range(3): + meter.record_usage(input_tokens=2000 + round_index, source_chars=8000) + result = engine.compress(0, 1, "summary") + assert isinstance(result, CompressResult) + assert result.ledger_balance >= 0 + restored = engine.decompress(result.summary_block_id) + assert restored.ledger_balance >= 0 + assert engine.ledger.never_negative \ No newline at end of file diff --git a/backend/tests/test_acp_search.py b/backend/tests/test_acp_search.py new file mode 100644 index 000000000..656923fb7 --- /dev/null +++ b/backend/tests/test_acp_search.py @@ -0,0 +1,88 @@ +"""Tests for lexical retrieval over blocks.""" + +from app.core.acp import AcpEngine, SearchResult +from app.core.acp.search import analyze, cjk_bigrams, search_blocks, stem + + +def test_cjk_bigram_path_hits_chinese_content() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "退款政策说明"), ("m2", "发货时间安排")]) + result = engine.search_context("退款") + assert isinstance(result, SearchResult) + assert result.matched + assert result.hits[0].block_id == 1 + assert "退款" in result.hits[0].matched_terms + + +def test_stemming_path_hits_english_content() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "refund policy details"), ("m2", "shipping schedule")]) + result = engine.search_context("refunds") + assert isinstance(result, SearchResult) + assert result.matched + assert result.hits[0].block_id == 1 + + +def test_mixed_chinese_english_content_both_paths_hit() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "退款政策 refund policy"), ("m2", "发货 shipping")]) + chinese = engine.search_context("退款") + english = engine.search_context("refunds") + assert chinese.matched and chinese.hits[0].block_id == 1 + assert english.matched and english.hits[0].block_id == 1 + + +def test_no_hits_returns_empty_result_without_raising() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "退款政策")]) + result = engine.search_context("量子计算") + assert isinstance(result, SearchResult) + assert not result.matched + assert result.hits == () + assert result.total == 0 + + +def test_fuzzy_ngram_matching_finds_close_terms() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "refund policy details")]) + result = engine.search_context("refundpolicy") + assert isinstance(result, SearchResult) + assert result.matched + + +def test_stem_and_bigram_helpers() -> None: + assert stem("refunds") == "refund" + assert stem("running") == "runn" + assert cjk_bigrams("退款政策") == ["退款", "款政", "政策"] + terms = analyze("退款 refunds") + assert terms["退款"] > 0 + assert terms["refund"] > 0 + + +def test_search_blocks_ranks_best_match_first() -> None: + engine = AcpEngine() + engine.add_messages( + [("m1", "shipping schedule"), ("m2", "refund policy and refund handling")] + ) + result = engine.search_context("refund") + assert isinstance(result, SearchResult) + assert result.hits[0].block_id == 2 + + +def test_search_blocks_top_k_truncation() -> None: + engine = AcpEngine() + engine.add_messages( + [("m1", "refund a"), ("m2", "refund b"), ("m3", "refund c"), ("m4", "refund d")] + ) + result = engine.search_context("refund", top_k=2) + assert isinstance(result, SearchResult) + assert len(result.hits) == 2 + assert result.total == 4 + assert result.truncated + + +def test_search_blocks_returns_all_hits_sorted() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + hits = search_blocks(engine.blocks(), "alpha") + assert [hit.block_id for hit in hits] == [1] \ No newline at end of file diff --git a/backend/tests/test_acp_status.py b/backend/tests/test_acp_status.py new file mode 100644 index 000000000..3a4f9cc30 --- /dev/null +++ b/backend/tests/test_acp_status.py @@ -0,0 +1,69 @@ +"""Tests for the acp_status report structure.""" + +from app.core.acp import AcpEngine, CompressResult + + +def test_status_reports_initial_state() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + status = engine.status() + assert status.total_blocks == 2 + assert status.total_chars == 9 + assert status.summary_blocks == 0 + assert status.original_blocks == 2 + assert status.tier_counts == {1: 2} + assert status.ledger_balance == 0 + assert status.ledger_warnings == () + + +def test_status_reports_compression_ledger() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta"), ("m3", "gamma")]) + result = engine.compress(0, 2, "summary") + assert isinstance(result, CompressResult) + status = engine.status() + assert status.total_blocks == 1 + assert status.summary_blocks == 1 + assert status.original_blocks == 0 + assert status.tier_counts == {1: 1} + assert status.ledger_balance == result.ledger_balance + + +def test_status_lists_block_ledger_entries() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha")]) + status = engine.status() + assert len(status.blocks) == 1 + entry = status.blocks[0] + assert entry.block_id == 1 + assert entry.message_id == "m1" + assert entry.tier == 1 + assert not entry.is_summary + assert entry.content_length == 5 + + +def test_status_lists_checkpoint_mapping() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + result = engine.compress(0, 1, "summary") + assert isinstance(result, CompressResult) + status = engine.status() + assert len(status.checkpoints) == 1 + mapping = status.checkpoints[0] + assert mapping.checkpoint_id == result.checkpoint_id + assert mapping.seq_start == 0 + assert mapping.seq_end == 1 + assert mapping.summary_block_id == result.summary_block_id + assert mapping.tier == 1 + + +def test_status_surfaces_ledger_warnings() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + result = engine.compress(0, 1, "summary") + assert isinstance(result, CompressResult) + engine.decompress(result.summary_block_id) + status = engine.status() + assert status.ledger_balance >= 0 + assert status.ledger_warnings + assert any("clamped" in warning for warning in status.ledger_warnings) \ No newline at end of file diff --git a/backend/tests/test_acp_tiers.py b/backend/tests/test_acp_tiers.py new file mode 100644 index 000000000..6ca661cbe --- /dev/null +++ b/backend/tests/test_acp_tiers.py @@ -0,0 +1,83 @@ +"""Tests for tiered distillation of summary nodes.""" + +import pytest + +from app.core.acp import AcpEngine, AcpError, CompressResult +from app.core.acp.tiers import MAX_TIER, can_distill, next_tier, tier_label + + +def test_next_tier_progression() -> None: + assert next_tier(1) == 2 + assert next_tier(2) == 3 + + +def test_next_tier_rejects_invalid_input() -> None: + with pytest.raises(ValueError): + next_tier(0) + with pytest.raises(ValueError): + next_tier(MAX_TIER) + + +def test_can_distill() -> None: + assert can_distill(1) + assert can_distill(2) + assert not can_distill(3) + assert not can_distill(0) + + +def test_tier_label() -> None: + assert tier_label(2) == "tier2" + + +def test_recompressing_summary_produces_tier2_then_tier3() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta"), ("m3", "gamma")]) + first = engine.compress(0, 2, "summary one") + assert isinstance(first, CompressResult) + assert first.tier == 1 + second = engine.compress(0, 0, "summary two") + assert isinstance(second, CompressResult) + assert second.tier == 2 + third = engine.compress(0, 0, "summary three") + assert isinstance(third, CompressResult) + assert third.tier == 3 + block = engine.get_block(third.summary_block_id) + assert block is not None + assert block.tier == 3 + assert block.is_summary + + +def test_original_checkpoints_preserved_after_distillation() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + first = engine.compress(0, 1, "s1") + second = engine.compress(0, 0, "s2") + assert isinstance(first, CompressResult) + assert isinstance(second, CompressResult) + status = engine.status() + assert len(status.checkpoints) == 2 + assert [checkpoint.tier for checkpoint in status.checkpoints] == [1, 2] + + +def test_distillation_beyond_max_tier_returns_structured_error() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha")]) + for _ in range(3): + result = engine.compress(0, 0, "summary") + assert isinstance(result, CompressResult) + result = engine.compress(0, 0, "summary again") + assert isinstance(result, AcpError) + assert result.code == "tier_limit" + + +def test_distilled_chain_decompresses_level_by_level() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "alpha"), ("m2", "beta")]) + first = engine.compress(0, 1, "tier1 summary") + second = engine.compress(0, 0, "tier2 summary") + assert isinstance(first, CompressResult) + assert isinstance(second, CompressResult) + restored = engine.decompress(second.summary_block_id) + assert restored is not None and not isinstance(restored, AcpError) + assert restored.restored_block_ids == (first.summary_block_id,) + assert [block.content for block in engine.blocks()] == ["tier1 summary"] \ No newline at end of file diff --git a/backend/tests/test_conversation_context.py b/backend/tests/test_conversation_context.py index 7be48e21f..54c5e5512 100644 --- a/backend/tests/test_conversation_context.py +++ b/backend/tests/test_conversation_context.py @@ -1,7 +1,32 @@ +from types import SimpleNamespace + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from app.core.acp import AcpEngine +from app.core.agent_loop import ( + AgentLoop, + _execute_acp_ops, + _resolve_compression_mode, + _restore_acp_engine, + _serialize_acp_engine, +) +from app.core.capability_manifest import _acp_capability_descriptors from app.core.conversation_context import ( + LONG_SUMMARY_PREFIX, + MEDIUM_SUMMARY_PREFIX, ConversationContextSettings, build_conversation_context, ) +from app.core.harness_agent import HarnessTaskAgent, _harness_actions_from_raw +from app.core.task_request_compiler import ( + CapabilityDescriptor, + CapabilityManifest, + TaskRequirement, +) +from app.db.models import HarnessAgentLoopRecord, ModelConfig +from app.llm.client import _fit_request_messages def test_conversation_context_keeps_full_history_under_budget() -> None: @@ -96,6 +121,812 @@ def summarize(label: str, source: str, _budget: int) -> str: assert second["metadata"]["estimated_tokens"] <= 700 +def test_legacy_mode_keeps_existing_contract_and_shape() -> None: + messages = [ + {"role": "user", "content": "你好"}, + {"role": "assistant", "content": "您好"}, + ] + + context = build_conversation_context(messages, token_budget=1_000) + + assert set(context) == {"messages", "compacted_summary", "context_state", "metadata"} + assert set(context["context_state"]) == { + "long_term_summary", + "medium_term_summary", + "summarized_through_message_id", + "compaction_count", + } + assert context["messages"] == messages + assert context["metadata"].get("compression_mode") != "acp" + + +def test_acp_mode_projects_summary_blocks_with_existing_prefixes() -> None: + acp_state = { + "blocks": [ + { + "block_id": 1, + "message_id": "acp_summary_1", + "role": "user", + "content": "模型书写的长期摘要", + "tier": 1, + "is_summary": True, + "checkpoint_id": 1, + "skip": False, + }, + { + "block_id": 2, + "message_id": "acp_summary_2", + "role": "user", + "content": "模型书写的近期摘要", + "tier": 1, + "is_summary": True, + "checkpoint_id": 2, + "skip": False, + }, + { + "block_id": 3, + "message_id": "m3", + "role": "user", + "content": "最新消息", + "tier": 1, + "is_summary": False, + "checkpoint_id": None, + "skip": False, + }, + ], + "checkpoints": [], + "roles": {"m3": "user"}, + "ingested_message_ids": ["m1", "m2", "m3"], + "compaction_count": 1, + } + + context = build_conversation_context( + [{"role": "user", "content": "最新消息"}], + token_budget=1_000, + context_state={"acp": acp_state}, + compression_mode="acp", + ) + messages = context["messages"] + + assert messages[0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert messages[1]["content"].startswith(MEDIUM_SUMMARY_PREFIX) + assert messages[2]["content"] == "最新消息" + assert context["metadata"]["compression_mode"] == "acp" + assert context["metadata"]["compacted"] is True + assert context["compacted_summary"] == "模型书写的长期摘要\n模型书写的近期摘要" + + +def test_acp_mode_loads_legacy_four_key_state_without_loss() -> None: + legacy_state = { + "long_term_summary": "长期摘要", + "medium_term_summary": "近期摘要", + "summarized_through_message_id": "message_3", + "compaction_count": 3, + } + messages = [ + { + "id": f"message_{index}", + "role": "user" if index % 2 == 0 else "assistant", + "content": f"内容 {index}", + } + for index in range(8) + ] + + context = build_conversation_context( + messages, + token_budget=1_000, + context_state=legacy_state, + compression_mode="acp", + ) + state = context["context_state"] + + assert state["compaction_count"] == 3 + assert state["long_term_summary"] == "长期摘要" + assert state["medium_term_summary"] == "近期摘要" + assert context["messages"][0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert context["messages"][1]["content"].startswith(MEDIUM_SUMMARY_PREFIX) + assert context["messages"][-1]["content"] == "内容 7" + + +def test_normalize_state_preserves_unknown_keys() -> None: + context = build_conversation_context( + [], + context_state={"future_key": {"a": 1}, "compaction_count": 2}, + compression_mode="acp", + ) + + assert context["context_state"]["future_key"] == {"a": 1} + assert context["context_state"]["compaction_count"] == 2 + + +def test_harness_action_parses_acp_ops_attached_field() -> None: + actions = _harness_actions_from_raw( + { + "action": "finish", + "status": "completed", + "acp_ops": {"compress": {"seq_start": 0, "seq_end": 2, "summary": "摘要"}}, + } + ) + + assert len(actions) == 1 + assert actions[0].acp_ops == { + "compress": {"seq_start": 0, "seq_end": 2, "summary": "摘要"} + } + + +def test_harness_action_ignores_malformed_acp_ops() -> None: + actions = _harness_actions_from_raw( + {"action": "finish", "status": "completed", "acp_ops": "not-a-dict"} + ) + + assert len(actions) == 1 + assert actions[0].acp_ops is None + assert actions[0].action == "finish" + assert actions[0].status == "completed" + + +def test_fit_request_messages_keeps_acp_summary_messages() -> None: + messages = [ + {"role": "user", "content": f"{LONG_SUMMARY_PREFIX}\n长期摘要内容"}, + {"role": "user", "content": f"{MEDIUM_SUMMARY_PREFIX}\n近期摘要内容"}, + *[ + {"role": "user", "content": f"填充消息 {index} " + "x" * 200} + for index in range(10) + ], + {"role": "user", "content": "最后一条"}, + ] + + fitted = _fit_request_messages(messages, token_budget=200) + + assert fitted[0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert fitted[1]["content"].startswith(MEDIUM_SUMMARY_PREFIX) + assert fitted[-1]["content"] == "最后一条" + + +def test_acp_flag_off_forces_legacy_routing() -> None: + assert _resolve_compression_mode("acp", acp_enabled=False) == "legacy" + assert _resolve_compression_mode("acp", acp_enabled=True) == "acp" + assert _resolve_compression_mode("legacy", acp_enabled=True) == "legacy" + + +def test_context_compression_mode_preference_chain() -> None: + owner = SimpleNamespace(db=SimpleNamespace(get=lambda _model, _key: None)) + assert AgentLoop._get_context_compression_mode(owner, "tenant_test") == "legacy" + + def get_ui_config(model, _key): + if model.__name__ == "UIConfig": + return SimpleNamespace(context_compression_mode="acp") + return None + + owner = SimpleNamespace(db=SimpleNamespace(get=get_ui_config)) + assert AgentLoop._get_context_compression_mode(owner, "tenant_test") == "acp" + + agent = SimpleNamespace( + tenant_id="tenant_test", + status="active", + metadata_json={"context_compression_mode": "acp"}, + ) + + def get_agent_override(model, key): + if model.__name__ == "AgentProfile" and key == "agent_1": + return agent + return SimpleNamespace(context_compression_mode="legacy") + + owner = SimpleNamespace(db=SimpleNamespace(get=get_agent_override)) + assert AgentLoop._get_context_compression_mode(owner, "tenant_test", "agent_1") == "acp" + + def get_invalid(model, _key): + return SimpleNamespace(context_compression_mode="auto") + + owner = SimpleNamespace(db=SimpleNamespace(get=get_invalid)) + assert AgentLoop._get_context_compression_mode(owner, "tenant_test") == "legacy" + + +def test_acp_ops_compress_search_decompress_roundtrip() -> None: + engine = AcpEngine() + engine.add_messages( + [ + ("m1", "用户说需要退款 500 元"), + ("m2", "客服确认订单号 A2"), + ("m3", "最新消息"), + ] + ) + + results, ok = _execute_acp_ops( + engine, [{"compress": {"seq_start": 0, "seq_end": 1, "summary": "用户申请退款,订单号 A2"}}] + ) + assert ok is True + assert results[0]["op"] == "compress" + assert results[0]["success"] is True + assert len(engine._checkpoints.all()) == 1 + + search, ok = _execute_acp_ops(engine, [{"search_context": {"query": "500"}}]) + assert ok is True + assert search[0]["result"]["matched"] is True + assert any(hit["source"] == "hidden" for hit in search[0]["result"]["hits"]) + + summary_block_id = engine.blocks()[0].block_id + decompress, ok = _execute_acp_ops( + engine, [{"decompress": {"block_id": summary_block_id}}] + ) + assert ok is True + assert decompress[0]["success"] is True + assert len(engine.blocks()) == 3 + + +def test_acp_checkpoint_originals_capped_to_most_recent_five() -> None: + engine = AcpEngine() + engine.add_messages([(f"m{index}", f"消息内容 {index} " + "x" * 50) for index in range(12)]) + for index in range(6): + results, ok = _execute_acp_ops( + engine, + [{"compress": {"seq_start": 0, "seq_end": 1, "summary": f"摘要 {index}"}}], + ) + assert ok is True + + state = _serialize_acp_engine( + engine, + ingested_message_ids=[f"m{index}" for index in range(12)], + roles={}, + compaction_count=6, + ) + checkpoints = state["checkpoints"] + + assert len(checkpoints) == 6 + with_originals = [item for item in checkpoints if item["original_blocks"]] + assert len(with_originals) == 5 + assert checkpoints[0]["originals_evicted"] is True + assert checkpoints[0]["original_blocks"] == [] + + +def test_acp_state_restores_engine_for_next_turn() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "第一条"), ("m2", "第二条"), ("m3", "第三条")]) + _execute_acp_ops( + engine, [{"compress": {"seq_start": 0, "seq_end": 1, "summary": "前两条摘要"}}] + ) + state = _serialize_acp_engine( + engine, + ingested_message_ids=["m1", "m2", "m3"], + roles={"m1": "user", "m2": "assistant", "m3": "user"}, + compaction_count=1, + ) + + restored = AcpEngine() + _restore_acp_engine(restored, state) + + assert [block.message_id for block in restored.blocks()] == ["acp_summary_1", "m3"] + assert restored.blocks()[0].is_summary is True + + context = build_conversation_context( + [], + token_budget=1_000, + context_state={"acp": state}, + compression_mode="acp", + ) + assert context["messages"][0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert context["messages"][1]["content"] == "第三条" + + +def test_legacy_mode_preserves_legacy_four_key_state() -> None: + legacy_state = { + "long_term_summary": "长期摘要", + "medium_term_summary": "近期摘要", + "summarized_through_message_id": "message_3", + "compaction_count": 3, + } + messages = [ + { + "id": f"message_{index}", + "role": "user" if index % 2 == 0 else "assistant", + "content": f"内容 {index}", + } + for index in range(8) + ] + + context = build_conversation_context( + messages, token_budget=1_000, context_state=legacy_state + ) + state = context["context_state"] + + assert state["compaction_count"] == 3 + assert state["long_term_summary"] == "长期摘要" + assert state["medium_term_summary"] == "近期摘要" + assert context["messages"][0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert context["messages"][1]["content"].startswith(MEDIUM_SUMMARY_PREFIX) + assert context["messages"][-1]["content"] == "内容 7" + + +def test_invalid_context_state_falls_back_to_empty_state() -> None: + messages = [{"role": "user", "content": "你好"}] + for bad_state in ( + None, + "not-a-dict", + 42, + {"acp": "not-a-dict"}, + {"acp": {"blocks": "bad"}}, + ): + context = build_conversation_context( + messages, + context_state=bad_state, # type: ignore[arg-type] + compression_mode="acp", + ) + state = context["context_state"] + assert state["compaction_count"] == 0 + assert state["long_term_summary"] == "" + assert context["messages"][-1]["content"] == "你好" + + +def test_legacy_mode_skips_summary_prefixed_messages_when_compacting() -> None: + messages = [ + {"id": "m1", "role": "user", "content": f"{LONG_SUMMARY_PREFIX}\n历史摘要内容"}, + {"id": "m2", "role": "user", "content": f"{MEDIUM_SUMMARY_PREFIX}\n近期摘要内容"}, + *[ + { + "id": f"m{index}", + "role": "user", + "content": f"普通消息 {index} " + "x" * 120, + } + for index in range(3, 15) + ], + ] + + context = build_conversation_context(messages, token_budget=300) + state = context["context_state"] + + assert state["compaction_count"] == 1 + assert "历史摘要内容" not in state["medium_term_summary"] + assert "近期摘要内容" not in state["medium_term_summary"] + assert state["summarized_through_message_id"] == "m8" + + +def test_acp_switch_back_preserves_checkpoints_and_merges_new_messages() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "第一条"), ("m2", "第二条"), ("m3", "第三条")]) + _execute_acp_ops( + engine, [{"compress": {"seq_start": 0, "seq_end": 1, "summary": "前两条摘要"}}] + ) + acp_state = _serialize_acp_engine( + engine, + ingested_message_ids=["m1", "m2", "m3"], + roles={"m1": "user", "m2": "assistant", "m3": "user"}, + compaction_count=1, + ) + context_state = {"acp": acp_state} + + # Legacy 期间:acp 子状态与 checkpoint 保留不清理。 + legacy_context = build_conversation_context( + [ + {"id": "m1", "role": "user", "content": "第一条"}, + {"id": "m2", "role": "assistant", "content": "第二条"}, + {"id": "m3", "role": "user", "content": "第三条"}, + ], + token_budget=1_000, + context_state=context_state, + ) + assert legacy_context["context_state"]["acp"] == acp_state + + # 切回 ACP:恢复引擎与 checkpoint,legacy 期间的新消息合并为新块。 + loop = object.__new__(AgentLoop) + loop.db = SimpleNamespace( + get=lambda _model, _key: None, + exec=lambda _stmt: SimpleNamespace(first=lambda: None), + ) + chat_session = SimpleNamespace( + id="session_1", + tenant_id="tenant_test", + agent_id=None, + context_state_json=context_state, + ) + context = loop._acp_conversation_context( + chat_session, + [ + {"id": "m1", "role": "user", "content": "第一条"}, + {"id": "m2", "role": "assistant", "content": "第二条"}, + {"id": "m3", "role": "user", "content": "第三条"}, + {"id": "m4", "role": "user", "content": "第四条"}, + ], + model_config=None, + ) + messages = context["messages"] + + assert messages[0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert messages[1]["content"] == "第三条" + assert messages[2]["content"] == "第四条" + next_acp = context["context_state"]["acp"] + assert len(next_acp["checkpoints"]) == 1 + assert "m4" in next_acp["ingested_message_ids"] + + +def test_fit_request_messages_keeps_mixed_legacy_and_acp_summaries() -> None: + messages = [ + {"role": "user", "content": f"{LONG_SUMMARY_PREFIX}\n长期摘要内容"}, + {"role": "user", "content": f"{MEDIUM_SUMMARY_PREFIX}\n近期摘要内容"}, + {"role": "user", "content": f"{LONG_SUMMARY_PREFIX}\nACP 摘要块内容"}, + *[ + {"role": "user", "content": f"填充消息 {index} " + "x" * 200} + for index in range(10) + ], + {"role": "user", "content": "最后一条"}, + ] + + fitted = _fit_request_messages(messages, token_budget=200) + + assert fitted[0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert fitted[1]["content"].startswith(MEDIUM_SUMMARY_PREFIX) + assert fitted[2]["content"].startswith(LONG_SUMMARY_PREFIX) + assert fitted[-1]["content"] == "最后一条" + + +# -- residual-fix regression tests ---------------------------------------- + + +def _memory_db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _acp_requirement() -> TaskRequirement: + available: list[CapabilityDescriptor] = [ + CapabilityDescriptor( + capability_id="allowed", + name="allowed.tool", + kind="tool", + ), + *_acp_capability_descriptors(), + ] + return TaskRequirement( + task_frame_id="task-1", + kind="conversation", + goal="查询物流", + requirements=["查询 ORDER-1 的物流"], + capability_manifest=CapabilityManifest(available=available), + ) + + +def _model_config() -> ModelConfig: + return ModelConfig( + id="model-test", + tenant_id="tenant-demo", + name="测试模型", + api_key_encrypted="test", + model="test-model", + ) + + +def _fake_llm(monkeypatch, actions): + class FakeLLMClient: + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): + pass + + def generate_json( + self, system_prompt: str, payload: dict[str, object] + ) -> dict[str, object]: + return next(actions) + + monkeypatch.setattr("app.core.harness_agent.LLMClient", FakeLLMClient) + + +def _tool_invoke(): + def invoke_tool(name: str, arguments: dict[str, object]) -> dict[str, object]: + return { + "success": True, + "data": {"status": "in_transit", "note": "ORDER-1 已发货"}, + } + + return invoke_tool + + +def test_acp_ops_persist_execute_clear_full_chain() -> None: + """Model acp_ops -> loop_checkpoint -> next turn pending -> execute -> clear.""" + engine = _memory_db() + with Session(engine) as db: + loop = HarnessAgentLoopRecord( + tenant_id="tenant_test", + session_id="session_1", + loop_key="general:session_1", + kind="general", + status="active", + checkpoint_json={ + "acp_ops": [ + {"compress": {"seq_start": 0, "seq_end": 1, "summary": "前两条摘要"}} + ] + }, + ) + db.add(loop) + db.commit() + db.refresh(loop) + + agent_loop = AgentLoop.__new__(AgentLoop) + agent_loop.db = db + chat_session = SimpleNamespace( + id="session_1", + tenant_id="tenant_test", + agent_id=None, + context_state_json=None, + ) + context = agent_loop._acp_conversation_context( + chat_session, + [ + {"id": "m1", "role": "user", "content": "第一条"}, + {"id": "m2", "role": "assistant", "content": "第二条"}, + {"id": "m3", "role": "user", "content": "第三条"}, + ], + model_config=None, + ) + db.commit() + db.refresh(loop) + + assert context["messages"][0]["content"].startswith(LONG_SUMMARY_PREFIX) + assert context["messages"][1]["content"] == "第三条" + assert "acp_ops" not in loop.checkpoint_json + next_acp = context["context_state"]["acp"] + assert len(next_acp["checkpoints"]) == 1 + assert next_acp["compacted_message_ids"] == ["m1", "m2"] + + +def test_acp_ops_invalid_arguments_return_structured_errors() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "第一条"), ("m2", "第二条")]) + + results, ok = _execute_acp_ops( + engine, [{"compress": {"seq_start": "abc", "seq_end": 1, "summary": "x"}}] + ) + assert ok is False + assert results[0]["success"] is False + assert results[0]["error"]["code"] == "INVALID_ARGUMENTS" + + results, ok = _execute_acp_ops( + engine, [{"compress": {"seq_start": 0, "summary": "x"}}] + ) + assert results[0]["error"]["code"] == "INVALID_ARGUMENTS" + + results, ok = _execute_acp_ops(engine, [{"decompress": {"block_id": "x"}}]) + assert results[0]["error"]["code"] == "INVALID_ARGUMENTS" + + results, ok = _execute_acp_ops(engine, [{"decompress": {}}]) + assert results[0]["error"]["code"] == "INVALID_ARGUMENTS" + + for bad_top_k in (0, 101, "5", True): + results, ok = _execute_acp_ops( + engine, [{"search_context": {"query": "q", "top_k": bad_top_k}}] + ) + assert results[0]["error"]["code"] == "INVALID_ARGUMENTS" + + results, ok = _execute_acp_ops( + engine, [{"search_context": {"query": "q", "top_k": 5}}] + ) + assert ok is True + assert results[0]["success"] is True + + +def test_acp_ops_partial_failure_keeps_successful_mutations() -> None: + engine = AcpEngine() + engine.add_messages([("m1", "第一条"), ("m2", "第二条"), ("m3", "第三条")]) + + results, ok = _execute_acp_ops( + engine, + [ + {"compress": {"seq_start": 0, "seq_end": 1, "summary": "前两条摘要"}}, + {"compress": {"seq_start": 99, "seq_end": 100, "summary": "越界"}}, + ], + ) + + assert ok is False + assert results[0]["success"] is True + assert results[1]["success"] is False + assert results[1]["error"]["code"] == "invalid_range" + assert len(engine.blocks()) == 2 + assert engine.blocks()[0].is_summary is True + assert engine.blocks()[1].message_id == "m3" + + +def test_acp_ops_partial_failure_persists_successful_mutations() -> None: + engine = _memory_db() + with Session(engine) as db: + loop = HarnessAgentLoopRecord( + tenant_id="tenant_test", + session_id="session_1", + loop_key="general:session_1", + kind="general", + status="active", + checkpoint_json={ + "acp_ops": [ + {"compress": {"seq_start": 0, "seq_end": 1, "summary": "前两条摘要"}}, + {"compress": {"seq_start": 99, "seq_end": 100, "summary": "越界"}}, + ] + }, + ) + db.add(loop) + db.commit() + db.refresh(loop) + + agent_loop = AgentLoop.__new__(AgentLoop) + agent_loop.db = db + chat_session = SimpleNamespace( + id="session_1", + tenant_id="tenant_test", + agent_id=None, + context_state_json=None, + ) + context = agent_loop._acp_conversation_context( + chat_session, + [ + {"id": "m1", "role": "user", "content": "第一条"}, + {"id": "m2", "role": "assistant", "content": "第二条"}, + {"id": "m3", "role": "user", "content": "第三条"}, + ], + model_config=None, + ) + db.commit() + db.refresh(loop) + + # Legacy fallback context, but the successful compress mutation is + # persisted into the acp sub-state instead of being discarded. + assert "acp_ops" not in loop.checkpoint_json + next_acp = chat_session.context_state_json["acp"] + assert len(next_acp["checkpoints"]) == 1 + assert next_acp["compacted_message_ids"] == ["m1", "m2"] + assert context["metadata"].get("compression_mode") != "acp" + + +def test_acp_ops_clear_runs_even_when_execution_raises(monkeypatch) -> None: + engine = _memory_db() + with Session(engine) as db: + loop = HarnessAgentLoopRecord( + tenant_id="tenant_test", + session_id="session_1", + loop_key="general:session_1", + kind="general", + status="active", + checkpoint_json={ + "acp_ops": [ + {"compress": {"seq_start": 0, "seq_end": 1, "summary": "摘要"}} + ] + }, + ) + db.add(loop) + db.commit() + db.refresh(loop) + + agent_loop = AgentLoop.__new__(AgentLoop) + agent_loop.db = db + chat_session = SimpleNamespace( + id="session_1", + tenant_id="tenant_test", + agent_id=None, + context_state_json=None, + ) + + def exploding_execute(engine, ops): + raise RuntimeError("boom") + + monkeypatch.setattr( + "app.core.agent_loop._execute_acp_ops", exploding_execute + ) + with pytest.raises(RuntimeError): + agent_loop._acp_conversation_context( + chat_session, + [{"id": "m1", "role": "user", "content": "第一条"}], + model_config=None, + ) + db.commit() + db.refresh(loop) + + assert "acp_ops" not in loop.checkpoint_json + + +def test_acp_evicted_decompress_returns_error_and_keeps_summary() -> None: + # Simulate a serialized state where the checkpoint's originals were + # evicted by the originals cap: the summary block stays visible but the + # checkpoint carries no original blocks. + state = { + "blocks": [ + { + "block_id": 1, + "message_id": "acp_summary_1", + "content": "摘要", + "tier": 1, + "is_summary": True, + "checkpoint_id": 1, + "skip": False, + } + ], + "checkpoints": [ + { + "checkpoint_id": 1, + "seq_start": 0, + "seq_end": 1, + "summary_block_id": 1, + "tier": 1, + "token_delta": 0, + "created_at": 0.0, + "originals_evicted": True, + "original_blocks": [], + } + ], + "next_block_id": 2, + "next_checkpoint_id": 2, + "ledger_balance": 0, + "ledger_warnings": [], + } + restored = AcpEngine() + _restore_acp_engine(restored, state) + + results, ok = _execute_acp_ops(restored, [{"decompress": {"block_id": 1}}]) + assert ok is False + assert results[0]["error"]["code"] == "CHECKPOINT_ORIGINALS_EVICTED" + assert restored.blocks()[0].block_id == 1 + assert restored.blocks()[0].is_summary is True + + +def test_legacy_compaction_excludes_acp_compacted_message_ids() -> None: + context_state = { + "acp": { + "blocks": [], + "checkpoints": [], + "compacted_message_ids": ["m1", "m2", "m3", "m4"], + } + } + messages = [ + { + "id": f"m{index}", + "role": "user" if index % 2 == 0 else "assistant", + "content": f"内容 {index} " + "x" * 120, + } + for index in range(1, 21) + ] + + context = build_conversation_context( + messages, token_budget=300, context_state=context_state + ) + state = context["context_state"] + + assert state["compaction_count"] == 1 + assert state["summarized_through_message_id"] == "m9" + assert "内容 1" not in state["medium_term_summary"] + assert "内容 2" not in state["medium_term_summary"] + assert "内容 5" in state["medium_term_summary"] + assert "内容 9" in state["medium_term_summary"] + + +def test_task_layer_acp_ops_export_gated_by_frame_kind(monkeypatch) -> None: + finish_with_ops = { + "action": "finish", + "status": "completed", + "reply_fragment": "完成。", + "task_summary": "完成。", + "acp_ops": {"compress": {"seq_start": 0, "seq_end": 1, "summary": "摘要"}}, + } + + _fake_llm(monkeypatch, iter([finish_with_ops])) + conversation_result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + frame_kind="conversation", + ) + assert "acp_ops" not in conversation_result.loop_checkpoint + + _fake_llm(monkeypatch, iter([finish_with_ops])) + sop_result = HarnessTaskAgent().run( + _acp_requirement(), + _model_config(), + _tool_invoke(), + max_actions=1, + context_compression_mode="acp", + frame_kind="sop", + ) + assert sop_result.loop_checkpoint["acp_ops"] == [ + {"compress": {"seq_start": 0, "seq_end": 1, "summary": "摘要"}} + ] def test_conversation_context_uses_runtime_compaction_settings() -> None: messages = [ { diff --git a/backend/tests/test_harness_v2.py b/backend/tests/test_harness_v2.py index 0147fc87c..8b4bd082c 100644 --- a/backend/tests/test_harness_v2.py +++ b/backend/tests/test_harness_v2.py @@ -779,7 +779,7 @@ def test_turn_planner_retries_schema_invalid_json(monkeypatch) -> None: ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -822,7 +822,7 @@ def test_turn_planner_exposes_sops_but_not_runtime_capabilities(monkeypatch) -> payloads: list[dict[str, object]] = [] class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -2905,7 +2905,7 @@ def test_harness_agent_enforces_tool_allowlist_and_keeps_an_isolated_transcript( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -3011,7 +3011,7 @@ def test_harness_agent_adapts_bare_json_after_loading_general_skill( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, _payload): @@ -3095,7 +3095,7 @@ def test_harness_agent_does_not_adapt_bare_json_without_loaded_general_skill( monkeypatch, ) -> None: class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, _payload): @@ -3150,7 +3150,7 @@ def test_harness_agent_repairs_invalid_tool_action_envelope_once( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, payload): @@ -3225,7 +3225,7 @@ def test_harness_agent_executes_consecutive_json_actions_in_order( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json_sequence(self, _system_prompt, _payload): @@ -3349,7 +3349,7 @@ def test_harness_agent_blocks_repeated_non_retryable_action( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, _payload): @@ -3420,7 +3420,7 @@ def test_harness_agent_does_not_restore_non_retryable_failures_from_checkpoint( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, _payload): @@ -3490,7 +3490,7 @@ def test_harness_agent_activates_described_capability_for_current_revision( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json(self, _system_prompt, _payload): @@ -3595,7 +3595,7 @@ def test_harness_agent_keeps_knowledge_results_and_citations_linked( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -3696,7 +3696,7 @@ def test_harness_agent_limits_successful_knowledge_searches_to_two( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -3793,7 +3793,7 @@ def test_harness_agent_projects_only_validated_current_turn_images( payloads: list[dict[str, object]] = [] class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -3906,7 +3906,7 @@ def test_harness_agent_drops_tampered_image_data_url( payloads: list[dict[str, object]] = [] class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -3985,7 +3985,7 @@ def test_harness_agent_cannot_skip_required_sop_tool( payloads: list[dict[str, object]] = [] class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -4064,7 +4064,7 @@ def test_harness_agent_requires_the_configured_knowledge_base(monkeypatch) -> No ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( @@ -4334,7 +4334,7 @@ def test_harness_agent_checkpoint_restores_transcript_across_activation( ) class FakeLLMClient: - def __init__(self, _model_config: ModelConfig): + def __init__(self, _model_config: ModelConfig, session_id: str | None = None): pass def generate_json( diff --git a/backend/tests/test_llm_client.py b/backend/tests/test_llm_client.py index c3df61afa..f9b95b823 100644 --- a/backend/tests/test_llm_client.py +++ b/backend/tests/test_llm_client.py @@ -1,3 +1,4 @@ +import time from types import SimpleNamespace import pytest @@ -1516,3 +1517,61 @@ def success_chunks(): assert len(calls) == 2 assert calls[0]["max_tokens"] == 65536 assert calls[1]["max_tokens"] == 65536 + + +def test_usage_observation_scoped_per_session() -> None: + from app.llm import client as client_module + + client_module._usage_observations.clear() + try: + client_module._record_usage_observation("session_a", 100, 400) + client_module._record_usage_observation("session_b", 200, 800) + + assert client_module.latest_llm_usage_observation("session_a") == { + "input_tokens": 100, + "source_chars": 400, + } + assert client_module.latest_llm_usage_observation("session_b") == { + "input_tokens": 200, + "source_chars": 800, + } + # Absent usage clears only the owning session. + client_module._record_usage_observation("session_a", None, None) + assert client_module.latest_llm_usage_observation("session_a") is None + assert client_module.latest_llm_usage_observation("session_b") is not None + # Unknown sessions never observe another session's usage. + assert client_module.latest_llm_usage_observation("session_c") is None + finally: + client_module._usage_observations.clear() + + +def test_usage_observation_touch_refreshes_ttl_without_overwriting() -> None: + from app.llm import client as client_module + + client_module._usage_observations.clear() + try: + client_module._record_usage_observation("session_a", 100, 400) + client_module._touch_usage_observation("session_a") + observation = client_module._usage_observations["session_a"] + assert observation["input_tokens"] == 100 + assert observation["source_chars"] == 400 + # Touch on an unknown session is a no-op. + client_module._touch_usage_observation("session_unknown") + assert "session_unknown" not in client_module._usage_observations + finally: + client_module._usage_observations.clear() + + +def test_usage_observation_expires_after_ttl() -> None: + from app.llm import client as client_module + + client_module._usage_observations.clear() + try: + client_module._record_usage_observation("session_a", 100, 400) + client_module._usage_observations["session_a"]["observed_at"] = ( + time.time() - client_module._USAGE_OBSERVATION_TTL_SECONDS - 1 + ) + assert client_module.latest_llm_usage_observation("session_a") is None + assert "session_a" not in client_module._usage_observations + finally: + client_module._usage_observations.clear() diff --git a/backend/tests/test_ui_config.py b/backend/tests/test_ui_config.py index d6cf6204c..090137015 100644 --- a/backend/tests/test_ui_config.py +++ b/backend/tests/test_ui_config.py @@ -1,15 +1,22 @@ from __future__ import annotations +from pathlib import Path +from types import SimpleNamespace + import pytest +from fastapi import HTTPException from pydantic import ValidationError +from sqlalchemy import create_engine as sqlalchemy_create_engine +from sqlalchemy import inspect, text +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine from app.api import ui_config as ui_config_module from app.api.ui_config import UIConfigUpdateRequest, ui_config_read from app.api.ui_config import update_enterprise_ui_config from app.core.agent_loop import AgentLoop +from app.db import database from app.db.models import Tenant, UIConfig, User -from sqlalchemy.pool import StaticPool -from sqlmodel import Session, SQLModel, create_engine from app.harness.sandbox import SandboxDiagnostics @@ -189,6 +196,208 @@ def test_sandbox_toggle_schedules_application_restart( assert scheduled == [True] +def test_context_compression_mode_defaults_to_legacy() -> None: + request = UIConfigUpdateRequest(tenant_id="tenant_demo") + + assert request.context_compression_mode == "legacy" + assert UIConfig(tenant_id="tenant_demo").context_compression_mode == "legacy" + assert ui_config_read(UIConfig(tenant_id="tenant_demo")).context_compression_mode == "legacy" + + +def test_context_compression_mode_round_trip() -> None: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.commit() + admin = User( + id="user_admin", + tenant_id="tenant_demo", + username="admin", + password_hash="unused", + role="admin", + ) + result = update_enterprise_ui_config( + UIConfigUpdateRequest(tenant_id="tenant_demo", context_compression_mode="acp"), + db, + admin, + ) + + assert result.context_compression_mode == "acp" + assert ui_config_read(UIConfig(tenant_id="tenant_demo", context_compression_mode="acp")).context_compression_mode == "acp" + + +def test_context_compression_mode_rejects_invalid_value() -> None: + with pytest.raises(ValidationError): + UIConfigUpdateRequest( + tenant_id="tenant_demo", + context_compression_mode="auto", # type: ignore[arg-type] + ) + + +def test_context_compression_mode_requires_tenant_admin() -> None: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.commit() + member = User( + id="user_member", + tenant_id="tenant_demo", + username="member", + password_hash="unused", + role="member", + ) + with pytest.raises(HTTPException) as exc_info: + update_enterprise_ui_config( + UIConfigUpdateRequest(tenant_id="tenant_demo", context_compression_mode="acp"), + db, + member, + ) + + assert exc_info.value.status_code == 403 + + +def test_context_compression_mode_migration_defaults_legacy( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + db_path = tmp_path / "ui-config-migration.db" + engine = sqlalchemy_create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + conn.execute( + text( + "CREATE TABLE ui_configs (" + "tenant_id VARCHAR PRIMARY KEY, " + "show_thinking_trace BOOLEAN NOT NULL DEFAULT 1, " + "show_skill_trace BOOLEAN NOT NULL DEFAULT 1, " + "show_tool_trace BOOLEAN NOT NULL DEFAULT 1, " + "reflection_max_rounds INTEGER NOT NULL DEFAULT 1, " + "agent_loop_max_actions INTEGER NOT NULL DEFAULT 32, " + "sandbox_enabled BOOLEAN NOT NULL DEFAULT 0, " + "sandbox_network_mode VARCHAR(32) NOT NULL DEFAULT 'all', " + "sandbox_allowed_domains JSON NOT NULL DEFAULT '[]', " + "harness_storage_path VARCHAR, " + "created_at DATETIME, " + "updated_at DATETIME)" + ) + ) + conn.execute( + text( + "INSERT INTO ui_configs (tenant_id, created_at, updated_at) " + "VALUES ('tenant_legacy', '2026-01-01 00:00:00', '2026-01-01 00:00:00')" + ) + ) + monkeypatch.setattr(database, "database_url", f"sqlite:///{db_path}") + monkeypatch.setattr(database, "engine", engine) + + database._migrate_sqlite_skill_schema() + database._migrate_sqlite_skill_schema() + + inspector = inspect(engine) + ui_columns = {column["name"] for column in inspector.get_columns("ui_configs")} + assert { + "context_compression_mode", + "acp_model_context_limit", + "acp_nudge_max_pct", + "acp_nudge_emergency_pct", + "acp_nudge_min_pct", + } <= ui_columns + with engine.connect() as conn: + row = conn.execute( + text("SELECT * FROM ui_configs WHERE tenant_id = 'tenant_legacy'") + ).mappings().one() + assert row["context_compression_mode"] == "legacy" + assert row["acp_model_context_limit"] == 128000 + assert row["acp_nudge_max_pct"] == 0.70 + assert row["acp_nudge_emergency_pct"] == 0.85 + assert row["acp_nudge_min_pct"] == 0.45 + + +def test_acp_threshold_defaults_round_trip() -> None: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.commit() + admin = User( + id="user_admin", + tenant_id="tenant_demo", + username="admin", + password_hash="unused", + role="admin", + ) + result = update_enterprise_ui_config( + UIConfigUpdateRequest( + tenant_id="tenant_demo", + acp_model_context_limit=256000, + acp_nudge_max_pct=0.60, + acp_nudge_emergency_pct=0.90, + acp_nudge_min_pct=0.30, + ), + db, + admin, + ) + + assert result.acp_model_context_limit == 256000 + assert result.acp_nudge_max_pct == 0.60 + assert result.acp_nudge_emergency_pct == 0.90 + assert result.acp_nudge_min_pct == 0.30 + assert ui_config_read( + UIConfig( + tenant_id="tenant_demo", + acp_model_context_limit=256000, + acp_nudge_max_pct=0.60, + acp_nudge_emergency_pct=0.90, + acp_nudge_min_pct=0.30, + ) + ).acp_model_context_limit == 256000 + + +def test_acp_enabled_flag_in_get_response(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ui_config_module, "get_settings", lambda: SimpleNamespace(acp_enabled=True)) + + result = ui_config_read(UIConfig(tenant_id="tenant_demo")) + + assert result.acp_enabled is True + + +def test_acp_threshold_order_is_validated() -> None: + with pytest.raises(ValidationError): + UIConfigUpdateRequest( + tenant_id="tenant_demo", + acp_nudge_min_pct=0.80, + acp_nudge_max_pct=0.70, + ) + with pytest.raises(ValidationError): + UIConfigUpdateRequest( + tenant_id="tenant_demo", + acp_nudge_max_pct=0.90, + acp_nudge_emergency_pct=0.85, + ) + with pytest.raises(ValidationError): + UIConfigUpdateRequest( + tenant_id="tenant_demo", + acp_nudge_emergency_pct=1.5, + ) + request = UIConfigUpdateRequest( + tenant_id="tenant_demo", + acp_nudge_min_pct=0.30, + acp_nudge_max_pct=0.70, + acp_nudge_emergency_pct=0.85, + ) + assert request.acp_nudge_max_pct == 0.70 def test_context_runtime_settings_persist_without_restart( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 06414d642..4a9c22938 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -3235,6 +3235,28 @@ "下载 JSON": "Download JSON", "下载群聊日志失败": "Failed to download group-chat log", "下载完整日志": "Download Complete Log", + "导出中…": "Exporting…", + "上下文压缩机制": "Context Compression Mechanism", + "标准压缩按固定阈值自动生成摘要;智能可恢复压缩由模型自主决定压缩时机与内容,压缩块可恢复、可检索。代价:压缩依赖模型主动触发,若未及时触发,长对话可能触及请求长度上限被裁剪;恢复与检索同样由模型发起。": "Standard compression summarizes automatically at fixed thresholds; smart recoverable compression lets the model decide when and what to compress, with recoverable and searchable blocks. Cost: compression depends on the model acting proactively — if it does not, long conversations may hit the request-length limit and be clipped; recovery and search are also model-initiated.", + "标准压缩": "Standard Compression", + "智能可恢复压缩": "Smart Recoverable Compression", + "(未启用)": " (Not Enabled)", + "ACP 功能未启用,以下阈值将在启用后生效。": "ACP is not enabled; these thresholds take effect once it is enabled.", + "上下文上限(token)": "Context Limit (tokens)", + "模型上下文窗口上限,上下文占用达到阈值比例时触发压缩决策。": "Model context window limit; compression decisions trigger when usage reaches the threshold ratio.", + "常规压缩触发阈值": "Regular Compression Trigger Ratio", + "上下文占用达到上限的该比例时,提示模型考虑压缩。": "When context usage reaches this ratio of the limit, prompt the model to consider compressing.", + "紧急压缩触发阈值": "Emergency Compression Trigger Ratio", + "上下文占用达到上限的该比例时,升级为紧急压缩提示。": "When context usage reaches this ratio of the limit, escalate to an urgent compression prompt.", + "最低压缩触发阈值": "Minimum Compression Trigger Ratio", + "上下文占用低于上限的该比例时,不再提示压缩。": "When context usage drops below this ratio of the limit, stop prompting for compression.", + "确认保存": "Confirm Save", + "切换上下文压缩机制?": "Switch context compression mechanism?", + "该偏好将应用于当前租户的全部会话,旧上下文将按新机制处理。": "This preference applies to all sessions of the current tenant; existing context will be processed under the new mechanism.", + "反思轮数、单轮最大动作数与 ACP 阈值必须是数字": "Reflection rounds, maximum actions per turn, and ACP thresholds must be numbers.", + "上下文上限不能小于 1": "Context window limit cannot be less than 1.", + "压缩触发阈值必须在 0 到 1 之间": "Compression trigger ratios must be between 0 and 1.", + "常规压缩触发阈值不能高于紧急压缩触发阈值,最低压缩触发阈值不能高于常规压缩触发阈值": "The regular compression trigger ratio cannot exceed the emergency ratio, and the minimum ratio cannot exceed the regular ratio.", "加载完整日志失败": "Failed to load complete log", "查看完整日志": "View Complete Log", "团队完整日志": "Complete Team Log", @@ -3254,7 +3276,6 @@ "原始事件与工具调用": "Raw Events and Tool Calls", "查看调度、黑板与完整原始 JSON": "View scheduling, blackboard, and complete raw JSON", "导出时间:": "Exported at: ", - "导出中…": "Exporting…", "对话上下文与自动压缩": "Conversation Context and Automatic Compaction", "租户级即时生效;单员工会话和团队成员任务共享这套参数。": "Applies immediately across the tenant; individual conversations and team-member tasks share these settings.", "恢复上下文默认值": "Restore Context Defaults", diff --git a/frontend-enterprise/src/pages/RuntimeSettingsPage.test.tsx b/frontend-enterprise/src/pages/RuntimeSettingsPage.test.tsx index a49aca636..493ce855e 100644 --- a/frontend-enterprise/src/pages/RuntimeSettingsPage.test.tsx +++ b/frontend-enterprise/src/pages/RuntimeSettingsPage.test.tsx @@ -1,9 +1,335 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '@/i18n'; +import type { EnterpriseAuthUser } from '@/auth'; +import type { UIConfigRead } from '@/types'; + +import RuntimeSettingsPage from './RuntimeSettingsPage'; import { validateContextSettings } from './RuntimeSettingsPage'; +const { toastMock } = vi.hoisted(() => ({ + toastMock: { + custom: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ toast: toastMock })); + +const adminUser: EnterpriseAuthUser = { + id: 'user-1', + tenant_id: 'tenant_demo', + username: 'admin', + role: 'admin', +}; + +function makeUiConfig(overrides: Partial = {}): UIConfigRead { + return { + tenant_id: 'tenant_demo', + show_thinking_trace: true, + show_skill_trace: true, + show_tool_trace: true, + reflection_max_rounds: 1, + agent_loop_max_actions: 32, + context_token_budget: 32000, + context_compaction_trigger_ratio: 0.7, + context_recent_round_limit: 6, + context_long_summary_token_budget: 4000, + context_medium_summary_token_budget: 4000, + context_allowed_roles: ['user', 'assistant'], + context_long_summary_prefix: '历史的信息可以被总结为:', + context_medium_summary_prefix: '近期的历史信息总结为:', + sandbox_enabled: false, + harness_storage_path: '', + effective_harness_storage_path: '', + sandbox_network_mode: 'all', + sandbox_allowed_domains: [], + context_compression_mode: 'legacy', + acp_model_context_limit: 128000, + acp_nudge_max_pct: 0.7, + acp_nudge_emergency_pct: 0.85, + acp_nudge_min_pct: 0.45, + acp_enabled: true, + updated_at: '2026-08-25T00:00:00Z', + ...overrides, + }; +} + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +function makeFetchMock(config: UIConfigRead) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method || 'GET'; + if (method === 'GET' && url.includes('/api/enterprise/ui-config')) return jsonResponse(config); + if (method === 'PUT' && url.includes('/api/enterprise/ui-config')) { + const body = JSON.parse(String(init?.body || '{}')) as Record; + return jsonResponse({ ...config, ...body }); + } + return jsonResponse({}); + }); +} + +function putBody(fetchMock: ReturnType): Record { + const call = fetchMock.mock.calls.find( + ([input, init]) => init?.method === 'PUT' && String(input).includes('/api/enterprise/ui-config'), + ); + expect(call).toBeTruthy(); + return JSON.parse(String(call?.[1]?.body)) as Record; +} + +function renderPage() { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + toastMock.custom.mockClear(); +}); + +describe('RuntimeSettingsPage 上下文压缩机制', () => { + it('loads and shows the persisted compression mode with ACP thresholds', async () => { + const fetchMock = makeFetchMock(makeUiConfig({ context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + expect(select.value).toBe('acp'); + expect(await screen.findByLabelText(/上下文上限/)).toBeTruthy(); + expect(screen.getByLabelText(/常规压缩触发阈值/)).toBeTruthy(); + expect(screen.getByLabelText(/紧急压缩触发阈值/)).toBeTruthy(); + expect(screen.getByLabelText(/最低压缩触发阈值/)).toBeTruthy(); + }); + + it('sends the compression mode and ACP thresholds in the PUT body', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig()); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + await user.selectOptions(select, 'acp'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + await user.click(await screen.findByRole('button', { name: '确认保存' })); + + await waitFor(() => { + const body = putBody(fetchMock); + expect(body.context_compression_mode).toBe('acp'); + expect(body.acp_model_context_limit).toBe(128000); + expect(body.acp_nudge_max_pct).toBe(0.7); + expect(body.acp_nudge_emergency_pct).toBe(0.85); + expect(body.acp_nudge_min_pct).toBe(0.45); + }); + }); + + it('reloads the saved acp mode after a save round trip', async () => { + const user = userEvent.setup(); + let current = makeUiConfig(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method || 'GET'; + if (method === 'PUT' && url.includes('/api/enterprise/ui-config')) { + current = { ...current, ...(JSON.parse(String(init?.body || '{}')) as Record) }; + return jsonResponse(current); + } + if (method === 'GET' && url.includes('/api/enterprise/ui-config')) return jsonResponse(current); + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + + const { unmount } = renderPage(); + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + await user.selectOptions(select, 'acp'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + await user.click(await screen.findByRole('button', { name: '确认保存' })); + await waitFor(() => expect(putBody(fetchMock).context_compression_mode).toBe('acp')); + + // 重新加载页面:GET 回读已保存的 acp 与阈值 + unmount(); + renderPage(); + const reloaded = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + expect(reloaded.value).toBe('acp'); + expect(await screen.findByLabelText(/上下文上限/)).toBeTruthy(); + }); + + it('falls back to legacy when the backend returns an unknown mode', async () => { + const fetchMock = makeFetchMock( + makeUiConfig({ context_compression_mode: 'auto' as UIConfigRead['context_compression_mode'] }), + ); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + expect(select.value).toBe('legacy'); + expect(screen.queryByLabelText(/上下文上限/)).toBeNull(); + }); + + it('shows an error toast and keeps the previous selection when save fails', async () => { + const user = userEvent.setup(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method || 'GET'; + if (method === 'GET' && url.includes('/api/enterprise/ui-config')) return jsonResponse(makeUiConfig()); + if (method === 'PUT' && url.includes('/api/enterprise/ui-config')) { + return { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => JSON.stringify({ detail: '保存失败' }), + } as Response; + } + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + await user.selectOptions(select, 'acp'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + await user.click(await screen.findByRole('button', { name: '确认保存' })); + + await waitFor(() => expect(toastMock.custom).toHaveBeenCalled()); + // 失败后保留用户选择,不回退 + expect((screen.getByLabelText(/上下文压缩机制/) as HTMLSelectElement).value).toBe('acp'); + }); + + it('shows the confirm dialog only when the compression mode changed', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig()); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + // 未改变 mode:直接保存,无确认对话框 + await user.click(screen.getByRole('button', { name: '保存设置' })); + await waitFor(() => expect(putBody(fetchMock).context_compression_mode).toBe('legacy')); + expect(screen.queryByText('切换上下文压缩机制?')).toBeNull(); + + // 改变 mode:保存前出现确认对话框,说明影响范围 + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + await user.selectOptions(select, 'acp'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + expect(await screen.findByText('切换上下文压缩机制?')).toBeTruthy(); + expect(screen.getByText(/当前租户的全部会话/)).toBeTruthy(); + + // 取消:不发 PUT + await user.click(screen.getByRole('button', { name: '取消' })); + await waitFor(() => { + const puts = fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + expect(puts).toHaveLength(1); + }); + }); + + it('disables ACP controls when acp_enabled is false', async () => { + const fetchMock = makeFetchMock(makeUiConfig({ acp_enabled: false, context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const select = (await screen.findByLabelText(/上下文压缩机制/)) as HTMLSelectElement; + const acpOption = select.querySelector('option[value="acp"]') as HTMLOptionElement; + expect(acpOption.disabled).toBe(true); + expect(acpOption.textContent).toContain('未启用'); + + const limitInput = (await screen.findByLabelText(/上下文上限/)) as HTMLInputElement; + expect(limitInput.disabled).toBe(true); + expect((screen.getByLabelText(/常规压缩触发阈值/) as HTMLInputElement).disabled).toBe(true); + expect((screen.getByLabelText(/紧急压缩触发阈值/) as HTMLInputElement).disabled).toBe(true); + expect((screen.getByLabelText(/最低压缩触发阈值/) as HTMLInputElement).disabled).toBe(true); + }); + + it('rejects an empty ACP threshold input with an error toast and no PUT', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig({ context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const limitInput = (await screen.findByLabelText(/上下文上限/)) as HTMLInputElement; + await user.clear(limitInput); + await user.click(screen.getByRole('button', { name: '保存设置' })); + + await waitFor(() => expect(toastMock.custom).toHaveBeenCalled()); + const puts = fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + expect(puts).toHaveLength(0); + }); + + it('rejects an out-of-range ratio with an error toast and no PUT', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig({ context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const maxPctInput = (await screen.findByLabelText(/常规压缩触发阈值/)) as HTMLInputElement; + await user.clear(maxPctInput); + await user.type(maxPctInput, '1.5'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + + await waitFor(() => expect(toastMock.custom).toHaveBeenCalled()); + const puts = fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + expect(puts).toHaveLength(0); + }); + + it('rejects a context limit below 1 with an error toast and no PUT', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig({ context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + const limitInput = (await screen.findByLabelText(/上下文上限/)) as HTMLInputElement; + await user.clear(limitInput); + await user.type(limitInput, '0'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + + await waitFor(() => expect(toastMock.custom).toHaveBeenCalled()); + const puts = fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + expect(puts).toHaveLength(0); + }); + + it('rejects threshold ratios out of order with an error toast and no PUT', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(makeUiConfig({ context_compression_mode: 'acp' })); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + + // 最低阈值 0.9 > 常规阈值 0.7,违反 最低 <= 常规 <= 紧急 + const minPctInput = (await screen.findByLabelText(/最低压缩触发阈值/)) as HTMLInputElement; + await user.clear(minPctInput); + await user.type(minPctInput, '0.9'); + await user.click(screen.getByRole('button', { name: '保存设置' })); + + await waitFor(() => expect(toastMock.custom).toHaveBeenCalled()); + const puts = fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT'); + expect(puts).toHaveLength(0); + }); +}); + const validForm = { show_thinking_trace: true, show_skill_trace: true, @@ -18,6 +344,11 @@ const validForm = { context_allowed_roles: ['user', 'assistant'] as Array<'user' | 'assistant'>, context_long_summary_prefix: '历史的信息可以被总结为:', context_medium_summary_prefix: '近期的历史信息总结为:', + context_compression_mode: 'legacy' as const, + acp_model_context_limit: '128000', + acp_nudge_max_pct: '0.70', + acp_nudge_emergency_pct: '0.85', + acp_nudge_min_pct: '0.45', sandbox_enabled: false, harness_storage_path: '', sandbox_network_mode: 'all' as const, diff --git a/frontend-enterprise/src/pages/RuntimeSettingsPage.tsx b/frontend-enterprise/src/pages/RuntimeSettingsPage.tsx index bd688fc79..cd142693e 100644 --- a/frontend-enterprise/src/pages/RuntimeSettingsPage.tsx +++ b/frontend-enterprise/src/pages/RuntimeSettingsPage.tsx @@ -1,12 +1,15 @@ import { SaveOutlined } from '../icons'; -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Button as UIButton, Card, CardContent, CardHeader, CardTitle, Input, Switch, Textarea, notify } from '@/components/ui'; import { api, TENANT_ID } from '../api/client'; import type { EnterpriseAuthUser } from '../auth'; import AccountApiKeyDialog from '../components/AccountApiKeyDialog'; +import { ConfirmDialog } from '../components/ConfirmDialog'; import type { UIConfigRead } from '../types'; import { BrainCircuit, KeyRound, RotateCcw, ShieldCheck } from 'lucide-react'; +type CompressionMode = 'acp' | 'legacy'; + type UiConfigForm = { show_thinking_trace: boolean; show_skill_trace: boolean; @@ -25,6 +28,11 @@ type UiConfigForm = { harness_storage_path: string; sandbox_network_mode: 'all' | 'allowlist' | 'deny'; sandbox_allowed_domains: string; + context_compression_mode: CompressionMode; + acp_model_context_limit: string; + acp_nudge_max_pct: string; + acp_nudge_emergency_pct: string; + acp_nudge_min_pct: string; }; const DEFAULT_UI_CONFIG: UiConfigForm = { @@ -45,6 +53,11 @@ const DEFAULT_UI_CONFIG: UiConfigForm = { harness_storage_path: '', sandbox_network_mode: 'all', sandbox_allowed_domains: '', + context_compression_mode: 'legacy', + acp_model_context_limit: '128000', + acp_nudge_max_pct: '0.70', + acp_nudge_emergency_pct: '0.85', + acp_nudge_min_pct: '0.45', }; function formatDateOnly(value: string): string { @@ -61,12 +74,16 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente const [effectiveStoragePath, setEffectiveStoragePath] = useState(''); const [apiKeyOpen, setApiKeyOpen] = useState(false); const [restarting, setRestarting] = useState(false); + const [acpEnabled, setAcpEnabled] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const savedModeRef = useRef('legacy'); const [sandboxStatus, setSandboxStatus] = useState>({}); const update = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); useEffect(() => { api.get(`/api/enterprise/ui-config?tenant_id=${TENANT_ID}`) .then((row) => { + const compressionMode: CompressionMode = row.context_compression_mode === 'acp' ? 'acp' : 'legacy'; setForm({ show_thinking_trace: row.show_thinking_trace, show_skill_trace: row.show_skill_trace, @@ -85,7 +102,14 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente harness_storage_path: row.harness_storage_path || '', sandbox_network_mode: row.sandbox_network_mode || 'all', sandbox_allowed_domains: (row.sandbox_allowed_domains || []).join('\n'), + context_compression_mode: compressionMode, + acp_model_context_limit: String(row.acp_model_context_limit ?? 128000), + acp_nudge_max_pct: String(row.acp_nudge_max_pct ?? 0.7), + acp_nudge_emergency_pct: String(row.acp_nudge_emergency_pct ?? 0.85), + acp_nudge_min_pct: String(row.acp_nudge_min_pct ?? 0.45), }); + savedModeRef.current = compressionMode; + setAcpEnabled(row.acp_enabled); setUpdatedAt(row.updated_at); setEffectiveStoragePath(row.effective_harness_storage_path || ''); setSetupMessage(row.sandbox_setup_instructions || ''); @@ -94,11 +118,52 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente .catch((error) => notify.error(error.message)); }, []); + const NUMERIC_FIELDS = [ + 'reflection_max_rounds', + 'agent_loop_max_actions', + 'acp_model_context_limit', + 'acp_nudge_max_pct', + 'acp_nudge_emergency_pct', + 'acp_nudge_min_pct', + ] as const; + + function parseNumericForm(form: UiConfigForm) { + return { + reflectionMaxRounds: Number(form.reflection_max_rounds.trim()), + agentLoopMaxActions: Number(form.agent_loop_max_actions.trim()), + acpModelContextLimit: Number(form.acp_model_context_limit.trim()), + acpNudgeMaxPct: Number(form.acp_nudge_max_pct.trim()), + acpNudgeEmergencyPct: Number(form.acp_nudge_emergency_pct.trim()), + acpNudgeMinPct: Number(form.acp_nudge_min_pct.trim()), + }; + } + async function save() { - const reflectionMaxRounds = Number(form.reflection_max_rounds); - const agentLoopMaxActions = Number(form.agent_loop_max_actions); - if (Number.isNaN(reflectionMaxRounds) || Number.isNaN(agentLoopMaxActions)) { - notify.error('反思轮数与单轮最大动作数必须是数字'); + // 空/纯空白输入会被 Number() 解析为 0,必须先按字符串判空,避免把空值提交为 0 + if (NUMERIC_FIELDS.some((field) => form[field].trim() === '')) { + notify.error('反思轮数、单轮最大动作数与 ACP 阈值必须是数字'); + return; + } + const values = parseNumericForm(form); + if (Object.values(values).some(Number.isNaN)) { + notify.error('反思轮数、单轮最大动作数与 ACP 阈值必须是数字'); + return; + } + if (values.acpModelContextLimit < 1) { + notify.error('上下文上限不能小于 1'); + return; + } + const acpRatios = [values.acpNudgeMaxPct, values.acpNudgeEmergencyPct, values.acpNudgeMinPct]; + if (acpRatios.some((ratio) => ratio < 0 || ratio > 1)) { + notify.error('压缩触发阈值必须在 0 到 1 之间'); + return; + } + if (values.acpNudgeMinPct > values.acpNudgeMaxPct || values.acpNudgeMaxPct > values.acpNudgeEmergencyPct) { + notify.error('常规压缩触发阈值不能高于紧急压缩触发阈值,最低压缩触发阈值不能高于常规压缩触发阈值'); + return; + } + if (form.context_compression_mode !== savedModeRef.current) { + setConfirmOpen(true); return; } const contextError = validateContextSettings(form); @@ -106,6 +171,10 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente notify.error(contextError); return; } + await submitSave(values); + } + + async function submitSave(values: ReturnType) { setLoading(true); try { const row = await api.put('/api/enterprise/ui-config', { @@ -113,8 +182,8 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente show_thinking_trace: form.show_thinking_trace, show_skill_trace: form.show_skill_trace, show_tool_trace: form.show_tool_trace, - reflection_max_rounds: reflectionMaxRounds, - agent_loop_max_actions: agentLoopMaxActions, + reflection_max_rounds: values.reflectionMaxRounds, + agent_loop_max_actions: values.agentLoopMaxActions, context_token_budget: Number(form.context_token_budget), context_compaction_trigger_ratio: Number(form.context_compaction_trigger_ratio), context_recent_round_limit: Number(form.context_recent_round_limit), @@ -127,7 +196,13 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente harness_storage_path: form.harness_storage_path.trim(), sandbox_network_mode: form.sandbox_network_mode, sandbox_allowed_domains: form.sandbox_allowed_domains.split(/[\n,]/).map((item) => item.trim()).filter(Boolean), + context_compression_mode: form.context_compression_mode, + acp_model_context_limit: values.acpModelContextLimit, + acp_nudge_max_pct: values.acpNudgeMaxPct, + acp_nudge_emergency_pct: values.acpNudgeEmergencyPct, + acp_nudge_min_pct: values.acpNudgeMinPct, }); + savedModeRef.current = form.context_compression_mode; setUpdatedAt(row.updated_at); setEffectiveStoragePath(row.effective_harness_storage_path || ''); if (row.restart_scheduled) { @@ -157,6 +232,21 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente 执行记录与 Agent Loop + + + + {form.context_compression_mode === 'acp' && ( + <> + {!acpEnabled &&

ACP 功能未启用,以下阈值将在启用后生效。

} + update({ acp_model_context_limit: e.target.value })} /> + update({ acp_nudge_max_pct: e.target.value })} /> + update({ acp_nudge_emergency_pct: e.target.value })} /> + update({ acp_nudge_min_pct: e.target.value })} /> + + )} update({ show_thinking_trace: next })} /> update({ show_skill_trace: next })} /> update({ show_tool_trace: next })} /> @@ -268,6 +358,18 @@ export default function RuntimeSettingsPage({ currentUser }: { currentUser: Ente
setApiKeyOpen(false)} /> + { + setConfirmOpen(false); + void submitSave(parseNumericForm(form)); + }} + /> ); } diff --git a/frontend-enterprise/src/pages/chat/useChatSession.ts b/frontend-enterprise/src/pages/chat/useChatSession.ts index dbbb68a7e..d5b12f03d 100644 --- a/frontend-enterprise/src/pages/chat/useChatSession.ts +++ b/frontend-enterprise/src/pages/chat/useChatSession.ts @@ -398,6 +398,12 @@ export function useChatSession(options: UseChatSessionOptions = {}) { effective_harness_storage_path: '', sandbox_network_mode: 'all', sandbox_allowed_domains: [], + context_compression_mode: 'legacy', + acp_model_context_limit: 128000, + acp_nudge_max_pct: 0.7, + acp_nudge_emergency_pct: 0.85, + acp_nudge_min_pct: 0.45, + acp_enabled: false, updated_at: '', }); const chatMessagesRef = useRef(null); diff --git a/frontend-enterprise/src/types/index.ts b/frontend-enterprise/src/types/index.ts index f736f1dc5..865a14a8b 100644 --- a/frontend-enterprise/src/types/index.ts +++ b/frontend-enterprise/src/types/index.ts @@ -380,6 +380,12 @@ export type UIConfigRead = { sandbox_status_code?: string | null; sandbox_status_message?: string | null; sandbox_status_remediation?: string | null; + context_compression_mode: 'acp' | 'legacy'; + acp_model_context_limit: number; + acp_nudge_max_pct: number; + acp_nudge_emergency_pct: number; + acp_nudge_min_pct: number; + acp_enabled: boolean; updated_at: string; };