mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-21 04:57:58 -05:00
Compare commits
1 Commits
feat/sensi
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c6c3eb46 |
2
.github/workflows/classic-frontend-ci.yml
vendored
2
.github/workflows/classic-frontend-ci.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Create PR ${{ env.BUILD_BRANCH }} -> ${{ github.ref_name }}
|
||||
if: github.event_name == 'push'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
add-paths: classic/frontend/build/web
|
||||
base: ${{ github.ref_name }}
|
||||
|
||||
@@ -4,9 +4,14 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from langfuse import get_client, propagate_attributes
|
||||
from langfuse.openai import openai # type: ignore
|
||||
from openai import APIConnectionError, APIError, APIStatusError, RateLimitError
|
||||
from langfuse import Langfuse
|
||||
from openai import (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
AsyncOpenAI,
|
||||
RateLimitError,
|
||||
)
|
||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
|
||||
|
||||
from backend.data.understanding import (
|
||||
@@ -16,6 +21,7 @@ from backend.data.understanding import (
|
||||
from backend.util.exceptions import NotFoundError
|
||||
from backend.util.settings import Settings
|
||||
|
||||
from . import db as chat_db
|
||||
from .config import ChatConfig
|
||||
from .model import (
|
||||
ChatMessage,
|
||||
@@ -44,10 +50,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
settings = Settings()
|
||||
client = openai.AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||
client = AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||
|
||||
|
||||
langfuse = get_client()
|
||||
# Langfuse client (lazy initialization)
|
||||
_langfuse_client: Langfuse | None = None
|
||||
|
||||
|
||||
class LangfuseNotConfiguredError(Exception):
|
||||
@@ -63,6 +69,65 @@ def _is_langfuse_configured() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _get_langfuse_client() -> Langfuse:
|
||||
"""Get or create the Langfuse client for prompt management and tracing."""
|
||||
global _langfuse_client
|
||||
if _langfuse_client is None:
|
||||
if not _is_langfuse_configured():
|
||||
raise LangfuseNotConfiguredError(
|
||||
"Langfuse is not configured. The chat feature requires Langfuse for prompt management. "
|
||||
"Please set the LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment variables."
|
||||
)
|
||||
_langfuse_client = Langfuse(
|
||||
public_key=settings.secrets.langfuse_public_key,
|
||||
secret_key=settings.secrets.langfuse_secret_key,
|
||||
host=settings.secrets.langfuse_host or "https://cloud.langfuse.com",
|
||||
)
|
||||
return _langfuse_client
|
||||
|
||||
|
||||
def _get_environment() -> str:
|
||||
"""Get the current environment name for Langfuse tagging."""
|
||||
return settings.config.app_env.value
|
||||
|
||||
|
||||
def _get_langfuse_prompt() -> str:
|
||||
"""Fetch the latest production prompt from Langfuse.
|
||||
|
||||
Returns:
|
||||
The compiled prompt text from Langfuse.
|
||||
|
||||
Raises:
|
||||
Exception: If Langfuse is unavailable or prompt fetch fails.
|
||||
"""
|
||||
try:
|
||||
langfuse = _get_langfuse_client()
|
||||
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
||||
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
|
||||
compiled = prompt.compile()
|
||||
logger.info(
|
||||
f"Fetched prompt '{config.langfuse_prompt_name}' from Langfuse "
|
||||
f"(version: {prompt.version})"
|
||||
)
|
||||
return compiled
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch prompt from Langfuse: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _is_first_session(user_id: str) -> bool:
|
||||
"""Check if this is the user's first chat session.
|
||||
|
||||
Returns True if the user has 1 or fewer sessions (meaning this is their first).
|
||||
"""
|
||||
try:
|
||||
session_count = await chat_db.get_user_session_count(user_id)
|
||||
return session_count <= 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check session count for user {user_id}: {e}")
|
||||
return False # Default to non-onboarding if we can't check
|
||||
|
||||
|
||||
async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
||||
"""Build the full system prompt including business understanding if available.
|
||||
|
||||
@@ -74,6 +139,8 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
||||
Tuple of (compiled prompt string, Langfuse prompt object for tracing)
|
||||
"""
|
||||
|
||||
langfuse = _get_langfuse_client()
|
||||
|
||||
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
||||
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
|
||||
|
||||
@@ -91,7 +158,7 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
||||
context = "This is the first time you are meeting the user. Greet them and introduce them to the platform"
|
||||
|
||||
compiled = prompt.compile(users_information=context)
|
||||
return compiled, understanding
|
||||
return compiled, prompt
|
||||
|
||||
|
||||
async def _generate_session_title(message: str) -> str | None:
|
||||
@@ -150,7 +217,6 @@ async def assign_user_to_session(
|
||||
async def stream_chat_completion(
|
||||
session_id: str,
|
||||
message: str | None = None,
|
||||
tool_call_response: str | None = None,
|
||||
is_user_message: bool = True,
|
||||
user_id: str | None = None,
|
||||
retry_count: int = 0,
|
||||
@@ -190,6 +256,11 @@ async def stream_chat_completion(
|
||||
yield StreamFinish()
|
||||
return
|
||||
|
||||
# Langfuse observations will be created after session is loaded (need messages for input)
|
||||
# Initialize to None so finally block can safely check and end them
|
||||
trace = None
|
||||
generation = None
|
||||
|
||||
# Only fetch from Redis if session not provided (initial call)
|
||||
if session is None:
|
||||
session = await get_chat_session(session_id, user_id)
|
||||
@@ -265,259 +336,297 @@ async def stream_chat_completion(
|
||||
asyncio.create_task(_update_title())
|
||||
|
||||
# Build system prompt with business understanding
|
||||
system_prompt, understanding = await _build_system_prompt(user_id)
|
||||
system_prompt, langfuse_prompt = await _build_system_prompt(user_id)
|
||||
|
||||
# Build input messages including system prompt for complete Langfuse logging
|
||||
trace_input_messages = [{"role": "system", "content": system_prompt}] + [
|
||||
m.model_dump() for m in session.messages
|
||||
]
|
||||
|
||||
# Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id)
|
||||
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes
|
||||
input = message
|
||||
if not message and tool_call_response:
|
||||
input = tool_call_response
|
||||
|
||||
langfuse = get_client()
|
||||
with langfuse.start_as_current_observation(
|
||||
as_type="span",
|
||||
name="user-copilot-request",
|
||||
input=input,
|
||||
) as span:
|
||||
with propagate_attributes(
|
||||
try:
|
||||
langfuse = _get_langfuse_client()
|
||||
env = _get_environment()
|
||||
trace = langfuse.start_observation(
|
||||
name="chat_completion",
|
||||
input={"messages": trace_input_messages},
|
||||
metadata={
|
||||
"environment": env,
|
||||
"model": config.model,
|
||||
"message_count": len(session.messages),
|
||||
"prompt_name": langfuse_prompt.name if langfuse_prompt else None,
|
||||
"prompt_version": langfuse_prompt.version if langfuse_prompt else None,
|
||||
},
|
||||
)
|
||||
# Set trace-level attributes (session_id, user_id, tags)
|
||||
trace.update_trace(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
tags=["copilot"],
|
||||
metadata={
|
||||
"users_information": format_understanding_for_prompt(understanding)[
|
||||
:200
|
||||
] # langfuse only accepts upto to 200 chars
|
||||
},
|
||||
):
|
||||
tags=[env, "copilot"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create Langfuse trace: {e}")
|
||||
|
||||
# Initialize variables that will be used in finally block (must be defined before try)
|
||||
assistant_response = ChatMessage(
|
||||
role="assistant",
|
||||
content="",
|
||||
# Initialize variables that will be used in finally block (must be defined before try)
|
||||
assistant_response = ChatMessage(
|
||||
role="assistant",
|
||||
content="",
|
||||
)
|
||||
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
||||
try:
|
||||
has_yielded_end = False
|
||||
has_yielded_error = False
|
||||
has_done_tool_call = False
|
||||
has_received_text = False
|
||||
text_streaming_ended = False
|
||||
tool_response_messages: list[ChatMessage] = []
|
||||
should_retry = False
|
||||
|
||||
# Generate unique IDs for AI SDK protocol
|
||||
import uuid as uuid_module
|
||||
|
||||
message_id = str(uuid_module.uuid4())
|
||||
text_block_id = str(uuid_module.uuid4())
|
||||
|
||||
# Yield message start
|
||||
yield StreamStart(messageId=message_id)
|
||||
|
||||
# Create Langfuse generation for each LLM call, linked to the prompt
|
||||
# Using v3 SDK: start_observation with as_type="generation"
|
||||
generation = (
|
||||
trace.start_observation(
|
||||
as_type="generation",
|
||||
name="llm_call",
|
||||
model=config.model,
|
||||
input={"messages": trace_input_messages},
|
||||
prompt=langfuse_prompt,
|
||||
)
|
||||
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||
if trace
|
||||
else None
|
||||
)
|
||||
|
||||
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
||||
has_yielded_end = False
|
||||
has_yielded_error = False
|
||||
has_done_tool_call = False
|
||||
has_received_text = False
|
||||
text_streaming_ended = False
|
||||
tool_response_messages: list[ChatMessage] = []
|
||||
should_retry = False
|
||||
try:
|
||||
async for chunk in _stream_chat_chunks(
|
||||
session=session,
|
||||
tools=tools,
|
||||
system_prompt=system_prompt,
|
||||
text_block_id=text_block_id,
|
||||
):
|
||||
|
||||
# Generate unique IDs for AI SDK protocol
|
||||
import uuid as uuid_module
|
||||
|
||||
message_id = str(uuid_module.uuid4())
|
||||
text_block_id = str(uuid_module.uuid4())
|
||||
|
||||
# Yield message start
|
||||
yield StreamStart(messageId=message_id)
|
||||
|
||||
try:
|
||||
async for chunk in _stream_chat_chunks(
|
||||
session=session,
|
||||
tools=tools,
|
||||
system_prompt=system_prompt,
|
||||
text_block_id=text_block_id,
|
||||
):
|
||||
|
||||
if isinstance(chunk, StreamTextStart):
|
||||
# Emit text-start before first text delta
|
||||
if not has_received_text:
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamTextDelta):
|
||||
delta = chunk.delta or ""
|
||||
assert assistant_response.content is not None
|
||||
assistant_response.content += delta
|
||||
has_received_text = True
|
||||
if isinstance(chunk, StreamTextStart):
|
||||
# Emit text-start before first text delta
|
||||
if not has_received_text:
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamTextEnd):
|
||||
# Emit text-end after text completes
|
||||
if has_received_text and not text_streaming_ended:
|
||||
text_streaming_ended = True
|
||||
if assistant_response.content:
|
||||
logger.warn(
|
||||
f"StreamTextEnd: Attempting to set output {assistant_response.content}"
|
||||
)
|
||||
span.update_trace(output=assistant_response.content)
|
||||
span.update(output=assistant_response.content)
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamToolInputStart):
|
||||
# Emit text-end before first tool call, but only if we've received text
|
||||
elif isinstance(chunk, StreamTextDelta):
|
||||
delta = chunk.delta or ""
|
||||
assert assistant_response.content is not None
|
||||
assistant_response.content += delta
|
||||
has_received_text = True
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamTextEnd):
|
||||
# Emit text-end after text completes
|
||||
if has_received_text and not text_streaming_ended:
|
||||
text_streaming_ended = True
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamToolInputStart):
|
||||
# Emit text-end before first tool call, but only if we've received text
|
||||
if has_received_text and not text_streaming_ended:
|
||||
yield StreamTextEnd(id=text_block_id)
|
||||
text_streaming_ended = True
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamToolInputAvailable):
|
||||
# Accumulate tool calls in OpenAI format
|
||||
accumulated_tool_calls.append(
|
||||
{
|
||||
"id": chunk.toolCallId,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": chunk.toolName,
|
||||
"arguments": orjson.dumps(chunk.input).decode("utf-8"),
|
||||
},
|
||||
}
|
||||
)
|
||||
elif isinstance(chunk, StreamToolOutputAvailable):
|
||||
result_content = (
|
||||
chunk.output
|
||||
if isinstance(chunk.output, str)
|
||||
else orjson.dumps(chunk.output).decode("utf-8")
|
||||
)
|
||||
tool_response_messages.append(
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
content=result_content,
|
||||
tool_call_id=chunk.toolCallId,
|
||||
)
|
||||
)
|
||||
has_done_tool_call = True
|
||||
# Track if any tool execution failed
|
||||
if not chunk.success:
|
||||
logger.warning(
|
||||
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
|
||||
)
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamFinish):
|
||||
if not has_done_tool_call:
|
||||
# Emit text-end before finish if we received text but haven't closed it
|
||||
if has_received_text and not text_streaming_ended:
|
||||
yield StreamTextEnd(id=text_block_id)
|
||||
text_streaming_ended = True
|
||||
has_yielded_end = True
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamToolInputAvailable):
|
||||
# Accumulate tool calls in OpenAI format
|
||||
accumulated_tool_calls.append(
|
||||
{
|
||||
"id": chunk.toolCallId,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": chunk.toolName,
|
||||
"arguments": orjson.dumps(chunk.input).decode(
|
||||
"utf-8"
|
||||
),
|
||||
},
|
||||
}
|
||||
elif isinstance(chunk, StreamError):
|
||||
has_yielded_error = True
|
||||
elif isinstance(chunk, StreamUsage):
|
||||
session.usage.append(
|
||||
Usage(
|
||||
prompt_tokens=chunk.promptTokens,
|
||||
completion_tokens=chunk.completionTokens,
|
||||
total_tokens=chunk.totalTokens,
|
||||
)
|
||||
elif isinstance(chunk, StreamToolOutputAvailable):
|
||||
result_content = (
|
||||
chunk.output
|
||||
if isinstance(chunk.output, str)
|
||||
else orjson.dumps(chunk.output).decode("utf-8")
|
||||
)
|
||||
tool_response_messages.append(
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
content=result_content,
|
||||
tool_call_id=chunk.toolCallId,
|
||||
)
|
||||
)
|
||||
has_done_tool_call = True
|
||||
# Track if any tool execution failed
|
||||
if not chunk.success:
|
||||
logger.warning(
|
||||
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
|
||||
)
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamFinish):
|
||||
if not has_done_tool_call:
|
||||
# Emit text-end before finish if we received text but haven't closed it
|
||||
if has_received_text and not text_streaming_ended:
|
||||
yield StreamTextEnd(id=text_block_id)
|
||||
text_streaming_ended = True
|
||||
has_yielded_end = True
|
||||
yield chunk
|
||||
elif isinstance(chunk, StreamError):
|
||||
has_yielded_error = True
|
||||
elif isinstance(chunk, StreamUsage):
|
||||
session.usage.append(
|
||||
Usage(
|
||||
prompt_tokens=chunk.promptTokens,
|
||||
completion_tokens=chunk.completionTokens,
|
||||
total_tokens=chunk.totalTokens,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown chunk type: {type(chunk)}", exc_info=True
|
||||
)
|
||||
if assistant_response.content:
|
||||
langfuse.update_current_trace(output=assistant_response.content)
|
||||
langfuse.update_current_span(output=assistant_response.content)
|
||||
elif tool_response_messages:
|
||||
langfuse.update_current_trace(output=str(tool_response_messages))
|
||||
langfuse.update_current_span(output=str(tool_response_messages))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during stream: {e!s}", exc_info=True)
|
||||
|
||||
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
|
||||
is_retryable = isinstance(
|
||||
e, (orjson.JSONDecodeError, KeyError, TypeError)
|
||||
)
|
||||
|
||||
if is_retryable and retry_count < config.max_retries:
|
||||
logger.info(
|
||||
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
|
||||
)
|
||||
should_retry = True
|
||||
else:
|
||||
# Non-retryable error or max retries exceeded
|
||||
# Save any partial progress before reporting error
|
||||
messages_to_save: list[ChatMessage] = []
|
||||
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during stream: {e!s}", exc_info=True)
|
||||
|
||||
# Add assistant message if it has content or tool calls
|
||||
if accumulated_tool_calls:
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
if assistant_response.content or assistant_response.tool_calls:
|
||||
messages_to_save.append(assistant_response)
|
||||
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
|
||||
is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
|
||||
|
||||
# Add tool response messages after assistant message
|
||||
messages_to_save.extend(tool_response_messages)
|
||||
|
||||
session.messages.extend(messages_to_save)
|
||||
await upsert_chat_session(session)
|
||||
|
||||
if not has_yielded_error:
|
||||
error_message = str(e)
|
||||
if not is_retryable:
|
||||
error_message = f"Non-retryable error: {error_message}"
|
||||
elif retry_count >= config.max_retries:
|
||||
error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}"
|
||||
|
||||
error_response = StreamError(errorText=error_message)
|
||||
yield error_response
|
||||
if not has_yielded_end:
|
||||
yield StreamFinish()
|
||||
return
|
||||
|
||||
# Handle retry outside of exception handler to avoid nesting
|
||||
if should_retry and retry_count < config.max_retries:
|
||||
if is_retryable and retry_count < config.max_retries:
|
||||
logger.info(
|
||||
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
|
||||
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
|
||||
)
|
||||
async for chunk in stream_chat_completion(
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
retry_count=retry_count + 1,
|
||||
session=session,
|
||||
context=context,
|
||||
):
|
||||
yield chunk
|
||||
return # Exit after retry to avoid double-saving in finally block
|
||||
should_retry = True
|
||||
else:
|
||||
# Non-retryable error or max retries exceeded
|
||||
# Save any partial progress before reporting error
|
||||
messages_to_save: list[ChatMessage] = []
|
||||
|
||||
# Normal completion path - save session and handle tool call continuation
|
||||
# Add assistant message if it has content or tool calls
|
||||
if accumulated_tool_calls:
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
if assistant_response.content or assistant_response.tool_calls:
|
||||
messages_to_save.append(assistant_response)
|
||||
|
||||
# Add tool response messages after assistant message
|
||||
messages_to_save.extend(tool_response_messages)
|
||||
|
||||
session.messages.extend(messages_to_save)
|
||||
await upsert_chat_session(session)
|
||||
|
||||
if not has_yielded_error:
|
||||
error_message = str(e)
|
||||
if not is_retryable:
|
||||
error_message = f"Non-retryable error: {error_message}"
|
||||
elif retry_count >= config.max_retries:
|
||||
error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}"
|
||||
|
||||
error_response = StreamError(errorText=error_message)
|
||||
yield error_response
|
||||
if not has_yielded_end:
|
||||
yield StreamFinish()
|
||||
return
|
||||
|
||||
# Handle retry outside of exception handler to avoid nesting
|
||||
if should_retry and retry_count < config.max_retries:
|
||||
logger.info(
|
||||
f"Normal completion path: session={session.session_id}, "
|
||||
f"current message_count={len(session.messages)}"
|
||||
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
|
||||
)
|
||||
async for chunk in stream_chat_completion(
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
retry_count=retry_count + 1,
|
||||
session=session,
|
||||
context=context,
|
||||
):
|
||||
yield chunk
|
||||
return # Exit after retry to avoid double-saving in finally block
|
||||
|
||||
# Normal completion path - save session and handle tool call continuation
|
||||
logger.info(
|
||||
f"Normal completion path: session={session.session_id}, "
|
||||
f"current message_count={len(session.messages)}"
|
||||
)
|
||||
|
||||
# Build the messages list in the correct order
|
||||
messages_to_save: list[ChatMessage] = []
|
||||
|
||||
# Add assistant message with tool_calls if any
|
||||
if accumulated_tool_calls:
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
logger.info(
|
||||
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
||||
)
|
||||
if assistant_response.content or assistant_response.tool_calls:
|
||||
messages_to_save.append(assistant_response)
|
||||
logger.info(
|
||||
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
|
||||
)
|
||||
|
||||
# Build the messages list in the correct order
|
||||
messages_to_save: list[ChatMessage] = []
|
||||
# Add tool response messages after assistant message
|
||||
messages_to_save.extend(tool_response_messages)
|
||||
logger.info(
|
||||
f"Saving {len(tool_response_messages)} tool response messages, "
|
||||
f"total_to_save={len(messages_to_save)}"
|
||||
)
|
||||
|
||||
# Add assistant message with tool_calls if any
|
||||
if accumulated_tool_calls:
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
logger.info(
|
||||
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
||||
)
|
||||
if assistant_response.content or assistant_response.tool_calls:
|
||||
messages_to_save.append(assistant_response)
|
||||
logger.info(
|
||||
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
|
||||
)
|
||||
session.messages.extend(messages_to_save)
|
||||
logger.info(
|
||||
f"Extended session messages, new message_count={len(session.messages)}"
|
||||
)
|
||||
await upsert_chat_session(session)
|
||||
|
||||
# Add tool response messages after assistant message
|
||||
messages_to_save.extend(tool_response_messages)
|
||||
# If we did a tool call, stream the chat completion again to get the next response
|
||||
if has_done_tool_call:
|
||||
logger.info(
|
||||
f"Saving {len(tool_response_messages)} tool response messages, "
|
||||
f"total_to_save={len(messages_to_save)}"
|
||||
"Tool call executed, streaming chat completion again to get assistant response"
|
||||
)
|
||||
async for chunk in stream_chat_completion(
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
session=session, # Pass session object to avoid Redis refetch
|
||||
context=context,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
session.messages.extend(messages_to_save)
|
||||
logger.info(
|
||||
f"Extended session messages, new message_count={len(session.messages)}"
|
||||
)
|
||||
await upsert_chat_session(session)
|
||||
|
||||
# If we did a tool call, stream the chat completion again to get the next response
|
||||
if has_done_tool_call:
|
||||
logger.info(
|
||||
"Tool call executed, streaming chat completion again to get assistant response"
|
||||
finally:
|
||||
# Always end Langfuse observations to prevent resource leaks
|
||||
# Guard against None and catch errors to avoid masking original exceptions
|
||||
if generation is not None:
|
||||
try:
|
||||
latest_usage = session.usage[-1] if session.usage else None
|
||||
generation.update(
|
||||
model=config.model,
|
||||
output={
|
||||
"content": assistant_response.content,
|
||||
"tool_calls": accumulated_tool_calls or None,
|
||||
},
|
||||
usage_details=(
|
||||
{
|
||||
"input": latest_usage.prompt_tokens,
|
||||
"output": latest_usage.completion_tokens,
|
||||
"total": latest_usage.total_tokens,
|
||||
}
|
||||
if latest_usage
|
||||
else None
|
||||
),
|
||||
)
|
||||
async for chunk in stream_chat_completion(
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
session=session, # Pass session object to avoid Redis refetch
|
||||
context=context,
|
||||
tool_call_response=str(tool_response_messages),
|
||||
):
|
||||
yield chunk
|
||||
generation.end()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to end Langfuse generation: {e}")
|
||||
|
||||
if trace is not None:
|
||||
try:
|
||||
if accumulated_tool_calls:
|
||||
trace.update_trace(output={"tool_calls": accumulated_tool_calls})
|
||||
else:
|
||||
trace.update_trace(output={"response": assistant_response.content})
|
||||
trace.end()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to end Langfuse trace: {e}")
|
||||
|
||||
|
||||
# Retry configuration for OpenAI API calls
|
||||
@@ -791,4 +900,5 @@ async def _yield_tool_call(
|
||||
session=session,
|
||||
)
|
||||
|
||||
logger.info(f"Yielding Tool execution response: {tool_execution_response}")
|
||||
yield tool_execution_response
|
||||
|
||||
@@ -30,7 +30,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
||||
"find_library_agent": FindLibraryAgentTool(),
|
||||
"run_agent": RunAgentTool(),
|
||||
"run_block": RunBlockTool(),
|
||||
"view_agent_output": AgentOutputTool(),
|
||||
"agent_output": AgentOutputTool(),
|
||||
"search_docs": SearchDocsTool(),
|
||||
"get_doc_page": GetDocPageTool(),
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
from backend.data.understanding import (
|
||||
BusinessUnderstandingInput,
|
||||
@@ -61,7 +59,6 @@ and automations for the user's specific needs."""
|
||||
"""Requires authentication to store user-specific data."""
|
||||
return True
|
||||
|
||||
@observe(as_type="tool", name="add_understanding")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -218,7 +218,6 @@ async def save_agent_to_library(
|
||||
library_agents = await library_db.create_library_agent(
|
||||
graph=created_graph,
|
||||
user_id=user_id,
|
||||
sensitive_action_safe_mode=True,
|
||||
create_library_agents_for_sub_graphs=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
@@ -104,7 +103,7 @@ class AgentOutputTool(BaseTool):
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "view_agent_output"
|
||||
return "agent_output"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
@@ -329,7 +328,6 @@ class AgentOutputTool(BaseTool):
|
||||
total_executions=len(available_executions) if available_executions else 1,
|
||||
)
|
||||
|
||||
@observe(as_type="tool", name="view_agent_output")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
|
||||
from .agent_generator import (
|
||||
@@ -80,7 +78,6 @@ class CreateAgentTool(BaseTool):
|
||||
"required": ["description"],
|
||||
}
|
||||
|
||||
@observe(as_type="tool", name="create_agent")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
|
||||
from .agent_generator import (
|
||||
@@ -87,7 +85,6 @@ class EditAgentTool(BaseTool):
|
||||
"required": ["agent_id", "changes"],
|
||||
}
|
||||
|
||||
@observe(as_type="tool", name="edit_agent")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
|
||||
from .agent_search import search_agents
|
||||
@@ -37,7 +35,6 @@ class FindAgentTool(BaseTool):
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
@observe(as_type="tool", name="find_agent")
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
from prisma.enums import ContentType
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
@@ -56,7 +55,6 @@ class FindBlockTool(BaseTool):
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@observe(as_type="tool", name="find_block")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
|
||||
from .agent_search import search_agents
|
||||
@@ -43,7 +41,6 @@ class FindLibraryAgentTool(BaseTool):
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@observe(as_type="tool", name="find_library_agent")
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
|
||||
@@ -4,8 +4,6 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
from backend.api.features.chat.tools.base import BaseTool
|
||||
from backend.api.features.chat.tools.models import (
|
||||
@@ -73,7 +71,6 @@ class GetDocPageTool(BaseTool):
|
||||
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
||||
return f"{DOCS_BASE_URL}/{url_path}"
|
||||
|
||||
@observe(as_type="tool", name="get_doc_page")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.api.features.chat.config import ChatConfig
|
||||
@@ -33,7 +32,7 @@ from .models import (
|
||||
UserReadiness,
|
||||
)
|
||||
from .utils import (
|
||||
build_missing_credentials_from_graph,
|
||||
check_user_has_required_credentials,
|
||||
extract_credentials_from_schema,
|
||||
fetch_graph_from_store_slug,
|
||||
get_or_create_library_agent,
|
||||
@@ -155,7 +154,6 @@ class RunAgentTool(BaseTool):
|
||||
"""All operations require authentication."""
|
||||
return True
|
||||
|
||||
@observe(as_type="tool", name="run_agent")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
@@ -237,13 +235,15 @@ class RunAgentTool(BaseTool):
|
||||
# Return credentials needed response with input data info
|
||||
# The UI handles credential setup automatically, so the message
|
||||
# focuses on asking about input data
|
||||
requirements_creds_dict = build_missing_credentials_from_graph(
|
||||
graph, None
|
||||
credentials = extract_credentials_from_schema(
|
||||
graph.credentials_input_schema
|
||||
)
|
||||
missing_credentials_dict = build_missing_credentials_from_graph(
|
||||
graph, graph_credentials
|
||||
missing_creds_check = await check_user_has_required_credentials(
|
||||
user_id, credentials
|
||||
)
|
||||
requirements_creds_list = list(requirements_creds_dict.values())
|
||||
missing_credentials_dict = {
|
||||
c.id: c.model_dump() for c in missing_creds_check
|
||||
}
|
||||
|
||||
return SetupRequirementsResponse(
|
||||
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
|
||||
@@ -257,7 +257,7 @@ class RunAgentTool(BaseTool):
|
||||
ready_to_run=False,
|
||||
),
|
||||
requirements={
|
||||
"credentials": requirements_creds_list,
|
||||
"credentials": [c.model_dump() for c in credentials],
|
||||
"inputs": self._get_inputs_list(graph.input_schema),
|
||||
"execution_modes": self._get_execution_modes(graph),
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
from backend.data.block import get_block
|
||||
from backend.data.execution import ExecutionContext
|
||||
@@ -22,7 +20,6 @@ from .models import (
|
||||
ToolResponseBase,
|
||||
UserReadiness,
|
||||
)
|
||||
from .utils import build_missing_credentials_from_field_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -130,7 +127,6 @@ class RunBlockTool(BaseTool):
|
||||
|
||||
return matched_credentials, missing_credentials
|
||||
|
||||
@observe(as_type="tool", name="run_block")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
@@ -190,11 +186,7 @@ class RunBlockTool(BaseTool):
|
||||
|
||||
if missing_credentials:
|
||||
# Return setup requirements response with missing credentials
|
||||
credentials_fields_info = block.input_schema.get_credentials_fields_info()
|
||||
missing_creds_dict = build_missing_credentials_from_field_info(
|
||||
credentials_fields_info, set(matched_credentials.keys())
|
||||
)
|
||||
missing_creds_list = list(missing_creds_dict.values())
|
||||
missing_creds_dict = {c.id: c.model_dump() for c in missing_credentials}
|
||||
|
||||
return SetupRequirementsResponse(
|
||||
message=(
|
||||
@@ -211,7 +203,7 @@ class RunBlockTool(BaseTool):
|
||||
ready_to_run=False,
|
||||
),
|
||||
requirements={
|
||||
"credentials": missing_creds_list,
|
||||
"credentials": [c.model_dump() for c in missing_credentials],
|
||||
"inputs": self._get_inputs_list(block),
|
||||
"execution_modes": ["immediate"],
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse import observe
|
||||
from prisma.enums import ContentType
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
@@ -88,7 +87,6 @@ class SearchDocsTool(BaseTool):
|
||||
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
||||
return f"{DOCS_BASE_URL}/{url_path}"
|
||||
|
||||
@observe(as_type="tool", name="search_docs")
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.api.features.library import model as library_model
|
||||
from backend.api.features.store import db as store_db
|
||||
from backend.data import graph as graph_db
|
||||
from backend.data.graph import GraphModel
|
||||
from backend.data.model import CredentialsFieldInfo, CredentialsMetaInput
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
@@ -89,59 +89,6 @@ def extract_credentials_from_schema(
|
||||
return credentials
|
||||
|
||||
|
||||
def _serialize_missing_credential(
|
||||
field_key: str, field_info: CredentialsFieldInfo
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Convert credential field info into a serializable dict that preserves all supported
|
||||
credential types (e.g., api_key + oauth2) so the UI can offer multiple options.
|
||||
"""
|
||||
supported_types = sorted(field_info.supported_types)
|
||||
provider = next(iter(field_info.provider), "unknown")
|
||||
scopes = sorted(field_info.required_scopes or [])
|
||||
|
||||
return {
|
||||
"id": field_key,
|
||||
"title": field_key.replace("_", " ").title(),
|
||||
"provider": provider,
|
||||
"provider_name": provider.replace("_", " ").title(),
|
||||
"type": supported_types[0] if supported_types else "api_key",
|
||||
"types": supported_types,
|
||||
"scopes": scopes,
|
||||
}
|
||||
|
||||
|
||||
def build_missing_credentials_from_graph(
|
||||
graph: GraphModel, matched_credentials: dict[str, CredentialsMetaInput] | None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a missing_credentials mapping from a graph's aggregated credentials inputs,
|
||||
preserving all supported credential types for each field.
|
||||
"""
|
||||
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
|
||||
aggregated_fields = graph.aggregate_credentials_inputs()
|
||||
|
||||
return {
|
||||
field_key: _serialize_missing_credential(field_key, field_info)
|
||||
for field_key, (field_info, _node_fields) in aggregated_fields.items()
|
||||
if field_key not in matched_keys
|
||||
}
|
||||
|
||||
|
||||
def build_missing_credentials_from_field_info(
|
||||
credential_fields: dict[str, CredentialsFieldInfo],
|
||||
matched_keys: set[str],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build missing_credentials mapping from a simple credentials field info dictionary.
|
||||
"""
|
||||
return {
|
||||
field_key: _serialize_missing_credential(field_key, field_info)
|
||||
for field_key, field_info in credential_fields.items()
|
||||
if field_key not in matched_keys
|
||||
}
|
||||
|
||||
|
||||
def extract_credentials_as_dict(
|
||||
credentials_input_schema: dict[str, Any] | None,
|
||||
) -> dict[str, CredentialsMetaInput]:
|
||||
|
||||
@@ -41,7 +41,6 @@ class PendingHumanReviewModel(BaseModel):
|
||||
graph_exec_id: str = Field(description="Graph execution ID")
|
||||
graph_id: str = Field(description="Graph ID")
|
||||
graph_version: int = Field(description="Graph version")
|
||||
node_id: str = Field(description="Node ID in the graph definition")
|
||||
payload: SafeJsonData = Field(description="The actual data payload awaiting review")
|
||||
instructions: str | None = Field(
|
||||
description="Instructions or message for the reviewer", default=None
|
||||
@@ -82,7 +81,6 @@ class PendingHumanReviewModel(BaseModel):
|
||||
graph_exec_id=review.graphExecId,
|
||||
graph_id=review.graphId,
|
||||
graph_version=review.graphVersion,
|
||||
node_id=review.nodeId,
|
||||
payload=review.payload,
|
||||
instructions=review.instructions,
|
||||
editable=review.editable,
|
||||
@@ -181,15 +179,6 @@ class ReviewRequest(BaseModel):
|
||||
reviews: List[ReviewItem] = Field(
|
||||
description="All reviews with their approval status, data, and messages"
|
||||
)
|
||||
auto_approve_node_ids: List[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"List of node IDs (from the graph definition) to auto-approve for "
|
||||
"the remainder of this execution. Future reviews from these specific "
|
||||
"nodes will be automatically approved. This only affects the current "
|
||||
"execution run."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_review_completeness(self):
|
||||
|
||||
@@ -41,7 +41,6 @@ def sample_pending_review(test_user_id: str) -> PendingHumanReviewModel:
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
payload={"data": "test payload", "value": 42},
|
||||
instructions="Please review this data",
|
||||
editable=True,
|
||||
@@ -161,7 +160,6 @@ def test_process_review_action_approve_success(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
payload={"data": "modified payload", "value": 50},
|
||||
instructions="Please review this data",
|
||||
editable=True,
|
||||
@@ -225,7 +223,6 @@ def test_process_review_action_reject_success(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
payload={"data": "test payload"},
|
||||
instructions="Please review",
|
||||
editable=True,
|
||||
@@ -277,7 +274,6 @@ def test_process_review_action_mixed_success(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_456",
|
||||
payload={"data": "second payload"},
|
||||
instructions="Second review",
|
||||
editable=False,
|
||||
@@ -307,7 +303,6 @@ def test_process_review_action_mixed_success(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
payload={"data": "modified"},
|
||||
instructions="Please review",
|
||||
editable=True,
|
||||
@@ -326,7 +321,6 @@ def test_process_review_action_mixed_success(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_456",
|
||||
payload={"data": "second payload"},
|
||||
instructions="Second review",
|
||||
editable=False,
|
||||
|
||||
@@ -5,7 +5,7 @@ import autogpt_libs.auth as autogpt_auth_lib
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.data.execution import ExecutionContext, get_graph_execution_meta
|
||||
from backend.data.execution import get_graph_execution_meta
|
||||
from backend.data.human_review import (
|
||||
get_pending_reviews_for_execution,
|
||||
get_pending_reviews_for_user,
|
||||
@@ -169,23 +169,10 @@ async def process_review_action(
|
||||
if not still_has_pending:
|
||||
# Resume execution
|
||||
try:
|
||||
# If auto_approve_node_ids is set, create a context that will
|
||||
# automatically approve future reviews from these specific nodes
|
||||
execution_context = None
|
||||
if request.auto_approve_node_ids:
|
||||
execution_context = ExecutionContext(
|
||||
auto_approved_node_ids=set(request.auto_approve_node_ids),
|
||||
)
|
||||
logger.info(
|
||||
f"Auto-approving future reviews for nodes "
|
||||
f"{request.auto_approve_node_ids} in execution {graph_exec_id}"
|
||||
)
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id=first_review.graph_id,
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Resumed execution {graph_exec_id}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -401,11 +401,27 @@ async def add_generated_agent_image(
|
||||
)
|
||||
|
||||
|
||||
def _initialize_graph_settings(graph: graph_db.GraphModel) -> GraphSettings:
|
||||
"""
|
||||
Initialize GraphSettings based on graph content.
|
||||
|
||||
Args:
|
||||
graph: The graph to analyze
|
||||
|
||||
Returns:
|
||||
GraphSettings with appropriate human_in_the_loop_safe_mode value
|
||||
"""
|
||||
if graph.has_human_in_the_loop:
|
||||
# Graph has HITL blocks - set safe mode to True by default
|
||||
return GraphSettings(human_in_the_loop_safe_mode=True)
|
||||
else:
|
||||
# Graph has no HITL blocks - keep None
|
||||
return GraphSettings(human_in_the_loop_safe_mode=None)
|
||||
|
||||
|
||||
async def create_library_agent(
|
||||
graph: graph_db.GraphModel,
|
||||
user_id: str,
|
||||
hitl_safe_mode: bool = True,
|
||||
sensitive_action_safe_mode: bool = False,
|
||||
create_library_agents_for_sub_graphs: bool = True,
|
||||
) -> list[library_model.LibraryAgent]:
|
||||
"""
|
||||
@@ -414,8 +430,6 @@ async def create_library_agent(
|
||||
Args:
|
||||
agent: The agent/Graph to add to the library.
|
||||
user_id: The user to whom the agent will be added.
|
||||
hitl_safe_mode: Whether HITL blocks require manual review (default True).
|
||||
sensitive_action_safe_mode: Whether sensitive action blocks require review.
|
||||
create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well.
|
||||
|
||||
Returns:
|
||||
@@ -451,11 +465,7 @@ async def create_library_agent(
|
||||
}
|
||||
},
|
||||
settings=SafeJson(
|
||||
GraphSettings.from_graph(
|
||||
graph_entry,
|
||||
hitl_safe_mode=hitl_safe_mode,
|
||||
sensitive_action_safe_mode=sensitive_action_safe_mode,
|
||||
).model_dump()
|
||||
_initialize_graph_settings(graph_entry).model_dump()
|
||||
),
|
||||
),
|
||||
include=library_agent_include(
|
||||
@@ -617,6 +627,33 @@ async def update_library_agent(
|
||||
raise DatabaseError("Failed to update library agent") from e
|
||||
|
||||
|
||||
async def update_library_agent_settings(
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
settings: GraphSettings,
|
||||
) -> library_model.LibraryAgent:
|
||||
"""
|
||||
Updates the settings for a specific LibraryAgent.
|
||||
|
||||
Args:
|
||||
user_id: The owner of the LibraryAgent.
|
||||
agent_id: The ID of the LibraryAgent to update.
|
||||
settings: New GraphSettings to apply.
|
||||
|
||||
Returns:
|
||||
The updated LibraryAgent.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the specified LibraryAgent does not exist.
|
||||
DatabaseError: If there's an error in the update operation.
|
||||
"""
|
||||
return await update_library_agent(
|
||||
library_agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
async def delete_library_agent(
|
||||
library_agent_id: str, user_id: str, soft_delete: bool = True
|
||||
) -> None:
|
||||
@@ -801,7 +838,7 @@ async def add_store_agent_to_library(
|
||||
"isCreatedByUser": False,
|
||||
"useGraphIsActiveVersion": False,
|
||||
"settings": SafeJson(
|
||||
GraphSettings.from_graph(graph_model).model_dump()
|
||||
_initialize_graph_settings(graph_model).model_dump()
|
||||
),
|
||||
},
|
||||
include=library_agent_include(
|
||||
@@ -1191,15 +1228,8 @@ async def fork_library_agent(
|
||||
)
|
||||
new_graph = await on_graph_activate(new_graph, user_id=user_id)
|
||||
|
||||
# Create a library agent for the new graph, preserving safe mode settings
|
||||
return (
|
||||
await create_library_agent(
|
||||
new_graph,
|
||||
user_id,
|
||||
hitl_safe_mode=original_agent.settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=original_agent.settings.sensitive_action_safe_mode,
|
||||
)
|
||||
)[0]
|
||||
# Create a library agent for the new graph
|
||||
return (await create_library_agent(new_graph, user_id))[0]
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error cloning library agent: {e}")
|
||||
raise DatabaseError("Failed to fork library agent") from e
|
||||
|
||||
@@ -73,12 +73,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
has_external_trigger: bool = pydantic.Field(
|
||||
description="Whether the agent has an external trigger (e.g. webhook) node"
|
||||
)
|
||||
has_human_in_the_loop: bool = pydantic.Field(
|
||||
description="Whether the agent has human-in-the-loop blocks"
|
||||
)
|
||||
has_sensitive_action: bool = pydantic.Field(
|
||||
description="Whether the agent has sensitive action blocks"
|
||||
)
|
||||
trigger_setup_info: Optional[GraphTriggerInfo] = None
|
||||
|
||||
# Indicates whether there's a new output (based on recent runs)
|
||||
@@ -186,8 +180,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
graph.credentials_input_schema if sub_graphs is not None else None
|
||||
),
|
||||
has_external_trigger=graph.has_external_trigger,
|
||||
has_human_in_the_loop=graph.has_human_in_the_loop,
|
||||
has_sensitive_action=graph.has_sensitive_action,
|
||||
trigger_setup_info=graph.trigger_setup_info,
|
||||
new_output=new_output,
|
||||
can_access_graph=can_access_graph,
|
||||
|
||||
@@ -52,8 +52,6 @@ async def test_get_library_agents_success(
|
||||
output_schema={"type": "object", "properties": {}},
|
||||
credentials_input_schema={"type": "object", "properties": {}},
|
||||
has_external_trigger=False,
|
||||
has_human_in_the_loop=False,
|
||||
has_sensitive_action=False,
|
||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||
recommended_schedule_cron=None,
|
||||
new_output=False,
|
||||
@@ -77,8 +75,6 @@ async def test_get_library_agents_success(
|
||||
output_schema={"type": "object", "properties": {}},
|
||||
credentials_input_schema={"type": "object", "properties": {}},
|
||||
has_external_trigger=False,
|
||||
has_human_in_the_loop=False,
|
||||
has_sensitive_action=False,
|
||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||
recommended_schedule_cron=None,
|
||||
new_output=False,
|
||||
@@ -154,8 +150,6 @@ async def test_get_favorite_library_agents_success(
|
||||
output_schema={"type": "object", "properties": {}},
|
||||
credentials_input_schema={"type": "object", "properties": {}},
|
||||
has_external_trigger=False,
|
||||
has_human_in_the_loop=False,
|
||||
has_sensitive_action=False,
|
||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||
recommended_schedule_cron=None,
|
||||
new_output=False,
|
||||
@@ -224,8 +218,6 @@ def test_add_agent_to_library_success(
|
||||
output_schema={"type": "object", "properties": {}},
|
||||
credentials_input_schema={"type": "object", "properties": {}},
|
||||
has_external_trigger=False,
|
||||
has_human_in_the_loop=False,
|
||||
has_sensitive_action=False,
|
||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||
new_output=False,
|
||||
can_access_graph=True,
|
||||
|
||||
@@ -154,16 +154,15 @@ async def store_content_embedding(
|
||||
|
||||
# Upsert the embedding
|
||||
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
||||
# Use {pgvector_schema}.vector for explicit pgvector type qualification
|
||||
await execute_raw_with_schema(
|
||||
"""
|
||||
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
||||
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::{pgvector_schema}.vector, $5, $6::jsonb, NOW(), NOW())
|
||||
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::{pgvector_schema}.vector,
|
||||
"embedding" = $4::vector,
|
||||
"searchableText" = $5,
|
||||
"metadata" = $6::jsonb,
|
||||
"updatedAt" = NOW()
|
||||
@@ -178,6 +177,7 @@ async def store_content_embedding(
|
||||
searchable_text,
|
||||
metadata_json,
|
||||
client=client,
|
||||
set_public_search_path=True,
|
||||
)
|
||||
|
||||
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
||||
@@ -236,6 +236,7 @@ async def get_content_embedding(
|
||||
content_type,
|
||||
content_id,
|
||||
user_id,
|
||||
set_public_search_path=True,
|
||||
)
|
||||
|
||||
if result and len(result) > 0:
|
||||
@@ -870,46 +871,31 @@ async def semantic_search(
|
||||
# Add content type parameters and build placeholders dynamically
|
||||
content_type_start_idx = len(params) + 1
|
||||
content_type_placeholders = ", ".join(
|
||||
"$" + str(content_type_start_idx + i) + '::{schema_prefix}"ContentType"'
|
||||
f'${content_type_start_idx + i}::{{{{schema_prefix}}}}"ContentType"'
|
||||
for i in range(len(content_types))
|
||||
)
|
||||
params.extend([ct.value for ct in content_types])
|
||||
|
||||
# Build min_similarity param index before appending
|
||||
min_similarity_idx = len(params) + 1
|
||||
params.append(min_similarity)
|
||||
|
||||
# Use regular string (not f-string) for template to preserve {schema_prefix} and {schema} placeholders
|
||||
# Use OPERATOR({pgvector_schema}.<=>) for explicit operator schema qualification
|
||||
sql = (
|
||||
"""
|
||||
sql = f"""
|
||||
SELECT
|
||||
"contentId" as content_id,
|
||||
"contentType" as content_type,
|
||||
"searchableText" as searchable_text,
|
||||
metadata,
|
||||
1 - (embedding OPERATOR({pgvector_schema}.<=>) '"""
|
||||
+ embedding_str
|
||||
+ """'::{pgvector_schema}.vector) as similarity
|
||||
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||
WHERE "contentType" IN ("""
|
||||
+ content_type_placeholders
|
||||
+ """)
|
||||
"""
|
||||
+ user_filter
|
||||
+ """
|
||||
AND 1 - (embedding OPERATOR({pgvector_schema}.<=>) '"""
|
||||
+ embedding_str
|
||||
+ """'::{pgvector_schema}.vector) >= $"""
|
||||
+ str(min_similarity_idx)
|
||||
+ """
|
||||
1 - (embedding <=> '{embedding_str}'::vector) as similarity
|
||||
FROM {{{{schema_prefix}}}}"UnifiedContentEmbedding"
|
||||
WHERE "contentType" IN ({content_type_placeholders})
|
||||
{user_filter}
|
||||
AND 1 - (embedding <=> '{embedding_str}'::vector) >= ${len(params) + 1}
|
||||
ORDER BY similarity DESC
|
||||
LIMIT $1
|
||||
"""
|
||||
)
|
||||
params.append(min_similarity)
|
||||
|
||||
try:
|
||||
results = await query_raw_with_schema(sql, *params)
|
||||
results = await query_raw_with_schema(
|
||||
sql, *params, set_public_search_path=True
|
||||
)
|
||||
return [
|
||||
{
|
||||
"content_id": row["content_id"],
|
||||
@@ -936,41 +922,31 @@ async def semantic_search(
|
||||
# Add content type parameters and build placeholders dynamically
|
||||
content_type_start_idx = len(params_lexical) + 1
|
||||
content_type_placeholders_lexical = ", ".join(
|
||||
"$" + str(content_type_start_idx + i) + '::{schema_prefix}"ContentType"'
|
||||
f'${content_type_start_idx + i}::{{{{schema_prefix}}}}"ContentType"'
|
||||
for i in range(len(content_types))
|
||||
)
|
||||
params_lexical.extend([ct.value for ct in content_types])
|
||||
|
||||
# Build query param index before appending
|
||||
query_param_idx = len(params_lexical) + 1
|
||||
params_lexical.append(f"%{query}%")
|
||||
|
||||
# Use regular string (not f-string) for template to preserve {schema_prefix} placeholders
|
||||
sql_lexical = (
|
||||
"""
|
||||
sql_lexical = f"""
|
||||
SELECT
|
||||
"contentId" as content_id,
|
||||
"contentType" as content_type,
|
||||
"searchableText" as searchable_text,
|
||||
metadata,
|
||||
0.0 as similarity
|
||||
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||
WHERE "contentType" IN ("""
|
||||
+ content_type_placeholders_lexical
|
||||
+ """)
|
||||
"""
|
||||
+ user_filter
|
||||
+ """
|
||||
AND "searchableText" ILIKE $"""
|
||||
+ str(query_param_idx)
|
||||
+ """
|
||||
FROM {{{{schema_prefix}}}}"UnifiedContentEmbedding"
|
||||
WHERE "contentType" IN ({content_type_placeholders_lexical})
|
||||
{user_filter}
|
||||
AND "searchableText" ILIKE ${len(params_lexical) + 1}
|
||||
ORDER BY "updatedAt" DESC
|
||||
LIMIT $1
|
||||
"""
|
||||
)
|
||||
params_lexical.append(f"%{query}%")
|
||||
|
||||
try:
|
||||
results = await query_raw_with_schema(sql_lexical, *params_lexical)
|
||||
results = await query_raw_with_schema(
|
||||
sql_lexical, *params_lexical, set_public_search_path=True
|
||||
)
|
||||
return [
|
||||
{
|
||||
"content_id": row["content_id"],
|
||||
|
||||
@@ -155,14 +155,18 @@ async def test_store_embedding_success(mocker):
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# execute_raw is called once for INSERT (no separate SET search_path needed)
|
||||
assert mock_client.execute_raw.call_count == 1
|
||||
# execute_raw is called twice: once for SET search_path, once for INSERT
|
||||
assert mock_client.execute_raw.call_count == 2
|
||||
|
||||
# Verify the INSERT query with the actual data
|
||||
call_args = mock_client.execute_raw.call_args_list[0][0]
|
||||
assert "test-version-id" in call_args
|
||||
assert "[0.1,0.2,0.3]" in call_args
|
||||
assert None in call_args # userId should be None for store agents
|
||||
# First call: SET search_path
|
||||
first_call_args = mock_client.execute_raw.call_args_list[0][0]
|
||||
assert "SET search_path" in first_call_args[0]
|
||||
|
||||
# Second call: INSERT query with the actual data
|
||||
second_call_args = mock_client.execute_raw.call_args_list[1][0]
|
||||
assert "test-version-id" in second_call_args
|
||||
assert "[0.1,0.2,0.3]" in second_call_args
|
||||
assert None in second_call_args # userId should be None for store agents
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from prisma.enums import ContentType
|
||||
from rank_bm25 import BM25Okapi # type: ignore[import-untyped]
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from backend.api.features.store.embeddings import (
|
||||
EMBEDDING_DIM,
|
||||
@@ -295,7 +295,7 @@ async def unified_hybrid_search(
|
||||
FROM {{schema_prefix}}"UnifiedContentEmbedding" uce
|
||||
WHERE uce."contentType" = ANY({content_types_param}::{{schema_prefix}}"ContentType"[])
|
||||
{user_filter}
|
||||
ORDER BY uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector
|
||||
ORDER BY uce.embedding <=> {embedding_param}::vector
|
||||
LIMIT 200
|
||||
)
|
||||
),
|
||||
@@ -307,7 +307,7 @@ async def unified_hybrid_search(
|
||||
uce.metadata,
|
||||
uce."updatedAt" as updated_at,
|
||||
-- Semantic score: cosine similarity (1 - distance)
|
||||
COALESCE(1 - (uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector), 0) as semantic_score,
|
||||
COALESCE(1 - (uce.embedding <=> {embedding_param}::vector), 0) as semantic_score,
|
||||
-- Lexical score: ts_rank_cd
|
||||
COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw,
|
||||
-- Category match from metadata
|
||||
@@ -363,7 +363,9 @@ async def unified_hybrid_search(
|
||||
LIMIT {limit_param} OFFSET {offset_param}
|
||||
"""
|
||||
|
||||
results = await query_raw_with_schema(sql_query, *params)
|
||||
results = await query_raw_with_schema(
|
||||
sql_query, *params, set_public_search_path=True
|
||||
)
|
||||
|
||||
total = results[0]["total_count"] if results else 0
|
||||
# Apply BM25 reranking
|
||||
@@ -583,7 +585,7 @@ async def hybrid_search(
|
||||
WHERE uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
|
||||
AND uce."userId" IS NULL
|
||||
AND {where_clause}
|
||||
ORDER BY uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector
|
||||
ORDER BY uce.embedding <=> {embedding_param}::vector
|
||||
LIMIT 200
|
||||
) uce
|
||||
),
|
||||
@@ -605,7 +607,7 @@ async def hybrid_search(
|
||||
-- Searchable text for BM25 reranking
|
||||
COALESCE(sa.agent_name, '') || ' ' || COALESCE(sa.sub_heading, '') || ' ' || COALESCE(sa.description, '') as searchable_text,
|
||||
-- Semantic score
|
||||
COALESCE(1 - (uce.embedding OPERATOR({{pgvector_schema}}.<=>) {embedding_param}::{{pgvector_schema}}.vector), 0) as semantic_score,
|
||||
COALESCE(1 - (uce.embedding <=> {embedding_param}::vector), 0) as semantic_score,
|
||||
-- Lexical score (raw, will normalize)
|
||||
COALESCE(ts_rank_cd(uce.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw,
|
||||
-- Category match
|
||||
@@ -686,7 +688,9 @@ async def hybrid_search(
|
||||
LIMIT {limit_param} OFFSET {offset_param}
|
||||
"""
|
||||
|
||||
results = await query_raw_with_schema(sql_query, *params)
|
||||
results = await query_raw_with_schema(
|
||||
sql_query, *params, set_public_search_path=True
|
||||
)
|
||||
|
||||
total = results[0]["total_count"] if results else 0
|
||||
|
||||
|
||||
@@ -761,8 +761,10 @@ async def create_new_graph(
|
||||
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
|
||||
graph.validate_graph(for_run=False)
|
||||
|
||||
# The return value of the create graph & library function is intentionally not used here,
|
||||
# as the graph already valid and no sub-graphs are returned back.
|
||||
await graph_db.create_graph(graph, user_id=user_id)
|
||||
await library_db.create_library_agent(graph, user_id)
|
||||
await library_db.create_library_agent(graph, user_id=user_id)
|
||||
activated_graph = await on_graph_activate(graph, user_id=user_id)
|
||||
|
||||
if create_graph.source == "builder":
|
||||
@@ -886,19 +888,21 @@ async def set_graph_active_version(
|
||||
async def _update_library_agent_version_and_settings(
|
||||
user_id: str, agent_graph: graph_db.GraphModel
|
||||
) -> library_model.LibraryAgent:
|
||||
# Keep the library agent up to date with the new active version
|
||||
library = await library_db.update_agent_version_in_library(
|
||||
user_id, agent_graph.id, agent_graph.version
|
||||
)
|
||||
updated_settings = GraphSettings.from_graph(
|
||||
graph=agent_graph,
|
||||
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
|
||||
)
|
||||
if updated_settings != library.settings:
|
||||
library = await library_db.update_library_agent(
|
||||
library_agent_id=library.id,
|
||||
# If the graph has HITL node, initialize the setting if it's not already set.
|
||||
if (
|
||||
agent_graph.has_human_in_the_loop
|
||||
and library.settings.human_in_the_loop_safe_mode is None
|
||||
):
|
||||
await library_db.update_library_agent_settings(
|
||||
user_id=user_id,
|
||||
settings=updated_settings,
|
||||
agent_id=library.id,
|
||||
settings=library.settings.model_copy(
|
||||
update={"human_in_the_loop_safe_mode": True}
|
||||
),
|
||||
)
|
||||
return library
|
||||
|
||||
@@ -915,18 +919,21 @@ async def update_graph_settings(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> GraphSettings:
|
||||
"""Update graph settings for the user's library agent."""
|
||||
# Get the library agent for this graph
|
||||
library_agent = await library_db.get_library_agent_by_graph_id(
|
||||
graph_id=graph_id, user_id=user_id
|
||||
)
|
||||
if not library_agent:
|
||||
raise HTTPException(404, f"Graph #{graph_id} not found in user's library")
|
||||
|
||||
updated_agent = await library_db.update_library_agent(
|
||||
library_agent_id=library_agent.id,
|
||||
# Update the library agent settings
|
||||
updated_agent = await library_db.update_library_agent_settings(
|
||||
user_id=user_id,
|
||||
agent_id=library_agent.id,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Return the updated settings
|
||||
return GraphSettings.model_validate(updated_agent.settings)
|
||||
|
||||
|
||||
|
||||
@@ -680,58 +680,3 @@ class ListIsEmptyBlock(Block):
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
yield "is_empty", len(input_data.list) == 0
|
||||
|
||||
|
||||
class ConcatenateListsBlock(Block):
|
||||
class Input(BlockSchemaInput):
|
||||
lists: List[List[Any]] = SchemaField(
|
||||
description="A list of lists to concatenate together. All lists will be combined in order into a single list.",
|
||||
placeholder="e.g., [[1, 2], [3, 4], [5, 6]]",
|
||||
)
|
||||
|
||||
class Output(BlockSchemaOutput):
|
||||
concatenated_list: List[Any] = SchemaField(
|
||||
description="The concatenated list containing all elements from all input lists in order."
|
||||
)
|
||||
error: str = SchemaField(
|
||||
description="Error message if concatenation failed due to invalid input types."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="3cf9298b-5817-4141-9d80-7c2cc5199c8e",
|
||||
description="Concatenates multiple lists into a single list. All elements from all input lists are combined in order.",
|
||||
categories={BlockCategory.BASIC},
|
||||
input_schema=ConcatenateListsBlock.Input,
|
||||
output_schema=ConcatenateListsBlock.Output,
|
||||
test_input=[
|
||||
{"lists": [[1, 2, 3], [4, 5, 6]]},
|
||||
{"lists": [["a", "b"], ["c"], ["d", "e", "f"]]},
|
||||
{"lists": [[1, 2], []]},
|
||||
{"lists": []},
|
||||
],
|
||||
test_output=[
|
||||
("concatenated_list", [1, 2, 3, 4, 5, 6]),
|
||||
("concatenated_list", ["a", "b", "c", "d", "e", "f"]),
|
||||
("concatenated_list", [1, 2]),
|
||||
("concatenated_list", []),
|
||||
],
|
||||
)
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
concatenated = []
|
||||
for idx, lst in enumerate(input_data.lists):
|
||||
if lst is None:
|
||||
# Skip None values to avoid errors
|
||||
continue
|
||||
if not isinstance(lst, list):
|
||||
# Type validation: each item must be a list
|
||||
# Strings are iterable and would cause extend() to iterate character-by-character
|
||||
# Non-iterable types would raise TypeError
|
||||
yield "error", (
|
||||
f"Invalid input at index {idx}: expected a list, got {type(lst).__name__}. "
|
||||
f"All items in 'lists' must be lists (e.g., [[1, 2], [3, 4]])."
|
||||
)
|
||||
return
|
||||
concatenated.extend(lst)
|
||||
yield "concatenated_list", concatenated
|
||||
|
||||
@@ -55,7 +55,6 @@ class HITLReviewHelper:
|
||||
async def _handle_review_request(
|
||||
input_data: Any,
|
||||
user_id: str,
|
||||
node_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
@@ -63,7 +62,6 @@ class HITLReviewHelper:
|
||||
execution_context: ExecutionContext,
|
||||
block_name: str = "Block",
|
||||
editable: bool = False,
|
||||
skip_safe_mode_check: bool = False,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""
|
||||
Handle a review request for a block that requires human review.
|
||||
@@ -71,7 +69,6 @@ class HITLReviewHelper:
|
||||
Args:
|
||||
input_data: The input data to be reviewed
|
||||
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
|
||||
graph_exec_id: ID of the graph execution
|
||||
graph_id: ID of the graph
|
||||
@@ -79,8 +76,6 @@ class HITLReviewHelper:
|
||||
execution_context: Current execution context
|
||||
block_name: Name of the block requesting review
|
||||
editable: Whether the reviewer can edit the data
|
||||
skip_safe_mode_check: If True, skip the safe mode check (caller already
|
||||
verified). Used by sensitive action blocks that check their own flag.
|
||||
|
||||
Returns:
|
||||
ReviewResult if review is complete, None if waiting for human input
|
||||
@@ -89,11 +84,7 @@ class HITLReviewHelper:
|
||||
Exception: If review creation or status update fails
|
||||
"""
|
||||
# Skip review if safe mode is disabled - return auto-approved result
|
||||
# (unless caller already checked and wants to skip this check)
|
||||
if (
|
||||
not skip_safe_mode_check
|
||||
and not execution_context.human_in_the_loop_safe_mode
|
||||
):
|
||||
if not execution_context.safe_mode:
|
||||
logger.info(
|
||||
f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled"
|
||||
)
|
||||
@@ -105,27 +96,12 @@ class HITLReviewHelper:
|
||||
node_exec_id=node_exec_id,
|
||||
)
|
||||
|
||||
# Skip review if this specific node has been auto-approved by the user
|
||||
if node_id in execution_context.auto_approved_node_ids:
|
||||
logger.info(
|
||||
f"Block {block_name} skipping review for node {node_exec_id} - "
|
||||
f"node {node_id} is auto-approved"
|
||||
)
|
||||
return ReviewResult(
|
||||
data=input_data,
|
||||
status=ReviewStatus.APPROVED,
|
||||
message="Auto-approved (user approved all future actions for this block)",
|
||||
processed=True,
|
||||
node_exec_id=node_exec_id,
|
||||
)
|
||||
|
||||
result = await HITLReviewHelper.get_or_create_human_review(
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
node_id=node_id,
|
||||
input_data=input_data,
|
||||
message=f"Review required for {block_name} execution",
|
||||
editable=editable,
|
||||
@@ -153,7 +129,6 @@ class HITLReviewHelper:
|
||||
async def handle_review_decision(
|
||||
input_data: Any,
|
||||
user_id: str,
|
||||
node_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
@@ -161,7 +136,6 @@ class HITLReviewHelper:
|
||||
execution_context: ExecutionContext,
|
||||
block_name: str = "Block",
|
||||
editable: bool = False,
|
||||
skip_safe_mode_check: bool = False,
|
||||
) -> Optional[ReviewDecision]:
|
||||
"""
|
||||
Handle a review request and return the decision in a single call.
|
||||
@@ -169,7 +143,6 @@ class HITLReviewHelper:
|
||||
Args:
|
||||
input_data: The input data to be reviewed
|
||||
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
|
||||
graph_exec_id: ID of the graph execution
|
||||
graph_id: ID of the graph
|
||||
@@ -177,8 +150,6 @@ class HITLReviewHelper:
|
||||
execution_context: Current execution context
|
||||
block_name: Name of the block requesting review
|
||||
editable: Whether the reviewer can edit the data
|
||||
skip_safe_mode_check: If True, skip the safe mode check (caller already
|
||||
verified). Used by sensitive action blocks that check their own flag.
|
||||
|
||||
Returns:
|
||||
ReviewDecision if review is complete (approved/rejected),
|
||||
@@ -187,7 +158,6 @@ class HITLReviewHelper:
|
||||
review_result = await HITLReviewHelper._handle_review_request(
|
||||
input_data=input_data,
|
||||
user_id=user_id,
|
||||
node_id=node_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
@@ -195,7 +165,6 @@ class HITLReviewHelper:
|
||||
execution_context=execution_context,
|
||||
block_name=block_name,
|
||||
editable=editable,
|
||||
skip_safe_mode_check=skip_safe_mode_check,
|
||||
)
|
||||
|
||||
if review_result is None:
|
||||
|
||||
@@ -97,7 +97,6 @@ class HumanInTheLoopBlock(Block):
|
||||
input_data: Input,
|
||||
*,
|
||||
user_id: str,
|
||||
node_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
@@ -105,17 +104,7 @@ class HumanInTheLoopBlock(Block):
|
||||
execution_context: ExecutionContext,
|
||||
**_kwargs,
|
||||
) -> BlockOutput:
|
||||
# Check if this specific node has been auto-approved by the user
|
||||
if node_id in execution_context.auto_approved_node_ids:
|
||||
logger.info(
|
||||
f"HITL block skipping review for node {node_exec_id} - "
|
||||
f"node {node_id} is auto-approved"
|
||||
)
|
||||
yield "approved_data", input_data.data
|
||||
yield "review_message", "Auto-approved (user approved all future actions for this block)"
|
||||
return
|
||||
|
||||
if not execution_context.human_in_the_loop_safe_mode:
|
||||
if not execution_context.safe_mode:
|
||||
logger.info(
|
||||
f"HITL block skipping review for node {node_exec_id} - safe mode disabled"
|
||||
)
|
||||
@@ -126,7 +115,6 @@ class HumanInTheLoopBlock(Block):
|
||||
decision = await self.handle_review_decision(
|
||||
input_data=input_data.data,
|
||||
user_id=user_id,
|
||||
node_id=node_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
|
||||
@@ -242,7 +242,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -343,7 +343,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -409,7 +409,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -471,7 +471,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -535,7 +535,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -658,7 +658,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -730,7 +730,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -786,7 +786,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
outputs = {}
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
@@ -905,7 +905,7 @@ async def test_smart_decision_maker_agent_mode():
|
||||
# Create a mock execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=False,
|
||||
safe_mode=False,
|
||||
)
|
||||
|
||||
# Create a mock execution processor for agent mode tests
|
||||
@@ -1027,7 +1027,7 @@ async def test_smart_decision_maker_traditional_mode_default():
|
||||
|
||||
# Create execution context
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a mock execution processor for tests
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ async def test_output_yielding_with_dynamic_fields():
|
||||
outputs = {}
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
mock_execution_processor = MagicMock()
|
||||
|
||||
async for output_name, output_value in block.run(
|
||||
@@ -609,9 +609,7 @@ async def test_validation_errors_dont_pollute_conversation():
|
||||
outputs = {}
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
mock_execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=False
|
||||
)
|
||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
||||
|
||||
# Create a proper mock execution processor for agent mode
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -474,7 +474,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
self.block_type = block_type
|
||||
self.webhook_config = webhook_config
|
||||
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
||||
self.is_sensitive_action: bool = False
|
||||
self.requires_human_review: bool = False
|
||||
|
||||
if self.webhook_config:
|
||||
if isinstance(self.webhook_config, BlockWebhookConfig):
|
||||
@@ -622,7 +622,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
input_data: BlockInput,
|
||||
*,
|
||||
user_id: str,
|
||||
node_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
@@ -638,9 +637,8 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
- should_pause: True if execution should be paused for review
|
||||
- input_data_to_use: The input data to use (may be modified by reviewer)
|
||||
"""
|
||||
if not (
|
||||
self.is_sensitive_action and execution_context.sensitive_action_safe_mode
|
||||
):
|
||||
# Skip review if not required or safe mode is disabled
|
||||
if not self.requires_human_review or not execution_context.safe_mode:
|
||||
return False, input_data
|
||||
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
@@ -649,7 +647,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
decision = await HITLReviewHelper.handle_review_decision(
|
||||
input_data=input_data,
|
||||
user_id=user_id,
|
||||
node_id=node_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
|
||||
@@ -38,6 +38,20 @@ POOL_TIMEOUT = os.getenv("DB_POOL_TIMEOUT")
|
||||
if POOL_TIMEOUT:
|
||||
DATABASE_URL = add_param(DATABASE_URL, "pool_timeout", POOL_TIMEOUT)
|
||||
|
||||
# Add public schema to search_path for pgvector type access
|
||||
# The vector extension is in public schema, but search_path is determined by schema parameter
|
||||
# Extract the schema from DATABASE_URL or default to 'public' (matching get_database_schema())
|
||||
parsed_url = urlparse(DATABASE_URL)
|
||||
url_params = dict(parse_qsl(parsed_url.query))
|
||||
db_schema = url_params.get("schema", "public")
|
||||
# Build search_path, avoiding duplicates if db_schema is already 'public'
|
||||
search_path_schemas = list(
|
||||
dict.fromkeys([db_schema, "public"])
|
||||
) # Preserves order, removes duplicates
|
||||
search_path = ",".join(search_path_schemas)
|
||||
# This allows using ::vector without schema qualification
|
||||
DATABASE_URL = add_param(DATABASE_URL, "options", f"-c search_path={search_path}")
|
||||
|
||||
HTTP_TIMEOUT = int(POOL_TIMEOUT) if POOL_TIMEOUT else None
|
||||
|
||||
prisma = Prisma(
|
||||
@@ -113,48 +127,38 @@ async def _raw_with_schema(
|
||||
*args,
|
||||
execute: bool = False,
|
||||
client: Prisma | None = None,
|
||||
set_public_search_path: bool = False,
|
||||
) -> list[dict] | int:
|
||||
"""Internal: Execute raw SQL with proper schema handling.
|
||||
|
||||
Use query_raw_with_schema() or execute_raw_with_schema() instead.
|
||||
|
||||
Supports placeholders:
|
||||
- {schema_prefix}: Table/type prefix (e.g., "platform".)
|
||||
- {schema}: Raw schema name for application tables (e.g., platform)
|
||||
- {pgvector_schema}: Schema where pgvector is installed (defaults to "public")
|
||||
|
||||
Args:
|
||||
query_template: SQL query with {schema_prefix}, {schema}, and/or {pgvector_schema} placeholders
|
||||
query_template: SQL query with {schema_prefix} placeholder
|
||||
*args: Query parameters
|
||||
execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE.
|
||||
client: Optional Prisma client for transactions (only used when execute=True).
|
||||
set_public_search_path: If True, sets search_path to include public schema.
|
||||
Needed for pgvector types and other public schema objects.
|
||||
|
||||
Returns:
|
||||
- list[dict] if execute=False (query results)
|
||||
- int if execute=True (number of affected rows)
|
||||
|
||||
Example with vector type:
|
||||
await execute_raw_with_schema(
|
||||
'INSERT INTO {schema_prefix}"Embedding" (vec) VALUES ($1::{pgvector_schema}.vector)',
|
||||
embedding_data
|
||||
)
|
||||
"""
|
||||
schema = get_database_schema()
|
||||
schema_prefix = f'"{schema}".' if schema != "public" else ""
|
||||
# pgvector extension is typically installed in "public" schema
|
||||
# On Supabase it may be in "extensions" but "public" is the common default
|
||||
pgvector_schema = "public"
|
||||
|
||||
formatted_query = query_template.format(
|
||||
schema_prefix=schema_prefix,
|
||||
schema=schema,
|
||||
pgvector_schema=pgvector_schema,
|
||||
)
|
||||
formatted_query = query_template.format(schema_prefix=schema_prefix)
|
||||
|
||||
import prisma as prisma_module
|
||||
|
||||
db_client = client if client else prisma_module.get_client()
|
||||
|
||||
# Set search_path to include public schema if requested
|
||||
# Prisma doesn't support the 'options' connection parameter, so we set it per-session
|
||||
# This is idempotent and safe to call multiple times
|
||||
if set_public_search_path:
|
||||
await db_client.execute_raw(f"SET search_path = {schema}, public") # type: ignore
|
||||
|
||||
if execute:
|
||||
result = await db_client.execute_raw(formatted_query, *args) # type: ignore
|
||||
else:
|
||||
@@ -163,12 +167,16 @@ async def _raw_with_schema(
|
||||
return result
|
||||
|
||||
|
||||
async def query_raw_with_schema(query_template: str, *args) -> list[dict]:
|
||||
async def query_raw_with_schema(
|
||||
query_template: str, *args, set_public_search_path: bool = False
|
||||
) -> list[dict]:
|
||||
"""Execute raw SQL SELECT query with proper schema handling.
|
||||
|
||||
Args:
|
||||
query_template: SQL query with {schema_prefix} and/or {schema} placeholders
|
||||
query_template: SQL query with {schema_prefix} placeholder
|
||||
*args: Query parameters
|
||||
set_public_search_path: If True, sets search_path to include public schema.
|
||||
Needed for pgvector types and other public schema objects.
|
||||
|
||||
Returns:
|
||||
List of result rows as dictionaries
|
||||
@@ -179,20 +187,23 @@ async def query_raw_with_schema(query_template: str, *args) -> list[dict]:
|
||||
user_id
|
||||
)
|
||||
"""
|
||||
return await _raw_with_schema(query_template, *args, execute=False) # type: ignore
|
||||
return await _raw_with_schema(query_template, *args, execute=False, set_public_search_path=set_public_search_path) # type: ignore
|
||||
|
||||
|
||||
async def execute_raw_with_schema(
|
||||
query_template: str,
|
||||
*args,
|
||||
client: Prisma | None = None,
|
||||
set_public_search_path: bool = False,
|
||||
) -> int:
|
||||
"""Execute raw SQL command (INSERT/UPDATE/DELETE) with proper schema handling.
|
||||
|
||||
Args:
|
||||
query_template: SQL query with {schema_prefix} and/or {schema} placeholders
|
||||
query_template: SQL query with {schema_prefix} placeholder
|
||||
*args: Query parameters
|
||||
client: Optional Prisma client for transactions
|
||||
set_public_search_path: If True, sets search_path to include public schema.
|
||||
Needed for pgvector types and other public schema objects.
|
||||
|
||||
Returns:
|
||||
Number of affected rows
|
||||
@@ -204,7 +215,7 @@ async def execute_raw_with_schema(
|
||||
client=tx # Optional transaction client
|
||||
)
|
||||
"""
|
||||
return await _raw_with_schema(query_template, *args, execute=True, client=client) # type: ignore
|
||||
return await _raw_with_schema(query_template, *args, execute=True, client=client, set_public_search_path=set_public_search_path) # type: ignore
|
||||
|
||||
|
||||
class BaseDbModel(BaseModel):
|
||||
|
||||
@@ -81,12 +81,10 @@ class ExecutionContext(BaseModel):
|
||||
This includes information needed by blocks, sub-graphs, and execution management.
|
||||
"""
|
||||
|
||||
human_in_the_loop_safe_mode: bool = True
|
||||
sensitive_action_safe_mode: bool = False
|
||||
safe_mode: bool = True
|
||||
user_timezone: str = "UTC"
|
||||
root_execution_id: Optional[str] = None
|
||||
parent_execution_id: Optional[str] = None
|
||||
auto_approved_node_ids: set[str] = Field(default_factory=set)
|
||||
|
||||
|
||||
# -------------------------- Models -------------------------- #
|
||||
|
||||
@@ -62,23 +62,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphSettings(BaseModel):
|
||||
human_in_the_loop_safe_mode: bool = True
|
||||
sensitive_action_safe_mode: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_graph(
|
||||
cls,
|
||||
graph: "GraphModel",
|
||||
hitl_safe_mode: bool | None = None,
|
||||
sensitive_action_safe_mode: bool = False,
|
||||
) -> "GraphSettings":
|
||||
# Default to True if not explicitly set
|
||||
if hitl_safe_mode is None:
|
||||
hitl_safe_mode = True
|
||||
return cls(
|
||||
human_in_the_loop_safe_mode=hitl_safe_mode,
|
||||
sensitive_action_safe_mode=sensitive_action_safe_mode,
|
||||
)
|
||||
human_in_the_loop_safe_mode: bool | None = None
|
||||
|
||||
|
||||
class Link(BaseDbModel):
|
||||
@@ -260,14 +244,10 @@ class BaseGraph(BaseDbModel):
|
||||
return any(
|
||||
node.block_id
|
||||
for node in self.nodes
|
||||
if node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def has_sensitive_action(self) -> bool:
|
||||
return any(
|
||||
node.block_id for node in self.nodes if node.block.is_sensitive_action
|
||||
if (
|
||||
node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
or node.block.requires_human_review
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -38,7 +38,6 @@ async def get_or_create_human_review(
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
node_id: str,
|
||||
input_data: SafeJsonData,
|
||||
message: str,
|
||||
editable: bool,
|
||||
@@ -54,7 +53,6 @@ async def get_or_create_human_review(
|
||||
graph_exec_id: ID of the graph execution
|
||||
graph_id: ID of the graph template
|
||||
graph_version: Version of the graph template
|
||||
node_id: ID of the node in the graph definition
|
||||
input_data: The data to be reviewed
|
||||
message: Instructions for the reviewer
|
||||
editable: Whether the data can be edited
|
||||
@@ -75,7 +73,6 @@ async def get_or_create_human_review(
|
||||
"graphExecId": graph_exec_id,
|
||||
"graphId": graph_id,
|
||||
"graphVersion": graph_version,
|
||||
"nodeId": node_id,
|
||||
"payload": SafeJson(input_data),
|
||||
"instructions": message,
|
||||
"editable": editable,
|
||||
|
||||
@@ -23,7 +23,6 @@ def sample_db_review():
|
||||
mock_review.graphExecId = "test_graph_exec_456"
|
||||
mock_review.graphId = "test_graph_789"
|
||||
mock_review.graphVersion = 1
|
||||
mock_review.nodeId = "node_def_123"
|
||||
mock_review.payload = {"data": "test payload"}
|
||||
mock_review.instructions = "Please review"
|
||||
mock_review.editable = True
|
||||
@@ -56,7 +55,6 @@ async def test_get_or_create_human_review_new(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
input_data={"data": "test payload"},
|
||||
message="Please review",
|
||||
editable=True,
|
||||
@@ -86,7 +84,6 @@ async def test_get_or_create_human_review_approved(
|
||||
graph_exec_id="test_graph_exec_456",
|
||||
graph_id="test_graph_789",
|
||||
graph_version=1,
|
||||
node_id="node_def_123",
|
||||
input_data={"data": "test payload"},
|
||||
message="Please review",
|
||||
editable=True,
|
||||
@@ -186,7 +183,6 @@ async def test_process_all_reviews_for_execution_success(
|
||||
updated_review.graphExecId = "test_graph_exec_456"
|
||||
updated_review.graphId = "test_graph_789"
|
||||
updated_review.graphVersion = 1
|
||||
updated_review.nodeId = "node_def_123"
|
||||
updated_review.payload = {"data": "modified"}
|
||||
updated_review.instructions = "Please review"
|
||||
updated_review.editable = True
|
||||
@@ -276,7 +272,6 @@ async def test_process_all_reviews_mixed_approval_rejection(
|
||||
second_review.graphExecId = "test_graph_exec_456"
|
||||
second_review.graphId = "test_graph_789"
|
||||
second_review.graphVersion = 1
|
||||
second_review.nodeId = "node_def_456"
|
||||
second_review.payload = {"data": "original"}
|
||||
second_review.instructions = "Second review"
|
||||
second_review.editable = True
|
||||
@@ -301,7 +296,6 @@ async def test_process_all_reviews_mixed_approval_rejection(
|
||||
approved_review.graphExecId = "test_graph_exec_456"
|
||||
approved_review.graphId = "test_graph_789"
|
||||
approved_review.graphVersion = 1
|
||||
approved_review.nodeId = "node_def_123"
|
||||
approved_review.payload = {"data": "modified"}
|
||||
approved_review.instructions = "Please review"
|
||||
approved_review.editable = True
|
||||
@@ -319,7 +313,6 @@ async def test_process_all_reviews_mixed_approval_rejection(
|
||||
rejected_review.graphExecId = "test_graph_exec_456"
|
||||
rejected_review.graphId = "test_graph_789"
|
||||
rejected_review.graphVersion = 1
|
||||
rejected_review.nodeId = "node_def_456"
|
||||
rejected_review.payload = {"data": "original"}
|
||||
rejected_review.instructions = "Please review"
|
||||
rejected_review.editable = True
|
||||
|
||||
@@ -328,8 +328,6 @@ async def clear_business_understanding(user_id: str) -> bool:
|
||||
|
||||
def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str:
|
||||
"""Format business understanding as text for system prompt injection."""
|
||||
if not understanding:
|
||||
return ""
|
||||
sections = []
|
||||
|
||||
# User info section
|
||||
|
||||
@@ -309,7 +309,7 @@ def ensure_embeddings_coverage():
|
||||
|
||||
# Process in batches until no more missing embeddings
|
||||
while True:
|
||||
result = db_client.backfill_missing_embeddings(batch_size=100)
|
||||
result = db_client.backfill_missing_embeddings(batch_size=10)
|
||||
|
||||
total_processed += result["processed"]
|
||||
total_success += result["success"]
|
||||
|
||||
@@ -873,8 +873,11 @@ async def add_graph_execution(
|
||||
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
|
||||
|
||||
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,
|
||||
safe_mode=(
|
||||
settings.human_in_the_loop_safe_mode
|
||||
if settings.human_in_the_loop_safe_mode is not None
|
||||
else True
|
||||
),
|
||||
user_timezone=(
|
||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||
),
|
||||
|
||||
@@ -386,7 +386,6 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
||||
mock_user.timezone = "UTC"
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.human_in_the_loop_safe_mode = True
|
||||
mock_settings.sensitive_action_safe_mode = False
|
||||
|
||||
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||
@@ -652,7 +651,6 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
||||
mock_user.timezone = "UTC"
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.human_in_the_loop_safe_mode = True
|
||||
mock_settings.sensitive_action_safe_mode = False
|
||||
|
||||
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PendingHumanReview" ADD COLUMN "nodeId" TEXT NOT NULL DEFAULT '';
|
||||
@@ -573,7 +573,6 @@ model PendingHumanReview {
|
||||
graphExecId String
|
||||
graphId String
|
||||
graphVersion Int
|
||||
nodeId String // The node ID in the graph definition (for auto-approval tracking)
|
||||
payload Json // The actual payload data to be reviewed
|
||||
instructions String? // Instructions/message for the reviewer
|
||||
editable Boolean @default(true) // Whether the reviewer can edit the data
|
||||
|
||||
@@ -366,12 +366,12 @@ def generate_block_markdown(
|
||||
lines.append("")
|
||||
|
||||
# What it is (full description)
|
||||
lines.append("### What it is")
|
||||
lines.append(f"### What it is")
|
||||
lines.append(block.description or "No description available.")
|
||||
lines.append("")
|
||||
|
||||
# How it works (manual section)
|
||||
lines.append("### How it works")
|
||||
lines.append(f"### How it works")
|
||||
how_it_works = manual_content.get(
|
||||
"how_it_works", "_Add technical explanation here._"
|
||||
)
|
||||
@@ -383,7 +383,7 @@ def generate_block_markdown(
|
||||
# Inputs table (auto-generated)
|
||||
visible_inputs = [f for f in block.inputs if not f.hidden]
|
||||
if visible_inputs:
|
||||
lines.append("### Inputs")
|
||||
lines.append(f"### Inputs")
|
||||
lines.append("")
|
||||
lines.append("| Input | Description | Type | Required |")
|
||||
lines.append("|-------|-------------|------|----------|")
|
||||
@@ -400,7 +400,7 @@ def generate_block_markdown(
|
||||
# Outputs table (auto-generated)
|
||||
visible_outputs = [f for f in block.outputs if not f.hidden]
|
||||
if visible_outputs:
|
||||
lines.append("### Outputs")
|
||||
lines.append(f"### Outputs")
|
||||
lines.append("")
|
||||
lines.append("| Output | Description | Type |")
|
||||
lines.append("|--------|-------------|------|")
|
||||
@@ -414,7 +414,7 @@ def generate_block_markdown(
|
||||
lines.append("")
|
||||
|
||||
# Possible use case (manual section)
|
||||
lines.append("### Possible use case")
|
||||
lines.append(f"### Possible use case")
|
||||
use_case = manual_content.get("use_case", "_Add practical use case examples here._")
|
||||
lines.append("<!-- MANUAL: use_case -->")
|
||||
lines.append(use_case)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"forked_from_version": null,
|
||||
"has_external_trigger": false,
|
||||
"has_human_in_the_loop": false,
|
||||
"has_sensitive_action": false,
|
||||
"id": "graph-123",
|
||||
"input_schema": {
|
||||
"properties": {},
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"forked_from_version": null,
|
||||
"has_external_trigger": false,
|
||||
"has_human_in_the_loop": false,
|
||||
"has_sensitive_action": false,
|
||||
"id": "graph-123",
|
||||
"input_schema": {
|
||||
"properties": {},
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
"properties": {}
|
||||
},
|
||||
"has_external_trigger": false,
|
||||
"has_human_in_the_loop": false,
|
||||
"has_sensitive_action": false,
|
||||
"trigger_setup_info": null,
|
||||
"new_output": false,
|
||||
"can_access_graph": true,
|
||||
@@ -36,8 +34,7 @@
|
||||
"is_favorite": false,
|
||||
"recommended_schedule_cron": null,
|
||||
"settings": {
|
||||
"human_in_the_loop_safe_mode": true,
|
||||
"sensitive_action_safe_mode": false
|
||||
"human_in_the_loop_safe_mode": null
|
||||
},
|
||||
"marketplace_listing": null
|
||||
},
|
||||
@@ -68,8 +65,6 @@
|
||||
"properties": {}
|
||||
},
|
||||
"has_external_trigger": false,
|
||||
"has_human_in_the_loop": false,
|
||||
"has_sensitive_action": false,
|
||||
"trigger_setup_info": null,
|
||||
"new_output": false,
|
||||
"can_access_graph": false,
|
||||
@@ -77,8 +72,7 @@
|
||||
"is_favorite": false,
|
||||
"recommended_schedule_cron": null,
|
||||
"settings": {
|
||||
"human_in_the_loop_safe_mode": true,
|
||||
"sensitive_action_safe_mode": false
|
||||
"human_in_the_loop_safe_mode": null
|
||||
},
|
||||
"marketplace_listing": null
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
const {
|
||||
@@ -25,31 +24,6 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
|
||||
|
||||
// Determine which icon to show with proper animation
|
||||
const renderIcon = () => {
|
||||
const iconClass = cn(
|
||||
"size-4 transition-transform duration-200 ease-out",
|
||||
!isLoading && "group-hover:scale-110",
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className={cn(iconClass, "animate-spin")}
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGraphRunning) {
|
||||
return <StopIcon className={iconClass} weight="fill" />;
|
||||
}
|
||||
|
||||
return <PlayIcon className={iconClass} weight="fill" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
@@ -59,18 +33,18 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
variant={isGraphRunning ? "destructive" : "primary"}
|
||||
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
disabled={!flowID || isLoading}
|
||||
className="group"
|
||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
>
|
||||
{renderIcon()}
|
||||
{!isGraphRunning ? (
|
||||
<PlayIcon className="size-4" />
|
||||
) : (
|
||||
<StopIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLoading
|
||||
? "Processing..."
|
||||
: isGraphRunning
|
||||
? "Stop agent"
|
||||
: "Run agent"}
|
||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<RunInputDialog
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useRunInputDialog } from "./useRunInputDialog";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
|
||||
import { useEffect } from "react";
|
||||
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||
|
||||
export const RunInputDialog = ({
|
||||
isOpen,
|
||||
@@ -24,17 +23,19 @@ export const RunInputDialog = ({
|
||||
const hasInputs = useGraphStore((state) => state.hasInputs);
|
||||
const hasCredentials = useGraphStore((state) => state.hasCredentials);
|
||||
const inputSchema = useGraphStore((state) => state.inputSchema);
|
||||
const credentialsSchema = useGraphStore(
|
||||
(state) => state.credentialsInputSchema,
|
||||
);
|
||||
|
||||
const {
|
||||
credentialFields,
|
||||
requiredCredentials,
|
||||
credentialsUiSchema,
|
||||
handleManualRun,
|
||||
handleInputChange,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
inputValues,
|
||||
credentialValues,
|
||||
handleCredentialFieldChange,
|
||||
handleCredentialChange,
|
||||
isExecutingGraph,
|
||||
} = useRunInputDialog({ setIsOpen });
|
||||
|
||||
@@ -61,67 +62,67 @@ export const RunInputDialog = ({
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
styling={{ maxWidth: "700px", minWidth: "700px" }}
|
||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
className="grid grid-cols-[1fr_auto] gap-10 p-1"
|
||||
data-id="run-input-dialog-content"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Credentials Section */}
|
||||
{hasCredentials() && credentialFields.length > 0 && (
|
||||
<div data-id="run-input-credentials-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Credentials
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2" data-id="run-input-credentials-form">
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={credentialValues}
|
||||
inputValues={inputValues}
|
||||
onCredentialChange={handleCredentialFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
|
||||
{/* Credentials Section */}
|
||||
{hasCredentials() && (
|
||||
<div data-id="run-input-credentials-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Credentials
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inputs Section */}
|
||||
{hasInputs() && (
|
||||
<div data-id="run-input-inputs-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Inputs
|
||||
</Text>
|
||||
</div>
|
||||
<div data-id="run-input-inputs-form">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2" data-id="run-input-credentials-form">
|
||||
<FormRenderer
|
||||
jsonSchema={credentialsSchema as RJSFSchema}
|
||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
||||
uiSchema={credentialsUiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
showOptionalToggle: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inputs Section */}
|
||||
{hasInputs() && (
|
||||
<div data-id="run-input-inputs-section">
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Inputs
|
||||
</Text>
|
||||
</div>
|
||||
<div data-id="run-input-inputs-form">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<div
|
||||
className="flex flex-col items-end justify-start"
|
||||
className="flex justify-end pt-2"
|
||||
data-id="run-input-actions-section"
|
||||
>
|
||||
{purpose === "run" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2 px-10"
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={handleManualRun}
|
||||
loading={isExecutingGraph}
|
||||
data-id="run-input-manual-run-button"
|
||||
@@ -136,7 +137,7 @@ export const RunInputDialog = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2 px-10"
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={() => setOpenCronSchedulerDialog(true)}
|
||||
data-id="run-input-schedule-button"
|
||||
>
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
GraphExecutionMeta,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
|
||||
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
|
||||
|
||||
export const useRunInputDialog = ({
|
||||
setIsOpen,
|
||||
@@ -119,32 +120,27 @@ export const useRunInputDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Convert credentials schema to credential fields array for CredentialsGroupedView
|
||||
const credentialFields: CredentialField[] = useMemo(() => {
|
||||
if (!credentialsSchema?.properties) return [];
|
||||
return Object.entries(credentialsSchema.properties);
|
||||
}, [credentialsSchema]);
|
||||
// We are rendering the credentials field differently compared to other fields.
|
||||
// In the node, we have the field name as "credential" - so our library catches it and renders it differently.
|
||||
// But here we have a different name, something like `Firecrawl credentials`, so here we are telling the library that this field is a credential field type.
|
||||
|
||||
// Get required credentials as a Set
|
||||
const requiredCredentials = useMemo(() => {
|
||||
return new Set<string>(credentialsSchema?.required || []);
|
||||
}, [credentialsSchema]);
|
||||
const credentialsUiSchema = useMemo(() => {
|
||||
const dynamicUiSchema: any = { ...uiSchema };
|
||||
|
||||
// Handler for individual credential changes
|
||||
const handleCredentialFieldChange = useCallback(
|
||||
(key: string, value?: CredentialsMetaInput) => {
|
||||
setCredentialValues((prev) => {
|
||||
if (value) {
|
||||
return { ...prev, [key]: value };
|
||||
} else {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
if (credentialsSchema?.properties) {
|
||||
Object.keys(credentialsSchema.properties).forEach((fieldName) => {
|
||||
const fieldSchema = credentialsSchema.properties[fieldName];
|
||||
if (isCredentialFieldSchema(fieldSchema)) {
|
||||
dynamicUiSchema[fieldName] = {
|
||||
...dynamicUiSchema[fieldName],
|
||||
"ui:field": "custom/credential_field",
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicUiSchema;
|
||||
}, [credentialsSchema]);
|
||||
|
||||
const handleManualRun = async () => {
|
||||
// Filter out incomplete credentials (those without a valid id)
|
||||
@@ -177,14 +173,12 @@ export const useRunInputDialog = ({
|
||||
};
|
||||
|
||||
return {
|
||||
credentialFields,
|
||||
requiredCredentials,
|
||||
credentialsUiSchema,
|
||||
inputValues,
|
||||
credentialValues,
|
||||
isExecutingGraph,
|
||||
handleInputChange,
|
||||
handleCredentialChange,
|
||||
handleCredentialFieldChange,
|
||||
handleManualRun,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
|
||||
@@ -18,110 +18,69 @@ interface Props {
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
interface SafeModeButtonProps {
|
||||
isEnabled: boolean;
|
||||
label: string;
|
||||
tooltipEnabled: string;
|
||||
tooltipDisabled: string;
|
||||
onToggle: () => void;
|
||||
isPending: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
function SafeModeButton({
|
||||
isEnabled,
|
||||
label,
|
||||
tooltipEnabled,
|
||||
tooltipDisabled,
|
||||
onToggle,
|
||||
isPending,
|
||||
fullWidth = false,
|
||||
}: SafeModeButtonProps) {
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isEnabled ? "primary" : "outline"}
|
||||
size="small"
|
||||
onClick={onToggle}
|
||||
disabled={isPending}
|
||||
className={cn("justify-start", fullWidth ? "w-full" : "")}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<ShieldCheckIcon weight="bold" size={16} />
|
||||
<Text variant="body" className="text-zinc-200">
|
||||
{label}: ON
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldIcon weight="bold" size={16} />
|
||||
<Text variant="body" className="text-zinc-600">
|
||||
{label}: OFF
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">
|
||||
{label}: {isEnabled ? "ON" : "OFF"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{isEnabled ? tooltipEnabled : tooltipDisabled}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingSafeModeToggle({
|
||||
graph,
|
||||
className,
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const {
|
||||
currentHITLSafeMode,
|
||||
showHITLToggle,
|
||||
handleHITLToggle,
|
||||
currentSensitiveActionSafeMode,
|
||||
showSensitiveActionToggle,
|
||||
handleSensitiveActionToggle,
|
||||
currentSafeMode,
|
||||
isPending,
|
||||
shouldShowToggle,
|
||||
isStateUndetermined,
|
||||
handleToggle,
|
||||
} = useAgentSafeMode(graph);
|
||||
|
||||
if (!shouldShowToggle || isPending) {
|
||||
if (!shouldShowToggle || isStateUndetermined || isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("fixed z-50 flex flex-col gap-2", className)}>
|
||||
{showHITLToggle && (
|
||||
<SafeModeButton
|
||||
isEnabled={currentHITLSafeMode}
|
||||
label="Human in the loop block approval"
|
||||
tooltipEnabled="The agent will pause at human-in-the-loop blocks and wait for your approval"
|
||||
tooltipDisabled="Human in the loop blocks will proceed automatically"
|
||||
onToggle={handleHITLToggle}
|
||||
isPending={isPending}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
)}
|
||||
{showSensitiveActionToggle && (
|
||||
<SafeModeButton
|
||||
isEnabled={currentSensitiveActionSafeMode}
|
||||
label="Sensitive actions blocks approval"
|
||||
tooltipEnabled="The agent will pause at sensitive action blocks and wait for your approval"
|
||||
tooltipDisabled="Sensitive action blocks will proceed automatically"
|
||||
onToggle={handleSensitiveActionToggle}
|
||||
isPending={isPending}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("fixed z-50", className)}>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSafeMode! ? "primary" : "outline"}
|
||||
key={graph.id}
|
||||
size="small"
|
||||
title={
|
||||
currentSafeMode!
|
||||
? "Safe Mode: ON. Human in the loop blocks require manual review"
|
||||
: "Safe Mode: OFF. Human in the loop blocks proceed automatically"
|
||||
}
|
||||
onClick={handleToggle}
|
||||
className={cn(fullWidth ? "w-full" : "")}
|
||||
>
|
||||
{currentSafeMode! ? (
|
||||
<>
|
||||
<ShieldCheckIcon weight="bold" size={16} />
|
||||
<Text variant="body" className="text-zinc-200">
|
||||
Safe Mode: ON
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldIcon weight="bold" size={16} />
|
||||
<Text variant="body" className="text-zinc-600">
|
||||
Safe Mode: OFF
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">
|
||||
Safe Mode: {currentSafeMode! ? "ON" : "OFF"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{currentSafeMode!
|
||||
? "Human in the loop blocks require manual review"
|
||||
: "Human in the loop blocks proceed automatically"}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,14 +53,14 @@ export const CustomControls = memo(
|
||||
const controls = [
|
||||
{
|
||||
id: "zoom-in-button",
|
||||
icon: <PlusIcon className="size-3.5 text-zinc-600" />,
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
label: "Zoom In",
|
||||
onClick: () => zoomIn(),
|
||||
className: "h-10 w-10 border-none",
|
||||
},
|
||||
{
|
||||
id: "zoom-out-button",
|
||||
icon: <MinusIcon className="size-3.5 text-zinc-600" />,
|
||||
icon: <MinusIcon className="size-4" />,
|
||||
label: "Zoom Out",
|
||||
onClick: () => zoomOut(),
|
||||
className: "h-10 w-10 border-none",
|
||||
@@ -68,9 +68,9 @@ export const CustomControls = memo(
|
||||
{
|
||||
id: "tutorial-button",
|
||||
icon: isTutorialLoading ? (
|
||||
<CircleNotchIcon className="size-3.5 animate-spin text-zinc-600" />
|
||||
<CircleNotchIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChalkboardIcon className="size-3.5 text-zinc-600" />
|
||||
<ChalkboardIcon className="size-4" />
|
||||
),
|
||||
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
|
||||
onClick: handleTutorialClick,
|
||||
@@ -79,7 +79,7 @@ export const CustomControls = memo(
|
||||
},
|
||||
{
|
||||
id: "fit-view-button",
|
||||
icon: <FrameCornersIcon className="size-3.5 text-zinc-600" />,
|
||||
icon: <FrameCornersIcon className="size-4" />,
|
||||
label: "Fit View",
|
||||
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
||||
className: "h-10 w-10 border-none",
|
||||
@@ -87,9 +87,9 @@ export const CustomControls = memo(
|
||||
{
|
||||
id: "lock-button",
|
||||
icon: !isLocked ? (
|
||||
<LockOpenIcon className="size-3.5 text-zinc-600" />
|
||||
<LockOpenIcon className="size-4" />
|
||||
) : (
|
||||
<LockIcon className="size-3.5 text-zinc-600" />
|
||||
<LockIcon className="size-4" />
|
||||
),
|
||||
label: "Toggle Lock",
|
||||
onClick: () => setIsLocked(!isLocked),
|
||||
|
||||
@@ -139,6 +139,14 @@ export const useFlow = () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
useNodeStore.getState().clearResolutionState();
|
||||
addNodes(customNodes);
|
||||
|
||||
// Sync hardcoded values with handle IDs.
|
||||
// If a key–value field has a key without a value, the backend omits it from hardcoded values.
|
||||
// But if a handleId exists for that key, it causes inconsistency.
|
||||
// This ensures hardcoded values stay in sync with handle IDs.
|
||||
customNodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
}
|
||||
}, [customNodes, addNodes]);
|
||||
|
||||
@@ -150,14 +158,6 @@ export const useFlow = () => {
|
||||
}
|
||||
}, [graph?.links, addLinks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customNodes.length > 0 && graph?.links) {
|
||||
customNodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
}
|
||||
}, [customNodes, graph?.links]);
|
||||
|
||||
// update node execution status in nodes
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -19,8 +19,6 @@ export type CustomEdgeData = {
|
||||
beadUp?: number;
|
||||
beadDown?: number;
|
||||
beadData?: Map<string, NodeExecutionResult["status"]>;
|
||||
edgeColorClass?: string;
|
||||
edgeHexColor?: string;
|
||||
};
|
||||
|
||||
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
|
||||
@@ -38,6 +36,7 @@ const CustomEdge = ({
|
||||
selected,
|
||||
}: EdgeProps<CustomEdge>) => {
|
||||
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
||||
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
|
||||
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -53,7 +52,6 @@ const CustomEdge = ({
|
||||
const isStatic = data?.isStatic ?? false;
|
||||
const beadUp = data?.beadUp ?? 0;
|
||||
const beadDown = data?.beadDown ?? 0;
|
||||
const edgeColorClass = data?.edgeColorClass;
|
||||
|
||||
const handleRemoveEdge = () => {
|
||||
removeConnection(id);
|
||||
@@ -72,9 +70,7 @@ const CustomEdge = ({
|
||||
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
||||
: selected
|
||||
? "stroke-zinc-800"
|
||||
: edgeColorClass
|
||||
? cn(edgeColorClass, "opacity-70 hover:opacity-100")
|
||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||
)}
|
||||
/>
|
||||
<JSBeads
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useCallback } from "react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useHistoryStore } from "../../../stores/historyStore";
|
||||
import { CustomEdge } from "./CustomEdge";
|
||||
import { getEdgeColorFromOutputType } from "../nodes/helpers";
|
||||
|
||||
export const useCustomEdge = () => {
|
||||
const edges = useEdgeStore((s) => s.edges);
|
||||
@@ -35,13 +34,8 @@ export const useCustomEdge = () => {
|
||||
if (exists) return;
|
||||
|
||||
const nodes = useNodeStore.getState().nodes;
|
||||
const sourceNode = nodes.find((n) => n.id === conn.source);
|
||||
const isStatic = sourceNode?.data?.staticOutput;
|
||||
|
||||
const { colorClass, hexColor } = getEdgeColorFromOutputType(
|
||||
sourceNode?.data?.outputSchema,
|
||||
conn.sourceHandle,
|
||||
);
|
||||
const isStatic = nodes.find((n) => n.id === conn.source)?.data
|
||||
?.staticOutput;
|
||||
|
||||
addEdge({
|
||||
source: conn.source,
|
||||
@@ -50,8 +44,6 @@ export const useCustomEdge = () => {
|
||||
targetHandle: conn.targetHandle,
|
||||
data: {
|
||||
isStatic,
|
||||
edgeColorClass: colorClass,
|
||||
edgeHexColor: hexColor,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/molecules/Accordion/Accordion";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
|
||||
import { ContentRenderer } from "./components/ContentRenderer";
|
||||
import { useNodeOutput } from "./useNodeOutput";
|
||||
import { ViewMoreData } from "./components/ViewMoreData";
|
||||
|
||||
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
const { outputData, copiedKey, handleCopy, executionResultId, inputData } =
|
||||
useNodeOutput(nodeId);
|
||||
const {
|
||||
outputData,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
copiedKey,
|
||||
handleCopy,
|
||||
executionResultId,
|
||||
inputData,
|
||||
} = useNodeOutput(nodeId);
|
||||
|
||||
if (Object.keys(outputData).length === 0) {
|
||||
return null;
|
||||
@@ -24,117 +25,122 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
return (
|
||||
<div
|
||||
data-tutorial-id={`node-output`}
|
||||
className="rounded-b-xl border-t border-zinc-200 px-4 py-2"
|
||||
className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
|
||||
>
|
||||
<Accordion type="single" collapsible defaultValue="node-output">
|
||||
<AccordionItem value="node-output" className="border-none">
|
||||
<AccordionTrigger className="py-2 hover:no-underline">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="!font-semibold text-slate-700"
|
||||
>
|
||||
Node Output
|
||||
</Text>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-2">
|
||||
<div className="flex max-w-[350px] flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<Text variant="small-medium">Input</Text>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ContentRenderer value={inputData} shortContent={false} />
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="flex max-w-[350px] flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<Text variant="small-medium">Input</Text>
|
||||
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<NodeDataViewer
|
||||
data={inputData}
|
||||
pinName="Input"
|
||||
execId={executionResultId}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => handleCopy("input", inputData)}
|
||||
className={cn(
|
||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||
copiedKey === "input" &&
|
||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "input" ? (
|
||||
<CheckIcon size={12} className="text-green-600" />
|
||||
) : (
|
||||
<CopyIcon size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ContentRenderer value={inputData} shortContent={false} />
|
||||
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<NodeDataViewer
|
||||
data={inputData}
|
||||
pinName="Input"
|
||||
execId={executionResultId}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => handleCopy("input", inputData)}
|
||||
className={cn(
|
||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||
copiedKey === "input" &&
|
||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "input" ? (
|
||||
<CheckIcon size={12} className="text-green-600" />
|
||||
) : (
|
||||
<CopyIcon size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(outputData)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Pin:
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-700">
|
||||
{beautifyString(key)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Data:
|
||||
</Text>
|
||||
<div className="relative space-y-2">
|
||||
{value.map((item, index) => (
|
||||
<div key={index}>
|
||||
<ContentRenderer value={item} shortContent={true} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<NodeDataViewer
|
||||
data={value}
|
||||
pinName={key}
|
||||
execId={executionResultId}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => handleCopy(key, value)}
|
||||
className={cn(
|
||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||
copiedKey === key &&
|
||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||
)}
|
||||
>
|
||||
{copiedKey === key ? (
|
||||
<CheckIcon size={12} className="text-green-600" />
|
||||
) : (
|
||||
<CopyIcon size={12} />
|
||||
)}
|
||||
</Button>
|
||||
{Object.entries(outputData)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Pin:
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-700">
|
||||
{beautifyString(key)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="!font-semibold text-slate-600"
|
||||
>
|
||||
Data:
|
||||
</Text>
|
||||
<div className="relative space-y-2">
|
||||
{value.map((item, index) => (
|
||||
<div key={index}>
|
||||
<ContentRenderer value={item} shortContent={true} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<NodeDataViewer
|
||||
data={value}
|
||||
pinName={key}
|
||||
execId={executionResultId}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => handleCopy(key, value)}
|
||||
className={cn(
|
||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||
copiedKey === key &&
|
||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||
)}
|
||||
>
|
||||
{copiedKey === key ? (
|
||||
<CheckIcon size={12} className="text-green-600" />
|
||||
) : (
|
||||
<CopyIcon size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Object.keys(outputData).length > 2 && (
|
||||
<ViewMoreData
|
||||
outputData={outputData}
|
||||
execId={executionResultId}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{Object.keys(outputData).length > 2 && (
|
||||
<ViewMoreData outputData={outputData} execId={executionResultId} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useNodeOutput = (nodeId: string) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -36,10 +37,13 @@ export const useNodeOutput = (nodeId: string) => {
|
||||
}
|
||||
};
|
||||
return {
|
||||
outputData,
|
||||
inputData,
|
||||
copiedKey,
|
||||
handleCopy,
|
||||
outputData: outputData,
|
||||
inputData: inputData,
|
||||
isExpanded: isExpanded,
|
||||
setIsExpanded: setIsExpanded,
|
||||
copiedKey: copiedKey,
|
||||
setCopiedKey: setCopiedKey,
|
||||
handleCopy: handleCopy,
|
||||
executionResultId: nodeExecutionResult?.node_exec_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -187,38 +187,3 @@ export const getTypeDisplayInfo = (schema: any) => {
|
||||
hexColor,
|
||||
};
|
||||
};
|
||||
|
||||
export function getEdgeColorFromOutputType(
|
||||
outputSchema: RJSFSchema | undefined,
|
||||
sourceHandle: string,
|
||||
): { colorClass: string; hexColor: string } {
|
||||
const defaultColor = {
|
||||
colorClass: "stroke-zinc-500/50",
|
||||
hexColor: "#6b7280",
|
||||
};
|
||||
|
||||
if (!outputSchema?.properties) return defaultColor;
|
||||
|
||||
const properties = outputSchema.properties as Record<string, unknown>;
|
||||
const handleParts = sourceHandle.split("_#_");
|
||||
let currentSchema: Record<string, unknown> = properties;
|
||||
|
||||
for (let i = 0; i < handleParts.length; i++) {
|
||||
const part = handleParts[i];
|
||||
const fieldSchema = currentSchema[part] as Record<string, unknown>;
|
||||
if (!fieldSchema) return defaultColor;
|
||||
|
||||
if (i === handleParts.length - 1) {
|
||||
const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
|
||||
return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
|
||||
}
|
||||
|
||||
if (fieldSchema.properties) {
|
||||
currentSchema = fieldSchema.properties as Record<string, unknown>;
|
||||
} else {
|
||||
return defaultColor;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
type IconOptions = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE = 16;
|
||||
const DEFAULT_COLOR = "#52525b"; // zinc-600
|
||||
|
||||
const iconPaths = {
|
||||
ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
|
||||
Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
|
||||
Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
|
||||
};
|
||||
|
||||
function createIcon(path: string, options: IconOptions = {}): string {
|
||||
const size = options.size ?? DEFAULT_SIZE;
|
||||
const color = options.color ?? DEFAULT_COLOR;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" fill="${color}" viewBox="0 0 256 256"><path d="${path}"></path></svg>`;
|
||||
}
|
||||
// These are SVG Phosphor icons
|
||||
|
||||
export const ICONS = {
|
||||
ClickIcon: createIcon(iconPaths.ClickIcon),
|
||||
Keyboard: createIcon(iconPaths.Keyboard),
|
||||
Drag: createIcon(iconPaths.Drag),
|
||||
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z"></path></svg>`,
|
||||
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"></path></svg>`,
|
||||
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z"></path></svg>`,
|
||||
};
|
||||
|
||||
export function getIcon(
|
||||
name: keyof typeof iconPaths,
|
||||
options?: IconOptions,
|
||||
): string {
|
||||
return createIcon(iconPaths[name], options);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "./helpers";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { useTutorialStore } from "../../../stores/tutorialStore";
|
||||
|
||||
let isTutorialLoading = false;
|
||||
let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
|
||||
@@ -61,14 +60,12 @@ export const startTutorial = async () => {
|
||||
handleTutorialComplete();
|
||||
removeTutorialStyles();
|
||||
clearPrefetchedBlocks();
|
||||
useTutorialStore.getState().setIsTutorialRunning(false);
|
||||
});
|
||||
|
||||
tour.on("cancel", () => {
|
||||
handleTutorialCancel(tour);
|
||||
removeTutorialStyles();
|
||||
clearPrefetchedBlocks();
|
||||
useTutorialStore.getState().setIsTutorialRunning(false);
|
||||
});
|
||||
|
||||
for (const step of tour.steps) {
|
||||
|
||||
@@ -61,18 +61,12 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
|
||||
return customNode;
|
||||
};
|
||||
|
||||
const isToolSourceName = (sourceName: string): boolean =>
|
||||
sourceName.startsWith("tools_^_");
|
||||
|
||||
const cleanupSourceName = (sourceName: string): string =>
|
||||
isToolSourceName(sourceName) ? "tools" : sourceName;
|
||||
|
||||
export const linkToCustomEdge = (link: Link): CustomEdge => ({
|
||||
id: link.id ?? "",
|
||||
type: "custom" as const,
|
||||
source: link.source_id,
|
||||
target: link.sink_id,
|
||||
sourceHandle: cleanupSourceName(link.source_name),
|
||||
sourceHandle: link.source_name,
|
||||
targetHandle: link.sink_name,
|
||||
data: {
|
||||
isStatic: link.is_static,
|
||||
|
||||
@@ -267,34 +267,23 @@ export function extractCredentialsNeeded(
|
||||
| undefined;
|
||||
if (missingCreds && Object.keys(missingCreds).length > 0) {
|
||||
const agentName = (setupInfo?.agent_name as string) || "this block";
|
||||
const credentials = Object.values(missingCreds).map((credInfo) => {
|
||||
// Normalize to array at boundary - prefer 'types' array, fall back to single 'type'
|
||||
const typesArray = credInfo.types as
|
||||
| Array<"api_key" | "oauth2" | "user_password" | "host_scoped">
|
||||
| undefined;
|
||||
const singleType =
|
||||
const credentials = Object.values(missingCreds).map((credInfo) => ({
|
||||
provider: (credInfo.provider as string) || "unknown",
|
||||
providerName:
|
||||
(credInfo.provider_name as string) ||
|
||||
(credInfo.provider as string) ||
|
||||
"Unknown Provider",
|
||||
credentialType:
|
||||
(credInfo.type as
|
||||
| "api_key"
|
||||
| "oauth2"
|
||||
| "user_password"
|
||||
| "host_scoped"
|
||||
| undefined) || "api_key";
|
||||
const credentialTypes =
|
||||
typesArray && typesArray.length > 0 ? typesArray : [singleType];
|
||||
|
||||
return {
|
||||
provider: (credInfo.provider as string) || "unknown",
|
||||
providerName:
|
||||
(credInfo.provider_name as string) ||
|
||||
(credInfo.provider as string) ||
|
||||
"Unknown Provider",
|
||||
credentialTypes,
|
||||
title:
|
||||
(credInfo.title as string) ||
|
||||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
||||
scopes: credInfo.scopes as string[] | undefined,
|
||||
};
|
||||
});
|
||||
| "host_scoped") || "api_key",
|
||||
title:
|
||||
(credInfo.title as string) ||
|
||||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
||||
scopes: credInfo.scopes as string[] | undefined,
|
||||
}));
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
toolName,
|
||||
@@ -369,14 +358,11 @@ export function extractInputsNeeded(
|
||||
credentials.forEach((cred) => {
|
||||
const id = cred.id as string;
|
||||
if (id) {
|
||||
const credentialTypes = Array.isArray(cred.types)
|
||||
? cred.types
|
||||
: [(cred.type as string) || "api_key"];
|
||||
credentialsSchema[id] = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [cred.provider as string],
|
||||
credentials_types: credentialTypes,
|
||||
credentials_types: [(cred.type as string) || "api_key"],
|
||||
credentials_scopes: cred.scopes as string[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
|
||||
export interface CredentialInfo {
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialTypes: Array<
|
||||
"api_key" | "oauth2" | "user_password" | "host_scoped"
|
||||
>;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
@@ -32,7 +30,7 @@ function createSchemaFromCredentialInfo(
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [credential.provider],
|
||||
credentials_types: credential.credentialTypes,
|
||||
credentials_types: [credential.credentialType],
|
||||
credentials_scopes: credential.scopes,
|
||||
discriminator: undefined,
|
||||
discriminator_mapping: undefined,
|
||||
|
||||
@@ -41,9 +41,7 @@ export type ChatMessageData =
|
||||
credentials: Array<{
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialTypes: Array<
|
||||
"api_key" | "oauth2" | "user_password" | "host_scoped"
|
||||
>;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}>;
|
||||
|
||||
@@ -31,18 +31,10 @@ export function AgentSettingsModal({
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
currentHITLSafeMode,
|
||||
showHITLToggle,
|
||||
handleHITLToggle,
|
||||
currentSensitiveActionSafeMode,
|
||||
showSensitiveActionToggle,
|
||||
handleSensitiveActionToggle,
|
||||
isPending,
|
||||
shouldShowToggle,
|
||||
} = useAgentSafeMode(agent);
|
||||
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
|
||||
useAgentSafeMode(agent);
|
||||
|
||||
if (!shouldShowToggle) return null;
|
||||
if (!hasHITLBlocks) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -65,48 +57,23 @@ export function AgentSettingsModal({
|
||||
)}
|
||||
<Dialog.Content>
|
||||
<div className="space-y-6">
|
||||
{showHITLToggle && (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">
|
||||
Human-in-the-loop approval
|
||||
</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause at human-in-the-loop blocks and wait
|
||||
for your review before continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentHITLSafeMode || false}
|
||||
onCheckedChange={handleHITLToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">Require human approval</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause and wait for your review before
|
||||
continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentSafeMode || false}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showSensitiveActionToggle && (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">
|
||||
Sensitive action approval
|
||||
</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause at sensitive action blocks and wait for
|
||||
your review before continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentSensitiveActionSafeMode}
|
||||
onCheckedChange={handleSensitiveActionToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
|
||||
import {
|
||||
AIAgentSafetyPopup,
|
||||
useAIAgentSafetyPopup,
|
||||
} from "./components/AIAgentSafetyPopup/AIAgentSafetyPopup";
|
||||
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
|
||||
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
|
||||
import { RunActions } from "./components/RunActions/RunActions";
|
||||
@@ -87,17 +83,8 @@ export function RunAgentModal({
|
||||
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = 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 { shouldShowPopup, dismissPopup } = useAIAgentSafetyPopup(
|
||||
agent.has_sensitive_action,
|
||||
agent.has_human_in_the_loop,
|
||||
);
|
||||
|
||||
const hasAnySetupFields =
|
||||
Object.keys(agentInputFields || {}).length > 0 ||
|
||||
Object.keys(agentCredentialsInputFields || {}).length > 0;
|
||||
@@ -178,24 +165,6 @@ export function RunAgentModal({
|
||||
onScheduleCreated?.(schedule);
|
||||
}
|
||||
|
||||
function handleRunWithSafetyCheck() {
|
||||
if (shouldShowPopup) {
|
||||
setPendingRunAction(() => handleRun);
|
||||
setIsSafetyPopupOpen(true);
|
||||
} else {
|
||||
handleRun();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSafetyPopupAcknowledge() {
|
||||
setIsSafetyPopupOpen(false);
|
||||
dismissPopup();
|
||||
if (pendingRunAction) {
|
||||
pendingRunAction();
|
||||
setPendingRunAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -279,7 +248,7 @@ export function RunAgentModal({
|
||||
)}
|
||||
<RunActions
|
||||
defaultRunType={defaultRunType}
|
||||
onRun={handleRunWithSafetyCheck}
|
||||
onRun={handleRun}
|
||||
isExecuting={isExecuting}
|
||||
isSettingUpTrigger={isSettingUpTrigger}
|
||||
isRunReady={allRequiredInputsAreSet}
|
||||
@@ -297,11 +266,6 @@ export function RunAgentModal({
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
|
||||
<AIAgentSafetyPopup
|
||||
isOpen={isSafetyPopupOpen}
|
||||
onAcknowledge={handleSafetyPopupAcknowledge}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +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 {
|
||||
onAcknowledge: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function AIAgentSafetyPopup({ onAcknowledge, isOpen }: Props) {
|
||||
function handleAcknowledge() {
|
||||
// Mark popup as shown so it won't appear again
|
||||
storage.set(Key.AI_AGENT_SAFETY_POPUP_SHOWN, "true");
|
||||
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(
|
||||
hasSensitiveAction: boolean,
|
||||
hasHumanInTheLoop: boolean,
|
||||
) {
|
||||
const [shouldShowPopup, setShouldShowPopup] = useState(false);
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only check once after mount (to avoid SSR issues)
|
||||
if (hasChecked) return;
|
||||
|
||||
const hasSeenPopup =
|
||||
storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN) === "true";
|
||||
const isRelevantAgent = hasSensitiveAction || hasHumanInTheLoop;
|
||||
|
||||
setShouldShowPopup(!hasSeenPopup && isRelevantAgent);
|
||||
setHasChecked(true);
|
||||
}, [hasSensitiveAction, hasHumanInTheLoop, hasChecked]);
|
||||
|
||||
const dismissPopup = useCallback(() => {
|
||||
setShouldShowPopup(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shouldShowPopup,
|
||||
dismissPopup,
|
||||
};
|
||||
}
|
||||
@@ -5,37 +5,30 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/molecules/Accordion/Accordion";
|
||||
import {
|
||||
CredentialsMetaInput,
|
||||
CredentialsType,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
import { SlidersHorizontalIcon } from "@phosphor-icons/react";
|
||||
import { SlidersHorizontal } from "@phosphor-icons/react";
|
||||
import { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useRunAgentModalContext } from "../../context";
|
||||
import {
|
||||
areSystemCredentialProvidersLoading,
|
||||
CredentialField,
|
||||
findSavedCredentialByProviderAndType,
|
||||
hasMissingRequiredSystemCredentials,
|
||||
splitCredentialFieldsBySystem,
|
||||
} from "./helpers";
|
||||
} from "../helpers";
|
||||
|
||||
type Props = {
|
||||
credentialFields: CredentialField[];
|
||||
requiredCredentials: Set<string>;
|
||||
inputCredentials: Record<string, CredentialsMetaInput | undefined>;
|
||||
inputValues: Record<string, any>;
|
||||
onCredentialChange: (key: string, value?: CredentialsMetaInput) => void;
|
||||
};
|
||||
|
||||
export function CredentialsGroupedView({
|
||||
credentialFields,
|
||||
requiredCredentials,
|
||||
inputCredentials,
|
||||
inputValues,
|
||||
onCredentialChange,
|
||||
}: Props) {
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
const { inputCredentials, setInputCredentialsValue, inputValues } =
|
||||
useRunAgentModalContext();
|
||||
|
||||
const { userCredentialFields, systemCredentialFields } = useMemo(
|
||||
() =>
|
||||
@@ -94,11 +87,11 @@ export function CredentialsGroupedView({
|
||||
);
|
||||
|
||||
if (savedCredential) {
|
||||
onCredentialChange(key, {
|
||||
setInputCredentialsValue(key, {
|
||||
id: savedCredential.id,
|
||||
provider: savedCredential.provider,
|
||||
type: savedCredential.type as CredentialsType,
|
||||
title: savedCredential.title,
|
||||
type: savedCredential.type,
|
||||
title: (savedCredential as { title?: string }).title,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -110,7 +103,7 @@ export function CredentialsGroupedView({
|
||||
systemCredentialFields,
|
||||
requiredCredentials,
|
||||
inputCredentials,
|
||||
onCredentialChange,
|
||||
setInputCredentialsValue,
|
||||
isLoadingProviders,
|
||||
]);
|
||||
|
||||
@@ -130,7 +123,7 @@ export function CredentialsGroupedView({
|
||||
}
|
||||
selectedCredentials={selectedCred}
|
||||
onSelectCredentials={(value) => {
|
||||
onCredentialChange(key, value);
|
||||
setInputCredentialsValue(key, value);
|
||||
}}
|
||||
siblingInputs={inputValues}
|
||||
isOptional={!requiredCredentials.has(key)}
|
||||
@@ -150,8 +143,7 @@ export function CredentialsGroupedView({
|
||||
<AccordionItem value="system-credentials" className="border-none">
|
||||
<AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline">
|
||||
<div className="flex items-center gap-1">
|
||||
<SlidersHorizontalIcon size={16} weight="bold" /> System
|
||||
credentials
|
||||
<SlidersHorizontal size={16} weight="bold" /> System credentials
|
||||
{hasMissingSystemCredentials && (
|
||||
<span className="text-destructive">(missing)</span>
|
||||
)}
|
||||
@@ -171,7 +163,7 @@ export function CredentialsGroupedView({
|
||||
}
|
||||
selectedCredentials={selectedCred}
|
||||
onSelectCredentials={(value) => {
|
||||
onCredentialChange(key, value);
|
||||
setInputCredentialsValue(key, value);
|
||||
}}
|
||||
siblingInputs={inputValues}
|
||||
isOptional={!requiredCredentials.has(key)}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import { useMemo } from "react";
|
||||
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
|
||||
import { useRunAgentModalContext } from "../../context";
|
||||
import { CredentialsGroupedView } from "../CredentialsGroupedView/CredentialsGroupedView";
|
||||
import { ModalSection } from "../ModalSection/ModalSection";
|
||||
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
|
||||
|
||||
@@ -19,8 +19,6 @@ export function ModalRunSection() {
|
||||
setInputValue,
|
||||
agentInputFields,
|
||||
agentCredentialsInputFields,
|
||||
inputCredentials,
|
||||
setInputCredentialsValue,
|
||||
} = useRunAgentModalContext();
|
||||
|
||||
const inputFields = Object.entries(agentInputFields || {});
|
||||
@@ -104,9 +102,6 @@ export function ModalRunSection() {
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={inputCredentials}
|
||||
inputValues={inputValues}
|
||||
onCredentialChange={setInputCredentialsValue}
|
||||
/>
|
||||
</ModalSection>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider";
|
||||
import { getSystemCredentials } from "../../helpers";
|
||||
import { getSystemCredentials } from "../../../../../../../../../../../components/contextual/CredentialsInput/helpers";
|
||||
|
||||
export type CredentialField = [string, any];
|
||||
|
||||
@@ -5,104 +5,48 @@ import { Graph } from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShieldCheckIcon, ShieldIcon } from "@phosphor-icons/react";
|
||||
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
|
||||
interface Props {
|
||||
graph: GraphModel | LibraryAgent | Graph;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
interface SafeModeIconButtonProps {
|
||||
isEnabled: boolean;
|
||||
label: string;
|
||||
tooltipEnabled: string;
|
||||
tooltipDisabled: string;
|
||||
onToggle: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function SafeModeIconButton({
|
||||
isEnabled,
|
||||
label,
|
||||
tooltipEnabled,
|
||||
tooltipDisabled,
|
||||
onToggle,
|
||||
isPending,
|
||||
}: SafeModeIconButtonProps) {
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label={`${label}: ${isEnabled ? "ON" : "OFF"}. ${isEnabled ? tooltipEnabled : tooltipDisabled}`}
|
||||
onClick={onToggle}
|
||||
disabled={isPending}
|
||||
className={cn(isPending ? "opacity-0" : "opacity-100")}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<ShieldCheckIcon weight="bold" size={16} />
|
||||
) : (
|
||||
<ShieldIcon weight="bold" size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">
|
||||
{label}: {isEnabled ? "ON" : "OFF"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{isEnabled ? tooltipEnabled : tooltipDisabled}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function SafeModeToggle({ graph, className }: Props) {
|
||||
export function SafeModeToggle({ graph }: Props) {
|
||||
const {
|
||||
currentHITLSafeMode,
|
||||
showHITLToggle,
|
||||
handleHITLToggle,
|
||||
currentSensitiveActionSafeMode,
|
||||
showSensitiveActionToggle,
|
||||
handleSensitiveActionToggle,
|
||||
currentSafeMode,
|
||||
isPending,
|
||||
shouldShowToggle,
|
||||
isStateUndetermined,
|
||||
handleToggle,
|
||||
} = useAgentSafeMode(graph);
|
||||
|
||||
if (!shouldShowToggle) {
|
||||
if (!shouldShowToggle || isStateUndetermined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-1", className)}>
|
||||
{showHITLToggle && (
|
||||
<SafeModeIconButton
|
||||
isEnabled={currentHITLSafeMode}
|
||||
label="Human-in-the-loop"
|
||||
tooltipEnabled="The agent will pause at human-in-the-loop blocks and wait for your approval"
|
||||
tooltipDisabled="Human-in-the-loop blocks will proceed automatically"
|
||||
onToggle={handleHITLToggle}
|
||||
isPending={isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
key={graph.id}
|
||||
size="icon"
|
||||
aria-label={
|
||||
currentSafeMode!
|
||||
? "Safe Mode: ON. Human in the loop blocks require manual review"
|
||||
: "Safe Mode: OFF. Human in the loop blocks proceed automatically"
|
||||
}
|
||||
onClick={handleToggle}
|
||||
className={cn(isPending ? "opacity-0" : "opacity-100")}
|
||||
>
|
||||
{currentSafeMode! ? (
|
||||
<>
|
||||
<ShieldCheckIcon weight="bold" size={16} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldIcon weight="bold" size={16} />
|
||||
</>
|
||||
)}
|
||||
{showSensitiveActionToggle && (
|
||||
<SafeModeIconButton
|
||||
isEnabled={currentSensitiveActionSafeMode}
|
||||
label="Sensitive actions"
|
||||
tooltipEnabled="The agent will pause at sensitive action blocks and wait for your approval"
|
||||
tooltipDisabled="Sensitive action blocks will proceed automatically"
|
||||
onToggle={handleSensitiveActionToggle}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
|
||||
const {
|
||||
currentHITLSafeMode,
|
||||
showHITLToggle,
|
||||
handleHITLToggle,
|
||||
currentSensitiveActionSafeMode,
|
||||
showSensitiveActionToggle,
|
||||
handleSensitiveActionToggle,
|
||||
isPending,
|
||||
shouldShowToggle,
|
||||
} = useAgentSafeMode(agent);
|
||||
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
|
||||
useAgentSafeMode(agent);
|
||||
|
||||
return (
|
||||
<SelectedViewLayout agent={agent}>
|
||||
@@ -42,51 +34,24 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
|
||||
</div>
|
||||
|
||||
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
|
||||
{shouldShowToggle ? (
|
||||
<>
|
||||
{showHITLToggle && (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">
|
||||
Human-in-the-loop approval
|
||||
</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause at human-in-the-loop blocks and
|
||||
wait for your review before continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentHITLSafeMode || false}
|
||||
onCheckedChange={handleHITLToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{hasHITLBlocks ? (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">Require human approval</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause and wait for your review before
|
||||
continuing
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{showSensitiveActionToggle && (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">
|
||||
Sensitive action approval
|
||||
</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause at sensitive action blocks and wait
|
||||
for your review before continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentSensitiveActionSafeMode}
|
||||
onCheckedChange={handleSensitiveActionToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Switch
|
||||
checked={currentSafeMode || false}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<Text variant="body" className="text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
@@ -121,7 +120,7 @@ export default function LibraryUploadAgentDialog() {
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<LoadingSpinner size="small" className="text-white" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-t-2 border-white"></div>
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -6383,11 +6383,6 @@
|
||||
"title": "Has Human In The Loop",
|
||||
"readOnly": true
|
||||
},
|
||||
"has_sensitive_action": {
|
||||
"type": "boolean",
|
||||
"title": "Has Sensitive Action",
|
||||
"readOnly": true
|
||||
},
|
||||
"trigger_setup_info": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/GraphTriggerInfo" },
|
||||
@@ -6404,7 +6399,6 @@
|
||||
"output_schema",
|
||||
"has_external_trigger",
|
||||
"has_human_in_the_loop",
|
||||
"has_sensitive_action",
|
||||
"trigger_setup_info"
|
||||
],
|
||||
"title": "BaseGraph"
|
||||
@@ -7635,11 +7629,6 @@
|
||||
"title": "Has Human In The Loop",
|
||||
"readOnly": true
|
||||
},
|
||||
"has_sensitive_action": {
|
||||
"type": "boolean",
|
||||
"title": "Has Sensitive Action",
|
||||
"readOnly": true
|
||||
},
|
||||
"trigger_setup_info": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/GraphTriggerInfo" },
|
||||
@@ -7663,7 +7652,6 @@
|
||||
"output_schema",
|
||||
"has_external_trigger",
|
||||
"has_human_in_the_loop",
|
||||
"has_sensitive_action",
|
||||
"trigger_setup_info",
|
||||
"credentials_input_schema"
|
||||
],
|
||||
@@ -7742,11 +7730,6 @@
|
||||
"title": "Has Human In The Loop",
|
||||
"readOnly": true
|
||||
},
|
||||
"has_sensitive_action": {
|
||||
"type": "boolean",
|
||||
"title": "Has Sensitive Action",
|
||||
"readOnly": true
|
||||
},
|
||||
"trigger_setup_info": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/GraphTriggerInfo" },
|
||||
@@ -7771,7 +7754,6 @@
|
||||
"output_schema",
|
||||
"has_external_trigger",
|
||||
"has_human_in_the_loop",
|
||||
"has_sensitive_action",
|
||||
"trigger_setup_info",
|
||||
"credentials_input_schema"
|
||||
],
|
||||
@@ -7780,14 +7762,8 @@
|
||||
"GraphSettings": {
|
||||
"properties": {
|
||||
"human_in_the_loop_safe_mode": {
|
||||
"type": "boolean",
|
||||
"title": "Human In The Loop Safe Mode",
|
||||
"default": true
|
||||
},
|
||||
"sensitive_action_safe_mode": {
|
||||
"type": "boolean",
|
||||
"title": "Sensitive Action Safe Mode",
|
||||
"default": false
|
||||
"anyOf": [{ "type": "boolean" }, { "type": "null" }],
|
||||
"title": "Human In The Loop Safe Mode"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -7945,16 +7921,6 @@
|
||||
"title": "Has External Trigger",
|
||||
"description": "Whether the agent has an external trigger (e.g. webhook) node"
|
||||
},
|
||||
"has_human_in_the_loop": {
|
||||
"type": "boolean",
|
||||
"title": "Has Human In The Loop",
|
||||
"description": "Whether the agent has human-in-the-loop blocks"
|
||||
},
|
||||
"has_sensitive_action": {
|
||||
"type": "boolean",
|
||||
"title": "Has Sensitive Action",
|
||||
"description": "Whether the agent has sensitive action blocks"
|
||||
},
|
||||
"trigger_setup_info": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/GraphTriggerInfo" },
|
||||
@@ -8001,8 +7967,6 @@
|
||||
"output_schema",
|
||||
"credentials_input_schema",
|
||||
"has_external_trigger",
|
||||
"has_human_in_the_loop",
|
||||
"has_sensitive_action",
|
||||
"new_output",
|
||||
"can_access_graph",
|
||||
"is_latest_version",
|
||||
@@ -8829,11 +8793,6 @@
|
||||
"title": "Graph Version",
|
||||
"description": "Graph version"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string",
|
||||
"title": "Node Id",
|
||||
"description": "Node ID in the graph definition"
|
||||
},
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{ "additionalProperties": true, "type": "object" },
|
||||
@@ -8907,7 +8866,6 @@
|
||||
"graph_exec_id",
|
||||
"graph_id",
|
||||
"graph_version",
|
||||
"node_id",
|
||||
"payload",
|
||||
"editable",
|
||||
"status",
|
||||
@@ -9431,12 +9389,6 @@
|
||||
"type": "array",
|
||||
"title": "Reviews",
|
||||
"description": "All reviews with their approval status, data, and messages"
|
||||
},
|
||||
"auto_approve_node_ids": {
|
||||
"items": { "type": "string" },
|
||||
"type": "array",
|
||||
"title": "Auto Approve Node Ids",
|
||||
"description": "List of node IDs (from the graph definition) to auto-approve for the remainder of this execution. Future reviews from these specific nodes will be automatically approved. This only affects the current execution run."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PendingReviewsList({
|
||||
>({});
|
||||
|
||||
const [pendingAction, setPendingAction] = useState<
|
||||
"approve" | "approve-all" | "reject" | null
|
||||
"approve" | "reject" | null
|
||||
>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -92,10 +92,7 @@ export function PendingReviewsList({
|
||||
setReviewMessageMap((prev) => ({ ...prev, [nodeExecId]: message }));
|
||||
}
|
||||
|
||||
function processReviews(
|
||||
approved: boolean,
|
||||
autoApproveFutureActions: boolean = false,
|
||||
) {
|
||||
function processReviews(approved: boolean) {
|
||||
if (reviews.length === 0) {
|
||||
toast({
|
||||
title: "No reviews to process",
|
||||
@@ -105,13 +102,7 @@ export function PendingReviewsList({
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction(
|
||||
autoApproveFutureActions
|
||||
? "approve-all"
|
||||
: approved
|
||||
? "approve"
|
||||
: "reject",
|
||||
);
|
||||
setPendingAction(approved ? "approve" : "reject");
|
||||
const reviewItems = [];
|
||||
|
||||
for (const review of reviews) {
|
||||
@@ -143,15 +134,9 @@ export function PendingReviewsList({
|
||||
});
|
||||
}
|
||||
|
||||
// Collect unique node_ids if auto-approving future actions
|
||||
const autoApproveNodeIds = autoApproveFutureActions
|
||||
? [...new Set(reviews.map((r) => r.node_id))]
|
||||
: [];
|
||||
|
||||
reviewActionMutation.mutate({
|
||||
data: {
|
||||
reviews: reviewItems,
|
||||
auto_approve_node_ids: autoApproveNodeIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -206,8 +191,12 @@ export function PendingReviewsList({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-7">
|
||||
<Text variant="body" className="text-textGrey">
|
||||
Note: Changes you make here apply only to this task
|
||||
</Text>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => processReviews(true)}
|
||||
disabled={reviewActionMutation.isPending || reviews.length === 0}
|
||||
@@ -219,17 +208,6 @@ export function PendingReviewsList({
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => processReviews(true, true)}
|
||||
disabled={reviewActionMutation.isPending || reviews.length === 0}
|
||||
variant="secondary"
|
||||
className="flex items-center justify-center gap-2 rounded-full px-4 py-3"
|
||||
loading={
|
||||
pendingAction === "approve-all" && reviewActionMutation.isPending
|
||||
}
|
||||
>
|
||||
Approve all future actions
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => processReviews(false)}
|
||||
disabled={reviewActionMutation.isPending || reviews.length === 0}
|
||||
@@ -242,11 +220,6 @@ export function PendingReviewsList({
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Text variant="small" className="text-textGrey">
|
||||
You can turn auto-approval on or off anytime in this agent's
|
||||
settings.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,13 +35,12 @@ export const CredentialFieldTitle = (props: {
|
||||
uiOptions,
|
||||
);
|
||||
|
||||
const provider = getCredentialProviderFromSchema(
|
||||
useNodeStore.getState().getHardCodedValues(nodeId),
|
||||
schema as BlockIOCredentialsSubSchema,
|
||||
const credentialProvider = toDisplayName(
|
||||
getCredentialProviderFromSchema(
|
||||
useNodeStore.getState().getHardCodedValues(nodeId),
|
||||
schema as BlockIOCredentialsSubSchema,
|
||||
) ?? "",
|
||||
);
|
||||
const credentialProvider = provider
|
||||
? `${toDisplayName(provider)} credential`
|
||||
: "credential";
|
||||
|
||||
const updatedUiSchema = updateUiOption(uiSchema, {
|
||||
showHandles: false,
|
||||
|
||||
@@ -20,15 +20,11 @@ function hasHITLBlocks(graph: GraphModel | LibraryAgent | Graph): boolean {
|
||||
if ("has_human_in_the_loop" in graph) {
|
||||
return !!graph.has_human_in_the_loop;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSensitiveActionBlocks(
|
||||
graph: GraphModel | LibraryAgent | Graph,
|
||||
): boolean {
|
||||
if ("has_sensitive_action" in graph) {
|
||||
return !!graph.has_sensitive_action;
|
||||
if (isLibraryAgent(graph)) {
|
||||
return graph.settings?.human_in_the_loop_safe_mode !== null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,9 +40,7 @@ export function useAgentSafeMode(graph: GraphModel | LibraryAgent | Graph) {
|
||||
|
||||
const graphId = getGraphId(graph);
|
||||
const isAgent = isLibraryAgent(graph);
|
||||
const showHITLToggle = hasHITLBlocks(graph);
|
||||
const showSensitiveActionToggle = hasSensitiveActionBlocks(graph);
|
||||
const shouldShowToggle = showHITLToggle || showSensitiveActionToggle;
|
||||
const shouldShowToggle = hasHITLBlocks(graph);
|
||||
|
||||
const { mutateAsync: updateGraphSettings, isPending } =
|
||||
usePatchV1UpdateGraphSettings();
|
||||
@@ -62,37 +56,27 @@ export function useAgentSafeMode(graph: GraphModel | LibraryAgent | Graph) {
|
||||
},
|
||||
);
|
||||
|
||||
const [localHITLSafeMode, setLocalHITLSafeMode] = useState<boolean>(true);
|
||||
const [localSensitiveActionSafeMode, setLocalSensitiveActionSafeMode] =
|
||||
useState<boolean>(false);
|
||||
const [isLocalStateLoaded, setIsLocalStateLoaded] = useState<boolean>(false);
|
||||
const [localSafeMode, setLocalSafeMode] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgent && libraryAgent) {
|
||||
setLocalHITLSafeMode(
|
||||
libraryAgent.settings?.human_in_the_loop_safe_mode ?? true,
|
||||
);
|
||||
setLocalSensitiveActionSafeMode(
|
||||
libraryAgent.settings?.sensitive_action_safe_mode ?? false,
|
||||
);
|
||||
setIsLocalStateLoaded(true);
|
||||
const backendValue = libraryAgent.settings?.human_in_the_loop_safe_mode;
|
||||
if (backendValue !== undefined) {
|
||||
setLocalSafeMode(backendValue);
|
||||
}
|
||||
}
|
||||
}, [isAgent, libraryAgent]);
|
||||
|
||||
const currentHITLSafeMode = isAgent
|
||||
? (graph.settings?.human_in_the_loop_safe_mode ?? true)
|
||||
: localHITLSafeMode;
|
||||
const currentSafeMode = isAgent
|
||||
? graph.settings?.human_in_the_loop_safe_mode
|
||||
: localSafeMode;
|
||||
|
||||
const currentSensitiveActionSafeMode = isAgent
|
||||
? (graph.settings?.sensitive_action_safe_mode ?? false)
|
||||
: localSensitiveActionSafeMode;
|
||||
const isStateUndetermined = isAgent
|
||||
? graph.settings?.human_in_the_loop_safe_mode == null
|
||||
: isLoading || localSafeMode === null;
|
||||
|
||||
const isHITLStateUndetermined = isAgent
|
||||
? false
|
||||
: isLoading || !isLocalStateLoaded;
|
||||
|
||||
const handleHITLToggle = useCallback(async () => {
|
||||
const newSafeMode = !currentHITLSafeMode;
|
||||
const handleToggle = useCallback(async () => {
|
||||
const newSafeMode = !currentSafeMode;
|
||||
|
||||
try {
|
||||
await updateGraphSettings({
|
||||
@@ -101,7 +85,7 @@ export function useAgentSafeMode(graph: GraphModel | LibraryAgent | Graph) {
|
||||
});
|
||||
|
||||
if (!isAgent) {
|
||||
setLocalHITLSafeMode(newSafeMode);
|
||||
setLocalSafeMode(newSafeMode);
|
||||
}
|
||||
|
||||
if (isAgent) {
|
||||
@@ -117,62 +101,37 @@ export function useAgentSafeMode(graph: GraphModel | LibraryAgent | Graph) {
|
||||
queryClient.invalidateQueries({ queryKey: ["v2", "executions"] });
|
||||
|
||||
toast({
|
||||
title: `HITL safe mode ${newSafeMode ? "enabled" : "disabled"}`,
|
||||
title: `Safe mode ${newSafeMode ? "enabled" : "disabled"}`,
|
||||
description: newSafeMode
|
||||
? "Human-in-the-loop blocks will require manual review"
|
||||
: "Human-in-the-loop blocks will proceed automatically",
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
handleToggleError(error, isAgent, toast);
|
||||
}
|
||||
}, [
|
||||
currentHITLSafeMode,
|
||||
graphId,
|
||||
isAgent,
|
||||
graph.id,
|
||||
updateGraphSettings,
|
||||
queryClient,
|
||||
toast,
|
||||
]);
|
||||
const isNotFoundError =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("404") || error.message.includes("not found"));
|
||||
|
||||
const handleSensitiveActionToggle = useCallback(async () => {
|
||||
const newSafeMode = !currentSensitiveActionSafeMode;
|
||||
|
||||
try {
|
||||
await updateGraphSettings({
|
||||
graphId,
|
||||
data: { sensitive_action_safe_mode: newSafeMode },
|
||||
});
|
||||
|
||||
if (!isAgent) {
|
||||
setLocalSensitiveActionSafeMode(newSafeMode);
|
||||
}
|
||||
|
||||
if (isAgent) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetLibraryAgentQueryOptions(graph.id.toString())
|
||||
.queryKey,
|
||||
if (!isAgent && isNotFoundError) {
|
||||
toast({
|
||||
title: "Safe mode not available",
|
||||
description:
|
||||
"To configure safe mode, please save this graph to your library first.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to update safe mode",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1", "graphs", graphId, "executions"],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["v2", "executions"] });
|
||||
|
||||
toast({
|
||||
title: `Sensitive action safe mode ${newSafeMode ? "enabled" : "disabled"}`,
|
||||
description: newSafeMode
|
||||
? "Sensitive action blocks will require manual review"
|
||||
: "Sensitive action blocks will proceed automatically",
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
handleToggleError(error, isAgent, toast);
|
||||
}
|
||||
}, [
|
||||
currentSensitiveActionSafeMode,
|
||||
currentSafeMode,
|
||||
graphId,
|
||||
isAgent,
|
||||
graph.id,
|
||||
@@ -182,53 +141,11 @@ export function useAgentSafeMode(graph: GraphModel | LibraryAgent | Graph) {
|
||||
]);
|
||||
|
||||
return {
|
||||
// HITL safe mode
|
||||
currentHITLSafeMode,
|
||||
showHITLToggle,
|
||||
isHITLStateUndetermined,
|
||||
handleHITLToggle,
|
||||
|
||||
// Sensitive action safe mode
|
||||
currentSensitiveActionSafeMode,
|
||||
showSensitiveActionToggle,
|
||||
handleSensitiveActionToggle,
|
||||
|
||||
// General
|
||||
currentSafeMode,
|
||||
isPending,
|
||||
shouldShowToggle,
|
||||
|
||||
// Backwards compatibility
|
||||
currentSafeMode: currentHITLSafeMode,
|
||||
isStateUndetermined: isHITLStateUndetermined,
|
||||
handleToggle: handleHITLToggle,
|
||||
hasHITLBlocks: showHITLToggle,
|
||||
isStateUndetermined,
|
||||
handleToggle,
|
||||
hasHITLBlocks: shouldShowToggle,
|
||||
};
|
||||
}
|
||||
|
||||
function handleToggleError(
|
||||
error: unknown,
|
||||
isAgent: boolean,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const isNotFoundError =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("404") || error.message.includes("not found"));
|
||||
|
||||
if (!isAgent && isNotFoundError) {
|
||||
toast({
|
||||
title: "Safe mode not available",
|
||||
description:
|
||||
"To configure safe mode, please save this graph to your library first.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to update safe mode",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import isEqual from "lodash/isEqual";
|
||||
export function cleanNode(node: CustomNode) {
|
||||
return {
|
||||
id: node.id,
|
||||
// Note: position is intentionally excluded to prevent draft saves when dragging nodes
|
||||
position: node.position,
|
||||
data: {
|
||||
hardcodedValues: node.data.hardcodedValues,
|
||||
title: node.data.title,
|
||||
|
||||
@@ -10,7 +10,6 @@ export enum Key {
|
||||
LIBRARY_AGENTS_CACHE = "library-agents-cache",
|
||||
CHAT_SESSION_ID = "chat_session_id",
|
||||
COOKIE_CONSENT = "autogpt_cookie_consent",
|
||||
AI_AGENT_SAFETY_POPUP_SHOWN = "ai-agent-safety-popup-shown",
|
||||
}
|
||||
|
||||
function get(key: Key) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Documentation Guidelines
|
||||
|
||||
## Block Documentation Manual Sections
|
||||
|
||||
When updating manual sections (`<!-- MANUAL: ... -->`) in block documentation files (e.g., `docs/integrations/basic.md`), follow these formats:
|
||||
|
||||
### How It Works Section
|
||||
|
||||
Provide a technical explanation of how the block functions:
|
||||
- Describe the processing logic in 1-2 paragraphs
|
||||
- Mention any validation, error handling, or edge cases
|
||||
- Use code examples with backticks when helpful (e.g., `[[1, 2], [3, 4]]` becomes `[1, 2, 3, 4]`)
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
<!-- MANUAL: how_it_works -->
|
||||
The block iterates through each list in the input and extends a result list with all elements from each one. It processes lists in order, so `[[1, 2], [3, 4]]` becomes `[1, 2, 3, 4]`.
|
||||
|
||||
The block includes validation to ensure each item is actually a list. If a non-list value is encountered, the block outputs an error message instead of proceeding.
|
||||
<!-- END MANUAL -->
|
||||
```
|
||||
|
||||
### Use Case Section
|
||||
|
||||
Provide 3 practical use cases in this format:
|
||||
- **Bold Heading**: Short one-sentence description
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
<!-- MANUAL: use_case -->
|
||||
**Paginated API Merging**: Combine results from multiple API pages into a single list for batch processing or display.
|
||||
|
||||
**Parallel Task Aggregation**: Merge outputs from parallel workflow branches that each produce a list of results.
|
||||
|
||||
**Multi-Source Data Collection**: Combine data collected from different sources (like multiple RSS feeds or API endpoints) into one unified list.
|
||||
<!-- END MANUAL -->
|
||||
```
|
||||
|
||||
### Style Guidelines
|
||||
|
||||
- Keep descriptions concise and action-oriented
|
||||
- Focus on practical, real-world scenarios
|
||||
- Use consistent terminology with other blocks
|
||||
- Avoid overly technical jargon unless necessary
|
||||
@@ -31,7 +31,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
||||
| [Agent Time Input](basic.md#agent-time-input) | Block for time input |
|
||||
| [Agent Toggle Input](basic.md#agent-toggle-input) | Block for boolean toggle input |
|
||||
| [Block Installation](basic.md#block-installation) | Given a code string, this block allows the verification and installation of a block code into the system |
|
||||
| [Concatenate Lists](basic.md#concatenate-lists) | Concatenates multiple lists into a single list |
|
||||
| [Dictionary Is Empty](basic.md#dictionary-is-empty) | Checks if a dictionary is empty |
|
||||
| [File Store](basic.md#file-store) | Stores the input file in the temporary directory |
|
||||
| [Find In Dictionary](basic.md#find-in-dictionary) | A block that looks up a value in a dictionary, list, or object by key or index and returns the corresponding value |
|
||||
|
||||
@@ -634,42 +634,6 @@ This enables extensibility by allowing custom blocks to be added without modifyi
|
||||
|
||||
---
|
||||
|
||||
## Concatenate Lists
|
||||
|
||||
### What it is
|
||||
Concatenates multiple lists into a single list. All elements from all input lists are combined in order.
|
||||
|
||||
### How it works
|
||||
<!-- MANUAL: how_it_works -->
|
||||
The block iterates through each list in the input and extends a result list with all elements from each one. It processes lists in order, so `[[1, 2], [3, 4]]` becomes `[1, 2, 3, 4]`.
|
||||
|
||||
The block includes validation to ensure each item is actually a list. If a non-list value (like a string or number) is encountered, the block outputs an error message instead of proceeding. None values are skipped automatically.
|
||||
<!-- END MANUAL -->
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Description | Type | Required |
|
||||
|-------|-------------|------|----------|
|
||||
| lists | A list of lists to concatenate together. All lists will be combined in order into a single list. | List[List[Any]] | Yes |
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Description | Type |
|
||||
|--------|-------------|------|
|
||||
| error | Error message if concatenation failed due to invalid input types. | str |
|
||||
| concatenated_list | The concatenated list containing all elements from all input lists in order. | List[Any] |
|
||||
|
||||
### Possible use case
|
||||
<!-- MANUAL: use_case -->
|
||||
**Paginated API Merging**: Combine results from multiple API pages into a single list for batch processing or display.
|
||||
|
||||
**Parallel Task Aggregation**: Merge outputs from parallel workflow branches that each produce a list of results.
|
||||
|
||||
**Multi-Source Data Collection**: Combine data collected from different sources (like multiple RSS feeds or API endpoints) into one unified list.
|
||||
<!-- END MANUAL -->
|
||||
|
||||
---
|
||||
|
||||
## Dictionary Is Empty
|
||||
|
||||
### What it is
|
||||
|
||||
Reference in New Issue
Block a user