From a136451568fbd871276de1a9befb2f0831636f24 Mon Sep 17 00:00:00 2001 From: za-ek <1@zaek.eu> Date: Sat, 26 Sep 2026 12:50:19 +0300 Subject: [PATCH] GapDetector --- CHANGELOG.md | 6 + agents/coders/steps/plan.py | 12 +- agents/issue_triage/IssueTriageAgent.py | 660 ++++++++++++++++++------ agents/issue_triage/context_builder.py | 132 ++++- agents/issue_triage/gap_parser.py | 133 +++++ agents/issue_triage/marker.py | 156 ++++-- agents/issue_triage/merge.py | 131 ++++- agents/issue_triage/models.py | 7 + agents/issue_triage/triage.py | 188 ++++++- contracts/CodeLanguages.py | 7 + contracts/coders/PlanOutput.py | 42 +- main.py | 7 +- 12 files changed, 1209 insertions(+), 272 deletions(-) create mode 100644 agents/issue_triage/gap_parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b5ba5..d15230e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/agents/coders/steps/plan.py b/agents/coders/steps/plan.py index e8098e7..e945724 100644 --- a/agents/coders/steps/plan.py +++ b/agents/coders/steps/plan.py @@ -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 diff --git a/agents/issue_triage/IssueTriageAgent.py b/agents/issue_triage/IssueTriageAgent.py index 04f2312..be8cb4c 100644 --- a/agents/issue_triage/IssueTriageAgent.py +++ b/agents/issue_triage/IssueTriageAgent.py @@ -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, + ) + 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], ) - 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: + 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: + 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=ctx.issue.body_hash(), - capability=cap.status, - clarity="AWAITING_REPLY", + issue_hash=issue.body_hash(), decision="BLOCKED_CLARITY", - missing_caps=cap.missing_capabilities, + 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], + if decision.action == Action.POST_WIKI_GAP: + try: + ticket_raw = await ctx.triage_agent.youtrack_mcp.call_tool( + "add_issue", + { + "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 + ), + }, ) - 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", - { - "issueId": ctx.issue.issue_id, - "text": render_validation_failure(marker, plan, errors), - }, - ) - 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": "", "why_it_matters": "", "suggested_question": "", - "blocking": true | false + "blocking": true | false, + "source": "wiki" | "task" | "implementation" | "external", + "impact": 0.0, + "slot": "" } ], "assumptions_planner_may_make": [ @@ -240,6 +550,16 @@ valid, common, and correct answer. "questions": [""] } +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 - .replace("{{AGENTS}}", self.agent_registry.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 [])) + 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("{{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( @@ -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 + diff --git a/agents/issue_triage/context_builder.py b/agents/issue_triage/context_builder.py index a4c4154..06a810f 100644 --- a/agents/issue_triage/context_builder.py +++ b/agents/issue_triage/context_builder.py @@ -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", []) diff --git a/agents/issue_triage/gap_parser.py b/agents/issue_triage/gap_parser.py new file mode 100644 index 0000000..e5110dc --- /dev/null +++ b/agents/issue_triage/gap_parser.py @@ -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": "", "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, + ) \ No newline at end of file diff --git a/agents/issue_triage/marker.py b/agents/issue_triage/marker.py index cadcab7..9d5ae63 100644 --- a/agents/issue_triage/marker.py +++ b/agents/issue_triage/marker.py @@ -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,41 +11,116 @@ from contracts.IssueContext import IssueContext logger = logging.getLogger("marker_agent") logger.setLevel(logging.DEBUG) -MARKER_PREFIX = "", re.DOTALL) +MARKER_RE_V1 = re.compile( + r"" +) + +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} -->" + "" ) + # ---- parsing ---- @classmethod def parse(cls, text: str) -> "TriageMarker | None": - import re - m = re.search( - r"", - text, - ) - if not m: - 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 - missing_caps=caps, + 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 + return cls( + 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, ) @@ -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 \ No newline at end of file + return False diff --git a/agents/issue_triage/merge.py b/agents/issue_triage/merge.py index 5eea91e..01971c0 100644 --- a/agents/issue_triage/merge.py +++ b/agents/issue_triage/merge.py @@ -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], ) \ No newline at end of file diff --git a/agents/issue_triage/models.py b/agents/issue_triage/models.py index d513c95..0b0a6dc 100644 --- a/agents/issue_triage/models.py +++ b/agents/issue_triage/models.py @@ -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 ], diff --git a/agents/issue_triage/triage.py b/agents/issue_triage/triage.py index bfeb367..35fe41c 100644 --- a/agents/issue_triage/triage.py +++ b/agents/issue_triage/triage.py @@ -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", "", ] - for o in cap.objections: + + if cap.objections: + for o in cap.objections: + lines += [ + 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 += [ - 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}", + f"**Missing capabilities:** " + f"{', '.join(f'`{c}`' for c in decision.missing_caps)}", "", ] - if decision.missing_caps: - lines.append(f"**Missing capabilities:** {', '.join(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, diff --git a/contracts/CodeLanguages.py b/contracts/CodeLanguages.py index 9b33b16..003fbcb 100644 --- a/contracts/CodeLanguages.py +++ b/contracts/CodeLanguages.py @@ -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 diff --git a/contracts/coders/PlanOutput.py b/contracts/coders/PlanOutput.py index f4bd83b..d8800b1 100644 --- a/contracts/coders/PlanOutput.py +++ b/contracts/coders/PlanOutput.py @@ -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): @@ -86,4 +71,15 @@ class PlanOutput(_StrictModel): if f.startswith("/") or ".." in f.split("/"): raise ValueError(f"unsafe path: {f!r}") - return self \ No newline at end of file + 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 diff --git a/main.py b/main.py index 3051ed3..97add06 100644 --- a/main.py +++ b/main.py @@ -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