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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
182
agent/src/ss_tools/agent/tools_038.py
Normal file
182
agent/src/ss_tools/agent/tools_038.py
Normal 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
|
||||
Reference in New Issue
Block a user