Merge pull request #81891 from kshitijk4poor/revert/dcp-context-engine

revert: remove DCP context engine
This commit is contained in:
kshitij 2026-08-08 23:16:06 +05:30 committed by GitHub
commit e65204b953
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 6 additions and 1585 deletions

View File

@ -31,7 +31,6 @@ from typing import Any, Callable, Dict, List, Optional
from urllib.parse import parse_qs, urlparse, urlunparse
from agent.context_compressor import ContextCompressor
from agent.dcp_context_engine import DCPContextEngine
from agent.iteration_budget import IterationBudget
from agent.memory_manager import StreamingContextScrubber
from agent.session_activity import ActivityProvenance
@ -2412,14 +2411,7 @@ def init_agent(
except Exception:
pass
if _engine_name == "dcp":
_selected_engine = DCPContextEngine(
config=_ctx_cfg.get("dcp", {}) if isinstance(_ctx_cfg, dict) else {},
model=agent.model,
provider=agent.provider,
quiet_mode=agent.quiet_mode,
)
elif _engine_name != "compressor":
if _engine_name != "compressor":
# Try loading from plugins/context_engine/<name>/
try:
from plugins.context_engine import load_context_engine

View File

@ -382,39 +382,6 @@ class ContextEngine(ABC):
"""
return True
# -- Optional: API-call-time transform ---------------------------------
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``.
Contract:
- Receives the API-call copy, not authoritative history.
- Must not mutate ``canonical_messages``.
- Must preserve valid OpenAI message ordering.
- Must not separate an assistant ``tool_calls`` message from its
required tool results.
- Should run before prompt-cache marker placement so caching logic
sees the actual outgoing request.
- Should avoid churning the stable prefix on every call to preserve
prompt-cache hit rates.
"""
return api_messages
# -- Optional: session lifecycle ---------------------------------------
def on_session_start(self, session_id: str, **kwargs) -> None:

View File

@ -1981,34 +1981,6 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# Let the active context engine transform the API-call copy.
# DCP uses this to add refs, compression block placeholders, and
# nudges without mutating the canonical messages transcript.
# Runs after sanitization/normalization so the transform sees the
# final wire shape, and before prompt-cache marker placement so
# caching logic sees the actual outgoing request.
_ctx_engine = getattr(agent, "context_compressor", None)
if _ctx_engine is not None:
_transform_hook = getattr(_ctx_engine, "transform_api_messages", None)
if callable(_transform_hook):
try:
api_messages = _transform_hook(
api_messages,
canonical_messages=messages,
system_prompt=effective_system,
tools=agent.tools,
api_call_count=api_call_count,
model=agent.model,
provider=agent.provider,
session_id=agent.session_id,
)
except Exception as _ctx_err:
request_logger.warning(
"Context engine transform_api_messages failed (session=%s): %s",
getattr(agent, "session_id", None) or "-",
_ctx_err,
)
# NOTE (empty-content class fix): no send-time pad loop here. The
# single owner for "never send a turn strict wire validation rejects
# as empty" is ``repair_empty_non_final_messages``, which runs inside

View File

@ -1,256 +0,0 @@
"""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)

View File

@ -1,656 +0,0 @@
"""DCP-style model-guided context engine for Hermes Agent."""
from __future__ import annotations
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
_ERROR_RE = re.compile(r"\b(error|exception|traceback|failed|failure|timed out|timeout)\b", re.I)
_DCP_SYSTEM_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."
)
# Maximum deactivated blocks to retain; older ones are evicted to bound memory.
_MAX_INACTIVE_BLOCKS = 50
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()
# Cache for message signatures keyed by id(msg) — invalidated when
# the canonical message list changes. Avoids re-hashing the same
# dicts on every API call.
self._sig_cache: dict[int, str] = {}
@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:
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]]:
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)
self._sig_cache.clear()
def update_model(
self,
model: str,
context_length: int,
base_url: str = "",
api_key: str = "",
provider: str = "",
api_mode: 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 []
return [self._compress_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:
result = self._handle_compress(args, messages)
except Exception as exc:
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
# Build / refresh refs from canonical messages. This also updates
# turn counters and message-since-last-user tracking.
self._ensure_refs(canonical_messages)
# Match API messages to refs by content signature. We use a
# role+content-based signature that excludes tool_calls (whose JSON
# may have been re-serialised by _canonicalize_api_tool_calls between
# the canonical list and the API copy) so that assistant tool-calling
# messages still match.
ref_by_api_index = self._match_api_messages_to_refs(api_messages, canonical_messages)
# Shallow-copy the list and structurally clone messages we will
# mutate. This avoids the O(n) cost of copy.deepcopy on every call
# while still protecting the caller's message dicts.
transformed = list(api_messages)
mutated: set[int] = set()
self._annotate_refs(transformed, ref_by_api_index, mutated)
self._apply_blocks(transformed, ref_by_api_index, mutated)
if self._automatic_strategies_enabled():
if self.config.deduplication.enabled:
self._apply_deduplication(transformed, mutated)
if self.config.purge_errors.enabled:
self._apply_purge_errors(transformed, mutated)
self._inject_system_extension(transformed, mutated)
self._inject_nudge(transformed, api_call_count=api_call_count, mutated=mutated)
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 schema ------------------------------------------------------
def _compress_tool_schema(self) -> dict[str, Any]:
is_message_mode = self.config.compress.mode == "message"
if is_message_mode:
item_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."},
}
item_required = ["messageId", "topic", "summary"]
description = (
"Compress individual high-volume messages by ref. Preserve concrete "
"technical facts, file paths, commands, decisions, errors, and open questions."
)
else:
item_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."},
}
item_required = ["startId", "endId", "summary"]
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."
)
return {
"name": "compress",
"description": description,
"parameters": {
"type": "object",
"properties": {
"topic": {"type": "string", "description": "Short 3-5 word label for this compression batch."},
"content": {
"type": "array",
"items": {
"type": "object",
"properties": item_properties,
"required": item_required,
},
},
},
"required": ["topic", "content"],
},
}
# -- Tool handling ----------------------------------------------------
def _handle_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")
is_message_mode = self.config.compress.mode == "message"
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 entry must be an object")
summary = self._require_str(item, "summary")
if is_message_mode:
ref = self._require_str(item, "messageId")
if ref not in self.state.index_by_ref:
raise ValueError(f"Unknown message ref: {ref}")
item_topic = item.get("topic") if isinstance(item.get("topic"), str) else topic
message_refs = [ref]
included_blocks: list[int] = []
block = CompressionBlock(
block_id=self.state.new_block_id(),
run_id=run_id,
mode="message",
topic=item_topic,
summary=self._augment_summary(summary, message_refs),
message_refs=message_refs,
included_block_ids=[],
consumed_block_ids=[],
created_at=time.time(),
)
else:
start_ref = self._require_str(item, "startId")
end_ref = self._require_str(item, "endId")
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")
item_topic = topic
consumed_blocks: list[int] = []
block_id = self.state.new_block_id()
for included in included_blocks:
old = self.state.blocks_by_id.get(included)
if old and old.active:
old.active = False
old.deactivated_at = time.time()
old.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=item_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.block_id] = block
self.state.active_block_ids.add(block.block_id)
created.append(block.block_id)
self.compression_count += len(created)
self.state.turns_since_last_compress = 0
self._evict_inactive_blocks()
mode = "message" if is_message_mode else "range"
return {
"ok": True,
"mode": mode,
"created_blocks": created,
"deactivated_blocks": deactivated,
"active_blocks": sorted(self.state.active_block_ids),
"message": f"Compressed {len(created)} {mode}(s) into {', '.join(f'b{i}' for i in created)}.",
}
# -- Transforms -------------------------------------------------------
def _ensure_refs(self, messages: list[dict[str, Any]]) -> None:
self._sig_cache.clear()
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]:
# Build a mapping from content signature (role + content only,
# NOT tool_calls) to a deque of refs. We exclude tool_calls from
# the signature because _canonicalize_api_tool_calls may have
# re-serialised tool-call argument JSON with sort_keys=True on the
# API copy, producing a different hash than the canonical message.
refs_by_sig: 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_sig[self._content_signature(msg)].append(ref)
out: dict[int, str] = {}
for api_idx, msg in enumerate(api_messages):
if msg.get("role") == "system":
continue
sig = self._content_signature(msg)
queue = refs_by_sig.get(sig)
if queue:
out[api_idx] = queue.popleft()
return out
def _clone_if_needed(self, messages: list[dict[str, Any]], idx: int, mutated: set[int]) -> dict[str, Any]:
"""Clone a message dict before mutating it (copy-on-write)."""
if idx not in mutated:
messages[idx] = dict(messages[idx])
mutated.add(idx)
return messages[idx]
def _annotate_refs(self, messages: list[dict[str, Any]], ref_by_api_index: dict[int, str], mutated: set[int]) -> None:
for idx, ref in ref_by_api_index.items():
msg = self._clone_if_needed(messages, idx, mutated)
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], mutated: set[int]) -> 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 = self._clone_if_needed(messages, anchor_idx, mutated)
anchor["content"] = self._block_summary_text(block)
for ref in covered[1:]:
idx = ref_to_api_index[ref]
msg = self._clone_if_needed(messages, idx, mutated)
msg["content"] = f"[DCP: content moved into compressed block {block.ref}.]"
def _apply_deduplication(self, messages: list[dict[str, Any]], mutated: set[int]) -> 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:
msg = self._clone_if_needed(messages, result_idx, mutated)
msg["content"] = "[DCP: duplicate tool output removed. Same tool and arguments were called again later.]"
def _apply_purge_errors(self, messages: list[dict[str, Any]], mutated: set[int]) -> 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]
cloned = self._clone_if_needed(messages, idx, mutated)
cloned["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]], mutated: set[int]) -> None:
if self.config.compress.permission == "deny":
return
if messages and messages[0].get("role") == "system" and isinstance(messages[0].get("content"), str):
if _DCP_SYSTEM_EXTENSION not in messages[0]["content"]:
msg = self._clone_if_needed(messages, 0, mutated)
msg["content"] = f"{msg['content']}\n\n{_DCP_SYSTEM_EXTENSION}"
def _inject_nudge(self, messages: list[dict[str, Any]], *, api_call_count: int, mutated: set[int]) -> None:
# Use the provider-reported token count from the last response if
# available — avoids re-estimating tokens on every API call.
prompt_tokens = self.last_prompt_tokens
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
# Only inject into user messages — never into tool results or
# assistant messages, which could violate provider message semantics.
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
cloned = self._clone_if_needed(messages, idx, mutated)
cloned["content"] = f"{cloned['content']}\n\n<dcp-nudge>{nudge}</dcp-nudge>"
return
# -- Helpers ----------------------------------------------------------
def _message_key(self, msg: dict[str, Any], idx: int) -> str:
return f"{idx}:{self._content_signature(msg)}"
def _content_signature(self, msg: dict[str, Any]) -> str:
"""Signature based on role + content only.
Excludes tool_calls and tool_call_id because the API copy may have
been re-serialised (sorted JSON keys) by _canonicalize_api_tool_calls,
which would produce a different hash than the canonical message.
"""
cache_key = id(msg)
cached = self._sig_cache.get(cache_key)
if cached is not None:
return cached
clean = {
"role": msg.get("role"),
"content": msg.get("content"),
}
raw = json.dumps(clean, sort_keys=True, default=str, separators=(",", ":"))
sig = hashlib.sha1(raw.encode("utf-8", "ignore")).hexdigest()
self._sig_cache[cache_key] = sig
return sig
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 _evict_inactive_blocks(self) -> None:
"""Bound memory by evicting old deactivated blocks."""
inactive = sorted(
(bid for bid, b in self.state.blocks_by_id.items() if not b.active),
key=lambda bid: self.state.blocks_by_id[bid].deactivated_at or 0,
)
for bid in inactive[_MAX_INACTIVE_BLOCKS:]:
del self.state.blocks_by_id[bid]
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,
)

View File

@ -1,70 +0,0 @@
"""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
]

View File

@ -1665,32 +1665,6 @@ DEFAULT_CONFIG = {
# a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
"context": {
"engine": "compressor",
# DCP context engine config — only active when engine == "dcp".
"dcp": {
"enabled": True,
"compress": {
"mode": "range",
"permission": "allow",
"maxContextLimit": 100000,
"minContextLimit": 50000,
"nudgeFrequency": 5,
"iterationNudgeThreshold": 15,
"nudgeForce": "soft",
},
"strategies": {
"deduplication": {
"enabled": True,
},
"purgeErrors": {
"enabled": True,
"turns": 4,
},
},
"turnProtection": {
"enabled": False,
"turns": 4,
},
},
# Return freed glibc allocator pages after long-running agent/TUI
# cleanup boundaries. Unsupported platforms are safe no-ops.
"memory_trim": {

View File

@ -1,63 +0,0 @@
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"

View File

@ -1,331 +0,0 @@
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_disabled_engine_exposes_no_tool_and_returns_original_api_messages():
engine = DCPContextEngine(config={"enabled": False}, context_length=200000)
api_messages = [{"role": "user", "content": "hello"}]
transformed = engine.transform_api_messages(
api_messages,
canonical_messages=[{"role": "user", "content": "hello"}],
system_prompt="",
tools=[],
api_call_count=1,
model="test-model",
provider="openai",
session_id="s1",
)
assert engine.get_tool_schemas() == []
assert transformed is api_messages
assert api_messages == [{"role": "user", "content": "hello"}]
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_range_compress_consumes_overlapping_active_blocks():
engine = DCPContextEngine(config={}, context_length=200000)
canonical = [
{"role": "user", "content": "phase one"},
{"role": "assistant", "content": "phase one result"},
{"role": "user", "content": "phase two"},
{"role": "assistant", "content": "phase two result"},
]
engine._ensure_refs(canonical)
first = json.loads(
engine.handle_tool_call(
"compress",
{"topic": "phase one", "content": [{"startId": "m0001", "endId": "m0002", "summary": "Phase one summary."}]},
messages=canonical,
)
)
second = json.loads(
engine.handle_tool_call(
"compress",
{"topic": "both phases", "content": [{"startId": "b1", "endId": "m0004", "summary": "Both phases summary."}]},
messages=canonical,
)
)
assert first["created_blocks"] == [1]
assert second["created_blocks"] == [2]
assert second["deactivated_blocks"] == [1]
assert engine.state.blocks_by_id[1].active is False
assert engine.state.blocks_by_id[1].deactivated_by_block_id == 2
assert engine.state.active_block_ids == {2}
def test_multimodal_messages_get_text_ref_without_mutating_canonical_content():
engine = DCPContextEngine(config={}, context_length=200000)
canonical = [
{
"role": "user",
"content": [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": "https://example.invalid/image.png"}},
],
}
]
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[0]["content"] == [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": "https://example.invalid/image.png"}},
]
assert transformed[1]["content"][-1] == {"type": "text", "text": '<dcp-ref id="m0001" />'}
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, set())
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, set())
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, set())
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, set())
assert messages[1]["content"] == ""
assert messages[2]["content"] == "old output"
def test_manual_mode_can_disable_automatic_strategies_in_transform():
engine = DCPContextEngine(
config={"manualMode": {"enabled": True, "automaticStrategies": False}},
context_length=200000,
)
canonical = [
{"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"},
]
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 transformed[1]["content"].startswith("old output")
assert transformed[3]["content"].startswith("new output")
def test_manual_compress_request_injects_one_shot_nudge_without_mutating_history():
engine = DCPContextEngine(config={}, context_length=200000)
canonical = [
{"role": "user", "content": "please compact old work"},
{"role": "assistant", "content": "working"},
]
returned = engine.compress(canonical, current_tokens=1234, focus_topic="old investigation")
first = 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",
)
second = engine.transform_api_messages(
[msg.copy() for msg in canonical],
canonical_messages=canonical,
system_prompt="",
tools=[],
api_call_count=2,
model="test-model",
provider="openai",
session_id="s1",
)
assert returned is canonical
assert "DCP manual compression requested" in first[0]["content"]
assert "old investigation" in first[0]["content"]
assert "DCP manual compression requested" not in second[0]["content"]
assert canonical == [
{"role": "user", "content": "please compact old work"},
{"role": "assistant", "content": "working"},
]

View File

@ -70,13 +70,10 @@ run_conversation()
- chat_completions: OpenAI format as-is
- codex_responses: convert to Responses API input items
- anthropic_messages: convert via anthropic_adapter.py
6. Let the active context engine transform the API-call copy, if supported
- DCP-style engines can add refs, compression placeholders, and nudges
- canonical conversation history must remain unchanged
7. Inject ephemeral prompt layers (budget warnings, context pressure)
8. Apply prompt caching markers if on Anthropic
9. Make interruptible API call (_interruptible_api_call)
10. Parse response:
6. Inject ephemeral prompt layers (budget warnings, context pressure)
7. Apply prompt caching markers if on Anthropic
8. Make interruptible API call (_interruptible_api_call)
9. Parse response:
- If tool_calls: execute them, append results, loop back to step 5
- If text response: persist session, flush memory if needed, return
```
@ -163,19 +160,6 @@ Some tools are intercepted by `run_agent.py` *before* reaching `handle_function_
These tools modify agent state directly and return synthetic tool results without going through the registry.
### Context-engine tools
The active context engine can expose tools via `get_tool_schemas()`. These tools
are injected into the model-visible tool list and routed back to
`handle_tool_call()` before normal registry dispatch.
This is how DCP-style context management exposes a model-callable `compress`
tool. The tool updates context-engine state, then the next API-call transform
applies compression blocks to the outbound message copy. It should not mutate
the canonical transcript unless the engine explicitly documents a
transcript-mutating mode.
## Callback Surfaces
`AIAgent` supports platform-specific callbacks that enable real-time progress in the CLI, gateway, and ACP integrations:

View File

@ -30,7 +30,7 @@ This page is the top-level map of Hermes Agent internals. Use it to orient yours
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ │
│ │ Context Mgmt │ │ 3 API Modes │ │ Tool Registry│ │
│ │ Compression │ │ 3 API Modes │ │ Tool Registry│ │
│ │ & Caching │ │ chat_compl. │ │ (registry.py)│ │
│ │ │ │ codex_resp. │ │ 70+ tools │ │
│ │ │ │ anthropic │ │ 28 toolsets │ │
@ -64,7 +64,6 @@ hermes-agent/
│ ├── prompt_builder.py # System prompt assembly
│ ├── context_engine.py # ContextEngine ABC (pluggable)
│ ├── context_compressor.py # Default engine — lossy summarization
│ ├── dcp_context_engine.py # Optional DCP-style model-guided context engine
│ ├── prompt_caching.py # Anthropic prompt caching
│ ├── auxiliary_client.py # Auxiliary LLM for side tasks (vision, summarization)
│ ├── model_metadata.py # Model context lengths, token estimation
@ -136,15 +135,6 @@ hermes-agent/
└── tests/ # Pytest suite (~25,000 tests across ~1,250 files)
```
### Context management
Context management is handled through the `ContextEngine` interface. The default
engine is `ContextCompressor`, which performs host-triggered summarization. Other
engines can expose tools and transform the provider-bound API message copy. A
DCP-style engine uses that path to keep the stored transcript complete while
sending compressed blocks, refs, and nudges to the model.
## Data Flow
### CLI Session

View File

@ -34,88 +34,6 @@ Configure via `hermes plugins` → Provider Plugins → Context Engine, or edit
For building a context engine plugin, see [Context Engine Plugins](/developer-guide/context-engine-plugin).
## DCP Context Engine
Hermes can also run a DCP-style context engine with:
```yaml
context:
engine: "dcp"
```
DCP mode is different from the built-in `ContextCompressor`. The built-in
compressor is host-driven: Hermes decides that the session is too large, calls
an auxiliary summarization model, and replaces the stored message list with a
compressed transcript. DCP mode is model-guided: Hermes exposes a `compress`
tool, adds stable message and block references to the outbound request, and
lets the active model compress completed ranges when it has enough semantic
context to know what is safe to replace.
The DCP invariant is:
> The canonical session transcript remains complete. DCP transforms only the
> API-call copy of the messages sent to the provider.
A DCP engine owns separate compression state:
- message refs such as `m0001`, `m0002`
- compression block refs such as `b1`, `b2`
- active block summaries
- duplicate-tool and old-error pruning state
- compression stats and nudge cadence
### DCP `compress` tool
When DCP mode is active, the context engine may expose a `compress` tool. The
tool does not rewrite the stored transcript. Instead, it records compression
blocks. The next API-call transform applies those blocks to the outbound copy.
DCP supports two modes:
- `range`: compress one or more contiguous spans using `{startId, endId, summary}`.
- `message`: compress individual high-volume messages using `{messageId, topic, summary}`.
Range mode is the default because it preserves chronology and usually gives
the model enough context to summarize closed work accurately. Message mode is
more surgical and should be treated as experimental until provider-format and
cache behavior are well tested.
### DCP nudges and automatic strategies
DCP mode can inject ephemeral nudges when context pressure rises. Nudges tell
the model to call `compress` before continuing if a completed topic is safe to
compact. The engine may also run cheap automatic strategies over the outbound
copy:
- deduplicate repeated tool calls with the same tool name and arguments, keeping
the latest output
- purge bulky old failed-tool inputs while preserving the error text
- protect recent user turns and configured protected tools
These strategies are DCP-state transformations, not transcript edits.
### Interaction with existing compression
When `context.engine: "dcp"`, the built-in `compression:` settings do not drive
normal compaction. DCP should return `False` from `should_compress()` during
normal operation and rely on nudges plus the `compress` tool. The built-in
`ContextCompressor` may still be used as an emergency fallback for hard context
limit failures, but it should not run as a parallel primary compressor.
Gateway session hygiene remains a safety net. Because hygiene compression
operates before the agent starts and may mutate gateway history, DCP-aware
hygiene behavior must be handled deliberately rather than implicitly reusing
the built-in compressor path.
### Prompt caching
DCP must run before provider cache-control markers are applied so prompt caching
sees the actual outgoing request. The transform should be deterministic and
should avoid modifying old stable content on every turn. Compression blocks and
automatic pruning should change the cached prefix only when compression state
changes, not as a side effect of moving counters or timestamps.
## Dual Compression System
Hermes has two separate compression layers that operate independently: