kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
155
agents/runner.py
155
agents/runner.py
|
|
@ -111,7 +111,9 @@ def run_agent(
|
|||
# Determine working directory
|
||||
project = models.get_project(conn, project_id)
|
||||
working_dir = None
|
||||
if project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security"):
|
||||
# Operations projects have no local path — sysadmin works via SSH
|
||||
is_operations = project and project.get("project_type") == "operations"
|
||||
if not is_operations and project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security"):
|
||||
project_path = Path(project["path"]).expanduser()
|
||||
if project_path.is_dir():
|
||||
working_dir = str(project_path)
|
||||
|
|
@ -417,6 +419,35 @@ def run_audit(
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocked protocol detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_agent_blocked(result: dict) -> dict | None:
|
||||
"""Detect semantic blocked status from a successful agent result.
|
||||
|
||||
Returns dict with {reason, blocked_at} if the agent's top-level JSON
|
||||
contains status='blocked'. Returns None otherwise.
|
||||
|
||||
Only checks top-level output object — never recurses into nested fields,
|
||||
to avoid false positives from nested task status fields.
|
||||
"""
|
||||
from datetime import datetime
|
||||
if not result.get("success"):
|
||||
return None
|
||||
output = result.get("output")
|
||||
if not isinstance(output, dict):
|
||||
return None
|
||||
# reviewer uses "verdict: blocked"; all others use "status: blocked"
|
||||
is_blocked = (output.get("status") == "blocked" or output.get("verdict") == "blocked")
|
||||
if not is_blocked:
|
||||
return None
|
||||
return {
|
||||
"reason": output.get("reason") or output.get("blocked_reason") or "",
|
||||
"blocked_at": output.get("blocked_at") or datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission error detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -490,6 +521,88 @@ def _run_autocommit(
|
|||
_logger.warning("Autocommit failed for %s: %s", task_id, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sysadmin output: save server map to decisions and modules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_sysadmin_output(
|
||||
conn: sqlite3.Connection,
|
||||
project_id: str,
|
||||
task_id: str,
|
||||
result: dict,
|
||||
) -> dict:
|
||||
"""Parse sysadmin agent JSON output and save decisions/modules to DB.
|
||||
|
||||
Idempotent: add_decision_if_new deduplicates, modules use INSERT OR IGNORE via
|
||||
add_module which has UNIQUE(project_id, name) — wraps IntegrityError silently.
|
||||
Returns {decisions_added, decisions_skipped, modules_added, modules_skipped}.
|
||||
"""
|
||||
raw = result.get("raw_output") or result.get("output") or ""
|
||||
if isinstance(raw, (dict, list)):
|
||||
raw = json.dumps(raw, ensure_ascii=False)
|
||||
|
||||
parsed = _try_parse_json(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
return {"decisions_added": 0, "decisions_skipped": 0, "modules_added": 0, "modules_skipped": 0}
|
||||
|
||||
decisions_added = 0
|
||||
decisions_skipped = 0
|
||||
for item in (parsed.get("decisions") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
d_type = item.get("type", "decision")
|
||||
if d_type not in VALID_DECISION_TYPES:
|
||||
d_type = "decision"
|
||||
d_title = (item.get("title") or "").strip()
|
||||
d_desc = (item.get("description") or "").strip()
|
||||
if not d_title or not d_desc:
|
||||
continue
|
||||
saved = models.add_decision_if_new(
|
||||
conn,
|
||||
project_id=project_id,
|
||||
type=d_type,
|
||||
title=d_title,
|
||||
description=d_desc,
|
||||
tags=item.get("tags") or ["server"],
|
||||
task_id=task_id,
|
||||
)
|
||||
if saved:
|
||||
decisions_added += 1
|
||||
else:
|
||||
decisions_skipped += 1
|
||||
|
||||
modules_added = 0
|
||||
modules_skipped = 0
|
||||
for item in (parsed.get("modules") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
m_name = (item.get("name") or "").strip()
|
||||
m_type = (item.get("type") or "service").strip()
|
||||
m_path = (item.get("path") or "").strip()
|
||||
if not m_name:
|
||||
continue
|
||||
try:
|
||||
models.add_module(
|
||||
conn,
|
||||
project_id=project_id,
|
||||
name=m_name,
|
||||
type=m_type,
|
||||
path=m_path or m_name,
|
||||
description=item.get("description"),
|
||||
owner_role="sysadmin",
|
||||
)
|
||||
modules_added += 1
|
||||
except Exception:
|
||||
modules_skipped += 1
|
||||
|
||||
return {
|
||||
"decisions_added": decisions_added,
|
||||
"decisions_skipped": decisions_skipped,
|
||||
"modules_added": modules_added,
|
||||
"modules_skipped": modules_skipped,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-learning: extract decisions from pipeline results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -779,6 +892,46 @@ def run_pipeline(
|
|||
|
||||
results.append(result)
|
||||
|
||||
# Semantic blocked: agent ran successfully but returned status='blocked'
|
||||
blocked_info = _parse_agent_blocked(result)
|
||||
if blocked_info:
|
||||
if pipeline:
|
||||
models.update_pipeline(
|
||||
conn, pipeline["id"],
|
||||
status="failed",
|
||||
total_cost_usd=total_cost,
|
||||
total_tokens=total_tokens,
|
||||
total_duration_seconds=total_duration,
|
||||
)
|
||||
models.update_task(
|
||||
conn, task_id,
|
||||
status="blocked",
|
||||
blocked_reason=blocked_info["reason"],
|
||||
blocked_at=blocked_info["blocked_at"],
|
||||
blocked_agent_role=role,
|
||||
blocked_pipeline_step=str(i + 1),
|
||||
)
|
||||
error_msg = f"Step {i+1}/{len(steps)} ({role}) blocked: {blocked_info['reason']}"
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"blocked_by": role,
|
||||
"blocked_reason": blocked_info["reason"],
|
||||
"steps_completed": i,
|
||||
"results": results,
|
||||
"total_cost_usd": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
"total_duration_seconds": total_duration,
|
||||
"pipeline_id": pipeline["id"] if pipeline else None,
|
||||
}
|
||||
|
||||
# Save sysadmin scan results immediately after a successful sysadmin step
|
||||
if role == "sysadmin" and result["success"] and not dry_run:
|
||||
try:
|
||||
_save_sysadmin_output(conn, project_id, task_id, result)
|
||||
except Exception:
|
||||
pass # Never block pipeline on sysadmin save errors
|
||||
|
||||
# Chain output to next step
|
||||
previous_output = result.get("raw_output") or result.get("output")
|
||||
if isinstance(previous_output, (dict, list)):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue