Skip to content

Commit 6518095

Browse files
coordtclaude
andcommitted
Task 15: Triage logic and prompt (prompts/triage.py)
- build_prompt: formats issue title/body/author/labels + memory_summary - parse_llm_response: extracts JSON from prose, validates decision type, applies allow_close guard, defaults to skip on parse failure - _call_llm: LiteLLM wrapper (provider/model from task context) - run_triage: duplicate-comment guard (memory keyword check) before LLM call - 18 triage tests + full suite at 195 passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 60778eb commit 6518095

2 files changed

Lines changed: 436 additions & 10 deletions

File tree

Lines changed: 160 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,177 @@
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."""
62

73
from __future__ import annotations
84

9-
from typing import TYPE_CHECKING
5+
import json
6+
from typing import TYPE_CHECKING, Any
7+
8+
import litellm
109

1110
if TYPE_CHECKING:
1211
from agent import DecisionMessage, TaskMessage
1312

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+
14148

15149
def run_triage(task: TaskMessage) -> DecisionMessage:
16150
"""Run LLM-based triage on *task* and return a decision.
17151
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+
18156
Args:
19157
task: The incoming :class:`~agent.TaskMessage` from the harness.
20158
21159
Returns:
22160
A :class:`~agent.DecisionMessage` with decision, rationale, and actions.
23-
24-
Raises:
25-
NotImplementedError: Always — implemented in Task 15.
26161
"""
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

Comments
 (0)