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
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
+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.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
+503 -157
View File
@@ -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": "<the other concrete reading>",
"why_it_matters": "<what the plan does differently under each>",
"suggested_question": "<one sentence to post to the reporter>",
"blocking": true | false
"blocking": true | false,
"source": "wiki" | "task" | "implementation" | "external",
"impact": 0.0,
"slot": "<short stable tag>"
}
],
"assumptions_planner_may_make": [
@@ -240,6 +550,16 @@ valid, common, and correct answer.
"questions": ["<deduped suggested_question for blocking findings>"]
}
Rules for the new fields:
- source:
* "wiki": stable facts (terms, API, policy, standard, compliance).
* "task": this issue's goal, scope, metric, priority, deadline.
* "implementation": engine choice the planner could reasonably decide.
* "external": legal / vendor / market, neither wiki nor planner.
- impact: float in [0,1]; <0.3 low-stakes, >=0.7 plan-changing.
- slot: short stable tag, e.g. "success_metric", "target_env".
Two findings with the same aspect MUST use the same slot value.
Interpretation, aspect, why it matters, suggested_question and assumptions_planner_may_make should be written in project language: {{COMMENT_LANGUAGE}}
## ROUTING
@@ -259,7 +579,7 @@ SEQUENCE_PROMPT = """\
You are a task sequencing planner for an autonomous coding system.
You receive an issue and a list of AVAILABLE AGENTS. Your job is to
produce a dependency-ordered plan — a DAG of steps — that, if executed
produce a dependency-ordered plan ? a DAG of steps ? that, if executed
in the correct order, resolves the issue.
You are NOT deciding whether the task is possible. That decision has
@@ -273,7 +593,7 @@ Violating any of these makes your output invalid:
1. You may ONLY use agents whose `id` appears in AVAILABLE AGENTS.
Never invent agents. Never use placeholder ids like "TODO".
2. Every step must have a unique `step_id` (s01, s02, ...).
3. Every step must have `depends_on` — an array of step_ids, possibly
3. Every step must have `depends_on` ? an array of step_ids, possibly
empty. Declare every dependency, including transitive ones you rely
on for data. Missing a dependency is worse than an unnecessary one.
4. The dependency graph must be acyclic.
@@ -352,7 +672,7 @@ Field notes:
a group must have identical `depends_on` sets and no inter-dependencies.
- `critical_path` is the longest dependency chain by `estimate_minutes.p50`.
- `total_estimate_minutes.p50` is the sum of p50 along the critical path
— NOT the sum of all steps. Parallel work does not add.
? NOT the sum of all steps. Parallel work does not add.
Compute it; do not guess.
- `total_estimate_minutes.p90` is the sum of p90 along the p90-weighted
critical path. This path may differ from the p50 one.
@@ -366,7 +686,7 @@ Field notes:
Do not add ceremony steps (planning, review, cleanup) unless the
issue explicitly asks for them or an agent is dedicated to them.
- Prefer narrow, verifiable steps over one large step.
- If two steps touch the same artifact, they are NOT parallel — one
- If two steps touch the same artifact, they are NOT parallel ? one
must depend on the other.
- A "review" or "gate" step is only valid if an agent whose job is
review/gating is listed in AVAILABLE AGENTS. Otherwise do not add one.
@@ -378,7 +698,7 @@ Field notes:
## EXAMPLES
### Example 1 — single-step plan
### Example 1 ? single-step plan
Issue: "Add a `--verbose` flag to the CLI that prints extra diagnostics."
@@ -411,7 +731,7 @@ Correct output:
"missing_capabilities": []
}
### Example 2 — two-step plan with a dependency
### Example 2 ? two-step plan with a dependency
Issue: "Fix the off-by-one in pagination and add a regression test."
@@ -455,11 +775,11 @@ Correct output:
"missing_capabilities": []
}
Note in Example 2: `test-runner` is available but NOT used — the issue
Note in Example 2: `test-runner` is available but NOT used ? the issue
asks to *add* a test, which is code-writing work. Do not force agents
into plans just because they exist.
### Example 3 — insufficient capability (escape hatch)
### Example 3 ? insufficient capability (escape hatch)
Issue: "Migrate the users table to add a `last_login` timestamp column."
@@ -483,62 +803,8 @@ Correct output:
Now produce the JSON for the issue above.
"""
def prior_comment_id(issue: IssueContext) -> str:
for c in reversed(issue.comments):
if TriageMarker.parse(c.body):
return c.id
return ""
def _capability_now_available(cap_str: str, registry: AgentRegistry) -> bool:
try:
cap = Capability(cap_str)
except ValueError:
return False
return bool(registry.by_capability(cap))
def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, TriageMarker | None]:
"""
Returns the action to take before doing any LLM work.
This is the cheap, deterministic gate.
"""
prior = find_prior_marker(issue)
if prior is None:
return Action.RERUN, None
# issue body changed since last triage → re-run everything
if prior.issue_hash != issue.body_hash():
return Action.RERUN, prior
# PLANNED: never re-plan. Execution is downstream's problem.
if prior.decision == "PLANNED":
return Action.SKIP, prior
# BLOCKED_CLARITY: wait for reporter reply
if prior.decision == "BLOCKED_CLARITY":
if has_reporter_reply_since(issue, prior_comment_id(issue)):
return Action.RERUN, prior
else:
logger.debug("NO REPLY")
return Action.SKIP, prior
# BLOCKED_CAPABILITY: check if new agents close the gap
if prior.decision == "BLOCKED_CAPABILITY":
still_missing = [
c for c in prior.missing_caps
if not _capability_now_available(c, registry)
]
if not still_missing:
return Action.RERUN, prior
# even if still missing, if reporter added info, re-run
# to potentially discover the issue changed shape
if has_reporter_reply_since(issue, prior_comment_id(issue)):
return Action.RERUN, prior
return Action.SKIP, prior
return Action.RERUN, prior
# ==== IssueTriageAgent (без изменений) ====
@agent(
name="IssueTriageAgent",
@@ -550,7 +816,13 @@ def precheck(issue: IssueContext, registry: AgentRegistry) -> tuple[Action, Tria
priority=10,
)
class IssueTriageAgent:
def __init__(self, context_builder: YouTrackContextBuilder, llm: LLMClient, agent_registry: AgentRegistry, youtrack_mcp: YouTrackMCPClient):
def __init__(
self,
context_builder: YouTrackContextBuilder,
llm: LLMClient,
agent_registry: AgentRegistry,
youtrack_mcp: YouTrackMCPClient,
):
self.name = "IssueTriageAgent"
self.ctx_builder = context_builder
self.decomposer = IssueDecomposer(llm)
@@ -558,7 +830,6 @@ class IssueTriageAgent:
self.agent_registry = agent_registry
self.youtrack_mcp = youtrack_mcp
async def plan_issue(self, issue_id: str) -> TriageMarker:
issue_ctx = await self.ctx_builder.build(issue_id)
@@ -571,16 +842,27 @@ class IssueTriageAgent:
self,
))
def audit_capability(
self, issue: IssueContext
) -> CapabilityResult:
def audit_capability(self, issue: IssueContext) -> CapabilityResult:
cloned_issue = issue
cloned_issue.comments = [c for c in cloned_issue.comments if c.author != os.getenv("YOUTRACK_TRIAGE_AUTHOR")]
prompt = (CAPABILITY_PROMPT
.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
+119 -13
View File
@@ -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", [])
+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,
)
+126 -28
View File
@@ -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 = "<!-- triage-agent:v1"
MARKER_RE_V2 = re.compile(r"<!-- triage-agent:v2 (\{.*?\}) -->", re.DOTALL)
MARKER_RE_V1 = re.compile(
r"<!-- triage-agent:v1 issue-hash:(\w+) capability:(\w+) "
r"clarity:(\w+) decision:(\w+) missing-caps:([\w:,\-/\s]*)\s*-->"
)
GapSource = Literal["wiki", "task", "implementation", "external"]
GapStatus = Literal["open", "asked", "answered", "unresolved", "wiki_gap", "assumed"]
@dataclass
class Gap:
gap_id: str
source: GapSource
slot: str
question: str
impact: float = 0.5
status: GapStatus = "open"
answer: str = ""
ticket: str = "" # для wiki_gap: ID созданного тикета
asked_at_comment: str = "" # ID комментария, где задан вопрос
@staticmethod
def make_id(slot: str, entity: str, aspect: str) -> str:
return hashlib.sha1(f"{slot}|{entity}|{aspect}".encode()).hexdigest()[:12]
@dataclass
class TriageMarker:
issue_hash: str
capability: Literal["COVERED", "GAP", "INSUFFICIENT_INFO"]
clarity: Literal["CLEAR", "AMBIGUOUS", "AWAITING_REPLY"]
decision: Literal["PLANNED", "BLOCKED_CAPABILITY", "BLOCKED_CLARITY"]
issue_hash: str = ""
decision: Literal[
"PLANNED",
"BLOCKED_CAPABILITY",
"BLOCKED_CLARITY",
"WIKI_GAP_RECORDED",
"ESCALATED",
] = "BLOCKED_CLARITY"
capability: str = "COVERED"
missing_caps: list[str] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
questions_asked_count: int = 0
last_seen_comment_count: int = 0
# ---- rendering ----
def render(self) -> str:
caps = ",".join(self.missing_caps)
payload = {
"issue_hash": self.issue_hash,
"decision": self.decision,
"capability": self.capability,
"missing_caps": self.missing_caps,
"questions_asked_count": self.questions_asked_count,
"last_seen_comment_count": self.last_seen_comment_count,
"gaps": [asdict(g) for g in self.gaps],
}
return (
f"{MARKER_PREFIX} issue-hash:{self.issue_hash} "
f"capability:{self.capability} clarity:{self.clarity} "
f"decision:{self.decision} missing-caps:{caps} -->"
"<!-- triage-agent:v2 "
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
+ " -->"
)
# ---- parsing ----
@classmethod
def parse(cls, text: str) -> "TriageMarker | None":
import re
m = re.search(
r"<!-- triage-agent:v1 issue-hash:(\w+) capability:(\w+) "
r"clarity:(\w+) decision:(\w+) missing-caps:([\w:,\-/\s]*)\s*-->",
text,
)
if not m:
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
+103 -28
View File
@@ -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],
)
+7
View File
@@ -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
],
+176 -12
View File
@@ -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,
+7
View File
@@ -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
+18 -22
View File
@@ -2,34 +2,19 @@ from typing import Literal
from pydantic import Field, model_validator
from contracts.CodeLanguages import Language
from contracts.base import _StrictModel
class LanguageSnapshot(_StrictModel):
"""
The language the plan commits to, plus the toolchain it will use.
Chosen by Plan from the target files; consumed by Act and Verify.
"""
name: str = Field(
min_length=1,
description="Language name, e.g. 'python', 'typescript', 'go'.",
)
name: Language = Field(description="Primary language the plan commits to.")
version: str | None = Field(
description="Language version if known, else null.",
)
test_runner: str = Field(
min_length=1,
description="How tests are run, e.g. 'pytest', 'vitest', 'go test'.",
)
formatter: str | None = Field(
description="Formatter command, or null if none.",
)
linter: str | None = Field(
description="Linter command, or null if none.",
)
detected_from: list[str] = Field(
description="Marker files used to detect, e.g. ['pyproject.toml'].",
description="Language version if known (e.g. '3.12'), else null.",
)
test_runner: str = Field(min_length=1)
formatter: str | None = Field(description="Formatter command, or null.")
linter: str | None = Field(description="Linter command, or null.")
detected_from: list[str] = Field(min_length=1)
class PlanStep(_StrictModel):
@@ -87,3 +72,14 @@ class PlanOutput(_StrictModel):
raise ValueError(f"unsafe path: {f!r}")
return self
def wrap_ref_siblings(node):
if isinstance(node, dict):
if "$ref" in node and len(node) > 1:
ref = node["$ref"]
siblings = {k: wrap_ref_siblings(v) for k, v in node.items() if k != "$ref"}
return {"anyOf": [{"$ref": ref}], **siblings}
return {k: wrap_ref_siblings(v) for k, v in node.items()}
if isinstance(node, list):
return [wrap_ref_siblings(x) for x in node]
return node
+2 -5
View File
@@ -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