Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

382 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CloudOpsBench-specific investigation agent.
Subclasses :class:`tools.investigation.stages.gather_evidence.ConnectedInvestigationAgent` to
enforce a minimum-tool-call floor before the agent is allowed to conclude.
Production code is untouched — bench-only termination behavior lives here.
Why we need a floor for the bench
----------------------------------
Production opensre lets the LLM decide when it has enough evidence. That's
the right default for real incidents: latency matters, the LLM is usually
right after a few tool calls, and forcing extra calls wastes tokens.
CloudOpsBench cases are different:
- The paper's protocol rewards deep multi-source evidence gathering
(15-20 tool calls typical in winning runs).
- The June-3 OpenAI bench showed gpt-4o median=7 steps and gpt-5
median=4 steps — both producing a1=0 despite the agent's structural
advantage over plain LLM.
- Tool coverage was 0.20 (gpt-4o) and 0.00 (gpt-5) — agents bailed
before exercising the tools the paper measures against.
We force the bench agent to gather more evidence before concluding. The
loop's outer cap (``MAX_INVESTIGATION_LOOPS``) still bounds the worst
case, so a stubborn model can't infinite-loop.
"""
from __future__ import annotations
import logging
import os
from typing import Any, ClassVar
from core.tool_framework.registered_tool import RegisteredTool
from tools.investigation.stages.gather_evidence import ConnectedInvestigationAgent
logger = logging.getLogger(__name__)
# Default minimum-tool-call floor for the opensre+llm arm. Overridable via the
# ``BENCH_MIN_TOOL_CALLS`` env var so the floor can be swept across runs WITHOUT
# editing code — each sweep point is a fresh CLI process, so an import-time read
# is sufficient. Tests still override the class attribute directly.
#
# Calibrated to 5 based on the 2026-06-06 floorsweep on 30 gpt-4o cases × 3
# seeds (.bench-results/cloudopsbench_floorsweep_openai/). Floor=5 produced the
# highest single-shot A@1 mean (0.578) and the highest object_a1 (0.811) while
# preserving a `rel` (0.374) much closer to the paper's gpt-4o reference (0.63)
# than floor=8 (rel=0.306). Floor=8 (the prior default) over-explored — agents
# averaged 9 tool calls per case, burning 3-4 calls on tools that didn't change
# the diagnosis. See EXPERIMENTS.md in bench-results-openai/ for the full table.
_DEFAULT_MIN_TOOL_CALLS = 5
_ENV_MIN_TOOL_CALLS = "BENCH_MIN_TOOL_CALLS"
def _resolve_min_tool_calls() -> int:
"""Read the floor from the environment, falling back to the default.
Invalid or negative values are ignored (with a warning) rather than
crashing a long bench run; a 0 floor is legal and means "let the LLM
decide", i.e. the same termination policy as the llm_alone control.
"""
raw = os.environ.get(_ENV_MIN_TOOL_CALLS)
if raw is None or raw.strip() == "":
return _DEFAULT_MIN_TOOL_CALLS
try:
value = int(raw)
except ValueError:
logger.warning(
"Ignoring non-integer %s=%r; using default floor %d",
_ENV_MIN_TOOL_CALLS,
raw,
_DEFAULT_MIN_TOOL_CALLS,
)
return _DEFAULT_MIN_TOOL_CALLS
if value < 0:
logger.warning(
"Ignoring negative %s=%d; using default floor %d",
_ENV_MIN_TOOL_CALLS,
value,
_DEFAULT_MIN_TOOL_CALLS,
)
return _DEFAULT_MIN_TOOL_CALLS
return value
# Tools available to the bench agent are exactly those registered by the
# bench-specific package. Production opensre tools (real EKS API calls,
# Hermes log tailing, etc.) would hit live infrastructure that the bench
# task role intentionally cannot reach — burning calls on AccessDenied
# instead of returning deterministic replay data.
#
# Trailing dot is deliberate: it matches anything UNDER the package, not
# the package root itself. The registry only auto-discovers submodules
# (via ``pkgutil.iter_modules``), so a tool whose ``origin_module`` is
# exactly the root is theoretical — but if you register a single-file
# bench tool module directly via :func:`register_external_tool_package`,
# its ``origin_module`` will be the root and it'll be dropped here. Use
# a submodule (e.g. ``tools/k8s/__init__.py``) instead.
_BENCH_TOOL_MODULE_PREFIX = "tests.benchmarks.cloudopsbench.tools."
class BenchInvestigationAgent(ConnectedInvestigationAgent):
"""Bench subclass that requires N tool calls before allowing conclusion.
Threshold is a class attribute so subclasses or tests can override it
without rebuilding the agent instance. Default 8 is calibrated for
CloudOpsBench's median win-trajectory (~15-20 tool calls) while
leaving headroom: even a perfect 8-call run is within the loop cap.
Set ``BENCH_MIN_TOOL_CALLS`` to sweep the floor across runs.
"""
MIN_TOOL_CALLS = _resolve_min_tool_calls()
ALLOWED_TOOL_MODULE_PREFIXES: ClassVar[tuple[str, ...]] = (_BENCH_TOOL_MODULE_PREFIX,)
def _should_accept_conclusion(
self,
*,
evidence_count: int,
iteration: int, # noqa: ARG002 — base class signature
) -> tuple[bool, str | None]:
if evidence_count >= self.MIN_TOOL_CALLS:
return True, None
return False, (
f"You've gathered {evidence_count} tool result(s) so far. Before "
f"concluding, please continue investigating — what dimensions "
f"of the system haven't you checked yet? Consider tool sources "
f"you haven't queried, or evidence that would support OR "
f"contradict your current hypothesis."
)
def _filter_tools(
self,
tools: list[RegisteredTool],
) -> list[RegisteredTool]:
"""Restrict to bench-package tools by origin module.
Filtering by ``origin_module`` instead of an explicit name list means
a new bench tool added under ``tests/benchmarks/cloudopsbench/tools/``
is picked up automatically — no risk of the whitelist drifting out
of sync with the tool registry.
Silent-exclusion edge cases to know about (rare today, but possible
if someone adds a tool in an unconventional way):
- A tool whose ``origin_module`` is exactly the prefix root (no
trailing submodule) is dropped — see the comment on
``_BENCH_TOOL_MODULE_PREFIX``.
- A tool whose ``origin_module`` defaults to the empty string
(e.g. directly-constructed ``RegisteredTool(...)`` without
``origin_module=`` set) is also dropped, and logged at
WARNING so the registry bug surfaces in the run log instead
of silently shrinking the bench tool set.
"""
return _filter_to_bench_package(tools, self.ALLOWED_TOOL_MODULE_PREFIXES)
def _filter_to_bench_package(
tools: list[RegisteredTool],
allowed_prefixes: tuple[str, ...],
) -> list[RegisteredTool]:
"""Shared bench-package tool filter — same policy across all bench agents.
Both :class:`BenchInvestigationAgent` (the opensre+llm path) and
:class:`BaselineLLMAloneAgent` (the llm_alone control arm) must see the
same tool surface; the comparison between modes is only fair when the
tool inventory is identical. Extracting the filter into a free function
keeps that contract enforced by reuse rather than by a "remember to keep
these in sync" comment.
"""
kept: list[RegisteredTool] = []
dropped: list[str] = []
for tool in tools:
if not tool.origin_module:
logger.warning(
"Bench filter dropping tool %r with empty origin_module — "
"registry bug: tool was constructed without origin_module=. "
"Set it explicitly so the bench can keep it.",
tool.name,
)
dropped.append(f"{tool.name} (no origin_module)")
continue
if tool.origin_module.startswith(allowed_prefixes):
kept.append(tool)
else:
dropped.append(f"{tool.name} ({tool.origin_module})")
if dropped:
logger.debug("Bench filter dropped %d tool(s): %s", len(dropped), ", ".join(dropped))
return kept
class BaselineLLMAloneAgent(ConnectedInvestigationAgent):
"""LLM-alone control arm for the bench.
The audit identified this as the single biggest scientific gap in the
cycle: without a matched in-harness baseline on the same cases, no
"opensre helps" claim is attributable. This subclass is that control.
What it inherits from :class:`ConnectedInvestigationAgent` (production):
- The ReAct loop, evidence accumulation, context-budget enforcement
- The default ``_should_accept_conclusion`` hook — accept whatever
the LLM decides, no minimum-tool-call floor
What it overrides:
- ``_filter_tools`` — same bench-package whitelist
:class:`BenchInvestigationAgent` uses, so the two modes see the
IDENTICAL tool inventory and the only difference between them is
the bench-specific termination policy (Lever #1's MIN_TOOL_CALLS=8)
What this measures: the marginal lift from the bench-specific lever
(MIN_TOOL_CALLS), not the full opensre-vs-bare-LLM gap. The system
prompt and ReAct loop are still opensre's. A truly pure baseline
(minimal SRE prompt, no opensre planning structure) is a follow-up;
surface this limitation in the report rather than hiding it.
"""
ALLOWED_TOOL_MODULE_PREFIXES: ClassVar[tuple[str, ...]] = (_BENCH_TOOL_MODULE_PREFIX,)
def _filter_tools(
self,
tools: list[RegisteredTool],
) -> list[RegisteredTool]:
return _filter_to_bench_package(tools, self.ALLOWED_TOOL_MODULE_PREFIXES)
# Minimal SRE-diagnostic system prompt for the pure baseline.
#
# Deliberately concise — no planner instructions, no stage-gate language, no
# anti-hallucination scaffolding, no evidence-budget guidance. The point of
# this control is to measure what a general-purpose LLM does with the same
# tools and zero opensre-specific framing. Anything richer than this prompt
# starts smuggling opensre's structural priors back into the "baseline."
#
# We DO ask for the same output shape (root cause + faulting component)
# because the scorer needs to find those fields; that's a measurement
# protocol requirement, not a reasoning prior.
_PURE_BASELINE_SYSTEM_PROMPT = (
"You are an SRE diagnosing a Kubernetes incident. An alert has been raised. "
"Use the available tools to investigate. When you have enough evidence to "
"name a root cause, state your conclusion in two short fields: "
"(1) the faulting component (Kubernetes object: deployment, pod, service, "
"secret, etc.), and (2) the root cause in 1-2 sentences."
)
class PureBaselineAgent(ConnectedInvestigationAgent):
"""Pure LLM-alone baseline — strips opensre's system prompt as well.
The third arm the audit asked for. Comparison hierarchy:
- ``opensre+llm`` → opensre prompt + Lever #1 floor (full opensre)
- ``llm_alone`` → opensre prompt Lever #1 floor (isolates Lever #1)
- ``llm_alone_pure`` (this) → minimal prompt Lever #1 floor (isolates opensre's PROMPT vs raw LLM+tools)
Reading the contrasts:
- (opensre+llm) (llm_alone) = lift from Lever #1
- (opensre+llm) (llm_alone_pure) = lift from full opensre stack (prompt + Lever #1)
- (llm_alone) (llm_alone_pure) = lift from opensre's PROMPT alone
What this STILL inherits from :class:`ConnectedInvestigationAgent`:
the ReAct loop scaffolding (tool execution, evidence accumulation,
context-budget enforcement, retry-on-tool-error, etc.). Those are
mechanical plumbing every baseline would need; they aren't
"opensre's reasoning." The honest framing is "minimal-prompt LLM
with tools," not "pure stdin/stdout LLM" — which would not be a
meaningful comparison anyway.
"""
ALLOWED_TOOL_MODULE_PREFIXES: ClassVar[tuple[str, ...]] = (_BENCH_TOOL_MODULE_PREFIX,)
def _filter_tools(
self,
tools: list[RegisteredTool],
) -> list[RegisteredTool]:
# Same bench-package whitelist as Bench + Baseline arms — tool
# surface is the methodological constant across all three modes.
return _filter_to_bench_package(tools, self.ALLOWED_TOOL_MODULE_PREFIXES)
def _build_system_prompt(self, state: dict[str, Any]) -> str: # noqa: ARG002 — interface contract
return _PURE_BASELINE_SYSTEM_PROMPT
# Trimmed bench prompt — sits between the full opensre prompt and the pure
# baseline. The 2026-06-08 full-N floor=0 run loss diagnosis (n=353 paired
# scenarios) showed 60% of opensre+llm losses against llm_alone_pure are
# "predictor drift" cases: opensre's investigation correctly identifies the
# fault_object (object_a1 is ~tied between the arms) but the predictor's
# rank-1 root_cause is a token adjacent to the truth — e.g.
# ``liveness_probe_incorrect_timing`` instead of ``..._protocol``,
# ``image_registry_dns_failure`` instead of ``incorrect_image_reference``,
# ``namespace_cpu_quota_exceeded`` instead of ``namespace_pod_quota_exceeded``.
#
# The predictor is faithful to its input; the wrong tokens come from
# opensre's investigation TEXT itself. The full opensre system prompt's
# hedging + validation + multi-stage scaffolding produces RCAs that lean on
# adjacent vocabulary the predictor then formalizes.
#
# This trimmed variant keeps the parts that have customer value
# (tool-output citation, structured component + root_cause output) and drops
# the parts that empirically produce noise on cloudopsbench (hedging-by-
# default language, multi-stage planner instructions, validation-of-
# validation directives). It is BENCH-ONLY — production opensre's prompt is
# unchanged.
_TRIMMED_BENCH_SYSTEM_PROMPT = (
"You are an SRE agent investigating a Kubernetes incident. Use the "
"available tools to gather evidence — typically pod state, error logs, "
"recent events, and resource configuration.\n\n"
"Dependency-traversal rule — INVESTIGATION-LAYER (does not affect "
"your final localization decision; just expands what evidence you "
"gather before concluding):\n"
" When the failing service shows connection-related errors in its "
"logs (connection refused, timeout, authentication failure, write "
"failure, port unreachable), the actual fault may live in a stateful "
"DEPENDENCY (database, cache, message queue) rather than in the "
"service that reports the symptom. Before concluding, also call "
"GetErrorLogs on the dependency pod itself. Stateful dependency pods "
"(MySQL / MariaDB / Postgres / Redis / RabbitMQ / etc.) log their "
"OWN internal failure modes — read-only mode enforcement, connection "
"pool exhaustion, replication errors, slow queries, credential "
"rejections — that are NOT visible from the caller's side. The same "
"applies for namespace-scoped admission failures: when multiple pods "
"in a namespace fail together, query for namespace-level resources "
"(quotas, network policies, service accounts) rather than diagnosing "
"from one victim service's logs.\n"
" This rule expands the EVIDENCE you collect; it does NOT bias your "
"localization. The final faulting component is whichever piece the "
"evidence trajectory points at, including 'the dependency is healthy "
"but the caller's config to reach it is wrong' — in which case the "
"caller IS the fault.\n\n"
"Alert-anchored upstream-attribution rule — INVESTIGATION-LAYER "
"(applies when the alert itself names a specific service AND describes "
"a performance, latency, network, or resource-saturation problem — "
"e.g. 'network delay', 'CPU saturation', 'memory pressure', 'slow "
"response'):\n"
" Treat the service named in the alert as the primary suspect. A "
"slow or saturated upstream service often produces NOISY error logs "
"in downstream services that depend on it — timeouts, retries, "
"'cannot reach', 'connection refused', 'request failed', 'order not "
"found' — while the actual faulting service may show NO error logs "
"at all (it is not crashing; it is just slow). The downstream "
"services with the loudest logs are usually VICTIMS, not causes.\n"
" When this pattern appears (alert names X with a performance / "
"network / resource issue; X has few or no error logs; multiple other "
"services Y, Z, W show timeout-shaped or 'cannot reach upstream' "
"errors), the answer is almost always X with a performance / network "
"/ resource root cause (pod_network_delay, pod_cpu_overload, "
"pod_memory_pressure, etc.), not Y/Z/W with a runtime / config / "
"database root cause. Investigate X's pod-level metrics — CPU, "
"memory, network — rather than chasing the loud downstream logs.\n"
" This rule does NOT override the dependency-traversal rule above. "
"If the alert is about a connection-shaped failure (not a "
"performance-shaped one), the dependency-traversal rule still "
"applies. The two rules describe complementary patterns: "
"connection-failure alerts often point DOWNSTREAM to a dependency; "
"performance-shaped alerts usually point AT the named service "
"itself.\n\n"
"When you have identified the failing component and root cause, "
"produce a concise conclusion:\n"
" (1) the faulting component — Kubernetes object (deployment, pod, "
"service, secret, namespace, etc.)\n"
" (2) the root cause in 1-2 sentences naming the specific failure "
"mode\n"
" (3) cite the tool output that supports your conclusion.\n\n"
"Do not hedge when the evidence is clear. Do not validate the same "
"claim multiple ways. Do not break the investigation into stages "
"unless the case genuinely requires multi-step escalation."
)
class BenchInvestigationAgentTrimmedPrompt(BenchInvestigationAgent):
"""Bench-only ``BenchInvestigationAgent`` variant with a trimmed prompt.
Inherits BenchInvestigationAgent's tool filter and the configurable
``MIN_TOOL_CALLS`` class attribute (set from config.min_tool_calls at
CLI startup). Overrides only the system prompt.
Selected by setting ``agent_variant: trimmed_prompt`` in a bench config.
The CLI override (see ``_framework/cli.py``) swaps the adapter's
investigation_agent_class to this when the field is set; default
behavior (agent_variant unset / "default") returns the original
``BenchInvestigationAgent`` class.
"""
def _build_system_prompt(self, state: dict[str, Any]) -> str: # noqa: ARG002 — interface contract
return _TRIMMED_BENCH_SYSTEM_PROMPT