-
Notifications
You must be signed in to change notification settings - Fork 347
Auto-apply high-confidence frontend-triage results #6439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the instruction for the Bugzilla action would better fit in the agent's system prompt. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,12 @@ | |
|
|
||
| On run completion the recorded actions from `summary["actions"]` are always | ||
| upserted as `run_actions` rows (one per entry) so they're visible and | ||
| manageable in the UI. Whether they're then applied *automatically* depends on | ||
| the agent's `auto_apply_actions` opt-in (see `app/agents.py`); either way they | ||
| can be applied on demand (manual apply-all from the UI). Application runs each | ||
| pending row through the handler registry in `hackbot_runtime.actions.handlers` | ||
| and is idempotent per action — an already-`applied` row is never re-applied, so | ||
| Pub/Sub retries and repeated manual applies are safe. | ||
| manageable in the UI. Whether they're then applied *automatically* is decided by | ||
| `_should_auto_apply` (see `app/agents.py`); either way they can be applied on demand | ||
| (manual apply-all from the UI). Application runs each pending row through the handler | ||
| registry in `hackbot_runtime.actions.handlers` and is idempotent per action — an | ||
| already-`applied` row is never re-applied, so Pub/Sub retries and repeated manual | ||
| applies are safe. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
@@ -28,9 +28,9 @@ | |
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from app import gcs | ||
| from app.agents import AGENT_REGISTRY | ||
| from app.agents import AGENT_REGISTRY, AgentSpec | ||
| from app.database.models import Run, RunAction | ||
| from app.schemas import RunStatus | ||
| from app.schemas import Confidence, RunStatus, parse_confidence | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -86,6 +86,58 @@ def _sub(match: re.Match) -> str: | |
| return value | ||
|
|
||
|
|
||
| def _reported_confidence(run: Run) -> Confidence | None: | ||
| """The run's self-reported confidence, or None if it didn't report a usable one.""" | ||
| return parse_confidence( | ||
| ((run.summary or {}).get("findings") or {}).get("confidence") | ||
| ) | ||
|
|
||
|
|
||
| def _should_auto_apply( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not do that in the agent itself? |
||
| spec: AgentSpec | None, run: Run, rows: list[tuple[RunAction, list[dict]]] | ||
| ) -> bool: | ||
| """Whether `run`'s recorded actions may be applied without a human. | ||
|
|
||
| The whole unattended-apply policy in one place, so "why didn't this apply?" has one | ||
| answer, and every gate fails closed. | ||
|
|
||
| Judged on the persisted rows rather than `summary["actions"]`, because the rows are | ||
| what gets dispatched: `ensure_action_rows` never rewrites an existing row, so if the | ||
| two diverge, checking the summary would approve one payload while a different one | ||
| went to Bugzilla. | ||
| """ | ||
| if spec is None or not spec.auto_apply_actions: | ||
| return False | ||
|
|
||
| # `rules/scoping.md` pairs an out-of-scope report with `confidence: low`, but nothing | ||
| # makes the agent do so — a `high` + `actionable: false` run would otherwise post an | ||
| # out-of-scope note on the strength of the confidence alone. `is False`, so a missing | ||
| # `actionable` doesn't read as "out of scope". | ||
| findings = (run.summary or {}).get("findings") or {} | ||
| if findings.get("actionable") is False: | ||
| return False | ||
|
|
||
| if ( | ||
| spec.auto_apply_confidence is not None | ||
| and _reported_confidence(run) not in spec.auto_apply_confidence | ||
| ): | ||
| return False | ||
|
|
||
| if spec.auto_apply_guard is not None: | ||
| reason = spec.auto_apply_guard(run, [row for row, _ in rows]) | ||
| if reason is not None: | ||
| log.warning( | ||
| "Holding run %s for review: %s (agent %s)", | ||
| run.run_id, | ||
| reason, | ||
| run.agent, | ||
| ) | ||
| return False | ||
|
|
||
| return True | ||
|
|
||
|
|
||
|
|
||
| async def ensure_action_rows( | ||
| db: AsyncSession, run: Run | ||
| ) -> list[tuple[RunAction, list[dict]]]: | ||
|
|
@@ -224,11 +276,11 @@ async def _apply_pending_rows( | |
|
|
||
|
|
||
| async def on_run_completed(db: AsyncSession, run: Run) -> None: | ||
| """Record a completed run's actions, and auto-apply them if the agent opts in. | ||
| """Record a completed run's actions, and auto-apply them if the agent qualifies. | ||
|
|
||
| Called from the `apply-run-actions` push route. Actions are always recorded | ||
| (so the UI can show/manually apply them); they're applied automatically only | ||
| when the run's agent has `auto_apply_actions=True`. | ||
| Called from the `apply-run-actions` push route. Actions are always recorded (so the | ||
| UI can show/manually apply them); they're applied automatically only when | ||
| `_should_auto_apply` says so. | ||
| """ | ||
| # Defense-in-depth: only a succeeded run's actions are recorded/applied. A | ||
| # failed/timed-out run may have recorded actions before erroring, but acting | ||
|
|
@@ -243,15 +295,17 @@ async def on_run_completed(db: AsyncSession, run: Run) -> None: | |
| await db.commit() | ||
|
|
||
| spec = AGENT_REGISTRY.get(run.agent) | ||
| if spec and spec.auto_apply_actions: | ||
| if _should_auto_apply(spec, run, rows): | ||
| await _apply_pending_rows(db, run, rows) | ||
| else: | ||
| log.info( | ||
| "Recorded %d action(s) for run %s; auto-apply off for agent %s", | ||
| len(rows), | ||
| run.run_id, | ||
| run.agent, | ||
| ) | ||
| return | ||
|
|
||
| log.info( | ||
| "Recorded %d action(s) for run %s; not auto-applying (agent %s, confidence %s)", | ||
| len(rows), | ||
| run.run_id, | ||
| run.agent, | ||
| _reported_confidence(run), | ||
| ) | ||
|
|
||
|
|
||
| async def apply_all_pending(db: AsyncSession, run: Run) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| """Per-agent limits on what a run may write to Bugzilla without a human. | ||
|
|
||
| An agent's `confidence` gates its *judgement*; a guard here gates its *reach*. The | ||
| two are separate because an action's params are model output and the apply step | ||
| dispatches them against the runtime's global handler registry — which can create | ||
| bugs, attach files and write to Phabricator — so restricting which tools the agent | ||
| was given does not restrict what its recorded actions can reach. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
| from app.database.models import Run, RunAction | ||
|
|
||
| _TRIAGE_FIELDS = frozenset({"keywords", "severity"}) | ||
|
|
||
|
|
||
| def _is_bug_id(value: Any) -> bool: | ||
| # An int or a plain run of digits, nothing looser: the handler interpolates this | ||
| # raw value into the REST path, whereas `int()` would also accept `"2_014_702"`, | ||
| # signs, whitespace and non-ASCII digits — validating a different string than the | ||
| # one sent. | ||
| if isinstance(value, bool): | ||
| return False | ||
| return isinstance(value, int) or (isinstance(value, str) and value.isdigit()) | ||
|
|
||
|
|
||
| def _field_change(field: str, value: Any) -> str | None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice work on this! One thing that looks left open is the values themselves.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could do this validation in an action hook for now. I'm planing to do more generic validation through pydantic. Once that in place, we could drop the or simplify the hook if needed. |
||
| """Why setting `field` to `value` is more than an addition, or None.""" | ||
| if field == "severity": | ||
| # A single-valued field has no additive form, so a scalar is the only way to | ||
| # set it. The value isn't checked against Bugzilla's vocabulary: an unknown one | ||
| # is rejected there, surfacing as a failed action rather than a wrong write. | ||
| if isinstance(value, str) and value.strip(): | ||
| return None | ||
| return f"severity is set to an unexpected {type(value).__name__}" | ||
|
|
||
| # A bare list *replaces* every keyword already on the bug; `{"add": [...]}` is the | ||
| # only form that adds. | ||
| if not isinstance(value, dict): | ||
| return f"{field} is set wholesale rather than added to" | ||
| if set(value) - {"add"}: | ||
| return f"{field} is edited with {', '.join(sorted(value))}, not add" | ||
| additions = value.get("add") | ||
| if not isinstance(additions, list) or not additions: | ||
| return f"{field}'s add is not a non-empty list" | ||
| if not all(isinstance(item, str) and item.strip() for item in additions): | ||
| return f"{field} adds something that isn't a non-empty string" | ||
| return None | ||
|
|
||
|
|
||
| def frontend_triage_guard(run: Run, rows: list[RunAction]) -> str | None: | ||
| """Why this triage run needs a human, or None if it may apply unattended. | ||
|
|
||
| What `rules/frontend-triage.md` sanctions: one plan comment and, at most, one | ||
| obviously-correct field addition on the bug the run was asked about. A run | ||
| proposing anything else is held whole rather than part-applied — the comment | ||
| explains the field change and the two are coalesced into one Bugzilla PUT, so | ||
| dropping one and applying the rest would post something the agent didn't propose. | ||
| """ | ||
| expected_bug_id = (run.inputs or {}).get("bug_id") | ||
| seen: set[str] = set() | ||
|
|
||
| for row in rows: | ||
| params = row.params or {} | ||
|
|
||
| if row.type not in ("bugzilla.add_comment", "bugzilla.update_bug"): | ||
| return f"{row.type} is not an action type it may apply unattended" | ||
| if row.type in seen: | ||
| return f"it records more than one {row.type}" | ||
| seen.add(row.type) | ||
|
|
||
| bug_id = params.get("bug_id") | ||
| # Required, not merely compared when present: `bugzilla.create_bug` carries no | ||
| # `bug_id` at all, and "no target" must not read as "target matches". | ||
| if bug_id is None or expected_bug_id is None: | ||
| return f"{row.type} names no bug to check against the run's input" | ||
| if not _is_bug_id(bug_id): | ||
| return f"it targets an unreadable bug id {bug_id!r}" | ||
| if int(bug_id) != int(expected_bug_id): | ||
| return f"it targets bug {bug_id}, not the run's bug {expected_bug_id}" | ||
|
|
||
| # A private comment is invisible to the reporter and the public, which defeats | ||
| # the review-by-visibility this design leans on. Wanting privacy is exactly the | ||
| # case that wants a human. | ||
| if row.type == "bugzilla.add_comment" and params.get("is_private"): | ||
| return "it posts a private comment" | ||
|
|
||
| if row.type != "bugzilla.update_bug": | ||
| continue | ||
|
|
||
| # `UpdateBugHandler` forwards a `comment` param straight into the PUT, so it is a | ||
| # second route to posting one — including a private one, past the check above. | ||
| # (A `comment` key inside `changes` is a third, caught by the allowlist below.) | ||
| if params.get("comment") is not None: | ||
| return "it carries its own comment rather than a coalesced one" | ||
| # Not `or {}`: a falsey non-mapping (`[]`, `""`, `0`) would become an empty dict | ||
| # and sail through as "changes nothing" instead of being held. | ||
| changes = params.get("changes") | ||
| if not isinstance(changes, dict) or not changes: | ||
| return "its `changes` is not a non-empty mapping of fields" | ||
| disallowed = sorted(set(changes) - _TRIAGE_FIELDS) | ||
| if disallowed: | ||
| return f"it changes {', '.join(disallowed)}, which it may not change" | ||
| for field, value in changes.items(): | ||
| reason = _field_change(field, value) | ||
| if reason is not None: | ||
| return reason | ||
|
|
||
| return None | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The agent doesn’t have access to that. And even if it has access, it might not be the best way.