feat(038): Phase 7 — API routes + agent scenario tools

- T031-T036: ScenarioGraph.Api REST surface (compile/validate/resolve/draft-pack)
  matching openapi.yaml with RBAC scopes + extra=forbid request schemas
- agent tools_038.py: scenario_compile/validate/resolve/generate_draft_pack
  registered in get_all_tools (36 total) + _SCENARIO_TOOL_ALLOWLIST
  (scenario mode keeps SQL tools excluded per invariant)
- 68 backend + 20 agent tests pass; ruff clean
This commit is contained in:
2026-07-31 13:13:45 +03:00
parent 4e93a31407
commit 8ca67beeea
7 changed files with 451 additions and 7 deletions

View File

@@ -31,6 +31,11 @@ _SCENARIO_TOOL_ALLOWLIST: frozenset = frozenset({
"decide_baseline_approval",
"consume_baseline_approval",
"create_verification_run_tool",
# 038: Scenario graph tools (deterministic compiler/validator via backend)
"scenario_compile",
"scenario_validate",
"scenario_resolve",
"scenario_generate_draft_pack",
})
"""Tools allowed in dashboard-testing scenario mode.
superset_execute_sql, superset_format_sql, superset_create_dataset, and any

View File

@@ -1367,6 +1367,14 @@ from ss_tools.agent.tools_037 import ( # noqa: E402 — intentional: import aft
request_baseline_approval,
)
# ── 038 scenario tools imported from bounded module ────────────────
from ss_tools.agent.tools_038 import ( # noqa: E402 — intentional: import after tool declarations to avoid circular import
scenario_compile,
scenario_generate_draft_pack,
scenario_resolve,
scenario_validate,
)
# ═══════════════════════════════════════════════════════════════════
# Tool registry
# ═══════════════════════════════════════════════════════════════════
@@ -1411,6 +1419,11 @@ def get_all_tools() -> list:
decide_baseline_approval,
consume_baseline_approval,
create_verification_run_tool,
# 038: Scenario graph tools
scenario_compile,
scenario_validate,
scenario_resolve,
scenario_generate_draft_pack,
]
# #endregion AgentChat.Tools.GetAll

View File

@@ -0,0 +1,182 @@
# #region AgentChat.ToolsScenarioGraph [C:4] [TYPE Module] [SEMANTICS scenario,agent,tools,compiler]
# @defgroup AgentChat Thin scenario tools that submit bounded intent and display compiler/validator results.
# @RELATION DEPENDS_ON -> [ScenarioGraph.Api]
# @RATIONALE Agent tools stay thin: the agent explains intent, the deterministic compiler owns the graph.
# @REJECTED Agent-side graph construction with free-form tool selection — bypasses validation and determinism.
# @INVARIANT Agent cannot submit executable code, custom tool categories, raw expected metrics, or artifact paths.
from __future__ import annotations
import json
from typing import Any
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from ss_tools.shared._llm_http import get_shared_http_client
from ss_tools.shared.cot_logger import log as _cot_log
from ss_tools.agent._config import FASTAPI_URL
from ss_tools.agent._tool_filter import enforce_tool_permission
from ss_tools.agent.context import get_service_jwt, get_user_jwt, get_user_role
TOOL_RESPONSE_LIMIT = 12000
TOOL_TIMEOUT_SECONDS = 120
# #region AgentChat.ToolsScenarioGraph.DualAuthHeaders [C:1] [TYPE Function] [SEMANTICS helpers,auth,http]
# @ingroup AgentChat
# @BRIEF Build dual-auth HTTP headers: service JWT + optional user JWT.
def _dual_auth_headers() -> dict[str, str]:
import os as _os
user_jwt = get_user_jwt() or ""
svc_jwt = get_service_jwt() or _os.environ.get("SERVICE_JWT", "")
headers: dict[str, str] = {}
if svc_jwt:
headers["Authorization"] = f"Bearer {svc_jwt}"
if user_jwt:
headers["X-User-JWT"] = user_jwt
elif user_jwt:
headers["Authorization"] = f"Bearer {user_jwt}"
return headers
# #endregion AgentChat.ToolsScenarioGraph.DualAuthHeaders
# #region AgentChat.ToolsScenarioGraph.Post [C:2] [TYPE Function] [SEMANTICS helpers,http,post]
# @ingroup AgentChat
# @BRIEF Async HTTP POST to the backend scenario API with dual-auth headers.
async def _post(path: str, payload: dict[str, Any] | None = None) -> Any:
client = get_shared_http_client(timeout=TOOL_TIMEOUT_SECONDS)
resp = await client.post(
f"{FASTAPI_URL}{path}",
json=payload or {},
headers=_dual_auth_headers(),
)
if resp.status_code not in (200, 201):
return {"error": resp.status_code, "detail": resp.text[:TOOL_RESPONSE_LIMIT]}
return resp.json()
# #endregion AgentChat.ToolsScenarioGraph.Post
# #region AgentChat.ToolsScenarioGraph.GuardPermission [C:1] [TYPE Function] [SEMANTICS helpers,rbac,permission]
# @ingroup AgentChat
# @BRIEF Enforce invocation-time RBAC before tool side effects.
def _guard_tool_permission(tool_name: str) -> None:
user_role = get_user_role()
if enforce_tool_permission(tool_name, user_role):
return
raise PermissionError(f"PERMISSION_DENIED:{tool_name}:{user_role}")
# #endregion AgentChat.ToolsScenarioGraph.GuardPermission
# #region AgentChat.ToolsScenarioGraph.CompileInput [C:1] [TYPE Class] [SEMANTICS scenario,tools,schema,compile]
class CompileScenarioInput(BaseModel):
agent_run_id: str = Field(..., description="AgentRun UUID that owns the scenario")
objective_json: str = Field(..., description="JSON: {goal, selected_case_ids, rationale?}")
query_model_json: str = Field(default="{}", description="JSON query model")
baseline_version: str = Field("latest", description="Baseline catalog version")
capabilities_json: str = Field(default="{}", description="JSON capability registry")
parameters_json: str = Field(default="{}", description="JSON parameter specs")
dashboard_id: int = Field(0, description="Superset dashboard ID")
dashboard_name: str = Field("unknown", description="Dashboard name")
# #endregion AgentChat.ToolsScenarioGraph.CompileInput
# #region AgentChat.ToolsScenarioGraph.ValidateInput [C:1] [TYPE Class] [SEMANTICS scenario,tools,schema,validate]
class ValidateScenarioInput(BaseModel):
scenario_json: str = Field(..., description="JSON of the DashboardTestScenario graph")
# #endregion AgentChat.ToolsScenarioGraph.ValidateInput
# #region AgentChat.ToolsScenarioGraph.ResolveInput [C:1] [TYPE Class] [SEMANTICS scenario,tools,schema,resolve]
class ResolveScenarioInput(BaseModel):
scenario_id: str = Field(..., description="Scenario id")
scenario_json: str = Field(..., description="JSON of the current graph")
base_revision_hash: str = Field(..., min_length=64, max_length=64, description="Base revision hash")
changes_json: str = Field(..., description="JSON array of {kind, target, value, reason?}")
# #endregion AgentChat.ToolsScenarioGraph.ResolveInput
# #region AgentChat.ToolsScenarioGraph.GenerateInput [C:1] [TYPE Class] [SEMANTICS scenario,tools,schema,generate]
class GenerateDraftPackInput(BaseModel):
scenario_id: str = Field(..., description="Scenario id")
scenario_json: str = Field(..., description="JSON of the validated graph")
# #endregion AgentChat.ToolsScenarioGraph.GenerateInput
# #region AgentChat.ToolsScenarioGraph.CompileTool [C:3] [TYPE Function] [SEMANTICS scenario,tools,compile]
# @ingroup AgentChat
# @BRIEF Submit bounded intent; backend compiles a deterministic ScenarioGraph.
@tool(args_schema=CompileScenarioInput)
async def scenario_compile(
agent_run_id: str, objective_json: str,
query_model_json: str = "{}", baseline_version: str = "latest",
capabilities_json: str = "{}", parameters_json: str = "{}",
dashboard_id: int = 0, dashboard_name: str = "unknown",
) -> str:
"""Compile a dashboard testing goal into a deterministic ScenarioGraph."""
_guard_tool_permission("scenario_compile")
_cot_log("AgentChat.ToolsScenarioGraph.CompileTool", "REASON",
"Submitting bounded scenario intent", {"agent_run_id": agent_run_id})
payload = {
"agent_run_id": agent_run_id,
"objective": json.loads(objective_json),
"query_model": json.loads(query_model_json),
"checklist_catalog_version": 1,
"baseline_version": baseline_version,
"capabilities": json.loads(capabilities_json),
"parameters": json.loads(parameters_json),
"has_dataset_fields": True,
"environment_id": "env-default",
"dashboard_id": dashboard_id,
"dashboard_name": dashboard_name,
}
result = await _post("/api/dashboard-testing/scenarios/compile", payload)
_cot_log("AgentChat.ToolsScenarioGraph.CompileTool", "REFLECT", "Scenario compiled",
{"ok": "error" not in result})
return json.dumps(result, ensure_ascii=False)[:TOOL_RESPONSE_LIMIT]
# #endregion AgentChat.ToolsScenarioGraph.CompileTool
# #region AgentChat.ToolsScenarioGraph.ValidateTool [C:3] [TYPE Function] [SEMANTICS scenario,tools,validate]
# @ingroup AgentChat
# @BRIEF Validate a candidate graph; returns deterministic findings.
@tool(args_schema=ValidateScenarioInput)
async def scenario_validate(scenario_json: str) -> str:
"""Validate a ScenarioGraph and return all findings."""
_guard_tool_permission("scenario_validate")
result = await _post("/api/dashboard-testing/scenarios/validate", json.loads(scenario_json))
return json.dumps(result, ensure_ascii=False)[:TOOL_RESPONSE_LIMIT]
# #endregion AgentChat.ToolsScenarioGraph.ValidateTool
# #region AgentChat.ToolsScenarioGraph.ResolveTool [C:3] [TYPE Function] [SEMANTICS scenario,tools,resolve]
# @ingroup AgentChat
# @BRIEF Apply typed resolutions; returns a new immutable revision.
@tool(args_schema=ResolveScenarioInput)
async def scenario_resolve(
scenario_id: str, scenario_json: str,
base_revision_hash: str, changes_json: str,
) -> str:
"""Resolve parameters/selectors and emit an immutable revision."""
_guard_tool_permission("scenario_resolve")
payload = {
"base_revision_hash": base_revision_hash,
"changes": json.loads(changes_json),
}
result = await _post(f"/api/dashboard-testing/scenarios/{scenario_id}/resolve", payload)
return json.dumps(result, ensure_ascii=False)[:TOOL_RESPONSE_LIMIT]
# #endregion AgentChat.ToolsScenarioGraph.ResolveTool
# #region AgentChat.ToolsScenarioGraph.GenerateTool [C:3] [TYPE Function] [SEMANTICS scenario,tools,generate]
# @ingroup AgentChat
# @BRIEF Generate a preview_only or save_eligible draft pack.
@tool(args_schema=GenerateDraftPackInput)
async def scenario_generate_draft_pack(scenario_id: str, scenario_json: str) -> str:
"""Generate a draft pack from a validated scenario graph."""
_guard_tool_permission("scenario_generate_draft_pack")
result = await _post(f"/api/dashboard-testing/scenarios/{scenario_id}/draft-pack", json.loads(scenario_json))
return json.dumps(result, ensure_ascii=False)[:TOOL_RESPONSE_LIMIT]
# #endregion AgentChat.ToolsScenarioGraph.GenerateTool
# #endregion AgentChat.ToolsScenarioGraph