mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-24 22:48:05 -05:00
Compare commits
1 Commits
dev
...
sanity-che
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fdc311293 |
@@ -1,28 +1,29 @@
|
|||||||
"""Agent generator package - Creates agents from natural language."""
|
"""Agent generator package - Creates agents from natural language."""
|
||||||
|
|
||||||
from .core import (
|
from .core import (
|
||||||
AgentGeneratorNotConfiguredError,
|
apply_agent_patch,
|
||||||
decompose_goal,
|
decompose_goal,
|
||||||
generate_agent,
|
generate_agent,
|
||||||
generate_agent_patch,
|
generate_agent_patch,
|
||||||
get_agent_as_json,
|
get_agent_as_json,
|
||||||
json_to_graph,
|
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
)
|
)
|
||||||
from .service import health_check as check_external_service_health
|
from .fixer import apply_all_fixes
|
||||||
from .service import is_external_service_configured
|
from .utils import get_blocks_info
|
||||||
|
from .validator import validate_agent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core functions
|
# Core functions
|
||||||
"decompose_goal",
|
"decompose_goal",
|
||||||
"generate_agent",
|
"generate_agent",
|
||||||
"generate_agent_patch",
|
"generate_agent_patch",
|
||||||
|
"apply_agent_patch",
|
||||||
"save_agent_to_library",
|
"save_agent_to_library",
|
||||||
"get_agent_as_json",
|
"get_agent_as_json",
|
||||||
"json_to_graph",
|
# Fixer
|
||||||
# Exceptions
|
"apply_all_fixes",
|
||||||
"AgentGeneratorNotConfiguredError",
|
# Validator
|
||||||
# Service
|
"validate_agent",
|
||||||
"is_external_service_configured",
|
# Utils
|
||||||
"check_external_service_health",
|
"get_blocks_info",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""OpenRouter client configuration for agent generation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
# Configuration - use OPEN_ROUTER_API_KEY for consistency with chat/config.py
|
||||||
|
OPENROUTER_API_KEY = os.getenv("OPEN_ROUTER_API_KEY")
|
||||||
|
AGENT_GENERATOR_MODEL = os.getenv("AGENT_GENERATOR_MODEL", "anthropic/claude-opus-4.5")
|
||||||
|
|
||||||
|
# OpenRouter client (OpenAI-compatible API)
|
||||||
|
_client: AsyncOpenAI | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> AsyncOpenAI:
|
||||||
|
"""Get or create the OpenRouter client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
if not OPENROUTER_API_KEY:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY environment variable is required")
|
||||||
|
_client = AsyncOpenAI(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key=OPENROUTER_API_KEY,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Core agent generation functions."""
|
"""Core agent generation functions."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -7,35 +9,13 @@ from typing import Any
|
|||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
from backend.data.graph import Graph, Link, Node, create_graph
|
from backend.data.graph import Graph, Link, Node, create_graph
|
||||||
|
|
||||||
from .service import (
|
from .client import AGENT_GENERATOR_MODEL, get_client
|
||||||
decompose_goal_external,
|
from .prompts import DECOMPOSITION_PROMPT, GENERATION_PROMPT, PATCH_PROMPT
|
||||||
generate_agent_external,
|
from .utils import get_block_summaries, parse_json_from_llm
|
||||||
generate_agent_patch_external,
|
|
||||||
is_external_service_configured,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AgentGeneratorNotConfiguredError(Exception):
|
|
||||||
"""Raised when the external Agent Generator service is not configured."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _check_service_configured() -> None:
|
|
||||||
"""Check if the external Agent Generator service is configured.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AgentGeneratorNotConfiguredError: If the service is not configured.
|
|
||||||
"""
|
|
||||||
if not is_external_service_configured():
|
|
||||||
raise AgentGeneratorNotConfiguredError(
|
|
||||||
"Agent Generator service is not configured. "
|
|
||||||
"Set AGENTGENERATOR_HOST environment variable to enable agent generation."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def decompose_goal(description: str, context: str = "") -> dict[str, Any] | None:
|
async def decompose_goal(description: str, context: str = "") -> dict[str, Any] | None:
|
||||||
"""Break down a goal into steps or return clarifying questions.
|
"""Break down a goal into steps or return clarifying questions.
|
||||||
|
|
||||||
@@ -48,13 +28,40 @@ async def decompose_goal(description: str, context: str = "") -> dict[str, Any]
|
|||||||
- {"type": "clarifying_questions", "questions": [...]}
|
- {"type": "clarifying_questions", "questions": [...]}
|
||||||
- {"type": "instructions", "steps": [...]}
|
- {"type": "instructions", "steps": [...]}
|
||||||
Or None on error
|
Or None on error
|
||||||
|
|
||||||
Raises:
|
|
||||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
|
||||||
"""
|
"""
|
||||||
_check_service_configured()
|
client = get_client()
|
||||||
logger.info("Calling external Agent Generator service for decompose_goal")
|
prompt = DECOMPOSITION_PROMPT.format(block_summaries=get_block_summaries())
|
||||||
return await decompose_goal_external(description, context)
|
|
||||||
|
full_description = description
|
||||||
|
if context:
|
||||||
|
full_description = f"{description}\n\nAdditional context:\n{context}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=AGENT_GENERATOR_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": full_description},
|
||||||
|
],
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
if content is None:
|
||||||
|
logger.error("LLM returned empty content for decomposition")
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = parse_json_from_llm(content)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.error(f"Failed to parse decomposition response: {content[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error decomposing goal: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
@@ -65,14 +72,31 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Agent JSON dict or None on error
|
Agent JSON dict or None on error
|
||||||
|
|
||||||
Raises:
|
|
||||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
|
||||||
"""
|
"""
|
||||||
_check_service_configured()
|
client = get_client()
|
||||||
logger.info("Calling external Agent Generator service for generate_agent")
|
prompt = GENERATION_PROMPT.format(block_summaries=get_block_summaries())
|
||||||
result = await generate_agent_external(instructions)
|
|
||||||
if result:
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=AGENT_GENERATOR_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": json.dumps(instructions, indent=2)},
|
||||||
|
],
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
if content is None:
|
||||||
|
logger.error("LLM returned empty content for agent generation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = parse_json_from_llm(content)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.error(f"Failed to parse agent JSON: {content[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
# Ensure required fields
|
# Ensure required fields
|
||||||
if "id" not in result:
|
if "id" not in result:
|
||||||
result["id"] = str(uuid.uuid4())
|
result["id"] = str(uuid.uuid4())
|
||||||
@@ -80,7 +104,12 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
result["version"] = 1
|
result["version"] = 1
|
||||||
if "is_active" not in result:
|
if "is_active" not in result:
|
||||||
result["is_active"] = True
|
result["is_active"] = True
|
||||||
return result
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating agent: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
||||||
@@ -255,23 +284,108 @@ async def get_agent_as_json(
|
|||||||
async def generate_agent_patch(
|
async def generate_agent_patch(
|
||||||
update_request: str, current_agent: dict[str, Any]
|
update_request: str, current_agent: dict[str, Any]
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update an existing agent using natural language.
|
"""Generate a patch to update an existing agent.
|
||||||
|
|
||||||
The external Agent Generator service handles:
|
|
||||||
- Generating the patch
|
|
||||||
- Applying the patch
|
|
||||||
- Fixing and validating the result
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
update_request: Natural language description of changes
|
update_request: Natural language description of changes
|
||||||
current_agent: Current agent JSON
|
current_agent: Current agent JSON
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated agent JSON, clarifying questions dict, or None on error
|
Patch dict or clarifying questions, or None on error
|
||||||
|
|
||||||
Raises:
|
|
||||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
|
||||||
"""
|
"""
|
||||||
_check_service_configured()
|
client = get_client()
|
||||||
logger.info("Calling external Agent Generator service for generate_agent_patch")
|
prompt = PATCH_PROMPT.format(
|
||||||
return await generate_agent_patch_external(update_request, current_agent)
|
current_agent=json.dumps(current_agent, indent=2),
|
||||||
|
block_summaries=get_block_summaries(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=AGENT_GENERATOR_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": update_request},
|
||||||
|
],
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
if content is None:
|
||||||
|
logger.error("LLM returned empty content for patch generation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return parse_json_from_llm(content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating patch: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_agent_patch(
|
||||||
|
current_agent: dict[str, Any], patch: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply a patch to an existing agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_agent: Current agent JSON
|
||||||
|
patch: Patch dict with operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated agent JSON
|
||||||
|
"""
|
||||||
|
agent = copy.deepcopy(current_agent)
|
||||||
|
patches = patch.get("patches", [])
|
||||||
|
|
||||||
|
for p in patches:
|
||||||
|
patch_type = p.get("type")
|
||||||
|
|
||||||
|
if patch_type == "modify":
|
||||||
|
node_id = p.get("node_id")
|
||||||
|
changes = p.get("changes", {})
|
||||||
|
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
if node["id"] == node_id:
|
||||||
|
_deep_update(node, changes)
|
||||||
|
logger.debug(f"Modified node {node_id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
elif patch_type == "add":
|
||||||
|
new_nodes = p.get("new_nodes", [])
|
||||||
|
new_links = p.get("new_links", [])
|
||||||
|
|
||||||
|
agent["nodes"] = agent.get("nodes", []) + new_nodes
|
||||||
|
agent["links"] = agent.get("links", []) + new_links
|
||||||
|
logger.debug(f"Added {len(new_nodes)} nodes, {len(new_links)} links")
|
||||||
|
|
||||||
|
elif patch_type == "remove":
|
||||||
|
node_ids_to_remove = set(p.get("node_ids", []))
|
||||||
|
link_ids_to_remove = set(p.get("link_ids", []))
|
||||||
|
|
||||||
|
# Remove nodes
|
||||||
|
agent["nodes"] = [
|
||||||
|
n for n in agent.get("nodes", []) if n["id"] not in node_ids_to_remove
|
||||||
|
]
|
||||||
|
|
||||||
|
# Remove links (both explicit and those referencing removed nodes)
|
||||||
|
agent["links"] = [
|
||||||
|
link
|
||||||
|
for link in agent.get("links", [])
|
||||||
|
if link["id"] not in link_ids_to_remove
|
||||||
|
and link["source_id"] not in node_ids_to_remove
|
||||||
|
and link["sink_id"] not in node_ids_to_remove
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Removed {len(node_ids_to_remove)} nodes, {len(link_ids_to_remove)} links"
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_update(target: dict, source: dict) -> None:
|
||||||
|
"""Recursively update a dict with another dict."""
|
||||||
|
for key, value in source.items():
|
||||||
|
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||||
|
_deep_update(target[key], value)
|
||||||
|
else:
|
||||||
|
target[key] = value
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
"""Agent fixer - Fixes common LLM generation errors."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .utils import (
|
||||||
|
ADDTODICTIONARY_BLOCK_ID,
|
||||||
|
ADDTOLIST_BLOCK_ID,
|
||||||
|
CODE_EXECUTION_BLOCK_ID,
|
||||||
|
CONDITION_BLOCK_ID,
|
||||||
|
CREATEDICT_BLOCK_ID,
|
||||||
|
CREATELIST_BLOCK_ID,
|
||||||
|
DATA_SAMPLING_BLOCK_ID,
|
||||||
|
DOUBLE_CURLY_BRACES_BLOCK_IDS,
|
||||||
|
GET_CURRENT_DATE_BLOCK_ID,
|
||||||
|
STORE_VALUE_BLOCK_ID,
|
||||||
|
UNIVERSAL_TYPE_CONVERTER_BLOCK_ID,
|
||||||
|
get_blocks_info,
|
||||||
|
is_valid_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_agent_ids(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix invalid UUIDs in agent and link IDs."""
|
||||||
|
# Fix agent ID
|
||||||
|
if not is_valid_uuid(agent.get("id", "")):
|
||||||
|
agent["id"] = str(uuid.uuid4())
|
||||||
|
logger.debug(f"Fixed agent ID: {agent['id']}")
|
||||||
|
|
||||||
|
# Fix node IDs
|
||||||
|
id_mapping = {} # Old ID -> New ID
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
if not is_valid_uuid(node.get("id", "")):
|
||||||
|
old_id = node.get("id", "")
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
id_mapping[old_id] = new_id
|
||||||
|
node["id"] = new_id
|
||||||
|
logger.debug(f"Fixed node ID: {old_id} -> {new_id}")
|
||||||
|
|
||||||
|
# Fix link IDs and update references
|
||||||
|
for link in agent.get("links", []):
|
||||||
|
if not is_valid_uuid(link.get("id", "")):
|
||||||
|
link["id"] = str(uuid.uuid4())
|
||||||
|
logger.debug(f"Fixed link ID: {link['id']}")
|
||||||
|
|
||||||
|
# Update source/sink IDs if they were remapped
|
||||||
|
if link.get("source_id") in id_mapping:
|
||||||
|
link["source_id"] = id_mapping[link["source_id"]]
|
||||||
|
if link.get("sink_id") in id_mapping:
|
||||||
|
link["sink_id"] = id_mapping[link["sink_id"]]
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_double_curly_braces(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix single curly braces to double in template blocks."""
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
if node.get("block_id") not in DOUBLE_CURLY_BRACES_BLOCK_IDS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
input_data = node.get("input_default", {})
|
||||||
|
for key in ("prompt", "format"):
|
||||||
|
if key in input_data and isinstance(input_data[key], str):
|
||||||
|
original = input_data[key]
|
||||||
|
# Fix simple variable references: {var} -> {{var}}
|
||||||
|
fixed = re.sub(
|
||||||
|
r"(?<!\{)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!\})",
|
||||||
|
r"{{\1}}",
|
||||||
|
original,
|
||||||
|
)
|
||||||
|
if fixed != original:
|
||||||
|
input_data[key] = fixed
|
||||||
|
logger.debug(f"Fixed curly braces in {key}")
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_storevalue_before_condition(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Add StoreValueBlock before ConditionBlock if needed for value2."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
|
||||||
|
# Find all ConditionBlock nodes
|
||||||
|
condition_node_ids = {
|
||||||
|
node["id"] for node in nodes if node.get("block_id") == CONDITION_BLOCK_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if not condition_node_ids:
|
||||||
|
return agent
|
||||||
|
|
||||||
|
new_nodes = []
|
||||||
|
new_links = []
|
||||||
|
processed_conditions = set()
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
sink_id = link.get("sink_id")
|
||||||
|
sink_name = link.get("sink_name")
|
||||||
|
|
||||||
|
# Check if this link goes to a ConditionBlock's value2
|
||||||
|
if sink_id in condition_node_ids and sink_name == "value2":
|
||||||
|
source_node = next(
|
||||||
|
(n for n in nodes if n["id"] == link.get("source_id")), None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip if source is already a StoreValueBlock
|
||||||
|
if source_node and source_node.get("block_id") == STORE_VALUE_BLOCK_ID:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if we already processed this condition
|
||||||
|
if sink_id in processed_conditions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed_conditions.add(sink_id)
|
||||||
|
|
||||||
|
# Create StoreValueBlock
|
||||||
|
store_node_id = str(uuid.uuid4())
|
||||||
|
store_node = {
|
||||||
|
"id": store_node_id,
|
||||||
|
"block_id": STORE_VALUE_BLOCK_ID,
|
||||||
|
"input_default": {"data": None},
|
||||||
|
"metadata": {"position": {"x": 0, "y": -100}},
|
||||||
|
}
|
||||||
|
new_nodes.append(store_node)
|
||||||
|
|
||||||
|
# Create link: original source -> StoreValueBlock
|
||||||
|
new_links.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"source_id": link["source_id"],
|
||||||
|
"source_name": link["source_name"],
|
||||||
|
"sink_id": store_node_id,
|
||||||
|
"sink_name": "input",
|
||||||
|
"is_static": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update original link: StoreValueBlock -> ConditionBlock
|
||||||
|
link["source_id"] = store_node_id
|
||||||
|
link["source_name"] = "output"
|
||||||
|
|
||||||
|
logger.debug(f"Added StoreValueBlock before ConditionBlock {sink_id}")
|
||||||
|
|
||||||
|
if new_nodes:
|
||||||
|
agent["nodes"] = nodes + new_nodes
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_addtolist_blocks(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix AddToList blocks by adding prerequisite empty AddToList block.
|
||||||
|
|
||||||
|
When an AddToList block is found:
|
||||||
|
1. Checks if there's a CreateListBlock before it
|
||||||
|
2. Removes CreateListBlock if linked directly to AddToList
|
||||||
|
3. Adds an empty AddToList block before the original
|
||||||
|
4. Ensures the original has a self-referencing link
|
||||||
|
"""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
new_nodes = []
|
||||||
|
original_addtolist_ids = set()
|
||||||
|
nodes_to_remove = set()
|
||||||
|
links_to_remove = []
|
||||||
|
|
||||||
|
# First pass: identify CreateListBlock nodes to remove
|
||||||
|
for link in links:
|
||||||
|
source_node = next(
|
||||||
|
(n for n in nodes if n.get("id") == link.get("source_id")), None
|
||||||
|
)
|
||||||
|
sink_node = next((n for n in nodes if n.get("id") == link.get("sink_id")), None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source_node
|
||||||
|
and sink_node
|
||||||
|
and source_node.get("block_id") == CREATELIST_BLOCK_ID
|
||||||
|
and sink_node.get("block_id") == ADDTOLIST_BLOCK_ID
|
||||||
|
):
|
||||||
|
nodes_to_remove.add(source_node.get("id"))
|
||||||
|
links_to_remove.append(link)
|
||||||
|
logger.debug(f"Removing CreateListBlock {source_node.get('id')}")
|
||||||
|
|
||||||
|
# Second pass: process AddToList blocks
|
||||||
|
filtered_nodes = []
|
||||||
|
for node in nodes:
|
||||||
|
if node.get("id") in nodes_to_remove:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if node.get("block_id") == ADDTOLIST_BLOCK_ID:
|
||||||
|
original_addtolist_ids.add(node.get("id"))
|
||||||
|
node_id = node.get("id")
|
||||||
|
pos = node.get("metadata", {}).get("position", {"x": 0, "y": 0})
|
||||||
|
|
||||||
|
# Check if already has prerequisite
|
||||||
|
has_prereq = any(
|
||||||
|
link.get("sink_id") == node_id
|
||||||
|
and link.get("sink_name") == "list"
|
||||||
|
and link.get("source_name") == "updated_list"
|
||||||
|
for link in links
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_prereq:
|
||||||
|
# Remove links to "list" input (except self-reference)
|
||||||
|
for link in links:
|
||||||
|
if (
|
||||||
|
link.get("sink_id") == node_id
|
||||||
|
and link.get("sink_name") == "list"
|
||||||
|
and link.get("source_id") != node_id
|
||||||
|
and link not in links_to_remove
|
||||||
|
):
|
||||||
|
links_to_remove.append(link)
|
||||||
|
|
||||||
|
# Create prerequisite AddToList block
|
||||||
|
prereq_id = str(uuid.uuid4())
|
||||||
|
prereq_node = {
|
||||||
|
"id": prereq_id,
|
||||||
|
"block_id": ADDTOLIST_BLOCK_ID,
|
||||||
|
"input_default": {"list": [], "entry": None, "entries": []},
|
||||||
|
"metadata": {
|
||||||
|
"position": {"x": pos.get("x", 0) - 800, "y": pos.get("y", 0)}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
new_nodes.append(prereq_node)
|
||||||
|
|
||||||
|
# Link prerequisite to original
|
||||||
|
links.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"source_id": prereq_id,
|
||||||
|
"source_name": "updated_list",
|
||||||
|
"sink_id": node_id,
|
||||||
|
"sink_name": "list",
|
||||||
|
"is_static": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.debug(f"Added prerequisite AddToList block for {node_id}")
|
||||||
|
|
||||||
|
filtered_nodes.append(node)
|
||||||
|
|
||||||
|
# Remove marked links
|
||||||
|
filtered_links = [link for link in links if link not in links_to_remove]
|
||||||
|
|
||||||
|
# Add self-referencing links for original AddToList blocks
|
||||||
|
for node in filtered_nodes + new_nodes:
|
||||||
|
if (
|
||||||
|
node.get("block_id") == ADDTOLIST_BLOCK_ID
|
||||||
|
and node.get("id") in original_addtolist_ids
|
||||||
|
):
|
||||||
|
node_id = node.get("id")
|
||||||
|
has_self_ref = any(
|
||||||
|
link["source_id"] == node_id
|
||||||
|
and link["sink_id"] == node_id
|
||||||
|
and link["source_name"] == "updated_list"
|
||||||
|
and link["sink_name"] == "list"
|
||||||
|
for link in filtered_links
|
||||||
|
)
|
||||||
|
if not has_self_ref:
|
||||||
|
filtered_links.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"source_id": node_id,
|
||||||
|
"source_name": "updated_list",
|
||||||
|
"sink_id": node_id,
|
||||||
|
"sink_name": "list",
|
||||||
|
"is_static": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.debug(f"Added self-reference for AddToList {node_id}")
|
||||||
|
|
||||||
|
agent["nodes"] = filtered_nodes + new_nodes
|
||||||
|
agent["links"] = filtered_links
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_addtodictionary_blocks(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix AddToDictionary blocks by removing empty CreateDictionary nodes."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
nodes_to_remove = set()
|
||||||
|
links_to_remove = []
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
source_node = next(
|
||||||
|
(n for n in nodes if n.get("id") == link.get("source_id")), None
|
||||||
|
)
|
||||||
|
sink_node = next((n for n in nodes if n.get("id") == link.get("sink_id")), None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source_node
|
||||||
|
and sink_node
|
||||||
|
and source_node.get("block_id") == CREATEDICT_BLOCK_ID
|
||||||
|
and sink_node.get("block_id") == ADDTODICTIONARY_BLOCK_ID
|
||||||
|
):
|
||||||
|
nodes_to_remove.add(source_node.get("id"))
|
||||||
|
links_to_remove.append(link)
|
||||||
|
logger.debug(f"Removing CreateDictionary {source_node.get('id')}")
|
||||||
|
|
||||||
|
agent["nodes"] = [n for n in nodes if n.get("id") not in nodes_to_remove]
|
||||||
|
agent["links"] = [link for link in links if link not in links_to_remove]
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_code_execution_output(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix CodeExecutionBlock output: change 'response' to 'stdout_logs'."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
source_node = next(
|
||||||
|
(n for n in nodes if n.get("id") == link.get("source_id")), None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
source_node
|
||||||
|
and source_node.get("block_id") == CODE_EXECUTION_BLOCK_ID
|
||||||
|
and link.get("source_name") == "response"
|
||||||
|
):
|
||||||
|
link["source_name"] = "stdout_logs"
|
||||||
|
logger.debug("Fixed CodeExecutionBlock output: response -> stdout_logs")
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_data_sampling_sample_size(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix DataSamplingBlock by setting sample_size to 1 as default."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
links_to_remove = []
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
if node.get("block_id") == DATA_SAMPLING_BLOCK_ID:
|
||||||
|
node_id = node.get("id")
|
||||||
|
input_default = node.get("input_default", {})
|
||||||
|
|
||||||
|
# Remove links to sample_size
|
||||||
|
for link in links:
|
||||||
|
if (
|
||||||
|
link.get("sink_id") == node_id
|
||||||
|
and link.get("sink_name") == "sample_size"
|
||||||
|
):
|
||||||
|
links_to_remove.append(link)
|
||||||
|
|
||||||
|
# Set default
|
||||||
|
input_default["sample_size"] = 1
|
||||||
|
node["input_default"] = input_default
|
||||||
|
logger.debug(f"Fixed DataSamplingBlock {node_id} sample_size to 1")
|
||||||
|
|
||||||
|
if links_to_remove:
|
||||||
|
agent["links"] = [link for link in links if link not in links_to_remove]
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_node_x_coordinates(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix node x-coordinates to ensure 800+ unit spacing between linked nodes."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
node_lookup = {n.get("id"): n for n in nodes}
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
source_id = link.get("source_id")
|
||||||
|
sink_id = link.get("sink_id")
|
||||||
|
|
||||||
|
source_node = node_lookup.get(source_id)
|
||||||
|
sink_node = node_lookup.get(sink_id)
|
||||||
|
|
||||||
|
if not source_node or not sink_node:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_pos = source_node.get("metadata", {}).get("position", {})
|
||||||
|
sink_pos = sink_node.get("metadata", {}).get("position", {})
|
||||||
|
|
||||||
|
source_x = source_pos.get("x", 0)
|
||||||
|
sink_x = sink_pos.get("x", 0)
|
||||||
|
|
||||||
|
if abs(sink_x - source_x) < 800:
|
||||||
|
new_x = source_x + 800
|
||||||
|
if "metadata" not in sink_node:
|
||||||
|
sink_node["metadata"] = {}
|
||||||
|
if "position" not in sink_node["metadata"]:
|
||||||
|
sink_node["metadata"]["position"] = {}
|
||||||
|
sink_node["metadata"]["position"]["x"] = new_x
|
||||||
|
logger.debug(f"Fixed node {sink_id} x: {sink_x} -> {new_x}")
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_getcurrentdate_offset(agent: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Fix GetCurrentDateBlock offset to ensure it's positive."""
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
if node.get("block_id") == GET_CURRENT_DATE_BLOCK_ID:
|
||||||
|
input_default = node.get("input_default", {})
|
||||||
|
if "offset" in input_default:
|
||||||
|
offset = input_default["offset"]
|
||||||
|
if isinstance(offset, (int, float)) and offset < 0:
|
||||||
|
input_default["offset"] = abs(offset)
|
||||||
|
logger.debug(f"Fixed offset: {offset} -> {abs(offset)}")
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_ai_model_parameter(
|
||||||
|
agent: dict[str, Any],
|
||||||
|
blocks_info: list[dict[str, Any]],
|
||||||
|
default_model: str = "gpt-4o",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Add default model parameter to AI blocks if missing."""
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
block_id = node.get("block_id")
|
||||||
|
block = block_map.get(block_id)
|
||||||
|
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if block has AI category
|
||||||
|
categories = block.get("categories", [])
|
||||||
|
is_ai_block = any(
|
||||||
|
cat.get("category") == "AI" for cat in categories if isinstance(cat, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_ai_block:
|
||||||
|
input_default = node.get("input_default", {})
|
||||||
|
if "model" not in input_default:
|
||||||
|
input_default["model"] = default_model
|
||||||
|
node["input_default"] = input_default
|
||||||
|
logger.debug(
|
||||||
|
f"Added model '{default_model}' to AI block {node.get('id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_link_static_properties(
|
||||||
|
agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fix is_static property based on source block's staticOutput."""
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
node_lookup = {n.get("id"): n for n in agent.get("nodes", [])}
|
||||||
|
|
||||||
|
for link in agent.get("links", []):
|
||||||
|
source_node = node_lookup.get(link.get("source_id"))
|
||||||
|
if not source_node:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_block = block_map.get(source_node.get("block_id"))
|
||||||
|
if not source_block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
static_output = source_block.get("staticOutput", False)
|
||||||
|
if link.get("is_static") != static_output:
|
||||||
|
link["is_static"] = static_output
|
||||||
|
logger.debug(f"Fixed link {link.get('id')} is_static to {static_output}")
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def fix_data_type_mismatch(
|
||||||
|
agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fix data type mismatches by inserting UniversalTypeConverterBlock."""
|
||||||
|
nodes = agent.get("nodes", [])
|
||||||
|
links = agent.get("links", [])
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
node_lookup = {n.get("id"): n for n in nodes}
|
||||||
|
|
||||||
|
def get_property_type(schema: dict, name: str) -> str | None:
|
||||||
|
if "_#_" in name:
|
||||||
|
parent, child = name.split("_#_", 1)
|
||||||
|
parent_schema = schema.get(parent, {})
|
||||||
|
if "properties" in parent_schema:
|
||||||
|
return parent_schema["properties"].get(child, {}).get("type")
|
||||||
|
return None
|
||||||
|
return schema.get(name, {}).get("type")
|
||||||
|
|
||||||
|
def are_types_compatible(src: str, sink: str) -> bool:
|
||||||
|
if {src, sink} <= {"integer", "number"}:
|
||||||
|
return True
|
||||||
|
return src == sink
|
||||||
|
|
||||||
|
type_mapping = {
|
||||||
|
"string": "string",
|
||||||
|
"text": "string",
|
||||||
|
"integer": "number",
|
||||||
|
"number": "number",
|
||||||
|
"float": "number",
|
||||||
|
"boolean": "boolean",
|
||||||
|
"bool": "boolean",
|
||||||
|
"array": "list",
|
||||||
|
"list": "list",
|
||||||
|
"object": "dictionary",
|
||||||
|
"dict": "dictionary",
|
||||||
|
"dictionary": "dictionary",
|
||||||
|
}
|
||||||
|
|
||||||
|
new_links = []
|
||||||
|
nodes_to_add = []
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
source_node = node_lookup.get(link.get("source_id"))
|
||||||
|
sink_node = node_lookup.get(link.get("sink_id"))
|
||||||
|
|
||||||
|
if not source_node or not sink_node:
|
||||||
|
new_links.append(link)
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_block = block_map.get(source_node.get("block_id"))
|
||||||
|
sink_block = block_map.get(sink_node.get("block_id"))
|
||||||
|
|
||||||
|
if not source_block or not sink_block:
|
||||||
|
new_links.append(link)
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_outputs = source_block.get("outputSchema", {}).get("properties", {})
|
||||||
|
sink_inputs = sink_block.get("inputSchema", {}).get("properties", {})
|
||||||
|
|
||||||
|
source_type = get_property_type(source_outputs, link.get("source_name", ""))
|
||||||
|
sink_type = get_property_type(sink_inputs, link.get("sink_name", ""))
|
||||||
|
|
||||||
|
if (
|
||||||
|
source_type
|
||||||
|
and sink_type
|
||||||
|
and not are_types_compatible(source_type, sink_type)
|
||||||
|
):
|
||||||
|
# Insert type converter
|
||||||
|
converter_id = str(uuid.uuid4())
|
||||||
|
target_type = type_mapping.get(sink_type, sink_type)
|
||||||
|
|
||||||
|
converter_node = {
|
||||||
|
"id": converter_id,
|
||||||
|
"block_id": UNIVERSAL_TYPE_CONVERTER_BLOCK_ID,
|
||||||
|
"input_default": {"type": target_type},
|
||||||
|
"metadata": {"position": {"x": 0, "y": 100}},
|
||||||
|
}
|
||||||
|
nodes_to_add.append(converter_node)
|
||||||
|
|
||||||
|
# source -> converter
|
||||||
|
new_links.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"source_id": link["source_id"],
|
||||||
|
"source_name": link["source_name"],
|
||||||
|
"sink_id": converter_id,
|
||||||
|
"sink_name": "value",
|
||||||
|
"is_static": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# converter -> sink
|
||||||
|
new_links.append(
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"source_id": converter_id,
|
||||||
|
"source_name": "value",
|
||||||
|
"sink_id": link["sink_id"],
|
||||||
|
"sink_name": link["sink_name"],
|
||||||
|
"is_static": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Inserted type converter: {source_type} -> {target_type}")
|
||||||
|
else:
|
||||||
|
new_links.append(link)
|
||||||
|
|
||||||
|
if nodes_to_add:
|
||||||
|
agent["nodes"] = nodes + nodes_to_add
|
||||||
|
agent["links"] = new_links
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def apply_all_fixes(
|
||||||
|
agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply all fixes to an agent JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent JSON dict
|
||||||
|
blocks_info: Optional list of block info dicts for advanced fixes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fixed agent JSON
|
||||||
|
"""
|
||||||
|
# Basic fixes (no block info needed)
|
||||||
|
agent = fix_agent_ids(agent)
|
||||||
|
agent = fix_double_curly_braces(agent)
|
||||||
|
agent = fix_storevalue_before_condition(agent)
|
||||||
|
agent = fix_addtolist_blocks(agent)
|
||||||
|
agent = fix_addtodictionary_blocks(agent)
|
||||||
|
agent = fix_code_execution_output(agent)
|
||||||
|
agent = fix_data_sampling_sample_size(agent)
|
||||||
|
agent = fix_node_x_coordinates(agent)
|
||||||
|
agent = fix_getcurrentdate_offset(agent)
|
||||||
|
|
||||||
|
# Advanced fixes (require block info)
|
||||||
|
if blocks_info is None:
|
||||||
|
blocks_info = get_blocks_info()
|
||||||
|
|
||||||
|
agent = fix_ai_model_parameter(agent, blocks_info)
|
||||||
|
agent = fix_link_static_properties(agent, blocks_info)
|
||||||
|
agent = fix_data_type_mismatch(agent, blocks_info)
|
||||||
|
|
||||||
|
return agent
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Prompt templates for agent generation."""
|
||||||
|
|
||||||
|
DECOMPOSITION_PROMPT = """
|
||||||
|
You are an expert AutoGPT Workflow Decomposer. Your task is to analyze a user's high-level goal and break it down into a clear, step-by-step plan using the available blocks.
|
||||||
|
|
||||||
|
Each step should represent a distinct, automatable action suitable for execution by an AI automation system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
FIRST: Analyze the user's goal and determine:
|
||||||
|
1) Design-time configuration (fixed settings that won't change per run)
|
||||||
|
2) Runtime inputs (values the agent's end-user will provide each time it runs)
|
||||||
|
|
||||||
|
For anything that can vary per run (email addresses, names, dates, search terms, etc.):
|
||||||
|
- DO NOT ask for the actual value
|
||||||
|
- Instead, define it as an Agent Input with a clear name, type, and description
|
||||||
|
|
||||||
|
Only ask clarifying questions about design-time config that affects how you build the workflow:
|
||||||
|
- Which external service to use (e.g., "Gmail vs Outlook", "Notion vs Google Docs")
|
||||||
|
- Required formats or structures (e.g., "CSV, JSON, or PDF output?")
|
||||||
|
- Business rules that must be hard-coded
|
||||||
|
|
||||||
|
IMPORTANT CLARIFICATIONS POLICY:
|
||||||
|
- Ask no more than five essential questions
|
||||||
|
- Do not ask for concrete values that can be provided at runtime as Agent Inputs
|
||||||
|
- Do not ask for API keys or credentials; the platform handles those directly
|
||||||
|
- If there is enough information to infer reasonable defaults, prefer to propose defaults
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
GUIDELINES:
|
||||||
|
1. List each step as a numbered item
|
||||||
|
2. Describe the action clearly and specify inputs/outputs
|
||||||
|
3. Ensure steps are in logical, sequential order
|
||||||
|
4. Mention block names naturally (e.g., "Use GetWeatherByLocationBlock to...")
|
||||||
|
5. Help the user reach their goal efficiently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. OUTPUT FORMAT: Only output either clarifying questions OR step-by-step instructions, not both
|
||||||
|
2. USE ONLY THE BLOCKS PROVIDED
|
||||||
|
3. ALL required_input fields must be provided
|
||||||
|
4. Data types of linked properties must match
|
||||||
|
5. Write expert-level prompts for AI-related blocks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
CRITICAL BLOCK RESTRICTIONS:
|
||||||
|
1. AddToListBlock: Outputs updated list EVERY addition, not after all additions
|
||||||
|
2. SendEmailBlock: Draft the email for user review; set SMTP config based on email type
|
||||||
|
3. ConditionBlock: value2 is reference, value1 is contrast
|
||||||
|
4. CodeExecutionBlock: DO NOT USE - use AI blocks instead
|
||||||
|
5. ReadCsvBlock: Only use the 'rows' output, not 'row'
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
|
||||||
|
If more information is needed:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"type": "clarifying_questions",
|
||||||
|
"questions": [
|
||||||
|
{{
|
||||||
|
"question": "Which email provider should be used? (Gmail, Outlook, custom SMTP)",
|
||||||
|
"keyword": "email_provider",
|
||||||
|
"example": "Gmail"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
If ready to proceed:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{{
|
||||||
|
"step_number": 1,
|
||||||
|
"block_name": "AgentShortTextInputBlock",
|
||||||
|
"description": "Get the URL of the content to analyze.",
|
||||||
|
"inputs": [{{"name": "name", "value": "URL"}}],
|
||||||
|
"outputs": [{{"name": "result", "description": "The URL entered by user"}}]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
AVAILABLE BLOCKS:
|
||||||
|
{block_summaries}
|
||||||
|
"""
|
||||||
|
|
||||||
|
GENERATION_PROMPT = """
|
||||||
|
You are an expert AI workflow builder. Generate a valid agent JSON from the given instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
NODES:
|
||||||
|
Each node must include:
|
||||||
|
- `id`: Unique UUID v4 (e.g. `a8f5b1e2-c3d4-4e5f-8a9b-0c1d2e3f4a5b`)
|
||||||
|
- `block_id`: The block identifier (must match an Allowed Block)
|
||||||
|
- `input_default`: Dict of inputs (can be empty if no static inputs needed)
|
||||||
|
- `metadata`: Must contain:
|
||||||
|
- `position`: {{"x": number, "y": number}} - adjacent nodes should differ by 800+ in X
|
||||||
|
- `customized_name`: Clear name describing this block's purpose in the workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
LINKS:
|
||||||
|
Each link connects a source node's output to a sink node's input:
|
||||||
|
- `id`: MUST be UUID v4 (NOT "link-1", "link-2", etc.)
|
||||||
|
- `source_id`: ID of the source node
|
||||||
|
- `source_name`: Output field name from the source block
|
||||||
|
- `sink_id`: ID of the sink node
|
||||||
|
- `sink_name`: Input field name on the sink block
|
||||||
|
- `is_static`: true only if source block has static_output: true
|
||||||
|
|
||||||
|
CRITICAL: All IDs must be valid UUID v4 format!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
AGENT (GRAPH):
|
||||||
|
Wrap nodes and links in:
|
||||||
|
- `id`: UUID of the agent
|
||||||
|
- `name`: Short, generic name (avoid specific company names, URLs)
|
||||||
|
- `description`: Short, generic description
|
||||||
|
- `nodes`: List of all nodes
|
||||||
|
- `links`: List of all links
|
||||||
|
- `version`: 1
|
||||||
|
- `is_active`: true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TIPS:
|
||||||
|
- All required_input fields must be provided via input_default or a valid link
|
||||||
|
- Ensure consistent source_id and sink_id references
|
||||||
|
- Avoid dangling links
|
||||||
|
- Input/output pins must match block schemas
|
||||||
|
- Do not invent unknown block_ids
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ALLOWED BLOCKS:
|
||||||
|
{block_summaries}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate the complete agent JSON. Output ONLY valid JSON, no explanation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PATCH_PROMPT = """
|
||||||
|
You are an expert at modifying AutoGPT agent workflows. Given the current agent and a modification request, generate a JSON patch to update the agent.
|
||||||
|
|
||||||
|
CURRENT AGENT:
|
||||||
|
{current_agent}
|
||||||
|
|
||||||
|
AVAILABLE BLOCKS:
|
||||||
|
{block_summaries}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
PATCH FORMAT:
|
||||||
|
Return a JSON object with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"type": "patch",
|
||||||
|
"intent": "Brief description of what the patch does",
|
||||||
|
"patches": [
|
||||||
|
{{
|
||||||
|
"type": "modify",
|
||||||
|
"node_id": "uuid-of-node-to-modify",
|
||||||
|
"changes": {{
|
||||||
|
"input_default": {{"field": "new_value"}},
|
||||||
|
"metadata": {{"customized_name": "New Name"}}
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"type": "add",
|
||||||
|
"new_nodes": [
|
||||||
|
{{
|
||||||
|
"id": "new-uuid",
|
||||||
|
"block_id": "block-uuid",
|
||||||
|
"input_default": {{}},
|
||||||
|
"metadata": {{"position": {{"x": 0, "y": 0}}, "customized_name": "Name"}}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"new_links": [
|
||||||
|
{{
|
||||||
|
"id": "link-uuid",
|
||||||
|
"source_id": "source-node-id",
|
||||||
|
"source_name": "output_field",
|
||||||
|
"sink_id": "sink-node-id",
|
||||||
|
"sink_name": "input_field"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"type": "remove",
|
||||||
|
"node_ids": ["uuid-of-node-to-remove"],
|
||||||
|
"link_ids": ["uuid-of-link-to-remove"]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need more information, return:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"type": "clarifying_questions",
|
||||||
|
"questions": [
|
||||||
|
{{
|
||||||
|
"question": "What specific change do you want?",
|
||||||
|
"keyword": "change_type",
|
||||||
|
"example": "Add error handling"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the minimal patch needed. Output ONLY valid JSON.
|
||||||
|
"""
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
"""External Agent Generator service client.
|
|
||||||
|
|
||||||
This module provides a client for communicating with the external Agent Generator
|
|
||||||
microservice. When AGENTGENERATOR_HOST is configured, the agent generation functions
|
|
||||||
will delegate to the external service instead of using the built-in LLM-based implementation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from backend.util.settings import Settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_client: httpx.AsyncClient | None = None
|
|
||||||
_settings: Settings | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_settings() -> Settings:
|
|
||||||
"""Get or create settings singleton."""
|
|
||||||
global _settings
|
|
||||||
if _settings is None:
|
|
||||||
_settings = Settings()
|
|
||||||
return _settings
|
|
||||||
|
|
||||||
|
|
||||||
def is_external_service_configured() -> bool:
|
|
||||||
"""Check if external Agent Generator service is configured."""
|
|
||||||
settings = _get_settings()
|
|
||||||
return bool(settings.config.agentgenerator_host)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_base_url() -> str:
|
|
||||||
"""Get the base URL for the external service."""
|
|
||||||
settings = _get_settings()
|
|
||||||
host = settings.config.agentgenerator_host
|
|
||||||
port = settings.config.agentgenerator_port
|
|
||||||
return f"http://{host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> httpx.AsyncClient:
|
|
||||||
"""Get or create the HTTP client for the external service."""
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
settings = _get_settings()
|
|
||||||
_client = httpx.AsyncClient(
|
|
||||||
base_url=_get_base_url(),
|
|
||||||
timeout=httpx.Timeout(settings.config.agentgenerator_timeout),
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
async def decompose_goal_external(
|
|
||||||
description: str, context: str = ""
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Call the external service to decompose a goal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Natural language goal description
|
|
||||||
context: Additional context (e.g., answers to previous questions)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with either:
|
|
||||||
- {"type": "clarifying_questions", "questions": [...]}
|
|
||||||
- {"type": "instructions", "steps": [...]}
|
|
||||||
- {"type": "unachievable_goal", ...}
|
|
||||||
- {"type": "vague_goal", ...}
|
|
||||||
Or None on error
|
|
||||||
"""
|
|
||||||
client = _get_client()
|
|
||||||
|
|
||||||
# Build the request payload
|
|
||||||
payload: dict[str, Any] = {"description": description}
|
|
||||||
if context:
|
|
||||||
# The external service uses user_instruction for additional context
|
|
||||||
payload["user_instruction"] = context
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.post("/api/decompose-description", json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get("success"):
|
|
||||||
logger.error(f"External service returned error: {data.get('error')}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Map the response to the expected format
|
|
||||||
response_type = data.get("type")
|
|
||||||
if response_type == "instructions":
|
|
||||||
return {"type": "instructions", "steps": data.get("steps", [])}
|
|
||||||
elif response_type == "clarifying_questions":
|
|
||||||
return {
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": data.get("questions", []),
|
|
||||||
}
|
|
||||||
elif response_type == "unachievable_goal":
|
|
||||||
return {
|
|
||||||
"type": "unachievable_goal",
|
|
||||||
"reason": data.get("reason"),
|
|
||||||
"suggested_goal": data.get("suggested_goal"),
|
|
||||||
}
|
|
||||||
elif response_type == "vague_goal":
|
|
||||||
return {
|
|
||||||
"type": "vague_goal",
|
|
||||||
"suggested_goal": data.get("suggested_goal"),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Unknown response type from external service: {response_type}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Request error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_external(
|
|
||||||
instructions: dict[str, Any]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Call the external service to generate an agent from instructions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instructions: Structured instructions from decompose_goal
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Agent JSON dict or None on error
|
|
||||||
"""
|
|
||||||
client = _get_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/generate-agent", json={"instructions": instructions}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get("success"):
|
|
||||||
logger.error(f"External service returned error: {data.get('error')}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return data.get("agent_json")
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Request error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_patch_external(
|
|
||||||
update_request: str, current_agent: dict[str, Any]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Call the external service to generate a patch for an existing agent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
update_request: Natural language description of changes
|
|
||||||
current_agent: Current agent JSON
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated agent JSON, clarifying questions dict, or None on error
|
|
||||||
"""
|
|
||||||
client = _get_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/update-agent",
|
|
||||||
json={
|
|
||||||
"update_request": update_request,
|
|
||||||
"current_agent_json": current_agent,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get("success"):
|
|
||||||
logger.error(f"External service returned error: {data.get('error')}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if it's clarifying questions
|
|
||||||
if data.get("type") == "clarifying_questions":
|
|
||||||
return {
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": data.get("questions", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Otherwise return the updated agent JSON
|
|
||||||
return data.get("agent_json")
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Request error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error calling external agent generator: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_blocks_external() -> list[dict[str, Any]] | None:
|
|
||||||
"""Get available blocks from the external service.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of block info dicts or None on error
|
|
||||||
"""
|
|
||||||
client = _get_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.get("/api/blocks")
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get("success"):
|
|
||||||
logger.error("External service returned error getting blocks")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return data.get("blocks", [])
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error getting blocks from external service: {e}")
|
|
||||||
return None
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Request error getting blocks from external service: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error getting blocks from external service: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def health_check() -> bool:
|
|
||||||
"""Check if the external service is healthy.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if healthy, False otherwise
|
|
||||||
"""
|
|
||||||
if not is_external_service_configured():
|
|
||||||
return False
|
|
||||||
|
|
||||||
client = _get_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await client.get("/health")
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("status") == "healthy" and data.get("blocks_loaded", False)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"External agent generator health check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def close_client() -> None:
|
|
||||||
"""Close the HTTP client."""
|
|
||||||
global _client
|
|
||||||
if _client is not None:
|
|
||||||
await _client.aclose()
|
|
||||||
_client = None
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Utilities for agent generation."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.block import get_blocks
|
||||||
|
|
||||||
|
# UUID validation regex
|
||||||
|
UUID_REGEX = re.compile(
|
||||||
|
r"^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Block IDs for various fixes
|
||||||
|
STORE_VALUE_BLOCK_ID = "1ff065e9-88e8-4358-9d82-8dc91f622ba9"
|
||||||
|
CONDITION_BLOCK_ID = "715696a0-e1da-45c8-b209-c2fa9c3b0be6"
|
||||||
|
ADDTOLIST_BLOCK_ID = "aeb08fc1-2fc1-4141-bc8e-f758f183a822"
|
||||||
|
ADDTODICTIONARY_BLOCK_ID = "31d1064e-7446-4693-a7d4-65e5ca1180d1"
|
||||||
|
CREATELIST_BLOCK_ID = "a912d5c7-6e00-4542-b2a9-8034136930e4"
|
||||||
|
CREATEDICT_BLOCK_ID = "b924ddf4-de4f-4b56-9a85-358930dcbc91"
|
||||||
|
CODE_EXECUTION_BLOCK_ID = "0b02b072-abe7-11ef-8372-fb5d162dd712"
|
||||||
|
DATA_SAMPLING_BLOCK_ID = "4a448883-71fa-49cf-91cf-70d793bd7d87"
|
||||||
|
UNIVERSAL_TYPE_CONVERTER_BLOCK_ID = "95d1b990-ce13-4d88-9737-ba5c2070c97b"
|
||||||
|
GET_CURRENT_DATE_BLOCK_ID = "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1"
|
||||||
|
|
||||||
|
DOUBLE_CURLY_BRACES_BLOCK_IDS = [
|
||||||
|
"44f6c8ad-d75c-4ae1-8209-aad1c0326928", # FillTextTemplateBlock
|
||||||
|
"6ab085e2-20b3-4055-bc3e-08036e01eca6",
|
||||||
|
"90f8c45e-e983-4644-aa0b-b4ebe2f531bc",
|
||||||
|
"363ae599-353e-4804-937e-b2ee3cef3da4", # AgentOutputBlock
|
||||||
|
"3b191d9f-356f-482d-8238-ba04b6d18381",
|
||||||
|
"db7d8f02-2f44-4c55-ab7a-eae0941f0c30",
|
||||||
|
"3a7c4b8d-6e2f-4a5d-b9c1-f8d23c5a9b0e",
|
||||||
|
"ed1ae7a0-b770-4089-b520-1f0005fad19a",
|
||||||
|
"a892b8d9-3e4e-4e9c-9c1e-75f8efcf1bfa",
|
||||||
|
"b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1",
|
||||||
|
"716a67b3-6760-42e7-86dc-18645c6e00fc",
|
||||||
|
"530cf046-2ce0-4854-ae2c-659db17c7a46",
|
||||||
|
"ed55ac19-356e-4243-a6cb-bc599e9b716f",
|
||||||
|
"1f292d4a-41a4-4977-9684-7c8d560b9f91", # LLM blocks
|
||||||
|
"32a87eab-381e-4dd4-bdb8-4c47151be35a",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_uuid(value: str) -> bool:
|
||||||
|
"""Check if a string is a valid UUID v4."""
|
||||||
|
return isinstance(value, str) and UUID_REGEX.match(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_schema(schema: dict) -> dict[str, str]:
|
||||||
|
"""Extract compact type info from a JSON schema properties dict.
|
||||||
|
|
||||||
|
Returns a dict of {field_name: type_string} for essential info only.
|
||||||
|
"""
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for name, prop in props.items():
|
||||||
|
# Skip internal/complex fields
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get type string
|
||||||
|
type_str = prop.get("type", "any")
|
||||||
|
|
||||||
|
# Handle anyOf/oneOf (optional types)
|
||||||
|
if "anyOf" in prop:
|
||||||
|
types = [t.get("type", "?") for t in prop["anyOf"] if t.get("type")]
|
||||||
|
type_str = "|".join(types) if types else "any"
|
||||||
|
elif "allOf" in prop:
|
||||||
|
type_str = "object"
|
||||||
|
|
||||||
|
# Add array item type if present
|
||||||
|
if type_str == "array" and "items" in prop:
|
||||||
|
items = prop["items"]
|
||||||
|
if isinstance(items, dict):
|
||||||
|
item_type = items.get("type", "any")
|
||||||
|
type_str = f"array[{item_type}]"
|
||||||
|
|
||||||
|
result[name] = type_str
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_block_summaries(include_schemas: bool = True) -> str:
|
||||||
|
"""Generate compact block summaries for prompts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_schemas: Whether to include input/output type info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string of block summaries (compact format)
|
||||||
|
"""
|
||||||
|
blocks = get_blocks()
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
for block_id, block_cls in blocks.items():
|
||||||
|
block = block_cls()
|
||||||
|
name = block.name
|
||||||
|
desc = getattr(block, "description", "") or ""
|
||||||
|
|
||||||
|
# Truncate description
|
||||||
|
if len(desc) > 150:
|
||||||
|
desc = desc[:147] + "..."
|
||||||
|
|
||||||
|
if not include_schemas:
|
||||||
|
summaries.append(f"- {name} (id: {block_id}): {desc}")
|
||||||
|
else:
|
||||||
|
# Compact format with type info only
|
||||||
|
inputs = {}
|
||||||
|
outputs = {}
|
||||||
|
required = []
|
||||||
|
|
||||||
|
if hasattr(block, "input_schema"):
|
||||||
|
try:
|
||||||
|
schema = block.input_schema.jsonschema()
|
||||||
|
inputs = _compact_schema(schema)
|
||||||
|
required = schema.get("required", [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if hasattr(block, "output_schema"):
|
||||||
|
try:
|
||||||
|
schema = block.output_schema.jsonschema()
|
||||||
|
outputs = _compact_schema(schema)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build compact line format
|
||||||
|
# Format: NAME (id): desc | in: {field:type, ...} [required] | out: {field:type}
|
||||||
|
in_str = ", ".join(f"{k}:{v}" for k, v in inputs.items())
|
||||||
|
out_str = ", ".join(f"{k}:{v}" for k, v in outputs.items())
|
||||||
|
req_str = f" req=[{','.join(required)}]" if required else ""
|
||||||
|
|
||||||
|
static = " [static]" if getattr(block, "static_output", False) else ""
|
||||||
|
|
||||||
|
line = f"- {name} (id: {block_id}): {desc}"
|
||||||
|
if in_str:
|
||||||
|
line += f"\n in: {{{in_str}}}{req_str}"
|
||||||
|
if out_str:
|
||||||
|
line += f"\n out: {{{out_str}}}{static}"
|
||||||
|
|
||||||
|
summaries.append(line)
|
||||||
|
|
||||||
|
return "\n".join(summaries)
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocks_info() -> list[dict[str, Any]]:
|
||||||
|
"""Get block information with schemas for validation and fixing."""
|
||||||
|
blocks = get_blocks()
|
||||||
|
blocks_info = []
|
||||||
|
for block_id, block_cls in blocks.items():
|
||||||
|
block = block_cls()
|
||||||
|
blocks_info.append(
|
||||||
|
{
|
||||||
|
"id": block_id,
|
||||||
|
"name": block.name,
|
||||||
|
"description": getattr(block, "description", ""),
|
||||||
|
"categories": getattr(block, "categories", []),
|
||||||
|
"staticOutput": getattr(block, "static_output", False),
|
||||||
|
"inputSchema": (
|
||||||
|
block.input_schema.jsonschema()
|
||||||
|
if hasattr(block, "input_schema")
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
"outputSchema": (
|
||||||
|
block.output_schema.jsonschema()
|
||||||
|
if hasattr(block, "output_schema")
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return blocks_info
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_from_llm(text: str) -> dict[str, Any] | None:
|
||||||
|
"""Extract JSON from LLM response (handles markdown code blocks)."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try fenced code block
|
||||||
|
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1).strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try raw text
|
||||||
|
try:
|
||||||
|
return json.loads(text.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try finding {...} span
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start != -1 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try finding [...] span
|
||||||
|
start = text.find("[")
|
||||||
|
end = text.rfind("]")
|
||||||
|
if start != -1 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Agent validator - Validates agent structure and connections."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .utils import get_blocks_info
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentValidator:
|
||||||
|
"""Validator for AutoGPT agents with detailed error reporting."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def add_error(self, error: str) -> None:
|
||||||
|
"""Add an error message."""
|
||||||
|
self.errors.append(error)
|
||||||
|
|
||||||
|
def validate_block_existence(
|
||||||
|
self, agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> bool:
|
||||||
|
"""Validate all block IDs exist in the blocks library."""
|
||||||
|
valid = True
|
||||||
|
valid_block_ids = {b.get("id") for b in blocks_info if b.get("id")}
|
||||||
|
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
block_id = node.get("block_id")
|
||||||
|
node_id = node.get("id")
|
||||||
|
|
||||||
|
if not block_id:
|
||||||
|
self.add_error(f"Node '{node_id}' is missing 'block_id' field.")
|
||||||
|
valid = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if block_id not in valid_block_ids:
|
||||||
|
self.add_error(
|
||||||
|
f"Node '{node_id}' references block_id '{block_id}' which does not exist."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate_link_node_references(self, agent: dict[str, Any]) -> bool:
|
||||||
|
"""Validate all node IDs referenced in links exist."""
|
||||||
|
valid = True
|
||||||
|
valid_node_ids = {n.get("id") for n in agent.get("nodes", []) if n.get("id")}
|
||||||
|
|
||||||
|
for link in agent.get("links", []):
|
||||||
|
link_id = link.get("id", "Unknown")
|
||||||
|
source_id = link.get("source_id")
|
||||||
|
sink_id = link.get("sink_id")
|
||||||
|
|
||||||
|
if not source_id:
|
||||||
|
self.add_error(f"Link '{link_id}' is missing 'source_id'.")
|
||||||
|
valid = False
|
||||||
|
elif source_id not in valid_node_ids:
|
||||||
|
self.add_error(
|
||||||
|
f"Link '{link_id}' references non-existent source_id '{source_id}'."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
if not sink_id:
|
||||||
|
self.add_error(f"Link '{link_id}' is missing 'sink_id'.")
|
||||||
|
valid = False
|
||||||
|
elif sink_id not in valid_node_ids:
|
||||||
|
self.add_error(
|
||||||
|
f"Link '{link_id}' references non-existent sink_id '{sink_id}'."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate_required_inputs(
|
||||||
|
self, agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> bool:
|
||||||
|
"""Validate required inputs are provided."""
|
||||||
|
valid = True
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
block_id = node.get("block_id")
|
||||||
|
block = block_map.get(block_id)
|
||||||
|
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
required_inputs = block.get("inputSchema", {}).get("required", [])
|
||||||
|
input_defaults = node.get("input_default", {})
|
||||||
|
node_id = node.get("id")
|
||||||
|
|
||||||
|
# Get linked inputs
|
||||||
|
linked_inputs = {
|
||||||
|
link["sink_name"]
|
||||||
|
for link in agent.get("links", [])
|
||||||
|
if link.get("sink_id") == node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
for req_input in required_inputs:
|
||||||
|
if (
|
||||||
|
req_input not in input_defaults
|
||||||
|
and req_input not in linked_inputs
|
||||||
|
and req_input != "credentials"
|
||||||
|
):
|
||||||
|
block_name = block.get("name", "Unknown Block")
|
||||||
|
self.add_error(
|
||||||
|
f"Node '{node_id}' ({block_name}) is missing required input '{req_input}'."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate_data_type_compatibility(
|
||||||
|
self, agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> bool:
|
||||||
|
"""Validate linked data types are compatible."""
|
||||||
|
valid = True
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
node_lookup = {n.get("id"): n for n in agent.get("nodes", [])}
|
||||||
|
|
||||||
|
def get_type(schema: dict, name: str) -> str | None:
|
||||||
|
if "_#_" in name:
|
||||||
|
parent, child = name.split("_#_", 1)
|
||||||
|
parent_schema = schema.get(parent, {})
|
||||||
|
if "properties" in parent_schema:
|
||||||
|
return parent_schema["properties"].get(child, {}).get("type")
|
||||||
|
return None
|
||||||
|
return schema.get(name, {}).get("type")
|
||||||
|
|
||||||
|
def are_compatible(src: str, sink: str) -> bool:
|
||||||
|
if {src, sink} <= {"integer", "number"}:
|
||||||
|
return True
|
||||||
|
return src == sink
|
||||||
|
|
||||||
|
for link in agent.get("links", []):
|
||||||
|
source_node = node_lookup.get(link.get("source_id"))
|
||||||
|
sink_node = node_lookup.get(link.get("sink_id"))
|
||||||
|
|
||||||
|
if not source_node or not sink_node:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_block = block_map.get(source_node.get("block_id"))
|
||||||
|
sink_block = block_map.get(sink_node.get("block_id"))
|
||||||
|
|
||||||
|
if not source_block or not sink_block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_outputs = source_block.get("outputSchema", {}).get("properties", {})
|
||||||
|
sink_inputs = sink_block.get("inputSchema", {}).get("properties", {})
|
||||||
|
|
||||||
|
source_type = get_type(source_outputs, link.get("source_name", ""))
|
||||||
|
sink_type = get_type(sink_inputs, link.get("sink_name", ""))
|
||||||
|
|
||||||
|
if source_type and sink_type and not are_compatible(source_type, sink_type):
|
||||||
|
self.add_error(
|
||||||
|
f"Type mismatch: {source_block.get('name')} output '{link['source_name']}' "
|
||||||
|
f"({source_type}) -> {sink_block.get('name')} input '{link['sink_name']}' ({sink_type})."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate_nested_sink_links(
|
||||||
|
self, agent: dict[str, Any], blocks_info: list[dict[str, Any]]
|
||||||
|
) -> bool:
|
||||||
|
"""Validate nested sink links (with _#_ notation)."""
|
||||||
|
valid = True
|
||||||
|
block_map = {b.get("id"): b for b in blocks_info}
|
||||||
|
node_lookup = {n.get("id"): n for n in agent.get("nodes", [])}
|
||||||
|
|
||||||
|
for link in agent.get("links", []):
|
||||||
|
sink_name = link.get("sink_name", "")
|
||||||
|
|
||||||
|
if "_#_" in sink_name:
|
||||||
|
parent, child = sink_name.split("_#_", 1)
|
||||||
|
|
||||||
|
sink_node = node_lookup.get(link.get("sink_id"))
|
||||||
|
if not sink_node:
|
||||||
|
continue
|
||||||
|
|
||||||
|
block = block_map.get(sink_node.get("block_id"))
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
|
||||||
|
input_props = block.get("inputSchema", {}).get("properties", {})
|
||||||
|
parent_schema = input_props.get(parent)
|
||||||
|
|
||||||
|
if not parent_schema:
|
||||||
|
self.add_error(
|
||||||
|
f"Invalid nested link '{sink_name}': parent '{parent}' not found."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not parent_schema.get("additionalProperties"):
|
||||||
|
if not (
|
||||||
|
isinstance(parent_schema, dict)
|
||||||
|
and "properties" in parent_schema
|
||||||
|
and child in parent_schema.get("properties", {})
|
||||||
|
):
|
||||||
|
self.add_error(
|
||||||
|
f"Invalid nested link '{sink_name}': child '{child}' not found in '{parent}'."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate_prompt_spaces(self, agent: dict[str, Any]) -> bool:
|
||||||
|
"""Validate prompts don't have spaces in template variables."""
|
||||||
|
valid = True
|
||||||
|
|
||||||
|
for node in agent.get("nodes", []):
|
||||||
|
input_default = node.get("input_default", {})
|
||||||
|
prompt = input_default.get("prompt", "")
|
||||||
|
|
||||||
|
if not isinstance(prompt, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find {{...}} with spaces
|
||||||
|
matches = re.finditer(r"\{\{([^}]+)\}\}", prompt)
|
||||||
|
for match in matches:
|
||||||
|
content = match.group(1)
|
||||||
|
if " " in content:
|
||||||
|
self.add_error(
|
||||||
|
f"Node '{node.get('id')}' has spaces in template variable: "
|
||||||
|
f"'{{{{{content}}}}}' should be '{{{{{content.replace(' ', '_')}}}}}'."
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Run all validations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
if blocks_info is None:
|
||||||
|
blocks_info = get_blocks_info()
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
self.validate_block_existence(agent, blocks_info),
|
||||||
|
self.validate_link_node_references(agent),
|
||||||
|
self.validate_required_inputs(agent, blocks_info),
|
||||||
|
self.validate_data_type_compatibility(agent, blocks_info),
|
||||||
|
self.validate_nested_sink_links(agent, blocks_info),
|
||||||
|
self.validate_prompt_spaces(agent),
|
||||||
|
]
|
||||||
|
|
||||||
|
all_passed = all(checks)
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
logger.info("Agent validation successful")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
error_message = "Agent validation failed:\n"
|
||||||
|
for i, error in enumerate(self.errors, 1):
|
||||||
|
error_message += f"{i}. {error}\n"
|
||||||
|
|
||||||
|
logger.warning(f"Agent validation failed with {len(self.errors)} errors")
|
||||||
|
return False, error_message
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent(
|
||||||
|
agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Convenience function to validate an agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
validator = AgentValidator()
|
||||||
|
return validator.validate(agent, blocks_info)
|
||||||
@@ -8,10 +8,12 @@ from langfuse import observe
|
|||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_generator import (
|
from .agent_generator import (
|
||||||
AgentGeneratorNotConfiguredError,
|
apply_all_fixes,
|
||||||
decompose_goal,
|
decompose_goal,
|
||||||
generate_agent,
|
generate_agent,
|
||||||
|
get_blocks_info,
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
|
validate_agent,
|
||||||
)
|
)
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -25,6 +27,9 @@ from .models import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum retries for agent generation with validation feedback
|
||||||
|
MAX_GENERATION_RETRIES = 2
|
||||||
|
|
||||||
|
|
||||||
class CreateAgentTool(BaseTool):
|
class CreateAgentTool(BaseTool):
|
||||||
"""Tool for creating agents from natural language descriptions."""
|
"""Tool for creating agents from natural language descriptions."""
|
||||||
@@ -86,8 +91,9 @@ class CreateAgentTool(BaseTool):
|
|||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Decompose the description into steps (may return clarifying questions)
|
1. Decompose the description into steps (may return clarifying questions)
|
||||||
2. Generate agent JSON (external service handles fixing and validation)
|
2. Generate agent JSON from the steps
|
||||||
3. Preview or save based on the save parameter
|
3. Apply fixes to correct common LLM errors
|
||||||
|
4. Preview or save based on the save parameter
|
||||||
"""
|
"""
|
||||||
description = kwargs.get("description", "").strip()
|
description = kwargs.get("description", "").strip()
|
||||||
context = kwargs.get("context", "")
|
context = kwargs.get("context", "")
|
||||||
@@ -104,13 +110,11 @@ class CreateAgentTool(BaseTool):
|
|||||||
# Step 1: Decompose goal into steps
|
# Step 1: Decompose goal into steps
|
||||||
try:
|
try:
|
||||||
decomposition_result = await decompose_goal(description, context)
|
decomposition_result = await decompose_goal(description, context)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except ValueError as e:
|
||||||
|
# Handle missing API key or configuration errors
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=f"Agent generation is not configured: {str(e)}",
|
||||||
"Agent generation is not available. "
|
error="configuration_error",
|
||||||
"The Agent Generator service is not configured."
|
|
||||||
),
|
|
||||||
error="service_not_configured",
|
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -167,32 +171,72 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Generate agent JSON (external service handles fixing and validation)
|
# Step 2: Generate agent JSON with retry on validation failure
|
||||||
try:
|
blocks_info = get_blocks_info()
|
||||||
agent_json = await generate_agent(decomposition_result)
|
agent_json = None
|
||||||
except AgentGeneratorNotConfiguredError:
|
validation_errors = None
|
||||||
return ErrorResponse(
|
|
||||||
message=(
|
for attempt in range(MAX_GENERATION_RETRIES + 1):
|
||||||
"Agent generation is not available. "
|
# Generate agent (include validation errors from previous attempt)
|
||||||
"The Agent Generator service is not configured."
|
if attempt == 0:
|
||||||
),
|
agent_json = await generate_agent(decomposition_result)
|
||||||
error="service_not_configured",
|
else:
|
||||||
session_id=session_id,
|
# Retry with validation error feedback
|
||||||
|
logger.info(
|
||||||
|
f"Retry {attempt}/{MAX_GENERATION_RETRIES} with validation feedback"
|
||||||
|
)
|
||||||
|
retry_instructions = {
|
||||||
|
**decomposition_result,
|
||||||
|
"previous_errors": validation_errors,
|
||||||
|
"retry_instructions": (
|
||||||
|
"The previous generation had validation errors. "
|
||||||
|
"Please fix these issues in the new generation:\n"
|
||||||
|
f"{validation_errors}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
agent_json = await generate_agent(retry_instructions)
|
||||||
|
|
||||||
|
if agent_json is None:
|
||||||
|
if attempt == MAX_GENERATION_RETRIES:
|
||||||
|
return ErrorResponse(
|
||||||
|
message="Failed to generate the agent. Please try again.",
|
||||||
|
error="Generation failed",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Step 3: Apply fixes to correct common errors
|
||||||
|
agent_json = apply_all_fixes(agent_json, blocks_info)
|
||||||
|
|
||||||
|
# Step 4: Validate the agent
|
||||||
|
is_valid, validation_errors = validate_agent(agent_json, blocks_info)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
logger.info(f"Agent generated successfully on attempt {attempt + 1}")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Validation failed on attempt {attempt + 1}: {validation_errors}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if agent_json is None:
|
if attempt == MAX_GENERATION_RETRIES:
|
||||||
return ErrorResponse(
|
# Return error with validation details
|
||||||
message="Failed to generate the agent. Please try again.",
|
return ErrorResponse(
|
||||||
error="Generation failed",
|
message=(
|
||||||
session_id=session_id,
|
f"Generated agent has validation errors after {MAX_GENERATION_RETRIES + 1} attempts. "
|
||||||
)
|
f"Please try rephrasing your request or simplify the workflow."
|
||||||
|
),
|
||||||
|
error="validation_failed",
|
||||||
|
details={"validation_errors": validation_errors},
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
agent_name = agent_json.get("name", "Generated Agent")
|
agent_name = agent_json.get("name", "Generated Agent")
|
||||||
agent_description = agent_json.get("description", "")
|
agent_description = agent_json.get("description", "")
|
||||||
node_count = len(agent_json.get("nodes", []))
|
node_count = len(agent_json.get("nodes", []))
|
||||||
link_count = len(agent_json.get("links", []))
|
link_count = len(agent_json.get("links", []))
|
||||||
|
|
||||||
# Step 3: Preview or save
|
# Step 4: Preview or save
|
||||||
if not save:
|
if not save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ from langfuse import observe
|
|||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_generator import (
|
from .agent_generator import (
|
||||||
AgentGeneratorNotConfiguredError,
|
apply_agent_patch,
|
||||||
|
apply_all_fixes,
|
||||||
generate_agent_patch,
|
generate_agent_patch,
|
||||||
get_agent_as_json,
|
get_agent_as_json,
|
||||||
|
get_blocks_info,
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
|
validate_agent,
|
||||||
)
|
)
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -25,6 +28,9 @@ from .models import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum retries for patch generation with validation feedback
|
||||||
|
MAX_GENERATION_RETRIES = 2
|
||||||
|
|
||||||
|
|
||||||
class EditAgentTool(BaseTool):
|
class EditAgentTool(BaseTool):
|
||||||
"""Tool for editing existing agents using natural language."""
|
"""Tool for editing existing agents using natural language."""
|
||||||
@@ -37,7 +43,7 @@ class EditAgentTool(BaseTool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Edit an existing agent from the user's library using natural language. "
|
"Edit an existing agent from the user's library using natural language. "
|
||||||
"Generates updates to the agent while preserving unchanged parts."
|
"Generates a patch to update the agent while preserving unchanged parts."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -92,8 +98,9 @@ class EditAgentTool(BaseTool):
|
|||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Fetch the current agent
|
1. Fetch the current agent
|
||||||
2. Generate updated agent (external service handles fixing and validation)
|
2. Generate a patch based on the requested changes
|
||||||
3. Preview or save based on the save parameter
|
3. Apply the patch to create an updated agent
|
||||||
|
4. Preview or save based on the save parameter
|
||||||
"""
|
"""
|
||||||
agent_id = kwargs.get("agent_id", "").strip()
|
agent_id = kwargs.get("agent_id", "").strip()
|
||||||
changes = kwargs.get("changes", "").strip()
|
changes = kwargs.get("changes", "").strip()
|
||||||
@@ -130,58 +137,121 @@ class EditAgentTool(BaseTool):
|
|||||||
if context:
|
if context:
|
||||||
update_request = f"{changes}\n\nAdditional context:\n{context}"
|
update_request = f"{changes}\n\nAdditional context:\n{context}"
|
||||||
|
|
||||||
# Step 2: Generate updated agent (external service handles fixing and validation)
|
# Step 2: Generate patch with retry on validation failure
|
||||||
try:
|
blocks_info = get_blocks_info()
|
||||||
result = await generate_agent_patch(update_request, current_agent)
|
updated_agent = None
|
||||||
except AgentGeneratorNotConfiguredError:
|
validation_errors = None
|
||||||
return ErrorResponse(
|
intent = "Applied requested changes"
|
||||||
message=(
|
|
||||||
"Agent editing is not available. "
|
|
||||||
"The Agent Generator service is not configured."
|
|
||||||
),
|
|
||||||
error="service_not_configured",
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result is None:
|
for attempt in range(MAX_GENERATION_RETRIES + 1):
|
||||||
return ErrorResponse(
|
# Generate patch (include validation errors from previous attempt)
|
||||||
message="Failed to generate changes. Please try rephrasing.",
|
try:
|
||||||
error="Update generation failed",
|
if attempt == 0:
|
||||||
session_id=session_id,
|
patch_result = await generate_agent_patch(
|
||||||
)
|
update_request, current_agent
|
||||||
|
|
||||||
# Check if LLM returned clarifying questions
|
|
||||||
if result.get("type") == "clarifying_questions":
|
|
||||||
questions = result.get("questions", [])
|
|
||||||
return ClarificationNeededResponse(
|
|
||||||
message=(
|
|
||||||
"I need some more information about the changes. "
|
|
||||||
"Please answer the following questions:"
|
|
||||||
),
|
|
||||||
questions=[
|
|
||||||
ClarifyingQuestion(
|
|
||||||
question=q.get("question", ""),
|
|
||||||
keyword=q.get("keyword", ""),
|
|
||||||
example=q.get("example"),
|
|
||||||
)
|
)
|
||||||
for q in questions
|
else:
|
||||||
],
|
# Retry with validation error feedback
|
||||||
session_id=session_id,
|
logger.info(
|
||||||
|
f"Retry {attempt}/{MAX_GENERATION_RETRIES} with validation feedback"
|
||||||
|
)
|
||||||
|
retry_request = (
|
||||||
|
f"{update_request}\n\n"
|
||||||
|
f"IMPORTANT: The previous edit had validation errors. "
|
||||||
|
f"Please fix these issues:\n{validation_errors}"
|
||||||
|
)
|
||||||
|
patch_result = await generate_agent_patch(
|
||||||
|
retry_request, current_agent
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Handle missing API key or configuration errors
|
||||||
|
return ErrorResponse(
|
||||||
|
message=f"Agent generation is not configured: {str(e)}",
|
||||||
|
error="configuration_error",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if patch_result is None:
|
||||||
|
if attempt == MAX_GENERATION_RETRIES:
|
||||||
|
return ErrorResponse(
|
||||||
|
message="Failed to generate changes. Please try rephrasing.",
|
||||||
|
error="Patch generation failed",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if LLM returned clarifying questions
|
||||||
|
if patch_result.get("type") == "clarifying_questions":
|
||||||
|
questions = patch_result.get("questions", [])
|
||||||
|
return ClarificationNeededResponse(
|
||||||
|
message=(
|
||||||
|
"I need some more information about the changes. "
|
||||||
|
"Please answer the following questions:"
|
||||||
|
),
|
||||||
|
questions=[
|
||||||
|
ClarifyingQuestion(
|
||||||
|
question=q.get("question", ""),
|
||||||
|
keyword=q.get("keyword", ""),
|
||||||
|
example=q.get("example"),
|
||||||
|
)
|
||||||
|
for q in questions
|
||||||
|
],
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Apply patch and fixes
|
||||||
|
try:
|
||||||
|
updated_agent = apply_agent_patch(current_agent, patch_result)
|
||||||
|
updated_agent = apply_all_fixes(updated_agent, blocks_info)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == MAX_GENERATION_RETRIES:
|
||||||
|
return ErrorResponse(
|
||||||
|
message=f"Failed to apply changes: {str(e)}",
|
||||||
|
error="patch_apply_failed",
|
||||||
|
details={"exception": str(e)},
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
validation_errors = str(e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Step 4: Validate the updated agent
|
||||||
|
is_valid, validation_errors = validate_agent(updated_agent, blocks_info)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
logger.info(f"Agent edited successfully on attempt {attempt + 1}")
|
||||||
|
intent = patch_result.get("intent", "Applied requested changes")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Validation failed on attempt {attempt + 1}: {validation_errors}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Result is the updated agent JSON
|
if attempt == MAX_GENERATION_RETRIES:
|
||||||
updated_agent = result
|
# Return error with validation details
|
||||||
|
return ErrorResponse(
|
||||||
|
message=(
|
||||||
|
f"Updated agent has validation errors after "
|
||||||
|
f"{MAX_GENERATION_RETRIES + 1} attempts. "
|
||||||
|
f"Please try rephrasing your request or simplify the changes."
|
||||||
|
),
|
||||||
|
error="validation_failed",
|
||||||
|
details={"validation_errors": validation_errors},
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# At this point, updated_agent is guaranteed to be set (we return on all failure paths)
|
||||||
|
assert updated_agent is not None
|
||||||
|
|
||||||
agent_name = updated_agent.get("name", "Updated Agent")
|
agent_name = updated_agent.get("name", "Updated Agent")
|
||||||
agent_description = updated_agent.get("description", "")
|
agent_description = updated_agent.get("description", "")
|
||||||
node_count = len(updated_agent.get("nodes", []))
|
node_count = len(updated_agent.get("nodes", []))
|
||||||
link_count = len(updated_agent.get("links", []))
|
link_count = len(updated_agent.get("links", []))
|
||||||
|
|
||||||
# Step 3: Preview or save
|
# Step 5: Preview or save
|
||||||
if not save:
|
if not save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
f"I've updated the agent. "
|
f"I've updated the agent. Changes: {intent}. "
|
||||||
f"The agent now has {node_count} blocks. "
|
f"The agent now has {node_count} blocks. "
|
||||||
f"Review it and call edit_agent with save=true to save the changes."
|
f"Review it and call edit_agent with save=true to save the changes."
|
||||||
),
|
),
|
||||||
@@ -207,7 +277,10 @@ class EditAgentTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return AgentSavedResponse(
|
return AgentSavedResponse(
|
||||||
message=f"Updated agent '{created_graph.name}' has been saved to your library!",
|
message=(
|
||||||
|
f"Updated agent '{created_graph.name}' has been saved to your library! "
|
||||||
|
f"Changes: {intent}"
|
||||||
|
),
|
||||||
agent_id=created_graph.id,
|
agent_id=created_graph.id,
|
||||||
agent_name=created_graph.name,
|
agent_name=created_graph.name,
|
||||||
library_agent_id=library_agent.id,
|
library_agent_id=library_agent.id,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def mock_embedding_functions():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent(setup_test_data):
|
async def test_run_agent(setup_test_data):
|
||||||
"""Test that the run_agent tool successfully executes an approved agent"""
|
"""Test that the run_agent tool successfully executes an approved agent"""
|
||||||
# Use test data from fixture
|
# Use test data from fixture
|
||||||
@@ -70,7 +70,7 @@ async def test_run_agent(setup_test_data):
|
|||||||
assert result_data["graph_name"] == "Test Agent"
|
assert result_data["graph_name"] == "Test Agent"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_missing_inputs(setup_test_data):
|
async def test_run_agent_missing_inputs(setup_test_data):
|
||||||
"""Test that the run_agent tool returns error when inputs are missing"""
|
"""Test that the run_agent tool returns error when inputs are missing"""
|
||||||
# Use test data from fixture
|
# Use test data from fixture
|
||||||
@@ -106,7 +106,7 @@ async def test_run_agent_missing_inputs(setup_test_data):
|
|||||||
assert "message" in result_data
|
assert "message" in result_data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_invalid_agent_id(setup_test_data):
|
async def test_run_agent_invalid_agent_id(setup_test_data):
|
||||||
"""Test that the run_agent tool returns error for invalid agent ID"""
|
"""Test that the run_agent tool returns error for invalid agent ID"""
|
||||||
# Use test data from fixture
|
# Use test data from fixture
|
||||||
@@ -141,7 +141,7 @@ async def test_run_agent_invalid_agent_id(setup_test_data):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_with_llm_credentials(setup_llm_test_data):
|
async def test_run_agent_with_llm_credentials(setup_llm_test_data):
|
||||||
"""Test that run_agent works with an agent requiring LLM credentials"""
|
"""Test that run_agent works with an agent requiring LLM credentials"""
|
||||||
# Use test data from fixture
|
# Use test data from fixture
|
||||||
@@ -185,7 +185,7 @@ async def test_run_agent_with_llm_credentials(setup_llm_test_data):
|
|||||||
assert result_data["graph_name"] == "LLM Test Agent"
|
assert result_data["graph_name"] == "LLM Test Agent"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_data):
|
async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_data):
|
||||||
"""Test that run_agent returns available inputs when called without inputs or use_defaults."""
|
"""Test that run_agent returns available inputs when called without inputs or use_defaults."""
|
||||||
user = setup_test_data["user"]
|
user = setup_test_data["user"]
|
||||||
@@ -219,7 +219,7 @@ async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_da
|
|||||||
assert "inputs" in result_data["message"].lower()
|
assert "inputs" in result_data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_with_use_defaults(setup_test_data):
|
async def test_run_agent_with_use_defaults(setup_test_data):
|
||||||
"""Test that run_agent executes successfully with use_defaults=True."""
|
"""Test that run_agent executes successfully with use_defaults=True."""
|
||||||
user = setup_test_data["user"]
|
user = setup_test_data["user"]
|
||||||
@@ -251,7 +251,7 @@ async def test_run_agent_with_use_defaults(setup_test_data):
|
|||||||
assert result_data["graph_id"] == graph.id
|
assert result_data["graph_id"] == graph.id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_missing_credentials(setup_firecrawl_test_data):
|
async def test_run_agent_missing_credentials(setup_firecrawl_test_data):
|
||||||
"""Test that run_agent returns setup_requirements when credentials are missing."""
|
"""Test that run_agent returns setup_requirements when credentials are missing."""
|
||||||
user = setup_firecrawl_test_data["user"]
|
user = setup_firecrawl_test_data["user"]
|
||||||
@@ -285,7 +285,7 @@ async def test_run_agent_missing_credentials(setup_firecrawl_test_data):
|
|||||||
assert len(setup_info["user_readiness"]["missing_credentials"]) > 0
|
assert len(setup_info["user_readiness"]["missing_credentials"]) > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_invalid_slug_format(setup_test_data):
|
async def test_run_agent_invalid_slug_format(setup_test_data):
|
||||||
"""Test that run_agent returns error for invalid slug format (no slash)."""
|
"""Test that run_agent returns error for invalid slug format (no slash)."""
|
||||||
user = setup_test_data["user"]
|
user = setup_test_data["user"]
|
||||||
@@ -313,7 +313,7 @@ async def test_run_agent_invalid_slug_format(setup_test_data):
|
|||||||
assert "username/agent-name" in result_data["message"]
|
assert "username/agent-name" in result_data["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_unauthenticated():
|
async def test_run_agent_unauthenticated():
|
||||||
"""Test that run_agent returns need_login for unauthenticated users."""
|
"""Test that run_agent returns need_login for unauthenticated users."""
|
||||||
tool = RunAgentTool()
|
tool = RunAgentTool()
|
||||||
@@ -340,7 +340,7 @@ async def test_run_agent_unauthenticated():
|
|||||||
assert "sign in" in result_data["message"].lower()
|
assert "sign in" in result_data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_schedule_without_cron(setup_test_data):
|
async def test_run_agent_schedule_without_cron(setup_test_data):
|
||||||
"""Test that run_agent returns error when scheduling without cron expression."""
|
"""Test that run_agent returns error when scheduling without cron expression."""
|
||||||
user = setup_test_data["user"]
|
user = setup_test_data["user"]
|
||||||
@@ -372,7 +372,7 @@ async def test_run_agent_schedule_without_cron(setup_test_data):
|
|||||||
assert "cron" in result_data["message"].lower()
|
assert "cron" in result_data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(scope="session")
|
||||||
async def test_run_agent_schedule_without_name(setup_test_data):
|
async def test_run_agent_schedule_without_name(setup_test_data):
|
||||||
"""Test that run_agent returns error when scheduling without schedule_name."""
|
"""Test that run_agent returns error when scheduling without schedule_name."""
|
||||||
user = setup_test_data["user"]
|
user = setup_test_data["user"]
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class PendingHumanReviewModel(BaseModel):
|
|||||||
id: Unique identifier for the review record
|
id: Unique identifier for the review record
|
||||||
user_id: ID of the user who must perform the review
|
user_id: ID of the user who must perform the review
|
||||||
node_exec_id: ID of the node execution that created this review
|
node_exec_id: ID of the node execution that created this review
|
||||||
node_id: ID of the node definition (for grouping reviews from same node)
|
|
||||||
graph_exec_id: ID of the graph execution containing the node
|
graph_exec_id: ID of the graph execution containing the node
|
||||||
graph_id: ID of the graph template being executed
|
graph_id: ID of the graph template being executed
|
||||||
graph_version: Version number of the graph template
|
graph_version: Version number of the graph template
|
||||||
@@ -38,10 +37,6 @@ class PendingHumanReviewModel(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
node_exec_id: str = Field(description="Node execution ID (primary key)")
|
node_exec_id: str = Field(description="Node execution ID (primary key)")
|
||||||
node_id: str = Field(
|
|
||||||
description="Node definition ID (for grouping)",
|
|
||||||
default="", # Temporary default for test compatibility
|
|
||||||
)
|
|
||||||
user_id: str = Field(description="User ID associated with the review")
|
user_id: str = Field(description="User ID associated with the review")
|
||||||
graph_exec_id: str = Field(description="Graph execution ID")
|
graph_exec_id: str = Field(description="Graph execution ID")
|
||||||
graph_id: str = Field(description="Graph ID")
|
graph_id: str = Field(description="Graph ID")
|
||||||
@@ -71,9 +66,7 @@ class PendingHumanReviewModel(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(
|
def from_db(cls, review: "PendingHumanReview") -> "PendingHumanReviewModel":
|
||||||
cls, review: "PendingHumanReview", node_id: str
|
|
||||||
) -> "PendingHumanReviewModel":
|
|
||||||
"""
|
"""
|
||||||
Convert a database model to a response model.
|
Convert a database model to a response model.
|
||||||
|
|
||||||
@@ -81,14 +74,9 @@ class PendingHumanReviewModel(BaseModel):
|
|||||||
payload, instructions, and editable flag.
|
payload, instructions, and editable flag.
|
||||||
|
|
||||||
Handles invalid data gracefully by using safe defaults.
|
Handles invalid data gracefully by using safe defaults.
|
||||||
|
|
||||||
Args:
|
|
||||||
review: Database review object
|
|
||||||
node_id: Node definition ID (fetched from NodeExecution)
|
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
node_exec_id=review.nodeExecId,
|
node_exec_id=review.nodeExecId,
|
||||||
node_id=node_id,
|
|
||||||
user_id=review.userId,
|
user_id=review.userId,
|
||||||
graph_exec_id=review.graphExecId,
|
graph_exec_id=review.graphExecId,
|
||||||
graph_id=review.graphId,
|
graph_id=review.graphId,
|
||||||
@@ -119,13 +107,6 @@ class ReviewItem(BaseModel):
|
|||||||
reviewed_data: SafeJsonData | None = Field(
|
reviewed_data: SafeJsonData | None = Field(
|
||||||
None, description="Optional edited data (ignored if approved=False)"
|
None, description="Optional edited data (ignored if approved=False)"
|
||||||
)
|
)
|
||||||
auto_approve_future: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description=(
|
|
||||||
"If true and this review is approved, future executions of this same "
|
|
||||||
"block (node) will be automatically approved. This only affects approved reviews."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("reviewed_data")
|
@field_validator("reviewed_data")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -193,9 +174,6 @@ class ReviewRequest(BaseModel):
|
|||||||
This request must include ALL pending reviews for a graph execution.
|
This request must include ALL pending reviews for a graph execution.
|
||||||
Each review will be either approved (with optional data modifications)
|
Each review will be either approved (with optional data modifications)
|
||||||
or rejected (data ignored). The execution will resume only after ALL reviews are processed.
|
or rejected (data ignored). The execution will resume only after ALL reviews are processed.
|
||||||
|
|
||||||
Each review item can individually specify whether to auto-approve future executions
|
|
||||||
of the same block via the `auto_approve_future` field on ReviewItem.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
reviews: List[ReviewItem] = Field(
|
reviews: List[ReviewItem] = Field(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,17 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, List
|
from typing import List
|
||||||
|
|
||||||
import autogpt_libs.auth as autogpt_auth_lib
|
import autogpt_libs.auth as autogpt_auth_lib
|
||||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||||
from prisma.enums import ReviewStatus
|
from prisma.enums import ReviewStatus
|
||||||
|
|
||||||
from backend.data.execution import (
|
from backend.data.execution import get_graph_execution_meta
|
||||||
ExecutionContext,
|
|
||||||
ExecutionStatus,
|
|
||||||
get_graph_execution_meta,
|
|
||||||
)
|
|
||||||
from backend.data.graph import get_graph_settings
|
|
||||||
from backend.data.human_review import (
|
from backend.data.human_review import (
|
||||||
create_auto_approval_record,
|
|
||||||
get_pending_reviews_by_node_exec_ids,
|
|
||||||
get_pending_reviews_for_execution,
|
get_pending_reviews_for_execution,
|
||||||
get_pending_reviews_for_user,
|
get_pending_reviews_for_user,
|
||||||
has_pending_reviews_for_graph_exec,
|
has_pending_reviews_for_graph_exec,
|
||||||
process_all_reviews_for_execution,
|
process_all_reviews_for_execution,
|
||||||
)
|
)
|
||||||
from backend.data.model import USER_TIMEZONE_NOT_SET
|
|
||||||
from backend.data.user import get_user_by_id
|
|
||||||
from backend.executor.utils import add_graph_execution
|
from backend.executor.utils import add_graph_execution
|
||||||
|
|
||||||
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
|
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
|
||||||
@@ -137,70 +127,17 @@ async def process_review_action(
|
|||||||
detail="At least one review must be provided",
|
detail="At least one review must be provided",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Batch fetch all requested reviews
|
# Build review decisions map
|
||||||
reviews_map = await get_pending_reviews_by_node_exec_ids(
|
|
||||||
list(all_request_node_ids), user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate all reviews were found
|
|
||||||
missing_ids = all_request_node_ids - set(reviews_map.keys())
|
|
||||||
if missing_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"No pending review found for node execution(s): {', '.join(missing_ids)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate all reviews belong to the same execution
|
|
||||||
graph_exec_ids = {review.graph_exec_id for review in reviews_map.values()}
|
|
||||||
if len(graph_exec_ids) > 1:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="All reviews in a single request must belong to the same execution.",
|
|
||||||
)
|
|
||||||
|
|
||||||
graph_exec_id = next(iter(graph_exec_ids))
|
|
||||||
|
|
||||||
# Validate execution status before processing reviews
|
|
||||||
graph_exec_meta = await get_graph_execution_meta(
|
|
||||||
user_id=user_id, execution_id=graph_exec_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not graph_exec_meta:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Graph execution #{graph_exec_id} not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only allow processing reviews if execution is paused for review
|
|
||||||
# or incomplete (partial execution with some reviews already processed)
|
|
||||||
if graph_exec_meta.status not in (
|
|
||||||
ExecutionStatus.REVIEW,
|
|
||||||
ExecutionStatus.INCOMPLETE,
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Cannot process reviews while execution status is {graph_exec_meta.status}. "
|
|
||||||
f"Reviews can only be processed when execution is paused (REVIEW status). "
|
|
||||||
f"Current status: {graph_exec_meta.status}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build review decisions map and track which reviews requested auto-approval
|
|
||||||
# Auto-approved reviews use original data (no modifications allowed)
|
|
||||||
review_decisions = {}
|
review_decisions = {}
|
||||||
auto_approve_requests = {} # Map node_exec_id -> auto_approve_future flag
|
|
||||||
|
|
||||||
for review in request.reviews:
|
for review in request.reviews:
|
||||||
review_status = (
|
review_status = (
|
||||||
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
|
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
|
||||||
)
|
)
|
||||||
# If this review requested auto-approval, don't allow data modifications
|
|
||||||
reviewed_data = None if review.auto_approve_future else review.reviewed_data
|
|
||||||
review_decisions[review.node_exec_id] = (
|
review_decisions[review.node_exec_id] = (
|
||||||
review_status,
|
review_status,
|
||||||
reviewed_data,
|
review.reviewed_data,
|
||||||
review.message,
|
review.message,
|
||||||
)
|
)
|
||||||
auto_approve_requests[review.node_exec_id] = review.auto_approve_future
|
|
||||||
|
|
||||||
# Process all reviews
|
# Process all reviews
|
||||||
updated_reviews = await process_all_reviews_for_execution(
|
updated_reviews = await process_all_reviews_for_execution(
|
||||||
@@ -208,87 +145,6 @@ async def process_review_action(
|
|||||||
review_decisions=review_decisions,
|
review_decisions=review_decisions,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create auto-approval records for approved reviews that requested it
|
|
||||||
# Deduplicate by node_id to avoid race conditions when multiple reviews
|
|
||||||
# for the same node are processed in parallel
|
|
||||||
async def create_auto_approval_for_node(
|
|
||||||
node_id: str, review_result
|
|
||||||
) -> tuple[str, bool]:
|
|
||||||
"""
|
|
||||||
Create auto-approval record for a node.
|
|
||||||
Returns (node_id, success) tuple for tracking failures.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await create_auto_approval_record(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_exec_id=review_result.graph_exec_id,
|
|
||||||
graph_id=review_result.graph_id,
|
|
||||||
graph_version=review_result.graph_version,
|
|
||||||
node_id=node_id,
|
|
||||||
payload=review_result.payload,
|
|
||||||
)
|
|
||||||
return (node_id, True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to create auto-approval record for node {node_id}",
|
|
||||||
exc_info=e,
|
|
||||||
)
|
|
||||||
return (node_id, False)
|
|
||||||
|
|
||||||
# Collect node_exec_ids that need auto-approval
|
|
||||||
node_exec_ids_needing_auto_approval = [
|
|
||||||
node_exec_id
|
|
||||||
for node_exec_id, review_result in updated_reviews.items()
|
|
||||||
if review_result.status == ReviewStatus.APPROVED
|
|
||||||
and auto_approve_requests.get(node_exec_id, False)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Batch-fetch node executions to get node_ids
|
|
||||||
nodes_needing_auto_approval: dict[str, Any] = {}
|
|
||||||
if node_exec_ids_needing_auto_approval:
|
|
||||||
from backend.data.execution import get_node_executions
|
|
||||||
|
|
||||||
node_execs = await get_node_executions(
|
|
||||||
graph_exec_id=graph_exec_id, include_exec_data=False
|
|
||||||
)
|
|
||||||
node_exec_map = {node_exec.node_exec_id: node_exec for node_exec in node_execs}
|
|
||||||
|
|
||||||
for node_exec_id in node_exec_ids_needing_auto_approval:
|
|
||||||
node_exec = node_exec_map.get(node_exec_id)
|
|
||||||
if node_exec:
|
|
||||||
review_result = updated_reviews[node_exec_id]
|
|
||||||
# Use the first approved review for this node (deduplicate by node_id)
|
|
||||||
if node_exec.node_id not in nodes_needing_auto_approval:
|
|
||||||
nodes_needing_auto_approval[node_exec.node_id] = review_result
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to create auto-approval record for {node_exec_id}: "
|
|
||||||
f"Node execution not found. This may indicate a race condition "
|
|
||||||
f"or data inconsistency."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute all auto-approval creations in parallel (deduplicated by node_id)
|
|
||||||
auto_approval_results = await asyncio.gather(
|
|
||||||
*[
|
|
||||||
create_auto_approval_for_node(node_id, review_result)
|
|
||||||
for node_id, review_result in nodes_needing_auto_approval.items()
|
|
||||||
],
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count auto-approval failures
|
|
||||||
auto_approval_failed_count = 0
|
|
||||||
for result in auto_approval_results:
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
# Unexpected exception during auto-approval creation
|
|
||||||
auto_approval_failed_count += 1
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected exception during auto-approval creation: {result}"
|
|
||||||
)
|
|
||||||
elif isinstance(result, tuple) and len(result) == 2 and not result[1]:
|
|
||||||
# Auto-approval creation failed (returned False)
|
|
||||||
auto_approval_failed_count += 1
|
|
||||||
|
|
||||||
# Count results
|
# Count results
|
||||||
approved_count = sum(
|
approved_count = sum(
|
||||||
1
|
1
|
||||||
@@ -301,53 +157,30 @@ async def process_review_action(
|
|||||||
if review.status == ReviewStatus.REJECTED
|
if review.status == ReviewStatus.REJECTED
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resume execution only if ALL pending reviews for this execution have been processed
|
# Resume execution if we processed some reviews
|
||||||
if updated_reviews:
|
if updated_reviews:
|
||||||
|
# Get graph execution ID from any processed review
|
||||||
|
first_review = next(iter(updated_reviews.values()))
|
||||||
|
graph_exec_id = first_review.graph_exec_id
|
||||||
|
|
||||||
|
# Check if any pending reviews remain for this execution
|
||||||
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
|
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
|
||||||
|
|
||||||
if not still_has_pending:
|
if not still_has_pending:
|
||||||
# Get the graph_id from any processed review
|
# Resume execution
|
||||||
first_review = next(iter(updated_reviews.values()))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch user and settings to build complete execution context
|
|
||||||
user = await get_user_by_id(user_id)
|
|
||||||
settings = await get_graph_settings(
|
|
||||||
user_id=user_id, graph_id=first_review.graph_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Preserve user's timezone preference when resuming execution
|
|
||||||
user_timezone = (
|
|
||||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
|
||||||
)
|
|
||||||
|
|
||||||
execution_context = ExecutionContext(
|
|
||||||
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
|
||||||
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
|
||||||
user_timezone=user_timezone,
|
|
||||||
)
|
|
||||||
|
|
||||||
await add_graph_execution(
|
await add_graph_execution(
|
||||||
graph_id=first_review.graph_id,
|
graph_id=first_review.graph_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
graph_exec_id=graph_exec_id,
|
graph_exec_id=graph_exec_id,
|
||||||
execution_context=execution_context,
|
|
||||||
)
|
)
|
||||||
logger.info(f"Resumed execution {graph_exec_id}")
|
logger.info(f"Resumed execution {graph_exec_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
||||||
|
|
||||||
# Build error message if auto-approvals failed
|
|
||||||
error_message = None
|
|
||||||
if auto_approval_failed_count > 0:
|
|
||||||
error_message = (
|
|
||||||
f"{auto_approval_failed_count} auto-approval setting(s) could not be saved. "
|
|
||||||
f"You may need to manually approve these reviews in future executions."
|
|
||||||
)
|
|
||||||
|
|
||||||
return ReviewResponse(
|
return ReviewResponse(
|
||||||
approved_count=approved_count,
|
approved_count=approved_count,
|
||||||
rejected_count=rejected_count,
|
rejected_count=rejected_count,
|
||||||
failed_count=auto_approval_failed_count,
|
failed_count=0,
|
||||||
error=error_message,
|
error=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -583,13 +583,7 @@ async def update_library_agent(
|
|||||||
)
|
)
|
||||||
update_fields["isDeleted"] = is_deleted
|
update_fields["isDeleted"] = is_deleted
|
||||||
if settings is not None:
|
if settings is not None:
|
||||||
existing_agent = await get_library_agent(id=library_agent_id, user_id=user_id)
|
update_fields["settings"] = SafeJson(settings.model_dump())
|
||||||
current_settings_dict = (
|
|
||||||
existing_agent.settings.model_dump() if existing_agent.settings else {}
|
|
||||||
)
|
|
||||||
new_settings = settings.model_dump(exclude_unset=True)
|
|
||||||
merged_settings = {**current_settings_dict, **new_settings}
|
|
||||||
update_fields["settings"] = SafeJson(merged_settings)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# If graph_version is provided, update to that specific version
|
# If graph_version is provided, update to that specific version
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from typing import AsyncGenerator
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
|
||||||
from autogpt_libs.api_key.keysmith import APIKeySmith
|
from autogpt_libs.api_key.keysmith import APIKeySmith
|
||||||
from prisma.enums import APIKeyPermission
|
from prisma.enums import APIKeyPermission
|
||||||
from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken
|
from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken
|
||||||
@@ -39,13 +38,13 @@ keysmith = APIKeySmith()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture
|
||||||
def test_user_id() -> str:
|
def test_user_id() -> str:
|
||||||
"""Test user ID for OAuth tests."""
|
"""Test user ID for OAuth tests."""
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
@pytest.fixture
|
||||||
async def test_user(server, test_user_id: str):
|
async def test_user(server, test_user_id: str):
|
||||||
"""Create a test user in the database."""
|
"""Create a test user in the database."""
|
||||||
await PrismaUser.prisma().create(
|
await PrismaUser.prisma().create(
|
||||||
@@ -68,7 +67,7 @@ async def test_user(server, test_user_id: str):
|
|||||||
await PrismaUser.prisma().delete(where={"id": test_user_id})
|
await PrismaUser.prisma().delete(where={"id": test_user_id})
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest.fixture
|
||||||
async def test_oauth_app(test_user: str):
|
async def test_oauth_app(test_user: str):
|
||||||
"""Create a test OAuth application in the database."""
|
"""Create a test OAuth application in the database."""
|
||||||
app_id = str(uuid.uuid4())
|
app_id = str(uuid.uuid4())
|
||||||
@@ -123,7 +122,7 @@ def pkce_credentials() -> tuple[str, str]:
|
|||||||
return generate_pkce()
|
return generate_pkce()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest.fixture
|
||||||
async def client(server, test_user: str) -> AsyncGenerator[httpx.AsyncClient, None]:
|
async def client(server, test_user: str) -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||||
"""
|
"""
|
||||||
Create an async HTTP client that talks directly to the FastAPI app.
|
Create an async HTTP client that talks directly to the FastAPI app.
|
||||||
@@ -166,7 +165,7 @@ async def client(server, test_user: str) -> AsyncGenerator[httpx.AsyncClient, No
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
async def test_authorize_creates_code_in_database(
|
async def test_authorize_creates_code_in_database_test(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
test_user: str,
|
test_user: str,
|
||||||
test_oauth_app: dict,
|
test_oauth_app: dict,
|
||||||
@@ -288,7 +287,7 @@ async def test_authorize_invalid_client_returns_error(
|
|||||||
assert query_params["error"][0] == "invalid_client"
|
assert query_params["error"][0] == "invalid_client"
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest.fixture
|
||||||
async def inactive_oauth_app(test_user: str):
|
async def inactive_oauth_app(test_user: str):
|
||||||
"""Create an inactive test OAuth application in the database."""
|
"""Create an inactive test OAuth application in the database."""
|
||||||
app_id = str(uuid.uuid4())
|
app_id = str(uuid.uuid4())
|
||||||
@@ -1005,7 +1004,7 @@ async def test_token_refresh_revoked(
|
|||||||
assert "revoked" in response.json()["detail"].lower()
|
assert "revoked" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest.fixture
|
||||||
async def other_oauth_app(test_user: str):
|
async def other_oauth_app(test_user: str):
|
||||||
"""Create a second OAuth application for cross-app tests."""
|
"""Create a second OAuth application for cross-app tests."""
|
||||||
app_id = str(uuid.uuid4())
|
app_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -1552,7 +1552,7 @@ async def review_store_submission(
|
|||||||
|
|
||||||
# Generate embedding for approved listing (blocking - admin operation)
|
# Generate embedding for approved listing (blocking - admin operation)
|
||||||
# Inside transaction: if embedding fails, entire transaction rolls back
|
# Inside transaction: if embedding fails, entire transaction rolls back
|
||||||
await ensure_embedding(
|
embedding_success = await ensure_embedding(
|
||||||
version_id=store_listing_version_id,
|
version_id=store_listing_version_id,
|
||||||
name=store_listing_version.name,
|
name=store_listing_version.name,
|
||||||
description=store_listing_version.description,
|
description=store_listing_version.description,
|
||||||
@@ -1560,6 +1560,12 @@ async def review_store_submission(
|
|||||||
categories=store_listing_version.categories or [],
|
categories=store_listing_version.categories or [],
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
if not embedding_success:
|
||||||
|
raise ValueError(
|
||||||
|
f"Failed to generate embedding for listing {store_listing_version_id}. "
|
||||||
|
"This is likely due to OpenAI API being unavailable. "
|
||||||
|
"Please try again later or contact support if the issue persists."
|
||||||
|
)
|
||||||
|
|
||||||
await prisma.models.StoreListing.prisma(tx).update(
|
await prisma.models.StoreListing.prisma(tx).update(
|
||||||
where={"id": store_listing_version.StoreListing.id},
|
where={"id": store_listing_version.StoreListing.id},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from backend.util.json import dumps
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# OpenAI embedding model configuration
|
# OpenAI embedding model configuration
|
||||||
EMBEDDING_MODEL = "text-embedding-3-small"
|
EMBEDDING_MODEL = "text-embedding-3-small"
|
||||||
# Embedding dimension for the model above
|
# Embedding dimension for the model above
|
||||||
@@ -62,42 +63,49 @@ def build_searchable_text(
|
|||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
async def generate_embedding(text: str) -> list[float]:
|
async def generate_embedding(text: str) -> list[float] | None:
|
||||||
"""
|
"""
|
||||||
Generate embedding for text using OpenAI API.
|
Generate embedding for text using OpenAI API.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
Returns None if embedding generation fails.
|
||||||
|
Fail-fast: no retries to maintain consistency with approval flow.
|
||||||
"""
|
"""
|
||||||
client = get_openai_client()
|
try:
|
||||||
if not client:
|
client = get_openai_client()
|
||||||
raise RuntimeError("openai_internal_api_key not set, cannot generate embedding")
|
if not client:
|
||||||
|
logger.error("openai_internal_api_key not set, cannot generate embedding")
|
||||||
|
return None
|
||||||
|
|
||||||
# Truncate text to token limit using tiktoken
|
# Truncate text to token limit using tiktoken
|
||||||
# Character-based truncation is insufficient because token ratios vary by content type
|
# Character-based truncation is insufficient because token ratios vary by content type
|
||||||
enc = encoding_for_model(EMBEDDING_MODEL)
|
enc = encoding_for_model(EMBEDDING_MODEL)
|
||||||
tokens = enc.encode(text)
|
tokens = enc.encode(text)
|
||||||
if len(tokens) > EMBEDDING_MAX_TOKENS:
|
if len(tokens) > EMBEDDING_MAX_TOKENS:
|
||||||
tokens = tokens[:EMBEDDING_MAX_TOKENS]
|
tokens = tokens[:EMBEDDING_MAX_TOKENS]
|
||||||
truncated_text = enc.decode(tokens)
|
truncated_text = enc.decode(tokens)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens"
|
f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
truncated_text = text
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await client.embeddings.create(
|
||||||
|
model=EMBEDDING_MODEL,
|
||||||
|
input=truncated_text,
|
||||||
)
|
)
|
||||||
else:
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
truncated_text = text
|
|
||||||
|
|
||||||
start_time = time.time()
|
embedding = response.data[0].embedding
|
||||||
response = await client.embeddings.create(
|
logger.info(
|
||||||
model=EMBEDDING_MODEL,
|
f"Generated embedding: {len(embedding)} dims, "
|
||||||
input=truncated_text,
|
f"{len(tokens)} tokens, {latency_ms:.0f}ms"
|
||||||
)
|
)
|
||||||
latency_ms = (time.time() - start_time) * 1000
|
return embedding
|
||||||
|
|
||||||
embedding = response.data[0].embedding
|
except Exception as e:
|
||||||
logger.info(
|
logger.error(f"Failed to generate embedding: {e}")
|
||||||
f"Generated embedding: {len(embedding)} dims, "
|
return None
|
||||||
f"{len(tokens)} tokens, {latency_ms:.0f}ms"
|
|
||||||
)
|
|
||||||
return embedding
|
|
||||||
|
|
||||||
|
|
||||||
async def store_embedding(
|
async def store_embedding(
|
||||||
@@ -136,45 +144,48 @@ async def store_content_embedding(
|
|||||||
|
|
||||||
New function for unified content embedding storage.
|
New function for unified content embedding storage.
|
||||||
Uses raw SQL since Prisma doesn't natively support pgvector.
|
Uses raw SQL since Prisma doesn't natively support pgvector.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
client = tx if tx else prisma.get_client()
|
try:
|
||||||
|
client = tx if tx else prisma.get_client()
|
||||||
|
|
||||||
# Convert embedding to PostgreSQL vector format
|
# Convert embedding to PostgreSQL vector format
|
||||||
embedding_str = embedding_to_vector_string(embedding)
|
embedding_str = embedding_to_vector_string(embedding)
|
||||||
metadata_json = dumps(metadata or {})
|
metadata_json = dumps(metadata or {})
|
||||||
|
|
||||||
# Upsert the embedding
|
# Upsert the embedding
|
||||||
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
||||||
# Use unqualified ::vector - pgvector is in search_path on all environments
|
# Use unqualified ::vector - pgvector is in search_path on all environments
|
||||||
await execute_raw_with_schema(
|
await execute_raw_with_schema(
|
||||||
"""
|
"""
|
||||||
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
||||||
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
|
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW())
|
||||||
|
ON CONFLICT ("contentType", "contentId", "userId")
|
||||||
|
DO UPDATE SET
|
||||||
|
"embedding" = $4::vector,
|
||||||
|
"searchableText" = $5,
|
||||||
|
"metadata" = $6::jsonb,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType"
|
||||||
|
AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2
|
||||||
|
AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL))
|
||||||
|
""",
|
||||||
|
content_type,
|
||||||
|
content_id,
|
||||||
|
user_id,
|
||||||
|
embedding_str,
|
||||||
|
searchable_text,
|
||||||
|
metadata_json,
|
||||||
|
client=client,
|
||||||
)
|
)
|
||||||
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW())
|
|
||||||
ON CONFLICT ("contentType", "contentId", "userId")
|
|
||||||
DO UPDATE SET
|
|
||||||
"embedding" = $4::vector,
|
|
||||||
"searchableText" = $5,
|
|
||||||
"metadata" = $6::jsonb,
|
|
||||||
"updatedAt" = NOW()
|
|
||||||
WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType"
|
|
||||||
AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2
|
|
||||||
AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL))
|
|
||||||
""",
|
|
||||||
content_type,
|
|
||||||
content_id,
|
|
||||||
user_id,
|
|
||||||
embedding_str,
|
|
||||||
searchable_text,
|
|
||||||
metadata_json,
|
|
||||||
client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_embedding(version_id: str) -> dict[str, Any] | None:
|
async def get_embedding(version_id: str) -> dict[str, Any] | None:
|
||||||
@@ -206,31 +217,34 @@ async def get_content_embedding(
|
|||||||
|
|
||||||
New function for unified content embedding retrieval.
|
New function for unified content embedding retrieval.
|
||||||
Returns dict with contentType, contentId, embedding, timestamps or None if not found.
|
Returns dict with contentType, contentId, embedding, timestamps or None if not found.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
result = await query_raw_with_schema(
|
try:
|
||||||
"""
|
result = await query_raw_with_schema(
|
||||||
SELECT
|
"""
|
||||||
"contentType",
|
SELECT
|
||||||
"contentId",
|
"contentType",
|
||||||
"userId",
|
"contentId",
|
||||||
"embedding"::text as "embedding",
|
"userId",
|
||||||
"searchableText",
|
"embedding"::text as "embedding",
|
||||||
"metadata",
|
"searchableText",
|
||||||
"createdAt",
|
"metadata",
|
||||||
"updatedAt"
|
"createdAt",
|
||||||
FROM {schema_prefix}"UnifiedContentEmbedding"
|
"updatedAt"
|
||||||
WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
|
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||||
""",
|
WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
|
||||||
content_type,
|
""",
|
||||||
content_id,
|
content_type,
|
||||||
user_id,
|
content_id,
|
||||||
)
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
if result and len(result) > 0:
|
if result and len(result) > 0:
|
||||||
return result[0]
|
return result[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def ensure_embedding(
|
async def ensure_embedding(
|
||||||
@@ -258,38 +272,46 @@ async def ensure_embedding(
|
|||||||
tx: Optional transaction client
|
tx: Optional transaction client
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if embedding exists/was created
|
True if embedding exists/was created, False on failure
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
# Check if embedding already exists
|
try:
|
||||||
if not force:
|
# Check if embedding already exists
|
||||||
existing = await get_embedding(version_id)
|
if not force:
|
||||||
if existing and existing.get("embedding"):
|
existing = await get_embedding(version_id)
|
||||||
logger.debug(f"Embedding for version {version_id} already exists")
|
if existing and existing.get("embedding"):
|
||||||
return True
|
logger.debug(f"Embedding for version {version_id} already exists")
|
||||||
|
return True
|
||||||
|
|
||||||
# Build searchable text for embedding
|
# Build searchable text for embedding
|
||||||
searchable_text = build_searchable_text(name, description, sub_heading, categories)
|
searchable_text = build_searchable_text(
|
||||||
|
name, description, sub_heading, categories
|
||||||
|
)
|
||||||
|
|
||||||
# Generate new embedding
|
# Generate new embedding
|
||||||
embedding = await generate_embedding(searchable_text)
|
embedding = await generate_embedding(searchable_text)
|
||||||
|
if embedding is None:
|
||||||
|
logger.warning(f"Could not generate embedding for version {version_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Store the embedding with metadata using new function
|
# Store the embedding with metadata using new function
|
||||||
metadata = {
|
metadata = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"subHeading": sub_heading,
|
"subHeading": sub_heading,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
}
|
}
|
||||||
return await store_content_embedding(
|
return await store_content_embedding(
|
||||||
content_type=ContentType.STORE_AGENT,
|
content_type=ContentType.STORE_AGENT,
|
||||||
content_id=version_id,
|
content_id=version_id,
|
||||||
embedding=embedding,
|
embedding=embedding,
|
||||||
searchable_text=searchable_text,
|
searchable_text=searchable_text,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
user_id=None, # Store agents are public
|
user_id=None, # Store agents are public
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ensure embedding for version {version_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def delete_embedding(version_id: str) -> bool:
|
async def delete_embedding(version_id: str) -> bool:
|
||||||
@@ -499,24 +521,6 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]:
|
|||||||
success = sum(1 for result in results if result is True)
|
success = sum(1 for result in results if result is True)
|
||||||
failed = len(results) - success
|
failed = len(results) - success
|
||||||
|
|
||||||
# Aggregate unique errors to avoid Sentry spam
|
|
||||||
if failed > 0:
|
|
||||||
# Group errors by type and message
|
|
||||||
error_summary: dict[str, int] = {}
|
|
||||||
for result in results:
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
error_key = f"{type(result).__name__}: {str(result)}"
|
|
||||||
error_summary[error_key] = error_summary.get(error_key, 0) + 1
|
|
||||||
|
|
||||||
# Log aggregated error summary
|
|
||||||
error_details = ", ".join(
|
|
||||||
f"{error} ({count}x)" for error, count in error_summary.items()
|
|
||||||
)
|
|
||||||
logger.error(
|
|
||||||
f"{content_type.value}: {failed}/{len(results)} embeddings failed. "
|
|
||||||
f"Errors: {error_details}"
|
|
||||||
)
|
|
||||||
|
|
||||||
results_by_type[content_type.value] = {
|
results_by_type[content_type.value] = {
|
||||||
"processed": len(missing_items),
|
"processed": len(missing_items),
|
||||||
"success": success,
|
"success": success,
|
||||||
@@ -553,12 +557,11 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def embed_query(query: str) -> list[float]:
|
async def embed_query(query: str) -> list[float] | None:
|
||||||
"""
|
"""
|
||||||
Generate embedding for a search query.
|
Generate embedding for a search query.
|
||||||
|
|
||||||
Same as generate_embedding but with clearer intent.
|
Same as generate_embedding but with clearer intent.
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
return await generate_embedding(query)
|
return await generate_embedding(query)
|
||||||
|
|
||||||
@@ -591,30 +594,40 @@ async def ensure_content_embedding(
|
|||||||
tx: Optional transaction client
|
tx: Optional transaction client
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if embedding exists/was created
|
True if embedding exists/was created, False on failure
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
# Check if embedding already exists
|
try:
|
||||||
if not force:
|
# Check if embedding already exists
|
||||||
existing = await get_content_embedding(content_type, content_id, user_id)
|
if not force:
|
||||||
if existing and existing.get("embedding"):
|
existing = await get_content_embedding(content_type, content_id, user_id)
|
||||||
logger.debug(f"Embedding for {content_type}:{content_id} already exists")
|
if existing and existing.get("embedding"):
|
||||||
return True
|
logger.debug(
|
||||||
|
f"Embedding for {content_type}:{content_id} already exists"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
# Generate new embedding
|
# Generate new embedding
|
||||||
embedding = await generate_embedding(searchable_text)
|
embedding = await generate_embedding(searchable_text)
|
||||||
|
if embedding is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not generate embedding for {content_type}:{content_id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
# Store the embedding
|
# Store the embedding
|
||||||
return await store_content_embedding(
|
return await store_content_embedding(
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
content_id=content_id,
|
content_id=content_id,
|
||||||
embedding=embedding,
|
embedding=embedding,
|
||||||
searchable_text=searchable_text,
|
searchable_text=searchable_text,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ensure embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_orphaned_embeddings() -> dict[str, Any]:
|
async def cleanup_orphaned_embeddings() -> dict[str, Any]:
|
||||||
@@ -841,8 +854,9 @@ async def semantic_search(
|
|||||||
limit = 100
|
limit = 100
|
||||||
|
|
||||||
# Generate query embedding
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
|
if query_embedding is not None:
|
||||||
# Semantic search with embeddings
|
# Semantic search with embeddings
|
||||||
embedding_str = embedding_to_vector_string(query_embedding)
|
embedding_str = embedding_to_vector_string(query_embedding)
|
||||||
|
|
||||||
@@ -893,21 +907,24 @@ async def semantic_search(
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
results = await query_raw_with_schema(sql, *params)
|
try:
|
||||||
return [
|
results = await query_raw_with_schema(sql, *params)
|
||||||
{
|
return [
|
||||||
"content_id": row["content_id"],
|
{
|
||||||
"content_type": row["content_type"],
|
"content_id": row["content_id"],
|
||||||
"searchable_text": row["searchable_text"],
|
"content_type": row["content_type"],
|
||||||
"metadata": row["metadata"],
|
"searchable_text": row["searchable_text"],
|
||||||
"similarity": float(row["similarity"]),
|
"metadata": row["metadata"],
|
||||||
}
|
"similarity": float(row["similarity"]),
|
||||||
for row in results
|
}
|
||||||
]
|
for row in results
|
||||||
except Exception as e:
|
]
|
||||||
logger.warning(f"Semantic search failed, falling back to lexical search: {e}")
|
except Exception as e:
|
||||||
|
logger.error(f"Semantic search failed: {e}")
|
||||||
|
# Fall through to lexical search below
|
||||||
|
|
||||||
# Fallback to lexical search if embeddings unavailable
|
# Fallback to lexical search if embeddings unavailable
|
||||||
|
logger.warning("Falling back to lexical search (embeddings unavailable)")
|
||||||
|
|
||||||
params_lexical: list[Any] = [limit]
|
params_lexical: list[Any] = [limit]
|
||||||
user_filter = ""
|
user_filter = ""
|
||||||
|
|||||||
@@ -298,16 +298,17 @@ async def test_schema_handling_error_cases():
|
|||||||
mock_client.execute_raw.side_effect = Exception("Database error")
|
mock_client.execute_raw.side_effect = Exception("Database error")
|
||||||
mock_get_client.return_value = mock_client
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
# Should raise exception on error
|
result = await embeddings.store_content_embedding(
|
||||||
with pytest.raises(Exception, match="Database error"):
|
content_type=ContentType.STORE_AGENT,
|
||||||
await embeddings.store_content_embedding(
|
content_id="test-id",
|
||||||
content_type=ContentType.STORE_AGENT,
|
embedding=[0.1] * EMBEDDING_DIM,
|
||||||
content_id="test-id",
|
searchable_text="test",
|
||||||
embedding=[0.1] * EMBEDDING_DIM,
|
metadata=None,
|
||||||
searchable_text="test",
|
user_id=None,
|
||||||
metadata=None,
|
)
|
||||||
user_id=None,
|
|
||||||
)
|
# Should return False on error, not raise
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ async def test_generate_embedding_no_api_key():
|
|||||||
) as mock_get_client:
|
) as mock_get_client:
|
||||||
mock_get_client.return_value = None
|
mock_get_client.return_value = None
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="openai_internal_api_key not set"):
|
result = await embeddings.generate_embedding("test text")
|
||||||
await embeddings.generate_embedding("test text")
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -96,8 +97,9 @@ async def test_generate_embedding_api_error():
|
|||||||
) as mock_get_client:
|
) as mock_get_client:
|
||||||
mock_get_client.return_value = mock_client
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
with pytest.raises(Exception, match="API Error"):
|
result = await embeddings.generate_embedding("test text")
|
||||||
await embeddings.generate_embedding("test text")
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -171,10 +173,11 @@ async def test_store_embedding_database_error(mocker):
|
|||||||
|
|
||||||
embedding = [0.1, 0.2, 0.3]
|
embedding = [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Database error"):
|
result = await embeddings.store_embedding(
|
||||||
await embeddings.store_embedding(
|
version_id="test-version-id", embedding=embedding, tx=mock_client
|
||||||
version_id="test-version-id", embedding=embedding, tx=mock_client
|
)
|
||||||
)
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -274,16 +277,17 @@ async def test_ensure_embedding_create_new(mock_get, mock_store, mock_generate):
|
|||||||
async def test_ensure_embedding_generation_fails(mock_get, mock_generate):
|
async def test_ensure_embedding_generation_fails(mock_get, mock_generate):
|
||||||
"""Test ensure_embedding when generation fails."""
|
"""Test ensure_embedding when generation fails."""
|
||||||
mock_get.return_value = None
|
mock_get.return_value = None
|
||||||
mock_generate.side_effect = Exception("Generation failed")
|
mock_generate.return_value = None
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Generation failed"):
|
result = await embeddings.ensure_embedding(
|
||||||
await embeddings.ensure_embedding(
|
version_id="test-id",
|
||||||
version_id="test-id",
|
name="Test",
|
||||||
name="Test",
|
description="Test description",
|
||||||
description="Test description",
|
sub_heading="Test heading",
|
||||||
sub_heading="Test heading",
|
categories=["test"],
|
||||||
categories=["test"],
|
)
|
||||||
)
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
|||||||
@@ -186,12 +186,13 @@ async def unified_hybrid_search(
|
|||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Generate query embedding with graceful degradation
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
except Exception as e:
|
# Graceful degradation if embedding unavailable
|
||||||
|
if query_embedding is None or not query_embedding:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to generate query embedding - falling back to lexical-only search: {e}. "
|
"Failed to generate query embedding - falling back to lexical-only search. "
|
||||||
"Check that openai_internal_api_key is configured and OpenAI API is accessible."
|
"Check that openai_internal_api_key is configured and OpenAI API is accessible."
|
||||||
)
|
)
|
||||||
query_embedding = [0.0] * EMBEDDING_DIM
|
query_embedding = [0.0] * EMBEDDING_DIM
|
||||||
@@ -463,12 +464,13 @@ async def hybrid_search(
|
|||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Generate query embedding with graceful degradation
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
except Exception as e:
|
# Graceful degradation
|
||||||
|
if query_embedding is None or not query_embedding:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to generate query embedding - falling back to lexical-only search: {e}"
|
"Failed to generate query embedding - falling back to lexical-only search."
|
||||||
)
|
)
|
||||||
query_embedding = [0.0] * EMBEDDING_DIM
|
query_embedding = [0.0] * EMBEDDING_DIM
|
||||||
total_non_semantic = (
|
total_non_semantic = (
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ async def test_hybrid_search_without_embeddings():
|
|||||||
with patch(
|
with patch(
|
||||||
"backend.api.features.store.hybrid_search.query_raw_with_schema"
|
"backend.api.features.store.hybrid_search.query_raw_with_schema"
|
||||||
) as mock_query:
|
) as mock_query:
|
||||||
# Simulate embedding failure by raising exception
|
# Simulate embedding failure
|
||||||
mock_embed.side_effect = Exception("Embedding generation failed")
|
mock_embed.return_value = None
|
||||||
mock_query.return_value = mock_results
|
mock_query.return_value = mock_results
|
||||||
|
|
||||||
# Should NOT raise - graceful degradation
|
# Should NOT raise - graceful degradation
|
||||||
@@ -613,9 +613,7 @@ async def test_unified_hybrid_search_graceful_degradation():
|
|||||||
"backend.api.features.store.hybrid_search.embed_query"
|
"backend.api.features.store.hybrid_search.embed_query"
|
||||||
) as mock_embed:
|
) as mock_embed:
|
||||||
mock_query.return_value = mock_results
|
mock_query.return_value = mock_results
|
||||||
mock_embed.side_effect = Exception(
|
mock_embed.return_value = None # Embedding failure
|
||||||
"Embedding generation failed"
|
|
||||||
) # Embedding failure
|
|
||||||
|
|
||||||
# Should NOT raise - graceful degradation
|
# Should NOT raise - graceful degradation
|
||||||
results, total = await unified_hybrid_search(
|
results, total = await unified_hybrid_search(
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ class PrintToConsoleBlock(Block):
|
|||||||
input_schema=PrintToConsoleBlock.Input,
|
input_schema=PrintToConsoleBlock.Input,
|
||||||
output_schema=PrintToConsoleBlock.Output,
|
output_schema=PrintToConsoleBlock.Output,
|
||||||
test_input={"text": "Hello, World!"},
|
test_input={"text": "Hello, World!"},
|
||||||
is_sensitive_action=True,
|
|
||||||
test_output=[
|
test_output=[
|
||||||
("output", "Hello, World!"),
|
("output", "Hello, World!"),
|
||||||
("status", "printed"),
|
("status", "printed"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, Optional
|
|||||||
from prisma.enums import ReviewStatus
|
from prisma.enums import ReviewStatus
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.data.execution import ExecutionStatus
|
from backend.data.execution import ExecutionContext, ExecutionStatus
|
||||||
from backend.data.human_review import ReviewResult
|
from backend.data.human_review import ReviewResult
|
||||||
from backend.executor.manager import async_update_node_execution_status
|
from backend.executor.manager import async_update_node_execution_status
|
||||||
from backend.util.clients import get_database_manager_async_client
|
from backend.util.clients import get_database_manager_async_client
|
||||||
@@ -28,11 +28,6 @@ class ReviewDecision(BaseModel):
|
|||||||
class HITLReviewHelper:
|
class HITLReviewHelper:
|
||||||
"""Helper class for Human-In-The-Loop review operations."""
|
"""Helper class for Human-In-The-Loop review operations."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def check_approval(**kwargs) -> Optional[ReviewResult]:
|
|
||||||
"""Check if there's an existing approval for this node execution."""
|
|
||||||
return await get_database_manager_async_client().check_approval(**kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_human_review(**kwargs) -> Optional[ReviewResult]:
|
async def get_or_create_human_review(**kwargs) -> Optional[ReviewResult]:
|
||||||
"""Create or retrieve a human review from the database."""
|
"""Create or retrieve a human review from the database."""
|
||||||
@@ -60,11 +55,11 @@ class HITLReviewHelper:
|
|||||||
async def _handle_review_request(
|
async def _handle_review_request(
|
||||||
input_data: Any,
|
input_data: Any,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
node_id: str,
|
|
||||||
node_exec_id: str,
|
node_exec_id: str,
|
||||||
graph_exec_id: str,
|
graph_exec_id: str,
|
||||||
graph_id: str,
|
graph_id: str,
|
||||||
graph_version: int,
|
graph_version: int,
|
||||||
|
execution_context: ExecutionContext,
|
||||||
block_name: str = "Block",
|
block_name: str = "Block",
|
||||||
editable: bool = False,
|
editable: bool = False,
|
||||||
) -> Optional[ReviewResult]:
|
) -> Optional[ReviewResult]:
|
||||||
@@ -74,11 +69,11 @@ class HITLReviewHelper:
|
|||||||
Args:
|
Args:
|
||||||
input_data: The input data to be reviewed
|
input_data: The input data to be reviewed
|
||||||
user_id: ID of the user requesting the review
|
user_id: ID of the user requesting the review
|
||||||
node_id: ID of the node in the graph definition
|
|
||||||
node_exec_id: ID of the node execution
|
node_exec_id: ID of the node execution
|
||||||
graph_exec_id: ID of the graph execution
|
graph_exec_id: ID of the graph execution
|
||||||
graph_id: ID of the graph
|
graph_id: ID of the graph
|
||||||
graph_version: Version of the graph
|
graph_version: Version of the graph
|
||||||
|
execution_context: Current execution context
|
||||||
block_name: Name of the block requesting review
|
block_name: Name of the block requesting review
|
||||||
editable: Whether the reviewer can edit the data
|
editable: Whether the reviewer can edit the data
|
||||||
|
|
||||||
@@ -88,41 +83,15 @@ class HITLReviewHelper:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: If review creation or status update fails
|
Exception: If review creation or status update fails
|
||||||
"""
|
"""
|
||||||
# Note: Safe mode checks (human_in_the_loop_safe_mode, sensitive_action_safe_mode)
|
# Skip review if safe mode is disabled - return auto-approved result
|
||||||
# are handled by the caller:
|
if not execution_context.human_in_the_loop_safe_mode:
|
||||||
# - HITL blocks check human_in_the_loop_safe_mode in their run() method
|
|
||||||
# - Sensitive action blocks check sensitive_action_safe_mode in is_block_exec_need_review()
|
|
||||||
# This function only handles checking for existing approvals.
|
|
||||||
|
|
||||||
# Check if this node has already been approved (normal or auto-approval)
|
|
||||||
if approval_result := await HITLReviewHelper.check_approval(
|
|
||||||
node_exec_id=node_exec_id,
|
|
||||||
graph_exec_id=graph_exec_id,
|
|
||||||
node_id=node_id,
|
|
||||||
user_id=user_id,
|
|
||||||
input_data=input_data,
|
|
||||||
):
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Block {block_name} skipping review for node {node_exec_id} - "
|
f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled"
|
||||||
f"found existing approval"
|
|
||||||
)
|
|
||||||
# Return a new ReviewResult with the current node_exec_id but approved status
|
|
||||||
# For auto-approvals, always use current input_data
|
|
||||||
# For normal approvals, use approval_result.data unless it's None
|
|
||||||
is_auto_approval = approval_result.node_exec_id != node_exec_id
|
|
||||||
approved_data = (
|
|
||||||
input_data
|
|
||||||
if is_auto_approval
|
|
||||||
else (
|
|
||||||
approval_result.data
|
|
||||||
if approval_result.data is not None
|
|
||||||
else input_data
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return ReviewResult(
|
return ReviewResult(
|
||||||
data=approved_data,
|
data=input_data,
|
||||||
status=ReviewStatus.APPROVED,
|
status=ReviewStatus.APPROVED,
|
||||||
message=approval_result.message,
|
message="Auto-approved (safe mode disabled)",
|
||||||
processed=True,
|
processed=True,
|
||||||
node_exec_id=node_exec_id,
|
node_exec_id=node_exec_id,
|
||||||
)
|
)
|
||||||
@@ -134,7 +103,7 @@ class HITLReviewHelper:
|
|||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
graph_version=graph_version,
|
graph_version=graph_version,
|
||||||
input_data=input_data,
|
input_data=input_data,
|
||||||
message=block_name, # Use block_name directly as the message
|
message=f"Review required for {block_name} execution",
|
||||||
editable=editable,
|
editable=editable,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,11 +129,11 @@ class HITLReviewHelper:
|
|||||||
async def handle_review_decision(
|
async def handle_review_decision(
|
||||||
input_data: Any,
|
input_data: Any,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
node_id: str,
|
|
||||||
node_exec_id: str,
|
node_exec_id: str,
|
||||||
graph_exec_id: str,
|
graph_exec_id: str,
|
||||||
graph_id: str,
|
graph_id: str,
|
||||||
graph_version: int,
|
graph_version: int,
|
||||||
|
execution_context: ExecutionContext,
|
||||||
block_name: str = "Block",
|
block_name: str = "Block",
|
||||||
editable: bool = False,
|
editable: bool = False,
|
||||||
) -> Optional[ReviewDecision]:
|
) -> Optional[ReviewDecision]:
|
||||||
@@ -174,11 +143,11 @@ class HITLReviewHelper:
|
|||||||
Args:
|
Args:
|
||||||
input_data: The input data to be reviewed
|
input_data: The input data to be reviewed
|
||||||
user_id: ID of the user requesting the review
|
user_id: ID of the user requesting the review
|
||||||
node_id: ID of the node in the graph definition
|
|
||||||
node_exec_id: ID of the node execution
|
node_exec_id: ID of the node execution
|
||||||
graph_exec_id: ID of the graph execution
|
graph_exec_id: ID of the graph execution
|
||||||
graph_id: ID of the graph
|
graph_id: ID of the graph
|
||||||
graph_version: Version of the graph
|
graph_version: Version of the graph
|
||||||
|
execution_context: Current execution context
|
||||||
block_name: Name of the block requesting review
|
block_name: Name of the block requesting review
|
||||||
editable: Whether the reviewer can edit the data
|
editable: Whether the reviewer can edit the data
|
||||||
|
|
||||||
@@ -189,11 +158,11 @@ class HITLReviewHelper:
|
|||||||
review_result = await HITLReviewHelper._handle_review_request(
|
review_result = await HITLReviewHelper._handle_review_request(
|
||||||
input_data=input_data,
|
input_data=input_data,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
node_id=node_id,
|
|
||||||
node_exec_id=node_exec_id,
|
node_exec_id=node_exec_id,
|
||||||
graph_exec_id=graph_exec_id,
|
graph_exec_id=graph_exec_id,
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
graph_version=graph_version,
|
graph_version=graph_version,
|
||||||
|
execution_context=execution_context,
|
||||||
block_name=block_name,
|
block_name=block_name,
|
||||||
editable=editable,
|
editable=editable,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ class HumanInTheLoopBlock(Block):
|
|||||||
input_data: Input,
|
input_data: Input,
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
node_id: str,
|
|
||||||
node_exec_id: str,
|
node_exec_id: str,
|
||||||
graph_exec_id: str,
|
graph_exec_id: str,
|
||||||
graph_id: str,
|
graph_id: str,
|
||||||
@@ -116,12 +115,12 @@ class HumanInTheLoopBlock(Block):
|
|||||||
decision = await self.handle_review_decision(
|
decision = await self.handle_review_decision(
|
||||||
input_data=input_data.data,
|
input_data=input_data.data,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
node_id=node_id,
|
|
||||||
node_exec_id=node_exec_id,
|
node_exec_id=node_exec_id,
|
||||||
graph_exec_id=graph_exec_id,
|
graph_exec_id=graph_exec_id,
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
graph_version=graph_version,
|
graph_version=graph_version,
|
||||||
block_name=input_data.name, # Use user-provided name instead of block type
|
execution_context=execution_context,
|
||||||
|
block_name=self.name,
|
||||||
editable=input_data.editable,
|
editable=input_data.editable,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest_asyncio
|
import pytest
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from backend.util.logging import configure_logging
|
from backend.util.logging import configure_logging
|
||||||
@@ -19,7 +19,7 @@ if not os.getenv("PRISMA_DEBUG"):
|
|||||||
prisma_logger.setLevel(logging.INFO)
|
prisma_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def server():
|
async def server():
|
||||||
from backend.util.test import SpinTestServer
|
from backend.util.test import SpinTestServer
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ async def server():
|
|||||||
yield server
|
yield server
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
async def graph_cleanup(server):
|
async def graph_cleanup(server):
|
||||||
created_graph_ids = []
|
created_graph_ids = []
|
||||||
original_create_graph = server.agent_server.test_create_graph
|
original_create_graph = server.agent_server.test_create_graph
|
||||||
|
|||||||
@@ -441,7 +441,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
static_output: bool = False,
|
static_output: bool = False,
|
||||||
block_type: BlockType = BlockType.STANDARD,
|
block_type: BlockType = BlockType.STANDARD,
|
||||||
webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None,
|
webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None,
|
||||||
is_sensitive_action: bool = False,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the block with the given schema.
|
Initialize the block with the given schema.
|
||||||
@@ -474,8 +473,8 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
self.static_output = static_output
|
self.static_output = static_output
|
||||||
self.block_type = block_type
|
self.block_type = block_type
|
||||||
self.webhook_config = webhook_config
|
self.webhook_config = webhook_config
|
||||||
self.is_sensitive_action = is_sensitive_action
|
|
||||||
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
||||||
|
self.is_sensitive_action: bool = False
|
||||||
|
|
||||||
if self.webhook_config:
|
if self.webhook_config:
|
||||||
if isinstance(self.webhook_config, BlockWebhookConfig):
|
if isinstance(self.webhook_config, BlockWebhookConfig):
|
||||||
@@ -623,7 +622,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
input_data: BlockInput,
|
input_data: BlockInput,
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
node_id: str,
|
|
||||||
node_exec_id: str,
|
node_exec_id: str,
|
||||||
graph_exec_id: str,
|
graph_exec_id: str,
|
||||||
graph_id: str,
|
graph_id: str,
|
||||||
@@ -650,11 +648,11 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
decision = await HITLReviewHelper.handle_review_decision(
|
decision = await HITLReviewHelper.handle_review_decision(
|
||||||
input_data=input_data,
|
input_data=input_data,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
node_id=node_id,
|
|
||||||
node_exec_id=node_exec_id,
|
node_exec_id=node_exec_id,
|
||||||
graph_exec_id=graph_exec_id,
|
graph_exec_id=graph_exec_id,
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
graph_version=graph_version,
|
graph_version=graph_version,
|
||||||
|
execution_context=execution_context,
|
||||||
block_name=self.name,
|
block_name=self.name,
|
||||||
editable=True,
|
editable=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ Handles all database operations for pending human reviews.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from prisma.enums import ReviewStatus
|
from prisma.enums import ReviewStatus
|
||||||
from prisma.models import AgentNodeExecution, PendingHumanReview
|
from prisma.models import PendingHumanReview
|
||||||
from prisma.types import PendingHumanReviewUpdateInput
|
from prisma.types import PendingHumanReviewUpdateInput
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -17,12 +17,8 @@ from backend.api.features.executions.review.model import (
|
|||||||
PendingHumanReviewModel,
|
PendingHumanReviewModel,
|
||||||
SafeJsonData,
|
SafeJsonData,
|
||||||
)
|
)
|
||||||
from backend.data.execution import get_graph_execution_meta
|
|
||||||
from backend.util.json import SafeJson
|
from backend.util.json import SafeJson
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,125 +32,6 @@ class ReviewResult(BaseModel):
|
|||||||
node_exec_id: str
|
node_exec_id: str
|
||||||
|
|
||||||
|
|
||||||
def get_auto_approve_key(graph_exec_id: str, node_id: str) -> str:
|
|
||||||
"""Generate the special nodeExecId key for auto-approval records."""
|
|
||||||
return f"auto_approve_{graph_exec_id}_{node_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def check_approval(
|
|
||||||
node_exec_id: str,
|
|
||||||
graph_exec_id: str,
|
|
||||||
node_id: str,
|
|
||||||
user_id: str,
|
|
||||||
input_data: SafeJsonData | None = None,
|
|
||||||
) -> Optional[ReviewResult]:
|
|
||||||
"""
|
|
||||||
Check if there's an existing approval for this node execution.
|
|
||||||
|
|
||||||
Checks both:
|
|
||||||
1. Normal approval by node_exec_id (previous run of the same node execution)
|
|
||||||
2. Auto-approval by special key pattern "auto_approve_{graph_exec_id}_{node_id}"
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node_exec_id: ID of the node execution
|
|
||||||
graph_exec_id: ID of the graph execution
|
|
||||||
node_id: ID of the node definition (not execution)
|
|
||||||
user_id: ID of the user (for data isolation)
|
|
||||||
input_data: Current input data (used for auto-approvals to avoid stale data)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ReviewResult if approval found (either normal or auto), None otherwise
|
|
||||||
"""
|
|
||||||
auto_approve_key = get_auto_approve_key(graph_exec_id, node_id)
|
|
||||||
|
|
||||||
# Check for either normal approval or auto-approval in a single query
|
|
||||||
existing_review = await PendingHumanReview.prisma().find_first(
|
|
||||||
where={
|
|
||||||
"OR": [
|
|
||||||
{"nodeExecId": node_exec_id},
|
|
||||||
{"nodeExecId": auto_approve_key},
|
|
||||||
],
|
|
||||||
"status": ReviewStatus.APPROVED,
|
|
||||||
"userId": user_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_review:
|
|
||||||
is_auto_approval = existing_review.nodeExecId == auto_approve_key
|
|
||||||
logger.info(
|
|
||||||
f"Found {'auto-' if is_auto_approval else ''}approval for node {node_id} "
|
|
||||||
f"(exec: {node_exec_id}) in execution {graph_exec_id}"
|
|
||||||
)
|
|
||||||
# For auto-approvals, use current input_data to avoid replaying stale payload
|
|
||||||
# For normal approvals, use the stored payload (which may have been edited)
|
|
||||||
return ReviewResult(
|
|
||||||
data=(
|
|
||||||
input_data
|
|
||||||
if is_auto_approval and input_data is not None
|
|
||||||
else existing_review.payload
|
|
||||||
),
|
|
||||||
status=ReviewStatus.APPROVED,
|
|
||||||
message=(
|
|
||||||
"Auto-approved (user approved all future actions for this node)"
|
|
||||||
if is_auto_approval
|
|
||||||
else existing_review.reviewMessage or ""
|
|
||||||
),
|
|
||||||
processed=True,
|
|
||||||
node_exec_id=existing_review.nodeExecId,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_auto_approval_record(
|
|
||||||
user_id: str,
|
|
||||||
graph_exec_id: str,
|
|
||||||
graph_id: str,
|
|
||||||
graph_version: int,
|
|
||||||
node_id: str,
|
|
||||||
payload: SafeJsonData,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create an auto-approval record for a node in this execution.
|
|
||||||
|
|
||||||
This is stored as a PendingHumanReview with a special nodeExecId pattern
|
|
||||||
and status=APPROVED, so future executions of the same node can skip review.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the graph execution doesn't belong to the user
|
|
||||||
"""
|
|
||||||
# Validate that the graph execution belongs to this user (defense in depth)
|
|
||||||
graph_exec = await get_graph_execution_meta(
|
|
||||||
user_id=user_id, execution_id=graph_exec_id
|
|
||||||
)
|
|
||||||
if not graph_exec:
|
|
||||||
raise ValueError(
|
|
||||||
f"Graph execution {graph_exec_id} not found or doesn't belong to user {user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
auto_approve_key = get_auto_approve_key(graph_exec_id, node_id)
|
|
||||||
|
|
||||||
await PendingHumanReview.prisma().upsert(
|
|
||||||
where={"nodeExecId": auto_approve_key},
|
|
||||||
data={
|
|
||||||
"create": {
|
|
||||||
"nodeExecId": auto_approve_key,
|
|
||||||
"userId": user_id,
|
|
||||||
"graphExecId": graph_exec_id,
|
|
||||||
"graphId": graph_id,
|
|
||||||
"graphVersion": graph_version,
|
|
||||||
"payload": SafeJson(payload),
|
|
||||||
"instructions": "Auto-approval record",
|
|
||||||
"editable": False,
|
|
||||||
"status": ReviewStatus.APPROVED,
|
|
||||||
"processed": True,
|
|
||||||
"reviewedAt": datetime.now(timezone.utc),
|
|
||||||
},
|
|
||||||
"update": {}, # Already exists, no update needed
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_human_review(
|
async def get_or_create_human_review(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
node_exec_id: str,
|
node_exec_id: str,
|
||||||
@@ -231,87 +108,6 @@ async def get_or_create_human_review(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_review_by_node_exec_id(
|
|
||||||
node_exec_id: str, user_id: str
|
|
||||||
) -> Optional["PendingHumanReviewModel"]:
|
|
||||||
"""
|
|
||||||
Get a pending review by its node execution ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node_exec_id: The node execution ID to look up
|
|
||||||
user_id: User ID for authorization (only returns if review belongs to this user)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The pending review if found and belongs to user, None otherwise
|
|
||||||
"""
|
|
||||||
review = await PendingHumanReview.prisma().find_first(
|
|
||||||
where={
|
|
||||||
"nodeExecId": node_exec_id,
|
|
||||||
"userId": user_id,
|
|
||||||
"status": ReviewStatus.WAITING,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not review:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Local import to avoid event loop conflicts in tests
|
|
||||||
from backend.data.execution import get_node_execution
|
|
||||||
|
|
||||||
node_exec = await get_node_execution(review.nodeExecId)
|
|
||||||
node_id = node_exec.node_id if node_exec else review.nodeExecId
|
|
||||||
return PendingHumanReviewModel.from_db(review, node_id=node_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_reviews_by_node_exec_ids(
|
|
||||||
node_exec_ids: list[str], user_id: str
|
|
||||||
) -> dict[str, "PendingHumanReviewModel"]:
|
|
||||||
"""
|
|
||||||
Get multiple pending reviews by their node execution IDs in a single batch query.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node_exec_ids: List of node execution IDs to look up
|
|
||||||
user_id: User ID for authorization (only returns reviews belonging to this user)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping node_exec_id -> PendingHumanReviewModel for found reviews
|
|
||||||
"""
|
|
||||||
if not node_exec_ids:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
reviews = await PendingHumanReview.prisma().find_many(
|
|
||||||
where={
|
|
||||||
"nodeExecId": {"in": node_exec_ids},
|
|
||||||
"userId": user_id,
|
|
||||||
"status": ReviewStatus.WAITING,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not reviews:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Batch fetch all node executions to avoid N+1 queries
|
|
||||||
node_exec_ids_to_fetch = [review.nodeExecId for review in reviews]
|
|
||||||
node_execs = await AgentNodeExecution.prisma().find_many(
|
|
||||||
where={"id": {"in": node_exec_ids_to_fetch}},
|
|
||||||
include={"Node": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create mapping from node_exec_id to node_id
|
|
||||||
node_exec_id_to_node_id = {
|
|
||||||
node_exec.id: node_exec.agentNodeId for node_exec in node_execs
|
|
||||||
}
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
for review in reviews:
|
|
||||||
node_id = node_exec_id_to_node_id.get(review.nodeExecId, review.nodeExecId)
|
|
||||||
result[review.nodeExecId] = PendingHumanReviewModel.from_db(
|
|
||||||
review, node_id=node_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def has_pending_reviews_for_graph_exec(graph_exec_id: str) -> bool:
|
async def has_pending_reviews_for_graph_exec(graph_exec_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a graph execution has any pending reviews.
|
Check if a graph execution has any pending reviews.
|
||||||
@@ -341,11 +137,8 @@ async def get_pending_reviews_for_user(
|
|||||||
page_size: Number of reviews per page
|
page_size: Number of reviews per page
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of pending review models with node_id included
|
List of pending review models
|
||||||
"""
|
"""
|
||||||
# Local import to avoid event loop conflicts in tests
|
|
||||||
from backend.data.execution import get_node_execution
|
|
||||||
|
|
||||||
# Calculate offset for pagination
|
# Calculate offset for pagination
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
@@ -356,14 +149,7 @@ async def get_pending_reviews_for_user(
|
|||||||
take=page_size,
|
take=page_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch node_id for each review from NodeExecution
|
return [PendingHumanReviewModel.from_db(review) for review in reviews]
|
||||||
result = []
|
|
||||||
for review in reviews:
|
|
||||||
node_exec = await get_node_execution(review.nodeExecId)
|
|
||||||
node_id = node_exec.node_id if node_exec else review.nodeExecId
|
|
||||||
result.append(PendingHumanReviewModel.from_db(review, node_id=node_id))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_reviews_for_execution(
|
async def get_pending_reviews_for_execution(
|
||||||
@@ -377,11 +163,8 @@ async def get_pending_reviews_for_execution(
|
|||||||
user_id: User ID for security validation
|
user_id: User ID for security validation
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of pending review models with node_id included
|
List of pending review models
|
||||||
"""
|
"""
|
||||||
# Local import to avoid event loop conflicts in tests
|
|
||||||
from backend.data.execution import get_node_execution
|
|
||||||
|
|
||||||
reviews = await PendingHumanReview.prisma().find_many(
|
reviews = await PendingHumanReview.prisma().find_many(
|
||||||
where={
|
where={
|
||||||
"userId": user_id,
|
"userId": user_id,
|
||||||
@@ -391,14 +174,7 @@ async def get_pending_reviews_for_execution(
|
|||||||
order={"createdAt": "asc"},
|
order={"createdAt": "asc"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch node_id for each review from NodeExecution
|
return [PendingHumanReviewModel.from_db(review) for review in reviews]
|
||||||
result = []
|
|
||||||
for review in reviews:
|
|
||||||
node_exec = await get_node_execution(review.nodeExecId)
|
|
||||||
node_id = node_exec.node_id if node_exec else review.nodeExecId
|
|
||||||
result.append(PendingHumanReviewModel.from_db(review, node_id=node_id))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def process_all_reviews_for_execution(
|
async def process_all_reviews_for_execution(
|
||||||
@@ -468,19 +244,11 @@ async def process_all_reviews_for_execution(
|
|||||||
# Note: Execution resumption is now handled at the API layer after ALL reviews
|
# Note: Execution resumption is now handled at the API layer after ALL reviews
|
||||||
# for an execution are processed (both approved and rejected)
|
# for an execution are processed (both approved and rejected)
|
||||||
|
|
||||||
# Fetch node_id for each review and return as dict for easy access
|
# Return as dict for easy access
|
||||||
# Local import to avoid event loop conflicts in tests
|
return {
|
||||||
from backend.data.execution import get_node_execution
|
review.nodeExecId: PendingHumanReviewModel.from_db(review)
|
||||||
|
for review in updated_reviews
|
||||||
result = {}
|
}
|
||||||
for review in updated_reviews:
|
|
||||||
node_exec = await get_node_execution(review.nodeExecId)
|
|
||||||
node_id = node_exec.node_id if node_exec else review.nodeExecId
|
|
||||||
result[review.nodeExecId] = PendingHumanReviewModel.from_db(
|
|
||||||
review, node_id=node_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def update_review_processed_status(node_exec_id: str, processed: bool) -> None:
|
async def update_review_processed_status(node_exec_id: str, processed: bool) -> None:
|
||||||
@@ -488,44 +256,3 @@ async def update_review_processed_status(node_exec_id: str, processed: bool) ->
|
|||||||
await PendingHumanReview.prisma().update(
|
await PendingHumanReview.prisma().update(
|
||||||
where={"nodeExecId": node_exec_id}, data={"processed": processed}
|
where={"nodeExecId": node_exec_id}, data={"processed": processed}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cancel_pending_reviews_for_execution(graph_exec_id: str, user_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Cancel all pending reviews for a graph execution (e.g., when execution is stopped).
|
|
||||||
|
|
||||||
Marks all WAITING reviews as REJECTED with a message indicating the execution was stopped.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
graph_exec_id: The graph execution ID
|
|
||||||
user_id: User ID who owns the execution (for security validation)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of reviews cancelled
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the graph execution doesn't belong to the user
|
|
||||||
"""
|
|
||||||
# Validate user ownership before cancelling reviews
|
|
||||||
graph_exec = await get_graph_execution_meta(
|
|
||||||
user_id=user_id, execution_id=graph_exec_id
|
|
||||||
)
|
|
||||||
if not graph_exec:
|
|
||||||
raise ValueError(
|
|
||||||
f"Graph execution {graph_exec_id} not found or doesn't belong to user {user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await PendingHumanReview.prisma().update_many(
|
|
||||||
where={
|
|
||||||
"graphExecId": graph_exec_id,
|
|
||||||
"userId": user_id,
|
|
||||||
"status": ReviewStatus.WAITING,
|
|
||||||
},
|
|
||||||
data={
|
|
||||||
"status": ReviewStatus.REJECTED,
|
|
||||||
"reviewMessage": "Execution was stopped by user",
|
|
||||||
"processed": True,
|
|
||||||
"reviewedAt": datetime.now(timezone.utc),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def sample_db_review():
|
|||||||
return mock_review
|
return mock_review
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_get_or_create_human_review_new(
|
async def test_get_or_create_human_review_new(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -46,8 +46,8 @@ async def test_get_or_create_human_review_new(
|
|||||||
sample_db_review.status = ReviewStatus.WAITING
|
sample_db_review.status = ReviewStatus.WAITING
|
||||||
sample_db_review.processed = False
|
sample_db_review.processed = False
|
||||||
|
|
||||||
mock_prisma = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
mock_upsert = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||||
mock_prisma.return_value.upsert = AsyncMock(return_value=sample_db_review)
|
mock_upsert.return_value.upsert = AsyncMock(return_value=sample_db_review)
|
||||||
|
|
||||||
result = await get_or_create_human_review(
|
result = await get_or_create_human_review(
|
||||||
user_id="test-user-123",
|
user_id="test-user-123",
|
||||||
@@ -64,7 +64,7 @@ async def test_get_or_create_human_review_new(
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_get_or_create_human_review_approved(
|
async def test_get_or_create_human_review_approved(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -75,8 +75,8 @@ async def test_get_or_create_human_review_approved(
|
|||||||
sample_db_review.processed = False
|
sample_db_review.processed = False
|
||||||
sample_db_review.reviewMessage = "Looks good"
|
sample_db_review.reviewMessage = "Looks good"
|
||||||
|
|
||||||
mock_prisma = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
mock_upsert = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||||
mock_prisma.return_value.upsert = AsyncMock(return_value=sample_db_review)
|
mock_upsert.return_value.upsert = AsyncMock(return_value=sample_db_review)
|
||||||
|
|
||||||
result = await get_or_create_human_review(
|
result = await get_or_create_human_review(
|
||||||
user_id="test-user-123",
|
user_id="test-user-123",
|
||||||
@@ -96,7 +96,7 @@ async def test_get_or_create_human_review_approved(
|
|||||||
assert result.message == "Looks good"
|
assert result.message == "Looks good"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_has_pending_reviews_for_graph_exec_true(
|
async def test_has_pending_reviews_for_graph_exec_true(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
):
|
):
|
||||||
@@ -109,7 +109,7 @@ async def test_has_pending_reviews_for_graph_exec_true(
|
|||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_has_pending_reviews_for_graph_exec_false(
|
async def test_has_pending_reviews_for_graph_exec_false(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
):
|
):
|
||||||
@@ -122,7 +122,7 @@ async def test_has_pending_reviews_for_graph_exec_false(
|
|||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_get_pending_reviews_for_user(
|
async def test_get_pending_reviews_for_user(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -131,19 +131,10 @@ async def test_get_pending_reviews_for_user(
|
|||||||
mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||||
mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review])
|
mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review])
|
||||||
|
|
||||||
# Mock get_node_execution to return node with node_id (async function)
|
|
||||||
mock_node_exec = Mock()
|
|
||||||
mock_node_exec.node_id = "test_node_def_789"
|
|
||||||
mocker.patch(
|
|
||||||
"backend.data.execution.get_node_execution",
|
|
||||||
new=AsyncMock(return_value=mock_node_exec),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await get_pending_reviews_for_user("test_user", page=2, page_size=10)
|
result = await get_pending_reviews_for_user("test_user", page=2, page_size=10)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].node_exec_id == "test_node_123"
|
assert result[0].node_exec_id == "test_node_123"
|
||||||
assert result[0].node_id == "test_node_def_789"
|
|
||||||
|
|
||||||
# Verify pagination parameters
|
# Verify pagination parameters
|
||||||
call_args = mock_find_many.return_value.find_many.call_args
|
call_args = mock_find_many.return_value.find_many.call_args
|
||||||
@@ -151,7 +142,7 @@ async def test_get_pending_reviews_for_user(
|
|||||||
assert call_args.kwargs["take"] == 10
|
assert call_args.kwargs["take"] == 10
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_get_pending_reviews_for_execution(
|
async def test_get_pending_reviews_for_execution(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -160,21 +151,12 @@ async def test_get_pending_reviews_for_execution(
|
|||||||
mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma")
|
||||||
mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review])
|
mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review])
|
||||||
|
|
||||||
# Mock get_node_execution to return node with node_id (async function)
|
|
||||||
mock_node_exec = Mock()
|
|
||||||
mock_node_exec.node_id = "test_node_def_789"
|
|
||||||
mocker.patch(
|
|
||||||
"backend.data.execution.get_node_execution",
|
|
||||||
new=AsyncMock(return_value=mock_node_exec),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await get_pending_reviews_for_execution(
|
result = await get_pending_reviews_for_execution(
|
||||||
"test_graph_exec_456", "test-user-123"
|
"test_graph_exec_456", "test-user-123"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].graph_exec_id == "test_graph_exec_456"
|
assert result[0].graph_exec_id == "test_graph_exec_456"
|
||||||
assert result[0].node_id == "test_node_def_789"
|
|
||||||
|
|
||||||
# Verify it filters by execution and user
|
# Verify it filters by execution and user
|
||||||
call_args = mock_find_many.return_value.find_many.call_args
|
call_args = mock_find_many.return_value.find_many.call_args
|
||||||
@@ -184,7 +166,7 @@ async def test_get_pending_reviews_for_execution(
|
|||||||
assert where_clause["status"] == ReviewStatus.WAITING
|
assert where_clause["status"] == ReviewStatus.WAITING
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_process_all_reviews_for_execution_success(
|
async def test_process_all_reviews_for_execution_success(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -219,14 +201,6 @@ async def test_process_all_reviews_for_execution_success(
|
|||||||
new=AsyncMock(return_value=[updated_review]),
|
new=AsyncMock(return_value=[updated_review]),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock get_node_execution to return node with node_id (async function)
|
|
||||||
mock_node_exec = Mock()
|
|
||||||
mock_node_exec.node_id = "test_node_def_789"
|
|
||||||
mocker.patch(
|
|
||||||
"backend.data.execution.get_node_execution",
|
|
||||||
new=AsyncMock(return_value=mock_node_exec),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await process_all_reviews_for_execution(
|
result = await process_all_reviews_for_execution(
|
||||||
user_id="test-user-123",
|
user_id="test-user-123",
|
||||||
review_decisions={
|
review_decisions={
|
||||||
@@ -237,10 +211,9 @@ async def test_process_all_reviews_for_execution_success(
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert "test_node_123" in result
|
assert "test_node_123" in result
|
||||||
assert result["test_node_123"].status == ReviewStatus.APPROVED
|
assert result["test_node_123"].status == ReviewStatus.APPROVED
|
||||||
assert result["test_node_123"].node_id == "test_node_def_789"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_process_all_reviews_for_execution_validation_errors(
|
async def test_process_all_reviews_for_execution_validation_errors(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
):
|
):
|
||||||
@@ -260,7 +233,7 @@ async def test_process_all_reviews_for_execution_validation_errors(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_process_all_reviews_edit_permission_error(
|
async def test_process_all_reviews_edit_permission_error(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -286,7 +259,7 @@ async def test_process_all_reviews_edit_permission_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
@pytest.mark.asyncio
|
||||||
async def test_process_all_reviews_mixed_approval_rejection(
|
async def test_process_all_reviews_mixed_approval_rejection(
|
||||||
mocker: pytest_mock.MockFixture,
|
mocker: pytest_mock.MockFixture,
|
||||||
sample_db_review,
|
sample_db_review,
|
||||||
@@ -356,14 +329,6 @@ async def test_process_all_reviews_mixed_approval_rejection(
|
|||||||
new=AsyncMock(return_value=[approved_review, rejected_review]),
|
new=AsyncMock(return_value=[approved_review, rejected_review]),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock get_node_execution to return node with node_id (async function)
|
|
||||||
mock_node_exec = Mock()
|
|
||||||
mock_node_exec.node_id = "test_node_def_789"
|
|
||||||
mocker.patch(
|
|
||||||
"backend.data.execution.get_node_execution",
|
|
||||||
new=AsyncMock(return_value=mock_node_exec),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await process_all_reviews_for_execution(
|
result = await process_all_reviews_for_execution(
|
||||||
user_id="test-user-123",
|
user_id="test-user-123",
|
||||||
review_decisions={
|
review_decisions={
|
||||||
@@ -375,5 +340,3 @@ async def test_process_all_reviews_mixed_approval_rejection(
|
|||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert "test_node_123" in result
|
assert "test_node_123" in result
|
||||||
assert "test_node_456" in result
|
assert "test_node_456" in result
|
||||||
assert result["test_node_123"].node_id == "test_node_def_789"
|
|
||||||
assert result["test_node_456"].node_id == "test_node_def_789"
|
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ from backend.data.graph import (
|
|||||||
validate_graph_execution_permissions,
|
validate_graph_execution_permissions,
|
||||||
)
|
)
|
||||||
from backend.data.human_review import (
|
from backend.data.human_review import (
|
||||||
cancel_pending_reviews_for_execution,
|
|
||||||
check_approval,
|
|
||||||
get_or_create_human_review,
|
get_or_create_human_review,
|
||||||
has_pending_reviews_for_graph_exec,
|
has_pending_reviews_for_graph_exec,
|
||||||
update_review_processed_status,
|
update_review_processed_status,
|
||||||
@@ -192,8 +190,6 @@ class DatabaseManager(AppService):
|
|||||||
get_user_notification_preference = _(get_user_notification_preference)
|
get_user_notification_preference = _(get_user_notification_preference)
|
||||||
|
|
||||||
# Human In The Loop
|
# Human In The Loop
|
||||||
cancel_pending_reviews_for_execution = _(cancel_pending_reviews_for_execution)
|
|
||||||
check_approval = _(check_approval)
|
|
||||||
get_or_create_human_review = _(get_or_create_human_review)
|
get_or_create_human_review = _(get_or_create_human_review)
|
||||||
has_pending_reviews_for_graph_exec = _(has_pending_reviews_for_graph_exec)
|
has_pending_reviews_for_graph_exec = _(has_pending_reviews_for_graph_exec)
|
||||||
update_review_processed_status = _(update_review_processed_status)
|
update_review_processed_status = _(update_review_processed_status)
|
||||||
@@ -317,8 +313,6 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
|||||||
set_execution_kv_data = d.set_execution_kv_data
|
set_execution_kv_data = d.set_execution_kv_data
|
||||||
|
|
||||||
# Human In The Loop
|
# Human In The Loop
|
||||||
cancel_pending_reviews_for_execution = d.cancel_pending_reviews_for_execution
|
|
||||||
check_approval = d.check_approval
|
|
||||||
get_or_create_human_review = d.get_or_create_human_review
|
get_or_create_human_review = d.get_or_create_human_review
|
||||||
update_review_processed_status = d.update_review_processed_status
|
update_review_processed_status = d.update_review_processed_status
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from pydantic import BaseModel, JsonValue, ValidationError
|
|||||||
|
|
||||||
from backend.data import execution as execution_db
|
from backend.data import execution as execution_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data import human_review as human_review_db
|
|
||||||
from backend.data import onboarding as onboarding_db
|
from backend.data import onboarding as onboarding_db
|
||||||
from backend.data import user as user_db
|
from backend.data import user as user_db
|
||||||
from backend.data.block import (
|
from backend.data.block import (
|
||||||
@@ -750,27 +749,9 @@ async def stop_graph_execution(
|
|||||||
if graph_exec.status in [
|
if graph_exec.status in [
|
||||||
ExecutionStatus.QUEUED,
|
ExecutionStatus.QUEUED,
|
||||||
ExecutionStatus.INCOMPLETE,
|
ExecutionStatus.INCOMPLETE,
|
||||||
ExecutionStatus.REVIEW,
|
|
||||||
]:
|
]:
|
||||||
# If the graph is queued/incomplete/paused for review, terminate immediately
|
# If the graph is still on the queue, we can prevent them from being executed
|
||||||
# No need to wait for executor since it's not actively running
|
# by setting the status to TERMINATED.
|
||||||
|
|
||||||
# If graph is in REVIEW status, clean up pending reviews before terminating
|
|
||||||
if graph_exec.status == ExecutionStatus.REVIEW:
|
|
||||||
# Use human_review_db if Prisma connected, else database manager
|
|
||||||
review_db = (
|
|
||||||
human_review_db
|
|
||||||
if prisma.is_connected()
|
|
||||||
else get_database_manager_async_client()
|
|
||||||
)
|
|
||||||
# Mark all pending reviews as rejected/cancelled
|
|
||||||
cancelled_count = await review_db.cancel_pending_reviews_for_execution(
|
|
||||||
graph_exec_id, user_id
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Cancelled {cancelled_count} pending review(s) for stopped execution {graph_exec_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
graph_exec.status = ExecutionStatus.TERMINATED
|
graph_exec.status = ExecutionStatus.TERMINATED
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
@@ -906,28 +887,9 @@ async def add_graph_execution(
|
|||||||
nodes_to_skip=nodes_to_skip,
|
nodes_to_skip=nodes_to_skip,
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
)
|
)
|
||||||
logger.info(f"Queueing execution {graph_exec.id}")
|
logger.info(f"Publishing execution {graph_exec.id} to execution queue")
|
||||||
|
|
||||||
# Update execution status to QUEUED BEFORE publishing to prevent race condition
|
|
||||||
# where two concurrent requests could both publish the same execution
|
|
||||||
updated_exec = await edb.update_graph_execution_stats(
|
|
||||||
graph_exec_id=graph_exec.id,
|
|
||||||
status=ExecutionStatus.QUEUED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the status update succeeded (prevents duplicate queueing in race conditions)
|
|
||||||
# If another request already updated the status, this execution will not be QUEUED
|
|
||||||
if not updated_exec or updated_exec.status != ExecutionStatus.QUEUED:
|
|
||||||
logger.warning(
|
|
||||||
f"Skipping queue publish for execution {graph_exec.id} - "
|
|
||||||
f"status update failed or execution already queued by another request"
|
|
||||||
)
|
|
||||||
return graph_exec
|
|
||||||
|
|
||||||
graph_exec.status = ExecutionStatus.QUEUED
|
|
||||||
|
|
||||||
# Publish to execution queue for executor to pick up
|
# Publish to execution queue for executor to pick up
|
||||||
# This happens AFTER status update to ensure only one request publishes
|
|
||||||
exec_queue = await get_async_execution_queue()
|
exec_queue = await get_async_execution_queue()
|
||||||
await exec_queue.publish_message(
|
await exec_queue.publish_message(
|
||||||
routing_key=GRAPH_EXECUTION_ROUTING_KEY,
|
routing_key=GRAPH_EXECUTION_ROUTING_KEY,
|
||||||
@@ -935,6 +897,13 @@ async def add_graph_execution(
|
|||||||
exchange=GRAPH_EXECUTION_EXCHANGE,
|
exchange=GRAPH_EXECUTION_EXCHANGE,
|
||||||
)
|
)
|
||||||
logger.info(f"Published execution {graph_exec.id} to RabbitMQ queue")
|
logger.info(f"Published execution {graph_exec.id} to RabbitMQ queue")
|
||||||
|
|
||||||
|
# Update execution status to QUEUED
|
||||||
|
graph_exec.status = ExecutionStatus.QUEUED
|
||||||
|
await edb.update_graph_execution_stats(
|
||||||
|
graph_exec_id=graph_exec.id,
|
||||||
|
status=graph_exec.status,
|
||||||
|
)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
err = str(e) or type(e).__name__
|
err = str(e) or type(e).__name__
|
||||||
if not graph_exec:
|
if not graph_exec:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import pytest
|
|||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from backend.data.dynamic_fields import merge_execution_input, parse_execution_output
|
from backend.data.dynamic_fields import merge_execution_input, parse_execution_output
|
||||||
from backend.data.execution import ExecutionStatus
|
|
||||||
from backend.util.mock import MockObject
|
from backend.util.mock import MockObject
|
||||||
|
|
||||||
|
|
||||||
@@ -347,7 +346,6 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
|||||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||||
mock_graph_exec.id = "execution-id-123"
|
mock_graph_exec.id = "execution-id-123"
|
||||||
mock_graph_exec.node_executions = [] # Add this to avoid AttributeError
|
mock_graph_exec.node_executions = [] # Add this to avoid AttributeError
|
||||||
mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check
|
|
||||||
mock_graph_exec.to_graph_execution_entry.return_value = mocker.MagicMock()
|
mock_graph_exec.to_graph_execution_entry.return_value = mocker.MagicMock()
|
||||||
|
|
||||||
# Mock the queue and event bus
|
# Mock the queue and event bus
|
||||||
@@ -613,7 +611,6 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
|||||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||||
mock_graph_exec.id = "execution-id-123"
|
mock_graph_exec.id = "execution-id-123"
|
||||||
mock_graph_exec.node_executions = []
|
mock_graph_exec.node_executions = []
|
||||||
mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check
|
|
||||||
|
|
||||||
# Track what's passed to to_graph_execution_entry
|
# Track what's passed to to_graph_execution_entry
|
||||||
captured_kwargs = {}
|
captured_kwargs = {}
|
||||||
@@ -673,232 +670,3 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
|||||||
# Verify nodes_to_skip was passed to to_graph_execution_entry
|
# Verify nodes_to_skip was passed to to_graph_execution_entry
|
||||||
assert "nodes_to_skip" in captured_kwargs
|
assert "nodes_to_skip" in captured_kwargs
|
||||||
assert captured_kwargs["nodes_to_skip"] == nodes_to_skip
|
assert captured_kwargs["nodes_to_skip"] == nodes_to_skip
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_graph_execution_in_review_status_cancels_pending_reviews(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
"""Test that stopping an execution in REVIEW status cancels pending reviews."""
|
|
||||||
from backend.data.execution import ExecutionStatus, GraphExecutionMeta
|
|
||||||
from backend.executor.utils import stop_graph_execution
|
|
||||||
|
|
||||||
user_id = "test-user"
|
|
||||||
graph_exec_id = "test-exec-123"
|
|
||||||
|
|
||||||
# Mock graph execution in REVIEW status
|
|
||||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionMeta)
|
|
||||||
mock_graph_exec.id = graph_exec_id
|
|
||||||
mock_graph_exec.status = ExecutionStatus.REVIEW
|
|
||||||
|
|
||||||
# Mock dependencies
|
|
||||||
mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue")
|
|
||||||
mock_queue_client = mocker.AsyncMock()
|
|
||||||
mock_get_queue.return_value = mock_queue_client
|
|
||||||
|
|
||||||
mock_prisma = mocker.patch("backend.executor.utils.prisma")
|
|
||||||
mock_prisma.is_connected.return_value = True
|
|
||||||
|
|
||||||
mock_human_review_db = mocker.patch("backend.executor.utils.human_review_db")
|
|
||||||
mock_human_review_db.cancel_pending_reviews_for_execution = mocker.AsyncMock(
|
|
||||||
return_value=2 # 2 reviews cancelled
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_execution_db = mocker.patch("backend.executor.utils.execution_db")
|
|
||||||
mock_execution_db.get_graph_execution_meta = mocker.AsyncMock(
|
|
||||||
return_value=mock_graph_exec
|
|
||||||
)
|
|
||||||
mock_execution_db.update_graph_execution_stats = mocker.AsyncMock()
|
|
||||||
|
|
||||||
mock_get_event_bus = mocker.patch(
|
|
||||||
"backend.executor.utils.get_async_execution_event_bus"
|
|
||||||
)
|
|
||||||
mock_event_bus = mocker.MagicMock()
|
|
||||||
mock_event_bus.publish = mocker.AsyncMock()
|
|
||||||
mock_get_event_bus.return_value = mock_event_bus
|
|
||||||
|
|
||||||
mock_get_child_executions = mocker.patch(
|
|
||||||
"backend.executor.utils._get_child_executions"
|
|
||||||
)
|
|
||||||
mock_get_child_executions.return_value = [] # No children
|
|
||||||
|
|
||||||
# Call stop_graph_execution with timeout to allow status check
|
|
||||||
await stop_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_exec_id=graph_exec_id,
|
|
||||||
wait_timeout=1.0, # Wait to allow status check
|
|
||||||
cascade=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify pending reviews were cancelled
|
|
||||||
mock_human_review_db.cancel_pending_reviews_for_execution.assert_called_once_with(
|
|
||||||
graph_exec_id, user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify execution status was updated to TERMINATED
|
|
||||||
mock_execution_db.update_graph_execution_stats.assert_called_once()
|
|
||||||
call_kwargs = mock_execution_db.update_graph_execution_stats.call_args[1]
|
|
||||||
assert call_kwargs["graph_exec_id"] == graph_exec_id
|
|
||||||
assert call_kwargs["status"] == ExecutionStatus.TERMINATED
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_graph_execution_with_database_manager_when_prisma_disconnected(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
"""Test that stop uses database manager when Prisma is not connected."""
|
|
||||||
from backend.data.execution import ExecutionStatus, GraphExecutionMeta
|
|
||||||
from backend.executor.utils import stop_graph_execution
|
|
||||||
|
|
||||||
user_id = "test-user"
|
|
||||||
graph_exec_id = "test-exec-456"
|
|
||||||
|
|
||||||
# Mock graph execution in REVIEW status
|
|
||||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionMeta)
|
|
||||||
mock_graph_exec.id = graph_exec_id
|
|
||||||
mock_graph_exec.status = ExecutionStatus.REVIEW
|
|
||||||
|
|
||||||
# Mock dependencies
|
|
||||||
mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue")
|
|
||||||
mock_queue_client = mocker.AsyncMock()
|
|
||||||
mock_get_queue.return_value = mock_queue_client
|
|
||||||
|
|
||||||
# Prisma is NOT connected
|
|
||||||
mock_prisma = mocker.patch("backend.executor.utils.prisma")
|
|
||||||
mock_prisma.is_connected.return_value = False
|
|
||||||
|
|
||||||
# Mock database manager client
|
|
||||||
mock_get_db_manager = mocker.patch(
|
|
||||||
"backend.executor.utils.get_database_manager_async_client"
|
|
||||||
)
|
|
||||||
mock_db_manager = mocker.AsyncMock()
|
|
||||||
mock_db_manager.get_graph_execution_meta = mocker.AsyncMock(
|
|
||||||
return_value=mock_graph_exec
|
|
||||||
)
|
|
||||||
mock_db_manager.cancel_pending_reviews_for_execution = mocker.AsyncMock(
|
|
||||||
return_value=3 # 3 reviews cancelled
|
|
||||||
)
|
|
||||||
mock_db_manager.update_graph_execution_stats = mocker.AsyncMock()
|
|
||||||
mock_get_db_manager.return_value = mock_db_manager
|
|
||||||
|
|
||||||
mock_get_event_bus = mocker.patch(
|
|
||||||
"backend.executor.utils.get_async_execution_event_bus"
|
|
||||||
)
|
|
||||||
mock_event_bus = mocker.MagicMock()
|
|
||||||
mock_event_bus.publish = mocker.AsyncMock()
|
|
||||||
mock_get_event_bus.return_value = mock_event_bus
|
|
||||||
|
|
||||||
mock_get_child_executions = mocker.patch(
|
|
||||||
"backend.executor.utils._get_child_executions"
|
|
||||||
)
|
|
||||||
mock_get_child_executions.return_value = [] # No children
|
|
||||||
|
|
||||||
# Call stop_graph_execution with timeout
|
|
||||||
await stop_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_exec_id=graph_exec_id,
|
|
||||||
wait_timeout=1.0,
|
|
||||||
cascade=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify database manager was used for cancel_pending_reviews
|
|
||||||
mock_db_manager.cancel_pending_reviews_for_execution.assert_called_once_with(
|
|
||||||
graph_exec_id, user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify execution status was updated via database manager
|
|
||||||
mock_db_manager.update_graph_execution_stats.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_graph_execution_cascades_to_child_with_reviews(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
"""Test that stopping parent execution cascades to children and cancels their reviews."""
|
|
||||||
from backend.data.execution import ExecutionStatus, GraphExecutionMeta
|
|
||||||
from backend.executor.utils import stop_graph_execution
|
|
||||||
|
|
||||||
user_id = "test-user"
|
|
||||||
parent_exec_id = "parent-exec"
|
|
||||||
child_exec_id = "child-exec"
|
|
||||||
|
|
||||||
# Mock parent execution in RUNNING status
|
|
||||||
mock_parent_exec = mocker.MagicMock(spec=GraphExecutionMeta)
|
|
||||||
mock_parent_exec.id = parent_exec_id
|
|
||||||
mock_parent_exec.status = ExecutionStatus.RUNNING
|
|
||||||
|
|
||||||
# Mock child execution in REVIEW status
|
|
||||||
mock_child_exec = mocker.MagicMock(spec=GraphExecutionMeta)
|
|
||||||
mock_child_exec.id = child_exec_id
|
|
||||||
mock_child_exec.status = ExecutionStatus.REVIEW
|
|
||||||
|
|
||||||
# Mock dependencies
|
|
||||||
mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue")
|
|
||||||
mock_queue_client = mocker.AsyncMock()
|
|
||||||
mock_get_queue.return_value = mock_queue_client
|
|
||||||
|
|
||||||
mock_prisma = mocker.patch("backend.executor.utils.prisma")
|
|
||||||
mock_prisma.is_connected.return_value = True
|
|
||||||
|
|
||||||
mock_human_review_db = mocker.patch("backend.executor.utils.human_review_db")
|
|
||||||
mock_human_review_db.cancel_pending_reviews_for_execution = mocker.AsyncMock(
|
|
||||||
return_value=1 # 1 child review cancelled
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock execution_db to return different status based on which execution is queried
|
|
||||||
mock_execution_db = mocker.patch("backend.executor.utils.execution_db")
|
|
||||||
|
|
||||||
# Track call count to simulate status transition
|
|
||||||
call_count = {"count": 0}
|
|
||||||
|
|
||||||
async def get_exec_meta_side_effect(execution_id, user_id):
|
|
||||||
call_count["count"] += 1
|
|
||||||
if execution_id == parent_exec_id:
|
|
||||||
# After a few calls (child processing happens), transition parent to TERMINATED
|
|
||||||
# This simulates the executor service processing the stop request
|
|
||||||
if call_count["count"] > 3:
|
|
||||||
mock_parent_exec.status = ExecutionStatus.TERMINATED
|
|
||||||
return mock_parent_exec
|
|
||||||
elif execution_id == child_exec_id:
|
|
||||||
return mock_child_exec
|
|
||||||
return None
|
|
||||||
|
|
||||||
mock_execution_db.get_graph_execution_meta = mocker.AsyncMock(
|
|
||||||
side_effect=get_exec_meta_side_effect
|
|
||||||
)
|
|
||||||
mock_execution_db.update_graph_execution_stats = mocker.AsyncMock()
|
|
||||||
|
|
||||||
mock_get_event_bus = mocker.patch(
|
|
||||||
"backend.executor.utils.get_async_execution_event_bus"
|
|
||||||
)
|
|
||||||
mock_event_bus = mocker.MagicMock()
|
|
||||||
mock_event_bus.publish = mocker.AsyncMock()
|
|
||||||
mock_get_event_bus.return_value = mock_event_bus
|
|
||||||
|
|
||||||
# Mock _get_child_executions to return the child
|
|
||||||
mock_get_child_executions = mocker.patch(
|
|
||||||
"backend.executor.utils._get_child_executions"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_children_side_effect(parent_id):
|
|
||||||
if parent_id == parent_exec_id:
|
|
||||||
return [mock_child_exec]
|
|
||||||
return []
|
|
||||||
|
|
||||||
mock_get_child_executions.side_effect = get_children_side_effect
|
|
||||||
|
|
||||||
# Call stop_graph_execution on parent with cascade=True
|
|
||||||
await stop_graph_execution(
|
|
||||||
user_id=user_id,
|
|
||||||
graph_exec_id=parent_exec_id,
|
|
||||||
wait_timeout=1.0,
|
|
||||||
cascade=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify child reviews were cancelled
|
|
||||||
mock_human_review_db.cancel_pending_reviews_for_execution.assert_called_once_with(
|
|
||||||
child_exec_id, user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify both parent and child status updates
|
|
||||||
assert mock_execution_db.update_graph_execution_stats.call_count >= 1
|
|
||||||
|
|||||||
@@ -350,19 +350,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
|||||||
description="Whether to mark failed scans as clean or not",
|
description="Whether to mark failed scans as clean or not",
|
||||||
)
|
)
|
||||||
|
|
||||||
agentgenerator_host: str = Field(
|
|
||||||
default="",
|
|
||||||
description="The host for the Agent Generator service (empty to use built-in)",
|
|
||||||
)
|
|
||||||
agentgenerator_port: int = Field(
|
|
||||||
default=8000,
|
|
||||||
description="The port for the Agent Generator service",
|
|
||||||
)
|
|
||||||
agentgenerator_timeout: int = Field(
|
|
||||||
default=120,
|
|
||||||
description="The timeout in seconds for Agent Generator service requests",
|
|
||||||
)
|
|
||||||
|
|
||||||
enable_example_blocks: bool = Field(
|
enable_example_blocks: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Whether to enable example blocks in production",
|
description="Whether to enable example blocks in production",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -59,11 +58,6 @@ class SpinTestServer:
|
|||||||
self.db_api.__exit__(exc_type, exc_val, exc_tb)
|
self.db_api.__exit__(exc_type, exc_val, exc_tb)
|
||||||
self.notif_manager.__exit__(exc_type, exc_val, exc_tb)
|
self.notif_manager.__exit__(exc_type, exc_val, exc_tb)
|
||||||
|
|
||||||
# Give services time to fully shut down
|
|
||||||
# This prevents event loop issues where services haven't fully cleaned up
|
|
||||||
# before the next test starts
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
def setup_dependency_overrides(self):
|
def setup_dependency_overrides(self):
|
||||||
# Override get_user_id for testing
|
# Override get_user_id for testing
|
||||||
self.agent_server.set_test_dependency_overrides(
|
self.agent_server.set_test_dependency_overrides(
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
-- Remove NodeExecution foreign key from PendingHumanReview
|
|
||||||
-- The nodeExecId column remains as the primary key, but we remove the FK constraint
|
|
||||||
-- to AgentNodeExecution since PendingHumanReview records can persist after node
|
|
||||||
-- execution records are deleted.
|
|
||||||
|
|
||||||
-- Drop foreign key constraint that linked PendingHumanReview.nodeExecId to AgentNodeExecution.id
|
|
||||||
ALTER TABLE "PendingHumanReview" DROP CONSTRAINT IF EXISTS "PendingHumanReview_nodeExecId_fkey";
|
|
||||||
@@ -517,6 +517,8 @@ model AgentNodeExecution {
|
|||||||
|
|
||||||
stats Json?
|
stats Json?
|
||||||
|
|
||||||
|
PendingHumanReview PendingHumanReview?
|
||||||
|
|
||||||
@@index([agentGraphExecutionId, agentNodeId, executionStatus])
|
@@index([agentGraphExecutionId, agentNodeId, executionStatus])
|
||||||
@@index([agentNodeId, executionStatus])
|
@@index([agentNodeId, executionStatus])
|
||||||
@@index([addedTime, queuedTime])
|
@@index([addedTime, queuedTime])
|
||||||
@@ -565,7 +567,6 @@ enum ReviewStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pending human reviews for Human-in-the-loop blocks
|
// Pending human reviews for Human-in-the-loop blocks
|
||||||
// Also stores auto-approval records with special nodeExecId patterns (e.g., "auto_approve_{graph_exec_id}_{node_id}")
|
|
||||||
model PendingHumanReview {
|
model PendingHumanReview {
|
||||||
nodeExecId String @id
|
nodeExecId String @id
|
||||||
userId String
|
userId String
|
||||||
@@ -584,6 +585,7 @@ model PendingHumanReview {
|
|||||||
reviewedAt DateTime?
|
reviewedAt DateTime?
|
||||||
|
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
NodeExecution AgentNodeExecution @relation(fields: [nodeExecId], references: [id], onDelete: Cascade)
|
||||||
GraphExecution AgentGraphExecution @relation(fields: [graphExecId], references: [id], onDelete: Cascade)
|
GraphExecution AgentGraphExecution @relation(fields: [graphExecId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([nodeExecId]) // One pending review per node execution
|
@@unique([nodeExecId]) // One pending review per node execution
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for agent generator module."""
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the Agent Generator core module.
|
|
||||||
|
|
||||||
This test suite verifies that the core functions correctly delegate to
|
|
||||||
the external Agent Generator service.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from backend.api.features.chat.tools.agent_generator import core
|
|
||||||
from backend.api.features.chat.tools.agent_generator.core import (
|
|
||||||
AgentGeneratorNotConfiguredError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestServiceNotConfigured:
|
|
||||||
"""Test that functions raise AgentGeneratorNotConfiguredError when service is not configured."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_raises_when_not_configured(self):
|
|
||||||
"""Test that decompose_goal raises error when service not configured."""
|
|
||||||
with patch.object(core, "is_external_service_configured", return_value=False):
|
|
||||||
with pytest.raises(AgentGeneratorNotConfiguredError):
|
|
||||||
await core.decompose_goal("Build a chatbot")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_agent_raises_when_not_configured(self):
|
|
||||||
"""Test that generate_agent raises error when service not configured."""
|
|
||||||
with patch.object(core, "is_external_service_configured", return_value=False):
|
|
||||||
with pytest.raises(AgentGeneratorNotConfiguredError):
|
|
||||||
await core.generate_agent({"steps": []})
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_agent_patch_raises_when_not_configured(self):
|
|
||||||
"""Test that generate_agent_patch raises error when service not configured."""
|
|
||||||
with patch.object(core, "is_external_service_configured", return_value=False):
|
|
||||||
with pytest.raises(AgentGeneratorNotConfiguredError):
|
|
||||||
await core.generate_agent_patch("Add a node", {"nodes": []})
|
|
||||||
|
|
||||||
|
|
||||||
class TestDecomposeGoal:
|
|
||||||
"""Test decompose_goal function service delegation."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_calls_external_service(self):
|
|
||||||
"""Test that decompose_goal calls the external service."""
|
|
||||||
expected_result = {"type": "instructions", "steps": ["Step 1"]}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "decompose_goal_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result
|
|
||||||
|
|
||||||
result = await core.decompose_goal("Build a chatbot")
|
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Build a chatbot", "")
|
|
||||||
assert result == expected_result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_passes_context_to_external_service(self):
|
|
||||||
"""Test that decompose_goal passes context to external service."""
|
|
||||||
expected_result = {"type": "instructions", "steps": ["Step 1"]}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "decompose_goal_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result
|
|
||||||
|
|
||||||
await core.decompose_goal("Build a chatbot", "Use Python")
|
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Build a chatbot", "Use Python")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_none_on_service_failure(self):
|
|
||||||
"""Test that decompose_goal returns None when external service fails."""
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "decompose_goal_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = None
|
|
||||||
|
|
||||||
result = await core.decompose_goal("Build a chatbot")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateAgent:
|
|
||||||
"""Test generate_agent function service delegation."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_calls_external_service(self):
|
|
||||||
"""Test that generate_agent calls the external service."""
|
|
||||||
expected_result = {"name": "Test Agent", "nodes": [], "links": []}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result
|
|
||||||
|
|
||||||
instructions = {"type": "instructions", "steps": ["Step 1"]}
|
|
||||||
result = await core.generate_agent(instructions)
|
|
||||||
|
|
||||||
mock_external.assert_called_once_with(instructions)
|
|
||||||
# Result should have id, version, is_active added if not present
|
|
||||||
assert result is not None
|
|
||||||
assert result["name"] == "Test Agent"
|
|
||||||
assert "id" in result
|
|
||||||
assert result["version"] == 1
|
|
||||||
assert result["is_active"] is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preserves_existing_id_and_version(self):
|
|
||||||
"""Test that external service result preserves existing id and version."""
|
|
||||||
expected_result = {
|
|
||||||
"id": "existing-id",
|
|
||||||
"version": 3,
|
|
||||||
"is_active": False,
|
|
||||||
"name": "Test Agent",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result.copy()
|
|
||||||
|
|
||||||
result = await core.generate_agent({"steps": []})
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result["id"] == "existing-id"
|
|
||||||
assert result["version"] == 3
|
|
||||||
assert result["is_active"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_none_when_external_service_fails(self):
|
|
||||||
"""Test that generate_agent returns None when external service fails."""
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = None
|
|
||||||
|
|
||||||
result = await core.generate_agent({"steps": []})
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateAgentPatch:
|
|
||||||
"""Test generate_agent_patch function service delegation."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_calls_external_service(self):
|
|
||||||
"""Test that generate_agent_patch calls the external service."""
|
|
||||||
expected_result = {"name": "Updated Agent", "nodes": [], "links": []}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_patch_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result
|
|
||||||
|
|
||||||
current_agent = {"nodes": [], "links": []}
|
|
||||||
result = await core.generate_agent_patch("Add a node", current_agent)
|
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Add a node", current_agent)
|
|
||||||
assert result == expected_result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_clarifying_questions(self):
|
|
||||||
"""Test that generate_agent_patch returns clarifying questions."""
|
|
||||||
expected_result = {
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": [{"question": "What type of node?"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_patch_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = expected_result
|
|
||||||
|
|
||||||
result = await core.generate_agent_patch("Add a node", {"nodes": []})
|
|
||||||
|
|
||||||
assert result == expected_result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_none_when_external_service_fails(self):
|
|
||||||
"""Test that generate_agent_patch returns None when service fails."""
|
|
||||||
with patch.object(
|
|
||||||
core, "is_external_service_configured", return_value=True
|
|
||||||
), patch.object(
|
|
||||||
core, "generate_agent_patch_external", new_callable=AsyncMock
|
|
||||||
) as mock_external:
|
|
||||||
mock_external.return_value = None
|
|
||||||
|
|
||||||
result = await core.generate_agent_patch("Add a node", {"nodes": []})
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestJsonToGraph:
|
|
||||||
"""Test json_to_graph function."""
|
|
||||||
|
|
||||||
def test_converts_agent_json_to_graph(self):
|
|
||||||
"""Test conversion of agent JSON to Graph model."""
|
|
||||||
agent_json = {
|
|
||||||
"id": "test-id",
|
|
||||||
"version": 2,
|
|
||||||
"is_active": True,
|
|
||||||
"name": "Test Agent",
|
|
||||||
"description": "A test agent",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "node1",
|
|
||||||
"block_id": "block1",
|
|
||||||
"input_default": {"key": "value"},
|
|
||||||
"metadata": {"x": 100},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"id": "link1",
|
|
||||||
"source_id": "node1",
|
|
||||||
"sink_id": "output",
|
|
||||||
"source_name": "result",
|
|
||||||
"sink_name": "input",
|
|
||||||
"is_static": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
graph = core.json_to_graph(agent_json)
|
|
||||||
|
|
||||||
assert graph.id == "test-id"
|
|
||||||
assert graph.version == 2
|
|
||||||
assert graph.is_active is True
|
|
||||||
assert graph.name == "Test Agent"
|
|
||||||
assert graph.description == "A test agent"
|
|
||||||
assert len(graph.nodes) == 1
|
|
||||||
assert graph.nodes[0].id == "node1"
|
|
||||||
assert graph.nodes[0].block_id == "block1"
|
|
||||||
assert len(graph.links) == 1
|
|
||||||
assert graph.links[0].source_id == "node1"
|
|
||||||
|
|
||||||
def test_generates_ids_if_missing(self):
|
|
||||||
"""Test that missing IDs are generated."""
|
|
||||||
agent_json = {
|
|
||||||
"name": "Test Agent",
|
|
||||||
"nodes": [{"block_id": "block1"}],
|
|
||||||
"links": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
graph = core.json_to_graph(agent_json)
|
|
||||||
|
|
||||||
assert graph.id is not None
|
|
||||||
assert graph.nodes[0].id is not None
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the Agent Generator external service client.
|
|
||||||
|
|
||||||
This test suite verifies the external Agent Generator service integration,
|
|
||||||
including service detection, API calls, and error handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from backend.api.features.chat.tools.agent_generator import service
|
|
||||||
|
|
||||||
|
|
||||||
class TestServiceConfiguration:
|
|
||||||
"""Test service configuration detection."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset settings singleton before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
def test_external_service_not_configured_when_host_empty(self):
|
|
||||||
"""Test that external service is not configured when host is empty."""
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.config.agentgenerator_host = ""
|
|
||||||
|
|
||||||
with patch.object(service, "_get_settings", return_value=mock_settings):
|
|
||||||
assert service.is_external_service_configured() is False
|
|
||||||
|
|
||||||
def test_external_service_configured_when_host_set(self):
|
|
||||||
"""Test that external service is configured when host is set."""
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.config.agentgenerator_host = "agent-generator.local"
|
|
||||||
|
|
||||||
with patch.object(service, "_get_settings", return_value=mock_settings):
|
|
||||||
assert service.is_external_service_configured() is True
|
|
||||||
|
|
||||||
def test_get_base_url(self):
|
|
||||||
"""Test base URL construction."""
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.config.agentgenerator_host = "agent-generator.local"
|
|
||||||
mock_settings.config.agentgenerator_port = 8000
|
|
||||||
|
|
||||||
with patch.object(service, "_get_settings", return_value=mock_settings):
|
|
||||||
url = service._get_base_url()
|
|
||||||
assert url == "http://agent-generator.local:8000"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDecomposeGoalExternal:
|
|
||||||
"""Test decompose_goal_external function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset client singleton before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_returns_instructions(self):
|
|
||||||
"""Test successful decomposition returning instructions."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"type": "instructions",
|
|
||||||
"steps": ["Step 1", "Step 2"],
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Build a chatbot")
|
|
||||||
|
|
||||||
assert result == {"type": "instructions", "steps": ["Step 1", "Step 2"]}
|
|
||||||
mock_client.post.assert_called_once_with(
|
|
||||||
"/api/decompose-description", json={"description": "Build a chatbot"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_returns_clarifying_questions(self):
|
|
||||||
"""Test decomposition returning clarifying questions."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": ["What platform?", "What language?"],
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Build something")
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": ["What platform?", "What language?"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_with_context(self):
|
|
||||||
"""Test decomposition with additional context."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"type": "instructions",
|
|
||||||
"steps": ["Step 1"],
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
await service.decompose_goal_external(
|
|
||||||
"Build a chatbot", context="Use Python"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_client.post.assert_called_once_with(
|
|
||||||
"/api/decompose-description",
|
|
||||||
json={"description": "Build a chatbot", "user_instruction": "Use Python"},
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_returns_unachievable_goal(self):
|
|
||||||
"""Test decomposition returning unachievable goal response."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"type": "unachievable_goal",
|
|
||||||
"reason": "Cannot do X",
|
|
||||||
"suggested_goal": "Try Y instead",
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Do something impossible")
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"type": "unachievable_goal",
|
|
||||||
"reason": "Cannot do X",
|
|
||||||
"suggested_goal": "Try Y instead",
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_handles_http_error(self):
|
|
||||||
"""Test decomposition handles HTTP errors gracefully."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.side_effect = httpx.HTTPStatusError(
|
|
||||||
"Server error", request=MagicMock(), response=MagicMock()
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Build a chatbot")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_handles_request_error(self):
|
|
||||||
"""Test decomposition handles request errors gracefully."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.side_effect = httpx.RequestError("Connection failed")
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Build a chatbot")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_decompose_goal_handles_service_error(self):
|
|
||||||
"""Test decomposition handles service returning error."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": False,
|
|
||||||
"error": "Internal error",
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.decompose_goal_external("Build a chatbot")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateAgentExternal:
|
|
||||||
"""Test generate_agent_external function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset client singleton before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_agent_success(self):
|
|
||||||
"""Test successful agent generation."""
|
|
||||||
agent_json = {
|
|
||||||
"name": "Test Agent",
|
|
||||||
"nodes": [],
|
|
||||||
"links": [],
|
|
||||||
}
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"agent_json": agent_json,
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
instructions = {"type": "instructions", "steps": ["Step 1"]}
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.generate_agent_external(instructions)
|
|
||||||
|
|
||||||
assert result == agent_json
|
|
||||||
mock_client.post.assert_called_once_with(
|
|
||||||
"/api/generate-agent", json={"instructions": instructions}
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_agent_handles_error(self):
|
|
||||||
"""Test agent generation handles errors gracefully."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.side_effect = httpx.RequestError("Connection failed")
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.generate_agent_external({"steps": []})
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateAgentPatchExternal:
|
|
||||||
"""Test generate_agent_patch_external function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset client singleton before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_patch_returns_updated_agent(self):
|
|
||||||
"""Test successful patch generation returning updated agent."""
|
|
||||||
updated_agent = {
|
|
||||||
"name": "Updated Agent",
|
|
||||||
"nodes": [{"id": "1", "block_id": "test"}],
|
|
||||||
"links": [],
|
|
||||||
}
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"agent_json": updated_agent,
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
current_agent = {"name": "Old Agent", "nodes": [], "links": []}
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.generate_agent_patch_external(
|
|
||||||
"Add a new node", current_agent
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == updated_agent
|
|
||||||
mock_client.post.assert_called_once_with(
|
|
||||||
"/api/update-agent",
|
|
||||||
json={
|
|
||||||
"update_request": "Add a new node",
|
|
||||||
"current_agent_json": current_agent,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_patch_returns_clarifying_questions(self):
|
|
||||||
"""Test patch generation returning clarifying questions."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": ["What type of node?"],
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.generate_agent_patch_external(
|
|
||||||
"Add something", {"nodes": []}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"type": "clarifying_questions",
|
|
||||||
"questions": ["What type of node?"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHealthCheck:
|
|
||||||
"""Test health_check function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset singletons before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health_check_returns_false_when_not_configured(self):
|
|
||||||
"""Test health check returns False when service not configured."""
|
|
||||||
with patch.object(
|
|
||||||
service, "is_external_service_configured", return_value=False
|
|
||||||
):
|
|
||||||
result = await service.health_check()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health_check_returns_true_when_healthy(self):
|
|
||||||
"""Test health check returns True when service is healthy."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"status": "healthy",
|
|
||||||
"blocks_loaded": True,
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.get.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "is_external_service_configured", return_value=True):
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.health_check()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_client.get.assert_called_once_with("/health")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health_check_returns_false_when_not_healthy(self):
|
|
||||||
"""Test health check returns False when service is not healthy."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"status": "unhealthy",
|
|
||||||
"blocks_loaded": False,
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.get.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "is_external_service_configured", return_value=True):
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.health_check()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health_check_returns_false_on_error(self):
|
|
||||||
"""Test health check returns False on connection error."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.get.side_effect = httpx.RequestError("Connection failed")
|
|
||||||
|
|
||||||
with patch.object(service, "is_external_service_configured", return_value=True):
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.health_check()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetBlocksExternal:
|
|
||||||
"""Test get_blocks_external function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset client singleton before each test."""
|
|
||||||
service._settings = None
|
|
||||||
service._client = None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_blocks_success(self):
|
|
||||||
"""Test successful blocks retrieval."""
|
|
||||||
blocks = [
|
|
||||||
{"id": "block1", "name": "Block 1"},
|
|
||||||
{"id": "block2", "name": "Block 2"},
|
|
||||||
]
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"success": True,
|
|
||||||
"blocks": blocks,
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.get.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.get_blocks_external()
|
|
||||||
|
|
||||||
assert result == blocks
|
|
||||||
mock_client.get.assert_called_once_with("/api/blocks")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_blocks_handles_error(self):
|
|
||||||
"""Test blocks retrieval handles errors gracefully."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.get.side_effect = httpx.RequestError("Connection failed")
|
|
||||||
|
|
||||||
with patch.object(service, "_get_client", return_value=mock_client):
|
|
||||||
result = await service.get_blocks_external()
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -86,6 +86,7 @@ export function FloatingSafeModeToggle({
|
|||||||
const {
|
const {
|
||||||
currentHITLSafeMode,
|
currentHITLSafeMode,
|
||||||
showHITLToggle,
|
showHITLToggle,
|
||||||
|
isHITLStateUndetermined,
|
||||||
handleHITLToggle,
|
handleHITLToggle,
|
||||||
currentSensitiveActionSafeMode,
|
currentSensitiveActionSafeMode,
|
||||||
showSensitiveActionToggle,
|
showSensitiveActionToggle,
|
||||||
@@ -98,9 +99,16 @@ export function FloatingSafeModeToggle({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showHITL = showHITLToggle && !isHITLStateUndetermined;
|
||||||
|
const showSensitive = showSensitiveActionToggle;
|
||||||
|
|
||||||
|
if (!showHITL && !showSensitive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("fixed z-50 flex flex-col gap-2", className)}>
|
<div className={cn("fixed z-50 flex flex-col gap-2", className)}>
|
||||||
{showHITLToggle && (
|
{showHITL && (
|
||||||
<SafeModeButton
|
<SafeModeButton
|
||||||
isEnabled={currentHITLSafeMode}
|
isEnabled={currentHITLSafeMode}
|
||||||
label="Human in the loop block approval"
|
label="Human in the loop block approval"
|
||||||
@@ -111,7 +119,7 @@ export function FloatingSafeModeToggle({
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSensitiveActionToggle && (
|
{showSensitive && (
|
||||||
<SafeModeButton
|
<SafeModeButton
|
||||||
isEnabled={currentSensitiveActionSafeMode}
|
isEnabled={currentSensitiveActionSafeMode}
|
||||||
label="Sensitive actions blocks approval"
|
label="Sensitive actions blocks approval"
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import {
|
|||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
|
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
|
||||||
import {
|
|
||||||
AIAgentSafetyPopup,
|
|
||||||
useAIAgentSafetyPopup,
|
|
||||||
} from "./components/AIAgentSafetyPopup/AIAgentSafetyPopup";
|
|
||||||
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
|
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
|
||||||
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
|
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
|
||||||
import { RunActions } from "./components/RunActions/RunActions";
|
import { RunActions } from "./components/RunActions/RunActions";
|
||||||
@@ -87,18 +83,8 @@ export function RunAgentModal({
|
|||||||
|
|
||||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||||
const [hasOverflow, setHasOverflow] = useState(false);
|
const [hasOverflow, setHasOverflow] = useState(false);
|
||||||
const [isSafetyPopupOpen, setIsSafetyPopupOpen] = useState(false);
|
|
||||||
const [pendingRunAction, setPendingRunAction] = useState<(() => void) | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { shouldShowPopup, dismissPopup } = useAIAgentSafetyPopup(
|
|
||||||
agent.id,
|
|
||||||
agent.has_sensitive_action,
|
|
||||||
agent.has_human_in_the_loop,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasAnySetupFields =
|
const hasAnySetupFields =
|
||||||
Object.keys(agentInputFields || {}).length > 0 ||
|
Object.keys(agentInputFields || {}).length > 0 ||
|
||||||
Object.keys(agentCredentialsInputFields || {}).length > 0;
|
Object.keys(agentCredentialsInputFields || {}).length > 0;
|
||||||
@@ -179,24 +165,6 @@ export function RunAgentModal({
|
|||||||
onScheduleCreated?.(schedule);
|
onScheduleCreated?.(schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRunWithSafetyCheck() {
|
|
||||||
if (shouldShowPopup) {
|
|
||||||
setPendingRunAction(() => handleRun);
|
|
||||||
setIsSafetyPopupOpen(true);
|
|
||||||
} else {
|
|
||||||
handleRun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSafetyPopupAcknowledge() {
|
|
||||||
setIsSafetyPopupOpen(false);
|
|
||||||
dismissPopup();
|
|
||||||
if (pendingRunAction) {
|
|
||||||
pendingRunAction();
|
|
||||||
setPendingRunAction(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -280,7 +248,7 @@ export function RunAgentModal({
|
|||||||
)}
|
)}
|
||||||
<RunActions
|
<RunActions
|
||||||
defaultRunType={defaultRunType}
|
defaultRunType={defaultRunType}
|
||||||
onRun={handleRunWithSafetyCheck}
|
onRun={handleRun}
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
isSettingUpTrigger={isSettingUpTrigger}
|
isSettingUpTrigger={isSettingUpTrigger}
|
||||||
isRunReady={allRequiredInputsAreSet}
|
isRunReady={allRequiredInputsAreSet}
|
||||||
@@ -298,12 +266,6 @@ export function RunAgentModal({
|
|||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AIAgentSafetyPopup
|
|
||||||
agentId={agent.id}
|
|
||||||
isOpen={isSafetyPopupOpen}
|
|
||||||
onAcknowledge={handleSafetyPopupAcknowledge}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { Key, storage } from "@/services/storage/local-storage";
|
|
||||||
import { ShieldCheckIcon } from "@phosphor-icons/react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
agentId: string;
|
|
||||||
onAcknowledge: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIAgentSafetyPopup({ agentId, onAcknowledge, isOpen }: Props) {
|
|
||||||
function handleAcknowledge() {
|
|
||||||
// Add this agent to the list of agents for which popup has been shown
|
|
||||||
const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN);
|
|
||||||
const seenAgents: string[] = seenAgentsJson
|
|
||||||
? JSON.parse(seenAgentsJson)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!seenAgents.includes(agentId)) {
|
|
||||||
seenAgents.push(agentId);
|
|
||||||
storage.set(Key.AI_AGENT_SAFETY_POPUP_SHOWN, JSON.stringify(seenAgents));
|
|
||||||
}
|
|
||||||
|
|
||||||
onAcknowledge();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
controlled={{ isOpen, set: () => {} }}
|
|
||||||
styling={{ maxWidth: "480px" }}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div className="flex flex-col items-center p-6 text-center">
|
|
||||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
|
||||||
<ShieldCheckIcon
|
|
||||||
weight="fill"
|
|
||||||
size={32}
|
|
||||||
className="text-blue-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text variant="h3" className="mb-4">
|
|
||||||
Safety Checks Enabled
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text variant="body" className="mb-2 text-zinc-700">
|
|
||||||
AI-generated agents may take actions that affect your data or
|
|
||||||
external systems.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text variant="body" className="mb-8 text-zinc-700">
|
|
||||||
AutoGPT includes safety checks so you'll always have the
|
|
||||||
opportunity to review and approve sensitive actions before they
|
|
||||||
happen.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleAcknowledge}
|
|
||||||
>
|
|
||||||
Got it
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAIAgentSafetyPopup(
|
|
||||||
agentId: string,
|
|
||||||
hasSensitiveAction: boolean,
|
|
||||||
hasHumanInTheLoop: boolean,
|
|
||||||
) {
|
|
||||||
const [shouldShowPopup, setShouldShowPopup] = useState(false);
|
|
||||||
const [hasChecked, setHasChecked] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasChecked) return;
|
|
||||||
|
|
||||||
const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN);
|
|
||||||
const seenAgents: string[] = seenAgentsJson
|
|
||||||
? JSON.parse(seenAgentsJson)
|
|
||||||
: [];
|
|
||||||
const hasSeenPopupForThisAgent = seenAgents.includes(agentId);
|
|
||||||
const isRelevantAgent = hasSensitiveAction || hasHumanInTheLoop;
|
|
||||||
|
|
||||||
setShouldShowPopup(!hasSeenPopupForThisAgent && isRelevantAgent);
|
|
||||||
setHasChecked(true);
|
|
||||||
}, [agentId, hasSensitiveAction, hasHumanInTheLoop, hasChecked]);
|
|
||||||
|
|
||||||
const dismissPopup = useCallback(() => {
|
|
||||||
setShouldShowPopup(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldShowPopup,
|
|
||||||
dismissPopup,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -69,6 +69,7 @@ export function SafeModeToggle({ graph, className }: Props) {
|
|||||||
const {
|
const {
|
||||||
currentHITLSafeMode,
|
currentHITLSafeMode,
|
||||||
showHITLToggle,
|
showHITLToggle,
|
||||||
|
isHITLStateUndetermined,
|
||||||
handleHITLToggle,
|
handleHITLToggle,
|
||||||
currentSensitiveActionSafeMode,
|
currentSensitiveActionSafeMode,
|
||||||
showSensitiveActionToggle,
|
showSensitiveActionToggle,
|
||||||
@@ -77,13 +78,20 @@ export function SafeModeToggle({ graph, className }: Props) {
|
|||||||
shouldShowToggle,
|
shouldShowToggle,
|
||||||
} = useAgentSafeMode(graph);
|
} = useAgentSafeMode(graph);
|
||||||
|
|
||||||
if (!shouldShowToggle) {
|
if (!shouldShowToggle || isHITLStateUndetermined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showHITL = showHITLToggle && !isHITLStateUndetermined;
|
||||||
|
const showSensitive = showSensitiveActionToggle;
|
||||||
|
|
||||||
|
if (!showHITL && !showSensitive) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-1", className)}>
|
<div className={cn("flex gap-1", className)}>
|
||||||
{showHITLToggle && (
|
{showHITL && (
|
||||||
<SafeModeIconButton
|
<SafeModeIconButton
|
||||||
isEnabled={currentHITLSafeMode}
|
isEnabled={currentHITLSafeMode}
|
||||||
label="Human-in-the-loop"
|
label="Human-in-the-loop"
|
||||||
@@ -93,7 +101,7 @@ export function SafeModeToggle({ graph, className }: Props) {
|
|||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSensitiveActionToggle && (
|
{showSensitive && (
|
||||||
<SafeModeIconButton
|
<SafeModeIconButton
|
||||||
isEnabled={currentSensitiveActionSafeMode}
|
isEnabled={currentSensitiveActionSafeMode}
|
||||||
label="Sensitive actions"
|
label="Sensitive actions"
|
||||||
|
|||||||
@@ -8809,12 +8809,6 @@
|
|||||||
"title": "Node Exec Id",
|
"title": "Node Exec Id",
|
||||||
"description": "Node execution ID (primary key)"
|
"description": "Node execution ID (primary key)"
|
||||||
},
|
},
|
||||||
"node_id": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Node Id",
|
|
||||||
"description": "Node definition ID (for grouping)",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"user_id": {
|
"user_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "User Id",
|
"title": "User Id",
|
||||||
@@ -8914,7 +8908,7 @@
|
|||||||
"created_at"
|
"created_at"
|
||||||
],
|
],
|
||||||
"title": "PendingHumanReviewModel",
|
"title": "PendingHumanReviewModel",
|
||||||
"description": "Response model for pending human review data.\n\nRepresents a human review request that is awaiting user action.\nContains all necessary information for a user to review and approve\nor reject data from a Human-in-the-Loop block execution.\n\nAttributes:\n id: Unique identifier for the review record\n user_id: ID of the user who must perform the review\n node_exec_id: ID of the node execution that created this review\n node_id: ID of the node definition (for grouping reviews from same node)\n graph_exec_id: ID of the graph execution containing the node\n graph_id: ID of the graph template being executed\n graph_version: Version number of the graph template\n payload: The actual data payload awaiting review\n instructions: Instructions or message for the reviewer\n editable: Whether the reviewer can edit the data\n status: Current review status (WAITING, APPROVED, or REJECTED)\n review_message: Optional message from the reviewer\n created_at: Timestamp when review was created\n updated_at: Timestamp when review was last modified\n reviewed_at: Timestamp when review was completed (if applicable)"
|
"description": "Response model for pending human review data.\n\nRepresents a human review request that is awaiting user action.\nContains all necessary information for a user to review and approve\nor reject data from a Human-in-the-Loop block execution.\n\nAttributes:\n id: Unique identifier for the review record\n user_id: ID of the user who must perform the review\n node_exec_id: ID of the node execution that created this review\n graph_exec_id: ID of the graph execution containing the node\n graph_id: ID of the graph template being executed\n graph_version: Version number of the graph template\n payload: The actual data payload awaiting review\n instructions: Instructions or message for the reviewer\n editable: Whether the reviewer can edit the data\n status: Current review status (WAITING, APPROVED, or REJECTED)\n review_message: Optional message from the reviewer\n created_at: Timestamp when review was created\n updated_at: Timestamp when review was last modified\n reviewed_at: Timestamp when review was completed (if applicable)"
|
||||||
},
|
},
|
||||||
"PostmarkBounceEnum": {
|
"PostmarkBounceEnum": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -9417,12 +9411,6 @@
|
|||||||
],
|
],
|
||||||
"title": "Reviewed Data",
|
"title": "Reviewed Data",
|
||||||
"description": "Optional edited data (ignored if approved=False)"
|
"description": "Optional edited data (ignored if approved=False)"
|
||||||
},
|
|
||||||
"auto_approve_future": {
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "Auto Approve Future",
|
|
||||||
"description": "If true and this review is approved, future executions of this same block (node) will be automatically approved. This only affects approved reviews.",
|
|
||||||
"default": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -9442,7 +9430,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["reviews"],
|
"required": ["reviews"],
|
||||||
"title": "ReviewRequest",
|
"title": "ReviewRequest",
|
||||||
"description": "Request model for processing ALL pending reviews for an execution.\n\nThis request must include ALL pending reviews for a graph execution.\nEach review will be either approved (with optional data modifications)\nor rejected (data ignored). The execution will resume only after ALL reviews are processed.\n\nEach review item can individually specify whether to auto-approve future executions\nof the same block via the `auto_approve_future` field on ReviewItem."
|
"description": "Request model for processing ALL pending reviews for an execution.\n\nThis request must include ALL pending reviews for a graph execution.\nEach review will be either approved (with optional data modifications)\nor rejected (data ignored). The execution will resume only after ALL reviews are processed."
|
||||||
},
|
},
|
||||||
"ReviewResponse": {
|
"ReviewResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -31,29 +31,6 @@ export function FloatingReviewsPanel({
|
|||||||
query: {
|
query: {
|
||||||
enabled: !!(graphId && executionId),
|
enabled: !!(graphId && executionId),
|
||||||
select: okData,
|
select: okData,
|
||||||
// Poll while execution is in progress to detect status changes
|
|
||||||
refetchInterval: (q) => {
|
|
||||||
// Note: refetchInterval callback receives raw data before select transform
|
|
||||||
const rawData = q.state.data as
|
|
||||||
| { status: number; data?: { status?: string } }
|
|
||||||
| undefined;
|
|
||||||
if (rawData?.status !== 200) return false;
|
|
||||||
|
|
||||||
const status = rawData?.data?.status;
|
|
||||||
if (!status) return false;
|
|
||||||
|
|
||||||
// Poll every 2 seconds while running or in review
|
|
||||||
if (
|
|
||||||
status === AgentExecutionStatus.RUNNING ||
|
|
||||||
status === AgentExecutionStatus.QUEUED ||
|
|
||||||
status === AgentExecutionStatus.INCOMPLETE ||
|
|
||||||
status === AgentExecutionStatus.REVIEW
|
|
||||||
) {
|
|
||||||
return 2000;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -63,47 +40,28 @@ export function FloatingReviewsPanel({
|
|||||||
useShallow((state) => state.graphExecutionStatus),
|
useShallow((state) => state.graphExecutionStatus),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine if we should poll for pending reviews
|
|
||||||
const isInReviewStatus =
|
|
||||||
executionDetails?.status === AgentExecutionStatus.REVIEW ||
|
|
||||||
graphExecutionStatus === AgentExecutionStatus.REVIEW;
|
|
||||||
|
|
||||||
const { pendingReviews, isLoading, refetch } = usePendingReviewsForExecution(
|
const { pendingReviews, isLoading, refetch } = usePendingReviewsForExecution(
|
||||||
executionId || "",
|
executionId || "",
|
||||||
{
|
|
||||||
enabled: !!executionId,
|
|
||||||
// Poll every 2 seconds when in REVIEW status to catch new reviews
|
|
||||||
refetchInterval: isInReviewStatus ? 2000 : false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refetch pending reviews when execution status changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (executionId && executionDetails?.status) {
|
if (executionId) {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
}, [executionDetails?.status, executionId, refetch]);
|
}, [executionDetails?.status, executionId, refetch]);
|
||||||
|
|
||||||
// Hide panel if:
|
// Refetch when graph execution status changes to REVIEW
|
||||||
// 1. No execution ID
|
useEffect(() => {
|
||||||
// 2. No pending reviews and not in REVIEW status
|
if (graphExecutionStatus === AgentExecutionStatus.REVIEW && executionId) {
|
||||||
// 3. Execution is RUNNING or QUEUED (hasn't paused for review yet)
|
refetch();
|
||||||
if (!executionId) {
|
}
|
||||||
return null;
|
}, [graphExecutionStatus, executionId, refetch]);
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isLoading &&
|
!executionId ||
|
||||||
pendingReviews.length === 0 &&
|
(!isLoading &&
|
||||||
executionDetails?.status !== AgentExecutionStatus.REVIEW
|
pendingReviews.length === 0 &&
|
||||||
) {
|
executionDetails?.status !== AgentExecutionStatus.REVIEW)
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't show panel while execution is still running/queued (not paused for review)
|
|
||||||
if (
|
|
||||||
executionDetails?.status === AgentExecutionStatus.RUNNING ||
|
|
||||||
executionDetails?.status === AgentExecutionStatus.QUEUED
|
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel";
|
import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||||
import { useEffect, useState } from "react";
|
import { TrashIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface StructuredReviewPayload {
|
interface StructuredReviewPayload {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@@ -38,49 +40,37 @@ function extractReviewData(payload: unknown): {
|
|||||||
interface PendingReviewCardProps {
|
interface PendingReviewCardProps {
|
||||||
review: PendingHumanReviewModel;
|
review: PendingHumanReviewModel;
|
||||||
onReviewDataChange: (nodeExecId: string, data: string) => void;
|
onReviewDataChange: (nodeExecId: string, data: string) => void;
|
||||||
autoApproveFuture?: boolean;
|
reviewMessage?: string;
|
||||||
onAutoApproveFutureChange?: (nodeExecId: string, enabled: boolean) => void;
|
onReviewMessageChange?: (nodeExecId: string, message: string) => void;
|
||||||
externalDataValue?: string;
|
isDisabled?: boolean;
|
||||||
showAutoApprove?: boolean;
|
onToggleDisabled?: (nodeExecId: string) => void;
|
||||||
nodeId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PendingReviewCard({
|
export function PendingReviewCard({
|
||||||
review,
|
review,
|
||||||
onReviewDataChange,
|
onReviewDataChange,
|
||||||
autoApproveFuture = false,
|
reviewMessage = "",
|
||||||
onAutoApproveFutureChange,
|
onReviewMessageChange,
|
||||||
externalDataValue,
|
isDisabled = false,
|
||||||
showAutoApprove = true,
|
onToggleDisabled,
|
||||||
nodeId,
|
|
||||||
}: PendingReviewCardProps) {
|
}: PendingReviewCardProps) {
|
||||||
const extractedData = extractReviewData(review.payload);
|
const extractedData = extractReviewData(review.payload);
|
||||||
const isDataEditable = review.editable;
|
const isDataEditable = review.editable;
|
||||||
|
const instructions = extractedData.instructions || review.instructions;
|
||||||
let instructions = review.instructions;
|
|
||||||
|
|
||||||
const isHITLBlock = instructions && !instructions.includes("Block");
|
|
||||||
|
|
||||||
if (instructions && !isHITLBlock) {
|
|
||||||
instructions = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [currentData, setCurrentData] = useState(extractedData.data);
|
const [currentData, setCurrentData] = useState(extractedData.data);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (externalDataValue !== undefined) {
|
|
||||||
try {
|
|
||||||
const parsedData = JSON.parse(externalDataValue);
|
|
||||||
setCurrentData(parsedData);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, [externalDataValue]);
|
|
||||||
|
|
||||||
const handleDataChange = (newValue: unknown) => {
|
const handleDataChange = (newValue: unknown) => {
|
||||||
setCurrentData(newValue);
|
setCurrentData(newValue);
|
||||||
onReviewDataChange(review.node_exec_id, JSON.stringify(newValue, null, 2));
|
onReviewDataChange(review.node_exec_id, JSON.stringify(newValue, null, 2));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMessageChange = (newMessage: string) => {
|
||||||
|
onReviewMessageChange?.(review.node_exec_id, newMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show simplified view when no toggle functionality is provided (Screenshot 1 mode)
|
||||||
|
const showSimplified = !onToggleDisabled;
|
||||||
|
|
||||||
const renderDataInput = () => {
|
const renderDataInput = () => {
|
||||||
const data = currentData;
|
const data = currentData;
|
||||||
|
|
||||||
@@ -147,59 +137,97 @@ export function PendingReviewCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getShortenedNodeId = (id: string) => {
|
// Helper function to get proper field label
|
||||||
if (id.length <= 8) return id;
|
const getFieldLabel = (instructions?: string) => {
|
||||||
return `${id.slice(0, 4)}...${id.slice(-4)}`;
|
if (instructions)
|
||||||
|
return instructions.charAt(0).toUpperCase() + instructions.slice(1);
|
||||||
|
return "Data to Review";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use the existing HITL review interface
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{nodeId && (
|
{!showSimplified && (
|
||||||
<Text variant="small" className="text-gray-500">
|
<div className="flex items-start justify-between">
|
||||||
Node #{getShortenedNodeId(nodeId)}
|
<div className="flex-1">
|
||||||
</Text>
|
{isDisabled && (
|
||||||
|
<Text variant="small" className="text-muted-foreground">
|
||||||
|
This item will be rejected
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => onToggleDisabled!(review.node_exec_id)}
|
||||||
|
variant={isDisabled ? "primary" : "secondary"}
|
||||||
|
size="small"
|
||||||
|
leftIcon={
|
||||||
|
isDisabled ? <EyeSlashIcon size={14} /> : <TrashIcon size={14} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDisabled ? "Include" : "Exclude"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Show instructions as field label */}
|
||||||
{instructions && (
|
{instructions && (
|
||||||
|
<div className="space-y-3">
|
||||||
<Text variant="body" className="font-semibold text-gray-900">
|
<Text variant="body" className="font-semibold text-gray-900">
|
||||||
{instructions}
|
{getFieldLabel(instructions)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{isDataEditable && !isDisabled ? (
|
||||||
|
renderDataInput()
|
||||||
{isDataEditable && !autoApproveFuture ? (
|
) : (
|
||||||
renderDataInput()
|
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||||
) : (
|
<Text variant="small" className="text-gray-600">
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
{JSON.stringify(currentData, null, 2)}
|
||||||
<Text variant="small" className="text-gray-600">
|
</Text>
|
||||||
{JSON.stringify(currentData, null, 2)}
|
</div>
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-approve toggle for this review */}
|
|
||||||
{showAutoApprove && onAutoApproveFutureChange && (
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch
|
|
||||||
checked={autoApproveFuture}
|
|
||||||
onCheckedChange={(enabled: boolean) =>
|
|
||||||
onAutoApproveFutureChange(review.node_exec_id, enabled)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-gray-700">
|
|
||||||
Auto-approve future executions of this block
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{autoApproveFuture && (
|
|
||||||
<Text variant="small" className="pl-11 text-gray-500">
|
|
||||||
Original data will be used for this and all future reviews from
|
|
||||||
this block.
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* If no instructions, show data directly */}
|
||||||
|
{!instructions && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Text variant="body" className="font-semibold text-gray-900">
|
||||||
|
Data to Review
|
||||||
|
{!isDataEditable && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
(Read-only)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{isDataEditable && !isDisabled ? (
|
||||||
|
renderDataInput()
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||||
|
<Text variant="small" className="text-gray-600">
|
||||||
|
{JSON.stringify(currentData, null, 2)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showSimplified && isDisabled && (
|
||||||
|
<div>
|
||||||
|
<Text variant="body" className="mb-2 font-semibold">
|
||||||
|
Rejection Reason (Optional):
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
id="rejection-reason"
|
||||||
|
label="Rejection Reason"
|
||||||
|
hideLabel
|
||||||
|
size="small"
|
||||||
|
type="textarea"
|
||||||
|
rows={3}
|
||||||
|
value={reviewMessage}
|
||||||
|
onChange={(e) => handleMessageChange(e.target.value)}
|
||||||
|
placeholder="Add any notes about why you're rejecting this..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel";
|
import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel";
|
||||||
import { PendingReviewCard } from "@/components/organisms/PendingReviewCard/PendingReviewCard";
|
import { PendingReviewCard } from "@/components/organisms/PendingReviewCard/PendingReviewCard";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
import {
|
import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
ClockIcon,
|
|
||||||
WarningIcon,
|
|
||||||
CaretDownIcon,
|
|
||||||
CaretRightIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { usePostV2ProcessReviewAction } from "@/app/api/__generated__/endpoints/executions/executions";
|
import { usePostV2ProcessReviewAction } from "@/app/api/__generated__/endpoints/executions/executions";
|
||||||
|
|
||||||
interface PendingReviewsListProps {
|
interface PendingReviewsListProps {
|
||||||
@@ -38,34 +32,16 @@ export function PendingReviewsList({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [reviewMessageMap, setReviewMessageMap] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
const [pendingAction, setPendingAction] = useState<
|
const [pendingAction, setPendingAction] = useState<
|
||||||
"approve" | "reject" | null
|
"approve" | "reject" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [autoApproveFutureMap, setAutoApproveFutureMap] = useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const groupedReviews = useMemo(() => {
|
|
||||||
return reviews.reduce(
|
|
||||||
(acc, review) => {
|
|
||||||
const nodeId = review.node_id || "unknown";
|
|
||||||
if (!acc[nodeId]) {
|
|
||||||
acc[nodeId] = [];
|
|
||||||
}
|
|
||||||
acc[nodeId].push(review);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, PendingHumanReviewModel[]>,
|
|
||||||
);
|
|
||||||
}, [reviews]);
|
|
||||||
|
|
||||||
const reviewActionMutation = usePostV2ProcessReviewAction({
|
const reviewActionMutation = usePostV2ProcessReviewAction({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
@@ -112,33 +88,8 @@ export function PendingReviewsList({
|
|||||||
setReviewDataMap((prev) => ({ ...prev, [nodeExecId]: data }));
|
setReviewDataMap((prev) => ({ ...prev, [nodeExecId]: data }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAutoApproveFutureToggle(nodeId: string, enabled: boolean) {
|
function handleReviewMessageChange(nodeExecId: string, message: string) {
|
||||||
setAutoApproveFutureMap((prev) => ({
|
setReviewMessageMap((prev) => ({ ...prev, [nodeExecId]: message }));
|
||||||
...prev,
|
|
||||||
[nodeId]: enabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
const nodeReviews = groupedReviews[nodeId] || [];
|
|
||||||
setReviewDataMap((prev) => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
nodeReviews.forEach((review) => {
|
|
||||||
updated[review.node_exec_id] = JSON.stringify(
|
|
||||||
review.payload,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGroupCollapse(nodeId: string) {
|
|
||||||
setCollapsedGroups((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[nodeId]: !prev[nodeId],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function processReviews(approved: boolean) {
|
function processReviews(approved: boolean) {
|
||||||
@@ -156,25 +107,22 @@ export function PendingReviewsList({
|
|||||||
|
|
||||||
for (const review of reviews) {
|
for (const review of reviews) {
|
||||||
const reviewData = reviewDataMap[review.node_exec_id];
|
const reviewData = reviewDataMap[review.node_exec_id];
|
||||||
const autoApproveThisNode = autoApproveFutureMap[review.node_id || ""];
|
const reviewMessage = reviewMessageMap[review.node_exec_id];
|
||||||
|
|
||||||
let parsedData: any = undefined;
|
let parsedData: any = review.payload; // Default to original payload
|
||||||
|
|
||||||
if (!autoApproveThisNode) {
|
// Parse edited data if available and editable
|
||||||
if (review.editable && reviewData) {
|
if (review.editable && reviewData) {
|
||||||
try {
|
try {
|
||||||
parsedData = JSON.parse(reviewData);
|
parsedData = JSON.parse(reviewData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid JSON",
|
title: "Invalid JSON",
|
||||||
description: `Please fix the JSON format in review for node ${review.node_exec_id}: ${error instanceof Error ? error.message : "Invalid syntax"}`,
|
description: `Please fix the JSON format in review for node ${review.node_exec_id}: ${error instanceof Error ? error.message : "Invalid syntax"}`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsedData = review.payload;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +130,7 @@ export function PendingReviewsList({
|
|||||||
node_exec_id: review.node_exec_id,
|
node_exec_id: review.node_exec_id,
|
||||||
approved,
|
approved,
|
||||||
reviewed_data: parsedData,
|
reviewed_data: parsedData,
|
||||||
auto_approve_future: autoApproveThisNode && approved,
|
message: reviewMessage || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +158,7 @@ export function PendingReviewsList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7 rounded-xl border border-yellow-150 bg-yellow-25 p-6">
|
<div className="space-y-7 rounded-xl border border-yellow-150 bg-yellow-25 p-6">
|
||||||
|
{/* Warning Box Header */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<WarningIcon
|
<WarningIcon
|
||||||
@@ -231,76 +180,23 @@ export function PendingReviewsList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
{Object.entries(groupedReviews).map(([nodeId, nodeReviews]) => {
|
{reviews.map((review) => (
|
||||||
const isCollapsed = collapsedGroups[nodeId] ?? nodeReviews.length > 1;
|
<PendingReviewCard
|
||||||
const reviewCount = nodeReviews.length;
|
key={review.node_exec_id}
|
||||||
|
review={review}
|
||||||
const firstReview = nodeReviews[0];
|
onReviewDataChange={handleReviewDataChange}
|
||||||
const blockName = firstReview?.instructions;
|
onReviewMessageChange={handleReviewMessageChange}
|
||||||
const reviewTitle = `Review required for ${blockName}`;
|
reviewMessage={reviewMessageMap[review.node_exec_id] || ""}
|
||||||
|
/>
|
||||||
const getShortenedNodeId = (id: string) => {
|
))}
|
||||||
if (id.length <= 8) return id;
|
|
||||||
return `${id.slice(0, 4)}...${id.slice(-4)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={nodeId} className="space-y-4">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleGroupCollapse(nodeId)}
|
|
||||||
className="flex w-full items-center gap-2 text-left"
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<CaretRightIcon size={20} className="text-gray-600" />
|
|
||||||
) : (
|
|
||||||
<CaretDownIcon size={20} className="text-gray-600" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<Text variant="body" className="font-semibold text-gray-900">
|
|
||||||
{reviewTitle}
|
|
||||||
</Text>
|
|
||||||
<Text variant="small" className="text-gray-500">
|
|
||||||
Node #{getShortenedNodeId(nodeId)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-600">
|
|
||||||
{reviewCount} {reviewCount === 1 ? "review" : "reviews"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{nodeReviews.map((review) => (
|
|
||||||
<PendingReviewCard
|
|
||||||
key={review.node_exec_id}
|
|
||||||
review={review}
|
|
||||||
onReviewDataChange={handleReviewDataChange}
|
|
||||||
autoApproveFuture={autoApproveFutureMap[nodeId] || false}
|
|
||||||
externalDataValue={reviewDataMap[review.node_exec_id]}
|
|
||||||
showAutoApprove={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<Switch
|
|
||||||
checked={autoApproveFutureMap[nodeId] || false}
|
|
||||||
onCheckedChange={(enabled: boolean) =>
|
|
||||||
handleAutoApproveFutureToggle(nodeId, enabled)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text variant="small" className="text-gray-700">
|
|
||||||
Auto-approve future executions of this node
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-7">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Text variant="body" className="text-textGrey">
|
||||||
|
Note: Changes you make here apply only to this task
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => processReviews(true)}
|
onClick={() => processReviews(true)}
|
||||||
disabled={reviewActionMutation.isPending || reviews.length === 0}
|
disabled={reviewActionMutation.isPending || reviews.length === 0}
|
||||||
@@ -324,11 +220,6 @@ export function PendingReviewsList({
|
|||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Text variant="small" className="text-textGrey">
|
|
||||||
You can turn auto-approval on or off using the toggle above for each
|
|
||||||
node.
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,22 +15,8 @@ export function usePendingReviews() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsePendingReviewsForExecutionOptions {
|
export function usePendingReviewsForExecution(graphExecId: string) {
|
||||||
enabled?: boolean;
|
const query = useGetV2GetPendingReviewsForExecution(graphExecId);
|
||||||
refetchInterval?: number | false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePendingReviewsForExecution(
|
|
||||||
graphExecId: string,
|
|
||||||
options?: UsePendingReviewsForExecutionOptions,
|
|
||||||
) {
|
|
||||||
const query = useGetV2GetPendingReviewsForExecution(graphExecId, {
|
|
||||||
query: {
|
|
||||||
enabled: options?.enabled ?? !!graphExecId,
|
|
||||||
refetchInterval: options?.refetchInterval,
|
|
||||||
refetchIntervalInBackground: !!options?.refetchInterval,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingReviews: okData(query.data) || [],
|
pendingReviews: okData(query.data) || [],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export enum Key {
|
|||||||
LIBRARY_AGENTS_CACHE = "library-agents-cache",
|
LIBRARY_AGENTS_CACHE = "library-agents-cache",
|
||||||
CHAT_SESSION_ID = "chat_session_id",
|
CHAT_SESSION_ID = "chat_session_id",
|
||||||
COOKIE_CONSENT = "autogpt_cookie_consent",
|
COOKIE_CONSENT = "autogpt_cookie_consent",
|
||||||
AI_AGENT_SAFETY_POPUP_SHOWN = "ai-agent-safety-popup-shown",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(key: Key) {
|
function get(key: Key) {
|
||||||
|
|||||||
Reference in New Issue
Block a user