open-swe/agent/tools/update_finding.py
Johannes du Plessis 4a55145bb1
feat: outcomes dataset + bootstrap/continual split via skills (#1365)
* fix: reset stale sandbox creation sentinel

Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>

* fix: treat SANDBOX_CREATING as a timestamped cross-process lock

Only reset the sentinel when proven stale (older than the creation
timeout); otherwise wait for the worker that holds the lock so a
concurrent run does not create a duplicate sandbox.

* feat(analyzer): outcomes dataset + bootstrap/continual split via skills

Rename the review_style_analyzer graph to `analyzer` and split it into two
modes, plus capture reviewer finding outcomes for continual learning.

- Outcomes dataset: upsert resolved-by-commit (positive), dismissed (false
  positive), and GitHub/Slack thumbs findings into a single LangSmith dataset
  (openswe-reviewer-outcomes), keyed deterministically per finding+source.
  Emit points wired into update_finding, resolve_finding_thread, and the
  GitHub/Slack reaction handlers.
- Two playbooks delivered as deepagents skills (bootstrap-repo-analysis,
  continual-learning), served as virtual files via a CompositeBackend /skills/
  route + StateBackend (seeded into the run files channel at invoke time, never
  written to the sandbox). Mode is set by the launcher; continual runs fall
  back to the GitHub App installation token.
- Split launcher into start_bootstrap_analysis + start_continual_run; register
  a per-repo nightly continual-learning cron when bootstrap completes.
- New read_finding_outcomes tool feeds confirmed/dismissed findings back to the
  continual playbook.

Tests for outcome label mapping, skills helper, and cron idempotency.

* fix(analyzer): anchor continual cron runs to a real thread_id

The nightly continual-learning cron is threadless, and get_analyzer
early-returns an empty agent when configurable.thread_id is missing — so
every cron-launched run no-op'd before reading outcomes or saving a refined
prompt. Include the repo's deterministic analyzer thread_id in the continual
run configurable so the run executes; the threadless run carries no message
history, so nightly runs don't accumulate context.

* refactor(analyzer): move cron lifecycle calls out of the review-styles store

Drop the inline `analyzer_cron` imports from review_styles.py (added only to
dodge a circular import) by relocating the cron-trigger calls to the layer
above the store: registration to the save_review_style tool (after a prompt is
saved) and removal to the dashboard delete route. review_styles.py is now a
pure store again with top-level imports only.

* refactor: hoist reviewer_outcomes imports to module level

Move the two inline emit_finding_status_outcome imports introduced in this PR
(update_finding, resolve_finding_thread) to top-level imports. reviewer_outcomes
only depends on langsmith, so there is no circular import to avoid.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-01 13:25:12 -07:00

210 lines
8.7 KiB
Python

"""Tool: ``update_finding``. Mutate an existing finding by id."""
from __future__ import annotations
import asyncio
from typing import Any
from langgraph.config import get_config
from ..reviewer_findings import (
DEFAULT_FINDING_TITLE,
MAX_SUGGESTION_LINES,
Finding,
clip_suggestion,
get_thread_id_from_runtime,
list_findings,
normalize_finding_title,
update_finding_fields,
)
from ..utils.reviewer_outcomes import emit_finding_status_outcome
def _is_non_empty_str(value: Any) -> bool:
return isinstance(value, str) and bool(value)
def _normalize_note(note: str | None) -> str | None:
if note is None:
return None
normalized = note.strip()
return normalized or None
def _has_published_github_surface(finding: Finding) -> bool:
surface = finding.get("surface")
if isinstance(surface, dict) and (
isinstance(surface.get("github_review_comment_id"), int)
or _is_non_empty_str(surface.get("github_review_thread_id"))
):
return True
comment_ids = finding.get("github_review_comment_ids")
thread_ids = finding.get("github_review_thread_ids")
return (
isinstance(finding.get("github_review_comment_id"), int)
or _is_non_empty_str(finding.get("github_review_thread_id"))
or (isinstance(comment_ids, list) and any(isinstance(item, int) for item in comment_ids))
or (isinstance(thread_ids, list) and any(_is_non_empty_str(item) for item in thread_ids))
)
def update_finding(
finding_id: str,
status: str | None = None,
severity: str | None = None,
confidence: str | None = None,
title: str | None = None,
description: str | None = None,
suggestion: str | None = None,
note: str | None = None,
) -> dict[str, Any]:
"""Update fields on an existing finding.
Use this on a re-review run to mark an existing finding as resolved or
dismissed, or to revise its severity/description/suggestion if the new
commits changed the situation.
Args:
finding_id: The id returned by ``add_finding`` (or shown in the
``Existing findings`` block of the re-review user message).
status: New status (``open``, ``resolved``, ``dismissed``).
Use ``resolved`` when the new commits address the issue. Resolving
or dismissing requires a ``note`` with the message to post.
severity: New severity, if reassessing.
confidence: New confidence rating (``low``, ``medium``, ``high``), if
new commits change how sure you are the finding is a real issue.
title: New concise generated headline, if revising.
description: New description body, if revising. Do not repeat ``title``
as the first line.
suggestion: New replacement text. Pass an empty string to clear it.
Capped at 4 lines — longer values are dropped (the finding keeps
its description). Only set this for small, obvious fixes.
note: Optional free-form note explaining the change. Required when
resolving or dismissing because it becomes the GitHub reply body.
Returns:
Dictionary with ``success`` and (on success) the updated ``finding``.
"""
if status is not None and status not in {"open", "resolved", "dismissed"}:
return {"success": False, "error": f"Invalid status: {status}"}
if severity is not None and severity not in {"low", "medium", "high", "critical"}:
return {"success": False, "error": f"Invalid severity: {severity}"}
if confidence is not None and confidence not in {"low", "medium", "high"}:
return {"success": False, "error": f"Invalid confidence: {confidence}"}
normalized_note = _normalize_note(note)
if status in {"resolved", "dismissed"} and normalized_note is None:
return {
"success": False,
"error": "Resolving or dismissing a finding requires a note with the message to post.",
}
updates: dict[str, Any] = {}
suggestion_dropped = False
if status is not None:
updates["status"] = status
if severity is not None:
updates["severity"] = severity
if confidence is not None:
updates["confidence"] = confidence
if title is not None:
normalized_title = normalize_finding_title(title)
if normalized_title == DEFAULT_FINDING_TITLE:
return {"success": False, "error": "title must be a non-empty generated headline"}
updates["title"] = normalized_title
if description is not None:
updates["description"] = description
if suggestion is not None:
if suggestion == "":
updates["suggestion"] = None
else:
clipped, suggestion_dropped = clip_suggestion(suggestion)
if not suggestion_dropped:
updates["suggestion"] = clipped
if normalized_note is not None:
updates["last_update_note"] = normalized_note
if status in {"resolved", "dismissed"}:
updates["resolution_note"] = normalized_note
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
head_sha = configurable.get("head_sha", "") if isinstance(configurable, dict) else ""
if status == "open" and isinstance(head_sha, str) and head_sha:
updates["last_confirmed_sha"] = head_sha
if not updates:
if suggestion_dropped:
return {
"success": False,
"suggestion_dropped": True,
"error": (
f"Suggestion exceeded the {MAX_SUGGESTION_LINES}-line cap "
"and was rejected; no other fields were provided, so "
"nothing was updated. Only include `suggestion` for "
"small, obvious fixes."
),
}
return {"success": False, "error": "No fields provided to update"}
thread_id = get_thread_id_from_runtime()
findings = asyncio.run(list_findings(thread_id))
finding = next((item for item in findings if item.get("id") == finding_id), None)
if finding is None:
return {"success": False, "error": f"No finding found with id {finding_id}"}
delegated_resolution = False
repo_config = configurable.get("repo") if isinstance(configurable, dict) else None
pr_number = configurable.get("pr_number") if isinstance(configurable, dict) else None
can_resolve_github_thread = (
isinstance(repo_config, dict)
and bool(repo_config.get("owner"))
and bool(repo_config.get("name"))
and isinstance(pr_number, int)
)
if (
status in {"resolved", "dismissed"}
and can_resolve_github_thread
and _has_published_github_surface(finding)
):
from .resolve_finding_thread import resolve_finding_thread
resolve_result = resolve_finding_thread(finding_id, status=status, note=normalized_note)
if not resolve_result.get("success"):
return {
"success": False,
"error": "GitHub review thread resolution failed; finding was left open.",
"github_resolution": resolve_result,
}
delegated_resolution = True
updates.pop("status", None)
updates.pop("last_update_note", None)
updates.pop("resolution_note", None)
if not updates:
result: dict[str, Any] = {
"success": True,
"finding": resolve_result.get("finding"),
"github_resolution": resolve_result,
}
if suggestion_dropped:
result["suggestion_dropped"] = True
result["warning"] = (
f"Suggestion exceeded the {MAX_SUGGESTION_LINES}-line cap and was "
"rejected — the finding's prior `suggestion` was left unchanged. "
"Only include `suggestion` for small, obvious fixes."
)
return result
updated = asyncio.run(update_finding_fields(thread_id, finding_id, updates))
if updated is None:
return {"success": False, "error": f"No finding found with id {finding_id}"}
if status in {"resolved", "dismissed"} and not delegated_resolution:
emit_finding_status_outcome(updated, status, configurable=configurable, thread_id=thread_id)
result = {"success": True, "finding": updated}
if suggestion_dropped:
result["suggestion_dropped"] = True
result["warning"] = (
f"Suggestion exceeded the {MAX_SUGGESTION_LINES}-line cap and was "
"rejected — the finding's prior `suggestion` was left unchanged "
"and other fields were updated normally. Only include "
"`suggestion` for small, obvious fixes."
)
return result