From 81d869cef4ec28393ab348ab8385168981ad3e6e Mon Sep 17 00:00:00 2001 From: H-maximedelpit Date: Wed, 23 Sep 2026 22:42:21 +0200 Subject: [PATCH] Add RuntimeConfig three-toggle switch layer --- README.md | 34 ++++ pyproject.toml | 9 +- src/hai_agents_runtime/__init__.py | 41 +++++ src/hai_agents_runtime/config.py | 239 +++++++++++++++++++++++++++ src/hai_agents_runtime/presets.py | 24 +++ src/hai_agents_runtime/validation.py | 38 +++++ 6 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/hai_agents_runtime/__init__.py create mode 100644 src/hai_agents_runtime/config.py create mode 100644 src/hai_agents_runtime/presets.py create mode 100644 src/hai_agents_runtime/validation.py diff --git a/README.md b/README.md index e575927..7d6a6a1 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,40 @@ print(result.answer) `result` is a `SessionRunResult`: `id`, `status`, `answer`, the accumulated `events`, and `final_changes`. +## Where things run — `RuntimeConfig` + +An agent task has three parts, and each can run in a different place: the **agent** (the reasoning loop), the **environment** it drives, and the **model** (inference). `RuntimeConfig` is one object where you set those three toggles and get an ordinary `Client` back — no manual wiring. + +```python +from hai_agents_runtime import RuntimeConfig, Inference + +# Everything in H's cloud (the default) — identical to Client(). +client = RuntimeConfig.cloud().client() + +# Agent and screen on this machine, H's cloud model. +client = RuntimeConfig.local().client() + +# Same, but point the model at one you host yourself (localhost is fine). +client = RuntimeConfig.local().with_( + inference=Inference.self_hosted("https://gpu.internal/v1") +).client() + +result = client.run_session(agent="h/web-surfer-pro", messages="…") +``` + +Presets are just shorthand — override any toggle with `.with_()`. The inference toggle is either H's cloud (`Inference.cloud()`) or a model you host by URL (`Inference.self_hosted(url)`). + +Running the agent on the user's own machine (`RuntimeConfig.local()`) needs the **local agent runtime**, shipped as an opt-in extra so the base install stays lean: + +```bash +pip install "hai-agents[local-agent]" # compiled sagent binary (public) +pip install "hai-agents[local-agent-src]" # sagent source, internal only (private index) +``` + +If neither is installed, building a `user_device` `RuntimeConfig` fails at construction with a message naming the extra to add. + +> `hai_agents_runtime` is experimental and may change while the three toggles are pre-release. + ## How a session works A session is one run of an agent against a task. It moves through a small set of states: `pending`, `running`, and then a settled state such as `completed`, `idle`, `failed`, `timed_out`, or `interrupted`. diff --git a/pyproject.toml b/pyproject.toml index e925c90..ba98e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,13 @@ cli = [ # `pip install "hai-agents[local]"` valid documentation and lets a real # dependency land later without a breaking install-instruction change. local = [] +# The local AGENT RUNTIME (the sagent binary) is an opt-in extra, so the base +# install stays lean for callers who only use cloud or a self-hosted URL. +# Compiled binary, fetched on first use from the public artifact store: +local-agent = ["hai-agents[local]"] +# Or the sagent Python source, served only from H's private CodeArtifact index +# (internal users; others get an install error). Configure that index, then: +local-agent-src = ["hai-agents[local]"] desktop = [ "hai-drivers[desktop]>=0.1.0", ] @@ -61,7 +68,7 @@ Issues = "https://github.com/hcompai/hai-agents-python/issues" Documentation = "https://hub.hcompany.ai/computer-use-agents" [tool.hatch.build.targets.wheel] -packages = ["src/hai_agents", "src/hai_agents_cli", "src/hai_agents_common", "src/hai_agents_local", "src/hai_agents_tools"] +packages = ["src/hai_agents", "src/hai_agents_cli", "src/hai_agents_common", "src/hai_agents_local", "src/hai_agents_runtime", "src/hai_agents_tools"] [dependency-groups] dev = [ diff --git a/src/hai_agents_runtime/__init__.py b/src/hai_agents_runtime/__init__.py new file mode 100644 index 0000000..3966799 --- /dev/null +++ b/src/hai_agents_runtime/__init__.py @@ -0,0 +1,41 @@ +"""Choose where each part of an agent task runs: agent, environment, inference. + +EXPERIMENTAL — the interface may change while the three toggles are pre-release. +Deliberately a hand-written sibling package (not inside `hai_agents`, which +codegen regenerates): regen-proof, not re-exported from the main package, and +free to graduate to `hai_agents.runtime` via a codegen overlay once the external +API is settled. +""" + +from hai_agents_runtime.config import ( + Agent, + AgentChoice, + CloudAgent, + CloudInference, + Environment, + Inference, + InferenceChoice, + Place, + RuntimeConfig, + SelfHostedAgent, + SelfHostedInference, + UserDeviceAgent, +) +from hai_agents_runtime.presets import cloud, local + +__all__ = [ + "Agent", + "AgentChoice", + "CloudAgent", + "CloudInference", + "Environment", + "Inference", + "InferenceChoice", + "Place", + "RuntimeConfig", + "SelfHostedAgent", + "SelfHostedInference", + "UserDeviceAgent", + "cloud", + "local", +] diff --git a/src/hai_agents_runtime/config.py b/src/hai_agents_runtime/config.py new file mode 100644 index 0000000..5b12eaa --- /dev/null +++ b/src/hai_agents_runtime/config.py @@ -0,0 +1,239 @@ +"""Choose where each part of an agent task runs — the three-toggle switch layer. + +Every agent task has three parts, and each can run in one of three places: + + user_device installed on the user's own machine + self_hosted the customer's own server / VM (BYO), inside their perimeter + cloud H's infrastructure + + toggle what it is positions + agent the reasoning loop user_device | self_hosted | cloud + environment what it drives user_device | self_hosted | cloud + inference the model self_hosted | cloud + +Pick each toggle, or use a preset: + + from hai_agents_runtime import RuntimeConfig, Agent, Environment, Inference + + config = RuntimeConfig.cloud() # everything in H's cloud (the default) + config = RuntimeConfig.local() # agent + screen on this machine, cloud model + client = config.client() # an ordinary hai_agents Client + +Presets are shorthand, never a gate — override any toggle with `.with_()`: + + RuntimeConfig.local().with_(inference=Inference.self_hosted("https://gpu.internal/v1")) + +EXPERIMENTAL — a hand-written sibling package (not inside `hai_agents`, which +codegen regenerates), so it is regen-proof and free to graduate to +`hai_agents.runtime` via a codegen overlay once the external API is settled. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field, replace +from typing import Literal + +Place = Literal["user_device", "self_hosted", "cloud"] + +DEFAULT_MODEL = "Hcompany/Holo-3.1-35B-A3B" +DEFAULT_AGENT_PORT = 18795 + +# External contract spelling (theirs, not ours): despite the name, this env var +# is the INFERENCE endpoint the agent runtime calls out to — not the runtime's +# own address. The cloud API key is never handed to a self-hosted model endpoint. +INFERENCE_BASE_URL_ENV = "HAI_AGENT_RUNTIME_BASE_URL" +INFERENCE_MODEL_ENV = "HAI_AGENT_RUNTIME_MODEL" +CLOUD_API_KEY_ENV = "HAI_API_KEY" + + +# ---- the agent toggle ------------------------------------------------------ + + +@dataclass(frozen=True) +class UserDeviceAgent: + """The reasoning loop runs on the user's own machine (a local agent runtime).""" + + port: int = DEFAULT_AGENT_PORT + where: Literal["user_device"] = field(default="user_device", init=False) + + +@dataclass(frozen=True) +class SelfHostedAgent: + """The reasoning loop runs on the customer's own server (BYO).""" + + url: str # required: the customer's agent-runtime address — no default to guess at + where: Literal["self_hosted"] = field(default="self_hosted", init=False) + + +@dataclass(frozen=True) +class CloudAgent: + """The reasoning loop runs on H's infrastructure.""" + + region: str = "eu" + where: Literal["cloud"] = field(default="cloud", init=False) + + +AgentChoice = UserDeviceAgent | SelfHostedAgent | CloudAgent + + +class Agent: + """Constructors for the agent toggle. Each place is its own small type, so an + agent object only carries the fields that mean something for it.""" + + @staticmethod + def user_device(port: int = DEFAULT_AGENT_PORT) -> UserDeviceAgent: + return UserDeviceAgent(port=port) + + @staticmethod + def self_hosted(url: str) -> SelfHostedAgent: + return SelfHostedAgent(url=url) + + @staticmethod + def cloud(region: str = "eu") -> CloudAgent: + return CloudAgent(region=region) + + +# ---- the environment toggle ------------------------------------------------ + + +@dataclass(frozen=True) +class Environment: + """Where the thing being driven (desktop, browser) lives.""" + + kind: Literal["desktop", "browser"] + host: Place + + @classmethod + def desktop(cls, host: Place = "user_device") -> "Environment": + return cls(kind="desktop", host=host) + + @classmethod + def browser(cls, host: Place = "cloud") -> "Environment": + return cls(kind="browser", host=host) + + +# ---- the inference toggle -------------------------------------------------- +# Two choices only: H's cloud model, or a model you host yourself by URL (a +# localhost URL is fine — there is no special local/loopback handling here). + + +@dataclass(frozen=True) +class SelfHostedInference: + """A model server you host yourself. Any URL, localhost included.""" + + base_url: str # required — no default to guess at + model: str = DEFAULT_MODEL + where: Literal["self_hosted"] = field(default="self_hosted", init=False) + + +@dataclass(frozen=True) +class CloudInference: + """H's cloud model. Carries no endpoint and no model name — the platform + resolves the model server-side.""" + + where: Literal["cloud"] = field(default="cloud", init=False) + + +InferenceChoice = SelfHostedInference | CloudInference + + +class Inference: + """Constructors for the inference toggle: our cloud, or your own URL.""" + + @staticmethod + def self_hosted(base_url: str, model: str = DEFAULT_MODEL) -> SelfHostedInference: + return SelfHostedInference(base_url=base_url, model=model) + + @staticmethod + def cloud() -> CloudInference: + return CloudInference() + + +# ---- the config ------------------------------------------------------------ + + +@dataclass(frozen=True) +class RuntimeConfig: + """One object, three toggles. Instantiate it; presets are ordinary shorthand.""" + + agent: AgentChoice + environment: Environment + inference: InferenceChoice + + def __post_init__(self) -> None: + from hai_agents_runtime.validation import validate + + validate(self) + if isinstance(self.agent, UserDeviceAgent): + _require_local_agent_runtime() + + @classmethod + def cloud(cls) -> "RuntimeConfig": + from hai_agents_runtime.presets import cloud + + return cloud() + + @classmethod + def local(cls) -> "RuntimeConfig": + from hai_agents_runtime.presets import local + + return local() + + def with_(self, **toggles) -> "RuntimeConfig": + """A copy with some toggles replaced: local().with_(inference=Inference.cloud()).""" + return replace(self, **toggles) + + def client(self): + """An ordinary hai_agents Client, wired for this configuration. + + Cloud agent: the plain SDK client, unchanged from today. User-device + agent: Client.local() (PR #185) spawns or attaches to the agent runtime + on loopback; a self-hosted inference endpoint is handed to it at spawn + time, without the cloud API key. + """ + from hai_agents.client import Client + + if isinstance(self.agent, CloudAgent): + return Client() # env-configured; nothing about this path changes + + if isinstance(self.agent, SelfHostedAgent): + raise NotImplementedError( + "self_hosted agent (a customer's own server) is not wired yet — the " + "contract is identical to user_device; only the address changes." + ) + + if self.environment.kind != "desktop" or self.environment.host != "user_device": + raise NotImplementedError( + "A user_device agent currently drives the user's own desktop only." + ) + + if isinstance(self.inference, CloudInference): + # The runtime inherits the environment and calls H's model gateway + # exactly as it does today. + return Client.local(port=self.agent.port) + + # self_hosted inference: point the runtime at the chosen endpoint, and + # keep the cloud key out of a model server we do not own. (Final spawn + # environment handling tracks agent_platform#1452's spawn_env hook.) + spawn_env = {k: v for k, v in os.environ.items() if k != CLOUD_API_KEY_ENV} + spawn_env[INFERENCE_BASE_URL_ENV] = self.inference.base_url + spawn_env[INFERENCE_MODEL_ENV] = self.inference.model + return Client.local(port=self.agent.port, spawn_env=spawn_env) + + +def _require_local_agent_runtime() -> None: + """A user_device agent needs the local agent runtime, shipped as an opt-in extra. + + Fail here, at construction, with a plain message naming the extra — never a + cryptic import error later. The compiled binary itself is fetched on first + use by LocalRuntime; this checks the local-mode support is installed at all. + """ + from importlib.util import find_spec + + if find_spec("hai_agents.local") is None: + raise RuntimeError( + "A user_device agent needs the local agent runtime, which is an opt-in extra. " + 'Install it with: pip install "hai-agents[local-agent]" (compiled binary, public) ' + 'or pip install "hai-agents[local-agent-src]" (source, internal only).' + ) diff --git a/src/hai_agents_runtime/presets.py b/src/hai_agents_runtime/presets.py new file mode 100644 index 0000000..dc5d979 --- /dev/null +++ b/src/hai_agents_runtime/presets.py @@ -0,0 +1,24 @@ +"""The named configurations. A preset is just a RuntimeConfig — override any +toggle with `.with_()`; presets are shorthand, never a gate.""" + +from __future__ import annotations + +from hai_agents_runtime.config import Agent, Environment, Inference, RuntimeConfig + + +def cloud() -> RuntimeConfig: + """Everything in H's cloud. This is the default.""" + return RuntimeConfig( + agent=Agent.cloud(), + environment=Environment.browser(host="cloud"), + inference=Inference.cloud(), + ) + + +def local() -> RuntimeConfig: + """Agent and screen on the user's own machine; H's cloud model.""" + return RuntimeConfig( + agent=Agent.user_device(), + environment=Environment.desktop(host="user_device"), + inference=Inference.cloud(), + ) diff --git a/src/hai_agents_runtime/validation.py b/src/hai_agents_runtime/validation.py new file mode 100644 index 0000000..b428072 --- /dev/null +++ b/src/hai_agents_runtime/validation.py @@ -0,0 +1,38 @@ +"""The crossing rule: a screenshot may cross the public internet at most once. + +Every step of a desktop task is the same loop — capture the screen, get it to +a model, decide, act — so the screenshot travels environment -> agent -> model +on every step. Each hop between different places crosses the public internet +once. Zero or one crossing is a working configuration; two means the same +image left, came back, and left again — always a mistake, so it is refused at +construction, naming both crossings. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +if TYPE_CHECKING: + from hai_agents_runtime.config import Place, RuntimeConfig + + +def crossings(environment_host: "Place", agent_place: "Place", inference_place: "Place") -> List[str]: + """The internet crossings one step's screenshot makes, described.""" + hops: List[str] = [] + if environment_host != agent_place: + hops.append(f"the {environment_host} screen up to the {agent_place} agent") + if agent_place != inference_place: + hops.append(f"the {agent_place} agent over to the {inference_place} model") + return hops + + +def validate(config: "RuntimeConfig") -> None: + """Raise ValueError when a configuration crosses the internet twice per step.""" + hops = crossings(config.environment.host, config.agent.where, config.inference.where) + if len(hops) >= 2: + raise ValueError( + "Illegal combination: the screenshot would cross the public internet " + f"{len(hops)} times per step — crossing 1: {hops[0]}; crossing 2: {hops[1]}. " + "Zero or one crossing is fine; move the agent next to the screen or " + "next to the model." + )