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
132 lines
5.3 KiB
Python
132 lines
5.3 KiB
Python
"""``AgentHarness`` — one-call agent startup shared by every surface.
|
|
|
|
Before a surface can drive agent turns it needs three things set up, in order:
|
|
env vars loaded, a session created or resumed, and the prompt context loaded.
|
|
``AgentHarness`` runs those steps in one call so the shell, gateway, and
|
|
investigation pipeline don't each wire them up their own way. Session lifecycle
|
|
(create / resolve / rotate / restore) belongs to
|
|
:class:`~core.agent_harness.session.lifecycle.SessionManager`; the harness sits
|
|
one layer above and adds env resolution and prompt context.
|
|
|
|
Must not import ``surfaces.interactive_shell`` (enforced by
|
|
``tests/core/agent/test_import_boundaries.py``): surfaces pass their own
|
|
prompt-context provider in through :class:`HarnessConfig`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from core.agent_harness.session import SessionManager
|
|
|
|
if TYPE_CHECKING:
|
|
from core.agent_harness.ports import PromptContextProvider
|
|
from core.agent_harness.session.session_core import SessionCore
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HarnessConfig:
|
|
"""What a surface hands :class:`AgentHarness` to start up an agent.
|
|
|
|
Every field is optional so a surface only opts into the behavior it
|
|
needs: a fresh gateway turn has nothing to resume (``session_id=None``);
|
|
a headless action-only turn has no grounded context (``prompts=None``).
|
|
"""
|
|
|
|
session_id: str | None = None
|
|
prompts: PromptContextProvider | None = None
|
|
load_env: bool = True
|
|
hydrate_integrations: bool = True
|
|
# None defers to SessionManager's own per-operation default: eager warm on
|
|
# resolve() (a resumed session needs tools ready immediately), lazy on
|
|
# create() (a fresh session can warm on first turn).
|
|
warm_integrations: bool | None = None
|
|
persistent_tasks: bool = True
|
|
open_storage: bool = True
|
|
session_manager: SessionManager | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HarnessStartupResult:
|
|
"""Outcome of :meth:`AgentHarness.startup`."""
|
|
|
|
session: SessionCore
|
|
prompts: PromptContextProvider | None
|
|
|
|
|
|
class AgentHarness:
|
|
"""Runs the startup steps every surface needs, in a fixed order.
|
|
|
|
Order matters: env vars must be resolved before session creation
|
|
(integration hydration/warm may depend on env-provided credentials), and
|
|
context loading is independent of both so it runs last for readability,
|
|
not because anything depends on it running after.
|
|
"""
|
|
|
|
def __init__(self, config: HarnessConfig | None = None) -> None:
|
|
self._config = config or HarnessConfig()
|
|
self._session_manager = self._config.session_manager or SessionManager()
|
|
|
|
def resolve_env_variables(self) -> None:
|
|
"""Load a local ``.env`` file into the process environment, once.
|
|
|
|
``override=False`` matches every existing call site (gateway,
|
|
``integrations/__main__.py``): a variable already set in the real
|
|
environment wins over the ``.env`` file.
|
|
"""
|
|
if self._config.load_env:
|
|
load_dotenv(override=False)
|
|
|
|
def load_or_create_session(self) -> SessionCore:
|
|
"""Resume a persisted session if ``session_id`` was given, else create one.
|
|
|
|
Delegates entirely to :class:`SessionManager` — this method does not
|
|
duplicate its bootstrap/restore logic, it just picks which lifecycle
|
|
call to make based on whether the surface is resuming.
|
|
"""
|
|
manager = self._session_manager
|
|
if self._config.session_id:
|
|
# SessionManager.resolve()'s own default is True: a resumed
|
|
# session needs tools ready immediately.
|
|
warm = (
|
|
True if self._config.warm_integrations is None else self._config.warm_integrations
|
|
)
|
|
return manager.resolve(
|
|
self._config.session_id,
|
|
hydrate_integrations=self._config.hydrate_integrations,
|
|
warm_integrations=warm,
|
|
persistent_tasks=self._config.persistent_tasks,
|
|
)
|
|
# SessionManager.create()'s own default is False: a fresh session can
|
|
# warm lazily on first turn.
|
|
warm = False if self._config.warm_integrations is None else self._config.warm_integrations
|
|
return manager.create(
|
|
hydrate_integrations=self._config.hydrate_integrations,
|
|
warm_integrations=warm,
|
|
persistent_tasks=self._config.persistent_tasks,
|
|
open_storage=self._config.open_storage,
|
|
)
|
|
|
|
def resolve_integrations(self, session: SessionCore) -> dict[str, Any]:
|
|
"""Return resolved integration configs for ``session``."""
|
|
from core.agent_harness.session.integration_resolution import resolve_and_cache_integrations
|
|
|
|
return resolve_and_cache_integrations(session)
|
|
|
|
def load_context(self) -> PromptContextProvider | None:
|
|
"""Return the surface's grounding-context provider, if any."""
|
|
return self._config.prompts
|
|
|
|
def startup(self) -> HarnessStartupResult:
|
|
"""Run env resolution, session bootstrap/resume, and context loading."""
|
|
self.resolve_env_variables()
|
|
session = self.load_or_create_session()
|
|
prompts = self.load_context()
|
|
return HarnessStartupResult(session=session, prompts=prompts)
|
|
|
|
|
|
__all__ = ["AgentHarness", "HarnessConfig", "HarnessStartupResult"]
|