diff --git a/.gitignore b/.gitignore index 57dcfc8..9a41a84 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,22 @@ my-app/ # ROFL config (user-generated) rofl.yaml + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +.venv/ +venv/ +ENV/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +.coverage.* +build/ +*.whl + diff --git a/docs/specs/2026-05-22-contexto-hermes-plugin-design.md b/docs/specs/2026-05-22-contexto-hermes-plugin-design.md new file mode 100644 index 0000000..0c1404f --- /dev/null +++ b/docs/specs/2026-05-22-contexto-hermes-plugin-design.md @@ -0,0 +1,477 @@ +# Contexto × Hermes Context Engine Plugin — Design + +**Status:** approved +**Date:** 2026-05-22 +**Implements:** Contexto as a first-class context engine plugin for hermes-agent + +--- + +## 1. Goal + +Make Contexto available to Hermes the same way it's available to OpenClaw: install a plugin, set one config key, and Contexto becomes the active context engine. + +Behavior matches `@ekai/contexto`'s remote mode in OpenClaw, mapped onto Hermes' `ContextEngine` ABC contract. + +## 2. Scope + +### In scope (v1) + +- Python package shipped from the `contexto` repo. Remote backend only — `POST /v1/webhooks/events` and `POST /v1/mindmap/search` against `https://api.getcontexto.com`. +- Full context engine in the `plugins/context_engine//` slot. Standard ABC only; no hermes-agent core changes. +- Compaction inside `compress()`: ingest the drop slice, retrieve, evict from context window. +- `contexto_search` engine tool via the ABC's `get_tool_schemas()` / `handle_tool_call()` — lets the agent pull prior context between compactions. +- Auth via `CONTEXTO_API_KEY` env var; all tunables via `CONTEXTO_*` env vars. No Hermes config block. +- `CONTEXTO_ENABLED=false` disables retrieval injection but keeps ingest + trim (mirrors TS `contextEnabled: false`). + +### Out of scope (v1) + +- Local backend (TS `local` mode). +- Per-turn retrieval injection — Hermes' ABC has no before-LLM-call hook ([official doc](https://hermes-agent.nousresearch.com/docs/developer-guide/context-engine-plugin) §9). `contexto_search` is the substitute. +- TS `sliding-window` token-budget eviction. v1 uses fixed `protect_first_n` / `protect_last_n`. +- Sub-agent context delegation, scoped boundaries, external doc ingestion, `ContextCompressor` state migration. +- Async client (ABC `compress()` is sync). + +## 3. Repo layout + +New Python package sibling to the existing TS package inside the `contexto` monorepo: + +``` +contexto/ +├── packages/ +│ ├── contexto/ # existing TS — @ekai/contexto +│ └── contexto-py/ # NEW Python — PyPI package `contexto-hermes` +│ ├── pyproject.toml +│ ├── README.md +│ ├── src/contexto_hermes/ +│ │ ├── __init__.py # plugin entry; exposes ContextoEngine +│ │ ├── engine.py # ContextoEngine(ContextEngine) +│ │ ├── client.py # RemoteBackend (httpx.Client) +│ │ ├── helpers.py # strip_metadata_envelope, format_search_results, build_episode_payload, normalize_message_text +│ │ ├── tools.py # contexto_search tool schema + handler +│ │ ├── types.py # dataclasses: ContextoConfig, WebhookPayload, SearchResult +│ │ └── install.py # python -m contexto_hermes.install entry point (see §4) +│ ├── tests/ +│ └── plugin.yaml # Hermes plugin manifest +``` + +`pnpm-workspace.yaml` globs `packages/**` but only picks up packages with `package.json`. The Python sibling is invisible to pnpm. Both packages share the repo, README links, CHANGELOG, and the `ContextoBackend` interface contract — but ship as independent artifacts on independent semver. + +**PyPI package name:** `contexto-hermes`. (Plain `contexto` may collide on PyPI; `contexto-hermes` is unambiguous and pairs cleanly with the OpenClaw-targeted `@ekai/contexto`.) + +**Python version:** 3.10+. The spec uses PEP 604 union syntax (`str | None`), PEP 585 generics (`dict[str, Any]`, `list[...]`), and dataclass features that require 3.10 or newer. Pinned in `pyproject.toml` via `requires-python = ">=3.10"`. + +**Versioning policy.** `contexto-hermes` and `@ekai/contexto` ship on independent semver. Python releases pin the compatible `api.getcontexto.com` schema version (effectively the TS plugin's API contract) in the `CHANGELOG.md` and in a module-level `__compatible_contexto_api__` string. Bumping either package never forces a bump in the other. + +## 4. Installation + +Hermes' context-engine loader only scans `plugins/context_engine//` inside the installed hermes-agent; `$HERMES_HOME/plugins/` is NOT scanned. The plugin must land in the bundled tree. + +Recommended path: + +```bash +pip install contexto-hermes +python -m contexto_hermes.install # detects Hermes path, symlinks (or copies) +export CONTEXTO_API_KEY=ckai_xxx +# then add `context: { engine: contexto }` to ~/.hermes/config.yaml +``` + +The `install` command MUST ship in v1 — bare shell snippets break on read-only site-packages, editable installs, and package-manager upgrades that wipe the bundled tree. The command verifies write permissions, prefers symlink, falls back to copy, and emits a clear error if the install path is read-only. + +(Widening Hermes' discovery to `$HERMES_HOME/plugins/context_engine/` is a future upstream PR — not v1.) + +## 5. Architecture + +Six modules, each with one job. + +### `__init__.py` — plugin entry + +```python +import logging +from .engine import ContextoEngine + +logger = logging.getLogger("plugins.context_engine.contexto") + +def register(ctx): + engine = ContextoEngine.from_env() + if engine is None: + logger.error( + "Contexto plugin not registered: CONTEXTO_API_KEY is not set. " + "Hermes will fall back to the default 'compressor' engine. " + "Get a key at https://getcontexto.com and `export CONTEXTO_API_KEY=...`." + ) + return + ctx.register_context_engine(engine) +``` + +`ContextoEngine.from_env()` returns `None` (matching the `mem0` plugin pattern) when `CONTEXTO_API_KEY` is unset. The classmethod is a thin wrapper: + +```python +@classmethod +def from_env(cls) -> "ContextoEngine | None": + config = ContextoConfig.from_env() + if config is None: + return None + return cls(config) +``` + +`ContextoConfig.from_env()` (defined in `types.py`) is what actually parses the environment. + +### `engine.py` — `ContextoEngine(ContextEngine)` + +Implements the standard Hermes ABC: + +| Method | Behavior | +|---|---| +| `name` | Returns `"contexto"`. | +| `update_from_response(usage)` | Updates `last_prompt_tokens`, `last_completion_tokens`, `last_total_tokens` from OpenAI-style usage dict. | +| `should_compress(prompt_tokens=None)` | Returns `True` when `prompt_tokens / context_length >= threshold_percent`. Defaults: `threshold_percent=0.75`, `protect_first_n=3`, `protect_last_n=6` (inherited from ABC). | +| `compress(messages, current_tokens=None, focus_topic=None)` | The compaction entry point. See §6. | +| `on_session_start(session_id, **kwargs)` | Stores `session_id` for use as both Contexto's `sessionId` and `sessionKey` (TS pattern: `sessionKey` defaults to `sessionId` when not separately provided). If `session_id` is empty/None, generates a UUID4 fallback. | +| `on_session_end(session_id, messages)` | No-op. The plugin uses per-request `with httpx.Client(...)` so no connection cleanup is needed. | +| `on_session_reset()` | `super().on_session_reset()` resets token counters; clears `self.injected_item_ids`. Does NOT rotate `self.session_id` — Hermes' `/reset` keeps the same session identity per its existing semantics (ContextCompressor pattern). | +| `has_content_to_compress(messages)` | `True` when `non_system_count(messages) > protect_first_n + protect_last_n`. System messages are not counted against head/tail budgets. | +| `update_model(model, context_length, ...)` | Calls `super().update_model(...)` for token-budget recalc, then stores `self.model = model` and `self.provider = provider` (read from kwargs) for use in episode payloads' `runtime_context`. | +| `get_tool_schemas()` | Returns one tool schema for `contexto_search` (see §5 / tools.py). Hermes wires these into the active tool list at session start, wrapping each as `{"type": "function", "function": }`. | +| `handle_tool_call(name, args, **kwargs)` | Dispatches to `tools.contexto_search(...)` and returns a JSON string. Called by Hermes when the model emits a tool call whose name matches one of our registered schemas. The `messages=` kwarg is always passed. | +| `get_status()` | Extends ABC default with `auth_state` ("ok"\|"auth_error"\|"degraded") and `last_api_error: str \| None`. Surfaced by Hermes' `/status` command. | + +Internal state: +- `session_id: str` (also used as `sessionKey` in payloads; UUID4 fallback if unset) +- `model: str | None`, `provider: str | None` — set by `update_model`; used in episode `runtime_context`. +- `config: ContextoConfig` (loaded from env) +- `client: RemoteBackend` +- `injected_item_ids: set[str]` — dedup across compactions and tool calls in a single session. +- `auth_state: str` (one of `"ok" | "auth_error" | "degraded"`) — observed from `on_error`. +- `last_api_error: str | None` — observed from `on_error`. +- Rate-limit suppression state lives in `RemoteBackend`, not here. + +### `client.py` — `RemoteBackend` + +Sync `httpx.Client`. Mirror of TS `RemoteBackend`. Per-request client construction (`with httpx.Client(...) as c:`) — no shared connection state, no cleanup obligations. + +```python +class RemoteBackend: + def __init__( + self, + config: ContextoConfig, + on_error: Callable[[ApiError], None], + on_success: Callable[[], None], + ): + # on_error(error) — fires for every non-2xx + suppression entry. + # on_success() — fires on every 2xx; engine uses this to clear + # transient `auth_state="degraded"` back to `"ok"`. + self._rate_limit_reset_at: float | None = None # backend-owned + + def ingest(self, payloads: list[WebhookPayload]) -> bool: + # POST /v1/webhooks/events Authorization: Bearer + # If self._rate_limit_reset_at is in the future, returns False + # without making a request (and without calling on_error). + # On 429, sets self._rate_limit_reset_at from Retry-After header + # and calls on_error(ApiError(category="ratelimit", ...)). + # On 2xx, calls on_success() and returns True. + # Never raises. + + def search(self, query: str, max_results: int, + filter: dict | None, min_score: float) -> SearchResult | None: + # Same suppression + success contract as ingest(). Returns None when + # suppressed. Returns parsed SearchResult on 2xx, None otherwise. + # Never raises. + +@dataclass +class ApiError: + category: str # "auth" | "schema" | "ratelimit" | "server" | "network" + message: str # log-friendly description + retry_after: float | None = None # seconds the backend will suppress calls +``` + +**Ownership:** rate-limit suppression state lives inside `RemoteBackend` (it's the only thing with HTTP context and the `Retry-After` header). The engine observes errors via `on_error` and successes via `on_success`, both updating `auth_state` / `last_api_error`: + +| Event | `auth_state` transition | +|---|---| +| `on_error("auth")` | → `"auth_error"` | +| `on_error("schema" \| "ratelimit" \| "server" \| "network")` | → `"degraded"` (if not already `"auth_error"`) | +| `on_success()` | `"degraded"` → `"ok"`; `"auth_error"` → `"ok"` (key may have been rotated) | + +The engine never gates calls itself. Engine→backend boundary stays clean: engine pushes payloads + queries; backend decides whether to send and reports outcomes. + +- Base URL: `https://api.getcontexto.com`. +- Headers: `Authorization: Bearer `, `Content-Type: application/json`. +- Timeouts (configurable via env): search = 10s (`CONTEXTO_SEARCH_TIMEOUT`), ingest = 30s (`CONTEXTO_INGEST_TIMEOUT`). +- HTTP error categorization is mapped to `ApiError.category` values; the engine's `on_error` callback derives `auth_state` from each category. Full mapping table in §7. + +### `helpers.py` + +- `strip_metadata_envelope(text: str) -> str` — drops Hermes' metadata prefix if present. Same regex as TS: `^Sender\s*\(untrusted metadata\)\s*:\s*```json\s*[\s\S]*?```\s*` (preserved verbatim from `packages/contexto/src/helpers.ts`). +- `format_search_results(items: list) -> str` — mirrors TS `formatSearchResults`. Produces a `## Relevant Context\n\n...` markdown block with metadata-aware item rendering (summary vs. raw, evidence_refs, trace_ref, status/confidence header). +- `normalize_message_text(message: dict) -> str` — extracts a single text string from a message regardless of shape: + - `content: str` → return as-is. + - `content: list[part]` → concatenate `part.text` for parts where `part.type == "text"`. Ignore non-text parts (images, audio). + - `content: None` (assistant with tool_calls only) → return empty string. + - Tool-role messages (`role: "tool"`) → use `content` (always string per OpenAI spec). +- `build_episode_payload(messages, session_id, session_key, runtime_context, now=None) -> WebhookPayload` — produces the same payload shape as TS `buildEpisodePayload`. The `now` parameter is an optional zero-arg callable returning a `datetime` for the `timestamp` field; defaults to `lambda: datetime.now(timezone.utc)`. The timestamp is serialized via a custom formatter that emits the TS-style `Z` suffix instead of Python's default `+00:00` (e.g., `"2026-05-22T18:30:00.000Z"`), so fixtures match TS output. Tests inject a frozen clock to compare against TS fixtures (see §9). **Important:** TS uses `JSON.stringify(undefined)` semantics — `undefined` values are OMITTED from the serialized JSON, not serialized as `null`. The Python implementation MUST conditionally exclude None-valued keys to achieve canonical parity: + ```python + context: dict[str, Any] = {"sessionId": session_id} + if runtime_context.get("model") is not None: + context["model"] = runtime_context["model"] + if runtime_context.get("provider") is not None: + context["provider"] = runtime_context["provider"] + + payload: dict[str, Any] = { + "event": {"type": "episode", "action": "combined"}, + "sessionKey": session_key, + "timestamp": (now or (lambda: datetime.now(timezone.utc)))().isoformat(), + "context": context, + "data": {"messages": messages}, # raw messages preserved unchanged + } + # `agent` field omitted entirely (TS passes undefined → omitted) + return payload + ``` + Each `compress()` produces a single episode payload from the drop slice, identical to how TS's `default` strategy buffers per turn and ingests on compact. Canonical JSON parity with TS output (not literal byte equality) is enforced by the test in §9. + +### `tools.py` + +```python +CONTEXTO_SEARCH_SCHEMA = { + "name": "contexto_search", + "description": ( + "Search prior conversation context stored in Contexto. Use this to recall " + "a constraint, decision, or detail from earlier in the conversation that " + "may no longer be in the active context window. Results are scoped to this " + "Contexto account." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "default": 5}, + }, + "required": ["query"], + }, +} +``` + +`contexto_search(engine, args) -> str`: calls `engine.client.search(...)`, dedups against `engine.injected_item_ids`, formats via `format_search_results`, returns a JSON string with `items` and `paths`. + +### `types.py` + +Plain dataclasses; no pydantic dependency. + +```python +@dataclass +class ContextoConfig: + api_key: str + context_enabled: bool = True + max_context_chars: int = 2000 + min_score: float = 0.45 + max_results: int = 7 + search_timeout: float = 10.0 + ingest_timeout: float = 30.0 + + @classmethod + def from_env(cls) -> "ContextoConfig | None": + """Read `CONTEXTO_*` env vars. Returns None iff `CONTEXTO_API_KEY` is unset.""" +``` + +The same module exposes `_env_bool(key, default)`, `_env_int(key, default)`, and `_env_float(key, default)`. Each returns the parsed value, or the default on missing/invalid input, with a `WARNING` log when input is present but unparseable. None of them raise — registration must succeed whenever `CONTEXTO_API_KEY` is set, regardless of other env-var hygiene. + +### `plugin.yaml` + +```yaml +name: contexto +description: Contexto context engine — full episodes + mindmap retrieval (remote) +version: 0.1.0 +# Auth and tunables are env-var driven. No YAML config block. +env_vars: + - name: CONTEXTO_API_KEY + required: true + description: API key from getcontexto.com + - name: CONTEXTO_ENABLED + default: "true" + - name: CONTEXTO_MAX_CONTEXT_CHARS + default: "2000" + - name: CONTEXTO_MIN_SCORE + default: "0.45" + - name: CONTEXTO_MAX_RESULTS + default: "7" + - name: CONTEXTO_SEARCH_TIMEOUT + default: "10" + - name: CONTEXTO_INGEST_TIMEOUT + default: "30" +``` + +## 6. Data flow inside `compress()` + +``` + ┌─────────────────────────────────────┐ + │ ContextoEngine.compress(messages) │ + └─────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ +1. SPLIT 2. INGEST 3. RETRIEVE + ASSEMBLE +protected head/tail drop slice → API search(query) ++ "drop slice" mid POST /v1/webhooks → format → inject as user+assistant pair + /events + ▼ + 4. TOKEN-INVARIANT CHECK + drop retrieved block + if result is not strictly smaller +``` + +### Step 1 — Split + +- Collect all system messages (role == "system") → kept verbatim, placed first in the returned list. +- From non-system messages: keep first `protect_first_n` (default 3) verbatim. +- Keep last `protect_last_n` (default 6) verbatim. +- Everything in between = the **drop slice**. +- If `len(drop_slice) == 0` → return `messages` unchanged (the should_compress threshold was hit but there's nothing to drop; rare). + +### Step 2 — Ingest drop slice + +- Build one `WebhookPayload` via `build_episode_payload(messages=drop_slice, session_id=self.session_id, session_key=self.session_id, runtime_context={"model": self.model, "provider": self.provider})`. `session_key` defaults to `session_id` (TS pattern). `runtime_context` values may be `None` if `update_model` hasn't fired yet — `build_episode_payload` strips `None` values; TS treats both fields as optional. +- Single `client.ingest([payload])` call. +- Fail-soft: on failure, the backend invokes the engine's `on_error` callback (which updates `auth_state` / `last_api_error` and logs at `ERROR`). Compaction continues to Step 3. + +### Step 3 — Retrieve + assemble + +- **If `config.context_enabled` is False, skip Step 3 entirely** — no search, no retrieved-pair injection. Step 4a still runs to produce the trimmed `system + head + tail` candidate. This matches the TS plugin's `contextEnabled: false` semantics: ingestion (Step 2) keeps writing to Contexto, but the engine doesn't read back. +- Query selection: + - If `focus_topic` is set, use it as-is. + - Else find the last user-role message in the tail and apply `normalize_message_text(...)` then `strip_metadata_envelope(...)`. If empty, skip Step 3. +- `client.search(query, max_results=config.max_results, filter={"source": "summary"}, min_score=config.min_score)`. The `{"source": "summary"}` filter mirrors the TS plugin's `AbstractContextEngine.assemble`. +- Dedup `result.items` against `self.injected_item_ids`. +- `format_search_results(filtered_items)` → markdown context block. +- Truncate to `config.max_context_chars` (default 2000), appending `…` if cut. +- **Wrap retrieved context as a synthetic user+assistant pair** (matching TS `assembleContextMessages`): + ```python + [ + {"role": "user", "content": [{"type": "text", "text": "[Recalled context from previous conversations]"}]}, + {"role": "assistant", "content": [{"type": "text", "text": context_block}]}, + ] + ``` + Rationale: a system message late in the conversation can confuse models that expect system messages only at the head; a synthetic user/assistant turn is treated as normal dialogue. + +### Step 4 — Invariant checks + +Two checks, applied in order: + +**4a. Message-count guard.** The retrieved_pair adds 2 messages. To preserve strict message-count reduction, ONLY inject the retrieved_pair when `len(drop_slice) >= 3`. If `len(drop_slice) < 3`, skip injection — return `system_messages + head + tail` directly (strictly fewer messages than input). + +**4b. Token-invariant check** (only runs when 4a allowed injection): +- Assemble candidate list: `system_messages + head + retrieved_pair + tail`. +- Estimate tokens for `messages` (input) and `candidate` (output). Estimator: `tiktoken` for `gpt-*` model families, else `len(text) // 4` heuristic. **Estimates are approximate** — they don't account for model-specific message-envelope overhead. +- If `estimate(candidate) >= estimate(messages)`: + - Drop the `retrieved_pair`. Return `system_messages + head + tail`. +- Otherwise, return `candidate`. + +Record `item.id` for each injected item in `self.injected_item_ids` only if the retrieved pair survives both checks. + +### Invariant + +When `should_compress()` returned `True` and Step 1 produced a non-empty drop slice, `compress()` returns: +- **Strictly fewer messages** than the input (Step 4a's gate ensures this in both branches). +- **Non-increasing estimated tokens** vs the input (Step 4b drops the retrieved pair if it would grow tokens). + +Strict token reduction is not guaranteed for pathological slices (all-empty tool messages, etc.). The message-count invariant alone bounds the loop — convergence after at most `len(messages) - protect_first_n - protect_last_n` compactions. Hermes' real-tokenizer threshold check on the next turn re-triggers `compress()` against a now-smaller list if needed. + +## 7. Error handling + +Fail-soft for all external calls. Contexto must never break the agent. + +| Failure | HTTP / category | Behavior | +|---|---|---| +| `CONTEXTO_API_KEY` unset | — | `ContextoConfig.from_env()` returns `None`, so `register()` returns without calling `ctx.register_context_engine`. The context-engine loader's `_load_engine_from_dir` consequently returns `None`, and `discover_context_engines()` reports `contexto` as unavailable. If the user selected `context.engine: contexto` anyway, Hermes' `load_context_engine` returns `None` and the runtime falls back to the default `compressor` engine. The plugin logs the missing-key reason at `ERROR` during `register()` so it surfaces in plugin load logs. | +| Auth | 401, 403 | Log `ERROR` with "check CONTEXTO_API_KEY". Set `auth_state="auth_error"`, `last_api_error=...`. Skip the current op. Future calls still attempted (key may have been rotated). | +| Schema | 422 | Log `ERROR` with response body. Set `auth_state="degraded"`. Skip the current op. | +| Rate limit | 429 | Log `WARNING`. Honor `Retry-After` header by skipping calls until the deadline. Set `auth_state="degraded"` while suppressed, back to `"ok"` after first successful call. | +| Server | 5xx | Log `ERROR`. Set `auth_state="degraded"`. Skip the current op. | +| Network / timeout | — | Log `ERROR`. Same as 5xx. | +| Ingest fails | any | `compress()` Step 3 still runs (retrieval). Step 4 enforces token reduction. | +| Search fails | any | `compress()` Step 4 falls back to head+tail (no retrieved block). | +| Search returns empty / all dedup'd | — | No retrieved-context pair. Step 4 falls back to head+tail. | +| No usable query | — | Skip Step 3. Step 2 still runs. | + +Logger: `logging.getLogger("plugins.context_engine.contexto")`. `ERROR` for failures, `WARNING` for rate limit / silent degradations, `INFO` for lifecycle, `DEBUG` for payloads. + +`get_status()` exposes `auth_state` and `last_api_error` so the `/status` slash command can surface Contexto health to the user. + +## 8. Configuration + +User selects the engine the standard Hermes way: + +```yaml +# ~/.hermes/config.yaml +context: + engine: contexto +``` + +Everything else is env-var driven (§5 / `ContextoConfig.from_env`). The plugin reads nothing from `config.yaml` itself. This avoids a Hermes-config-injection plumbing problem that the `_EngineCollector` loader doesn't support, and matches how the `mem0` memory plugin handles its own config. + +Required: `CONTEXTO_API_KEY`. Everything else optional with defaults. + +## 9. Testing + +Three layers. + +### Unit (no network) + +- `RemoteBackend.ingest/search` with `httpx.MockTransport`: verify URL, headers, body shape, timeouts, fail-soft on each HTTP error class (401/403/422/429/5xx/network). Verify 429 sets `_rate_limit_reset_at` and suppresses subsequent calls without invoking `on_error` again until the deadline passes. +- `build_episode_payload`: **canonical JSON parity** with a TS fixture. (Not literal byte equality — TS and Python serializers differ on key order, whitespace, and Unicode escaping. We compare canonical forms by re-serializing both sides with `json.dumps(..., sort_keys=True, ensure_ascii=False)`.) Fixture is generated once by running the TS `buildEpisodePayload` against canned inputs with a frozen timestamp, checked into `tests/fixtures/`. The Python `build_episode_payload` accepts an injectable `now: Callable[[], datetime]` parameter (default `lambda: datetime.now(timezone.utc)`) so the test can freeze the clock to match the fixture's `timestamp` field. +- `helpers.strip_metadata_envelope`, `format_search_results`, `normalize_message_text`: table-driven, with cases for string/multipart/null content, tool_calls, tool-role messages. +- `ContextoEngine.compress` with a stub backend: split logic, drop slice construction, token-invariant fallback when retrieved block makes the result larger, dedup updates, `focus_topic` override. + +### ABC compliance + +Following the official doc's template: +- `isinstance(engine, ContextEngine)`, `engine.name == "contexto"`. +- `compress(msgs)` returns a list of role-bearing dicts. +- **Strict message-count reduction:** `len(result) < len(msgs)` (always holds — see §6 invariant). +- **Non-increasing estimated tokens:** `estimate_tokens(result) <= estimate_tokens(msgs)`. +- **Strict token reduction** in the normal path: separate test against a text-heavy conversation asserts `estimate_tokens(result) < estimate_tokens(msgs)`. +- `should_compress` at boundary ratios; `on_session_reset` clears `injected_item_ids`; `has_content_to_compress` counts only non-system messages; Step 4a gate (drop_slice < 3 → no retrieved pair). + +### Smoke (live, opt-in) + +- Gated on `CONTEXTO_API_KEY` env var (skipped if unset). +- Round-trip: ingest a synthetic episode, search with a known query, assert non-empty result. +- Tool round-trip: invoke `contexto_search` via the engine's `handle_tool_call`. +- Runs in CI only on `main` to avoid spamming the API on every PR. + +Pytest only. `pyproject.toml` declares `pytest`, `httpx`, and `tiktoken` as test deps. + +## 10. Known v1 limitations + +1. **No per-turn retrieval injection.** Hermes' ABC has no before-LLM-call hook. The `contexto_search` tool is the v1 substitute, but it depends on the model calling it. +2. **Ingestion only on compaction.** Sessions that never cross the threshold contribute nothing to Contexto. Matches the TS plugin's `default` strategy — not a Hermes-specific regression. +3. **Install requires writing into hermes-agent's bundled tree.** `$HERMES_HOME/plugins/` is not scanned for context engines. Widening that is a future upstream PR. +4. **Token estimation is approximate.** `tiktoken` for OpenAI families, `len(text) // 4` heuristic otherwise. The §6 Step 4 fallback is still correct, may just over-shrink. +5. **No `sliding-window` token-budget eviction.** Uses fixed `protect_first_n` / `protect_last_n`. +6. **Image/audio-only user turns skip retrieval.** No text → no query. The drop slice is still ingested with multimodal content preserved. +7. **`install` command may need re-running on Hermes upgrades.** The bundled tree gets wiped; `rm -rf /contexto` is the uninstall. + +## 11. Future enhancements (post-v1) + +- **Per-turn ingestion + retrieval injection** via an upstream Hermes ABC extension (`after_turn(messages)` and/or `augment_messages(messages, prompt)`). Would close the gap with TS `afterTurn` / `assemble`. +- **`contexto_ingest` tool** as a v1.1 stopgap for ingestion-between-compactions (agents are unreliable at calling "save this" tools, so this is lower priority than the ABC extension). +- **Local backend** (TS-parity mindmap + LLM summarization). +- **`sliding-window` token-budget eviction.** +- **`on_session_start` warm-up.** Pre-fetch top relevant context at session start when resuming a known `session_id`. +- **Widen Hermes context-engine discovery path** to include `$HERMES_HOME/plugins/context_engine/`. + +## 12. Open items to resolve during plan phase + +1. **PyPI name availability.** Confirm `contexto-hermes` is available; if not, fall back to `ekai-contexto-hermes`. + +2. **`update_from_response` usage dict shape.** Confirm Hermes passes the OpenAI-style `{"prompt_tokens", "completion_tokens", "total_tokens"}` dict (matching `ContextCompressor.update_from_response`). + +3. **`/status` integration.** Confirm Hermes' `/status` slash command surfaces `context_compressor.get_status()` extra fields (`auth_state`, `last_api_error`) — or, if it doesn't, decide whether to log them at `INFO` instead. + +## 13. Hermes runtime contract assumptions + +The design assumes these behaviors of the Hermes runtime. They were confirmed against the current implementation at the time of writing. + +- **Loader fallback.** When `context.engine: ` is set but the named engine fails to load (e.g., our `register()` returned without registering because `CONTEXTO_API_KEY` was unset), Hermes logs a WARNING and falls back to the built-in `ContextCompressor`. The plugin's missing-key error log appears in plugin-load logs. +- **`update_model` timing.** Hermes calls `update_model(model, context_length, base_url, api_key, provider=...)` immediately after engine selection and before the first `compress()` or LLM call. `provider` is passed as a kwarg. +- **Context engine tool wiring.** At session start, Hermes iterates `get_tool_schemas()`, wraps each result as `{"type": "function", "function": }`, and adds them to the active tool list. Tool calls matching those names dispatch to `handle_tool_call(name, args, messages=...)`. +- **`on_session_start` invocation.** Called early with `(session_id, hermes_home=..., platform=..., model=..., context_length=...)`. `session_id` is always non-empty. +- **Context engine plugin context.** Plugins under `plugins/context_engine//` receive a stripped-down context that supports only `register_context_engine`. Tool / hook / CLI registration are not available via this slot; tools must be exposed through `get_tool_schemas()` on the engine class itself. diff --git a/packages/contexto-py/CHANGELOG.md b/packages/contexto-py/CHANGELOG.md new file mode 100644 index 0000000..fe82788 --- /dev/null +++ b/packages/contexto-py/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 0.1.0 — 2026-05-23 + +Initial release. Implements the v5 design (`docs/specs/2026-05-22-contexto-hermes-plugin-design.md`). + +- Remote backend only: `POST /v1/webhooks/events`, `POST /v1/mindmap/search` against `api.getcontexto.com`. +- Standard `ContextEngine` ABC implementation. +- Compaction in `compress()`: ingest drop slice → search → inject as synthetic user/assistant pair → token-invariant guard. +- `contexto_search` engine tool. +- Env-var-only config (`CONTEXTO_*`), with bounds validation: out-of-range/NaN values fall back to defaults with a `WARNING`. +- Defensive token coercion in `update_from_response` (string/garbled provider usage counts never raise). +- Installer discovers Hermes' `plugins/context_engine` even when it is a PEP-420 namespace package. +- `python -m contexto_hermes.install` installer. + +Compatible with `api.getcontexto.com` schema version pinned in `__compatible_contexto_api__`. diff --git a/packages/contexto-py/Makefile b/packages/contexto-py/Makefile new file mode 100644 index 0000000..d031457 --- /dev/null +++ b/packages/contexto-py/Makefile @@ -0,0 +1,18 @@ +.PHONY: install test fixtures install-local clean + +install: + pip install -e .[test] + +test: + python -m pytest -q + +fixtures: + cd tests/fixtures && node generate_episode_fixture.mjs + +install-local: + python -m contexto_hermes.install + +clean: + rm -rf build dist *.egg-info src/*.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type d -name .pytest_cache -exec rm -rf {} + diff --git a/packages/contexto-py/README.md b/packages/contexto-py/README.md new file mode 100644 index 0000000..9e5a63a --- /dev/null +++ b/packages/contexto-py/README.md @@ -0,0 +1,60 @@ +# contexto-hermes + +[Contexto](https://getcontexto.com) as a context engine plugin for [hermes-agent](https://hermes-agent.nousresearch.com). + +Mirrors the remote mode of `@ekai/contexto` (OpenClaw plugin) — ingestion of compacted episodes and mindmap retrieval against `api.getcontexto.com`. + +## Install + +```bash +pip install contexto-hermes +python -m contexto_hermes.install # symlink into hermes-agent's plugin tree +export CONTEXTO_API_KEY=ckai_xxx +``` + +Then in `~/.hermes/config.yaml`: + +```yaml +context: + engine: contexto +``` + +## Configuration + +All config is via env vars. Only `CONTEXTO_API_KEY` is required. + +| Env var | Default | Meaning | +|---|---|---| +| `CONTEXTO_API_KEY` | — | API key from getcontexto.com (required) | +| `CONTEXTO_ENABLED` | `true` | When `false`, ingestion still happens but retrieval injection is disabled | +| `CONTEXTO_MAX_CONTEXT_CHARS` | `2000` | Cap on retrieved-context-block size in chars (must be ≥ 1) | +| `CONTEXTO_MIN_SCORE` | `0.45` | Minimum similarity score for retrieved items (0.0–1.0) | +| `CONTEXTO_MAX_RESULTS` | `7` | Items fetched per automatic recall at compaction time | +| `CONTEXTO_SEARCH_TIMEOUT` | `10` | HTTP timeout (seconds) for search calls | +| `CONTEXTO_INGEST_TIMEOUT` | `30` | HTTP timeout (seconds) for ingest calls | + +Invalid, out-of-range, or NaN values fall back to the default with a `WARNING`; they never block registration. + +`CONTEXTO_MAX_RESULTS` sets recall breadth at compaction time. The `contexto_search` tool takes its own `max_results` (default `5`) for on-demand recall. + +## Status + +Health is observable via the engine's `get_status()`: + +```python +{ + "auth_state": "ok" | "degraded" | "auth_error", + "last_api_error": str | None, + "consecutive_ingest_failures": int, # fail-closed compactions in a row + "last_ingest_failure": str | None, # reason for the last fail-closed ingest + ... +} +``` + +On ingest failure, `compress()` fails closed — original messages kept, retrieval skipped, compaction count unchanged — so unpersisted history is never dropped. The counters above surface a sustained outage (e.g. a rate-limit window). + +Hermes' `/status` command surfaces only token-level fields directly; `auth_state` transitions are logged at INFO so they appear in hermes-agent logs. + +## License + +MIT diff --git a/packages/contexto-py/pyproject.toml b/packages/contexto-py/pyproject.toml new file mode 100644 index 0000000..85db44d --- /dev/null +++ b/packages/contexto-py/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "contexto-hermes" +version = "0.1.0" +description = "Contexto context engine plugin for hermes-agent — full episodes + mindmap retrieval (remote)" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +authors = [{ name = "Ekai Labs" }] +keywords = ["contexto", "hermes-agent", "context-engine", "rag", "llm"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["httpx>=0.27"] + +[project.optional-dependencies] +test = ["pytest>=8", "tiktoken>=0.7", "pyyaml>=6"] + +[project.scripts] +contexto-hermes-install = "contexto_hermes.install:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +contexto_hermes = ["py.typed", "plugin.yaml"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/packages/contexto-py/src/contexto_hermes/__init__.py b/packages/contexto-py/src/contexto_hermes/__init__.py new file mode 100644 index 0000000..b3c7aa9 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/__init__.py @@ -0,0 +1,34 @@ +"""contexto-hermes — Contexto context engine plugin for hermes-agent. + +Plugin entry point. Hermes' `_EngineCollector` exec's this module and calls +`register(ctx)`; we wire a `ContextoEngine` instance into `ctx`. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .engine import ContextoEngine + +__all__ = ["ContextoEngine", "register", "__compatible_contexto_api__"] +__version__ = "0.1.0" + +# Pinned `api.getcontexto.com` schema version compatible with this release. +# Bumped independently from `@ekai/contexto`'s semver. +__compatible_contexto_api__ = "2026-05" + +logger = logging.getLogger("plugins.context_engine.contexto") + + +def register(ctx: Any) -> None: + """Plugin registration. Called by hermes-agent's context-engine loader.""" + engine = ContextoEngine.from_env() + if engine is None: + logger.error( + "Contexto plugin not registered: CONTEXTO_API_KEY is not set. " + "Hermes will fall back to the default 'compressor' engine. " + "Get a key at https://getcontexto.com and `export CONTEXTO_API_KEY=...`." + ) + return + ctx.register_context_engine(engine) diff --git a/packages/contexto-py/src/contexto_hermes/__main__.py b/packages/contexto-py/src/contexto_hermes/__main__.py new file mode 100644 index 0000000..8a90bd2 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/__main__.py @@ -0,0 +1,6 @@ +"""`python -m contexto_hermes` → installer.""" + +from .install import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/contexto-py/src/contexto_hermes/client.py b/packages/contexto-py/src/contexto_hermes/client.py new file mode 100644 index 0000000..0b35e0a --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/client.py @@ -0,0 +1,196 @@ +"""RemoteBackend — sync httpx client for api.getcontexto.com. + +Mirrors TS RemoteBackend semantics (URLs, headers, body shapes) and adds +Python-side concerns the TS version doesn't have: configurable timeouts and +429 suppression with Retry-After. + +Per-request client construction (`with httpx.Client(...) as c`). No +module-level shared state. Never raises — every failure path goes through +the `on_error` callback. + +The engine observes errors via `on_error` and successes via `on_success`; +suppression state lives ONLY in the backend. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Callable + +import httpx + +from .types import ApiError, ContextoConfig, SearchResult, WebhookPayload + +logger = logging.getLogger("plugins.context_engine.contexto") + +API_BASE = "https://api.getcontexto.com" +INGEST_PATH = "/v1/webhooks/events" +SEARCH_PATH = "/v1/mindmap/search" + + +def _categorize_status(status: int) -> str: + if status in (401, 403): + return "auth" + if status == 422: + return "schema" + if status == 429: + return "ratelimit" + return "server" + + +def _parse_retry_after(raw: str | None) -> float: + if not raw: + return 60.0 # conservative default + raw = raw.strip() + try: + return max(0.0, float(raw)) + except (TypeError, ValueError): + return 60.0 # HTTP-date form is rare; fall back to 60s + + +class RemoteBackend: + """Sync HTTP backend talking to api.getcontexto.com.""" + + def __init__( + self, + config: ContextoConfig, + on_error: Callable[[ApiError], None], + on_success: Callable[[], None], + transport: httpx.BaseTransport | None = None, + ) -> None: + self._config = config + self._on_error = on_error + self._on_success = on_success + self._transport = transport + self._headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {config.api_key}", + } + self._rate_limit_reset_at: float | None = None + + # --- public surface --- + + def ingest(self, payloads: list[WebhookPayload]) -> bool: + if not payloads: + return True + if self._suppressed(): + return False + try: + with self._client(self._config.ingest_timeout) as client: + response = client.post( + API_BASE + INGEST_PATH, + headers=self._headers, + json=payloads, + ) + except httpx.HTTPError as exc: + self._handle_network_error(exc) + return False + except Exception as exc: + self._handle_unexpected_error(exc) + return False + + return self._handle_response(response, op="ingest") + + def search( + self, + query: str, + max_results: int, + filter: dict[str, Any] | None, + min_score: float, + ) -> SearchResult | None: + if self._suppressed(): + return None + body: dict[str, Any] = { + "query": query, + "maxResults": max_results, + "filter": filter, + "minScore": min_score, + } + try: + with self._client(self._config.search_timeout) as client: + response = client.post( + API_BASE + SEARCH_PATH, + headers=self._headers, + json=body, + ) + except httpx.HTTPError as exc: + self._handle_network_error(exc) + return None + except Exception as exc: + self._handle_unexpected_error(exc) + return None + + if not self._handle_response(response, op="search"): + return None + + try: + data = response.json() + except ValueError: + logger.error("Search response was not valid JSON") + return None + if not isinstance(data, dict): + logger.error("Search response JSON was not an object") + return None + items = data.get("items", []) + paths = data.get("paths", []) + return SearchResult( + items=items if isinstance(items, list) else [], + paths=paths if isinstance(paths, list) else [], + ) + + # --- internals --- + + def _client(self, timeout: float) -> httpx.Client: + kwargs: dict[str, Any] = {"timeout": timeout} + if self._transport is not None: + kwargs["transport"] = self._transport + return httpx.Client(**kwargs) + + def _suppressed(self) -> bool: + if self._rate_limit_reset_at is None: + return False + if time.time() >= self._rate_limit_reset_at: + self._rate_limit_reset_at = None + return False + return True + + def _handle_response(self, response: httpx.Response, *, op: str) -> bool: + if response.is_success: + self._on_success() + return True + + category = _categorize_status(response.status_code) + retry_after: float | None = None + if category == "ratelimit": + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + self._rate_limit_reset_at = time.time() + retry_after + logger.warning( + "[contexto] %s rate-limited (HTTP 429); suppressing for %.1fs", + op, retry_after, + ) + else: + body_preview = "" + try: + body_preview = response.text[:200] + except Exception: + pass + logger.error( + "[contexto] %s HTTP %d: %s", + op, response.status_code, body_preview, + ) + + self._on_error(ApiError( + category=category, + message=f"HTTP {response.status_code}", + retry_after=retry_after, + )) + return False + + def _handle_network_error(self, exc: httpx.HTTPError) -> None: + logger.error("[contexto] network error: %s", exc) + self._on_error(ApiError(category="network", message=str(exc) or type(exc).__name__)) + + def _handle_unexpected_error(self, exc: BaseException) -> None: + logger.error("[contexto] unexpected error: %s", exc, exc_info=True) + self._on_error(ApiError(category="network", message=str(exc) or type(exc).__name__)) diff --git a/packages/contexto-py/src/contexto_hermes/engine.py b/packages/contexto-py/src/contexto_hermes/engine.py new file mode 100644 index 0000000..ac47dce --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/engine.py @@ -0,0 +1,399 @@ +"""ContextoEngine — Hermes ContextEngine ABC implementation. + +Compaction lives in `compress()`: ingest drop slice → search → inject as +synthetic user+assistant pair → token-invariant guard. See spec §6. + +Note: `agent.context_engine` is imported lazily inside the class body. The +hermes-agent plugin loader exec's the plugin module before the agent module +path is fully set up. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from .client import RemoteBackend +from .helpers import ( + build_episode_payload, + format_search_results, + normalize_message_text, + strip_metadata_envelope, +) +from .tools import CONTEXTO_SEARCH_SCHEMA, contexto_search +from .types import ApiError, ContextoConfig, WebhookPayload + +logger = logging.getLogger("plugins.context_engine.contexto") + +_RECALL_LEAD_IN = "[Recalled context from previous conversations]" + + +def _coerce_token(value: Any, fallback: int) -> int: + """Coerce a provider-reported token count to int, never raising. + + Handles ints, floats, and numeric strings (including "1.5"). On anything + non-numeric, returns the prior value so a malformed usage dict is a no-op + rather than a crash. + """ + try: + return int(value or 0) + except (TypeError, ValueError): + try: + return int(float(value)) + except (TypeError, ValueError): + return fallback + + +def _load_base(): + """Return Hermes' ContextEngine ABC, or a minimal stub when running outside Hermes. + + The stub preserves the same class attributes (`last_prompt_tokens`, + `threshold_tokens`, etc.) Hermes' run_agent.py and gateway read directly. + Used so `python -m contexto_hermes.install` works without hermes-agent on + sys.path. + """ + try: + from agent.context_engine import ContextEngine # type: ignore[import-not-found] + return ContextEngine + except ModuleNotFoundError: + class _StubContextEngine: + last_prompt_tokens: int = 0 + last_completion_tokens: int = 0 + last_total_tokens: int = 0 + threshold_tokens: int = 0 + context_length: int = 0 + compression_count: int = 0 + threshold_percent: float = 0.75 + protect_first_n: int = 3 + protect_last_n: int = 6 + + def on_session_reset(self) -> None: + self.last_prompt_tokens = 0 + self.last_completion_tokens = 0 + self.last_total_tokens = 0 + self.compression_count = 0 + + def update_model( + self, + model: str, + context_length: int, + base_url: str = "", + api_key: str = "", + provider: str = "", + ) -> None: + self.context_length = context_length + self.threshold_tokens = int(context_length * self.threshold_percent) + + def get_status(self) -> dict: + return { + "last_prompt_tokens": self.last_prompt_tokens, + "threshold_tokens": self.threshold_tokens, + "context_length": self.context_length, + "usage_percent": ( + min(100, self.last_prompt_tokens / self.context_length * 100) + if self.context_length else 0 + ), + "compression_count": self.compression_count, + } + + return _StubContextEngine + + +class ContextoEngine(_load_base()): # type: ignore[misc] + """Contexto context engine. Implements the Hermes ABC. + + Internal state: + - `session_id`: also used as `sessionKey` in payloads. + - `model` / `provider`: set by `update_model`; used in episode runtime_context. + - `injected_item_ids`: dedup across compactions and tool calls. + - `auth_state`: "ok" | "degraded" | "auth_error" + - `last_api_error`: human-readable last error string. + - `consecutive_ingest_failures`: fail-closed compaction counter. + - `last_ingest_failure`: last fail-closed ingest reason. + """ + + @classmethod + def from_env(cls) -> "ContextoEngine | None": + """Read CONTEXTO_* env vars. Returns None iff CONTEXTO_API_KEY is unset.""" + config = ContextoConfig.from_env() + if config is None: + return None + return cls(config) + + def __init__(self, config: ContextoConfig, backend: Any | None = None) -> None: + super().__init__() + self.config = config + self.session_id: str = "" + self.model: str | None = None + self.provider: str | None = None + self.injected_item_ids: set[str] = set() + self.auth_state: str = "ok" + self.last_api_error: str | None = None + self.consecutive_ingest_failures: int = 0 + self.last_ingest_failure: str | None = None + if backend is None: + backend = RemoteBackend( + config, + on_error=self._on_backend_error, + on_success=self._on_backend_success, + ) + self.client = backend + + # ------------------------------------------------------------------ identity + @property + def name(self) -> str: + return "contexto" + + # --------------------------------------------------------- token / model + def update_from_response(self, usage: dict[str, Any]) -> None: + if not isinstance(usage, dict): + return + # Token counts come from upstream provider responses; some OpenAI-compatible + # providers emit them as strings (e.g. "1234") or omit/garble them. Coerce + # defensively so a malformed usage dict never crashes Hermes' response path. + if "prompt_tokens" in usage: + self.last_prompt_tokens = _coerce_token(usage["prompt_tokens"], self.last_prompt_tokens) + if "completion_tokens" in usage: + self.last_completion_tokens = _coerce_token( + usage["completion_tokens"], self.last_completion_tokens + ) + if "total_tokens" in usage: + self.last_total_tokens = _coerce_token(usage["total_tokens"], self.last_total_tokens) + + def update_model( + self, + model: str, + context_length: int, + base_url: str = "", + api_key: str = "", + provider: str = "", + **_kwargs: Any, + ) -> None: + # Accept and ignore unknown kwargs (e.g. `api_mode` from Hermes' + # run_agent.py / agent_runtime_helpers.py). Hermes may add more in + # future versions; we must never break model switching. + super().update_model(model, context_length, base_url, api_key, provider) + self.model = model or None + self.provider = provider or None + + def should_compress(self, prompt_tokens: int | None = None) -> bool: + if not self.context_length: + return False + tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens + return (tokens / self.context_length) >= self.threshold_percent + + def should_compress_preflight(self, messages: list[dict[str, Any]]) -> bool: + """Cheap fallback when a provider path has not reported prompt_tokens yet.""" + if not self.context_length or not self.has_content_to_compress(messages): + return False + return (self._estimate_tokens(messages) / self.context_length) >= self.threshold_percent + + def has_content_to_compress(self, messages: list[dict[str, Any]]) -> bool: + non_system = [m for m in messages if m.get("role") != "system"] + return len(non_system) > self.protect_first_n + self.protect_last_n + + # ----------------------------------------------------------- lifecycle + def on_session_start(self, session_id: str, **kwargs: Any) -> None: + self.session_id = session_id if session_id else f"contexto-{uuid.uuid4().hex}" + + def on_session_end(self, session_id: str, messages: list[dict[str, Any]]) -> None: + # Per-request httpx.Client; nothing to close. + return + + def on_session_reset(self) -> None: + super().on_session_reset() + self.injected_item_ids.clear() + # session_id intentionally preserved (matches ContextCompressor pattern). + + # ----------------------------------------------------------------- tools + def get_tool_schemas(self) -> list[dict[str, Any]]: + return [CONTEXTO_SEARCH_SCHEMA] + + def handle_tool_call(self, name: str, args: dict[str, Any], **kwargs: Any) -> str: + import json + if name != CONTEXTO_SEARCH_SCHEMA["name"]: + return json.dumps({"error": f"Unknown context engine tool: {name}"}) + return contexto_search(self, args) + + # ---------------------------------------------------------------- status + def get_status(self) -> dict[str, Any]: + status = super().get_status() + status["auth_state"] = self.auth_state + status["last_api_error"] = self.last_api_error + status["consecutive_ingest_failures"] = self.consecutive_ingest_failures + status["last_ingest_failure"] = self.last_ingest_failure + return status + + # -------------------------------------------------- backend observers + def _on_backend_error(self, err: ApiError) -> None: + self.last_api_error = f"{err.category}: {err.message}" + if err.category == "auth": + self._set_auth_state("auth_error", reason=err.message) + elif self.auth_state != "auth_error": + # All other failure modes degrade the engine, but never override auth_error. + self._set_auth_state("degraded", reason=f"{err.category} {err.message}") + + def _on_backend_success(self) -> None: + if self.auth_state != "ok": + self._set_auth_state("ok", reason="successful API call") + + def _set_auth_state(self, new_state: str, *, reason: str) -> None: + if self.auth_state == new_state: + return + prev = self.auth_state + self.auth_state = new_state + logger.info( + "[contexto] auth_state: %s → %s (%s)", prev, new_state, reason, + ) + + # ----------------------------------------------------------- compaction + def compress( + self, + messages: list[dict[str, Any]], + current_tokens: int | None = None, + focus_topic: str | None = None, + ) -> list[dict[str, Any]]: + """Compact messages per spec §6.""" + # Step 1 — Split + system_messages = [m for m in messages if m.get("role") == "system"] + non_system = [m for m in messages if m.get("role") != "system"] + + if len(non_system) <= self.protect_first_n + self.protect_last_n: + return messages + + head = non_system[: self.protect_first_n] + tail = ( + non_system[-self.protect_last_n :] + if self.protect_last_n > 0 + else [] + ) + drop_slice = non_system[self.protect_first_n : len(non_system) - self.protect_last_n] + + if not drop_slice: + return messages + + # Step 2 — Ingest drop slice (always, even when context_enabled=False) + payload: WebhookPayload = build_episode_payload( + messages=drop_slice, + session_id=self.session_id, + session_key=self.session_id, + runtime_context={"model": self.model, "provider": self.provider}, + ) + previous_api_error = self.last_api_error + ingest_ok = self.client.ingest([payload]) + if not ingest_ok: + self._record_ingest_failure(previous_api_error) + return messages + self._record_ingest_success() + + head_and_tail = system_messages + head + tail + self.compression_count += 1 + + # Step 3 — Retrieve + assemble + if not self.config.context_enabled: + return head_and_tail + + # Step 4a — Message-count gate: only inject pair when drop_slice has >= 3 messages + if len(drop_slice) < 3: + return head_and_tail + + query = self._select_query(tail, focus_topic) + if not query: + return head_and_tail + + result = self.client.search( + query, + max_results=self.config.max_results, + filter={"source": "summary"}, + min_score=self.config.min_score, + ) + if result is None: + return head_and_tail + + filtered_items = [ + entry for entry in result.items + if (item_id := self._entry_id(entry)) is None or item_id not in self.injected_item_ids + ] + if not filtered_items: + return head_and_tail + + context_block = format_search_results(filtered_items) + if self.config.max_context_chars > 0 and len(context_block) > self.config.max_context_chars: + context_block = context_block[: self.config.max_context_chars] + "…" + + retrieved_pair = [ + {"role": "user", "content": [{"type": "text", "text": _RECALL_LEAD_IN}]}, + {"role": "assistant", "content": [{"type": "text", "text": context_block}]}, + ] + candidate = system_messages + head + retrieved_pair + tail + + # Step 4b — Token-invariant check + if self._estimate_tokens(candidate) >= self._estimate_tokens(messages): + return head_and_tail + + # Survives both checks — record dedup ids + for entry in filtered_items: + item_id = self._entry_id(entry) + if item_id is not None: + self.injected_item_ids.add(item_id) + + return candidate + + # -------------------------------------------------------- internals + def _select_query( + self, + tail: list[dict[str, Any]], + focus_topic: str | None, + ) -> str: + if focus_topic: + return focus_topic.strip() + # Per spec §6 Step 3: use the LAST user-role message in the tail. + # If that specific message has no usable text, skip Step 3 entirely — + # don't fall back to earlier user messages. + for msg in reversed(tail): + if msg.get("role") == "user": + return strip_metadata_envelope(normalize_message_text(msg)) + return "" + + @staticmethod + def _entry_id(entry: Any) -> str | None: + if not isinstance(entry, dict): + return None + if "item" in entry and isinstance(entry["item"], dict): + return entry["item"].get("id") + return entry.get("id") + + def _record_ingest_failure(self, previous_api_error: str | None) -> None: + self.consecutive_ingest_failures += 1 + self.last_ingest_failure = self.last_api_error or "ingest returned False" + msg = ( + "[contexto] ingest failed; preserving original messages " + "(consecutive failures: %d, reason: %s)" + ) + args = (self.consecutive_ingest_failures, self.last_ingest_failure) + # If the backend already emitted a concrete error for this call, avoid + # a second warning. If it returned False silently (for example during + # rate-limit suppression), keep one visible engine-boundary signal. + if self.last_api_error != previous_api_error: + logger.debug(msg, *args) + else: + logger.warning(msg, *args) + + def _record_ingest_success(self) -> None: + self.consecutive_ingest_failures = 0 + self.last_ingest_failure = None + + def _estimate_tokens(self, messages: list[dict[str, Any]]) -> int: + text_buffer: list[str] = [] + for msg in messages: + text_buffer.append(normalize_message_text(msg)) + text = "\n".join(text_buffer) + if self.model and self.model.startswith("gpt-"): + try: + import tiktoken + enc = tiktoken.encoding_for_model(self.model) + return len(enc.encode(text)) + except Exception: + pass + return max(1, len(text) // 4) diff --git a/packages/contexto-py/src/contexto_hermes/helpers.py b/packages/contexto-py/src/contexto_hermes/helpers.py new file mode 100644 index 0000000..4114116 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/helpers.py @@ -0,0 +1,156 @@ +"""Helpers ported from the TS @ekai/contexto package. + +The `build_episode_payload` output must achieve canonical-JSON parity with TS +(after `json.dumps(..., sort_keys=True, ensure_ascii=False)`). Three TS behaviors +this module mirrors carefully: + +1. `JSON.stringify` omits keys whose value is `undefined`. Python equivalent: + conditionally exclude None values. +2. `new Date().toISOString()` emits a `Z` suffix. Python's `datetime.isoformat()` + emits `+00:00`. We format manually to match. +3. The `agent` field is omitted entirely in episode payloads (TS passes + `undefined`). +""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any, Callable + +from .types import WebhookPayload + +# Verbatim from packages/contexto/src/helpers.ts:4 — including the /i flag. +_METADATA_ENVELOPE_RE = re.compile( + r"^Sender\s*\(untrusted metadata\)\s*:\s*```json\s*[\s\S]*?```\s*", + re.IGNORECASE, +) + + +def strip_metadata_envelope(text: str) -> str: + """Strip the OpenClaw metadata envelope prefix from a user message.""" + return _METADATA_ENVELOPE_RE.sub("", text).strip() + + +def normalize_message_text(message: dict[str, Any]) -> str: + """Extract a single text string from a message regardless of content shape. + + - `content: str` → return as-is. + - `content: list[part]` → concatenate `part.text` for parts with `type == "text"`. + - `content: None` (assistant with tool_calls only) → empty string. + - Tool-role messages → use `content` (always string per OpenAI spec). + - Missing `content` key → empty string. + """ + content = message.get("content") + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text = part.get("text") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + return "" + + +def format_search_results(items: list[Any]) -> str: + """Render mindmap search items as a `## Relevant Context` markdown block. + + Mirrors `formatSearchResults` in packages/contexto/src/helpers.ts:11-44. + """ + rendered: list[str] = [] + for entry in items: + if isinstance(entry, dict) and "item" in entry: + item = entry["item"] + else: + item = entry + metadata = item.get("metadata", {}) if isinstance(item, dict) else {} + content = item.get("content", "") if isinstance(item, dict) else str(item) + + if metadata.get("source") != "summary": + rendered.append(f"- {content}") + continue + + parts: list[str] = [content] + + evidence_refs = metadata.get("evidence_refs") + if isinstance(evidence_refs, list) and len(evidence_refs) > 0: + refs = ", ".join( + f"{ref.get('type')}:{ref.get('value')}" + for ref in evidence_refs + if isinstance(ref, dict) + ) + parts.append(f"Refs: {refs}") + + trace_ref = metadata.get("trace_ref") + if trace_ref: + parts.append(f"Trace: {trace_ref}") + + header_bits: list[str] = [] + status = metadata.get("status") + if status: + header_bits.append(str(status)) + confidence = metadata.get("confidence") + if confidence is not None: + header_bits.append(f"confidence: {confidence}") + header = " | ".join(header_bits) + + body = "\n".join(parts) + rendered.append(f"### [{header}]\n{body}" if header else body) + + return "## Relevant Context\n\n" + "\n\n".join(rendered) + + +def _format_z_timestamp(dt: datetime) -> str: + """Emit `2026-05-23T18:30:00.000Z` — TS-compatible (milliseconds + Z suffix). + + `datetime.isoformat()` produces microseconds + `+00:00`; TS produces + milliseconds + `Z`. We bridge the gap manually. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + millis = dt.microsecond // 1000 + return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{millis:03d}Z" + + +def build_episode_payload( + messages: list[dict[str, Any]], + session_id: str, + session_key: str, + runtime_context: dict[str, Any], + now: Callable[[], datetime] | None = None, +) -> WebhookPayload: + """Build a single episode WebhookPayload for ingestion. + + Mirrors TS `buildEpisodePayload` (engine/utils.ts:34-47) which calls + `buildPayload('episode', 'combined', sessionKey, {...}, undefined, {messages})`. + + TS behavior reproduced: + - `agent` field omitted entirely (TS passes `undefined`). + - `context.model` / `context.provider` omitted when None (TS `undefined`). + - Timestamp is `Z`-suffixed. + """ + context: dict[str, Any] = {"sessionId": session_id} + model = runtime_context.get("model") + if model is not None: + context["model"] = model + provider = runtime_context.get("provider") + if provider is not None: + context["provider"] = provider + + clock = now or (lambda: datetime.now(timezone.utc)) + timestamp = _format_z_timestamp(clock()) + + return { + "event": {"type": "episode", "action": "combined"}, + "sessionKey": session_key, + "timestamp": timestamp, + "context": context, + "data": {"messages": messages}, + } diff --git a/packages/contexto-py/src/contexto_hermes/install.py b/packages/contexto-py/src/contexto_hermes/install.py new file mode 100644 index 0000000..f7e040f --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/install.py @@ -0,0 +1,155 @@ +"""`python -m contexto_hermes.install` — drop the plugin into hermes-agent's tree. + +Hermes' context-engine loader only scans `plugins/context_engine//` inside +the installed hermes-agent; `$HERMES_HOME/plugins/` is NOT scanned. The plugin +must land in the bundled tree. + +Detection order: + 1. `HERMES_AGENT_ROOT` env var (explicit override). + 2. Discovery via `import plugins.context_engine` — uses the same import + resolution Hermes itself uses. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import logging +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger("plugins.context_engine.contexto") + +_PLUGIN_NAME = "contexto" + + +@dataclass +class InstallResult: + success: bool + message: str + target: Path | None = None + + +def _discover_via_sys_path() -> Path | None: + """Find plugins/context_engine/ via the same import path Hermes uses. + + Handles both regular packages (``spec.origin`` points at ``__init__.py``) + and PEP-420 namespace packages (``spec.origin`` is None — the directory is + only reachable via ``submodule_search_locations``), which Hermes plugin + host trees commonly use. + """ + spec = importlib.util.find_spec("plugins.context_engine") + if spec is None: + return None + if spec.origin is not None: + return Path(spec.origin).parent + for location in spec.submodule_search_locations or []: + return Path(location) + return None + + +def detect_hermes_context_engine_dir() -> Path | None: + env = os.environ.get("HERMES_AGENT_ROOT") + if env: + candidate = Path(env).expanduser().resolve() / "plugins" / "context_engine" + if candidate.is_dir(): + return candidate + + discovered = _discover_via_sys_path() + if discovered is not None and discovered.is_dir(): + return discovered + + return None + + +def _resolve_package_dir() -> Path: + """Find the installed contexto_hermes package directory on disk.""" + spec = importlib.util.find_spec("contexto_hermes") + if spec is None or spec.origin is None: + raise RuntimeError("contexto_hermes is not importable") + return Path(spec.origin).parent + + +def _check_writable(directory: Path) -> bool: + return os.access(directory, os.W_OK) + + +def install_plugin(package_dir: Path | None = None) -> InstallResult: + """Symlink (or copy) the plugin into hermes-agent's plugins/context_engine/.""" + target_parent = detect_hermes_context_engine_dir() + if target_parent is None: + return InstallResult( + success=False, + message=( + "Could not locate hermes-agent's plugins/context_engine directory. " + "Set HERMES_AGENT_ROOT or install hermes-agent so that " + "`python -c 'import plugins.context_engine'` resolves." + ), + ) + + if not _check_writable(target_parent): + return InstallResult( + success=False, + message=( + f"No write permission on {target_parent}. " + "Re-run with sudo, or install Hermes into a writable location." + ), + target=target_parent, + ) + + src = (package_dir or _resolve_package_dir()).resolve() + target = target_parent / _PLUGIN_NAME + + # Remove any prior install (symlink, dir, or stray file). + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.is_dir(): + shutil.rmtree(target) + + try: + os.symlink(src, target, target_is_directory=True) + logger.info("[contexto] installed symlink: %s → %s", target, src) + return InstallResult(success=True, message=f"Symlinked {target} → {src}", target=target) + except OSError as exc: + logger.info("[contexto] symlink failed (%s); falling back to copy", exc) + + try: + shutil.copytree(src, target) + logger.info("[contexto] installed copy: %s", target) + return InstallResult(success=True, message=f"Copied to {target}", target=target) + except Exception as exc: # noqa: BLE001 + return InstallResult( + success=False, + message=f"Failed to install plugin: {exc}", + target=target, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m contexto_hermes.install", + description="Install the Contexto plugin into hermes-agent's context-engine slot.", + ) + parser.add_argument( + "--package-dir", + type=Path, + default=None, + help="Override the source package directory (default: installed contexto_hermes).", + ) + args = parser.parse_args(argv) + + package_dir = args.package_dir or _resolve_package_dir() + result = install_plugin(package_dir=package_dir) + + if result.success: + sys.stderr.write(f"[contexto-hermes] {result.message}\n") + return 0 + sys.stderr.write(f"[contexto-hermes] ERROR: {result.message}\n") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/contexto-py/src/contexto_hermes/plugin.yaml b/packages/contexto-py/src/contexto_hermes/plugin.yaml new file mode 100644 index 0000000..b3a48e2 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/plugin.yaml @@ -0,0 +1,20 @@ +name: contexto +description: Contexto context engine — full episodes + mindmap retrieval (remote) +version: 0.1.0 +# Auth and tunables are env-var driven. No YAML config block. +env_vars: + - name: CONTEXTO_API_KEY + required: true + description: API key from getcontexto.com + - name: CONTEXTO_ENABLED + default: "true" + - name: CONTEXTO_MAX_CONTEXT_CHARS + default: "2000" + - name: CONTEXTO_MIN_SCORE + default: "0.45" + - name: CONTEXTO_MAX_RESULTS + default: "7" + - name: CONTEXTO_SEARCH_TIMEOUT + default: "10" + - name: CONTEXTO_INGEST_TIMEOUT + default: "30" diff --git a/packages/contexto-py/src/contexto_hermes/py.typed b/packages/contexto-py/src/contexto_hermes/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/contexto-py/src/contexto_hermes/tools.py b/packages/contexto-py/src/contexto_hermes/tools.py new file mode 100644 index 0000000..4238c94 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/tools.py @@ -0,0 +1,137 @@ +"""The `contexto_search` engine tool — schema + handler. + +Exposed via `ContextoEngine.get_tool_schemas()`; dispatched by Hermes when the +model emits a tool call whose name matches the schema. +""" + +from __future__ import annotations + +import json +from typing import Any + +from .helpers import format_search_results + +CONTEXTO_SEARCH_SCHEMA: dict[str, Any] = { + "name": "contexto_search", + "description": ( + "Search prior conversation context stored in Contexto. Use this to recall " + "a constraint, decision, or detail from earlier in the conversation that " + "may no longer be in the active context window. Results are scoped to this " + "Contexto account." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "default": 5}, + }, + "required": ["query"], + }, +} + +_DEFAULT_MAX_RESULTS = CONTEXTO_SEARCH_SCHEMA["parameters"]["properties"]["max_results"]["default"] +_MAX_RESULTS_CAP = 50 + + +def _item_id(entry: Any) -> str | None: + if not isinstance(entry, dict): + return None + if "item" in entry and isinstance(entry["item"], dict): + return entry["item"].get("id") + return entry.get("id") + + +def _coerce_query(raw: Any) -> str: + """Best-effort query coercion. Empty string means 'skip the search'.""" + if raw is None: + return "" + if isinstance(raw, str): + return raw.strip() + if isinstance(raw, (int, float, bool)): + return str(raw).strip() + return "" + + +def _coerce_max_results(raw: Any) -> int: + """Coerce a model-supplied max_results to a valid int in [1, _MAX_RESULTS_CAP].""" + if raw is None: + value = _DEFAULT_MAX_RESULTS + else: + try: + value = int(raw) + except (TypeError, ValueError): + value = _DEFAULT_MAX_RESULTS + if value < 1: + return 1 + if value > _MAX_RESULTS_CAP: + return _MAX_RESULTS_CAP + return value + + +def _degraded(note: str) -> str: + import json + return json.dumps({ + "context": "", + "items": [], + "paths": [], + "status": "degraded", + "note": note, + }) + + +def contexto_search(engine: Any, args: dict[str, Any]) -> str: + """Handle a `contexto_search` tool call. Returns a JSON string. + + Calls `engine.client.search(...)` with the `{"source": "summary"}` filter + (matching the TS plugin's `AbstractContextEngine.assemble`). Filters out + items already injected this session, then returns `{items, paths}`. + + Fail-soft: malformed model-supplied args (null, wrong types, missing keys) + never raise — they return a degraded-status JSON instead. + """ + if not isinstance(args, dict): + return _degraded("contexto_search received non-object arguments") + + query = _coerce_query(args.get("query")) + if not query: + return _degraded("contexto_search called without a usable query") + + max_results = _coerce_max_results(args.get("max_results")) + + result = engine.client.search( + query, + max_results=max_results, + filter={"source": "summary"}, + min_score=engine.config.min_score, + ) + + if result is None: + return json.dumps({ + "context": "", + "items": [], + "paths": [], + "status": "degraded", + "note": "Contexto search unavailable (auth, rate-limit, or network).", + }) + + filtered_items = [ + entry for entry in result.items + if (item_id := _item_id(entry)) is None or item_id not in engine.injected_item_ids + ] + + # Record IDs of items we're returning so future compactions and tool calls + # dedup against them (spec §5: dedup across compactions AND tool calls). + for entry in filtered_items: + item_id = _item_id(entry) + if item_id is not None: + engine.injected_item_ids.add(item_id) + + context = format_search_results(filtered_items) if filtered_items else "" + if engine.config.max_context_chars > 0 and len(context) > engine.config.max_context_chars: + context = context[: engine.config.max_context_chars] + "…" + + return json.dumps({ + "context": context, + "items": filtered_items, + "paths": list(result.paths), + }) diff --git a/packages/contexto-py/src/contexto_hermes/types.py b/packages/contexto-py/src/contexto_hermes/types.py new file mode 100644 index 0000000..eeeb905 --- /dev/null +++ b/packages/contexto-py/src/contexto_hermes/types.py @@ -0,0 +1,142 @@ +"""Dataclasses + env-var parsing for the Contexto Hermes plugin. + +Plain dataclasses; no pydantic dependency. Env helpers never raise — registration +must succeed whenever CONTEXTO_API_KEY is set, regardless of other env-var hygiene. +""" + +from __future__ import annotations + +import logging +import math +import os +from dataclasses import dataclass, field +from typing import Any, TypeAlias + +logger = logging.getLogger("plugins.context_engine.contexto") + +WebhookPayload: TypeAlias = dict[str, Any] + + +_TRUE_VALUES = {"true", "1", "yes", "on"} +_FALSE_VALUES = {"false", "0", "no", "off"} + + +def _env_bool(key: str, default: bool) -> bool: + """Parse a boolean env var. Returns default on missing/invalid, with WARNING.""" + raw = os.environ.get(key) + if raw is None: + return default + lowered = raw.strip().lower() + if lowered in _TRUE_VALUES: + return True + if lowered in _FALSE_VALUES: + return False + logger.warning( + "Invalid boolean for %s=%r; using default %s", key, raw, default + ) + return default + + +def _env_int( + key: str, + default: int, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> int: + """Parse an int env var. Returns default on missing/invalid/out-of-range, with WARNING.""" + raw = os.environ.get(key) + if raw is None: + return default + try: + value = int(raw) + except (TypeError, ValueError): + logger.warning( + "Invalid integer for %s=%r; using default %s", key, raw, default + ) + return default + if (minimum is not None and value < minimum) or (maximum is not None and value > maximum): + logger.warning( + "Out-of-range integer for %s=%r (expected %s..%s); using default %s", + key, raw, minimum, maximum, default, + ) + return default + return value + + +def _env_float( + key: str, + default: float, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> float: + """Parse a float env var. Returns default on missing/invalid/out-of-range/NaN, with WARNING.""" + raw = os.environ.get(key) + if raw is None: + return default + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning( + "Invalid float for %s=%r; using default %s", key, raw, default + ) + return default + if not math.isfinite(value): + logger.warning( + "Non-finite float for %s=%r; using default %s", key, raw, default + ) + return default + if (minimum is not None and value < minimum) or (maximum is not None and value > maximum): + logger.warning( + "Out-of-range float for %s=%r (expected %s..%s); using default %s", + key, raw, minimum, maximum, default, + ) + return default + return value + + +@dataclass +class ContextoConfig: + """Runtime configuration. All fields except api_key have defaults.""" + + api_key: str + context_enabled: bool = True + max_context_chars: int = 2000 + min_score: float = 0.45 + max_results: int = 7 + search_timeout: float = 10.0 + ingest_timeout: float = 30.0 + + @classmethod + def from_env(cls) -> "ContextoConfig | None": + """Read CONTEXTO_* env vars. Returns None iff CONTEXTO_API_KEY is unset/empty.""" + api_key = os.environ.get("CONTEXTO_API_KEY", "").strip() + if not api_key: + return None + return cls( + api_key=api_key, + context_enabled=_env_bool("CONTEXTO_ENABLED", default=True), + max_context_chars=_env_int("CONTEXTO_MAX_CONTEXT_CHARS", default=2000, minimum=1), + min_score=_env_float("CONTEXTO_MIN_SCORE", default=0.45, minimum=0.0, maximum=1.0), + max_results=_env_int("CONTEXTO_MAX_RESULTS", default=7, minimum=1), + search_timeout=_env_float("CONTEXTO_SEARCH_TIMEOUT", default=10.0, minimum=0.0), + ingest_timeout=_env_float("CONTEXTO_INGEST_TIMEOUT", default=30.0, minimum=0.0), + ) + + +@dataclass +class ApiError: + """Categorized error from RemoteBackend. Passed to engine via on_error callback.""" + + category: str # "auth" | "schema" | "ratelimit" | "server" | "network" + message: str + retry_after: float | None = None + + +@dataclass +class SearchResult: + """Parsed /v1/mindmap/search response.""" + + items: list[dict[str, Any]] = field(default_factory=list) + paths: list[dict[str, Any]] = field(default_factory=list) diff --git a/packages/contexto-py/tests/__init__.py b/packages/contexto-py/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/contexto-py/tests/conftest.py b/packages/contexto-py/tests/conftest.py new file mode 100644 index 0000000..319d37b --- /dev/null +++ b/packages/contexto-py/tests/conftest.py @@ -0,0 +1,33 @@ +"""Shared test setup — makes hermes-agent's ContextEngine ABC importable.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _find_hermes_agent_root() -> Path | None: + """Locate hermes-agent so its `agent.context_engine` module is importable. + + Checks (in order): + 1. The HERMES_AGENT_ROOT env var. + 2. ../../../hermes-agent relative to this repo (sibling under research/). + """ + env = os.environ.get("HERMES_AGENT_ROOT") + if env: + path = Path(env).expanduser().resolve() + if (path / "agent" / "context_engine.py").exists(): + return path + + here = Path(__file__).resolve() + candidate = here.parents[4] / "hermes-agent" + if (candidate / "agent" / "context_engine.py").exists(): + return candidate + + return None + + +_HERMES_ROOT = _find_hermes_agent_root() +if _HERMES_ROOT is not None and str(_HERMES_ROOT) not in sys.path: + sys.path.insert(0, str(_HERMES_ROOT)) diff --git a/packages/contexto-py/tests/fixtures/episode_payload.json b/packages/contexto-py/tests/fixtures/episode_payload.json new file mode 100644 index 0000000..d7d7264 --- /dev/null +++ b/packages/contexto-py/tests/fixtures/episode_payload.json @@ -0,0 +1,98 @@ +{ + "basic": { + "inputs": { + "messages": [ + { + "role": "user", + "content": "Hello" + }, + { + "role": "assistant", + "content": "Hi there!" + } + ], + "sessionId": "s-abc", + "sessionKey": "s-abc", + "runtimeContext": { + "model": "gpt-4o", + "provider": "openai" + } + }, + "serialized": "{\"event\":{\"type\":\"episode\",\"action\":\"combined\"},\"sessionKey\":\"s-abc\",\"timestamp\":\"2026-05-23T18:30:00.000Z\",\"context\":{\"sessionId\":\"s-abc\",\"model\":\"gpt-4o\",\"provider\":\"openai\"},\"data\":{\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"},{\"role\":\"assistant\",\"content\":\"Hi there!\"}]}}" + }, + "no_runtime_context": { + "inputs": { + "messages": [ + { + "role": "user", + "content": "q" + } + ], + "sessionId": "s-no-rt", + "sessionKey": "s-no-rt", + "runtimeContext": null + }, + "serialized": "{\"event\":{\"type\":\"episode\",\"action\":\"combined\"},\"sessionKey\":\"s-no-rt\",\"timestamp\":\"2026-05-23T18:30:00.000Z\",\"context\":{\"sessionId\":\"s-no-rt\"},\"data\":{\"messages\":[{\"role\":\"user\",\"content\":\"q\"}]}}" + }, + "only_model_set": { + "inputs": { + "messages": [ + { + "role": "user", + "content": "q" + } + ], + "sessionId": "s-model-only", + "sessionKey": "s-model-only", + "runtimeContext": { + "model": "gpt-4o-mini" + } + }, + "serialized": "{\"event\":{\"type\":\"episode\",\"action\":\"combined\"},\"sessionKey\":\"s-model-only\",\"timestamp\":\"2026-05-23T18:30:00.000Z\",\"context\":{\"sessionId\":\"s-model-only\",\"model\":\"gpt-4o-mini\"},\"data\":{\"messages\":[{\"role\":\"user\",\"content\":\"q\"}]}}" + }, + "distinct_session_key": { + "inputs": { + "messages": [ + { + "role": "user", + "content": "hi" + } + ], + "sessionId": "s-id-1", + "sessionKey": "s-key-2", + "runtimeContext": { + "model": "claude-opus-4-7", + "provider": "anthropic" + } + }, + "serialized": "{\"event\":{\"type\":\"episode\",\"action\":\"combined\"},\"sessionKey\":\"s-key-2\",\"timestamp\":\"2026-05-23T18:30:00.000Z\",\"context\":{\"sessionId\":\"s-id-1\",\"model\":\"claude-opus-4-7\",\"provider\":\"anthropic\"},\"data\":{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}}" + }, + "multipart_content": { + "inputs": { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Look at this:" + }, + { + "type": "image_url", + "image_url": { + "url": "https://x.test/y.png" + } + } + ] + } + ], + "sessionId": "s-multi", + "sessionKey": "s-multi", + "runtimeContext": { + "model": "gpt-4o", + "provider": "openai" + } + }, + "serialized": "{\"event\":{\"type\":\"episode\",\"action\":\"combined\"},\"sessionKey\":\"s-multi\",\"timestamp\":\"2026-05-23T18:30:00.000Z\",\"context\":{\"sessionId\":\"s-multi\",\"model\":\"gpt-4o\",\"provider\":\"openai\"},\"data\":{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look at this:\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"https://x.test/y.png\"}}]}]}}" + } +} diff --git a/packages/contexto-py/tests/fixtures/generate_episode_fixture.mjs b/packages/contexto-py/tests/fixtures/generate_episode_fixture.mjs new file mode 100644 index 0000000..f5de8e2 --- /dev/null +++ b/packages/contexto-py/tests/fixtures/generate_episode_fixture.mjs @@ -0,0 +1,123 @@ +// Generate canonical-JSON fixture for buildEpisodePayload from the TS reference. +// +// This script re-implements buildPayload + buildEpisodePayload VERBATIM from +// /home/ubuntu/research/contexto/packages/contexto/src/helpers.ts and engine/utils.ts. +// It then writes the JSON output (after stringify) to episode_payload.json. +// +// Python's build_episode_payload must produce canonical-JSON-equal output: +// json.dumps(payload, sort_keys=True, ensure_ascii=False) +// === JSON.stringify(payload sorted keys) +// +// Run: node generate_episode_fixture.mjs + +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Verbatim port of TS buildPayload (src/helpers.ts) --- +function buildPayload(type, action, sessionKey, context, agent, data) { + return { + event: { type, action }, + sessionKey, + timestamp: new Date().toISOString(), + context, + agent, + data, + }; +} + +// --- Verbatim port of TS buildEpisodePayload (src/engine/utils.ts) --- +function buildEpisodePayload(messages, sessionId, sessionKey, runtimeContext) { + return buildPayload('episode', 'combined', sessionKey, { + sessionId, + model: runtimeContext?.model, + provider: runtimeContext?.provider, + }, undefined, { + messages, + }); +} + +// --- Frozen inputs --- +const FROZEN_TS = '2026-05-23T18:30:00.000Z'; +const realDate = Date; +globalThis.Date = class extends realDate { + constructor(...args) { + if (args.length === 0) { + super(FROZEN_TS); + } else { + super(...args); + } + } + static now() { return realDate.parse(FROZEN_TS); } +}; + +const FIXTURES = [ + { + name: 'basic', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + sessionId: 's-abc', + sessionKey: 's-abc', + runtimeContext: { model: 'gpt-4o', provider: 'openai' }, + }, + { + name: 'no_runtime_context', + messages: [{ role: 'user', content: 'q' }], + sessionId: 's-no-rt', + sessionKey: 's-no-rt', + runtimeContext: undefined, + }, + { + name: 'only_model_set', + messages: [{ role: 'user', content: 'q' }], + sessionId: 's-model-only', + sessionKey: 's-model-only', + runtimeContext: { model: 'gpt-4o-mini' }, + }, + { + name: 'distinct_session_key', + messages: [{ role: 'user', content: 'hi' }], + sessionId: 's-id-1', + sessionKey: 's-key-2', + runtimeContext: { model: 'claude-opus-4-7', provider: 'anthropic' }, + }, + { + name: 'multipart_content', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Look at this:' }, + { type: 'image_url', image_url: { url: 'https://x.test/y.png' } }, + ], + }, + ], + sessionId: 's-multi', + sessionKey: 's-multi', + runtimeContext: { model: 'gpt-4o', provider: 'openai' }, + }, +]; + +const out = {}; +for (const f of FIXTURES) { + const payload = buildEpisodePayload(f.messages, f.sessionId, f.sessionKey, f.runtimeContext); + // Serialize the way Python will compare: JSON.stringify naturally drops undefined. + // We then parse + canonicalize via sorted keys at compare time. + out[f.name] = { + inputs: { + messages: f.messages, + sessionId: f.sessionId, + sessionKey: f.sessionKey, + runtimeContext: f.runtimeContext === undefined ? null : f.runtimeContext, + }, + serialized: JSON.stringify(payload), + }; +} + +const target = join(__dirname, 'episode_payload.json'); +writeFileSync(target, JSON.stringify(out, null, 2) + '\n'); +console.log(`Wrote ${target}`); diff --git a/packages/contexto-py/tests/test_client.py b/packages/contexto-py/tests/test_client.py new file mode 100644 index 0000000..6c739db --- /dev/null +++ b/packages/contexto-py/tests/test_client.py @@ -0,0 +1,335 @@ +"""Tests for contexto_hermes.client — RemoteBackend with httpx.MockTransport.""" + +from __future__ import annotations + +import json +import time +from typing import Callable + +import httpx +import pytest + +from contexto_hermes.client import RemoteBackend +from contexto_hermes.types import ApiError, ContextoConfig + + +def _config(**overrides) -> ContextoConfig: + base = { + "api_key": "ckai_test", + "context_enabled": True, + "max_context_chars": 2000, + "min_score": 0.45, + "max_results": 7, + "search_timeout": 10.0, + "ingest_timeout": 30.0, + } + base.update(overrides) + return ContextoConfig(**base) + + +def _make_backend( + handler: Callable[[httpx.Request], httpx.Response], + on_error_list: list[ApiError] | None = None, + on_success_count: list[int] | None = None, + **config_overrides, +) -> RemoteBackend: + cfg = _config(**config_overrides) + on_error_list = on_error_list if on_error_list is not None else [] + on_success_count = on_success_count if on_success_count is not None else [0] + + def on_error(err: ApiError) -> None: + on_error_list.append(err) + + def on_success() -> None: + on_success_count[0] += 1 + + transport = httpx.MockTransport(handler) + backend = RemoteBackend(cfg, on_error=on_error, on_success=on_success, transport=transport) + return backend + + +class TestIngest: + def test_url_and_headers(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json={"ok": True}) + + backend = _make_backend(handler) + assert backend.ingest([{"hello": "world"}]) is True + req = captured[0] + assert str(req.url) == "https://api.getcontexto.com/v1/webhooks/events" + assert req.method == "POST" + assert req.headers["authorization"] == "Bearer ckai_test" + assert req.headers["content-type"].startswith("application/json") + + def test_body_is_raw_payload_array(self) -> None: + captured: list[bytes] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req.content) + return httpx.Response(200, json={"ok": True}) + + backend = _make_backend(handler) + payloads = [{"a": 1}, {"b": 2}] + backend.ingest(payloads) + body = json.loads(captured[0]) + # MUST be a raw array, NOT wrapped in {"events": [...]}. + assert isinstance(body, list) + assert body == payloads + + def test_empty_payload_list_is_noop(self) -> None: + called = [False] + + def handler(req: httpx.Request) -> httpx.Response: + called[0] = True + return httpx.Response(200) + + backend = _make_backend(handler) + assert backend.ingest([]) is True + assert called[0] is False + + def test_2xx_fires_on_success(self) -> None: + success_count = [0] + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}) + + backend = _make_backend(handler, on_error_list=errors, on_success_count=success_count) + assert backend.ingest([{"x": 1}]) is True + assert success_count[0] == 1 + assert errors == [] + + @pytest.mark.parametrize("status,expected_category", [ + (401, "auth"), + (403, "auth"), + (422, "schema"), + (500, "server"), + (502, "server"), + (503, "server"), + ]) + def test_http_error_categories(self, status: int, expected_category: str) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(status, json={"error": "fail"}) + + backend = _make_backend(handler, on_error_list=errors) + assert backend.ingest([{"x": 1}]) is False + assert len(errors) == 1 + assert errors[0].category == expected_category + + def test_429_sets_suppression_and_categorizes_ratelimit(self) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(429, headers={"Retry-After": "30"}, json={"error": "slow down"}) + + backend = _make_backend(handler, on_error_list=errors) + assert backend.ingest([{"x": 1}]) is False + assert errors[0].category == "ratelimit" + assert errors[0].retry_after == 30.0 + assert backend._rate_limit_reset_at is not None + assert backend._rate_limit_reset_at > time.time() + + def test_subsequent_calls_during_suppression_window(self) -> None: + errors: list[ApiError] = [] + call_count = [0] + + def handler(req: httpx.Request) -> httpx.Response: + call_count[0] += 1 + return httpx.Response(429, headers={"Retry-After": "30"}, json={"error": "slow down"}) + + backend = _make_backend(handler, on_error_list=errors) + backend.ingest([{"x": 1}]) # trips suppression + assert call_count[0] == 1 + assert len(errors) == 1 + + # Subsequent call during the window: no HTTP request, no new on_error + result = backend.ingest([{"x": 2}]) + assert result is False + assert call_count[0] == 1 + assert len(errors) == 1 + + def test_suppression_expires_then_call_proceeds(self) -> None: + errors: list[ApiError] = [] + call_count = [0] + + def handler(req: httpx.Request) -> httpx.Response: + call_count[0] += 1 + return httpx.Response(200, json={"ok": True}) + + backend = _make_backend(handler, on_error_list=errors) + backend._rate_limit_reset_at = time.time() - 1 # expired + result = backend.ingest([{"x": 1}]) + assert result is True + assert call_count[0] == 1 + assert backend._rate_limit_reset_at is None # cleared + + def test_network_error_categorized(self) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + backend = _make_backend(handler, on_error_list=errors) + assert backend.ingest([{"x": 1}]) is False + assert errors[0].category == "network" + + def test_timeout_categorized_as_network(self) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("timed out") + + backend = _make_backend(handler, on_error_list=errors) + assert backend.ingest([{"x": 1}]) is False + assert errors[0].category == "network" + + def test_never_raises_on_any_error(self) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + raise RuntimeError("unexpected") + + backend = _make_backend(handler, on_error_list=errors) + # Must not propagate + backend.ingest([{"x": 1}]) + assert len(errors) == 1 + + +class TestSearch: + def test_url_and_method(self) -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response(200, json={"items": [], "paths": []}) + + backend = _make_backend(handler) + result = backend.search("hello", max_results=5, filter={"source": "summary"}, min_score=0.5) + assert result is not None + assert str(captured[0].url) == "https://api.getcontexto.com/v1/mindmap/search" + assert captured[0].method == "POST" + + def test_body_uses_ts_camelcase_wire_format(self) -> None: + captured: list[bytes] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req.content) + return httpx.Response(200, json={"items": [], "paths": []}) + + backend = _make_backend(handler) + backend.search("what's up", max_results=7, filter={"source": "summary"}, min_score=0.45) + body = json.loads(captured[0]) + # MUST be camelCase, not snake_case. + assert body["query"] == "what's up" + assert body["maxResults"] == 7 + assert body["filter"] == {"source": "summary"} + assert body["minScore"] == 0.45 + assert "max_results" not in body + assert "min_score" not in body + + def test_parses_2xx_response(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "items": [{"item": {"content": "hi"}}], + "paths": [{"id": "p1"}], + }) + + backend = _make_backend(handler) + result = backend.search("q", max_results=5, filter=None, min_score=0.1) + assert result is not None + assert result.items == [{"item": {"content": "hi"}}] + assert result.paths == [{"id": "p1"}] + + def test_returns_none_on_error(self) -> None: + errors: list[ApiError] = [] + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"err": "boom"}) + + backend = _make_backend(handler, on_error_list=errors) + result = backend.search("q", max_results=5, filter=None, min_score=0.1) + assert result is None + assert errors[0].category == "server" + + def test_search_honors_suppression(self) -> None: + errors: list[ApiError] = [] + call_count = [0] + + def handler(req: httpx.Request) -> httpx.Response: + call_count[0] += 1 + return httpx.Response(429, headers={"Retry-After": "10"}, json={}) + + backend = _make_backend(handler, on_error_list=errors) + backend.search("q", max_results=5, filter=None, min_score=0.1) + assert call_count[0] == 1 + # Suppressed + assert backend.search("q2", max_results=5, filter=None, min_score=0.1) is None + assert call_count[0] == 1 + assert len(errors) == 1 + + def test_search_2xx_fires_on_success(self) -> None: + success_count = [0] + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"items": [], "paths": []}) + + backend = _make_backend(handler, on_success_count=success_count) + backend.search("q", max_results=5, filter=None, min_score=0.1) + assert success_count[0] == 1 + + @pytest.mark.parametrize("body", [ + [], + "not an object", + 42, + None, + ]) + def test_non_object_2xx_response_returns_none(self, body) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=body) + + backend = _make_backend(handler) + assert backend.search("q", max_results=5, filter=None, min_score=0.1) is None + + def test_malformed_items_and_paths_default_to_empty_lists(self) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"items": "bad", "paths": {"bad": True}}) + + backend = _make_backend(handler) + result = backend.search("q", max_results=5, filter=None, min_score=0.1) + assert result is not None + assert result.items == [] + assert result.paths == [] + + +class TestTimeouts: + def test_search_uses_search_timeout(self) -> None: + captured: list[float | None] = [] + + def handler(req: httpx.Request) -> httpx.Response: + extensions = req.extensions or {} + timeout = extensions.get("timeout") or {} + captured.append(timeout.get("connect")) + return httpx.Response(200, json={"items": [], "paths": []}) + + backend = _make_backend(handler, search_timeout=4.0) + backend.search("q", max_results=5, filter=None, min_score=0.1) + # All timeout dimensions should be 4.0 + assert captured[0] == 4.0 + + def test_ingest_uses_ingest_timeout(self) -> None: + captured: list[float | None] = [] + + def handler(req: httpx.Request) -> httpx.Response: + extensions = req.extensions or {} + timeout = extensions.get("timeout") or {} + captured.append(timeout.get("connect")) + return httpx.Response(200, json={"ok": True}) + + backend = _make_backend(handler, ingest_timeout=20.0) + backend.ingest([{"x": 1}]) + assert captured[0] == 20.0 diff --git a/packages/contexto-py/tests/test_engine.py b/packages/contexto-py/tests/test_engine.py new file mode 100644 index 0000000..67bd598 --- /dev/null +++ b/packages/contexto-py/tests/test_engine.py @@ -0,0 +1,678 @@ +"""Tests for contexto_hermes.engine — ContextoEngine. + +Covers ABC compliance, compress() paths in spec §6, and invariants in §9. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any + +import pytest + +try: + from agent.context_engine import ContextEngine +except ModuleNotFoundError: + ContextEngine = None + +from contexto_hermes.engine import ContextoEngine +from contexto_hermes.types import ApiError, ContextoConfig, SearchResult + + +@dataclass +class StubBackend: + """In-memory stand-in for RemoteBackend. Captures ingest/search calls.""" + + search_result: SearchResult | None = None + ingest_succeeds: bool = True + ingest_calls: list[list[dict[str, Any]]] = field(default_factory=list) + search_calls: list[dict[str, Any]] = field(default_factory=list) + on_error_handler: Any = None + on_success_handler: Any = None + force_error: ApiError | None = None + + def ingest(self, payloads): + self.ingest_calls.append(list(payloads)) + if self.force_error and self.on_error_handler: + self.on_error_handler(self.force_error) + return False + if self.ingest_succeeds and self.on_success_handler: + self.on_success_handler() + return self.ingest_succeeds + + def search(self, query, max_results, filter, min_score): + self.search_calls.append({ + "query": query, "max_results": max_results, + "filter": filter, "min_score": min_score, + }) + if self.force_error and self.on_error_handler: + self.on_error_handler(self.force_error) + return None + if self.search_result is not None and self.on_success_handler: + self.on_success_handler() + return self.search_result + + +def _config(**overrides) -> ContextoConfig: + base = { + "api_key": "ckai_test", + "context_enabled": True, + "max_context_chars": 2000, + "min_score": 0.45, + "max_results": 7, + "search_timeout": 10.0, + "ingest_timeout": 30.0, + } + base.update(overrides) + return ContextoConfig(**base) + + +def _build_engine(*, search_result=None, ingest_succeeds=True, **cfg_overrides): + cfg = _config(**cfg_overrides) + backend = StubBackend(search_result=search_result, ingest_succeeds=ingest_succeeds) + engine = ContextoEngine(cfg, backend=backend) + backend.on_error_handler = engine._on_backend_error # type: ignore[attr-defined] + backend.on_success_handler = engine._on_backend_success # type: ignore[attr-defined] + return engine, backend + + +def _conversation(n_non_system: int) -> list[dict[str, Any]]: + """Build a system + n_non_system messages conversation.""" + msgs = [{"role": "system", "content": "You are a helpful assistant."}] + for i in range(n_non_system): + role = "user" if i % 2 == 0 else "assistant" + msgs.append({"role": role, "content": f"msg {i} " + "x" * 200}) + return msgs + + +# ============================================================================ +# Identity / ABC compliance +# ============================================================================ + +class TestIdentity: + def test_name(self) -> None: + engine, _ = _build_engine() + assert engine.name == "contexto" + + def test_is_context_engine_subclass(self) -> None: + if ContextEngine is None: + pytest.skip("hermes-agent is not available") + engine, _ = _build_engine() + assert isinstance(engine, ContextEngine) + + +# ============================================================================ +# should_compress / has_content_to_compress / counters +# ============================================================================ + +class TestShouldCompress: + def test_below_threshold(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=10000) + assert engine.should_compress(prompt_tokens=5000) is False + + def test_at_threshold(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=10000) + # threshold = 7500 by default (0.75 * 10000) + assert engine.should_compress(prompt_tokens=7500) is True + + def test_above_threshold(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=10000) + assert engine.should_compress(prompt_tokens=8000) is True + + def test_uses_last_prompt_tokens_when_not_given(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=10000) + engine.last_prompt_tokens = 9000 + assert engine.should_compress() is True + + def test_preflight_fallback_uses_message_estimate(self) -> None: + engine, _ = _build_engine() + engine.update_model("claude-test", context_length=100) + msgs = _conversation(20) + for msg in msgs: + if msg["role"] != "system": + msg["content"] = "x" * 20 + assert engine.should_compress_preflight(msgs) is True + + def test_preflight_fallback_requires_compressible_content(self) -> None: + engine, _ = _build_engine() + engine.update_model("claude-test", context_length=10) + msgs = [ + {"role": "system", "content": "x" * 1000}, + {"role": "user", "content": "x" * 1000}, + ] + assert engine.should_compress_preflight(msgs) is False + + +class TestHasContentToCompress: + def test_false_when_only_protected(self) -> None: + engine, _ = _build_engine() + # protect_first_n=3, protect_last_n=6 → 9 protected non-system msgs + assert engine.has_content_to_compress(_conversation(9)) is False + + def test_true_when_drop_slice_non_empty(self) -> None: + engine, _ = _build_engine() + assert engine.has_content_to_compress(_conversation(15)) is True + + def test_system_messages_not_counted(self) -> None: + engine, _ = _build_engine() + msgs = [ + {"role": "system", "content": "sys1"}, + {"role": "system", "content": "sys2"}, + *[{"role": "user", "content": f"u{i}"} for i in range(9)], + ] + assert engine.has_content_to_compress(msgs) is False + + +class TestUpdateFromResponse: + def test_updates_token_counters(self) -> None: + engine, _ = _build_engine() + engine.update_from_response({ + "prompt_tokens": 1000, + "completion_tokens": 200, + "total_tokens": 1200, + }) + assert engine.last_prompt_tokens == 1000 + assert engine.last_completion_tokens == 200 + assert engine.last_total_tokens == 1200 + + def test_handles_missing_keys(self) -> None: + engine, _ = _build_engine() + engine.update_from_response({"prompt_tokens": 50}) + assert engine.last_prompt_tokens == 50 + # Should not raise on missing completion/total + + def test_numeric_string_token_counts_coerced(self) -> None: + engine, _ = _build_engine() + engine.update_from_response({ + "prompt_tokens": "1000", + "completion_tokens": "1.5", + "total_tokens": "1200", + }) + assert engine.last_prompt_tokens == 1000 + assert engine.last_completion_tokens == 1 # "1.5" -> float -> int + assert engine.last_total_tokens == 1200 + + def test_non_numeric_token_counts_preserve_prior_value(self) -> None: + engine, _ = _build_engine() + engine.update_from_response({"prompt_tokens": 500}) + # A later malformed usage dict must not crash and must not corrupt state. + engine.update_from_response({"prompt_tokens": "n/a"}) + assert engine.last_prompt_tokens == 500 + + +class TestUpdateModel: + def test_recalculates_threshold(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=8000) + assert engine.context_length == 8000 + assert engine.threshold_tokens == int(8000 * 0.75) + + def test_stores_model_and_provider(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=8000, provider="openai") + assert engine.model == "gpt-4o" + assert engine.provider == "openai" + + def test_accepts_extra_kwargs_from_hermes(self) -> None: + # Hermes' run_agent.py and agent_runtime_helpers.py pass `api_mode=...` + # in addition to the documented params. Future Hermes versions may add + # more. We must accept and ignore unknown kwargs — never raise. + engine, _ = _build_engine() + engine.update_model( + model="gpt-4o", + context_length=8000, + base_url="https://x", + api_key="ckai", + provider="openai", + api_mode="chat", # the actual extra Hermes passes today + ) + assert engine.model == "gpt-4o" + assert engine.context_length == 8000 + + def test_accepts_arbitrary_future_kwargs(self) -> None: + engine, _ = _build_engine() + engine.update_model( + model="gpt-4o", + context_length=8000, + some_future_field="x", + another=42, + ) + assert engine.model == "gpt-4o" + + +# ============================================================================ +# Session lifecycle +# ============================================================================ + +class TestSessionLifecycle: + def test_on_session_start_stores_id(self) -> None: + engine, _ = _build_engine() + engine.on_session_start("session-abc") + assert engine.session_id == "session-abc" + + def test_on_session_start_uuid_fallback_when_empty(self) -> None: + engine, _ = _build_engine() + engine.on_session_start("") + assert engine.session_id # non-empty + assert len(engine.session_id) >= 8 + + def test_on_session_start_uuid_fallback_when_none(self) -> None: + engine, _ = _build_engine() + engine.on_session_start(None) # type: ignore[arg-type] + assert engine.session_id + + def test_on_session_reset_clears_injected_ids(self) -> None: + engine, _ = _build_engine() + engine.injected_item_ids.add("x") + engine.on_session_reset() + assert engine.injected_item_ids == set() + + def test_on_session_reset_resets_counters(self) -> None: + engine, _ = _build_engine() + engine.last_prompt_tokens = 1000 + engine.compression_count = 3 + engine.on_session_reset() + assert engine.last_prompt_tokens == 0 + assert engine.compression_count == 0 + + def test_on_session_reset_preserves_session_id(self) -> None: + engine, _ = _build_engine() + engine.on_session_start("keep-me") + engine.on_session_reset() + assert engine.session_id == "keep-me" + + def test_on_session_end_is_noop(self) -> None: + engine, _ = _build_engine() + engine.on_session_end("any", []) # must not raise + + +# ============================================================================ +# Tools +# ============================================================================ + +class TestTools: + def test_get_tool_schemas(self) -> None: + engine, _ = _build_engine() + schemas = engine.get_tool_schemas() + assert len(schemas) == 1 + assert schemas[0]["name"] == "contexto_search" + + def test_handle_tool_call_dispatches(self) -> None: + items = [{"item": {"id": "x", "content": "hi"}}] + engine, backend = _build_engine(search_result=SearchResult(items=items, paths=[])) + result = engine.handle_tool_call("contexto_search", {"query": "q"}) + parsed = json.loads(result) + assert parsed["items"] == items + assert "## Relevant Context" in parsed["context"] + assert "hi" in parsed["context"] + + def test_handle_tool_call_unknown_returns_error_json(self) -> None: + engine, _ = _build_engine() + result = engine.handle_tool_call("nope", {}) + parsed = json.loads(result) + assert "error" in parsed + + +# ============================================================================ +# Status +# ============================================================================ + +class TestStatus: + def test_get_status_extras(self) -> None: + engine, _ = _build_engine() + engine.update_model("gpt-4o", context_length=8000) + status = engine.get_status() + assert status["auth_state"] == "ok" + assert status["last_api_error"] is None + assert status["consecutive_ingest_failures"] == 0 + assert status["last_ingest_failure"] is None + # ABC defaults still present + assert "context_length" in status + assert "compression_count" in status + + +# ============================================================================ +# auth_state transitions (INFO logging — decision #3) +# ============================================================================ + +class TestAuthStateTransitions: + def test_ok_to_degraded_logs_info(self, caplog: pytest.LogCaptureFixture) -> None: + engine, _ = _build_engine() + with caplog.at_level(logging.INFO, logger="plugins.context_engine.contexto"): + engine._on_backend_error(ApiError(category="server", message="502")) + assert engine.auth_state == "degraded" + assert any( + "auth_state" in r.message and "degraded" in r.message + for r in caplog.records + ) + + def test_degraded_to_ok_logs_info(self, caplog: pytest.LogCaptureFixture) -> None: + engine, _ = _build_engine() + engine._on_backend_error(ApiError(category="server", message="502")) + assert engine.auth_state == "degraded" + with caplog.at_level(logging.INFO, logger="plugins.context_engine.contexto"): + engine._on_backend_success() + assert engine.auth_state == "ok" + assert any( + "auth_state" in r.message and "ok" in r.message + for r in caplog.records + ) + + def test_auth_error_priority_over_degraded(self) -> None: + engine, _ = _build_engine() + engine._on_backend_error(ApiError(category="auth", message="401")) + assert engine.auth_state == "auth_error" + # A later server error must NOT downgrade us to "degraded" + engine._on_backend_error(ApiError(category="server", message="502")) + assert engine.auth_state == "auth_error" + + def test_auth_error_recovers_to_ok_on_success(self) -> None: + engine, _ = _build_engine() + engine._on_backend_error(ApiError(category="auth", message="401")) + assert engine.auth_state == "auth_error" + engine._on_backend_success() + assert engine.auth_state == "ok" + + def test_same_state_does_not_relog(self, caplog: pytest.LogCaptureFixture) -> None: + engine, _ = _build_engine() + engine._on_backend_error(ApiError(category="server", message="502")) + with caplog.at_level(logging.INFO, logger="plugins.context_engine.contexto"): + engine._on_backend_error(ApiError(category="server", message="503")) + # already in "degraded" — no INFO transition + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert all("auth_state" not in r.message for r in info_records) + + def test_last_api_error_captured(self) -> None: + engine, _ = _build_engine() + engine._on_backend_error(ApiError(category="schema", message="422 bad")) + assert engine.last_api_error is not None + assert "422" in engine.last_api_error + + +# ============================================================================ +# compress() — the core path +# ============================================================================ + +class TestCompressSplit: + def test_empty_drop_slice_returns_input_unchanged(self) -> None: + engine, backend = _build_engine() + msgs = _conversation(9) # exactly protect_first_n + protect_last_n + result = engine.compress(msgs) + assert result == msgs + assert backend.ingest_calls == [] + assert backend.search_calls == [] + + def test_basic_split_keeps_system_head_tail(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + msgs = _conversation(20) # 3 head + 11 drop + 6 tail + result = engine.compress(msgs) + # System always present + assert result[0]["role"] == "system" + # First 3 non-system messages == original first 3 + non_system = [m for m in result if m["role"] != "system"] + # Tail end matches input tail + original_non_system = [m for m in msgs if m["role"] != "system"] + assert non_system[-6:] == original_non_system[-6:] + + def test_strict_message_count_reduction(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + msgs = _conversation(20) + result = engine.compress(msgs) + assert len(result) < len(msgs) + + +class TestCompressIngest: + def test_ingests_drop_slice_as_single_episode(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + engine.on_session_start("s1") + engine.update_model("gpt-4o", context_length=10000, provider="openai") + msgs = _conversation(20) + engine.compress(msgs) + assert len(backend.ingest_calls) == 1 + episodes = backend.ingest_calls[0] + assert len(episodes) == 1 + payload = episodes[0] + assert payload["event"] == {"type": "episode", "action": "combined"} + assert payload["sessionKey"] == "s1" + assert payload["context"]["sessionId"] == "s1" + assert payload["context"]["model"] == "gpt-4o" + assert payload["context"]["provider"] == "openai" + # drop_slice = non_system[3:-6] = 11 messages + assert len(payload["data"]["messages"]) == 11 + + def test_ingest_fires_when_context_disabled(self) -> None: + engine, backend = _build_engine( + search_result=SearchResult(items=[{"item": {"id": "a", "content": "x"}}], paths=[]), + context_enabled=False, + ) + engine.compress(_conversation(20)) + assert len(backend.ingest_calls) == 1 + # But no search + assert backend.search_calls == [] + + def test_ingest_failure_preserves_original_messages(self, caplog: pytest.LogCaptureFixture) -> None: + engine, backend = _build_engine( + ingest_succeeds=False, + search_result=SearchResult(items=[], paths=[]), + ) + msgs = _conversation(20) + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + result = engine.compress(msgs) + assert backend.ingest_calls + assert result == msgs + assert backend.search_calls == [] + assert engine.compression_count == 0 + assert engine.consecutive_ingest_failures == 1 + assert engine.last_ingest_failure == "ingest returned False" + assert any("preserving original messages" in r.message for r in caplog.records) + + def test_ingest_failure_after_backend_error_does_not_warn_twice( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + engine, backend = _build_engine( + search_result=SearchResult(items=[], paths=[]), + ) + backend.force_error = ApiError(category="network", message="boom") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + result = engine.compress(_conversation(20)) + assert result == _conversation(20) + assert engine.consecutive_ingest_failures == 1 + assert engine.last_ingest_failure == "network: boom" + assert not any("preserving original messages" in r.message for r in caplog.records) + + def test_successful_ingest_resets_failure_counter(self) -> None: + engine, backend = _build_engine( + ingest_succeeds=False, + search_result=SearchResult(items=[], paths=[]), + ) + msgs = _conversation(20) + engine.compress(msgs) + assert engine.consecutive_ingest_failures == 1 + + backend.ingest_succeeds = True + result = engine.compress(msgs) + assert len(result) < len(msgs) + assert engine.consecutive_ingest_failures == 0 + assert engine.last_ingest_failure is None + + +class TestCompressRetrieve: + def test_retrieves_when_drop_slice_large_enough(self) -> None: + item = {"item": {"id": "x1", "content": "important fact"}} + engine, backend = _build_engine( + search_result=SearchResult(items=[item], paths=[]), + ) + result = engine.compress(_conversation(20)) + assert backend.search_calls + # Should have injected retrieved pair somewhere + roles_contents = [(m["role"], m.get("content")) for m in result] + flat = json.dumps(roles_contents) + assert "Recalled context" in flat + + def test_no_search_when_context_disabled(self) -> None: + item = {"item": {"id": "x1", "content": "fact"}} + engine, backend = _build_engine( + search_result=SearchResult(items=[item], paths=[]), + context_enabled=False, + ) + result = engine.compress(_conversation(20)) + assert backend.search_calls == [] + # No retrieved pair + roles_contents = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "Recalled context" not in roles_contents + + def test_step_4a_gate_drop_slice_under_3(self) -> None: + # protect_first_n=3 + drop_slice=2 + protect_last_n=6 = 11 total non-system + item = {"item": {"id": "x1", "content": "fact"}} + engine, backend = _build_engine( + search_result=SearchResult(items=[item], paths=[]), + ) + result = engine.compress(_conversation(11)) + # drop_slice=2, gate fires: no retrieved pair + flat = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "Recalled context" not in flat + # Search may or may not be called; spec allows skipping it when gate would fire + # but the engine MAY still call it — what matters is no injection. + # Strict message reduction still holds + assert len(result) < len(_conversation(11)) + + def test_search_failure_falls_back_to_head_tail(self) -> None: + engine, backend = _build_engine(search_result=None) + result = engine.compress(_conversation(20)) + flat = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "Recalled context" not in flat + assert len(result) < len(_conversation(20)) + + def test_search_uses_focus_topic_when_provided(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + engine.compress(_conversation(20), focus_topic="my custom topic") + assert backend.search_calls + assert backend.search_calls[0]["query"] == "my custom topic" + + def test_search_uses_last_user_message_when_no_focus_topic(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + msgs = _conversation(20) + # Force the last non-system message to be a user message with known content + msgs[-1] = {"role": "user", "content": "what about postgres?"} + engine.compress(msgs) + assert backend.search_calls + assert "postgres" in backend.search_calls[0]["query"] + + def test_search_strips_metadata_envelope(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + msgs = _conversation(20) + msgs[-1] = { + "role": "user", + "content": ( + 'Sender (untrusted metadata):\n```json\n{"x":1}\n```\n\n' + "Real question here" + ), + } + engine.compress(msgs) + assert backend.search_calls + assert backend.search_calls[0]["query"] == "Real question here" + + def test_no_search_when_query_empty(self) -> None: + engine, backend = _build_engine(search_result=SearchResult(items=[], paths=[])) + msgs = _conversation(20) + # Image-only tail user message + msgs[-1] = { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "x"}}], + } + engine.compress(msgs) + assert backend.search_calls == [] + + +class TestCompressDedup: + def test_records_injected_item_ids(self) -> None: + items = [ + {"item": {"id": "i1", "content": "fact 1"}}, + {"item": {"id": "i2", "content": "fact 2"}}, + ] + engine, backend = _build_engine( + search_result=SearchResult(items=items, paths=[]), + ) + engine.compress(_conversation(20)) + assert engine.injected_item_ids == {"i1", "i2"} + + def test_skips_already_injected(self) -> None: + items = [ + {"item": {"id": "i1", "content": "fact 1"}}, + {"item": {"id": "i2", "content": "fact 2"}}, + ] + engine, backend = _build_engine( + search_result=SearchResult(items=items, paths=[]), + ) + engine.injected_item_ids.add("i1") + result = engine.compress(_conversation(20)) + flat = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "fact 2" in flat + assert "fact 1" not in flat + # i2 newly injected, i1 still tracked + assert engine.injected_item_ids == {"i1", "i2"} + + def test_all_dedup_skips_pair(self) -> None: + items = [{"item": {"id": "i1", "content": "fact"}}] + engine, backend = _build_engine( + search_result=SearchResult(items=items, paths=[]), + ) + engine.injected_item_ids.add("i1") + result = engine.compress(_conversation(20)) + flat = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "Recalled context" not in flat + + +# ============================================================================ +# Token-invariant checks (Step 4b) +# ============================================================================ + +class TestTokenInvariant: + def test_non_increasing_estimated_tokens(self) -> None: + # Heavy retrieved content + thin drop slice; engine must drop the pair. + big_blob = "x" * 200000 + items = [{"item": {"id": "x1", "content": big_blob}}] + engine, backend = _build_engine( + search_result=SearchResult(items=items, paths=[]), + ) + result = engine.compress(_conversation(20)) + # Pair must be dropped — no recalled context appears + flat = json.dumps([(m["role"], m.get("content")) for m in result]) + assert "Recalled context" not in flat or len(json.dumps(result)) < len(big_blob) + + def test_strict_reduction_on_normal_path(self) -> None: + # Small retrieved content, large drop slice: candidate must be strictly smaller. + items = [{"item": {"id": "x1", "content": "concise summary"}}] + engine, backend = _build_engine( + search_result=SearchResult(items=items, paths=[]), + ) + msgs = _conversation(30) + result = engine.compress(msgs) + # Use the engine's own estimator + assert engine._estimate_tokens(result) < engine._estimate_tokens(msgs) # type: ignore[attr-defined] + + +# ============================================================================ +# Compression count +# ============================================================================ + +class TestCompressionCount: + def test_increments_on_successful_compaction(self) -> None: + engine, _ = _build_engine(search_result=SearchResult(items=[], paths=[])) + assert engine.compression_count == 0 + engine.compress(_conversation(20)) + assert engine.compression_count == 1 + engine.compress(_conversation(20)) + assert engine.compression_count == 2 + + def test_does_not_increment_on_no_op(self) -> None: + engine, _ = _build_engine() + engine.compress(_conversation(9)) # nothing to drop + assert engine.compression_count == 0 diff --git a/packages/contexto-py/tests/test_helpers.py b/packages/contexto-py/tests/test_helpers.py new file mode 100644 index 0000000..00b45ae --- /dev/null +++ b/packages/contexto-py/tests/test_helpers.py @@ -0,0 +1,269 @@ +"""Tests for contexto_hermes.helpers — TS parity helpers.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from contexto_hermes.helpers import ( + build_episode_payload, + format_search_results, + normalize_message_text, + strip_metadata_envelope, +) + + +FIXTURES_PATH = Path(__file__).parent / "fixtures" / "episode_payload.json" + + +def _canonical(obj: dict) -> str: + return json.dumps(obj, sort_keys=True, ensure_ascii=False) + + +def _frozen_now(iso: str): + """Return a no-arg callable producing the given UTC datetime.""" + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + return lambda: dt + + +class TestStripMetadataEnvelope: + def test_present(self) -> None: + text = ( + 'Sender (untrusted metadata):\n' + '```json\n{"sender":"alice"}\n```\n\n' + "Actual message body" + ) + assert strip_metadata_envelope(text) == "Actual message body" + + def test_case_insensitive(self) -> None: + text = ( + 'sender (UNTRUSTED Metadata) :\n' + '```json\n{"x":1}\n```\n' + "body" + ) + assert strip_metadata_envelope(text) == "body" + + def test_absent_returns_trimmed(self) -> None: + assert strip_metadata_envelope(" plain text ") == "plain text" + + def test_empty_string(self) -> None: + assert strip_metadata_envelope("") == "" + + +class TestNormalizeMessageText: + def test_string_content(self) -> None: + assert normalize_message_text({"role": "user", "content": "hello"}) == "hello" + + def test_list_content_text_only(self) -> None: + msg = {"role": "user", "content": [ + {"type": "text", "text": "part1"}, + {"type": "text", "text": "part2"}, + ]} + assert normalize_message_text(msg) == "part1part2" + + def test_list_content_mixed_image_and_text(self) -> None: + msg = {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "https://x"}}, + {"type": "text", "text": "describe"}, + ]} + assert normalize_message_text(msg) == "describe" + + def test_list_content_image_only_returns_empty(self) -> None: + msg = {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "https://x"}}, + ]} + assert normalize_message_text(msg) == "" + + def test_none_content(self) -> None: + # Assistant message with tool_calls only + msg = {"role": "assistant", "content": None, "tool_calls": [{"id": "x"}]} + assert normalize_message_text(msg) == "" + + def test_tool_role_string_content(self) -> None: + msg = {"role": "tool", "content": "tool output", "tool_call_id": "x"} + assert normalize_message_text(msg) == "tool output" + + def test_missing_content_key(self) -> None: + assert normalize_message_text({"role": "user"}) == "" + + +class TestFormatSearchResults: + def test_header_always_present(self) -> None: + out = format_search_results([]) + assert out.startswith("## Relevant Context\n\n") + + def test_non_summary_item_uses_bullet(self) -> None: + items = [{"item": {"content": "raw stuff", "metadata": {"source": "raw"}}}] + out = format_search_results(items) + assert out == "## Relevant Context\n\n- raw stuff" + + def test_summary_item_no_meta(self) -> None: + items = [{"item": {"content": "summary body", "metadata": {"source": "summary"}}}] + out = format_search_results(items) + assert out == "## Relevant Context\n\nsummary body" + + def test_summary_with_evidence_refs(self) -> None: + items = [{"item": { + "content": "decision: use Postgres", + "metadata": { + "source": "summary", + "evidence_refs": [ + {"type": "msg", "value": "m1"}, + {"type": "msg", "value": "m2"}, + ], + }, + }}] + out = format_search_results(items) + assert "decision: use Postgres\nRefs: msg:m1, msg:m2" in out + + def test_summary_with_trace_ref(self) -> None: + items = [{"item": { + "content": "body", + "metadata": {"source": "summary", "trace_ref": "t-99"}, + }}] + out = format_search_results(items) + assert "body\nTrace: t-99" in out + + def test_summary_with_status_and_confidence_header(self) -> None: + items = [{"item": { + "content": "body", + "metadata": { + "source": "summary", + "status": "confirmed", + "confidence": 0.92, + }, + }}] + out = format_search_results(items) + assert "### [confirmed | confidence: 0.92]\nbody" in out + + def test_summary_status_only_header(self) -> None: + items = [{"item": { + "content": "body", + "metadata": {"source": "summary", "status": "draft"}, + }}] + out = format_search_results(items) + assert "### [draft]\nbody" in out + + def test_multiple_items_separated_by_blank_line(self) -> None: + items = [ + {"item": {"content": "a", "metadata": {"source": "raw"}}}, + {"item": {"content": "b", "metadata": {"source": "raw"}}}, + ] + out = format_search_results(items) + assert out == "## Relevant Context\n\n- a\n\n- b" + + def test_handles_item_without_envelope(self) -> None: + # Some callers pass {item: ...}; others pass the item directly. + items = [{"content": "direct", "metadata": {"source": "raw"}}] + out = format_search_results(items) + assert out == "## Relevant Context\n\n- direct" + + def test_handles_non_dict_item(self) -> None: + out = format_search_results(["bare string item"]) + assert out == "## Relevant Context\n\n- bare string item" + + def test_handles_non_dict_wrapped_item(self) -> None: + out = format_search_results([{"item": "wrapped string item"}]) + assert out == "## Relevant Context\n\n- wrapped string item" + + +class TestBuildEpisodePayload: + @pytest.fixture(scope="class") + def fixtures(self) -> dict: + return json.loads(FIXTURES_PATH.read_text()) + + def _compare(self, fixture: dict, *, runtime_context: dict | None) -> None: + inputs = fixture["inputs"] + expected_obj = json.loads(fixture["serialized"]) + # Frozen timestamp: parse it from the expected serialized form. + now = _frozen_now(expected_obj["timestamp"]) + actual = build_episode_payload( + messages=inputs["messages"], + session_id=inputs["sessionId"], + session_key=inputs["sessionKey"], + runtime_context=runtime_context if runtime_context is not None else {}, + now=now, + ) + assert _canonical(actual) == _canonical(expected_obj) + + def test_basic_parity(self, fixtures: dict) -> None: + self._compare(fixtures["basic"], runtime_context={"model": "gpt-4o", "provider": "openai"}) + + def test_no_runtime_context_parity(self, fixtures: dict) -> None: + # Python: empty dict; TS: undefined. Both should produce context={"sessionId": ...}. + self._compare(fixtures["no_runtime_context"], runtime_context={}) + + def test_only_model_set_parity(self, fixtures: dict) -> None: + self._compare(fixtures["only_model_set"], runtime_context={"model": "gpt-4o-mini"}) + + def test_distinct_session_key_parity(self, fixtures: dict) -> None: + self._compare( + fixtures["distinct_session_key"], + runtime_context={"model": "claude-opus-4-7", "provider": "anthropic"}, + ) + + def test_multipart_content_parity(self, fixtures: dict) -> None: + self._compare( + fixtures["multipart_content"], + runtime_context={"model": "gpt-4o", "provider": "openai"}, + ) + + def test_omits_none_values(self) -> None: + out = build_episode_payload( + messages=[], + session_id="s", + session_key="s", + runtime_context={"model": None, "provider": "openai"}, + now=_frozen_now("2026-05-23T18:30:00.000Z"), + ) + assert out["context"] == {"sessionId": "s", "provider": "openai"} + assert "model" not in out["context"] + + def test_agent_field_absent(self) -> None: + out = build_episode_payload( + messages=[], + session_id="s", + session_key="s", + runtime_context={}, + now=_frozen_now("2026-05-23T18:30:00.000Z"), + ) + assert "agent" not in out + + def test_z_suffixed_timestamp(self) -> None: + # datetime.now(timezone.utc).isoformat() produces "+00:00", not "Z". + # We must emit Z to match TS. + out = build_episode_payload( + messages=[], + session_id="s", + session_key="s", + runtime_context={}, + now=_frozen_now("2026-05-23T18:30:00.000Z"), + ) + assert out["timestamp"] == "2026-05-23T18:30:00.000Z" + + def test_default_now_when_omitted(self) -> None: + # Just verify it doesn't crash and produces a Z-suffixed ISO string. + out = build_episode_payload( + messages=[], + session_id="s", + session_key="s", + runtime_context={}, + ) + ts = out["timestamp"] + assert ts.endswith("Z") + # Parses cleanly + datetime.fromisoformat(ts.replace("Z", "+00:00")) + + def test_data_messages_preserved_unchanged(self) -> None: + msgs = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + out = build_episode_payload( + messages=msgs, + session_id="s", + session_key="s", + runtime_context={}, + now=_frozen_now("2026-05-23T18:30:00.000Z"), + ) + assert out["data"]["messages"] is msgs or out["data"]["messages"] == msgs diff --git a/packages/contexto-py/tests/test_install.py b/packages/contexto-py/tests/test_install.py new file mode 100644 index 0000000..b9e1b48 --- /dev/null +++ b/packages/contexto-py/tests/test_install.py @@ -0,0 +1,172 @@ +"""Tests for `python -m contexto_hermes.install`.""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +from contexto_hermes import install + + +@pytest.fixture +def fake_hermes(tmp_path: Path) -> Path: + """A throwaway hermes-agent-like tree with plugins/context_engine/.""" + root = tmp_path / "fake_hermes" + (root / "plugins" / "context_engine").mkdir(parents=True) + # The marker file the loader looks for: + (root / "plugins" / "context_engine" / "__init__.py").write_text("# stub\n") + return root + + +@pytest.fixture +def fake_package_dir(tmp_path: Path) -> Path: + """A directory pretending to be the installed contexto_hermes package.""" + src = tmp_path / "site-packages" / "contexto_hermes" + src.mkdir(parents=True) + (src / "__init__.py").write_text("# stub\n") + (src / "plugin.yaml").write_text("name: contexto\n") + return src + + +class TestDetectHermesContextEngineDir: + def test_via_env_var(self, fake_hermes: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(fake_hermes)) + path = install.detect_hermes_context_engine_dir() + assert path == fake_hermes / "plugins" / "context_engine" + + def test_returns_none_when_invalid( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(tmp_path / "nope")) + # Also kill sys.path discovery for this test + monkeypatch.setattr(install, "_discover_via_sys_path", lambda: None) + assert install.detect_hermes_context_engine_dir() is None + + def test_via_env_var_namespace_package_no_init( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # PEP-420 namespace layout: plugins/context_engine/ exists with NO __init__.py. + root = tmp_path / "ns_hermes" + (root / "plugins" / "context_engine").mkdir(parents=True) + monkeypatch.setenv("HERMES_AGENT_ROOT", str(root)) + path = install.detect_hermes_context_engine_dir() + assert path == root / "plugins" / "context_engine" + + def test_discover_via_sys_path_uses_namespace_search_locations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Simulate find_spec returning a namespace-package spec: origin is None, + # directory only reachable via submodule_search_locations. + ns_dir = tmp_path / "site" / "plugins" / "context_engine" + ns_dir.mkdir(parents=True) + + class _NamespaceSpec: + origin = None + submodule_search_locations = [str(ns_dir)] + + monkeypatch.setattr( + install.importlib.util, "find_spec", lambda name: _NamespaceSpec() + ) + assert install._discover_via_sys_path() == ns_dir + + +class TestInstallPlugin: + def test_creates_symlink_to_package( + self, fake_hermes: Path, fake_package_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(fake_hermes)) + result = install.install_plugin(package_dir=fake_package_dir) + assert result.success is True + target = fake_hermes / "plugins" / "context_engine" / "contexto" + assert target.exists() + # Either symlink or copy + if target.is_symlink(): + assert target.resolve() == fake_package_dir.resolve() + else: + assert (target / "plugin.yaml").exists() + + def test_idempotent_second_run( + self, fake_hermes: Path, fake_package_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(fake_hermes)) + install.install_plugin(package_dir=fake_package_dir) + # Run again — should not error + result = install.install_plugin(package_dir=fake_package_dir) + assert result.success is True + target = fake_hermes / "plugins" / "context_engine" / "contexto" + assert target.exists() + + def test_copy_fallback_when_symlink_fails( + self, + fake_hermes: Path, + fake_package_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(fake_hermes)) + + def boom(src, dst, *args, **kwargs): # noqa: ARG001 + raise OSError("symlink not supported") + + monkeypatch.setattr(install.os, "symlink", boom) + result = install.install_plugin(package_dir=fake_package_dir) + assert result.success is True + target = fake_hermes / "plugins" / "context_engine" / "contexto" + assert target.exists() + assert not target.is_symlink() + assert (target / "plugin.yaml").exists() + + def test_returns_failure_when_target_dir_unwritable( + self, + tmp_path: Path, + fake_package_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readonly_root = tmp_path / "ro_hermes" + (readonly_root / "plugins" / "context_engine").mkdir(parents=True) + (readonly_root / "plugins" / "context_engine" / "__init__.py").write_text("") + monkeypatch.setenv("HERMES_AGENT_ROOT", str(readonly_root)) + os.chmod(readonly_root / "plugins" / "context_engine", 0o555) + try: + result = install.install_plugin(package_dir=fake_package_dir) + assert result.success is False + assert "write" in result.message.lower() or "permission" in result.message.lower() + finally: + os.chmod(readonly_root / "plugins" / "context_engine", 0o755) + + def test_returns_failure_when_no_hermes_detected( + self, fake_package_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(tmp_path / "nope")) + monkeypatch.setattr(install, "_discover_via_sys_path", lambda: None) + result = install.install_plugin(package_dir=fake_package_dir) + assert result.success is False + assert "hermes" in result.message.lower() + + +class TestMainEntryPoint: + def test_main_returns_zero_on_success( + self, + fake_hermes: Path, + fake_package_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(fake_hermes)) + monkeypatch.setattr(install, "_resolve_package_dir", lambda: fake_package_dir) + rc = install.main([]) + assert rc == 0 + + def test_main_returns_nonzero_on_failure( + self, + fake_package_dir: Path, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("HERMES_AGENT_ROOT", str(tmp_path / "no-such-dir")) + monkeypatch.setattr(install, "_discover_via_sys_path", lambda: None) + monkeypatch.setattr(install, "_resolve_package_dir", lambda: fake_package_dir) + rc = install.main([]) + assert rc != 0 diff --git a/packages/contexto-py/tests/test_plugin_yaml.py b/packages/contexto-py/tests/test_plugin_yaml.py new file mode 100644 index 0000000..fc33312 --- /dev/null +++ b/packages/contexto-py/tests/test_plugin_yaml.py @@ -0,0 +1,43 @@ +"""Sanity test for plugin.yaml — required env_vars and name.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +PLUGIN_YAML = Path(__file__).parent.parent / "src" / "contexto_hermes" / "plugin.yaml" + + +def _load() -> dict: + return yaml.safe_load(PLUGIN_YAML.read_text()) + + +def test_name_is_contexto() -> None: + assert _load()["name"] == "contexto" + + +def test_required_env_vars_present() -> None: + data = _load() + names = {ev["name"] for ev in data["env_vars"]} + for required in ( + "CONTEXTO_API_KEY", + "CONTEXTO_ENABLED", + "CONTEXTO_MAX_CONTEXT_CHARS", + "CONTEXTO_MIN_SCORE", + "CONTEXTO_MAX_RESULTS", + "CONTEXTO_SEARCH_TIMEOUT", + "CONTEXTO_INGEST_TIMEOUT", + ): + assert required in names, f"missing env var: {required}" + + +def test_api_key_marked_required() -> None: + data = _load() + api_key_entry = next(ev for ev in data["env_vars"] if ev["name"] == "CONTEXTO_API_KEY") + assert api_key_entry.get("required") is True + + +def test_version_matches_package() -> None: + import contexto_hermes + assert _load()["version"] == contexto_hermes.__version__ diff --git a/packages/contexto-py/tests/test_register.py b/packages/contexto-py/tests/test_register.py new file mode 100644 index 0000000..e93aff3 --- /dev/null +++ b/packages/contexto-py/tests/test_register.py @@ -0,0 +1,60 @@ +"""Tests for the plugin entry point (`register(ctx)`).""" + +from __future__ import annotations + +import logging + +import pytest + + +class _CapturingCtx: + """Mimics hermes-agent's `_EngineCollector`.""" + + def __init__(self) -> None: + self.registered: list = [] + self.tools: list = [] + self.hooks: list = [] + + def register_context_engine(self, engine) -> None: + self.registered.append(engine) + + def register_tool(self, *a, **kw) -> None: # no-op shim + self.tools.append((a, kw)) + + def register_hook(self, *a, **kw) -> None: + self.hooks.append((a, kw)) + + def register_cli_command(self, *a, **kw) -> None: ... + def register_memory_provider(self, *a, **kw) -> None: ... + + +def test_register_with_api_key_registers_engine(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + import contexto_hermes + ctx = _CapturingCtx() + contexto_hermes.register(ctx) + assert len(ctx.registered) == 1 + assert ctx.registered[0].name == "contexto" + + +def test_register_without_api_key_does_not_register( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.delenv("CONTEXTO_API_KEY", raising=False) + import contexto_hermes + ctx = _CapturingCtx() + with caplog.at_level(logging.ERROR, logger="plugins.context_engine.contexto"): + contexto_hermes.register(ctx) + assert ctx.registered == [] + assert any("CONTEXTO_API_KEY" in r.message for r in caplog.records) + + +def test_compatible_api_version_constant_is_a_string() -> None: + import contexto_hermes + assert isinstance(contexto_hermes.__compatible_contexto_api__, str) + assert len(contexto_hermes.__compatible_contexto_api__) > 0 + + +def test_engine_class_exported() -> None: + import contexto_hermes + assert hasattr(contexto_hermes, "ContextoEngine") diff --git a/packages/contexto-py/tests/test_smoke.py b/packages/contexto-py/tests/test_smoke.py new file mode 100644 index 0000000..a015279 --- /dev/null +++ b/packages/contexto-py/tests/test_smoke.py @@ -0,0 +1,67 @@ +"""Live smoke tests — gated on CONTEXTO_API_KEY. + +Skipped when the key is not set. Run intentionally: + + CONTEXTO_API_KEY=ckai_... pytest tests/test_smoke.py +""" + +from __future__ import annotations + +import json +import os +import uuid + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("CONTEXTO_API_KEY"), + reason="CONTEXTO_API_KEY not set", +) + + +def test_ingest_and_search_round_trip() -> None: + from contexto_hermes.engine import ContextoEngine + + engine = ContextoEngine.from_env() + assert engine is not None + session_id = f"smoke-{uuid.uuid4().hex[:8]}" + engine.on_session_start(session_id) + engine.update_model("gpt-4o", context_length=8000, provider="openai") + + # Drive one compaction with a text-heavy conversation. + msgs = [{"role": "system", "content": "You are helpful."}] + msgs.extend( + {"role": "user" if i % 2 == 0 else "assistant", + "content": f"smoke-fact #{i}: the answer is forty-two"} + for i in range(20) + ) + result = engine.compress(msgs) + assert len(result) < len(msgs) + + # Search — may need a brief moment for the just-ingested episode to be indexed. + search_result = engine.client.search( + "smoke-fact", + max_results=5, + filter={"source": "summary"}, + min_score=0.0, + ) + # We only assert "didn't blow up". Empty results are acceptable on a fresh account + # or before indexing completes — the existence of a SearchResult (not None) is enough. + assert search_result is not None + + +def test_tool_handle_round_trip() -> None: + from contexto_hermes.engine import ContextoEngine + + engine = ContextoEngine.from_env() + assert engine is not None + engine.on_session_start(f"smoke-tool-{uuid.uuid4().hex[:8]}") + + raw = engine.handle_tool_call( + "contexto_search", + {"query": "hello", "max_results": 3}, + messages=[], + ) + parsed = json.loads(raw) + assert "items" in parsed + assert "paths" in parsed diff --git a/packages/contexto-py/tests/test_tools.py b/packages/contexto-py/tests/test_tools.py new file mode 100644 index 0000000..a832998 --- /dev/null +++ b/packages/contexto-py/tests/test_tools.py @@ -0,0 +1,299 @@ +"""Tests for contexto_hermes.tools — contexto_search schema + handler.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from contexto_hermes.tools import CONTEXTO_SEARCH_SCHEMA, contexto_search +from contexto_hermes.types import ContextoConfig, SearchResult + + +class StubBackend: + def __init__(self, result: SearchResult | None = None) -> None: + self._result = result + self.calls: list[dict[str, Any]] = [] + + def search(self, query, max_results, filter, min_score): + self.calls.append({ + "query": query, "max_results": max_results, + "filter": filter, "min_score": min_score, + }) + return self._result + + +@dataclass +class StubEngine: + config: ContextoConfig + client: Any + injected_item_ids: set[str] = field(default_factory=set) + + +def _engine(result: SearchResult | None, **cfg_overrides) -> tuple[StubEngine, StubBackend]: + base = { + "api_key": "ckai_test", + "context_enabled": True, + "max_context_chars": 2000, + "min_score": 0.45, + "max_results": 7, + "search_timeout": 10.0, + "ingest_timeout": 30.0, + } + base.update(cfg_overrides) + cfg = ContextoConfig(**base) + backend = StubBackend(result) + return StubEngine(config=cfg, client=backend), backend + + +class TestSchema: + def test_name(self) -> None: + assert CONTEXTO_SEARCH_SCHEMA["name"] == "contexto_search" + + def test_description_mentions_recall(self) -> None: + desc = CONTEXTO_SEARCH_SCHEMA["description"] + assert "recall" in desc.lower() + assert "constraint" in desc.lower() or "earlier" in desc.lower() + + def test_required_query(self) -> None: + params = CONTEXTO_SEARCH_SCHEMA["parameters"] + assert params["type"] == "object" + assert "query" in params["properties"] + assert params["properties"]["query"]["type"] == "string" + assert "query" in params["required"] + + def test_max_results_optional_with_default(self) -> None: + params = CONTEXTO_SEARCH_SCHEMA["parameters"] + assert "max_results" in params["properties"] + assert params["properties"]["max_results"]["type"] == "integer" + assert params["properties"]["max_results"]["default"] == 5 + + +class TestSearchInvocation: + def test_calls_backend_with_summary_filter_and_engine_min_score(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[]), min_score=0.6) + contexto_search(engine, {"query": "hello"}) + assert len(backend.calls) == 1 + call = backend.calls[0] + assert call["query"] == "hello" + assert call["filter"] == {"source": "summary"} + assert call["min_score"] == 0.6 + + def test_default_max_results_from_schema(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x"}) + assert backend.calls[0]["max_results"] == 5 + + def test_explicit_max_results_overrides(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x", "max_results": 12}) + assert backend.calls[0]["max_results"] == 12 + + +class TestResultShape: + def test_returns_json_string(self) -> None: + engine, _ = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {"query": "x"}) + assert isinstance(result, str) + parsed = json.loads(result) + assert "context" in parsed + assert "items" in parsed + assert "paths" in parsed + + def test_empty_results_returns_empty_arrays(self) -> None: + engine, _ = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + assert parsed["context"] == "" + assert parsed["items"] == [] + assert parsed["paths"] == [] + + def test_returns_formatted_context_items_and_paths(self) -> None: + items = [ + {"item": {"id": "i1", "content": "hi", "metadata": {"source": "summary"}}}, + {"item": {"id": "i2", "content": "ho", "metadata": {"source": "raw"}}}, + ] + paths = [{"id": "p1"}] + engine, _ = _engine(SearchResult(items=items, paths=paths)) + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + assert parsed["context"] == "## Relevant Context\n\nhi\n\n- ho" + assert parsed["items"] == items + assert parsed["paths"] == paths + + def test_formatted_context_honors_max_context_chars(self) -> None: + items = [{"item": {"id": "i1", "content": "x" * 200}}] + engine, _ = _engine(SearchResult(items=items, paths=[]), max_context_chars=40) + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + assert len(parsed["context"]) == 41 + assert parsed["context"].endswith("…") + + def test_zero_max_context_chars_does_not_collapse_to_ellipsis(self) -> None: + # Guard against a degenerate cap (e.g. directly constructed config): the + # context must not collapse to just "…" — truncation is skipped instead. + items = [{"item": {"id": "i1", "content": "real recalled content"}}] + engine, _ = _engine(SearchResult(items=items, paths=[]), max_context_chars=0) + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + assert parsed["context"] != "…" + assert "real recalled content" in parsed["context"] + + +class TestDedup: + def test_filters_already_injected_ids(self) -> None: + items = [ + {"item": {"id": "a", "content": "ax"}}, + {"item": {"id": "b", "content": "bx"}}, + {"item": {"id": "c", "content": "cx"}}, + ] + engine, _ = _engine(SearchResult(items=items, paths=[])) + engine.injected_item_ids.add("b") + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + ids = [r["item"]["id"] for r in parsed["items"]] + assert "b" not in ids + assert "a" in ids + assert "c" in ids + + def test_dedup_handles_top_level_id(self) -> None: + # Some items may not be wrapped in {item: ...} + items = [ + {"id": "a", "content": "ax"}, + {"id": "b", "content": "bx"}, + ] + engine, _ = _engine(SearchResult(items=items, paths=[])) + engine.injected_item_ids.add("a") + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + ids = [r.get("id") or r.get("item", {}).get("id") for r in parsed["items"]] + assert "a" not in ids + assert "b" in ids + + def test_returned_ids_are_recorded_for_future_dedup(self) -> None: + # Per spec §5: injected_item_ids dedups across compactions AND tool calls. + items = [ + {"item": {"id": "x1", "content": "fact 1"}}, + {"item": {"id": "x2", "content": "fact 2"}}, + ] + engine, _ = _engine(SearchResult(items=items, paths=[])) + contexto_search(engine, {"query": "q"}) + assert engine.injected_item_ids == {"x1", "x2"} + + def test_recorded_ids_persist_across_tool_calls(self) -> None: + first_items = [{"item": {"id": "a", "content": "first"}}] + engine, backend = _engine(SearchResult(items=first_items, paths=[])) + contexto_search(engine, {"query": "q1"}) + assert engine.injected_item_ids == {"a"} + + # Next call returns a + b; only b should appear, but both should be in the set. + backend._result = SearchResult( # type: ignore[attr-defined] + items=[ + {"item": {"id": "a", "content": "first"}}, + {"item": {"id": "b", "content": "second"}}, + ], + paths=[], + ) + result = contexto_search(engine, {"query": "q2"}) + parsed = json.loads(result) + ids = [r["item"]["id"] for r in parsed["items"]] + assert ids == ["b"] + assert engine.injected_item_ids == {"a", "b"} + + def test_items_without_id_not_recorded(self) -> None: + items = [ + {"item": {"content": "no-id-here"}}, + {"item": {"id": "has-id", "content": "yes"}}, + ] + engine, _ = _engine(SearchResult(items=items, paths=[])) + contexto_search(engine, {"query": "q"}) + assert engine.injected_item_ids == {"has-id"} + + def test_degraded_path_does_not_record(self) -> None: + engine, _ = _engine(None) + contexto_search(engine, {"query": "q"}) + assert engine.injected_item_ids == set() + + +class TestMalformedArgs: + """LLM tool args are not fully trustworthy — handler must fail-soft.""" + + def test_max_results_null_uses_default(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {"query": "x", "max_results": None}) + json.loads(result) # must not raise + assert backend.calls[0]["max_results"] == 5 # schema default + + def test_max_results_string_uses_default(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {"query": "x", "max_results": "many"}) + json.loads(result) + assert backend.calls[0]["max_results"] == 5 + + def test_max_results_numeric_string_parsed(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x", "max_results": "8"}) + assert backend.calls[0]["max_results"] == 8 + + def test_max_results_zero_clamped_to_one(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x", "max_results": 0}) + assert backend.calls[0]["max_results"] == 1 + + def test_max_results_negative_clamped_to_one(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x", "max_results": -5}) + assert backend.calls[0]["max_results"] == 1 + + def test_max_results_huge_clamped(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + contexto_search(engine, {"query": "x", "max_results": 10_000}) + # Clamped to a reasonable cap + assert backend.calls[0]["max_results"] <= 50 + + def test_query_null_returns_degraded(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {"query": None}) + parsed = json.loads(result) + # Must not raise; no backend call when query is unusable + assert parsed["items"] == [] + assert backend.calls == [] + + def test_query_missing_returns_degraded(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + result = contexto_search(engine, {}) + parsed = json.loads(result) + assert parsed["items"] == [] + assert backend.calls == [] + + def test_query_non_string_coerced_or_rejected(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + # An LLM might emit a number or object. Either coerce or treat as empty, + # but MUST NOT raise. + result = contexto_search(engine, {"query": 42}) + json.loads(result) + # If coerced, we'd send "42"; if rejected, we'd skip. Either is acceptable. + if backend.calls: + assert backend.calls[0]["query"] == "42" + + def test_args_not_a_dict_returns_degraded(self) -> None: + engine, backend = _engine(SearchResult(items=[], paths=[])) + # Defensive: model occasionally emits malformed JSON that ends up as + # a non-dict. Handler must not raise. + result = contexto_search(engine, None) # type: ignore[arg-type] + parsed = json.loads(result) + assert parsed["items"] == [] + assert backend.calls == [] + + +class TestFailSoft: + def test_backend_none_returns_degraded_json(self) -> None: + engine, _ = _engine(None) + result = contexto_search(engine, {"query": "x"}) + parsed = json.loads(result) + # Doesn't raise; returns sensible empty payload with a note + assert parsed["items"] == [] + assert parsed["paths"] == [] + assert "status" in parsed or "error" in parsed or "note" in parsed diff --git a/packages/contexto-py/tests/test_types.py b/packages/contexto-py/tests/test_types.py new file mode 100644 index 0000000..4437baf --- /dev/null +++ b/packages/contexto-py/tests/test_types.py @@ -0,0 +1,230 @@ +"""Tests for contexto_hermes.types — config + dataclasses.""" + +from __future__ import annotations + +import logging + +import pytest + +from contexto_hermes.types import ( + ApiError, + ContextoConfig, + SearchResult, + WebhookPayload, + _env_bool, + _env_float, + _env_int, +) + + +class TestContextoConfigFromEnv: + def test_returns_none_when_api_key_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONTEXTO_API_KEY", raising=False) + assert ContextoConfig.from_env() is None + + def test_returns_none_when_api_key_empty_string(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "") + assert ContextoConfig.from_env() is None + + def test_uses_defaults_when_only_key_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + for var in [ + "CONTEXTO_ENABLED", + "CONTEXTO_MAX_CONTEXT_CHARS", + "CONTEXTO_MIN_SCORE", + "CONTEXTO_MAX_RESULTS", + "CONTEXTO_SEARCH_TIMEOUT", + "CONTEXTO_INGEST_TIMEOUT", + ]: + monkeypatch.delenv(var, raising=False) + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.api_key == "ckai_abc" + assert cfg.context_enabled is True + assert cfg.max_context_chars == 2000 + assert cfg.min_score == 0.45 + assert cfg.max_results == 7 + assert cfg.search_timeout == 10.0 + assert cfg.ingest_timeout == 30.0 + + def test_reads_all_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_xyz") + monkeypatch.setenv("CONTEXTO_ENABLED", "false") + monkeypatch.setenv("CONTEXTO_MAX_CONTEXT_CHARS", "500") + monkeypatch.setenv("CONTEXTO_MIN_SCORE", "0.7") + monkeypatch.setenv("CONTEXTO_MAX_RESULTS", "3") + monkeypatch.setenv("CONTEXTO_SEARCH_TIMEOUT", "5") + monkeypatch.setenv("CONTEXTO_INGEST_TIMEOUT", "15") + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.context_enabled is False + assert cfg.max_context_chars == 500 + assert cfg.min_score == 0.7 + assert cfg.max_results == 3 + assert cfg.search_timeout == 5.0 + assert cfg.ingest_timeout == 15.0 + + +class TestEnvBool: + @pytest.mark.parametrize( + "raw,expected", + [ + ("true", True), ("True", True), ("TRUE", True), + ("1", True), ("yes", True), ("YES", True), ("on", True), + ("false", False), ("False", False), ("FALSE", False), + ("0", False), ("no", False), ("NO", False), ("off", False), + ], + ) + def test_parses_truthy_and_falsy(self, monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool) -> None: + monkeypatch.setenv("CONTEXTO_TEST_BOOL", raw) + assert _env_bool("CONTEXTO_TEST_BOOL", default=not expected) is expected + + def test_missing_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONTEXTO_TEST_BOOL", raising=False) + assert _env_bool("CONTEXTO_TEST_BOOL", default=True) is True + assert _env_bool("CONTEXTO_TEST_BOOL", default=False) is False + + def test_invalid_returns_default_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("CONTEXTO_TEST_BOOL", "maybe") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + assert _env_bool("CONTEXTO_TEST_BOOL", default=True) is True + assert any("CONTEXTO_TEST_BOOL" in r.message for r in caplog.records) + + +class TestEnvInt: + def test_parses_int(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_INT", "42") + assert _env_int("CONTEXTO_TEST_INT", default=0) == 42 + + def test_missing_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONTEXTO_TEST_INT", raising=False) + assert _env_int("CONTEXTO_TEST_INT", default=9) == 9 + + def test_invalid_returns_default_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("CONTEXTO_TEST_INT", "not-a-number") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + assert _env_int("CONTEXTO_TEST_INT", default=11) == 11 + assert any("CONTEXTO_TEST_INT" in r.message for r in caplog.records) + + +class TestEnvIntBounds: + def test_below_minimum_returns_default_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("CONTEXTO_TEST_INT", "0") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + assert _env_int("CONTEXTO_TEST_INT", default=2000, minimum=1) == 2000 + assert any("CONTEXTO_TEST_INT" in r.message for r in caplog.records) + + def test_negative_below_minimum_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_INT", "-5") + assert _env_int("CONTEXTO_TEST_INT", default=7, minimum=1) == 7 + + def test_in_range_value_kept(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_INT", "3") + assert _env_int("CONTEXTO_TEST_INT", default=7, minimum=1) == 3 + + +class TestEnvFloat: + def test_parses_float(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "0.25") + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.0) == 0.25 + + def test_int_string_parses_as_float(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "10") + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.0) == 10.0 + + def test_missing_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CONTEXTO_TEST_FLOAT", raising=False) + assert _env_float("CONTEXTO_TEST_FLOAT", default=1.5) == 1.5 + + def test_invalid_returns_default_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "nope") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + assert _env_float("CONTEXTO_TEST_FLOAT", default=2.5) == 2.5 + assert any("CONTEXTO_TEST_FLOAT" in r.message for r in caplog.records) + + def test_nan_returns_default_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "nan") + with caplog.at_level(logging.WARNING, logger="plugins.context_engine.contexto"): + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.45) == 0.45 + assert any("CONTEXTO_TEST_FLOAT" in r.message for r in caplog.records) + + def test_infinity_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "inf") + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.45) == 0.45 + + def test_out_of_range_returns_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "2.0") + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.45, minimum=0.0, maximum=1.0) == 0.45 + + def test_in_range_value_kept(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_TEST_FLOAT", "0.7") + assert _env_float("CONTEXTO_TEST_FLOAT", default=0.45, minimum=0.0, maximum=1.0) == 0.7 + + +class TestConfigBoundsFromEnv: + def test_zero_max_context_chars_falls_back_to_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + monkeypatch.setenv("CONTEXTO_MAX_CONTEXT_CHARS", "0") + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.max_context_chars == 2000 + + def test_negative_max_results_falls_back_to_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + monkeypatch.setenv("CONTEXTO_MAX_RESULTS", "-1") + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.max_results == 7 + + def test_out_of_range_min_score_falls_back_to_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + monkeypatch.setenv("CONTEXTO_MIN_SCORE", "5.0") + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.min_score == 0.45 + + def test_nan_min_score_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CONTEXTO_API_KEY", "ckai_abc") + monkeypatch.setenv("CONTEXTO_MIN_SCORE", "nan") + cfg = ContextoConfig.from_env() + assert cfg is not None + assert cfg.min_score == 0.45 + + +class TestDataclasses: + def test_api_error_defaults(self) -> None: + err = ApiError(category="auth", message="bad key") + assert err.category == "auth" + assert err.message == "bad key" + assert err.retry_after is None + + def test_api_error_with_retry_after(self) -> None: + err = ApiError(category="ratelimit", message="too many", retry_after=12.0) + assert err.retry_after == 12.0 + + def test_search_result_minimal(self) -> None: + sr = SearchResult(items=[], paths=[]) + assert sr.items == [] + assert sr.paths == [] + + def test_webhook_payload_is_typealias_for_dict(self) -> None: + # WebhookPayload is a TypeAlias for dict[str, Any]; verify import succeeds + # and that a dict can be passed where WebhookPayload is expected. + payload: WebhookPayload = {"event": {"type": "episode", "action": "combined"}} + assert payload["event"]["type"] == "episode"