Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3c5d04f
Add configuration options for the AI-assisted triage
renanrodrigo Jun 16, 2026
ff1d8de
Add custom to_agent_payload method
renanrodrigo Jun 16, 2026
008d883
Add provider layer to copilot+openrouter
renanrodrigo Jun 16, 2026
3cb5ec6
Add agent loop and result contract for the AI-assisted triage
renanrodrigo Jun 16, 2026
14996da
Render triage results into the autotriage report
renanrodrigo Jun 16, 2026
940728a
Add CLI --ai option and ai-triage command
renanrodrigo Jun 16, 2026
ac1c44a
Package the Copilot runtime and ubuntu-dev-tools
renanrodrigo Jun 16, 2026
3f20940
Add step logging for AI triage observability
renanrodrigo Jun 16, 2026
4f1acf2
Remove timeouts and add a spinner to follow AI triage execution
renanrodrigo Jun 16, 2026
5851c2f
Reformat AI generated output
renanrodrigo Jun 16, 2026
7041f85
Reformat agent_prompts into proper markdown
renanrodrigo Jun 16, 2026
2687096
ai: log full backtrace when a provider run fails
renanrodrigo Jul 2, 2026
97729f2
ai: use match statements for provider kwargs dispatch
renanrodrigo Jul 2, 2026
9091f39
tests: fix indentation of inline TOML heredocs
renanrodrigo Jul 2, 2026
d701770
spinner: make TTY detection a property of Spinner
renanrodrigo Jul 2, 2026
991915a
ai: resolve report directory before running the agent
renanrodrigo Jul 2, 2026
521641f
tests: drop test_packaging
renanrodrigo Jul 2, 2026
7c17019
ai: parse bug numbers strictly
renanrodrigo Jul 2, 2026
f3877ad
cli: use 'is not None' for AI config-set flags
renanrodrigo Jul 2, 2026
77fed27
cli: move AI orchestration into ai/
renanrodrigo Jul 2, 2026
c1f64d8
cli: rename ai-triage to 'analyze' with an --ai flag
renanrodrigo Jul 2, 2026
545b211
ai: emit one triage report to stdout or the --markdown file
renanrodrigo Jul 2, 2026
361ef7a
prompt: improve prompt instructions
renanrodrigo Jul 3, 2026
d2e172b
config: gate the ai credential check behind a context-aware validator
renanrodrigo Jul 3, 2026
d1a76d2
cli: move ai provider build and report emit into the ai package
renanrodrigo Jul 3, 2026
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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,48 @@ startriage todo --subscribed

Run `startriage triage --help` for the full option reference, including the bug flags legend.

## AI Triage (experimental)

Inspect one or more Launchpad bugs, optionally running an AI agent over them. By
default `analyze` prints the bug metadata (status, tags, affected targets,
description, comments) so you can eyeball it — no provider or credentials
required. Add `--ai` to run the agent, which produces a suggested status, tags,
analysis, and (where applicable) a proposed fix. The agent never edits bugs and
never applies patches — it only prints its analysis.

```bash
# Show metadata for one or more specific bugs (URL, NNNNNN, or #NNNNNN)
startriage analyze 2101234 '#2105678'

# Run the AI agent over those bugs instead of just showing metadata
startriage analyze --ai 2101234 '#2105678'

# Run the normal daily triage, then AI-triage every bug found
startriage triage --ai
```

The AI output is printed after the normal triage results. With `--markdown FILE`
the AI section is folded into that same report file (behind a clear "review
critically" notice) so you get a single cohesive document.

Configure a provider first (credentials are written to the 0600 config, never
echoed):

```bash
# Default provider: GitHub Copilot (needs a Copilot-enabled account)
startriage config set --ai-provider copilot --ai-github-token github_pat_...

# Or bring your own key via an OpenAI-compatible provider (e.g. OpenRouter)
startriage config set --ai-provider openrouter \
--ai-model anthropic/claude-opus-4.1 \
--ai-openrouter-key sk-or-...
```

The Copilot token may also come from `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` /
`GITHUB_TOKEN`, and the OpenRouter key from `OPENROUTER_API_KEY`. The snap bundles
the Copilot runtime and `ubuntu-dev-tools`, so source analysis works inside strict
confinement; from a git checkout install the extra with `uv sync --extra ai`.

## Configuration

adjust [the defaults](startriage/data/defaults.toml) with your user configuration file:
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ dependencies = [
"platformdirs",
]

[project.optional-dependencies]
# Agentic AI triage. The SDK bundles the Copilot CLI runtime it spawns; kept
# optional so non-AI installs stay lean. The snap ships it (see snapcraft.yaml).
ai = ["github-copilot-sdk"]

[project.scripts]
startriage = "startriage.__main__:main"

Expand All @@ -35,7 +40,7 @@ build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = {find = {where = ["."]}}
package-data = {"startriage" = ["data/*.toml"]}
package-data = {"startriage" = ["data/*.toml", "data/*.md"]}

[tool.setuptools_scm]
version_scheme = "only-version"
Expand Down
14 changes: 14 additions & 0 deletions snapcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ grade: stable
apps:
startriage:
command: bin/startriage
environment:
# The Copilot runtime defaults COPILOT_HOME to ~/.copilot, a hidden path the
# `home` plug cannot write to. Point it at SNAP_USER_DATA, always writable.
COPILOT_HOME: $SNAP_USER_DATA/.copilot
# Expose staged ubuntu-dev-tools helpers (pull-lp-source, debdiff, …) on PATH
# so the agent's shell tool can pull and diff package source.
PATH: $SNAP/usr/bin:$SNAP/bin:$PATH
plugs:
- network
- network-bind
Expand All @@ -39,3 +46,10 @@ parts:
plugin: python
source: .
source-type: git
# github-copilot-sdk bundles the Copilot CLI runtime binary it spawns, so no
# separate Node part is needed; pip ships the runtime inside the snap.
python-packages:
- github-copilot-sdk

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's ok, for the textual only bug analysis.

stage-packages:
# pull-lp-source / dpkg-source / debdiff for the agent's source analysis.
- ubuntu-dev-tools

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should rather be set up inside the agent's analysis environment (the lxd container spinup i was proposing earlier). since we're running full shell command stuff instead of textual analysis only, we should containerize it properly and the ubuntu-dev-tools should then be within that container

64 changes: 64 additions & 0 deletions startriage/ai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""AI/agentic triage layer for startriage."""

from __future__ import annotations

from .agent import BugOutcome, load_system_prompt, triage_bug, triage_bugs
from .contract import (
AgentResult,
AgentResultError,
ProposedFix,
extract_json_block,
parse_agent_result,
)
from .provider import (
CopilotProvider,
FakeProvider,
Provider,
build_client_kwargs,
build_provider,
build_session_kwargs,
)
from .render import (
append_report,
emit_ai_report,
render_bug_metadata,
render_report,
)
from .run import (
describe_bug_specs,
gather_user_bug_payloads,
parse_bug_number,
payloads_from_tasks,
run_agent_on_payloads,
run_ai_over_bug_specs,
run_ai_over_triage_results,
)

__all__ = [
"AgentResult",
"AgentResultError",
"BugOutcome",
"CopilotProvider",
"FakeProvider",
"ProposedFix",
"Provider",
"append_report",
"build_client_kwargs",
"build_provider",
"build_session_kwargs",
"describe_bug_specs",
"emit_ai_report",
"extract_json_block",
"gather_user_bug_payloads",
"load_system_prompt",
"parse_agent_result",
"parse_bug_number",
"payloads_from_tasks",
"render_bug_metadata",
"render_report",
"run_agent_on_payloads",
"run_ai_over_bug_specs",
"run_ai_over_triage_results",
"triage_bug",
"triage_bugs",
]
112 changes: 112 additions & 0 deletions startriage/ai/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Sequential agent loop: run one triage session per bug, skip-and-continue.

The provider (see :mod:`startriage.ai.provider`) runs the agent and returns its
final text; this module loads the behavioural system prompt, feeds each bug's
payload as the user message, and parses the result via the contract. A failure on
one bug is recorded and the run continues with the next, never aborting the batch.
"""

from __future__ import annotations

import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from importlib.resources import files

from .contract import AgentResult, AgentResultError, parse_agent_result
from .provider import Provider

logger = logging.getLogger(__name__)


@dataclass
class BugOutcome:
"""Result of triaging a single bug: either a parsed result or a failure."""

bug: str
result: AgentResult | None
error: str | None
raw: str

@property
def ok(self) -> bool:
return self.result is not None


def load_system_prompt() -> str:
"""Load the agent behavioural prompt shipped as a package resource."""
prompt_path = files("startriage") / "data" / "agents_prompt.md"
return prompt_path.read_text(encoding="utf-8")


def _log_outcome(outcome: BugOutcome) -> None:
"""Emit a per-bug step log: the decision at -v, deeper detail at -vv."""
if outcome.ok and outcome.result is not None:
result = outcome.result
logger.info(
"Bug %s → status=%s, tags=%s",
outcome.bug,
result.status.value,
", ".join(result.tags) or "(none)",
)
logger.debug("Bug %s proposed fix: %s", outcome.bug, result.proposed_fix.kind.value)
if result.thought_process:
logger.debug("Bug %s thought process: %s", outcome.bug, result.thought_process)
else:
logger.warning("Bug %s failed: %s", outcome.bug, outcome.error)


async def triage_bug(
provider: Provider,
payload: dict,
system_prompt: str,
) -> BugOutcome:
"""Run one agent session for ``payload`` and parse its result.

Never raises for triage/agent failures: any error is captured on the returned
:class:`BugOutcome` so the caller can record it and continue.
"""
bug = str(payload.get("number", ""))
user_message = json.dumps(payload, ensure_ascii=False)
logger.debug("Bug %s: sending %d-char payload to the agent", bug, len(user_message))
try:
raw = await provider.run(system_prompt, user_message)
except Exception as exc:
# Record any provider/runtime failure and keep going (skip-and-continue).
logger.warning("Bug %s: provider run failed", bug, exc_info=True)
return BugOutcome(bug=bug, result=None, error=f"provider error: {exc}", raw="")
Comment thread
renanrodrigo marked this conversation as resolved.
logger.debug("Bug %s: received %d-char agent response", bug, len(raw))
try:
result = parse_agent_result(raw)
except AgentResultError as exc:
return BugOutcome(bug=bug, result=None, error=str(exc), raw=raw)
return BugOutcome(bug=bug, result=result, error=None, raw=raw)


async def triage_bugs(
provider: Provider,
payloads: list[dict],
system_prompt: str | None = None,
*,
on_progress: Callable[[int, int, str], None] | None = None,
) -> list[BugOutcome]:
"""Triage ``payloads`` sequentially, recording per-bug failures and continuing.

``on_progress`` (when given) is called as ``(index, total, bug)`` just before
each bug is sent to the agent, so a caller can drive a spinner/progress line.
"""
prompt = system_prompt if system_prompt is not None else load_system_prompt()
total = len(payloads)
outcomes: list[BugOutcome] = []
for index, payload in enumerate(payloads, start=1):
bug = str(payload.get("number", ""))
if on_progress is not None:
on_progress(index, total, bug)
logger.info("Triaging bug %s (%d/%d)…", bug, index, total)
outcome = await triage_bug(provider, payload, prompt)
_log_outcome(outcome)
outcomes.append(outcome)
succeeded = sum(o.ok for o in outcomes)
logger.info("AI triage complete: %d succeeded, %d failed", succeeded, total - succeeded)
return outcomes
90 changes: 90 additions & 0 deletions startriage/ai/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Agent → tool result contract: the JSON each bug triage must return.

The Copilot CLI returns a free-text final assistant message, so the agent is
instructed to end with a single fenced ``json`` block. This module extracts that
block, parses it, and validates it against the schema in ``agents_prompt.md``.
Validation is enforced in code (status / fix-kind enums) so a hallucinated or
malformed result is rejected rather than trusted.
"""

from __future__ import annotations

import json
import re

from pydantic import BaseModel, ConfigDict, ValidationError

from ..enums import ProposedFixKind, TriageStatus

# Matches fenced code blocks, optionally tagged with a language (e.g. ```json).
_FENCED_BLOCK = re.compile(
r"```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n(.*?)\r?\n```",
re.DOTALL,
)


class AgentResultError(ValueError):
"""Raised when the agent's output cannot be parsed/validated as a result."""


class ProposedFix(BaseModel):
model_config = ConfigDict(extra="forbid")

kind: ProposedFixKind
value: str = ""


class AgentResult(BaseModel):
"""One bug's triage result, as returned by the agent and rendered by the tool."""

# Tolerate extra keys: LLM output is noisy and harmless additions should not
# fail an otherwise-valid result. The fields below are still validated strictly.
model_config = ConfigDict(extra="ignore")

bug: str
package: str = ""
short_title: str = ""
status: TriageStatus
tags: list[str] = []
analysis: str = ""
thought_process: str = ""
proposed_fix: ProposedFix
references: list[str] = []
suggested_improvements: str = ""


def extract_json_block(text: str) -> str:
"""Return the JSON payload of the last fenced block in ``text``.

Prefers a ```json-tagged block; falls back to the last untagged fenced block so
a missing language hint does not break parsing. Raises :class:`AgentResultError`
when no fenced block is present.
"""
matches = _FENCED_BLOCK.findall(text)
if not matches:
raise AgentResultError("no fenced code block found in agent output")

json_blocks = [body for lang, body in matches if lang.lower() == "json"]
if json_blocks:
return json_blocks[-1].strip()
# No language-tagged json block; use the last fenced block of any kind.
return matches[-1][1].strip()


def parse_agent_result(text: str) -> AgentResult:
"""Extract, decode, and validate a single :class:`AgentResult` from agent text.

Raises :class:`AgentResultError` on a missing block, invalid JSON, or schema /
enum validation failure.
"""
block = extract_json_block(text)
try:
data = json.loads(block)
except json.JSONDecodeError as exc:
raise AgentResultError(f"agent output is not valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise AgentResultError("agent JSON result must be an object")
try:
return AgentResult.model_validate(data)
except ValidationError as exc:
raise AgentResultError(f"agent result failed validation: {exc}") from exc
Loading