Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict
from hackbot_runtime import BaseAgentInputs, HackbotContext, run_async

from .agent import AutowebcompatReproResult, run_autowebcompat_repro
from .firefox_install import install_firefox_nightly
from .setup_profile import setup_profile


class AgentInputs(BaseSettings):
class AgentInputs(BaseAgentInputs):
bugzilla_mcp_url: str
bug_data: str | None = None
bug_id: int | None = None
model: str | None = None
max_turns: int | None = None
effort: str | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> AutowebcompatReproResult:
inputs = AgentInputs()
inputs = ctx.load_inputs(AgentInputs)

# Provision a fresh Nightly at startup so each run reproduces against a
# current build; drive the binary the install reports back.
Expand Down
9 changes: 3 additions & 6 deletions agents/bug-fix/hackbot_agents/bug_fix/__main__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict
from hackbot_runtime import BaseAgentInputs, HackbotContext, run_async

from .agent import BugFixResult, run_bug_fix


class AgentInputs(BaseSettings):
class AgentInputs(BaseAgentInputs):
bug_id: int
bugzilla_mcp_url: str
model: str | None = None
max_turns: int | None = None
effort: str | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> BugFixResult:
inputs = AgentInputs()
inputs = ctx.load_inputs(AgentInputs)

return await run_bug_fix(
task="Triage and fix the bug, and verify the fix",
Expand Down
9 changes: 3 additions & 6 deletions agents/build-repair/hackbot_agents/build_repair/__main__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import os

from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict
from hackbot_runtime import BaseAgentInputs, HackbotContext, run_async

from .agent import BuildRepairResult, run_build_repair


class AgentInputs(BaseSettings):
class AgentInputs(BaseAgentInputs):
bug_id: int | None = None
git_commit: str
failure_tasks: dict[str, str]
Expand All @@ -15,11 +14,9 @@ class AgentInputs(BaseSettings):
model: str | None = None
max_turns: int | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> BuildRepairResult:
inputs = AgentInputs()
inputs = ctx.load_inputs(AgentInputs)

# The build failure lives at this commit; pin the checkout there before the
# runtime prepares the source tree (consumed in HackbotContext.source_repo).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict
from hackbot_runtime import BaseAgentInputs, HackbotContext, run_async

from .agent import FrontendTriageResult, run_frontend_triage

Expand All @@ -13,18 +12,16 @@
)


class AgentInputs(BaseSettings):
class AgentInputs(BaseAgentInputs):
bug_id: int
bugzilla_mcp_url: str
model: str | None = None
max_turns: int | None = None
effort: str | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> FrontendTriageResult:
inputs = AgentInputs()
inputs = ctx.load_inputs(AgentInputs)

return await run_frontend_triage(
task=TRIAGE_TASK,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict
from hackbot_runtime import BaseAgentInputs, HackbotContext, run_async

from .agent import TestPlanGeneratorResult, run_test_plan_generator
from .firefox_install import install_firefox_nightly


class AgentInputs(BaseSettings):
class AgentInputs(BaseAgentInputs):
feature_name: str
feature_description: str
test_scope: str
model: str | None = None
max_turns: int | None = None
effort: str | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> TestPlanGeneratorResult:
inputs = AgentInputs()
inputs = ctx.load_inputs(AgentInputs)

firefox_path = str(install_firefox_nightly())

Expand Down
3 changes: 2 additions & 1 deletion libs/hackbot-runtime/hackbot_runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from hackbot_runtime.actions.recorder import ActionsRecorder
from hackbot_runtime.config import HackbotConfig
from hackbot_runtime.context import HackbotContext
from hackbot_runtime.context import BaseAgentInputs, HackbotContext
from hackbot_runtime.errors import AgentError
from hackbot_runtime.results import HackbotAgentResult
from hackbot_runtime.runtime import run, run_async
Expand All @@ -13,6 +13,7 @@
"HackbotAgentResult",
"HackbotConfig",
"HackbotContext",
"BaseAgentInputs",
"SignedPolicyUploader",
"ensure_source_repo",
"run",
Expand Down
38 changes: 36 additions & 2 deletions libs/hackbot-runtime/hackbot_runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@
import uuid
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeVar

from pydantic import Field, PrivateAttr
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)

from hackbot_runtime import artifacts, changes
from hackbot_runtime.actions.recorder import ActionsRecorder
from hackbot_runtime.config import HackbotConfig, load_config
from hackbot_runtime.providers import AnthropicAuth
from hackbot_runtime.remote_config import load_remote_config
from hackbot_runtime.source import ensure_source_repo
from hackbot_runtime.uploader import SignedPolicyUploader

Expand All @@ -37,6 +42,26 @@
log = logging.getLogger("hackbot_runtime.context")


class BaseAgentInputs(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")

@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
# Environment variables override settings passed through
# as init parameters
return (env_settings, dotenv_settings, init_settings, file_secret_settings)


InputsType = TypeVar("InputsType", bound=BaseAgentInputs)


def _default_run_id() -> str:
"""A unique, sortable id for runs that don't get one from the platform.

Expand All @@ -62,6 +87,7 @@ class HackbotContext(BaseSettings):
results_prefix: str = ""
results_policy_url: str | None = None
results_policy_fields: dict[str, str] = {}
run_inputs_url: str | None = None
# Base for locally-persisted artifacts when no uploader is configured
# (compose/direct runs). Each run is namespaced under it by run_id (see
# `run_artifacts_dir`). Overridable via ARTIFACTS_DIR — compose points this
Expand Down Expand Up @@ -177,6 +203,14 @@ def log_path(self) -> Path:
def actions(self) -> ActionsRecorder:
return ActionsRecorder(self.uploader, artifacts_dir=self.run_artifacts_dir)

def load_inputs(self, inputs_cls: type[InputsType]) -> InputsType:
remote_config = load_remote_config(self.run_inputs_url)
if remote_config:
kwargs = remote_config
else:
kwargs = {}
return inputs_cls(**kwargs)

def publish_file(
self, key: str, path: Path, content_type: str | None = None
) -> str:
Expand Down
25 changes: 25 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/remote_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

import logging
from typing import Mapping

import requests
from pydantic.types import Json

log = logging.getLogger("hackbot_runtime.remote_config")


def load_remote_config(config_url: str | None) -> Mapping[str, Json] | None:
if config_url is None:
return None

response = requests.get(config_url, timeout=30)
response.raise_for_status()
config = response.json()

if not isinstance(config, dict):
raise ValueError(
f"Config fetched from {config_url} was not a JSON object; got {type(config).__name__}"
)

return config
55 changes: 54 additions & 1 deletion libs/hackbot-runtime/tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path

import pytest
from hackbot_runtime import HackbotContext
from hackbot_runtime import BaseAgentInputs, HackbotContext
from hackbot_runtime.config import FirefoxConfig, HackbotConfig, SourceConfig


Expand All @@ -13,6 +13,11 @@ def _hb(tmp_path, config: HackbotConfig) -> HackbotContext:
return hb


class _SampleInputs(BaseAgentInputs):
bug_id: int
model: str | None = None


def test_source_repo_without_declaration_raises(tmp_path):
hb = _hb(tmp_path, HackbotConfig())
with pytest.raises(RuntimeError, match="\\[source\\]"):
Expand Down Expand Up @@ -101,3 +106,51 @@ def test_results_plumbing(tmp_path):

hb.actions.record("bugzilla.update_bug", {"bug_id": 1}, reasoning="r")
assert hb.actions.actions[0]["type"] == "bugzilla.update_bug"


def test_load_inputs_without_url_reads_env(tmp_path, monkeypatch):
# Local/docker path: no RUN_INPUTS_URL, so inputs come from the environment.
monkeypatch.setenv("BUG_ID", "42")
monkeypatch.setenv("MODEL", "claude-opus")
hb = _hb(tmp_path, HackbotConfig())
assert hb.run_inputs_url is None

inputs = hb.load_inputs(_SampleInputs)

assert inputs.bug_id == 42
assert inputs.model == "claude-opus"


def test_load_inputs_uses_remote_config(tmp_path, monkeypatch):
# Production path: the required field is supplied by the fetched file, not env.
monkeypatch.delenv("BUG_ID", raising=False)
monkeypatch.delenv("MODEL", raising=False)
monkeypatch.setattr(
"hackbot_runtime.context.load_remote_config",
lambda url: {"bug_id": 7, "model": "from-config"},
)
hb = _hb(tmp_path, HackbotConfig())
hb.run_inputs_url = "https://signed.example/inputs.json"

inputs = hb.load_inputs(_SampleInputs)

assert inputs.bug_id == 7
assert inputs.model == "from-config"


def test_load_inputs_env_overrides_remote_config(tmp_path, monkeypatch):
# An env var wins over the same key in the config, while keys absent from the
# environment still fall through to the config.
monkeypatch.setenv("MODEL", "from-env")
monkeypatch.delenv("BUG_ID", raising=False)
monkeypatch.setattr(
"hackbot_runtime.context.load_remote_config",
lambda url: {"bug_id": 7, "model": "from-config"},
)
hb = _hb(tmp_path, HackbotConfig())
hb.run_inputs_url = "https://signed.example/inputs.json"

inputs = hb.load_inputs(_SampleInputs)

assert inputs.model == "from-env" # env overrides config
assert inputs.bug_id == 7 # config supplies what env doesn't
28 changes: 0 additions & 28 deletions services/hackbot-api/app/agents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import json
from collections.abc import Callable
from dataclasses import dataclass

from pydantic import BaseModel
Expand All @@ -19,32 +17,6 @@ class AgentSpec:
description: str
job_name: str
input_schema: type[BaseModel]
# Optional override for the rare agent whose env vars don't map 1:1 from
# its input schema. Defaults to ``model_to_env`` (field -> UPPER_SNAKE env).
build_env: Callable[[BaseModel], dict[str, str]] | None = None


def model_to_env(inputs: BaseModel) -> dict[str, str]:
"""Serialise validated inputs into Cloud Run Job env overrides.

Each schema field maps to an upper-cased env var (``bug_id`` -> ``BUG_ID``);
``None`` fields are skipped, and the agent reads them back via
``pydantic_settings.BaseSettings`` (which upper-cases field names by
default). Lists/dicts are JSON-encoded. Deploy-time constants (e.g. the
broker loopback URL) are NOT inputs — they belong in the Job's static env
config, not here.
"""
env: dict[str, str] = {}
for name, value in inputs.model_dump(mode="json").items():
if value is None:
continue
if isinstance(value, str):
env[name.upper()] = value
elif isinstance(value, (list, dict)):
env[name.upper()] = json.dumps(value)
else:
env[name.upper()] = str(value)
return env


AGENT_REGISTRY: dict[str, AgentSpec] = {
Expand Down
Loading