|
1 | | -"""Triage prompt construction and LLM response parsing. |
2 | | -
|
3 | | -The full implementation is provided in Task 15. This stub satisfies type |
4 | | -checking and allows the agent server scaffold to be tested in isolation. |
5 | | -""" |
| 1 | +"""Triage prompt construction and LLM response parsing.""" |
6 | 2 |
|
7 | 3 | from __future__ import annotations |
8 | 4 |
|
9 | | -from typing import TYPE_CHECKING |
| 5 | +import json |
| 6 | +from typing import TYPE_CHECKING, Any |
| 7 | + |
| 8 | +import litellm |
10 | 9 |
|
11 | 10 | if TYPE_CHECKING: |
12 | 11 | from agent import DecisionMessage, TaskMessage |
13 | 12 |
|
| 13 | +_VALID_DECISIONS = {"label_and_respond", "close", "escalate", "skip"} |
| 14 | + |
| 15 | +# Keywords in memory_summary that indicate a comment was recently posted. |
| 16 | +_RECENT_COMMENT_KEYWORDS = [ |
| 17 | + "comment was posted", |
| 18 | + "commented", |
| 19 | + "a comment", |
| 20 | + "responded", |
| 21 | +] |
| 22 | + |
| 23 | + |
| 24 | +def build_prompt(task: TaskMessage) -> str: |
| 25 | + """Build the triage prompt from a task message. |
| 26 | +
|
| 27 | + Args: |
| 28 | + task: The incoming :class:`~agent.TaskMessage` from the harness. |
| 29 | +
|
| 30 | + Returns: |
| 31 | + A formatted prompt string ready to send to the LLM. |
| 32 | + """ |
| 33 | + payload = task.payload |
| 34 | + title = payload.get("title", "") |
| 35 | + body = payload.get("body", "") |
| 36 | + author = payload.get("author", "unknown") |
| 37 | + labels = payload.get("labels", []) |
| 38 | + |
| 39 | + memory_section = "" |
| 40 | + if task.context.memory_summary: |
| 41 | + memory_section = f"\n\nPrevious context on this issue:\n{task.context.memory_summary}" |
| 42 | + |
| 43 | + return ( |
| 44 | + f"You are an issue triage assistant for the GitHub repository {task.repo}.\n" |
| 45 | + f"A new or updated issue has been submitted. Analyse it and return a JSON decision.\n" |
| 46 | + f"{memory_section}\n" |
| 47 | + f"Issue #{payload.get('issue_number', '?')} by @{author}\n" |
| 48 | + f"Title: {title}\n" |
| 49 | + f"Body:\n{body}\n" |
| 50 | + f"Current labels: {labels}\n\n" |
| 51 | + f"Return ONLY a JSON object with this exact shape (no markdown fences):\n" |
| 52 | + f'{{"decision": "<label_and_respond|close|escalate|skip>", ' |
| 53 | + f'"rationale": "<one sentence>", ' |
| 54 | + f'"actions": [<action objects>]}}\n\n' |
| 55 | + f"Action object shapes:\n" |
| 56 | + f' add_label: {{"type": "add_label", "label": "<name>"}}\n' |
| 57 | + f' comment: {{"type": "comment", "body": "<markdown>"}}\n' |
| 58 | + f' close_issue: {{"type": "close_issue"}}\n' |
| 59 | + ) |
| 60 | + |
| 61 | + |
| 62 | +def parse_llm_response( |
| 63 | + raw: str, |
| 64 | + *, |
| 65 | + task_id: str, |
| 66 | + allow_close: bool = False, |
| 67 | +) -> DecisionMessage: |
| 68 | + """Extract and validate a :class:`~agent.DecisionMessage` from raw LLM text. |
| 69 | +
|
| 70 | + Searches for the first JSON object in *raw*, validates it, and applies the |
| 71 | + ``allow_close`` guard. Returns a ``skip`` decision on any parse failure. |
| 72 | +
|
| 73 | + Args: |
| 74 | + raw: Raw text returned by the LLM. |
| 75 | + task_id: Task identifier to set on the returned message. |
| 76 | + allow_close: Whether ``close_issue`` actions are permitted. |
| 77 | +
|
| 78 | + Returns: |
| 79 | + A validated :class:`~agent.DecisionMessage`. |
| 80 | + """ |
| 81 | + from agent import ActionItem, DecisionMessage |
| 82 | + |
| 83 | + def _skip(rationale: str = "Could not parse LLM response") -> DecisionMessage: |
| 84 | + return DecisionMessage(task_id=task_id, decision="skip", rationale=rationale, actions=[]) |
| 85 | + |
| 86 | + # Find the first '{' and attempt to parse the JSON from that position. |
| 87 | + start = raw.find("{") |
| 88 | + if start == -1: |
| 89 | + return _skip() |
| 90 | + |
| 91 | + try: |
| 92 | + data: dict[str, Any] = json.loads(raw[start:]) |
| 93 | + except json.JSONDecodeError: |
| 94 | + return _skip() |
| 95 | + |
| 96 | + decision_value = data.get("decision", "") |
| 97 | + if decision_value not in _VALID_DECISIONS: |
| 98 | + return _skip(f"Unknown decision value: '{decision_value}'") |
| 99 | + |
| 100 | + actions = [ActionItem(**a) for a in data.get("actions", []) if isinstance(a, dict)] |
| 101 | + |
| 102 | + # apply allow_close guard |
| 103 | + if not allow_close: |
| 104 | + actions = [a for a in actions if a.type != "close_issue"] |
| 105 | + |
| 106 | + return DecisionMessage( |
| 107 | + task_id=task_id, |
| 108 | + decision=decision_value, |
| 109 | + rationale=data.get("rationale", ""), |
| 110 | + actions=actions, |
| 111 | + ) |
| 112 | + |
| 113 | + |
| 114 | +def _recent_comment_in_memory(memory_summary: str | None) -> bool: |
| 115 | + """Return True if *memory_summary* indicates a recent comment was posted. |
| 116 | +
|
| 117 | + Args: |
| 118 | + memory_summary: LLM-generated summary of prior actions, or ``None``. |
| 119 | +
|
| 120 | + Returns: |
| 121 | + ``True`` when any recent-comment keyword is found in the summary. |
| 122 | + """ |
| 123 | + if not memory_summary: |
| 124 | + return False |
| 125 | + lower = memory_summary.lower() |
| 126 | + return any(kw in lower for kw in _RECENT_COMMENT_KEYWORDS) |
| 127 | + |
| 128 | + |
| 129 | +def _call_llm(prompt: str, provider: str, model: str, api_key: str | None = None) -> str: |
| 130 | + """Call the LLM via LiteLLM and return the response text. |
| 131 | +
|
| 132 | + Args: |
| 133 | + prompt: The user prompt to send. |
| 134 | + provider: LLM provider identifier (e.g. ``"anthropic"``). |
| 135 | + model: Model name (e.g. ``"claude-haiku-4-5-20251001"``). |
| 136 | + api_key: Optional API key (required for Anthropic; omit for Ollama). |
| 137 | +
|
| 138 | + Returns: |
| 139 | + The model's response text. |
| 140 | + """ |
| 141 | + full_model = model if "/" in model else f"{provider}/{model}" |
| 142 | + kwargs: dict[str, Any] = {"model": full_model, "messages": [{"role": "user", "content": prompt}]} |
| 143 | + if api_key: |
| 144 | + kwargs["api_key"] = api_key |
| 145 | + response = litellm.completion(**kwargs) |
| 146 | + return response.choices[0].message.content or "" |
| 147 | + |
14 | 148 |
|
15 | 149 | def run_triage(task: TaskMessage) -> DecisionMessage: |
16 | 150 | """Run LLM-based triage on *task* and return a decision. |
17 | 151 |
|
| 152 | + Applies a duplicate-comment guard before calling the LLM: if |
| 153 | + ``memory_summary`` indicates a comment was posted within the last 24 hours, |
| 154 | + the task is immediately skipped without an LLM call. |
| 155 | +
|
18 | 156 | Args: |
19 | 157 | task: The incoming :class:`~agent.TaskMessage` from the harness. |
20 | 158 |
|
21 | 159 | Returns: |
22 | 160 | A :class:`~agent.DecisionMessage` with decision, rationale, and actions. |
23 | | -
|
24 | | - Raises: |
25 | | - NotImplementedError: Always — implemented in Task 15. |
26 | 161 | """ |
27 | | - raise NotImplementedError("Triage logic implemented in Task 15") |
| 162 | + if _recent_comment_in_memory(task.context.memory_summary): |
| 163 | + from agent import DecisionMessage |
| 164 | + |
| 165 | + return DecisionMessage( |
| 166 | + task_id=task.task_id, |
| 167 | + decision="skip", |
| 168 | + rationale="A comment was posted recently — skipping to avoid duplicate response.", |
| 169 | + actions=[], |
| 170 | + ) |
| 171 | + |
| 172 | + prompt = build_prompt(task) |
| 173 | + backend = task.context.llm_backend |
| 174 | + raw = _call_llm(prompt, provider=backend.provider, model=backend.model) |
| 175 | + |
| 176 | + allow_close = bool(task.payload.get("allow_close", False)) |
| 177 | + return parse_llm_response(raw, task_id=task.task_id, allow_close=allow_close) |
0 commit comments