diff --git a/.github/workflows/agents-pr-health.yml b/.github/workflows/agents-pr-health.yml new file mode 100644 index 00000000..9c00e879 --- /dev/null +++ b/.github/workflows/agents-pr-health.yml @@ -0,0 +1,61 @@ +# --------------------------------------------------------------------------- +# Agents PR Health — Consumer Repo Thin Wrapper +# --------------------------------------------------------------------------- +# Periodically scans open PRs for merge conflicts and failing checks, +# then routes remediation to the coding agent that most recently worked +# on each PR. +# +# Copy this file to: .github/workflows/agents-pr-health.yml +# +# Required secrets (inherited from org / repo): +# - WORKFLOWS_APP_ID / WORKFLOWS_APP_PRIVATE_KEY (preferred) +# - ACTIONS_BOT_PAT or SERVICE_BOT_PAT (fallback) +# --------------------------------------------------------------------------- + +name: Agents PR Health + +on: + schedule: + # Runs every hour — actual frequency is controlled by the repo + # variable PR_HEALTH_INTERVAL_HOURS (Settings → Secrets and + # variables → Actions → Variables). The reusable workflow skips + # runs at non-aligned hours, so most hourly triggers exit in + # seconds with no API calls. + # + # 1 = every hour (active development — default) + # 4 = every 4 hours + # 6 = every 6 hours (moderate activity) + # 12 = twice daily + # 0 = disabled + - cron: '0 * * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Preview mode — scan and report without pushing or dispatching' + required: false + default: false + type: boolean + max_prs: + description: 'Maximum PRs to process' + required: false + default: 10 + type: number + +permissions: + contents: write + pull-requests: write + actions: write + +concurrency: + group: pr-health-${{ github.repository }} + cancel-in-progress: true + +jobs: + health: + uses: stranske/Workflows/.github/workflows/reusable-agents-pr-health.yml@main + with: + dry_run: ${{ inputs.dry_run || false }} + max_prs: ${{ inputs.max_prs || 10 }} + cron_interval_hours: ${{ vars.PR_HEALTH_INTERVAL_HOURS || 1 }} + is_scheduled_trigger: ${{ github.event_name == 'schedule' }} + secrets: inherit diff --git a/tools/langchain_client.py b/tools/langchain_client.py index 8e5769bd..c80d31d9 100644 --- a/tools/langchain_client.py +++ b/tools/langchain_client.py @@ -8,7 +8,6 @@ from __future__ import annotations import contextlib -import importlib.util import json import logging import os @@ -33,27 +32,6 @@ DEFAULT_SLOT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "llm_slots.json" -LANGCHAIN_OPENAI_DIST = "langchain-openai" -LANGCHAIN_ANTHROPIC_DIST = "langchain-anthropic" - - -def _module_available(module_name: str) -> bool: - return importlib.util.find_spec(module_name) is not None - - -def missing_provider_dependencies(provider: str) -> tuple[str, ...]: - """Return missing package distributions required by *provider*.""" - - normalized = (provider or "").strip().lower() - missing: list[str] = [] - if normalized in {PROVIDER_OPENAI, PROVIDER_GITHUB} and not _module_available( - "langchain_openai" - ): - missing.append(LANGCHAIN_OPENAI_DIST) - if normalized == PROVIDER_ANTHROPIC and not _module_available("langchain_anthropic"): - missing.append(LANGCHAIN_ANTHROPIC_DIST) - return tuple(missing) - def _env_int(name: str, default: int) -> int: value = os.environ.get(name) @@ -170,23 +148,6 @@ def _resolve_slots() -> list[SlotDefinition]: return _apply_slot_env_overrides(_load_slot_config()) -def get_provider_model_catalog() -> dict[str, set[str]]: - """Return provider->model mappings from configured slots.""" - - catalog: dict[str, set[str]] = { - PROVIDER_OPENAI: set(), - PROVIDER_ANTHROPIC: set(), - PROVIDER_GITHUB: set(), - } - for slot in _resolve_slots(): - catalog.setdefault(slot.provider, set()).add(slot.model) - - if not any(catalog.values()): - for slot in _default_slots(): - catalog.setdefault(slot.provider, set()).add(slot.model) - return catalog - - def _is_reasoning_model(model: str) -> bool: """Return True if the model is an OpenAI reasoning model that rejects temperature. @@ -250,8 +211,15 @@ def build_chat_client( timeout: int | None = None, max_retries: int | None = None, ) -> ClientInfo | None: - chat_openai_cls = None - chat_anthropic_cls = None + try: + from langchain_openai import ChatOpenAI + except ImportError: + return None + + try: + from langchain_anthropic import ChatAnthropic + except ImportError: + ChatAnthropic = None # noqa: N806 github_token = os.environ.get("GITHUB_TOKEN") openai_token = os.environ.get("OPENAI_API_KEY") @@ -270,14 +238,9 @@ def build_chat_client( if selected_provider == PROVIDER_GITHUB: if not github_token: return None - try: - from langchain_openai import ChatOpenAI - except ImportError: - return None - chat_openai_cls = ChatOpenAI try: client = _build_github_client( - chat_openai_cls, + ChatOpenAI, model=selected_model, token=github_token, timeout=selected_timeout, @@ -290,14 +253,9 @@ def build_chat_client( if selected_provider == PROVIDER_OPENAI: if not openai_token: return None - try: - from langchain_openai import ChatOpenAI - except ImportError: - return None - chat_openai_cls = ChatOpenAI try: client = _build_openai_client( - chat_openai_cls, + ChatOpenAI, model=selected_model, token=openai_token, timeout=selected_timeout, @@ -308,16 +266,11 @@ def build_chat_client( return None if selected_provider == PROVIDER_ANTHROPIC: - if not anthropic_token: + if not anthropic_token or not ChatAnthropic: return None - try: - from langchain_anthropic import ChatAnthropic - except ImportError: - return None - chat_anthropic_cls = ChatAnthropic try: client = _build_anthropic_client( - chat_anthropic_cls, + ChatAnthropic, model=selected_model, token=anthropic_token, timeout=selected_timeout, @@ -334,18 +287,9 @@ def build_chat_client( for slot in slots: slot_model = model_override if model_override and not used_override else slot.model if slot.provider == PROVIDER_OPENAI and openai_token: - if chat_openai_cls is None: - try: - from langchain_openai import ChatOpenAI - except ImportError: - chat_openai_cls = None - else: - chat_openai_cls = ChatOpenAI - if chat_openai_cls is None: - continue with contextlib.suppress(Exception): client = _build_openai_client( - chat_openai_cls, + ChatOpenAI, model=slot_model, token=openai_token, timeout=selected_timeout, @@ -353,19 +297,10 @@ def build_chat_client( ) used_override = True return ClientInfo(client=client, provider=PROVIDER_OPENAI, model=slot_model) - if slot.provider == PROVIDER_ANTHROPIC and anthropic_token: - if chat_anthropic_cls is None: - try: - from langchain_anthropic import ChatAnthropic - except ImportError: - chat_anthropic_cls = None - else: - chat_anthropic_cls = ChatAnthropic - if chat_anthropic_cls is None: - continue + if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropic: with contextlib.suppress(Exception): client = _build_anthropic_client( - chat_anthropic_cls, + ChatAnthropic, model=slot_model, token=anthropic_token, timeout=selected_timeout, @@ -374,18 +309,9 @@ def build_chat_client( used_override = True return ClientInfo(client=client, provider=PROVIDER_ANTHROPIC, model=slot_model) if slot.provider == PROVIDER_GITHUB and github_token: - if chat_openai_cls is None: - try: - from langchain_openai import ChatOpenAI - except ImportError: - chat_openai_cls = None - else: - chat_openai_cls = ChatOpenAI - if chat_openai_cls is None: - continue with contextlib.suppress(Exception): client = _build_github_client( - chat_openai_cls, + ChatOpenAI, model=slot_model, token=github_token, timeout=selected_timeout, @@ -405,8 +331,15 @@ def build_chat_clients( timeout: int | None = None, max_retries: int | None = None, ) -> list[ClientInfo]: - chat_openai_cls = None - chat_anthropic_cls = None + try: + from langchain_openai import ChatOpenAI + except ImportError: + return [] + + try: + from langchain_anthropic import ChatAnthropic + except ImportError: + ChatAnthropic = None # noqa: N806 github_token = os.environ.get("GITHUB_TOKEN") openai_token = os.environ.get("OPENAI_API_KEY") @@ -428,16 +361,11 @@ def build_chat_clients( if selected_provider: if selected_provider == PROVIDER_GITHUB and github_token: - try: - from langchain_openai import ChatOpenAI - except ImportError: - return clients - chat_openai_cls = ChatOpenAI with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_github_client( - chat_openai_cls, + ChatOpenAI, model=first_model, token=github_token, timeout=selected_timeout, @@ -452,7 +380,7 @@ def build_chat_clients( clients.append( ClientInfo( client=_build_github_client( - chat_openai_cls, + ChatOpenAI, model=second_model, token=github_token, timeout=selected_timeout, @@ -463,16 +391,11 @@ def build_chat_clients( ) ) elif selected_provider == PROVIDER_OPENAI and openai_token: - try: - from langchain_openai import ChatOpenAI - except ImportError: - return clients - chat_openai_cls = ChatOpenAI with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_openai_client( - chat_openai_cls, + ChatOpenAI, model=first_model, token=openai_token, timeout=selected_timeout, @@ -487,7 +410,7 @@ def build_chat_clients( clients.append( ClientInfo( client=_build_openai_client( - chat_openai_cls, + ChatOpenAI, model=second_model, token=openai_token, timeout=selected_timeout, @@ -497,17 +420,12 @@ def build_chat_clients( model=second_model, ) ) - elif selected_provider == PROVIDER_ANTHROPIC and anthropic_token: - try: - from langchain_anthropic import ChatAnthropic - except ImportError: - return clients - chat_anthropic_cls = ChatAnthropic + elif selected_provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropic: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_anthropic_client( - chat_anthropic_cls, + ChatAnthropic, model=first_model, token=anthropic_token, timeout=selected_timeout, @@ -522,7 +440,7 @@ def build_chat_clients( clients.append( ClientInfo( client=_build_anthropic_client( - chat_anthropic_cls, + ChatAnthropic, model=second_model, token=anthropic_token, timeout=selected_timeout, @@ -541,7 +459,7 @@ def build_chat_clients( if any( ( slot.provider == PROVIDER_OPENAI and openai_token, - slot.provider == PROVIDER_ANTHROPIC and anthropic_token and chat_anthropic_cls, + slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropic, slot.provider == PROVIDER_GITHUB and github_token, ) ): @@ -556,20 +474,11 @@ def build_chat_clients( slot_model = model_overrides[idx] if idx < len(model_overrides) else None slot_model = slot_model or slot.model if slot.provider == PROVIDER_OPENAI and openai_token: - if chat_openai_cls is None: - try: - from langchain_openai import ChatOpenAI - except ImportError: - chat_openai_cls = None - else: - chat_openai_cls = ChatOpenAI - if chat_openai_cls is None: - continue with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_openai_client( - chat_openai_cls, + ChatOpenAI, model=slot_model, token=openai_token, timeout=selected_timeout, @@ -579,21 +488,12 @@ def build_chat_clients( model=slot_model, ) ) - if slot.provider == PROVIDER_ANTHROPIC and anthropic_token: - if chat_anthropic_cls is None: - try: - from langchain_anthropic import ChatAnthropic - except ImportError: - chat_anthropic_cls = None - else: - chat_anthropic_cls = ChatAnthropic - if chat_anthropic_cls is None: - continue + if slot.provider == PROVIDER_ANTHROPIC and anthropic_token and ChatAnthropic: with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_anthropic_client( - chat_anthropic_cls, + ChatAnthropic, model=slot_model, token=anthropic_token, timeout=selected_timeout, @@ -604,20 +504,11 @@ def build_chat_clients( ) ) if slot.provider == PROVIDER_GITHUB and github_token: - if chat_openai_cls is None: - try: - from langchain_openai import ChatOpenAI - except ImportError: - chat_openai_cls = None - else: - chat_openai_cls = ChatOpenAI - if chat_openai_cls is None: - continue with contextlib.suppress(Exception): clients.append( ClientInfo( client=_build_github_client( - chat_openai_cls, + ChatOpenAI, model=slot_model, token=github_token, timeout=selected_timeout, diff --git a/tools/requirements-llm.txt b/tools/requirements-llm.txt index 7c59f74f..0ed22431 100644 --- a/tools/requirements-llm.txt +++ b/tools/requirements-llm.txt @@ -6,7 +6,7 @@ # change is required when bumping this file. # - Use strict X.Y.Z pins to keep workflow installs reproducible. langchain==1.2.10 -langchain-core==1.2.15 +langchain-core==1.2.14 langchain-community==0.4.1 langchain-openai==1.1.10 langchain-anthropic==1.3.3