GapDetector

This commit is contained in:
2026-09-26 12:50:19 +03:00
parent 7f15d0e0ac
commit a136451568
12 changed files with 1209 additions and 272 deletions
+6
View File
@@ -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 ### 2026-09-23
1. Add memory for Agent - memory dir. Not count timestamps, need work 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 2. Add FileSelection - file graph is converting to consumable table, works for small amount of files
+7 -5
View File
@@ -10,8 +10,10 @@ from pydantic import TypeAdapter, ValidationError
from agents.codebase_analyst.graph_tool import analyze_repo_with_cache 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.codebase_analyst.select_files import format_table, select_files, SelectFilesInput
from agents.coders.repo import ensure_repo_ready 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.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.RunContext import RunContext
from contracts.coders.VerifyOutput import FeasibleVerdict from contracts.coders.VerifyOutput import FeasibleVerdict
@@ -111,7 +113,7 @@ class PlanInput:
feasibility: FeasibleVerdict 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: if not inp.gather.issue_context.project or len(inp.gather.issue_context.project.repos) == 0:
raise "No project repo" raise "No project repo"
@@ -137,7 +139,7 @@ async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
print(selected_files) print(selected_files)
prompt = _build_user_prompt(inp, repo, 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( raw = run_ctx.llm.chat_with_schema(
prompt, prompt,
{ {
@@ -151,9 +153,9 @@ async def handle_plan(inp: PlanInput, *, run_ctx: RunContext) -> PlanOutput:
system=SYSTEM_PROMPT, system=SYSTEM_PROMPT,
) )
raw = _ensure_dict(raw) raw = _normalize_language(_ensure_dict(raw))
try: try:
return PlanAdapter.validate_python(raw) return PlanAdapter.validate_python(raw), repo
except ValidationError as e: except ValidationError as e:
raise PlanError(str(e)) from e raise PlanError(str(e)) from e
+503 -157
View File
@@ -5,12 +5,31 @@ from dataclasses import dataclass
from agents.issue_triage.context_builder import YouTrackContextBuilder from agents.issue_triage.context_builder import YouTrackContextBuilder
from agents.issue_triage.decomposer import IssueDecomposer 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.gap_parser import parse_replies
from agents.issue_triage.merge import Action, merge from agents.issue_triage.marker import (
from agents.issue_triage.models import CapabilityResult, AmbiguityResult, parse_capability_result, \ Gap,
parse_ambiguity_result, AmbiguityFinding, Plan, parse_task_plan TriageMarker,
from agents.issue_triage.triage import render_validation_failure, render_plan_comment, validate_plan, \ find_prior_marker, find_marker_comment, has_reporter_reply_since,
render_questions_comment, render_blocker_comment )
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 agents.registry import agent, AgentRegistry, Capability
from common.llm_client import LLMClient from common.llm_client import LLMClient
from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient
@@ -19,6 +38,9 @@ from contracts.IssueContext import IssueContext
logger = logging.getLogger("triage_agent") logger = logging.getLogger("triage_agent")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# ==== TriageContext ====
@dataclass @dataclass
class TriageContext: class TriageContext:
issue: IssueContext issue: IssueContext
@@ -26,107 +48,392 @@ class TriageContext:
triage_agent: "IssueTriageAgent" triage_agent: "IssueTriageAgent"
async def run_triage(ctx: TriageContext) -> TriageMarker: # ==== precheck ====
# --- Step 1: cheap deterministic gate ---
action, prior = precheck(ctx.issue, ctx.registry)
if action == Action.SKIP:
assert prior is not None
return prior
# --- Step 2: run both Phase A passes --- def _capability_now_available(cap_str: str, registry: AgentRegistry) -> bool:
# in production: concurrent; here sequential for clarity try:
cap = ctx.triage_agent.audit_capability(ctx.issue) cap = Capability(cap_str)
amb = ctx.triage_agent.audit_ambiguity(ctx.issue) except ValueError:
return False
return bool(registry.by_capability(cap))
# --- Step 3: merge ---
decision = merge(cap, amb)
# --- Step 4: act --- def precheck(
if decision.action == Action.POST_CAPABILITY_BLOCKER: issue: IssueContext,
marker = TriageMarker( registry: AgentRegistry,
issue_hash=ctx.issue.body_hash(), prior: TriageMarker | None,
capability=cap.status, ) -> tuple[Action, TriageMarker | None]:
clarity=amb.status, """
decision="BLOCKED_CAPABILITY", Cheap deterministic gate before any LLM work.
missing_caps=decision.missing_caps,
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( await ctx.triage_agent.youtrack_mcp.call_tool(
"add_issue_comment", "add_issue_comment",
{ {
"issueId": ctx.issue.issue_id, "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), "text": render_blocker_comment(marker, cap, amb, decision),
}, },
) )
return marker return marker
if decision.action == Action.POST_QUESTIONS: 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( marker = TriageMarker(
issue_hash=ctx.issue.body_hash(), issue_hash=issue.body_hash(),
capability=cap.status,
clarity="AWAITING_REPLY",
decision="BLOCKED_CLARITY", 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( await ctx.triage_agent.youtrack_mcp.call_tool(
"add_issue_comment", "add_issue_comment",
{ {
"issueId": ctx.issue.issue_id, "issueId": issue.issue_id,
"text": render_questions_comment(marker, cap, amb, decision), "text": render_questions_comment(marker, cap, amb, decision),
}, },
) )
return marker return marker
if decision.action == Action.PROCEED_TO_SEQUENCE: if decision.action == Action.POST_WIKI_GAP:
plan = ctx.triage_agent.sequence( try:
ctx.issue, ctx.registry, ticket_raw = await ctx.triage_agent.youtrack_mcp.call_tool(
assumptions=decision.assumptions, "add_issue",
) {
# validate before posting — see previous message "project": os.getenv("YOUTRACK_DOC_PROJECT", "DOC"),
errors = validate_plan(plan, ctx.registry) "summary": f"[wiki-gap] {issue.title}",
if errors: "description": "\n".join(
plan = ctx.triage_agent.sequence( # one retry with errors fed back f"- {g.slot}: {g.question}" for g in decision.gaps
ctx.issue, ctx.registry, ),
assumptions=decision.assumptions + [f"FIX: {e}" for e in errors], },
) )
errors = validate_plan(plan, ctx.registry) ticket_id = str(ticket_raw)[:80]
if errors: except Exception as exc:
marker = TriageMarker( logger.warning("add_issue failed: %s", exc)
issue_hash=ctx.issue.body_hash(), ticket_id = "(ticket creation failed)"
capability="COVERED",
clarity=amb.status, new_gaps = [
decision="BLOCKED_CAPABILITY", Gap(
missing_caps=["plan-validation-failed"], gap_id=g.gap_id,
) source=g.source,
await ctx.triage_agent.youtrack_mcp.call_tool( slot=g.slot,
"add_issue_comment", question=g.question,
{ impact=g.impact,
"issueId": ctx.issue.issue_id, status="wiki_gap",
"text": render_validation_failure(marker, plan, errors), ticket=ticket_id,
}, )
) for g in decision.gaps
return marker ]
marker = TriageMarker( marker = TriageMarker(
issue_hash=ctx.issue.body_hash(), issue_hash=issue.body_hash(),
capability="COVERED", decision="WIKI_GAP_RECORDED",
clarity=amb.status, capability=cap.status,
decision="PLANNED", gaps=base_gaps + new_gaps,
questions_asked_count=asked_count,
) )
marker = _stamp(marker, issue)
await ctx.triage_agent.youtrack_mcp.call_tool( await ctx.triage_agent.youtrack_mcp.call_tool(
"add_issue_comment", "add_issue_comment",
{ {
"issueId": ctx.issue.issue_id, "issueId": issue.issue_id,
"text": render_plan_comment(marker, plan, decision.assumptions, decision.questions), "text": render_wiki_gap_comment(marker, decision, ticket_id),
}, },
) )
# handoff to executor — enqueue, don't block return await _do_sequence(ctx, marker, decision)
# ctx.tracker.enqueue_execution(ctx.issue.id, plan)
return marker 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}") raise AssertionError(f"unhandled action: {decision.action}")
# ==== prompts (без изменений) ====
CAPABILITY_PROMPT = """ CAPABILITY_PROMPT = """
You are an adversarial capability auditor for an autonomous coding system. 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): Invalid objections (do NOT produce these):
- Vague worries ("this might be complex"). - Vague worries ("this might be complex").
- Missing information ("I don't know the repo"). That's a separate - 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 - Capabilities that ARE covered but that you'd prefer were covered
by a different agent. by a different agent.
@@ -231,7 +538,10 @@ valid, common, and correct answer.
"interpretation_b": "<the other concrete reading>", "interpretation_b": "<the other concrete reading>",
"why_it_matters": "<what the plan does differently under each>", "why_it_matters": "<what the plan does differently under each>",
"suggested_question": "<one sentence to post to the reporter>", "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": [ "assumptions_planner_may_make": [
@@ -240,6 +550,16 @@ valid, common, and correct answer.
"questions": ["<deduped suggested_question for blocking findings>"] "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}} Interpretation, aspect, why it matters, suggested_question and assumptions_planner_may_make should be written in project language: {{COMMENT_LANGUAGE}}
## ROUTING ## ROUTING
@@ -259,7 +579,7 @@ SEQUENCE_PROMPT = """\
You are a task sequencing planner for an autonomous coding system. 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 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. in the correct order, resolves the issue.
You are NOT deciding whether the task is possible. That decision has 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. 1. You may ONLY use agents whose `id` appears in AVAILABLE AGENTS.
Never invent agents. Never use placeholder ids like "TODO". Never invent agents. Never use placeholder ids like "TODO".
2. Every step must have a unique `step_id` (s01, s02, ...). 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 empty. Declare every dependency, including transitive ones you rely
on for data. Missing a dependency is worse than an unnecessary one. on for data. Missing a dependency is worse than an unnecessary one.
4. The dependency graph must be acyclic. 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. a group must have identical `depends_on` sets and no inter-dependencies.
- `critical_path` is the longest dependency chain by `estimate_minutes.p50`. - `critical_path` is the longest dependency chain by `estimate_minutes.p50`.
- `total_estimate_minutes.p50` is the sum of p50 along the critical path - `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. Compute it; do not guess.
- `total_estimate_minutes.p90` is the sum of p90 along the p90-weighted - `total_estimate_minutes.p90` is the sum of p90 along the p90-weighted
critical path. This path may differ from the p50 one. 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 Do not add ceremony steps (planning, review, cleanup) unless the
issue explicitly asks for them or an agent is dedicated to them. issue explicitly asks for them or an agent is dedicated to them.
- Prefer narrow, verifiable steps over one large step. - 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. must depend on the other.
- A "review" or "gate" step is only valid if an agent whose job is - 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. review/gating is listed in AVAILABLE AGENTS. Otherwise do not add one.
@@ -378,7 +698,7 @@ Field notes:
## EXAMPLES ## EXAMPLES
### Example 1 — single-step plan ### Example 1 ? single-step plan
Issue: "Add a `--verbose` flag to the CLI that prints extra diagnostics." Issue: "Add a `--verbose` flag to the CLI that prints extra diagnostics."
@@ -411,7 +731,7 @@ Correct output:
"missing_capabilities": [] "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." Issue: "Fix the off-by-one in pagination and add a regression test."
@@ -455,11 +775,11 @@ Correct output:
"missing_capabilities": [] "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 asks to *add* a test, which is code-writing work. Do not force agents
into plans just because they exist. 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." 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. 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( @agent(
name="IssueTriageAgent", name="IssueTriageAgent",
@@ -550,7 +816,13 @@ def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, Tria
priority=10, priority=10,
) )
class IssueTriageAgent: 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.name = "IssueTriageAgent"
self.ctx_builder = context_builder self.ctx_builder = context_builder
self.decomposer = IssueDecomposer(llm) self.decomposer = IssueDecomposer(llm)
@@ -558,7 +830,6 @@ class IssueTriageAgent:
self.agent_registry = agent_registry self.agent_registry = agent_registry
self.youtrack_mcp = youtrack_mcp self.youtrack_mcp = youtrack_mcp
async def plan_issue(self, issue_id: str) -> TriageMarker: async def plan_issue(self, issue_id: str) -> TriageMarker:
issue_ctx = await self.ctx_builder.build(issue_id) issue_ctx = await self.ctx_builder.build(issue_id)
@@ -571,16 +842,27 @@ class IssueTriageAgent:
self, self,
)) ))
def audit_capability( def audit_capability(self, issue: IssueContext) -> CapabilityResult:
self, issue: IssueContext
) -> CapabilityResult:
cloned_issue = issue cloned_issue = issue
cloned_issue.comments = [c for c in cloned_issue.comments if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] cloned_issue.comments = [
prompt = (CAPABILITY_PROMPT c for c in cloned_issue.comments
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text()) if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english") ]
.replace("{{ISSUE}}", cloned_issue.to_prompt_text()) prompt = (
.replace("{{TEAM}}", "\n".join([f"- `{m.nick}` — {m.role}" for m in issue.project.team] if issue.project else [])) 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) logger.debug(prompt)
raw = self.llm.chat_with_schema( raw = self.llm.chat_with_schema(
@@ -596,22 +878,31 @@ class IssueTriageAgent:
) )
return parse_capability_result(raw) return parse_capability_result(raw)
def audit_ambiguity( def audit_ambiguity(self, issue: IssueContext) -> AmbiguityResult:
self, issue: IssueContext
) -> AmbiguityResult:
cloned_issue = issue cloned_issue = issue
cloned_issue.comments = [c for c in cloned_issue.comments if cloned_issue.comments = [
c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")] c for c in cloned_issue.comments
if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")
]
prompt = ( prompt = (
AMBIGUITY_PROMPT AMBIGUITY_PROMPT
.replace("{{ISSUE}}", cloned_issue.to_prompt_text()) .replace("{{ISSUE}}", cloned_issue.to_prompt_text())
.replace("{{AGENTS}}", self.agent_registry.to_prompt_text()) .replace("{{AGENTS}}", self.agent_registry.to_prompt_text())
.replace("{{COMMENT_LANGUAGE}}", issue.project.project_language if issue.project else "english") .replace(
"{{COMMENT_LANGUAGE}}",
issue.project.project_language if issue.project else "english",
)
.replace( .replace(
"{{PROJECT}}", "{{PROJECT}}",
issue.project.to_prompt_text() if issue.project else "", issue.project.to_prompt_text() if issue.project else "",
) )
.replace("{{TEAM}}", "\n".join([f"- `{m.nick}` — {m.role}" for m in issue.project.team] if issue.project else [])) .replace(
"{{TEAM}}",
"\n".join(
[f"- `{m.nick}` ? {m.role}" for m in issue.project.team]
if issue.project else []
),
)
) )
logger.debug(prompt) logger.debug(prompt)
raw = self.llm.chat_with_schema( raw = self.llm.chat_with_schema(
@@ -628,7 +919,7 @@ class IssueTriageAgent:
try: try:
return parse_ambiguity_result(raw) return parse_ambiguity_result(raw)
except ValueError as exc: 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( return AmbiguityResult(
status="AMBIGUOUS", status="AMBIGUOUS",
findings=[ findings=[
@@ -639,21 +930,37 @@ class IssueTriageAgent:
why_it_matters="triage agent could not interpret its own analysis", why_it_matters="triage agent could not interpret its own analysis",
suggested_question="triage agent failed to analyze this issue; please review manually", suggested_question="triage agent failed to analyze this issue; please review manually",
blocking=True, 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( def sequence(
self, issue: IssueContext, registry: AgentRegistry, self,
issue: IssueContext,
registry: AgentRegistry,
assumptions: list[str], assumptions: list[str],
) -> Plan: ) -> Plan:
prompt = ( prompt = (
SEQUENCE_PROMPT SEQUENCE_PROMPT
.replace("{{ISSUE}}", issue.to_prompt_text()) .replace("{{ISSUE}}", issue.to_prompt_text())
.replace("{{AGENTS}}", registry.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(
.replace("{{PROJECT}}", issue.project.to_prompt_text() if issue.project else "") "{{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( raw = self.llm.chat_with_schema(
prompt, prompt,
@@ -667,3 +974,42 @@ class IssueTriageAgent:
}, },
) )
return parse_task_plan(raw) 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
+119 -13
View File
@@ -6,6 +6,7 @@ import re
import httpx import httpx
from agents.issue_triage.marker import is_agent_author, TriageMarker
from agents.issue_triage.project_loader import load_project from agents.issue_triage.project_loader import load_project
from common.as_type import _as_dict, _as_list, _first, _extract_base64 from common.as_type import _as_dict, _as_list, _first, _extract_base64
from common.youtrack_mcp_client import IssueNotFound from common.youtrack_mcp_client import IssueNotFound
@@ -25,6 +26,105 @@ class YouTrackContextBuilder:
self.mcp = mcp_client self.mcp = mcp_client
self.http = http_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: async def build(self, issue_id: str) -> IssueContext | None:
try: try:
issue_raw = await self.mcp.call_tool( issue_raw = await self.mcp.call_tool(
@@ -42,10 +142,7 @@ class YouTrackContextBuilder:
# comments are optional — missing comments shouldn't kill the plan # comments are optional — missing comments shouldn't kill the plan
try: try:
logger.debug(f"Lookup for comments") logger.debug(f"Lookup for comments")
comments_raw = await self.mcp.call_tool( comments_raw = await self._fetch_all_comments(issue_id)
"get_issue_comments",
{"issueId": issue_id, "limit": 10},
)
logger.debug(f"Found {len(comments_raw)} comments") logger.debug(f"Found {len(comments_raw)} comments")
logger.debug(json.dumps(comments_raw)) logger.debug(json.dumps(comments_raw))
@@ -59,19 +156,28 @@ class YouTrackContextBuilder:
logger.debug(f"Lookup for project") logger.debug(f"Lookup for project")
logger.debug(project.model_dump_json()) 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( return IssueContext(
issue_id=_first(issue, "idReadable", "id") or issue_id, issue_id=_first(issue, "idReadable", "id") or issue_id,
title=_first(issue, "summary", "title", "name", default=""), title=_first(issue, "summary", "title", "name", default=""),
body=_first(issue, "description", "body", default=""), body=_first(issue, "description", "body", default=""),
comments=[ comments=comments_objs,
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
],
labels=[ labels=[
t["name"] t["name"]
for t in issue.get("tags", []) for t in issue.get("tags", [])
+133
View File
@@ -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,
)
+127 -29
View File
@@ -1,7 +1,9 @@
import hashlib
import json import json
import logging import logging
import os import os
from dataclasses import dataclass, field import re
from dataclasses import dataclass, field, asdict
from typing import Literal from typing import Literal
from contracts.IssueContext import IssueContext from contracts.IssueContext import IssueContext
@@ -9,41 +11,116 @@ from contracts.IssueContext import IssueContext
logger = logging.getLogger("marker_agent") logger = logging.getLogger("marker_agent")
logger.setLevel(logging.DEBUG) 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 @dataclass
class TriageMarker: class TriageMarker:
issue_hash: str issue_hash: str = ""
capability: Literal["COVERED", "GAP", "INSUFFICIENT_INFO"] decision: Literal[
clarity: Literal["CLEAR", "AMBIGUOUS", "AWAITING_REPLY"] "PLANNED",
decision: Literal["PLANNED", "BLOCKED_CAPABILITY", "BLOCKED_CLARITY"] "BLOCKED_CAPABILITY",
"BLOCKED_CLARITY",
"WIKI_GAP_RECORDED",
"ESCALATED",
] = "BLOCKED_CLARITY"
capability: str = "COVERED"
missing_caps: list[str] = field(default_factory=list) 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: 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 ( return (
f"{MARKER_PREFIX} issue-hash:{self.issue_hash} " "<!-- triage-agent:v2 "
f"capability:{self.capability} clarity:{self.clarity} " + json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
f"decision:{self.decision} missing-caps:{caps} -->" + " -->"
) )
# ---- parsing ----
@classmethod @classmethod
def parse(cls, text: str) -> "TriageMarker | None": def parse(cls, text: str) -> "TriageMarker | None":
import re m = MARKER_RE_V2.search(text or "")
m = re.search( if m:
r"<!-- triage-agent:v1 issue-hash:(\w+) capability:(\w+) " try:
r"clarity:(\w+) decision:(\w+) missing-caps:([\w:,\-/\s]*)\s*-->", data = json.loads(m.group(1))
text, except json.JSONDecodeError as exc:
) logger.warning("marker v2 json decode failed: %s", exc)
if not m: return None
return None return cls(
caps = [c for c in m.group(5).split(",") if c] issue_hash=data.get("issue_hash", ""),
return cls( decision=data.get("decision", "BLOCKED_CLARITY"),
issue_hash=m.group(1), capability=data.get("capability", "COVERED"),
capability=m.group(2), # type: ignore missing_caps=list(data.get("missing_caps", [])),
clarity=m.group(3), # type: ignore questions_asked_count=int(data.get("questions_asked_count", 0)),
decision=m.group(4), # type: ignore last_seen_comment_count=data.get("last_seen_comment_count", 0),
missing_caps=caps, 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) m = TriageMarker.parse(c.body)
if m: if m:
return m return m
logger.debug("No prior marker found")
return None 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: def has_reporter_reply_since(issue: IssueContext, marker_comment_id: str) -> bool:
"""
True, если после комментария с маркером появился хотя бы один
комментарий от НЕ-агента (т.е. от человека).
Порядок определяется по позиции в issue.comments (уже хронологический).
"""
found = False found = False
for c in issue.comments: for c in issue.comments:
if c.id == marker_comment_id: if c.id == marker_comment_id:
found = True found = True
continue continue
if found and c.author != os.getenv('YOUTRACK_TRIAGE_AUTHOR'): if found and not is_agent_author(c.author):
return True return True
return False return False
+103 -28
View File
@@ -1,63 +1,138 @@
from dataclasses import field, dataclass from dataclasses import dataclass, field
from enum import Enum, auto 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): class Action(Enum):
PROCEED_TO_SEQUENCE = auto() PROCEED_TO_SEQUENCE = auto()
PROCEED_WITH_ASSUMPTIONS = auto()
POST_CAPABILITY_BLOCKER = auto() POST_CAPABILITY_BLOCKER = auto()
POST_QUESTIONS = auto() POST_QUESTIONS = auto()
SKIP = auto() # idempotent no-op POST_WIKI_GAP = auto()
RERUN = auto() # re-trigger fired, redo passes ESCALATE_TO_HUMAN = auto()
SKIP = auto()
RERUN = auto()
@dataclass @dataclass
class MergeDecision: class MergeDecision:
action: Action action: Action
reason: str reason: str
# for POST_* actions, what to include
missing_caps: list[str] = field(default_factory=list) missing_caps: list[str] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
questions: list[str] = field(default_factory=list) questions: list[str] = field(default_factory=list)
assumptions: 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( def merge(
cap: CapabilityResult, cap: CapabilityResult,
amb: AmbiguityResult, amb: AmbiguityResult,
prior_gaps: dict[str, Gap] | None = None,
max_questions: int = 3,
) -> MergeDecision: ) -> MergeDecision:
# Insufficient info from either pass dominates — we don't have enough prior_gaps = prior_gaps or {}
# to even classify. Ask questions, don't post a blocker.
if cap.status == "INSUFFICIENT_INFO": if cap.status == "INSUFFICIENT_INFO":
return MergeDecision( return MergeDecision(
Action.POST_QUESTIONS, Action.POST_QUESTIONS, "capability needs info",
reason="capability pass needs more info", questions=list(cap.questions),
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,
) )
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": if cap.status == "GAP":
return MergeDecision( return MergeDecision(
Action.POST_CAPABILITY_BLOCKER, Action.POST_CAPABILITY_BLOCKER,
reason="capability gap", "capability gap",
missing_caps=cap.missing_capabilities, missing_caps=list(cap.missing_capabilities),
# non-blocking ambiguity still goes into the plan later, but gaps=candidate_gaps,
# we can't plan yet, so surface it in the blocker comment too questions=[g.question for g in candidate_gaps],
questions=[f.suggested_question for f in amb.findings], 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( return MergeDecision(
Action.PROCEED_TO_SEQUENCE, Action.PROCEED_TO_SEQUENCE,
reason="covered and clear", "covered and clear",
assumptions=amb.assumptions_planner_may_make, assumptions=list(amb.assumptions_planner_may_make)
questions=[f.suggested_question for f in amb.findings if not f.blocking], + [f"Assume '{g.slot}' = (planner default)"
for g in impl_gaps],
questions=[g.question for g in impl_gaps],
) )
+7
View File
@@ -39,6 +39,10 @@ class AmbiguityFinding(BaseModel):
directed_to: str directed_to: str
blocking: bool blocking: bool
source: Literal["wiki", "task", "implementation", "external"]
impact: float
slot: str
class AmbiguityResult(BaseModel): class AmbiguityResult(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -170,6 +174,9 @@ def parse_ambiguity_result(raw: str | dict) -> AmbiguityResult:
suggested_question=f.suggested_question, suggested_question=f.suggested_question,
blocking=f.blocking, blocking=f.blocking,
directed_to=f.directed_to, directed_to=f.directed_to,
source=f.source,
impact=f.impact,
slot=f.slot,
) )
for f in model.findings for f in model.findings
], ],
+176 -12
View File
@@ -1,13 +1,9 @@
import json import json
from dataclasses import dataclass, asdict
from agents.issue_triage.helpers import has_cycle, longest_path, path_steps from agents.issue_triage.helpers import has_cycle, longest_path, path_steps
from agents.issue_triage.marker import TriageMarker from agents.issue_triage.marker import TriageMarker
from agents.issue_triage.merge import Action, merge, MergeDecision from agents.issue_triage.merge import MergeDecision
from agents.issue_triage.models import CapabilityResult, AmbiguityResult, Plan 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): def validate_plan(plan: Plan, registry):
@@ -56,23 +52,88 @@ def validate_plan(plan: Plan, registry):
return errors 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 = [ lines = [
marker.render(), marker.render(),
"**Triage: BLOCKED — missing capabilities**", "**Triage: BLOCKED — missing capabilities**",
"", "",
"This issue cannot be resolved with the currently registered agents.", "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 += [ lines += [
f"- **`{o.required_capability}`** needed for: {o.needed_for}", f"**Missing capabilities:** "
f" - Why no agent covers it: {o.why_no_agent_covers_it}", f"{', '.join(f'`{c}`' for c in decision.missing_caps)}",
f" - Substitution attempt: {o.substitution_attempt}",
"", "",
] ]
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) return "\n".join(lines)
@@ -125,6 +186,109 @@ def render_questions_comment(
return "\n".join(lines) 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( def render_validation_failure(
marker: TriageMarker, marker: TriageMarker,
plan: Plan, plan: Plan,
+7
View File
@@ -22,3 +22,10 @@ LanguageLiteral = Literal[
Language.CPP, Language.CPP,
Language.C, 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
+19 -23
View File
@@ -2,34 +2,19 @@ from typing import Literal
from pydantic import Field, model_validator from pydantic import Field, model_validator
from contracts.CodeLanguages import Language
from contracts.base import _StrictModel from contracts.base import _StrictModel
class LanguageSnapshot(_StrictModel): class LanguageSnapshot(_StrictModel):
""" name: Language = Field(description="Primary language the plan commits to.")
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'.",
)
version: str | None = Field( version: str | None = Field(
description="Language version if known, else null.", description="Language version if known (e.g. '3.12'), 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'].",
) )
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): class PlanStep(_StrictModel):
@@ -86,4 +71,15 @@ class PlanOutput(_StrictModel):
if f.startswith("/") or ".." in f.split("/"): if f.startswith("/") or ".." in f.split("/"):
raise ValueError(f"unsafe path: {f!r}") raise ValueError(f"unsafe path: {f!r}")
return self 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
+2 -5
View File
@@ -7,13 +7,10 @@ from pydantic import BaseModel
from agents.coders.BaseCoder import CoderAgent from agents.coders.BaseCoder import CoderAgent
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent 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.gitea_mcp_client import GiteaMCPClient
from common.hot_env import load_env, reload_if_changed from common.hot_env import load_env, reload_if_changed
from common.llm_client import LLMClient
from common.singleton import AgentSingleton 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) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) 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") 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) value = os.environ.get(key)
if value is None: if value is None:
return default return default