4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
"""Per-session integration state, cache helpers, and turn-time resolution.
|
|
|
|
Owns everything session-scoped for integration discovery: configured service
|
|
names, the resolved-config cache, GitHub repo scope, background warm tasks, and
|
|
``resolve_and_cache_integrations`` for the turn engine.
|
|
|
|
``SessionCore`` composes :class:`IntegrationState` as ``session.integrations`` and
|
|
re-exposes public fields via properties for API stability. Port-level fetch/classify
|
|
logic lives in :mod:`platform.harness_ports` (wired at startup from
|
|
``integrations/harness_adapters``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from platform.harness_ports import (
|
|
IntegrationResolutionResult,
|
|
resolve_integrations,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from core.agent_harness.ports import SessionStore
|
|
|
|
__all__ = [
|
|
"IntegrationResolutionResult",
|
|
"IntegrationState",
|
|
"has_only_underscore_prefixed_keys",
|
|
"has_resolved_integrations",
|
|
"merge_resolved_integrations",
|
|
"resolve_and_cache_integrations",
|
|
"resolve_integrations",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache helpers (shared by IntegrationState and resolve_and_cache_integrations)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def has_resolved_integrations(cache: dict[str, Any] | None) -> bool:
|
|
"""Return True when the cache holds at least one integration config."""
|
|
if not cache:
|
|
return False
|
|
return any(not str(key).startswith("_") for key in cache)
|
|
|
|
|
|
def has_only_underscore_prefixed_keys(cache: dict[str, Any] | None) -> bool:
|
|
"""True when every cache key starts with ``_`` (book-keeping only, no configs)."""
|
|
if not cache:
|
|
return False
|
|
return all(str(key).startswith("_") for key in cache)
|
|
|
|
|
|
def merge_resolved_integrations(
|
|
base: dict[str, Any] | None,
|
|
updates: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Merge integration configs while preserving gateway/runtime metadata keys."""
|
|
merged = dict(base or {})
|
|
merged.update(updates)
|
|
return merged
|
|
|
|
|
|
def _has_usable_cache(cache: dict[str, Any] | None) -> bool:
|
|
"""True when a cache holds resolved configs and need not be re-resolved."""
|
|
return cache is not None and (
|
|
has_resolved_integrations(cache) or not has_only_underscore_prefixed_keys(cache)
|
|
)
|
|
|
|
|
|
def resolve_and_cache_integrations(session: SessionStore) -> dict[str, Any]:
|
|
"""Resolve a session's integration configs, using and updating its cache."""
|
|
cached = session.resolved_integrations_cache
|
|
if _has_usable_cache(cached):
|
|
return dict(cached or {})
|
|
|
|
resolved = resolve_integrations()
|
|
if resolved:
|
|
session.resolved_integrations_cache = merge_resolved_integrations(cached, resolved)
|
|
return dict(session.resolved_integrations_cache or {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session integration facet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class IntegrationState:
|
|
"""A session's integration-resolution state and the logic that warms it."""
|
|
|
|
configured: tuple[str, ...] = ()
|
|
"""Session-scoped configured integration names for planning-time capability checks."""
|
|
configured_known: bool = False
|
|
"""Whether ``configured`` reflects known state (vs default unknown)."""
|
|
resolved_cache: dict[str, Any] | None = None
|
|
"""Resolved integration configs (env/store) shared across turns.
|
|
|
|
Populated silently at REPL boot and again after integration mutations so the
|
|
conversational assistant and investigations can call registered tools without
|
|
waiting for the first user message to trigger a visible "Loading integrations"
|
|
pass. Cleared by :meth:`refresh` when integrations change."""
|
|
github_repo_scope: tuple[str, str] | None = None
|
|
"""Sticky owner/repo inferred from chat, env, or git remote for GitHub tools."""
|
|
|
|
_warm_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
|
_warm_generation: int = field(default=0, repr=False, compare=False)
|
|
_warm_task: Any = field(default=None, repr=False, compare=False)
|
|
|
|
def hydrate(self) -> None:
|
|
"""Load configured integration names (env + local store) — metadata only.
|
|
|
|
Run at REPL boot and again whenever an integration is added or removed so
|
|
capability checks and the tool-gathering pass reflect the current store
|
|
state instead of a stale boot-time snapshot. Must not resolve keyring-backed
|
|
secrets; full configs are resolved on demand via :meth:`warm`/:meth:`get`.
|
|
"""
|
|
try:
|
|
from platform.harness_ports import configured_integration_services
|
|
|
|
self.configured = tuple(sorted(configured_integration_services()))
|
|
self.configured_known = True
|
|
except Exception:
|
|
# Best-effort: keep whatever state we already had (default unknown).
|
|
pass
|
|
|
|
def warm(self, *, generation: int | None = None) -> None:
|
|
"""Resolve full integration configs once, without progress UI.
|
|
|
|
Empty resolves are not cached so a later turn can retry if boot-time
|
|
resolution raced store/env hydration. Failures leave the cache unset.
|
|
"""
|
|
cached = self.resolved_cache
|
|
if cached is not None and not has_only_underscore_prefixed_keys(cached):
|
|
return
|
|
if generation is None:
|
|
with self._warm_lock:
|
|
generation = self._warm_generation
|
|
try:
|
|
resolved = resolve_integrations()
|
|
except Exception:
|
|
# Best-effort warmup: leave cache unset so later turns can retry.
|
|
return
|
|
self._store(resolved, generation=generation)
|
|
|
|
def _store(self, resolved: dict[str, Any], *, generation: int) -> None:
|
|
if not resolved:
|
|
return
|
|
with self._warm_lock:
|
|
if generation != self._warm_generation:
|
|
return
|
|
if self.resolved_cache is not None and not has_only_underscore_prefixed_keys(
|
|
self.resolved_cache
|
|
):
|
|
return
|
|
self.resolved_cache = merge_resolved_integrations(self.resolved_cache, resolved)
|
|
|
|
def get(self) -> IntegrationResolutionResult:
|
|
"""Return the session's integration configs as a typed snapshot (cache-aware).
|
|
|
|
An explicit empty cache is treated as known state; metadata-only caches
|
|
trigger one quiet warmup, merged through the same generation guard as startup.
|
|
"""
|
|
cached = self.resolved_cache
|
|
if _has_usable_cache(cached):
|
|
return IntegrationResolutionResult(resolved_integrations=dict(cached or {}))
|
|
self.warm()
|
|
return IntegrationResolutionResult(resolved_integrations=dict(self.resolved_cache or {}))
|
|
|
|
def refresh(self) -> None:
|
|
"""Re-resolve after the local store changes: drop cache, re-hydrate, re-warm."""
|
|
self._cancel_warm(drop_cache=True)
|
|
self.hydrate()
|
|
self.warm()
|
|
|
|
def reset(self) -> None:
|
|
"""Reset all resolution state for /new (cancels any in-flight warm task)."""
|
|
self._cancel_warm(drop_cache=True)
|
|
self.configured = ()
|
|
self.configured_known = False
|
|
|
|
def release(self) -> None:
|
|
"""Cancel the in-flight warm task for teardown (keeps cached data)."""
|
|
self._cancel_warm(drop_cache=False)
|
|
|
|
def _cancel_warm(self, *, drop_cache: bool) -> None:
|
|
with self._warm_lock:
|
|
self._warm_generation += 1
|
|
pending = self._warm_task
|
|
self._warm_task = None
|
|
if drop_cache:
|
|
self.resolved_cache = None
|
|
self.github_repo_scope = None
|
|
if pending is not None and not pending.done():
|
|
pending.cancel()
|