IssueTriage fixes

This commit is contained in:
2026-09-21 12:58:10 +03:00
parent f25d9a5fab
commit 3d1df7fee5
8 changed files with 136 additions and 46 deletions
+1
View File
@@ -1 +1,2 @@
.env .env
CHANGELOG.md
+5
View File
@@ -1,3 +1,8 @@
### 2026-09-21
1. Now comments are directed to team members with care of the language they can read
2. Comments for context are filtered from TriageAgent to avoid hallucination
3. Fixed missing ambiguous comment
### 2026-09-20 ### 2026-09-20
1. Each agent has a *Model* for both input and output 1. Each agent has a *Model* for both input and output
2. Implemented base class for Model that provide abstract render_dbg function (allows print debug info in console) and to_prompt_text that allows to summarize a model for prompting 2. Implemented base class for Model that provide abstract render_dbg function (allows print debug info in console) and to_prompt_text that allows to summarize a model for prompting
+49 -6
View File
@@ -1,5 +1,6 @@
import json import json
import logging import logging
import os
from dataclasses import dataclass from dataclasses import dataclass
from agents.issue_triage.context_builder import YouTrackContextBuilder from agents.issue_triage.context_builder import YouTrackContextBuilder
@@ -170,6 +171,21 @@ Do NOT produce a plan. Do NOT suggest new agents in this step.
"missing_capabilities": ["<deduped tags from objections>"], "missing_capabilities": ["<deduped tags from objections>"],
"questions": ["..."] // only if status = INSUFFICIENT_INFO "questions": ["..."] // only if status = INSUFFICIENT_INFO
} }
Interpretation, aspect, why it matters, suggested_question and assumptions_planner_may_make should be written in project language: {{COMMENT_LANGUAGE}}
missing_capabilities - in english
## ROUTING
Every finding MUST include a `directed_to` field. It must be one of the
`nick` values listed under Team members, without the leading `@`.
Choose the member who owns the aspect the question is about:
{{TEAM}}
If no team member clearly owns the question, use the Default responder
nick. Never leave `directed_to` empty. Never invent a nick that is not
in the roster.
""" """
AMBIGUITY_PROMPT = """ AMBIGUITY_PROMPT = """
@@ -224,6 +240,20 @@ valid, common, and correct answer.
], ],
"questions": ["<deduped suggested_question for blocking findings>"] "questions": ["<deduped suggested_question for blocking findings>"]
} }
Interpretation, aspect, why it matters, suggested_question and assumptions_planner_may_make should be written in project language: {{COMMENT_LANGUAGE}}
## ROUTING
Every finding MUST include a `directed_to` field. It must be one of the
`nick` values listed under Team members, without the leading `@`.
Choose the member who owns the aspect the question is about:
{{TEAM}}
If no team member clearly owns the question, use the Default responder
nick. Never leave `directed_to` empty. Never invent a nick that is not
in the roster.
""" """
SEQUENCE_PROMPT = """\ SEQUENCE_PROMPT = """\
@@ -545,10 +575,17 @@ class IssueTriageAgent:
def audit_capability( def audit_capability(
self, issue: IssueContext self, issue: IssueContext
) -> CapabilityResult: ) -> CapabilityResult:
raw = self.llm.chat_with_schema( cloned_issue = issue
(CAPABILITY_PROMPT cloned_issue.comments = [c for c in cloned_issue.comments if c.get("author") != os.getenv("YOUTRACK_TRIAGE_AUTHOR")]
prompt = (CAPABILITY_PROMPT
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text()) .replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
.replace("{{ISSUE}}", issue.to_prompt_text())), .replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
.replace("{{ISSUE}}", cloned_issue.to_prompt_text())
.replace("{{TEAM}}", "\n".join([f"- `{m.nick}` — {m.role}" for m in issue.project.team] if issue.project else []))
)
logger.debug(prompt)
raw = self.llm.chat_with_schema(
prompt,
{ {
"type": "json_schema", "type": "json_schema",
"json_schema": { "json_schema": {
@@ -563,15 +600,21 @@ class IssueTriageAgent:
def audit_ambiguity( def audit_ambiguity(
self, issue: IssueContext self, issue: IssueContext
) -> AmbiguityResult: ) -> AmbiguityResult:
cloned_issue = issue
cloned_issue.comments = [c for c in cloned_issue.comments if
c.get("author") != os.getenv("YOUTRACK_TRIAGE_AUTHOR")]
prompt = ( prompt = (
AMBIGUITY_PROMPT AMBIGUITY_PROMPT
.replace("{{ISSUE}}", issue.to_prompt_text()) .replace("{{ISSUE}}", cloned_issue.to_prompt_text())
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text()) .replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
.replace( .replace(
"{{PROJECT}}", "{{PROJECT}}",
issue.project.to_prompt_text() if issue.project else "", issue.project.to_prompt_text() if issue.project else "",
) )
.replace("{{TEAM}}", "\n".join([f"- `{m.nick}` — {m.role}" for m in issue.project.team] if issue.project else []))
) )
logger.debug(prompt)
raw = self.llm.chat_with_schema( raw = self.llm.chat_with_schema(
prompt, prompt,
{ {
@@ -613,9 +656,9 @@ class IssueTriageAgent:
.replace("{{ASSUMPTIONS}}", "\n".join(f"- {a}" for a in assumptions) if assumptions else "(none — you are free to make low-stakes defaults)") .replace("{{ASSUMPTIONS}}", "\n".join(f"- {a}" for a in assumptions) if assumptions else "(none — you are free to make low-stakes defaults)")
.replace("{{PROJECT}}", issue.project.to_prompt_text() if issue.project else "") .replace("{{PROJECT}}", issue.project.to_prompt_text() if issue.project else "")
) )
raw = self.llm.client.chat( raw = self.llm.chat_with_schema(
prompt, prompt,
response_format={ {
"type": "json_schema", "type": "json_schema",
"json_schema": { "json_schema": {
"name": "TaskPlan", "name": "TaskPlan",
+4 -1
View File
@@ -56,6 +56,9 @@ class YouTrackContextBuilder:
project_key = issue_id.split("-", 1)[0] project_key = issue_id.split("-", 1)[0]
project = load_project(project_key) project = load_project(project_key)
logger.debug(f"Lookup for project")
logger.debug(project.model_dump_json())
return IssueContext( return IssueContext(
issue_id=_first(issue, "idReadable", "id") or issue_id, issue_id=_first(issue, "idReadable", "id") or issue_id,
title=_first(issue, "summary", "title", "name", default=""), title=_first(issue, "summary", "title", "name", default=""),
@@ -65,7 +68,7 @@ class YouTrackContextBuilder:
"id": c.get("url"), "id": c.get("url"),
"author": c.get("author") or "unknown", "author": c.get("author") or "unknown",
"body": c.get("text") or c.get("body") or "", "body": c.get("text") or c.get("body") or "",
"created_at": str(c.get("created") or c.get("created_at") or ""), "created_at": str(c.get("created") or c.get("createdAt") or ""),
} }
for c in comments for c in comments
], ],
+9 -9
View File
@@ -1,17 +1,17 @@
from collections.abc import Iterator from collections.abc import Iterator
from agents.issue_triage.models import Step from agents.issue_triage.models import Step, Plan
from contracts.Task import Task from contracts.Task import Task
from contracts.TaskPlan import TaskPlan from contracts.TaskPlan import TaskPlan
def has_cycle(steps: list[Task]) -> bool: def has_cycle(steps: list[Step]) -> bool:
""" """
Return True if the dependency graph contains a cycle. Return True if the dependency graph contains a cycle.
Ignores depends_on entries that reference unknown step_ids Ignores depends_on entries that reference unknown step_ids
(those are caught by a separate validator). (those are caught by a separate validator).
""" """
by_id = {s.id: s for s in steps} by_id = {s.step_id: s for s in steps}
WHITE, GRAY, BLACK = 0, 1, 2 WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {sid: WHITE for sid in by_id} color: dict[str, int] = {sid: WHITE for sid in by_id}
@@ -82,7 +82,7 @@ GLOBAL_INPUTS: frozenset[str] = frozenset({
}) })
def longest_path(steps: list[Task]) -> list[str]: def longest_path(steps: list[Step]) -> list[str]:
""" """
Return the step_ids on the critical path (by estimate_p50). Return the step_ids on the critical path (by estimate_p50).
Assumes acyclic. Returns the empty list for empty input. Assumes acyclic. Returns the empty list for empty input.
@@ -90,7 +90,7 @@ def longest_path(steps: list[Task]) -> list[str]:
if not steps: if not steps:
return [] return []
by_id = {s.id: s for s in steps} by_id = {s.step_id: s for s in steps}
# topological order (Kahn's algorithm) # topological order (Kahn's algorithm)
indeg: dict[str, int] = {sid: 0 for sid in by_id} indeg: dict[str, int] = {sid: 0 for sid in by_id}
@@ -99,8 +99,8 @@ def longest_path(steps: list[Task]) -> list[str]:
for dep in s.depends_on: for dep in s.depends_on:
if dep not in by_id: if dep not in by_id:
continue continue
indeg[s.id] += 1 indeg[s.step_id] += 1
children[dep].append(s.id) children[dep].append(s.step_id)
queue = [sid for sid, d in indeg.items() if d == 0] queue = [sid for sid, d in indeg.items() if d == 0]
topo: list[str] = [] topo: list[str] = []
@@ -147,7 +147,7 @@ def longest_path(steps: list[Task]) -> list[str]:
path.reverse() path.reverse()
return path return path
def path_steps(path: list[str], plan: TaskPlan) -> list[Task]: def path_steps(path: list[str], plan: Plan) -> list[Step]:
"""Resolve a list of step_ids to their Step objects, in path order.""" """Resolve a list of step_ids to their Step objects, in path order."""
by_id = {s.id: s for s in plan.tasks} by_id = {s.step_id: s for s in plan.steps}
return [by_id[sid] for sid in path if sid in by_id] return [by_id[sid] for sid in path if sid in by_id]
+9 -3
View File
@@ -1,9 +1,12 @@
import json import json
from dataclasses import dataclass, field import logging
from dataclasses import dataclass, field, asdict
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field, ValidationError, ConfigDict from pydantic import BaseModel, Field, ValidationError, ConfigDict
logger = logging.getLogger("models")
logger.setLevel(logging.DEBUG)
class Objection(BaseModel): class Objection(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -33,6 +36,7 @@ class AmbiguityFinding(BaseModel):
interpretation_b: str interpretation_b: str
why_it_matters: str why_it_matters: str
suggested_question: str suggested_question: str
directed_to: str
blocking: bool blocking: bool
@@ -165,6 +169,7 @@ def parse_ambiguity_result(raw: str | dict) -> AmbiguityResult:
why_it_matters=f.why_it_matters, why_it_matters=f.why_it_matters,
suggested_question=f.suggested_question, suggested_question=f.suggested_question,
blocking=f.blocking, blocking=f.blocking,
directed_to=f.directed_to,
) )
for f in model.findings for f in model.findings
], ],
@@ -207,8 +212,8 @@ def parse_task_plan(raw: str | dict) -> Plan:
], ],
parallel_groups=[list(g) for g in model.parallel_groups], parallel_groups=[list(g) for g in model.parallel_groups],
critical_path=list(model.critical_path), critical_path=list(model.critical_path),
total_p50=model.total_estimate_minutes.p50, total_p50=model.total_p50,
total_p90=model.total_estimate_minutes.p90, total_p90=model.total_p90,
assumptions=list(model.assumptions), assumptions=list(model.assumptions),
open_questions=list(model.open_questions), open_questions=list(model.open_questions),
missing_capabilities=list(model.missing_capabilities), missing_capabilities=list(model.missing_capabilities),
@@ -226,6 +231,7 @@ def _check_task_plan_semantics(model: Plan) -> None:
raise ValueError("status=INSUFFICIENT_CAPABILITY but steps is non-empty") raise ValueError("status=INSUFFICIENT_CAPABILITY but steps is non-empty")
elif model.status == "INSUFFICIENT_INFO": elif model.status == "INSUFFICIENT_INFO":
if not model.open_questions: if not model.open_questions:
logger.debug(model.model_dump_json())
raise ValueError("status=INSUFFICIENT_INFO but open_questions is empty") raise ValueError("status=INSUFFICIENT_INFO but open_questions is empty")
if model.steps: if model.steps:
raise ValueError("status=INSUFFICIENT_INFO but steps is non-empty") raise ValueError("status=INSUFFICIENT_INFO but steps is non-empty")
+37 -25
View File
@@ -4,30 +4,30 @@ from dataclasses import dataclass, asdict
from agents.issue_triage.helpers import has_cycle, longest_path, path_steps from agents.issue_triage.helpers import has_cycle, longest_path, path_steps
from agents.issue_triage.marker import TriageMarker from agents.issue_triage.marker import TriageMarker
from agents.issue_triage.merge import Action, merge, MergeDecision from agents.issue_triage.merge import Action, merge, MergeDecision
from agents.issue_triage.models import CapabilityResult, AmbiguityResult from agents.issue_triage.models import CapabilityResult, AmbiguityResult, Plan
from agents.registry import AgentRegistry from agents.registry import AgentRegistry
from contracts.IssueContext import IssueContext from contracts.IssueContext import IssueContext
from contracts.TaskPlan import TaskPlan from contracts.TaskPlan import TaskPlan
def validate_plan(plan: TaskPlan, registry): def validate_plan(plan: Plan, registry):
errors = [] errors = []
# 1. All agent_ids exist # 1. All agent_ids exist
for step in plan.tasks: for step in plan.steps:
if step.agent not in registry and step.agent != "human": if step.agent_id not in registry and step.agent_id != "human":
errors.append(f"{step.id}: unknown agent {step.agent}") errors.append(f"{step.step_id}: unknown agent {step.agent_id}")
# 2. depends_on references valid, earlier-declared step_ids # 2. depends_on references valid, earlier-declared step_ids
seen = set() seen = set()
for step in plan.tasks: for step in plan.steps:
for dep in step.depends_on: for dep in step.depends_on:
if dep not in seen: if dep not in seen:
errors.append(f"{step.id}: forward or unknown dep {dep}") errors.append(f"{step.step_id}: forward or unknown dep {dep}")
seen.add(step.id) seen.add(step.step_id)
# 3. Acyclicity (topological sort) # 3. Acyclicity (topological sort)
if has_cycle(plan.tasks): if has_cycle(plan.steps):
errors.append("dependency cycle detected") errors.append("dependency cycle detected")
# 4. Dataflow: inputs must be produced by an ancestor # 4. Dataflow: inputs must be produced by an ancestor
@@ -39,19 +39,19 @@ def validate_plan(plan: TaskPlan, registry):
# errors.append(f"{step['step_id']}: input '{inp}' has no producer") # errors.append(f"{step['step_id']}: input '{inp}' has no producer")
# 5. Estimates sane # 5. Estimates sane
for step in plan.tasks: for step in plan.steps:
e = step.estimate_minutes # if e["p90"] < e["p50"]:
if e["p90"] < e["p50"]: if step.estimate_p90 < step.estimate_p50:
errors.append(f"{step.id}: p90 < p50") errors.append(f"{step.step_id}: p90 < p50")
# 6. Critical path matches depends_on # 6. Critical path matches depends_on
computed = longest_path(plan.tasks) computed = longest_path(plan.steps)
if set(computed) != set(plan.critical_path): if set(computed) != set(plan.critical_path):
errors.append("critical_path mismatch") errors.append("critical_path mismatch")
# 7. Totals recompute, don't trust # 7. Totals recompute, don't trust
recomputed_p50 = sum(s.estimate_minutes["p50"] for s in path_steps(computed, plan)) recomputed_p50 = sum(s.estimate_p50 for s in path_steps(computed, plan))
if recomputed_p50 != plan.total_estimate_minutes["p50"]: if recomputed_p50 != plan.total_p50:
errors.append("total p50 mismatch") errors.append("total p50 mismatch")
return errors return errors
@@ -92,6 +92,22 @@ def render_questions_comment(
for q in decision.questions: for q in decision.questions:
lines.append(f"- {q}") lines.append(f"- {q}")
if amb.status == "AMBIGUOUS" and amb.blocking_findings():
for f in amb.blocking_findings():
lines += [
f"### {f.aspect}",
"",
f"- **Reading A:** {f.interpretation_a}",
f"- **Reading B:** {f.interpretation_b}",
f"- **Why it matters:** {f.why_it_matters}",
f"- **Question:** {"@" + f.directed_to if f.directed_to not in f.suggested_question else ""} {f.suggested_question}",
"",
]
else:
# capability-driven questions — flat list is fine
for q in decision.questions:
lines.append(f"- {q}")
# if the capability pass also flagged something, surface it — the # if the capability pass also flagged something, surface it — the
# question may resolve it (e.g. "which DB?" could unblock db:migration) # question may resolve it (e.g. "which DB?" could unblock db:migration)
if cap.status == "GAP" and cap.missing_capabilities: if cap.status == "GAP" and cap.missing_capabilities:
@@ -109,13 +125,9 @@ def render_questions_comment(
return "\n".join(lines) return "\n".join(lines)
def plan_to_dict(plan: TaskPlan) -> dict:
return asdict(plan)
def render_validation_failure( def render_validation_failure(
marker: TriageMarker, marker: TriageMarker,
plan: TaskPlan, plan: Plan,
errors: list[str], errors: list[str],
) -> str: ) -> str:
lines = [ lines = [
@@ -136,7 +148,7 @@ def render_validation_failure(
"### Rejected plan (raw)", "### Rejected plan (raw)",
"", "",
"```json", "```json",
json.dumps(plan_to_dict(plan), indent=2), json.dumps(plan.model_dump(mode="json"), indent=2),
"```", "```",
"", "",
"_This comment was posted automatically. Do not edit the marker " "_This comment was posted automatically. Do not edit the marker "
@@ -145,7 +157,7 @@ def render_validation_failure(
return "\n".join(lines) return "\n".join(lines)
def render_plan_comment(marker, plan: TaskPlan, assumptions, questions) -> str: def render_plan_comment(marker, plan: Plan, assumptions, questions) -> str:
lines = [ lines = [
marker.render(), marker.render(),
f"**Triage: PLANNED** — {plan.summary}", f"**Triage: PLANNED** — {plan.summary}",
@@ -155,10 +167,10 @@ def render_plan_comment(marker, plan: TaskPlan, assumptions, questions) -> str:
"### Steps", "### Steps",
"", "",
] ]
for s in plan.tasks: for s in plan.steps:
deps = f" (after {', '.join(s.depends_on)})" if s.depends_on else "" deps = f" (after {', '.join(s.depends_on)})" if s.depends_on else ""
lines.append( lines.append(
f"- `{s.id}` **{s.agent}**{deps}: {s.acceptance_criteria} " f"- `{s.step_id}` **{s.agent_id}**{deps}: {s.verification} "
f"[{s.estimate_p50}–{s.estimate_p90}m, {s.risk}]" f"[{s.estimate_p50}–{s.estimate_p90}m, {s.risk}]"
) )
if assumptions: if assumptions:
+22 -2
View File
@@ -1,8 +1,13 @@
from dataclasses import field, dataclass from dataclasses import field, dataclass
from pydantic import BaseModel
@dataclass
class ProjectContext: class TeamMemberModel(BaseModel):
role: str
nick: str
class ProjectContext(BaseModel):
"""Static, per-project knowledge injected into every decomposition.""" """Static, per-project knowledge injected into every decomposition."""
project_key: str # e.g. "ARCH" project_key: str # e.g. "ARCH"
language: str = "" language: str = ""
@@ -15,6 +20,9 @@ class ProjectContext:
package_manager: str = "" # "uv", "poetry", "npm" package_manager: str = "" # "uv", "poetry", "npm"
auth: str = "" # "JWT via fastapi-users" auth: str = "" # "JWT via fastapi-users"
deployment: str = "" # "Docker Compose on Hetzner" deployment: str = "" # "Docker Compose on Hetzner"
project_language: str = "english"
default_responder: str | None = None
team: list[TeamMemberModel] = field(default_factory=list)
conventions: list[str] = field(default_factory=list) # free-form notes conventions: list[str] = field(default_factory=list) # free-form notes
extra: dict[str, str] = field(default_factory=dict) # anything else extra: dict[str, str] = field(default_factory=dict) # anything else
@@ -31,6 +39,7 @@ class ProjectContext:
("Package manager", self.package_manager), ("Package manager", self.package_manager),
("Auth", self.auth), ("Auth", self.auth),
("Deployment", self.deployment), ("Deployment", self.deployment),
("Comment language", self.project_language),
] ]
for label, value in fields: for label, value in fields:
if value: if value:
@@ -42,4 +51,15 @@ class ProjectContext:
lines.append("") lines.append("")
lines.append("Conventions:") lines.append("Conventions:")
lines.extend(f"- {c}" for c in self.conventions) lines.extend(f"- {c}" for c in self.conventions)
if self.team:
lines.append("**Team members** (address questions using the exact `nick`):")
for m in self.team:
lines.append(f"- `{m.nick}` — {m.role}")
if self.default_responder:
lines.append(
f"**Default responder** (use this nick when no team member "
f"clearly owns the question): `{self.default_responder}`"
)
lines.append("")
return "\n".join(lines) return "\n".join(lines)