feat: add DCP context engine
This commit is contained in:
parent
bac2955df9
commit
4420e0b018
|
|
@ -105,6 +105,26 @@ class ContextEngine(ABC):
|
|||
"""
|
||||
return False
|
||||
|
||||
def transform_api_messages(
|
||||
self,
|
||||
api_messages: List[Dict[str, Any]],
|
||||
*,
|
||||
canonical_messages: List[Dict[str, Any]],
|
||||
system_prompt: str,
|
||||
tools: List[Dict[str, Any]] | None,
|
||||
api_call_count: int,
|
||||
model: str,
|
||||
provider: str | None,
|
||||
session_id: str | None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Transform the provider-bound API-call copy of the transcript.
|
||||
|
||||
Default returns ``api_messages`` unchanged. Engines may override this
|
||||
to add ephemeral refs, compression placeholders, or other context
|
||||
layers without mutating ``canonical_messages``.
|
||||
"""
|
||||
return api_messages
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"""Configuration helpers for the DCP context engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
DCP_DEFAULT_PROTECTED_TOOLS = {
|
||||
"delegate_task",
|
||||
"todo",
|
||||
"memory",
|
||||
"skill_view",
|
||||
"skill_manage",
|
||||
"write_file",
|
||||
"patch",
|
||||
"clarify",
|
||||
"cronjob",
|
||||
"compress",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPStrategyConfig:
|
||||
enabled: bool = True
|
||||
protected_tools: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPPurgeErrorsConfig(DCPStrategyConfig):
|
||||
turns: int = 4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPCompressConfig:
|
||||
mode: Literal["range", "message"] = "range"
|
||||
permission: Literal["allow", "ask", "deny"] = "allow"
|
||||
show_compression: bool = False
|
||||
summary_buffer: bool = True
|
||||
max_context_limit: int | str = 100000
|
||||
min_context_limit: int | str = 50000
|
||||
model_max_limits: dict[str, int | str] = field(default_factory=dict)
|
||||
model_min_limits: dict[str, int | str] = field(default_factory=dict)
|
||||
nudge_frequency: int = 5
|
||||
iteration_nudge_threshold: int = 15
|
||||
nudge_force: Literal["soft", "strong"] = "soft"
|
||||
protected_tools: set[str] = field(default_factory=set)
|
||||
protect_user_messages: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPTurnProtectionConfig:
|
||||
enabled: bool = False
|
||||
turns: int = 4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPManualModeConfig:
|
||||
enabled: bool = False
|
||||
automatic_strategies: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPCommandsConfig:
|
||||
enabled: bool = True
|
||||
protected_tools: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPExperimentalConfig:
|
||||
allow_subagents: bool = False
|
||||
custom_prompts: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPConfig:
|
||||
enabled: bool = True
|
||||
debug: bool = False
|
||||
prune_notification: Literal["off", "minimal", "detailed"] = "detailed"
|
||||
prune_notification_type: Literal["chat", "toast"] = "chat"
|
||||
commands: DCPCommandsConfig = field(default_factory=DCPCommandsConfig)
|
||||
manual_mode: DCPManualModeConfig = field(default_factory=DCPManualModeConfig)
|
||||
turn_protection: DCPTurnProtectionConfig = field(default_factory=DCPTurnProtectionConfig)
|
||||
experimental: DCPExperimentalConfig = field(default_factory=DCPExperimentalConfig)
|
||||
protected_file_patterns: list[str] = field(default_factory=list)
|
||||
compress: DCPCompressConfig = field(default_factory=DCPCompressConfig)
|
||||
deduplication: DCPStrategyConfig = field(default_factory=DCPStrategyConfig)
|
||||
purge_errors: DCPPurgeErrorsConfig = field(default_factory=DCPPurgeErrorsConfig)
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int, *, minimum: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if minimum is not None:
|
||||
parsed = max(minimum, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _as_choice(value: Any, choices: set[str], default: str) -> str:
|
||||
if isinstance(value, str) and value in choices:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _as_limit(value: Any, default: int | str) -> int | str:
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if raw.endswith("%"):
|
||||
try:
|
||||
pct = float(raw[:-1])
|
||||
except ValueError:
|
||||
return default
|
||||
if pct > 0:
|
||||
return raw
|
||||
else:
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return default
|
||||
|
||||
|
||||
def _as_limit_map(value: Any) -> dict[str, int | str]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
out: dict[str, int | str] = {}
|
||||
for key, limit in value.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
parsed = _as_limit(limit, 0)
|
||||
if parsed:
|
||||
out[key] = parsed
|
||||
return out
|
||||
|
||||
|
||||
def _tool_set(value: Any) -> set[str]:
|
||||
if not isinstance(value, list):
|
||||
return set()
|
||||
return {item for item in value if isinstance(item, str) and item.strip()}
|
||||
|
||||
|
||||
def _str_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
|
||||
def parse_dcp_config(config: dict[str, Any] | None) -> DCPConfig:
|
||||
"""Parse ``context.dcp`` config into typed defaults."""
|
||||
raw = config if isinstance(config, dict) else {}
|
||||
|
||||
commands_raw = raw.get("commands", {}) if isinstance(raw.get("commands", {}), dict) else {}
|
||||
manual_raw = raw.get("manualMode", {}) if isinstance(raw.get("manualMode", {}), dict) else {}
|
||||
turn_raw = raw.get("turnProtection", {}) if isinstance(raw.get("turnProtection", {}), dict) else {}
|
||||
exp_raw = raw.get("experimental", {}) if isinstance(raw.get("experimental", {}), dict) else {}
|
||||
compress_raw = raw.get("compress", {}) if isinstance(raw.get("compress", {}), dict) else {}
|
||||
strategies_raw = raw.get("strategies", {}) if isinstance(raw.get("strategies", {}), dict) else {}
|
||||
dedup_raw = strategies_raw.get("deduplication", {}) if isinstance(strategies_raw.get("deduplication", {}), dict) else {}
|
||||
purge_raw = strategies_raw.get("purgeErrors", {}) if isinstance(strategies_raw.get("purgeErrors", {}), dict) else {}
|
||||
|
||||
return DCPConfig(
|
||||
enabled=_as_bool(raw.get("enabled"), True),
|
||||
debug=_as_bool(raw.get("debug"), False),
|
||||
prune_notification=_as_choice(raw.get("pruneNotification"), {"off", "minimal", "detailed"}, "detailed"), # type: ignore[arg-type]
|
||||
prune_notification_type=_as_choice(raw.get("pruneNotificationType"), {"chat", "toast"}, "chat"), # type: ignore[arg-type]
|
||||
commands=DCPCommandsConfig(
|
||||
enabled=_as_bool(commands_raw.get("enabled"), True),
|
||||
protected_tools=_tool_set(commands_raw.get("protectedTools")),
|
||||
),
|
||||
manual_mode=DCPManualModeConfig(
|
||||
enabled=_as_bool(manual_raw.get("enabled"), False),
|
||||
automatic_strategies=_as_bool(manual_raw.get("automaticStrategies"), True),
|
||||
),
|
||||
turn_protection=DCPTurnProtectionConfig(
|
||||
enabled=_as_bool(turn_raw.get("enabled"), False),
|
||||
turns=_as_int(turn_raw.get("turns"), 4, minimum=0),
|
||||
),
|
||||
experimental=DCPExperimentalConfig(
|
||||
allow_subagents=_as_bool(exp_raw.get("allowSubAgents"), False),
|
||||
custom_prompts=_as_bool(exp_raw.get("customPrompts"), False),
|
||||
),
|
||||
protected_file_patterns=_str_list(raw.get("protectedFilePatterns")),
|
||||
compress=DCPCompressConfig(
|
||||
mode=_as_choice(compress_raw.get("mode"), {"range", "message"}, "range"), # type: ignore[arg-type]
|
||||
permission=_as_choice(compress_raw.get("permission"), {"allow", "ask", "deny"}, "allow"), # type: ignore[arg-type]
|
||||
show_compression=_as_bool(compress_raw.get("showCompression"), False),
|
||||
summary_buffer=_as_bool(compress_raw.get("summaryBuffer"), True),
|
||||
max_context_limit=_as_limit(compress_raw.get("maxContextLimit"), 100000),
|
||||
min_context_limit=_as_limit(compress_raw.get("minContextLimit"), 50000),
|
||||
model_max_limits=_as_limit_map(compress_raw.get("modelMaxLimits")),
|
||||
model_min_limits=_as_limit_map(compress_raw.get("modelMinLimits")),
|
||||
nudge_frequency=_as_int(compress_raw.get("nudgeFrequency"), 5, minimum=1),
|
||||
iteration_nudge_threshold=_as_int(compress_raw.get("iterationNudgeThreshold"), 15, minimum=1),
|
||||
nudge_force=_as_choice(compress_raw.get("nudgeForce"), {"soft", "strong"}, "soft"), # type: ignore[arg-type]
|
||||
protected_tools=_tool_set(compress_raw.get("protectedTools")),
|
||||
protect_user_messages=_as_bool(compress_raw.get("protectUserMessages"), False),
|
||||
),
|
||||
deduplication=DCPStrategyConfig(
|
||||
enabled=_as_bool(dedup_raw.get("enabled"), True),
|
||||
protected_tools=_tool_set(dedup_raw.get("protectedTools")),
|
||||
),
|
||||
purge_errors=DCPPurgeErrorsConfig(
|
||||
enabled=_as_bool(purge_raw.get("enabled"), True),
|
||||
protected_tools=_tool_set(purge_raw.get("protectedTools")),
|
||||
turns=_as_int(purge_raw.get("turns"), 4, minimum=0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_limit(limit: int | str, context_length: int) -> int:
|
||||
"""Resolve an absolute token limit or percentage string."""
|
||||
if isinstance(limit, int):
|
||||
return limit
|
||||
raw = limit.strip()
|
||||
if raw.endswith("%"):
|
||||
try:
|
||||
pct = float(raw[:-1]) / 100.0
|
||||
except ValueError:
|
||||
return 0
|
||||
return int(context_length * pct)
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_model_limit(
|
||||
limits: dict[str, int | str],
|
||||
*,
|
||||
provider: str | None,
|
||||
model: str | None,
|
||||
context_length: int,
|
||||
fallback: int | str,
|
||||
) -> int:
|
||||
"""Resolve DCP per-model limits with a simple provider/model key."""
|
||||
keys = []
|
||||
if provider and model:
|
||||
keys.append(f"{provider}/{model}")
|
||||
if model:
|
||||
keys.append(model)
|
||||
for key in keys:
|
||||
if key in limits:
|
||||
return resolve_limit(limits[key], context_length)
|
||||
return resolve_limit(fallback, context_length)
|
||||
|
|
@ -0,0 +1,624 @@
|
|||
"""DCP-style model-guided context engine for Hermes Agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.dcp_config import (
|
||||
DCP_DEFAULT_PROTECTED_TOOLS,
|
||||
DCPConfig,
|
||||
parse_dcp_config,
|
||||
resolve_model_limit,
|
||||
)
|
||||
from agent.dcp_state import CompressionBlock, DCPSessionState
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
|
||||
_ERROR_RE = re.compile(r"\b(error|exception|traceback|failed|failure|timed out|timeout)\b", re.I)
|
||||
|
||||
|
||||
class DCPContextEngine(ContextEngine):
|
||||
"""Model-guided context engine inspired by Dynamic Context Pruning.
|
||||
|
||||
The engine keeps canonical history intact. ``compress`` creates DCP state;
|
||||
``transform_api_messages`` applies that state to the provider-bound copy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: dict[str, Any] | DCPConfig | None = None,
|
||||
context_length: int = 0,
|
||||
model: str = "",
|
||||
provider: str = "",
|
||||
quiet_mode: bool = False,
|
||||
) -> None:
|
||||
self.config = config if isinstance(config, DCPConfig) else parse_dcp_config(config)
|
||||
self.context_length = context_length or 0
|
||||
self.model = model or ""
|
||||
self.provider = provider or ""
|
||||
self.quiet_mode = quiet_mode
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_total_tokens = 0
|
||||
self.compression_count = 0
|
||||
self.threshold_tokens = self._min_limit()
|
||||
self.state = DCPSessionState()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "dcp"
|
||||
|
||||
def update_from_response(self, usage: dict[str, Any]) -> None:
|
||||
self.last_prompt_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
|
||||
self.last_completion_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
|
||||
self.last_total_tokens = int(usage.get("total_tokens") or (self.last_prompt_tokens + self.last_completion_tokens))
|
||||
self.state.last_prompt_tokens = self.last_prompt_tokens
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
# DCP is normally model-guided through the compress tool, not host-driven.
|
||||
return False
|
||||
|
||||
def should_compress_preflight(self, messages: list[dict[str, Any]]) -> bool:
|
||||
return False
|
||||
|
||||
def has_content_to_compress(self, messages: list[dict[str, Any]]) -> bool:
|
||||
self._ensure_refs(messages)
|
||||
return len(self.state.index_by_ref) > 4
|
||||
|
||||
def compress(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
current_tokens: int = None,
|
||||
focus_topic: str = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Manual /compress fallback: record a pending nudge and leave transcript intact.
|
||||
if focus_topic:
|
||||
self.state.manual_mode = "compress-pending"
|
||||
self.state.pending_manual_focus = focus_topic
|
||||
return messages
|
||||
|
||||
def on_session_start(self, session_id: str, **kwargs: Any) -> None:
|
||||
self.state.session_id = session_id
|
||||
model = kwargs.get("model")
|
||||
context_length = kwargs.get("context_length")
|
||||
if isinstance(model, str) and model:
|
||||
self.model = model
|
||||
if isinstance(context_length, int) and context_length > 0:
|
||||
self.context_length = context_length
|
||||
self.threshold_tokens = self._min_limit()
|
||||
|
||||
def on_session_reset(self) -> None:
|
||||
super().on_session_reset()
|
||||
self.state = DCPSessionState(session_id=self.state.session_id)
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
model: str,
|
||||
context_length: int,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
provider: str = "",
|
||||
) -> None:
|
||||
self.model = model or self.model
|
||||
self.provider = provider or self.provider
|
||||
self.context_length = context_length
|
||||
self.threshold_tokens = self._min_limit()
|
||||
|
||||
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
if not self.config.enabled or self.config.compress.permission == "deny":
|
||||
return []
|
||||
if self.config.compress.mode == "message":
|
||||
return [self._message_tool_schema()]
|
||||
return [self._range_tool_schema()]
|
||||
|
||||
def handle_tool_call(self, name: str, args: dict[str, Any], **kwargs: Any) -> str:
|
||||
if name != "compress":
|
||||
return json.dumps({"ok": False, "error": f"Unknown context engine tool: {name}"})
|
||||
messages = kwargs.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return json.dumps({"ok": False, "error": "compress requires current messages"})
|
||||
try:
|
||||
if self.config.compress.mode == "message":
|
||||
result = self._handle_message_compress(args, messages)
|
||||
else:
|
||||
result = self._handle_range_compress(args, messages)
|
||||
except Exception as exc: # fail loudly to the model, not to the agent loop
|
||||
return json.dumps({"ok": False, "error": str(exc)})
|
||||
return json.dumps(result)
|
||||
|
||||
def transform_api_messages(
|
||||
self,
|
||||
api_messages: list[dict[str, Any]],
|
||||
*,
|
||||
canonical_messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
api_call_count: int,
|
||||
model: str,
|
||||
provider: str | None,
|
||||
session_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not self.config.enabled:
|
||||
return api_messages
|
||||
|
||||
self.model = model or self.model
|
||||
self.provider = provider or self.provider
|
||||
self.state.session_id = session_id or self.state.session_id
|
||||
self._ensure_refs(canonical_messages)
|
||||
|
||||
transformed = copy.deepcopy(api_messages)
|
||||
ref_by_api_index = self._match_api_messages_to_refs(transformed, canonical_messages)
|
||||
self._annotate_refs(transformed, ref_by_api_index)
|
||||
self._apply_blocks(transformed, ref_by_api_index)
|
||||
|
||||
if self._automatic_strategies_enabled():
|
||||
if self.config.deduplication.enabled:
|
||||
self._apply_deduplication(transformed)
|
||||
if self.config.purge_errors.enabled:
|
||||
self._apply_purge_errors(transformed)
|
||||
|
||||
self._inject_system_extension(transformed)
|
||||
self._inject_nudge(transformed, api_call_count=api_call_count)
|
||||
return transformed
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
active_blocks = self.state.active_blocks()
|
||||
return {
|
||||
**super().get_status(),
|
||||
"engine": "dcp",
|
||||
"active_blocks": len(active_blocks),
|
||||
"message_refs": len(self.state.ref_by_message_key),
|
||||
"min_context_limit": self._min_limit(),
|
||||
"max_context_limit": self._max_limit(),
|
||||
"compress_mode": self.config.compress.mode,
|
||||
"compress_permission": self.config.compress.permission,
|
||||
}
|
||||
|
||||
# -- Tool schemas -----------------------------------------------------
|
||||
|
||||
def _range_tool_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "compress",
|
||||
"description": (
|
||||
"Compress completed, stale context ranges by message/block ref. "
|
||||
"Use this when prior work is closed and a concise technical summary "
|
||||
"will preserve the useful state. Do not compress the active task."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {"type": "string", "description": "Short 3-5 word label for this compression batch."},
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"startId": {"type": "string", "description": "Starting message or block ref, e.g. m0004 or b2."},
|
||||
"endId": {"type": "string", "description": "Ending message or block ref, e.g. m0018 or b3."},
|
||||
"summary": {"type": "string", "description": "Complete technical summary replacing the range."},
|
||||
},
|
||||
"required": ["startId", "endId", "summary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["topic", "content"],
|
||||
},
|
||||
}
|
||||
|
||||
def _message_tool_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "compress",
|
||||
"description": (
|
||||
"Compress individual high-volume messages by ref. Preserve concrete "
|
||||
"technical facts, file paths, commands, decisions, errors, and open questions."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {"type": "string", "description": "Short label for this compression batch."},
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageId": {"type": "string", "description": "Message ref, e.g. m0042."},
|
||||
"topic": {"type": "string", "description": "Short label for this message."},
|
||||
"summary": {"type": "string", "description": "Complete technical summary replacing this message."},
|
||||
},
|
||||
"required": ["messageId", "topic", "summary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["topic", "content"],
|
||||
},
|
||||
}
|
||||
|
||||
# -- Tool handling ----------------------------------------------------
|
||||
|
||||
def _handle_range_compress(self, args: dict[str, Any], messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
self._ensure_refs(messages)
|
||||
topic = self._require_str(args, "topic")
|
||||
content = args.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
raise ValueError("compress.content must be a non-empty array")
|
||||
|
||||
created: list[int] = []
|
||||
deactivated: list[int] = []
|
||||
run_id = self.state.new_run_id()
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Each compression range must be an object")
|
||||
start_ref = self._require_str(item, "startId")
|
||||
end_ref = self._require_str(item, "endId")
|
||||
summary = self._require_str(item, "summary")
|
||||
message_refs, included_blocks = self._resolve_range(start_ref, end_ref)
|
||||
if not message_refs:
|
||||
raise ValueError(f"Range {start_ref}-{end_ref} does not cover any messages")
|
||||
block_id = self.state.new_block_id()
|
||||
consumed_blocks: list[int] = []
|
||||
for included in included_blocks:
|
||||
block = self.state.blocks_by_id.get(included)
|
||||
if block and block.active:
|
||||
block.active = False
|
||||
block.deactivated_at = time.time()
|
||||
block.deactivated_by_block_id = block_id
|
||||
self.state.active_block_ids.discard(included)
|
||||
consumed_blocks.append(included)
|
||||
deactivated.append(included)
|
||||
block = CompressionBlock(
|
||||
block_id=block_id,
|
||||
run_id=run_id,
|
||||
mode="range",
|
||||
topic=topic,
|
||||
summary=self._augment_summary(summary, message_refs),
|
||||
start_ref=start_ref,
|
||||
end_ref=end_ref,
|
||||
message_refs=message_refs,
|
||||
included_block_ids=included_blocks,
|
||||
consumed_block_ids=consumed_blocks,
|
||||
created_at=time.time(),
|
||||
)
|
||||
self.state.blocks_by_id[block_id] = block
|
||||
self.state.active_block_ids.add(block_id)
|
||||
created.append(block_id)
|
||||
|
||||
self.compression_count += len(created)
|
||||
self.state.turns_since_last_compress = 0
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "range",
|
||||
"created_blocks": created,
|
||||
"deactivated_blocks": deactivated,
|
||||
"active_blocks": sorted(self.state.active_block_ids),
|
||||
"message": f"Compressed {len(created)} range(s) into {', '.join(f'b{i}' for i in created)}.",
|
||||
}
|
||||
|
||||
def _handle_message_compress(self, args: dict[str, Any], messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
self._ensure_refs(messages)
|
||||
batch_topic = self._require_str(args, "topic")
|
||||
content = args.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
raise ValueError("compress.content must be a non-empty array")
|
||||
created: list[int] = []
|
||||
run_id = self.state.new_run_id()
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Each compressed message must be an object")
|
||||
ref = self._require_str(item, "messageId")
|
||||
if ref not in self.state.index_by_ref:
|
||||
raise ValueError(f"Unknown message ref: {ref}")
|
||||
topic = item.get("topic") if isinstance(item.get("topic"), str) else batch_topic
|
||||
summary = self._require_str(item, "summary")
|
||||
block_id = self.state.new_block_id()
|
||||
block = CompressionBlock(
|
||||
block_id=block_id,
|
||||
run_id=run_id,
|
||||
mode="message",
|
||||
topic=topic,
|
||||
summary=self._augment_summary(summary, [ref]),
|
||||
message_refs=[ref],
|
||||
created_at=time.time(),
|
||||
)
|
||||
self.state.blocks_by_id[block_id] = block
|
||||
self.state.active_block_ids.add(block_id)
|
||||
created.append(block_id)
|
||||
self.compression_count += len(created)
|
||||
self.state.turns_since_last_compress = 0
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "message",
|
||||
"created_blocks": created,
|
||||
"active_blocks": sorted(self.state.active_block_ids),
|
||||
"message": f"Compressed {len(created)} message(s) into {', '.join(f'b{i}' for i in created)}.",
|
||||
}
|
||||
|
||||
# -- Transforms -------------------------------------------------------
|
||||
|
||||
def _ensure_refs(self, messages: list[dict[str, Any]]) -> None:
|
||||
self.state.index_by_ref.clear()
|
||||
for idx, msg in enumerate(messages):
|
||||
key = self._message_key(msg, idx)
|
||||
ref = self.state.ref_by_message_key.get(key)
|
||||
if ref is None:
|
||||
ref = self.state.new_message_ref()
|
||||
self.state.ref_by_message_key[key] = ref
|
||||
self.state.message_key_by_ref[ref] = key
|
||||
self.state.index_by_ref[ref] = idx
|
||||
user_indices = [idx for idx, msg in enumerate(messages) if msg.get("role") == "user"]
|
||||
if user_indices:
|
||||
last_user = user_indices[-1]
|
||||
if last_user != self.state.last_user_turn_index:
|
||||
self.state.turns_since_last_compress += 1
|
||||
self.state.last_user_turn_index = last_user
|
||||
self.state.messages_since_last_user = len(messages) - last_user - 1
|
||||
|
||||
def _match_api_messages_to_refs(
|
||||
self,
|
||||
api_messages: list[dict[str, Any]],
|
||||
canonical_messages: list[dict[str, Any]],
|
||||
) -> dict[int, str]:
|
||||
self._ensure_refs(canonical_messages)
|
||||
refs_by_key: dict[str, deque[str]] = defaultdict(deque)
|
||||
for idx, msg in enumerate(canonical_messages):
|
||||
key = self._message_key(msg, idx)
|
||||
ref = self.state.ref_by_message_key.get(key)
|
||||
if ref:
|
||||
refs_by_key[self._message_signature(msg)].append(ref)
|
||||
|
||||
out: dict[int, str] = {}
|
||||
for api_idx, msg in enumerate(api_messages):
|
||||
if msg.get("role") == "system":
|
||||
continue
|
||||
sig = self._message_signature(msg)
|
||||
queue = refs_by_key.get(sig)
|
||||
if queue:
|
||||
out[api_idx] = queue.popleft()
|
||||
return out
|
||||
|
||||
def _annotate_refs(self, messages: list[dict[str, Any]], ref_by_api_index: dict[int, str]) -> None:
|
||||
for idx, ref in ref_by_api_index.items():
|
||||
msg = messages[idx]
|
||||
content = msg.get("content")
|
||||
marker = f'<dcp-ref id="{ref}" />'
|
||||
if isinstance(content, str):
|
||||
if marker not in content:
|
||||
msg["content"] = f"{content}\n\n{marker}" if content else marker
|
||||
elif isinstance(content, list):
|
||||
msg["content"] = content + [{"type": "text", "text": marker}]
|
||||
|
||||
def _apply_blocks(self, messages: list[dict[str, Any]], ref_by_api_index: dict[int, str]) -> None:
|
||||
ref_to_api_index = {ref: idx for idx, ref in ref_by_api_index.items()}
|
||||
for block in self.state.active_blocks():
|
||||
covered = [ref for ref in block.message_refs if ref in ref_to_api_index]
|
||||
if not covered:
|
||||
continue
|
||||
anchor_ref = covered[0]
|
||||
anchor_idx = ref_to_api_index[anchor_ref]
|
||||
anchor = messages[anchor_idx]
|
||||
anchor["content"] = self._block_summary_text(block)
|
||||
for ref in covered[1:]:
|
||||
idx = ref_to_api_index[ref]
|
||||
msg = messages[idx]
|
||||
msg["content"] = f"[DCP: content moved into compressed block {block.ref}.]"
|
||||
|
||||
def _apply_deduplication(self, messages: list[dict[str, Any]]) -> None:
|
||||
protected = DCP_DEFAULT_PROTECTED_TOOLS | self.config.deduplication.protected_tools
|
||||
latest_by_sig: dict[str, int] = {}
|
||||
result_by_call_id: dict[str, int] = {}
|
||||
calls: list[tuple[int, str, str]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
name = self._tool_name(tc)
|
||||
if not name or name in protected:
|
||||
continue
|
||||
call_id = tc.get("id") if isinstance(tc, dict) else None
|
||||
if not isinstance(call_id, str):
|
||||
continue
|
||||
sig = self._tool_signature(tc)
|
||||
calls.append((idx, call_id, sig))
|
||||
latest_by_sig[sig] = idx
|
||||
elif msg.get("role") == "tool":
|
||||
call_id = msg.get("tool_call_id")
|
||||
if isinstance(call_id, str):
|
||||
result_by_call_id[call_id] = idx
|
||||
protected_indices = self._turn_protected_indices(messages)
|
||||
for call_idx, call_id, sig in calls:
|
||||
if latest_by_sig.get(sig) == call_idx:
|
||||
continue
|
||||
result_idx = result_by_call_id.get(call_id)
|
||||
if result_idx is not None and result_idx not in protected_indices:
|
||||
messages[result_idx]["content"] = "[DCP: duplicate tool output removed. Same tool and arguments were called again later.]"
|
||||
|
||||
def _apply_purge_errors(self, messages: list[dict[str, Any]]) -> None:
|
||||
protected = DCP_DEFAULT_PROTECTED_TOOLS | self.config.purge_errors.protected_tools
|
||||
keep_tail = max(0, self.config.purge_errors.turns * 2)
|
||||
cutoff = max(0, len(messages) - keep_tail)
|
||||
call_name_by_id: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
call_id = tc.get("id") if isinstance(tc, dict) else None
|
||||
name = self._tool_name(tc)
|
||||
if isinstance(call_id, str) and name:
|
||||
call_name_by_id[call_id] = name
|
||||
protected_indices = self._turn_protected_indices(messages)
|
||||
for idx, msg in enumerate(messages[:cutoff]):
|
||||
if idx in protected_indices or msg.get("role") != "tool":
|
||||
continue
|
||||
call_id = msg.get("tool_call_id")
|
||||
name = call_name_by_id.get(call_id) if isinstance(call_id, str) else None
|
||||
if name in protected:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and len(content) > 240 and _ERROR_RE.search(content):
|
||||
first_line = content.strip().splitlines()[0][:240]
|
||||
msg["content"] = f"[DCP: old failed tool output pruned after {self.config.purge_errors.turns} turns. Error preserved: {first_line}]"
|
||||
|
||||
def _inject_system_extension(self, messages: list[dict[str, Any]]) -> None:
|
||||
if self.config.compress.permission == "deny":
|
||||
return
|
||||
extension = (
|
||||
"DCP context management is active. Message refs look like m0001; "
|
||||
"compressed blocks look like b1. Use the compress tool when older work "
|
||||
"is complete or stale. Preserve concrete file paths, commands, errors, "
|
||||
"test results, decisions, constraints, and open questions. Do not compress "
|
||||
"the active task or very recent user turns."
|
||||
)
|
||||
if messages and messages[0].get("role") == "system" and isinstance(messages[0].get("content"), str):
|
||||
if "DCP context management is active" not in messages[0]["content"]:
|
||||
messages[0]["content"] = f"{messages[0]['content']}\n\n{extension}"
|
||||
|
||||
def _inject_nudge(self, messages: list[dict[str, Any]], *, api_call_count: int) -> None:
|
||||
prompt_tokens = estimate_messages_tokens_rough(messages)
|
||||
max_limit = self._max_limit()
|
||||
min_limit = self._min_limit()
|
||||
nudge: str | None = None
|
||||
if max_limit and prompt_tokens >= max_limit:
|
||||
force = "Before continuing, call compress on any completed range if safe." if self.config.compress.nudge_force == "strong" else "Consider calling compress on completed older ranges before continuing."
|
||||
nudge = f"DCP context pressure is high (~{prompt_tokens:,} tokens). {force}"
|
||||
elif min_limit and prompt_tokens >= min_limit and self.state.turns_since_last_compress >= self.config.compress.nudge_frequency:
|
||||
nudge = "DCP: context is growing. If an older topic is complete, use compress with the visible refs."
|
||||
elif self.state.messages_since_last_user >= self.config.compress.iteration_nudge_threshold:
|
||||
nudge = "DCP: many assistant/tool messages have accumulated since the last user turn. Compress closed context if safe."
|
||||
elif self.state.manual_mode == "compress-pending":
|
||||
focus = f" Focus: {self.state.pending_manual_focus}." if self.state.pending_manual_focus else ""
|
||||
nudge = f"DCP manual compression requested.{focus} Call compress before continuing if there is safe completed context."
|
||||
self.state.manual_mode = False
|
||||
self.state.pending_manual_focus = None
|
||||
|
||||
if not nudge:
|
||||
return
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
msg["content"] = f"{msg['content']}\n\n<dcp-nudge>{nudge}</dcp-nudge>"
|
||||
return
|
||||
if messages and isinstance(messages[-1].get("content"), str):
|
||||
messages[-1]["content"] = f"{messages[-1]['content']}\n\n<dcp-nudge>{nudge}</dcp-nudge>"
|
||||
|
||||
# -- Helpers ----------------------------------------------------------
|
||||
|
||||
def _message_key(self, msg: dict[str, Any], idx: int) -> str:
|
||||
return f"{idx}:{self._message_signature(msg)}"
|
||||
|
||||
def _message_signature(self, msg: dict[str, Any]) -> str:
|
||||
clean = {
|
||||
"role": msg.get("role"),
|
||||
"content": msg.get("content"),
|
||||
"tool_call_id": msg.get("tool_call_id"),
|
||||
"tool_calls": msg.get("tool_calls"),
|
||||
}
|
||||
raw = json.dumps(clean, sort_keys=True, default=str, separators=(",", ":"))
|
||||
return hashlib.sha1(raw.encode("utf-8", "ignore")).hexdigest()
|
||||
|
||||
def _require_str(self, args: dict[str, Any], key: str) -> str:
|
||||
value = args.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"compress.{key} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
def _resolve_range(self, start_ref: str, end_ref: str) -> tuple[list[str], list[int]]:
|
||||
start_idx = self._resolve_ref_to_index(start_ref)
|
||||
end_idx = self._resolve_ref_to_index(end_ref)
|
||||
if start_idx is None:
|
||||
raise ValueError(f"Unknown startId: {start_ref}")
|
||||
if end_idx is None:
|
||||
raise ValueError(f"Unknown endId: {end_ref}")
|
||||
if end_idx < start_idx:
|
||||
start_idx, end_idx = end_idx, start_idx
|
||||
refs = [ref for ref, idx in self.state.index_by_ref.items() if start_idx <= idx <= end_idx]
|
||||
refs.sort(key=lambda ref: self.state.index_by_ref[ref])
|
||||
included_blocks = [
|
||||
block.block_id
|
||||
for block in self.state.active_blocks()
|
||||
if any(ref in refs for ref in block.message_refs)
|
||||
]
|
||||
return refs, included_blocks
|
||||
|
||||
def _resolve_ref_to_index(self, ref: str) -> int | None:
|
||||
if ref.startswith("m"):
|
||||
return self.state.index_by_ref.get(ref)
|
||||
if ref.startswith("b"):
|
||||
try:
|
||||
block_id = int(ref[1:])
|
||||
except ValueError:
|
||||
return None
|
||||
block = self.state.blocks_by_id.get(block_id)
|
||||
if not block or not block.message_refs:
|
||||
return None
|
||||
return self.state.index_by_ref.get(block.message_refs[0])
|
||||
return None
|
||||
|
||||
def _augment_summary(self, summary: str, message_refs: list[str]) -> str:
|
||||
parts = [summary.strip()]
|
||||
if self.config.compress.protect_user_messages:
|
||||
parts.append(f"Covered refs: {', '.join(message_refs)}")
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
def _block_summary_text(self, block: CompressionBlock) -> str:
|
||||
covers = f"{block.start_ref}-{block.end_ref}" if block.start_ref and block.end_ref else ", ".join(block.message_refs)
|
||||
return (
|
||||
f'<dcp-compressed-block id="{block.ref}" topic="{block.topic}">\n'
|
||||
f"Summary: {block.summary}\n"
|
||||
f"Covers: {covers}\n"
|
||||
"</dcp-compressed-block>"
|
||||
)
|
||||
|
||||
def _tool_name(self, tool_call: Any) -> str | None:
|
||||
if not isinstance(tool_call, dict):
|
||||
return None
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||||
return function["name"]
|
||||
return None
|
||||
|
||||
def _tool_signature(self, tool_call: dict[str, Any]) -> str:
|
||||
function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {}
|
||||
name = function.get("name", "")
|
||||
args = function.get("arguments", "")
|
||||
try:
|
||||
args_obj = json.loads(args) if isinstance(args, str) else args
|
||||
args_norm = json.dumps(args_obj, sort_keys=True, separators=(",", ":"), default=str)
|
||||
except Exception:
|
||||
args_norm = str(args)
|
||||
return f"{name}::{args_norm}"
|
||||
|
||||
def _automatic_strategies_enabled(self) -> bool:
|
||||
if self.config.manual_mode.enabled and not self.config.manual_mode.automatic_strategies:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _turn_protected_indices(self, messages: list[dict[str, Any]]) -> set[int]:
|
||||
if not self.config.turn_protection.enabled or self.config.turn_protection.turns <= 0:
|
||||
return set()
|
||||
user_indices = [idx for idx, msg in enumerate(messages) if msg.get("role") == "user"]
|
||||
if not user_indices:
|
||||
return set()
|
||||
start = user_indices[-self.config.turn_protection.turns] if len(user_indices) >= self.config.turn_protection.turns else user_indices[0]
|
||||
return set(range(start, len(messages)))
|
||||
|
||||
def _min_limit(self) -> int:
|
||||
return resolve_model_limit(
|
||||
self.config.compress.model_min_limits,
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
context_length=self.context_length,
|
||||
fallback=self.config.compress.min_context_limit,
|
||||
)
|
||||
|
||||
def _max_limit(self) -> int:
|
||||
return resolve_model_limit(
|
||||
self.config.compress.model_max_limits,
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
context_length=self.context_length,
|
||||
fallback=self.config.compress.max_context_limit,
|
||||
)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""State model for the DCP context engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CompressionBlock:
|
||||
block_id: int
|
||||
run_id: int
|
||||
mode: Literal["range", "message"]
|
||||
topic: str
|
||||
summary: str
|
||||
active: bool = True
|
||||
start_ref: str | None = None
|
||||
end_ref: str | None = None
|
||||
message_refs: list[str] = field(default_factory=list)
|
||||
included_block_ids: list[int] = field(default_factory=list)
|
||||
consumed_block_ids: list[int] = field(default_factory=list)
|
||||
created_at: float = 0.0
|
||||
deactivated_at: float | None = None
|
||||
deactivated_by_block_id: int | None = None
|
||||
|
||||
@property
|
||||
def ref(self) -> str:
|
||||
return f"b{self.block_id}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DCPSessionState:
|
||||
session_id: str | None = None
|
||||
next_message_ref: int = 1
|
||||
next_block_id: int = 1
|
||||
next_run_id: int = 1
|
||||
ref_by_message_key: dict[str, str] = field(default_factory=dict)
|
||||
message_key_by_ref: dict[str, str] = field(default_factory=dict)
|
||||
index_by_ref: dict[str, int] = field(default_factory=dict)
|
||||
blocks_by_id: dict[int, CompressionBlock] = field(default_factory=dict)
|
||||
active_block_ids: set[int] = field(default_factory=set)
|
||||
last_prompt_tokens: int = 0
|
||||
last_user_turn_index: int = 0
|
||||
turns_since_last_compress: int = 0
|
||||
messages_since_last_user: int = 0
|
||||
manual_mode: bool | Literal["compress-pending"] = False
|
||||
pending_manual_focus: str | None = None
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def new_message_ref(self) -> str:
|
||||
ref = f"m{self.next_message_ref:04d}"
|
||||
self.next_message_ref += 1
|
||||
return ref
|
||||
|
||||
def new_block_id(self) -> int:
|
||||
block_id = self.next_block_id
|
||||
self.next_block_id += 1
|
||||
return block_id
|
||||
|
||||
def new_run_id(self) -> int:
|
||||
run_id = self.next_run_id
|
||||
self.next_run_id += 1
|
||||
return run_id
|
||||
|
||||
def active_blocks(self) -> list[CompressionBlock]:
|
||||
return [
|
||||
self.blocks_by_id[block_id]
|
||||
for block_id in sorted(self.active_block_ids)
|
||||
if block_id in self.blocks_by_id and self.blocks_by_id[block_id].active
|
||||
]
|
||||
|
|
@ -946,6 +946,55 @@ DEFAULT_CONFIG = {
|
|||
# a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
|
||||
"context": {
|
||||
"engine": "compressor",
|
||||
"dcp": {
|
||||
"enabled": True,
|
||||
"debug": False,
|
||||
"pruneNotification": "detailed",
|
||||
"pruneNotificationType": "chat",
|
||||
"commands": {
|
||||
"enabled": True,
|
||||
"protectedTools": [],
|
||||
},
|
||||
"manualMode": {
|
||||
"enabled": False,
|
||||
"automaticStrategies": True,
|
||||
},
|
||||
"turnProtection": {
|
||||
"enabled": False,
|
||||
"turns": 4,
|
||||
},
|
||||
"experimental": {
|
||||
"allowSubAgents": False,
|
||||
"customPrompts": False,
|
||||
},
|
||||
"protectedFilePatterns": [],
|
||||
"compress": {
|
||||
"mode": "range",
|
||||
"permission": "allow",
|
||||
"showCompression": False,
|
||||
"summaryBuffer": True,
|
||||
"maxContextLimit": 100000,
|
||||
"minContextLimit": 50000,
|
||||
"modelMaxLimits": {},
|
||||
"modelMinLimits": {},
|
||||
"nudgeFrequency": 5,
|
||||
"iterationNudgeThreshold": 15,
|
||||
"nudgeForce": "soft",
|
||||
"protectedTools": [],
|
||||
"protectUserMessages": False,
|
||||
},
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": True,
|
||||
"protectedTools": [],
|
||||
},
|
||||
"purgeErrors": {
|
||||
"enabled": True,
|
||||
"turns": 4,
|
||||
"protectedTools": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
# Persistent memory -- bounded curated memory injected into system prompt
|
||||
|
|
|
|||
29
run_agent.py
29
run_agent.py
|
|
@ -147,6 +147,7 @@ from agent.model_metadata import (
|
|||
query_ollama_num_ctx,
|
||||
)
|
||||
from agent.context_compressor import ContextCompressor
|
||||
from agent.dcp_context_engine import DCPContextEngine
|
||||
from agent.subdirectory_hints import SubdirectoryHintTracker
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
|
||||
|
|
@ -2005,7 +2006,15 @@ class AIAgent:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if _engine_name != "compressor":
|
||||
if _engine_name == "dcp":
|
||||
_selected_engine = DCPContextEngine(
|
||||
config=_ctx_cfg.get("dcp", {}) if isinstance(_ctx_cfg, dict) else {},
|
||||
model=self.model,
|
||||
provider=self.provider,
|
||||
quiet_mode=self.quiet_mode,
|
||||
)
|
||||
|
||||
if _engine_name != "compressor" and _selected_engine is None:
|
||||
# Try loading from plugins/context_engine/<name>/
|
||||
try:
|
||||
from plugins.context_engine import load_context_engine
|
||||
|
|
@ -11149,6 +11158,24 @@ class AIAgent:
|
|||
for idx, pfm in enumerate(self.prefill_messages):
|
||||
api_messages.insert(sys_offset + idx, pfm.copy())
|
||||
|
||||
# Let the active context engine transform the provider-bound copy.
|
||||
# DCP uses this to add refs, compression block placeholders, and
|
||||
# nudges without mutating the canonical `messages` transcript.
|
||||
if self.context_compressor is not None:
|
||||
try:
|
||||
api_messages = self.context_compressor.transform_api_messages(
|
||||
api_messages,
|
||||
canonical_messages=messages,
|
||||
system_prompt=effective_system,
|
||||
tools=self.tools,
|
||||
api_call_count=api_call_count,
|
||||
model=self.model,
|
||||
provider=self.provider,
|
||||
session_id=self.session_id,
|
||||
)
|
||||
except Exception as _ctx_transform_err:
|
||||
logger.warning("Context engine API-message transform failed: %s", _ctx_transform_err)
|
||||
|
||||
# Apply Anthropic prompt caching for Claude models on native
|
||||
# Anthropic, OpenRouter, and third-party Anthropic-compatible
|
||||
# gateways. Auto-detected: if ``_use_prompt_caching`` is set,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from agent.dcp_config import parse_dcp_config, resolve_limit, resolve_model_limit
|
||||
|
||||
|
||||
def test_dcp_config_defaults_match_supported_surface():
|
||||
cfg = parse_dcp_config({})
|
||||
|
||||
assert cfg.enabled is True
|
||||
assert cfg.prune_notification == "detailed"
|
||||
assert cfg.compress.mode == "range"
|
||||
assert cfg.compress.permission == "allow"
|
||||
assert cfg.compress.max_context_limit == 100000
|
||||
assert cfg.compress.min_context_limit == 50000
|
||||
assert cfg.deduplication.enabled is True
|
||||
assert cfg.purge_errors.enabled is True
|
||||
assert cfg.purge_errors.turns == 4
|
||||
|
||||
|
||||
def test_dcp_config_parses_percent_limits_and_model_overrides():
|
||||
cfg = parse_dcp_config(
|
||||
{
|
||||
"compress": {
|
||||
"maxContextLimit": "80%",
|
||||
"minContextLimit": "40%",
|
||||
"modelMaxLimits": {"openai/test-model": "90%"},
|
||||
"modelMinLimits": {"test-model": 12345},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert resolve_limit(cfg.compress.max_context_limit, 200000) == 160000
|
||||
assert resolve_limit(cfg.compress.min_context_limit, 200000) == 80000
|
||||
assert resolve_model_limit(
|
||||
cfg.compress.model_max_limits,
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
context_length=200000,
|
||||
fallback=cfg.compress.max_context_limit,
|
||||
) == 180000
|
||||
assert resolve_model_limit(
|
||||
cfg.compress.model_min_limits,
|
||||
provider="openai",
|
||||
model="test-model",
|
||||
context_length=200000,
|
||||
fallback=cfg.compress.min_context_limit,
|
||||
) == 12345
|
||||
|
||||
|
||||
def test_dcp_config_rejects_invalid_choices_to_defaults():
|
||||
cfg = parse_dcp_config(
|
||||
{
|
||||
"compress": {
|
||||
"mode": "bad",
|
||||
"permission": "root",
|
||||
"nudgeForce": "loud",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert cfg.compress.mode == "range"
|
||||
assert cfg.compress.permission == "allow"
|
||||
assert cfg.compress.nudge_force == "soft"
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from agent.dcp_context_engine import DCPContextEngine
|
||||
|
||||
|
||||
def _tool_call(call_id: str, name: str, args: dict) -> dict:
|
||||
return {
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(args)},
|
||||
}
|
||||
|
||||
|
||||
def test_range_tool_schema_is_exposed_by_default():
|
||||
engine = DCPContextEngine(config={}, context_length=200000)
|
||||
|
||||
schemas = engine.get_tool_schemas()
|
||||
|
||||
assert len(schemas) == 1
|
||||
schema = schemas[0]
|
||||
assert schema["name"] == "compress"
|
||||
assert schema["parameters"]["required"] == ["topic", "content"]
|
||||
item = schema["parameters"]["properties"]["content"]["items"]
|
||||
assert item["required"] == ["startId", "endId", "summary"]
|
||||
|
||||
|
||||
def test_message_tool_schema_when_configured():
|
||||
engine = DCPContextEngine(config={"compress": {"mode": "message"}}, context_length=200000)
|
||||
|
||||
schema = engine.get_tool_schemas()[0]
|
||||
|
||||
item = schema["parameters"]["properties"]["content"]["items"]
|
||||
assert item["required"] == ["messageId", "topic", "summary"]
|
||||
|
||||
|
||||
def test_deny_permission_hides_compress_tool():
|
||||
engine = DCPContextEngine(config={"compress": {"permission": "deny"}}, context_length=200000)
|
||||
|
||||
assert engine.get_tool_schemas() == []
|
||||
|
||||
|
||||
def test_transform_does_not_mutate_canonical_messages_and_adds_refs():
|
||||
engine = DCPContextEngine(config={}, context_length=200000)
|
||||
canonical = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
||||
original = [msg.copy() for msg in canonical]
|
||||
api_messages = [{"role": "system", "content": "sys"}] + [msg.copy() for msg in canonical]
|
||||
|
||||
transformed = engine.transform_api_messages(
|
||||
api_messages,
|
||||
canonical_messages=canonical,
|
||||
system_prompt="sys",
|
||||
tools=[],
|
||||
api_call_count=1,
|
||||
model="test-model",
|
||||
provider="openai",
|
||||
session_id="s1",
|
||||
)
|
||||
|
||||
assert canonical == original
|
||||
assert '<dcp-ref id="m0001" />' in transformed[1]["content"]
|
||||
assert '<dcp-ref id="m0002" />' in transformed[2]["content"]
|
||||
assert "DCP context management is active" in transformed[0]["content"]
|
||||
|
||||
|
||||
def test_range_compress_creates_block_and_transform_applies_placeholder():
|
||||
engine = DCPContextEngine(config={}, context_length=200000)
|
||||
canonical = [
|
||||
{"role": "user", "content": "start"},
|
||||
{"role": "assistant", "content": "old work"},
|
||||
{"role": "user", "content": "new task"},
|
||||
]
|
||||
engine._ensure_refs(canonical)
|
||||
|
||||
result = json.loads(
|
||||
engine.handle_tool_call(
|
||||
"compress",
|
||||
{
|
||||
"topic": "old work",
|
||||
"content": [{"startId": "m0001", "endId": "m0002", "summary": "Old work summary."}],
|
||||
},
|
||||
messages=canonical,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["created_blocks"] == [1]
|
||||
transformed = engine.transform_api_messages(
|
||||
[msg.copy() for msg in canonical],
|
||||
canonical_messages=canonical,
|
||||
system_prompt="",
|
||||
tools=[],
|
||||
api_call_count=1,
|
||||
model="test-model",
|
||||
provider="openai",
|
||||
session_id="s1",
|
||||
)
|
||||
assert '<dcp-compressed-block id="b1" topic="old work">' in transformed[0]["content"]
|
||||
assert "content moved into compressed block b1" in transformed[1]["content"]
|
||||
assert "new task" in transformed[2]["content"]
|
||||
|
||||
|
||||
def test_message_compress_creates_message_block():
|
||||
engine = DCPContextEngine(config={"compress": {"mode": "message"}}, context_length=200000)
|
||||
canonical = [{"role": "user", "content": "huge pasted log"}]
|
||||
engine._ensure_refs(canonical)
|
||||
|
||||
result = json.loads(
|
||||
engine.handle_tool_call(
|
||||
"compress",
|
||||
{"topic": "logs", "content": [{"messageId": "m0001", "topic": "log", "summary": "Useful log facts."}]},
|
||||
messages=canonical,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["mode"] == "message"
|
||||
assert result["created_blocks"] == [1]
|
||||
|
||||
|
||||
def test_deduplication_prunes_older_duplicate_tool_output():
|
||||
engine = DCPContextEngine(config={}, context_length=200000)
|
||||
messages = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("a", "read_file", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "a", "content": "old output"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("b", "read_file", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "b", "content": "new output"},
|
||||
]
|
||||
|
||||
engine._apply_deduplication(messages)
|
||||
|
||||
assert "duplicate tool output removed" in messages[1]["content"]
|
||||
assert messages[3]["content"] == "new output"
|
||||
|
||||
|
||||
def test_deduplication_respects_protected_tools():
|
||||
engine = DCPContextEngine(config={}, context_length=200000)
|
||||
messages = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("a", "patch", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "a", "content": "old output"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("b", "patch", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "b", "content": "new output"},
|
||||
]
|
||||
|
||||
engine._apply_deduplication(messages)
|
||||
|
||||
assert messages[1]["content"] == "old output"
|
||||
|
||||
|
||||
def test_purge_errors_preserves_error_summary():
|
||||
engine = DCPContextEngine(config={"strategies": {"purgeErrors": {"turns": 0}}}, context_length=200000)
|
||||
messages = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("a", "terminal", {"command": "bad"})]},
|
||||
{"role": "tool", "tool_call_id": "a", "content": "ERROR: failed\n" + "x" * 500},
|
||||
{"role": "user", "content": "next"},
|
||||
]
|
||||
|
||||
engine._apply_purge_errors(messages)
|
||||
|
||||
assert "old failed tool output pruned" in messages[1]["content"]
|
||||
assert "ERROR: failed" in messages[1]["content"]
|
||||
|
||||
|
||||
def test_turn_protection_prevents_dedup_pruning_recent_messages():
|
||||
engine = DCPContextEngine(
|
||||
config={"turnProtection": {"enabled": True, "turns": 1}},
|
||||
context_length=200000,
|
||||
)
|
||||
messages = [
|
||||
{"role": "user", "content": "latest turn"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("a", "read_file", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "a", "content": "old output"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("b", "read_file", {"path": "x"})]},
|
||||
{"role": "tool", "tool_call_id": "b", "content": "new output"},
|
||||
]
|
||||
|
||||
engine._apply_deduplication(messages)
|
||||
|
||||
assert messages[1]["content"] == ""
|
||||
assert messages[2]["content"] == "old output"
|
||||
|
|
@ -7,6 +7,7 @@ context_length, causing the CLI status bar to show 'ctx --'.
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.context_engine import ContextEngine
|
||||
from agent.dcp_context_engine import DCPContextEngine
|
||||
|
||||
|
||||
class _StubEngine(ContextEngine):
|
||||
|
|
@ -88,4 +89,37 @@ def test_plugin_engine_update_model_args():
|
|||
assert "model" in kw
|
||||
assert "provider" in kw
|
||||
# Should NOT pass api_mode — the ABC doesn't accept it
|
||||
assert "api_mode" not in kw
|
||||
assert agent.context_compressor is engine
|
||||
|
||||
|
||||
def test_builtin_dcp_engine_selected_from_config():
|
||||
cfg = {
|
||||
"context": {
|
||||
"engine": "dcp",
|
||||
"dcp": {"compress": {"mode": "range", "permission": "allow"}},
|
||||
},
|
||||
"agent": {},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.config.load_config", return_value=cfg),
|
||||
patch("agent.model_metadata.get_model_context_length", return_value=204_800),
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
model="test/model",
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
assert isinstance(agent.context_compressor, DCPContextEngine)
|
||||
assert agent.context_compressor.context_length == 204_800
|
||||
assert "compress" in agent._context_engine_tool_names
|
||||
assert any(tool.get("function", {}).get("name") == "compress" for tool in agent.tools)
|
||||
|
|
|
|||
Loading…
Reference in New Issue