GapDetector
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
### 2026-09-26
|
||||
1. Fix 10 comments limit
|
||||
2. Update marker v1 -> v2
|
||||
3. Add GapDetector (between Merge and Action) for holding gaps as structured data (gaps may be "wiki", "task", "implementation", "external")
|
||||
4. New prompt and new kind of comments (as Youtrack does not provide MCP for issue comment update - new comment added)
|
||||
|
||||
### 2026-09-23
|
||||
1. Add memory for Agent - memory dir. Not count timestamps, need work
|
||||
2. Add FileSelection - file graph is converting to consumable table, works for small amount of files
|
||||
|
||||
@@ -10,8 +10,10 @@ from pydantic import TypeAdapter, ValidationError
|
||||
from agents.codebase_analyst.graph_tool import analyze_repo_with_cache
|
||||
from agents.codebase_analyst.select_files import format_table, select_files, SelectFilesInput
|
||||
from agents.coders.repo import ensure_repo_ready
|
||||
from contracts.CodeLanguages import _normalize_language
|
||||
from contracts.RepoContext import RepoContext
|
||||
from contracts.coders.GatherOutput import GatherOutput
|
||||
from contracts.coders.PlanOutput import PlanOutput
|
||||
from contracts.coders.PlanOutput import PlanOutput, wrap_ref_siblings
|
||||
from contracts.coders.RunContext import RunContext
|
||||
from contracts.coders.VerifyOutput import FeasibleVerdict
|
||||
|
||||
@@ -111,7 +113,7 @@ class PlanInput:
|
||||
feasibility: FeasibleVerdict
|
||||
|
||||
|
||||
async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
|
||||
async def handle_plan(inp: PlanInput, run_ctx: RunContext) -> tuple[PlanOutput, RepoContext]:
|
||||
if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0:
|
||||
raise "No project repo"
|
||||
|
||||
@@ -137,7 +139,7 @@ async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
|
||||
print(selected_files)
|
||||
|
||||
prompt = _build_user_prompt(inp, repo, selected_files)
|
||||
schema = PlanAdapter.json_schema()
|
||||
schema = wrap_ref_siblings(PlanAdapter.json_schema())
|
||||
raw = run_ctx.llm.chat_with_schema(
|
||||
prompt,
|
||||
{
|
||||
@@ -151,9 +153,9 @@ async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
|
||||
system=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
raw = _ensure_dict(raw)
|
||||
raw = _normalize_language(_ensure_dict(raw))
|
||||
try:
|
||||
return PlanAdapter.validate_python(raw)
|
||||
return PlanAdapter.validate_python(raw), repo
|
||||
except ValidationError as e:
|
||||
raise PlanError(str(e)) from e
|
||||
|
||||
|
||||
@@ -5,12 +5,31 @@ from dataclasses import dataclass
|
||||
|
||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||
from agents.issue_triage.decomposer import IssueDecomposer
|
||||
from agents.issue_triage.marker import TriageMarker, find_prior_marker, has_reporter_reply_since
|
||||
from agents.issue_triage.merge import Action, merge
|
||||
from agents.issue_triage.models import CapabilityResult, AmbiguityResult, parse_capability_result, \
|
||||
parse_ambiguity_result, AmbiguityFinding, Plan, parse_task_plan
|
||||
from agents.issue_triage.triage import render_validation_failure, render_plan_comment, validate_plan, \
|
||||
render_questions_comment, render_blocker_comment
|
||||
from agents.issue_triage.gap_parser import parse_replies
|
||||
from agents.issue_triage.marker import (
|
||||
Gap,
|
||||
TriageMarker,
|
||||
find_prior_marker, find_marker_comment, has_reporter_reply_since,
|
||||
)
|
||||
from agents.issue_triage.merge import Action, MergeDecision, merge
|
||||
from agents.issue_triage.models import (
|
||||
AmbiguityFinding,
|
||||
AmbiguityResult,
|
||||
CapabilityResult,
|
||||
Plan,
|
||||
parse_ambiguity_result,
|
||||
parse_capability_result,
|
||||
parse_task_plan,
|
||||
)
|
||||
from agents.issue_triage.triage import (
|
||||
render_blocker_comment,
|
||||
render_escalation_comment,
|
||||
render_plan_comment,
|
||||
render_questions_comment,
|
||||
render_validation_failure,
|
||||
render_wiki_gap_comment,
|
||||
validate_plan,
|
||||
)
|
||||
from agents.registry import agent, AgentRegistry, Capability
|
||||
from common.llm_client import LLMClient
|
||||
from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient
|
||||
@@ -19,6 +38,9 @@ from contracts.IssueContext import IssueContext
|
||||
logger = logging.getLogger("triage_agent")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
# ==== TriageContext ====
|
||||
|
||||
@dataclass
|
||||
class TriageContext:
|
||||
issue: IssueContext
|
||||
@@ -26,107 +48,392 @@ class TriageContext:
|
||||
triage_agent: "IssueTriageAgent"
|
||||
|
||||
|
||||
async def run_triage(ctx: TriageContext) -> TriageMarker:
|
||||
# --- Step 1: cheap deterministic gate ---
|
||||
action, prior = precheck(ctx.issue, ctx.registry)
|
||||
if action == Action.SKIP:
|
||||
assert prior is not None
|
||||
return prior
|
||||
# ==== precheck ====
|
||||
|
||||
# --- Step 2: run both Phase A passes ---
|
||||
# in production: concurrent; here sequential for clarity
|
||||
cap = ctx.triage_agent.audit_capability(ctx.issue)
|
||||
amb = ctx.triage_agent.audit_ambiguity(ctx.issue)
|
||||
def _capability_now_available(cap_str: str, registry: AgentRegistry) -> bool:
|
||||
try:
|
||||
cap = Capability(cap_str)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(registry.by_capability(cap))
|
||||
|
||||
# --- Step 3: merge ---
|
||||
decision = merge(cap, amb)
|
||||
|
||||
# --- Step 4: act ---
|
||||
if decision.action == Action.POST_CAPABILITY_BLOCKER:
|
||||
marker = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
capability=cap.status,
|
||||
clarity=amb.status,
|
||||
decision="BLOCKED_CAPABILITY",
|
||||
missing_caps=decision.missing_caps,
|
||||
def precheck(
|
||||
issue: IssueContext,
|
||||
registry: AgentRegistry,
|
||||
prior: TriageMarker | None,
|
||||
) -> tuple[Action, TriageMarker | None]:
|
||||
"""
|
||||
Cheap deterministic gate before any LLM work.
|
||||
|
||||
Returns (action, prior_marker):
|
||||
- SKIP -> ничего не делать, вернуть prior
|
||||
- RERUN -> идти в полный цикл (audit + merge + act)
|
||||
"""
|
||||
if prior is None:
|
||||
return Action.RERUN, None
|
||||
|
||||
# Body changed since last triage -> re-run everything
|
||||
if prior.issue_hash != issue.body_hash():
|
||||
return Action.RERUN, prior
|
||||
|
||||
if prior.decision == "PLANNED":
|
||||
return Action.SKIP, prior
|
||||
|
||||
if prior.decision == "BLOCKED_CLARITY":
|
||||
# still waiting on a reply?
|
||||
if prior.open_gaps():
|
||||
return Action.SKIP, prior
|
||||
# все gaps закрыты -> планируем
|
||||
return Action.RERUN, prior
|
||||
|
||||
if prior.decision == "BLOCKED_CAPABILITY":
|
||||
still_missing = [
|
||||
c for c in prior.missing_caps
|
||||
if not _capability_now_available(c, registry)
|
||||
]
|
||||
if not still_missing:
|
||||
return Action.RERUN, prior
|
||||
from agents.issue_triage.marker import find_marker_comment, has_reporter_reply_since
|
||||
marker_cid = find_marker_comment(issue, prior)
|
||||
if marker_cid and has_reporter_reply_since(issue, marker_cid):
|
||||
return Action.RERUN, prior
|
||||
return Action.SKIP, prior
|
||||
|
||||
if prior.decision == "WIKI_GAP_RECORDED":
|
||||
# тикет уже создан, gaps помечены wiki_gap;
|
||||
# merge отфильтрует их -> decision.gaps пустой -> уйдём в sequence
|
||||
return Action.RERUN, prior
|
||||
|
||||
if prior.decision == "ESCALATED":
|
||||
return Action.SKIP, prior
|
||||
|
||||
return Action.RERUN, prior
|
||||
|
||||
|
||||
# ==== sequence helper ====
|
||||
|
||||
async def _do_sequence(
|
||||
ctx: TriageContext,
|
||||
marker: TriageMarker,
|
||||
decision: MergeDecision,
|
||||
) -> TriageMarker:
|
||||
plan = ctx.triage_agent.sequence(
|
||||
ctx.issue, ctx.registry,
|
||||
assumptions=decision.assumptions,
|
||||
)
|
||||
logger.debug("add_issue_comment" + json.dumps({ "issueId": ctx.issue.issue_id, "text": render_blocker_comment(marker, cap, amb, decision), })),
|
||||
errors = validate_plan(plan, ctx.registry)
|
||||
if errors:
|
||||
plan = ctx.triage_agent.sequence(
|
||||
ctx.issue, ctx.registry,
|
||||
assumptions=decision.assumptions + [f"FIX: {e}" for e in errors],
|
||||
)
|
||||
errors = validate_plan(plan, ctx.registry)
|
||||
|
||||
if errors:
|
||||
failed = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
decision="BLOCKED_CAPABILITY",
|
||||
capability="COVERED",
|
||||
missing_caps=["plan-validation-failed"],
|
||||
gaps=marker.gaps,
|
||||
questions_asked_count=marker.questions_asked_count,
|
||||
)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": ctx.issue.issue_id,
|
||||
"text": render_validation_failure(failed, plan, errors),
|
||||
},
|
||||
)
|
||||
return failed
|
||||
|
||||
planned = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
decision="PLANNED",
|
||||
capability="COVERED",
|
||||
missing_caps=[],
|
||||
gaps=marker.gaps,
|
||||
questions_asked_count=marker.questions_asked_count,
|
||||
)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": ctx.issue.issue_id,
|
||||
"text": render_plan_comment(
|
||||
planned, plan, decision.assumptions, decision.questions,
|
||||
),
|
||||
},
|
||||
)
|
||||
return planned
|
||||
|
||||
|
||||
def _stamp(marker: TriageMarker, issue: IssueContext) -> TriageMarker:
|
||||
"""Проставляет счётчик ПОСЛЕ нашего поста (=len+1)."""
|
||||
return TriageMarker(
|
||||
issue_hash=marker.issue_hash,
|
||||
decision=marker.decision,
|
||||
capability=marker.capability,
|
||||
missing_caps=list(marker.missing_caps),
|
||||
gaps=marker.gaps,
|
||||
questions_asked_count=marker.questions_asked_count,
|
||||
last_seen_comment_count=len(issue.comments) + 1,
|
||||
)
|
||||
|
||||
|
||||
def _render_ack(marker: TriageMarker) -> str:
|
||||
closed = sum(1 for g in marker.gaps if g.status in ("answered", "unresolved"))
|
||||
open_g = sum(1 for g in marker.gaps if g.status in ("open", "asked"))
|
||||
if closed and not open_g:
|
||||
head = "✅ Принял, все вопросы закрыты."
|
||||
elif closed:
|
||||
head = f"✅ Принял часть ({closed} закрыто, {open_g} открыто)."
|
||||
else:
|
||||
head = "👀 Принял, новых ответов не нашёл."
|
||||
if marker.decision == "BLOCKED_CAPABILITY":
|
||||
head += " Продолжу, как только появятся capabilities (см. предыдущий комментарий)."
|
||||
return head
|
||||
|
||||
|
||||
def _is_stale(issue: IssueContext, prior: TriageMarker, registry: AgentRegistry) -> bool:
|
||||
"""
|
||||
True, если с прошлого прогона НИЧЕГО значимого не изменилось
|
||||
и можно вернуть prior без вызова LLM.
|
||||
"""
|
||||
logger.info(
|
||||
"_is_stale check: decision=%s issue_hash_match=%s last_author=%r "
|
||||
"last_id=%s comment_count=%d",
|
||||
prior.decision,
|
||||
prior.issue_hash == issue.body_hash(),
|
||||
issue.comments[-1].author if issue.comments else None,
|
||||
issue.comments[-1].id if issue.comments else None,
|
||||
len(issue.comments),
|
||||
)
|
||||
|
||||
# 1. body изменился — надо переразбирать
|
||||
if prior.issue_hash != issue.body_hash():
|
||||
return False
|
||||
|
||||
# 2. терминальные статусы — вообще ничего не делаем
|
||||
if prior.decision in ("PLANNED", "ESCALATED", "WIKI_GAP_RECORDED"):
|
||||
return True
|
||||
|
||||
if prior.last_seen_comment_count >= len(issue.comments):
|
||||
return True
|
||||
|
||||
marker_cid = find_marker_comment(issue, prior)
|
||||
new_reply = (
|
||||
marker_cid is not None
|
||||
and has_reporter_reply_since(issue, marker_cid)
|
||||
)
|
||||
|
||||
# 3. Ждём ответа, ответа нет — стоим
|
||||
if prior.decision == "BLOCKED_CLARITY":
|
||||
if prior.open_gaps() and not new_reply:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 4. Блокер по capabilities: реестр не менялся и комментариев нет — стоим
|
||||
if prior.decision == "BLOCKED_CAPABILITY":
|
||||
if new_reply:
|
||||
return False
|
||||
still_missing = [
|
||||
c for c in prior.missing_caps
|
||||
if not _capability_now_available(c, registry)
|
||||
]
|
||||
return bool(still_missing)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ==== main loop ====
|
||||
|
||||
async def run_triage(ctx: TriageContext) -> TriageMarker:
|
||||
issue = ctx.issue
|
||||
|
||||
# 1. Idempotency: читаем prior marker и обновляем статусы gaps по ответам
|
||||
prior = find_prior_marker(issue)
|
||||
|
||||
# --- 0. Дешёвый детерминированный гейт: LLM не вызываем вообще ---
|
||||
if prior is not None and _is_stale(issue, prior, ctx.registry):
|
||||
logger.info("triage is up to date (decision=%s), skipping LLM",
|
||||
prior.decision)
|
||||
return prior
|
||||
|
||||
# --- 1. Только теперь, когда точно есть что парсить: LLM ---
|
||||
if prior is not None:
|
||||
updated = parse_replies(
|
||||
issue, prior, ctx.triage_agent.llm,
|
||||
project_lang=(issue.project.project_language if issue.project else "english"),
|
||||
)
|
||||
stamped = _stamp(updated, issue)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": issue.issue_id,
|
||||
"text": stamped.render() + "\n\n" + _render_ack(stamped),
|
||||
},
|
||||
)
|
||||
prior = stamped
|
||||
|
||||
action, prior = precheck(issue, ctx.registry, prior)
|
||||
if action == Action.SKIP and prior is not None:
|
||||
return prior
|
||||
|
||||
# 2. Audits
|
||||
cap = ctx.triage_agent.audit_capability(issue)
|
||||
amb = ctx.triage_agent.audit_ambiguity(issue)
|
||||
|
||||
# 3. Merge with prior gap state
|
||||
prior_gaps = prior.gap_by_id() if prior else {}
|
||||
decision = merge(cap, amb, prior_gaps=prior_gaps, max_questions=3)
|
||||
|
||||
base_gaps = prior.gaps if prior else []
|
||||
asked_count = prior.questions_asked_count if prior else 0
|
||||
|
||||
# 4. Act
|
||||
if decision.action == Action.ESCALATE_TO_HUMAN:
|
||||
marker = TriageMarker(
|
||||
issue_hash=issue.body_hash(),
|
||||
decision="ESCALATED",
|
||||
capability="COVERED",
|
||||
gaps=base_gaps + decision.gaps,
|
||||
questions_asked_count=asked_count,
|
||||
)
|
||||
marker = _stamp(marker, issue)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": issue.issue_id,
|
||||
"text": render_escalation_comment(marker, decision),
|
||||
},
|
||||
)
|
||||
return marker
|
||||
|
||||
if decision.action == Action.POST_CAPABILITY_BLOCKER:
|
||||
marker = TriageMarker(
|
||||
issue_hash=issue.body_hash(),
|
||||
decision="BLOCKED_CAPABILITY",
|
||||
capability=cap.status,
|
||||
missing_caps=list(decision.missing_caps),
|
||||
gaps=base_gaps + decision.gaps,
|
||||
questions_asked_count=asked_count,
|
||||
)
|
||||
|
||||
if (prior is not None
|
||||
and prior.decision == "BLOCKED_CAPABILITY"
|
||||
and set(prior.missing_caps) == set(decision.missing_caps)):
|
||||
logger.info("identical capability blocker already posted, skipping")
|
||||
return marker
|
||||
marker = _stamp(marker, issue)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": issue.issue_id,
|
||||
"text": render_blocker_comment(marker, cap, amb, decision),
|
||||
},
|
||||
)
|
||||
return marker
|
||||
|
||||
if decision.action == Action.POST_QUESTIONS:
|
||||
marker = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
capability=cap.status,
|
||||
clarity="AWAITING_REPLY",
|
||||
decision="BLOCKED_CLARITY",
|
||||
missing_caps=cap.missing_capabilities,
|
||||
new_gaps = [
|
||||
Gap(
|
||||
gap_id=g.gap_id,
|
||||
source=g.source,
|
||||
slot=g.slot,
|
||||
question=g.question,
|
||||
impact=g.impact,
|
||||
status="asked",
|
||||
)
|
||||
for g in decision.gaps
|
||||
]
|
||||
merged_gaps = list(base_gaps)
|
||||
for ng in new_gaps:
|
||||
merged_gaps = [g for g in merged_gaps if g.gap_id != ng.gap_id]
|
||||
merged_gaps.append(ng)
|
||||
|
||||
marker = TriageMarker(
|
||||
issue_hash=issue.body_hash(),
|
||||
decision="BLOCKED_CLARITY",
|
||||
capability=cap.status,
|
||||
gaps=merged_gaps,
|
||||
questions_asked_count=asked_count + 1,
|
||||
)
|
||||
marker = _stamp(marker, issue)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": ctx.issue.issue_id,
|
||||
"issueId": issue.issue_id,
|
||||
"text": render_questions_comment(marker, cap, amb, decision),
|
||||
},
|
||||
)
|
||||
return marker
|
||||
|
||||
if decision.action == Action.PROCEED_TO_SEQUENCE:
|
||||
plan = ctx.triage_agent.sequence(
|
||||
ctx.issue, ctx.registry,
|
||||
assumptions=decision.assumptions,
|
||||
)
|
||||
# validate before posting — see previous message
|
||||
errors = validate_plan(plan, ctx.registry)
|
||||
if errors:
|
||||
plan = ctx.triage_agent.sequence( # one retry with errors fed back
|
||||
ctx.issue, ctx.registry,
|
||||
assumptions=decision.assumptions + [f"FIX: {e}" for e in errors],
|
||||
)
|
||||
errors = validate_plan(plan, ctx.registry)
|
||||
if errors:
|
||||
marker = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
capability="COVERED",
|
||||
clarity=amb.status,
|
||||
decision="BLOCKED_CAPABILITY",
|
||||
missing_caps=["plan-validation-failed"],
|
||||
)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
if decision.action == Action.POST_WIKI_GAP:
|
||||
try:
|
||||
ticket_raw = await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue",
|
||||
{
|
||||
"issueId": ctx.issue.issue_id,
|
||||
"text": render_validation_failure(marker, plan, errors),
|
||||
"project": os.getenv("YOUTRACK_DOC_PROJECT", "DOC"),
|
||||
"summary": f"[wiki-gap] {issue.title}",
|
||||
"description": "\n".join(
|
||||
f"- {g.slot}: {g.question}" for g in decision.gaps
|
||||
),
|
||||
},
|
||||
)
|
||||
return marker
|
||||
ticket_id = str(ticket_raw)[:80]
|
||||
except Exception as exc:
|
||||
logger.warning("add_issue failed: %s", exc)
|
||||
ticket_id = "(ticket creation failed)"
|
||||
|
||||
new_gaps = [
|
||||
Gap(
|
||||
gap_id=g.gap_id,
|
||||
source=g.source,
|
||||
slot=g.slot,
|
||||
question=g.question,
|
||||
impact=g.impact,
|
||||
status="wiki_gap",
|
||||
ticket=ticket_id,
|
||||
)
|
||||
for g in decision.gaps
|
||||
]
|
||||
|
||||
marker = TriageMarker(
|
||||
issue_hash=ctx.issue.body_hash(),
|
||||
capability="COVERED",
|
||||
clarity=amb.status,
|
||||
decision="PLANNED",
|
||||
issue_hash=issue.body_hash(),
|
||||
decision="WIKI_GAP_RECORDED",
|
||||
capability=cap.status,
|
||||
gaps=base_gaps + new_gaps,
|
||||
questions_asked_count=asked_count,
|
||||
)
|
||||
marker = _stamp(marker, issue)
|
||||
await ctx.triage_agent.youtrack_mcp.call_tool(
|
||||
"add_issue_comment",
|
||||
{
|
||||
"issueId": ctx.issue.issue_id,
|
||||
"text": render_plan_comment(marker, plan, decision.assumptions, decision.questions),
|
||||
"issueId": issue.issue_id,
|
||||
"text": render_wiki_gap_comment(marker, decision, ticket_id),
|
||||
},
|
||||
)
|
||||
# handoff to executor — enqueue, don't block
|
||||
# ctx.tracker.enqueue_execution(ctx.issue.id, plan)
|
||||
return marker
|
||||
return await _do_sequence(ctx, marker, decision)
|
||||
|
||||
if decision.action in (
|
||||
Action.PROCEED_TO_SEQUENCE,
|
||||
Action.PROCEED_WITH_ASSUMPTIONS,
|
||||
):
|
||||
marker = TriageMarker(
|
||||
issue_hash=issue.body_hash(),
|
||||
decision="PLANNED",
|
||||
capability=cap.status,
|
||||
gaps=base_gaps,
|
||||
questions_asked_count=asked_count,
|
||||
)
|
||||
return await _do_sequence(ctx, marker, decision)
|
||||
|
||||
raise AssertionError(f"unhandled action: {decision.action}")
|
||||
|
||||
|
||||
# ==== prompts (без изменений) ====
|
||||
|
||||
CAPABILITY_PROMPT = """
|
||||
You are an adversarial capability auditor for an autonomous coding system.
|
||||
|
||||
@@ -142,7 +449,7 @@ Be specific. A valid objection must:
|
||||
Invalid objections (do NOT produce these):
|
||||
- Vague worries ("this might be complex").
|
||||
- Missing information ("I don't know the repo"). That's a separate
|
||||
category — see below.
|
||||
category ? see below.
|
||||
- Capabilities that ARE covered but that you'd prefer were covered
|
||||
by a different agent.
|
||||
|
||||
@@ -231,7 +538,10 @@ valid, common, and correct answer.
|
||||
"interpretation_b": "<the other concrete reading>",
|
||||
"why_it_matters": "<what the plan does differently under each>",
|
||||
"suggested_question": "<one sentence to post to the reporter>",
|
||||
"blocking": true | false
|
||||
"blocking": true | false,
|
||||
"source": "wiki" | "task" | "implementation" | "external",
|
||||
"impact": 0.0,
|
||||
"slot": "<short stable tag>"
|
||||
}
|
||||
],
|
||||
"assumptions_planner_may_make": [
|
||||
@@ -240,6 +550,16 @@ valid, common, and correct answer.
|
||||
"questions": ["<deduped suggested_question for blocking findings>"]
|
||||
}
|
||||
|
||||
Rules for the new fields:
|
||||
- source:
|
||||
* "wiki": stable facts (terms, API, policy, standard, compliance).
|
||||
* "task": this issue's goal, scope, metric, priority, deadline.
|
||||
* "implementation": engine choice the planner could reasonably decide.
|
||||
* "external": legal / vendor / market, neither wiki nor planner.
|
||||
- impact: float in [0,1]; <0.3 low-stakes, >=0.7 plan-changing.
|
||||
- slot: short stable tag, e.g. "success_metric", "target_env".
|
||||
Two findings with the same aspect MUST use the same slot value.
|
||||
|
||||
Interpretation, aspect, why it matters, suggested_question and assumptions_planner_may_make should be written in project language: {{COMMENT_LANGUAGE}}
|
||||
|
||||
## ROUTING
|
||||
@@ -259,7 +579,7 @@ SEQUENCE_PROMPT = """\
|
||||
You are a task sequencing planner for an autonomous coding system.
|
||||
|
||||
You receive an issue and a list of AVAILABLE AGENTS. Your job is to
|
||||
produce a dependency-ordered plan — a DAG of steps — that, if executed
|
||||
produce a dependency-ordered plan ? a DAG of steps ? that, if executed
|
||||
in the correct order, resolves the issue.
|
||||
|
||||
You are NOT deciding whether the task is possible. That decision has
|
||||
@@ -273,7 +593,7 @@ Violating any of these makes your output invalid:
|
||||
1. You may ONLY use agents whose `id` appears in AVAILABLE AGENTS.
|
||||
Never invent agents. Never use placeholder ids like "TODO".
|
||||
2. Every step must have a unique `step_id` (s01, s02, ...).
|
||||
3. Every step must have `depends_on` — an array of step_ids, possibly
|
||||
3. Every step must have `depends_on` ? an array of step_ids, possibly
|
||||
empty. Declare every dependency, including transitive ones you rely
|
||||
on for data. Missing a dependency is worse than an unnecessary one.
|
||||
4. The dependency graph must be acyclic.
|
||||
@@ -352,7 +672,7 @@ Field notes:
|
||||
a group must have identical `depends_on` sets and no inter-dependencies.
|
||||
- `critical_path` is the longest dependency chain by `estimate_minutes.p50`.
|
||||
- `total_estimate_minutes.p50` is the sum of p50 along the critical path
|
||||
— NOT the sum of all steps. Parallel work does not add.
|
||||
? NOT the sum of all steps. Parallel work does not add.
|
||||
Compute it; do not guess.
|
||||
- `total_estimate_minutes.p90` is the sum of p90 along the p90-weighted
|
||||
critical path. This path may differ from the p50 one.
|
||||
@@ -366,7 +686,7 @@ Field notes:
|
||||
Do not add ceremony steps (planning, review, cleanup) unless the
|
||||
issue explicitly asks for them or an agent is dedicated to them.
|
||||
- Prefer narrow, verifiable steps over one large step.
|
||||
- If two steps touch the same artifact, they are NOT parallel — one
|
||||
- If two steps touch the same artifact, they are NOT parallel ? one
|
||||
must depend on the other.
|
||||
- A "review" or "gate" step is only valid if an agent whose job is
|
||||
review/gating is listed in AVAILABLE AGENTS. Otherwise do not add one.
|
||||
@@ -378,7 +698,7 @@ Field notes:
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
### Example 1 — single-step plan
|
||||
### Example 1 ? single-step plan
|
||||
|
||||
Issue: "Add a `--verbose` flag to the CLI that prints extra diagnostics."
|
||||
|
||||
@@ -411,7 +731,7 @@ Correct output:
|
||||
"missing_capabilities": []
|
||||
}
|
||||
|
||||
### Example 2 — two-step plan with a dependency
|
||||
### Example 2 ? two-step plan with a dependency
|
||||
|
||||
Issue: "Fix the off-by-one in pagination and add a regression test."
|
||||
|
||||
@@ -455,11 +775,11 @@ Correct output:
|
||||
"missing_capabilities": []
|
||||
}
|
||||
|
||||
Note in Example 2: `test-runner` is available but NOT used — the issue
|
||||
Note in Example 2: `test-runner` is available but NOT used ? the issue
|
||||
asks to *add* a test, which is code-writing work. Do not force agents
|
||||
into plans just because they exist.
|
||||
|
||||
### Example 3 — insufficient capability (escape hatch)
|
||||
### Example 3 ? insufficient capability (escape hatch)
|
||||
|
||||
Issue: "Migrate the users table to add a `last_login` timestamp column."
|
||||
|
||||
@@ -483,62 +803,8 @@ Correct output:
|
||||
Now produce the JSON for the issue above.
|
||||
"""
|
||||
|
||||
def prior_comment_id(issue: IssueContext) -> str:
|
||||
for c in reversed(issue.comments):
|
||||
if TriageMarker.parse(c.body):
|
||||
return c.id
|
||||
return ""
|
||||
|
||||
|
||||
def _capability_now_available(cap_str: str, registry: AgentRegistry) -> bool:
|
||||
try:
|
||||
cap = Capability(cap_str)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(registry.by_capability(cap))
|
||||
|
||||
|
||||
def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, TriageMarker | None]:
|
||||
"""
|
||||
Returns the action to take before doing any LLM work.
|
||||
This is the cheap, deterministic gate.
|
||||
"""
|
||||
prior = find_prior_marker(issue)
|
||||
if prior is None:
|
||||
return Action.RERUN, None
|
||||
|
||||
# issue body changed since last triage → re-run everything
|
||||
if prior.issue_hash != issue.body_hash():
|
||||
return Action.RERUN, prior
|
||||
|
||||
# PLANNED: never re-plan. Execution is downstream's problem.
|
||||
if prior.decision == "PLANNED":
|
||||
return Action.SKIP, prior
|
||||
|
||||
# BLOCKED_CLARITY: wait for reporter reply
|
||||
if prior.decision == "BLOCKED_CLARITY":
|
||||
if has_reporter_reply_since(issue, prior_comment_id(issue)):
|
||||
return Action.RERUN, prior
|
||||
else:
|
||||
logger.debug("NO REPLY")
|
||||
return Action.SKIP, prior
|
||||
|
||||
# BLOCKED_CAPABILITY: check if new agents close the gap
|
||||
if prior.decision == "BLOCKED_CAPABILITY":
|
||||
still_missing = [
|
||||
c for c in prior.missing_caps
|
||||
if not _capability_now_available(c, registry)
|
||||
]
|
||||
if not still_missing:
|
||||
return Action.RERUN, prior
|
||||
# even if still missing, if reporter added info, re-run
|
||||
# to potentially discover the issue changed shape
|
||||
if has_reporter_reply_since(issue, prior_comment_id(issue)):
|
||||
return Action.RERUN, prior
|
||||
return Action.SKIP, prior
|
||||
|
||||
return Action.RERUN, prior
|
||||
|
||||
# ==== IssueTriageAgent (без изменений) ====
|
||||
|
||||
@agent(
|
||||
name="IssueTriageAgent",
|
||||
@@ -550,7 +816,13 @@ def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, Tria
|
||||
priority=10,
|
||||
)
|
||||
class IssueTriageAgent:
|
||||
def __init__(self, context_builder: YouTrackContextBuilder, llm: LLMClient, agent_registry: AgentRegistry, youtrack_mcp: YouTrackMCPClient):
|
||||
def __init__(
|
||||
self,
|
||||
context_builder: YouTrackContextBuilder,
|
||||
llm: LLMClient,
|
||||
agent_registry: AgentRegistry,
|
||||
youtrack_mcp: YouTrackMCPClient,
|
||||
):
|
||||
self.name = "IssueTriageAgent"
|
||||
self.ctx_builder = context_builder
|
||||
self.decomposer = IssueDecomposer(llm)
|
||||
@@ -558,7 +830,6 @@ class IssueTriageAgent:
|
||||
self.agent_registry = agent_registry
|
||||
self.youtrack_mcp = youtrack_mcp
|
||||
|
||||
|
||||
async def plan_issue(self, issue_id: str) -> TriageMarker:
|
||||
issue_ctx = await self.ctx_builder.build(issue_id)
|
||||
|
||||
@@ -571,16 +842,27 @@ class IssueTriageAgent:
|
||||
self,
|
||||
))
|
||||
|
||||
def audit_capability(
|
||||
self, issue: IssueContext
|
||||
) -> CapabilityResult:
|
||||
def audit_capability(self, issue: IssueContext) -> CapabilityResult:
|
||||
cloned_issue = issue
|
||||
cloned_issue.comments = [c for c in cloned_issue.comments if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")]
|
||||
prompt = (CAPABILITY_PROMPT
|
||||
cloned_issue.comments = [
|
||||
c for c in cloned_issue.comments
|
||||
if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")
|
||||
]
|
||||
prompt = (
|
||||
CAPABILITY_PROMPT
|
||||
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
|
||||
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
|
||||
.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 []))
|
||||
.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(
|
||||
@@ -596,22 +878,31 @@ class IssueTriageAgent:
|
||||
)
|
||||
return parse_capability_result(raw)
|
||||
|
||||
def audit_ambiguity(
|
||||
self, issue: IssueContext
|
||||
) -> AmbiguityResult:
|
||||
def audit_ambiguity(self, issue: IssueContext) -> AmbiguityResult:
|
||||
cloned_issue = issue
|
||||
cloned_issue.comments = [c for c in cloned_issue.comments if
|
||||
c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")]
|
||||
cloned_issue.comments = [
|
||||
c for c in cloned_issue.comments
|
||||
if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")
|
||||
]
|
||||
prompt = (
|
||||
AMBIGUITY_PROMPT
|
||||
.replace("{{ISSUE}}", cloned_issue.to_prompt_text())
|
||||
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
|
||||
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english")
|
||||
.replace(
|
||||
"{{COMMENT_LANGUAGE}}",
|
||||
issue.project.project_language if issue.project else "english",
|
||||
)
|
||||
.replace(
|
||||
"{{PROJECT}}",
|
||||
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 []))
|
||||
.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(
|
||||
@@ -628,7 +919,7 @@ class IssueTriageAgent:
|
||||
try:
|
||||
return parse_ambiguity_result(raw)
|
||||
except ValueError as exc:
|
||||
logger.warning("ambiguity parse failed: %s — raw=%r", exc, raw)
|
||||
logger.warning("ambiguity parse failed: %s ? raw=%r", exc, raw)
|
||||
return AmbiguityResult(
|
||||
status="AMBIGUOUS",
|
||||
findings=[
|
||||
@@ -639,21 +930,37 @@ class IssueTriageAgent:
|
||||
why_it_matters="triage agent could not interpret its own analysis",
|
||||
suggested_question="triage agent failed to analyze this issue; please review manually",
|
||||
blocking=True,
|
||||
directed_to="",
|
||||
source="task",
|
||||
impact=1.0,
|
||||
slot="triage-internal-error",
|
||||
)
|
||||
],
|
||||
questions=["triage agent failed to analyze this issue; please review manually"],
|
||||
questions=[
|
||||
"triage agent failed to analyze this issue; please review manually"
|
||||
],
|
||||
)
|
||||
|
||||
def sequence(
|
||||
self, issue: IssueContext, registry: AgentRegistry,
|
||||
self,
|
||||
issue: IssueContext,
|
||||
registry: AgentRegistry,
|
||||
assumptions: list[str],
|
||||
) -> Plan:
|
||||
prompt = (
|
||||
SEQUENCE_PROMPT
|
||||
.replace("{{ISSUE}}", issue.to_prompt_text())
|
||||
.replace("{{AGENTS}}", registry.to_prompt_text())
|
||||
.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(
|
||||
"{{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 "",
|
||||
)
|
||||
)
|
||||
raw = self.llm.chat_with_schema(
|
||||
prompt,
|
||||
@@ -667,3 +974,42 @@ class IssueTriageAgent:
|
||||
},
|
||||
)
|
||||
return parse_task_plan(raw)
|
||||
|
||||
async def _persist_marker(
|
||||
self, issue: IssueContext, marker: TriageMarker, comment_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Обновляет in-place комментарий с маркером. Не создаёт новый — иначе
|
||||
на каждый прогон будет +1 комментарий, и last_seen_comment_count
|
||||
никогда не сойдётся.
|
||||
"""
|
||||
await self.youtrack_mcp.call_tool(
|
||||
"update_issue_comment",
|
||||
{
|
||||
"issueId": issue.issue_id,
|
||||
"commentId": comment_id,
|
||||
"text": marker.render(),
|
||||
},
|
||||
)
|
||||
|
||||
def _stamp_last_seen(marker: TriageMarker, issue: IssueContext) -> TriageMarker:
|
||||
"""Возвращает копию маркера с актуальным last_seen_comment_id."""
|
||||
return TriageMarker(
|
||||
issue_hash=marker.issue_hash,
|
||||
decision=marker.decision,
|
||||
capability=marker.capability,
|
||||
missing_caps=list(marker.missing_caps),
|
||||
gaps=marker.gaps,
|
||||
questions_asked_count=marker.questions_asked_count,
|
||||
last_seen_comment_id=(issue.comments[-1].id if issue.comments else ""),
|
||||
)
|
||||
|
||||
|
||||
def _gaps_changed(a: TriageMarker, b: TriageMarker) -> bool:
|
||||
if len(a.gaps) != len(b.gaps):
|
||||
return True
|
||||
for ga, gb in zip(a.gaps, b.gaps):
|
||||
if ga.gap_id != gb.gap_id or ga.status != gb.status or ga.answer != gb.answer:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
|
||||
import httpx
|
||||
|
||||
from agents.issue_triage.marker import is_agent_author, TriageMarker
|
||||
from agents.issue_triage.project_loader import load_project
|
||||
from common.as_type import _as_dict, _as_list, _first, _extract_base64
|
||||
from common.youtrack_mcp_client import IssueNotFound
|
||||
@@ -25,6 +26,105 @@ class YouTrackContextBuilder:
|
||||
self.mcp = mcp_client
|
||||
self.http = http_client
|
||||
|
||||
COMMENTS_PAGE_SIZE = 10
|
||||
COMMENTS_HARD_CAP = 5000 # защита от бесконечного цикла
|
||||
|
||||
async def _fetch_all_comments(self, issue_id: str) -> list[dict]:
|
||||
"""
|
||||
Тянет ВСЕ комментарии через пагинацию.
|
||||
Пробует несколько схем параметров, потому что разные YouTrack MCP
|
||||
серверы называют их по-разному: (limit, skip), (top, skip),
|
||||
(first, after) — на крайний случай есть fallback на одну большую страницу.
|
||||
"""
|
||||
all_comments: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
skip = 0
|
||||
|
||||
while True:
|
||||
page = await self._fetch_comments_page(issue_id, skip=skip,
|
||||
limit=self.COMMENTS_PAGE_SIZE)
|
||||
if not page:
|
||||
logger.debug("No next page")
|
||||
break
|
||||
|
||||
logger.debug("Got comment page")
|
||||
# дедуп по id — если сервер отдаёт пересекающиеся страницы
|
||||
new_on_page = 0
|
||||
for c in page:
|
||||
cid = c.get("id") or c.get("url") or c.get("idReadable")
|
||||
if cid and cid in seen_ids:
|
||||
continue
|
||||
if cid:
|
||||
seen_ids.add(cid)
|
||||
all_comments.append(c)
|
||||
new_on_page += 1
|
||||
|
||||
logger.debug(f"Comments length: %s", len(all_comments))
|
||||
|
||||
# конец: страница короче запрошенной, или ничего нового
|
||||
if len(page) < self.COMMENTS_PAGE_SIZE or new_on_page == 0:
|
||||
break
|
||||
|
||||
skip += len(page)
|
||||
|
||||
if len(all_comments) >= self.COMMENTS_HARD_CAP:
|
||||
logger.warning(
|
||||
"hit COMMENTS_HARD_CAP=%d for issue %s; truncating",
|
||||
self.COMMENTS_HARD_CAP, issue_id,
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug("fetched %d comments for %s", len(all_comments), issue_id)
|
||||
|
||||
return all_comments
|
||||
|
||||
async def _fetch_comments_page(
|
||||
self, issue_id: str, *, skip: int, limit: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Одна страница комментариев. Пробует разные имена параметров,
|
||||
потому что MCP-обёртки над YouTrack различаются.
|
||||
"""
|
||||
attempts = [
|
||||
# {"issueId": issue_id, "skip": skip, "top": limit},
|
||||
# {"issueId": issue_id, "skip": skip, "limit": limit},
|
||||
# {"issueId": issue_id, "$skip": skip, "$top": limit},
|
||||
{"issueId": issue_id, "offset": skip, "limit": limit},
|
||||
]
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for args in attempts:
|
||||
try:
|
||||
raw = await self.mcp.call_tool("get_issue_comments", args)
|
||||
page = _as_list(raw)
|
||||
return page
|
||||
except IssueNotFound:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# сервер не знает такого параметра — пробуем следующий набор
|
||||
last_exc = exc
|
||||
logger.debug(
|
||||
"get_issue_comments failed with args=%s: %s", args, exc,
|
||||
)
|
||||
continue
|
||||
|
||||
# Все схемы провалились — пробуем один большой запрос без skip,
|
||||
# чтобы не остаться совсем без комментариев.
|
||||
logger.warning(
|
||||
"all pagination schemes failed for %s; falling back to a single "
|
||||
"big page. last error: %s", issue_id, last_exc,
|
||||
)
|
||||
try:
|
||||
raw = await self.mcp.call_tool(
|
||||
"get_issue_comments",
|
||||
{"issueId": issue_id, "limit": self.COMMENTS_HARD_CAP},
|
||||
)
|
||||
return _as_list(raw)
|
||||
except IssueNotFound:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def build(self, issue_id: str) -> IssueContext | None:
|
||||
try:
|
||||
issue_raw = await self.mcp.call_tool(
|
||||
@@ -42,10 +142,7 @@ class YouTrackContextBuilder:
|
||||
# comments are optional — missing comments shouldn't kill the plan
|
||||
try:
|
||||
logger.debug(f"Lookup for comments")
|
||||
comments_raw = await self.mcp.call_tool(
|
||||
"get_issue_comments",
|
||||
{"issueId": issue_id, "limit": 10},
|
||||
)
|
||||
comments_raw = await self._fetch_all_comments(issue_id)
|
||||
logger.debug(f"Found {len(comments_raw)} comments")
|
||||
|
||||
logger.debug(json.dumps(comments_raw))
|
||||
@@ -59,19 +156,28 @@ class YouTrackContextBuilder:
|
||||
logger.debug(f"Lookup for project")
|
||||
logger.debug(project.model_dump_json())
|
||||
|
||||
comments_objs: list[IssueComment] = []
|
||||
for c in comments:
|
||||
author = c.get("author") or "unknown"
|
||||
body = c.get("text") or c.get("body") or ""
|
||||
parsed = TriageMarker.parse(body)
|
||||
comments_objs.append(
|
||||
IssueComment(
|
||||
id=c.get("url") or c.get("id") or "undefined",
|
||||
author=author,
|
||||
body=body,
|
||||
created_at=c.get("created") or c.get("createdAt") or 0,
|
||||
is_agent=is_agent_author(author) or parsed is not None,
|
||||
marker=({"decision": parsed.decision,
|
||||
"issue_hash": parsed.issue_hash} if parsed else {}),
|
||||
)
|
||||
)
|
||||
|
||||
return IssueContext(
|
||||
issue_id=_first(issue, "idReadable", "id") or issue_id,
|
||||
title=_first(issue, "summary", "title", "name", default=""),
|
||||
body=_first(issue, "description", "body", default=""),
|
||||
comments=[
|
||||
IssueComment(
|
||||
id=c.get("url") or "undefined",
|
||||
author=c.get("author") or "unknown",
|
||||
body=c.get("text") or c.get("body") or "",
|
||||
created_at=c.get("created") or c.get("createdAt") or 0,
|
||||
)
|
||||
for c in comments
|
||||
],
|
||||
comments=comments_objs,
|
||||
labels=[
|
||||
t["name"]
|
||||
for t in issue.get("tags", [])
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from agents.issue_triage.marker import (
|
||||
Gap, TriageMarker, find_marker_comment, is_agent_author,
|
||||
)
|
||||
from common.llm_client import LLMClient
|
||||
from contracts.IssueContext import IssueContext
|
||||
|
||||
logger = logging.getLogger("gap_parser")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def parse_replies(
|
||||
issue: IssueContext,
|
||||
marker: TriageMarker,
|
||||
llm: LLMClient,
|
||||
project_lang: str = "english",
|
||||
) -> TriageMarker:
|
||||
"""
|
||||
Обновляет статусы gaps на основе ответов пользователя.
|
||||
Если ответов нет — возвращает marker без изменений.
|
||||
"""
|
||||
marker_comment_id = find_marker_comment(issue, marker)
|
||||
if not marker_comment_id:
|
||||
return marker
|
||||
|
||||
# 1. Собираем всё, что написано ПОСЛЕ комментария с маркером, от людей
|
||||
replies: list[dict] = []
|
||||
found = False
|
||||
for c in issue.comments:
|
||||
if c.id == marker_comment_id:
|
||||
found = True
|
||||
continue
|
||||
if found and not is_agent_author(c.author):
|
||||
replies.append({"id": c.id, "text": c.body})
|
||||
|
||||
if not replies:
|
||||
return marker
|
||||
|
||||
# 2. Только gaps в статусе asked ждут ответа
|
||||
pending = [g for g in marker.gaps if g.status == "asked"]
|
||||
if not pending:
|
||||
return marker
|
||||
|
||||
# 3. LLM-матчер: какой ответ к какому gap относится
|
||||
prompt = f"""You are matching a user's replies to the questions an agent previously asked.
|
||||
|
||||
Open questions (JSON):
|
||||
{json.dumps([{"gap_id": g.gap_id, "question": g.question} for g in pending],
|
||||
ensure_ascii=False, indent=2)}
|
||||
|
||||
User replies (in chronological order):
|
||||
{json.dumps([{"text": r["text"]} for r in replies], ensure_ascii=False, indent=2)}
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"resolved": [
|
||||
{{"gap_id": "...", "answer": "<verbatim or paraphrase>", "status": "answered" | "unresolved"}}
|
||||
]
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- status="answered" if the reply substantively addresses the question.
|
||||
- status="unresolved" if the user explicitly said "I don't know", "no data", "skip".
|
||||
- If a reply does not clearly match any question, do NOT include it.
|
||||
- A single reply may resolve multiple questions if it clearly addresses them.
|
||||
- Answer in {project_lang}.
|
||||
"""
|
||||
try:
|
||||
raw = llm.chat_with_schema(
|
||||
prompt,
|
||||
{
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "GapReplyMatch",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resolved": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gap_id": {"type": "string"},
|
||||
"answer": {"type": "string"},
|
||||
"status": {"type": "string",
|
||||
"enum": ["answered", "unresolved"]},
|
||||
},
|
||||
"required": ["gap_id", "answer", "status"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["resolved"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception as exc:
|
||||
logger.warning("parse_replies LLM failed: %s", exc)
|
||||
return marker
|
||||
|
||||
by_id = {r["gap_id"]: r for r in data.get("resolved", [])}
|
||||
|
||||
new_gaps: list[Gap] = []
|
||||
for g in marker.gaps:
|
||||
if g.status == "asked" and g.gap_id in by_id:
|
||||
r = by_id[g.gap_id]
|
||||
g = Gap(
|
||||
gap_id=g.gap_id,
|
||||
source=g.source,
|
||||
slot=g.slot,
|
||||
question=g.question,
|
||||
impact=g.impact,
|
||||
status=r["status"],
|
||||
answer=r["answer"],
|
||||
ticket=g.ticket,
|
||||
asked_at_comment=g.asked_at_comment,
|
||||
)
|
||||
new_gaps.append(g)
|
||||
|
||||
return TriageMarker(
|
||||
issue_hash=marker.issue_hash,
|
||||
decision=marker.decision,
|
||||
capability=marker.capability,
|
||||
missing_caps=list(marker.missing_caps),
|
||||
gaps=new_gaps,
|
||||
questions_asked_count=marker.questions_asked_count,
|
||||
)
|
||||
+123
-25
@@ -1,7 +1,9 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Literal
|
||||
|
||||
from contracts.IssueContext import IssueContext
|
||||
@@ -9,42 +11,117 @@ from contracts.IssueContext import IssueContext
|
||||
logger = logging.getLogger("marker_agent")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
MARKER_PREFIX = "<!-- triage-agent:v1"
|
||||
MARKER_RE_V2 = re.compile(r"<!-- triage-agent:v2 (\{.*?\}) -->", re.DOTALL)
|
||||
MARKER_RE_V1 = re.compile(
|
||||
r"<!-- triage-agent:v1 issue-hash:(\w+) capability:(\w+) "
|
||||
r"clarity:(\w+) decision:(\w+) missing-caps:([\w:,\-/\s]*)\s*-->"
|
||||
)
|
||||
|
||||
GapSource = Literal["wiki", "task", "implementation", "external"]
|
||||
GapStatus = Literal["open", "asked", "answered", "unresolved", "wiki_gap", "assumed"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gap:
|
||||
gap_id: str
|
||||
source: GapSource
|
||||
slot: str
|
||||
question: str
|
||||
impact: float = 0.5
|
||||
status: GapStatus = "open"
|
||||
answer: str = ""
|
||||
ticket: str = "" # для wiki_gap: ID созданного тикета
|
||||
asked_at_comment: str = "" # ID комментария, где задан вопрос
|
||||
|
||||
@staticmethod
|
||||
def make_id(slot: str, entity: str, aspect: str) -> str:
|
||||
return hashlib.sha1(f"{slot}|{entity}|{aspect}".encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageMarker:
|
||||
issue_hash: str
|
||||
capability: Literal["COVERED", "GAP", "INSUFFICIENT_INFO"]
|
||||
clarity: Literal["CLEAR", "AMBIGUOUS", "AWAITING_REPLY"]
|
||||
decision: Literal["PLANNED", "BLOCKED_CAPABILITY", "BLOCKED_CLARITY"]
|
||||
issue_hash: str = ""
|
||||
decision: Literal[
|
||||
"PLANNED",
|
||||
"BLOCKED_CAPABILITY",
|
||||
"BLOCKED_CLARITY",
|
||||
"WIKI_GAP_RECORDED",
|
||||
"ESCALATED",
|
||||
] = "BLOCKED_CLARITY"
|
||||
capability: str = "COVERED"
|
||||
missing_caps: list[str] = field(default_factory=list)
|
||||
gaps: list[Gap] = field(default_factory=list)
|
||||
questions_asked_count: int = 0
|
||||
last_seen_comment_count: int = 0
|
||||
|
||||
# ---- rendering ----
|
||||
def render(self) -> str:
|
||||
caps = ",".join(self.missing_caps)
|
||||
payload = {
|
||||
"issue_hash": self.issue_hash,
|
||||
"decision": self.decision,
|
||||
"capability": self.capability,
|
||||
"missing_caps": self.missing_caps,
|
||||
"questions_asked_count": self.questions_asked_count,
|
||||
"last_seen_comment_count": self.last_seen_comment_count,
|
||||
"gaps": [asdict(g) for g in self.gaps],
|
||||
}
|
||||
return (
|
||||
f"{MARKER_PREFIX} issue-hash:{self.issue_hash} "
|
||||
f"capability:{self.capability} clarity:{self.clarity} "
|
||||
f"decision:{self.decision} missing-caps:{caps} -->"
|
||||
"<!-- triage-agent:v2 "
|
||||
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
+ " -->"
|
||||
)
|
||||
|
||||
# ---- parsing ----
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> "TriageMarker | None":
|
||||
import re
|
||||
m = re.search(
|
||||
r"<!-- triage-agent:v1 issue-hash:(\w+) capability:(\w+) "
|
||||
r"clarity:(\w+) decision:(\w+) missing-caps:([\w:,\-/\s]*)\s*-->",
|
||||
text,
|
||||
)
|
||||
if not m:
|
||||
m = MARKER_RE_V2.search(text or "")
|
||||
if m:
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("marker v2 json decode failed: %s", exc)
|
||||
return None
|
||||
caps = [c for c in m.group(5).split(",") if c]
|
||||
return cls(
|
||||
issue_hash=m.group(1),
|
||||
capability=m.group(2), # type: ignore
|
||||
clarity=m.group(3), # type: ignore
|
||||
decision=m.group(4), # type: ignore
|
||||
issue_hash=data.get("issue_hash", ""),
|
||||
decision=data.get("decision", "BLOCKED_CLARITY"),
|
||||
capability=data.get("capability", "COVERED"),
|
||||
missing_caps=list(data.get("missing_caps", [])),
|
||||
questions_asked_count=int(data.get("questions_asked_count", 0)),
|
||||
last_seen_comment_count=data.get("last_seen_comment_count", 0),
|
||||
gaps=[Gap(**g) for g in data.get("gaps", [])],
|
||||
)
|
||||
|
||||
m1 = MARKER_RE_V1.search(text or "")
|
||||
if m1:
|
||||
caps = [c for c in m1.group(5).split(",") if c]
|
||||
return cls(
|
||||
issue_hash=m1.group(1),
|
||||
capability=m1.group(2),
|
||||
decision={"PLANNED": "PLANNED", "BLOCKED_CAPABILITY": "BLOCKED_CAPABILITY"}
|
||||
.get(m1.group(4), "BLOCKED_CLARITY"),
|
||||
missing_caps=caps,
|
||||
)
|
||||
return None
|
||||
|
||||
# ---- helpers ----
|
||||
def open_gaps(self) -> list[Gap]:
|
||||
return [g for g in self.gaps if g.status in ("open", "asked")]
|
||||
|
||||
def gap_by_id(self) -> dict[str, Gap]:
|
||||
return {g.gap_id: g for g in self.gaps}
|
||||
|
||||
def with_gap(self, gap: Gap) -> "TriageMarker":
|
||||
"""Возвращает копию маркера с заменённым/добавленным gap по gap_id."""
|
||||
kept = [g for g in self.gaps if g.gap_id != gap.gap_id]
|
||||
kept.append(gap)
|
||||
return TriageMarker(
|
||||
issue_hash=self.issue_hash,
|
||||
decision=self.decision,
|
||||
capability=self.capability,
|
||||
missing_caps=list(self.missing_caps),
|
||||
gaps=kept,
|
||||
questions_asked_count=self.questions_asked_count,
|
||||
)
|
||||
|
||||
|
||||
def find_prior_marker(issue: IssueContext) -> TriageMarker | None:
|
||||
@@ -53,17 +130,38 @@ def find_prior_marker(issue: IssueContext) -> TriageMarker | None:
|
||||
m = TriageMarker.parse(c.body)
|
||||
if m:
|
||||
return m
|
||||
|
||||
logger.debug("No prior marker found")
|
||||
return None
|
||||
|
||||
|
||||
def find_marker_comment(issue: IssueContext, marker: TriageMarker) -> str | None:
|
||||
"""ID комментария, в котором лежит этот маркер (совпадение по body)."""
|
||||
rendered = marker.render()
|
||||
for c in reversed(issue.comments):
|
||||
if rendered in c.body:
|
||||
return c.id
|
||||
# fallback: по issue_hash + decision
|
||||
for c in reversed(issue.comments):
|
||||
m = TriageMarker.parse(c.body)
|
||||
if m and m.issue_hash == marker.issue_hash and m.decision == marker.decision:
|
||||
return c.id
|
||||
return None
|
||||
|
||||
|
||||
def is_agent_author(author: str) -> bool:
|
||||
return author == os.getenv("YOUTRACK_TRIAGE_AUTHOR", "__triage_agent__")
|
||||
|
||||
|
||||
def has_reporter_reply_since(issue: IssueContext, marker_comment_id: str) -> bool:
|
||||
"""
|
||||
True, если после комментария с маркером появился хотя бы один
|
||||
комментарий от НЕ-агента (т.е. от человека).
|
||||
Порядок определяется по позиции в issue.comments (уже хронологический).
|
||||
"""
|
||||
found = False
|
||||
for c in issue.comments:
|
||||
if c.id == marker_comment_id:
|
||||
found = True
|
||||
continue
|
||||
if found and c.author != os.getenv('YOUTRACK_TRIAGE_AUTHOR'):
|
||||
if found and not is_agent_author(c.author):
|
||||
return True
|
||||
return False
|
||||
+103
-28
@@ -1,63 +1,138 @@
|
||||
from dataclasses import field, dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
from agents.issue_triage.models import CapabilityResult, AmbiguityResult
|
||||
from agents.issue_triage.marker import Gap
|
||||
from agents.issue_triage.models import (
|
||||
AmbiguityFinding, AmbiguityResult, CapabilityResult,
|
||||
)
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
PROCEED_TO_SEQUENCE = auto()
|
||||
PROCEED_WITH_ASSUMPTIONS = auto()
|
||||
POST_CAPABILITY_BLOCKER = auto()
|
||||
POST_QUESTIONS = auto()
|
||||
SKIP = auto() # idempotent no-op
|
||||
RERUN = auto() # re-trigger fired, redo passes
|
||||
POST_WIKI_GAP = auto()
|
||||
ESCALATE_TO_HUMAN = auto()
|
||||
SKIP = auto()
|
||||
RERUN = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeDecision:
|
||||
action: Action
|
||||
reason: str
|
||||
# for POST_* actions, what to include
|
||||
missing_caps: list[str] = field(default_factory=list)
|
||||
gaps: list[Gap] = field(default_factory=list)
|
||||
questions: list[str] = field(default_factory=list)
|
||||
assumptions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _finding_to_gap(f: AmbiguityFinding) -> Gap:
|
||||
slot = f.slot or f.aspect
|
||||
return Gap(
|
||||
gap_id=Gap.make_id(slot, "", f.aspect),
|
||||
source=f.source,
|
||||
slot=slot,
|
||||
question=f.suggested_question,
|
||||
impact=f.impact,
|
||||
status="open",
|
||||
)
|
||||
|
||||
|
||||
def merge(
|
||||
cap: CapabilityResult,
|
||||
amb: AmbiguityResult,
|
||||
prior_gaps: dict[str, Gap] | None = None,
|
||||
max_questions: int = 3,
|
||||
) -> MergeDecision:
|
||||
# Insufficient info from either pass dominates — we don't have enough
|
||||
# to even classify. Ask questions, don't post a blocker.
|
||||
prior_gaps = prior_gaps or {}
|
||||
|
||||
if cap.status == "INSUFFICIENT_INFO":
|
||||
return MergeDecision(
|
||||
Action.POST_QUESTIONS,
|
||||
reason="capability pass needs more info",
|
||||
questions=cap.questions,
|
||||
)
|
||||
if amb.status == "AMBIGUOUS" and amb.blocking_findings():
|
||||
# blocking ambiguity masks capability — we may be asking about
|
||||
# capabilities the issue didn't actually require.
|
||||
return MergeDecision(
|
||||
Action.POST_QUESTIONS,
|
||||
reason="blocking ambiguity",
|
||||
questions=[f.suggested_question for f in amb.blocking_findings()],
|
||||
assumptions=amb.assumptions_planner_may_make,
|
||||
Action.POST_QUESTIONS, "capability needs info",
|
||||
questions=list(cap.questions),
|
||||
)
|
||||
|
||||
blocking = amb.blocking_findings()
|
||||
|
||||
# превращаем в Gap и отсеиваем уже отвеченные
|
||||
candidate_gaps: list[Gap] = []
|
||||
for f in blocking:
|
||||
g = _finding_to_gap(f)
|
||||
prev = prior_gaps.get(g.gap_id)
|
||||
if prev and prev.status in ("answered", "unresolved", "assumed", "wiki_gap"):
|
||||
continue
|
||||
candidate_gaps.append(g)
|
||||
|
||||
wiki_gaps = [g for g in candidate_gaps if g.source == "wiki"]
|
||||
task_gaps = [g for g in candidate_gaps
|
||||
if g.source == "task" and g.impact >= 0.3]
|
||||
impl_gaps = [g for g in candidate_gaps
|
||||
if g.source == "implementation" or g.impact < 0.3]
|
||||
external_gaps = [g for g in candidate_gaps if g.source == "external"]
|
||||
|
||||
asked_already = sum(1 for g in prior_gaps.values() if g.status == "asked")
|
||||
|
||||
# 1. External — эскалируем немедленно
|
||||
if external_gaps:
|
||||
return MergeDecision(
|
||||
Action.ESCALATE_TO_HUMAN,
|
||||
"external dependency",
|
||||
gaps=external_gaps,
|
||||
questions=[g.question for g in external_gaps],
|
||||
)
|
||||
|
||||
# 2. Task-gaps СНАЧАЛА — пока не исчерпан лимит вопросов.
|
||||
# Ответ пользователя может изменить оценку capabilities,
|
||||
# поэтому не блокируем раньше, чем спросим.
|
||||
if task_gaps and asked_already < max_questions:
|
||||
return MergeDecision(
|
||||
Action.POST_QUESTIONS,
|
||||
"blocking task ambiguity",
|
||||
gaps=task_gaps,
|
||||
questions=[g.question for g in task_gaps],
|
||||
assumptions=list(amb.assumptions_planner_may_make),
|
||||
)
|
||||
|
||||
# 3. Capability gap — теперь блокер
|
||||
# (либо task_gaps нет, либо лимит вопросов исчерпан)
|
||||
if cap.status == "GAP":
|
||||
return MergeDecision(
|
||||
Action.POST_CAPABILITY_BLOCKER,
|
||||
reason="capability gap",
|
||||
missing_caps=cap.missing_capabilities,
|
||||
# non-blocking ambiguity still goes into the plan later, but
|
||||
# we can't plan yet, so surface it in the blocker comment too
|
||||
questions=[f.suggested_question for f in amb.findings],
|
||||
"capability gap",
|
||||
missing_caps=list(cap.missing_capabilities),
|
||||
gaps=candidate_gaps,
|
||||
questions=[g.question for g in candidate_gaps],
|
||||
assumptions=list(amb.assumptions_planner_may_make),
|
||||
)
|
||||
|
||||
# cap == COVERED, clarity == CLEAR (or non-blocking ambiguous)
|
||||
# 4. Только wiki-gaps → создаём тикет, не спрашиваем пользователя
|
||||
if wiki_gaps and not task_gaps:
|
||||
return MergeDecision(
|
||||
Action.POST_WIKI_GAP,
|
||||
"wiki gaps only",
|
||||
gaps=wiki_gaps,
|
||||
assumptions=[f"Assume standard default for '{g.slot}'"
|
||||
for g in wiki_gaps + impl_gaps],
|
||||
)
|
||||
|
||||
# 5. Лимит вопросов достигнут → идём с допущениями
|
||||
if task_gaps and asked_already >= max_questions:
|
||||
return MergeDecision(
|
||||
Action.PROCEED_WITH_ASSUMPTIONS,
|
||||
"question limit reached",
|
||||
assumptions=list(amb.assumptions_planner_may_make)
|
||||
+ [f"Assume '{g.slot}' = (planner default)"
|
||||
for g in task_gaps + impl_gaps],
|
||||
)
|
||||
|
||||
# 6. Только implementation/low-impact → идём с допущениями
|
||||
return MergeDecision(
|
||||
Action.PROCEED_TO_SEQUENCE,
|
||||
reason="covered and clear",
|
||||
assumptions=amb.assumptions_planner_may_make,
|
||||
questions=[f.suggested_question for f in amb.findings if not f.blocking],
|
||||
"covered and clear",
|
||||
assumptions=list(amb.assumptions_planner_may_make)
|
||||
+ [f"Assume '{g.slot}' = (planner default)"
|
||||
for g in impl_gaps],
|
||||
questions=[g.question for g in impl_gaps],
|
||||
)
|
||||
@@ -39,6 +39,10 @@ class AmbiguityFinding(BaseModel):
|
||||
directed_to: str
|
||||
blocking: bool
|
||||
|
||||
source: Literal["wiki", "task", "implementation", "external"]
|
||||
impact: float
|
||||
slot: str
|
||||
|
||||
|
||||
class AmbiguityResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -170,6 +174,9 @@ def parse_ambiguity_result(raw: str | dict) -> AmbiguityResult:
|
||||
suggested_question=f.suggested_question,
|
||||
blocking=f.blocking,
|
||||
directed_to=f.directed_to,
|
||||
source=f.source,
|
||||
impact=f.impact,
|
||||
slot=f.slot,
|
||||
)
|
||||
for f in model.findings
|
||||
],
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from agents.issue_triage.helpers import has_cycle, longest_path, path_steps
|
||||
from agents.issue_triage.marker import TriageMarker
|
||||
from agents.issue_triage.merge import Action, merge, MergeDecision
|
||||
from agents.issue_triage.merge import MergeDecision
|
||||
from agents.issue_triage.models import CapabilityResult, AmbiguityResult, Plan
|
||||
from agents.registry import AgentRegistry
|
||||
from contracts.IssueContext import IssueContext
|
||||
from contracts.TaskPlan import TaskPlan
|
||||
|
||||
|
||||
def validate_plan(plan: Plan, registry):
|
||||
@@ -56,23 +52,88 @@ def validate_plan(plan: Plan, registry):
|
||||
|
||||
return errors
|
||||
|
||||
def render_blocker_comment(marker, cap, amb, decision) -> str:
|
||||
def render_blocker_comment(
|
||||
marker: TriageMarker,
|
||||
cap: CapabilityResult,
|
||||
amb: AmbiguityResult,
|
||||
decision: MergeDecision,
|
||||
) -> str:
|
||||
"""
|
||||
Комментарий к задаче: планирование невозможно, потому что нет агентов,
|
||||
покрывающих требуемые capabilities.
|
||||
|
||||
Дополнительно показываем ambiguity-пробелы и допущения, если они
|
||||
есть — чтобы у читателя был полный контекст, а не только capability-часть.
|
||||
"""
|
||||
lines = [
|
||||
marker.render(),
|
||||
"**Triage: BLOCKED — missing capabilities**",
|
||||
"",
|
||||
"This issue cannot be resolved with the currently registered agents.",
|
||||
"Planning is suspended until the gap is closed (new agent, or a "
|
||||
"decision to shrink the issue).",
|
||||
"",
|
||||
"### Capability objections",
|
||||
"",
|
||||
]
|
||||
|
||||
if cap.objections:
|
||||
for o in cap.objections:
|
||||
lines += [
|
||||
f"- **`{o.required_capability}`** needed for: {o.needed_for}",
|
||||
f"- **`{o.required_capability}`** — needed for: {o.needed_for}",
|
||||
f" - Why no agent covers it: {o.why_no_agent_covers_it}",
|
||||
f" - Substitution attempt: {o.substitution_attempt}",
|
||||
"",
|
||||
]
|
||||
else:
|
||||
lines += ["_(no specific objections recorded)_", ""]
|
||||
|
||||
if decision.missing_caps:
|
||||
lines.append(f"**Missing capabilities:** {', '.join(decision.missing_caps)}")
|
||||
lines += [
|
||||
f"**Missing capabilities:** "
|
||||
f"{', '.join(f'`{c}`' for c in decision.missing_caps)}",
|
||||
"",
|
||||
]
|
||||
|
||||
if decision.gaps:
|
||||
lines += [
|
||||
"### Also open (ambiguity)",
|
||||
"",
|
||||
"_These do not block on their own, but will need an answer "
|
||||
"once the capability gap is closed._",
|
||||
"",
|
||||
]
|
||||
for g in decision.gaps:
|
||||
lines += [
|
||||
f"- **`{g.slot}`** [`{g.source}`, impact {g.impact:.2f}] — "
|
||||
f"{g.question}",
|
||||
]
|
||||
lines.append("")
|
||||
|
||||
if decision.assumptions:
|
||||
lines += [
|
||||
"### Defaults the planner will assume",
|
||||
"",
|
||||
"_Applied only if the capability gap is resolved without an "
|
||||
"explicit answer to the ambiguity above._",
|
||||
"",
|
||||
*(f"- {a}" for a in decision.assumptions),
|
||||
"",
|
||||
]
|
||||
|
||||
if decision.questions and cap.status != "GAP":
|
||||
lines += [
|
||||
"### Questions",
|
||||
"",
|
||||
*(f"- {q}" for q in decision.questions),
|
||||
"",
|
||||
]
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"_Re-trigger triage after registering a new agent or trimming the "
|
||||
"issue scope. The marker above makes this idempotent._",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -125,6 +186,109 @@ def render_questions_comment(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_wiki_gap_comment(
|
||||
marker: TriageMarker,
|
||||
decision: MergeDecision,
|
||||
ticket_id: str,
|
||||
) -> str:
|
||||
"""
|
||||
Комментарий к задаче: агент обнаружил knowledge gap, создал тикет
|
||||
на документирование и продолжает работу с дефолтами.
|
||||
|
||||
Пользователя НЕ спрашивают — это не его зона ответственности.
|
||||
"""
|
||||
lines = [
|
||||
marker.render(),
|
||||
"**Triage: wiki gaps recorded — proceeding with defaults**",
|
||||
"",
|
||||
"The following questions are documentation-level and should be "
|
||||
"answered by the project wiki, not by the reporter. A ticket has "
|
||||
"been opened so the gap does not repeat on future issues.",
|
||||
"",
|
||||
]
|
||||
|
||||
for g in decision.gaps:
|
||||
lines += [
|
||||
f"### `{g.slot}`",
|
||||
"",
|
||||
f"- **Question:** {g.question}",
|
||||
f"- **Impact if left unspecified:** {g.impact:.2f}",
|
||||
f"- **Ticket:** `{ticket_id}`",
|
||||
"",
|
||||
]
|
||||
|
||||
if decision.assumptions:
|
||||
lines += [
|
||||
"### Defaults used for planning",
|
||||
"",
|
||||
*(f"- {a}" for a in decision.assumptions),
|
||||
"",
|
||||
]
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"_Planning proceeds with the defaults above. Update the wiki and "
|
||||
"re-trigger triage if a different answer is required._",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_escalation_comment(
|
||||
marker: TriageMarker,
|
||||
decision: MergeDecision,
|
||||
) -> str:
|
||||
"""
|
||||
Комментарий к задаче: агент не может планировать, потому что
|
||||
ответ лежит вне команды (юрист, вендор, рынок).
|
||||
|
||||
Агент останавливается и передаёт задачу человеку.
|
||||
"""
|
||||
lines = [
|
||||
marker.render(),
|
||||
"**Triage: ESCALATED — external input required**",
|
||||
"",
|
||||
"Planning cannot continue. The following questions depend on "
|
||||
"information the team does not own. A human owner must resolve "
|
||||
"them before triage can resume.",
|
||||
"",
|
||||
]
|
||||
|
||||
for g in decision.gaps:
|
||||
lines += [
|
||||
f"### `{g.slot}`",
|
||||
"",
|
||||
f"- **Source:** `{g.source}`",
|
||||
f"- **Impact if unset:** {g.impact:.2f}",
|
||||
f"- **Question:** {g.question}",
|
||||
"",
|
||||
]
|
||||
|
||||
if decision.questions:
|
||||
lines += [
|
||||
"### Who to ask",
|
||||
"",
|
||||
*(f"- {q}" for q in decision.questions),
|
||||
"",
|
||||
]
|
||||
|
||||
if decision.assumptions:
|
||||
lines += [
|
||||
"### Tentative defaults (NOT applied)",
|
||||
"",
|
||||
"_Recorded for reference only — the plan will not use them._",
|
||||
"",
|
||||
*(f"- {a}" for a in decision.assumptions),
|
||||
"",
|
||||
]
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"_After external input is provided, add a comment and re-trigger "
|
||||
"triage (remove the `ESCALATED` marker from this issue)._",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_validation_failure(
|
||||
marker: TriageMarker,
|
||||
plan: Plan,
|
||||
|
||||
@@ -22,3 +22,10 @@ LanguageLiteral = Literal[
|
||||
Language.CPP,
|
||||
Language.C,
|
||||
]
|
||||
|
||||
|
||||
def _normalize_language(raw: dict) -> dict:
|
||||
lang = raw.get("language")
|
||||
if isinstance(lang, dict) and isinstance(lang.get("name"), str):
|
||||
lang["name"] = lang["name"].strip().lower()
|
||||
return raw
|
||||
|
||||
@@ -2,34 +2,19 @@ from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from contracts.CodeLanguages import Language
|
||||
from contracts.base import _StrictModel
|
||||
|
||||
|
||||
class LanguageSnapshot(_StrictModel):
|
||||
"""
|
||||
The language the plan commits to, plus the toolchain it will use.
|
||||
Chosen by Plan from the target files; consumed by Act and Verify.
|
||||
"""
|
||||
name: str = Field(
|
||||
min_length=1,
|
||||
description="Language name, e.g. 'python', 'typescript', 'go'.",
|
||||
)
|
||||
name: Language = Field(description="Primary language the plan commits to.")
|
||||
version: str | None = Field(
|
||||
description="Language version if known, else null.",
|
||||
)
|
||||
test_runner: str = Field(
|
||||
min_length=1,
|
||||
description="How tests are run, e.g. 'pytest', 'vitest', 'go test'.",
|
||||
)
|
||||
formatter: str | None = Field(
|
||||
description="Formatter command, or null if none.",
|
||||
)
|
||||
linter: str | None = Field(
|
||||
description="Linter command, or null if none.",
|
||||
)
|
||||
detected_from: list[str] = Field(
|
||||
description="Marker files used to detect, e.g. ['pyproject.toml'].",
|
||||
description="Language version if known (e.g. '3.12'), else null.",
|
||||
)
|
||||
test_runner: str = Field(min_length=1)
|
||||
formatter: str | None = Field(description="Formatter command, or null.")
|
||||
linter: str | None = Field(description="Linter command, or null.")
|
||||
detected_from: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class PlanStep(_StrictModel):
|
||||
@@ -87,3 +72,14 @@ class PlanOutput(_StrictModel):
|
||||
raise ValueError(f"unsafe path: {f!r}")
|
||||
|
||||
return self
|
||||
|
||||
def wrap_ref_siblings(node):
|
||||
if isinstance(node, dict):
|
||||
if "$ref" in node and len(node) > 1:
|
||||
ref = node["$ref"]
|
||||
siblings = {k: wrap_ref_siblings(v) for k, v in node.items() if k != "$ref"}
|
||||
return {"anyOf": [{"$ref": ref}], **siblings}
|
||||
return {k: wrap_ref_siblings(v) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [wrap_ref_siblings(x) for x in node]
|
||||
return node
|
||||
|
||||
@@ -7,13 +7,10 @@ from pydantic import BaseModel
|
||||
|
||||
from agents.coders.BaseCoder import CoderAgent
|
||||
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
|
||||
from agents.issue_triage.context_builder import YouTrackContextBuilder
|
||||
from agents.registry import AgentRegistry
|
||||
from common.gitea_mcp_client import GiteaMCPClient
|
||||
from common.hot_env import load_env, reload_if_changed
|
||||
from common.llm_client import LLMClient
|
||||
from common.singleton import AgentSingleton
|
||||
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
|
||||
from common.youtrack_mcp_client import IssueNotFound
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,7 +21,7 @@ if not load_env(force=False):
|
||||
logger.warning("No .env file found at startup, will rely on os.environ")
|
||||
|
||||
|
||||
def env_optional(key: str, default: str | None = None) -> str:
|
||||
def env_optional(key: str, default: str | None = None) -> str | None:
|
||||
value = os.environ.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
Reference in New Issue
Block a user